fix: enhance tox dev environment (port, banner, reload) (#386)
Co-authored-by: Kimi Agent <kimi@timmy.local> Co-committed-by: Kimi Agent <kimi@timmy.local>
This commit was merged in pull request #386.
This commit is contained in:
169
scripts/dev_server.py
Normal file
169
scripts/dev_server.py
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user