diff --git a/scripts/dev_server.py b/scripts/dev_server.py new file mode 100644 index 00000000..0c8db970 --- /dev/null +++ b/scripts/dev_server.py @@ -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() diff --git a/tox.ini b/tox.ini index 6626fd87..0be8d624 100644 --- a/tox.ini +++ b/tox.ini @@ -163,9 +163,11 @@ commands = [testenv:dev] description = Start dashboard with auto-reload (local development) +setenv = + {[testenv]setenv} + TIMMY_TEST_MODE = 0 commands = - uvicorn dashboard.app:app --reload --host 0.0.0.0 --port 8000 \ - --reload-exclude ".claude" + python {toxinidir}/scripts/dev_server.py {posargs} # ── All Tests (parallel) ─────────────────────────────────────────────────────