pyplotutil 2.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,743 @@
1
+ """Event logging utility for streamlined log management.
2
+
3
+ This module provides a robust logging system that combines file and console logging with
4
+ configurable verbosity levels. It features:
5
+
6
+ - Unified interface for file and console logging
7
+ - FakeLogger implementation for testing scenarios
8
+ - Automatic log file management
9
+ - Verbosity control through command line arguments
10
+
11
+ Classes
12
+ -------
13
+ FakeLogger
14
+ Mock logger implementing standard logging interface
15
+
16
+ Functions
17
+ ---------
18
+ start_event_logging
19
+ Configure and initialize logging system
20
+ get_logging_level_from_verbose_count
21
+ Convert verbosity flags to logging levels
22
+ start_logging
23
+ Simplified logging setup with verbosity control
24
+ event_logger, evlog
25
+ Convenient access to global logger instance
26
+
27
+ Examples
28
+ --------
29
+ >>> from pyplotutil.loggingutil import start_logging
30
+ >>> logger = start_event_logging(argv=["script.py"], output_dir="logs", logging_level="INFO")
31
+ >>> logger.info("Processing started")
32
+
33
+ Notes
34
+ -----
35
+ - Uses Python's built-in logging module as the foundation
36
+ - Automatically disables console output during pytest execution
37
+ - Supports both string and integer logging levels
38
+ - Maintains compatibility with standard logging interfaces
39
+
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import io
45
+ import logging
46
+ import sys
47
+ import tempfile
48
+ import traceback
49
+ import warnings
50
+ from pathlib import Path
51
+ from typing import TYPE_CHECKING, TextIO
52
+
53
+ if TYPE_CHECKING:
54
+ from collections.abc import Mapping
55
+ from types import FrameType
56
+
57
+ from pyplotutil._typing import Unknown
58
+
59
+ DEFAULT_FORMAT = "%(asctime)s (%(module)s:%(lineno)d) [%(levelname)s]: %(message)s"
60
+ _default_formatter = logging.Formatter(DEFAULT_FORMAT)
61
+
62
+ CRITICAL = 50
63
+ FATAL = CRITICAL
64
+ ERROR = 40
65
+ WARNING = 30
66
+ WARN = WARNING
67
+ INFO = 20
68
+ DEBUG = 10
69
+ NOTSET = 0
70
+
71
+ _LEVEL_TO_NAME = {
72
+ CRITICAL: "CRITICAL",
73
+ ERROR: "ERROR",
74
+ WARNING: "WARNING",
75
+ INFO: "INFO",
76
+ DEBUG: "DEBUG",
77
+ NOTSET: "NOTSET",
78
+ }
79
+ _NAME_TO_LEVEL = {
80
+ "CRITICAL": CRITICAL,
81
+ "FATAL": FATAL,
82
+ "ERROR": ERROR,
83
+ "WARN": WARNING,
84
+ "WARNING": WARNING,
85
+ "INFO": INFO,
86
+ "DEBUG": DEBUG,
87
+ "NOTSET": NOTSET,
88
+ }
89
+
90
+
91
+ class _Error(Exception):
92
+ pass
93
+
94
+
95
+ if hasattr(sys, "_getframe"):
96
+
97
+ def currentframe() -> FrameType | None:
98
+ """Return the frame object for the caller's stack frame."""
99
+ return sys._getframe(1) # noqa: SLF001
100
+ else:
101
+
102
+ def currentframe() -> FrameType | None:
103
+ """Return the frame object for the caller's stack frame."""
104
+ try:
105
+ raise _Error # noqa: TRY301
106
+ except _Error as exc:
107
+ if exc.__traceback__ is not None:
108
+ return exc.__traceback__.tb_frame.f_back
109
+ return None
110
+
111
+
112
+ def check_level(level: int | str) -> int:
113
+ """Convert logging level string or int to corresponding integer level.
114
+
115
+ Parameters
116
+ ----------
117
+ level : int or str
118
+ Logging level as integer or string name (e.g. 'INFO', 'DEBUG')
119
+
120
+ Returns
121
+ -------
122
+ int
123
+ Integer logging level value
124
+
125
+ Raises
126
+ ------
127
+ ValueError
128
+ If level string is not recognized
129
+ TypeError
130
+ If level is not an integer or string
131
+
132
+ """
133
+ if isinstance(level, int):
134
+ rv = level
135
+ elif str(level) == level:
136
+ if level not in _NAME_TO_LEVEL:
137
+ msg = f"Unknown level: {level}"
138
+ raise ValueError(msg)
139
+ rv = _NAME_TO_LEVEL[level]
140
+ else:
141
+ msg = f"Level not an integer or a valid string: {level}"
142
+ raise TypeError(msg)
143
+ return rv
144
+
145
+
146
+ class FakeLogger:
147
+ """Mock logger implementing standard logging interface.
148
+
149
+ Provides logging functionality that mimics Python's standard Logger class, with output directed
150
+ to a specified stream.
151
+
152
+ Attributes
153
+ ----------
154
+ level : int
155
+ Current logging level
156
+ disabled : bool
157
+ Whether logging is currently disabled
158
+ terminator : str
159
+ String appended after each log message
160
+
161
+ """
162
+
163
+ _level: int
164
+ _formatter: logging.Formatter
165
+ _stream: TextIO
166
+ _disabled: bool
167
+ terminator: str = "\n"
168
+
169
+ def __init__(self, level: int | str = NOTSET, stream: TextIO | None = None, *, disable: bool = False) -> None:
170
+ """Initialize FakeLogger with specified level and output stream.
171
+
172
+ Parameters
173
+ ----------
174
+ level : int or str
175
+ The logging level to set, either as an integer or a string (default is WARNING)
176
+ stream : TextIO or None
177
+ Output stream for log messages, defaults to sys.stderr
178
+ disable : bool
179
+ Whether to start with logging disabled (default is False)
180
+
181
+ """
182
+ self.set_level(level)
183
+ self._formatter = _default_formatter
184
+ self._stream = stream if stream is not None else sys.stderr
185
+ self._disabled = disable
186
+
187
+ def set_level(self, level: int | str) -> None:
188
+ """Set the logging level threshold.
189
+
190
+ Parameters
191
+ ----------
192
+ level : int or str
193
+ New logging level to set
194
+
195
+ """
196
+ self._level = check_level(level)
197
+
198
+ def setLevel(self, level: int | str) -> None: # noqa: N802
199
+ """Legacy-style alias for set_level.
200
+
201
+ Parameters
202
+ ----------
203
+ level : int or str
204
+ New logging level to set
205
+
206
+ """
207
+ return self.set_level(level)
208
+
209
+ def is_enabled_for(self, level: int) -> bool:
210
+ """Check if logger will process messages at given level.
211
+
212
+ Parameters
213
+ ----------
214
+ level : int
215
+ Logging level to check
216
+
217
+ Returns
218
+ -------
219
+ bool
220
+ True if messages at this level will be processed
221
+
222
+ """
223
+ if self.disabled:
224
+ return False
225
+ return level >= self.level
226
+
227
+ def find_caller(self, *, stack_info: bool = False, stacklevel: int = 1) -> tuple[str, int, str, str | None]:
228
+ """Identify the module and line number where logging was called.
229
+
230
+ Parameters
231
+ ----------
232
+ stack_info : bool
233
+ Whether to capture full stack information
234
+ stacklevel : int
235
+ Number of frames to skip when finding caller
236
+
237
+ Returns
238
+ -------
239
+ tuple
240
+ (filename, line number, function name, stack info)
241
+
242
+ """
243
+ f = currentframe()
244
+ if f is None:
245
+ return "(unknown file)", 0, "(unknown function)", None
246
+ while stacklevel > 0:
247
+ next_f = f.f_back
248
+ if next_f is None:
249
+ break
250
+ f = next_f
251
+ stacklevel -= 1
252
+ co = f.f_code
253
+ sinfo = None
254
+ if stack_info:
255
+ with io.StringIO() as sio:
256
+ sio.write("Stack (most recent call last):\n")
257
+ traceback.print_stack(f, file=sio)
258
+ sinfo = sio.getvalue()
259
+ if sinfo[-1] == "\n":
260
+ sinfo = sinfo[:-1]
261
+ return co.co_filename, f.f_lineno, co.co_name, sinfo
262
+
263
+ def _log(
264
+ self,
265
+ level: int,
266
+ msg: str,
267
+ args: tuple[object, ...],
268
+ *,
269
+ exc_info: tuple[None, None, None] | None = None,
270
+ extra: Mapping[str, object] | None = None,
271
+ stack_info: bool = False,
272
+ stacklevel: int = 1,
273
+ ) -> None:
274
+ """Core logging method that handles message creation and routing.
275
+
276
+ Parameters
277
+ ----------
278
+ level : int
279
+ Logging level for this message
280
+ msg : str
281
+ Message format string
282
+ args : tuple
283
+ Arguments for message formatting
284
+ **kwargs : dict
285
+ Additional logging context
286
+
287
+ """
288
+ fn, lno, func, sinfo = self.find_caller(stack_info=stack_info, stacklevel=stacklevel)
289
+ _ = extra
290
+ record = logging.LogRecord("root", level, fn, lno, msg, args, exc_info, func, sinfo)
291
+ self.handle(record)
292
+
293
+ def handle(self, record: logging.LogRecord) -> None:
294
+ """Process a LogRecord by emitting it.
295
+
296
+ Parameters
297
+ ----------
298
+ record : logging.LogRecord
299
+ The record to be processed
300
+
301
+ """
302
+ self.emit(record)
303
+
304
+ def flush(self) -> None:
305
+ """Ensure all logging output has been flushed to the stream."""
306
+ if self._stream and hasattr(self._stream, "flush"):
307
+ self._stream.flush()
308
+
309
+ def emit(self, record: logging.LogRecord) -> None:
310
+ """Write formatted LogRecord to output stream.
311
+
312
+ Parameters
313
+ ----------
314
+ record : logging.LogRecord
315
+ The record to be emitted
316
+
317
+ """
318
+ msg = self.format(record)
319
+ self._stream.write(msg + self.terminator)
320
+ self.flush()
321
+
322
+ def set_formatter(self, fmt: logging.Formatter) -> None:
323
+ """Set the formatter for log messages.
324
+
325
+ Parameters
326
+ ----------
327
+ fmt : logging.Formatter
328
+ Formatter to use for log messages
329
+
330
+ """
331
+ self._formatter = fmt
332
+
333
+ def format(self, record: logging.LogRecord) -> str:
334
+ """Apply formatter to LogRecord.
335
+
336
+ Parameters
337
+ ----------
338
+ record : logging.LogRecord
339
+ The record to format
340
+
341
+ Returns
342
+ -------
343
+ str
344
+ Formatted log message
345
+
346
+ """
347
+ return self._formatter.format(record)
348
+
349
+ def debug(self, msg: str, *args: object, **kwargs: Unknown) -> None:
350
+ """Log message at DEBUG level.
351
+
352
+ Parameters
353
+ ----------
354
+ msg : str
355
+ Message format string
356
+ *args : tuple
357
+ Arguments for message formatting
358
+ **kwargs : dict
359
+ Additional logging context
360
+
361
+ """
362
+ if self.is_enabled_for(DEBUG):
363
+ self._log(DEBUG, msg, args, **kwargs)
364
+
365
+ def info(self, msg: str, *args: object, **kwargs: Unknown) -> None:
366
+ """Log message at INFO level.
367
+
368
+ Parameters
369
+ ----------
370
+ msg : str
371
+ Message format string
372
+ *args : tuple
373
+ Arguments for message formatting
374
+ **kwargs : dict
375
+ Additional logging context
376
+
377
+ """
378
+ if self.is_enabled_for(INFO):
379
+ self._log(INFO, msg, args, **kwargs)
380
+
381
+ def warning(self, msg: str, *args: object, **kwargs: Unknown) -> None:
382
+ """Log message at WARNING level.
383
+
384
+ Parameters
385
+ ----------
386
+ msg : str
387
+ Message format string
388
+ *args : tuple
389
+ Arguments for message formatting
390
+ **kwargs : dict
391
+ Additional logging context
392
+
393
+ """
394
+ if self.is_enabled_for(WARNING):
395
+ self._log(WARNING, msg, args, **kwargs)
396
+
397
+ def error(self, msg: str, *args: object, **kwargs: Unknown) -> None:
398
+ """Log message at ERROR level.
399
+
400
+ Parameters
401
+ ----------
402
+ msg : str
403
+ Message format string
404
+ *args : tuple
405
+ Arguments for message formatting
406
+ **kwargs : dict
407
+ Additional logging context
408
+
409
+ """
410
+ if self.is_enabled_for(ERROR):
411
+ self._log(ERROR, msg, args, **kwargs)
412
+
413
+ def critical(self, msg: str, *args: object, **kwargs: Unknown) -> None:
414
+ """Log message at CRITICAL level.
415
+
416
+ Parameters
417
+ ----------
418
+ msg : str
419
+ Message format string
420
+ *args : tuple
421
+ Arguments for message formatting
422
+ **kwargs : dict
423
+ Additional logging context
424
+
425
+ """
426
+ if self.is_enabled_for(CRITICAL):
427
+ self._log(CRITICAL, msg, args, **kwargs)
428
+
429
+ def log(self, level: int, msg: str, *args: object, **kwargs: Unknown) -> None:
430
+ """Log message at specified level.
431
+
432
+ Parameters
433
+ ----------
434
+ level : int
435
+ Logging level to use
436
+ msg : str
437
+ Message format string
438
+ *args : tuple
439
+ Arguments for message formatting
440
+ **kwargs : dict
441
+ Additional logging context
442
+
443
+ """
444
+ if self.is_enabled_for(level):
445
+ self._log(level, msg, args, **kwargs)
446
+
447
+ def toggle(self, *, disabled: bool | None = None) -> bool:
448
+ """Toggle or set logger's disabled state.
449
+
450
+ Parameters
451
+ ----------
452
+ disabled : bool or None
453
+ New disabled state, or None to toggle current state
454
+
455
+ Returns
456
+ -------
457
+ bool
458
+ New disabled state
459
+
460
+ """
461
+ if disabled is None:
462
+ self._disabled = not self._disabled
463
+ else:
464
+ self._disabled = disabled
465
+ return self._disabled
466
+
467
+ @property
468
+ def level(self) -> int:
469
+ """Get current logging level.
470
+
471
+ Returns
472
+ -------
473
+ int
474
+ Current logging level
475
+
476
+ """
477
+ return self._level
478
+
479
+ @property
480
+ def disabled(self) -> bool:
481
+ """Get logger's disabled state.
482
+
483
+ Returns
484
+ -------
485
+ bool
486
+ Whether logging is currently disabled
487
+
488
+ Return whether the console output is disabled or not.
489
+
490
+ """
491
+ return self._disabled
492
+
493
+
494
+ def _running_in_pytest() -> bool:
495
+ """Check if the code is running inside pytest.
496
+
497
+ Returns
498
+ -------
499
+ bool
500
+ True if running in pytest, False otherwise.
501
+
502
+ """
503
+ return "pytest" in sys.modules
504
+
505
+
506
+ _event_logger: logging.Logger | FakeLogger = FakeLogger(disable=_running_in_pytest())
507
+
508
+
509
+ def _get_default_event_log_filename(
510
+ argv: list[str],
511
+ output_dir: str | Path | None,
512
+ given_log_filename: str | Path | None,
513
+ ) -> Path:
514
+ """Get the default event log filename.
515
+
516
+ Parameters
517
+ ----------
518
+ argv : list of str
519
+ Command-line arguments.
520
+ output_dir : str or Path or None
521
+ Directory to save the log file, or None for the default temporary directory.
522
+ given_log_filename : str or Path or None
523
+ Specific log filename, or None to generate one.
524
+
525
+ Returns
526
+ -------
527
+ Path
528
+ The path to the log file.
529
+
530
+ """
531
+ event_log_filename: Path
532
+ if given_log_filename is None:
533
+ if output_dir is not None:
534
+ event_log_filename = (Path(output_dir) / Path(argv[0]).name).with_suffix(".log")
535
+ else:
536
+ event_log_filename = (Path(tempfile.gettempdir()) / Path(argv[0]).name).with_suffix(".log")
537
+ else:
538
+ event_log_filename = Path(given_log_filename)
539
+
540
+ return event_log_filename
541
+
542
+
543
+ def start_event_logging(
544
+ argv: list[str],
545
+ output_dir: str | Path | None = None,
546
+ log_filename: str | Path | None = None,
547
+ name: str | None = None,
548
+ logging_level: int | str = logging.WARNING,
549
+ logging_level_file: int | str = logging.DEBUG,
550
+ fmt: str | None = None,
551
+ ) -> logging.Logger:
552
+ """Configure and start event logging system.
553
+
554
+ Sets up both console and file handlers with specified logging levels.
555
+
556
+ Parameters
557
+ ----------
558
+ argv : list[str]
559
+ Command line arguments used to generate default log filename
560
+ output_dir : str or Path or None
561
+ Directory for log file output
562
+ log_filename : str or Path or None
563
+ Custom log filename, generated from argv if None
564
+ name : str or None
565
+ Logger name, defaults to module name if None
566
+ logging_level : int or str
567
+ Console handler logging level
568
+ logging_level_file : int or str
569
+ File handler logging level
570
+ fmt : str or None
571
+ Custom log message format string
572
+
573
+ Returns
574
+ -------
575
+ logging.Logger
576
+ Configured logger instance ready for use
577
+
578
+ """
579
+ if fmt is None:
580
+ fmt = DEFAULT_FORMAT
581
+ if name is None:
582
+ name = __name__
583
+
584
+ global _event_logger # noqa: PLW0603
585
+ if isinstance(_event_logger, logging.Logger) and _event_logger.name == name:
586
+ # Event logging has been started.
587
+ return _event_logger
588
+
589
+ # Create a new logger instance.
590
+ logger = logging.getLogger(name)
591
+ logger.setLevel(logging.DEBUG)
592
+
593
+ # Create a formatter
594
+ formatter = logging.Formatter(fmt)
595
+
596
+ # Setup a console handler
597
+ ch = logging.StreamHandler()
598
+ ch.setLevel(logging_level)
599
+ ch.setFormatter(formatter)
600
+ logger.addHandler(ch)
601
+
602
+ # Setup a file handler
603
+ event_log_filename = _get_default_event_log_filename(argv, output_dir, log_filename)
604
+ try:
605
+ fh = logging.FileHandler(event_log_filename)
606
+ except FileNotFoundError:
607
+ # Maybe, running in dry-run mode...
608
+ msg = f"Unable to save log file (if running in dry-run mode, ignore this): {event_log_filename}"
609
+ warnings.warn(msg, RuntimeWarning, stacklevel=2)
610
+ else:
611
+ fh.setLevel(logging_level_file)
612
+ fh.setFormatter(formatter)
613
+ logger.addHandler(fh)
614
+
615
+ # Always log command arguments.
616
+ logger.debug("Start event logging")
617
+ logger.debug("Logger name: %s", logger.name)
618
+ logger.debug("Log filename: %s", event_log_filename)
619
+ cmd = "python " + " ".join(argv)
620
+ logger.debug("Command: %s", cmd)
621
+
622
+ _event_logger = logger
623
+ return _event_logger
624
+
625
+
626
+ def event_logger() -> logging.Logger | FakeLogger:
627
+ """Get the global event logger instance.
628
+
629
+ Returns
630
+ -------
631
+ logging.Logger or FakeLogger
632
+ The global event logger instance.
633
+
634
+ """
635
+ return _event_logger
636
+
637
+
638
+ def evlog() -> logging.Logger | FakeLogger:
639
+ """Alias for getting the global event logger instance.
640
+
641
+ Returns
642
+ -------
643
+ logging.Logger or FakeLogger
644
+ The global event logger instance.
645
+
646
+ """
647
+ return event_logger()
648
+
649
+
650
+ def get_event_logger_filename() -> Path | None:
651
+ """Retrieve Path object of log file when the event logger is started.
652
+
653
+ Returns
654
+ -------
655
+ Path or None
656
+ Path object of log file, or None when the event logger is not started.
657
+
658
+ """
659
+ logger = event_logger()
660
+ if isinstance(logger, FakeLogger):
661
+ return None
662
+ for handler in logger.handlers:
663
+ if isinstance(handler, logging.FileHandler):
664
+ return Path(handler.baseFilename)
665
+ return None
666
+
667
+
668
+ def get_logging_level_from_verbose_count(verbose_count: int) -> str:
669
+ """Convert verbosity count to logging level string.
670
+
671
+ Parameters
672
+ ----------
673
+ verbose_count : int
674
+ Number of verbose flags (e.g. -v, -vv, -vvv)
675
+ 0: WARNING
676
+ 1: INFO
677
+ 2+: DEBUG
678
+
679
+ Returns
680
+ -------
681
+ str
682
+ Corresponding logging level name
683
+
684
+ """
685
+ match verbose_count:
686
+ case 0:
687
+ return "WARNING"
688
+ case 1:
689
+ return "INFO"
690
+ case _:
691
+ return "DEBUG"
692
+
693
+
694
+ def start_logging(
695
+ argv: list[str],
696
+ output_dir: str | Path | None,
697
+ name: str,
698
+ verbose_count: int,
699
+ *,
700
+ dry_run: bool = False,
701
+ ) -> logging.Logger:
702
+ """Initialize logging system with verbosity control and output configuration.
703
+
704
+ Sets up a complete logging system with both file and console handlers based on the specified
705
+ verbosity level. Creates output directory if it doesn't exist (unless in dry-run mode).
706
+
707
+ Parameters
708
+ ----------
709
+ argv : list[str]
710
+ Command line arguments used for log file naming
711
+ output_dir : Path or None
712
+ Directory where log files will be stored
713
+ name : str
714
+ Name for the logger instance
715
+ verbose_count : int
716
+ Verbosity level from command line:
717
+ - 0: WARNING level
718
+ - 1: INFO level
719
+ - 2+: DEBUG level
720
+ dry_run : bool, optional
721
+ When True, prevents creation of output directory and log files,
722
+ default is False
723
+
724
+ Returns
725
+ -------
726
+ logging.Logger
727
+ Configured logger instance ready for immediate use
728
+
729
+ Examples
730
+ --------
731
+ >>> logger = start_logging(argv=["script.py"], output_dir=Path("logs"), name="myapp", verbose_count=1)
732
+ >>> logger.info("Application started")
733
+
734
+ """
735
+ logging_level = get_logging_level_from_verbose_count(verbose_count)
736
+ if not dry_run and output_dir is not None:
737
+ Path(output_dir).mkdir(parents=True, exist_ok=True)
738
+ return start_event_logging(argv, output_dir, name=name, logging_level=logging_level)
739
+
740
+
741
+ # Local Variables:
742
+ # jinx-local-words: "TextIO args argv asctime dir evlog fmt getframe kwargs levelname lineno loggingutil msg myapp noqa py pyplotutil pytest stacklevel stderr str sys vv vvv" # noqa: E501
743
+ # End: