From c54a2fb169d11bae8958c6e6eb1615a358f64011 Mon Sep 17 00:00:00 2001 From: Irving Rodriguez Date: Fri, 9 Jan 2026 23:10:12 -0800 Subject: [PATCH 1/5] add template sync [assisted by Claude] --- .gitignore | 3 + README.md | 43 +++++ pyproject.toml | 1 + sync_project_template/__init__.py | 0 sync_project_template/sync.py | 308 ++++++++++++++++++++++++++++++ template_sync_cli.py | 66 +++++++ tests/test_sync.py | 283 +++++++++++++++++++++++++++ uv.lock | 27 ++- 8 files changed, 730 insertions(+), 1 deletion(-) create mode 100644 sync_project_template/__init__.py create mode 100644 sync_project_template/sync.py create mode 100644 template_sync_cli.py create mode 100644 tests/test_sync.py 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 ccccfcb..6824677 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,47 @@ Uv Integration: Effortless dependency management and packaging with [uv](https:/ * **Comprehensive Documentation:** [pdoc](https://pdoc.dev/) generates API documentation, and Markdown files provide clear usage instructions. * **GitHub Workflow Integration:** Continuous integration and deployment workflows are set up using [GitHub Actions](https://github.com/features/actions), automating testing, checks, and publishing. +### 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:** @@ -102,3 +143,5 @@ This builds a Docker image based on your [`Dockerfile`](https://github.com/fmind ## License The source material this is adapted from is licensed under the [MIT License](https://opensource.org/license/mit). See the [`LICENSE.txt`](https://github.com/fmind/cookiecutter-mlops-package/blob/main/LICENSE.txt) file for details. + +This is my Python project cookie cutter template. As you can see, it has various tools that come with it out of the box, things like using UV for dependency management, using coverage to test unit test coverage comes with rough for formatting and linting and fixes and also has several just tasks that simplify utilizing a lot of these tools. One of the problems that I face is that some of my projects that were created before I had this template don't have all of these things out of the box. But I think it's fairly simple to migrate them over. For example, adding the just tasks is simply making sure that the project has UV and then uh migrating the uh tasks subdirectory over. I'm curious if it's easy to implement a claude code agent to be able to in a sense sync this project template upstream to those projects. diff --git a/pyproject.toml b/pyproject.toml index 1083e03..3c04af0 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_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/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 }, +] From 0cdc8ae025e2586a8f75758271d06c339ab4f2c0 Mon Sep 17 00:00:00 2001 From: Irving Rodriguez Date: Sat, 10 Jan 2026 22:55:24 -0800 Subject: [PATCH 2/5] add sync analyzer and create personal Claude Code command [assisted by Claude] --- SYNC_TOOL_README.md | 263 ++++++++++++++++++++++++++++++++++++++++++++ sync_analyzer.py | 222 +++++++++++++++++++++++++++++++++++++ 2 files changed, 485 insertions(+) create mode 100644 SYNC_TOOL_README.md create mode 100644 sync_analyzer.py diff --git a/SYNC_TOOL_README.md b/SYNC_TOOL_README.md new file mode 100644 index 0000000..0315344 --- /dev/null +++ b/SYNC_TOOL_README.md @@ -0,0 +1,263 @@ +# 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 + +```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/) \ No newline at end of file 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 From 9cf610855b2d5f2c008c772f866001eb946583dc Mon Sep 17 00:00:00 2001 From: Irving Rodriguez Date: Sun, 8 Feb 2026 20:30:38 -0800 Subject: [PATCH 3/5] Fix sync test path [assisted by Claude] --- tests/conftest.py | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 tests/conftest.py 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)) From 4e2f9f789c38e0e32d491ca26f4ddb00a8b9eaaf Mon Sep 17 00:00:00 2001 From: Irving Rodriguez Date: Sun, 8 Feb 2026 20:32:40 -0800 Subject: [PATCH 4/5] Fix README --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index a804dc9..2ae723e 100644 --- a/README.md +++ b/README.md @@ -153,5 +153,3 @@ Use the provided `just` commands to manage your development workflow: ## License The source material this is adapted from is licensed under the [MIT License](https://opensource.org/license/mit). See the [`LICENSE.txt`](https://github.com/fmind/cookiecutter-mlops-package/blob/main/LICENSE.txt) file for details. - -This is my Python project cookie cutter template. As you can see, it has various tools that come with it out of the box, things like using UV for dependency management, using coverage to test unit test coverage comes with rough for formatting and linting and fixes and also has several just tasks that simplify utilizing a lot of these tools. One of the problems that I face is that some of my projects that were created before I had this template don't have all of these things out of the box. But I think it's fairly simple to migrate them over. For example, adding the just tasks is simply making sure that the project has UV and then uh migrating the uh tasks subdirectory over. I'm curious if it's easy to implement a claude code agent to be able to in a sense sync this project template upstream to those projects. From 30da775de09755bef54a686dc26c8c6d3dd3c3d2 Mon Sep 17 00:00:00 2001 From: Irving Rodriguez Date: Sun, 8 Feb 2026 20:36:18 -0800 Subject: [PATCH 5/5] Add sync-project-template Claude command --- .claude/commands/sync-project-template.md | 119 ++++++++++++++++++++++ SYNC_TOOL_README.md | 4 +- 2 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 .claude/commands/sync-project-template.md 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/SYNC_TOOL_README.md b/SYNC_TOOL_README.md index 0315344..6f23c39 100644 --- a/SYNC_TOOL_README.md +++ b/SYNC_TOOL_README.md @@ -40,6 +40,8 @@ uv run python template_sync_cli.py \ ### 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 ``` @@ -260,4 +262,4 @@ Planned improvements: - [Template Documentation](README.md) - [Sync Tool Tests](tests/test_sync.py) -- [Just Task Definitions]({{cookiecutter.repository}}/tasks/) \ No newline at end of file +- [Just Task Definitions]({{cookiecutter.repository}}/tasks/)