forked from Rockachopa/Timmy-time-dashboard
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 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()
|
||||||
6
tox.ini
6
tox.ini
@@ -163,9 +163,11 @@ commands =
|
|||||||
|
|
||||||
[testenv:dev]
|
[testenv:dev]
|
||||||
description = Start dashboard with auto-reload (local development)
|
description = Start dashboard with auto-reload (local development)
|
||||||
|
setenv =
|
||||||
|
{[testenv]setenv}
|
||||||
|
TIMMY_TEST_MODE = 0
|
||||||
commands =
|
commands =
|
||||||
uvicorn dashboard.app:app --reload --host 0.0.0.0 --port 8000 \
|
python {toxinidir}/scripts/dev_server.py {posargs}
|
||||||
--reload-exclude ".claude"
|
|
||||||
|
|
||||||
# ── All Tests (parallel) ─────────────────────────────────────────────────────
|
# ── All Tests (parallel) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user