Co-authored-by: Kimi Agent <kimi@timmy.local> Co-committed-by: Kimi Agent <kimi@timmy.local>
170 lines
4.7 KiB
Python
170 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
||
"""Timmy Time — Development server launcher.
|
||
|
||
Satisfies tox -e dev criteria:
|
||
- Graceful port selection (finds next free port if default is taken)
|
||
- Clickable links to dashboard and other web GUIs
|
||
- Status line: backend inference source, version, git commit, smoke tests
|
||
- Auto-reload on code changes (delegates to uvicorn --reload)
|
||
|
||
Usage: python scripts/dev_server.py [--port PORT]
|
||
"""
|
||
|
||
import argparse
|
||
import datetime
|
||
import os
|
||
import socket
|
||
import subprocess
|
||
import sys
|
||
|
||
DEFAULT_PORT = 8000
|
||
MAX_PORT_ATTEMPTS = 10
|
||
OLLAMA_DEFAULT = "http://localhost:11434"
|
||
|
||
|
||
def _port_free(port: int) -> bool:
|
||
"""Return True if the TCP port is available on localhost."""
|
||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||
try:
|
||
s.bind(("0.0.0.0", port))
|
||
return True
|
||
except OSError:
|
||
return False
|
||
|
||
|
||
def _find_port(start: int) -> int:
|
||
"""Return *start* if free, otherwise probe up to MAX_PORT_ATTEMPTS higher."""
|
||
for offset in range(MAX_PORT_ATTEMPTS):
|
||
candidate = start + offset
|
||
if _port_free(candidate):
|
||
return candidate
|
||
raise RuntimeError(
|
||
f"No free port found in range {start}–{start + MAX_PORT_ATTEMPTS - 1}"
|
||
)
|
||
|
||
|
||
def _git_info() -> str:
|
||
"""Return short commit hash + timestamp, or 'unknown'."""
|
||
try:
|
||
sha = subprocess.check_output(
|
||
["git", "rev-parse", "--short", "HEAD"],
|
||
stderr=subprocess.DEVNULL,
|
||
text=True,
|
||
).strip()
|
||
ts = subprocess.check_output(
|
||
["git", "log", "-1", "--format=%ci"],
|
||
stderr=subprocess.DEVNULL,
|
||
text=True,
|
||
).strip()
|
||
return f"{sha} ({ts})"
|
||
except Exception:
|
||
return "unknown"
|
||
|
||
|
||
def _project_version() -> str:
|
||
"""Read version from pyproject.toml without importing toml libs."""
|
||
pyproject = os.path.join(os.path.dirname(__file__), "..", "pyproject.toml")
|
||
try:
|
||
with open(pyproject) as f:
|
||
for line in f:
|
||
if line.strip().startswith("version"):
|
||
# version = "1.0.0"
|
||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||
except Exception:
|
||
pass
|
||
return "unknown"
|
||
|
||
|
||
def _ollama_url() -> str:
|
||
return os.environ.get("OLLAMA_URL", OLLAMA_DEFAULT)
|
||
|
||
|
||
def _smoke_ollama(url: str) -> str:
|
||
"""Quick connectivity check against Ollama."""
|
||
import urllib.request
|
||
import urllib.error
|
||
|
||
try:
|
||
req = urllib.request.Request(url, method="GET")
|
||
with urllib.request.urlopen(req, timeout=3):
|
||
return "ok"
|
||
except Exception:
|
||
return "unreachable"
|
||
|
||
|
||
def _print_banner(port: int) -> None:
|
||
version = _project_version()
|
||
git = _git_info()
|
||
ollama_url = _ollama_url()
|
||
ollama_status = _smoke_ollama(ollama_url)
|
||
|
||
hr = "─" * 62
|
||
print(flush=True)
|
||
print(f" {hr}")
|
||
print(f" ┃ Timmy Time — Development Server")
|
||
print(f" {hr}")
|
||
print()
|
||
print(f" Dashboard: http://localhost:{port}")
|
||
print(f" API docs: http://localhost:{port}/docs")
|
||
print(f" Health: http://localhost:{port}/health")
|
||
print()
|
||
print(f" ── Status ──────────────────────────────────────────────")
|
||
print(f" Backend: {ollama_url} [{ollama_status}]")
|
||
print(f" Version: {version}")
|
||
print(f" Git commit: {git}")
|
||
print(f" {hr}")
|
||
print(flush=True)
|
||
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(description="Timmy dev server")
|
||
parser.add_argument(
|
||
"--port",
|
||
type=int,
|
||
default=DEFAULT_PORT,
|
||
help=f"Preferred port (default: {DEFAULT_PORT})",
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
port = _find_port(args.port)
|
||
if port != args.port:
|
||
print(f" ⚠ Port {args.port} in use — using {port} instead")
|
||
|
||
_print_banner(port)
|
||
|
||
# Set PYTHONPATH so `timmy` CLI inside the tox venv resolves to this source.
|
||
src_dir = os.path.join(os.path.dirname(__file__), "..", "src")
|
||
os.environ["PYTHONPATH"] = os.path.abspath(src_dir)
|
||
|
||
# Launch uvicorn with auto-reload
|
||
cmd = [
|
||
sys.executable,
|
||
"-m",
|
||
"uvicorn",
|
||
"dashboard.app:app",
|
||
"--reload",
|
||
"--host",
|
||
"0.0.0.0",
|
||
"--port",
|
||
str(port),
|
||
"--reload-dir",
|
||
os.path.abspath(src_dir),
|
||
"--reload-include",
|
||
"*.html",
|
||
"--reload-include",
|
||
"*.css",
|
||
"--reload-include",
|
||
"*.js",
|
||
"--reload-exclude",
|
||
".claude",
|
||
]
|
||
|
||
try:
|
||
subprocess.run(cmd, check=True)
|
||
except KeyboardInterrupt:
|
||
print("\n Shutting down dev server.")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|