from __future__ import annotations import logging import os import re from datetime import datetime from pathlib import Path from typing import Literal import click from serena.constants import REPO_ROOT from serena.util.git import get_git_status log = logging.getLogger(__name__) VersionPart = Literal["major", "minor", "patch"] _VERSION_PATTERN = re.compile(r"^(?P\d+)\.(?P\d+)\.(?P\d+)(\.\w+)?$") _INIT_VERSION_PATTERN = re.compile(r'^(?P__version__\s*=\s*")(?P\d+\.\d+\.\d+(?:\.\w+)?)(?P"\s*)$', re.MULTILINE) _PYPROJECT_VERSION_PATTERN = re.compile( r'(?m)^(?P\[project\]\n(?:.*\n)*?^version\s*=\s*")(?P\d+\.\d+\.\d+(?:\.\w+)?)(?P"\s*)$' ) _UNRELEASED_HEADER = "# Unreleased (main)\n" @click.command() @click.option("--major", "major", is_flag=True, help="Bump the major version and reset minor and patch to 0.") @click.option("--minor", "minor", is_flag=True, help="Bump the minor version and reset patch to 0.") @click.option("--patch", "patch", is_flag=True, help="Bump the patch version.") @click.option("--version", "-v", "target_version", metavar="X.Y.Z", help="Set an explicit version instead of bumping.") @click.option("--dry-run", is_flag=True, help="Show what would change without writing any files.") def bump_version(major: bool, minor: bool, patch: bool, target_version: str | None, dry_run: bool) -> None: git_status = get_git_status() if not git_status.is_clean: raise click.ClickException("Working directory is not clean. Please commit or stash your changes first.") log.info("bump_version called: major=%s, minor=%s, patch=%s, target_version=%s", major, minor, patch, target_version) # determine part to bump version_part = resolve_version_selection(major=major, minor=minor, patch=patch, target_version=target_version) log.info("Resolved version_part=%s", version_part) # bump it (never incrementing patch because it was already updated with the last .dev version) repo_root = find_repo_root() log.info("Repo root: %s", repo_root) new_version = bump_repo_version( repo_root, version_part=version_part, target_version=target_version, dry_run=dry_run, increment_patch=False ) log.info("New version: %s", new_version) # commit and tag for new version if dry_run: click.echo(f"Dry run complete. Version would be bumped to {new_version}") return else: os.system("uv lock") click.echo(f"Bumped version to {new_version}") os.system("git add -u") os.system(f'git commit -m "Release v{new_version}"') os.system(f"git tag v{new_version}") # bump patch and add suffix for next dev iteration new_snapshot_version = bump_repo_version( repo_root, version_part="patch", target_version=None, dry_run=dry_run, target_version_suffix=".dev0", increment_patch=True, ) log.info("New snapshot version: %s", new_snapshot_version) # commit the new snapshot version os.system("uv lock") click.echo(f"Bumped version to {new_snapshot_version}") os.system("git add -u") os.system(f'git commit -m "Set version to v{new_snapshot_version}"') def find_repo_root() -> Path: return Path(REPO_ROOT) def resolve_version_selection(*, major: bool, minor: bool, patch: bool, target_version: str | None) -> VersionPart | None: bump_flags_selected = sum([major, minor, patch]) if target_version is not None and bump_flags_selected > 0: raise click.ClickException("Use either --version or one of --major/--minor/--patch, not both.") if bump_flags_selected > 1: raise click.ClickException("Use only one of --major, --minor, or --patch.") if target_version is not None: validate_version_string(target_version) return None if major: return "major" if minor: return "minor" if patch: return "patch" raise click.ClickException("No version bump selected. Use --major, --minor, --patch or --version.") def bump_repo_version( repo_root: Path, *, version_part: VersionPart | None, target_version: str | None, dry_run: bool = False, target_version_suffix: str | None = None, increment_patch: bool = True, ) -> str: pyproject_path = repo_root / "pyproject.toml" init_path = repo_root / "src" / "serena" / "__init__.py" changelog_path = repo_root / "CHANGELOG.md" log.info("Reading pyproject.toml from %s", pyproject_path) pyproject_text = pyproject_path.read_text(encoding="utf-8") log.info("Reading __init__.py from %s", init_path) init_text = init_path.read_text(encoding="utf-8") log.info("Reading CHANGELOG.md from %s", changelog_path) changelog_text = changelog_path.read_text(encoding="utf-8") log.info("Extracting versions") current_version = extract_version(pyproject_text, _PYPROJECT_VERSION_PATTERN, "pyproject.toml") init_version = extract_version(init_text, _INIT_VERSION_PATTERN, "src/serena/__init__.py") log.info("pyproject.toml version: %s, __init__.py version: %s", current_version, init_version) if current_version != init_version: raise click.ClickException( f"Version mismatch between pyproject.toml and src/serena/__init__.py: {current_version} != {init_version}" ) if target_version is not None: new_version = validate_version_string(target_version) else: if version_part is None: raise click.ClickException("No version target specified.") new_version = increment_version(current_version, version_part, increment_patch=increment_patch) if target_version_suffix is not None: new_version += target_version_suffix log.info("New version will be: %s", new_version) new_pyproject_text = replace_version(pyproject_text, _PYPROJECT_VERSION_PATTERN, new_version, "pyproject.toml") new_init_text = replace_version(init_text, _INIT_VERSION_PATTERN, new_version, "src/serena/__init__.py") file_changes: list[tuple[Path, str, str]] = [ (pyproject_path, pyproject_text, new_pyproject_text), (init_path, init_text, new_init_text), ] # update changelog only for actual releases (not -dev versions with suffixes) if target_version_suffix is None: new_changelog_text = update_changelog(changelog_text, new_version) file_changes.append((changelog_path, changelog_text, new_changelog_text)) if dry_run: for path, old, new in file_changes: if old != new: rel = path.relative_to(repo_root) click.echo(f"\n--- {rel}") _print_diff(old, new) else: for path, _old, new in file_changes: log.info("Writing %s", path) path.write_text(new, encoding="utf-8") log.info("All files written successfully") return new_version def _print_diff(old: str, new: str) -> None: import difflib diff = difflib.unified_diff(old.splitlines(), new.splitlines(), lineterm="") # Skip the --- / +++ header lines from unified_diff lines = list(diff) for line in lines[2:]: click.echo(line) def extract_version(text: str, pattern: re.Pattern[str], file_label: str) -> str: """ Extracts the core version Major.Minor.Patch in the given text :param text: :param pattern: the pattern to search for :param file_label: file reference for error messages :return: the core version """ match = pattern.search(text) if match is None: raise click.ClickException(f"Could not find version in {file_label}.") return match.group("version") def replace_version(text: str, pattern: re.Pattern[str], new_version: str, file_label: str) -> str: match = pattern.search(text) if match is None: raise click.ClickException(f"Could not update version in {file_label}.") return f"{text[: match.start('version')]}{new_version}{text[match.end('version') :]}" def increment_version(version: str, version_part: VersionPart, increment_patch: bool) -> str: match = _VERSION_PATTERN.fullmatch(version) if match is None: raise click.ClickException(f"Unsupported version format: {version}") major = int(match.group("major")) minor = int(match.group("minor")) patch = int(match.group("patch")) if version_part == "major": return f"{major + 1}.0.0" if version_part == "minor": return f"{major}.{minor + 1}.0" elif version_part == "patch": if increment_patch: patch += 1 return f"{major}.{minor}.{patch}" else: raise ValueError(version_part) def validate_version_string(version: str) -> str: if _VERSION_PATTERN.fullmatch(version) is None: raise click.ClickException(f"Unsupported version format: {version}") return version def update_changelog(changelog_text: str, new_version: str) -> str: if not changelog_text.startswith(_UNRELEASED_HEADER): raise click.ClickException("CHANGELOG.md must start with '# Unreleased (main)'.") next_header_index = changelog_text.find("\n# ", len(_UNRELEASED_HEADER)) unreleased_section_end = len(changelog_text) if next_header_index == -1 else next_header_index + 1 unreleased_section = changelog_text[:unreleased_section_end] remaining_text = changelog_text[unreleased_section_end:] unreleased_body = unreleased_section[len(_UNRELEASED_HEADER) :] intro, unreleased_entries = split_unreleased_body(unreleased_body) date_str = datetime.now().strftime("%Y-%m-%d") updated_section = _UNRELEASED_HEADER + intro + f"# v{new_version} ({date_str})\n" if unreleased_entries.strip(): updated_section += "\n" + unreleased_entries.lstrip("\n") else: updated_section += "\n" if remaining_text: updated_section += remaining_text.lstrip("\n") return updated_section def split_unreleased_body(unreleased_body: str) -> tuple[str, str]: """Go until first line with content, the intro will be until the end of that line. :return: changelog_intro, changelog_body """ lines = unreleased_body.splitlines(keepends=True) intro_end = 0 seen_content = False for index, line in enumerate(lines): intro_end = index + 1 if line.strip(): seen_content = True continue if seen_content: break if not seen_content: raise click.ClickException("Could not determine the introduction paragraph in CHANGELOG.md.") return "".join(lines[:intro_end]), "".join(lines[intro_end:]) if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG, format="%(levelname)s %(name)s: %(message)s") log.info("Script starting") bump_version()