aetherscan 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ """
2
+ Console entry point (`aetherscan-dashboard`) for manual runs of the live dashboard.
3
+
4
+ main.py auto-launches the dashboard alongside a run (dashboard_launcher.py); this shim covers the
5
+ ad-hoc case — inspecting a saved DB after the fact — replacing the verbose manual incantation from
6
+ dashboard.py's docstring with:
7
+
8
+ aetherscan-dashboard --db-path /path/to/aetherscan.db --tag train_20260101_120000
9
+
10
+ Everything on the command line is forwarded verbatim to dashboard.py's argparse (--db-path, --tag,
11
+ --plots-dir, --refresh). Streamlit must OWN the process (`streamlit run <file>`) for the `st.*`
12
+ calls to render, so this execs `python -m streamlit run` in place — a plain
13
+ `python -m aetherscan.dashboard` would call `st.*` outside a ScriptRunContext and render nothing.
14
+ In a source checkout / the container (no installed entry point), the same shim runs as
15
+ `PYTHONPATH=src python -m aetherscan.dashboard_cli <args>`.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import importlib.util
21
+ import os
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ # Same resolution as dashboard_launcher._DASHBOARD_SCRIPT — not imported from there, so this shim
26
+ # stays stdlib-only and the console script works (and fails fast) without the pipeline deps.
27
+ _DASHBOARD_SCRIPT = Path(__file__).resolve().parent / "dashboard.py"
28
+
29
+
30
+ def build_exec_argv(argv: list[str]) -> list[str]:
31
+ """`python -m streamlit run <packaged dashboard.py> -- <argv>` — build_dashboard_command's
32
+ shape, with the caller's args forwarded verbatim to dashboard.py's argparse. Pure/testable."""
33
+ return [sys.executable, "-m", "streamlit", "run", str(_DASHBOARD_SCRIPT), "--", *argv]
34
+
35
+
36
+ def main(argv: list[str] | None = None) -> None:
37
+ """Replace this process with the Streamlit server (os.execv never returns on success)."""
38
+ if importlib.util.find_spec("streamlit") is None:
39
+ raise SystemExit(
40
+ "aetherscan-dashboard: streamlit is not installed. "
41
+ "Install the dashboard extra: pip install 'aetherscan[dashboard]'"
42
+ )
43
+ # Parity with dashboard_launcher._DASHBOARD_SCRIPT.is_file(): fail with a clear message
44
+ # instead of a cryptic os.execv OSError / Streamlit error if the packaged script is absent.
45
+ if not _DASHBOARD_SCRIPT.is_file():
46
+ raise SystemExit(f"aetherscan-dashboard: dashboard script not found: {_DASHBOARD_SCRIPT}")
47
+ os.execv(sys.executable, build_exec_argv(sys.argv[1:] if argv is None else argv))
48
+
49
+
50
+ if __name__ == "__main__":
51
+ main()
@@ -0,0 +1,194 @@
1
+ """
2
+ Auto-launch the live monitoring dashboard (aetherscan/dashboard.py) alongside a train/inference run.
3
+
4
+ main.py calls launch_dashboard() once at startup (after the DB is initialized). It spawns a
5
+ headless Streamlit server as a detached subprocess pointed at this run's DB + tag, logs the
6
+ SSH-forward instructions, and registers atexit + SIGTERM/SIGINT teardown so the server is reaped
7
+ whether the pipeline exits gracefully or is signal-killed. Fully guarded: a missing streamlit, a
8
+ missing dashboard.py, a port already in use, or any spawn failure degrades to a warning — the
9
+ dashboard is optional observability and must never fail the pipeline. Opt out with --no-dashboard
10
+ (config.monitor.dashboard_enabled = False).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import atexit
16
+ import contextlib
17
+ import importlib.util
18
+ import logging
19
+ import os
20
+ import signal
21
+ import socket
22
+ import subprocess
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ from aetherscan.config import get_config
27
+ from aetherscan.db import get_db
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # dashboard.py ships alongside this module inside the package, so it resolves identically for a
32
+ # source checkout, the container, and a pip install (streamlit runs it by file path).
33
+ _DASHBOARD_SCRIPT = Path(__file__).resolve().parent / "dashboard.py"
34
+
35
+
36
+ def build_dashboard_command(
37
+ python_exe: str,
38
+ dashboard_script: str,
39
+ db_path: str,
40
+ tag: str,
41
+ plots_dir: str,
42
+ port: int,
43
+ ) -> list[str]:
44
+ """Construct the `python -m streamlit run ...` argv. Streamlit's own flags precede `--`;
45
+ everything after `--` is forwarded to dashboard.py's argparse. Pure/testable."""
46
+ return [
47
+ python_exe,
48
+ "-m",
49
+ "streamlit",
50
+ "run",
51
+ dashboard_script,
52
+ "--server.port",
53
+ str(port),
54
+ "--server.headless",
55
+ "true",
56
+ "--browser.gatherUsageStats",
57
+ "false",
58
+ "--",
59
+ "--db-path",
60
+ db_path,
61
+ "--tag",
62
+ tag,
63
+ "--plots-dir",
64
+ plots_dir,
65
+ ]
66
+
67
+
68
+ def _port_in_use(port: int) -> bool:
69
+ """True if `port` can't be bound on localhost — i.e. a concurrent run's dashboard already holds
70
+ it. Best-effort probe; either way the caller only ever warns, never fails the pipeline."""
71
+ try:
72
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
73
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
74
+ s.bind(("127.0.0.1", port))
75
+ except OSError:
76
+ return True
77
+ return False
78
+
79
+
80
+ def _terminate(proc: subprocess.Popen) -> None:
81
+ """Best-effort teardown (no logging — may run during interpreter shutdown or in a signal
82
+ handler). The dashboard is spawned with start_new_session=True, so it leads its own process
83
+ group; signal the whole group (killpg) to reap Streamlit AND any grandchildren, not just the
84
+ direct child."""
85
+ if proc.poll() is not None:
86
+ return
87
+
88
+ def _signal_group(sig: int) -> None:
89
+ try:
90
+ os.killpg(os.getpgid(proc.pid), sig)
91
+ except (ProcessLookupError, PermissionError, OSError):
92
+ # group already gone, or getpgid raced the exit — fall back to the direct child
93
+ with contextlib.suppress(Exception):
94
+ proc.send_signal(sig)
95
+
96
+ try:
97
+ _signal_group(signal.SIGTERM)
98
+ try:
99
+ proc.wait(timeout=5)
100
+ except subprocess.TimeoutExpired:
101
+ _signal_group(signal.SIGKILL)
102
+ except Exception:
103
+ pass
104
+
105
+
106
+ def _install_signal_teardown(proc: subprocess.Popen) -> None:
107
+ """Reap the dashboard on SIGTERM/SIGINT too — atexit hooks do NOT run on a signal-kill, and
108
+ the new-session detachment means a process-group signal to the pipeline never reaches the
109
+ dashboard. Each handler tears down the dashboard, restores the previous disposition, and
110
+ re-raises the signal so the pipeline's own shutdown proceeds exactly as before (no logging in
111
+ the handler — that can deadlock). Signal handlers can only be installed from the main thread."""
112
+ for sig in (signal.SIGTERM, signal.SIGINT):
113
+ try:
114
+ prev = signal.getsignal(sig)
115
+ except (ValueError, OSError):
116
+ continue # not the main thread / unsupported — atexit still covers graceful exits
117
+
118
+ def _handler(signum, frame, _prev=prev):
119
+ _terminate(proc)
120
+ # Restore callables AND SIG_IGN as-is (collapsing SIG_IGN to SIG_DFL would let the
121
+ # re-delivery below kill a process that was ignoring the signal); anything else
122
+ # (SIG_DFL, None from a non-Python handler) falls back to SIG_DFL.
123
+ restore = _prev if callable(_prev) or _prev == signal.SIG_IGN else signal.SIG_DFL
124
+ signal.signal(signum, restore)
125
+ os.kill(os.getpid(), signum) # re-deliver so the pipeline shuts down as it would have
126
+
127
+ with contextlib.suppress(ValueError, OSError):
128
+ signal.signal(sig, _handler)
129
+
130
+
131
+ def launch_dashboard() -> subprocess.Popen | None:
132
+ """Spawn the headless Streamlit dashboard for this run, or return None (disabled / failed)."""
133
+ config = get_config()
134
+ if config is None or not config.monitor.dashboard_enabled:
135
+ return None
136
+
137
+ if not _DASHBOARD_SCRIPT.is_file():
138
+ logger.warning(f"Dashboard not launched: {_DASHBOARD_SCRIPT} not found")
139
+ return None
140
+
141
+ if importlib.util.find_spec("streamlit") is None:
142
+ logger.warning(
143
+ "Dashboard skipped: streamlit not installed. Install the extra "
144
+ "(pip install 'aetherscan[dashboard]'), rebuild the NGC container (.sif) / conda env "
145
+ "after the streamlit/plotly deps landed, or run with --no-dashboard."
146
+ )
147
+ return None
148
+
149
+ db = get_db()
150
+ db_path = (
151
+ db.db_path if db is not None else os.path.join(config.output_path, "db", "aetherscan.db")
152
+ )
153
+ tag = config.checkpoint.save_tag
154
+ plots_dir = os.path.join(config.output_path, "plots")
155
+ port = config.monitor.dashboard_port
156
+
157
+ if _port_in_use(port):
158
+ # A concurrent run on this node already holds the port. Don't spawn a Streamlit that would
159
+ # exit immediately (stderr is DEVNULL'd) and, worse, don't log an SSH-forward line that
160
+ # would tunnel the user to the OTHER run's dashboard — a wrong-tag/wrong-DB footgun.
161
+ logger.warning(
162
+ f"Dashboard skipped: port {port} is already in use (another run's dashboard?). "
163
+ f"Free the port or pass --dashboard-port to pick another."
164
+ )
165
+ return None
166
+
167
+ cmd = build_dashboard_command(
168
+ sys.executable, str(_DASHBOARD_SCRIPT), db_path, tag, plots_dir, port
169
+ )
170
+
171
+ try:
172
+ # start_new_session puts the server in its own session/process group so a Ctrl-C sent to
173
+ # the pipeline's whole foreground group doesn't kill it mid-write; teardown is instead
174
+ # driven deterministically by our atexit hook (graceful exit) and SIGTERM/SIGINT handlers
175
+ # (signal-kill), both of which killpg the new group to reap Streamlit + any grandchildren.
176
+ proc = subprocess.Popen(
177
+ cmd,
178
+ stdout=subprocess.DEVNULL,
179
+ stderr=subprocess.DEVNULL,
180
+ stdin=subprocess.DEVNULL,
181
+ start_new_session=True,
182
+ )
183
+ except (OSError, ValueError) as e:
184
+ logger.warning(f"Dashboard failed to launch (is streamlit installed?): {e}")
185
+ return None
186
+
187
+ atexit.register(_terminate, proc)
188
+ _install_signal_teardown(proc)
189
+ host = socket.gethostname()
190
+ logger.info(
191
+ f"Live dashboard for '{tag}' on port {port}. Reach it with: "
192
+ f"ssh -L {port}:localhost:{port} {host} then open http://localhost:{port}"
193
+ )
194
+ return proc