Skip to content

Commit 2cbb98d

Browse files
committed
Small rework of the pypi cleanup script
1 parent d07d4a8 commit 2cbb98d

File tree

3 files changed

+134
-113
lines changed

3 files changed

+134
-113
lines changed

.github/workflows/cleanup_pypi.yml

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ on:
66
description: CI environment to run in (pypi-test or pypi-prod-nightly)
77
type: string
88
required: true
9+
subcommand:
10+
description: List or Delete
11+
type: string
12+
default: delete
13+
verbosity:
14+
description: Tool verbosity ("verbose" or "debug")
15+
type: string
16+
default: verbose
917
secrets:
1018
PYPI_CLEANUP_OTP:
1119
description: PyPI OTP
@@ -15,17 +23,29 @@ on:
1523
required: true
1624
workflow_dispatch:
1725
inputs:
18-
dry-run:
19-
description: List packages that would be deleted but don't delete them
20-
type: boolean
21-
default: false
26+
subcommand:
27+
description: List or Delete
28+
type: choice
29+
required: true
30+
options:
31+
- list
32+
- delete
33+
default: list
2234
environment:
2335
description: CI environment to run in
2436
type: choice
2537
required: true
2638
options:
2739
- pypi-prod-nightly
2840
- pypi-test
41+
verbosity:
42+
description: Tool verbosity
43+
type: choice
44+
required: true
45+
options:
46+
- verbose
47+
- debug
48+
default: verbose
2949

3050
jobs:
3151
cleanup_pypi:
@@ -54,22 +74,37 @@ jobs:
5474
with:
5575
version: "0.9.0"
5676

57-
- name: Run Cleanup
77+
- name: Install dependencies
78+
run: uv sync --only-group pypi --no-install-project
79+
80+
- name: List Stale Packages on PyPI
81+
if: inputs.subcommand == 'list'
82+
env:
83+
PYTHON_UNBUFFERED: 1
84+
run: |
85+
set -x
86+
uv run --no-sync python -u -m duckdb_packaging.pypi_cleanup \
87+
--${{ inputs.environment == 'pypi-prod-nightly' && 'prod' || 'test' }} \
88+
--max-nightlies ${{ vars.PYPI_MAX_NIGHTLIES }} \
89+
--${{ inputs.verbosity }} \
90+
list 2>&1 | tee cleanup_output
91+
92+
- name: Delete Stale Packages from PyPI
93+
if: inputs.subcommand == 'delete'
5894
env:
5995
PYTHON_UNBUFFERED: 1
6096
run: |
6197
set -x
62-
uv sync --only-group pypi --no-install-project
63-
uv run --no-sync python -u -m duckdb_packaging.pypi_cleanup ${{ inputs.dry-run && '--dry' || '' }} \
64-
${{ inputs.environment == 'pypi-prod-nightly' && '--prod' || '--test' }} \
65-
--verbose \
66-
--username "${{ vars.PYPI_CLEANUP_USERNAME }}" \
67-
--max-nightlies ${{ vars.PYPI_MAX_NIGHTLIES }} 2>&1 | tee cleanup_output
98+
uv run --no-sync python -u -m duckdb_packaging.pypi_cleanup \
99+
--${{ inputs.environment == 'pypi-prod-nightly' && 'prod' || 'test' }} \
100+
--max-nightlies ${{ vars.PYPI_MAX_NIGHTLIES }} \
101+
--${{ inputs.verbosity }} \
102+
delete --username "${{ vars.PYPI_CLEANUP_USERNAME }}" 2>&1 | tee cleanup_output
68103
69104
- name: PyPI Cleanup Summary
70105
run : |
71106
echo "## PyPI Cleanup Summary" >> $GITHUB_STEP_SUMMARY
72-
echo "* Dry run: ${{ inputs.dry-run }}" >> $GITHUB_STEP_SUMMARY
107+
echo "* Subcommand: ${{ inputs.subcommand }}" >> $GITHUB_STEP_SUMMARY
73108
echo "* CI Environment: ${{ inputs.environment }}" >> $GITHUB_STEP_SUMMARY
74109
echo "* Output:" >> $GITHUB_STEP_SUMMARY
75110
echo '```' >> $GITHUB_STEP_SUMMARY

duckdb_packaging/pypi_cleanup.py

Lines changed: 76 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@
3838

3939
def 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="""
4351
PyPI 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-
163191
class 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+
567594
if __name__ == "__main__":
568595
sys.exit(main())

tests/fast/test_pypi_cleanup.py

Lines changed: 11 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
duckdb_packaging = pytest.importorskip("duckdb_packaging")
1616

1717
from duckdb_packaging.pypi_cleanup import ( # noqa: E402
18+
_DEFAULT_MAX_NIGHTLIES,
19+
_PYPI_URL_PROD,
1820
AuthenticationError,
1921
CleanMode,
2022
CsrfParser,
@@ -26,7 +28,6 @@
2628
main,
2729
session_with_retries,
2830
setup_logging,
29-
validate_arguments,
3031
validate_username,
3132
)
3233

@@ -60,23 +61,6 @@ def test_validate_username_invalid(self):
6061
with pytest.raises(ArgumentTypeError, match="Invalid username format"):
6162
validate_username("invalid-")
6263

63-
def test_validate_arguments_dry_run(self):
64-
"""Test argument validation for dry run mode."""
65-
args = Mock(dry_run=True, username=None, max_nightlies=2)
66-
validate_arguments(args) # Should not raise
67-
68-
def test_validate_arguments_live_mode_no_username(self):
69-
"""Test argument validation for live mode without username."""
70-
args = Mock(dry_run=False, username=None, max_nightlies=2)
71-
with pytest.raises(ValidationError, match="username is required"):
72-
validate_arguments(args)
73-
74-
def test_validate_arguments_negative_nightlies(self):
75-
"""Test argument validation with negative max nightlies."""
76-
args = Mock(dry_run=True, username="test", max_nightlies=-1)
77-
with pytest.raises(ValidationError, match="must be non-negative"):
78-
validate_arguments(args)
79-
8064

8165
class TestCredentials:
8266
"""Test credential loading."""
@@ -465,26 +449,20 @@ def test_argument_parser_creation(self):
465449
parser = create_argument_parser()
466450
assert parser.prog is not None
467451

468-
def test_parse_args_prod_dry_run(self):
452+
def test_parse_args_prod_list(self):
469453
"""Test parsing arguments for production dry run."""
470454
parser = create_argument_parser()
471-
args = parser.parse_args(["--prod", "--dry-run"])
455+
args = parser.parse_args(["--prod", "list"])
472456

473-
assert args.prod is True
474-
assert args.test is False
475-
assert args.dry_run is True
476-
assert args.max_nightlies == 2
477-
assert args.verbose is False
457+
assert args.pypi_url is _PYPI_URL_PROD
458+
assert args.max_nightlies == _DEFAULT_MAX_NIGHTLIES
459+
assert args.loglevel is logging.WARN
478460

479-
def test_parse_args_test_with_username(self):
461+
def test_parse_args_test_list_with_username(self):
480462
"""Test parsing arguments for test with username."""
481463
parser = create_argument_parser()
482-
args = parser.parse_args(["--test", "-u", "testuser", "--verbose"])
483-
484-
assert args.test is True
485-
assert args.prod is False
486-
assert args.username == "testuser"
487-
assert args.verbose is True
464+
with pytest.raises(SystemExit):
465+
parser.parse_args(["--test", "list", "-u", "testuser"])
488466

489467
def test_parse_args_missing_host(self):
490468
"""Test parsing arguments with missing host selection."""
@@ -506,28 +484,9 @@ def test_main_success(self, mock_cleanup_class, mock_setup_logging):
506484
mock_cleanup.run.return_value = 0
507485
mock_cleanup_class.return_value = mock_cleanup
508486

509-
with patch("sys.argv", ["pypi_cleanup.py", "--test", "-u", "testuser"]):
487+
with patch("sys.argv", ["pypi_cleanup.py", "--test", "delete", "-u", "testuser"]):
510488
result = main()
511489

512490
assert result == 0
513491
mock_setup_logging.assert_called_once()
514492
mock_cleanup.run.assert_called_once()
515-
516-
@patch("duckdb_packaging.pypi_cleanup.setup_logging")
517-
def test_main_validation_error(self, mock_setup_logging):
518-
"""Test main function with validation error."""
519-
with patch("sys.argv", ["pypi_cleanup.py", "--test"]): # Missing username for live mode
520-
result = main()
521-
522-
assert result == 2 # Validation error exit code
523-
524-
@patch("duckdb_packaging.pypi_cleanup.setup_logging")
525-
@patch("duckdb_packaging.pypi_cleanup.validate_arguments")
526-
def test_main_keyboard_interrupt(self, mock_validate, mock_setup_logging):
527-
"""Test main function with keyboard interrupt."""
528-
mock_validate.side_effect = KeyboardInterrupt()
529-
530-
with patch("sys.argv", ["pypi_cleanup.py", "--test", "--dry-run"]):
531-
result = main()
532-
533-
assert result == 130 # Keyboard interrupt exit code

0 commit comments

Comments
 (0)