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.
- aetherscan/__init__.py +45 -0
- aetherscan/benchmark.py +179 -0
- aetherscan/candidate_figures.py +328 -0
- aetherscan/cli.py +3032 -0
- aetherscan/config.py +1002 -0
- aetherscan/dashboard.py +709 -0
- aetherscan/dashboard_cli.py +51 -0
- aetherscan/dashboard_launcher.py +194 -0
- aetherscan/data_generation.py +1448 -0
- aetherscan/db/__init__.py +21 -0
- aetherscan/db/db.py +2789 -0
- aetherscan/hf_hub.py +547 -0
- aetherscan/inference.py +912 -0
- aetherscan/inference_viz.py +1494 -0
- aetherscan/latent_gif.py +512 -0
- aetherscan/latent_variants.py +352 -0
- aetherscan/logger/__init__.py +21 -0
- aetherscan/logger/logger.py +467 -0
- aetherscan/logger/slack_handler.py +760 -0
- aetherscan/main.py +1269 -0
- aetherscan/manager/__init__.py +21 -0
- aetherscan/manager/manager.py +781 -0
- aetherscan/models/__init__.py +21 -0
- aetherscan/models/random_forest.py +171 -0
- aetherscan/models/vae.py +849 -0
- aetherscan/monitor/__init__.py +19 -0
- aetherscan/monitor/monitor.py +935 -0
- aetherscan/pfb.py +161 -0
- aetherscan/preprocessing.py +2718 -0
- aetherscan/rf_metrics.py +100 -0
- aetherscan/round_data.py +932 -0
- aetherscan/run_state.py +277 -0
- aetherscan/seeding.py +160 -0
- aetherscan/shap_parallel.py +238 -0
- aetherscan/tag_guards.py +206 -0
- aetherscan/train.py +7681 -0
- aetherscan-1.0.0.dist-info/METADATA +1187 -0
- aetherscan-1.0.0.dist-info/RECORD +41 -0
- aetherscan-1.0.0.dist-info/WHEEL +4 -0
- aetherscan-1.0.0.dist-info/entry_points.txt +2 -0
- aetherscan-1.0.0.dist-info/licenses/LICENSE +13 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
# TODO: archive old logs
|
|
2
|
+
"""
|
|
3
|
+
Logger for Aetherscan Pipeline
|
|
4
|
+
Runs as background thread & uses thread-safe queue-based logging to avoid deadlocks and corrupted
|
|
5
|
+
outputs from concurrent writes (e.g. from worker processes)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import io
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
from logging.handlers import QueueHandler, QueueListener
|
|
17
|
+
from multiprocessing import Queue
|
|
18
|
+
|
|
19
|
+
from aetherscan.config import get_config
|
|
20
|
+
|
|
21
|
+
# NOTE: can this just be from aetherscan.logger import SlackHandler?
|
|
22
|
+
from aetherscan.logger.slack_handler import SlackHandler
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_level(level_str: str) -> int:
|
|
28
|
+
"""Map a level name ('INFO', 'WARNING', 'DEBUG', etc.) to its logging constant; defaults to
|
|
29
|
+
logging.INFO for unknown values. Case-insensitive."""
|
|
30
|
+
level_map = {
|
|
31
|
+
"DEBUG": logging.DEBUG,
|
|
32
|
+
"INFO": logging.INFO,
|
|
33
|
+
"WARNING": logging.WARNING,
|
|
34
|
+
"WARN": logging.WARNING,
|
|
35
|
+
"ERROR": logging.ERROR,
|
|
36
|
+
"CRITICAL": logging.CRITICAL,
|
|
37
|
+
}
|
|
38
|
+
return level_map.get(level_str.upper(), logging.INFO)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ShutdownSafeQueueHandler(QueueHandler):
|
|
42
|
+
"""
|
|
43
|
+
QueueHandler that silently drops records once its multiprocessing queue is closed (#281).
|
|
44
|
+
|
|
45
|
+
After Logger.stop() disposes the queue, a vanilla emit() raises ValueError("Queue ... is
|
|
46
|
+
closed"); logging's handleError() then prints the traceback to sys.stderr — and if stderr
|
|
47
|
+
is (or becomes) a StreamToLogger redirect, that print logs again, raises again, and
|
|
48
|
+
recurses without bound (the observed RecursionError + "lost sys.stderr" at teardown).
|
|
49
|
+
Swallowing the closed-queue error here makes that loop structurally impossible no matter
|
|
50
|
+
what the streams point at; late shutdown-time records are dropped, which is the only
|
|
51
|
+
sane destination for them anyway.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def enqueue(self, record):
|
|
55
|
+
# Queue closed (or its pipe broken) during teardown — drop the record
|
|
56
|
+
with contextlib.suppress(ValueError, OSError):
|
|
57
|
+
super().enqueue(record)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def log_path_for_tag(output_path: str, tag: str) -> str:
|
|
61
|
+
"""Return this run's log-file path: ``{output_path}/logs/aetherscan_{tag}.log``.
|
|
62
|
+
|
|
63
|
+
Pure and dependency-free so the tag->path derivation is unit-testable on its own. Naming the
|
|
64
|
+
log file with the run's save_tag mirrors how every other artifact is already tag-scoped
|
|
65
|
+
(config_{tag}.json, model files, plots, DB rows), so each run gets its own log instead of
|
|
66
|
+
overwriting a single shared aetherscan.log.
|
|
67
|
+
"""
|
|
68
|
+
return os.path.join(output_path, "logs", f"aetherscan_{tag}.log")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class StreamToLogger:
|
|
72
|
+
"""Redirect stream (stdout/stderr) to main logging system"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, logger, level):
|
|
75
|
+
self.logger = logger
|
|
76
|
+
self.level = level
|
|
77
|
+
self.linebuf = ""
|
|
78
|
+
# Re-entrancy guard (#281): if anything downstream of logger.log ever writes back to
|
|
79
|
+
# this stream (e.g. logging's handleError printing to a redirected stderr), the
|
|
80
|
+
# second entry is dropped instead of recursing without bound. Thread-local so
|
|
81
|
+
# concurrent writers on other threads are unaffected.
|
|
82
|
+
self._reentrancy = threading.local()
|
|
83
|
+
|
|
84
|
+
def write(self, buf):
|
|
85
|
+
if getattr(self._reentrancy, "writing", False):
|
|
86
|
+
return
|
|
87
|
+
self._reentrancy.writing = True
|
|
88
|
+
try:
|
|
89
|
+
for line in buf.rstrip().splitlines():
|
|
90
|
+
self.logger.log(self.level, line.rstrip())
|
|
91
|
+
finally:
|
|
92
|
+
self._reentrancy.writing = False
|
|
93
|
+
|
|
94
|
+
def flush(self):
|
|
95
|
+
# Flush any remaining content in linebuf if needed
|
|
96
|
+
if self.linebuf:
|
|
97
|
+
self.logger.log(self.level, self.linebuf.rstrip())
|
|
98
|
+
self.linebuf = ""
|
|
99
|
+
|
|
100
|
+
# Flush all handlers attached to the logger
|
|
101
|
+
for handler in self.logger.handlers:
|
|
102
|
+
handler.flush()
|
|
103
|
+
|
|
104
|
+
# File-protocol probes: libraries that write to sys.stdout/sys.stderr (tqdm progress
|
|
105
|
+
# bars via huggingface_hub, click, rich, ...) probe the stream for interactivity and
|
|
106
|
+
# capabilities before writing. Without these, any such probe crashes with
|
|
107
|
+
# AttributeError (e.g. hf_upload failed with "'StreamToLogger' object has no
|
|
108
|
+
# attribute 'isatty'"), so answer like the non-interactive pipe this stream is.
|
|
109
|
+
|
|
110
|
+
def isatty(self) -> bool:
|
|
111
|
+
"""Never a terminal — suppresses interactive control codes/progress rendering."""
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
def readable(self) -> bool:
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
def writable(self) -> bool:
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
def fileno(self):
|
|
121
|
+
"""No OS-level file descriptor backs this stream. io.UnsupportedOperation is the
|
|
122
|
+
io-module convention (what io.StringIO raises); it subclasses both OSError and
|
|
123
|
+
ValueError, which is what fileno() probers guard against."""
|
|
124
|
+
raise io.UnsupportedOperation("fileno")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Logger:
|
|
128
|
+
"""
|
|
129
|
+
Thread- and process-safe logging singleton.
|
|
130
|
+
|
|
131
|
+
The main process runs a QueueListener on a background thread; both the main process and any
|
|
132
|
+
multiprocessing workers push records onto the same shared queue, and the listener drains it
|
|
133
|
+
and writes to file/console/Slack. Funneling concurrent writes through one consumer eliminates
|
|
134
|
+
interleaved-output and lock-contention issues from naive multi-process logging.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
_instance = None # Stores singleton instance
|
|
138
|
+
_lock = threading.Lock() # Ensures thread safety on object initialization
|
|
139
|
+
|
|
140
|
+
# __new__ allocates the object in memory (constructor at the object-creation level)
|
|
141
|
+
# __init__ initializes the object's attributes after it's created
|
|
142
|
+
# since __new__ is called before __init__ every time we instantiate a class,
|
|
143
|
+
# by overriding __new__, we can short-circuit object creation entirely, and control whether a
|
|
144
|
+
# new instance is created, or just return the existing instance
|
|
145
|
+
def __new__(cls, *args, **kwargs):
|
|
146
|
+
# *args/**kwargs are accepted (and ignored here) so the constructor can forward
|
|
147
|
+
# __init__ arguments like save_tag; object.__new__ takes no extra args itself.
|
|
148
|
+
# Double-checked locking pattern:
|
|
149
|
+
# First check if _instance is None, without lock (for performance)
|
|
150
|
+
if cls._instance is None:
|
|
151
|
+
# If None, acquire the lock to serialize the initialization path,
|
|
152
|
+
# preventing race conditions (2 threads violating singleton semantics)
|
|
153
|
+
with cls._lock:
|
|
154
|
+
# Check if _instance is None again inside the lock
|
|
155
|
+
# (since multiple threads can be calling simultaneously)
|
|
156
|
+
if cls._instance is None:
|
|
157
|
+
# If still None, only then we construct the singleton instance
|
|
158
|
+
cls._instance = super().__new__(cls)
|
|
159
|
+
cls._instance._initialized = False # Mark as not initialized (for __init__)
|
|
160
|
+
# Return the same instance for all subsequent constructor calls
|
|
161
|
+
return cls._instance
|
|
162
|
+
|
|
163
|
+
def __init__(self, save_tag: str | None = None):
|
|
164
|
+
"""Initialize logger.
|
|
165
|
+
|
|
166
|
+
`save_tag` names this run's log file (aetherscan_{save_tag}.log). Callers pass the run's
|
|
167
|
+
resolved {command}_{datetime} tag (main() resolves it via cli.resolve_save_tag before
|
|
168
|
+
calling init_logger); when omitted, it falls back to config.checkpoint.save_tag directly.
|
|
169
|
+
"""
|
|
170
|
+
# Note, __init__ is triggered every time the class's constructor is called,
|
|
171
|
+
# even if __new__ returned the existing singleton instance
|
|
172
|
+
# Hence, we use the _initialized flag to make sure __init__ only runs once
|
|
173
|
+
if self._initialized:
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
self._initialized = True
|
|
177
|
+
|
|
178
|
+
self.config = get_config()
|
|
179
|
+
if self.config is None:
|
|
180
|
+
raise ValueError("get_config() returned None")
|
|
181
|
+
|
|
182
|
+
# Per-run, tag-scoped log file: each run writes aetherscan_{tag}.log, so a new run no
|
|
183
|
+
# longer overwrites the previous run's log. main.py resolves the run's {command}_{datetime}
|
|
184
|
+
# tag onto config.checkpoint.save_tag before calling init_logger and passes it in here; the
|
|
185
|
+
# config value is the fallback for any other caller.
|
|
186
|
+
tag = save_tag if save_tag is not None else self.config.checkpoint.save_tag
|
|
187
|
+
self.log_path = log_path_for_tag(self.config.output_path, tag)
|
|
188
|
+
os.makedirs(os.path.dirname(self.log_path), exist_ok=True) # Create dir if it doesn't exist
|
|
189
|
+
|
|
190
|
+
# Create queue for worker processes (no size limit)
|
|
191
|
+
self.log_queue = Queue(-1)
|
|
192
|
+
|
|
193
|
+
# Setup root logger - set to DEBUG to let handlers filter
|
|
194
|
+
root_logger = logging.getLogger()
|
|
195
|
+
root_logger.setLevel(logging.DEBUG)
|
|
196
|
+
root_logger.handlers.clear() # Clear existing handlers
|
|
197
|
+
|
|
198
|
+
# Create formatter
|
|
199
|
+
formatter = logging.Formatter("%(asctime)s | %(name)s | %(levelname)s | %(message)s")
|
|
200
|
+
|
|
201
|
+
# Setup file handler (only used by main process via listener)
|
|
202
|
+
file_handler = logging.FileHandler(self.log_path, mode="w")
|
|
203
|
+
file_handler.setLevel(_parse_level(self.config.logger.file_level))
|
|
204
|
+
file_handler.setFormatter(formatter)
|
|
205
|
+
|
|
206
|
+
# Setup stream handler (only used by main process via listener)
|
|
207
|
+
stream_handler = logging.StreamHandler(sys.stdout)
|
|
208
|
+
stream_handler.setLevel(_parse_level(self.config.logger.console_level))
|
|
209
|
+
stream_handler.setFormatter(formatter)
|
|
210
|
+
|
|
211
|
+
# Build handler list for QueueListener
|
|
212
|
+
handlers: list[logging.Handler] = [file_handler, stream_handler]
|
|
213
|
+
|
|
214
|
+
# Initialize Slack handler if enabled
|
|
215
|
+
self.slack_handler: SlackHandler | None = None
|
|
216
|
+
slack_init_message: str | None = None
|
|
217
|
+
slack_init_level: int = logging.INFO
|
|
218
|
+
|
|
219
|
+
if self.config.logger.slack_enabled:
|
|
220
|
+
slack_token = os.environ.get("SLACK_BOT_TOKEN")
|
|
221
|
+
slack_channel = os.environ.get("SLACK_CHANNEL", self.config.logger.slack_channel)
|
|
222
|
+
|
|
223
|
+
# Defer logging until after the queue infrastructure is ready
|
|
224
|
+
if not slack_token:
|
|
225
|
+
slack_init_message = (
|
|
226
|
+
"Slack logging enabled but SLACK_BOT_TOKEN not found in environment. "
|
|
227
|
+
"Slack logging disabled."
|
|
228
|
+
)
|
|
229
|
+
slack_init_level = logging.WARNING
|
|
230
|
+
elif not slack_channel:
|
|
231
|
+
slack_init_message = (
|
|
232
|
+
"Slack logging enabled but no channel configured. "
|
|
233
|
+
"Set SLACK_CHANNEL env var or config.logger.slack_channel. Slack logging disabled."
|
|
234
|
+
)
|
|
235
|
+
slack_init_level = logging.WARNING
|
|
236
|
+
else:
|
|
237
|
+
try:
|
|
238
|
+
self.slack_handler = SlackHandler(
|
|
239
|
+
token=slack_token,
|
|
240
|
+
channel=slack_channel,
|
|
241
|
+
username=self.config.logger.slack_username,
|
|
242
|
+
timeout=self.config.logger.slack_timeout,
|
|
243
|
+
retry_attempts=self.config.logger.slack_retry_attempts,
|
|
244
|
+
buffer_size=self.config.logger.slack_buffer_size,
|
|
245
|
+
flush_interval=self.config.logger.slack_flush_interval,
|
|
246
|
+
broadcast_level=_parse_level(self.config.logger.slack_broadcast_level),
|
|
247
|
+
)
|
|
248
|
+
self.slack_handler.setLevel(_parse_level(self.config.logger.slack_level))
|
|
249
|
+
slack_formatter = logging.Formatter("%(message)s")
|
|
250
|
+
self.slack_handler.setFormatter(slack_formatter)
|
|
251
|
+
handlers.append(self.slack_handler)
|
|
252
|
+
slack_init_message = f"Slack handler initialized for channel: {slack_channel}"
|
|
253
|
+
slack_init_level = logging.INFO
|
|
254
|
+
except Exception as e:
|
|
255
|
+
slack_init_message = f"Failed to initialize Slack handler: {e}"
|
|
256
|
+
slack_init_level = logging.WARNING
|
|
257
|
+
|
|
258
|
+
# Create queue listener - runs in background thread, writes logs from queue
|
|
259
|
+
self.log_listener = QueueListener(self.log_queue, *handlers, respect_handler_level=True)
|
|
260
|
+
self.log_listener.start()
|
|
261
|
+
|
|
262
|
+
# Add queue handler to root logger (both main and workers use this); shutdown-safe
|
|
263
|
+
# so post-stop() records are dropped instead of raising through handleError (#281)
|
|
264
|
+
queue_handler = ShutdownSafeQueueHandler(self.log_queue)
|
|
265
|
+
root_logger.addHandler(queue_handler)
|
|
266
|
+
|
|
267
|
+
# Start the Slack run thread FIRST (posts run summary, all logs become replies)
|
|
268
|
+
# This must happen before any log messages so they appear in the thread
|
|
269
|
+
if self.slack_handler is not None:
|
|
270
|
+
self.slack_handler.start_run()
|
|
271
|
+
|
|
272
|
+
# Now that logging infrastructure is ready, log Slack initialization status
|
|
273
|
+
if slack_init_message:
|
|
274
|
+
logger.log(slack_init_level, slack_init_message)
|
|
275
|
+
|
|
276
|
+
# Redirect TensorFlow logs to Python logging. Lazy import: TF is only needed for this
|
|
277
|
+
# one call, and log_path_for_tag (and this class's non-TF behavior) stay importable
|
|
278
|
+
# without pulling in the full TF stack — e.g. from a fast, TF-free test/tooling context.
|
|
279
|
+
import tensorflow as tf # noqa: PLC0415
|
|
280
|
+
|
|
281
|
+
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "0" # Show all TF logs
|
|
282
|
+
tf.get_logger().setLevel(logging.INFO)
|
|
283
|
+
tf_logger = tf.get_logger()
|
|
284
|
+
tf_logger.handlers = [] # Remove TF's default handlers
|
|
285
|
+
tf_logger.propagate = True # Use root logger handlers
|
|
286
|
+
|
|
287
|
+
# Capture Python warnings module output
|
|
288
|
+
logging.captureWarnings(True)
|
|
289
|
+
warnings_logger = logging.getLogger("py.warnings")
|
|
290
|
+
warnings_logger.setLevel(logging.WARNING)
|
|
291
|
+
|
|
292
|
+
# Redirect stdout and stderr to logging
|
|
293
|
+
# This captures print statements and C library output
|
|
294
|
+
# Note that workers will reset these with init_worker_logging to avoid inheritance issues
|
|
295
|
+
sys.stdout = StreamToLogger(logging.getLogger("STDOUT"), logging.INFO)
|
|
296
|
+
sys.stderr = StreamToLogger(logging.getLogger("STDERR"), logging.ERROR)
|
|
297
|
+
|
|
298
|
+
logger.info(f"Logger initialized at: {self.log_path}")
|
|
299
|
+
|
|
300
|
+
@classmethod
|
|
301
|
+
def _reset(cls):
|
|
302
|
+
"""
|
|
303
|
+
Teardown hook for the thread-safe singleton — discards the cached instance so the next
|
|
304
|
+
constructor call yields a fresh one. Only safe to call after stop() has completed;
|
|
305
|
+
calling it while the listener thread is still active leaves callers holding a stale
|
|
306
|
+
reference.
|
|
307
|
+
"""
|
|
308
|
+
# Acquire lock to prevent race conditions
|
|
309
|
+
with cls._lock:
|
|
310
|
+
# Discard the singleton instance by removing the global reference
|
|
311
|
+
# Guarantees the next constructor call will produce a fresh instance
|
|
312
|
+
# Note, resources held by the old instance will remain alive unless explicitly closed beforehand
|
|
313
|
+
cls._instance = None
|
|
314
|
+
# Note, can't log here after tear down
|
|
315
|
+
|
|
316
|
+
def stop(self):
|
|
317
|
+
"""
|
|
318
|
+
Stop the queue listener thread and flush Slack messages.
|
|
319
|
+
|
|
320
|
+
Idempotent (#281): a second call is a no-op — QueueListener.stop() raises if called
|
|
321
|
+
twice, and cleanup paths can legitimately overlap (conftest teardown + explicit
|
|
322
|
+
stops). Restores sys.stdout/sys.stderr BEFORE the listener/queue teardown, so any
|
|
323
|
+
output produced during or after shutdown (including logging's own handleError)
|
|
324
|
+
reaches the real console instead of recursing through the redirect — the
|
|
325
|
+
RecursionError / "lost sys.stderr" loop this method used to leave armed.
|
|
326
|
+
"""
|
|
327
|
+
if getattr(self, "_stopped", False):
|
|
328
|
+
return
|
|
329
|
+
self._stopped = True
|
|
330
|
+
|
|
331
|
+
# Root fix (#281): put the real streams back first — same idiom the worker path
|
|
332
|
+
# already uses in init_worker_logging. Guarded so a foreign redirect installed on
|
|
333
|
+
# top of ours (e.g. pytest's capture) is left alone.
|
|
334
|
+
if isinstance(sys.stdout, StreamToLogger):
|
|
335
|
+
sys.stdout = sys.__stdout__
|
|
336
|
+
if isinstance(sys.stderr, StreamToLogger):
|
|
337
|
+
sys.stderr = sys.__stderr__
|
|
338
|
+
|
|
339
|
+
# Flush and close Slack handler first (while listener is still running)
|
|
340
|
+
if self.slack_handler is not None:
|
|
341
|
+
self.slack_handler.close()
|
|
342
|
+
|
|
343
|
+
if self.log_listener is not None:
|
|
344
|
+
self.log_listener.stop()
|
|
345
|
+
# Note, can't log after this point -- listener thread has stopped
|
|
346
|
+
# All subsequent logs will get queued but never logged
|
|
347
|
+
|
|
348
|
+
# Dispose of the multiprocessing.Queue's feeder thread. Once the listener (the
|
|
349
|
+
# consumer) is stopped, a mp.Queue with buffered records left its feeder thread
|
|
350
|
+
# blocked in _feed/_send_bytes on a pipe nobody drains; its exit-time join finalizer
|
|
351
|
+
# then hangs the interpreter (surfaces when a real Logger is built in-process, e.g.
|
|
352
|
+
# the unit tests). close() + cancel_join_thread() drops those undelivered
|
|
353
|
+
# shutdown-time records and lets the process exit cleanly.
|
|
354
|
+
if self.log_queue is not None:
|
|
355
|
+
with contextlib.suppress(Exception):
|
|
356
|
+
self.log_queue.close()
|
|
357
|
+
self.log_queue.cancel_join_thread()
|
|
358
|
+
|
|
359
|
+
# NOTE:
|
|
360
|
+
# currently only title is broadcasted to main channel,
|
|
361
|
+
# since there's a delay in the title vs image appearing in the thread
|
|
362
|
+
# not sure if this is a fundamental limitation to slack webhooks?
|
|
363
|
+
# maybe instead of forcing image to be kept in thread AND broadcast to main,
|
|
364
|
+
# we simply broadcast it to main and forget about keeping it in thread
|
|
365
|
+
# logs can always have text references to where images are saved
|
|
366
|
+
# but the main logs of interest in main are errors & results
|
|
367
|
+
def upload_image_to_slack(
|
|
368
|
+
self,
|
|
369
|
+
file_path: str,
|
|
370
|
+
channels: str | list[str] | None = None,
|
|
371
|
+
title: str | None = None,
|
|
372
|
+
message: str | None = None,
|
|
373
|
+
broadcast: bool = True,
|
|
374
|
+
) -> bool:
|
|
375
|
+
"""
|
|
376
|
+
Upload an image to Slack and return True on success, False otherwise.
|
|
377
|
+
|
|
378
|
+
The image goes into the current run's thread. With broadcast=True (default) a link-back
|
|
379
|
+
message is also posted to the main channel — equivalent to ticking the "Also send to
|
|
380
|
+
channel" checkbox in Slack. `channels` overrides the handler's configured channel; if
|
|
381
|
+
None, the configured one is used.
|
|
382
|
+
"""
|
|
383
|
+
if self.slack_handler is None:
|
|
384
|
+
logger.debug("Slack handler not initialized, skipping image upload")
|
|
385
|
+
return False
|
|
386
|
+
|
|
387
|
+
return self.slack_handler.upload_file(
|
|
388
|
+
file_path=file_path,
|
|
389
|
+
channels=channels,
|
|
390
|
+
title=title,
|
|
391
|
+
initial_comment=message,
|
|
392
|
+
broadcast=broadcast,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def init_logger(save_tag: str | None = None) -> Logger:
|
|
397
|
+
"""
|
|
398
|
+
Initialize global logger instance (call once at startup).
|
|
399
|
+
|
|
400
|
+
`save_tag` names this run's log file (aetherscan_{save_tag}.log). Pass the run's resolved
|
|
401
|
+
{command}_{datetime} tag (main() resolves it via cli.resolve_save_tag beforehand); when
|
|
402
|
+
omitted, the Logger falls back to config.checkpoint.save_tag.
|
|
403
|
+
"""
|
|
404
|
+
logger_instance = Logger(save_tag=save_tag)
|
|
405
|
+
|
|
406
|
+
# Note, unlike other modules, due to dependency chains,
|
|
407
|
+
# we wait to call register_logger() inside main.py:main()
|
|
408
|
+
|
|
409
|
+
return logger_instance
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def init_worker_logging(log_queue=None):
|
|
413
|
+
"""
|
|
414
|
+
Initialize logging in a multiprocessing worker: reset stdout/stderr to the original streams
|
|
415
|
+
(so the inherited StreamToLogger from the parent doesn't fire), then attach a QueueHandler
|
|
416
|
+
pointing at the main process's shared log queue.
|
|
417
|
+
|
|
418
|
+
`log_queue` defaults to the Logger singleton's queue, which fork-started workers inherit.
|
|
419
|
+
Spawn-started processes (e.g. the RoundDataProducer and its pool workers) run a fresh
|
|
420
|
+
interpreter where the singleton doesn't exist — they must pass the queue explicitly
|
|
421
|
+
(a multiprocessing.Queue survives the spawn pickling boundary). Without either, falls back
|
|
422
|
+
to a NullHandler to avoid noise.
|
|
423
|
+
"""
|
|
424
|
+
if log_queue is None:
|
|
425
|
+
logger_instance = Logger._instance
|
|
426
|
+
|
|
427
|
+
if logger_instance is None:
|
|
428
|
+
logger.warning(
|
|
429
|
+
"No logger instance initialized - disabling worker logging to avoid conflicts"
|
|
430
|
+
)
|
|
431
|
+
logging.getLogger().handlers.clear()
|
|
432
|
+
logging.getLogger().addHandler(logging.NullHandler())
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
log_queue = logger_instance.log_queue
|
|
436
|
+
|
|
437
|
+
# Reset stdout/stderr to avoid inherited StreamToLogger from parent
|
|
438
|
+
sys.stdout = sys.__stdout__
|
|
439
|
+
sys.stderr = sys.__stderr__
|
|
440
|
+
|
|
441
|
+
# Configure process-local logging to use queue
|
|
442
|
+
root_logger = logging.getLogger()
|
|
443
|
+
root_logger.handlers.clear()
|
|
444
|
+
root_logger.addHandler(ShutdownSafeQueueHandler(log_queue))
|
|
445
|
+
root_logger.setLevel(logging.INFO)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def get_logger() -> Logger | None:
|
|
449
|
+
"""Get the global logger instance"""
|
|
450
|
+
logger_instance = Logger._instance
|
|
451
|
+
|
|
452
|
+
if logger_instance is None:
|
|
453
|
+
logger.warning("No logger instance initialized")
|
|
454
|
+
|
|
455
|
+
return logger_instance
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def shutdown_logger():
|
|
459
|
+
"""Shutdown the global logger instance (call on exit)"""
|
|
460
|
+
logger_instance = Logger._instance
|
|
461
|
+
|
|
462
|
+
if logger_instance is None:
|
|
463
|
+
logger.warning("No logger instance initialized")
|
|
464
|
+
return
|
|
465
|
+
|
|
466
|
+
logger_instance.stop()
|
|
467
|
+
Logger._reset()
|