Source code for ezmsg.dashboard.server

from __future__ import annotations

import argparse
import asyncio
import logging
import os
import socket
import threading
import time
import webbrowser
from dataclasses import dataclass

import uvicorn
from ezmsg.core.netprotocol import (
    DEFAULT_HOST as EZMSG_DEFAULT_HOST,
)
from ezmsg.core.netprotocol import (
    GRAPHSERVER_ADDR_ENV,
    GRAPHSERVER_PORT_DEFAULT,
    Address,
)

from .backend.app import create_app
from .backend.services import GraphContextLifecycleService

DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8000
STARTUP_TIMEOUT_SECONDS = 10.0
DASHBOARD_ADDR_ENV = "EZMSG_DASHBOARD_ADDR"

logger = logging.getLogger("ezmsg.dashboard")


def _normalize_graph_address(graph_address: str | Address | None) -> str | None:
    if graph_address is None:
        return None
    if isinstance(graph_address, Address):
        return str(graph_address)
    return graph_address


[docs] class DashboardGraphServerUnavailableError(RuntimeError): pass
def _default_graph_address() -> Address: address_str = os.environ.get(GRAPHSERVER_ADDR_ENV, f"{EZMSG_DEFAULT_HOST}:{GRAPHSERVER_PORT_DEFAULT}") return Address.from_string(address_str) def _resolve_graph_server_address(graph_address: str | Address | None = None) -> Address: normalized_graph_address = _normalize_graph_address(graph_address) if normalized_graph_address is None: return _default_graph_address() return Address.from_string(normalized_graph_address) def _graph_server_unavailable_message(graph_address: Address) -> str: return ( f"Could not connect to GraphServer at {graph_address}. " "Start one with `ezmsg serve` or `ezmsg serve --dashboard`, " "or specify a running GraphServer with `ezmsg dashboard --graph-address HOST:PORT`." ) def _ensure_graph_server_available(graph_address: str | Address | None = None) -> Address: resolved_graph_address = _resolve_graph_server_address(graph_address) try: with socket.create_connection( (resolved_graph_address.host, resolved_graph_address.port), timeout=1.0, ): pass except OSError as exc: raise DashboardGraphServerUnavailableError(_graph_server_unavailable_message(resolved_graph_address)) from exc return resolved_graph_address def _resolve_dashboard_bind( *, graph_address: str | Address | None = None, host: str | None = None, port: int | None = None, ) -> Address: if DASHBOARD_ADDR_ENV in os.environ: resolved = Address.from_string(os.environ[DASHBOARD_ADDR_ENV]) else: normalized_graph_address = _normalize_graph_address(graph_address) base_graph_address = ( Address.from_string(normalized_graph_address) if normalized_graph_address is not None else _default_graph_address() ) resolved = Address(base_graph_address.host, base_graph_address.port + 1) return Address( resolved.host if host is None else host, resolved.port if port is None else port, ) def _browser_host(host: str) -> str: if host in {"0.0.0.0", "::"}: return "127.0.0.1" return host
[docs] def dashboard_url(host: str, port: int) -> str: return f"http://{_browser_host(host)}:{port}"
[docs] def create_dashboard_app(*, graph_address: str | Address | None = None): return create_app( GraphContextLifecycleService( graph_address=_normalize_graph_address(graph_address), auto_start=False, ) )
[docs] @dataclass(slots=True) class DashboardServerHandle: server: uvicorn.Server thread: threading.Thread host: str port: int @property def url(self) -> str: return dashboard_url(self.host, self.port)
[docs] def stop(self, *, timeout: float = 10.0) -> None: self.server.should_exit = True self.thread.join(timeout=timeout)
def _resolve_bound_port(server: uvicorn.Server, fallback_port: int) -> int: servers = getattr(server, "servers", None) if not servers: return fallback_port for item in servers: sockets = getattr(item, "sockets", None) or [] if sockets: sockname = sockets[0].getsockname() return int(sockname[1]) return fallback_port
[docs] def start_dashboard_server( *, graph_address: str | Address | None = None, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, log_level: str = "info", ) -> DashboardServerHandle: app = create_dashboard_app(graph_address=graph_address) config = uvicorn.Config(app=app, host=host, port=port, log_level=log_level) server = uvicorn.Server(config=config) thread = threading.Thread( target=server.run, daemon=True, name="ezmsg-dashboard-server", ) thread.start() deadline = time.monotonic() + STARTUP_TIMEOUT_SECONDS while not getattr(server, "started", False): if not thread.is_alive(): raise RuntimeError("Dashboard server stopped before startup completed.") if time.monotonic() >= deadline: raise RuntimeError("Timed out while waiting for dashboard server startup.") time.sleep(0.05) bound_port = _resolve_bound_port(server, port) return DashboardServerHandle(server=server, thread=thread, host=host, port=bound_port)
[docs] def serve_dashboard( *, graph_address: str | Address | None = None, host: str | None = None, port: int | None = None, log_level: str = "info", open_browser: bool = False, ) -> None: _ensure_graph_server_available(graph_address) bind_address = _resolve_dashboard_bind( graph_address=graph_address, host=host, port=port, ) url = dashboard_url(bind_address.host, bind_address.port) if open_browser: timer = threading.Timer(0.5, lambda: webbrowser.open(url)) timer.daemon = True timer.start() app = create_dashboard_app(graph_address=graph_address) config = uvicorn.Config( app=app, host=bind_address.host, port=bind_address.port, log_level=log_level, ) server = uvicorn.Server(config=config) logger.info("Dashboard listening on %s", url) try: server.run() except (KeyboardInterrupt, asyncio.CancelledError): # uvicorn handles SIGINT itself: it shuts the application down # gracefully and then re-raises the signal so that callers see # conventional interrupt semantics. For an interactive server that # only amounts to a traceback after a clean shutdown, so swallow it. pass logger.info("Dashboard stopped.")
[docs] def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="ezmsg dashboard", description="Launch the ezmsg dashboard server.", ) _add_dashboard_arguments(parser) return parser
def _add_dashboard_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument("--graph-address", default=None, help="Address of the ezmsg graph server.") parser.add_argument( "--host", default=None, help="HTTP bind host for the dashboard. Defaults to the dashboard env host or graph host.", ) parser.add_argument( "--port", type=int, default=None, help="HTTP bind port for the dashboard. Defaults to the dashboard env port or graph port + 1.", ) parser.add_argument( "--open-browser", action="store_true", help="Open the dashboard in a browser after startup.", ) parser.add_argument( "--log-level", default="info", choices=["critical", "error", "warning", "info", "debug", "trace"], help="Uvicorn log verbosity.", )
[docs] def handle_dashboard(args: argparse.Namespace) -> None: try: serve_dashboard( graph_address=args.graph_address, host=args.host, port=args.port, log_level=args.log_level, open_browser=args.open_browser, ) except DashboardGraphServerUnavailableError as exc: logger.error("%s", exc)
[docs] def setup_dashboard_cmdline(subparsers: argparse._SubParsersAction) -> None: parser = subparsers.add_parser( "dashboard", description="Launch the ezmsg dashboard server.", help="launch the optional ezmsg dashboard server", ) _add_dashboard_arguments(parser) parser.set_defaults(_handler=handle_dashboard)
[docs] def cmdline(argv: list[str] | None = None) -> None: parser = build_parser() args = parser.parse_args(argv) handle_dashboard(args)
__all__ = [ "DEFAULT_HOST", "DEFAULT_PORT", "DashboardServerHandle", "DASHBOARD_ADDR_ENV", "DashboardGraphServerUnavailableError", "cmdline", "create_dashboard_app", "dashboard_url", "handle_dashboard", "_graph_server_unavailable_message", "_ensure_graph_server_available", "_resolve_dashboard_bind", "serve_dashboard", "setup_dashboard_cmdline", "start_dashboard_server", ]