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.
pull/73834/merge
Matt Clay 4 months ago committed by GitHub
parent 207a5fbebb
commit 4e69d83fac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -369,6 +369,7 @@ ANSIBLE_DIR = ANSIBLE_LIB_DIR / "ansible"
ANSIBLE_BIN_DIR = CHECKOUT_DIR / "bin" ANSIBLE_BIN_DIR = CHECKOUT_DIR / "bin"
ANSIBLE_RELEASE_FILE = ANSIBLE_DIR / "release.py" ANSIBLE_RELEASE_FILE = ANSIBLE_DIR / "release.py"
ANSIBLE_REQUIREMENTS_FILE = CHECKOUT_DIR / "requirements.txt" ANSIBLE_REQUIREMENTS_FILE = CHECKOUT_DIR / "requirements.txt"
ANSIBLE_PYPROJECT_TOML_FILE = CHECKOUT_DIR / "pyproject.toml"
DIST_DIR = CHECKOUT_DIR / "dist" DIST_DIR = CHECKOUT_DIR / "dist"
VENV_DIR = DIST_DIR / ".venv" / "release" VENV_DIR = DIST_DIR / ".venv" / "release"
@ -708,6 +709,35 @@ twine
return env 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: 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.""" """Parse and return the current ansible-core version, the provided version or the version from the provided commit."""
if version and 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) 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'^(?P<begin>requires = \["setuptools >= )(?P<lower>[^,]+)(?P<middle>, <= )(?P<upper>[^"]+)(?P<end>".*)$', 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<begin>\g<lower>\g<middle>{requested_version}\g<end>', 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: 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.""" """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: with tarfile.open(original_path) as original_archive:
@ -879,21 +941,7 @@ def calculate_digest(path: pathlib.Path) -> str:
@functools.cache @functools.cache
def get_release_artifact_details(repository: str, version: Version, validate: bool) -> list[ReleaseArtifact]: def get_release_artifact_details(repository: str, version: Version, validate: bool) -> list[ReleaseArtifact]:
"""Return information about the release artifacts hosted on PyPI.""" """Return information about the release artifacts hosted on PyPI."""
endpoint = PYPI_ENDPOINTS[repository] data = get_pypi_project(repository, 'ansible-core', version)
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
artifacts = [describe_release_artifact(version, item, validate) for item in data["urls"]] artifacts = [describe_release_artifact(version, item, validate) for item in data["urls"]]
expected_artifact_types = {"bdist_wheel", "sdist"} 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"), 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"), 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"), 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_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_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)"), 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.""" """Prepare a release."""
command.run( command.run(
update_version, update_version,
update_setuptools,
check_state, check_state,
generate_summary, generate_summary,
generate_changelog, 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) 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 @command
def generate_summary() -> None: def generate_summary() -> None:
"""Generate a summary changelog fragment for this release.""" """Generate a summary changelog fragment for this release."""

@ -1,3 +1,3 @@
[build-system] [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" build-backend = "setuptools.build_meta"

@ -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]: def build(source_dir: str, tmp_dir: str) -> tuple[pathlib.Path, pathlib.Path]:
"""Create a sdist and wheel.""" """Create a sdist and wheel."""
create = subprocess.run( create = subprocess.run(
[sys.executable, '-m', 'build', '--no-isolation', '--outdir', tmp_dir], [sys.executable, '-m', 'build', '--outdir', tmp_dir],
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
capture_output=True, capture_output=True,
text=True, text=True,

Loading…
Cancel
Save