#!/usr/bin/env uv run python """ Honcho Version Update Script This script helps update version numbers across the Honcho repository. It handles the main API, Python SDK, and TypeScript SDK in a single operation. """ import json import os import re import subprocess import sys import tempfile from datetime import datetime class VersionUpdater: def __init__(self, base_path: str): self.base_path = base_path def get_current_versions(self) -> dict[str, str]: """Get current version numbers from the repository.""" versions = {} # Main API version with open(os.path.join(self.base_path, "pyproject.toml")) as f: for line in f: if line.startswith("version = "): versions["api"] = line.split('"')[1] break # Python SDK version with open(os.path.join(self.base_path, "sdks/python/pyproject.toml")) as f: for line in f: if line.startswith("version = "): versions["python_sdk"] = line.split('"')[1] break # TypeScript SDK version with open(os.path.join(self.base_path, "sdks/typescript/package.json")) as f: data = json.load(f) versions["typescript_sdk"] = data["version"] return versions def get_all_versions_from_editor( self, current_versions: dict[str, str] ) -> dict[str, dict[str, str]]: """Open editor to get all version updates at once.""" template = f"""# Honcho Version Update # Enter new version numbers below. Leave blank to skip updating that component. # # MAIN API # Current version: {current_versions["api"]} API_VERSION= # API Changelog (use ### for section headers: Added, Changed, Fixed, etc.) # PYTHON SDK # Current version: {current_versions["python_sdk"]} PYTHON_VERSION= # Python SDK Changelog # TYPESCRIPT SDK # Current version: {current_versions["typescript_sdk"]} TYPESCRIPT_VERSION= # TypeScript SDK Changelog # Lines starting with # are comments and will be ignored """ with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: f.write(template) temp_file = f.name # Open in vim subprocess.call(["vim", temp_file]) # Parse the file with open(temp_file) as f: content = f.read() os.unlink(temp_file) # Extract all versions and changelogs updates = {} # Parse API version api_match = re.search(r"^API_VERSION=(.*)$", content, re.MULTILINE) if api_match and api_match.group(1).strip(): changelog = self._extract_changelog_between( content, "API_VERSION=", "PYTHON_VERSION=" ) updates["api"] = { "version": api_match.group(1).strip(), "changelog": self._clean_changelog_sections(changelog), } # Parse Python SDK version python_match = re.search(r"^PYTHON_VERSION=(.*)$", content, re.MULTILINE) if python_match and python_match.group(1).strip(): changelog = self._extract_changelog_between( content, "PYTHON_VERSION=", "TYPESCRIPT_VERSION=" ) updates["python_sdk"] = { "version": python_match.group(1).strip(), "changelog": self._clean_changelog_sections(changelog), } # Parse TypeScript SDK version ts_match = re.search(r"^TYPESCRIPT_VERSION=(.*)$", content, re.MULTILINE) if ts_match and ts_match.group(1).strip(): changelog = self._extract_changelog_between( content, "TYPESCRIPT_VERSION=", None ) updates["typescript_sdk"] = { "version": ts_match.group(1).strip(), "changelog": self._clean_changelog_sections(changelog), } return updates def _extract_changelog_between( self, content: str, start_marker: str, end_marker: str | None ) -> str: """Extract changelog content between markers.""" lines = content.split("\n") changelog_lines = [] in_section = False for line in lines: if start_marker in line: in_section = True continue if end_marker and end_marker in line: break if in_section: # Skip comment lines but keep markdown headers if line.strip().startswith("# ") and not line.strip().startswith("###"): continue if line.strip() == "#": continue changelog_lines.append(line) # Remove trailing empty lines while changelog_lines and not changelog_lines[-1].strip(): changelog_lines.pop() return "\n".join(changelog_lines).strip() def _clean_changelog_sections(self, changelog: str) -> str: """Remove empty changelog sections.""" sections = ["Added", "Changed", "Fixed", "Deprecated", "Removed", "Security"] lines = changelog.split("\n") cleaned_lines = [] current_section = None section_has_content = False section_start_idx = -1 for i, line in enumerate(lines): # Check if this is a section header is_section_header = False for section in sections: if line.strip() == f"### {section}": # If we have a previous section, decide whether to keep it if ( current_section is not None and section_start_idx != -1 and section_has_content ): # Keep the section cleaned_lines.extend(lines[section_start_idx:i]) # Start tracking new section current_section = section section_start_idx = i section_has_content = False is_section_header = True break if ( not is_section_header and current_section is not None and line.strip() and not line.strip().startswith("#") ): section_has_content = True # Handle the last section if ( current_section is not None and section_start_idx != -1 and section_has_content ): cleaned_lines.extend(lines[section_start_idx:]) # If no sections were found, return original if not cleaned_lines and "###" not in changelog: return changelog return "\n".join(cleaned_lines).strip() def update_all( self, updates: dict[str, dict[str, str]], current_versions: dict[str, str] ): """Update all components that have new versions.""" # Update API if specified if "api" in updates: print(f"\nUpdating API version to {updates['api']['version']}...") self.update_api_version( updates["api"]["version"], updates["api"]["changelog"] ) # Update compatibility guide for new API version self._update_compatibility_guide_for_api( updates["api"]["version"], updates.get("python_sdk", {}).get( "version", current_versions["python_sdk"] ), updates.get("typescript_sdk", {}).get( "version", current_versions["typescript_sdk"] ), ) # Update Python SDK if specified if "python_sdk" in updates: print( f"Updating Python SDK version to {updates['python_sdk']['version']}..." ) self.update_python_sdk_version( updates["python_sdk"]["version"], updates["python_sdk"]["changelog"] ) # Update TypeScript SDK if specified if "typescript_sdk" in updates: print( f"Updating TypeScript SDK version to {updates['typescript_sdk']['version']}..." ) self.update_typescript_sdk_version( updates["typescript_sdk"]["version"], updates["typescript_sdk"]["changelog"], ) def update_api_version(self, new_version: str, changelog: str): """Update main API version across all files.""" updates = [ # Simple replacements { "file": "pyproject.toml", "pattern": r'version = "[^"]*"', "replacement": f'version = "{new_version}"', }, { "file": "src/main.py", "pattern": r'version="[^"]*"', "replacement": f'version="{new_version}"', }, { "file": "README.md", "pattern": r"Version-\d+\.\d+\.\d+-blue", "replacement": f"Version-{new_version}-blue", }, # docs.json is handled separately to only update same major version ] # Apply simple updates for update in updates: file_path = os.path.join(self.base_path, update["file"]) with open(file_path) as f: content = f.read() content = re.sub(update["pattern"], update["replacement"], content) with open(file_path, "w") as f: f.write(content) # Update docs.json - only update same major version self._update_docs_json(new_version) # Update CHANGELOG.md (prepend new entry) self._update_changelog_md(new_version, changelog) # Update docs changelog (MDX format) self._update_docs_changelog(new_version, changelog, "api") def update_python_sdk_version(self, new_version: str, changelog: str): """Update Python SDK version.""" updates = [ { "file": "sdks/python/pyproject.toml", "pattern": r'version = "[^"]*"', "replacement": f'version = "{new_version}"', }, { "file": "sdks/python/src/honcho/__init__.py", "pattern": r'__version__ = "[^"]*"', "replacement": f'__version__ = "{new_version}"', }, ] for update in updates: file_path = os.path.join(self.base_path, update["file"]) with open(file_path) as f: content = f.read() content = re.sub(update["pattern"], update["replacement"], content) with open(file_path, "w") as f: f.write(content) # Update SDK's own CHANGELOG.md self._update_sdk_changelog(new_version, changelog, "sdks/python/CHANGELOG.md") # Update docs changelog self._update_docs_changelog(new_version, changelog, "python_sdk") # Update compatibility guide SDK version self._update_compatibility_guide("python", new_version) def update_typescript_sdk_version(self, new_version: str, changelog: str): """Update TypeScript SDK version.""" # Update package.json file_path = os.path.join(self.base_path, "sdks/typescript/package.json") with open(file_path) as f: data = json.load(f) data["version"] = new_version with open(file_path, "w") as f: json.dump(data, f, indent=2) f.write("\n") # Add trailing newline # Update SDK's own CHANGELOG.md self._update_sdk_changelog( new_version, changelog, "sdks/typescript/CHANGELOG.md" ) # Update docs changelog self._update_docs_changelog(new_version, changelog, "typescript_sdk") # Update compatibility guide SDK version self._update_compatibility_guide("typescript", new_version) def _update_docs_json(self, new_version: str): """Update docs.json - only update versions with same major version.""" file_path = os.path.join(self.base_path, "docs/docs.json") with open(file_path) as f: data = json.load(f) # Get major version of new version new_major = new_version.split(".")[0] # Update only matching major versions if "navigation" in data and "versions" in data["navigation"]: for version_entry in data["navigation"]["versions"]: if "version" in version_entry: current_version = version_entry["version"].lstrip("v") current_major = current_version.split(".")[0] if current_major == new_major: version_entry["version"] = f"v{new_version}" with open(file_path, "w") as f: json.dump(data, f, indent=2) f.write("\n") def _update_sdk_changelog(self, version: str, changelog: str, relative_path: str): """Update an SDK's CHANGELOG.md file.""" file_path = os.path.join(self.base_path, relative_path) with open(file_path) as f: content = f.read() # Find the position after the header header_end = content.find("\n## [") if header_end == -1: header_end = content.find("\n##") if header_end == -1: # No existing entries, add after title section header_end = content.find("and this project adheres to") if header_end != -1: header_end = content.find("\n", header_end) # Create new entry with proper formatting date = datetime.now().strftime("%Y-%m-%d") # Ensure changelog content is properly formatted if changelog.strip(): formatted_changelog = changelog.strip() else: formatted_changelog = "### Changed\n\n- Updated version" new_entry = f"\n\n## [{version}] - {date}\n\n{formatted_changelog}\n" # Insert the new entry new_content = content[:header_end] + new_entry + content[header_end:] with open(file_path, "w") as f: f.write(new_content) def _update_changelog_md(self, version: str, changelog: str): """Update the main CHANGELOG.md file.""" file_path = os.path.join(self.base_path, "CHANGELOG.md") with open(file_path) as f: content = f.read() # Find the position after the header header_end = content.find("\n## [") if header_end == -1: header_end = content.find("\n##") if header_end == -1: # No existing entries, add after title header_end = content.find("\n", content.find("# Changelog")) # Create new entry with proper formatting date = datetime.now().strftime("%Y-%m-%d") # Ensure changelog content is properly formatted if changelog.strip(): formatted_changelog = changelog.strip() else: formatted_changelog = "### Changed\n\n- Updated version" new_entry = f"\n\n## [{version}] - {date}\n\n{formatted_changelog}\n" # Insert the new entry new_content = content[:header_end] + new_entry + content[header_end:] with open(file_path, "w") as f: f.write(new_content) def _update_docs_changelog(self, version: str, changelog: str, component: str): """Update the docs/changelog/introduction.mdx file.""" file_path = os.path.join(self.base_path, "docs/changelog/introduction.mdx") with open(file_path) as f: content = f.read() if component == "api": # Find the Honcho API tab content tab_start = content.find('') if tab_start == -1: return # Find where to insert (after the Tab opening) insert_pos = content.find("\n", tab_start) + 1 # Format the changelog with proper indentation indented_changelog = "\n".join( " " + line if line.strip() else "" for line in changelog.strip().split("\n") ) # Create the new update entry new_entry = f""" {indented_changelog} """ # Remove (Current) from previous entries # Use a more specific pattern to avoid replacing in other contexts content = re.sub( r'(' tab_start = content.find(tab_pattern) if tab_start == -1: return content # Format the changelog with proper indentation indented_changelog = "\n".join( " " + line if line.strip() else "" for line in changelog.strip().split("\n") ) # Create new update entry new_entry = f""" {indented_changelog} """ # Remove (Current) from previous SDK entries # More precise pattern to avoid issues pattern = rf'(.*?)' content = re.sub(pattern, r"\1\2", content, flags=re.DOTALL) # Find where to insert the new entry # Look for the line after the SDK link (e.g., [Python SDK](...)) tab_pos = content.find(tab_pattern) if tab_pos == -1: return content # Find the end of the SDK link line link_start = content.find("[", tab_pos) if link_start != -1: link_end = content.find("\n", link_start) if link_end != -1: insert_pos = link_end + 1 content = content[:insert_pos] + new_entry + content[insert_pos:] return content def _update_compatibility_guide(self, sdk_type: str, version: str): """Update the compatibility guide with new SDK version.""" file_path = os.path.join( self.base_path, "docs/changelog/compatibility-guide.mdx" ) with open(file_path) as f: content = f.read() if sdk_type == "typescript": # Update in the card content = re.sub( r'(