Source code for python_release_master.core.release

"""Release management utilities for Python Release Master."""

import re
import subprocess
from pathlib import Path
from typing import List, Optional

from rich.console import Console

from python_release_master.core.ai import generate_changelog_with_ai
from python_release_master.core.config import Config

console = Console()


[docs] def create_release( bump_type: str, title: str, description: Optional[str], config: Config, skip_steps: Optional[List[str]] = None, ) -> None: """Create a new release.""" skip_steps = skip_steps or [] # Update version if "version" not in skip_steps: bump_version(bump_type, config.version_files) # Generate changelog if "changelog" not in skip_steps: changelog = generate_changelog(config) if description: changelog = f"{description}\n\n{changelog}" # Build package if "build" not in skip_steps: build_package() # Publish to PyPI if "publish" not in skip_steps: publish_to_pypi() # Create git tag and release if "git" not in skip_steps: create_git_release(title, changelog if "changelog" not in skip_steps else description)
[docs] def bump_version(bump_type: str, version_files: List[str]) -> None: """Bump version in all specified files.""" console.print(f"Bumping {bump_type} version...") if bump_type not in ["major", "minor", "patch"]: raise ValueError(f"Invalid bump type: {bump_type}") # Get current version version = get_current_version(version_files[0]) major, minor, patch = map(int, version.split(".")) # Calculate new version if bump_type == "major": new_version = f"{major + 1}.0.0" elif bump_type == "minor": new_version = f"{major}.{minor + 1}.0" else: # patch new_version = f"{major}.{minor}.{patch + 1}" # Update version in all files for file in version_files: update_version_in_file(file, new_version) console.print(f"[green]Version bumped to {new_version}[/green]")
def get_current_version(version_file: str) -> str: """Extract current version from a file.""" with open(version_file) as f: content = f.read() # Try different version patterns patterns = [ r'version\s*=\s*["\']([^"\']+)["\']', # pyproject.toml r'__version__\s*=\s*["\']([^"\']+)["\']', # __init__.py r'VERSION\s*=\s*["\']([^"\']+)["\']', # Other common formats ] for pattern in patterns: if match := re.search(pattern, content): return match.group(1) raise ValueError(f"Could not find version in {version_file}") def update_version_in_file(file_path: str, new_version: str) -> None: """Update version string in a file.""" with open(file_path) as f: content = f.read() # Update version using appropriate pattern patterns = { r'version\s*=\s*["\']([^"\']+)["\']': f'version = "{new_version}"', r'__version__\s*=\s*["\']([^"\']+)["\']': f'__version__ = "{new_version}"', r'VERSION\s*=\s*["\']([^"\']+)["\']': f'VERSION = "{new_version}"', } for pattern, replacement in patterns.items(): if re.search(pattern, content): content = re.sub(pattern, replacement, content) break else: raise ValueError(f"Could not find version pattern in {file_path}") with open(file_path, "w") as f: f.write(content) def generate_changelog(config: Config) -> str: """Generate changelog using AI if enabled.""" console.print("Generating changelog...") if config.changelog.ai_powered: try: changelog = generate_changelog_with_ai(config.changelog.sections) console.print("[green]Changelog generated successfully[/green]") return changelog except Exception as e: console.print(f"[yellow]AI-powered changelog generation failed: {str(e)}[/yellow]") console.print("[yellow]Falling back to commit list[/yellow]") # Fallback to simple commit list commits = subprocess.run( ["git", "log", "--pretty=format:- %s", "HEAD^..HEAD"], check=True, capture_output=True, text=True, ).stdout return f"## Changes\n\n{commits}" def build_package() -> None: """Build the Python package.""" console.print("Building package...") try: subprocess.run( ["python", "-m", "build"], check=True, capture_output=True, text=True, ) console.print("[green]Package built successfully[/green]") except subprocess.CalledProcessError as e: raise ValueError(f"Package build failed:\n{e.stdout}\n{e.stderr}") def publish_to_pypi() -> None: """Publish the package to PyPI.""" console.print("Publishing to PyPI...") try: subprocess.run( ["python", "-m", "twine", "upload", "dist/*"], check=True, capture_output=True, text=True, env={"TWINE_USERNAME": "__token__", "TWINE_PASSWORD": "${PYPI_TOKEN}"}, ) console.print("[green]Package published successfully[/green]") except subprocess.CalledProcessError as e: raise ValueError(f"Package upload failed:\n{e.stdout}\n{e.stderr}") def create_git_release(title: str, description: Optional[str] = None) -> None: """Create a Git tag and release.""" console.print("Creating Git release...") try: # Create and push tag version = get_current_version("pyproject.toml") subprocess.run( ["git", "tag", "-a", f"v{version}", "-m", title], check=True, capture_output=True, text=True, ) subprocess.run( ["git", "push", "origin", f"v{version}"], check=True, capture_output=True, text=True, ) # Create GitHub release if gh CLI is available if description: try: subprocess.run( [ "gh", "release", "create", f"v{version}", "--title", title, "--notes", description, ], check=True, capture_output=True, text=True, ) console.print("[green]GitHub release created successfully[/green]") except subprocess.CalledProcessError: console.print("[yellow]GitHub CLI not available, skipping release creation[/yellow]") console.print("[green]Git release created successfully[/green]") except subprocess.CalledProcessError as e: raise ValueError(f"Git release failed:\n{e.stdout}\n{e.stderr}")