|
| 1 | +''' |
| 2 | +python find_unused_images.py |
| 3 | +python find_unused_images.py --delete |
| 4 | +python find_unused_images.py --delete --yes |
| 5 | +python find_unused_images.py --debug |
| 6 | +''' |
| 7 | + |
| 8 | +#!/usr/bin/env python3 |
| 9 | +import argparse |
| 10 | +import csv |
| 11 | +import os |
| 12 | +import re |
| 13 | +from pathlib import Path |
| 14 | +from urllib.parse import urlparse |
| 15 | + |
| 16 | +ROOT_DIRS = [ |
| 17 | + Path(r"source"), |
| 18 | + Path(r"student-source"), |
| 19 | +] |
| 20 | + |
| 21 | +# IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"} # .svg for logos. |
| 22 | +IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp"} |
| 23 | + |
| 24 | +# Matches: |
| 25 | +# .. image:: /path.png |
| 26 | +# .. figure:: /path.png |
| 27 | +# .. |Alias| image:: /path.png |
| 28 | +# And when the directive appears inline (e.g., inside a table cell). |
| 29 | +# Accept plain, "quoted", or 'quoted' paths so we handle spaces. |
| 30 | +DIRECTIVE_RE = re.compile( |
| 31 | + r""" |
| 32 | + \.\.\s+ # leading '.. ' |
| 33 | + (?:\|[^|]+\|\s+)? # optional '|Alias| ' |
| 34 | + (?:image|figure)::\s+ # image:: or figure:: |
| 35 | + (?P<path>"[^"]+"|'[^']+'|\S+) # the path (quoted or non-space) |
| 36 | + """, |
| 37 | + re.IGNORECASE | re.VERBOSE, |
| 38 | +) |
| 39 | + |
| 40 | +SKIP_DIRS = {"_build", ".venv", "venv", "node_modules", ".git", "__pycache__"} |
| 41 | + |
| 42 | +def is_url(s: str) -> bool: |
| 43 | + try: |
| 44 | + p = urlparse(s) |
| 45 | + return bool(p.scheme) and bool(p.netloc) |
| 46 | + except Exception: |
| 47 | + return False |
| 48 | + |
| 49 | +def common_parent(paths: list[Path]) -> Path: |
| 50 | + parts = os.path.commonpath([str(p) for p in paths]) |
| 51 | + return Path(parts) |
| 52 | + |
| 53 | +def strip_quotes(s: str) -> str: |
| 54 | + if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")): |
| 55 | + return s[1:-1] |
| 56 | + return s |
| 57 | + |
| 58 | +def normalize_ref(s: str) -> str: |
| 59 | + # normalize backslashes, trim quotes, strip leading/trailing spaces |
| 60 | + s = strip_quotes(s.strip()).replace("\\", "/") |
| 61 | + return s |
| 62 | + |
| 63 | +def rel_from_any_root(p: Path, roots_abs: list[Path]) -> tuple[Path, str] | None: |
| 64 | + """Return (root, relative_posix) if p is under any root; else None.""" |
| 65 | + for r in roots_abs: |
| 66 | + try: |
| 67 | + rel = p.relative_to(r).as_posix() |
| 68 | + return r, rel |
| 69 | + except ValueError: |
| 70 | + continue |
| 71 | + return None |
| 72 | + |
| 73 | +def list_image_files(roots_abs: list[Path], debug=False) -> set[tuple[Path, str]]: |
| 74 | + """ |
| 75 | + Return a set of (root, rel_path) for all images under all roots. |
| 76 | + """ |
| 77 | + found: set[tuple[Path, str]] = set() |
| 78 | + for root in roots_abs: |
| 79 | + for dirpath, dirnames, filenames in os.walk(str(root)): |
| 80 | + dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] |
| 81 | + for fn in filenames: |
| 82 | + if Path(fn).suffix.lower() in IMAGE_EXTS: |
| 83 | + full = (Path(dirpath) / fn).resolve() |
| 84 | + try: |
| 85 | + rel = full.relative_to(root).as_posix() |
| 86 | + found.add((root, rel)) |
| 87 | + except ValueError: |
| 88 | + if debug: |
| 89 | + print(f"[skip-not-under-root] {full}") |
| 90 | + continue |
| 91 | + if debug: |
| 92 | + print(f"[debug] Found {len(found)} images on disk across {len(roots_abs)} root(s).") |
| 93 | + for root, rel in list(sorted(found))[:10]: |
| 94 | + print(f" - [{root.name}] {rel}") |
| 95 | + return found |
| 96 | + |
| 97 | +def resolve_reference(rst_file: Path, ref: str, roots_abs: list[Path]) -> tuple[Path, str] | None: |
| 98 | + """ |
| 99 | + Resolve an image/figure reference to (root, relative_posix) across multiple roots. |
| 100 | + Strategy: |
| 101 | + * Absolute (/img/foo.png): try each root/<rel>. |
| 102 | + * Relative (img/foo.png or ../img/foo.png): resolve against rst_file and then map to a root. |
| 103 | + * If file doesn't exist, still attempt to map path to a root by anchor (best-effort). |
| 104 | + """ |
| 105 | + if is_url(ref): |
| 106 | + return None |
| 107 | + |
| 108 | + ref = normalize_ref(ref) |
| 109 | + |
| 110 | + if ref.startswith("/"): |
| 111 | + rel = ref.lstrip("/") |
| 112 | + for r in roots_abs: |
| 113 | + cand = (r / rel).resolve() |
| 114 | + if cand.exists(): |
| 115 | + return r, rel |
| 116 | + for r in roots_abs: |
| 117 | + try: |
| 118 | + rst_file.relative_to(r) |
| 119 | + return r, rel |
| 120 | + except ValueError: |
| 121 | + continue |
| 122 | + return None |
| 123 | + |
| 124 | + candidate = (rst_file.parent / ref) |
| 125 | + try: |
| 126 | + cand_abs = candidate.resolve() |
| 127 | + except FileNotFoundError: |
| 128 | + cand_abs = candidate.absolute() |
| 129 | + |
| 130 | + mapped = rel_from_any_root(cand_abs, roots_abs) |
| 131 | + if mapped: |
| 132 | + return mapped |
| 133 | + |
| 134 | + for r in roots_abs: |
| 135 | + try: |
| 136 | + rst_file.relative_to(r) |
| 137 | + try: |
| 138 | + rel = cand_abs.relative_to(r).as_posix() |
| 139 | + return r, rel |
| 140 | + except ValueError: |
| 141 | + return r, Path(ref).as_posix() |
| 142 | + except ValueError: |
| 143 | + continue |
| 144 | + return None |
| 145 | + |
| 146 | +def parse_rst_image_refs(roots_abs: list[Path], debug=False) -> set[tuple[Path, str]]: |
| 147 | + """ |
| 148 | + Parse all .rst files under all roots and collect referenced images as (root, rel_path). |
| 149 | + """ |
| 150 | + refs: set[tuple[Path, str]] = set() |
| 151 | + for root in roots_abs: |
| 152 | + for dirpath, dirnames, filenames in os.walk(str(root)): |
| 153 | + dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] |
| 154 | + for fn in filenames: |
| 155 | + if not fn.lower().endswith(".rst"): |
| 156 | + continue |
| 157 | + rst_path = Path(dirpath) / fn |
| 158 | + try: |
| 159 | + text = rst_path.read_text(encoding="utf-8") |
| 160 | + except UnicodeDecodeError: |
| 161 | + text = rst_path.read_text(encoding="latin-1") |
| 162 | + |
| 163 | + for m in DIRECTIVE_RE.finditer(text): |
| 164 | + raw = m.group("path") |
| 165 | + raw = normalize_ref(raw) |
| 166 | + if not any(raw.lower().endswith(ext) for ext in IMAGE_EXTS): |
| 167 | + continue |
| 168 | + resolved = resolve_reference(rst_path.resolve(), raw, roots_abs) |
| 169 | + if resolved is not None: |
| 170 | + refs.add(resolved) |
| 171 | + elif debug: |
| 172 | + print(f"[warn] Could not resolve: {raw} in {rst_path}") |
| 173 | + |
| 174 | + if debug: |
| 175 | + print(f"[debug] Found {len(refs)} image/figure references across {len(roots_abs)} root(s).") |
| 176 | + for root, rel in list(sorted(refs))[:10]: |
| 177 | + print(f" - [{root.name}] {rel}") |
| 178 | + return refs |
| 179 | + |
| 180 | +def write_csv(unused: list[tuple[Path, str]], csv_path: Path): |
| 181 | + csv_path.parent.mkdir(parents=True, exist_ok=True) |
| 182 | + with csv_path.open("w", newline="", encoding="utf-8") as f: |
| 183 | + writer = csv.writer(f) |
| 184 | + writer.writerow(["root", "image_path"]) # which root + rel path |
| 185 | + for root, rel in unused: |
| 186 | + writer.writerow([str(root), rel]) |
| 187 | + print(f"[info] CSV written: {csv_path}") |
| 188 | + |
| 189 | +def delete_files(unused: list[tuple[Path, str]], yes=False): |
| 190 | + if not unused: |
| 191 | + print("[info] No unused images to delete.") |
| 192 | + return |
| 193 | + if not yes: |
| 194 | + confirm = input(f"⚠️ Delete {len(unused)} unused images across all roots? (y/N): ").strip().lower() |
| 195 | + if confirm != "y": |
| 196 | + print("[info] Deletion canceled.") |
| 197 | + return |
| 198 | + deleted = 0 |
| 199 | + for root, rel in unused: |
| 200 | + try: |
| 201 | + (root / rel).unlink(missing_ok=True) |
| 202 | + deleted += 1 |
| 203 | + except Exception as e: |
| 204 | + print(f"[error] Could not delete [{root.name}] {rel}: {e}") |
| 205 | + print(f"[info] Deleted {deleted} unused image(s).") |
| 206 | + |
| 207 | +def main(): |
| 208 | + parser = argparse.ArgumentParser( |
| 209 | + description="Find or delete unused images referenced via '.. image::', '.. figure::', or '.. |alias| image::' across multiple Sphinx roots." |
| 210 | + ) |
| 211 | + parser.add_argument("--delete", action="store_true", help="Delete unused images instead of listing them") |
| 212 | + parser.add_argument("--yes", action="store_true", help="Skip confirmation prompt when deleting") |
| 213 | + parser.add_argument("--debug", action="store_true", help="Show debug info") |
| 214 | + args = parser.parse_args() |
| 215 | + |
| 216 | + roots_abs = [p.resolve() for p in ROOT_DIRS] |
| 217 | + for r in roots_abs: |
| 218 | + print(f"[info] Root directory: {r}") |
| 219 | + |
| 220 | + images_on_disk = list_image_files(roots_abs, debug=args.debug) # set[(root, rel)] |
| 221 | + referenced = parse_rst_image_refs(roots_abs, debug=args.debug) # set[(root, rel)] |
| 222 | + |
| 223 | + referenced_rels = {rel for (_root, rel) in referenced} |
| 224 | + unused = sorted([(root, rel) for (root, rel) in images_on_disk if rel not in referenced_rels], |
| 225 | + key=lambda x: (str(x[0]), x[1])) |
| 226 | + |
| 227 | + print(f"[result] Found {len(unused)} unused image(s) across all roots).") |
| 228 | + |
| 229 | + out_csv = common_parent(roots_abs) / "unused_images.csv" |
| 230 | + |
| 231 | + if args.delete: |
| 232 | + delete_files(unused, yes=args.yes) |
| 233 | + else: |
| 234 | + write_csv(unused, out_csv) |
| 235 | + if args.debug and unused: |
| 236 | + print("First few unused images:") |
| 237 | + for root, rel in unused[:10]: |
| 238 | + print(f" - [{root.name}] {rel}") |
| 239 | + |
| 240 | +if __name__ == "__main__": |
| 241 | + main() |
0 commit comments