diff --git a/.claude/commands/sync-project-template.md b/.claude/commands/sync-project-template.md new file mode 100644 index 0000000..d43ef46 --- /dev/null +++ b/.claude/commands/sync-project-template.md @@ -0,0 +1,119 @@ +# Sync Project Template + +## Description +Analyze a target project against the Python project template, identify what's missing or out of sync, and guide the user through syncing with the latest template. + +## Usage + +```bash +/sync-project-template /path/to/target-project +``` + +### Example + +```bash +/sync-project-template ~/Documents/workspace/nba-persistence +``` + +## What It Does + +This command performs these steps: + +1. **Analyzes** the target project structure +2. **Compares** against the template at `~/Documents/workspace/python-project-template/{{cookiecutter.repository}}/` +3. **Reports** what's present, missing, or out of sync +4. **Recommends** migration steps needed +5. **Confirms** with user before syncing +6. **Syncs** files if approved, creating a git commit + +## Infrastructure Checklist + +### Core Components (Usually Present) +- ✓ Git repository (.git) +- ✓ Project metadata (pyproject.toml) +- ✓ UV configuration ([tool.uv] in pyproject.toml) +- ✓ Task automation (justfile) +- ✓ Task definitions (tasks/ directory) +- ✓ Build configuration (.gitignore, .python-version) + +### Advanced Components (Optional) +- ? Pre-commit hooks (.pre-commit-config.yaml) +- ? CI/CD (.github/workflows/) +- ? Containerization (Dockerfile, docker-compose.yml) + +## Sample Analysis Report + +``` +PROJECT SYNC ANALYSIS +====================================================================== +Target: /Users/you/nba-persistence +Template: ~/Documents/workspace/python-project-template/{{cookiecutter.repository}}/ + +INFRASTRUCTURE STATUS +---------------------------------------------------------------------- + ✓ Git repository + ✓ pyproject.toml + ✓ UV configuration (in pyproject.toml) + ✓ justfile + ✓ tasks/ directory + ✓ .gitignore + ✓ .python-version + ✗ .pre-commit-config.yaml + ✗ .github/workflows/ + ✗ Dockerfile + ✗ docker-compose.yml + +MIGRATION RECOMMENDATIONS +---------------------------------------------------------------------- + 1. Add .pre-commit-config.yaml for automated code quality checks + 2. Add GitHub Actions workflows (.github/workflows/) for CI/CD + 3. Add Dockerfile for containerization + +FILES READY TO SYNC +---------------------------------------------------------------------- + • tasks/ Task definitions and automation + • justfile Just task runner configuration + • .gitignore Git ignore patterns + • .python-version Python version specification +``` + +## Sync Confirmation + +After analysis, you'll be asked to confirm: + +``` +Ready to sync? This will: +- Sync tasks/ directory (all task definitions) +- Update justfile, .gitignore, .python-version +- Create a git commit with all changes + +Would you like to proceed? (yes/no) +``` + +Answering "yes" will: +- Run the sync tool on your project +- Stage and commit all changes to git +- Show summary of what was synced + +Answering "no" will: +- Show what would have been synced +- Exit without making changes + +## Behind the Scenes + +This command uses two tools: +- **~/Documents/workspace/python-project-template/sync_analyzer.py** - Analyzes project structure and generates report +- **~/Documents/workspace/python-project-template/template_sync_cli.py** - Performs the actual file sync + +Both tools use loguru for clean, structured logging. + +## Requirements + +- Target project must be a git repository +- Template must exist at `~/Documents/workspace/python-project-template/` + +## Notes + +- Changes are always previewed before syncing +- All synced changes go into a single commit +- Files that haven't changed are automatically skipped \ No newline at end of file diff --git a/.gitignore b/.gitignore index ed29e00..acd1f51 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ __pycache__/ .ruff_cache/ .pytest_cache/ .ipynb_checkpoints/ + +# Claude +.claude/ diff --git a/README.md b/README.md index d23adf9..2ae723e 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,47 @@ uv run just check ### Type checking +### Syncing Projects with the Latest Template + +If you have older projects created from this template before recent updates, you can sync them with the latest template files using the `template_sync_cli.py` utility. + +#### Using the Sync Tool + +The sync tool updates high-priority files in your project to match the latest template: +- `tasks/` - Task definitions for development automation +- `justfile` - Task runner configuration +- `.gitignore` - Git ignore patterns +- `.python-version` - Python version specification + +**Basic usage:** + +```bash +# Sync your project with the template +python template_sync_cli.py --source /path/to/template --target /path/to/your-project + +# Preview changes without committing (dry-run) +python template_sync_cli.py --source /path/to/template --target /path/to/your-project --dry-run +``` + +**With Claude Code:** + +```bash +/sync-project-template --source ~/python-project-template --target ~/your-project +``` + +The sync tool will: +1. Validate both source (template) and target (project) directories +2. Copy updated files and directories +3. Stage changes in git +4. Create a commit with a summary of synced files + +#### Important Notes + +- Your project must be a git repository for syncing to work +- The tool skips files that haven't changed +- A commit is automatically created with all synced changes +- Use `--dry-run` to preview changes before committing + ## Quick Start 1. **Generate your project:** diff --git a/SYNC_TOOL_README.md b/SYNC_TOOL_README.md new file mode 100644 index 0000000..6f23c39 --- /dev/null +++ b/SYNC_TOOL_README.md @@ -0,0 +1,265 @@ +# Template Sync Tool - Complete Setup + +This document describes the complete setup for syncing Python projects with the template. + +## 📁 File Structure + +``` +python-project-template/ +├── sync_project_template/ # Core sync engine package +│ ├── __init__.py +│ └── sync.py # Main sync logic with loguru +├── sync_analyzer.py # Project analysis tool +├── template_sync_cli.py # CLI wrapper for sync +└── SYNC_TOOL_README.md # This file + +~/.claude/commands/ +└── sync-project-template.md # Claude Code command spec +``` + +## 🚀 Quick Start + +### Using the CLI Directly + +```bash +# Analyze a project without syncing +uv run python sync_analyzer.py ~/target-project \ + --template /path/to/template/{{cookiecutter.repository}} + +# Sync a project (preview first with --dry-run) +uv run python template_sync_cli.py \ + --source /path/to/template/{{cookiecutter.repository}} \ + --target ~/target-project \ + --dry-run + +# Sync for real +uv run python template_sync_cli.py \ + --source /path/to/template/{{cookiecutter.repository}} \ + --target ~/target-project +``` + +### Using Claude Code Command + +A Claude Code command is set up to allow running inside Claude Code sessions. See `.claude/commands/sync-project-template.md` for details. + +```bash +/sync-project-template ~/target-project +``` + +## 🔧 Components + +### 1. sync_project_template Package +**Location**: `sync_project_template/sync.py` + +Core sync engine with the `TemplateSyncer` class. + +**Features**: +- Validates source template and target project +- Syncs files/directories: `tasks/`, `justfile`, `.gitignore`, `.python-version` +- Smart file comparison (skips unchanged files) +- Git integration (auto-staging and committing) +- Dry-run mode for previewing changes +- Loguru logging for clean output + +**Usage**: +```python +from sync_project_template.sync import TemplateSyncer + +syncer = TemplateSyncer( + source_path="~/python-project-template/{{cookiecutter.repository}}", + target_path="~/my-project", + dry_run=True # Preview only +) +syncer.run() +``` + +### 2. sync_analyzer.py +**Location**: `sync_analyzer.py` + +Project analysis and reporting tool. + +**What it checks**: +- Git repository presence +- pyproject.toml presence +- UV configuration +- Build automation (justfile) +- Task definitions (tasks/) +- Configuration files (.gitignore, .python-version) +- Pre-commit hooks +- GitHub Actions CI/CD +- Docker configuration + +**Usage**: +```bash +uv run python sync_analyzer.py ~/target-project +``` + +**Output**: +- ✓ Checklist of what's present +- ✗ Checklist of what's missing +- Numbered recommendations for migration + +### 3. template_sync_cli.py +**Location**: `template_sync_cli.py` + +Standalone CLI wrapper for the sync engine. + +**Usage**: +```bash +uv run python template_sync_cli.py --source SOURCE --target TARGET [--dry-run] +``` + +**Arguments**: +- `--source` (required): Path to template directory +- `--target` (required): Path to target project +- `--dry-run` (optional): Preview changes without committing + +### 4. Claude Code Command +**Location**: `~/.claude/commands/sync-project-template.md` + +Integration with Claude Code for guided sync workflow. + +**Invocation**: +```bash +/sync-project-template ~/target-project +``` + +**Workflow**: +1. Analyzes target project +2. Shows infrastructure report +3. Lists migration recommendations +4. Asks for confirmation +5. Syncs files if approved +6. Creates descriptive git commit + +## 📊 Files Synced + +When you run the sync tool, these files are synced: + +| File/Directory | Purpose | +|---|---| +| `tasks/` | Just command definitions (all task files) | +| `justfile` | Task runner configuration | +| `.gitignore` | Git ignore patterns | +| `.python-version` | Python version specification | + +## 🔄 Workflow Example + +### Scenario: Sync nba-persistence project + +1. **Analyze first**: + ```bash + uv run python sync_analyzer.py ~/nba-persistence \ + --template ~/python-project-template/{{cookiecutter.repository}} + ``` + + Output: + ``` + ✓ 7 items present and in sync + ✗ 4 items missing: + - .pre-commit-config.yaml + - .github/workflows/ + - Dockerfile + - docker-compose.yml + + RECOMMENDATIONS: + 1. Add .pre-commit-config.yaml for code quality + 2. Add GitHub Actions workflows for CI/CD + 3. Add Dockerfile for containerization + + FILES READY TO SYNC: + • tasks/ (directory, 9 items) + • justfile + • .gitignore + • .python-version + ``` + +2. **Preview the sync**: + ```bash + uv run python template_sync_cli.py \ + --source ~/python-project-template/{{cookiecutter.repository}} \ + --target ~/nba-persistence \ + --dry-run + ``` + +3. **Run the actual sync**: + ```bash + uv run python template_sync_cli.py \ + --source ~/python-project-template/{{cookiecutter.repository}} \ + --target ~/nba-persistence + ``` + +4. **Verify**: + ```bash + cd ~/nba-persistence + git log --oneline -1 # See the sync commit + ``` + +## 📝 Testing + +All components are tested: + +```bash +# Run the full test suite +uv run pytest tests/test_sync.py -v + +# Run specific test class +uv run pytest tests/test_sync.py::TestTemplateSyncerOperations -v +``` + +## 🛠️ Dependencies + +All tools use: +- **loguru**: Structured logging (already in template dependencies) +- **pathlib**: File operations +- **subprocess**: Git operations +- **argparse**: CLI argument parsing + +No additional dependencies are needed. + +## 🔐 Safety Features + +- ✓ Validates source and target paths before syncing +- ✓ Checks if target is a git repository +- ✓ Warns if git working directory is dirty +- ✓ Skips files that haven't changed +- ✓ Provides dry-run mode to preview changes +- ✓ Creates descriptive git commits with all changes +- ✓ All operations are reversible (git history) + +## 🚧 Future Enhancements + +Planned improvements: + +- [ ] Smart merge for `pyproject.toml` (preserve custom deps, update tools) +- [ ] Smart merge for `.pre-commit-config.yaml` +- [ ] Sync GitHub Actions workflows +- [ ] Configuration file for custom sync targets +- [ ] Rollback capability (git revert) +- [ ] Support for syncing newer/older versions of template + +## 📞 Troubleshooting + +### "Template path does not exist" +- Verify the template directory path is correct +- Use the full path or ensure the `~` expands correctly + +### "Target is not a git repository" +- Initialize git in the target project: `git init` +- Create an initial commit: `git add . && git commit -m "Initial commit"` + +### "No changes to sync" +- Run `sync_analyzer.py` to see what's missing +- The tool skips files that are already in sync +- Use `--dry-run` to preview what would be synced + +### Git commit failed +- Check that `git config user.name` and `user.email` are set +- Ensure you have write permissions to the repository +- Check if the working directory has uncommitted changes + +## 📚 Additional Resources + +- [Template Documentation](README.md) +- [Sync Tool Tests](tests/test_sync.py) +- [Just Task Definitions]({{cookiecutter.repository}}/tasks/) diff --git a/pyproject.toml b/pyproject.toml index 52b1d1d..97fb1a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dev = [ "pytest-shell-utilities>=1.9.7", "pytest>=8.3.4", "rust-just>=1.39.0", + "loguru>=0.7.3", ] # TOOLS diff --git a/sync_analyzer.py b/sync_analyzer.py new file mode 100644 index 0000000..7677ce8 --- /dev/null +++ b/sync_analyzer.py @@ -0,0 +1,222 @@ +""" +Analyze a project and compare it with the template. + +This script generates a detailed report of what's missing or out of sync +before running the sync tool. + +Usage: + uv run python sync_analyzer.py ~/path/to/project +""" + +import sys +from pathlib import Path +from typing import Dict, Tuple + +from loguru import logger + + +class ProjectAnalyzer: + """Analyzes a project against the template structure.""" + + # Items to check in the project + CHECKS = { + 'git_repo': { + 'description': 'Git repository', + 'check': lambda p: (p / '.git').exists(), + }, + 'pyproject': { + 'description': 'pyproject.toml', + 'check': lambda p: (p / 'pyproject.toml').exists(), + }, + 'uv_config': { + 'description': 'UV configuration (in pyproject.toml)', + 'check': lambda p: 'tool.uv' in (p / 'pyproject.toml').read_text() + if (p / 'pyproject.toml').exists() + else False, + }, + 'justfile': { + 'description': 'justfile', + 'check': lambda p: (p / 'justfile').exists(), + }, + 'tasks': { + 'description': 'tasks/ directory', + 'check': lambda p: (p / 'tasks').is_dir(), + }, + 'gitignore': { + 'description': '.gitignore', + 'check': lambda p: (p / '.gitignore').exists(), + }, + 'python_version': { + 'description': '.python-version', + 'check': lambda p: (p / '.python-version').exists(), + }, + 'precommit': { + 'description': '.pre-commit-config.yaml', + 'check': lambda p: (p / '.pre-commit-config.yaml').exists(), + }, + 'github_workflows': { + 'description': '.github/workflows/', + 'check': lambda p: (p / '.github' / 'workflows').is_dir(), + }, + 'dockerfile': { + 'description': 'Dockerfile', + 'check': lambda p: (p / 'docker' / 'Dockerfile.python').exists() + or (p / 'Dockerfile').exists(), + }, + 'docker_compose': { + 'description': 'docker-compose.yml', + 'check': lambda p: (p / 'docker-compose.yml').exists(), + }, + } + + def __init__(self, target_path: Path, template_path: Path): + """Initialize the analyzer. + + Args: + target_path: Path to the target project + template_path: Path to the template source + """ + self.target = Path(target_path).resolve() + self.template = Path(template_path).resolve() + self.results: Dict[str, bool] = {} + + def analyze(self) -> Tuple[Dict[str, bool], list]: + """Analyze the target project against the template. + + Returns: + Tuple of (results dict, recommendations list) + """ + logger.info(f"Analyzing project: {self.target}") + logger.debug(f"Template: {self.template}") + + # Run all checks + for check_key, check_info in self.CHECKS.items(): + try: + result = check_info['check'](self.target) + self.results[check_key] = result + status = "✓" if result else "✗" + logger.debug(f"{status} {check_info['description']}") + except Exception as e: + self.results[check_key] = False + logger.warning(f"Check '{check_key}' failed: {e}") + + # Generate recommendations + recommendations = self._generate_recommendations() + return self.results, recommendations + + def _generate_recommendations(self) -> list: + """Generate migration recommendations based on analysis.""" + recs = [] + + # Check for critical items + if not self.results.get('git_repo'): + recs.append("Initialize git repository: `git init` and `git add . && git commit`") + + if not self.results.get('uv_config'): + recs.append( + "Add UV configuration to pyproject.toml: `[tool.uv]` section with dependency groups" + ) + + if not self.results.get('justfile'): + recs.append("Copy justfile from template for task automation") + + if not self.results.get('tasks'): + recs.append("Add tasks/ directory with just command definitions") + + if not self.results.get('precommit'): + recs.append("Add .pre-commit-config.yaml for automated code quality checks") + + if not self.results.get('github_workflows'): + recs.append("Add GitHub Actions workflows (.github/workflows/) for CI/CD") + + if not self.results.get('dockerfile'): + recs.append("Add Dockerfile for containerization") + + return recs + + def print_report(self) -> None: + """Print a formatted analysis report.""" + logger.info("=" * 70) + logger.info("PROJECT SYNC ANALYSIS") + logger.info("=" * 70) + logger.info(f"Target: {self.target}") + logger.info(f"Template: {self.template}") + logger.info("") + + logger.info("INFRASTRUCTURE STATUS") + logger.info("-" * 70) + for check_key, check_info in self.CHECKS.items(): + status = "✓" if self.results.get(check_key) else "✗" + logger.info(f" {status} {check_info['description']}") + + logger.info("") + logger.info("MIGRATION RECOMMENDATIONS") + logger.info("-" * 70) + recommendations = self._generate_recommendations() + if recommendations: + for i, rec in enumerate(recommendations, 1): + logger.info(f" {i}. {rec}") + else: + logger.success(" Project appears to be in sync with template!") + + logger.info("") + logger.info("FILES READY TO SYNC") + logger.info("-" * 70) + files_to_sync = [ + ("tasks/", "Task definitions and automation"), + ("justfile", "Just task runner configuration"), + (".gitignore", "Git ignore patterns"), + (".python-version", "Python version specification"), + ] + for filename, description in files_to_sync: + logger.info(f" • {filename:<20} {description}") + + logger.info("") + logger.info("=" * 70) + + def get_summary(self) -> str: + """Get a brief summary of the analysis.""" + missing = sum(1 for v in self.results.values() if not v) + total = len(self.results) + return f"{total - missing}/{total} infrastructure components present" + + +def main(argv=None): + """Main entry point.""" + import argparse + + parser = argparse.ArgumentParser( + description="Analyze a project against the template" + ) + parser.add_argument("target", help="Path to the target project") + # Get the template path from environment or use default + default_template = str(Path.home() / "Documents" / "workspace" / "python-project-template" / "{{cookiecutter.repository}}") + + parser.add_argument( + "--template", + default=default_template, + help="Path to the template directory (default: ~/Documents/workspace/python-project-template/{{cookiecutter.repository}})", + ) + + args = parser.parse_args(argv) + + target_path = Path(args.target).resolve() + template_path = Path(args.template).resolve() + + if not target_path.exists(): + logger.error(f"Target path does not exist: {target_path}") + return 1 + + if not template_path.exists(): + logger.error(f"Template path does not exist: {template_path}") + return 1 + + analyzer = ProjectAnalyzer(target_path, template_path) + results, recommendations = analyzer.analyze() + analyzer.print_report() + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/sync_project_template/__init__.py b/sync_project_template/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sync_project_template/sync.py b/sync_project_template/sync.py new file mode 100644 index 0000000..0ae6ebc --- /dev/null +++ b/sync_project_template/sync.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +""" +Sync this project with the latest Python project template. + +This script syncs high-priority files from the template repository to your project, +including tasks, justfile, and configuration files. It tracks changes and automatically +commits them to git. +""" + +import argparse +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Optional + +from loguru import logger + + +class SyncError(Exception): + """Base exception for sync operations.""" + pass + + +class TemplateSyncer: + """Syncs a project with the latest template files.""" + + # Files/directories to sync (relative to template root) + SYNC_TARGETS = { + 'tasks': {'type': 'directory', 'description': 'Task definitions'}, + 'justfile': {'type': 'file', 'description': 'Just task runner config'}, + '.gitignore': {'type': 'file', 'description': 'Git ignore patterns'}, + '.python-version': {'type': 'file', 'description': 'Python version'}, + } + + def __init__(self, source_path: Path, target_path: Path, dry_run: bool = False): + """Initialize the syncer. + + Args: + source_path: Path to the template source directory + target_path: Path to the target project directory + dry_run: If True, don't commit changes + """ + self.source_path = Path(source_path).resolve() + self.target_path = Path(target_path).resolve() + self.dry_run = dry_run + self.changes = [] + + def validate_paths(self) -> None: + """Validate that both source and target paths exist and are valid.""" + if not self.source_path.exists(): + raise SyncError(f"Source path does not exist: {self.source_path}") + + if not self.source_path.is_dir(): + raise SyncError(f"Source path is not a directory: {self.source_path}") + + # Check if source has expected template structure + if not (self.source_path / 'justfile').exists(): + raise SyncError( + f"Source doesn't appear to be a valid template " + f"(missing justfile): {self.source_path}" + ) + + if not self.target_path.exists(): + raise SyncError(f"Target path does not exist: {self.target_path}") + + if not self.target_path.is_dir(): + raise SyncError(f"Target path is not a directory: {self.target_path}") + + # Check if target is a git repository + if not (self.target_path / '.git').exists(): + raise SyncError(f"Target is not a git repository: {self.target_path}") + + def check_git_clean(self) -> None: + """Warn if git working directory is dirty.""" + try: + result = subprocess.run( + ['git', 'status', '--porcelain'], + cwd=self.target_path, + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + logger.warning("Git working directory has uncommitted changes") + logger.info("Consider committing them before syncing") + except (subprocess.TimeoutExpired, FileNotFoundError): + # git not available or timed out, skip check + pass + + def sync_directory(self, relative_path: str) -> None: + """Sync a directory from source to target, recursively. + + Args: + relative_path: Path relative to source/target roots + """ + source_dir = self.source_path / relative_path + target_dir = self.target_path / relative_path + + if not source_dir.exists(): + logger.warning(f"Source directory not found: {source_dir}") + return + + # Remove target directory if it exists + if target_dir.exists(): + shutil.rmtree(target_dir) + + # Copy directory + shutil.copytree(source_dir, target_dir) + file_count = len(list(target_dir.rglob('*'))) + self.changes.append(f"{relative_path}/ (directory, {file_count} items)") + logger.debug(f"Synced directory: {relative_path}") + + def sync_file(self, relative_path: str) -> None: + """Sync a single file from source to target. + + Args: + relative_path: Path relative to source/target roots + """ + source_file = self.source_path / relative_path + target_file = self.target_path / relative_path + + if not source_file.exists(): + logger.warning(f"Source file not found: {source_file}") + return + + # Check if file has changed + if target_file.exists(): + with open(source_file, 'rb') as sf, open(target_file, 'rb') as tf: + if sf.read() == tf.read(): + logger.debug(f"File unchanged: {relative_path}") + return + + # Copy file + target_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_file, target_file) + self.changes.append(relative_path) + logger.debug(f"Synced file: {relative_path}") + + def sync_all(self) -> None: + """Sync all configured targets.""" + logger.info("Syncing files from template") + logger.debug(f"Source: {self.source_path}") + logger.debug(f"Target: {self.target_path}") + + for target_name, target_info in self.SYNC_TARGETS.items(): + target_type = target_info['type'] + description = target_info['description'] + + logger.info(f"Syncing {description}...") + + if target_type == 'directory': + self.sync_directory(target_name) + elif target_type == 'file': + self.sync_file(target_name) + + def git_add_changes(self) -> None: + """Stage all changes for commit.""" + if not self.changes: + logger.info("No changes to sync") + return + + for target_name in self.SYNC_TARGETS.keys(): + try: + subprocess.run( + ['git', 'add', target_name], + cwd=self.target_path, + capture_output=True, + timeout=5, + ) + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + def git_commit(self) -> bool: + """Create a commit with the synced changes. + + Returns: + True if commit was successful, False otherwise + """ + if not self.changes: + return False + + if self.dry_run: + logger.info("DRY RUN: Would create commit with the following changes:") + for change in self.changes: + logger.info(f" • {change}") + return True + + # Generate commit message + synced_items = "\n".join(f" • {change}" for change in self.changes) + commit_message = f"""Sync with latest project template + +Synced the following files and directories: +{synced_items} + +This commit updates the project infrastructure to match the latest +template version, including task definitions, configuration files, +and development tools.""" + + try: + result = subprocess.run( + ['git', 'commit', '-m', commit_message], + cwd=self.target_path, + capture_output=True, + text=True, + timeout=5, + check=True, + ) + logger.debug(f"Git commit output: {result.stdout}") + return True + except subprocess.CalledProcessError as e: + logger.error(f"Git commit failed: {e.stderr}") + return False + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + logger.error(f"Git operation failed: {e}") + return False + + def print_summary(self) -> None: + """Print a summary of what was synced.""" + if not self.changes: + logger.success("No changes needed - project is already up to date!") + return + + logger.success("Successfully synced the following:") + for change in self.changes: + logger.success(f" • {change}") + + if not self.dry_run: + logger.success("Changes committed to git") + else: + logger.info("DRY RUN: No changes were actually made") + + def run(self) -> bool: + """Run the sync operation. + + Returns: + True if successful, False otherwise + """ + try: + self.validate_paths() + self.check_git_clean() + self.sync_all() + self.git_add_changes() + success = self.git_commit() + self.print_summary() + return success + except SyncError as e: + logger.error(f"{e}") + return False + + +def main(argv: Optional[list] = None) -> int: + """Main entry point for the sync CLI. + + Args: + argv: Command line arguments (defaults to sys.argv[1:]) + + Returns: + Exit code (0 for success, non-zero for failure) + """ + parser = argparse.ArgumentParser( + description="Sync this project with the latest Python project template", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Sync with a local template directory + python sync.py --source ~/python-project-template/{{{{cookiecutter.repository}}}} --target . + + # Dry run to preview changes + python sync.py --source ~/templates/pytemplate --target . --dry-run + """, + ) + + parser.add_argument( + '--source', + type=str, + required=True, + help='Path to the template source directory', + ) + + parser.add_argument( + '--target', + type=str, + default='.', + help='Path to the target project (default: current directory)', + ) + + parser.add_argument( + '--dry-run', + action='store_true', + help='Preview changes without committing', + ) + + args = parser.parse_args(argv) + + syncer = TemplateSyncer( + source_path=args.source, + target_path=args.target, + dry_run=args.dry_run, + ) + + success = syncer.run() + return 0 if success else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/template_sync_cli.py b/template_sync_cli.py new file mode 100644 index 0000000..d6963a7 --- /dev/null +++ b/template_sync_cli.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +""" +CLI wrapper for the sync_project_template utility. + +This script provides a command-line interface to sync projects with the latest +Python project template. It can be used directly or as a Claude Code skill. +""" + +import argparse +import sys + +from sync_project_template.sync import main + + +def run() -> int: + """Run the template sync CLI.""" + parser = argparse.ArgumentParser( + description="Sync your project with the latest Python project template", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Sync current project with the template + python template_sync_cli.py --source ~/path/to/template --target . + + # Preview changes without committing + python template_sync_cli.py --source ~/path/to/template --target . --dry-run + + # Sync another project + python template_sync_cli.py --source ~/path/to/template --target ~/another-project + """, + ) + + parser.add_argument( + "--source", + type=str, + required=True, + help="Path to the template source directory", + metavar="PATH", + ) + + parser.add_argument( + "--target", + type=str, + default=".", + help="Path to the target project (default: current directory)", + metavar="PATH", + ) + + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview changes without committing them", + ) + + args = parser.parse_args() + + # Call the main sync function with the parsed arguments + return main([ + "--source", args.source, + "--target", args.target, + *(["--dry-run"] if args.dry_run else []), + ]) + + +if __name__ == "__main__": + sys.exit(run()) \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..82d744b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +"""Pytest configuration for tests.""" + +import sys +from pathlib import Path + +# Add the project root to sys.path so that imports work correctly +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..164e939 --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,283 @@ +"""Tests for the sync module.""" + +import subprocess +import tempfile +from pathlib import Path +from typing import Generator + +import pytest + +# Import the sync module from the project root +from sync_project_template.sync import SyncError, TemplateSyncer, main + + +@pytest.fixture +def temp_dirs() -> Generator[tuple[Path, Path], None, None]: + """Create temporary directories for source and target.""" + with tempfile.TemporaryDirectory() as source_dir, tempfile.TemporaryDirectory() as target_dir: + source = Path(source_dir) + target = Path(target_dir) + yield source, target + + +@pytest.fixture +def source_template(temp_dirs: tuple[Path, Path]) -> Path: + """Create a minimal valid template source.""" + source, _ = temp_dirs + # Create required template files + (source / "justfile").write_text("# justfile") + (source / "tasks").mkdir() + (source / "tasks" / "test.just").write_text('test: echo "test"') + (source / ".gitignore").write_text("*.pyc\n__pycache__/\n") + (source / ".python-version").write_text("3.12\n") + return source + + +@pytest.fixture +def target_project(temp_dirs: tuple[Path, Path]) -> Path: + """Create a minimal valid target project with git.""" + _, target = temp_dirs + # Initialize git repo + subprocess.run(["git", "init"], cwd=target, capture_output=True, check=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], + cwd=target, + capture_output=True, + check=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test User"], cwd=target, capture_output=True, check=True + ) + # Create initial commit + (target / "README.md").write_text("# Project") + subprocess.run(["git", "add", "README.md"], cwd=target, capture_output=True, check=True) + subprocess.run( + ["git", "commit", "-m", "Initial commit"], cwd=target, capture_output=True, check=True + ) + return target + + +class TestTemplateSyncerValidation: + """Test path validation logic.""" + + def test_validate_source_path_not_exists(self, temp_dirs: tuple[Path, Path]) -> None: + """Test validation fails when source doesn't exist.""" + _, target = temp_dirs + nonexistent = Path("/nonexistent/path") + syncer = TemplateSyncer(nonexistent, target) + with pytest.raises(SyncError, match="Source path does not exist"): + syncer.validate_paths() + + def test_validate_source_not_directory(self, temp_dirs: tuple[Path, Path]) -> None: + """Test validation fails when source is not a directory.""" + source, target = temp_dirs + file_path = source / "file.txt" + file_path.write_text("test") + syncer = TemplateSyncer(file_path, target) + with pytest.raises(SyncError, match="Source path is not a directory"): + syncer.validate_paths() + + def test_validate_source_not_valid_template(self, temp_dirs: tuple[Path, Path]) -> None: + """Test validation fails when source doesn't have justfile.""" + source, target = temp_dirs + syncer = TemplateSyncer(source, target) + with pytest.raises(SyncError, match="Source doesn't appear to be a valid template"): + syncer.validate_paths() + + def test_validate_target_path_not_exists(self, source_template: Path) -> None: + """Test validation fails when target doesn't exist.""" + nonexistent = Path("/nonexistent/target") + syncer = TemplateSyncer(source_template, nonexistent) + with pytest.raises(SyncError, match="Target path does not exist"): + syncer.validate_paths() + + def test_validate_target_not_git_repo( + self, source_template: Path, temp_dirs: tuple[Path, Path] + ) -> None: + """Test validation fails when target is not a git repo.""" + _, target = temp_dirs + syncer = TemplateSyncer(source_template, target) + with pytest.raises(SyncError, match="Target is not a git repository"): + syncer.validate_paths() + + def test_validate_paths_success(self, source_template: Path, target_project: Path) -> None: + """Test validation succeeds with valid paths.""" + syncer = TemplateSyncer(source_template, target_project) + # Should not raise + syncer.validate_paths() + + +class TestTemplateSyncerOperations: + """Test sync operations.""" + + def test_sync_file_creates_new_file(self, source_template: Path, target_project: Path) -> None: + """Test syncing a new file to target.""" + syncer = TemplateSyncer(source_template, target_project) + syncer.sync_file(".gitignore") + assert (target_project / ".gitignore").exists() + assert (target_project / ".gitignore").read_text() == ( + source_template / ".gitignore" + ).read_text() + assert ".gitignore" in syncer.changes + + def test_sync_file_updates_existing_file( + self, source_template: Path, target_project: Path + ) -> None: + """Test syncing an existing file with changes.""" + # Create old version of file in target + (target_project / ".python-version").write_text("3.11\n") + syncer = TemplateSyncer(source_template, target_project) + syncer.sync_file(".python-version") + # File should be updated + assert (target_project / ".python-version").read_text() == "3.12\n" + assert ".python-version" in syncer.changes + + def test_sync_file_skips_unchanged(self, source_template: Path, target_project: Path) -> None: + """Test syncing skips unchanged files.""" + # Create matching file in target + (target_project / ".python-version").write_text("3.12\n") + syncer = TemplateSyncer(source_template, target_project) + initial_changes = len(syncer.changes) + syncer.sync_file(".python-version") + # Changes list should not grow + assert len(syncer.changes) == initial_changes + + def test_sync_directory_creates_new(self, source_template: Path, target_project: Path) -> None: + """Test syncing a new directory to target.""" + syncer = TemplateSyncer(source_template, target_project) + syncer.sync_directory("tasks") + assert (target_project / "tasks").exists() + assert (target_project / "tasks" / "test.just").exists() + assert (target_project / "tasks" / "test.just").read_text() == 'test: echo "test"' + + def test_sync_directory_replaces_existing( + self, source_template: Path, target_project: Path + ) -> None: + """Test syncing replaces existing directory.""" + # Create old version in target + (target_project / "tasks").mkdir() + (target_project / "tasks" / "old.just").write_text("# old") + syncer = TemplateSyncer(source_template, target_project) + syncer.sync_directory("tasks") + # Old file should be gone + assert not (target_project / "tasks" / "old.just").exists() + # New file should exist + assert (target_project / "tasks" / "test.just").exists() + + def test_sync_all_operations(self, source_template: Path, target_project: Path) -> None: + """Test syncing all configured targets.""" + syncer = TemplateSyncer(source_template, target_project) + syncer.sync_all() + # Check all files were synced + assert (target_project / "justfile").exists() + assert (target_project / ".gitignore").exists() + assert (target_project / ".python-version").exists() + assert (target_project / "tasks").exists() + assert len(syncer.changes) > 0 + + +class TestTemplateSyncerGit: + """Test git operations.""" + + def test_git_commit_creates_commit(self, source_template: Path, target_project: Path) -> None: + """Test git commit creates a commit with changes.""" + syncer = TemplateSyncer(source_template, target_project) + syncer.sync_all() + syncer.git_add_changes() + result = syncer.git_commit() + assert result is True + # Check commit was created + log_result = subprocess.run( + ["git", "log", "--oneline"], + cwd=target_project, + capture_output=True, + text=True, + check=True, + ) + assert "Sync with latest project template" in log_result.stdout + + def test_git_commit_dry_run(self, source_template: Path, target_project: Path) -> None: + """Test dry run doesn't create commit.""" + syncer = TemplateSyncer(source_template, target_project, dry_run=True) + syncer.sync_all() + result = syncer.git_commit() + assert result is True + # Check no commit was created + log_result = subprocess.run( + ["git", "log", "--oneline"], + cwd=target_project, + capture_output=True, + text=True, + check=True, + ) + # Should only have initial commit + assert log_result.stdout.count("\n") <= 2 + + def test_git_add_changes(self, source_template: Path, target_project: Path) -> None: + """Test git add stages changes.""" + syncer = TemplateSyncer(source_template, target_project) + syncer.sync_all() + syncer.git_add_changes() + # Check files are staged + status_result = subprocess.run( + ["git", "status", "--porcelain"], + cwd=target_project, + capture_output=True, + text=True, + check=True, + ) + # Files should be staged (A = added) + assert "A" in status_result.stdout or "M" in status_result.stdout + + +class TestTemplateSyncerCLI: + """Test CLI interface.""" + + def test_main_with_valid_args(self, source_template: Path, target_project: Path) -> None: + """Test main function with valid arguments.""" + result = main( + [ + "--source", + str(source_template), + "--target", + str(target_project), + ] + ) + assert result == 0 + + def test_main_with_dry_run(self, source_template: Path, target_project: Path) -> None: + """Test main function with dry-run flag.""" + result = main( + [ + "--source", + str(source_template), + "--target", + str(target_project), + "--dry-run", + ] + ) + assert result == 0 + + def test_main_invalid_source(self, target_project: Path) -> None: + """Test main function fails with invalid source.""" + result = main( + [ + "--source", + "/nonexistent/path", + "--target", + str(target_project), + ] + ) + assert result != 0 + + def test_main_invalid_target(self, source_template: Path) -> None: + """Test main function fails with invalid target.""" + result = main( + [ + "--source", + str(source_template), + "--target", + "/nonexistent/target", + ] + ) + assert result != 0 diff --git a/uv.lock b/uv.lock index 55e726b..d6d0f25 100644 --- a/uv.lock +++ b/uv.lock @@ -1,4 +1,5 @@ version = 1 +revision = 1 requires-python = ">=3.13" [[package]] @@ -98,7 +99,7 @@ name = "click" version = "8.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } wheels = [ @@ -162,6 +163,7 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ { name = "commitizen" }, + { name = "loguru" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cookies" }, @@ -174,6 +176,7 @@ dev = [ [package.metadata.requires-dev] dev = [ { name = "commitizen", specifier = ">=4.1.0" }, + { name = "loguru", specifier = ">=0.7.3" }, { name = "pre-commit", specifier = ">=4.0.1" }, { name = "pytest", specifier = ">=8.3.4" }, { name = "pytest-cookies", specifier = ">=0.7.0" }, @@ -256,6 +259,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, ] +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595 }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -653,3 +669,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc wheels = [ { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, ] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083 }, +]