diff --git a/release.py b/release.py index 20feb4cf..8d6ca8b1 100755 --- a/release.py +++ b/release.py @@ -11,6 +11,7 @@ import datetime import glob import hashlib +import json import optparse import os import re @@ -31,6 +32,7 @@ Self, overload, ) +from urllib.request import urlopen COMMASPACE = ", " SPACE = " " @@ -68,6 +70,11 @@ def get( @overload def get(self, key: Literal["sign_gpg"], default: bool | None = None) -> bool: ... + @overload + def get( + self, key: Literal["security_release"], default: bool | None = None + ) -> bool: ... + @overload def get(self, key: Literal["release"], default: Tag | None = None) -> Tag: ... @@ -95,6 +102,9 @@ def __getitem__(self, key: Literal["ssh_key"]) -> str | None: ... @overload def __getitem__(self, key: Literal["sign_gpg"]) -> bool: ... + @overload + def __getitem__(self, key: Literal["security_release"]) -> bool: ... + @overload def __getitem__(self, key: Literal["release"]) -> Tag: ... @@ -124,6 +134,9 @@ def __setitem__(self, key: Literal["ssh_key"], value: str | None) -> None: ... @overload def __setitem__(self, key: Literal["sign_gpg"], value: bool) -> None: ... + @overload + def __setitem__(self, key: Literal["security_release"], value: bool) -> None: ... + @overload def __setitem__(self, key: Literal["release"], value: Tag) -> None: ... @@ -193,6 +206,13 @@ def is_release_candidate(self) -> bool: def is_feature_freeze_release(self) -> bool: return self.level == "b" and self.serial == 1 + @property + def is_security_release(self) -> bool: + url = "https://peps.python.org/api/release-cycle.json" + with urlopen(url) as response: + data = json.loads(response.read()) + return str(data[self.basic_version]["status"]) == "security" + @property def nickname(self) -> str: return self.text.replace(".", "") diff --git a/run_release.py b/run_release.py index 8d5c8a15..633395c8 100755 --- a/run_release.py +++ b/run_release.py @@ -248,9 +248,10 @@ def __init__( self.db["ssh_key"] = ssh_key if not self.db.get("sign_gpg"): self.db["sign_gpg"] = sign_gpg - if not self.db.get("release"): self.db["release"] = release_tag + if not self.db.get("security_release"): + self.db["security_release"] = self.db["release"].is_security_release print("Release data: ") print(f"- Branch: {release_tag.branch}") @@ -260,6 +261,7 @@ def __init__( print(f"- SSH username: {self.db['ssh_user']}") print(f"- SSH key: {self.db['ssh_key'] or 'Default'}") print(f"- Sign with GPG: {self.db['sign_gpg']}") + print(f"- Security release: {self.db['security_release']}") print() def checkpoint(self) -> None: @@ -1000,18 +1002,29 @@ def wait_until_all_files_are_in_folder(db: ReleaseShelf) -> None: are_windows_files_there = f"python-{release}.exe" in all_files are_macos_files_there = f"python-{release}-macos11.pkg" in all_files are_linux_files_there = f"Python-{release}.tgz" in all_files - are_all_files_there = ( - are_linux_files_there and are_windows_files_there and are_macos_files_there - ) + + if db["security_release"]: + # For security releases, only check Linux files + are_all_files_there = are_linux_files_there + else: + # For regular releases, check all platforms + are_all_files_there = ( + are_linux_files_there + and are_windows_files_there + and are_macos_files_there + ) + if not are_all_files_there: linux_tick = "✅" if are_linux_files_there else "❌" windows_tick = "✅" if are_windows_files_there else "❌" macos_tick = "✅" if are_macos_files_there else "❌" - print( - f"\rWaiting for files: Linux {linux_tick} Windows {windows_tick} Mac {macos_tick} ", - flush=True, - end="", - ) + + if db["security_release"]: + waiting = f"\rWaiting for files: Linux {linux_tick} (security release - only checking Linux)" + else: + waiting = f"\rWaiting for files: Linux {linux_tick} Windows {windows_tick} Mac {macos_tick} " + + print(waiting, flush=True, end="") time.sleep(1) print() diff --git a/tests/test_release_tag.py b/tests/test_release_tag.py index 33964f37..b08be88b 100644 --- a/tests/test_release_tag.py +++ b/tests/test_release_tag.py @@ -1,3 +1,4 @@ +import io from subprocess import CompletedProcess import pytest @@ -140,3 +141,33 @@ def test_tag_long_name() -> None: assert rc.long_name == "3.13.0 release candidate 3" assert final_zero.long_name == "3.13.0" assert final_3.long_name == "3.13.3" + + +@pytest.mark.parametrize( + ["version", "expected"], + [ + ("3.12.10", True), + ("3.13.3", False), + ], +) +def test_tag_is_security_release( + version: str, expected: str, mocker: MockerFixture +) -> None: + # Arrange + mock_response = b""" + { + "3.13": { + "status": "bugfix" + }, + "3.12": { + "status": "security" + } + } + """ + mocker.patch("urllib.request.urlopen", return_value=io.BytesIO(mock_response)) + + # Act + tag = release.Tag(version) + + # Assert + assert tag.is_security_release is expected