Skip to content

Commit ff6a34f

Browse files
committed
Cleanup workflow fixes
1 parent 6261380 commit ff6a34f

File tree

3 files changed

+103
-100
lines changed

3 files changed

+103
-100
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,8 @@ git config --local core.hooksPath .githooks/
6767

6868
### Editable installs (general)
6969

70-
It's good to be aware of the following when creating an editable install:
71-
- `uv sync` or `uv run [tool]` create editable installs by default, however, it work the way you expect. We have
70+
It's good to be aware of the following when performing an editable install:
71+
- `uv sync` or `uv run [tool]` perform an editable install by default. We have
7272
configured the project so that scikit-build-core will use a persistent build-dir, but since the build itself
7373
happens in an isolated, ephemeral environment, cmake's paths will point to non-existing directories. CMake itself
7474
will be missing.

duckdb_packaging/pypi_cleanup.py

Lines changed: 74 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
import argparse
1212
import contextlib
13-
import datetime
1413
import heapq
1514
import logging
1615
import os
@@ -19,7 +18,7 @@
1918
import time
2019
from collections import defaultdict
2120
from html.parser import HTMLParser
22-
from typing import Dict, Optional, Set, Generator
21+
from typing import Optional, Set, Generator
2322
from urllib.parse import urlparse
2423

2524
import 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

540536
def main() -> int:

tests/fast/test_pypi_cleanup.py

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,8 @@ def test_execute_cleanup_dry_run(self, mock_determine, mock_fetch, mock_delete,
256256
mock_fetch.return_value = {"1.0.0.dev1"}
257257
mock_determine.return_value = {"1.0.0.dev1"}
258258

259-
result = cleanup_dryrun_max_2._execute_cleanup()
259+
with session_with_retries() as session:
260+
result = cleanup_dryrun_max_2._execute_cleanup(session)
260261

261262
assert result == 0
262263
mock_fetch.assert_called_once()
@@ -266,7 +267,8 @@ def test_execute_cleanup_dry_run(self, mock_determine, mock_fetch, mock_delete,
266267
@patch('duckdb_packaging.pypi_cleanup.PyPICleanup._fetch_released_versions')
267268
def test_execute_cleanup_no_releases(self, mock_fetch, cleanup_dryrun_max_2):
268269
mock_fetch.return_value = {}
269-
result = cleanup_dryrun_max_2._execute_cleanup()
270+
with session_with_retries() as session:
271+
result = cleanup_dryrun_max_2._execute_cleanup(session)
270272
assert result == 0
271273

272274
@patch('requests.Session.get')
@@ -281,7 +283,8 @@ def test_fetch_released_versions_success(self, mock_get, cleanup_dryrun_max_2):
281283
}
282284
mock_get.return_value = mock_response
283285

284-
releases = cleanup_dryrun_max_2._fetch_released_versions()
286+
with session_with_retries() as session:
287+
releases = cleanup_dryrun_max_2._fetch_released_versions(session)
285288

286289
assert releases == {"1.0.0", "1.0.0.dev1"}
287290

@@ -293,46 +296,50 @@ def test_fetch_released_versions_not_found(self, mock_get, cleanup_dryrun_max_2)
293296
mock_get.return_value = mock_response
294297

295298
with pytest.raises(PyPICleanupError, match="Failed to fetch package information"):
296-
cleanup_dryrun_max_2._fetch_released_versions()
299+
with session_with_retries() as session:
300+
cleanup_dryrun_max_2._fetch_released_versions(session)
297301

298302
@patch('duckdb_packaging.pypi_cleanup.PyPICleanup._get_csrf_token')
299-
@patch('duckdb_packaging.pypi_cleanup.PyPICleanup._perform_login')
300-
def test_authenticate_success(self, mock_login, mock_csrf, cleanup_max_2):
303+
@patch('requests.Session.post')
304+
def test_authenticate_success(self, mock_post, mock_csrf, cleanup_max_2):
301305
"""Test successful authentication."""
302306
mock_csrf.return_value = "csrf123"
303307
mock_response = Mock()
304308
mock_response.url = "https://test.pypi.org/manage/"
305-
mock_login.return_value = mock_response
309+
mock_post.return_value = mock_response
306310

307-
cleanup_max_2._authenticate() # Should not raise
311+
with session_with_retries() as session:
312+
cleanup_max_2._authenticate(session) # Should not raise
313+
mock_csrf.assert_called_once_with(session, "/account/login/")
308314

309-
mock_csrf.assert_called_once_with("/account/login/")
310-
mock_login.assert_called_once_with("csrf123")
315+
mock_post.assert_called_once()
316+
assert mock_post.call_args.args[0].endswith('/account/login/')
311317

312318
@patch('duckdb_packaging.pypi_cleanup.PyPICleanup._get_csrf_token')
313-
@patch('duckdb_packaging.pypi_cleanup.PyPICleanup._perform_login')
319+
@patch('requests.Session.post')
314320
@patch('duckdb_packaging.pypi_cleanup.PyPICleanup._handle_two_factor_auth')
315-
def test_authenticate_with_2fa(self, mock_2fa, mock_login, mock_csrf, cleanup_max_2):
321+
def test_authenticate_with_2fa(self, mock_2fa, mock_post, mock_csrf, cleanup_max_2):
316322
mock_csrf.return_value = "csrf123"
317323
mock_response = Mock()
318324
mock_response.url = "https://test.pypi.org/account/two-factor/totp"
319-
mock_login.return_value = mock_response
325+
mock_post.return_value = mock_response
320326

321-
cleanup_max_2._authenticate()
322-
323-
mock_2fa.assert_called_once_with(mock_response)
327+
with session_with_retries() as session:
328+
cleanup_max_2._authenticate(session)
329+
mock_2fa.assert_called_once_with(session, mock_response)
324330

325331
def test_authenticate_missing_credentials(self, cleanup_dryrun_max_2):
326332
with pytest.raises(AuthenticationError, match="Username and password are required"):
327-
cleanup_dryrun_max_2._authenticate()
333+
cleanup_dryrun_max_2._authenticate(None)
328334

329335
@patch('duckdb_packaging.pypi_cleanup.PyPICleanup._delete_single_version')
330336
def test_delete_versions_success(self, mock_delete, cleanup_max_2):
331337
"""Test successful version deletion."""
332338
versions = {"1.0.0.dev1", "1.0.0.dev2"}
333339
mock_delete.side_effect = [None, None] # Successful deletions
334340

335-
cleanup_max_2._delete_versions(versions)
341+
with session_with_retries() as session:
342+
cleanup_max_2._delete_versions(session, versions)
336343

337344
assert mock_delete.call_count == 2
338345

@@ -343,12 +350,12 @@ def test_delete_versions_partial_failure(self, mock_delete, cleanup_max_2):
343350
mock_delete.side_effect = [None, Exception("Delete failed")]
344351

345352
with pytest.raises(PyPICleanupError, match="Failed to delete 1/2 versions"):
346-
cleanup_max_2._delete_versions(versions)
353+
cleanup_max_2._delete_versions(None, versions)
347354

348355
def test_delete_single_version_safety_check(self, cleanup_max_2):
349356
"""Test single version deletion safety check."""
350357
with pytest.raises(PyPICleanupError, match="Refusing to delete non-\\[dev\\|rc\\] version"):
351-
cleanup_max_2._delete_single_version("1.0.0") # Non-dev version
358+
cleanup_max_2._delete_single_version(None, "1.0.0") # Non-dev version
352359

353360

354361
class TestArgumentParser:

0 commit comments

Comments
 (0)