3838
3939def create_argument_parser () -> argparse .ArgumentParser :
4040 """Create and configure the argument parser."""
41+
42+ def max_nightlies_type (value : int ) -> int :
43+ """Validate that --max-nightlies is set to a positive integer."""
44+ if int (value ) < 0 :
45+ msg = f"max-nightlies must be a positive integer, got { int (value )} "
46+ raise ValueError (msg )
47+ return int (value )
48+
4149 parser = argparse .ArgumentParser (
4250 description = """
4351PyPI cleanup script for removing development versions.
@@ -54,23 +62,54 @@ def create_argument_parser() -> argparse.ArgumentParser:
5462 formatter_class = argparse .RawDescriptionHelpFormatter ,
5563 )
5664
57- parser .add_argument ("--dry-run" , action = "store_true" , help = "Show what would be deleted but don't actually do it" )
65+ loglevel_group = parser .add_mutually_exclusive_group (required = False )
66+ loglevel_group .add_argument (
67+ "-d" ,
68+ "--debug" ,
69+ help = "Show debug logs" ,
70+ dest = "loglevel" ,
71+ action = "store_const" ,
72+ const = logging .DEBUG ,
73+ default = logging .WARNING ,
74+ )
75+ loglevel_group .add_argument (
76+ "-v" ,
77+ "--verbose" ,
78+ help = "Show info logs" ,
79+ dest = "loglevel" ,
80+ action = "store_const" ,
81+ const = logging .INFO ,
82+ )
5883
5984 host_group = parser .add_mutually_exclusive_group (required = True )
60- host_group .add_argument ("--prod" , action = "store_true" , help = "Use production PyPI (pypi.org)" )
61- host_group .add_argument ("--test" , action = "store_true" , help = "Use test PyPI (test.pypi.org)" )
85+ host_group .add_argument (
86+ "--prod" , help = "Use production PyPI (pypi.org)" , dest = "pypi_url" , action = "store_const" , const = _PYPI_URL_PROD
87+ )
88+ host_group .add_argument (
89+ "--test" , help = "Use test PyPI (test.pypi.org)" , dest = "pypi_url" , action = "store_const" , const = _PYPI_URL_TEST
90+ )
6291
6392 parser .add_argument (
6493 "-m" ,
6594 "--max-nightlies" ,
66- type = int ,
95+ type = max_nightlies_type ,
6796 default = _DEFAULT_MAX_NIGHTLIES ,
6897 help = f"Max number of nightlies of unreleased versions (default={ _DEFAULT_MAX_NIGHTLIES } )" ,
6998 )
7099
71- parser .add_argument ( "-u" , "--username" , type = validate_username , help = "PyPI username (required unless --dry-run) " )
100+ subparsers = parser .add_subparsers ( title = "Subcommands " )
72101
73- parser .add_argument ("-v" , "--verbose" , action = "store_true" , help = "Enable verbose debug logging" )
102+ # Add the "list" subcommand
103+ parser_list = subparsers .add_parser ("list" , help = "List all packages available for deletion" )
104+ parser_list .set_defaults (func = lambda args : _run (CleanMode .LIST_ONLY , args ))
105+ # Add the "delete" subcommand
106+ parser_delete = subparsers .add_parser (
107+ "delete" , help = "Delete packages that match the given criteria (use with care!"
108+ )
109+ parser_delete .add_argument (
110+ "-u" , "--username" , type = validate_username , help = "PyPI username (required)" , required = True
111+ )
112+ parser_delete .set_defaults (func = lambda args : _run (CleanMode .DELETE , args ))
74113
75114 return parser
76115
@@ -149,17 +188,6 @@ def load_credentials() -> tuple[Optional[str], Optional[str]]:
149188 return password , otp
150189
151190
152- def validate_arguments (args : argparse .Namespace ) -> None :
153- """Validate parsed arguments."""
154- if not args .dry_run and not args .username :
155- msg = "--username is required when not in dry-run mode"
156- raise ValidationError (msg )
157-
158- if args .max_nightlies < 0 :
159- msg = "--max-nightlies must be non-negative"
160- raise ValidationError (msg )
161-
162-
163191class CsrfParser (HTMLParser ):
164192 """HTML parser to extract CSRF tokens from PyPI forms.
165193
@@ -230,7 +258,7 @@ def run(self) -> int:
230258 if self ._mode == CleanMode .DELETE :
231259 logging .warning ("NOT A DRILL: WILL DELETE PACKAGES" )
232260 elif self ._mode == CleanMode .LIST_ONLY :
233- logging .info ("Running in DRY RUN mode, nothing will be deleted" )
261+ logging .debug ("Running in DRY RUN mode, nothing will be deleted" )
234262 else :
235263 msg = "Unexpected mode"
236264 raise RuntimeError (msg )
@@ -255,20 +283,22 @@ def _execute_cleanup(self, http_session: Session) -> int:
255283 logging .info (f"No releases found for { self ._package } " )
256284 return 0
257285
258- # Determine versions to delete
286+ # Determine and report versions to delete
259287 versions_to_delete = self ._determine_versions_to_delete (versions )
260- if not versions_to_delete :
261- logging .info ("No versions to delete (no stale rc's or dev releases)" )
288+ if len (versions_to_delete ) > 0 :
289+ print (f"Found the following stale releases on { self ._index_host } :" )
290+ for version in sorted (versions_to_delete ):
291+ print (f"- { version } " )
292+ else :
293+ print (f"No stale releases found on { self ._index_host } " )
262294 return 0
263295
264- logging .warning (f"Found { len (versions_to_delete )} versions to clean up:" )
265- for version in sorted (versions_to_delete ):
266- logging .warning (version )
267-
268296 if self ._mode != CleanMode .DELETE :
269297 logging .info ("Dry run complete - no packages were deleted" )
270298 return 0
271299
300+ logging .warning (f"Will try to delete { len (versions_to_delete )} releases from { self ._index_host } " )
301+
272302 # Perform authentication and deletion
273303 self ._authenticate (http_session )
274304 self ._delete_versions (http_session , versions_to_delete )
@@ -527,42 +557,39 @@ def _delete_single_version(self, http_session: Session, version: str) -> None:
527557 delete_response .raise_for_status ()
528558
529559
530- def main () -> int :
531- """Main entry point for the script."""
532- parser = create_argument_parser ()
533- args = parser .parse_args ()
534-
535- # Setup logging
536- setup_logging ((args .verbose and logging .DEBUG ) or logging .INFO )
537-
560+ def _run (mode : CleanMode , args : argparse .Namespace ) -> int :
561+ """Action called by the subcommands after arg parsing."""
562+ setup_logging (args .loglevel )
538563 try :
539- # Validate arguments
540- validate_arguments (args )
541-
542- # Dry run vs delete
543- password , otp , mode = None , None , CleanMode .LIST_ONLY
544- if not args .dry_run :
564+ if mode == CleanMode .DELETE :
545565 password , otp = load_credentials ()
546- mode = CleanMode . DELETE
547-
548- # Determine PyPI URL
549- pypi_url = _PYPI_URL_PROD if args . prod else _PYPI_URL_TEST
550-
551- # Create and run cleanup
552- cleanup = PyPICleanup ( pypi_url , mode , args . max_nightlies , username = args . username , password = password , otp = otp )
553-
566+ cleanup = PyPICleanup (
567+ args . pypi_url , mode , args . max_nightlies , username = args . username , password = password , otp = otp
568+ )
569+ elif mode == CleanMode . LIST_ONLY :
570+ cleanup = PyPICleanup ( args . pypi_url , mode , args . max_nightlies )
571+ else :
572+ print ( f"Unknown mode { mode } . Did nothing." )
573+ return - 1
554574 return cleanup .run ()
555-
556575 except ValidationError :
557576 logging .exception ("Configuration error" )
558577 return 2
559578 except KeyboardInterrupt :
560579 logging .info ("Operation cancelled by user" )
561580 return 130
562581 except Exception :
563- logging .exception ("Unexpected error" , exc_info = args . verbose )
582+ logging .exception ("Unexpected error" )
564583 return 1
565584
566585
586+ def main () -> int :
587+ """Main entry point for the script."""
588+ parser = create_argument_parser ()
589+ args = parser .parse_args ()
590+ # call the subcommand's func
591+ return args .func (args )
592+
593+
567594if __name__ == "__main__" :
568595 sys .exit (main ())
0 commit comments