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,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()