diff --git a/.gitignore b/.gitignore index ec5f36f4..07e79f1a 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,4 @@ ISSUES.index # Test artifacts and temporary files tmp/ +markitect/_version.py diff --git a/markitect/__version__.py b/markitect/__version__.py index ad576394..12c47c82 100644 --- a/markitect/__version__.py +++ b/markitect/__version__.py @@ -1,123 +1,16 @@ """ Version information for MarkiTect. -This module provides version and release information for the MarkiTect package. -Version information is sourced from pyproject.toml and git metadata when available. +This module provides version information using setuptools-scm. +Version is automatically derived from git tags. """ -import os -import subprocess -from pathlib import Path -from typing import Optional +try: + from ._version import version as __version__ +except ImportError: + # Fallback when _version.py is not available (e.g., during development without setuptools-scm) + __version__ = "unknown" -# Base version from pyproject.toml -__version__ = "0.5.0" - -def get_git_commit_hash() -> Optional[str]: - """Get the current git commit hash if available.""" - try: - result = subprocess.run( - ['git', 'rev-parse', '--short', 'HEAD'], - capture_output=True, - text=True, - check=True, - cwd=Path(__file__).parent.parent - ) - return result.stdout.strip() - except (subprocess.CalledProcessError, FileNotFoundError): - return None - -def get_git_branch() -> Optional[str]: - """Get the current git branch if available.""" - try: - result = subprocess.run( - ['git', 'branch', '--show-current'], - capture_output=True, - text=True, - check=True, - cwd=Path(__file__).parent.parent - ) - return result.stdout.strip() - except (subprocess.CalledProcessError, FileNotFoundError): - return None - -def get_git_tag() -> Optional[str]: - """Get the current git tag if available.""" - try: - result = subprocess.run( - ['git', 'describe', '--tags', '--exact-match'], - capture_output=True, - text=True, - check=True, - cwd=Path(__file__).parent.parent - ) - return result.stdout.strip() - except (subprocess.CalledProcessError, FileNotFoundError): - return None - -def is_development_version() -> bool: - """Check if this is a development version (has uncommitted changes).""" - try: - result = subprocess.run( - ['git', 'status', '--porcelain'], - capture_output=True, - text=True, - check=True, - cwd=Path(__file__).parent.parent - ) - return bool(result.stdout.strip()) - except (subprocess.CalledProcessError, FileNotFoundError): - return False - -def get_version_info() -> dict: - """Get comprehensive version information.""" - git_commit = get_git_commit_hash() - git_branch = get_git_branch() - git_tag = get_git_tag() - is_dev = is_development_version() - - # Build version string - version_parts = [__version__] - - if git_tag and git_tag != f"v{__version__}": - # If we have a different tag, use it - version_parts = [git_tag.lstrip('v')] - - if git_commit: - if is_dev: - version_parts.append(f"dev+{git_commit}") - elif not git_tag: - version_parts.append(f"+{git_commit}") - - if is_dev and not git_commit: - version_parts.append("dev") - - full_version = ".".join(version_parts) - - return { - "version": __version__, - "full_version": full_version, - "git_commit": git_commit, - "git_branch": git_branch, - "git_tag": git_tag, - "is_development": is_dev, - "is_git_repo": git_commit is not None - } - -def get_release_info() -> dict: - """Get release information.""" - version_info = get_version_info() - - release_type = "development" if version_info["is_development"] else "release" - if version_info["git_tag"]: - release_type = "tagged-release" - elif version_info["git_commit"] and not version_info["is_development"]: - release_type = "commit-build" - - return { - "release_type": release_type, - "build_from": version_info["git_branch"] or "unknown", - "commit": version_info["git_commit"] or "unknown", - "clean_build": not version_info["is_development"], - **version_info - } \ No newline at end of file +def get_version(): + """Get the current version string.""" + return __version__ \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 787ec9fe..c62e990c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["setuptools>=61.0"] +requires = ["setuptools>=64", "setuptools-scm>=8"] build-backend = "setuptools.build_meta" [project] name = "markitect" -version = "0.7.0" +dynamic = ["version"] description = "Advanced Markdown engine for structured content" readme = "README.md" requires-python = ">=3.8" @@ -100,3 +100,6 @@ module = [ "yaml.*" ] ignore_missing_imports = true + +[tool.setuptools_scm] +write_to = "markitect/_version.py" diff --git a/release_simplified.py b/release_simplified.py new file mode 100644 index 00000000..1cb4bcf3 --- /dev/null +++ b/release_simplified.py @@ -0,0 +1,258 @@ +#!/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_simplified.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) + +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 +""" + +import subprocess +import argparse +import sys +from pathlib import Path +from datetime import datetime +from typing import Dict, List, Optional, Tuple + + +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 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'], + 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') + + 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) + manager.publish_release(args.version) + + except Exception as e: + print(f"❌ Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file