From 6bc6d347980d71b3167a77f9c62376843fe45033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Wed, 31 Jul 2024 15:13:33 +0200 Subject: [PATCH 1/7] Add types with enforcement to SBOM --- mypy-requirements.txt | 1 + pyproject.toml | 1 - sbom.py | 136 ++++++++++++++++++++++++++++++++---------- 3 files changed, 107 insertions(+), 31 deletions(-) diff --git a/mypy-requirements.txt b/mypy-requirements.txt index 258cd58e..01302a32 100644 --- a/mypy-requirements.txt +++ b/mypy-requirements.txt @@ -4,3 +4,4 @@ pytest pytest-mock sigstore==1.1.2 types-requests +types-paramiko diff --git a/pyproject.toml b/pyproject.toml index f9a34a51..d11197fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,6 @@ warn_unreachable = true exclude = [ "^buildbotapi.py$", "^run_release.py$", - "^sbom.py$", "^tests/test_release_tag.py$", "^tests/test_run_release.py$", "^tests/test_sbom.py$", diff --git a/sbom.py b/sbom.py index c5cad12e..c7122139 100644 --- a/sbom.py +++ b/sbom.py @@ -10,29 +10,92 @@ """ +from __future__ import annotations + import argparse import datetime import hashlib import io import json import os -import pathlib import re import subprocess import sys import tarfile import typing import zipfile -from typing import Any +from pathlib import Path +from typing import Any, NotRequired, TypedDict, cast from urllib.request import urlopen +class SBOM(TypedDict): + SPDXID: str + spdxVersion: str + name: str + dataLicense: str + documentNamespace: str + creationInfo: CreationInfo + packages: list[Package] + files: list[File] + relationships: list[Relationship] + + +class Package(TypedDict): + SPDXID: str + name: str + versionInfo: str + packageFileName: NotRequired[str] + supplier: NotRequired[str] + originator: NotRequired[str] + licenseConcluded: str + downloadLocation: str + checksums: list[Checksum] + primaryPackagePurpose: str + packageVerificationCode: NotRequired[PackageVerificationCode] + externalRefs: list[Ref] + filesAnalyzed: NotRequired[bool] + + +class File(TypedDict): + SPDXID: str + fileName: str + checksums: list[Checksum] + + +class Relationship(TypedDict): + spdxElementId: str + relatedSpdxElement: str + relationshipType: str + + +class Checksum(TypedDict): + algorithm: str + checksumValue: str + + +class PackageVerificationCode(TypedDict): + packageVerificationCodeValue: str + + +class Ref(TypedDict): + referenceCategory: str + referenceLocator: str + referenceType: str + + +class CreationInfo(TypedDict): + created: str # timestamp + creators: list[str] + licenseListVersion: str + + def spdx_id(value: str) -> str: """Encode a value into characters that are valid in an SPDX ID""" return re.sub(r"[^a-zA-Z0-9.\-]+", "-", value) -def calculate_package_verification_codes(sbom) -> None: +def calculate_package_verification_codes(sbom: SBOM) -> None: """ Calculate SPDX 'packageVerificationCode' values for each package with 'filesAnalyzed' set to 'true'. @@ -46,7 +109,7 @@ def calculate_package_verification_codes(sbom) -> None: sbom_package_id_to_file_sha1s: dict[str, list[bytes]] = {} for sbom_package in sbom["packages"]: # If this value is 'false' we skip calculating. - if sbom_package["filesAnalyzed"]: + if sbom_package.get("filesAnalyzed", False): sbom_package_id = sbom_package["SPDXID"] sbom_package_id_to_file_sha1s[sbom_package_id] = [] @@ -119,13 +182,13 @@ def get_release_tools_commit_sha() -> str: return stdout -def normalize_sbom_data(sbom_data: dict[str, Any]) -> None: +def normalize_sbom_data(sbom_data: SBOM) -> None: """ Normalize SBOM data in-place by recursion and sorting lists by some repeatable key. """ - def recursive_sort_in_place(value: list | dict) -> None: + def recursive_sort_in_place(value: list[Any] | dict[str, Any]) -> None: if isinstance(value, list): # We need to recurse first so bottom-most elements are sorted first. for item in value: @@ -140,12 +203,12 @@ def recursive_sort_in_place(value: list | dict) -> None: for dict_val in value.values(): recursive_sort_in_place(dict_val) - recursive_sort_in_place(sbom_data) + recursive_sort_in_place(cast(dict[str, Any], sbom_data)) def fetch_package_metadata_from_pypi( project: str, version: str, filename: str | None = None -) -> tuple[str, str] | None: +) -> tuple[str, str]: """ Fetches the SHA256 checksum and download location from PyPI. If we're given a filename then we match with that, otherwise we use wheels. @@ -173,13 +236,13 @@ def fetch_package_metadata_from_pypi( checksum_sha256 = url["digests"]["sha256"] return download_url, checksum_sha256 - except (OSError, ValueError) as e: + except Exception as e: raise ValueError( f"Couldn't fetch metadata for project '{project}' from PyPI: {e}" ) -def remove_pip_from_sbom(sbom_data: dict[str, typing.Any]) -> None: +def remove_pip_from_sbom(sbom_data: SBOM) -> None: """ Removes pip and its dependencies from the SBOM data. This is only necessary if there's potential we get @@ -210,7 +273,7 @@ def remove_pip_from_sbom(sbom_data: dict[str, typing.Any]) -> None: def create_pip_sbom_from_wheel( - sbom_data: dict[str, typing.Any], pip_wheel_filename: str, pip_wheel_bytes: bytes + sbom_data: SBOM, pip_wheel_filename: str, pip_wheel_bytes: bytes ) -> None: """ pip is a part of a packaging ecosystem (Python, surprise!) so it's actually @@ -336,13 +399,16 @@ def create_pip_sbom_from_wheel( def create_cpython_sbom( - sbom_data: dict[str, typing.Any], + sbom_data: SBOM, cpython_version: str, artifact_path: str, ) -> None: """Creates the top-level SBOM metadata and the CPython SBOM package.""" - cpython_version_without_suffix = re.match(r"^([0-9.]+)", cpython_version).group(1) + if m := re.match(pat := r"^([0-9.]+)", cpython_version): + cpython_version_without_suffix = m.group(1) + else: + raise ValueError(f"Invalid {cpython_version=}, expected {pat!r}") artifact_name = os.path.basename(artifact_path) artifact_download_location = f"https://www.python.org/ftp/python/{cpython_version_without_suffix}/{artifact_name}" @@ -378,7 +444,7 @@ def create_cpython_sbom( # Create the SBOM entry for the CPython package. We use # the SPDXID later on for creating relationships to files. - sbom_cpython_package = { + sbom_cpython_package: Package = { "SPDXID": "SPDXRef-PACKAGE-cpython", "name": "CPython", "versionInfo": cpython_version, @@ -413,7 +479,7 @@ def create_cpython_sbom( sbom_data["packages"].append(sbom_cpython_package) -def create_sbom_for_source_tarball(tarball_path: str) -> dict[str, Any]: +def create_sbom_for_source_tarball(tarball_path: str) -> SBOM: """Stitches together an SBOM for a source tarball""" tarball_name = os.path.basename(tarball_path) @@ -427,7 +493,11 @@ def create_sbom_for_source_tarball(tarball_path: str) -> dict[str, Any]: # Parse the CPython version from the tarball. # Calculate the download locations from the CPython version and tarball name. - cpython_version = re.match(r"^Python-([0-9abrc.]+)\.t", tarball_name).group(1) + + if m := re.match(pat := r"^Python-([0-9abrc.]+)\.t", tarball_name): + cpython_version = m.group(1) + else: + raise ValueError(f"Invalid {tarball_name=}, expected {pat!r}") # There should be an SBOM included in the tarball. # If there's not we can't create an SBOM. @@ -439,8 +509,10 @@ def create_sbom_for_source_tarball(tarball_path: str) -> dict[str, Any]: raise ValueError( "Tarball doesn't contain an SBOM at 'Misc/sbom.spdx.json'" ) from None - sbom_bytes = tarball.extractfile(sbom_tarball_member).read() - sbom_data = json.loads(sbom_bytes) + reader = tarball.extractfile(sbom_tarball_member) + assert reader, f"{sbom_tarball_member} is not a file in {tarball_path}" + sbom_bytes = reader.read() + sbom_data: SBOM = json.loads(sbom_bytes) create_cpython_sbom( sbom_data, cpython_version=cpython_version, artifact_path=tarball_path @@ -449,13 +521,14 @@ def create_sbom_for_source_tarball(tarball_path: str) -> dict[str, Any]: # Find the pip wheel in ensurepip in the tarball for member in tarball.getmembers(): - match = re.match( + if match := re.match( rf"^Python-{cpython_version}/Lib/ensurepip/_bundled/(pip-.*\.whl)$", member.name, - ) - if match is not None: + ): pip_wheel_filename = match.group(1) - pip_wheel_bytes = tarball.extractfile(member).read() + reader = tarball.extractfile(member) + assert reader, f"{member} is not a file in {tarball_path}" + pip_wheel_bytes = reader.read() break else: raise ValueError("Could not find pip wheel in 'Lib/ensurepip/_bundled/...'") @@ -496,7 +569,9 @@ def create_sbom_for_source_tarball(tarball_path: str) -> dict[str, Any]: # Calculate the hashes, either for comparison with a known value # or to embed in the SBOM as a new file. SHA1 is only used because # SPDX requires it for all file entries. - file_bytes = tarball.extractfile(member).read() + reader = tarball.extractfile(member) + assert reader, f"{member} is not a file in {tarball_path}" + file_bytes = reader.read() actual_file_checksum_sha1 = hashlib.sha1(file_bytes).hexdigest() actual_file_checksum_sha256 = hashlib.sha256(file_bytes).hexdigest() @@ -569,20 +644,21 @@ def create_sbom_for_source_tarball(tarball_path: str) -> dict[str, Any]: def create_sbom_for_windows_artifact( - artifact_path: str, cpython_source_dir: str -) -> dict[str, Any]: + artifact_path: str, cpython_source_dir: Path | str +) -> SBOM: artifact_name = os.path.basename(artifact_path) - cpython_version = re.match( - r"^python-([0-9abrc.]+)(?:-|\.exe|\.zip)", artifact_name - ).group(1) + if m := re.match(pat := r"^python-([0-9abrc.]+)(?:-|\.exe|\.zip)", artifact_name): + cpython_version = m.group(1) + else: + raise ValueError(f"Invalid {artifact_name=}, expected {pat!r}") if not cpython_source_dir: raise ValueError("Must specify --cpython-source-dir for Windows artifacts") - cpython_source_dir = pathlib.Path(cpython_source_dir) + cpython_source_dir = Path(cpython_source_dir) # Start with the CPython source SBOM as a base with (cpython_source_dir / "Misc/externals.spdx.json").open() as f: - sbom_data = json.loads(f.read()) + sbom_data: SBOM = json.loads(f.read()) sbom_data["relationships"] = [] sbom_data["files"] = [] From d6ebfabda6e767559b17a8cc77dfd8640e44cd43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Wed, 31 Jul 2024 15:46:33 +0200 Subject: [PATCH 2/7] Add types to buildbotapi.py --- buildbotapi.py | 52 ++++++++++++++++++++++++-------------------------- pyproject.toml | 1 - 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/buildbotapi.py b/buildbotapi.py index 649469c3..915025b6 100644 --- a/buildbotapi.py +++ b/buildbotapi.py @@ -1,20 +1,22 @@ import asyncio import json from dataclasses import dataclass -from typing import Any, Dict, List, Optional, Union +from typing import Any, cast from aiohttp.client import ClientSession +JSON = dict[str, Any] + @dataclass class Builder: builderid: int - description: Optional[str] - masterids: List[int] + description: str | None + masterids: list[int] name: str - tags: List[str] + tags: list[str] - def __init__(self, **kwargs) -> None: + def __init__(self, **kwargs: Any) -> None: self.__dict__.update(**kwargs) def __hash__(self) -> int: @@ -25,15 +27,17 @@ def __hash__(self) -> int: class Build: id: int is_currently_failing: bool + builderid: int + builder: Builder | None - def __init__(self, **kwargs): + def __init__(self, **kwargs: Any) -> None: self.__dict__.update(**kwargs) - self.id = kwargs.get("number") - self.is_currently_failing = kwargs.get("currently_failing") + self.id = int(kwargs.get("number", -1)) + self.is_currently_failing = kwargs.get("currently_failing", False) self.builder = None - def __eq__(self, other): - return self.id == other.id + def __eq__(self, other: object) -> bool: + return isinstance(other, Build) and self.id == other.id def __hash__(self) -> int: return hash(self.id) @@ -52,17 +56,10 @@ async def _fetch_text(self, url: str) -> str: async with self._session.get(url) as resp: return await resp.text() - async def _fetch_json(self, url: str) -> Dict[ - str, - Union[ - List[Dict[str, Union[int, bool, str]]], - Dict[str, int], - List[Dict[str, Optional[Union[int, List[int], str, List[str]]]]], - ], - ]: - return json.loads(await self._fetch_text(url)) - - async def stable_builders(self, branch: Optional[str]) -> Dict[int, Builder]: + async def _fetch_json(self, url: str) -> JSON: + return cast(JSON, json.loads(await self._fetch_text(url))) + + async def stable_builders(self, branch: str | None = None) -> dict[int, Builder]: stable_builders = { id: builder for (id, builder) in (await self.all_builders(branch=branch)).items() @@ -70,11 +67,11 @@ async def stable_builders(self, branch: Optional[str]) -> Dict[int, Builder]: } return stable_builders - async def all_builders(self, branch: Optional[str] = None) -> Dict[int, Builder]: + async def all_builders(self, branch: str | None = None) -> dict[int, Builder]: url = "https://buildbot.python.org/all/api/v2/builders" if branch is not None: url = f"{url}?tags__contains={branch}" - _builders: Dict[str, Any] = await self._fetch_json(url) + _builders: dict[str, Any] = await self._fetch_json(url) builders = _builders["builders"] all_builders = { builder["builderid"]: Builder(**builder) for builder in builders @@ -82,7 +79,7 @@ async def all_builders(self, branch: Optional[str] = None) -> Dict[int, Builder] return all_builders async def is_builder_failing_currently(self, builder: Builder) -> bool: - builds_: Dict[str, Any] = await self._fetch_json( + builds_: dict[str, Any] = await self._fetch_json( f"https://buildbot.python.org/all/api/v2/builds?complete__eq=true" f"&&builderid__eq={builder.builderid}&&order=-complete_at" f"&&limit=1" @@ -95,13 +92,13 @@ async def is_builder_failing_currently(self, builder: Builder) -> bool: return True return False - async def get_build(self, builder_id, build_id): + async def get_build(self, builder_id: int, build_id: int) -> Build: data = await self._fetch_json( f"https://buildbot.python.org/all/api/v2/builders/{builder_id}" f"/builds/{build_id}" ) (build_data,) = data["builds"] - build = Build(**build_data) + build: Build = Build(**build_data) build.builder = (await self.all_builders())[build.builderid] build.is_currently_failing = await self.is_builder_failing_currently( build.builder @@ -126,7 +123,8 @@ async def get_recent_failures(self, limit: int = 100) -> set[Build]: for failure in all_failures: failure.builder = stable_builders[failure.builderid] - async def _get_missing_info(failure): + async def _get_missing_info(failure: Build) -> None: + assert failure.builder is not None failure.is_currently_failing = await self.is_builder_failing_currently( failure.builder ) diff --git a/pyproject.toml b/pyproject.toml index d11197fc..c1389589 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,6 @@ extra_checks = true warn_unreachable = true exclude = [ - "^buildbotapi.py$", "^run_release.py$", "^tests/test_release_tag.py$", "^tests/test_run_release.py$", From 2acaa32b1a490765c46fb6ea2c97e0e6af622324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Wed, 31 Jul 2024 16:06:01 +0200 Subject: [PATCH 3/7] Enforce typing in run_release.py and release.py --- pyproject.toml | 1 - release.py | 104 ++++++++++++++++++++++++++++-- run_release.py | 169 ++++++++++++++++++++++++------------------------- 3 files changed, 181 insertions(+), 93 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c1389589..d5d23dad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,6 @@ extra_checks = true warn_unreachable = true exclude = [ - "^run_release.py$", "^tests/test_release_tag.py$", "^tests/test_run_release.py$", "^tests/test_sbom.py$", diff --git a/release.py b/release.py index ac1af697..03490670 100755 --- a/release.py +++ b/release.py @@ -20,14 +20,110 @@ import sys import tempfile from contextlib import contextmanager -from typing import Any, Callable, Generator, Self +from dataclasses import dataclass +from pathlib import Path +from typing import ( + Any, + Callable, + Generator, + Literal, + Protocol, + Self, + overload, +) COMMASPACE = ", " SPACE = " " tag_cre = re.compile(r"(\d+)(?:\.(\d+)(?:\.(\d+))?)?(?:([ab]|rc)(\d+))?$") -# Ideas stolen from Mailman's release script, Lib/tokens.py and welease +class ReleaseShelf(Protocol): # this will be easier to type in 3.13 + def close(self) -> None: ... + + @overload + def get(self, key: Literal["finished"], default: bool | None = None) -> bool: ... + + @overload + def get( + self, key: Literal["completed_tasks"], default: list[Task] | None = None + ) -> list[Task]: ... + + @overload + def get(self, key: Literal["gpg_key"], default: str | None = None) -> str: ... + + @overload + def get(self, key: Literal["git_repo"], default: Path | None = None) -> Path: ... + + @overload + def get(self, key: Literal["auth_info"], default: str | None = None) -> str: ... + + @overload + def get(self, key: Literal["ssh_user"], default: str | None = None) -> str: ... + + @overload + def get(self, key: Literal["sign_gpg"], default: bool | None = None) -> bool: ... + + @overload + def get(self, key: Literal["release"], default: Tag | None = None) -> Tag: ... + + @overload + def __getitem__(self, key: Literal["finished"]) -> bool: ... + + @overload + def __getitem__(self, key: Literal["completed_tasks"]) -> list[Task]: ... + + @overload + def __getitem__(self, key: Literal["gpg_key"]) -> str: ... + + @overload + def __getitem__(self, key: Literal["git_repo"]) -> Path: ... + + @overload + def __getitem__(self, key: Literal["auth_info"]) -> str: ... + + @overload + def __getitem__(self, key: Literal["ssh_user"]) -> str: ... + + @overload + def __getitem__(self, key: Literal["sign_gpg"]) -> bool: ... + + @overload + def __getitem__(self, key: Literal["release"]) -> Tag: ... + + @overload + def __setitem__(self, key: Literal["finished"], value: bool) -> None: ... + + @overload + def __setitem__( + self, key: Literal["completed_tasks"], value: list[Task] + ) -> None: ... + + @overload + def __setitem__(self, key: Literal["gpg_key"], value: str) -> None: ... + + @overload + def __setitem__(self, key: Literal["git_repo"], value: Path) -> None: ... + + @overload + def __setitem__(self, key: Literal["auth_info"], value: str) -> None: ... + + @overload + def __setitem__(self, key: Literal["ssh_user"], value: str) -> None: ... + + @overload + def __setitem__(self, key: Literal["sign_gpg"], value: bool) -> None: ... + + @overload + def __setitem__(self, key: Literal["release"], value: Tag) -> None: ... + + +@dataclass +class Task: + function: Callable[[ReleaseShelf], None] + description: str + + def __call__(self, db: ReleaseShelf) -> Any: + return getattr(self, "function")(db) class Tag: @@ -139,12 +235,8 @@ def run_cmd( readme_re = re.compile(r"This is Python version [23]\.\d").match -root = None - def chdir_to_repo_root() -> str: - global root - # find the root of the local CPython repo # note that we can't ask git, because we might # be in an exported directory tree! diff --git a/run_release.py b/run_release.py index b6131ce9..3bcf8b3d 100755 --- a/run_release.py +++ b/run_release.py @@ -13,7 +13,6 @@ import getpass import json import os -import pathlib import re import shelve import shutil @@ -21,27 +20,24 @@ import sys import time import urllib.request -from dataclasses import dataclass -from shelve import DbfilenameShelf -from typing import Any, Callable, Iterator +from pathlib import Path +from typing import Any, Iterator, cast import aiohttp -import gnupg +import gnupg # type: ignore[import-untyped] import paramiko -import sigstore.oidc -from alive_progress import alive_bar +import sigstore.oidc # type: ignore[import-untyped] +from alive_progress import alive_bar # type: ignore[import-untyped] import release as release_mod import sbom from buildbotapi import BuildBotAPI, Builder +from release import ReleaseShelf, Tag, Task API_KEY_REGEXP = re.compile(r"(?P\w+):(?P\w+)") - - RELEASE_REGEXP = re.compile( r"(?P\d+)\.(?P\d+)\.(?P\d+)\.?(?P.*)?" ) - DOWNLOADS_SERVER = "downloads.nyc1.psf.io" DOCS_SERVER = "docs.nyc1.psf.io" @@ -179,15 +175,6 @@ """ -@dataclass -class Task: - function: Callable[[DbfilenameShelf], None] - description: str - - def __call__(self, db: DbfilenameShelf) -> Any: - return getattr(self, "function")(db) - - class ReleaseException(Exception): """An error happened in the release process""" @@ -197,7 +184,7 @@ def __init__( self, tasks: list[Task], *, - release_tag: release_mod.Tag, + release_tag: Tag, git_repo: str, api_key: str, ssh_user: str, @@ -205,13 +192,13 @@ def __init__( first_state: Task | None = None, ) -> None: self.tasks = tasks - dbfile = pathlib.Path.home() / ".python_release" - self.db = shelve.open(str(dbfile), "c") + dbfile = Path.home() / ".python_release" + self.db: ReleaseShelf = cast(ReleaseShelf, shelve.open(str(dbfile), "c")) if not self.db.get("finished"): self.db["finished"] = False else: self.db.close() - self.db = shelve.open(str(dbfile), "n") + self.db = cast(ReleaseShelf, shelve.open(str(dbfile), "n")) self.current_task: Task | None = first_state self.completed_tasks = self.db.get("completed_tasks", []) @@ -219,7 +206,7 @@ def __init__( if self.db.get("gpg_key"): os.environ["GPG_KEY_FOR_RELEASE"] = self.db["gpg_key"] if not self.db.get("git_repo"): - self.db["git_repo"] = pathlib.Path(git_repo) + self.db["git_repo"] = Path(git_repo) if not self.db.get("auth_info"): self.db["auth_info"] = api_key if not self.db.get("ssh_user"): @@ -278,14 +265,14 @@ def ask_question(question: str) -> bool: @contextlib.contextmanager -def cd(path: str) -> Iterator[None]: +def cd(path: Path) -> Iterator[None]: current_path = os.getcwd() os.chdir(path) yield os.chdir(current_path) -def check_tool(db: DbfilenameShelf, tool: str) -> None: +def check_tool(db: ReleaseShelf, tool: str) -> None: if shutil.which(tool) is None: raise ReleaseException(f"{tool} is not available") @@ -297,7 +284,7 @@ def check_tool(db: DbfilenameShelf, tool: str) -> None: check_docker = functools.partial(check_tool, tool="docker") -def check_gpg_keys(db: DbfilenameShelf) -> None: +def check_gpg_keys(db: ReleaseShelf) -> None: pg = gnupg.GPG() keys = pg.list_keys(secret=True) if not keys: @@ -318,7 +305,7 @@ def check_gpg_keys(db: DbfilenameShelf) -> None: os.environ["GPG_KEY_FOR_RELEASE"] = db["gpg_key"] -def check_ssh_connection(db: DbfilenameShelf) -> None: +def check_ssh_connection(db: ReleaseShelf) -> None: client = paramiko.SSHClient() client.load_system_host_keys() client.set_missing_host_key_policy(paramiko.WarningPolicy) @@ -328,7 +315,7 @@ def check_ssh_connection(db: DbfilenameShelf) -> None: client.exec_command("pwd") -def check_buildbots(db: DbfilenameShelf) -> None: +def check_buildbots(db: ReleaseShelf) -> None: async def _check() -> set[Builder]: async def _get_builder_status( buildbot_api: BuildBotAPI, the_builder: Builder @@ -371,11 +358,11 @@ async def _get_builder_status( raise ReleaseException("Buildbots are failing!") -def check_docker_running(db: DbfilenameShelf) -> None: +def check_docker_running(db: ReleaseShelf) -> None: subprocess.check_call(["docker", "container", "ls"]) -def run_blurb_release(db: DbfilenameShelf) -> None: +def run_blurb_release(db: ReleaseShelf) -> None: subprocess.check_call(["blurb", "release", str(db["release"])], cwd=db["git_repo"]) subprocess.check_call( ["git", "commit", "-m", f"Python {db['release']}"], @@ -383,24 +370,24 @@ def run_blurb_release(db: DbfilenameShelf) -> None: ) -def check_cpython_repo_is_clean(db: DbfilenameShelf) -> None: +def check_cpython_repo_is_clean(db: ReleaseShelf) -> None: if subprocess.check_output(["git", "status", "--porcelain"], cwd=db["git_repo"]): raise ReleaseException("Git repository is not clean") -def prepare_temporary_branch(db: DbfilenameShelf) -> None: +def prepare_temporary_branch(db: ReleaseShelf) -> None: subprocess.check_call( ["git", "checkout", "-b", f"branch-{db['release']}"], cwd=db["git_repo"] ) -def remove_temporary_branch(db: DbfilenameShelf) -> None: +def remove_temporary_branch(db: ReleaseShelf) -> None: subprocess.check_call( ["git", "branch", "-D", f"branch-{db['release']}"], cwd=db["git_repo"] ) -def prepare_pydoc_topics(db: DbfilenameShelf) -> None: +def prepare_pydoc_topics(db: ReleaseShelf) -> None: subprocess.check_call(["make", "pydoc-topics"], cwd=db["git_repo"] / "Doc") shutil.copy2( db["git_repo"] / "Doc" / "build" / "pydoc-topics" / "topics.py", @@ -411,7 +398,7 @@ def prepare_pydoc_topics(db: DbfilenameShelf) -> None: ) -def run_autoconf(db: DbfilenameShelf) -> None: +def run_autoconf(db: ReleaseShelf) -> None: # Python 3.12 and newer have a script that runs autoconf. regen_configure_sh = db["git_repo"] / "Tools/build/regen-configure.sh" if regen_configure_sh.exists(): @@ -444,7 +431,7 @@ def run_autoconf(db: DbfilenameShelf) -> None: ) -def check_pyspecific(db): +def check_pyspecific(db: ReleaseShelf) -> None: with open( db["git_repo"] / "Doc" / "tools" / "extensions" / "pyspecific.py" ) as pyspecific: @@ -459,7 +446,7 @@ def check_pyspecific(db): raise ReleaseException("SOURCE_URI is incorrect") -def bump_version(db: DbfilenameShelf) -> None: +def bump_version(db: ReleaseShelf) -> None: with cd(db["git_repo"]): release_mod.bump(db["release"]) subprocess.check_call( @@ -467,7 +454,7 @@ def bump_version(db: DbfilenameShelf) -> None: ) -def create_tag(db: DbfilenameShelf) -> None: +def create_tag(db: ReleaseShelf) -> None: with cd(db["git_repo"]): if not release_mod.make_tag(db["release"], sign_gpg=db["sign_gpg"]): raise ReleaseException("Error when creating tag") @@ -476,13 +463,13 @@ def create_tag(db: DbfilenameShelf) -> None: ) -def wait_for_source_and_docs_artifacts(db: DbfilenameShelf) -> None: +def wait_for_source_and_docs_artifacts(db: ReleaseShelf) -> None: # Determine if we need to wait for docs or only source artifacts. release_tag = db["release"] should_wait_for_docs = release_tag.is_final or release_tag.is_release_candidate # Create the directory so it's easier to place the artifacts there. - release_path = pathlib.Path(db["git_repo"] / str(release_tag)) + release_path = Path(db["git_repo"] / str(release_tag)) release_path.mkdir(parents=True, exist_ok=True) # Build the list of filepaths we're expecting. @@ -519,7 +506,7 @@ def wait_for_source_and_docs_artifacts(db: DbfilenameShelf) -> None: time.sleep(1) -def sign_source_artifacts(db: DbfilenameShelf) -> None: +def sign_source_artifacts(db: ReleaseShelf) -> None: print("Signing tarballs with GPG") uid = os.environ.get("GPG_KEY_FOR_RELEASE") if not uid: @@ -527,7 +514,7 @@ def sign_source_artifacts(db: DbfilenameShelf) -> None: subprocess.check_call('gpg -K | grep -A 1 "^sec"', shell=True) uid = input("Please enter key ID to use for signing: ") - tarballs_path = pathlib.Path(db["git_repo"] / str(db["release"]) / "src") + tarballs_path = Path(db["git_repo"] / str(db["release"]) / "src") tgz = str(tarballs_path / f"Python-{db['release']}.tgz") xz = str(tarballs_path / f"Python-{db['release']}.tar.xz") @@ -548,7 +535,7 @@ def sign_source_artifacts(db: DbfilenameShelf) -> None: ) -def build_sbom_artifacts(db): +def build_sbom_artifacts(db: ReleaseShelf) -> None: # Skip building an SBOM if there isn't a 'Misc/sbom.spdx.json' file. if not (db["git_repo"] / "Misc/sbom.spdx.json").exists(): @@ -569,7 +556,9 @@ def build_sbom_artifacts(db): class MySFTPClient(paramiko.SFTPClient): - def put_dir(self, source: str, target: str, progress: Any = None) -> None: + def put_dir( + self, source: str | Path, target: str | Path, progress: Any = None + ) -> None: for item in os.listdir(source): if os.path.isfile(os.path.join(source, item)): progress.text(item) @@ -583,7 +572,9 @@ def put_dir(self, source: str, target: str, progress: Any = None) -> None: progress=progress, ) - def mkdir(self, path: str, mode: int = 511, ignore_existing: bool = False) -> None: + def mkdir( + self, path: bytes | str, mode: int = 511, ignore_existing: bool = False + ) -> None: try: super().mkdir(path, mode) except OSError: @@ -593,21 +584,24 @@ def mkdir(self, path: str, mode: int = 511, ignore_existing: bool = False) -> No raise -def upload_files_to_server(db: DbfilenameShelf) -> None: +def upload_files_to_server(db: ReleaseShelf) -> None: client = paramiko.SSHClient() client.load_system_host_keys() client.set_missing_host_key_policy(paramiko.WarningPolicy) client.connect(DOWNLOADS_SERVER, port=22, username=db["ssh_user"]) + transport = client.get_transport() + assert transport is not None, f"SSH transport to {DOWNLOADS_SERVER} is None" - destination = pathlib.Path(f"/home/psf-users/{db['ssh_user']}/{db['release']}") - ftp_client = MySFTPClient.from_transport(client.get_transport()) + destination = Path(f"/home/psf-users/{db['ssh_user']}/{db['release']}") + ftp_client = MySFTPClient.from_transport(transport) + assert ftp_client is not None, f"SFTP client to {DOWNLOADS_SERVER} is None" client.exec_command(f"rm -rf {destination}") with contextlib.suppress(OSError): ftp_client.mkdir(str(destination)) - artifacts_path = pathlib.Path(db["git_repo"] / str(db["release"])) + artifacts_path = Path(db["git_repo"] / str(db["release"])) shutil.rmtree(artifacts_path / f"Python-{db['release']}", ignore_errors=True) @@ -627,11 +621,13 @@ def upload_subdir(subdir: str) -> None: ftp_client.close() -def place_files_in_download_folder(db: DbfilenameShelf) -> None: +def place_files_in_download_folder(db: ReleaseShelf) -> None: client = paramiko.SSHClient() client.load_system_host_keys() client.set_missing_host_key_policy(paramiko.WarningPolicy) client.connect(DOWNLOADS_SERVER, port=22, username=db["ssh_user"]) + transport = client.get_transport() + assert transport is not None, f"SSH transport to {DOWNLOADS_SERVER} is None" # Sources @@ -639,7 +635,7 @@ def place_files_in_download_folder(db: DbfilenameShelf) -> None: destination = f"/srv/www.python.org/ftp/python/{db['release'].normalized()}" def execute_command(command: str) -> None: - channel = client.get_transport().open_session() + channel = transport.open_session() channel.exec_command(command) if channel.recv_exit_status() != 0: raise ReleaseException(channel.recv_stderr(1000)) @@ -652,17 +648,11 @@ def execute_command(command: str) -> None: # Docs - release_tag: release_mod.Tag = db["release"] + release_tag = db["release"] if release_tag.is_final or release_tag.is_release_candidate: source = f"/home/psf-users/{db['ssh_user']}/{db['release']}" destination = f"/srv/www.python.org/ftp/python/doc/{release_tag}" - def execute_command(command: str) -> None: - channel = client.get_transport().open_session() - channel.exec_command(command) - if channel.recv_exit_status() != 0: - raise ReleaseException(channel.recv_stderr(1000)) - execute_command(f"mkdir -p {destination}") execute_command(f"cp {source}/docs/* {destination}") execute_command(f"chgrp downloads {destination}") @@ -670,7 +660,7 @@ def execute_command(command: str) -> None: execute_command(f"find {destination} -type f -exec chmod 664 {{}} \\;") -def upload_docs_to_the_docs_server(db: DbfilenameShelf) -> None: +def upload_docs_to_the_docs_server(db: ReleaseShelf) -> None: release_tag: release_mod.Tag = db["release"] if not (release_tag.is_final or release_tag.is_release_candidate): return @@ -679,16 +669,19 @@ def upload_docs_to_the_docs_server(db: DbfilenameShelf) -> None: client.load_system_host_keys() client.set_missing_host_key_policy(paramiko.WarningPolicy) client.connect(DOCS_SERVER, port=22, username=db["ssh_user"]) + transport = client.get_transport() + assert transport is not None, f"SSH transport to {DOCS_SERVER} is None" - destination = pathlib.Path(f"/home/psf-users/{db['ssh_user']}/{db['release']}") - ftp_client = MySFTPClient.from_transport(client.get_transport()) + destination = Path(f"/home/psf-users/{db['ssh_user']}/{db['release']}") + ftp_client = MySFTPClient.from_transport(transport) + assert ftp_client is not None, f"SFTP client to {DOCS_SERVER} is None" client.exec_command(f"rm -rf {destination}") with contextlib.suppress(OSError): ftp_client.mkdir(str(destination)) - artifacts_path = pathlib.Path(db["git_repo"] / str(db["release"])) + artifacts_path = Path(db["git_repo"] / str(db["release"])) shutil.rmtree(artifacts_path / f"Python-{db['release']}", ignore_errors=True) @@ -706,7 +699,7 @@ def upload_subdir(subdir: str) -> None: ftp_client.close() -def unpack_docs_in_the_docs_server(db: DbfilenameShelf) -> None: +def unpack_docs_in_the_docs_server(db: ReleaseShelf) -> None: release_tag: release_mod.Tag = db["release"] if not (release_tag.is_final or release_tag.is_release_candidate): return @@ -715,6 +708,8 @@ def unpack_docs_in_the_docs_server(db: DbfilenameShelf) -> None: client.load_system_host_keys() client.set_missing_host_key_policy(paramiko.WarningPolicy) client.connect(DOCS_SERVER, port=22, username=db["ssh_user"]) + transport = client.get_transport() + assert transport is not None, f"SSH transport to {DOCS_SERVER} is None" # Sources @@ -722,7 +717,7 @@ def unpack_docs_in_the_docs_server(db: DbfilenameShelf) -> None: destination = f"/srv/docs.python.org/release/{release_tag}" def execute_command(command: str) -> None: - channel = client.get_transport().open_session() + channel = transport.open_session() channel.exec_command(command) if channel.recv_exit_status() != 0: raise ReleaseException(channel.recv_stderr(1000)) @@ -748,7 +743,7 @@ def extract_github_owner(url: str) -> str: ) -def start_build_of_source_and_docs(db: DbfilenameShelf) -> None: +def start_build_of_source_and_docs(db: ReleaseShelf) -> None: # Get the git commit SHA for the tag commit_sha = ( subprocess.check_output( @@ -802,14 +797,14 @@ def start_build_of_source_and_docs(db: DbfilenameShelf) -> None: raise ReleaseException("Source and docs build must be started") -def send_email_to_platform_release_managers(db: DbfilenameShelf) -> None: +def send_email_to_platform_release_managers(db: ReleaseShelf) -> None: if not ask_question( "Have you notified the platform release managers about the availability of the commit SHA and tag?" ): raise ReleaseException("Platform release managers must be notified") -def create_release_object_in_db(db: DbfilenameShelf) -> None: +def create_release_object_in_db(db: ReleaseShelf) -> None: print( "Go to https://www.python.org/admin/downloads/release/add/ and create a new release" ) @@ -817,7 +812,7 @@ def create_release_object_in_db(db: DbfilenameShelf) -> None: raise ReleaseException("The Django release object has not been created") -def wait_until_all_files_are_in_folder(db: DbfilenameShelf) -> None: +def wait_until_all_files_are_in_folder(db: ReleaseShelf) -> None: client = paramiko.SSHClient() client.load_system_host_keys() client.set_missing_host_key_policy(paramiko.WarningPolicy) @@ -855,16 +850,19 @@ def wait_until_all_files_are_in_folder(db: DbfilenameShelf) -> None: print() -def run_add_to_python_dot_org(db: DbfilenameShelf) -> None: +def run_add_to_python_dot_org(db: ReleaseShelf) -> None: client = paramiko.SSHClient() client.load_system_host_keys() client.set_missing_host_key_policy(paramiko.WarningPolicy) client.connect(DOWNLOADS_SERVER, port=22, username=db["ssh_user"]) + transport = client.get_transport() + assert transport is not None, f"SSH transport to {DOWNLOADS_SERVER} is None" # Ensure the file is there - source = pathlib.Path(__file__).parent / "add_to_pydotorg.py" - destination = pathlib.Path(f"/home/psf-users/{db['ssh_user']}/add_to_pydotorg.py") - ftp_client = MySFTPClient.from_transport(client.get_transport()) + source = Path(__file__).parent / "add_to_pydotorg.py" + destination = Path(f"/home/psf-users/{db['ssh_user']}/add_to_pydotorg.py") + ftp_client = MySFTPClient.from_transport(transport) + assert ftp_client is not None, f"SFTP client to {DOWNLOADS_SERVER} is None" ftp_client.put(str(source), str(destination)) ftp_client.close() @@ -887,7 +885,7 @@ def run_add_to_python_dot_org(db: DbfilenameShelf) -> None: print("-- End of command output --") -def purge_the_cdn(db: DbfilenameShelf) -> None: +def purge_the_cdn(db: ReleaseShelf) -> None: headers = { "User-Agent": "Mozilla/5.0 (Windows; U; Windows NT 6.1; en-US; rv:1.9.1.6) Gecko/20091201 Firefox/3.5.6" } @@ -913,7 +911,7 @@ def purge_the_cdn(db: DbfilenameShelf) -> None: raise RuntimeError("Failed to purge the python.org/downloads CDN") -def modify_the_release_to_the_prerelease_pages(db: DbfilenameShelf) -> None: +def modify_the_release_to_the_prerelease_pages(db: ReleaseShelf) -> None: pre_release_tags = {"rc", "b", "a"} if any(tag in str(db["release"]) for tag in pre_release_tags): if not ask_question( @@ -931,7 +929,7 @@ def modify_the_release_to_the_prerelease_pages(db: DbfilenameShelf) -> None: ) -def post_release_merge(db: DbfilenameShelf) -> None: +def post_release_merge(db: ReleaseShelf) -> None: subprocess.check_call( ["git", "fetch", "--all"], cwd=db["git_repo"], @@ -955,7 +953,7 @@ def post_release_merge(db: DbfilenameShelf) -> None: ) -def post_release_tagging(db: DbfilenameShelf) -> None: +def post_release_tagging(db: ReleaseShelf) -> None: release_tag: release_mod.Tag = db["release"] subprocess.check_call( @@ -977,7 +975,7 @@ def post_release_tagging(db: DbfilenameShelf) -> None: ) -def maybe_prepare_new_main_branch(db: DbfilenameShelf) -> None: +def maybe_prepare_new_main_branch(db: ReleaseShelf) -> None: release_tag: release_mod.Tag = db["release"] if not release_tag.is_feature_freeze_release: @@ -1009,7 +1007,7 @@ def maybe_prepare_new_main_branch(db: DbfilenameShelf) -> None: ) -def branch_new_versions(db: DbfilenameShelf) -> None: +def branch_new_versions(db: ReleaseShelf) -> None: release_tag: release_mod.Tag = db["release"] if not release_tag.is_feature_freeze_release: @@ -1023,7 +1021,7 @@ def branch_new_versions(db: DbfilenameShelf) -> None: ) -def is_mirror(repo: pathlib.Path, remote: str) -> bool: +def is_mirror(repo: Path, remote: str) -> bool: """Return True if the `repo` directory was created with --mirror.""" cmd = ["git", "config", "--local", "--get", f"remote.{remote}.mirror"] @@ -1034,7 +1032,7 @@ def is_mirror(repo: pathlib.Path, remote: str) -> bool: return out.startswith(b"true") -def push_to_local_fork(db: DbfilenameShelf) -> None: +def push_to_local_fork(db: ReleaseShelf) -> None: def _push_to_local(dry_run: bool = False) -> None: git_command = ["git", "push"] if dry_run: @@ -1058,7 +1056,7 @@ def _push_to_local(dry_run: bool = False) -> None: _push_to_local(dry_run=False) -def push_to_upstream(db: DbfilenameShelf) -> None: +def push_to_upstream(db: ReleaseShelf) -> None: release_tag: release_mod.Tag = db["release"] def _push_to_upstream(dry_run: bool = False) -> None: @@ -1166,6 +1164,7 @@ def _api_key(api_key: str) -> str: "This release script is not compatible with the running platform" ) + no_gpg = args.no_gpg tasks = [ Task(check_git, "Checking Git is available"), Task(check_make, "Checking make is available"), @@ -1173,7 +1172,7 @@ def _api_key(api_key: str) -> str: Task(check_docker, "Checking Docker is available"), Task(check_docker_running, "Checking Docker is running"), Task(check_autoconf, "Checking autoconf is available"), - None if args.no_gpg else Task(check_gpg_keys, "Checking GPG keys"), + *([] if no_gpg else [Task(check_gpg_keys, "Checking GPG keys")]), Task( check_ssh_connection, f"Validating ssh connection to {DOWNLOADS_SERVER} and {DOCS_SERVER}", @@ -1205,7 +1204,7 @@ def _api_key(api_key: str) -> str: "Wait for source and docs artifacts to build", ), Task(build_sbom_artifacts, "Building SBOM artifacts"), - None if args.no_gpg else Task(sign_source_artifacts, "Sign source artifacts"), + *([] if no_gpg else [Task(sign_source_artifacts, "Sign source artifacts")]), Task(upload_files_to_server, "Upload files to the PSF server"), Task(place_files_in_download_folder, "Place files in the download folder"), Task(upload_docs_to_the_docs_server, "Upload docs to the PSF docs server"), @@ -1225,8 +1224,6 @@ def _api_key(api_key: str) -> str: Task(purge_the_cdn, "Purge the CDN of python.org/downloads"), Task(modify_the_release_to_the_prerelease_pages, "Modify the pre-release page"), ] - # Remove any skipped tasks - tasks = [task for task in tasks if task] automata = ReleaseDriver( git_repo=args.repo, release_tag=release_mod.Tag(args.release), From 480c69091a99e2166f738a4d16c44c12d4a03609 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Wed, 31 Jul 2024 16:16:33 +0200 Subject: [PATCH 4/7] Add missing deps to appease type: ignore --- mypy-requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mypy-requirements.txt b/mypy-requirements.txt index 01302a32..e48e2de1 100644 --- a/mypy-requirements.txt +++ b/mypy-requirements.txt @@ -5,3 +5,5 @@ pytest-mock sigstore==1.1.2 types-requests types-paramiko +alive_progress # untyped :( +python-gnupg # untyped :( From 7b74f42cd3a304b28b2f976306880fdcd0ced21a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Wed, 31 Jul 2024 18:57:42 +0200 Subject: [PATCH 5/7] Check for magic number mismatch early to avoid surprises in RC stage --- run_release.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/run_release.py b/run_release.py index 3bcf8b3d..e671dc0e 100755 --- a/run_release.py +++ b/run_release.py @@ -375,6 +375,49 @@ def check_cpython_repo_is_clean(db: ReleaseShelf) -> None: raise ReleaseException("Git repository is not clean") +def check_magic_number(db: ReleaseShelf) -> None: + release_tag = db["release"] + if release_tag.is_final or release_tag.is_release_candidate: + + def out(msg: str) -> None: + raise ReleaseException(msg) + + else: + + def out(msg: str) -> None: + print("warning:", msg, file=sys.stderr, flush=True) + + def get_magic(source: Path, regex: re.Pattern[str]) -> str: + if m := regex.search(source.read_text()): + return m.group("magic") + + out(f"Cannot find magic in {source}, tried {regex.pattern}") + return "unknown" + + work_dir = Path(db["git_repo"]) + magic_actual_file = work_dir / "Include" / "internal" / "pycore_magic_number.h" + magic_actual_re = re.compile( + r"^#define\s+PYC_MAGIC_NUMBER\s+(?P\d+)$", re.MULTILINE + ) + magic_actual = get_magic(magic_actual_file, magic_actual_re) + + magic_expected_file = work_dir / "Lib" / "test" / "test_importlib" / "test_util.py" + magic_expected_re = re.compile( + r"^\s+EXPECTED_MAGIC_NUMBER = (?P\d+)$", re.MULTILINE + ) + magic_expected = get_magic(magic_expected_file, magic_expected_re) + + if magic_actual == magic_expected: + return + + out( + f"Magic numbers in {magic_actual_file} ({magic_actual})" + f" and {magic_expected_file} ({magic_expected}) don't match." + ) + if not ask_question("Do you want to continue? This will fail tests in RC stage."): + raise ReleaseException("Magic numbers don't match!") + + def prepare_temporary_branch(db: ReleaseShelf) -> None: subprocess.check_call( ["git", "checkout", "-b", f"branch-{db['release']}"], cwd=db["git_repo"] @@ -1179,6 +1222,7 @@ def _api_key(api_key: str) -> str: ), Task(check_buildbots, "Check buildbots are good"), Task(check_cpython_repo_is_clean, "Checking Git repository is clean"), + Task(check_magic_number, "Checking the magic number is up-to-date"), Task(prepare_temporary_branch, "Checking out a temporary release branch"), Task(run_blurb_release, "Run blurb release"), Task(check_cpython_repo_is_clean, "Checking Git repository is clean"), From 5db1d8645e2227c573c5b0aac43eb9971eb14d87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Wed, 31 Jul 2024 19:18:01 +0200 Subject: [PATCH 6/7] Add a test --- .../Include/internal/pycore_magic_number.h | 11 +++++++++++ tests/test_run_release.py | 15 +++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 tests/magicdata/Include/internal/pycore_magic_number.h diff --git a/tests/magicdata/Include/internal/pycore_magic_number.h b/tests/magicdata/Include/internal/pycore_magic_number.h new file mode 100644 index 00000000..e7c1893d --- /dev/null +++ b/tests/magicdata/Include/internal/pycore_magic_number.h @@ -0,0 +1,11 @@ +// copied from cpython bd3d31f380cd451a4ab6da5fbfde463fed95b5b5 +// ... + +#ifndef Py_INTERNAL_MAGIC_NUMBER_H +#define Py_INTERNAL_MAGIC_NUMBER_H + +#define PYC_MAGIC_NUMBER 3603 + +#endif // !Py_INTERNAL_MAGIC_NUMBER_H + +// ... diff --git a/tests/test_run_release.py b/tests/test_run_release.py index 7268ccfb..8f545bd0 100644 --- a/tests/test_run_release.py +++ b/tests/test_run_release.py @@ -1,6 +1,10 @@ +from pathlib import Path +from typing import cast + import pytest import run_release +from release import ReleaseShelf, Tag @pytest.mark.parametrize( @@ -22,3 +26,14 @@ def test_invalid_extract_github_owner() -> None: "https://example.com", ): run_release.extract_github_owner("https://example.com") + + +def test_check_magic_number() -> None: + db = { + "release": Tag("3.13.0rc1"), + "git_repo": str(Path(__file__).parent / "magicdata"), + } + with pytest.raises( + run_release.ReleaseException, match="Magic numbers in .* don't match" + ): + run_release.check_magic_number(cast(ReleaseShelf, db)) From 70a9790bea46c537925fc81e26db25ce15f67a76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Wed, 31 Jul 2024 19:21:08 +0200 Subject: [PATCH 7/7] Add a file that was .gitignore'd by shouldn't --- .gitignore | 1 - tests/magicdata/Lib/test/test_importlib/test_util.py | 7 +++++++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 tests/magicdata/Lib/test/test_importlib/test_util.py diff --git a/.gitignore b/.gitignore index 7608dc7a..53ab61f7 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,6 @@ dist/ downloads/ eggs/ .eggs/ -lib/ lib64/ parts/ sdist/ diff --git a/tests/magicdata/Lib/test/test_importlib/test_util.py b/tests/magicdata/Lib/test/test_importlib/test_util.py new file mode 100644 index 00000000..c6164096 --- /dev/null +++ b/tests/magicdata/Lib/test/test_importlib/test_util.py @@ -0,0 +1,7 @@ +# copied from cpython bd3d31f380cd451a4ab6da5fbfde463fed95b5b5 + + +class SomeClass: + def some_method(self) -> None: + EXPECTED_MAGIC_NUMBER = 3495 + print(EXPECTED_MAGIC_NUMBER)