robo-goggles 0.1.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.
goggles/__init__.py ADDED
@@ -0,0 +1,786 @@
1
+ """Goggles: Structured logging and experiment tracking.
2
+
3
+ This package provides a stable public API for logging experiments, metrics,
4
+ and media in a consistent and composable way.
5
+
6
+ >>> import goggles as gg
7
+ >>>
8
+ >>> with gg.run("experiment_42"):
9
+ >>> logger = gg.get_logger("train", seed=0)
10
+ >>> logger.info("Training started.")
11
+ >>> logger.scalar("train/loss", 0.123, step=0)
12
+
13
+ See Also:
14
+ - README.md for detailed usage examples.
15
+ - API docs for full reference of public interfaces.
16
+ - Internal implementations live under `goggles/_core/`
17
+
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import portal
23
+ from collections import defaultdict
24
+ from typing import (
25
+ Any,
26
+ Callable,
27
+ Callable,
28
+ ClassVar,
29
+ FrozenSet,
30
+ List,
31
+ Literal,
32
+ Optional,
33
+ Protocol,
34
+ Dict,
35
+ Set,
36
+ overload,
37
+ runtime_checkable,
38
+ )
39
+ from typing_extensions import Self
40
+ import logging
41
+ import os
42
+
43
+ from .types import Kind, Event, VectorField, Video, Image, Vector, Metrics
44
+ from ._core.integrations import *
45
+ from .decorators import timeit, trace_on_error
46
+ from .shutdown import GracefulShutdown
47
+ from .config import load_configuration, save_configuration
48
+
49
+ # Goggles port for bus communication
50
+ GOGGLES_PORT = os.getenv("GOGGLES_PORT", "2304")
51
+
52
+ # Handler registry for custom handlers
53
+ _HANDLER_REGISTRY: Dict[str, type] = {}
54
+ GOGGLES_HOST = os.getenv("GOGGLES_HOST", "localhost")
55
+ GOGGLES_ASYNC = os.getenv("GOGGLES_ASYNC", "0").lower() in ("1", "true", "yes")
56
+
57
+ # Cache the implementation after first use to avoid repeated imports
58
+ __impl_get_bus: Optional[Callable[[], EventBus]] = None
59
+
60
+
61
+ def _make_text_logger(
62
+ name: Optional[str],
63
+ scope: str,
64
+ to_bind: dict[str, Any],
65
+ ) -> TextLogger:
66
+ from ._core.logger import CoreTextLogger
67
+
68
+ return CoreTextLogger(name=name, scope=scope, to_bind=to_bind)
69
+
70
+
71
+ def _make_goggles_logger(
72
+ name: Optional[str],
73
+ scope: str,
74
+ to_bind: dict[str, Any],
75
+ ) -> GogglesLogger:
76
+ from ._core.logger import CoreGogglesLogger
77
+
78
+ return CoreGogglesLogger(name=name, scope=scope, to_bind=to_bind)
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Public API
83
+ # ---------------------------------------------------------------------------
84
+
85
+
86
+ @overload
87
+ def get_logger(
88
+ name: Optional[str] = None,
89
+ /,
90
+ *,
91
+ scope: str = "global",
92
+ **to_bind: Any,
93
+ ) -> TextLogger: ...
94
+
95
+
96
+ @overload
97
+ def get_logger(
98
+ name: Optional[str] = None,
99
+ /,
100
+ *,
101
+ with_metrics: Literal[True],
102
+ scope: str = "global",
103
+ **to_bind: Any,
104
+ ) -> GogglesLogger: ...
105
+
106
+
107
+ @overload
108
+ def get_logger(name: Optional[str] = None, /, **to_bind: Any) -> TextLogger: ...
109
+
110
+
111
+ def get_logger(
112
+ name: Optional[str] = None,
113
+ /,
114
+ *,
115
+ with_metrics: bool = False,
116
+ scope: str = "global",
117
+ **to_bind: Any,
118
+ ) -> TextLogger | GogglesLogger:
119
+ """Return a structured logger (text-only by default, metrics-enabled on opt-in).
120
+
121
+ This is the primary entry point for obtaining Goggles' structured loggers.
122
+ Depending on the active run and configuration, the returned adapter will
123
+ inject structured context (e.g., `RunContext` info) and persistent fields
124
+ into each emitted log record.
125
+
126
+ Args:
127
+ name (Optional[str]): Logger name. If None, the root logger is used.
128
+ with_metrics (bool): If True, return a logger exposing `.metrics`.
129
+ scope (str): The logging scope, e.g., "global" or "run".
130
+ **to_bind (Any): Fields persisted and injected into every record.
131
+
132
+ Returns:
133
+ Union[TextLogger, GogglesLogger]: A text-only `TextLogger` by default,
134
+ or a `GogglesLogger` when `with_metrics=True`.
135
+
136
+ Examples:
137
+ >>> # Text-only
138
+ >>> log = get_logger("eval", dataset="CIFAR10")
139
+ >>> log.info("starting")
140
+ >>>
141
+ >>> # Explicit metrics surface
142
+ >>> tlog = get_logger("train", with_metrics=True, seed=0)
143
+ >>> tlog.scalar("loss", 0.42, step=1)
144
+
145
+ """
146
+ if with_metrics:
147
+ return _make_goggles_logger(name, scope, to_bind)
148
+ else:
149
+ return _make_text_logger(name, scope, to_bind)
150
+
151
+
152
+ @runtime_checkable
153
+ class TextLogger(Protocol):
154
+ """Protocol for Goggles' structured logger adapters.
155
+
156
+ This protocol defines the expected interface for logger adapters returned
157
+ by `goggles.get_logger()`. It extends standard Python logging methods with
158
+ support for persistent bound fields.
159
+
160
+ Examples:
161
+ >>> log = get_logger("goggles")
162
+ >>> log.info("Hello, Goggles!", user="alice")
163
+ >>> run_log = log.bind(run_id="exp42")
164
+ >>> run_log.debug("Debugging info", step=1)
165
+ ... # Both log records include any persistent bound fields.
166
+ ... # The second record also includes run_id="exp42".
167
+
168
+ """
169
+
170
+ def bind(self, /, *, scope: str = "global", **fields: Any) -> Self:
171
+ """Return a new adapter with `fields` merged into persistent state.
172
+
173
+ Args:
174
+ scope (str): The binding scope, e.g., "global" or "run".
175
+ **fields (Any): Key-value pairs to bind persistently.
176
+
177
+
178
+ Returns:
179
+ Self: A new `TextLogger` instance
180
+ with updated bound fields and scope.
181
+
182
+ """
183
+ ...
184
+
185
+ def log(
186
+ self,
187
+ severity: int,
188
+ msg: str,
189
+ /,
190
+ *,
191
+ step: Optional[int] = None,
192
+ time: Optional[float] = None,
193
+ **extra: Any,
194
+ ) -> None:
195
+ """Log message at the given severity with optional structured extras.
196
+
197
+ Args:
198
+ severity (int): Numeric log level (e.g., logging.INFO).
199
+ msg (str): The log message.
200
+ step (Optional[int]): The step number.
201
+ time (Optional[float]): The timestamp.
202
+ **extra (Any):
203
+ Additional structured key-value pairs for this record.
204
+
205
+ """
206
+ if severity >= logging.CRITICAL:
207
+ self.critical(msg, step=step, time=time, **extra)
208
+ elif severity >= logging.ERROR:
209
+ self.error(msg, step=step, time=time, **extra)
210
+ elif severity >= logging.WARNING:
211
+ self.warning(msg, step=step, time=time, **extra)
212
+ elif severity >= logging.INFO:
213
+ self.info(msg, step=step, time=time, **extra)
214
+ elif severity >= logging.DEBUG:
215
+ self.debug(msg, step=step, time=time, **extra)
216
+ else:
217
+ # Below DEBUG level; no-op by default.
218
+ pass
219
+
220
+ def debug(
221
+ self,
222
+ msg: str,
223
+ /,
224
+ *,
225
+ step: Optional[int] = None,
226
+ time: Optional[float] = None,
227
+ **extra: Any,
228
+ ) -> None:
229
+ """Log a DEBUG message with optional structured extras.
230
+
231
+ Args:
232
+ msg (str): The log message.
233
+ step (Optional[int]): The step number.
234
+ time (Optional[float]): The timestamp.
235
+ **extra (Any):
236
+ Additional structured key-value pairs for this record.
237
+
238
+ """
239
+
240
+ def info(
241
+ self,
242
+ msg: str,
243
+ /,
244
+ *,
245
+ step: Optional[int] = None,
246
+ time: Optional[float] = None,
247
+ **extra: Any,
248
+ ) -> None:
249
+ """Log an INFO message with optional structured extras.
250
+
251
+ Args:
252
+ msg (str): The log message.
253
+ step (Optional[int]): The step number.
254
+ time (Optional[float]): The timestamp.
255
+ **extra (Any):
256
+ Additional structured key-value pairs for this record.
257
+
258
+ """
259
+
260
+ def warning(
261
+ self,
262
+ msg: str,
263
+ /,
264
+ *,
265
+ step: Optional[int] = None,
266
+ time: Optional[float] = None,
267
+ **extra: Any,
268
+ ) -> None:
269
+ """Log a WARNING message with optional structured extras.
270
+
271
+ Args:
272
+ msg (str): The log message.
273
+ step (Optional[int]): The step number.
274
+ time (Optional[float]): The timestamp.
275
+ **extra (Any):
276
+ Additional structured key-value pairs for this record.
277
+
278
+ """
279
+
280
+ def error(
281
+ self,
282
+ msg: str,
283
+ /,
284
+ *,
285
+ step: Optional[int] = None,
286
+ time: Optional[float] = None,
287
+ **extra: Any,
288
+ ) -> None:
289
+ """Log an ERROR message with optional structured extras.
290
+
291
+ Args:
292
+ msg (str): The log message.
293
+ step (Optional[int]): The step number.
294
+ time (Optional[float]): The timestamp.
295
+ **extra (Any):
296
+ Additional structured key-value pairs for this record.
297
+
298
+ """
299
+
300
+ def critical(
301
+ self,
302
+ msg: str,
303
+ /,
304
+ *,
305
+ step: Optional[int] = None,
306
+ time: Optional[float] = None,
307
+ **extra: Any,
308
+ ) -> None:
309
+ """Log a CRITICAL message with current exception info attached.
310
+
311
+ Args:
312
+ msg (str): The log message.
313
+ step (Optional[int]): The step number.
314
+ time (Optional[float]): The timestamp.
315
+ **extra (Any):
316
+ Additional structured key-value pairs for this record.
317
+
318
+ """
319
+
320
+
321
+ @runtime_checkable
322
+ class DataLogger(Protocol):
323
+ """Protocol for logging metrics, media, artifacts, and analytics data."""
324
+
325
+ def push(
326
+ self,
327
+ metrics: Metrics,
328
+ *,
329
+ step: Optional[int] = None,
330
+ time: Optional[float] = None,
331
+ **extra: Dict[str, Any],
332
+ ) -> None:
333
+ """Emit a batch of scalar metrics.
334
+
335
+ Args:
336
+ metrics (Metrics): (Name,value) pairs.
337
+ step (Optional[int]): Optional global step index.
338
+ time (Optional[float]): Optional global timestamp.
339
+ **extra (Dict[str, Any]):
340
+ Additional routing metadata (e.g., split="train").
341
+
342
+ """
343
+
344
+ def scalar(
345
+ self,
346
+ name: str,
347
+ value: float | int,
348
+ *,
349
+ step: Optional[int] = None,
350
+ time: Optional[float] = None,
351
+ **extra: Dict[str, Any],
352
+ ) -> None:
353
+ """Emit a single scalar metric.
354
+
355
+ Args:
356
+ name (str): Metric name.
357
+ value (float|int): Metric value.
358
+ step (Optional[int]): Optional global step index.
359
+ time (Optional[float]): Optional global timestamp.
360
+ **extra (Dict[str, Any]):
361
+ Additional routing metadata (e.g., split="train").
362
+
363
+ """
364
+
365
+ def image(
366
+ self,
367
+ image: Image,
368
+ *,
369
+ name: Optional[str] = None,
370
+ format: str = "png",
371
+ step: Optional[int] = None,
372
+ time: Optional[float] = None,
373
+ **extra: Dict[str, Any],
374
+ ) -> None:
375
+ """Emit an image artifact (encoded bytes).
376
+
377
+ Args:
378
+ name (str): Artifact name.
379
+ image (Image): Image.
380
+ format (str): Image format, e.g., "png", "jpeg".
381
+ step (Optional[int]): Optional global step index.
382
+ time (Optional[float]): Optional global timestamp.
383
+ **extra: Dict[str, Any]: Additional routing metadata.
384
+
385
+ """
386
+
387
+ def video(
388
+ self,
389
+ video: Video,
390
+ *,
391
+ name: Optional[str] = None,
392
+ fps: int = 30,
393
+ format: str = "gif",
394
+ step: Optional[int] = None,
395
+ time: Optional[float] = None,
396
+ **extra: Dict[str, Any],
397
+ ) -> None:
398
+ """Emit a video artifact (encoded bytes).
399
+
400
+ Args:
401
+ video (Video): Video.
402
+ name (Optional[str]): Artifact name.
403
+ fps (int): Frames per second.
404
+ format (str): Video format, e.g., "gif", "mp4".
405
+ step (Optional[int]): Optional global step index.
406
+ time (Optional[float]): Optional global timestamp.
407
+ **extra (Dict[str, Any]): Additional routing metadata.
408
+
409
+ """
410
+
411
+ def artifact(
412
+ self,
413
+ data: Any,
414
+ *,
415
+ name: Optional[str] = None,
416
+ format: str = "bin",
417
+ step: Optional[int] = None,
418
+ time: Optional[float] = None,
419
+ **extra: Dict[str, Any],
420
+ ) -> None:
421
+ """Emit a generic artifact (encoded bytes).
422
+
423
+ Args:
424
+ data (bytes): Artifact data.
425
+ name (Optional[str]): Artifact name.
426
+ format (str): Artifact format, e.g., "txt", "bin".
427
+ step (Optional[int]): Optional global step index.
428
+ time (Optional[float]): Optional global timestamp.
429
+ **extra (Dict[str, Any]): Additional routing metadata.
430
+
431
+ """
432
+
433
+ def vector_field(
434
+ self,
435
+ vector_field: VectorField,
436
+ *,
437
+ name: Optional[str] = None,
438
+ step: Optional[int] = None,
439
+ time: Optional[float] = None,
440
+ **extra: Dict[str, Any],
441
+ ) -> None:
442
+ """Emit a vector field artifact.
443
+
444
+ Args:
445
+ vector_field (VectorField): Vector field data.
446
+ name (Optional[str]): Artifact name.
447
+ step (Optional[int]): Optional global step index.
448
+ time (Optional[float]): Optional global timestamp.
449
+ **extra (Dict[str, Any]): Additional routing metadata.
450
+
451
+ """
452
+
453
+ def histogram(
454
+ self,
455
+ histogram: Vector,
456
+ *,
457
+ name: Optional[str] = None,
458
+ step: Optional[int] = None,
459
+ time: Optional[float] = None,
460
+ **extra: Dict[str, Any],
461
+ ) -> None:
462
+ """Emit a histogram artifact.
463
+
464
+ Args:
465
+ histogram (Vector): Histogram data.
466
+ name (Optional[str]): Artifact name.
467
+ step (Optional[int]): Optional global step index.
468
+ time (Optional[float]): Optional global timestamp.
469
+ **extra (Dict[str, Any]): Additional routing metadata.
470
+
471
+ """
472
+
473
+
474
+ @runtime_checkable
475
+ class GogglesLogger(TextLogger, DataLogger, Protocol):
476
+ """Protocol for Goggles loggers with metrics support.
477
+
478
+ Composite logger combining text logging with a metrics facet.
479
+
480
+ Examples:
481
+ >>> # Text-only
482
+ >>> log = get_logger("eval", dataset="CIFAR10")
483
+ >>> log.info("starting")
484
+ >>>
485
+ >>> # Explicit metrics surface
486
+ >>> tlog = get_logger("train", with_metrics=True, seed=0)
487
+ >>> tlog.scalar("loss", 0.42, step=1)
488
+ >>> tlog.info("Training step completed")
489
+ ... # Both log records include any persistent bound fields.
490
+ ... # The second record also includes run_id="exp42".
491
+
492
+ """
493
+
494
+
495
+ @runtime_checkable
496
+ class Handler(Protocol):
497
+ """Protocol for EventBus handlers.
498
+
499
+ Attributes:
500
+ name (str): Stable handler identifier for diagnostics.
501
+ capabilities (FrozenSet[Kind]):
502
+ Supported kinds, e.g. {'logs','metrics','artifacts', ...}.
503
+
504
+ """
505
+
506
+ name: str
507
+ capabilities: ClassVar[FrozenSet[Kind]]
508
+
509
+ def can_handle(self, kind: Kind) -> bool:
510
+ """Return whether this handler can process events of the given kind.
511
+
512
+ Args:
513
+ kind (Kind):
514
+ The kind of event ("log", "metric", "image", "artifact").
515
+
516
+ Returns:
517
+ bool: True if the handler can process the event kind,
518
+ False otherwise.
519
+
520
+ """
521
+ ...
522
+
523
+ def handle(self, event: Event) -> None:
524
+ """Handle an emitted event.
525
+
526
+ Args:
527
+ event (Event): The event to handle.
528
+
529
+ """
530
+
531
+ def open(self) -> None:
532
+ """Initialize the handler (called when entering a scope)."""
533
+
534
+ def close(self) -> None:
535
+ """Flush and release resources (called when leaving a scope).
536
+
537
+ Args:
538
+ run (Optional[RunContext]): The active run context if any.
539
+
540
+ """
541
+
542
+ def to_dict(self) -> Dict:
543
+ """Serialize the handler.
544
+
545
+ This method is needed during attachment. Will be called before binding.
546
+
547
+ Returns:
548
+ (dict) A dictionary that allows to instantiate the Handler.
549
+ Must contain:
550
+ - "cls": The handler class name.
551
+ - "data": The handler data to be used in from_dict.
552
+
553
+ """
554
+ ...
555
+
556
+ @classmethod
557
+ def from_dict(cls, serialized: Dict) -> Self:
558
+ """De-serialize the handler.
559
+
560
+ Args:
561
+ serialized (Dict): Serialized handler with handler.to_dict
562
+
563
+ Returns:
564
+ Self: The Handler instance.
565
+
566
+ """
567
+ ...
568
+
569
+
570
+ # ---------------------------------------------------------------------------
571
+ # EventBus and run management
572
+ # ---------------------------------------------------------------------------
573
+ class EventBus:
574
+ """Protocol for the process-wide event router."""
575
+
576
+ handlers: Dict[str, Handler]
577
+ scopes: Dict[str, Set[str]]
578
+
579
+ def __init__(self):
580
+ super().__init__()
581
+ self.handlers: Dict[str, Handler] = {}
582
+ self.scopes: Dict[str, Set[str]] = defaultdict(set)
583
+
584
+ def shutdown(self) -> None:
585
+ """Shutdown the EventBus and close all handlers."""
586
+ copy_map = {
587
+ scope: handlers_names.copy()
588
+ for scope, handlers_names in self.scopes.items()
589
+ }
590
+ for scope, handlers_names in copy_map.items():
591
+ for handler_name in handlers_names:
592
+ self.detach(handler_name, scope)
593
+
594
+ def attach(self, handlers: List[dict], scopes: List[str]) -> None:
595
+ """Attach a handler under the given scope.
596
+
597
+ Args:
598
+ handlers (List[dict]):
599
+ The serialized handlers to attach to the scopes.
600
+ scopes (List[str]): The scopes under which to attach.
601
+
602
+ """
603
+ for handler_dict in handlers:
604
+ handler_class = _get_handler_class(handler_dict["cls"])
605
+ handler = handler_class.from_dict(handler_dict["data"])
606
+ if handler.name not in self.handlers:
607
+ # Initialize handler and store it
608
+ handler.open()
609
+ self.handlers[handler.name] = handler
610
+
611
+ # Add to requested scopes
612
+ for scope in scopes:
613
+ if scope not in self.scopes:
614
+ self.scopes[scope] = set()
615
+ self.scopes[scope].add(handler.name)
616
+
617
+ def detach(self, handler_name: str, scope: str) -> None:
618
+ """Detach a handler from the given scope.
619
+
620
+ Args:
621
+ handler_name (str): The name of the handler to detach.
622
+ scope (str): The scope from which to detach.
623
+
624
+ Raises:
625
+ ValueError: If the handler was not attached under the requested scope.
626
+
627
+ """
628
+ if scope not in self.scopes or handler_name not in self.scopes[scope]:
629
+ raise ValueError(
630
+ f"Handler '{handler_name}' not attached under scope '{scope}'"
631
+ )
632
+ self.scopes[scope].remove(handler_name)
633
+ if not self.scopes[scope]:
634
+ del self.scopes[scope]
635
+ if not any(handler_name in self.scopes[s] for s in self.scopes):
636
+ self.handlers[handler_name].close()
637
+ del self.handlers[handler_name]
638
+
639
+ def emit(self, event: Dict | Event) -> None:
640
+ """Emit an event to eligible handlers (errors isolated per handler).
641
+
642
+ Args:
643
+ event (dict | Event): The event (serialized) to emit, or an Event instance.
644
+
645
+ """
646
+ if isinstance(event, dict):
647
+ event = Event.from_dict(event)
648
+ elif not isinstance(event, Event):
649
+ raise TypeError(f"emit expects a dict or Event, got {type(event)!r}")
650
+
651
+ if event.scope not in self.scopes:
652
+ return
653
+
654
+ for handler_name in self.scopes[event.scope]:
655
+ handler = self.handlers.get(handler_name)
656
+ if handler and handler.can_handle(event.kind):
657
+ handler.handle(event)
658
+
659
+
660
+ def get_bus() -> portal.Client:
661
+ """Return the process-wide EventBus singleton client.
662
+
663
+ The EventBus owns handlers and routes events based on scope and kind.
664
+
665
+ Returns:
666
+ portal.Client: The singleton EventBus client.
667
+
668
+ """
669
+ global __impl_get_bus
670
+ if __impl_get_bus is None:
671
+ from ._core.routing import get_bus as _impl_get_bus
672
+
673
+ __impl_get_bus = _impl_get_bus # type: ignore
674
+ return __impl_get_bus() # type: ignore
675
+
676
+
677
+ def attach(handler: Handler, scopes: List[str] = ["global"]) -> None:
678
+ """Attach a handler to the global EventBus under the specified scopes.
679
+
680
+ Args:
681
+ handler (Handler): The handler to attach.
682
+ scopes (List[str]): The scopes under which to attach.
683
+
684
+ Raises:
685
+ ValueError: If the handler disallows the requested scope.
686
+
687
+ """
688
+ bus = get_bus()
689
+ bus.attach([handler.to_dict()], scopes)
690
+
691
+
692
+ def detach(handler_name: str, scope: str) -> None:
693
+ """Detach a handler from the global EventBus under the specified scope.
694
+
695
+ Args:
696
+ handler_name (str): The name of the handler to detach.
697
+ scope (str): The scope from which to detach.
698
+
699
+ Raises:
700
+ ValueError: If the handler was not attached under the requested scope.
701
+
702
+ """
703
+ bus = get_bus()
704
+ bus.detach(handler_name, scope)
705
+
706
+
707
+ def finish() -> None:
708
+ """Shutdown the global EventBus and close all handlers."""
709
+ bus = get_bus()
710
+ bus.shutdown().result()
711
+
712
+
713
+ def register_handler(handler_class: type) -> None:
714
+ """Register a custom handler class for serialization/deserialization.
715
+
716
+ Args:
717
+ handler_class: The handler class to register. Must have a __name__ attribute.
718
+
719
+ Example:
720
+ class CustomHandler(gg.ConsoleHandler):
721
+ pass
722
+
723
+ gg.register_handler(CustomHandler)
724
+
725
+ """
726
+ _HANDLER_REGISTRY[handler_class.__name__] = handler_class
727
+
728
+
729
+ def _get_handler_class(class_name: str) -> type:
730
+ """Get a handler class by name from registry or globals.
731
+
732
+ Args:
733
+ class_name: Name of the handler class.
734
+
735
+ Returns:
736
+ The handler class.
737
+
738
+ Raises:
739
+ KeyError: If the handler class is not found.
740
+
741
+ """
742
+ # First check the registry for custom handlers
743
+ if class_name in _HANDLER_REGISTRY:
744
+ return _HANDLER_REGISTRY[class_name]
745
+
746
+ # Fall back to globals for built-in handlers
747
+ if class_name in globals():
748
+ return globals()[class_name]
749
+
750
+ raise KeyError(
751
+ f"Handler class '{class_name}' not found. "
752
+ f"Available handlers: {list(_HANDLER_REGISTRY.keys()) + [k for k in globals().keys() if k.endswith('Handler')]}"
753
+ )
754
+
755
+
756
+ try:
757
+ from ._core.integrations.wandb import WandBHandler
758
+ except ImportError:
759
+ WandBHandler = None
760
+
761
+ __all__ = [
762
+ "TextLogger",
763
+ "GogglesLogger",
764
+ "get_logger",
765
+ "attach",
766
+ "detach",
767
+ "register_handler",
768
+ "load_configuration",
769
+ "save_configuration",
770
+ "timeit",
771
+ "trace_on_error",
772
+ "GracefulShutdown",
773
+ "ConsoleHandler",
774
+ "LocalStorageHandler",
775
+ "WandBHandler",
776
+ ]
777
+
778
+ # ---------------------------------------------------------------------------
779
+ # Import-time safety
780
+ # ---------------------------------------------------------------------------
781
+
782
+ # Attach a NullHandler so importing goggles never emits logs by default.
783
+
784
+ _logger = logging.getLogger(__name__)
785
+ if not any(isinstance(h, logging.NullHandler) for h in _logger.handlers):
786
+ _logger.addHandler(logging.NullHandler())