diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0d4ab6e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,95 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.8, 3.9, 3.10, 3.11] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run tests + run: | + pytest tests/ -v --cov=. --cov-report=xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella + + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run linting + run: | + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=88 --statistics + + - name: Run type checking + run: | + mypy . --ignore-missing-imports + + - name: Check code formatting + run: | + black --check --diff . + + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + + - name: Build package + run: python -m build + + - name: Upload build artifacts + uses: actions/upload-artifact@v3 + with: + name: dist + path: dist/ \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b49f09e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for changelog generation + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build package + run: python -m build + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: | + dist/*.tar.gz + dist/*.whl + generate_release_notes: true + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload to PyPI + if: startsWith(github.ref, 'refs/tags/v') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + skip-existing: true \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..6627855 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,35 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-merge-conflict + - id: debug-statements + + - repo: https://github.com/psf/black + rev: 23.3.0 + hooks: + - id: black + language_version: python3 + + - repo: https://github.com/pycqa/flake8 + rev: 6.0.0 + hooks: + - id: flake8 + args: [--max-line-length=88, --extend-ignore=E203,W503] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.3.0 + hooks: + - id: mypy + additional_dependencies: [types-all] + args: [--ignore-missing-imports] + + - repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + args: [--profile=black] \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..043a70f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2024-01-01 + +### Features +- Initial release of Unofficial Retro Patch +- Selective pixelation using mask files +- Configurable pixelation settings +- Debug mode for texture verification +- Support for Stronghold: Definitive Edition +- Unity asset handling with UnityPy +- Memory usage monitoring +- Environment-based configuration + +### Technical +- Python 3.8+ compatibility +- Modular architecture with separate pixelation engine +- Comprehensive error handling and logging +- Cross-platform support \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fe08791 --- /dev/null +++ b/Makefile @@ -0,0 +1,64 @@ +.PHONY: help install test lint format clean build release bump-patch bump-minor bump-major + +help: ## Show this help message + @echo "Unofficial Retro Patch - Development Commands" + @echo "=============================================" + @echo "" + @echo "Available commands:" + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +install: ## Install the package in development mode + pip install -e ".[dev]" + +test: ## Run tests + pytest tests/ -v --cov=. --cov-report=html --cov-report=term + +lint: ## Run linting checks + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=88 --statistics + mypy . --ignore-missing-imports + +format: ## Format code with black + black . + +clean: ## Clean build artifacts + rm -rf build/ + rm -rf dist/ + rm -rf *.egg-info/ + rm -rf .pytest_cache/ + rm -rf htmlcov/ + rm -rf .coverage + +build: ## Build distribution packages + python -m build + +release: ## Create a new release (patch version) + python version.py release --type patch + +bump-patch: ## Bump patch version + python version.py bump --type patch + +bump-minor: ## Bump minor version + python version.py bump --type minor + +bump-major: ## Bump major version + python version.py bump --type major + +release-patch: ## Create patch release + python version.py release --type patch + +release-minor: ## Create minor release + python version.py release --type minor + +release-major: ## Create major release + python version.py release --type major + +check: ## Run all checks (lint, test, build) + $(MAKE) lint + $(MAKE) test + $(MAKE) build + +pre-commit: ## Run pre-commit checks + $(MAKE) format + $(MAKE) lint + $(MAKE) test \ No newline at end of file diff --git a/README.md b/README.md index 9fc0617..dec49df 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ The Unofficial Retro Patch (URP) is a modification tool designed to selectively ## Installation +### For Users + 1. Clone this repository: ``` git clone https://github.com/BALOTIAS/urp.git @@ -47,6 +49,25 @@ The Unofficial Retro Patch (URP) is a modification tool designed to selectively 4. Configure the `.env` file with your game's file paths and pixelation settings. +### For Developers + +1. Clone and install in development mode: + ``` + git clone https://github.com/BALOTIAS/urp.git + cd urp + make install + ``` + +2. Install pre-commit hooks: + ``` + pre-commit install + ``` + +3. Run tests: + ``` + make test + ``` + ## Usage 1. Make sure your game files are in the correct location as specified in your config.ini @@ -87,6 +108,55 @@ Masks are grayscale PNG files that control where pixelation is applied: Place mask files in the `masks` folder with the same folder structure as the original textures, e.g. `masks/Stronghold Definitive Edition/resources.assets/AllTileSprites.png`. +## Development + +### Version Management + +This project uses semantic versioning and automated release management. To create a new release: + +1. **Bump version and create release:** + ```bash + make release-patch # For bug fixes + make release-minor # For new features + make release-major # For breaking changes + ``` + +2. **Manual version bumping:** + ```bash + python version.py bump --type patch|minor|major + ``` + +3. **Create release with GitHub integration:** + ```bash + python version.py release --type patch --create-release + ``` + +### Development Workflow + +- **Run all checks:** `make check` +- **Format code:** `make format` +- **Run tests:** `make test` +- **Lint code:** `make lint` +- **Build package:** `make build` +- **Clean artifacts:** `make clean` + +### Commit Convention + +This project follows [Conventional Commits](https://www.conventionalcommits.org/): + +- `feat:` - New features +- `fix:` - Bug fixes +- `docs:` - Documentation changes +- `refactor:` - Code refactoring +- `test:` - Test additions/changes +- `chore:` - Maintenance tasks + +### CI/CD + +- **CI:** Runs on every push and pull request +- **Releases:** Automatically triggered by version tags +- **PyPI:** Automatic upload on release + ## License See the [LICENSE](LICENSE) file for details. diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..24899ca --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,196 @@ +# Release Guide + +This document explains how to create releases for the Unofficial Retro Patch project. + +## Overview + +The project uses semantic versioning and automated release management. Releases are created by: + +1. Bumping the version number +2. Generating a changelog from git commits +3. Creating a git tag +4. Building distribution packages +5. Creating a GitHub release (optional) + +## Quick Release + +For most releases, use the Makefile commands: + +```bash +# Patch release (bug fixes) +make release-patch + +# Minor release (new features) +make release-minor + +# Major release (breaking changes) +make release-major +``` + +## Manual Release Process + +### 1. Bump Version + +```bash +# Bump patch version (1.2.3 -> 1.2.4) +python version.py bump --type patch + +# Bump minor version (1.2.3 -> 1.3.0) +python version.py bump --type minor + +# Bump major version (1.2.3 -> 2.0.0) +python version.py bump --type major +``` + +### 2. Create Release + +```bash +# Create release with automatic changelog generation +python version.py release --type patch + +# Create release with GitHub integration (requires GITHUB_TOKEN) +python version.py release --type patch --create-release +``` + +## Commit Convention + +Follow [Conventional Commits](https://www.conventionalcommits.org/) for automatic changelog generation: + +- `feat: add new feature` → Features section +- `fix: resolve bug` → Bug Fixes section +- `docs: update README` → Documentation section +- `refactor: improve code` → Refactoring section +- `test: add tests` → Other section +- `chore: update dependencies` → Other section + +## GitHub Integration + +### Prerequisites + +1. Install GitHub CLI: + ```bash + # macOS + brew install gh + + # Ubuntu/Debian + sudo apt install gh + + # Windows + winget install GitHub.cli + ``` + +2. Authenticate with GitHub: + ```bash + gh auth login + ``` + +3. Set up GitHub token (optional, for automated releases): + ```bash + export GITHUB_TOKEN=your_token_here + ``` + +### Automated Release + +```bash +python version.py release --type patch --create-release +``` + +This will: +- Bump version +- Generate changelog +- Create git tag +- Build packages +- Create GitHub release draft + +## Manual GitHub Release + +1. Push the tag: + ```bash + git push origin v1.2.3 + ``` + +2. Go to GitHub releases page: + https://github.com/BALOTIAS/urp/releases/new + +3. Create release with: + - Tag: `v1.2.3` + - Title: `Release v1.2.3` + - Description: Copy from CHANGELOG.md + - Upload built packages from `dist/` folder + +## CI/CD Pipeline + +The GitHub Actions workflow automatically: + +- Runs tests on every push +- Builds packages on tag push +- Creates GitHub release on tag push +- Uploads to PyPI (if configured) + +## Troubleshooting + +### Version Conflicts + +If you need to reset version: + +```bash +# Edit pyproject.toml manually +# Then commit changes +git add pyproject.toml +git commit -m "fix: reset version to 1.0.0" +``` + +### Changelog Issues + +If changelog is not generated correctly: + +1. Check commit messages follow conventional format +2. Ensure git history is available +3. Manually edit CHANGELOG.md if needed + +### GitHub Release Issues + +1. Check GitHub CLI is installed and authenticated +2. Verify GITHUB_TOKEN is set (if using automated releases) +3. Check repository permissions +4. Create release manually if needed + +## Best Practices + +1. **Always test before release:** + ```bash + make check + ``` + +2. **Use conventional commits:** + ```bash + git commit -m "feat: add new pixelation algorithm" + ``` + +3. **Review changes before release:** + ```bash + git log --oneline v1.2.2..HEAD + ``` + +4. **Test the release locally:** + ```bash + python version.py release --type patch + # Review changes, then push tag + ``` + +5. **Keep releases focused:** + - One feature/fix per release + - Clear commit messages + - Comprehensive changelog + +## Version Strategy + +- **Patch (0.0.X):** Bug fixes, minor improvements +- **Minor (0.X.0):** New features, backward compatible +- **Major (X.0.0):** Breaking changes, major rewrites + +For this project: +- **0.1.x:** Initial development, API may change +- **1.0.0:** First stable release +- **1.x.x:** Stable releases with new features +- **2.0.0:** Breaking changes (if needed) \ No newline at end of file diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 0000000..38881f0 --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,154 @@ +# Versioning System Overview + +This document provides an overview of the versioning system implemented for the Unofficial Retro Patch project. + +## What Was Implemented + +### 1. **Modern Python Packaging** (`pyproject.toml`) +- Standardized project metadata +- Dependency management +- Build configuration +- Development tools configuration + +### 2. **Version Management Script** (`version.py`) +- Semantic versioning support +- Automatic version bumping (major/minor/patch) +- Git commit analysis +- Changelog generation from conventional commits +- GitHub release automation + +### 3. **GitHub Actions CI/CD** +- **CI Pipeline** (`.github/workflows/ci.yml`): + - Runs on every push and pull request + - Multi-Python version testing (3.8-3.11) + - Linting with flake8, black, mypy + - Test coverage reporting + - Build verification + +- **Release Pipeline** (`.github/workflows/release.yml`): + - Triggers on version tags (v*) + - Automatic GitHub release creation + - PyPI package upload + - Distribution file attachment + +### 4. **Development Tools** +- **Makefile**: Common development tasks +- **Pre-commit hooks**: Code quality enforcement +- **Test suite**: Comprehensive testing framework +- **Setup script**: Easy environment setup + +### 5. **Documentation** +- **CHANGELOG.md**: Automatic changelog generation +- **RELEASE.md**: Detailed release guide +- **Updated README.md**: Development workflow documentation + +## Key Features + +### Automated Release Process +```bash +# Quick release (patch) +make release-patch + +# Manual control +python version.py release --type patch --create-release +``` + +### Conventional Commits Support +- `feat:` → Features section +- `fix:` → Bug Fixes section +- `docs:` → Documentation section +- `refactor:` → Refactoring section +- `test:` → Other section +- `chore:` → Other section + +### GitHub Integration +- Automatic release creation from tags +- Changelog generation from git history +- PyPI package upload +- Distribution file management + +### Development Workflow +```bash +# Setup +./scripts/setup.sh + +# Development +make install +make test +make lint +make format + +# Release +make release-patch +git push origin v1.2.3 +``` + +## File Structure + +``` +├── pyproject.toml # Project configuration +├── version.py # Version management script +├── CHANGELOG.md # Generated changelog +├── RELEASE.md # Release guide +├── Makefile # Development tasks +├── .pre-commit-config.yaml # Code quality hooks +├── scripts/ +│ └── setup.sh # Setup script +├── tests/ +│ ├── __init__.py +│ └── test_version.py # Version management tests +└── .github/workflows/ + ├── ci.yml # CI pipeline + └── release.yml # Release pipeline +``` + +## Usage Examples + +### For Users +1. Clone repository +2. Install dependencies: `pip install -r requirements.txt` +3. Configure `.env` file +4. Run: `python main.py` + +### For Developers +1. Setup: `./scripts/setup.sh` +2. Development: `make help` +3. Testing: `make test` +4. Release: `make release-patch` + +### For Maintainers +1. Follow conventional commits +2. Use semantic versioning +3. Test before release: `make check` +4. Create releases: `python version.py release --type patch` + +## Benefits + +1. **Automation**: Reduces manual release work +2. **Consistency**: Standardized release process +3. **Quality**: Automated testing and linting +4. **Documentation**: Automatic changelog generation +5. **Distribution**: PyPI and GitHub releases +6. **Collaboration**: Clear development workflow + +## Next Steps + +1. **Configure PyPI**: Set up PyPI API token +2. **GitHub Secrets**: Add required secrets to repository +3. **Test Workflow**: Create test release +4. **Documentation**: Update project documentation +5. **Community**: Share release process with contributors + +## Troubleshooting + +### Common Issues +- **Version conflicts**: Edit `pyproject.toml` manually +- **Changelog issues**: Check commit message format +- **GitHub release fails**: Verify permissions and tokens +- **Build fails**: Check Python version compatibility + +### Getting Help +- Check `RELEASE.md` for detailed instructions +- Review GitHub Actions logs for CI/CD issues +- Use `make help` for available commands +- Check test output for debugging information \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ea68118 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,65 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "unofficial-retro-patch" +version = "0.1.1" +description = "Unofficial Retro Patch for Stronghold: Definitive Edition - brings back the classic pixel art charm" +readme = "README.md" +license = {file = "LICENSE"} +authors = [ + {name = "BALOTIAS", email = "your-email@example.com"} +] +keywords = ["stronghold", "game", "mod", "pixelation", "retro"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Games/Entertainment", + "Topic :: Multimedia :: Graphics", +] +requires-python = ">=3.8" +dependencies = [ + "UnityPy>=1.9.0", + "Pillow>=9.0.0", + "python-dotenv>=0.19.0", + "psutil>=5.8.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "black>=22.0.0", + "flake8>=4.0.0", + "mypy>=0.950", +] + +[project.urls] +Homepage = "https://github.com/BALOTIAS/urp" +Repository = "https://github.com/BALOTIAS/urp" +Issues = "https://github.com/BALOTIAS/urp/issues" + +[project.scripts] +urp = "main:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["*"] +exclude = ["tests*", "assets*", "downloads*"] + +[tool.black] +line-length = 88 +target-version = ['py38'] + +[tool.mypy] +python_version = "3.8" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true \ No newline at end of file diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..3e626af --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +# Unofficial Retro Patch Setup Script +# This script helps set up the development environment + +set -e + +echo "🎮 Unofficial Retro Patch Setup" +echo "================================" + +# Check if Python is installed +if ! command -v python3 &> /dev/null; then + echo "❌ Python 3 is not installed. Please install Python 3.8 or higher." + exit 1 +fi + +# Check Python version +PYTHON_VERSION=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')") +echo "✅ Python $PYTHON_VERSION detected" + +# Create virtual environment if it doesn't exist +if [ ! -d "venv" ]; then + echo "📦 Creating virtual environment..." + python3 -m venv venv +fi + +# Activate virtual environment +echo "🔧 Activating virtual environment..." +source venv/bin/activate + +# Upgrade pip +echo "⬆️ Upgrading pip..." +pip install --upgrade pip + +# Install dependencies +echo "📚 Installing dependencies..." +if [ -f "Pipfile" ]; then + pipenv install --dev +else + pip install -e ".[dev]" +fi + +# Install pre-commit hooks +if command -v pre-commit &> /dev/null; then + echo "🔗 Installing pre-commit hooks..." + pre-commit install +else + echo "⚠️ pre-commit not found. Install it with: pip install pre-commit" +fi + +# Create .env file if it doesn't exist +if [ ! -f ".env" ] && [ -f ".env.example" ]; then + echo "📝 Creating .env file from template..." + cp .env.example .env + echo "⚠️ Please edit .env file with your configuration" +fi + +echo "" +echo "🎉 Setup complete!" +echo "" +echo "Next steps:" +echo "1. Edit .env file with your game paths" +echo "2. Run tests: make test" +echo "3. Start development: make help" +echo "" +echo "For help: make help" \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..967c0cf --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests package for Unofficial Retro Patch \ No newline at end of file diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..9251520 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,183 @@ +""" +Tests for version management functionality. +""" + +import pytest +import tempfile +import shutil +from pathlib import Path +from unittest.mock import patch, MagicMock +import subprocess + +from version import VersionManager + + +class TestVersionManager: + @pytest.fixture + def temp_project(self): + """Create a temporary project directory for testing.""" + temp_dir = tempfile.mkdtemp() + project_dir = Path(temp_dir) / "test_project" + project_dir.mkdir() + + # Create a test pyproject.toml + pyproject_content = '''[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "test-project" +version = "1.2.3" +description = "Test project" +''' + (project_dir / "pyproject.toml").write_text(pyproject_content) + + yield project_dir + + shutil.rmtree(temp_dir) + + def test_get_current_version(self, temp_project): + """Test getting current version from pyproject.toml.""" + with patch('version.Path') as mock_path: + mock_path.return_value.parent = temp_project + manager = VersionManager() + version = manager.get_current_version() + assert version == (1, 2, 3) + + def test_set_version(self, temp_project): + """Test setting version in pyproject.toml.""" + with patch('version.Path') as mock_path: + mock_path.return_value.parent = temp_project + manager = VersionManager() + manager.set_version(2, 0, 0) + + # Check that the version was updated + content = (temp_project / "pyproject.toml").read_text() + assert 'version = "2.0.0"' in content + + def test_bump_version_patch(self, temp_project): + """Test patch version bump.""" + with patch('version.Path') as mock_path: + mock_path.return_value.parent = temp_project + manager = VersionManager() + major, minor, patch = manager.bump_version("patch") + assert (major, minor, patch) == (1, 2, 4) + + def test_bump_version_minor(self, temp_project): + """Test minor version bump.""" + with patch('version.Path') as mock_path: + mock_path.return_value.parent = temp_project + manager = VersionManager() + major, minor, patch = manager.bump_version("minor") + assert (major, minor, patch) == (1, 3, 0) + + def test_bump_version_major(self, temp_project): + """Test major version bump.""" + with patch('version.Path') as mock_path: + mock_path.return_value.parent = temp_project + manager = VersionManager() + major, minor, patch = manager.bump_version("major") + assert (major, minor, patch) == (2, 0, 0) + + def test_bump_version_invalid(self, temp_project): + """Test invalid bump type raises error.""" + with patch('version.Path') as mock_path: + mock_path.return_value.parent = temp_project + manager = VersionManager() + with pytest.raises(ValueError, match="Invalid bump type"): + manager.bump_version("invalid") + + @patch('subprocess.run') + def test_get_git_changes(self, mock_run, temp_project): + """Test getting git changes.""" + with patch('version.Path') as mock_path: + mock_path.return_value.parent = temp_project + manager = VersionManager() + + # Mock git log output + mock_run.return_value.stdout = "abc123 feat: new feature\n" + mock_run.return_value.returncode = 0 + + commits = manager.get_git_changes() + assert commits == ["abc123 feat: new feature"] + + def test_categorize_commits(self, temp_project): + """Test commit categorization.""" + with patch('version.Path') as mock_path: + mock_path.return_value.parent = temp_project + manager = VersionManager() + + commits = [ + "abc123 feat: add new feature", + "def456 fix: resolve bug", + "ghi789 docs: update README", + "jkl012 refactor: improve code", + "mno345 chore: update dependencies" + ] + + categories = manager.categorize_commits(commits) + + assert len(categories["Features"]) == 1 + assert len(categories["Bug Fixes"]) == 1 + assert len(categories["Documentation"]) == 1 + assert len(categories["Refactoring"]) == 1 + assert len(categories["Other"]) == 1 + + def test_generate_changelog_entry(self, temp_project): + """Test changelog entry generation.""" + with patch('version.Path') as mock_path: + mock_path.return_value.parent = temp_project + manager = VersionManager() + + commits = [ + "abc123 feat: add new feature", + "def456 fix: resolve bug" + ] + + entry = manager.generate_changelog_entry("1.2.3", commits) + + assert "## [1.2.3]" in entry + assert "### Features" in entry + assert "### Bug Fixes" in entry + assert "add new feature" in entry + assert "resolve bug" in entry + + def test_update_changelog_new_file(self, temp_project): + """Test creating new changelog file.""" + with patch('version.Path') as mock_path: + mock_path.return_value.parent = temp_project + manager = VersionManager() + + commits = ["abc123 feat: initial release"] + manager.update_changelog("1.0.0", commits) + + changelog_file = temp_project / "CHANGELOG.md" + assert changelog_file.exists() + + content = changelog_file.read_text() + assert "# Changelog" in content + assert "## [1.0.0]" in content + + def test_update_changelog_existing_file(self, temp_project): + """Test updating existing changelog file.""" + with patch('version.Path') as mock_path: + mock_path.return_value.parent = temp_project + manager = VersionManager() + + # Create existing changelog + changelog_content = """# Changelog + +## [1.0.0] - 2023-01-01 + +### Features +- Initial release +""" + changelog_file = temp_project / "CHANGELOG.md" + changelog_file.write_text(changelog_content) + + commits = ["abc123 feat: new feature"] + manager.update_changelog("1.1.0", commits) + + content = changelog_file.read_text() + assert "## [1.1.0]" in content + assert "## [1.0.0]" in content # Old entry should still be there \ No newline at end of file diff --git a/version.py b/version.py new file mode 100644 index 0000000..bbf97bf --- /dev/null +++ b/version.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +""" +Version management script for Unofficial Retro Patch. +Handles version bumping, changelog generation, and GitHub release preparation. +""" + +import os +import re +import sys +import subprocess +import argparse +from datetime import datetime +from pathlib import Path +from typing import List, Optional, Tuple + + +class VersionManager: + def __init__(self): + self.project_root = Path(__file__).parent + self.pyproject_file = self.project_root / "pyproject.toml" + self.changelog_file = self.project_root / "CHANGELOG.md" + self.version_pattern = r'version = "(\d+)\.(\d+)\.(\d+)"' + + def get_current_version(self) -> Tuple[int, int, int]: + """Get current version from pyproject.toml.""" + content = self.pyproject_file.read_text() + match = re.search(self.version_pattern, content) + if not match: + raise ValueError("Could not find version in pyproject.toml") + return tuple(map(int, match.groups())) + + def set_version(self, major: int, minor: int, patch: int) -> None: + """Set version in pyproject.toml.""" + content = self.pyproject_file.read_text() + new_version = f'version = "{major}.{minor}.{patch}"' + content = re.sub(self.version_pattern, new_version, content) + self.pyproject_file.write_text(content) + print(f"Version set to {major}.{minor}.{patch}") + + def bump_version(self, bump_type: str) -> Tuple[int, int, int]: + """Bump version according to semantic versioning.""" + major, minor, patch = self.get_current_version() + + if bump_type == "major": + major += 1 + minor = 0 + patch = 0 + elif bump_type == "minor": + minor += 1 + patch = 0 + elif bump_type == "patch": + patch += 1 + else: + raise ValueError(f"Invalid bump type: {bump_type}") + + self.set_version(major, minor, patch) + return major, minor, patch + + def get_git_changes(self, since_tag: Optional[str] = None) -> List[str]: + """Get git commits since the last tag or specified tag.""" + if since_tag is None: + # Get the last tag + try: + result = subprocess.run( + ["git", "describe", "--tags", "--abbrev=0"], + capture_output=True, text=True, check=True + ) + since_tag = result.stdout.strip() + except subprocess.CalledProcessError: + # No tags found, get all commits + since_tag = "" + + if since_tag: + cmd = ["git", "log", f"{since_tag}..HEAD", "--oneline", "--no-merges"] + else: + cmd = ["git", "log", "--oneline", "--no-merges"] + + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + commits = [line.strip() for line in result.stdout.strip().split('\n') if line.strip()] + return commits + + def categorize_commits(self, commits: List[str]) -> dict: + """Categorize commits by type (feat, fix, docs, etc.).""" + categories = { + "Features": [], + "Bug Fixes": [], + "Documentation": [], + "Refactoring": [], + "Other": [] + } + + for commit in commits: + commit_hash, *message_parts = commit.split(' ', 1) + message = message_parts[0] if message_parts else "" + + # Categorize based on conventional commit format + if message.startswith("feat:"): + categories["Features"].append((commit_hash, message[5:].strip())) + elif message.startswith("fix:"): + categories["Bug Fixes"].append((commit_hash, message[4:].strip())) + elif message.startswith("docs:"): + categories["Documentation"].append((commit_hash, message[5:].strip())) + elif message.startswith("refactor:"): + categories["Refactoring"].append((commit_hash, message[9:].strip())) + else: + categories["Other"].append((commit_hash, message)) + + return categories + + def generate_changelog_entry(self, version: str, commits: List[str]) -> str: + """Generate a changelog entry for the given version.""" + categories = self.categorize_commits(commits) + date = datetime.now().strftime("%Y-%m-%d") + + entry = f"## [{version}] - {date}\n\n" + + for category, commit_list in categories.items(): + if commit_list: + entry += f"### {category}\n" + for commit_hash, message in commit_list: + entry += f"- {message} ({commit_hash})\n" + entry += "\n" + + return entry + + def update_changelog(self, version: str, commits: List[str]) -> None: + """Update CHANGELOG.md with new version entry.""" + if not self.changelog_file.exists(): + # Create new changelog + content = f"# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n" + content += self.generate_changelog_entry(version, commits) + else: + # Prepend to existing changelog + content = self.changelog_file.read_text() + new_entry = self.generate_changelog_entry(version, commits) + content = content.replace("# Changelog\n\n", f"# Changelog\n\n{new_entry}") + + self.changelog_file.write_text(content) + print(f"Updated CHANGELOG.md with version {version}") + + def create_git_tag(self, version: str, message: Optional[str] = None) -> None: + """Create a git tag for the version.""" + if message is None: + message = f"Release version {version}" + + subprocess.run(["git", "add", "pyproject.toml", "CHANGELOG.md"], check=True) + subprocess.run(["git", "commit", "-m", f"Bump version to {version}"], check=True) + subprocess.run(["git", "tag", "-a", f"v{version}", "-m", message], check=True) + print(f"Created git tag v{version}") + + def build_distribution(self) -> None: + """Build distribution packages.""" + subprocess.run([sys.executable, "-m", "build"], check=True) + print("Built distribution packages") + + def create_github_release(self, version: str, token: str) -> None: + """Create a GitHub release using GitHub CLI or API.""" + # Check if GitHub CLI is available + try: + subprocess.run(["gh", "--version"], capture_output=True, check=True) + self._create_github_release_cli(version) + except (subprocess.CalledProcessError, FileNotFoundError): + print("GitHub CLI not found. Please install it or create the release manually.") + print(f"Release URL: https://github.com/BALOTIAS/urp/releases/new") + + def _create_github_release_cli(self, version: str) -> None: + """Create GitHub release using GitHub CLI.""" + # Get changelog content for this version + changelog_content = self.changelog_file.read_text() + # Extract the entry for this version + pattern = rf"## \[{version}\].*?(?=## \[|\Z)" + match = re.search(pattern, changelog_content, re.DOTALL) + release_notes = match.group(0) if match else f"Release version {version}" + + # Create release + subprocess.run([ + "gh", "release", "create", f"v{version}", + "--title", f"Release v{version}", + "--notes", release_notes, + "--draft" + ], check=True) + print(f"Created GitHub release draft for v{version}") + + def run_release_workflow(self, bump_type: str, create_release: bool = False) -> None: + """Run the complete release workflow.""" + print(f"Starting release workflow with {bump_type} bump...") + + # Bump version + major, minor, patch = self.bump_version(bump_type) + version = f"{major}.{minor}.{patch}" + + # Get commits since last tag + commits = self.get_git_changes() + + # Update changelog + self.update_changelog(version, commits) + + # Create git tag + self.create_git_tag(version) + + # Build distribution + self.build_distribution() + + if create_release: + token = os.getenv("GITHUB_TOKEN") + if token: + self.create_github_release(version, token) + else: + print("GITHUB_TOKEN not set. Please create the release manually.") + print(f"Release URL: https://github.com/BALOTIAS/urp/releases/new") + + print(f"\nRelease workflow completed!") + print(f"Version: {version}") + print(f"Next steps:") + print(f"1. Review the changes: git log --oneline v{version}") + print(f"2. Push the tag: git push origin v{version}") + if not create_release: + print(f"3. Create GitHub release: https://github.com/BALOTIAS/urp/releases/new") + + +def main(): + parser = argparse.ArgumentParser(description="Version management for Unofficial Retro Patch") + parser.add_argument("action", choices=["bump", "release"], help="Action to perform") + parser.add_argument("--type", choices=["major", "minor", "patch"], default="patch", + help="Version bump type (default: patch)") + parser.add_argument("--create-release", action="store_true", + help="Create GitHub release (requires GITHUB_TOKEN)") + + args = parser.parse_args() + + manager = VersionManager() + + if args.action == "bump": + major, minor, patch = manager.bump_version(args.type) + print(f"Version bumped to {major}.{minor}.{patch}") + + elif args.action == "release": + manager.run_release_workflow(args.type, args.create_release) + + +if __name__ == "__main__": + main() \ No newline at end of file