← oracle_full

fastapi_15661

resolved RESOLVED UNSUBMITTED PASS · None tool calls · 0 s · fastapi/fastapi

Task input

👷 Automate release preparation

## Pull Request

👷 Automate release preparation

<!--
Please start with a GitHub Discussion.

Once a team member asks you to open a PR, create it and link the discussion here.

Obvious typo fixes can be made in a PR without starting a discussion.
-->

Discussion: <!-- Link to the GitHub Discussion -->

## Description

<!-- Write the description of your PR here -->

## AI Disclaimer

Codex with GPT-5.5, based on https://github.com/fastapi/asyncer/pull/608, reviewed by hand.

<!-- If using AI, write here the prompt and model used -->

<details>
<summary>AI transcript</summary>

<!-- Paste here the entire AI transcript -->

</details>

## Checklist

- [ ] This PR is an obvious typo fix, or it links to a GitHub Discussion for the proposed code change.
- [ ] I added tests for the change.
- [ ] The new or updated tests fail on the main branch and pass on this PR.
- [ ] Coverage stays at 100%.
- [ ] The documentation explains the change if needed.

Tool calls (0)

#ToolArgumentsResult
No trace captured.

Patch

--- a/scripts/prepare_release.py
+++ b/scripts/prepare_release.py
@@ -0,0 +1,216 @@
+"""Prepare a release by updating the package version and release notes."""
+
+import re
+from datetime import date
+from pathlib import Path
+from typing import Annotated, Literal
+
+import typer
+
+VERSION_PATTERN = re.compile(r'(?m)^__version__ = "(\d+\.\d+\.\d+)"$')
+VERSION_HEADING_PATTERN = re.compile(r"(?m)^## (\d+\.\d+\.\d+)(?: \([^)]+\))?$")
+RELEASE_NOTES_HEADER = """---
+hide:
+  - navigation
+---
+
+# Release Notes
+
+"""
+LATEST_CHANGES_HEADER = "## Latest Changes"
+BumpType = Literal["major", "minor", "patch"]
+
+app = typer.Typer()
+
+
+def parse_version(version: str) -> tuple[int, int, int]:
+    match = re.fullmatch(r"\d+\.\d+\.\d+", version)
+    if not match:
+        raise ValueError(f"Invalid version: {version!r}. Expected format: X.Y.Z")
+    major, minor, patch = version.split(".")
+    return int(major), int(minor), int(patch)
+
+
+def get_current_version(content: str, version_file: Path) -> str:
+    matches = list(VERSION_PATTERN.finditer(content))
+    if len(matches) != 1:
+        raise RuntimeError(
+            f"Expected exactly one __version__ assignment in {version_file}, "
+            f"found {len(matches)}"
+        )
+    return matches[0].group(1)
+
+
+def bump_version(version: str, bump: BumpType) -> str:
+    major, minor, patch = parse_version(version)
+    if bump == "major":
+        return f"{major + 1}.0.0"
+    if bump == "minor":
+        return f"{major}.{minor + 1}.0"
+    return f"{major}.{minor}.{patch + 1}"
+
+
+def update_version_file(content: str, version: str, version_file: Path) -> str:
+    current_version = get_current_version(content, version_file)
+    if parse_version(version) <= parse_version(current_version):
+        raise RuntimeError(
+            f"New version {version} must be greater than current version {current_version}"
+        )
+    return VERSION_PATTERN.sub(f'__version__ = "{version}"', content, count=1)
+
+
+def update_release_notes(
+    content: str, version: str, release_date: date, release_notes_file: Path
+) -> str:
+    if not content.startswith(RELEASE_NOTES_HEADER):
+        raise RuntimeError(
+            f"{release_notes_file} must start with {RELEASE_NOTES_HEADER!r}"
+        )
+    if re.search(rf"^## {re.escape(version)}(?: \([^)]+\))?$", content, re.M):
+        raise RuntimeError(f"Release notes already contain a section for {version}")
+
+    latest_header = f"{RELEASE_NOTES_HEADER}{LATEST_CHANGES_HEADER}\n"
+    if not content.startswith(latest_header):
+        raise RuntimeError(f"{release_notes_file} must start with {latest_header!r}")
+
+    release_header = f"## {version} ({release_date.isoformat()})"
+    return content.replace(
+        latest_header,
+        f"{RELEASE_NOTES_HEADER}{LATEST_CHANGES_HEADER}\n\n{release_header}\n",
+        1,
+    )
+
+
+def get_release_notes_body(content: str, version: str, release_notes_file: Path) -> str:
+    version_heading = re.compile(rf"(?m)^## {re.escape(version)}(?: \([^)]+\))?$")
+    match = version_heading.search(content)
+    if not match:
+        raise RuntimeError(
+            f"Could not find release notes section for {version} in {release_notes_file}"
+        )
+
+    next_match = VERSION_HEADING_PATTERN.search(content, match.end())
+    end = next_match.start() if next_match else len(content)
+    body = content[match.end() : end].strip()
+    if not body:
+        raise RuntimeError(
+            f"Release notes section for {version} in {release_notes_file} is empty"
+        )
+    return f"{body}\n"
+
+
+@app.command()
+def prepare(
+    bump: Annotated[
+        BumpType,
+        typer.Argument(
+            envvar="PREPARE_RELEASE_BUMP",
+            help="The release bump to make: major, minor, or patch.",
+        ),
+    ],
+    version_file: Annotated[
+        Path,
+        typer.Option(
+            envvar="PREPARE_RELEASE_VERSION_FILE",
+            exists=True,
+            file_okay=True,
+            dir_okay=False,
+            readable=True,
+            writable=True,
+            help="Path to the Python file containing the __version__ assignment.",
+        ),
+    ],
+    release_notes_file: Annotated[
+        Path,
+        typer.Option(
+            envvar="PREPARE_RELEASE_RELEASE_NOTES_FILE",
+            exists=True,
+            file_okay=True,
+            dir_okay=False,
+            readable=True,
+            writable=True,
+            help="Path to the release notes Markdown file.",
+        ),
+    ],
+    release_date: Annotated[
+        str,
+        typer.Option(
+            "--date",
+            envvar="PREPARE_RELEASE_DATE",
+            help="Release date in YYYY-MM-DD format. Defaults to today.",
+        ),
+    ] = date.today().isoformat(),
+) -> None:
+    parsed_release_date = date.fromisoformat(release_date or date.today().isoformat())
+
+    version_file_content = version_file.read_text()
+    release_notes_content = release_notes_file.read_text()
+    version = bump_version(
+        get_current_version(version_file_content, version_file), bump
+    )
+
+    version_file.write_text(
+        update_version_file(version_file_content, version, version_file)
+    )
+    release_notes_file.write_text(
+        update_release_notes(
+            release_notes_content, version, parsed_release_date, release_notes_file
+        )
+    )
+
+    typer.echo(f"Prepared release {version} ({parsed_release_date.isoformat()})")
+
+
+@app.command()
+def current_version(
+    version_file: Annotated[
+        Path,
+        typer.Option(
+            envvar="PREPARE_RELEASE_VERSION_FILE",
+            exists=True,
+            file_okay=True,
+            dir_okay=False,
+            readable=True,
+            help="Path to the Python file containing the __version__ assignment.",
+        ),
+    ],
+) -> None:
+    typer.echo(get_current_version(version_file.read_text(), version_file))
+
+
+@app.command()
+def release_notes(
+    version_file: Annotated[
+        Path,
+        typer.Option(
+            envvar="PREPARE_RELEASE_VERSION_FILE",
+            exists=True,
+            file_okay=True,
+            dir_okay=False,
+            readable=True,
+            help="Path to the Python file containing the __version__ assignment.",
+        ),
+    ],
+    release_notes_file: Annotated[
+        Path,
+        typer.Option(
+            envvar="PREPARE_RELEASE_RELEASE_NOTES_FILE",
+            exists=True,
+            file_okay=True,
+            dir_okay=False,
+            readable=True,
+            help="Path to the release notes Markdown file.",
+        ),
+    ],
+) -> None:
+    version = get_current_version(version_file.read_text(), version_file)
+    typer.echo(
+        get_release_notes_body(
+            release_notes_file.read_text(), version, release_notes_file
+        ),
+        nl=False,
+    )
+
+
+if __name__ == "__main__":
+    app()

Test output

show
................                                                         [100%]
16 passed in 0.20s