Skip to content

Commit 18e8edd

Browse files
authored
Merge pull request #10 from cmpnd-ai/isaac/dependency-test
Add testing workflow and require local installation
2 parents dfbceda + 90bba50 commit 18e8edd

File tree

12 files changed

+3018
-113
lines changed

12 files changed

+3018
-113
lines changed

.github/workflows/test.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
workflow_dispatch:
8+
9+
jobs:
10+
test:
11+
runs-on: blacksmith-4vcpu-ubuntu-2404
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- name: Install uv
16+
uses: astral-sh/setup-uv@v6
17+
18+
- name: Install dependencies and run tests
19+
run: |
20+
uv sync --all-extras
21+
uv run pytest

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ A command-line interface tool for creating and serving DSPy projects, inspired b
55
## Installation
66

77
```bash
8-
pip install dspy-cli
8+
uv add dspy-cli
99
```
1010

1111
### Installing for Development/Testing
@@ -16,8 +16,8 @@ If you're testing or developing dspy-cli itself:
1616
# Clone or navigate to the dspy-cli repository
1717
cd /path/to/dspy-cli
1818

19-
# Install in editable mode
20-
pip install -e .
19+
# Sync dependencies
20+
uv sync --extra dev
2121

2222
# Now the dspy-cli command is available
2323
dspy-cli --help

examples/blog-tools/uv.lock

Lines changed: 2325 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ target-version = ["py39"]
5454
line-length = 120
5555
target-version = "py39"
5656

57+
[tool.pytest.ini_options]
58+
testpaths = ["tests"]
59+
5760
[dependency-groups]
5861
dev = [
5962
"docker>=7.1.0",

src/dspy_cli/commands/new.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,13 @@
2323
default=None,
2424
help='Inline signature string (e.g., "question -> answer" or "post -> tags: list[str]")',
2525
)
26-
def new(project_name, program_name, signature):
26+
@click.option(
27+
"--link-dspy-cli",
28+
type=click.Path(exists=True, file_okay=False, dir_okay=True),
29+
default=None,
30+
help="Add a uv path override for local dspy-cli development",
31+
)
32+
def new(project_name, program_name, signature, link_dspy_cli):
2733
"""Create a new DSPy project with boilerplate structure.
2834
2935
Creates a directory with PROJECT_NAME and sets up a complete
@@ -86,6 +92,9 @@ def new(project_name, program_name, signature):
8692
# Create configuration files
8793
_create_config_files(project_path, project_name, program_name, package_name)
8894

95+
# Add uv path override if requested
96+
_add_uv_override_if_requested(project_path, link_dspy_cli)
97+
8998
# Create Python code files
9099
_create_code_files(project_path, package_name, program_name, signature, signature_fields)
91100

@@ -97,7 +106,8 @@ def new(project_name, program_name, signature):
97106
click.echo("Next steps:")
98107
click.echo(f" cd {project_name}")
99108
click.echo(" # Edit .env and add your API keys")
100-
click.echo(" pip install -e .")
109+
click.echo(" uv sync")
110+
click.echo(" source .venv/bin/activate")
101111
click.echo(" dspy-cli serve")
102112

103113
except Exception as e:
@@ -128,6 +138,31 @@ def _create_directory_structure(project_path, package_name, program_name):
128138
click.echo(f" Created: {directory.relative_to(project_path.parent)}")
129139

130140

141+
def _add_uv_override_if_requested(project_path, link_dspy_cli):
142+
"""Add uv path override for dspy-cli if requested."""
143+
# Check flag or environment variable
144+
link = link_dspy_cli or os.getenv("DSPY_CLI_PATH")
145+
if not link:
146+
return
147+
148+
# Convert to relative path
149+
link_path = Path(link).resolve()
150+
try:
151+
rel_path = os.path.relpath(link_path, project_path)
152+
except ValueError:
153+
# Different drives on Windows, use absolute
154+
rel_path = str(link_path)
155+
156+
# Append uv.sources block
157+
uv_block = f'\n[tool.uv.sources]\ndspy-cli = {{ path = "{rel_path}", editable = true }}\n'
158+
pyproject_path = project_path / "pyproject.toml"
159+
current_content = pyproject_path.read_text()
160+
pyproject_path.write_text(current_content + uv_block)
161+
162+
click.echo(click.style(f" Added uv path override: dspy-cli -> {rel_path}", fg="cyan"))
163+
click.echo(click.style(" Note: This is for local development only - do not commit this override", fg="yellow"))
164+
165+
131166
def _create_config_files(project_path, project_name, program_name, package_name):
132167
"""Create configuration files from templates."""
133168
from dspy_cli.templates import code_templates

src/dspy_cli/commands/serve.py

Lines changed: 117 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,58 @@
11
"""Command to serve DSPy programs as an API."""
22

3+
import os
4+
import shlex
5+
import subprocess
36
import sys
47
from pathlib import Path
8+
from typing import NoReturn
59

610
import click
7-
import uvicorn
811

9-
from dspy_cli.config import ConfigError, load_config
10-
from dspy_cli.config.validator import find_package_directory, validate_project_structure
11-
from dspy_cli.server.app import create_app
12+
from dspy_cli.server.runner import main as runner_main
13+
from dspy_cli.utils.venv import (
14+
detect_venv_python,
15+
has_package,
16+
is_in_project_venv,
17+
sanitize_env_for_exec,
18+
show_install_instructions,
19+
show_venv_warning,
20+
validate_python_version,
21+
)
22+
23+
24+
def _exec_clean(target_python: Path, args: list[str]) -> NoReturn:
25+
"""Execute the server using the target Python with a clean environment."""
26+
env = sanitize_env_for_exec()
27+
cmd = [str(target_python)] + args
28+
29+
# On Windows, os.execvpe has issues (Python bug #19124), use subprocess
30+
if sys.platform == "win32":
31+
try:
32+
result = subprocess.run(cmd, env=env)
33+
sys.exit(result.returncode)
34+
except FileNotFoundError:
35+
click.echo(click.style(f"Error: Python interpreter not found: {target_python}", fg="red"), err=True)
36+
sys.exit(1)
37+
except PermissionError:
38+
click.echo(click.style(f"Error: Permission denied executing: {target_python}", fg="red"), err=True)
39+
sys.exit(1)
40+
except KeyboardInterrupt:
41+
sys.exit(130)
42+
else:
43+
# Unix: use exec for efficient process replacement
44+
try:
45+
os.execvpe(str(target_python), cmd, env)
46+
except OSError as e:
47+
click.echo(click.style(f"Error executing {target_python}: {e}", fg="red"), err=True)
48+
sys.exit(1)
1249

1350

1451
@click.command()
1552
@click.option(
1653
"--port",
1754
default=8000,
18-
type=int,
55+
type=click.IntRange(1, 65535),
1956
help="Port to run the server on (default: 8000)",
2057
)
2158
@click.option(
@@ -35,7 +72,18 @@
3572
is_flag=True,
3673
help="Enable web UI for interactive testing",
3774
)
38-
def serve(port, host, logs_dir, ui):
75+
@click.option(
76+
"--python",
77+
default=None,
78+
type=click.Path(exists=True, dir_okay=False),
79+
help="Path to Python interpreter to use (default: auto-detect)",
80+
)
81+
@click.option(
82+
"--system",
83+
is_flag=True,
84+
help="Use system Python environment instead of project venv",
85+
)
86+
def serve(port, host, logs_dir, ui, python, system):
3987
"""Start an HTTP API server that exposes your DSPy programs.
4088
4189
This command:
@@ -47,107 +95,68 @@ def serve(port, host, logs_dir, ui):
4795
Example:
4896
dspy-cli serve
4997
dspy-cli serve --port 8080 --host 127.0.0.1
98+
dspy-cli serve --python /path/to/venv/bin/python
5099
"""
51-
click.echo("Starting DSPy API server...")
52-
click.echo()
53-
54-
# Validate project structure
55-
if not validate_project_structure():
56-
click.echo(click.style("Error: Not a valid DSPy project directory", fg="red"))
57-
click.echo()
58-
click.echo("Make sure you're in a directory created with 'dspy-cli new'")
59-
click.echo("Required files: dspy.config.yaml, src/")
60-
raise click.Abort()
61-
62-
# Find package directory
63-
package_dir = find_package_directory()
64-
if not package_dir:
65-
click.echo(click.style("Error: Could not find package in src/", fg="red"))
66-
raise click.Abort()
67-
68-
package_name = package_dir.name
69-
modules_path = package_dir / "modules"
70-
71-
if not modules_path.exists():
72-
click.echo(click.style(f"Error: modules directory not found: {modules_path}", fg="red"))
73-
raise click.Abort()
74-
75-
# Load configuration
76-
try:
77-
config = load_config()
78-
except ConfigError as e:
79-
click.echo(click.style(f"Configuration error: {e}", fg="red"))
80-
raise click.Abort()
81-
82-
click.echo(click.style("✓ Configuration loaded", fg="green"))
83-
84-
# Create logs directory
85-
if logs_dir:
86-
logs_path = Path(logs_dir)
87-
else:
88-
logs_path = Path.cwd() / "logs"
89-
logs_path.mkdir(exist_ok=True)
90-
91-
# Create FastAPI app
92-
try:
93-
app = create_app(
94-
config=config,
95-
package_path=modules_path,
96-
package_name=f"{package_name}.modules",
97-
logs_dir=logs_path,
98-
enable_ui=ui
99-
)
100-
except Exception as e:
101-
click.echo(click.style(f"Error creating application: {e}", fg="red"))
102-
raise click.Abort()
103-
104-
# Print discovered programs
105-
click.echo()
106-
click.echo(click.style("Discovered Programs:", fg="cyan", bold=True))
107-
click.echo()
108-
109-
if hasattr(app.state, 'modules') and app.state.modules:
110-
for module in app.state.modules:
111-
click.echo(f" • {module.name}")
112-
click.echo(f" POST /{module.name}")
100+
if system:
101+
runner_main(port=port, host=host, logs_dir=logs_dir, ui=ui)
102+
return
103+
104+
target_python = None
105+
if python:
106+
target_python = Path(python)
107+
108+
# Validate it's actually a Python interpreter
109+
if not target_python.is_file():
110+
click.echo(click.style(f"Error: Not a valid Python executable: {target_python}", fg="red"), err=True)
111+
sys.exit(1)
112+
113+
# On Unix, check if executable
114+
if sys.platform != "win32" and not os.access(target_python, os.X_OK):
115+
click.echo(click.style(f"Error: Python interpreter is not executable: {target_python}", fg="red"), err=True)
116+
sys.exit(1)
117+
118+
# Validate Python version
119+
is_valid, version = validate_python_version(target_python, min_version=(3, 9))
120+
if not is_valid:
121+
if version:
122+
click.echo(click.style(f"Error: Python {version} is too old. Minimum required: Python 3.9", fg="red"), err=True)
123+
else:
124+
click.echo(click.style(f"Error: Could not determine Python version for: {target_python}", fg="red"), err=True)
125+
sys.exit(1)
126+
elif not is_in_project_venv():
127+
target_python = detect_venv_python()
128+
if not target_python:
129+
show_venv_warning()
130+
131+
if target_python:
132+
import dspy_cli
133+
134+
has_cli, local_version = has_package(target_python, "dspy_cli")
135+
136+
if not has_cli:
137+
global_version = dspy_cli.__version__
138+
show_install_instructions(target_python, global_version)
139+
sys.exit(1)
140+
141+
if local_version:
142+
global_version = dspy_cli.__version__
143+
local_major = local_version.split('.')[0]
144+
global_major = global_version.split('.')[0]
145+
146+
if local_major != global_major:
147+
click.echo(click.style(
148+
f"⚠ Version mismatch: local dspy-cli {local_version} vs global {global_version}",
149+
fg="yellow"
150+
))
151+
click.echo(f"Consider upgrading: {shlex.quote(str(target_python))} -m uv add 'dspy-cli=={global_version}'")
152+
click.echo()
153+
154+
args = ["-m", "dspy_cli.server.runner", "--port", str(port), "--host", host]
155+
if logs_dir:
156+
args.extend(["--logs-dir", logs_dir])
157+
if ui:
158+
args.append("--ui")
159+
160+
_exec_clean(target_python, args)
113161
else:
114-
click.echo(click.style(" No programs discovered", fg="yellow"))
115-
click.echo()
116-
click.echo("Make sure your DSPy modules:")
117-
click.echo(" 1. Are in src/<package>/modules/")
118-
click.echo(" 2. Subclass dspy.Module")
119-
click.echo(" 3. Are not named with a leading underscore")
120-
121-
click.echo()
122-
click.echo(click.style("Additional Endpoints:", fg="cyan", bold=True))
123-
click.echo()
124-
click.echo(" GET /programs - List all programs and their schemas")
125-
if ui:
126-
click.echo(" GET / - Web UI for interactive testing")
127-
click.echo()
128-
129-
# Print server information
130-
click.echo(click.style("=" * 60, fg="cyan"))
131-
click.echo(click.style(f"Server starting on http://{host}:{port}", fg="green", bold=True))
132-
click.echo(click.style("=" * 60, fg="cyan"))
133-
click.echo()
134-
click.echo("Press Ctrl+C to stop the server")
135-
click.echo()
136-
137-
# Start uvicorn server
138-
try:
139-
uvicorn.run(
140-
app,
141-
host=host,
142-
port=port,
143-
log_level="info",
144-
access_log=True
145-
)
146-
except KeyboardInterrupt:
147-
click.echo()
148-
click.echo(click.style("Server stopped", fg="yellow"))
149-
sys.exit(0)
150-
except Exception as e:
151-
click.echo()
152-
click.echo(click.style(f"Server error: {e}", fg="red"))
153-
sys.exit(1)
162+
runner_main(port=port, host=host, logs_dir=logs_dir, ui=ui)

src/dspy_cli/discovery/module_finder.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,17 @@ def discover_modules(
122122
)
123123
)
124124

125+
except ModuleNotFoundError as e:
126+
logger.error(f"Error loading module {py_file}: {e}")
127+
logger.warning(
128+
f"\n⚠ Missing dependency detected while importing {py_file.name}\n"
129+
f" This might be because you are using a global dspy-cli install rather than a local one.\n\n"
130+
f" To fix this:\n"
131+
f" 1. Install dependencies: uv sync (or pip install -e .)\n"
132+
f" 2. Run from within the venv: source .venv/bin/activate && dspy-cli serve\n"
133+
f" 3. Or use a task runner: uv run dspy-cli serve\n"
134+
)
135+
continue
125136
except Exception as e:
126137
logger.error(f"Error loading module {py_file}: {e}", exc_info=True)
127138
continue

0 commit comments

Comments
 (0)