Skip to content

Commit ab596ff

Browse files
rustyconoverclaude
andcommitted
Add vgi-serve CLI for zero-boilerplate worker serving
Introduces `vgi-serve`, a CLI command that loads any Worker by module reference and serves it — stdio by default, --http for cloud deployment. Includes load_worker_class() for module/file loading with auto-discovery, create_app() WSGI factory for programmatic use (gunicorn, etc.), and refactors Worker._run_http() to use create_app() eliminating duplication. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 12e318c commit ab596ff

6 files changed

Lines changed: 731 additions & 38 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ dev = [
2727
"ruff",
2828
]
2929
[project.scripts]
30+
vgi-serve = "vgi.serve:main"
3031
vgi-client = "vgi.client.cli:main"
3132
vgi-example-worker = "vgi.examples.worker:main"
3233
vgi-example-http = "vgi.examples.http_server:main"

tests/test_serve.py

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
"""Tests for the vgi-serve CLI and programmatic API."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import socket
7+
import subprocess
8+
import sys
9+
import textwrap
10+
import time
11+
12+
import pyarrow as pa
13+
import pytest
14+
15+
from vgi.scalar_function import ScalarFunction
16+
from vgi.serve import create_app, load_worker_class
17+
from vgi.worker import Worker
18+
19+
# ---------------------------------------------------------------------------
20+
# Fixture workers for testing
21+
# ---------------------------------------------------------------------------
22+
23+
24+
class _DoubleFunc(ScalarFunction):
25+
class Meta:
26+
name = "double"
27+
28+
def compute(self, x: pa.Int64Array) -> pa.Int64Array:
29+
return pa.compute.multiply(x, 2)
30+
31+
32+
class _SingleWorker(Worker):
33+
"""Worker with one function — for auto-discover tests."""
34+
35+
functions = [_DoubleFunc]
36+
37+
38+
class _AnotherWorker(Worker):
39+
"""Second worker in same module — for multiple-workers error test."""
40+
41+
functions = [_DoubleFunc]
42+
43+
44+
# ---------------------------------------------------------------------------
45+
# Tests: load_worker_class
46+
# ---------------------------------------------------------------------------
47+
48+
49+
class TestLoadWorkerClass:
50+
"""Tests for load_worker_class()."""
51+
52+
def test_module_colon_classname(self) -> None:
53+
"""module:ClassName loads the exact class."""
54+
cls = load_worker_class("vgi.examples.worker:ExampleWorker")
55+
from vgi.examples.worker import ExampleWorker
56+
57+
assert cls is ExampleWorker
58+
59+
def test_auto_discover(self) -> None:
60+
"""Bare module auto-discovers the single Worker subclass."""
61+
cls = load_worker_class("vgi.examples.worker")
62+
from vgi.examples.worker import ExampleWorker
63+
64+
assert cls is ExampleWorker
65+
66+
def test_file_path(self, tmp_path: object) -> None:
67+
"""./file.py loads from a file path."""
68+
p = os.path.join(str(tmp_path), "my_worker.py")
69+
with open(p, "w") as f:
70+
f.write(
71+
textwrap.dedent("""\
72+
from vgi.worker import Worker
73+
from vgi.scalar_function import ScalarFunction
74+
import pyarrow as pa
75+
76+
class Dbl(ScalarFunction):
77+
class Meta:
78+
name = "dbl"
79+
def compute(self, x: pa.Int64Array) -> pa.Int64Array:
80+
return pa.compute.multiply(x, 2)
81+
82+
class FileWorker(Worker):
83+
functions = [Dbl]
84+
""")
85+
)
86+
cls = load_worker_class(p)
87+
assert cls.__name__ == "FileWorker"
88+
assert issubclass(cls, Worker)
89+
90+
def test_file_path_with_classname(self, tmp_path: object) -> None:
91+
"""./file.py:ClassName loads a specific class from a file."""
92+
p = os.path.join(str(tmp_path), "multi.py")
93+
with open(p, "w") as f:
94+
f.write(
95+
textwrap.dedent("""\
96+
from vgi.worker import Worker
97+
98+
class WorkerA(Worker):
99+
functions = []
100+
101+
class WorkerB(Worker):
102+
functions = []
103+
""")
104+
)
105+
cls = load_worker_class(f"{p}:WorkerB")
106+
assert cls.__name__ == "WorkerB"
107+
108+
def test_no_worker_exits(self, tmp_path: object) -> None:
109+
"""Module with no Worker subclass exits with error."""
110+
p = os.path.join(str(tmp_path), "empty.py")
111+
with open(p, "w") as f:
112+
f.write("x = 1\n")
113+
with pytest.raises(SystemExit):
114+
load_worker_class(p)
115+
116+
def test_multiple_workers_exits(self, tmp_path: object) -> None:
117+
"""Module with multiple Workers (no :Class) exits with error."""
118+
p = os.path.join(str(tmp_path), "multi.py")
119+
with open(p, "w") as f:
120+
f.write(
121+
textwrap.dedent("""\
122+
from vgi.worker import Worker
123+
124+
class WorkerA(Worker):
125+
functions = []
126+
127+
class WorkerB(Worker):
128+
functions = []
129+
""")
130+
)
131+
with pytest.raises(SystemExit):
132+
load_worker_class(p)
133+
134+
def test_bad_classname_exits(self) -> None:
135+
"""module:NonExistent exits with error."""
136+
with pytest.raises(SystemExit):
137+
load_worker_class("vgi.examples.worker:NonExistent")
138+
139+
def test_not_a_worker_exits(self) -> None:
140+
"""module:NotAWorker exits with error."""
141+
with pytest.raises(SystemExit):
142+
load_worker_class("vgi.examples.worker:ExampleWorker.Settings")
143+
144+
def test_bad_module_exits(self) -> None:
145+
"""Non-existent module exits with error."""
146+
with pytest.raises(SystemExit):
147+
load_worker_class("nonexistent_module_xyz_123")
148+
149+
def test_bad_file_exits(self) -> None:
150+
"""Non-existent file exits with error."""
151+
with pytest.raises(SystemExit):
152+
load_worker_class("./no_such_file.py")
153+
154+
def test_excludes_imported_workers(self, tmp_path: object) -> None:
155+
"""Auto-discover excludes Worker subclasses imported from elsewhere."""
156+
p = os.path.join(str(tmp_path), "importer.py")
157+
with open(p, "w") as f:
158+
f.write(
159+
textwrap.dedent("""\
160+
from vgi.examples.worker import ExampleWorker # noqa: F401
161+
from vgi.worker import Worker
162+
163+
class LocalWorker(Worker):
164+
functions = []
165+
""")
166+
)
167+
cls = load_worker_class(p)
168+
assert cls.__name__ == "LocalWorker"
169+
170+
171+
# ---------------------------------------------------------------------------
172+
# Tests: create_app
173+
# ---------------------------------------------------------------------------
174+
175+
176+
class TestCreateApp:
177+
"""Tests for create_app()."""
178+
179+
def test_returns_falcon_app(self) -> None:
180+
"""create_app() returns a Falcon WSGI app."""
181+
import falcon
182+
183+
app = create_app(_SingleWorker)
184+
assert isinstance(app, falcon.App)
185+
186+
def test_custom_prefix(self) -> None:
187+
"""Custom prefix is used in the app."""
188+
app = create_app(_SingleWorker, prefix="/api")
189+
# The app should have routes under /api — verify it's a Falcon app
190+
import falcon
191+
192+
assert isinstance(app, falcon.App)
193+
194+
def test_describe_disabled(self) -> None:
195+
"""describe=False skips the worker page route."""
196+
app = create_app(_SingleWorker, describe=False)
197+
import falcon
198+
199+
assert isinstance(app, falcon.App)
200+
201+
202+
# ---------------------------------------------------------------------------
203+
# Tests: CLI integration
204+
# ---------------------------------------------------------------------------
205+
206+
207+
def _free_port() -> int:
208+
"""Allocate an available localhost TCP port."""
209+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
210+
s.bind(("127.0.0.1", 0))
211+
return int(s.getsockname()[1])
212+
213+
214+
class TestCLI:
215+
"""Integration tests for the vgi-serve CLI."""
216+
217+
def test_bad_reference_exits(self) -> None:
218+
"""Bad worker reference prints error and exits non-zero."""
219+
result = subprocess.run(
220+
[sys.executable, "-m", "vgi.serve", "nonexistent_module_xyz"],
221+
capture_output=True,
222+
text=True,
223+
timeout=10,
224+
)
225+
assert result.returncode != 0
226+
assert "Error" in result.stderr
227+
228+
def test_http_mode_starts_and_responds(self) -> None:
229+
"""HTTP mode starts and serves requests."""
230+
port = _free_port()
231+
proc = subprocess.Popen(
232+
[
233+
sys.executable,
234+
"-m",
235+
"vgi.serve",
236+
"vgi.examples.worker:ExampleWorker",
237+
"--http",
238+
"--host",
239+
"127.0.0.1",
240+
"--port",
241+
str(port),
242+
],
243+
stdout=subprocess.PIPE,
244+
stderr=subprocess.PIPE,
245+
text=True,
246+
)
247+
try:
248+
# Wait for PORT: line
249+
assert proc.stdout is not None
250+
port_line = proc.stdout.readline()
251+
assert port_line.strip() == f"PORT:{port}"
252+
253+
# Give server a moment to start
254+
time.sleep(0.5)
255+
256+
# Hit the worker description page
257+
import urllib.request
258+
259+
url = f"http://127.0.0.1:{port}/vgi/worker"
260+
with urllib.request.urlopen(url, timeout=5) as resp:
261+
body = resp.read()
262+
assert resp.status == 200
263+
assert b"ExampleWorker" in body
264+
finally:
265+
proc.terminate()
266+
proc.wait(timeout=5)
267+
268+
def test_port_env_var(self) -> None:
269+
"""$PORT env var is respected when --port is not given."""
270+
port = _free_port()
271+
env = os.environ.copy()
272+
env["PORT"] = str(port)
273+
proc = subprocess.Popen(
274+
[
275+
sys.executable,
276+
"-m",
277+
"vgi.serve",
278+
"vgi.examples.worker:ExampleWorker",
279+
"--http",
280+
"--host",
281+
"127.0.0.1",
282+
],
283+
stdout=subprocess.PIPE,
284+
stderr=subprocess.PIPE,
285+
text=True,
286+
env=env,
287+
)
288+
try:
289+
assert proc.stdout is not None
290+
port_line = proc.stdout.readline()
291+
assert port_line.strip() == f"PORT:{port}"
292+
finally:
293+
proc.terminate()
294+
proc.wait(timeout=5)
295+
296+
def test_auto_discover_module(self) -> None:
297+
"""vgi-serve with bare module auto-discovers the worker."""
298+
port = _free_port()
299+
proc = subprocess.Popen(
300+
[
301+
sys.executable,
302+
"-m",
303+
"vgi.serve",
304+
"vgi.examples.worker",
305+
"--http",
306+
"--host",
307+
"127.0.0.1",
308+
"--port",
309+
str(port),
310+
],
311+
stdout=subprocess.PIPE,
312+
stderr=subprocess.PIPE,
313+
text=True,
314+
)
315+
try:
316+
assert proc.stdout is not None
317+
port_line = proc.stdout.readline()
318+
assert port_line.strip() == f"PORT:{port}"
319+
finally:
320+
proc.terminate()
321+
proc.wait(timeout=5)

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vgi/examples/http_server.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def main() -> None:
118118
@app.command()
119119
def _run(
120120
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Bind address"),
121-
port: int = typer.Option(8000, "--port", "-p", help="Bind port"),
121+
port: int = typer.Option(0, "--port", "-p", help="Bind port (0 = auto-select)"),
122122
prefix: str = typer.Option("/vgi", "--prefix", help="URL prefix for RPC endpoints"),
123123
cors_origins: str = typer.Option("*", "--cors-origins", help="Allowed CORS origins"),
124124
describe: bool = typer.Option( # noqa: B008
@@ -214,8 +214,15 @@ def _run(
214214
)
215215
sys.stderr.flush()
216216

217+
import socket
218+
217219
from vgi.protocol import VgiProtocol
218220

221+
if port == 0:
222+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
223+
s.bind((host, 0))
224+
port = int(s.getsockname()[1])
225+
219226
worker = ExampleWorker(quiet=True, log_level=effective_level)
220227
server = RpcServer(VgiProtocol, worker, external_location=external_location, enable_describe=describe)
221228
wsgi_app = make_wsgi_app(
@@ -232,6 +239,7 @@ def _run(
232239
worker_page_body = build_worker_page(ExampleWorker, prefix)
233240
wsgi_app.add_route(f"{prefix}/worker", WorkerPageResource(worker_page_body))
234241

242+
print(f"PORT:{port}", flush=True)
235243
sys.stderr.write(f"Serving ExampleWorker on http://{host}:{port}{prefix}\n")
236244
sys.stderr.flush()
237245
waitress.serve(wsgi_app, host=host, port=port, _quiet=True)

0 commit comments

Comments
 (0)