diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aaeacb1..e012bfa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,6 +58,52 @@ poetry run twine check dist/* Offline CI is the normal pull-request gate. External tests are available manually, on a weekly schedule, and before releases. +## Preparing a release + +Release preparation starts with one command: + +```bash +python scripts/prepare_release.py +``` + +The version must be a plain `MAJOR.MINOR.PATCH` newer than the version in `pyproject.toml`. The script makes the version-specific edits every release needs: + +- bumps the version in `pyproject.toml` +- creates a release-notes skeleton at `docs/releases/.md` +- lists the release first in `docs/releases.md` and in the Release Notes section of `mkdocs.yml` +- updates the current User-Agent version in `README.md` +- updates the current version, release-notes link, and User-Agent in `docs/http-transport.md` +- points `CURRENT_RELEASE_NOTES` in `tests/test_release_validation.py` at the new notes and moves the previous release into `HISTORICAL_RELEASE_NOTES` + +Preview the same checks and the exact diff without changing any file: + +```bash +python scripts/prepare_release.py --dry-run +``` + +`--check` is an alias for `--dry-run`. Both exit with status 0 when preparation would succeed and 1 when it would be refused. + +The script refuses, without changing anything, when the version is invalid, not newer than the current version, already prepared, or partially prepared, or when any file no longer has the exact structure it expects. Every edit is computed before anything is written, so a refusal never leaves the repository half-updated. When it reports a partially prepared release, restore the listed files and run it again. + +After the script runs: + +1. Write the release notes. Replace every `TODO(release)` marker in `docs/releases/.md` and the summary line in `docs/releases.md`, and confirm the `Python support` section carried over from the previous release is still accurate. Offline tests fail while any `TODO(release)` marker remains. +2. Review the complete diff. +3. Run the full validation: + + ```bash + poetry run pytest tests/ --ignore=tests/external_tests + poetry run pytest tests/external_tests/ + rm -rf dist + poetry build + python3 scripts/validate_release.py + poetry run twine check dist/* + ``` + +4. Commit and open the release pull request. + +`prepare_release.py` only edits files in the working tree. It does not commit, push, tag, publish to PyPI, or create a GitHub Release; those steps remain manual. + ## Pull Request Guidelines - Run offline tests before submitting a PR diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py new file mode 100644 index 0000000..890e677 --- /dev/null +++ b/scripts/prepare_release.py @@ -0,0 +1,595 @@ +"""Prepare the version-specific files for a new python-mlb-statsapi release. + +Applies the same edits every release has needed so far (see the 1.1.2 +preparation commit for the reference set): + +* bump the version in ``pyproject.toml`` +* create a release-notes skeleton at ``docs/releases/.md`` +* list the release first in ``docs/releases.md`` +* add the notes page to the Release Notes section of ``mkdocs.yml`` +* update the current User-Agent version in ``README.md`` +* update the current version, release-notes link, and User-Agent in + ``docs/http-transport.md`` +* point ``CURRENT_RELEASE_NOTES`` in ``tests/test_release_validation.py`` at + the new notes and move the previous release into ``HISTORICAL_RELEASE_NOTES`` + +Every edit is computed in memory first. Each one must find its expected source +text exactly once; otherwise nothing is written and the script explains which +file did not match. The script never commits, tags, pushes, or publishes. + +Usage:: + + python scripts/prepare_release.py 1.1.3 + python scripts/prepare_release.py 1.1.3 --dry-run + +``--dry-run`` (alias ``--check``) runs every version and structure check and +prints the diff that would be applied without changing any file. Exit status +is 0 when preparation succeeds (or would succeed) and 1 when it is refused. +""" + +from __future__ import annotations + +import argparse +import difflib +import importlib.util +import os +import re +import shutil +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Callable, NamedTuple + +# Repository-relative paths. POSIX paths keep messages identical on every OS. +PYPROJECT = PurePosixPath("pyproject.toml") +README = PurePosixPath("README.md") +MKDOCS = PurePosixPath("mkdocs.yml") +RELEASE_INDEX = PurePosixPath("docs/releases.md") +TRANSPORT_DOC = PurePosixPath("docs/http-transport.md") +RELEASE_NOTES_DIR = PurePosixPath("docs/releases") +RELEASE_VALIDATION_TESTS = PurePosixPath("tests/test_release_validation.py") + +# Marks text a maintainer must replace before the release ships. An offline +# test fails while any release notes still contain it. +PLACEHOLDER = "TODO(release)" + +# Every published version so far is a plain MAJOR.MINOR.PATCH with no prefix, +# prerelease, or local segment, so nothing broader is accepted. [0-9] rather +# than \d keeps non-ASCII digits out. +VERSION_PATTERN = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)") + +PYTHON_SUPPORT_HEADING = "## Python support" + + +class PrepareReleaseError(Exception): + """Release preparation was refused; no files were changed.""" + + +class Version(NamedTuple): + major: int + minor: int + patch: int + + def __str__(self) -> str: + return f"{self.major}.{self.minor}.{self.patch}" + + +@dataclass(frozen=True) +class FileChange: + """New content for one repository file; ``original`` is None when created.""" + + path: PurePosixPath + original: str | None + updated: str + + +@dataclass(frozen=True) +class ReleasePlan: + current: Version + target: Version + changes: tuple[FileChange, ...] + + +# --------------------------------------------------------------------------- +# Version parsing +# --------------------------------------------------------------------------- + + +def parse_version(text: str) -> Version: + match = VERSION_PATTERN.fullmatch(text) + if match is None: + raise PrepareReleaseError( + f"invalid version {text!r}: expected MAJOR.MINOR.PATCH such as 1.1.3 " + "(no 'v' prefix, prerelease suffix, or leading zeros)" + ) + major, minor, patch = (int(part) for part in match.groups()) + return Version(major, minor, patch) + + +def _version_ref(version: Version) -> str: + """Regex for *version* that does not also match e.g. 1.1.30 or 11.1.3.""" + return rf"(? str: + """Apply *replacement* to the single match of *pattern* in *text*. + + Zero or several matches mean the file no longer has the structure this + script was written against, so the caller must fix it by hand. + """ + compiled = re.compile(pattern, re.MULTILINE) + count = len(compiled.findall(text)) + if count != 1: + raise PrepareReleaseError( + f"{path}: expected exactly one {expected}, found {count}" + ) + return compiled.sub(replacement, text, count=1) + + +def _require_once(text: str, pattern: str, *, path: PurePosixPath, expected: str) -> None: + count = len(re.findall(pattern, text, flags=re.MULTILINE)) + if count != 1: + raise PrepareReleaseError( + f"{path}: expected exactly one {expected}, found {count}" + ) + + +def update_pyproject(text: str, current: Version, target: Version) -> str: + # The version is the only top-level `version = "..."` line; dependency + # tables such as httpx's carry `version` inline and are not matched. + return _replace_once( + text, + rf'^version = "{re.escape(str(current))}"$', + f'version = "{target}"', + path=PYPROJECT, + expected=f'line `version = "{current}"`', + ) + + +def update_release_index(text: str, current: Version, target: Version) -> str: + escaped = re.escape(str(current)) + current_entry = rf"- \[{escaped}\]\(releases/{escaped}\.md\) " + _require_once( + text, + rf"^{current_entry}", + path=RELEASE_INDEX, + expected=f"list entry for {current}", + ) + new_entry = f"- [{target}](releases/{target}.md) — {PLACEHOLDER}: one-line summary\n" + return _replace_once( + text, + rf"^## Releases\n\n(?={current_entry})", + lambda match: match.group(0) + new_entry, + path=RELEASE_INDEX, + expected=f"'## Releases' section whose first entry is {current}", + ) + + +def update_mkdocs_nav(text: str, current: Version, target: Version) -> str: + current_page = rf"- {re.escape(str(current))}: releases/{re.escape(str(current))}\.md\n" + return _replace_once( + text, + rf"^(?P[ ]+)- Overview: releases\.md\n(?=(?P=indent){current_page})", + lambda match: ( + match.group(0) + f"{match.group('indent')}- {target}: releases/{target}.md\n" + ), + path=MKDOCS, + expected=( + f"Release Notes nav entry 'Overview: releases.md' followed directly " + f"by '{current}: releases/{current}.md'" + ), + ) + + +def update_readme(text: str, current: Version, target: Version) -> str: + sentence = "The current package version sends `python-mlb-statsapi/" + return _replace_once( + text, + rf"({re.escape(sentence)}){re.escape(str(current))}(`)", + rf"\g<1>{target}\g<2>", + path=README, + expected=f"'{sentence}{current}`' sentence", + ) + + +def update_transport_doc(text: str, current: Version, target: Version) -> str: + escaped = re.escape(str(current)) + text = _replace_once( + text, + # The sentence is wrapped, so any whitespace may separate the words. + rf"(behavior of the current release,\s+version ){escaped}(\.)", + rf"\g<1>{target}\g<2>", + path=TRANSPORT_DOC, + expected=f"'current release, version {current}.' statement", + ) + text = _replace_once( + text, + rf"\[the {escaped} release notes\]\(releases/{escaped}\.md\)", + f"[the {target} release notes](releases/{target}.md)", + path=TRANSPORT_DOC, + expected=f"'[the {current} release notes](releases/{current}.md)' link", + ) + return _replace_once( + text, + rf"^python-mlb-statsapi/{escaped}$", + f"python-mlb-statsapi/{target}", + path=TRANSPORT_DOC, + expected=f"'python-mlb-statsapi/{current}' User-Agent example line", + ) + + +_HISTORICAL_BLOCK = re.compile( + r"^HISTORICAL_RELEASE_NOTES = \(\n" + r"(?P(?:[ ]+RELEASE_NOTES_DIR / \"[^\"\n]+\.md\",\n)+)" + r"\)$", + re.MULTILINE, +) +_HISTORICAL_ENTRY = re.compile( + r"^(?P[ ]+)RELEASE_NOTES_DIR / \"(?P[^\"\n]+)\",$", + re.MULTILINE, +) + + +def update_release_validation_tests( + text: str, current: Version, target: Version +) -> str: + text = _replace_once( + text, + rf'^CURRENT_RELEASE_NOTES = RELEASE_NOTES_DIR / "{re.escape(str(current))}\.md"$', + f'CURRENT_RELEASE_NOTES = RELEASE_NOTES_DIR / "{target}.md"', + path=RELEASE_VALIDATION_TESTS, + expected=f'line `CURRENT_RELEASE_NOTES = RELEASE_NOTES_DIR / "{current}.md"`', + ) + + blocks = list(_HISTORICAL_BLOCK.finditer(text)) + if len(blocks) != 1: + raise PrepareReleaseError( + f"{RELEASE_VALIDATION_TESTS}: expected exactly one " + "HISTORICAL_RELEASE_NOTES tuple of RELEASE_NOTES_DIR / \".md\" " + f"entries, found {len(blocks)}" + ) + block = blocks[0] + entries = list(_HISTORICAL_ENTRY.finditer(block.group("entries"))) + if f"{current}.md" in {entry.group("name") for entry in entries}: + raise PrepareReleaseError( + f"{RELEASE_VALIDATION_TESTS}: {current}.md is already listed in " + "HISTORICAL_RELEASE_NOTES while it is also the current release" + ) + indent = entries[-1].group("indent") + new_entry = f'{indent}RELEASE_NOTES_DIR / "{current}.md",\n' + insert_at = block.end("entries") + return text[:insert_at] + new_entry + text[insert_at:] + + +def extract_python_support(notes: str, *, path: PurePosixPath) -> str: + """Return the '## Python support' section, carried forward unchanged. + + The current release notes must state the CI-validated Python range, so the + previous release's wording is reused instead of being invented here. + """ + heading_pattern = rf"^{re.escape(PYTHON_SUPPORT_HEADING)}$" + _require_once( + notes, + heading_pattern, + path=path, + expected=f"'{PYTHON_SUPPORT_HEADING}' heading", + ) + heading = re.search(heading_pattern, notes, re.MULTILINE) + # The section runs until the next level-one or level-two heading. + next_heading = re.compile(r"^#{1,2} ", re.MULTILINE).search(notes, heading.end()) + end = len(notes) if next_heading is None else next_heading.start() + return notes[heading.start() : end].strip() + "\n" + + +def render_release_notes(target: Version, python_support: str) -> str: + # Release-notes tests require a compilable python block in every notes + # file, so the skeleton carries a placeholder one. + return ( + f"# python-mlb-statsapi {target}\n" + "\n" + f"{PLACEHOLDER}: Summarize version {target} in one or two sentences.\n" + "\n" + "## Changes\n" + "\n" + f"{PLACEHOLDER}: Describe each user-visible change and link the pull " + "requests that introduced it.\n" + "\n" + "```python\n" + f"# {PLACEHOLDER}: Replace with an example relevant to this release.\n" + "from mlbstatsapi import Mlb\n" + "```\n" + "\n" + f"{python_support}" + ) + + +# --------------------------------------------------------------------------- +# Repository inspection +# --------------------------------------------------------------------------- + + +def _read(root: Path, relative: PurePosixPath) -> str: + path = root / relative + if not path.is_file(): + raise PrepareReleaseError(f"{relative}: file not found under {root}") + # newline="" keeps the existing line endings byte-for-byte. + with path.open(encoding="utf-8", newline="") as handle: + return handle.read() + + +def _load_validator(): + """Import scripts/validate_release.py so both scripts read one version.""" + module_path = Path(__file__).resolve().with_name("validate_release.py") + spec = importlib.util.spec_from_file_location("_validate_release", module_path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def read_current_version(root: Path) -> Version: + validator = _load_validator() + try: + declared = validator._read_expected_version(root) + except validator.ValidationError as exc: + raise PrepareReleaseError(str(exc)) from exc + try: + return parse_version(declared) + except PrepareReleaseError as exc: + raise PrepareReleaseError( + f"{PYPROJECT}: declared version is not usable: {exc}" + ) from exc + + +def find_target_references(root: Path, target: Version) -> dict[str, bool]: + """Report, per release file, whether it already refers to *target*.""" + ref = _version_ref(target) + escaped = re.escape(str(target)) + notes = RELEASE_NOTES_DIR / f"{target}.md" + + def contains(relative: PurePosixPath, pattern: str) -> bool: + path = root / relative + if not path.is_file(): + return False + return re.search(pattern, path.read_text(encoding="utf-8"), re.MULTILINE) is not None + + return { + str(PYPROJECT): contains(PYPROJECT, rf'^version = "{escaped}"$'), + str(notes): (root / notes).exists(), + str(RELEASE_INDEX): contains(RELEASE_INDEX, rf"releases/{escaped}\.md"), + str(MKDOCS): contains(MKDOCS, rf"releases/{escaped}\.md"), + str(README): contains(README, rf"python-mlb-statsapi/{ref}"), + str(TRANSPORT_DOC): contains( + TRANSPORT_DOC, + rf"current release,\s+version {ref}" + rf"|releases/{escaped}\.md" + rf"|python-mlb-statsapi/{ref}", + ), + str(RELEASE_VALIDATION_TESTS): contains(RELEASE_VALIDATION_TESTS, rf'"{escaped}\.md"'), + } + + +def _refuse_existing_target(target: Version, references: dict[str, bool]) -> None: + present = [name for name, found in references.items() if found] + if not present: + return + missing = [name for name, found in references.items() if not found] + if not missing: + raise PrepareReleaseError( + f"{target} already exists: every release file already refers to it. " + "Nothing was changed. Choose a newer version." + ) + lines = [ + f"{target} looks partially prepared, so nothing was changed.", + "Already refers to it:", + *(f" - {name}" for name in present), + "Does not refer to it yet:", + *(f" - {name}" for name in missing), + "Restore the files above to the previous release (for example with " + "`git restore` and by removing a new notes file) and re-run, or finish " + "the remaining edits by hand.", + ] + raise PrepareReleaseError("\n".join(lines)) + + +def plan_release(root: Path, target: Version) -> ReleasePlan: + """Validate the repository and compute every edit without writing.""" + current = read_current_version(root) + if target < current: + raise PrepareReleaseError( + f"{target} is older than the current version {current}; " + "only a newer version can be prepared" + ) + # Checked before the structural edits so a re-run or a hand-started + # release is reported as such rather than as a confusing mismatch. + _refuse_existing_target(target, find_target_references(root, target)) + if target == current: + raise PrepareReleaseError(f"{target} is already the current version") + + previous_notes_path = RELEASE_NOTES_DIR / f"{current}.md" + previous_notes = _read(root, previous_notes_path) + python_support = extract_python_support(previous_notes, path=previous_notes_path) + + edits = ( + (PYPROJECT, update_pyproject), + (RELEASE_INDEX, update_release_index), + (MKDOCS, update_mkdocs_nav), + (README, update_readme), + (TRANSPORT_DOC, update_transport_doc), + (RELEASE_VALIDATION_TESTS, update_release_validation_tests), + ) + changes = [] + for relative, transform in edits: + original = _read(root, relative) + changes.append( + FileChange(relative, original, transform(original, current, target)) + ) + changes.insert( + 1, + FileChange( + RELEASE_NOTES_DIR / f"{target}.md", + None, + render_release_notes(target, python_support), + ), + ) + return ReleasePlan(current, target, tuple(changes)) + + +# --------------------------------------------------------------------------- +# Writing +# --------------------------------------------------------------------------- + + +def _write_atomically(path: Path, content: str) -> None: + fd, temp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle: + handle.write(content) + # mkstemp creates the file as 0600; keep the original permissions. + shutil.copymode(path, temp_name) + os.replace(temp_name, path) + except BaseException: + Path(temp_name).unlink(missing_ok=True) + raise + + +def apply_plan(root: Path, plan: ReleasePlan) -> None: + """Write every change, restoring the original files if any write fails.""" + written: list[FileChange] = [] + try: + for change in plan.changes: + path = root / change.path + if change.original is None: + # "x" refuses to overwrite a file that appeared after planning. + with path.open("x", encoding="utf-8", newline="") as handle: + written.append(change) + handle.write(change.updated) + else: + _write_atomically(path, change.updated) + written.append(change) + except BaseException as exc: + for change in reversed(written): + path = root / change.path + if change.original is None: + path.unlink(missing_ok=True) + else: + _write_atomically(path, change.original) + if not isinstance(exc, OSError): + raise + raise PrepareReleaseError( + f"writing {exc.filename or 'a release file'} failed ({exc.strerror or exc}); " + "every file written so far was restored" + ) from exc + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def format_diff(plan: ReleasePlan) -> str: + chunks = [] + for change in plan.changes: + before = "/dev/null" if change.original is None else f"a/{change.path.as_posix()}" + chunks.extend( + difflib.unified_diff( + (change.original or "").splitlines(keepends=True), + change.updated.splitlines(keepends=True), + fromfile=before, + tofile=f"b/{change.path.as_posix()}", + ) + ) + return "".join(chunks) + + +def format_summary(plan: ReleasePlan, *, dry_run: bool) -> str: + target, current = plan.target, plan.current + notes = (RELEASE_NOTES_DIR / f"{target}.md").as_posix() + heading = ( + f"Dry run: preparing {target} (current release {current}) would change " + "the files below. Nothing was written." + if dry_run + else f"Prepared release {target} (previous release {current})." + ) + lines = [heading, "", "Files:"] + for change in plan.changes: + action = "create" if change.original is None else "modify" + if not dry_run: + action += "d" + lines.append(f" {action:<9} {change.path.as_posix()}") + lines += [ + "", + "Remaining manual steps:", + f" 1. Write the release notes in {notes}: replace every {PLACEHOLDER} " + f"marker and confirm the Python support section copied from {current} " + "is still accurate.", + f" 2. Replace the {PLACEHOLDER} summary for {target} in {RELEASE_INDEX.as_posix()}.", + " 3. Review the complete diff (git diff).", + " 4. Validate:", + " poetry run pytest tests/ --ignore=tests/external_tests", + " poetry run pytest tests/external_tests/", + " rm -rf dist && poetry build", + " python scripts/validate_release.py", + " poetry run twine check dist/*", + " 5. Commit and open the release pull request. Tagging, publishing, and " + "GitHub Releases stay manual; this script does none of them.", + ] + if dry_run: + lines += [ + "", + f"Run without --dry-run to apply: python scripts/prepare_release.py {target}", + ] + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("version", help="release to prepare, e.g. 1.1.3") + parser.add_argument( + "--dry-run", + "--check", + dest="dry_run", + action="store_true", + help="run every check and print the diff without changing any file", + ) + parser.add_argument( + "--project-root", + type=Path, + default=Path(__file__).resolve().parent.parent, + help="repository root containing pyproject.toml", + ) + args = parser.parse_args(argv) + root = args.project_root.resolve() + + try: + plan = plan_release(root, parse_version(args.version)) + if args.dry_run: + print(format_diff(plan)) + else: + apply_plan(root, plan) + except PrepareReleaseError as exc: + print(f"Release preparation refused: {exc}", file=sys.stderr) + return 1 + + print(format_summary(plan, dry_run=args.dry_run)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_prepare_release.py b/tests/test_prepare_release.py new file mode 100644 index 0000000..98e2493 --- /dev/null +++ b/tests/test_prepare_release.py @@ -0,0 +1,707 @@ +"""Offline tests for ``scripts/prepare_release.py``. + +Every test that prepares a release works on a temporary copy of the release +files, so the repository checkout is never modified. Synthetic fixtures pin +exact transformation output; copies of the real files prove the script still +matches the repository's current structure. +""" + +from __future__ import annotations + +import ast +import importlib.util +import re +import shutil +import sys +import types +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +PREPARE_RELEASE = PROJECT_ROOT / "scripts" / "prepare_release.py" +RELEASE_NOTES_DIR = PROJECT_ROOT / "docs" / "releases" + +USER_AGENT_PATTERN = re.compile(r"python-mlb-statsapi/[0-9][^\s`\"']*") + + +def _load_script() -> types.ModuleType: + """Import scripts/prepare_release.py, which is not an installable package.""" + spec = importlib.util.spec_from_file_location( + "prepare_release_under_test", + PREPARE_RELEASE, + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + # dataclasses resolve string annotations through sys.modules. + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +prepare = _load_script() +Version = prepare.Version +PrepareReleaseError = prepare.PrepareReleaseError + +CURRENT = Version(2, 3, 4) +TARGET = Version(2, 3, 5) + +PYTHON_SUPPORT = """\ +## Python support + +python-mlb-statsapi requires Python >=3.10. + +CI validates Python 3.10 through 3.14. +""" + +SYNTHETIC_PYPROJECT = """\ +[tool.poetry] +name = "python-mlb-statsapi" +version = "2.3.4" + +[tool.poetry.dependencies] +python = ">=3.10" +httpx = { version = ">=0.28.1,<1.0", optional = true } +""" + +SYNTHETIC_RELEASE_INDEX = """\ +# Release Notes + +Release notes describe each published version. + +## Releases + +- [2.3.4](releases/2.3.4.md) — current fixes +- [2.3.3](releases/2.3.3.md) — older fixes +""" + +SYNTHETIC_MKDOCS = """\ +site_name: Example +nav: + - Home: index.md + - Release Notes: + - Overview: releases.md + - 2.3.4: releases/2.3.4.md + - 2.3.3: releases/2.3.3.md + +plugins: + - search +""" + +SYNTHETIC_README = """\ +# python-mlb-statsapi + +Library-created clients send a versioned User-Agent. The current package version sends `python-mlb-statsapi/2.3.4`. See the docs. +""" + +SYNTHETIC_TRANSPORT_DOC = """\ +# HTTP Transport + +This document describes the HTTP transport behavior of the current release, +version 2.3.4. + +Version 2.3.3 introduced something else. + +See [the 2.3.4 release notes](releases/2.3.4.md) for a shorter summary of what +changed in the current release. + +```text +python-mlb-statsapi/2.3.4 +``` +""" + +SYNTHETIC_RELEASE_TESTS = """\ +from pathlib import Path + +RELEASE_NOTES_DIR = Path("docs") / "releases" + +CURRENT_RELEASE_NOTES = RELEASE_NOTES_DIR / "2.3.4.md" + +# Historical notes. + +HISTORICAL_RELEASE_NOTES = ( + RELEASE_NOTES_DIR / "2.3.2.md", + RELEASE_NOTES_DIR / "2.3.3.md", +) + +OTHER = 1 +""" + +SYNTHETIC_CURRENT_NOTES = f"""\ +# python-mlb-statsapi 2.3.4 + +Version 2.3.4 fixes things. + +```python +import mlbstatsapi +``` + +{PYTHON_SUPPORT}""" + + +def _write_synthetic_repo(root: Path) -> Path: + files = { + "pyproject.toml": SYNTHETIC_PYPROJECT, + "README.md": SYNTHETIC_README, + "mkdocs.yml": SYNTHETIC_MKDOCS, + "docs/releases.md": SYNTHETIC_RELEASE_INDEX, + "docs/http-transport.md": SYNTHETIC_TRANSPORT_DOC, + "docs/releases/2.3.2.md": "# python-mlb-statsapi 2.3.2\n", + "docs/releases/2.3.3.md": "# python-mlb-statsapi 2.3.3\n", + "docs/releases/2.3.4.md": SYNTHETIC_CURRENT_NOTES, + "tests/test_release_validation.py": SYNTHETIC_RELEASE_TESTS, + } + for relative, content in files.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return root + + +def _copy_real_repo(root: Path) -> Path: + """Copy only the files release preparation reads or writes.""" + for relative in ( + "pyproject.toml", + "README.md", + "mkdocs.yml", + "docs/releases.md", + "docs/http-transport.md", + "tests/test_release_validation.py", + ): + destination = root / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(PROJECT_ROOT / relative, destination) + shutil.copytree(RELEASE_NOTES_DIR, root / "docs" / "releases") + return root + + +def _snapshot(root: Path) -> dict[str, bytes]: + return { + path.relative_to(root).as_posix(): path.read_bytes() + for path in sorted(root.rglob("*")) + if path.is_file() + } + + +def _next_patch(version) -> Version: + return Version(version.major, version.minor, version.patch + 1) + + +def _classified_release_notes(test_source: str) -> tuple[str, set[str]]: + """Return (current, historical) note file names from the validation tests.""" + current = None + historical: set[str] = set() + for node in ast.parse(test_source).body: + if not isinstance(node, ast.Assign) or len(node.targets) != 1: + continue + name = getattr(node.targets[0], "id", None) + if name == "CURRENT_RELEASE_NOTES": + current = node.value.right.value + elif name == "HISTORICAL_RELEASE_NOTES": + historical = {element.right.value for element in node.value.elts} + assert current is not None + return current, historical + + +# --------------------------------------------------------------------------- +# Version parsing +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("1.1.3", Version(1, 1, 3)), + ("0.0.0", Version(0, 0, 0)), + ("2.0.0", Version(2, 0, 0)), + ("10.20.30", Version(10, 20, 30)), + ], +) +def test_valid_versions_are_parsed(text: str, expected) -> None: + version = prepare.parse_version(text) + + assert version == expected + assert str(version) == text + + +@pytest.mark.parametrize( + "text", + [ + "", + "1", + "1.1", + "1.1.3.4", + "v1.1.3", + "1.1.3rc1", + "1.1.3-rc.1", + "1.1.3.dev0", + "1.1.3.post1", + "1.1.3+local", + "01.1.3", + "1.01.3", + "1.1.03", + " 1.1.3", + "1.1.3\n", + "1..3", + "a.b.c", + "1.1.3", + ], +) +def test_invalid_versions_are_rejected(text: str) -> None: + with pytest.raises(PrepareReleaseError, match="invalid version"): + prepare.parse_version(text) + + +def test_versions_compare_numerically() -> None: + assert prepare.parse_version("1.10.0") > prepare.parse_version("1.9.9") + assert prepare.parse_version("2.0.0") > prepare.parse_version("1.99.99") + + +# --------------------------------------------------------------------------- +# Individual transformations +# --------------------------------------------------------------------------- + + +def test_pyproject_version_is_updated_without_touching_dependency_versions() -> None: + updated = prepare.update_pyproject(SYNTHETIC_PYPROJECT, CURRENT, TARGET) + + assert updated == SYNTHETIC_PYPROJECT.replace( + 'version = "2.3.4"', 'version = "2.3.5"' + ) + assert 'httpx = { version = ">=0.28.1,<1.0", optional = true }' in updated + + +@pytest.mark.parametrize( + "text", + [ + SYNTHETIC_PYPROJECT.replace('version = "2.3.4"', 'version = "9.9.9"'), + SYNTHETIC_PYPROJECT + '\n[project]\nversion = "2.3.4"\n', + ], + ids=["missing", "duplicated"], +) +def test_pyproject_requires_exactly_one_current_version(text: str) -> None: + with pytest.raises(PrepareReleaseError, match="pyproject.toml: expected exactly one"): + prepare.update_pyproject(text, CURRENT, TARGET) + + +def test_release_notes_skeleton_has_no_invented_content() -> None: + notes = prepare.render_release_notes(TARGET, PYTHON_SUPPORT) + + assert notes.startswith("# python-mlb-statsapi 2.3.5\n") + assert notes.endswith(PYTHON_SUPPORT) + assert notes.count(prepare.PLACEHOLDER) == 3 + # The release-notes tests require a compilable python example per file. + block = re.search(r"^```python\n(.*?)^```", notes, re.MULTILINE | re.DOTALL) + assert block is not None + compile(block.group(1), "skeleton", "exec") + + +def test_python_support_section_is_carried_forward_up_to_the_next_heading() -> None: + notes = ( + "# python-mlb-statsapi 2.3.4\n\n" + f"{PYTHON_SUPPORT}\n### Detail\n\nKept.\n\n## Thanks\n\nNot carried.\n" + ) + + section = prepare.extract_python_support(notes, path=Path("x.md")) + + assert section == PYTHON_SUPPORT + "\n### Detail\n\nKept.\n" + + +@pytest.mark.parametrize( + "notes", + ["# python-mlb-statsapi 2.3.4\n", PYTHON_SUPPORT + "\n" + PYTHON_SUPPORT], + ids=["missing", "duplicated"], +) +def test_python_support_section_must_exist_exactly_once(notes: str) -> None: + with pytest.raises(PrepareReleaseError, match="'## Python support' heading"): + prepare.extract_python_support(notes, path=Path("x.md")) + + +def test_release_index_gets_the_new_release_first() -> None: + updated = prepare.update_release_index(SYNTHETIC_RELEASE_INDEX, CURRENT, TARGET) + + assert updated == SYNTHETIC_RELEASE_INDEX.replace( + "## Releases\n\n", + "## Releases\n\n- [2.3.5](releases/2.3.5.md) — TODO(release): one-line summary\n", + ) + + +@pytest.mark.parametrize( + "text", + [ + SYNTHETIC_RELEASE_INDEX.replace("- [2.3.4](releases/2.3.4.md) — current fixes\n", ""), + SYNTHETIC_RELEASE_INDEX.replace( + "- [2.3.4](releases/2.3.4.md) — current fixes\n" + "- [2.3.3](releases/2.3.3.md) — older fixes\n", + "- [2.3.3](releases/2.3.3.md) — older fixes\n" + "- [2.3.4](releases/2.3.4.md) — current fixes\n", + ), + SYNTHETIC_RELEASE_INDEX.replace("## Releases", "## Versions"), + ], + ids=["current-missing", "current-not-first", "heading-renamed"], +) +def test_release_index_rejects_unexpected_structure(text: str) -> None: + with pytest.raises(PrepareReleaseError, match="docs/releases.md: expected exactly one"): + prepare.update_release_index(text, CURRENT, TARGET) + + +def test_mkdocs_nav_gets_the_new_release_page_first() -> None: + updated = prepare.update_mkdocs_nav(SYNTHETIC_MKDOCS, CURRENT, TARGET) + + assert updated == SYNTHETIC_MKDOCS.replace( + " - Overview: releases.md\n", + " - Overview: releases.md\n - 2.3.5: releases/2.3.5.md\n", + ) + + +@pytest.mark.parametrize( + "text", + [ + SYNTHETIC_MKDOCS.replace(" - 2.3.4: releases/2.3.4.md\n", ""), + SYNTHETIC_MKDOCS.replace(" - Overview: releases.md\n", ""), + SYNTHETIC_MKDOCS + + " - Again:\n - Overview: releases.md\n - 2.3.4: releases/2.3.4.md\n", + ], + ids=["current-missing", "overview-missing", "duplicated"], +) +def test_mkdocs_nav_rejects_unexpected_structure(text: str) -> None: + with pytest.raises(PrepareReleaseError, match="mkdocs.yml: expected exactly one"): + prepare.update_mkdocs_nav(text, CURRENT, TARGET) + + +def test_readme_user_agent_version_is_updated() -> None: + updated = prepare.update_readme(SYNTHETIC_README, CURRENT, TARGET) + + assert updated == SYNTHETIC_README.replace( + "python-mlb-statsapi/2.3.4", "python-mlb-statsapi/2.3.5" + ) + + +@pytest.mark.parametrize( + "text", + [ + SYNTHETIC_README.replace("python-mlb-statsapi/2.3.4", "python-mlb-statsapi/2.3.3"), + SYNTHETIC_README + SYNTHETIC_README, + ], + ids=["stale-version", "duplicated"], +) +def test_readme_rejects_unexpected_structure(text: str) -> None: + with pytest.raises(PrepareReleaseError, match="README.md: expected exactly one"): + prepare.update_readme(text, CURRENT, TARGET) + + +def test_transport_doc_version_link_and_user_agent_are_updated() -> None: + updated = prepare.update_transport_doc(SYNTHETIC_TRANSPORT_DOC, CURRENT, TARGET) + + assert updated == ( + SYNTHETIC_TRANSPORT_DOC.replace("version 2.3.4.", "version 2.3.5.") + .replace( + "[the 2.3.4 release notes](releases/2.3.4.md)", + "[the 2.3.5 release notes](releases/2.3.5.md)", + ) + .replace("python-mlb-statsapi/2.3.4", "python-mlb-statsapi/2.3.5") + ) + # Historical statements about other versions are left alone. + assert "Version 2.3.3 introduced something else." in updated + + +@pytest.mark.parametrize( + ("old", "new"), + [ + ("version 2.3.4.", "version 2.3.3."), + ("[the 2.3.4 release notes](releases/2.3.4.md)", "the release notes"), + ("python-mlb-statsapi/2.3.4\n", "python-mlb-statsapi/\n"), + ], + ids=["version-statement", "notes-link", "user-agent"], +) +def test_transport_doc_rejects_unexpected_structure(old: str, new: str) -> None: + text = SYNTHETIC_TRANSPORT_DOC.replace(old, new) + + with pytest.raises(PrepareReleaseError, match="docs/http-transport.md: expected exactly one"): + prepare.update_transport_doc(text, CURRENT, TARGET) + + +def test_release_validation_tests_track_the_new_current_notes() -> None: + updated = prepare.update_release_validation_tests(SYNTHETIC_RELEASE_TESTS, CURRENT, TARGET) + + assert updated == SYNTHETIC_RELEASE_TESTS.replace( + 'CURRENT_RELEASE_NOTES = RELEASE_NOTES_DIR / "2.3.4.md"', + 'CURRENT_RELEASE_NOTES = RELEASE_NOTES_DIR / "2.3.5.md"', + ).replace( + ' RELEASE_NOTES_DIR / "2.3.3.md",\n)', + ' RELEASE_NOTES_DIR / "2.3.3.md",\n RELEASE_NOTES_DIR / "2.3.4.md",\n)', + ) + assert _classified_release_notes(updated) == ("2.3.5.md", {"2.3.2.md", "2.3.3.md", "2.3.4.md"}) + + +@pytest.mark.parametrize( + ("old", "new", "message"), + [ + ('/ "2.3.4.md"\n\n#', '/ "2.3.3.md"\n\n#', "CURRENT_RELEASE_NOTES"), + ( + ' RELEASE_NOTES_DIR / "2.3.3.md",\n)', + ' RELEASE_NOTES_DIR / "2.3.3.md",\n RELEASE_NOTES_DIR / "2.3.4.md",\n)', + "already listed", + ), + ( + "HISTORICAL_RELEASE_NOTES = (", + "HISTORICAL_RELEASE_NOTES = tuple(", + "HISTORICAL_RELEASE_NOTES tuple", + ), + ], + ids=["current-mismatch", "current-already-historical", "historical-reshaped"], +) +def test_release_validation_tests_reject_unexpected_structure( + old: str, new: str, message: str +) -> None: + text = SYNTHETIC_RELEASE_TESTS.replace(old, new) + assert text != SYNTHETIC_RELEASE_TESTS + + with pytest.raises(PrepareReleaseError, match=message): + prepare.update_release_validation_tests(text, CURRENT, TARGET) + + +# --------------------------------------------------------------------------- +# End-to-end preparation on temporary copies +# --------------------------------------------------------------------------- + + +def test_preparing_a_copy_of_the_real_repository(tmp_path: Path, capsys) -> None: + root = _copy_real_repo(tmp_path) + current = prepare.read_current_version(root) + target = _next_patch(current) + historical_before = { + path.name: path.read_bytes() for path in (root / "docs" / "releases").glob("*.md") + } + + assert prepare.main([str(target), "--project-root", str(root)]) == 0 + + output = capsys.readouterr().out + assert f"Prepared release {target} (previous release {current})" in output + assert f"created docs/releases/{target}.md" in output + assert "Remaining manual steps" in output + assert "python scripts/validate_release.py" in output + + assert prepare.read_current_version(root) == target + + # Existing notes, including the previous current release, stay byte-identical. + for name, content in historical_before.items(): + assert (root / "docs" / "releases" / name).read_bytes() == content + notes = (root / "docs" / "releases" / f"{target}.md").read_text(encoding="utf-8") + assert notes.startswith(f"# python-mlb-statsapi {target}\n") + assert prepare.PLACEHOLDER in notes + + index = (root / "docs" / "releases.md").read_text(encoding="utf-8") + entries = re.findall(r"^- \[([^\]]+)\]", index, re.MULTILINE) + assert entries[:2] == [str(target), str(current)] + + mkdocs = (root / "mkdocs.yml").read_text(encoding="utf-8") + assert ( + " - Overview: releases.md\n" + f" - {target}: releases/{target}.md\n" + f" - {current}: releases/{current}.md\n" + ) in mkdocs + + expected_agent = {f"python-mlb-statsapi/{target}"} + for relative in ("README.md", "docs/http-transport.md"): + text = (root / relative).read_text(encoding="utf-8") + assert set(USER_AGENT_PATTERN.findall(text)) == expected_agent, relative + transport = (root / "docs" / "http-transport.md").read_text(encoding="utf-8") + assert f"current release,\nversion {target}." in transport + assert f"[the {target} release notes](releases/{target}.md)" in transport + + # Every notes file stays classified exactly once, as test_release_validation requires. + test_source = (root / "tests" / "test_release_validation.py").read_text(encoding="utf-8") + current_notes, historical_notes = _classified_release_notes(test_source) + assert current_notes == f"{target}.md" + assert f"{current}.md" in historical_notes + assert {path.name for path in (root / "docs" / "releases").glob("*.md")} == { + current_notes, + *historical_notes, + } + + +@pytest.mark.parametrize("flag", ["--dry-run", "--check"]) +def test_dry_run_reports_changes_without_writing(tmp_path: Path, capsys, flag: str) -> None: + root = _write_synthetic_repo(tmp_path) + before = _snapshot(root) + + assert prepare.main(["2.3.5", flag, "--project-root", str(root)]) == 0 + + assert _snapshot(root) == before + output = capsys.readouterr().out + assert "Nothing was written" in output + assert "--- /dev/null\n+++ b/docs/releases/2.3.5.md" in output + assert '-version = "2.3.4"\n+version = "2.3.5"' in output + assert "create docs/releases/2.3.5.md" in output + + +def test_dry_run_reports_refusal_without_writing(tmp_path: Path, capsys) -> None: + root = _write_synthetic_repo(tmp_path) + (root / "README.md").write_text("# no user agent sentence\n", encoding="utf-8") + before = _snapshot(root) + + assert prepare.main(["2.3.5", "--dry-run", "--project-root", str(root)]) == 1 + + assert _snapshot(root) == before + assert "README.md: expected exactly one" in capsys.readouterr().err + + +@pytest.mark.parametrize("version", ["2.3.4", "2.3.3", "2.3.2", "1.0.0"]) +def test_existing_or_older_versions_are_refused(tmp_path: Path, capsys, version: str) -> None: + root = _write_synthetic_repo(tmp_path) + before = _snapshot(root) + + assert prepare.main([version, "--project-root", str(root)]) == 1 + + assert _snapshot(root) == before + message = capsys.readouterr().err + assert "already exists" in message or "older than the current version 2.3.4" in message + + +def test_rerunning_a_completed_preparation_is_refused(tmp_path: Path, capsys) -> None: + root = _write_synthetic_repo(tmp_path) + assert prepare.main(["2.3.5", "--project-root", str(root)]) == 0 + prepared = _snapshot(root) + capsys.readouterr() + + assert prepare.main(["2.3.5", "--project-root", str(root)]) == 1 + + assert _snapshot(root) == prepared + assert "2.3.5 already exists" in capsys.readouterr().err + + +def test_invalid_version_is_refused_by_the_cli(tmp_path: Path, capsys) -> None: + root = _write_synthetic_repo(tmp_path) + before = _snapshot(root) + + assert prepare.main(["v2.3.5", "--project-root", str(root)]) == 1 + + assert _snapshot(root) == before + assert "invalid version 'v2.3.5'" in capsys.readouterr().err + + +@pytest.mark.parametrize( + "applied", + [ + "pyproject.toml", + "docs/releases/2.3.5.md", + "docs/releases.md", + "mkdocs.yml", + "README.md", + "docs/http-transport.md", + "tests/test_release_validation.py", + ], +) +def test_partially_prepared_release_is_refused(tmp_path: Path, capsys, applied: str) -> None: + """A release started by hand or interrupted is reported, never compounded.""" + root = _write_synthetic_repo(tmp_path) + plan = prepare.plan_release(root, TARGET) + (change,) = [change for change in plan.changes if change.path.as_posix() == applied] + (root / change.path).write_text(change.updated, encoding="utf-8") + before = _snapshot(root) + + assert prepare.main(["2.3.5", "--project-root", str(root)]) == 1 + + assert _snapshot(root) == before + message = capsys.readouterr().err + assert "looks partially prepared" in message + already, missing = message.split("Does not refer to it yet:") + assert applied in already + assert applied not in missing + + +def test_unexpected_structure_writes_nothing(tmp_path: Path, capsys) -> None: + """A late structural failure must not leave earlier files edited.""" + root = _write_synthetic_repo(tmp_path) + tests_file = root / "tests" / "test_release_validation.py" + tests_file.write_text( + SYNTHETIC_RELEASE_TESTS.replace("HISTORICAL_RELEASE_NOTES = (", "HISTORICAL = ("), + encoding="utf-8", + ) + before = _snapshot(root) + + assert prepare.main(["2.3.5", "--project-root", str(root)]) == 1 + + assert _snapshot(root) == before + assert "HISTORICAL_RELEASE_NOTES tuple" in capsys.readouterr().err + + +def test_missing_previous_release_notes_are_refused(tmp_path: Path, capsys) -> None: + root = _write_synthetic_repo(tmp_path) + (root / "docs" / "releases" / "2.3.4.md").unlink() + + assert prepare.main(["2.3.5", "--project-root", str(root)]) == 1 + + assert "docs/releases/2.3.4.md: file not found" in capsys.readouterr().err + assert not (root / "docs" / "releases" / "2.3.5.md").exists() + + +def test_failed_write_restores_every_file(tmp_path: Path, monkeypatch) -> None: + root = _write_synthetic_repo(tmp_path) + before = _snapshot(root) + plan = prepare.plan_release(root, TARGET) + real_write = prepare._write_atomically + calls = {"count": 0} + + def failing_write(path: Path, content: str) -> None: + calls["count"] += 1 + if calls["count"] == 4: + raise OSError(28, "No space left on device", str(path)) + real_write(path, content) + + monkeypatch.setattr(prepare, "_write_atomically", failing_write) + + with pytest.raises(PrepareReleaseError, match="restored"): + prepare.apply_plan(root, plan) + + assert _snapshot(root) == before + + +def test_rewritten_files_keep_their_permissions(tmp_path: Path) -> None: + root = _write_synthetic_repo(tmp_path) + readme = root / "README.md" + readme.chmod(0o644) + + assert prepare.main(["2.3.5", "--project-root", str(root)]) == 0 + + assert readme.stat().st_mode & 0o777 == 0o644 + + +def test_line_endings_and_unrelated_formatting_are_preserved(tmp_path: Path) -> None: + root = _write_synthetic_repo(tmp_path) + readme = root / "README.md" + readme.write_bytes(SYNTHETIC_README.encode("utf-8") + b"\n\n trailing \n") + + assert prepare.main(["2.3.5", "--project-root", str(root)]) == 0 + + assert readme.read_bytes() == ( + SYNTHETIC_README.replace("2.3.4", "2.3.5").encode("utf-8") + b"\n\n trailing \n" + ) + + +# --------------------------------------------------------------------------- +# Real repository (read-only) +# --------------------------------------------------------------------------- + + +def test_real_repository_can_plan_the_next_release() -> None: + """Fails if a release file drifts from the structure the script edits.""" + current = prepare.read_current_version(PROJECT_ROOT) + + plan = prepare.plan_release(PROJECT_ROOT, _next_patch(current)) + + assert len(plan.changes) == 7 + + +@pytest.mark.parametrize( + "path", + [PROJECT_ROOT / "docs" / "releases.md", *sorted(RELEASE_NOTES_DIR.glob("*.md"))], + ids=lambda path: path.name, +) +def test_release_notes_have_no_unfilled_placeholders(path: Path) -> None: + """Notes generated by prepare_release.py must be written before release.""" + assert prepare.PLACEHOLDER not in path.read_text(encoding="utf-8"), ( + f"{path.relative_to(PROJECT_ROOT)} still contains {prepare.PLACEHOLDER} " + "placeholders from scripts/prepare_release.py" + )