11"""Command to serve DSPy programs as an API."""
22
3+ import os
4+ import shlex
5+ import subprocess
36import sys
47from pathlib import Path
8+ from typing import NoReturn
59
610import 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 (
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 )
0 commit comments