""" CHANGELOG.md editor for programmatic updates. This module provides tools for editing CHANGELOG.md files following the Keep a Changelog format. """ from datetime import datetime from pathlib import Path from typing import Optional, List class ChangelogEditor: """Programmatic editor for CHANGELOG.md files.""" def __init__(self, changelog_path: Optional[Path] = None): """Initialize changelog editor. Args: changelog_path: Path to CHANGELOG.md file """ self.changelog_path = changelog_path or Path.cwd() / 'CHANGELOG.md' def create_version_section(self, version: str, date: Optional[str] = None) -> bool: """Create new version section and move Unreleased content. Args: version: Version number (e.g., "0.11.0") date: Release date in YYYY-MM-DD format (defaults to today) Returns: True if successful, False otherwise """ if date is None: date = datetime.now().strftime("%Y-%m-%d") # Validate version format version_clean = version.lstrip('v') if not self.changelog_path.exists(): print(f"❌ CHANGELOG.md not found at {self.changelog_path}") return False try: with open(self.changelog_path) as f: lines = f.readlines() # Find Unreleased section unreleased_idx = None for i, line in enumerate(lines): if line.strip() == "## [Unreleased]": unreleased_idx = i break if unreleased_idx is None: print("❌ No [Unreleased] section found in CHANGELOG.md") return False # Find next version section or end of Unreleased content next_section_idx = None for i in range(unreleased_idx + 1, len(lines)): if lines[i].startswith("## [") and not lines[i].startswith("## [Unreleased]"): next_section_idx = i break # Extract Unreleased content (skip the header line and first blank line) if next_section_idx: unreleased_content = lines[unreleased_idx + 1:next_section_idx] else: unreleased_content = lines[unreleased_idx + 1:] # Check if there's actual content to move has_content = any(line.strip() and not line.strip().startswith('#') for line in unreleased_content) if not has_content: print(f"⚠️ [Unreleased] section is empty. Add changes before creating release section.") print(f"💡 Tip: You can still create the section, but it will be empty.") # Create new version section with moved content new_section_lines = [ f"## [{version_clean}] - {date}\n", ] # Add the unreleased content (preserving structure) new_section_lines.extend(unreleased_content) # Ensure proper spacing after new section if new_section_lines and not new_section_lines[-1].endswith('\n\n'): if not new_section_lines[-1].endswith('\n'): new_section_lines[-1] += '\n' new_section_lines.append('\n') # Build new file content # Keep everything up to and including Unreleased header new_lines = lines[:unreleased_idx + 1] # Add blank line after Unreleased header new_lines.append('\n') # Add the new version section new_lines.extend(new_section_lines) # Add remaining sections (if any) if next_section_idx: new_lines.extend(lines[next_section_idx:]) # Write back with open(self.changelog_path, 'w') as f: f.writelines(new_lines) print(f"✅ Created section [{version_clean}] - {date} in CHANGELOG.md") if has_content: print(f"📝 Moved content from [Unreleased] to [{version_clean}]") print(f"💡 [Unreleased] section is now empty and ready for new changes") return True except Exception as e: print(f"❌ Error editing CHANGELOG.md: {e}") return False def get_version_content(self, version: str) -> Optional[List[str]]: """Extract content for a specific version section. Args: version: Version number to extract (e.g., "0.10.0") Returns: List of lines in the version section, or None if not found """ version_clean = version.lstrip('v') if not self.changelog_path.exists(): return None try: with open(self.changelog_path) as f: lines = f.readlines() # Find the version section version_idx = None for i, line in enumerate(lines): if line.strip().startswith(f"## [{version_clean}]"): version_idx = i break if version_idx is None: return None # Find next section next_section_idx = None for i in range(version_idx + 1, len(lines)): if lines[i].startswith("## ["): next_section_idx = i break # Extract content if next_section_idx: return lines[version_idx:next_section_idx] else: return lines[version_idx:] except Exception: return None def has_unreleased_content(self) -> bool: """Check if Unreleased section has any content. Returns: True if Unreleased section has content, False otherwise """ if not self.changelog_path.exists(): return False try: with open(self.changelog_path) as f: lines = f.readlines() # Find Unreleased section unreleased_idx = None for i, line in enumerate(lines): if line.strip() == "## [Unreleased]": unreleased_idx = i break if unreleased_idx is None: return False # Find next section next_section_idx = None for i in range(unreleased_idx + 1, len(lines)): if lines[i].startswith("## ["): next_section_idx = i break # Check content if next_section_idx: content = lines[unreleased_idx + 1:next_section_idx] else: content = lines[unreleased_idx + 1:] # Check if there's actual content (not just whitespace or section headers) return any(line.strip() and not line.strip().startswith('#') for line in content) except Exception: return False