|
| 1 | +#!/usr/bin/env python3 |
| 2 | + |
| 3 | +# Pick a random sample of code_id keys for YAML files under codes/ that received |
| 4 | +# a substantive edit within the N months preceding a given reference date. |
| 5 | +# |
| 6 | +# Bulk commits that only stamp a new _meta changelog entry (e.g. e9237537c |
| 7 | +# touched 1104 of 1142 files) would otherwise mark nearly every code as |
| 8 | +# recently edited, so changes consisting solely of changelog entries do not |
| 9 | +# count as edits here. |
| 10 | + |
| 11 | +from __future__ import annotations |
| 12 | + |
| 13 | +import argparse |
| 14 | +import calendar |
| 15 | +import datetime |
| 16 | +import random |
| 17 | +import re |
| 18 | +import subprocess |
| 19 | +from pathlib import Path |
| 20 | + |
| 21 | +REPO_ROOT = Path(__file__).resolve().parent.parent |
| 22 | + |
| 23 | +CHANGELOG_LINE = re.compile(r"^[+-]\s*(-\s*)?(user_id|date)\s*:", re.IGNORECASE) |
| 24 | + |
| 25 | + |
| 26 | +def months_before(date: datetime.date, months: int) -> datetime.date: |
| 27 | + month_index = date.month - 1 - months |
| 28 | + year = date.year + month_index // 12 |
| 29 | + month = month_index % 12 + 1 |
| 30 | + return datetime.date(year, month, min(date.day, calendar.monthrange(year, month)[1])) |
| 31 | + |
| 32 | + |
| 33 | +def build_rename_map(start_date: datetime.date, end_date: datetime.date) -> dict[str, str]: |
| 34 | + # Codes get reshuffled through the taxonomy, so an edit often lands under a |
| 35 | + # path that no longer exists. Chain renames oldest-first so a historical |
| 36 | + # path can be walked forward to where the entry lives now. |
| 37 | + result = subprocess.run( |
| 38 | + [ |
| 39 | + "git", "log", "--reverse", |
| 40 | + f"--since={start_date.isoformat()}", |
| 41 | + f"--until={end_date.isoformat()} 23:59:59", |
| 42 | + "--name-status", "--find-renames", "--no-color", "--pretty=format:", |
| 43 | + "--", "codes", |
| 44 | + ], |
| 45 | + cwd=REPO_ROOT, |
| 46 | + capture_output=True, |
| 47 | + text=True, |
| 48 | + check=True, |
| 49 | + ) |
| 50 | + |
| 51 | + renames: dict[str, str] = {} |
| 52 | + for line in result.stdout.splitlines(): |
| 53 | + if not line.startswith("R"): |
| 54 | + continue |
| 55 | + fields = line.split("\t") |
| 56 | + if len(fields) == 3: |
| 57 | + renames[fields[1]] = fields[2] |
| 58 | + return renames |
| 59 | + |
| 60 | + |
| 61 | +def resolve_current_path(path: str, renames: dict[str, str]) -> str: |
| 62 | + seen: set[str] = set() |
| 63 | + while path in renames and path not in seen: |
| 64 | + seen.add(path) |
| 65 | + path = renames[path] |
| 66 | + return path |
| 67 | + |
| 68 | + |
| 69 | +def find_substantively_edited_paths(start_date: datetime.date, end_date: datetime.date) -> set[Path]: |
| 70 | + process = subprocess.Popen( |
| 71 | + [ |
| 72 | + "git", "log", |
| 73 | + f"--since={start_date.isoformat()}", |
| 74 | + f"--until={end_date.isoformat()} 23:59:59", |
| 75 | + "-p", "--unified=0", "--no-color", "--pretty=format:", |
| 76 | + "--", "codes", |
| 77 | + ], |
| 78 | + cwd=REPO_ROOT, |
| 79 | + stdout=subprocess.PIPE, |
| 80 | + text=True, |
| 81 | + ) |
| 82 | + |
| 83 | + edited: set[str] = set() |
| 84 | + current: str | None = None |
| 85 | + in_hunk = False |
| 86 | + |
| 87 | + # Track hunk state explicitly: only inside a hunk does a leading +/- mean a |
| 88 | + # changed line. Otherwise the "--- a/path" header, or a blank line between |
| 89 | + # commits, gets misread as content. |
| 90 | + assert process.stdout is not None |
| 91 | + for line in process.stdout: |
| 92 | + line = line.rstrip("\n") |
| 93 | + if line.startswith("diff --git "): |
| 94 | + current, in_hunk = None, False |
| 95 | + elif line.startswith("+++ "): |
| 96 | + target = line[4:].strip() |
| 97 | + # "+++ /dev/null" marks a deletion and must not inherit the previous file. |
| 98 | + current = target[2:] if target.startswith("b/") and target.endswith(".yml") else None |
| 99 | + elif line.startswith("@@"): |
| 100 | + in_hunk = True |
| 101 | + elif in_hunk and current is not None and line[:1] in ("+", "-"): |
| 102 | + if line.strip() in {"+", "-"} or CHANGELOG_LINE.match(line): |
| 103 | + continue |
| 104 | + edited.add(current) |
| 105 | + current = None # one substantive line settles this file |
| 106 | + |
| 107 | + if process.wait() != 0: |
| 108 | + raise subprocess.CalledProcessError(process.returncode, "git log") |
| 109 | + |
| 110 | + renames = build_rename_map(start_date, end_date) |
| 111 | + resolved = {REPO_ROOT / resolve_current_path(path, renames) for path in edited} |
| 112 | + return {path for path in resolved if path.is_file()} |
| 113 | + |
| 114 | + |
| 115 | +def extract_code_id(path: Path) -> str | None: |
| 116 | + with path.open("r", encoding="utf-8") as handle: |
| 117 | + for line in handle: |
| 118 | + stripped = line.split("#", 1)[0].strip() |
| 119 | + if stripped.startswith("code_id:"): |
| 120 | + return stripped[len("code_id:"):].strip().strip("'\"") |
| 121 | + return None |
| 122 | + |
| 123 | + |
| 124 | +def main() -> None: |
| 125 | + parser = argparse.ArgumentParser( |
| 126 | + description="Randomly sample code_id keys for code YAML files edited in the months before a given date." |
| 127 | + ) |
| 128 | + parser.add_argument( |
| 129 | + "date", |
| 130 | + nargs="?", |
| 131 | + type=datetime.date.fromisoformat, |
| 132 | + default=datetime.date.today(), |
| 133 | + help="Reference date, YYYY-MM-DD (default: today).", |
| 134 | + ) |
| 135 | + parser.add_argument("--months", type=int, default=3, help="Lookback window in months (default: 3).") |
| 136 | + parser.add_argument("--count", type=int, default=5, help="Number of code_id keys to print (default: 5).") |
| 137 | + parser.add_argument("--seed", type=int, default=None, help="Random seed, for reproducible sampling.") |
| 138 | + args = parser.parse_args() |
| 139 | + |
| 140 | + reference_date = args.date |
| 141 | + start_date = months_before(reference_date, args.months) |
| 142 | + |
| 143 | + candidates = sorted( |
| 144 | + code_id |
| 145 | + for code_id in ( |
| 146 | + extract_code_id(path) |
| 147 | + for path in find_substantively_edited_paths(start_date, reference_date) |
| 148 | + ) |
| 149 | + if code_id is not None |
| 150 | + ) |
| 151 | + |
| 152 | + rng = random.Random(args.seed) |
| 153 | + for code_id in rng.sample(candidates, min(args.count, len(candidates))): |
| 154 | + print(code_id) |
| 155 | + |
| 156 | + |
| 157 | +if __name__ == "__main__": |
| 158 | + main() |
0 commit comments