From fa0e5e7b7f4155f69df98c2b1d74061a1748f251 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 21:13:42 +0000 Subject: [PATCH 1/4] Initial plan From 093d528e23037845c31156a77cde534e5d706bb6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 21:22:15 +0000 Subject: [PATCH 2/4] Implement Python environment management during install - Created PythonEnvironment class for managing virtual environments - Added support for uv, system Python, and skip options - Added --python-manager CLI flag to install command - Implemented interactive prompt for Python setup - Added detection of existing virtual environments - Updated config.yml schema to include python section - Added comprehensive unit and integration tests - Updated existing tests to work with new Python manager option Co-authored-by: ncrmro <8276365+ncrmro@users.noreply.github.com> --- src/deepwork/cli/install.py | 80 +++++++- src/deepwork/utils/python_env.py | 124 +++++++++++ tests/integration/test_install_flow.py | 16 +- tests/integration/test_install_python.py | 193 ++++++++++++++++++ .../integration/test_install_requirements.py | 2 +- tests/unit/test_python_env.py | 150 ++++++++++++++ 6 files changed, 553 insertions(+), 12 deletions(-) create mode 100644 src/deepwork/utils/python_env.py create mode 100644 tests/integration/test_install_python.py create mode 100644 tests/unit/test_python_env.py diff --git a/src/deepwork/cli/install.py b/src/deepwork/cli/install.py index 5d525475..aca2a28c 100644 --- a/src/deepwork/cli/install.py +++ b/src/deepwork/cli/install.py @@ -2,14 +2,17 @@ import shutil from pathlib import Path +from typing import Optional import click from rich.console import Console +from rich.prompt import Prompt from deepwork.core.adapters import AgentAdapter from deepwork.core.detector import PlatformDetector from deepwork.utils.fs import ensure_dir from deepwork.utils.git import is_git_repo +from deepwork.utils.python_env import PythonEnvironment from deepwork.utils.yaml_utils import load_yaml, save_yaml console = Console() @@ -226,6 +229,42 @@ def _create_rules_directory(project_path: Path) -> bool: return True +def _prompt_python_setup(console: Console) -> dict: + """Prompt user for Python environment preferences. + + Args: + console: Rich console for output + + Returns: + Dictionary containing python configuration: + - manager: "uv" | "system" | "skip" + - version: Python version string + - venv_path: Path to virtual environment + """ + console.print("\n[bold]Python Environment Setup[/bold]") + console.print("=" * 40) + console.print("\nHow should Python dependencies be managed?\n") + + choices_display = [ + ("1", "uv (Recommended)", "Creates isolated .venv with project-specific Python"), + ("2", "System Python", "Uses existing python3 from PATH"), + ("3", "Skip", "No Python environment setup"), + ] + + for key, name, desc in choices_display: + console.print(f" [{key}] {name}") + console.print(f" {desc}\n") + + choice = Prompt.ask("Choice", default="1", choices=["1", "2", "3"]) + + manager_map = {"1": "uv", "2": "system", "3": "skip"} + return { + "manager": manager_map[choice], + "version": "3.11", + "venv_path": ".venv" + } + + class DynamicChoice(click.Choice): """A Click Choice that gets its values dynamically from AgentAdapter.""" @@ -248,7 +287,12 @@ def __init__(self) -> None: default=".", help="Path to project directory (default: current directory)", ) -def install(platform: str | None, path: Path) -> None: +@click.option( + "--python-manager", + type=click.Choice(["uv", "system", "skip"]), + help="Python environment manager (skips interactive prompt)", +) +def install(platform: str | None, path: Path, python_manager: str | None) -> None: """ Install DeepWork in a project. @@ -256,7 +300,7 @@ def install(platform: str | None, path: Path) -> None: commands for all configured platforms. """ try: - _install_deepwork(platform, path) + _install_deepwork(platform, path, python_manager) except InstallError as e: console.print(f"[red]Error:[/red] {e}") raise click.Abort() from e @@ -265,13 +309,14 @@ def install(platform: str | None, path: Path) -> None: raise -def _install_deepwork(platform_name: str | None, project_path: Path) -> None: +def _install_deepwork(platform_name: str | None, project_path: Path, python_manager: str | None) -> None: """ Install DeepWork in a project. Args: platform_name: Platform to install for (or None to auto-detect) project_path: Path to project directory + python_manager: Python environment manager choice (or None to prompt) Raises: InstallError: If installation fails @@ -335,6 +380,32 @@ def _install_deepwork(platform_name: str | None, project_path: Path) -> None: platforms_to_add.append(adapter.name) detected_adapters = available_adapters + # Step 2b: Python environment setup + if python_manager: + python_config = {"manager": python_manager, "version": "3.11", "venv_path": ".venv"} + else: + # Check for existing venv + existing = PythonEnvironment.detect_existing(project_path) + if existing: + console.print(f"\n[green]→[/green] Found existing virtual environment: {existing.relative_to(project_path)}") + python_config = {"manager": "skip", "version": "3.11", "venv_path": str(existing.relative_to(project_path))} + else: + python_config = _prompt_python_setup(console) + + # Create Python environment + if python_config["manager"] != "skip": + console.print(f"\n[yellow]→[/yellow] Setting up Python environment with {python_config['manager']}...") + env = PythonEnvironment(python_config) + try: + success = env.setup(project_path) + if success: + console.print(" [green]✓[/green] Virtual environment created") + else: + console.print(" [yellow]⚠[/yellow] Virtual environment setup returned False") + except RuntimeError as e: + console.print(f" [red]✗[/red] Failed: {e}") + raise InstallError(f"Python environment setup failed: {e}") from e + # Step 3: Create .deepwork/ directory structure console.print("[yellow]→[/yellow] Creating DeepWork directory structure...") deepwork_dir = project_path / ".deepwork" @@ -393,6 +464,9 @@ def _install_deepwork(platform_name: str | None, project_path: Path) -> None: else: console.print(f" [dim]•[/dim] {adapter.display_name} already configured") + # Add python configuration + config_data["python"] = python_config + save_yaml(config_file, config_data) console.print(f" [green]✓[/green] Updated {config_file.relative_to(project_path)}") diff --git a/src/deepwork/utils/python_env.py b/src/deepwork/utils/python_env.py new file mode 100644 index 00000000..c3a6cc22 --- /dev/null +++ b/src/deepwork/utils/python_env.py @@ -0,0 +1,124 @@ +"""Python environment management utilities.""" + +import shutil +import subprocess +from pathlib import Path +from typing import Optional + + +class PythonEnvironment: + """Manages Python virtual environments.""" + + def __init__(self, config: dict): + """Initialize Python environment manager. + + Args: + config: Dictionary containing: + - manager: "uv" | "system" | "skip" + - version: Python version string (e.g., "3.11") + - venv_path: Path to virtual environment (e.g., ".venv") + """ + self.manager = config.get("manager", "uv") + self.version = config.get("version", "3.11") + self.venv_path = Path(config.get("venv_path", ".venv")) + + def setup(self, project_root: Path) -> bool: + """Create virtual environment based on configured manager. + + Args: + project_root: Path to the project root directory + + Returns: + True if setup succeeded, False otherwise + + Raises: + RuntimeError: If required tools are not available + """ + if self.manager == "skip": + return True + + venv_dir = project_root / self.venv_path + + if self.manager == "uv": + return self._setup_with_uv(venv_dir) + elif self.manager == "system": + return self._setup_with_system(venv_dir) + + return False + + def _setup_with_uv(self, venv_dir: Path) -> bool: + """Create venv using uv. + + Args: + venv_dir: Path where virtual environment should be created + + Returns: + True if creation succeeded, False otherwise + + Raises: + RuntimeError: If uv is not found + """ + if not shutil.which("uv"): + raise RuntimeError("uv not found. Install via: brew install uv") + + cmd = ["uv", "venv", str(venv_dir), "--python", self.version] + result = subprocess.run(cmd, capture_output=True, text=True) + return result.returncode == 0 + + def _setup_with_system(self, venv_dir: Path) -> bool: + """Create venv using system Python. + + Args: + venv_dir: Path where virtual environment should be created + + Returns: + True if creation succeeded, False otherwise + + Raises: + RuntimeError: If Python is not found + """ + python = shutil.which("python3") or shutil.which("python") + if not python: + raise RuntimeError("Python not found in PATH") + + cmd = [python, "-m", "venv", str(venv_dir)] + result = subprocess.run(cmd, capture_output=True, text=True) + return result.returncode == 0 + + def install_package(self, package: str, project_root: Path) -> bool: + """Install a package into the virtual environment. + + Args: + package: Package name to install + project_root: Path to the project root directory + + Returns: + True if installation succeeded, False otherwise + """ + venv_dir = project_root / self.venv_path + + if self.manager == "uv": + cmd = ["uv", "pip", "install", package] + else: + pip = venv_dir / "bin" / "pip" + cmd = [str(pip), "install", package] + + result = subprocess.run(cmd, capture_output=True, text=True, cwd=project_root) + return result.returncode == 0 + + @staticmethod + def detect_existing(project_root: Path) -> Optional[Path]: + """Detect existing virtual environment. + + Args: + project_root: Path to the project root directory + + Returns: + Path to detected virtual environment, or None if not found + """ + candidates = [".venv", "venv", ".virtualenv"] + for name in candidates: + venv_dir = project_root / name + if (venv_dir / "bin" / "python").exists(): + return venv_dir + return None diff --git a/tests/integration/test_install_flow.py b/tests/integration/test_install_flow.py index beabf811..73c2dc0e 100644 --- a/tests/integration/test_install_flow.py +++ b/tests/integration/test_install_flow.py @@ -17,7 +17,7 @@ def test_install_with_claude(self, mock_claude_project: Path) -> None: result = runner.invoke( cli, - ["install", "--platform", "claude", "--path", str(mock_claude_project)], + ["install", "--platform", "claude", "--path", str(mock_claude_project), "--python-manager", "skip"], catch_exceptions=False, ) @@ -63,7 +63,7 @@ def test_install_with_auto_detect(self, mock_claude_project: Path) -> None: runner = CliRunner() result = runner.invoke( - cli, ["install", "--path", str(mock_claude_project)], catch_exceptions=False + cli, ["install", "--path", str(mock_claude_project), "--python-manager", "skip"], catch_exceptions=False ) assert result.exit_code == 0 @@ -84,7 +84,7 @@ def test_install_defaults_to_claude_when_no_platform(self, mock_git_repo: Path) runner = CliRunner() result = runner.invoke( - cli, ["install", "--path", str(mock_git_repo)], catch_exceptions=False + cli, ["install", "--path", str(mock_git_repo), "--python-manager", "skip"], catch_exceptions=False ) assert result.exit_code == 0 @@ -114,7 +114,7 @@ def test_install_with_multiple_platforms_auto_detect( result = runner.invoke( cli, - ["install", "--path", str(mock_multi_platform_project)], + ["install", "--path", str(mock_multi_platform_project), "--python-manager", "skip"], catch_exceptions=False, ) @@ -162,7 +162,7 @@ def test_install_is_idempotent(self, mock_claude_project: Path) -> None: # First install result1 = runner.invoke( cli, - ["install", "--platform", "claude", "--path", str(mock_claude_project)], + ["install", "--platform", "claude", "--path", str(mock_claude_project), "--python-manager", "skip"], catch_exceptions=False, ) assert result1.exit_code == 0 @@ -170,7 +170,7 @@ def test_install_is_idempotent(self, mock_claude_project: Path) -> None: # Second install result2 = runner.invoke( cli, - ["install", "--platform", "claude", "--path", str(mock_claude_project)], + ["install", "--platform", "claude", "--path", str(mock_claude_project), "--python-manager", "skip"], catch_exceptions=False, ) assert result2.exit_code == 0 @@ -191,7 +191,7 @@ def test_install_creates_rules_directory(self, mock_claude_project: Path) -> Non result = runner.invoke( cli, - ["install", "--platform", "claude", "--path", str(mock_claude_project)], + ["install", "--platform", "claude", "--path", str(mock_claude_project), "--python-manager", "skip"], catch_exceptions=False, ) @@ -231,7 +231,7 @@ def test_install_preserves_existing_rules_directory(self, mock_claude_project: P result = runner.invoke( cli, - ["install", "--platform", "claude", "--path", str(mock_claude_project)], + ["install", "--platform", "claude", "--path", str(mock_claude_project), "--python-manager", "skip"], catch_exceptions=False, ) diff --git a/tests/integration/test_install_python.py b/tests/integration/test_install_python.py new file mode 100644 index 00000000..d5b083f5 --- /dev/null +++ b/tests/integration/test_install_python.py @@ -0,0 +1,193 @@ +"""Integration tests for Python environment installation.""" + +from pathlib import Path + +from click.testing import CliRunner + +from deepwork.cli.main import cli +from deepwork.utils.yaml_utils import load_yaml + + +class TestInstallPythonEnvironment: + """Integration tests for Python environment setup during install.""" + + def test_install_with_uv_manager(self, mock_claude_project: Path) -> None: + """Test installing with uv Python manager.""" + runner = CliRunner() + + result = runner.invoke( + cli, + [ + "install", + "--platform", "claude", + "--path", str(mock_claude_project), + "--python-manager", "uv" + ], + catch_exceptions=False, + ) + + # Note: This might fail if uv isn't available, but we can still check config + # Verify config.yml has python section + config_file = mock_claude_project / ".deepwork" / "config.yml" + assert config_file.exists() + config = load_yaml(config_file) + assert config is not None + assert "python" in config + assert config["python"]["manager"] == "uv" + assert config["python"]["version"] == "3.11" + assert config["python"]["venv_path"] == ".venv" + + def test_install_with_system_python_manager(self, mock_claude_project: Path) -> None: + """Test installing with system Python manager.""" + runner = CliRunner() + + result = runner.invoke( + cli, + [ + "install", + "--platform", "claude", + "--path", str(mock_claude_project), + "--python-manager", "system" + ], + catch_exceptions=False, + ) + + # Verify config.yml has python section + config_file = mock_claude_project / ".deepwork" / "config.yml" + assert config_file.exists() + config = load_yaml(config_file) + assert config is not None + assert "python" in config + assert config["python"]["manager"] == "system" + + def test_install_with_skip_python(self, mock_claude_project: Path) -> None: + """Test installing with Python setup skipped.""" + runner = CliRunner() + + result = runner.invoke( + cli, + [ + "install", + "--platform", "claude", + "--path", str(mock_claude_project), + "--python-manager", "skip" + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + + # Verify config.yml has python section with skip + config_file = mock_claude_project / ".deepwork" / "config.yml" + assert config_file.exists() + config = load_yaml(config_file) + assert config is not None + assert "python" in config + assert config["python"]["manager"] == "skip" + + def test_install_detects_existing_venv(self, mock_claude_project: Path) -> None: + """Test that install detects existing virtual environment.""" + # Create a mock existing venv + venv_dir = mock_claude_project / ".venv" / "bin" + venv_dir.mkdir(parents=True) + (venv_dir / "python").touch() + + runner = CliRunner() + + result = runner.invoke( + cli, + [ + "install", + "--platform", "claude", + "--path", str(mock_claude_project), + ], + input="3\n", # Choose skip when prompted + catch_exceptions=False, + ) + + # Should detect the existing venv + assert "Found existing virtual environment" in result.output + + # Verify config shows skip (since we have existing venv) + config_file = mock_claude_project / ".deepwork" / "config.yml" + config = load_yaml(config_file) + assert config is not None + assert "python" in config + # Should use skip since existing venv was detected + assert config["python"]["manager"] == "skip" + + def test_install_interactive_prompt_uv(self, mock_claude_project: Path) -> None: + """Test interactive Python setup prompt choosing uv.""" + runner = CliRunner() + + result = runner.invoke( + cli, + [ + "install", + "--platform", "claude", + "--path", str(mock_claude_project), + ], + input="1\n", # Choose uv + catch_exceptions=False, + ) + + # Should show the prompt + assert "Python Environment Setup" in result.output + assert "uv (Recommended)" in result.output + + # Verify config + config_file = mock_claude_project / ".deepwork" / "config.yml" + config = load_yaml(config_file) + assert config is not None + assert "python" in config + assert config["python"]["manager"] == "uv" + + def test_install_interactive_prompt_system(self, mock_claude_project: Path) -> None: + """Test interactive Python setup prompt choosing system Python.""" + runner = CliRunner() + + result = runner.invoke( + cli, + [ + "install", + "--platform", "claude", + "--path", str(mock_claude_project), + ], + input="2\n", # Choose system Python + catch_exceptions=False, + ) + + # Should show the prompt + assert "Python Environment Setup" in result.output + assert "System Python" in result.output + + # Verify config + config_file = mock_claude_project / ".deepwork" / "config.yml" + config = load_yaml(config_file) + assert config is not None + assert "python" in config + assert config["python"]["manager"] == "system" + + def test_install_interactive_prompt_skip(self, mock_claude_project: Path) -> None: + """Test interactive Python setup prompt choosing skip.""" + runner = CliRunner() + + result = runner.invoke( + cli, + [ + "install", + "--platform", "claude", + "--path", str(mock_claude_project), + ], + input="3\n", # Choose skip + catch_exceptions=False, + ) + + assert result.exit_code == 0 + + # Verify config + config_file = mock_claude_project / ".deepwork" / "config.yml" + config = load_yaml(config_file) + assert config is not None + assert "python" in config + assert config["python"]["manager"] == "skip" diff --git a/tests/integration/test_install_requirements.py b/tests/integration/test_install_requirements.py index 63d8dcba..a99be4c7 100644 --- a/tests/integration/test_install_requirements.py +++ b/tests/integration/test_install_requirements.py @@ -46,7 +46,7 @@ def run_install(project_path: Path) -> None: runner = CliRunner() result = runner.invoke( cli, - ["install", "--platform", "claude", "--path", str(project_path)], + ["install", "--platform", "claude", "--path", str(project_path), "--python-manager", "skip"], catch_exceptions=False, ) assert result.exit_code == 0, f"Install failed: {result.output}" diff --git a/tests/unit/test_python_env.py b/tests/unit/test_python_env.py new file mode 100644 index 00000000..09d412e4 --- /dev/null +++ b/tests/unit/test_python_env.py @@ -0,0 +1,150 @@ +"""Unit tests for Python environment management.""" + +from pathlib import Path +from unittest.mock import Mock + +import pytest + +from deepwork.utils.python_env import PythonEnvironment + + +class TestPythonEnvironment: + """Tests for PythonEnvironment class.""" + + def test_init_with_defaults(self): + """Test initialization with default values.""" + env = PythonEnvironment({}) + assert env.manager == "uv" + assert env.version == "3.11" + assert env.venv_path == Path(".venv") + + def test_init_with_custom_config(self): + """Test initialization with custom configuration.""" + config = { + "manager": "system", + "version": "3.12", + "venv_path": "custom_venv" + } + env = PythonEnvironment(config) + assert env.manager == "system" + assert env.version == "3.12" + assert env.venv_path == Path("custom_venv") + + def test_detect_existing_venv(self, tmp_path): + """Test detection of existing .venv directory.""" + venv = tmp_path / ".venv" / "bin" + venv.mkdir(parents=True) + (venv / "python").touch() + + result = PythonEnvironment.detect_existing(tmp_path) + assert result == tmp_path / ".venv" + + def test_detect_existing_venv_alternative_names(self, tmp_path): + """Test detection of venv with alternative names.""" + venv = tmp_path / "venv" / "bin" + venv.mkdir(parents=True) + (venv / "python").touch() + + result = PythonEnvironment.detect_existing(tmp_path) + assert result == tmp_path / "venv" + + def test_detect_no_existing_venv(self, tmp_path): + """Test detection returns None when no venv exists.""" + result = PythonEnvironment.detect_existing(tmp_path) + assert result is None + + def test_setup_with_skip(self, tmp_path): + """Test skip mode returns True without creating venv.""" + env = PythonEnvironment({"manager": "skip"}) + assert env.setup(tmp_path) is True + + def test_setup_with_uv(self, tmp_path, mocker): + """Test creating venv using uv.""" + mocker.patch("shutil.which", return_value="/usr/bin/uv") + mock_run = mocker.patch("subprocess.run", return_value=Mock(returncode=0)) + + env = PythonEnvironment({"manager": "uv", "version": "3.11"}) + result = env.setup(tmp_path) + + assert result is True + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + assert args[0] == "uv" + assert args[1] == "venv" + assert "--python" in args + assert "3.11" in args + + def test_setup_with_uv_not_found(self, tmp_path, mocker): + """Test error when uv is not found.""" + mocker.patch("shutil.which", return_value=None) + + env = PythonEnvironment({"manager": "uv"}) + with pytest.raises(RuntimeError, match="uv not found"): + env.setup(tmp_path) + + def test_setup_with_system_python(self, tmp_path, mocker): + """Test creating venv using system Python.""" + mocker.patch("shutil.which", return_value="/usr/bin/python3") + mock_run = mocker.patch("subprocess.run", return_value=Mock(returncode=0)) + + env = PythonEnvironment({"manager": "system"}) + result = env.setup(tmp_path) + + assert result is True + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + assert args[0] == "/usr/bin/python3" + assert args[1] == "-m" + assert args[2] == "venv" + + def test_setup_with_system_python_not_found(self, tmp_path, mocker): + """Test error when system Python is not found.""" + mocker.patch("shutil.which", return_value=None) + + env = PythonEnvironment({"manager": "system"}) + with pytest.raises(RuntimeError, match="Python not found"): + env.setup(tmp_path) + + def test_setup_with_invalid_manager(self, tmp_path): + """Test setup with invalid manager returns False.""" + env = PythonEnvironment({"manager": "invalid"}) + result = env.setup(tmp_path) + assert result is False + + def test_install_package_with_uv(self, tmp_path, mocker): + """Test installing package with uv.""" + mock_run = mocker.patch("subprocess.run", return_value=Mock(returncode=0)) + + env = PythonEnvironment({"manager": "uv"}) + result = env.install_package("pytest", tmp_path) + + assert result is True + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + assert args == ["uv", "pip", "install", "pytest"] + + def test_install_package_with_system(self, tmp_path, mocker): + """Test installing package with system Python.""" + venv_dir = tmp_path / ".venv" / "bin" + venv_dir.mkdir(parents=True) + + mock_run = mocker.patch("subprocess.run", return_value=Mock(returncode=0)) + + env = PythonEnvironment({"manager": "system"}) + result = env.install_package("pytest", tmp_path) + + assert result is True + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + assert str(tmp_path / ".venv" / "bin" / "pip") in args[0] + assert args[1] == "install" + assert args[2] == "pytest" + + def test_install_package_failure(self, tmp_path, mocker): + """Test package installation failure.""" + mock_run = mocker.patch("subprocess.run", return_value=Mock(returncode=1)) + + env = PythonEnvironment({"manager": "uv"}) + result = env.install_package("pytest", tmp_path) + + assert result is False From e00aa43590a71eecd70a0566bc224e72c4abdd74 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 19:52:54 +0000 Subject: [PATCH 3/4] Ensure .venv is added to project .gitignore - Created _ensure_venv_in_gitignore() function to manage project .gitignore - Automatically creates or updates .gitignore to include .venv when venv is created - Handles existing .gitignore files gracefully by appending - Prevents duplication if .venv already exists in .gitignore - Added 3 new integration tests to verify gitignore handling - All 40 integration tests pass - All 14 unit tests for python_env pass Co-authored-by: ncrmro <8276365+ncrmro@users.noreply.github.com> --- src/deepwork/cli/install.py | 39 +++++++++++++ tests/integration/test_install_python.py | 74 ++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/src/deepwork/cli/install.py b/src/deepwork/cli/install.py index aca2a28c..47843d1f 100644 --- a/src/deepwork/cli/install.py +++ b/src/deepwork/cli/install.py @@ -148,6 +148,42 @@ def _create_tmp_directory(deepwork_dir: Path) -> None: ) +def _ensure_venv_in_gitignore(project_path: Path, venv_path: str) -> None: + """ + Ensure the virtual environment is in the project's .gitignore. + + Creates or updates the project's .gitignore file to include the venv path. + + Args: + project_path: Path to project root directory + venv_path: Path to virtual environment (e.g., ".venv") + """ + gitignore_path = project_path / ".gitignore" + + # Read existing .gitignore if it exists + if gitignore_path.exists(): + content = gitignore_path.read_text() + lines = content.splitlines() + else: + content = "" + lines = [] + + # Check if venv_path is already in .gitignore (with or without trailing slash) + venv_patterns = {venv_path, f"{venv_path}/", f"/{venv_path}", f"/{venv_path}/"} + if any(line.strip() in venv_patterns for line in lines): + return # Already present + + # Add venv to .gitignore + if content and not content.endswith("\n"): + content += "\n" + + if content: + content += "\n" + + content += f"# Python virtual environment (added by DeepWork)\n{venv_path}\n" + gitignore_path.write_text(content) + + def _create_rules_directory(project_path: Path) -> bool: """ Create the v2 rules directory structure with example templates. @@ -400,6 +436,9 @@ def _install_deepwork(platform_name: str | None, project_path: Path, python_mana success = env.setup(project_path) if success: console.print(" [green]✓[/green] Virtual environment created") + # Ensure venv is in project's .gitignore + _ensure_venv_in_gitignore(project_path, python_config["venv_path"]) + console.print(" [green]✓[/green] Added .venv to .gitignore") else: console.print(" [yellow]⚠[/yellow] Virtual environment setup returned False") except RuntimeError as e: diff --git a/tests/integration/test_install_python.py b/tests/integration/test_install_python.py index d5b083f5..2f3b179e 100644 --- a/tests/integration/test_install_python.py +++ b/tests/integration/test_install_python.py @@ -191,3 +191,77 @@ def test_install_interactive_prompt_skip(self, mock_claude_project: Path) -> Non assert config is not None assert "python" in config assert config["python"]["manager"] == "skip" + + def test_install_creates_gitignore_with_venv(self, mock_claude_project: Path) -> None: + """Test that install creates .gitignore with .venv entry.""" + runner = CliRunner() + + result = runner.invoke( + cli, + [ + "install", + "--platform", "claude", + "--path", str(mock_claude_project), + "--python-manager", "uv" + ], + catch_exceptions=False, + ) + + # Verify .gitignore was created + gitignore_path = mock_claude_project / ".gitignore" + assert gitignore_path.exists() + + # Verify .venv is in .gitignore + gitignore_content = gitignore_path.read_text() + assert ".venv" in gitignore_content + + def test_install_appends_to_existing_gitignore(self, mock_claude_project: Path) -> None: + """Test that install appends .venv to existing .gitignore.""" + # Create existing .gitignore + gitignore_path = mock_claude_project / ".gitignore" + original_content = "*.pyc\n__pycache__/\n" + gitignore_path.write_text(original_content) + + runner = CliRunner() + + result = runner.invoke( + cli, + [ + "install", + "--platform", "claude", + "--path", str(mock_claude_project), + "--python-manager", "uv" + ], + catch_exceptions=False, + ) + + # Verify original content is preserved + gitignore_content = gitignore_path.read_text() + assert "*.pyc" in gitignore_content + assert "__pycache__/" in gitignore_content + + # Verify .venv was added + assert ".venv" in gitignore_content + + def test_install_does_not_duplicate_venv_in_gitignore(self, mock_claude_project: Path) -> None: + """Test that install doesn't duplicate .venv if already in .gitignore.""" + # Create .gitignore with .venv already present + gitignore_path = mock_claude_project / ".gitignore" + gitignore_path.write_text(".venv\n") + + runner = CliRunner() + + result = runner.invoke( + cli, + [ + "install", + "--platform", "claude", + "--path", str(mock_claude_project), + "--python-manager", "uv" + ], + catch_exceptions=False, + ) + + # Verify .venv appears only once + gitignore_content = gitignore_path.read_text() + assert gitignore_content.count(".venv") == 1 From 4130a983cb7ecd6389633a6625bcc49b8fc332fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:10:28 +0000 Subject: [PATCH 4/4] Run uv init --no-workspace to create pyproject.toml - Modified _setup_with_uv() to run uv init --no-workspace before creating venv - Only runs uv init if pyproject.toml doesn't already exist - Preserves existing pyproject.toml files without modification - Updated unit test to verify uv init is called appropriately - Added new unit test for when pyproject.toml already exists - Added 2 new integration tests to verify pyproject.toml handling - All 15 unit tests pass - All 42 integration tests pass Co-authored-by: ncrmro <8276365+ncrmro@users.noreply.github.com> --- src/deepwork/utils/python_env.py | 14 +++++- tests/integration/test_install_python.py | 54 ++++++++++++++++++++++++ tests/unit/test_python_env.py | 30 +++++++++++++ 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/deepwork/utils/python_env.py b/src/deepwork/utils/python_env.py index c3a6cc22..c1a01618 100644 --- a/src/deepwork/utils/python_env.py +++ b/src/deepwork/utils/python_env.py @@ -40,17 +40,18 @@ def setup(self, project_root: Path) -> bool: venv_dir = project_root / self.venv_path if self.manager == "uv": - return self._setup_with_uv(venv_dir) + return self._setup_with_uv(venv_dir, project_root) elif self.manager == "system": return self._setup_with_system(venv_dir) return False - def _setup_with_uv(self, venv_dir: Path) -> bool: + def _setup_with_uv(self, venv_dir: Path, project_root: Path) -> bool: """Create venv using uv. Args: venv_dir: Path where virtual environment should be created + project_root: Path to the project root directory Returns: True if creation succeeded, False otherwise @@ -61,6 +62,15 @@ def _setup_with_uv(self, venv_dir: Path) -> bool: if not shutil.which("uv"): raise RuntimeError("uv not found. Install via: brew install uv") + # Initialize pyproject.toml if it doesn't exist + pyproject_path = project_root / "pyproject.toml" + if not pyproject_path.exists(): + init_cmd = ["uv", "init", "--no-workspace"] + init_result = subprocess.run(init_cmd, capture_output=True, text=True, cwd=project_root) + if init_result.returncode != 0: + # Non-fatal: Continue even if init fails + pass + cmd = ["uv", "venv", str(venv_dir), "--python", self.version] result = subprocess.run(cmd, capture_output=True, text=True) return result.returncode == 0 diff --git a/tests/integration/test_install_python.py b/tests/integration/test_install_python.py index 2f3b179e..b398f53a 100644 --- a/tests/integration/test_install_python.py +++ b/tests/integration/test_install_python.py @@ -265,3 +265,57 @@ def test_install_does_not_duplicate_venv_in_gitignore(self, mock_claude_project: # Verify .venv appears only once gitignore_content = gitignore_path.read_text() assert gitignore_content.count(".venv") == 1 + + def test_install_creates_pyproject_with_uv(self, mock_claude_project: Path) -> None: + """Test that install creates pyproject.toml when using uv.""" + runner = CliRunner() + + result = runner.invoke( + cli, + [ + "install", + "--platform", "claude", + "--path", str(mock_claude_project), + "--python-manager", "uv" + ], + catch_exceptions=False, + ) + + # Verify pyproject.toml was created + pyproject_path = mock_claude_project / "pyproject.toml" + assert pyproject_path.exists() + + # Verify it has basic structure + pyproject_content = pyproject_path.read_text() + assert "[project]" in pyproject_content + assert "name" in pyproject_content + + def test_install_preserves_existing_pyproject(self, mock_claude_project: Path) -> None: + """Test that install doesn't overwrite existing pyproject.toml.""" + # Create existing pyproject.toml + pyproject_path = mock_claude_project / "pyproject.toml" + original_content = """[project] +name = "my-custom-project" +version = "2.0.0" +dependencies = ["custom-package"] +""" + pyproject_path.write_text(original_content) + + runner = CliRunner() + + result = runner.invoke( + cli, + [ + "install", + "--platform", "claude", + "--path", str(mock_claude_project), + "--python-manager", "uv" + ], + catch_exceptions=False, + ) + + # Verify original content is preserved + pyproject_content = pyproject_path.read_text() + assert pyproject_content == original_content + assert "my-custom-project" in pyproject_content + assert "custom-package" in pyproject_content diff --git a/tests/unit/test_python_env.py b/tests/unit/test_python_env.py index 09d412e4..e02c93d7 100644 --- a/tests/unit/test_python_env.py +++ b/tests/unit/test_python_env.py @@ -67,6 +67,35 @@ def test_setup_with_uv(self, tmp_path, mocker): result = env.setup(tmp_path) assert result is True + # Should call uv init first (since pyproject.toml doesn't exist), then uv venv + assert mock_run.call_count == 2 + + # First call should be uv init + first_call_args = mock_run.call_args_list[0][0][0] + assert first_call_args[0] == "uv" + assert first_call_args[1] == "init" + assert "--no-workspace" in first_call_args + + # Second call should be uv venv + second_call_args = mock_run.call_args_list[1][0][0] + assert second_call_args[0] == "uv" + assert second_call_args[1] == "venv" + assert "--python" in second_call_args + assert "3.11" in second_call_args + + def test_setup_with_uv_existing_pyproject(self, tmp_path, mocker): + """Test creating venv using uv when pyproject.toml already exists.""" + # Create existing pyproject.toml + (tmp_path / "pyproject.toml").write_text("[project]\nname = \"test\"\n") + + mocker.patch("shutil.which", return_value="/usr/bin/uv") + mock_run = mocker.patch("subprocess.run", return_value=Mock(returncode=0)) + + env = PythonEnvironment({"manager": "uv", "version": "3.11"}) + result = env.setup(tmp_path) + + assert result is True + # Should only call uv venv (not uv init since pyproject.toml exists) mock_run.assert_called_once() args = mock_run.call_args[0][0] assert args[0] == "uv" @@ -74,6 +103,7 @@ def test_setup_with_uv(self, tmp_path, mocker): assert "--python" in args assert "3.11" in args + def test_setup_with_uv_not_found(self, tmp_path, mocker): """Test error when uv is not found.""" mocker.patch("shutil.which", return_value=None)