1+ import argparse
2+ import os
3+ from pathlib import Path
4+ from typing import Iterable , Tuple
5+
6+
7+ def find_pyc_files (root : Path ) -> Iterable [Path ]:
8+ """Yield all .pyc files under root (recursive)."""
9+ for dirpath , _ , filenames in os .walk (root ):
10+ for fn in filenames :
11+ if fn .endswith ('.pyc' ):
12+ yield Path (dirpath ) / fn
13+
14+
15+ def corresponding_py_for_pyc (pyc_path : Path ) -> Path :
16+ """Return the Path to the likely corresponding .py source for a .pyc file.
17+
18+ Rules:
19+ - If the .pyc is inside a __pycache__ directory, the source is one level up
20+ with the base module name (strip everything after the first dot in the
21+ pyc file name).
22+ - Otherwise, replace the .pyc suffix with .py in the same directory.
23+ """
24+ if pyc_path .parent .name == '__pycache__' :
25+ # Example: __pycache__/module.cpython-38.opt-1.pyc -> ../module.py
26+ base = pyc_path .stem .split ('.' , 1 )[0 ]
27+ return pyc_path .parent .parent / (base + '.py' )
28+ else :
29+ return pyc_path .with_suffix ('.py' )
30+
31+
32+ def clean_pyc (root : Path , dry_run : bool = True , verbose : bool = False ) -> Tuple [int , int ]:
33+ """Remove .pyc files that have corresponding .py sources.
34+
35+ Returns a tuple (checked, removed).
36+ """
37+ checked = 0
38+ removed = 0
39+ for pyc in find_pyc_files (root ):
40+ checked += 1
41+ src = corresponding_py_for_pyc (pyc )
42+ if src .exists ():
43+ if verbose :
44+ print (f"Will remove: { pyc } (found source: { src } )" )
45+ if not dry_run :
46+ try :
47+ pyc .unlink ()
48+ removed += 1
49+ except Exception as e :
50+ print (f"Failed to remove { pyc } : { e } " )
51+ else :
52+ if verbose :
53+ print (f"Keep: { pyc } (no source { src } )" )
54+ return checked , removed
55+
56+
57+ def parse_args () -> argparse .Namespace :
58+ p = argparse .ArgumentParser (
59+ description = 'Delete .pyc files when corresponding .py sources exist.'
60+ )
61+ p .add_argument ('path' , nargs = '?' , default = '.' , help = 'Root path to scan' )
62+ p .add_argument ('--dry-run' , action = 'store_true' , help = 'Only show what would be deleted' )
63+ p .add_argument ('--verbose' , action = 'store_true' , help = 'Show verbose output' )
64+ return p .parse_args ()
65+
66+
67+ def main () -> int :
68+ args = parse_args ()
69+ root = Path (args .path ).resolve ()
70+ if not root .exists ():
71+ print (f'Path does not exist: { root } ' )
72+ return 2
73+
74+ dry_run = args .dry_run
75+
76+ if args .verbose :
77+ print (f'Scanning: { root } ' )
78+ print (f'dry_run={ dry_run } ' )
79+
80+ checked , removed = clean_pyc (root , dry_run = dry_run , verbose = args .verbose )
81+
82+ print (f"Checked .pyc files: { checked } " )
83+ if dry_run :
84+ print (f"Dry run: would remove { removed } files" )
85+ else :
86+ print (f"Removed { removed } files" )
87+
88+ return 0
89+
90+
91+ if __name__ == '__main__' :
92+ raise SystemExit (main ())
0 commit comments