1010
1111import argparse
1212import contextlib
13- import datetime
1413import heapq
1514import logging
1615import os
1918import time
2019from collections import defaultdict
2120from html .parser import HTMLParser
22- from typing import Dict , Optional , Set , Generator
21+ from typing import Optional , Set , Generator
2322from urllib .parse import urlparse
2423
2524import pyotp
@@ -252,19 +251,20 @@ def run(self) -> int:
252251 logging .info (f"Max development releases to keep per unreleased version: { self ._max_dev_releases } " )
253252
254253 try :
255- return self ._execute_cleanup ()
254+ with session_with_retries () as http_session :
255+ return self ._execute_cleanup (http_session )
256256 except PyPICleanupError as e :
257257 logging .error (f"Cleanup failed: { e } " )
258258 return 1
259259 except Exception as e :
260260 logging .error (f"Unexpected error: { e } " , exc_info = True )
261261 return 1
262262
263- def _execute_cleanup (self ) -> int :
263+ def _execute_cleanup (self , http_session : Session ) -> int :
264264 """Execute the main cleanup logic."""
265265
266266 # Get released versions
267- versions = self ._fetch_released_versions ()
267+ versions = self ._fetch_released_versions (http_session )
268268 if not versions :
269269 logging .info (f"No releases found for { self ._package } " )
270270 return 0
@@ -284,24 +284,23 @@ def _execute_cleanup(self) -> int:
284284 return 0
285285
286286 # Perform authentication and deletion
287- self ._authenticate ()
288- self ._delete_versions (versions_to_delete )
287+ self ._authenticate (http_session )
288+ self ._delete_versions (http_session , versions_to_delete )
289289
290290 logging .info (f"Successfully cleaned up { len (versions_to_delete )} development versions" )
291291 return 0
292292
293- def _fetch_released_versions (self ) -> Set [str ]:
293+ def _fetch_released_versions (self , http_session : Session ) -> Set [str ]:
294294 """Fetch package release information from PyPI API."""
295295 logging .debug (f"Fetching package information for '{ self ._package } '" )
296296
297297 try :
298- with session_with_retries () as session :
299- req = session .get (f"{ self ._index_url } /pypi/{ self ._package } /json" )
300- req .raise_for_status ()
301- data = req .json ()
302- versions = {v for v , files in data ["releases" ].items () if len (files ) > 0 }
303- logging .debug (f"Found { len (versions )} releases with files" )
304- return versions
298+ req = http_session .get (f"{ self ._index_url } /pypi/{ self ._package } /json" )
299+ req .raise_for_status ()
300+ data = req .json ()
301+ versions = {v for v , files in data ["releases" ].items () if len (files ) > 0 }
302+ logging .debug (f"Found { len (versions )} releases with files" )
303+ return versions
305304 except RequestException as e :
306305 raise PyPICleanupError (f"Failed to fetch package information for '{ self ._package } ': { e } " ) from e
307306
@@ -394,94 +393,92 @@ def _determine_versions_to_delete(self, versions: Set[str]) -> Set[str]:
394393
395394 return versions_to_delete
396395
397- def _authenticate (self ) -> None :
396+ def _authenticate (self , http_session : Session ) -> None :
398397 """Authenticate with PyPI."""
399398 if not self ._username or not self ._password :
400399 raise AuthenticationError ("Username and password are required for authentication" )
401400
402401 logging .info (f"Authenticating user '{ self ._username } ' with PyPI" )
403402
404403 try :
405- # Get login form and CSRF token
406- csrf_token = self ._get_csrf_token ("/account/login/" )
407-
408404 # Attempt login
409- login_response = self ._perform_login (csrf_token )
410-
405+ login_response = self ._perform_login (http_session )
406+
411407 # Handle two-factor authentication if required
412408 if login_response .url .startswith (f"{ self ._index_url } /account/two-factor/" ):
413409 logging .debug ("Two-factor authentication required" )
414- self ._handle_two_factor_auth (login_response )
410+ self ._handle_two_factor_auth (http_session , login_response )
415411
416412 logging .info ("Authentication successful" )
417-
413+
418414 except RequestException as e :
419415 raise AuthenticationError (f"Network error during authentication: { e } " ) from e
420416
421- def _get_csrf_token (self , form_action : str ) -> str :
417+ def _get_csrf_token (self , http_session : Session , form_action : str ) -> str :
422418 """Extract CSRF token from a form page."""
423- with session_with_retries () as session :
424- req = session .get (f"{ self ._index_url } { form_action } " )
425- req .raise_for_status ()
426- parser = CsrfParser (form_action )
427- parser .feed (req .text )
428- if not parser .csrf :
429- raise AuthenticationError (f"No CSRF token found in { form_action } " )
430- return parser .csrf
419+ resp = http_session .get (f"{ self ._index_url } { form_action } " )
420+ resp .raise_for_status ()
421+ parser = CsrfParser (form_action )
422+ parser .feed (resp .text )
423+ if not parser .csrf :
424+ raise AuthenticationError (f"No CSRF token found in { form_action } " )
425+ return parser .csrf
431426
432- def _perform_login (self , csrf_token : str ) -> requests .Response :
427+ def _perform_login (self , http_session : Session ) -> requests .Response :
433428 """Perform the initial login with username/password."""
429+
430+ # Get login form and CSRF token
431+ csrf_token = self ._get_csrf_token (http_session , "/account/login/" )
432+
434433 login_data = {
435434 "csrf_token" : csrf_token ,
436435 "username" : self ._username ,
437436 "password" : self ._password
438437 }
439438
440- with session_with_retries () as session :
441- response = session .post (
442- f"{ self ._index_url } /account/login/" ,
443- data = login_data ,
444- headers = {"referer" : f"{ self ._index_url } /account/login/" }
445- )
446- response .raise_for_status ()
439+ response = http_session .post (
440+ f"{ self ._index_url } /account/login/" ,
441+ data = login_data ,
442+ headers = {"referer" : f"{ self ._index_url } /account/login/" }
443+ )
444+ response .raise_for_status ()
447445
448- # Check if login failed (redirected back to login page)
449- if response .url == f"{ self ._index_url } /account/login/" :
450- raise AuthenticationError (f"Login failed for user '{ self ._username } ' - check credentials" )
446+ # Check if login failed (redirected back to login page)
447+ if response .url == f"{ self ._index_url } /account/login/" :
448+ raise AuthenticationError (f"Login failed for user '{ self ._username } ' - check credentials" )
451449
452- return response
450+ return response
453451
454- def _handle_two_factor_auth (self , response : requests .Response ) -> None :
452+ def _handle_two_factor_auth (self , http_session : Session , response : requests .Response ) -> None :
455453 """Handle two-factor authentication."""
456454 if not self ._otp :
457455 raise AuthenticationError ("Two-factor authentication required but no OTP secret provided" )
458456
459457 two_factor_url = response .url
460458 form_action = two_factor_url [len (self ._index_url ):]
461- csrf_token = self ._get_csrf_token (form_action )
459+ csrf_token = self ._get_csrf_token (http_session , form_action )
462460
463461 # Try authentication with retries
464462 for attempt in range (_LOGIN_RETRY_ATTEMPTS ):
465463 try :
466464 auth_code = pyotp .TOTP (self ._otp ).now ()
467465 logging .debug (f"Attempting 2FA with code (attempt { attempt + 1 } /{ _LOGIN_RETRY_ATTEMPTS } )" )
468466
469- with session_with_retries () as session :
470- auth_response = session .post (
471- two_factor_url ,
472- data = {"csrf_token" : csrf_token , "method" : "totp" , "totp_value" : auth_code },
473- headers = {"referer" : two_factor_url }
474- )
475- auth_response .raise_for_status ()
476-
477- # Check if 2FA succeeded (redirected away from 2FA page)
478- if auth_response .url != two_factor_url :
479- logging .debug ("Two-factor authentication successful" )
480- return
481-
482- if attempt < _LOGIN_RETRY_ATTEMPTS - 1 :
483- logging .debug (f"2FA code rejected, retrying in { _LOGIN_RETRY_DELAY } seconds..." )
484- time .sleep (_LOGIN_RETRY_DELAY )
467+ auth_response = http_session .post (
468+ two_factor_url ,
469+ data = {"csrf_token" : csrf_token , "method" : "totp" , "totp_value" : auth_code },
470+ headers = {"referer" : two_factor_url }
471+ )
472+ auth_response .raise_for_status ()
473+
474+ # Check if 2FA succeeded (redirected away from 2FA page)
475+ if auth_response .url != two_factor_url :
476+ logging .debug ("Two-factor authentication successful" )
477+ return
478+
479+ if attempt < _LOGIN_RETRY_ATTEMPTS - 1 :
480+ logging .debug (f"2FA code rejected, retrying in { _LOGIN_RETRY_DELAY } seconds..." )
481+ time .sleep (_LOGIN_RETRY_DELAY )
485482
486483 except RequestException as e :
487484 if attempt == _LOGIN_RETRY_ATTEMPTS - 1 :
@@ -491,14 +488,14 @@ def _handle_two_factor_auth(self, response: requests.Response) -> None:
491488
492489 raise AuthenticationError ("Two-factor authentication failed after all attempts" )
493490
494- def _delete_versions (self , versions_to_delete : Set [str ]) -> None :
491+ def _delete_versions (self , http_session : Session , versions_to_delete : Set [str ]) -> None :
495492 """Delete the specified package versions."""
496493 logging .info (f"Starting deletion of { len (versions_to_delete )} development versions" )
497494
498495 failed_deletions = list ()
499496 for version in sorted (versions_to_delete ):
500497 try :
501- self ._delete_single_version (version )
498+ self ._delete_single_version (http_session , version )
502499 logging .info (f"Successfully deleted { self ._package } version { version } " )
503500 except Exception as e :
504501 # Continue with other versions rather than failing completely
@@ -510,7 +507,7 @@ def _delete_versions(self, versions_to_delete: Set[str]) -> None:
510507 f"Failed to delete { len (failed_deletions )} /{ len (versions_to_delete )} versions: { failed_deletions } "
511508 )
512509
513- def _delete_single_version (self , version : str ) -> None :
510+ def _delete_single_version (self , http_session : Session , version : str ) -> None :
514511 """Delete a single package version."""
515512 # Safety check
516513 if not self ._is_dev_version (version ) or self ._is_rc_version (version ):
@@ -522,19 +519,18 @@ def _delete_single_version(self, version: str) -> None:
522519 form_action = f"/manage/project/{ self ._package } /release/{ version } /"
523520 form_url = f"{ self ._index_url } { form_action } "
524521
525- csrf_token = self ._get_csrf_token (form_action )
526-
527- with session_with_retries () as session :
528- # Submit deletion request
529- delete_response = session .post (
530- form_url ,
531- data = {
532- "csrf_token" : csrf_token ,
533- "confirm_delete_version" : version ,
534- },
535- headers = {"referer" : form_url }
536- )
537- delete_response .raise_for_status ()
522+ csrf_token = self ._get_csrf_token (http_session , form_action )
523+
524+ # Submit deletion request
525+ delete_response = http_session .post (
526+ form_url ,
527+ data = {
528+ "csrf_token" : csrf_token ,
529+ "confirm_delete_version" : version ,
530+ },
531+ headers = {"referer" : form_url }
532+ )
533+ delete_response .raise_for_status ()
538534
539535
540536def main () -> int :
0 commit comments