Convert to setuptools-scm for automatic version management

- Remove manual version management in favor of git tag-based versioning
- Simplify __version__.py to import from generated _version.py
- Add simplified release_simplified.py script
- Add _version.py to .gitignore (auto-generated)

🚀 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-08 20:23:16 +01:00
parent 1d26770110
commit 8249296a43
4 changed files with 274 additions and 119 deletions

1
.gitignore vendored
View File

@@ -96,3 +96,4 @@ ISSUES.index
# Test artifacts and temporary files
tmp/
markitect/_version.py

View File

@@ -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
}
def get_version():
"""Get the current version string."""
return __version__

View File

@@ -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"

258
release_simplified.py Normal file
View File

@@ -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()