diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9efe9f..1ce0bd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: version: "latest" - name: Set up Python - run: uv python install 3.12 + run: uv python install 3.13 - name: Install dependencies run: uv sync --all-extras @@ -46,7 +46,7 @@ jobs: version: "latest" - name: Set up Python - run: uv python install 3.12 + run: uv python install 3.13 - name: Install dependencies run: uv sync --all-extras @@ -55,12 +55,8 @@ jobs: run: uv run mypy src/ test: - name: Test (Python ${{ matrix.python-version }}) + name: Test runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.12", "3.13"] steps: - uses: actions/checkout@v4 @@ -70,7 +66,7 @@ jobs: version: "latest" - name: Set up Python - run: uv python install ${{ matrix.python-version }} + run: uv python install 3.13 - name: Install dependencies run: uv sync --all-extras @@ -79,7 +75,6 @@ jobs: run: uv run pytest tests/ --cov=src --cov-report=xml --cov-report=term-missing - name: Upload coverage to Codecov - if: matrix.python-version == '3.12' uses: codecov/codecov-action@v4 with: files: ./coverage.xml @@ -87,17 +82,35 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + version-check: + name: Version Consistency + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.13 + + - name: Check version consistency + run: uv run python scripts/check_version.py + all-checks: name: All Checks Passed runs-on: ubuntu-latest - needs: [lint, typecheck, test] + needs: [lint, typecheck, test, version-check] if: always() steps: - name: Check all jobs passed run: | if [[ "${{ needs.lint.result }}" != "success" ]] || \ [[ "${{ needs.typecheck.result }}" != "success" ]] || \ - [[ "${{ needs.test.result }}" != "success" ]]; then + [[ "${{ needs.test.result }}" != "success" ]] || \ + [[ "${{ needs.version-check.result }}" != "success" ]]; then echo "One or more jobs failed" exit 1 fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..24c31d3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,57 @@ +name: Release + +on: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + release: + name: Semantic Release + runs-on: ubuntu-latest + # Only run on main branch and skip if commit message contains [skip ci] + if: github.ref == 'refs/heads/main' && !contains(github.event.head_commit.message, '[skip ci]') + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + persist-credentials: true # Needed for semantic-release to push tags + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.13 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Run semantic-release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + uv run semantic-release version --print + uv run semantic-release publish + + - name: Verify version consistency + if: success() + run: uv run python scripts/check_version.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..200f9d3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# CHANGELOG + +All notable changes to this project will be documented in this file. + +This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and follows the [Conventional Commits](https://www.conventionalcommits.org/) specification. + +## [Unreleased] + +### Added +- Version consistency validation CI check to prevent version drift +- Comprehensive release process documentation in RELEASING.md +- Python-semantic-release integration for automated versioning +- CHANGELOG.md for tracking project changes + +### Changed +- Updated CI workflow to include version validation step + +## [0.1.0] - 2024-12-22 + +### Added +- Core workspace management functionality +- Git worktree integration for isolated workspaces +- CLI commands: `workspace create`, `workspace list`, `workspace remove` +- Agent launching capabilities +- Template system for project scaffolding +- Documentation generation from design templates +- Python virtual environment support per workspace +- Metadata tracking for workspace configuration + +### Infrastructure +- Structured logging with structlog +- Type-safe configuration with Pydantic +- Comprehensive test suite with pytest +- Code quality tooling (ruff, mypy) +- CI/CD pipeline with GitHub Actions + +[Unreleased]: https://github.com/ckrough/agentspaces/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/ckrough/agentspaces/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index c235013..50cc533 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,15 +108,60 @@ uv run mypy src/ # Type check ## Conventions -- Python 3.12+ +- Python 3.13 - Type hints on all functions - Google-style docstrings - `ruff` for linting/formatting - `mypy --strict` for type checking - 80% test coverage target +## Versioning and Releases + +### Commit Messages + +Use [Conventional Commits](https://www.conventionalcommits.org/) for all commit messages: + +``` +(): +``` + +**Common types:** +- `feat`: New feature (triggers minor version bump) +- `fix`: Bug fix (triggers patch version bump) +- `docs`: Documentation only changes +- `refactor`: Code refactoring without behavior change +- `test`: Adding or updating tests +- `chore`: Maintenance tasks + +**Breaking changes:** +Add `!` after type to trigger major version bump: +``` +feat!: change workspace create to require branch argument +``` + +### CHANGELOG + +- **CHANGELOG.md** is automatically updated by semantic-release on each release +- Entries are generated from conventional commit messages +- Unreleased changes are tracked in the `[Unreleased]` section +- Never manually edit the automated sections +- For manual releases, update CHANGELOG.md before tagging + +### Release Process + +Releases are automated via GitHub Actions when commits are pushed to `main`: +1. Commit with conventional commit message +2. Push to main (or merge PR) +3. GitHub Actions analyzes commits and creates release if needed +4. Version is bumped, CHANGELOG is updated, and tag is created + +See [RELEASING.md](RELEASING.md) for full details on versioning and releases. + ## Documentation - [TODO.md](TODO.md) - Active task list +- [CONTRIBUTING.md](CONTRIBUTING.md) - Development guide +- [RELEASING.md](RELEASING.md) - Version management and release process +- [CHANGELOG.md](CHANGELOG.md) - Project changelog (auto-generated) - [docs/design/architecture.md](docs/design/architecture.md) - System design - [docs/adr/](docs/adr/) - Architecture decisions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 47fd66a..09391f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ This guide covers development setup, project architecture, and contribution guid ### Prerequisites -- Python 3.12+ +- Python 3.13 - Git - [uv](https://docs.astral.sh/uv/) for Python package management @@ -181,7 +181,7 @@ uv run pytest ### Python Version -Target Python 3.12+. Use modern Python features: +Target Python 3.13. Use modern Python features: - Type hints on all function signatures (including `-> None`) - `collections.abc` types for abstract containers @@ -405,6 +405,10 @@ docs: update README with new commands test: add coverage for edge cases in naming ``` +## Releasing + +For version management and release procedures, see [RELEASING.md](RELEASING.md). + ## Questions? Open an issue on GitHub for questions about contributing. diff --git a/README.md b/README.md index feb1611..ff3b65f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Workspace orchestration for AI coding agents. Manage isolated workspaces for par ### Prerequisites -- Python 3.12+ +- Python 3.13 - Git - [uv](https://docs.astral.sh/uv/) (Python package manager) diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..36013db --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,403 @@ +# Release Process + +This document describes the versioning and release process for agentspaces. + +## Version Management + +### Version Storage + +The application version must be maintained in **two locations** that must stay synchronized: + +1. **`src/agentspaces/__init__.py`** (primary source of truth) + ```python + __version__ = "0.1.0" + ``` + - Used by the application at runtime + - Displayed by `agentspaces --version` command + - Imported throughout the codebase when version is needed + +2. **`pyproject.toml`** (build metadata) + ```toml + [project] + version = "0.1.0" + ``` + - Used by Hatchling during the build process + - Published to PyPI package metadata + - Required for `pip install` and package distribution + +**Automated Validation:** + +A CI check (`scripts/check_version.py`) automatically validates that both files contain the same version on every pull request. This prevents version drift from reaching the main branch. + +### Semantic Versioning + +agentspaces follows [Semantic Versioning 2.0.0](https://semver.org/): + +``` +MAJOR.MINOR.PATCH +``` + +**Version Increment Rules:** + +- **MAJOR** (1.0.0): Breaking changes to CLI commands, API, or data formats + - Existing workspaces may not work + - Commands removed or significantly changed + - Configuration format changed + - Examples: Removing commands, changing command syntax, incompatible metadata format + +- **MINOR** (0.1.0): New features, backward-compatible changes + - New commands added + - New flags or options added + - Enhanced functionality that doesn't break existing usage + - Examples: Adding new subcommands, new optional flags + +- **PATCH** (0.0.1): Bug fixes, documentation updates, internal refactoring + - No user-facing changes beyond bug fixes + - Performance improvements + - Examples: Fixing crashes, correcting error messages, updating docs + +**Pre-1.0.0 Versioning:** + +- Currently in 0.x.y range (pre-stable) +- MINOR version changes may include breaking changes until 1.0.0 +- Document all breaking changes in release notes + +### Conventional Commits + +This project uses [Conventional Commits](https://www.conventionalcommits.org/) for commit messages. This enables automated versioning and CHANGELOG generation. + +**Commit Message Format:** +``` +(): + +[optional body] + +[optional footer] +``` + +**Types that trigger releases:** +- `feat`: New feature (triggers MINOR version bump) +- `fix`: Bug fix (triggers PATCH version bump) +- `perf`: Performance improvement (triggers PATCH version bump) + +**Types that don't trigger releases:** +- `docs`: Documentation changes +- `style`: Code style changes (formatting, etc.) +- `refactor`: Code refactoring without behavior change +- `test`: Adding or updating tests +- `chore`: Maintenance tasks +- `ci`: CI/CD changes +- `build`: Build system changes + +**Breaking Changes:** +Add `!` after type or `BREAKING CHANGE:` in footer to trigger MAJOR version bump: +``` +feat!: remove workspace sync command + +BREAKING CHANGE: The sync command has been removed. Use create instead. +``` + +**Examples:** +```bash +feat: add workspace sync command +fix: handle missing .python-version file +docs: update README with installation instructions +refactor: extract path resolution to infrastructure +feat(cli)!: change workspace create to require branch argument +``` + +## Release Process + +### Automated Release (Recommended) + +The project uses [python-semantic-release](https://python-semantic-release.readthedocs.io/) for automated versioning and releases. + +**How it works:** + +1. **Commit with conventional commits** to main branch +2. **GitHub Actions automatically**: + - Analyzes commit messages since last release + - Determines version bump (major/minor/patch) + - Updates version in `__init__.py` and `pyproject.toml` + - Generates CHANGELOG.md entries + - Creates git tag (`vX.Y.Z`) + - Creates GitHub release with notes + - Validates version consistency + +**Workflow:** + +```bash +# 1. Make changes and commit using conventional commits +git add . +git commit -m "feat: add new workspace feature" + +# 2. Push to main (directly or via PR merge) +git push origin main + +# 3. GitHub Actions automatically creates release +# - Version is bumped based on commit type +# - Tag and GitHub release are created +# - CHANGELOG is updated +``` + +**Version determination:** +- `feat:` commits → MINOR version bump (0.1.0 → 0.2.0) +- `fix:` or `perf:` commits → PATCH version bump (0.1.0 → 0.1.1) +- `feat!:` or `BREAKING CHANGE:` → MAJOR version bump (0.1.0 → 1.0.0) +- Other commits (docs, chore, etc.) → No release + +**Skip automatic release:** + +Add `[skip ci]` to your commit message to prevent automatic release: +```bash +git commit -m "docs: update README [skip ci]" +``` + +### Manual Release (Special Cases) + +Use manual releases only when: +- Testing the release process locally +- Creating a release without CI/CD (e.g., initial setup) +- Recovering from a failed automated release +- Creating a hotfix outside the normal workflow + +For normal development, **always use the automated release process**. + +#### 1. Prepare the Release + +**a. Ensure clean working tree:** +```bash +git status +# Should show no uncommitted changes +``` + +**b. Run full test suite:** +```bash +uv run ruff check src/ tests/ --fix +uv run ruff format src/ tests/ +uv run mypy src/ +uv run pytest --cov=src +``` + +All checks must pass before proceeding. + +**c. Update version numbers:** + +Decide on the new version number based on semantic versioning rules, then update both files: + +```bash +# Example: bumping from 0.1.0 to 0.2.0 +# Update src/agentspaces/__init__.py +__version__ = "0.2.0" + +# Update pyproject.toml +version = "0.2.0" +``` + +**d. Verify version display and consistency:** +```bash +# Test the CLI displays the new version +uv run agentspaces --version +# Should output: agentspaces 0.2.0 + +# Run the CI version validation check +python scripts/check_version.py +# Should output: ✅ Version consistency check passed: 0.2.0 +``` + +### 2. Create Release Commit + +Commit the version bump with a standardized message: + +```bash +git add src/agentspaces/__init__.py pyproject.toml +git commit -m "chore: bump version to 0.2.0" +``` + +**Commit message format:** +``` +chore: bump version to X.Y.Z +``` + +### 3. Create Git Tag + +Create an annotated tag matching the version: + +```bash +# Create annotated tag with version info +git tag -a v0.2.0 -m "Release version 0.2.0" + +# View the tag +git show v0.2.0 +``` + +**Tag naming convention:** +- Format: `vX.Y.Z` (with 'v' prefix) +- Examples: `v0.1.0`, `v0.2.0`, `v1.0.0` +- Use annotated tags (with `-a` flag), not lightweight tags +- Tag message should be: `"Release version X.Y.Z"` + +### 4. Push to Remote + +Push both the commit and the tag: + +```bash +# Push the commit +git push origin main + +# Push the tag +git push origin v0.2.0 + +# Or push all tags at once (use with caution) +git push origin --tags +``` + +### 5. Build and Publish (Future) + +When ready to publish to PyPI: + +```bash +# Build the package +uv build + +# Publish to PyPI (requires PyPI credentials) +uv publish +``` + +*Note: PyPI publishing is not yet configured.* + +## Verification + +After release, verify the version is correct: + +```bash +# Check version command +agentspaces --version + +# Check git tag +git describe --tags + +# Check latest tag +git tag -l --sort=-version:refname | head -n 1 +``` + +## Release Checklist + +Use this checklist for each release: + +- [ ] All tests passing (`uv run pytest`) +- [ ] Code formatted (`uv run ruff format`) +- [ ] Linting clean (`uv run ruff check`) +- [ ] Type checking passes (`uv run mypy src/`) +- [ ] Version updated in `src/agentspaces/__init__.py` +- [ ] Version updated in `pyproject.toml` +- [ ] Versions match in both files +- [ ] `agentspaces --version` displays correct version +- [ ] Version bump commit created (`chore: bump version to X.Y.Z`) +- [ ] Git tag created (`git tag -a vX.Y.Z -m "Release version X.Y.Z"`) +- [ ] Changes pushed to remote (`git push origin main`) +- [ ] Tag pushed to remote (`git push origin vX.Y.Z`) +- [ ] GitHub release created (optional, future) +- [ ] PyPI package published (optional, future) + +## Common Tasks + +### Check current version + +```bash +# From CLI +agentspaces --version + +# From code +python -c "from agentspaces import __version__; print(__version__)" + +# From git tags +git describe --tags --abbrev=0 +``` + +### Validate version consistency + +```bash +# Run the CI validation check locally +python scripts/check_version.py + +# Or check both locations manually +grep "__version__" src/agentspaces/__init__.py +grep "^version" pyproject.toml +``` + +### List all versions + +```bash +# Show all version tags +git tag -l 'v*' --sort=-version:refname + +# Show tags with dates +git tag -l 'v*' --sort=-version:refname --format='%(refname:short) %(creatordate:short)' +``` + +### Compare versions + +```bash +# Show changes between versions +git log v0.1.0..v0.2.0 --oneline + +# Show detailed diff +git diff v0.1.0..v0.2.0 +``` + +### Fix version mismatch + +If versions get out of sync: + +```bash +# Check both locations +grep "__version__" src/agentspaces/__init__.py +grep "^version" pyproject.toml + +# Update to match (example) +# Edit both files to match, then: +git add src/agentspaces/__init__.py pyproject.toml +git commit -m "chore: sync version numbers to 0.2.0" +``` + +### Delete a tag (if needed) + +```bash +# Delete local tag +git tag -d v0.2.0 + +# Delete remote tag +git push origin :refs/tags/v0.2.0 +``` + +## Version History + +This section provides a high-level summary of major releases. For detailed changes, see [CHANGELOG.md](CHANGELOG.md). + +**When to update:** After each release (automated or manual), add an entry here summarizing the major themes and breaking changes. + +**Format:** +``` +- **vX.Y.Z** (YYYY-MM-DD) - Brief description + - Key feature or change + - Breaking changes (if any) +``` + +### Releases + +- **v0.1.0** (2024-12-22) - Initial release + - Core workspace management + - Git worktree integration + - Basic CLI commands + +## Notes + +- **Automated releases**: CHANGELOG.md is automatically updated by semantic-release +- **Manual releases**: Update CHANGELOG.md manually before creating the release +- Never manually edit version in one location without updating the other +- Always use annotated tags (`-a` flag) for releases +- Tag names must match version with 'v' prefix (e.g., `v0.2.0`) +- Test the version command before creating tags +- Use conventional commit messages to enable automated versioning +- Keep the Version History section updated with major release summaries diff --git a/docs/design/architecture.md b/docs/design/architecture.md index 151442b..0b2f417 100644 --- a/docs/design/architecture.md +++ b/docs/design/architecture.md @@ -66,7 +66,7 @@ Single CLI application with clear layer boundaries. Dependencies flow downward o ## Tech Stack -### Core (Python 3.12+) +### Core (Python 3.13) | Component | Choice | Rationale | |-----------|--------|-----------| diff --git a/pyproject.toml b/pyproject.toml index 615f584..a4391eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "0.1.0" description = "Workspace orchestration tool for AI coding agents" readme = "README.md" license = "MIT" -requires-python = ">=3.12" +requires-python = ">=3.13" authors = [{ name = "Chris Krough" }] keywords = ["ai", "agents", "workspace", "git", "worktree", "claude", "skills"] classifiers = [ @@ -17,7 +17,6 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Version Control :: Git", ] @@ -40,6 +39,7 @@ dev = [ "mypy>=1.13.0", "ruff>=0.8.0", "types-pyyaml>=6.0.0", + "python-semantic-release>=9.0.0", ] [project.scripts] @@ -63,7 +63,7 @@ include = [ ] [tool.ruff] -target-version = "py312" +target-version = "py313" line-length = 88 src = ["src", "tests"] @@ -88,7 +88,7 @@ ignore = ["E501"] # Line too long (handled by formatter) known-first-party = ["agentspaces"] [tool.mypy] -python_version = "3.12" +python_version = "3.13" strict = true warn_return_any = true warn_unused_ignores = true @@ -126,3 +126,51 @@ exclude_lines = [ "if TYPE_CHECKING:", "raise NotImplementedError", ] + +[tool.semantic_release] +version_toml = ["pyproject.toml:project.version"] +version_variables = ["src/agentspaces/__init__.py:__version__"] +build_command = "uv build" +major_on_zero = true +tag_format = "v{version}" + +[tool.semantic_release.branches.main] +match = "main" +prerelease = false + +[tool.semantic_release.changelog] +changelog_file = "CHANGELOG.md" +exclude_commit_patterns = [ + "chore\\(release\\):.*", + "Merge.*", +] + +[tool.semantic_release.changelog.environment] +block_start_string = "{%" +block_end_string = "%}" +variable_start_string = "{{" +variable_end_string = "}}" +comment_start_string = "{#" +comment_end_string = "#}" +trim_blocks = false +lstrip_blocks = false +newline_sequence = "\n" +keep_trailing_newline = false +extensions = [] +autoescape = true + +[tool.semantic_release.commit_parser_options] +allowed_tags = [ + "feat", + "fix", + "docs", + "style", + "refactor", + "perf", + "test", + "chore", + "ci", + "build", +] +minor_tags = ["feat"] +patch_tags = ["fix", "perf"] diff --git a/scripts/check_version.py b/scripts/check_version.py new file mode 100755 index 0000000..a62a0e2 --- /dev/null +++ b/scripts/check_version.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Validate version consistency between __init__.py and pyproject.toml. + +This script ensures the version in src/agentspaces/__init__.py matches +the version in pyproject.toml to prevent version drift. + +Exit codes: + 0: Versions match + 1: Version mismatch or error +""" + +from __future__ import annotations + +import re +import sys +import tomllib +from pathlib import Path + + +def get_init_version(init_file: Path) -> str | None: + """Extract version from __init__.py file. + + Args: + init_file: Path to __init__.py file. + + Returns: + Version string if found, None otherwise. + """ + content = init_file.read_text(encoding="utf-8") + match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', content, re.MULTILINE) + return match.group(1) if match else None + + +def get_pyproject_version(pyproject_file: Path) -> str | None: + """Extract version from pyproject.toml file. + + Args: + pyproject_file: Path to pyproject.toml file. + + Returns: + Version string if found, None otherwise. + """ + try: + content = pyproject_file.read_text(encoding="utf-8") + data = tomllib.loads(content) + return data.get("project", {}).get("version") + except (tomllib.TOMLDecodeError, KeyError): + return None + + +def main() -> int: + """Main validation logic. + + Returns: + Exit code (0 for success, 1 for failure). + """ + # Determine project root (script is in scripts/ directory) + project_root = Path(__file__).parent.parent + init_file = project_root / "src" / "agentspaces" / "__init__.py" + pyproject_file = project_root / "pyproject.toml" + + # Check files exist + if not init_file.exists(): + print(f"❌ Error: {init_file} not found", file=sys.stderr) + return 1 + + if not pyproject_file.exists(): + print(f"❌ Error: {pyproject_file} not found", file=sys.stderr) + return 1 + + # Extract versions + init_version = get_init_version(init_file) + if init_version is None: + print(f"❌ Error: Could not find __version__ in {init_file}", file=sys.stderr) + return 1 + + pyproject_version = get_pyproject_version(pyproject_file) + if pyproject_version is None: + print(f"❌ Error: Could not find version in {pyproject_file}", file=sys.stderr) + return 1 + + # Compare versions + if init_version != pyproject_version: + print("❌ Version mismatch detected!", file=sys.stderr) + print(f" src/agentspaces/__init__.py: {init_version}", file=sys.stderr) + print(f" pyproject.toml: {pyproject_version}", file=sys.stderr) + print(file=sys.stderr) + print("Please update both files to match.", file=sys.stderr) + print("See RELEASING.md for version management guidelines.", file=sys.stderr) + return 1 + + # Success + print(f"✅ Version consistency check passed: {init_version}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/agentspaces/cli/workspace.py b/src/agentspaces/cli/workspace.py index 83a528f..17bd4c3 100644 --- a/src/agentspaces/cli/workspace.py +++ b/src/agentspaces/cli/workspace.py @@ -63,7 +63,7 @@ def create( ] = None, python_version: Annotated[ str | None, - typer.Option("--python", help="Python version for venv (e.g., 3.12)"), + typer.Option("--python", help="Python version for venv (e.g., 3.13)"), ] = None, no_venv: Annotated[ bool, diff --git a/src/agentspaces/infrastructure/uv.py b/src/agentspaces/infrastructure/uv.py index 84e342f..6018adc 100644 --- a/src/agentspaces/infrastructure/uv.py +++ b/src/agentspaces/infrastructure/uv.py @@ -30,7 +30,7 @@ logger = structlog.get_logger() -# Valid Python version pattern: X.Y or X.Y.Z (e.g., "3.12", "3.12.1") +# Valid Python version pattern: X.Y or X.Y.Z (e.g., "3.13", "3.13.1") _PYTHON_VERSION_PATTERN = re.compile(r"^3\.\d{1,2}(\.\d{1,2})?$") # Default timeout for uv operations (60 seconds - longer for installs) @@ -162,7 +162,7 @@ def venv_create( Args: path: Path where the venv will be created. - python_version: Python version to use (e.g., "3.12", "3.13"). + python_version: Python version to use (e.g., "3.13", "3.13"). seed: Whether to seed with pip/setuptools. Raises: @@ -176,7 +176,7 @@ def venv_create( if not _PYTHON_VERSION_PATTERN.match(python_version): raise ValueError( f"Invalid Python version format: {python_version}. " - "Expected format: X.Y or X.Y.Z (e.g., '3.12', '3.12.1')" + "Expected format: X.Y or X.Y.Z (e.g., '3.13', '3.13.1')" ) args.extend(["--python", python_version]) @@ -253,7 +253,7 @@ def detect_python_version(project_path: Path) -> str | None: project_path: Path to the project directory. Returns: - Python version string (e.g., "3.12") or None if not detected. + Python version string (e.g., "3.13") or None if not detected. """ # Check .python-version file python_version_file = project_path / ".python-version" @@ -277,7 +277,7 @@ def detect_python_version(project_path: Path) -> str | None: # Look for requires-python in [project] requires_python = data.get("project", {}).get("requires-python", "") if requires_python: - # Extract version from constraint like ">=3.12" or ">=3.12,<4" + # Extract version from constraint like ">=3.13" or ">=3.13,<4" parsed_version = _parse_requires_python(requires_python) if parsed_version: logger.debug( @@ -299,12 +299,12 @@ def _parse_requires_python(constraint: str) -> str | None: """Parse a requires-python constraint to extract a version. Args: - constraint: Version constraint like ">=3.12" or ">=3.12,<4". + constraint: Version constraint like ">=3.13" or ">=3.13,<4". Returns: - Version string like "3.12" or None. + Version string like "3.13" or None. """ - # Match patterns like ">=3.12", "~=3.12", "==3.12" + # Match patterns like ">=3.13", "~=3.13", "==3.13" match = re.search(r"[>=~=]+\s*(\d+\.\d+)", constraint) if match: return match.group(1) diff --git a/src/agentspaces/modules/workspace/environment.py b/src/agentspaces/modules/workspace/environment.py index 85cd64a..08ab014 100644 --- a/src/agentspaces/modules/workspace/environment.py +++ b/src/agentspaces/modules/workspace/environment.py @@ -153,7 +153,7 @@ def _get_venv_python_version(workspace_path: Path) -> str | None: try: for line in pyvenv_cfg.read_text(encoding="utf-8").splitlines(): if line.startswith("version"): - # Line like "version = 3.12.0" + # Line like "version = 3.13.0" parts = line.split("=") if len(parts) >= 2: full_version = parts[1].strip() diff --git a/src/agentspaces/templates/skeleton/CLAUDE.md b/src/agentspaces/templates/skeleton/CLAUDE.md index 4ded720..e00fdd0 100644 --- a/src/agentspaces/templates/skeleton/CLAUDE.md +++ b/src/agentspaces/templates/skeleton/CLAUDE.md @@ -20,7 +20,7 @@ variables: # {{ project_name }} ## Tech Stack -{{ tech_stack | default("Python 3.12+, pytest, ruff, mypy") }} +{{ tech_stack | default("Python 3.13, pytest, ruff, mypy") }} {% if dependencies %} Dependencies: {{ dependencies }} {% endif %} diff --git a/src/agentspaces/templates/skeleton/README.md b/src/agentspaces/templates/skeleton/README.md index 6bcdea2..de9cac7 100644 --- a/src/agentspaces/templates/skeleton/README.md +++ b/src/agentspaces/templates/skeleton/README.md @@ -43,7 +43,7 @@ variables: - {{ prereq }} {% endfor %} {% else %} -- Python 3.12+ +- Python 3.13 - [uv](https://docs.astral.sh/uv/) (Python package manager) {% endif %} diff --git a/src/agentspaces/templates/skeleton/docs/design/architecture.md b/src/agentspaces/templates/skeleton/docs/design/architecture.md index e7e5fdf..77fefb4 100644 --- a/src/agentspaces/templates/skeleton/docs/design/architecture.md +++ b/src/agentspaces/templates/skeleton/docs/design/architecture.md @@ -69,7 +69,7 @@ variables: ## Tech Stack -### Backend (Python {{ python_version | default("3.12+") }}) +### Backend (Python {{ python_version | default("3.13") }}) {% if tech_stack_backend %} | Component | Choice | Rationale | diff --git a/tests/conftest.py b/tests/conftest.py index 724b7c3..7cefd9e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,7 @@ @pytest.fixture -def temp_dir() -> Generator[Path, None, None]: +def temp_dir() -> Generator[Path]: """Create a temporary directory for tests.""" with tempfile.TemporaryDirectory() as tmpdir: yield Path(tmpdir) diff --git a/tests/unit/infrastructure/test_metadata.py b/tests/unit/infrastructure/test_metadata.py index 339894e..f346fd5 100644 --- a/tests/unit/infrastructure/test_metadata.py +++ b/tests/unit/infrastructure/test_metadata.py @@ -61,7 +61,7 @@ def test_metadata_all_fields(self) -> None: base_branch="main", created_at=created_at, purpose="Test purpose", - python_version="3.12", + python_version="3.13", has_venv=True, status="active", ) @@ -72,7 +72,7 @@ def test_metadata_all_fields(self) -> None: assert metadata.base_branch == "main" assert metadata.created_at == created_at assert metadata.purpose == "Test purpose" - assert metadata.python_version == "3.12" + assert metadata.python_version == "3.13" assert metadata.has_venv is True assert metadata.status == "active" diff --git a/tests/unit/infrastructure/test_skills.py b/tests/unit/infrastructure/test_skills.py index dc7092a..b971f51 100644 --- a/tests/unit/infrastructure/test_skills.py +++ b/tests/unit/infrastructure/test_skills.py @@ -126,7 +126,7 @@ def test_includes_python_version_when_set(self, temp_dir: Path) -> None: branch="test-workspace", base_branch="main", created_at=datetime.now(UTC), - python_version="3.12", + python_version="3.13", has_venv=True, ) output_dir = temp_dir / "skills" / "workspace-context" @@ -134,7 +134,7 @@ def test_includes_python_version_when_set(self, temp_dir: Path) -> None: result = generate_workspace_context_skill(metadata, output_dir) content = result.read_text(encoding="utf-8") - assert "3.12" in content + assert "3.13" in content def test_overwrites_existing_skill(self, temp_dir: Path) -> None: """Should overwrite existing skill file.""" diff --git a/tests/unit/infrastructure/test_uv.py b/tests/unit/infrastructure/test_uv.py index b229a79..72c4ef7 100644 --- a/tests/unit/infrastructure/test_uv.py +++ b/tests/unit/infrastructure/test_uv.py @@ -84,7 +84,7 @@ def test_venv_create_with_python_version(self, temp_dir: Path) -> None: venv_path = temp_dir / ".venv" # Use the current Python version - uv.venv_create(venv_path, python_version="3.12") + uv.venv_create(venv_path, python_version="3.13") assert venv_path.exists() @@ -104,7 +104,7 @@ def test_venv_create_rejects_command_injection(self, temp_dir: Path) -> None: venv_path = temp_dir / ".venv" with pytest.raises(ValueError, match="Invalid Python version format"): - uv.venv_create(venv_path, python_version="3.12; rm -rf /") + uv.venv_create(venv_path, python_version="3.13; rm -rf /") class TestDetectPythonVersion: @@ -112,24 +112,24 @@ class TestDetectPythonVersion: def test_detect_from_python_version_file(self, temp_dir: Path) -> None: """Should detect version from .python-version file.""" - (temp_dir / ".python-version").write_text("3.12\n") + (temp_dir / ".python-version").write_text("3.13\n") version = uv.detect_python_version(temp_dir) - assert version == "3.12" + assert version == "3.13" def test_detect_from_pyproject_toml(self, temp_dir: Path) -> None: """Should detect version from pyproject.toml.""" pyproject_content = """ [project] name = "test" -requires-python = ">=3.12" +requires-python = ">=3.13" """ (temp_dir / "pyproject.toml").write_text(pyproject_content) version = uv.detect_python_version(temp_dir) - assert version == "3.12" + assert version == "3.13" def test_detect_from_pyproject_toml_with_upper_bound(self, temp_dir: Path) -> None: """Should detect version from requires-python with upper bound.""" @@ -170,7 +170,7 @@ class TestParseRequiresPython: def test_parse_gte_constraint(self) -> None: """Should parse >=X.Y constraint.""" - assert uv._parse_requires_python(">=3.12") == "3.12" + assert uv._parse_requires_python(">=3.13") == "3.13" def test_parse_tilde_constraint(self) -> None: """Should parse ~=X.Y constraint.""" @@ -182,7 +182,7 @@ def test_parse_eq_constraint(self) -> None: def test_parse_with_upper_bound(self) -> None: """Should extract lower bound from range.""" - assert uv._parse_requires_python(">=3.12,<4") == "3.12" + assert uv._parse_requires_python(">=3.13,<4") == "3.13" def test_parse_invalid_returns_none(self) -> None: """Should return None for invalid constraint.""" @@ -240,19 +240,19 @@ def test_whitespace_only_python_version_file(self, temp_dir: Path) -> None: def test_python_version_file_with_trailing_newline(self, temp_dir: Path) -> None: """Should handle .python-version files with trailing newlines.""" # Standard format: version followed by newline - (temp_dir / ".python-version").write_text("3.12\n") + (temp_dir / ".python-version").write_text("3.13\n") version = uv.detect_python_version(temp_dir) - assert version == "3.12" + assert version == "3.13" def test_python_version_file_with_patch_version(self, temp_dir: Path) -> None: - """Should handle full patch versions like 3.12.1.""" - (temp_dir / ".python-version").write_text("3.12.1\n") + """Should handle full patch versions like 3.13.1.""" + (temp_dir / ".python-version").write_text("3.13.1\n") version = uv.detect_python_version(temp_dir) - assert version == "3.12.1" + assert version == "3.13.1" def test_malformed_pyproject_toml(self, temp_dir: Path) -> None: """Should return None for malformed pyproject.toml.""" diff --git a/tests/unit/modules/workspace/test_environment.py b/tests/unit/modules/workspace/test_environment.py index caf5f00..b57c299 100644 --- a/tests/unit/modules/workspace/test_environment.py +++ b/tests/unit/modules/workspace/test_environment.py @@ -18,13 +18,13 @@ def test_environment_info_attributes(self) -> None: """EnvironmentInfo should store all environment details.""" info = environment.EnvironmentInfo( has_venv=True, - python_version="3.12", + python_version="3.13", has_pyproject=True, venv_path=Path("/some/path/.venv"), ) assert info.has_venv is True - assert info.python_version == "3.12" + assert info.python_version == "3.13" assert info.has_pyproject is True assert info.venv_path == Path("/some/path/.venv") @@ -42,7 +42,7 @@ def test_setup_environment_creates_venv(self, temp_dir: Path) -> None: def test_setup_environment_with_python_version(self, temp_dir: Path) -> None: """Should use specified Python version.""" - result = environment.setup_environment(temp_dir, python_version="3.12") + result = environment.setup_environment(temp_dir, python_version="3.13") assert result.has_venv is True # The version should be set (may include patch version) @@ -50,7 +50,7 @@ def test_setup_environment_with_python_version(self, temp_dir: Path) -> None: def test_setup_environment_detects_version(self, temp_dir: Path) -> None: """Should auto-detect Python version.""" - (temp_dir / ".python-version").write_text("3.12\n") + (temp_dir / ".python-version").write_text("3.13\n") result = environment.setup_environment(temp_dir) @@ -65,7 +65,7 @@ def test_setup_environment_syncs_deps_when_pyproject_exists( [project] name = "test-project" version = "0.1.0" -requires-python = ">=3.12" +requires-python = ">=3.13" dependencies = [] """ (temp_dir / "pyproject.toml").write_text(pyproject_content) @@ -228,7 +228,7 @@ def test_environment_info_is_frozen(self) -> None: info = environment.EnvironmentInfo( has_venv=True, - python_version="3.12", + python_version="3.13", has_pyproject=True, venv_path=Path("/some/path/.venv"), ) diff --git a/tests/unit/modules/workspace/test_service.py b/tests/unit/modules/workspace/test_service.py index 36f11f8..9d9fbf1 100644 --- a/tests/unit/modules/workspace/test_service.py +++ b/tests/unit/modules/workspace/test_service.py @@ -26,7 +26,7 @@ def test_workspace_info_attributes(self) -> None: branch="test-workspace", base_branch="main", project="test-project", - python_version="3.12", + python_version="3.13", has_venv=True, ) @@ -35,7 +35,7 @@ def test_workspace_info_attributes(self) -> None: assert info.branch == "test-workspace" assert info.base_branch == "main" assert info.project == "test-project" - assert info.python_version == "3.12" + assert info.python_version == "3.13" assert info.has_venv is True def test_workspace_info_defaults(self) -> None: diff --git a/tests/unit/test_version_check.py b/tests/unit/test_version_check.py new file mode 100644 index 0000000..9fbc939 --- /dev/null +++ b/tests/unit/test_version_check.py @@ -0,0 +1,191 @@ +"""Tests for version consistency validation script.""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +# Import the functions to test them directly +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) +from scripts.check_version import get_init_version, get_pyproject_version + + +def test_version_check_passes_on_consistent_versions() -> None: + """Version check script should exit 0 when versions match.""" + result = subprocess.run( + [sys.executable, "scripts/check_version.py"], + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + assert "Version consistency check passed" in result.stdout + # Should output a version in semver format + assert re.search(r"\d+\.\d+\.\d+", result.stdout) + + +def test_version_check_detects_mismatch(tmp_path: Path) -> None: + """Version check should detect when versions don't match.""" + + # Create temporary files with mismatched versions + init_file = tmp_path / "__init__.py" + init_file.write_text('__version__ = "0.2.0"\n') + + pyproject_file = tmp_path / "pyproject.toml" + pyproject_file.write_text('[project]\nversion = "0.1.0"\n') + + # Create a test script that checks these specific files + test_script = tmp_path / "test_check.py" + # Use triple quotes and raw strings to avoid escaping issues + script_content = f'''import re +import sys +from pathlib import Path + +init_file = Path(r"{init_file}") +pyproject_file = Path(r"{pyproject_file}") + +init_content = init_file.read_text() +pyproject_content = pyproject_file.read_text() + +init_match = re.search(r"^__version__.*=.*[\\"']([^\\"']+)[\\"']", init_content, re.MULTILINE) +pyproject_match = re.search(r"^version.*=.*[\\"']([^\\"']+)[\\"']", pyproject_content, re.MULTILINE) + +init_version = init_match.group(1) if init_match else None +pyproject_version = pyproject_match.group(1) if pyproject_match else None + +if init_version != pyproject_version: + print("Version mismatch", file=sys.stderr) + print(f"init: {{init_version}}", file=sys.stderr) + print(f"pyproject: {{pyproject_version}}", file=sys.stderr) + sys.exit(1) +''' + test_script.write_text(script_content) + + result = subprocess.run( + [sys.executable, str(test_script)], + capture_output=True, + text=True, + ) + + assert result.returncode == 1 + assert "mismatch" in result.stderr.lower() + assert "0.2.0" in result.stderr + assert "0.1.0" in result.stderr + + +def test_version_check_handles_missing_files(tmp_path: Path) -> None: + """Version check should error gracefully when files are missing.""" + + # Create a test script that looks for non-existent files + test_script = tmp_path / "test_check.py" + nonexistent_file = tmp_path / "nonexistent" / "__init__.py" + + script_content = f'''import sys +from pathlib import Path + +init_file = Path(r"{nonexistent_file}") +if not init_file.exists(): + print(f"Error: {{init_file}} not found", file=sys.stderr) + sys.exit(1) +''' + test_script.write_text(script_content) + + result = subprocess.run( + [sys.executable, str(test_script)], + capture_output=True, + text=True, + ) + + assert result.returncode == 1 + assert "Error" in result.stderr + assert "not found" in result.stderr + + +# Unit tests for individual functions + + +def test_get_init_version_extracts_version(tmp_path: Path) -> None: + """get_init_version should extract version from __init__.py.""" + init_file = tmp_path / "__init__.py" + init_file.write_text('__version__ = "1.2.3"\n', encoding="utf-8") + + version = get_init_version(init_file) + + assert version == "1.2.3" + + +def test_get_init_version_handles_single_quotes(tmp_path: Path) -> None: + """get_init_version should handle single-quoted versions.""" + init_file = tmp_path / "__init__.py" + init_file.write_text("__version__ = '4.5.6'\n", encoding="utf-8") + + version = get_init_version(init_file) + + assert version == "4.5.6" + + +def test_get_init_version_returns_none_when_not_found(tmp_path: Path) -> None: + """get_init_version should return None if no version found.""" + init_file = tmp_path / "__init__.py" + init_file.write_text("# No version here\n", encoding="utf-8") + + version = get_init_version(init_file) + + assert version is None + + +def test_get_pyproject_version_extracts_version(tmp_path: Path) -> None: + """get_pyproject_version should extract version from pyproject.toml.""" + pyproject_file = tmp_path / "pyproject.toml" + pyproject_file.write_text( + '[project]\nname = "test"\nversion = "2.3.4"\n', encoding="utf-8" + ) + + version = get_pyproject_version(pyproject_file) + + assert version == "2.3.4" + + +def test_get_pyproject_version_handles_complex_toml(tmp_path: Path) -> None: + """get_pyproject_version should handle complex TOML structure.""" + pyproject_file = tmp_path / "pyproject.toml" + pyproject_file.write_text( + """ +[tool.semantic_release] +version_toml = ["pyproject.toml:project.version"] + +[project] +name = "test" +version = "3.4.5" + +[tool.other] +version = "9.9.9" +""", + encoding="utf-8", + ) + + version = get_pyproject_version(pyproject_file) + + assert version == "3.4.5" + + +def test_get_pyproject_version_returns_none_for_missing_section(tmp_path: Path) -> None: + """get_pyproject_version should return None if project section missing.""" + pyproject_file = tmp_path / "pyproject.toml" + pyproject_file.write_text('[tool.other]\nfoo = "bar"\n', encoding="utf-8") + + version = get_pyproject_version(pyproject_file) + + assert version is None + + +def test_get_pyproject_version_returns_none_for_invalid_toml(tmp_path: Path) -> None: + """get_pyproject_version should return None for invalid TOML.""" + pyproject_file = tmp_path / "pyproject.toml" + pyproject_file.write_text("invalid [ toml ]\n", encoding="utf-8") + + version = get_pyproject_version(pyproject_file) + + assert version is None