#!/usr/bin/env python3 """Download the latest German amateur-radio exam question catalog from the Bundesnetzagentur (BNetzA), validate it, and extract it into a per-edition directory on disk. The full design and contract live in DESIGN.md. As an overview the script does: 1. Ask the BNetzA server for the file's HTTP `Last-Modified` header. 2. Skip everything if the previous run already extracted that exact version (we recorded the header in manifest-latest.json). 3. Otherwise stream the ZIP to a scratch file, computing its sha256 as bytes arrive, and reject anything unsafe to unpack (zip-slip paths, symlinks, oversized archives, etc.). 4. Validate the JSON schema and that every picture reference resolves to a file under svgs/. Missing pictures are a SOFT check: they get recorded in the manifest but do not fail the extraction. 5. Extract into a per-edition directory like `data/2024-03-20-3-auflage/`, with a `manifest.json` next to the data. 6. Atomically update `data/manifest-latest.json` so a future run can do step 1 and skip immediately. The script is intentionally a single file with stdlib only. Readability beats cleverness here — most of the bytes below are docstrings and comments. Performance is a non-goal (the catalog is ~3 MB and changes maybe once a quarter). """ import argparse import dataclasses import datetime as dt import hashlib import json import os import posixpath import re import shutil import stat import sys import urllib.error import urllib.request import zipfile from pathlib import Path # ============================================================================ # Constants # ============================================================================ # Canonical download URL. BNetzA replaces the file in-place across # editions, so this URL is stable; we rely on the HTTP Last-Modified # header to notice updates. DEFAULT_SOURCE_URL = ( "https://www.bundesnetzagentur.de/SharedDocs/Downloads/DE/Sachgebiete/" "Telekommunikation/Unternehmen_Institutionen/Frequenzen/Amateurfunk/" "Fragenkatalog/PruefungsfragenZIP.zip?__blob=publicationFile" ) # Where extracted editions land by default. Created if it doesn't exist. DEFAULT_OUT_DIR = Path("data") # Polite identifier for the BNetzA web server's access log. Not required # by the server, but it's a friendly thing to send. USER_AGENT = ( "amateurfunk-fetch/0.1 " "(+https://www.bnetza.de/amateurfunk-fragenkatalog)" ) # Compressed-size guard. The real archive is ~3 MB; 50 MB gives a lot # of headroom while still rejecting a catastrophic upstream swap (such # as an accidentally-published 10 GB file). MAX_COMPRESSED_BYTES = 50 * 1024 * 1024 # Uncompressed-size guard. Defends against zip-bomb-style upstream # regressions where a small ZIP expands to gigabytes. The real archive # expands to ~8 MB. MAX_UNCOMPRESSED_BYTES = 200 * 1024 * 1024 # Sanity floor for the figures directory. The real archive contains # ~700 SVGs; an archive with fewer than 100 is almost certainly broken. MIN_SVG_ENTRIES = 100 # Connection timeout in seconds for all HTTP requests. NETWORK_TIMEOUT = 60 # Exit codes. See DESIGN.md for the contract behind each one. EXIT_OK = 0 # Extracted, or already up to date. EXIT_ERROR = 1 # Network or validation problem. EXIT_BAD_STATE = 2 # Local state conflict the operator must resolve # (stale .bak/.tmp dir, or different content # already present without --force). # ============================================================================ # Exception types and data classes # ============================================================================ class ValidationError(Exception): """Raised when the downloaded archive fails a structural check.""" @dataclasses.dataclass class ValidatedArchive: """The result of a successful validation pass. Carries everything the caller needs to write a per-edition manifest and to know what to expect from the extracted tree. """ # Filename of the catalog JSON inside the archive. The name encodes # the edition (e.g. `fragenkatalog3b.json` for "3rd edition, # revision b"). json_filename: str # The parsed JSON catalog itself. json_data: dict # Picture references that point at files not present in the archive. # Each entry has keys: question_number, field, file. # Soft check: recorded for downstream consumers; doesn't fail the run. missing_pictures: list # Raw text of README.txt from the archive (license + schema docs). # Preserved verbatim into the extracted tree. readme_text: str # The Quellenvermerk (attribution) sentence pulled out of the # README. Empty string if the README is missing or has no quoted # block. attribution: str # ============================================================================ # HTTP # ============================================================================ def http_head_last_modified(url, timeout=NETWORK_TIMEOUT): """Ask the server for the file's `Last-Modified` header via HEAD. Returns the header value as-is (a string like `"Wed, 20 May 2026 11:30:40 GMT"`) or `None` if the server didn't send one. This is the foundation of the idempotency check: on subsequent runs we compare this value against what we recorded last time and skip the actual download if they match. """ request = urllib.request.Request( url, method="HEAD", headers={"User-Agent": USER_AGENT}, ) with urllib.request.urlopen(request, timeout=timeout) as response: return response.headers.get("Last-Modified") def http_get_to_file(url, destination, max_bytes, timeout=NETWORK_TIMEOUT): """Stream `url` to a local file, computing sha256 as bytes arrive. Returns a 3-tuple `(sha256_hex, last_modified, byte_count)`. The sha256 is computed on the fly so we never need to re-read the file to hash it. Raises `ValidationError` if the response exceeds `max_bytes`, and `urllib.error.URLError` on connection problems. """ request = urllib.request.Request( url, headers={"User-Agent": USER_AGENT}, ) hasher = hashlib.sha256() bytes_written = 0 with urllib.request.urlopen(request, timeout=timeout) as response, \ destination.open("wb") as out_file: last_modified = response.headers.get("Last-Modified") while True: chunk = response.read(64 * 1024) if not chunk: break bytes_written += len(chunk) if bytes_written > max_bytes: raise ValidationError( f"upstream ZIP exceeds the " f"{max_bytes}-byte compressed cap" ) hasher.update(chunk) out_file.write(chunk) return hasher.hexdigest(), last_modified, bytes_written # ============================================================================ # ZIP safety # ============================================================================ def _normalize_zip_name(name): """Normalize a ZIP member's path for comparison. Backslashes (from Windows-produced archives) become forward slashes, and `posixpath.normpath` collapses redundant separators and `.` segments. """ return posixpath.normpath(name.replace("\\", "/")) def _is_symlink_entry(zi): """Return True if a ZIP member encodes a POSIX symlink. ZIPs created on Unix store the file mode in the upper 16 bits of `external_attr`. The S_IFLNK file-type bit identifies symlinks. Path-based checks (rejecting absolute paths and `..` segments) are *not* enough on their own: a symlink with an innocuous name could still point outside the destination root. """ upper_mode_bits = (zi.external_attr >> 16) & 0o170000 return upper_mode_bits == stat.S_IFLNK def _check_zip_member_safe(zi): """Raise `ValidationError` if extracting this member could escape the destination directory, or if it's a symlink. The whole archive is rejected on the first offence — we never partially extract a suspicious one. """ name = zi.filename # Some ZIP producers (mainly on Windows) use backslash separators. # Reject these outright to avoid ambiguity about what the path means. if "\\" in name: raise ValidationError(f"backslash in path: {name!r}") # An absolute path would land outside the chosen destination root. if posixpath.isabs(name): raise ValidationError(f"absolute path: {name!r}") # Reject ANY `..` segment in the raw path, not only entries that # would escape after normalization. `posixpath.normpath` would # happily collapse "foo/../bar" to "bar" — which lands in-bounds — # but the design contract says fail closed on suspicious paths # regardless of whether the normalized form happens to escape. A # well-formed archive has no reason to ship `..` anywhere. raw_segments = name.split("/") if ".." in raw_segments: raise ValidationError(f"path escape (.. segment): {name!r}") # Reject symlinks identified by the POSIX file-type bits. if _is_symlink_entry(zi): raise ValidationError(f"symlink entry: {name!r}") # ============================================================================ # Validation # ============================================================================ def validate_zip( zip_path, max_uncompressed=MAX_UNCOMPRESSED_BYTES, min_svg_entries=MIN_SVG_ENTRIES, ): """Open the ZIP at `zip_path`, run every structural check, and return a `ValidatedArchive` describing what we found. Raises `ValidationError` on the first structural problem. Soft issues (missing picture references) are collected and returned in the result rather than raised. See DESIGN.md §4 "Validate" for the full list of checks. """ if not zipfile.is_zipfile(zip_path): raise ValidationError(f"not a valid ZIP: {zip_path}") with zipfile.ZipFile(zip_path) as zf: members = zf.infolist() # First pass: per-member safety check + accumulate uncompressed size. total_uncompressed = 0 for member in members: _check_zip_member_safe(member) total_uncompressed += member.file_size if total_uncompressed > max_uncompressed: raise ValidationError( f"uncompressed total {total_uncompressed} exceeds cap " f"{max_uncompressed} (zip-bomb guard)" ) # Find the catalog JSON. There must be exactly one at the # archive root — more than one would mean we don't know which # one is canonical. root_jsons = _find_root_catalog_jsons(members) if len(root_jsons) != 1: raise ValidationError( f"expected exactly one root-level fragenkatalog*.json, " f"found {len(root_jsons)}: {root_jsons}" ) json_filename = root_jsons[0] # Find the figure files. We require more than a configurable # floor (default 100) so that we'd notice if BNetzA shipped # only a handful of figures by accident. svg_members = _find_svg_members(members) if len(svg_members) <= min_svg_entries: raise ValidationError( f"expected more than {min_svg_entries} svgs/ file " f"entries, found {len(svg_members)}" ) svg_stems = _build_svg_stem_index(svg_members) # Parse the catalog JSON. `utf-8-sig` tolerates an optional # UTF-8 BOM at the very start of the file. with zf.open(json_filename) as f: try: catalog = json.loads(f.read().decode("utf-8-sig")) except json.JSONDecodeError as e: raise ValidationError( f"malformed JSON in {json_filename}: {e}" ) from e # Pull the README into memory if it's there. It carries the # attribution string mandated by the DL-DE license. readme_text = "" if "README.txt" in zf.namelist(): readme_bytes = zf.read("README.txt") readme_text = readme_bytes.decode( "utf-8-sig", errors="replace", ) # The JSON-shape checks happen after we've closed the ZIP; they # only need the parsed catalog and the precomputed SVG stem index. _validate_catalog_shape(catalog) missing_pictures = _find_missing_picture_references(catalog, svg_stems) attribution = _extract_attribution_from_readme(readme_text) return ValidatedArchive( json_filename=json_filename, json_data=catalog, missing_pictures=missing_pictures, readme_text=readme_text, attribution=attribution, ) def _find_root_catalog_jsons(members): """Return ZIP-member filenames that look like the catalog JSON file at the archive root. "Root level" means the path contains no `/`. The actual name encodes the edition (e.g. `fragenkatalog3b.json` for the 3rd edition, revision b), so we match against a pattern rather than hard-coding the current filename. """ pattern = re.compile(r"fragenkatalog.+\.json") matches = [] for member in members: if member.is_dir(): continue if "/" in member.filename: continue if pattern.fullmatch(member.filename): matches.append(member.filename) return matches def _find_svg_members(members): """Return ZIP members whose normalized path starts with `svgs/`. Directory entries are skipped, and we deliberately don't require a standalone `svgs/` directory record — many ZIP producers omit directory entries and emit only file entries like `svgs/AB108_q.svg`. """ result = [] for member in members: if member.is_dir(): continue if _normalize_zip_name(member.filename).startswith("svgs/"): result.append(member) return result def _build_svg_stem_index(svg_members): """Build a set of basenames-without-extension, keyed for picture lookup. The catalog references pictures by stem (e.g. `"AB109_q"`), not by full filename (`"AB109_q.svg"`). Indexing by stem keeps the soft cross-reference check accurate. """ stems = set() for member in svg_members: basename = posixpath.basename(_normalize_zip_name(member.filename)) stem, _extension = posixpath.splitext(basename) stems.add(stem) return stems # ============================================================================ # JSON schema # ============================================================================ # Every question MUST carry these keys. Missing any is a hard failure: # we can't usefully present a question without four answer slots plus # the basic identifying fields. REQUIRED_QUESTION_KEYS = ( "number", "class", "question", "answer_a", "answer_b", "answer_c", "answer_d", ) # Optional fields that, when set, point at an image file under svgs/. # Soft-validated: a broken reference goes into the manifest but does # not stop the extraction. PICTURE_FIELDS = ( "picture_question", "picture_a", "picture_b", "picture_c", "picture_d", ) def _validate_catalog_shape(catalog): """Verify the top-level catalog object has the required structure. Hard validation: if the schema doesn't match we won't write a manifest that claims this is a BNetzA catalog. """ if not isinstance(catalog, dict): raise ValidationError("top-level JSON is not an object") for key in ("metadata", "sections"): if key not in catalog: raise ValidationError( f"top-level JSON missing required key {key!r}" ) metadata = catalog["metadata"] if not isinstance(metadata, dict): raise ValidationError("metadata is not an object") for key in ("edition", "issued_on", "valid_from", "license"): if key not in metadata: raise ValidationError( f"metadata missing required key {key!r}" ) if not isinstance(catalog["sections"], list): raise ValidationError("sections is not a list") for section in catalog["sections"]: _validate_section(section) def _validate_section(node): """Recursive check for one node in the sections tree. Each node is either an inner section (carries nested `sections`) or a leaf (carries `questions`). Carrying both is ambiguous, and carrying neither means the node holds no information. We treat the *presence* of the key as the signal, not whether the list inside it is empty. """ if not isinstance(node, dict): raise ValidationError("section is not an object") # Use `is not None` (rather than truthiness) so that an empty list # still counts as "the key is present" — that's what the original # contract calls for. has_subsections = node.get("sections") is not None has_questions = node.get("questions") is not None if has_subsections and has_questions: raise ValidationError( f"section {node.get('title')!r} has both " f"sections and questions" ) if not has_subsections and not has_questions: raise ValidationError( f"section {node.get('title')!r} has neither " f"sections nor questions" ) if has_subsections: for child in node["sections"]: _validate_section(child) else: for question in node["questions"]: _validate_question(question) def _validate_question(question): """Check that a question object has every required field.""" if not isinstance(question, dict): raise ValidationError("question is not an object") for key in REQUIRED_QUESTION_KEYS: if key not in question: raise ValidationError( f"question {question.get('number', '?')!r} " f"missing required key {key!r}" ) # ============================================================================ # Picture cross-references (soft check) # ============================================================================ def _picture_reference_resolves(reference, svg_stems): """Return True if a picture reference matches a known SVG/PNG file. Accepts both forms: - the canonical stem (`"AB109_q"`) — matches directly against the precomputed stem index - a filename with extension (`"AB109_q.svg"`) — defensive fallback in case a future BNetzA edition changes the convention; we strip the extension and try again Returning False here is what marks a reference as "missing" — but that is a soft signal that goes into the manifest, not a hard failure. """ if reference in svg_stems: return True stem, _extension = posixpath.splitext(reference) return stem in svg_stems def _find_missing_picture_references(catalog, svg_stems): """Walk the catalog and return picture references that don't resolve. Soft check: a single upstream typo should not brick the downloader for everyone until BNetzA ships a fix. Downstream consumers decide whether to ignore, warn, or hide the affected questions. Returns a list of dicts with keys: question_number, field, file. """ missing = [] def walk(node): # Inspect every question in this node, if any. for question in node.get("questions") or []: for field in PICTURE_FIELDS: value = question.get(field) if value and not _picture_reference_resolves(value, svg_stems): missing.append({ "question_number": question.get("number"), "field": field, "file": value, }) # Recurse into sub-sections, if any. for child in node.get("sections") or []: walk(child) walk({"sections": catalog["sections"]}) return missing # ============================================================================ # Attribution # ============================================================================ def _extract_attribution_from_readme(readme_text): """Pull the Quellenvermerk (attribution) string out of the README. The BNetzA README contains exactly one multi-line quoted block — the Quellenvermerk that consumers must reproduce verbatim when redistributing the data under the DL-DE license. We return the matched text exactly as it appears (multi-line, original spacing preserved), so a downstream consumer can reproduce it byte-for- byte from `manifest["attribution"]`. The extracted `README.txt` file in the per-edition directory remains the lossless source of truth on disk regardless. Returns "" if no quoted block is found. """ match = re.search(r'"([^"]+)"', readme_text, flags=re.DOTALL) if not match: return "" return match.group(1) # ============================================================================ # Slug derivation # ============================================================================ def derive_slug(metadata): """Build a filesystem-safe, sortable directory name for this edition. The slug combines the issue date and the edition ordinal so that directories sort chronologically and we never have to parse German month names from the human-readable `edition` field. Example: `{"edition": "3. Auflage, März 2024", "issued_on": "2024-03-20"}` -> `"2024-03-20-3-auflage"` Both inputs are best-effort. Missing pieces become `"unknown"` so the slug is still a valid directory name. """ issued_on = (metadata.get("issued_on") or "").strip() or "unknown" edition_text = metadata.get("edition") or "" # The German edition string starts with the ordinal # ("3. Auflage..."), so grab the leading run of digits. match = re.match(r"\s*(\d+)", edition_text) ordinal = match.group(1) if match else "unknown" return f"{issued_on}-{ordinal}-auflage" # ============================================================================ # Extraction # ============================================================================ def extract_zip(zip_path, destination): """Extract every file from `zip_path` into `destination`. Callers must run `validate_zip()` first — this function assumes the archive has already been checked for path traversal and symlinks. Directory entries inside the archive are skipped; parent directories are created on the fly. """ destination.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(zip_path) as zf: for member in zf.infolist(): if member.is_dir(): continue relative_path = _normalize_zip_name(member.filename) target_path = destination / relative_path target_path.parent.mkdir(parents=True, exist_ok=True) with zf.open(member) as source, target_path.open("wb") as out: shutil.copyfileobj(source, out) # ============================================================================ # Atomic file write # ============================================================================ def atomic_write_json(path, payload): """Write `payload` as pretty JSON to `path`, atomically. Sequence (see DESIGN.md §4 step 7): 1. Write the bytes to a sibling `.tmp` so any failure doesn't leave a half-written `path`. 2. fsync the tmp file so the bytes are on disk, not just buffered. 3. `os.replace` performs the atomic swap. 4. Best-effort fsync the containing directory so the rename itself is durable on POSIX. Windows doesn't support directory fsync — we catch and ignore that. A failure during step 1-2 removes the tmp file and the old `path` stays intact. A failure after step 3 leaves the new file in place. Either way we never end up with a corrupted `path`. """ parent = path.parent parent.mkdir(parents=True, exist_ok=True) tmp_path = parent / (path.name + ".tmp") encoded = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") # Open with low-level os.open so we can fsync the file descriptor # before closing — important for crash safety. fd = os.open( tmp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644, ) try: with os.fdopen(fd, "wb") as f: f.write(encoded) f.flush() os.fsync(f.fileno()) except Exception: # If anything went wrong before we got to `os.replace`, drop # the tmp file so we don't leak garbage on disk. tmp_path.unlink(missing_ok=True) raise # The atomic swap. os.replace(tmp_path, path) # Best-effort: persist the rename itself. _fsync_directory_best_effort(parent) def _fsync_directory_best_effort(directory): """fsync a directory so the most recent rename inside it is durable. Some platforms (notably Windows) don't support opening a directory for fsync — that's fine, the `os.replace` itself is still atomic, just slightly less crash-durable on those platforms. """ try: dir_fd = os.open(directory, os.O_DIRECTORY) except OSError: return try: os.fsync(dir_fd) except OSError: pass finally: os.close(dir_fd) # ============================================================================ # Idempotency check # ============================================================================ def already_up_to_date(out_dir, upstream_last_modified): """Return True if the previous run already fetched this exact version. Compares the upstream Last-Modified value against the one we recorded in `manifest-latest.json` on the previous successful run. Also verifies the per-edition `manifest.json` still exists and parses, so a half-cleaned-up `data/` doesn't fool us. Returns False if anything is missing, mismatched, or unparseable — the safe choice is always to re-download. """ if upstream_last_modified is None: # Server didn't give us a Last-Modified; we can't trust the # cache for this run. return False latest_pointer = out_dir / "manifest-latest.json" if not latest_pointer.exists(): return False # Read the pointer file. Anything unparseable means we proceed # with a fresh download. try: latest = json.loads(latest_pointer.read_text("utf-8")) except (OSError, json.JSONDecodeError): return False if not isinstance(latest, dict): return False # The header on disk must exactly equal the one the server just # told us about. if latest.get("http_last_modified") != upstream_last_modified: return False # Cross-check that the edition the pointer references is still on # disk and not just a stale pointer. cached_slug = latest.get("slug") or "" if not cached_slug: return False cached_manifest = out_dir / cached_slug / "manifest.json" if not cached_manifest.exists(): return False try: json.loads(cached_manifest.read_text("utf-8")) except (OSError, json.JSONDecodeError): return False return True # ============================================================================ # Main orchestration # ============================================================================ def _parse_args(argv): """Build the argparse object and parse `argv`.""" parser = argparse.ArgumentParser( description=( "Download the BNetzA German amateur-radio exam question " "catalog, validate it, and extract it into a per-edition " "directory." ), ) parser.add_argument( "--out", type=Path, default=DEFAULT_OUT_DIR, help="output root directory (default: ./data)", ) parser.add_argument( "--force", action="store_true", help=( "re-download and replace an existing edition directory " "when its content has changed" ), ) parser.add_argument( "--keep-zip", action="store_true", help=( "keep the raw downloaded ZIP next to the extracted tree, " "as /source.zip" ), ) # Hidden flag, useful for tests that want to point at a local # mirror or fixture URL. parser.add_argument( "--source-url", default=DEFAULT_SOURCE_URL, help=argparse.SUPPRESS, ) return parser.parse_args(argv) def main(argv=None): """Top-level entry point. Returns an exit code; never raises.""" args = _parse_args(argv) out_dir = args.out out_dir.mkdir(parents=True, exist_ok=True) # ---- Step 1: HEAD + idempotency skip. # If the upstream file hasn't been touched since our last # successful run, there's no useful work left to do. try: upstream_last_modified = http_head_last_modified(args.source_url) except (urllib.error.URLError, TimeoutError) as e: print(f"error: HEAD request failed: {e}", file=sys.stderr) return EXIT_ERROR if not args.force and already_up_to_date(out_dir, upstream_last_modified): cached = json.loads( (out_dir / "manifest-latest.json").read_text("utf-8"), ) print( f"up to date: {cached['slug']} " f"(Last-Modified={upstream_last_modified})" ) return EXIT_OK # ---- Step 2: stream the ZIP to a scratch location. # We put the scratch file inside `out_dir/.tmp/` (rather than # /tmp/) so that the later `os.replace` happens within the same # filesystem and is therefore atomic. scratch_dir = out_dir / ".tmp" scratch_dir.mkdir(parents=True, exist_ok=True) zip_scratch = scratch_dir / "download.zip" try: sha256, get_last_modified, zip_size = http_get_to_file( args.source_url, zip_scratch, max_bytes=MAX_COMPRESSED_BYTES, ) except (urllib.error.URLError, TimeoutError, ValidationError) as e: print(f"error: download failed: {e}", file=sys.stderr) zip_scratch.unlink(missing_ok=True) return EXIT_ERROR # Prefer the Last-Modified from the GET; fall back to the HEAD # value if the GET response didn't include one. effective_last_modified = get_last_modified or upstream_last_modified # ---- Step 3: validate the archive. Stops here on any structural # problem. try: archive = validate_zip(zip_scratch) except ValidationError as e: print(f"error: validation failed: {e}", file=sys.stderr) zip_scratch.unlink(missing_ok=True) return EXIT_ERROR # ---- Step 4: derive the per-edition directory name. slug = derive_slug(archive.json_data["metadata"]) edition_dir = out_dir / slug edition_tmp_dir = out_dir / f"{slug}.tmp" edition_bak_dir = out_dir / f"{slug}.bak" # ---- Step 5: decide what to do given any existing edition_dir. # When the operator did NOT pass --force, we honor any existing # extraction: # * same bytes already on disk: skip re-extraction, just # refresh the latest pointer # * different bytes already on disk: refuse to clobber # # When --force IS set, we always proceed to re-extract. The # sha-match shortcut would mask a corrupted on-disk tree (the # manifest could record the correct sha while files inside are # missing or truncated), and that is exactly the situation # --force exists to repair. if not args.force and edition_dir.exists(): existing_sha = _read_existing_zip_sha(edition_dir) if existing_sha == sha256: print(f"already extracted (sha256 match): {slug}") _handle_zip_after_success( zip_scratch, edition_dir, args.keep_zip, ) _write_latest_pointer( out_dir, slug, effective_last_modified, sha256, ) _try_remove_empty(scratch_dir) return EXIT_OK print( f"error: {edition_dir} already exists with different " f"content. Re-run with --force to replace.", file=sys.stderr, ) zip_scratch.unlink(missing_ok=True) return EXIT_BAD_STATE # ---- Step 6: refuse to clobber any stale .tmp/.bak siblings. # Each indicates a previous crash and might hold data the operator # wants to inspect; we never touch them without explicit human # review. for stale_path in (edition_tmp_dir, edition_bak_dir): if stale_path.exists(): print( f"error: stale path {stale_path} exists. Inspect " f"and remove manually before retrying.", file=sys.stderr, ) zip_scratch.unlink(missing_ok=True) return EXIT_BAD_STATE # ---- Step 7: extract into edition_tmp_dir, write its manifest. try: extract_zip(zip_scratch, edition_tmp_dir) except Exception as e: # noqa: BLE001 (surface unexpected fails as exit 1) print(f"error: extraction failed: {e}", file=sys.stderr) shutil.rmtree(edition_tmp_dir, ignore_errors=True) zip_scratch.unlink(missing_ok=True) return EXIT_ERROR # Wrap the remaining steps (manifest write, atomic swap, post- # swap housekeeping) so any failure becomes EXIT_ERROR instead of # an exception escaping main(). The docstring promises we never # raise; this `try` is what enforces it. try: # Belt-and-braces: extraction should already have written # README.txt, but if a hypothetical future archive lacks one # we fall back to the text we read into memory during # validation. readme_in_tmp = edition_tmp_dir / "README.txt" if not readme_in_tmp.exists() and archive.readme_text: readme_in_tmp.write_text(archive.readme_text, encoding="utf-8") atomic_write_json( edition_tmp_dir / "manifest.json", _build_manifest( source_url=args.source_url, archive=archive, slug=slug, sha256=sha256, zip_size=zip_size, last_modified=effective_last_modified, ), ) # ---- Step 8: atomic directory swap. # If a previous edition_dir is present, rename it out of the # way first. If the new rename fails we attempt to restore # the old one so the operator doesn't end up empty-handed. # If the restore ALSO fails we leave both `.bak/` and `.tmp/` # on disk: the operator can recover by hand # (`mv .bak/ /`). if edition_dir.exists(): os.replace(edition_dir, edition_bak_dir) try: os.replace(edition_tmp_dir, edition_dir) except Exception: os.replace(edition_bak_dir, edition_dir) raise shutil.rmtree(edition_bak_dir, ignore_errors=True) else: os.replace(edition_tmp_dir, edition_dir) # ---- Step 9: post-success housekeeping. # These don't undo the extraction, but a failure here (disk # full, permission flip) is something the operator should # see — so we surface it as EXIT_ERROR rather than swallow. _handle_zip_after_success( zip_scratch, edition_dir, args.keep_zip, ) _write_latest_pointer( out_dir, slug, effective_last_modified, sha256, ) except Exception as e: # noqa: BLE001 (main() promises not to raise) print( f"error: failure after extraction: {e}", file=sys.stderr, ) # If the tmp dir is still on disk it's the abandoned new # extraction (either pre-swap, or rolled back after a swap # failure) — safe to drop. We deliberately don't touch # edition_bak_dir: if it still exists it holds the previous # edition's content and the operator may want it for manual # recovery. if edition_tmp_dir.exists(): shutil.rmtree(edition_tmp_dir, ignore_errors=True) zip_scratch.unlink(missing_ok=True) return EXIT_ERROR _try_remove_empty(scratch_dir) print(f"extracted: {edition_dir}") if archive.missing_pictures: print( f"note: {len(archive.missing_pictures)} missing picture " f"reference(s) recorded in manifest (soft check)" ) return EXIT_OK # ============================================================================ # Small helpers used by main() # ============================================================================ def _read_existing_zip_sha(edition_dir): """Return the `zip_sha256` from an existing edition's manifest, or `None` if it's missing or unreadable.""" manifest_path = edition_dir / "manifest.json" try: existing = json.loads(manifest_path.read_text("utf-8")) except (OSError, json.JSONDecodeError): return None if not isinstance(existing, dict): return None return existing.get("zip_sha256") def _build_manifest( source_url, archive, slug, sha256, zip_size, last_modified, ): """Construct the per-edition `manifest.json` payload.""" return { "source_url": source_url, "fetched_at": dt.datetime.now(dt.timezone.utc).isoformat( timespec="seconds", ), "http_last_modified": last_modified, "zip_sha256": sha256, "zip_size": zip_size, "json_filename": archive.json_filename, "metadata": archive.json_data["metadata"], "attribution": archive.attribution, "missing_pictures": archive.missing_pictures, "slug": slug, } def _handle_zip_after_success(zip_path, edition_dir, keep_zip): """Either move the downloaded ZIP into `edition_dir` (when `--keep-zip` is set) or remove it. Either way, the scratch ZIP is gone by the time this returns. """ if keep_zip and zip_path.exists(): try: shutil.move(str(zip_path), str(edition_dir / "source.zip")) return except OSError: # Falling through to unlink: archival is best-effort and # never worth failing the whole run over. pass zip_path.unlink(missing_ok=True) def _write_latest_pointer(out_dir, slug, last_modified, sha256): """Atomically update the pointer that drives the idempotency check.""" atomic_write_json( out_dir / "manifest-latest.json", { "slug": slug, "http_last_modified": last_modified, "zip_sha256": sha256, }, ) def _try_remove_empty(directory): """Remove `directory` if it's empty. Silent if it isn't or doesn't exist.""" try: directory.rmdir() except OSError: pass if __name__ == "__main__": sys.exit(main())