Skip to content

Commit 093d528

Browse files
Copilotncrmro
andcommitted
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>
1 parent fa0e5e7 commit 093d528

6 files changed

Lines changed: 553 additions & 12 deletions

File tree

src/deepwork/cli/install.py

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22

33
import shutil
44
from pathlib import Path
5+
from typing import Optional
56

67
import click
78
from rich.console import Console
9+
from rich.prompt import Prompt
810

911
from deepwork.core.adapters import AgentAdapter
1012
from deepwork.core.detector import PlatformDetector
1113
from deepwork.utils.fs import ensure_dir
1214
from deepwork.utils.git import is_git_repo
15+
from deepwork.utils.python_env import PythonEnvironment
1316
from deepwork.utils.yaml_utils import load_yaml, save_yaml
1417

1518
console = Console()
@@ -226,6 +229,42 @@ def _create_rules_directory(project_path: Path) -> bool:
226229
return True
227230

228231

232+
def _prompt_python_setup(console: Console) -> dict:
233+
"""Prompt user for Python environment preferences.
234+
235+
Args:
236+
console: Rich console for output
237+
238+
Returns:
239+
Dictionary containing python configuration:
240+
- manager: "uv" | "system" | "skip"
241+
- version: Python version string
242+
- venv_path: Path to virtual environment
243+
"""
244+
console.print("\n[bold]Python Environment Setup[/bold]")
245+
console.print("=" * 40)
246+
console.print("\nHow should Python dependencies be managed?\n")
247+
248+
choices_display = [
249+
("1", "uv (Recommended)", "Creates isolated .venv with project-specific Python"),
250+
("2", "System Python", "Uses existing python3 from PATH"),
251+
("3", "Skip", "No Python environment setup"),
252+
]
253+
254+
for key, name, desc in choices_display:
255+
console.print(f" [{key}] {name}")
256+
console.print(f" {desc}\n")
257+
258+
choice = Prompt.ask("Choice", default="1", choices=["1", "2", "3"])
259+
260+
manager_map = {"1": "uv", "2": "system", "3": "skip"}
261+
return {
262+
"manager": manager_map[choice],
263+
"version": "3.11",
264+
"venv_path": ".venv"
265+
}
266+
267+
229268
class DynamicChoice(click.Choice):
230269
"""A Click Choice that gets its values dynamically from AgentAdapter."""
231270

@@ -248,15 +287,20 @@ def __init__(self) -> None:
248287
default=".",
249288
help="Path to project directory (default: current directory)",
250289
)
251-
def install(platform: str | None, path: Path) -> None:
290+
@click.option(
291+
"--python-manager",
292+
type=click.Choice(["uv", "system", "skip"]),
293+
help="Python environment manager (skips interactive prompt)",
294+
)
295+
def install(platform: str | None, path: Path, python_manager: str | None) -> None:
252296
"""
253297
Install DeepWork in a project.
254298
255299
Adds the specified AI platform to the project configuration and syncs
256300
commands for all configured platforms.
257301
"""
258302
try:
259-
_install_deepwork(platform, path)
303+
_install_deepwork(platform, path, python_manager)
260304
except InstallError as e:
261305
console.print(f"[red]Error:[/red] {e}")
262306
raise click.Abort() from e
@@ -265,13 +309,14 @@ def install(platform: str | None, path: Path) -> None:
265309
raise
266310

267311

268-
def _install_deepwork(platform_name: str | None, project_path: Path) -> None:
312+
def _install_deepwork(platform_name: str | None, project_path: Path, python_manager: str | None) -> None:
269313
"""
270314
Install DeepWork in a project.
271315
272316
Args:
273317
platform_name: Platform to install for (or None to auto-detect)
274318
project_path: Path to project directory
319+
python_manager: Python environment manager choice (or None to prompt)
275320
276321
Raises:
277322
InstallError: If installation fails
@@ -335,6 +380,32 @@ def _install_deepwork(platform_name: str | None, project_path: Path) -> None:
335380
platforms_to_add.append(adapter.name)
336381
detected_adapters = available_adapters
337382

383+
# Step 2b: Python environment setup
384+
if python_manager:
385+
python_config = {"manager": python_manager, "version": "3.11", "venv_path": ".venv"}
386+
else:
387+
# Check for existing venv
388+
existing = PythonEnvironment.detect_existing(project_path)
389+
if existing:
390+
console.print(f"\n[green]→[/green] Found existing virtual environment: {existing.relative_to(project_path)}")
391+
python_config = {"manager": "skip", "version": "3.11", "venv_path": str(existing.relative_to(project_path))}
392+
else:
393+
python_config = _prompt_python_setup(console)
394+
395+
# Create Python environment
396+
if python_config["manager"] != "skip":
397+
console.print(f"\n[yellow]→[/yellow] Setting up Python environment with {python_config['manager']}...")
398+
env = PythonEnvironment(python_config)
399+
try:
400+
success = env.setup(project_path)
401+
if success:
402+
console.print(" [green]✓[/green] Virtual environment created")
403+
else:
404+
console.print(" [yellow]⚠[/yellow] Virtual environment setup returned False")
405+
except RuntimeError as e:
406+
console.print(f" [red]✗[/red] Failed: {e}")
407+
raise InstallError(f"Python environment setup failed: {e}") from e
408+
338409
# Step 3: Create .deepwork/ directory structure
339410
console.print("[yellow]→[/yellow] Creating DeepWork directory structure...")
340411
deepwork_dir = project_path / ".deepwork"
@@ -393,6 +464,9 @@ def _install_deepwork(platform_name: str | None, project_path: Path) -> None:
393464
else:
394465
console.print(f" [dim]•[/dim] {adapter.display_name} already configured")
395466

467+
# Add python configuration
468+
config_data["python"] = python_config
469+
396470
save_yaml(config_file, config_data)
397471
console.print(f" [green]✓[/green] Updated {config_file.relative_to(project_path)}")
398472

src/deepwork/utils/python_env.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Python environment management utilities."""
2+
3+
import shutil
4+
import subprocess
5+
from pathlib import Path
6+
from typing import Optional
7+
8+
9+
class PythonEnvironment:
10+
"""Manages Python virtual environments."""
11+
12+
def __init__(self, config: dict):
13+
"""Initialize Python environment manager.
14+
15+
Args:
16+
config: Dictionary containing:
17+
- manager: "uv" | "system" | "skip"
18+
- version: Python version string (e.g., "3.11")
19+
- venv_path: Path to virtual environment (e.g., ".venv")
20+
"""
21+
self.manager = config.get("manager", "uv")
22+
self.version = config.get("version", "3.11")
23+
self.venv_path = Path(config.get("venv_path", ".venv"))
24+
25+
def setup(self, project_root: Path) -> bool:
26+
"""Create virtual environment based on configured manager.
27+
28+
Args:
29+
project_root: Path to the project root directory
30+
31+
Returns:
32+
True if setup succeeded, False otherwise
33+
34+
Raises:
35+
RuntimeError: If required tools are not available
36+
"""
37+
if self.manager == "skip":
38+
return True
39+
40+
venv_dir = project_root / self.venv_path
41+
42+
if self.manager == "uv":
43+
return self._setup_with_uv(venv_dir)
44+
elif self.manager == "system":
45+
return self._setup_with_system(venv_dir)
46+
47+
return False
48+
49+
def _setup_with_uv(self, venv_dir: Path) -> bool:
50+
"""Create venv using uv.
51+
52+
Args:
53+
venv_dir: Path where virtual environment should be created
54+
55+
Returns:
56+
True if creation succeeded, False otherwise
57+
58+
Raises:
59+
RuntimeError: If uv is not found
60+
"""
61+
if not shutil.which("uv"):
62+
raise RuntimeError("uv not found. Install via: brew install uv")
63+
64+
cmd = ["uv", "venv", str(venv_dir), "--python", self.version]
65+
result = subprocess.run(cmd, capture_output=True, text=True)
66+
return result.returncode == 0
67+
68+
def _setup_with_system(self, venv_dir: Path) -> bool:
69+
"""Create venv using system Python.
70+
71+
Args:
72+
venv_dir: Path where virtual environment should be created
73+
74+
Returns:
75+
True if creation succeeded, False otherwise
76+
77+
Raises:
78+
RuntimeError: If Python is not found
79+
"""
80+
python = shutil.which("python3") or shutil.which("python")
81+
if not python:
82+
raise RuntimeError("Python not found in PATH")
83+
84+
cmd = [python, "-m", "venv", str(venv_dir)]
85+
result = subprocess.run(cmd, capture_output=True, text=True)
86+
return result.returncode == 0
87+
88+
def install_package(self, package: str, project_root: Path) -> bool:
89+
"""Install a package into the virtual environment.
90+
91+
Args:
92+
package: Package name to install
93+
project_root: Path to the project root directory
94+
95+
Returns:
96+
True if installation succeeded, False otherwise
97+
"""
98+
venv_dir = project_root / self.venv_path
99+
100+
if self.manager == "uv":
101+
cmd = ["uv", "pip", "install", package]
102+
else:
103+
pip = venv_dir / "bin" / "pip"
104+
cmd = [str(pip), "install", package]
105+
106+
result = subprocess.run(cmd, capture_output=True, text=True, cwd=project_root)
107+
return result.returncode == 0
108+
109+
@staticmethod
110+
def detect_existing(project_root: Path) -> Optional[Path]:
111+
"""Detect existing virtual environment.
112+
113+
Args:
114+
project_root: Path to the project root directory
115+
116+
Returns:
117+
Path to detected virtual environment, or None if not found
118+
"""
119+
candidates = [".venv", "venv", ".virtualenv"]
120+
for name in candidates:
121+
venv_dir = project_root / name
122+
if (venv_dir / "bin" / "python").exists():
123+
return venv_dir
124+
return None

tests/integration/test_install_flow.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ def test_install_with_claude(self, mock_claude_project: Path) -> None:
1717

1818
result = runner.invoke(
1919
cli,
20-
["install", "--platform", "claude", "--path", str(mock_claude_project)],
20+
["install", "--platform", "claude", "--path", str(mock_claude_project), "--python-manager", "skip"],
2121
catch_exceptions=False,
2222
)
2323

@@ -63,7 +63,7 @@ def test_install_with_auto_detect(self, mock_claude_project: Path) -> None:
6363
runner = CliRunner()
6464

6565
result = runner.invoke(
66-
cli, ["install", "--path", str(mock_claude_project)], catch_exceptions=False
66+
cli, ["install", "--path", str(mock_claude_project), "--python-manager", "skip"], catch_exceptions=False
6767
)
6868

6969
assert result.exit_code == 0
@@ -84,7 +84,7 @@ def test_install_defaults_to_claude_when_no_platform(self, mock_git_repo: Path)
8484
runner = CliRunner()
8585

8686
result = runner.invoke(
87-
cli, ["install", "--path", str(mock_git_repo)], catch_exceptions=False
87+
cli, ["install", "--path", str(mock_git_repo), "--python-manager", "skip"], catch_exceptions=False
8888
)
8989

9090
assert result.exit_code == 0
@@ -114,7 +114,7 @@ def test_install_with_multiple_platforms_auto_detect(
114114

115115
result = runner.invoke(
116116
cli,
117-
["install", "--path", str(mock_multi_platform_project)],
117+
["install", "--path", str(mock_multi_platform_project), "--python-manager", "skip"],
118118
catch_exceptions=False,
119119
)
120120

@@ -162,15 +162,15 @@ def test_install_is_idempotent(self, mock_claude_project: Path) -> None:
162162
# First install
163163
result1 = runner.invoke(
164164
cli,
165-
["install", "--platform", "claude", "--path", str(mock_claude_project)],
165+
["install", "--platform", "claude", "--path", str(mock_claude_project), "--python-manager", "skip"],
166166
catch_exceptions=False,
167167
)
168168
assert result1.exit_code == 0
169169

170170
# Second install
171171
result2 = runner.invoke(
172172
cli,
173-
["install", "--platform", "claude", "--path", str(mock_claude_project)],
173+
["install", "--platform", "claude", "--path", str(mock_claude_project), "--python-manager", "skip"],
174174
catch_exceptions=False,
175175
)
176176
assert result2.exit_code == 0
@@ -191,7 +191,7 @@ def test_install_creates_rules_directory(self, mock_claude_project: Path) -> Non
191191

192192
result = runner.invoke(
193193
cli,
194-
["install", "--platform", "claude", "--path", str(mock_claude_project)],
194+
["install", "--platform", "claude", "--path", str(mock_claude_project), "--python-manager", "skip"],
195195
catch_exceptions=False,
196196
)
197197

@@ -231,7 +231,7 @@ def test_install_preserves_existing_rules_directory(self, mock_claude_project: P
231231

232232
result = runner.invoke(
233233
cli,
234-
["install", "--platform", "claude", "--path", str(mock_claude_project)],
234+
["install", "--platform", "claude", "--path", str(mock_claude_project), "--python-manager", "skip"],
235235
catch_exceptions=False,
236236
)
237237

0 commit comments

Comments
 (0)