#!/usr/bin/env python3 """ MarkiTect Release Management Tool (setuptools-scm version) This simplified script works with setuptools-scm for automatic version management. Versions are automatically derived from git tags - no manual version bumping needed. Usage: python release.py [command] [options] Commands: status Show current release status validate Validate current state for release tag Create git tag for version (e.g., v0.8.0) build Build release packages publish Complete release workflow (tag + build + distribute) upload Upload packages to Gitea registry registry Show Gitea package registry information Options: --version VERSION Git tag version (e.g., 0.8.0, 1.0.0-rc1) --dry-run Show what would be done without making changes --force Force operation even with warnings --to-gitea Upload to Gitea package registry """ import subprocess import argparse import sys from pathlib import Path from datetime import datetime from typing import Dict, List, Optional, Tuple try: from gitea.package_registry import GiteaPackageRegistry GITEA_AVAILABLE = True except ImportError: GITEA_AVAILABLE = False class SimpleReleaseManager: """Simplified release manager using setuptools-scm.""" def __init__(self, dry_run=False, force=False): self.dry_run = dry_run self.force = force self.project_root = Path(__file__).parent.absolute() def run_command(self, cmd: List[str], capture=True, check=True, skip_dry_run=False) -> subprocess.CompletedProcess: """Run a command with optional dry-run support.""" if self.dry_run and not skip_dry_run: print(f"[DRY RUN] Would run: {' '.join(cmd)}") return subprocess.CompletedProcess(cmd, 0, "", "") return subprocess.run(cmd, capture_output=capture, text=True, check=check, cwd=self.project_root) def get_current_version_from_scm(self) -> str: """Get current version using setuptools-scm.""" try: result = self.run_command(['python', '-m', 'setuptools_scm'], skip_dry_run=True) return result.stdout.strip() except subprocess.CalledProcessError: return "unknown" def get_git_status(self) -> Dict[str, any]: """Get current git repository status.""" try: # Get current branch branch_result = self.run_command(['git', 'branch', '--show-current'], skip_dry_run=True) current_branch = branch_result.stdout.strip() # Check for uncommitted changes status_result = self.run_command(['git', 'status', '--porcelain'], skip_dry_run=True) has_changes = bool(status_result.stdout.strip()) # Get latest commit commit_result = self.run_command(['git', 'rev-parse', '--short', 'HEAD'], skip_dry_run=True) latest_commit = commit_result.stdout.strip() # Get latest tag try: tag_result = self.run_command(['git', 'describe', '--tags', '--abbrev=0'], skip_dry_run=True) latest_tag = tag_result.stdout.strip() except subprocess.CalledProcessError: latest_tag = None return { 'is_repo': True, 'branch': current_branch, 'has_changes': has_changes, 'latest_commit': latest_commit, 'latest_tag': latest_tag } except subprocess.CalledProcessError: return {'is_repo': False} def validate_release_state(self) -> Tuple[bool, List[str]]: """Validate that the repository is ready for release.""" issues = [] git_status = self.get_git_status() if not git_status['is_repo']: issues.append("Not in a git repository") else: if git_status['has_changes'] and not self.force: issues.append("Repository has uncommitted changes") if git_status['branch'] != 'main' and not self.force: issues.append(f"Not on main branch (currently on {git_status['branch']})") return len(issues) == 0, issues def create_git_tag(self, version: str, message: str = None): """Create and push git tag.""" if not version.startswith('v'): tag_name = f"v{version}" else: tag_name = version tag_message = message or f"Release {version}" print(f"šŸ·ļø Creating git tag {tag_name}") # Create annotated tag self.run_command(['git', 'tag', '-a', tag_name, '-m', tag_message]) print(f"āœ… Tag {tag_name} created") # Optionally push tag (can be done manually) try: print(f"šŸ“¤ Pushing tag to origin...") self.run_command(['git', 'push', 'origin', tag_name]) print(f"āœ… Tag pushed to origin") except subprocess.CalledProcessError as e: print(f"āš ļø Could not push tag to origin: {e}") print("You can push it manually with: git push origin " + tag_name) def build_packages(self): """Build release packages using setuptools-scm.""" print(f"šŸ“¦ Building packages (version will be auto-determined by setuptools-scm)") # Clean previous builds for pattern in ['build', 'dist', '*.egg-info']: try: self.run_command(['rm', '-rf', pattern]) except subprocess.CalledProcessError: pass # Build source distribution and wheel print("Building packages...") self.run_command(['python', '-m', 'build'], capture=False) print("āœ… Packages built successfully") def show_status(self): """Show current release status.""" print("šŸ” MarkiTect Release Status (setuptools-scm)") print("=" * 60) # Get version from setuptools-scm scm_version = self.get_current_version_from_scm() print(f"Current Version (setuptools-scm): {scm_version}") git_status = self.get_git_status() if git_status['is_repo']: print(f"Git Branch: {git_status['branch']}") print(f"Latest Commit: {git_status['latest_commit']}") print(f"Latest Tag: {git_status['latest_tag'] or 'None'}") print(f"Uncommitted Changes: {'Yes' if git_status['has_changes'] else 'No'}") else: print("Git Repository: Not available") # Check build tools print("\nBuild Tools:") try: self.run_command(['python', '-m', 'build', '--help'], skip_dry_run=True) print("āœ… build module available") except (subprocess.CalledProcessError, FileNotFoundError): print("āŒ build module not available (pip install build)") try: self.run_command(['python', '-m', 'setuptools_scm'], skip_dry_run=True) print("āœ… setuptools-scm available") except (subprocess.CalledProcessError, FileNotFoundError): print("āŒ setuptools-scm not available") # Check existing packages dist_dir = self.project_root / "dist" if dist_dir.exists(): packages = list(dist_dir.glob("*")) print(f"\nExisting Packages: {len(packages)} files in dist/") for pkg in packages[-5:]: # Show last 5 print(f" - {pkg.name}") else: print("\nExisting Packages: None") def publish_release(self, version: str): """Complete release workflow.""" print(f"šŸš€ Publishing release {version}") # Validate state is_valid, issues = self.validate_release_state() if not is_valid and not self.force: print("āŒ Cannot publish release:") for issue in issues: print(f" - {issue}") return False # Create git tag (this determines the version for setuptools-scm) self.create_git_tag(version) # Build packages (setuptools-scm will use the tag for version) self.build_packages() print(f"āœ… Release {version} completed!") print("šŸ“¦ Packages available in dist/") print(f"šŸ·ļø Git tag v{version} created") return True def upload_to_gitea(self, dry_run: bool = False) -> bool: """Upload packages to Gitea package registry.""" if not GITEA_AVAILABLE: print("āŒ Gitea package registry not available (missing gitea module)") return False try: registry = GiteaPackageRegistry() print(f"šŸ“” Uploading to Gitea registry: {registry.pypi_registry_url}") # Find built packages dist_dir = self.project_root / "dist" if not dist_dir.exists(): print("āŒ No dist/ directory found. Run 'build' command first.") return False wheel_files = list(dist_dir.glob("*.whl")) sdist_files = list(dist_dir.glob("*.tar.gz")) if not wheel_files and not sdist_files: print("āŒ No package files found in dist/") return False # Upload each package success = True for wheel_file in wheel_files: # Find matching sdist sdist_file = None for sdist in sdist_files: if wheel_file.stem.split('-')[0] == sdist.stem.split('-')[0]: sdist_file = sdist break if not registry.upload_package(wheel_file, sdist_file, dry_run=dry_run): success = False # Upload any remaining sdists uploaded_sdists = [] for wheel_file in wheel_files: for sdist in sdist_files: if wheel_file.stem.split('-')[0] == sdist.stem.split('-')[0]: uploaded_sdists.append(sdist) for sdist_file in sdist_files: if sdist_file not in uploaded_sdists: if not registry.upload_package(sdist_file, dry_run=dry_run): success = False return success except Exception as e: print(f"āŒ Upload to Gitea failed: {e}") return False def show_gitea_registry_info(self): """Show Gitea package registry information.""" if not GITEA_AVAILABLE: print("āŒ Gitea package registry not available (missing gitea module)") return try: registry = GiteaPackageRegistry() info = registry.get_registry_info() print("šŸ“¦ Gitea Package Registry Information") print("=" * 50) print(f"Gitea URL: {info['gitea_url']}") print(f"Repository: {info['repo_owner']}/{info['repo_name']}") print(f"PyPI Registry URL: {info['pypi_registry_url']}") print(f"Package List URL: {info['package_list_url']}") print(f"Authentication Configured: {'āœ…' if info['auth_configured'] else 'āŒ'}") print(f"Authentication Valid: {'āœ…' if info['auth_valid'] else 'āŒ' if info['auth_configured'] else 'N/A'}") if info['auth_configured']: try: packages = registry.list_packages() print(f"\nExisting Packages: {len(packages)}") for package in packages[:5]: # Show first 5 print(f" - {package.get('name', 'unknown')} (type: {package.get('type', 'unknown')})") except Exception as e: print(f"\nError listing packages: {e}") else: print("\nā„¹ļø Set GITEA_API_TOKEN environment variable for package management") except Exception as e: print(f"āŒ Error getting registry info: {e}") def publish_with_gitea(self, version: str, dry_run: bool = False) -> bool: """Complete release workflow including Gitea upload.""" if not self.publish_release(version): return False if not self.upload_to_gitea(dry_run=dry_run): print("āš ļø Release completed but Gitea upload failed") return False print("šŸŽ‰ Complete release with Gitea upload successful!") return True def main(): parser = argparse.ArgumentParser( description="MarkiTect Release Management Tool (setuptools-scm)", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__.split('\n\n')[1] ) parser.add_argument('command', choices=['status', 'validate', 'tag', 'build', 'publish', 'upload', 'registry'], help='Release command to execute') parser.add_argument('--version', type=str, help='Target version for git tag (e.g., 0.8.0)') parser.add_argument('--dry-run', action='store_true', help='Show what would be done') parser.add_argument('--force', action='store_true', help='Force operation despite warnings') parser.add_argument('--to-gitea', action='store_true', help='Include Gitea package registry upload') args = parser.parse_args() manager = SimpleReleaseManager(dry_run=args.dry_run, force=args.force) try: if args.command == 'status': manager.show_status() elif args.command == 'validate': is_valid, issues = manager.validate_release_state() if is_valid: print("āœ… Repository is ready for release") else: print("āŒ Release validation failed:") for issue in issues: print(f" - {issue}") sys.exit(1) elif args.command == 'tag': if not args.version: print("āŒ --version is required for tag command") sys.exit(1) manager.create_git_tag(args.version) elif args.command == 'build': manager.build_packages() elif args.command == 'publish': if not args.version: print("āŒ --version is required for publish command") sys.exit(1) if args.to_gitea: manager.publish_with_gitea(args.version, args.dry_run) else: manager.publish_release(args.version) elif args.command == 'upload': manager.upload_to_gitea(args.dry_run) elif args.command == 'registry': manager.show_gitea_registry_info() except Exception as e: print(f"āŒ Error: {e}") sys.exit(1) if __name__ == "__main__": main()