Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,35 @@ jobs:
with:
release-type: python

- name: Checkout release PR
if: ${{ steps.release.outputs.prs_created == 'true' }}
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Normalize release PR pyproject prerelease version
if: ${{ steps.release.outputs.prs_created == 'true' }}
env:
RELEASE_PR_BRANCH: release-please--branches--${{ github.ref_name }}
run: |
set -euo pipefail

git fetch origin "${RELEASE_PR_BRANCH}:${RELEASE_PR_BRANCH}"
git switch "${RELEASE_PR_BRANCH}"

python3 scripts/normalize_pyproject_prerelease.py pyproject.toml

if git diff --quiet -- pyproject.toml; then
echo "pyproject.toml already uses a PEP 440-compatible version"
exit 0
fi

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add pyproject.toml
git commit -m "chore: normalize prerelease version to PEP 440"
git push origin "HEAD:${RELEASE_PR_BRANCH}"

# Publish to PyPI when a release is created
- name: Checkout
if: ${{ steps.release.outputs.release_created }}
Expand Down
2 changes: 1 addition & 1 deletion release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"changelog-path": "CHANGELOG.md",
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": false,
"versioning": "default",
"versioning": "prerelease",
"prerelease": true,
"prerelease-type": "beta"
}
Expand Down
102 changes: 102 additions & 0 deletions scripts/normalize_pyproject_prerelease.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Normalize release-please prerelease versions in pyproject.toml."""

from __future__ import annotations

import argparse
import re
from pathlib import Path

_SEMVER_PRERELEASE_RE = re.compile(
r"^(?P<release>\d+\.\d+\.\d+)-(?P<label>alpha|beta|rc)\.(?P<number>0|[1-9]\d*)$"
)
_PYPROJECT_SECTION_RE = re.compile(r"^\s*\[(?P<section>[^\]]+)\]\s*$")
_PYPROJECT_VERSION_LINE_RE = re.compile(
r'^(?P<prefix>\s*version\s*=\s*")'
r"(?P<version>\d+\.\d+\.\d+-(?:alpha|beta|rc)\.(?:0|[1-9]\d*))"
r'(?P<suffix>"\s*)$'
)
_PEP440_LABELS = {
"alpha": "a",
"beta": "b",
"rc": "rc",
}


def pep440_prerelease(version: str) -> str:
"""Convert a SemVer prerelease version to PEP 440 when needed."""
match = _SEMVER_PRERELEASE_RE.fullmatch(version)
if not match:
return version

label = _PEP440_LABELS[match.group("label")]
return f"{match.group('release')}{label}{match.group('number')}"


def normalize_pyproject_text(text: str) -> str:
"""Normalize a pyproject.toml project version line if it is a prerelease."""

lines = text.splitlines(keepends=True)
current_section: str | None = None
for index, line in enumerate(lines):
content = line.removesuffix("\n")
newline = "\n" if line.endswith("\n") else ""
section_match = _PYPROJECT_SECTION_RE.match(content)
if section_match:
current_section = section_match.group("section").strip()
continue

if current_section != "project":
continue

version_match = _PYPROJECT_VERSION_LINE_RE.match(content)
if not version_match:
continue

lines[index] = (
f"{version_match.group('prefix')}"
f"{pep440_prerelease(version_match.group('version'))}"
f"{version_match.group('suffix')}"
f"{newline}"
)

return "".join(lines)


def normalize_pyproject(path: Path) -> bool:
"""Normalize pyproject.toml in place.

Returns True when the file changed.
"""
text = path.read_text(encoding="utf-8")
normalized = normalize_pyproject_text(text)
if normalized == text:
return False

path.write_text(normalized, encoding="utf-8")
return True


def main() -> int:
parser = argparse.ArgumentParser(
description="Normalize release-please SemVer prereleases to PEP 440 in pyproject.toml."
)
parser.add_argument(
"path",
nargs="?",
default="pyproject.toml",
type=Path,
help="Path to pyproject.toml.",
)
args = parser.parse_args()

changed = normalize_pyproject(args.path)
if changed:
print(f"Normalized prerelease version in {args.path}")
else:
print(f"No prerelease normalization needed for {args.path}")
return 0


if __name__ == "__main__":
raise SystemExit(main())
51 changes: 51 additions & 0 deletions tests/test_normalize_pyproject_prerelease.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from pathlib import Path

from scripts.normalize_pyproject_prerelease import (
normalize_pyproject,
normalize_pyproject_text,
pep440_prerelease,
)


def test_pep440_prerelease_converts_supported_semver_labels() -> None:
assert pep440_prerelease("7.0.0-alpha.1") == "7.0.0a1"
assert pep440_prerelease("7.0.0-beta.2") == "7.0.0b2"
assert pep440_prerelease("7.0.0-rc.3") == "7.0.0rc3"


def test_pep440_prerelease_leaves_other_versions_unchanged() -> None:
assert pep440_prerelease("7.0.0") == "7.0.0"
assert pep440_prerelease("7.0.0b1") == "7.0.0b1"
assert pep440_prerelease("7.0.0-dev.1") == "7.0.0-dev.1"


def test_normalize_pyproject_text_changes_only_project_version_line() -> None:
text = """\
[project]
name = "adcp"
version = "7.0.0-beta.1"

[tool.example]
version = "1.0.0-beta.1"
"""

assert (
normalize_pyproject_text(text)
== """\
[project]
name = "adcp"
version = "7.0.0b1"

[tool.example]
version = "1.0.0-beta.1"
"""
)


def test_normalize_pyproject_reports_whether_file_changed(tmp_path: Path) -> None:
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[project]\nversion = "6.1.0-beta.1"\n', encoding="utf-8")

assert normalize_pyproject(pyproject) is True
assert pyproject.read_text(encoding="utf-8") == '[project]\nversion = "6.1.0b1"\n'
assert normalize_pyproject(pyproject) is False
Loading