diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 52339c4..683f280 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -27,6 +27,15 @@ jobs: uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2 with: enable-cache: true + - name: Check version not on PyPI + run: | + VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') + echo "Checking version: $VERSION" + if curl -s "https://pypi.org/pypi/pyosmo/$VERSION/json" | grep -q "\"$VERSION\""; then + echo "Error: Version $VERSION already exists on PyPI!" + exit 1 + fi + echo "Version $VERSION is available for publishing" - name: Build package run: uv build - name: Publish package diff --git a/CLAUDE.md b/CLAUDE.md index f182384..fe650fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,24 @@ python -m build pyosmo examples/calculator_example.py --algorithm weighted --test-len 100 ``` +### Publishing (NEW) +```bash +# Automated publishing workflow +make publish-patch # Bump patch version (0.2.2 -> 0.2.3) and publish +make publish-minor # Bump minor version (0.2.2 -> 0.3.0) and publish +make publish-major # Bump major version (0.2.2 -> 1.0.0) and publish + +# Just bump version (no publish) +make version-patch +make version-minor +make version-major + +# Check if version exists on PyPI +make check-pypi +``` + +See [PUBLISHING.md](PUBLISHING.md) for detailed publishing instructions and troubleshooting. + ## Architecture ### Core Components diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2b27718 --- /dev/null +++ b/Makefile @@ -0,0 +1,83 @@ +.PHONY: help test lint format version-patch version-minor version-major publish-patch publish-minor publish-major build check-pypi + +help: + @echo "PyOsmo Development Commands" + @echo "" + @echo "Testing:" + @echo " make test Run all tests" + @echo " make test-cov Run tests with coverage" + @echo "" + @echo "Linting & Formatting:" + @echo " make lint Run ruff linter" + @echo " make format Format code with ruff" + @echo " make typecheck Run mypy type checker" + @echo "" + @echo "Version Management:" + @echo " make version-patch Bump patch version (0.2.2 -> 0.2.3)" + @echo " make version-minor Bump minor version (0.2.2 -> 0.3.0)" + @echo " make version-major Bump major version (0.2.2 -> 1.0.0)" + @echo " make check-pypi Check if current version exists on PyPI" + @echo "" + @echo "Publishing:" + @echo " make publish-patch Bump patch version and publish" + @echo " make publish-minor Bump minor version and publish" + @echo " make publish-major Bump major version and publish" + @echo "" + @echo "Building:" + @echo " make build Build distribution packages" + @echo " make clean Clean build artifacts" + +# Testing +test: + pytest pyosmo/tests/ + +test-cov: + pytest pyosmo/tests/ --cov=pyosmo + +# Linting & Formatting +lint: + ruff check pyosmo/ + +lint-fix: + ruff check pyosmo/ --fix + +format: + ruff format pyosmo/ + +format-check: + ruff format --check pyosmo/ + +typecheck: + mypy pyosmo/ + +# Version Management +version-patch: + python scripts/bump_version.py patch + +version-minor: + python scripts/bump_version.py minor + +version-major: + python scripts/bump_version.py major + +check-pypi: + @python scripts/check_pypi.py + +# Publishing (automated workflow) +publish-patch: + python scripts/publish.py patch + +publish-minor: + python scripts/publish.py minor + +publish-major: + python scripts/publish.py major + +# Building +build: + python -m build + +clean: + rm -rf build/ dist/ *.egg-info + find . -type d -name __pycache__ -exec rm -rf {} + + find . -type f -name '*.pyc' -delete diff --git a/PUBLISHING.md b/PUBLISHING.md new file mode 100644 index 0000000..4afed60 --- /dev/null +++ b/PUBLISHING.md @@ -0,0 +1,226 @@ +# Publishing Guide + +This guide explains how to publish new versions of PyOsmo to PyPI. + +## Quick Start + +The easiest way to publish a new version is to use the automated scripts: + +```bash +# Bump patch version (0.2.2 -> 0.2.3) and publish +make publish-patch + +# Bump minor version (0.2.2 -> 0.3.0) and publish +make publish-minor + +# Bump major version (0.2.2 -> 1.0.0) and publish +make publish-major +``` + +## What the Automated Script Does + +The `scripts/publish.py` script automates the entire publishing workflow: + +1. **Checks git status** - Ensures working directory is clean +2. **Checks PyPI** - Verifies the current and new versions don't already exist +3. **Bumps version** - Updates version in `pyproject.toml` +4. **Commits changes** - Creates a commit with the version bump +5. **Creates git tag** - Tags the commit with `v{version}` +6. **Pushes to GitHub** - Pushes both commit and tag +7. **Provides next steps** - Shows link to create GitHub release + +## Manual Version Bumping + +If you just want to bump the version without publishing: + +```bash +# Using make +make version-patch # 0.2.2 -> 0.2.3 +make version-minor # 0.2.2 -> 0.3.0 +make version-major # 0.2.2 -> 1.0.0 + +# Using the script directly +python scripts/bump_version.py patch +python scripts/bump_version.py minor +python scripts/bump_version.py major + +# Set specific version +python scripts/bump_version.py --version 1.0.0 + +# Dry run (see what would change) +python scripts/bump_version.py --dry-run patch +``` + +## Manual Publishing Workflow + +If you prefer to do things manually: + +1. **Check current version on PyPI:** + ```bash + make check-pypi + ``` + +2. **Bump version:** + ```bash + python scripts/bump_version.py patch + ``` + +3. **Review changes:** + ```bash + git diff pyproject.toml + ``` + +4. **Commit and tag:** + ```bash + git add pyproject.toml + git commit -m "Bump version to 0.2.3" + git tag v0.2.3 + ``` + +5. **Push to GitHub:** + ```bash + git push origin main # or your branch + git push origin v0.2.3 + ``` + +6. **Create GitHub Release:** + - Go to https://github.com/osmo-tool/pyosmo/releases/new + - Select the tag you just created (e.g., `v0.2.3`) + - Click "Generate release notes" + - Review and edit the release notes if needed + - Click "Publish release" + +7. **Automatic PyPI Upload:** + - The GitHub Action workflow (`.github/workflows/python-publish.yml`) will automatically: + - Check that the version doesn't exist on PyPI + - Build the distribution packages + - Upload to PyPI using the configured API token + +## Troubleshooting + +### Error: Version already exists on PyPI + +If you see this error: +``` +HTTPError: 400 Bad Request from https://upload.pypi.org/legacy/ +File already exists ('pyosmo-0.2.2-py3-none-any.whl'...) +``` + +**Solution:** You need to bump the version before publishing: +```bash +make publish-patch # This will automatically bump and publish +``` + +Or manually: +```bash +python scripts/bump_version.py patch +``` + +### Error: Working directory is not clean + +The automated script requires a clean git working directory. + +**Solution:** Commit or stash your changes first: +```bash +git status +git add . +git commit -m "Your commit message" +# Then try publishing again +make publish-patch +``` + +### Version Check Fails in GitHub Actions + +If the GitHub Action fails with "Version already exists on PyPI", it means: +- You created a GitHub release without bumping the version first +- The version in `pyproject.toml` was already published + +**Solution:** +1. Delete the GitHub release and tag +2. Bump the version using the scripts +3. Create a new release with the new version + +### Skipping Pre-Publish Checks + +If you need to skip the git status and PyPI checks (not recommended): +```bash +python scripts/publish.py patch --skip-checks +``` + +## Version Numbering Guidelines + +Follow [Semantic Versioning (SemVer)](https://semver.org/): + +- **MAJOR** version (1.0.0 -> 2.0.0): Breaking changes / incompatible API changes +- **MINOR** version (0.2.0 -> 0.3.0): New features, backward compatible +- **PATCH** version (0.2.2 -> 0.2.3): Bug fixes, backward compatible + +### Examples + +- Bug fix: `make publish-patch` +- New feature: `make publish-minor` +- Breaking change: `make publish-major` + +## CI/CD Workflow + +The publishing workflow uses GitHub Actions: + +1. **Trigger**: Creating a GitHub release +2. **Actions performed**: + - Checkout code + - Set up Python 3.11 + - Install `uv` for fast builds + - **Check version doesn't exist on PyPI** ⭐ (prevents duplicate uploads) + - Build distribution packages (wheel + source) + - Upload to PyPI using API token + +**Required Secret**: `PYPI_API_TOKEN` must be configured in GitHub repository secrets. + +## First-Time Setup + +If you're setting up publishing for the first time: + +1. **Create PyPI API token:** + - Go to https://pypi.org/manage/account/token/ + - Create a new API token scoped to the `pyosmo` project + - Copy the token (it starts with `pypi-`) + +2. **Add token to GitHub:** + - Go to repository Settings > Secrets and variables > Actions + - Create new secret named `PYPI_API_TOKEN` + - Paste the PyPI token + +3. **Test publishing:** + - Consider testing on TestPyPI first + - Or publish a patch version bump as a test + +## Best Practices + +1. **Always bump version before creating a release** - Use the automated scripts +2. **Test thoroughly before publishing** - Run `make test` and `make lint` +3. **Use semantic versioning** - Follow the MAJOR.MINOR.PATCH convention +4. **Write good release notes** - Use GitHub's "Generate release notes" feature +5. **Check PyPI after publishing** - Verify the new version appears at https://pypi.org/project/pyosmo/ + +## Useful Commands + +```bash +# Development +make test # Run tests +make lint # Check code quality +make format # Format code + +# Version management +make check-pypi # Check if current version exists on PyPI +make version-patch # Bump patch version only (no publish) + +# Publishing +make publish-patch # Complete automated workflow +``` + +## Additional Resources + +- [PyPI Help](https://pypi.org/help/) +- [Semantic Versioning](https://semver.org/) +- [Python Packaging Guide](https://packaging.python.org/) +- [GitHub Actions for Python](https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python) diff --git a/scripts/bump_version.py b/scripts/bump_version.py new file mode 100755 index 0000000..bffa253 --- /dev/null +++ b/scripts/bump_version.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +""" +Version bumping script for PyOsmo. + +Usage: + python scripts/bump_version.py patch # 0.2.2 -> 0.2.3 + python scripts/bump_version.py minor # 0.2.2 -> 0.3.0 + python scripts/bump_version.py major # 0.2.2 -> 1.0.0 + python scripts/bump_version.py 0.3.0 # Set specific version +""" + +import argparse +import re +import sys +from pathlib import Path + + +def get_current_version(pyproject_path: Path) -> str: + """Extract current version from pyproject.toml.""" + content = pyproject_path.read_text() + match = re.search(r'^version\s*=\s*["\']([^"\']+)["\']', content, re.MULTILINE) + if not match: + raise ValueError('Could not find version in pyproject.toml') + return match.group(1) + + +def parse_version(version: str) -> tuple[int, int, int]: + """Parse version string into (major, minor, patch) tuple.""" + match = re.match(r'^(\d+)\.(\d+)\.(\d+)$', version) + if not match: + raise ValueError(f'Invalid version format: {version}') + return int(match.group(1)), int(match.group(2)), int(match.group(3)) + + +def bump_version(current: str, bump_type: str) -> str: + """Bump version according to bump_type (patch/minor/major).""" + major, minor, patch = parse_version(current) + + if bump_type == 'patch': + patch += 1 + elif bump_type == 'minor': + minor += 1 + patch = 0 + elif bump_type == 'major': + major += 1 + minor = 0 + patch = 0 + else: + # Assume it's a specific version string + parse_version(bump_type) # Validate format + return bump_type + + return f'{major}.{minor}.{patch}' + + +def update_pyproject(pyproject_path: Path, old_version: str, new_version: str) -> None: + """Update version in pyproject.toml.""" + content = pyproject_path.read_text() + + # Replace version line + old_line = f'version = "{old_version}"' + new_line = f'version = "{new_version}"' + + if old_line not in content: + # Try single quotes + old_line = f"version = '{old_version}'" + new_line = f"version = '{new_version}'" + + if old_line not in content: + raise ValueError(f'Could not find version line in pyproject.toml: {old_line}') + + new_content = content.replace(old_line, new_line) + pyproject_path.write_text(new_content) + + +def main() -> int: + parser = argparse.ArgumentParser(description='Bump version in pyproject.toml') + parser.add_argument( + 'bump_type', + choices=['patch', 'minor', 'major'], + nargs='?', + help='Version component to bump, or specific version number', + ) + parser.add_argument( + '--version', + help='Set specific version number (e.g., 0.3.0)', + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Show what would be changed without making changes', + ) + + args = parser.parse_args() + + # Determine what version change to make + if args.version: + bump_type = args.version + elif args.bump_type: + bump_type = args.bump_type + else: + parser.print_help() + return 1 + + # Find pyproject.toml + repo_root = Path(__file__).parent.parent + pyproject_path = repo_root / 'pyproject.toml' + + if not pyproject_path.exists(): + print(f'Error: pyproject.toml not found at {pyproject_path}', file=sys.stderr) + return 1 + + try: + current_version = get_current_version(pyproject_path) + new_version = bump_version(current_version, bump_type) + + print(f'Current version: {current_version}') + print(f'New version: {new_version}') + + if args.dry_run: + print('\n[DRY RUN] No changes made.') + return 0 + + update_pyproject(pyproject_path, current_version, new_version) + print(f'\n✓ Successfully updated pyproject.toml to version {new_version}') + print('\nNext steps:') + print(f' 1. Review changes: git diff pyproject.toml') + print(f' 2. Commit: git add pyproject.toml && git commit -m "Bump version to {new_version}"') + print(f' 3. Tag: git tag v{new_version} && git push origin v{new_version}') + print(f' 4. Create GitHub release from tag v{new_version}') + + return 0 + + except Exception as e: + print(f'Error: {e}', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/check_pypi.py b/scripts/check_pypi.py new file mode 100755 index 0000000..bfeb0bf --- /dev/null +++ b/scripts/check_pypi.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Check if the current version exists on PyPI.""" + +import re +import sys +import urllib.error +import urllib.request +from pathlib import Path + + +def main() -> int: + # Read version from pyproject.toml + pyproject = Path(__file__).parent.parent / 'pyproject.toml' + content = pyproject.read_text() + + match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content) + if not match: + print('Error: Could not find version in pyproject.toml', file=sys.stderr) + return 1 + + version = match.group(1) + print(f'Current version: {version}') + + # Check if version exists on PyPI + url = f'https://pypi.org/pypi/pyosmo/{version}/json' + try: + with urllib.request.urlopen(url) as response: + if response.status == 200: + print(f'❌ Version {version} already exists on PyPI') + print(f' URL: https://pypi.org/project/pyosmo/{version}/') + return 1 + except urllib.error.HTTPError as e: + if e.code == 404: + print(f'✓ Version {version} is available on PyPI') + return 0 + else: + print(f'Error checking PyPI: {e}', file=sys.stderr) + return 1 + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/publish.py b/scripts/publish.py new file mode 100755 index 0000000..0291305 --- /dev/null +++ b/scripts/publish.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +""" +Automated publishing script for PyOsmo. + +This script automates the entire publishing workflow: +1. Checks if version already exists on PyPI +2. Bumps version if needed +3. Commits and tags the new version +4. Pushes to GitHub +5. Creates a GitHub release (which triggers automatic PyPI upload) + +Usage: + python scripts/publish.py patch # Bump patch version and publish + python scripts/publish.py minor # Bump minor version and publish + python scripts/publish.py major # Bump major version and publish +""" + +import argparse +import json +import subprocess +import sys +import urllib.request +from pathlib import Path + + +def run_command(cmd: list[str], check: bool = True, capture: bool = False) -> subprocess.CompletedProcess: + """Run a shell command.""" + print(f'Running: {" ".join(cmd)}') + if capture: + return subprocess.run(cmd, check=check, capture_output=True, text=True) + return subprocess.run(cmd, check=check) + + +def get_current_version() -> str: + """Get current version from pyproject.toml.""" + result = subprocess.run( + ['python', 'scripts/bump_version.py', '--dry-run', 'patch'], + capture_output=True, + text=True, + check=True, + ) + # Parse output to get current version + for line in result.stdout.split('\n'): + if line.startswith('Current version:'): + return line.split(':')[1].strip() + raise ValueError('Could not determine current version') + + +def check_version_on_pypi(package_name: str, version: str) -> bool: + """Check if version exists on PyPI.""" + url = f'https://pypi.org/pypi/{package_name}/json' + try: + with urllib.request.urlopen(url) as response: + data = json.loads(response.read()) + versions = data.get('releases', {}).keys() + return version in versions + except urllib.error.HTTPError as e: + if e.code == 404: + # Package doesn't exist yet + return False + raise + + +def main() -> int: + parser = argparse.ArgumentParser(description='Automated PyOsmo publishing') + parser.add_argument( + 'bump_type', + choices=['patch', 'minor', 'major'], + help='Version component to bump', + ) + parser.add_argument( + '--skip-checks', + action='store_true', + help='Skip git status and PyPI version checks', + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Show what would happen without making changes', + ) + + args = parser.parse_args() + + try: + # Check git status + if not args.skip_checks: + result = run_command(['git', 'status', '--porcelain'], capture=True) + if result.stdout.strip() and not args.dry_run: + print('\nError: Working directory is not clean. Please commit or stash changes first.') + print(result.stdout) + return 1 + + # Get current version + current_version = get_current_version() + print(f'\nCurrent version: {current_version}') + + # Check if current version exists on PyPI + if not args.skip_checks: + print('Checking PyPI for existing version...') + if check_version_on_pypi('pyosmo', current_version): + print(f'⚠ Version {current_version} already exists on PyPI') + else: + print(f'✓ Version {current_version} not found on PyPI') + + # Bump version + print(f'\nBumping {args.bump_type} version...') + bump_cmd = ['python', 'scripts/bump_version.py', args.bump_type] + if args.dry_run: + bump_cmd.append('--dry-run') + + result = run_command(bump_cmd, capture=True) + print(result.stdout) + + # Extract new version from output + new_version = None + for line in result.stdout.split('\n'): + if line.startswith('New version:'): + new_version = line.split(':')[1].strip() + break + + if not new_version: + print('Error: Could not determine new version') + return 1 + + if args.dry_run: + print('\n[DRY RUN] Stopping here. No changes made.') + return 0 + + # Check if new version already exists on PyPI + if not args.skip_checks: + print(f'\nChecking if version {new_version} exists on PyPI...') + if check_version_on_pypi('pyosmo', new_version): + print(f'Error: Version {new_version} already exists on PyPI!') + print('You may need to bump a higher version component (minor or major).') + return 1 + else: + print(f'✓ Version {new_version} is available on PyPI') + + # Git operations + print('\nCommitting version bump...') + run_command(['git', 'add', 'pyproject.toml']) + run_command(['git', 'commit', '-m', f'Bump version to {new_version}']) + + print(f'\nCreating git tag v{new_version}...') + run_command(['git', 'tag', '-a', f'v{new_version}', '-m', f'Release version {new_version}']) + + print('\nPushing to GitHub...') + run_command(['git', 'push', 'origin', 'HEAD']) + run_command(['git', 'push', 'origin', f'v{new_version}']) + + print(f'\n✓ Successfully prepared release {new_version}!') + print('\nNext steps:') + print(f' 1. Go to GitHub: https://github.com/osmo-tool/pyosmo/releases/new') + print(f' 2. Select tag: v{new_version}') + print(f' 3. Click "Generate release notes"') + print(f' 4. Click "Publish release"') + print('\nThe GitHub Action will automatically publish to PyPI when the release is created.') + + return 0 + + except subprocess.CalledProcessError as e: + print(f'\nError: Command failed: {e}', file=sys.stderr) + return 1 + except Exception as e: + print(f'\nError: {e}', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + sys.exit(main())