From 4e69d83fac2efff3ac8f2fbc8a1e8a9728edc57e Mon Sep 17 00:00:00 2001 From: Matt Clay Date: Mon, 5 Aug 2024 14:59:26 -0700 Subject: [PATCH] release.py - Auto-update setuptools upper bound (#83713) When releases are prepared, the upper bound on setuptools in pyproject.toml will be automatically updated to the latest version available on PyPI. This version will then be tested by the package-data sanity test during the release process and will be used to build the release. This change ensures that a released version of ansible-core can be built in the future if a new setuptools release includes breaking changes that would prevent building a functional package. If a downstream package maintainer requires a newer setuptools version than the upper bound permits, they can patch pyproject.toml as needed. Since ansible-core releases support specific Python versions, lack of support for new setuptools releases will have no effect on support for future Python versions. --- packaging/release.py | 90 +++++++++++++++++++++----- pyproject.toml | 2 +- test/sanity/code-smell/package-data.py | 2 +- 3 files changed, 77 insertions(+), 17 deletions(-) diff --git a/packaging/release.py b/packaging/release.py index a076f4bba39..109fa811b94 100755 --- a/packaging/release.py +++ b/packaging/release.py @@ -369,6 +369,7 @@ ANSIBLE_DIR = ANSIBLE_LIB_DIR / "ansible" ANSIBLE_BIN_DIR = CHECKOUT_DIR / "bin" ANSIBLE_RELEASE_FILE = ANSIBLE_DIR / "release.py" ANSIBLE_REQUIREMENTS_FILE = CHECKOUT_DIR / "requirements.txt" +ANSIBLE_PYPROJECT_TOML_FILE = CHECKOUT_DIR / "pyproject.toml" DIST_DIR = CHECKOUT_DIR / "dist" VENV_DIR = DIST_DIR / ".venv" / "release" @@ -708,6 +709,35 @@ twine return env +def get_pypi_project(repository: str, project: str, version: Version | None = None) -> dict[str, t.Any]: + """Return the project JSON from PyPI for the specified repository, project and version (optional).""" + endpoint = PYPI_ENDPOINTS[repository] + + if version: + url = f"{endpoint}/{project}/{version}/json" + else: + url = f"{endpoint}/{project}/json" + + opener = urllib.request.build_opener() + response: http.client.HTTPResponse + + try: + with opener.open(url) as response: + data = json.load(response) + except urllib.error.HTTPError as ex: + if version: + target = f'{project!r} version {version}' + else: + target = f'{project!r}' + + if ex.status == http.HTTPStatus.NOT_FOUND: + raise ApplicationError(f"Could not find {target} on PyPI.") from None + + raise RuntimeError(f"Failed to get {target} from PyPI.") from ex + + return data + + def get_ansible_version(version: str | None = None, /, commit: str | None = None, mode: VersionMode = VersionMode.DEFAULT) -> Version: """Parse and return the current ansible-core version, the provided version or the version from the provided commit.""" if version and commit: @@ -802,6 +832,38 @@ def set_ansible_version(current_version: Version, requested_version: Version) -> ANSIBLE_RELEASE_FILE.write_text(updated) +def get_latest_setuptools_version() -> Version: + """Return the latest setuptools version found on PyPI.""" + data = get_pypi_project('pypi', 'setuptools') + version = Version(data['info']['version']) + + return version + + +def set_setuptools_upper_bound(requested_version: Version) -> None: + """Set the upper bound on setuptools in pyproject.toml.""" + current = ANSIBLE_PYPROJECT_TOML_FILE.read_text() + pattern = re.compile(r'^(?Prequires = \["setuptools >= )(?P[^,]+)(?P, <= )(?P[^"]+)(?P".*)$', re.MULTILINE) + match = pattern.search(current) + + if not match: + raise ApplicationError(f"Unable to find the 'requires' entry in: {ANSIBLE_PYPROJECT_TOML_FILE.relative_to(CHECKOUT_DIR)}") + + current_version = Version(match.group('upper')) + + if requested_version == current_version: + return + + display.show(f"Updating setuptools upper bound from {current_version} to {requested_version} ...") + + updated = pattern.sub(fr'\g\g\g{requested_version}\g', current) + + if current == updated: + raise RuntimeError("Failed to set the setuptools upper bound.") + + ANSIBLE_PYPROJECT_TOML_FILE.write_text(updated) + + def create_reproducible_sdist(original_path: pathlib.Path, output_path: pathlib.Path, mtime: int) -> None: """Read the specified sdist and write out a new copy with uniform file metadata at the specified location.""" with tarfile.open(original_path) as original_archive: @@ -879,21 +941,7 @@ def calculate_digest(path: pathlib.Path) -> str: @functools.cache def get_release_artifact_details(repository: str, version: Version, validate: bool) -> list[ReleaseArtifact]: """Return information about the release artifacts hosted on PyPI.""" - endpoint = PYPI_ENDPOINTS[repository] - url = f"{endpoint}/ansible-core/{version}/json" - - opener = urllib.request.build_opener() - response: http.client.HTTPResponse - - try: - with opener.open(url) as response: - data = json.load(response) - except urllib.error.HTTPError as ex: - if ex.status == http.HTTPStatus.NOT_FOUND: - raise ApplicationError(f"Version {version} not found on PyPI.") from None - - raise RuntimeError(f"Failed to get {version} from PyPI: {ex}") from ex - + data = get_pypi_project(repository, 'ansible-core', version) artifacts = [describe_release_artifact(version, item, validate) for item in data["urls"]] expected_artifact_types = {"bdist_wheel", "sdist"} @@ -1139,6 +1187,7 @@ command = CommandFramework( mailto=dict(name="--mailto", action="store_true", help="write announcement to mailto link instead of console"), validate=dict(name="--no-validate", action="store_false", help="disable validation of PyPI artifacts against local ones"), prompt=dict(name="--no-prompt", action="store_false", help="disable interactive prompt before publishing with twine"), + setuptools=dict(name='--no-setuptools', action="store_false", help="disable updating setuptools upper bound"), allow_tag=dict(action="store_true", help="allow an existing release tag (for testing)"), allow_stale=dict(action="store_true", help="allow a stale checkout (for testing)"), allow_dirty=dict(action="store_true", help="allow untracked files and files with changes (for testing)"), @@ -1199,6 +1248,7 @@ def prepare(final: bool = False, pre: str | None = None, version: str | None = N """Prepare a release.""" command.run( update_version, + update_setuptools, check_state, generate_summary, generate_changelog, @@ -1219,6 +1269,16 @@ def update_version(final: bool = False, pre: str | None = None, version: str | N set_ansible_version(current_version, requested_version) +@command +def update_setuptools(setuptools: bool) -> None: + """Update the setuptools upper bound in pyproject.toml.""" + if not setuptools: + return + + requested_version = get_latest_setuptools_version() + set_setuptools_upper_bound(requested_version) + + @command def generate_summary() -> None: """Generate a summary changelog fragment for this release.""" diff --git a/pyproject.toml b/pyproject.toml index b3d00425b20..f78c29c152d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,3 @@ [build-system] -requires = ["setuptools >= 66.1.0"] # minimum setuptools version supporting Python 3.12 +requires = ["setuptools >= 66.1.0, <= 72.1.0"] # lower bound to support controller Python versions, upper bound for latest version tested at release build-backend = "setuptools.build_meta" diff --git a/test/sanity/code-smell/package-data.py b/test/sanity/code-smell/package-data.py index 7a81b7597a9..4dc242a057a 100644 --- a/test/sanity/code-smell/package-data.py +++ b/test/sanity/code-smell/package-data.py @@ -95,7 +95,7 @@ def clean_repository(complete_file_list: list[str]) -> t.Generator[str, None, No def build(source_dir: str, tmp_dir: str) -> tuple[pathlib.Path, pathlib.Path]: """Create a sdist and wheel.""" create = subprocess.run( - [sys.executable, '-m', 'build', '--no-isolation', '--outdir', tmp_dir], + [sys.executable, '-m', 'build', '--outdir', tmp_dir], stdin=subprocess.DEVNULL, capture_output=True, text=True,