1- import os
1+ # Path: scripts/clean_unused_packages.py
2+ # pylint: disable=line-too-long,broad-exception-caught
3+ """
4+ Search a project and attempt to remove unused packages, accounting for dependencies,
5+ using importlib.metadata; generate cleaned requirements and removal recommendations.
6+ """
27import ast
38import subprocess
4- import pkg_resources
9+ import sys
10+ import sysconfig
511from pathlib import Path
12+ import importlib .metadata as metadata
13+
614
715def get_installed_packages ():
8- return {pkg .key for pkg in pkg_resources .working_set }
16+ """Get a set of all installed package distribution names (lowercased)."""
17+ return {dist .metadata ['Name' ].lower () for dist in metadata .distributions () if 'Name' in dist .metadata }
918
10- def get_imported_modules (code_root ):
11- imported = set ()
1219
13- for file in Path (code_root ).rglob ("*.py" ):
20+ def get_imported_modules (code_root : Path ):
21+ """Get a set of all top-level modules imported from code_root."""
22+ imported_loc = set ()
23+ for file in code_root .rglob ("*.py" ):
1424 try :
15- with open (file , "r" , encoding = "utf-8" ) as f :
16- tree = ast .parse (f .read (), filename = str (file ))
17- for node in ast .walk (tree ):
18- if isinstance (node , ast .Import ):
19- for alias in node .names :
20- imported .add (alias .name .split ('.' )[0 ])
21- elif isinstance (node , ast .ImportFrom ):
22- if node .module :
23- imported .add (node .module .split ('.' )[0 ])
25+ tree = ast .parse (file .read_text (encoding = "utf-8" ), filename = str (file ))
26+ for node in ast .walk (tree ):
27+ if isinstance (node , ast .Import ):
28+ for alias in node .names :
29+ imported_loc .add (alias .name .split ('.' )[0 ].lower ())
30+ elif isinstance (node , ast .ImportFrom ):
31+ if node .module :
32+ imported_loc .add (node .module .split ('.' )[0 ].lower ())
2433 except Exception as e :
2534 print (f"⚠️ Skipping { file } : { e } " )
26- return imported
35+ return imported_loc
2736
28- def find_unneeded_packages (imported_modules , installed_packages ):
29- builtin_modules = stdlib_modules ()
30- safe_imports = imported_modules - builtin_modules
31- return sorted (installed_packages - safe_imports )
3237
3338def stdlib_modules ():
34- # Includes common builtins — this list is a best-effort, not exhaustive
35- import sys
39+ """Return a set of standard library module names."""
3640 if hasattr (sys , 'stdlib_module_names' ):
37- return set (sys .stdlib_module_names )
38- else :
39- import distutils .sysconfig as sysconfig
40- stdlib = sysconfig .get_python_lib (standard_lib = True )
41- return {p .name for p in Path (stdlib ).iterdir () if p .is_dir ()}
41+ return set (m .lower () for m in sys .stdlib_module_names )
42+ stdlib_path = sysconfig .get_paths ()["stdlib" ]
43+ return {p .name .lower () for p in Path (stdlib_path ).iterdir () if p .is_dir ()}
44+
45+
46+ def get_all_dependencies (packages ):
47+ """
48+ Given an iterable of distribution names, return a set of all recursive dependencies.
49+ """
50+ deps = set ()
51+ to_process = list (packages )
52+ while to_process :
53+ pkg = to_process .pop ()
54+ try :
55+ dist = metadata .distribution (pkg )
56+ except metadata .PackageNotFoundError :
57+ continue
58+ for req in dist .requires or []:
59+ # Extract distribution name
60+ name = req .split (';' , 1 )[0 ].split ('[' )[0 ].split (' ' )[0 ].split ('=' )[0 ].lower ()
61+ if name and name not in packages and name not in deps :
62+ deps .add (name )
63+ to_process .append (name )
64+ return deps
65+
66+
67+ def find_unneeded_packages (imported_modules , installed_packages ):
68+ """Find distributions installed but not needed by imports or dependencies."""
69+ builtin = stdlib_modules ()
70+ # Map top-level modules to distributions
71+ pkg2dist = metadata .packages_distributions ()
72+ # Identify distributions directly used by imports
73+ safe = set ()
74+ for mod in imported_modules :
75+ if mod in builtin :
76+ continue
77+ for dist in pkg2dist .get (mod , []):
78+ safe .add (dist .lower ())
79+ # Filter to installed distributions
80+ safe = safe & set (installed_packages )
81+ # Add recursive dependencies
82+ all_needed = set (safe )
83+ all_needed .update (get_all_dependencies (safe ))
84+ # Unused = installed minus needed
85+ return sorted (installed_packages - all_needed )
86+
4287
4388def uninstall_packages (packages ):
89+ """Uninstall the given list of packages via pip."""
4490 for pkg in packages :
45- confirm = input (f"Uninstall '{ pkg } '? [y/N]: " ).lower ()
46- if confirm == "y" :
47- subprocess .run ([" pip" , " uninstall" , "-y" , pkg ])
91+ confirm = input (f"Uninstall '{ pkg } '? [y/N]: " ).strip (). lower ()
92+ if confirm == 'y' :
93+ subprocess .run ([sys . executable , '-m' , ' pip' , ' uninstall' , '-y' , pkg ], check = False )
4894
49- if __name__ == "__main__" :
95+
96+ def write_recommendations (unused , rec_file : Path ):
97+ """Write recommended removals to a text file."""
98+ rec_file .write_text ('\n ' .join (unused ) + '\n ' , encoding = 'utf-8' )
99+ print (f"📄 Recommended removals saved to: { rec_file } " )
100+
101+
102+ def write_clean_requirements (unused , req_file : Path ):
103+ """Generate a cleaned requirements.txt and write to file."""
104+ try :
105+ freeze = subprocess .check_output ([sys .executable , '-m' , 'pip' , 'freeze' ], text = True )
106+ lines = []
107+ for line in freeze .splitlines ():
108+ name = line .split ('==' )[0 ].lower ()
109+ if name not in unused :
110+ lines .append (line )
111+ req_file .write_text ('\n ' .join (lines ) + '\n ' , encoding = 'utf-8' )
112+ print (f"📄 Cleaned requirements saved to: { req_file } " )
113+ except subprocess .CalledProcessError as e :
114+ print (f"❌ Failed to generate cleaned requirements: { e } " )
115+
116+
117+ def main ():
118+ """Main function."""
50119 print ("🔍 Scanning for unused packages..." )
51- code_root = "." # You can customize this path
120+ code_root = Path ('.' ) # customize if needed
121+
52122 installed = get_installed_packages ()
53123 imported = get_imported_modules (code_root )
54124 unused = find_unneeded_packages (imported , installed )
@@ -57,7 +127,14 @@ def uninstall_packages(packages):
57127 for pkg in unused :
58128 print (f" - { pkg } " )
59129
60- if unused :
61- uninstall = input ("\n ❓ Do you want to uninstall them now? [y/N]: " ).lower ()
62- if uninstall == "y" :
63- uninstall_packages (unused )
130+ # Always write recommendations and cleaned requirements
131+ rec_file = Path ('recommended_removals.txt' )
132+ req_file = Path ('requirements_cleaned.txt' )
133+ write_recommendations (unused , rec_file )
134+ write_clean_requirements (unused , req_file )
135+
136+ if unused and input ("\n ❓ Uninstall them now? [y/N]: " ).strip ().lower () == 'y' :
137+ uninstall_packages (unused )
138+
139+ if __name__ == '__main__' :
140+ main ()
0 commit comments