e1f3a2221b4e3f3e27441d5ace08e959f3f18257
[pyutils.git] / src / pyutils / logging_utils.py
1 #!/usr/bin/env python3
2 # -*- coding: utf-8 -*-
3
4 # © Copyright 2021-2023, Scott Gasch
5
6 """
7 This is a module that offers an opinionated take on how whole program
8 logging should be initialized and controlled.  It uses the standard
9 Python :mod:`logging` but gives you control, via commandline config,
10 to do things such as:
11
12     * Set the logging default level (debug, info, warning, error, critical)
13       of the whole program (see: :code:`--logging_level`)... and to override
14       the logging level with :class:`LoggingContext`.
15     * Prepend or append a message to every log record also with
16       :class:`LoggingContext`.
17     * define the logging message format (see :code:`--logging_format` and
18       :code:`--logging_date_format`) including easily adding a PID/TID
19       marker on all messages to help with multithreaded debugging
20       (:code:`--logging_debug_threads`) and force module names of code
21       that emits log messages to be included in the format
22       (:code:`--logging_debug_modules`),
23     * control the destination of logged messages:
24
25         - log to the console/stderr (:code:`--logging_console`) and/or
26         - log to a rotated file (:code:`--logging_filename`,
27           :code:`--logging_filename_maxsize` and :code:`--logging_filename_count`)
28           and/or
29         - log to the UNIX syslog (:code:`--logging_syslog` and
30           :code:`--logging_syslog_facility`)
31
32     * optionally squelch repeated messages (:code:`--logging_squelch_repeats`),
33     * optionally log probalistically (:code:`--logging_probabilistically`),
34     * capture printed messages into the info log (:code:`--logging_captures_prints`),
35     * and optionally clear unwanted logging handlers added by other imports
36       before this one (:code:`--logging_clear_preexisting_handlers`).
37     * There are also :class:`LoggerAdapter` classes to implement prefix/suffix
38       functionality without using :class:`LoggingContext` by wrapping the
39       logger included.
40
41 To use this functionality, call :meth:`initialize_logging` early
42 in your program entry point.  If you use the
43 :meth:`pyutils.bootstrap.initialize` decorator on your program's entry
44 point, it will call this for you automatically.
45 """
46
47 import collections
48 import contextlib
49 import datetime
50 import enum
51 import io
52 import logging
53 import os
54 import sys
55 from logging.config import fileConfig
56 from logging.handlers import RotatingFileHandler, SysLogHandler
57 from typing import Any, Callable, Dict, Iterable, List, Optional, Union
58
59 import pytz
60 from overrides import overrides
61
62 # This module is commonly used by others in here and should avoid
63 # taking any unnecessary dependencies back on them.
64 from pyutils import argparse_utils, config, misc_utils
65
66 cfg = config.add_commandline_args(f"Logging ({__file__})", "Args related to logging")
67 cfg.add_argument(
68     "--logging_config_file",
69     type=argparse_utils.valid_filename,
70     default=None,
71     metavar="FILENAME",
72     help="Config file containing the logging setup, see: https://docs.python.org/3/howto/logging.html#logging-advanced-tutorial",
73 )
74 cfg.add_argument(
75     "--logging_level",
76     type=str,
77     default="INFO",
78     choices=["NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
79     metavar="LEVEL",
80     help="The global default level below which to squelch log messages",
81 )
82 cfg.add_argument(
83     "--logging_format",
84     type=str,
85     default=None,
86     help="The format for lines logged via the logger module.  See: https://docs.python.org/3/library/logging.html#formatter-objects",
87 )
88 cfg.add_argument(
89     "--logging_date_format",
90     type=str,
91     default="%Y/%m/%dT%H:%M:%S.%f%z",
92     metavar="DATEFMT",
93     help="The format of any dates in --logging_format.",
94 )
95 cfg.add_argument(
96     "--logging_console",
97     action=argparse_utils.ActionNoYes,
98     default=True,
99     help="Should we log to the console (stderr)",
100 )
101 cfg.add_argument(
102     "--logging_filename",
103     type=str,
104     default=None,
105     metavar="FILENAME",
106     help="The filename of the logfile to write.",
107 )
108 cfg.add_argument(
109     "--logging_filename_maxsize",
110     type=argparse_utils.valid_byte_count,
111     default=(1024 * 1024),
112     metavar="#BYTES",
113     help="The maximum size (in bytes) to write to the logging_filename.",
114 )
115 cfg.add_argument(
116     "--logging_filename_count",
117     type=int,
118     default=7,
119     metavar="COUNT",
120     help="The number of logging_filename copies to keep before deleting.",
121 )
122 cfg.add_argument(
123     "--logging_syslog",
124     action=argparse_utils.ActionNoYes,
125     default=False,
126     help="Should we log to localhost's syslog.",
127 )
128 cfg.add_argument(
129     "--logging_syslog_facility",
130     type=str,
131     default="USER",
132     choices=[
133         "NOTSET",
134         "AUTH",
135         "AUTH_PRIV",
136         "CRON",
137         "DAEMON",
138         "FTP",
139         "KERN",
140         "LPR",
141         "MAIL",
142         "NEWS",
143         "SYSLOG",
144         "USER",
145         "UUCP",
146         "LOCAL0",
147         "LOCAL1",
148         "LOCAL2",
149         "LOCAL3",
150         "LOCAL4",
151         "LOCAL5",
152         "LOCAL6",
153         "LOCAL7",
154     ],
155     metavar="SYSLOG_FACILITY_LIST",
156     help="The default syslog message facility identifier",
157 )
158 cfg.add_argument(
159     "--logging_debug_threads",
160     action=argparse_utils.ActionNoYes,
161     default=False,
162     help="Should we prepend pid/tid data to all log messages?",
163 )
164 cfg.add_argument(
165     "--logging_debug_modules",
166     action=argparse_utils.ActionNoYes,
167     default=False,
168     help="Should we prepend module/function data to all log messages?",
169 )
170 cfg.add_argument(
171     "--logging_info_is_print",
172     action=argparse_utils.ActionNoYes,
173     default=False,
174     help="logging.info also prints to stdout.",
175 )
176 cfg.add_argument(
177     "--logging_squelch_repeats",
178     action=argparse_utils.ActionNoYes,
179     default=True,
180     help="Do we allow code to indicate that it wants to squelch repeated logging messages or should we always log?",
181 )
182 cfg.add_argument(
183     "--logging_probabilistically",
184     action=argparse_utils.ActionNoYes,
185     default=True,
186     help="Do we allow probabilistic logging (for code that wants it) or should we always log?",
187 )
188 # See also: OutputMultiplexer
189 cfg.add_argument(
190     "--logging_captures_prints",
191     action=argparse_utils.ActionNoYes,
192     default=False,
193     help="When calling print, also log.info automatically.",
194 )
195 cfg.add_argument(
196     "--logging_clear_preexisting_handlers",
197     action=argparse_utils.ActionNoYes,
198     default=True,
199     help=(
200         "Should logging code clear preexisting global logging handlers and thus insist that is "
201         + "alone can add handlers.  Use this to work around annoying modules that insert global "
202         + "handlers with formats and logging levels you might now want.  Caveat emptor, this may "
203         + "cause you to miss logging messages."
204     ),
205 )
206
207 BUILT_IN_PRINT = print
208 LOGGING_INITIALIZED = False
209
210
211 # A map from logging_callsite_id -> count of logged messages.
212 squelched_logging_counts: Dict[str, int] = {}
213
214
215 def squelch_repeated_log_messages(squelch_after_n_repeats: int) -> Callable:
216     """
217     A decorator that marks a function as interested in having the logging
218     messages that it produces be squelched (ignored) after it logs the
219     same message more than N times.
220
221     .. note::
222
223         This decorator affects *ALL* logging messages produced
224         within the decorated function.  That said, messages must be
225         identical in order to be squelched.  For example, if the same line
226         of code produces different messages (because of, e.g., a format
227         string), the messages are considered to be different.
228
229     An example of this from the pyutils code itself can be found in
230     :meth:`pyutils.ansi.fg` and :meth:`pyutils.ansi.bg` methods::
231
232         @logging_utils.squelch_repeated_log_messages(1)
233         def fg(
234             name: Optional[str] = "",
235             red: Optional[int] = None,
236             green: Optional[int] = None,
237             blue: Optional[int] = None,
238             *,
239             force_16color: bool = False,
240             force_216color: bool = False,
241         ) -> str:
242             ...
243
244     These methods log stuff like "Using 24-bit color strategy" which
245     gets old really fast and fills up the logs.  By decorating the methods
246     with :code:`@logging_utils.squelch_repeated_log_messages(1)` the code
247     is requesting that its logged messages be dropped silently after the
248     first one is produced (note the argument 1).
249
250     Users can insist that all logged messages always be reflected in the
251     logs using the :code:`--no_logging_squelch_repeats` flag but the default
252     behavior is to allow code to request it be squelched.
253
254     :code:`--logging_squelch_repeats` only affects code with this decorator
255     on it; it ignores all other code.
256
257     Args:
258         squelch_after_n_repeats: the number of repeated messages allowed to
259             log before subsequent messages are silently dropped.
260     """
261
262     def squelch_logging_wrapper(f: Callable):
263         from pyutils import function_utils
264
265         identifier = function_utils.function_identifier(f)
266         squelched_logging_counts[identifier] = squelch_after_n_repeats
267         return f
268
269     return squelch_logging_wrapper
270
271
272 class SquelchRepeatedMessagesFilter(logging.Filter):
273     """A filter that only logs messages from a given site with the same
274     (exact) message at the same logging level N times and ignores
275     subsequent attempts to log.
276
277     This filter only affects logging messages that repeat more than a
278     threshold number of times from functions that are tagged with the
279     :code:`@logging_utils.squelched_logging_ok` decorator (see above);
280     all others are ignored.
281
282     This functionality is enabled by default but can be disabled via
283     the :code:`--no_logging_squelch_repeats` commandline flag.
284     """
285
286     def __init__(self) -> None:
287         super().__init__()
288         self.counters: collections.Counter = collections.Counter()
289
290     @overrides
291     def filter(self, record: logging.LogRecord) -> bool:
292         """Should we drop this log message?"""
293         id1 = f"{record.module}:{record.funcName}"
294         if id1 not in squelched_logging_counts:
295             return True
296         threshold = squelched_logging_counts[id1]
297         logsite = f"{record.pathname}+{record.lineno}+{record.levelno}+{record.msg}"
298         count = self.counters[logsite]
299         self.counters[logsite] += 1
300         return count < threshold
301
302
303 # A map from function_identifier -> probability of logging (0.0%..100.0%)
304 probabilistic_logging_levels: Dict[str, float] = {}
305
306
307 def logging_is_probabilistic(probability_of_logging: float) -> Callable:
308     """A decorator that indicates that all logging statements within the
309     scope of a particular (marked via decorator) function are not
310     deterministic (i.e. they do not always unconditionally log) but rather
311     are probabilistic (i.e. they log N% of the time, randomly) when the
312     user passes the :code:`--logging_probabilistically` commandline flag
313     (which is enabled by default).
314
315     .. note::
316
317         This affects *ALL* logging statements within the marked function.
318         If you want it to only affect a subset of logging statements,
319         log those statements in a separate function that you invoke
320         from within the "too large" scope and mark that separate function
321         with the :code:`logging_is_probabilistic` decorator instead.
322
323     That this functionality can be disabled (forcing all logged
324     messages to produce output) via the
325     :code:`--no_logging_probabilistically` cmdline argument.
326     """
327
328     def probabilistic_logging_wrapper(f: Callable):
329         from pyutils import function_utils
330
331         identifier = function_utils.function_identifier(f)
332         probabilistic_logging_levels[identifier] = probability_of_logging
333         return f
334
335     return probabilistic_logging_wrapper
336
337
338 class ProbabilisticFilter(logging.Filter):
339     """
340     A filter that logs messages probabilistically (i.e. randomly at some
341     percent chance).  This filter is used with a decorator (see
342     :meth:`logging_is_probabilistic`) to implement the
343     :code:`--logging_probabilistically` commandline flag.
344
345     This filter only affects logging messages from functions that have
346     been tagged with the `@logging_utils.probabilistic_logging` decorator.
347     """
348
349     @overrides
350     def filter(self, record: logging.LogRecord) -> bool:
351         """Should the message be logged?"""
352         identifier = f"{record.module}:{record.funcName}"
353         threshold = probabilistic_logging_levels.get(identifier, 100.0)
354         return misc_utils.execute_probabilistically(threshold)
355
356
357 class OnlyInfoFilter(logging.Filter):
358     """A filter that only logs messages produced at the INFO logging
359     level.  This is used by the :code:`--logging_info_is_print`
360     commandline option to select a subset of the logging stream to
361     send to a stdout handler.
362     """
363
364     @overrides
365     def filter(self, record: logging.LogRecord):
366         return record.levelno == logging.INFO
367
368
369 class PrefixAddingFilter(logging.Filter):
370     """A filter that adds a string prefix to the log record for the
371     formatter to later fill in.  Requires a %(prefix)s in the format
372     string.
373     """
374
375     def __init__(self, prefix: str, klobber: bool = False):
376         """
377         Args:
378             prefix: the prefix string to add
379             klobber: should we overwrite other prefixes?
380         """
381         super().__init__()
382         if prefix:
383             self.prefix = prefix
384         else:
385             self.prefix = ""
386         self.klobber = klobber
387
388     @overrides
389     def filter(self, record: logging.LogRecord):
390         if self.klobber:
391             record.prefix = self.prefix
392         elif "prefix" not in record.__dict__:
393             record.prefix = self.prefix
394         return True
395
396
397 class SuffixAddingFilter(logging.Filter):
398     """A filter that adds a string suffix to the log record for the
399     formatter to later fill in.  Requires a %(suffix)s in the format
400     string.
401     """
402
403     def __init__(self, suffix: str, klobber: bool = False):
404         """
405         Args:
406             suffix: the suffix string to add
407             klobber: should we overwrite other suffixes?
408         """
409         super().__init__()
410         if suffix:
411             self.suffix = suffix
412         else:
413             self.suffix = ""
414         self.klobber = klobber
415
416     @overrides
417     def filter(self, record: logging.LogRecord):
418         if self.klobber:
419             record.suffix = self.suffix
420         elif "suffix" not in record.__dict__:
421             record.suffix = self.suffix
422         return True
423
424
425 class PrependingLogAdapter(logging.LoggerAdapter):
426     """:class:`LoggingContext` adds prefixes and suffixes using a
427     logging.Filter that must insert "prefix" or "suffix" members into
428     each log record by using :class:`PrefixAddingFilter` and
429     :class:`SuffixAddingFilter`.  This relies on the logging format
430     string containing a %(prefix)s and a %(suffix)s to work correctly.
431
432     This is an alternate approach that basically just wraps the logger
433     in a class that has the same interface and thunks most calls down
434     to the wrapped logger.  It might be useful if you don't want to use
435     :class:`LoggingContext` or its friends.
436
437         >>> logger = logging.getLogger(__name__ + ".PrependingLogAdapter")
438         >>> logger.setLevel(logging.INFO)
439         >>> logger.addHandler(logging.StreamHandler(sys.stdout))
440
441     At this point logger doesn't have any format string and so it is
442     missing %(prefix)s and %(suffix)s.  It also doesn't have a
443     :class:`PrefixAddingFilter` or :class:`SuffixAddingFilter` added.
444     So using it in a :class:`LoggingContext` will not work.
445
446     But we can still add a prefix or suffix by just wrapping it:
447
448         >>> logger.info("TEST")
449         TEST
450
451         >>> log = PrependingLogAdapter.wrap_logger('prefix> ', logger)
452         >>> log.info("TEST")
453         prefix> TEST
454     """
455
456     def process(self, msg, kwargs):
457         return f'{self.extra.get("prefix", "")}{msg}', kwargs
458
459     @staticmethod
460     def wrap_logger(prefix: str, logger: logging.Logger) -> logging.LoggerAdapter:
461         """Helper method around the creation of a LogAdapter that prepends
462         a given string to every log message produced.
463
464         Args:
465             prefix: the message to prepend to every log message.
466             logger: the logger whose messages to modify.
467
468         Returns:
469             A new logger wrapping the old one with the given behavior.
470             The old logger will continue to behave as usual; simply drop
471             the reference to this wrapper when it's no longer needed.
472         """
473         assert prefix
474         return PrependingLogAdapter(logger, {"prefix": prefix})
475
476
477 class AppendingLogAdapter(logging.LoggerAdapter):
478     """:class:`LoggingContext` adds prefixes and suffixes using a
479     logging.Filter that must insert "prefix" or "suffix" members into
480     each log record by using :class:`PrefixAddingFilter` and
481     :class:`SuffixAddingFilter`.  This relies on the logging format
482     string containing a %(prefix)s and a %(suffix)s to work correctly.
483
484     This is an alternate approach that basically just wraps the logger
485     in a class that has the same interface and thunks most calls down
486     to the wrapped logger.  It might be useful if you don't want to use
487     :class:`LoggingContext` or its friends.
488
489         >>> logger = logging.getLogger(__name__ + ".AppendingLogAdapter")
490         >>> logger.setLevel(logging.INFO)
491         >>> logger.addHandler(logging.StreamHandler(sys.stdout))
492
493     At this point logger doesn't have any format string and so it is
494     missing %(prefix)s and %(suffix)s.  It also doesn't have a
495     :class:`PrefixAddingFilter` or :class:`SuffixAddingFilter` added.
496     So using it in a :class:`LoggingContext` will not work.
497
498     But we can still add a prefix or suffix by just wrapping it:
499
500         >>> logger.info("TEST")
501         TEST
502
503         >>> log = AppendingLogAdapter.wrap_logger('!!!', logger)
504         >>> log.info("TEST")
505         TEST!!!
506     """
507
508     def process(self, msg, kwargs):
509         return f'{msg}{self.extra.get("suffix", "")}', kwargs
510
511     @staticmethod
512     def wrap_logger(suffix: str, logger: logging.Logger) -> logging.LoggerAdapter:
513         """Helper method around the creation of a LoggerAdapter that appends
514         a given string to every log message produced.
515
516         Args:
517             suffix: the message to prepend to every log message.
518             logger: the logger whose messages to modify.
519
520         Returns:
521             A new logger wrapping the old one with the given behavior.
522             The old logger will continue to behave as usual; simply drop
523             the reference to this wrapper when it's no longer needed.
524         """
525         assert suffix
526         return AppendingLogAdapter(logger, {"suffix": suffix})
527
528
529 class LoggingContext(contextlib.ContextDecorator):
530     def __init__(
531         self,
532         logger: logging.Logger,
533         *,
534         level: Optional[int] = None,
535         handlers: Optional[List[logging.Handler]] = None,
536         prefix: Optional[str] = None,
537         suffix: Optional[str] = None,
538     ):
539         """This is a logging context that can be used to temporarily change the
540         way we are logging within its scope.  Logging changes may include:
541
542             - Changing the logging level (e.g. from INFO to DEBUG)
543             - Adding a prefix or suffix to every log message produced
544             - Adding temporary Handlers to direct the logging output elsewhere
545
546         Setup for doctest / examples.  This will normally be taken care of
547         by code in :meth:`initialize_logging` so you don't have to worry
548         about it.
549
550             >>> logging_format = "%(prefix)s%(message)s%(suffix)s"
551             >>> logger = logging.getLogger(__name__ + ".LoggingContext")
552             >>> logger.setLevel(logging.INFO)
553             >>> handler = logging.StreamHandler(sys.stdout)
554             >>> handler.setFormatter(
555             ...     MillisecondAwareFormatter(
556             ...         fmt=logging_format,
557             ...         datefmt='',
558             ...     )
559             ... )
560             >>> logger.addHandler(handler)
561             >>> logger.addFilter(PrefixAddingFilter(None))
562             >>> logger.addFilter(SuffixAddingFilter(None))
563
564         First, this logger should be currently be configured to send
565         INFO+ messages to sys.stdout.  Let's see it in action:
566
567             >>> logger.info("Hello world!")
568             Hello world!
569             >>> logger.debug("You should not see this")
570
571         The first example is to simply change the level of the logger.
572         Here we temporarily change it to DEBUG within the body of the
573         :class:`LoggingContext`:
574
575             >>> with LoggingContext(logger, level=logging.DEBUG):
576             ...     logger.debug("You should see this now")
577             ...     logger.info("Of course you should still see this too")
578             You should see this now
579             Of course you should still see this too
580
581             >>> logger.debug("Outside of the context we are at INFO again")
582             >>> logger.debug("(which is why you don't see these)")
583             >>> logger.info("But you should see this at INFO level")
584             But you should see this at INFO level
585
586         The prefix and suffix argument prepend or append a message to
587         all log output.  To do this, you need %(prefix)s and
588         %(suffix)s placeholders in your logger format string
589         indicating where to insert the data.  This is useful, for
590         example, to add an active request identifier to the set of log
591         messages produced while processing it.
592
593             >>> logger.info("About to work on a new request")
594             About to work on a new request
595
596             >>> with LoggingContext(logger, prefix='10.0.0.13> '):
597             ...     logger.info("Working on it now")
598             10.0.0.13> Working on it now
599
600             logger.info("Done with that request")
601             Done with that request
602
603         LoggingContext can also be used to add temporary handler(s).
604         This code temporarily uses two stdout handlers to double the
605         output for testing purporses but you could also temporarily,
606         e.g., add a RotatingFileHandler or SysLogHandler etc...
607
608             >>> with LoggingContext(logger, handlers=[logging.StreamHandler(sys.stdout)]):
609             ...     logger.info("TEST")
610             TEST
611             TEST
612
613         Once leaving the context, logger's behavior is restored.  In
614         this case, the extra handler is removed so output will not
615         longer be doubled.
616
617             >>> logger.info("OUTSIDE")
618             OUTSIDE
619
620         LoggingContext can also be used as a decorator if that is more
621         convenient:
622
623             >>> @LoggingContext(logger, level=logging.DEBUG)
624             ... def log_stuff(logger):
625             ...     logger.debug("But inside, the decorator has changed us to DEBUG")
626
627             >>> logger.debug("Outside, we're at INFO level and you don't see this")
628             >>> log_stuff(logger)
629             But inside, the decorator has changed us to DEBUG
630             >>> logger.debug("And, of course, out here we're still at INFO afterwards")
631
632         """
633         self.logger = logger
634         self.level = level
635         self.old_level: Optional[int] = None
636         self.handlers = handlers
637         if prefix:
638             self.prefix: Optional[logging.Filter] = PrefixAddingFilter(prefix, True)
639         else:
640             self.prefix = None
641         if suffix:
642             self.suffix: Optional[logging.Filter] = SuffixAddingFilter(suffix, True)
643         else:
644             self.suffix = None
645
646     def __enter__(self) -> Union[logging.Logger, logging.LoggerAdapter]:
647         assert self.logger
648         if self.level:
649             self.old_level = self.logger.level
650             self.logger.setLevel(self.level)
651
652         if self.handlers:
653             for handler in self.handlers:
654                 self.logger.addHandler(handler)
655
656         if self.prefix:
657             self.logger.addFilter(self.prefix)
658
659         if self.suffix:
660             self.logger.addFilter(self.suffix)
661         return self.logger
662
663     def __exit__(self, et, ev, tb) -> None:
664         if self.suffix:
665             self.logger.removeFilter(self.suffix)
666
667         if self.prefix:
668             self.logger.removeFilter(self.prefix)
669
670         if self.handlers:
671             for handler in self.handlers:
672                 self.logger.removeHandler(handler)
673                 handler.close()
674
675         if self.old_level:
676             self.logger.setLevel(self.old_level)
677         return None  # propagate exceptions out
678
679
680 class MillisecondAwareFormatter(logging.Formatter):
681     """A formatter for adding milliseconds to log messages which, for
682     whatever reason, the default Python logger doesn't do.
683
684     .. note::
685
686         You probably don't need to use this directly but it is
687         wired in under :meth:`initialize_logging` so that the
688         timestamps in log messages have millisecond level
689         precision.
690     """
691
692     converter = datetime.datetime.fromtimestamp  # type: ignore
693
694     @overrides
695     def formatTime(self, record, datefmt=None):
696         ct = MillisecondAwareFormatter.converter(
697             record.created, pytz.timezone("US/Pacific")
698         )
699         if datefmt:
700             s = ct.strftime(datefmt)
701         else:
702             t = ct.strftime("%Y-%m-%d %H:%M:%S")
703             s = f"{t},{record.msecs:%03d}"
704         return s
705
706
707 def _log_about_logging(
708     logger,
709     default_logging_level,
710     preexisting_handlers_count,
711     fmt,
712     facility_name,
713 ):
714     """This is invoked automatically after logging is initialized such
715     that the first messages in the log are about how logging itself
716     was configured.
717     """
718     level_name = logging._levelToName.get(
719         default_logging_level, str(default_logging_level)
720     )
721     logger.debug("Initialized global logging; logging level is %s.", level_name)
722     if (
723         config.config["logging_clear_preexisting_handlers"]
724         and preexisting_handlers_count > 0
725     ):
726         logger.debug(
727             "Logging cleared %d global handlers (--logging_clear_preexisting_handlers)",
728             preexisting_handlers_count,
729         )
730     logger.debug('Logging format specification is "%s"', fmt)
731     if config.config["logging_debug_threads"]:
732         logger.debug(
733             "...Logging format spec captures tid/pid. (--logging_debug_threads)"
734         )
735     if config.config["logging_debug_modules"]:
736         logger.debug(
737             "...Logging format spec captures files/functions/lineno. (--logging_debug_modules)"
738         )
739     if config.config["logging_syslog"]:
740         logger.debug(
741             "Logging to syslog as %s with priority mapping based on level. (--logging_syslog)",
742             facility_name,
743         )
744     if config.config["logging_filename"]:
745         logger.debug(
746             'Logging to file "%s". (--logging_filename)',
747             config.config["logging_filename"],
748         )
749         logger.debug(
750             "...with %d bytes max file size. (--logging_filename_maxsize)",
751             config.config["logging_filename_maxsize"],
752         )
753         logger.debug(
754             "...and %d rotating backup file count. (--logging_filename_count)",
755             config.config["logging_filename_count"],
756         )
757     if config.config["logging_console"]:
758         logger.debug("Logging to the console (stderr). (--logging_console)")
759     if config.config["logging_info_is_print"]:
760         logger.debug(
761             "Logging logger.info messages will be repeated on stdout. (--logging_info_is_print)"
762         )
763     if config.config["logging_squelch_repeats"]:
764         logger.debug(
765             "Logging code allowed to request repeated messages be squelched. (--logging_squelch_repeats)"
766         )
767     else:
768         logger.debug(
769             "Logging code forbidden to request messages be squelched; all messages logged. (--no_logging_squelch_repeats)"
770         )
771     if config.config["logging_probabilistically"]:
772         logger.debug(
773             "Logging code is allowed to request probabilistic logging. (--logging_probabilistically)"
774         )
775     else:
776         logger.debug(
777             "Logging code is forbidden to request probabilistic logging; messages always logged. (--no_logging_probabilistically)"
778         )
779     if config.config["logging_captures_prints"]:
780         logger.debug(
781             "Logging will capture printed data as logger.info messages. (--logging_captures_prints)"
782         )
783
784
785 def _construct_logging_format() -> str:
786     """Figure out the root logging format string based on commandling args."""
787
788     # Custom or default --logging_format?
789     if config.config["logging_format"]:
790         fmt = config.config["logging_format"]
791     elif config.config["logging_syslog"]:
792         fmt = "%(levelname).1s:%(filename)s[%(process)d]: "
793     else:
794         fmt = "%(levelname).1s:%(asctime)s: "
795
796     fmt += "%(prefix)s%(message)s%(suffix)s"
797
798     # --logging_debug_threads and --logging_debug_modules both affect
799     # the format by prepending information about the pid/tid or
800     # file/function.
801     if config.config["logging_debug_threads"]:
802         fmt = f"%(process)d.%(thread)d|{fmt}"
803     if config.config["logging_debug_modules"]:
804         fmt = f"%(filename)s:%(funcName)s:%(lineno)s|{fmt}"
805     return fmt
806
807
808 def _construct_handlers_list(
809     logging_format: str, facility_name: Optional[str]
810 ) -> List[logging.Handler]:
811     """Create the list of handlers to be added to the root logger
812     based on commandline flags.
813     """
814
815     retval: List[logging.Handler] = []
816
817     # --logging_syslog (optionally with --logging_syslog_facility)
818     # sets up for logging to use the standard system syslogd as a
819     # sink.
820     if config.config["logging_syslog"]:
821         if sys.platform not in ("win32", "cygwin"):
822             assert facility_name
823             facility = SysLogHandler.__dict__.get(facility_name, SysLogHandler.LOG_USER)
824             assert facility
825             syslog_handler = SysLogHandler(facility=facility, address="/dev/log")
826             syslog_handler.setFormatter(
827                 MillisecondAwareFormatter(
828                     fmt=logging_format,
829                     datefmt=config.config["logging_date_format"],
830                 )
831             )
832             retval.append(syslog_handler)
833
834     # --logging_filename (with friends --logging_filename_count and
835     # --logging_filename_maxsize) set up logging to a file on the
836     # filesystem with automatic rotation when it gets too big.
837     if config.config["logging_filename"]:
838         max_bytes = config.config["logging_filename_maxsize"]
839         assert max_bytes and isinstance(max_bytes, int)
840         backup_count = config.config["logging_filename_count"]
841         assert backup_count and isinstance(backup_count, int)
842         file_handler = RotatingFileHandler(
843             config.config["logging_filename"],
844             maxBytes=max_bytes,
845             backupCount=backup_count,
846         )
847         file_handler.setFormatter(
848             MillisecondAwareFormatter(
849                 fmt=logging_format,
850                 datefmt=config.config["logging_date_format"],
851             )
852         )
853         retval.append(file_handler)
854
855     # --logging_console is, ahem, logging to the console.
856     if config.config["logging_console"]:
857         console_handler = logging.StreamHandler(sys.stderr)
858         console_handler.setFormatter(
859             MillisecondAwareFormatter(
860                 fmt=logging_format,
861                 datefmt=config.config["logging_date_format"],
862             )
863         )
864         retval.append(console_handler)
865
866     # --logging_info_is_print echoes any message to logger.info(x) as
867     # a print statement on stdout.
868     if config.config["logging_info_is_print"]:
869         print_handler = logging.StreamHandler(sys.stdout)
870         print_handler.addFilter(OnlyInfoFilter())
871         retval.append(print_handler)
872
873     if len(retval) == 0:
874         retval.append(logging.NullHandler())
875     return retval
876
877
878 def _add_logging_handlers_and_filters(
879     logger: logging.Logger, handlers: List[logging.Handler]
880 ) -> None:
881     """Adds a list of handlers to the root logger."""
882
883     for handler in handlers:
884         # These two are always present and set %(prefix) and %(suffix)
885         # if appropriate (see :class:`LoggingContext`) or make them
886         # empty string.
887         handler.addFilter(PrefixAddingFilter("", False))
888         handler.addFilter(SuffixAddingFilter("", False))
889
890         # --logging_squelch_repeats allows code to request repeat logging
891         # messages (identical log site and message contents) to be
892         # silenced.  Logging code must request this explicitly, it isn't
893         # automatic.  This option just allows the silencing to happen.
894         if config.config["logging_squelch_repeats"]:
895             handler.addFilter(SquelchRepeatedMessagesFilter())
896
897         # --logging_probabilistically allows code to request
898         # non-deterministic logging where messages have some probability
899         # of being produced.  Logging code must request this explicitly.
900         # This option just allows the non-deterministic behavior to
901         # happen.  Disabling it will cause every log message to be
902         # produced.
903         if config.config["logging_probabilistically"]:
904             handler.addFilter(ProbabilisticFilter())
905         logger.addHandler(handler)
906
907
908 def initialize_logging(logger=None) -> logging.Logger:
909     """Initialize logging for the program.  See module level comments
910     for information about what functionality this provides and how to
911     enable or disable functionality via the commandline.
912
913     If you use the
914     :meth:`bootstrap.initialize` decorator on your program's entry point,
915     it will call this for you.  See :meth:`pyutils.bootstrap.initialize`
916     for more details.
917     """
918
919     global LOGGING_INITIALIZED
920     if LOGGING_INITIALIZED:
921         return logging.getLogger()
922     LOGGING_INITIALIZED = True
923
924     clear_preexisting = config.config["logging_clear_preexisting_handlers"]
925     preexisting_handlers_count = 0
926     if logger is None:
927         logging.basicConfig(force=clear_preexisting)
928         logger = logging.getLogger()
929
930     # --logging_clear_preexisting_handlers removes logging handlers
931     # that were registered by global statements during imported module
932     # setup.
933     if clear_preexisting:
934         while logger.hasHandlers():
935             logger.removeHandler(logger.handlers[0])
936             preexisting_handlers_count += 1
937
938     # --logging_config_file pulls logging settings from a config file
939     # skipping the rest of this setup.
940     if config.config["logging_config_file"] is not None:
941         fileConfig(config.config["logging_config_file"])
942         return logger
943
944     # Global default logging level (--logging_level); messages below
945     # this level will be silenced.
946     logging_level = config.config["logging_level"]
947     assert logging_level
948     logging_level = logging_level.upper()
949     default_logging_level = getattr(logging, logging_level, None)
950     if not isinstance(default_logging_level, int):
951         raise ValueError(f'Invalid level: {config.config["logging_level"]}')
952     logger.setLevel(default_logging_level)
953
954     logging_format = _construct_logging_format()
955     facility_name = None
956     if config.config["logging_syslog"]:
957         if sys.platform not in ("win32", "cygwin"):
958             if config.config["logging_syslog_facility"]:
959                 facility_name = "LOG_" + config.config["logging_syslog_facility"]
960     handlers: List[logging.Handler] = _construct_handlers_list(
961         logging_format, facility_name
962     )
963     _add_logging_handlers_and_filters(logger, handlers)
964
965     # --logging_captures_prints, if set, will capture and log.info
966     # anything printed on stdout.
967     if config.config["logging_captures_prints"]:
968         import builtins
969
970         def print_and_also_log(*arg, **kwarg):
971             f = kwarg.get("file", None)
972             if f == sys.stderr:
973                 logger.warning(*arg)
974             else:
975                 logger.info(*arg)
976             BUILT_IN_PRINT(*arg, **kwarg)
977
978         builtins.print = print_and_also_log
979
980     # At this point the logger is ready, handlers are set up,
981     # etc... so log about the logging configuration.
982     _log_about_logging(
983         logger,
984         default_logging_level,
985         preexisting_handlers_count,
986         logging_format,
987         facility_name,
988     )
989     return logger
990
991
992 def get_logger(name: str = ""):
993     """Get the global logger"""
994     logger = logging.getLogger(name)
995     return initialize_logging(logger)
996
997
998 class OutputMultiplexer(object):
999     """A class that broadcasts printed messages to several sinks
1000     (including various logging levels, different files, different file
1001     handles, the house log, etc...).  See also
1002     :class:`OutputMultiplexerContext` for an easy usage pattern.
1003     """
1004
1005     class Destination(enum.IntEnum):
1006         """Bits in the destination_bitv bitvector.  Used to indicate the
1007         output destination."""
1008
1009         # fmt: off
1010         LOG_DEBUG = 0x01     #  ⎫
1011         LOG_INFO = 0x02      #  ⎪
1012         LOG_WARNING = 0x04   #  ⎬ Must provide logger to the c'tor.
1013         LOG_ERROR = 0x08     #  ⎪
1014         LOG_CRITICAL = 0x10  #  ⎭
1015         FILENAMES = 0x20     # Must provide a filename to the c'tor.
1016         FILEHANDLES = 0x40   # Must provide a handle to the c'tor.
1017         HLOG = 0x80
1018         ALL_LOG_DESTINATIONS = (
1019             LOG_DEBUG | LOG_INFO | LOG_WARNING | LOG_ERROR | LOG_CRITICAL
1020         )
1021         ALL_OUTPUT_DESTINATIONS = 0x8F
1022         # fmt: on
1023
1024     def __init__(
1025         self,
1026         destination_bitv: int,
1027         *,
1028         logger=None,
1029         filenames: Optional[Iterable[str]] = None,
1030         handles: Optional[Iterable[io.TextIOWrapper]] = None,
1031     ):
1032         """
1033         Constructs the OutputMultiplexer instance.
1034
1035         Args:
1036             destination_bitv: a bitvector where each bit represents an
1037                 output destination.  Multiple bits may be set.
1038             logger: if LOG_* bits are set, you must pass a logger here.
1039             filenames: if FILENAMES bit is set, this should be a list of
1040                 files you'd like to output into.  This code handles opening
1041                 and closing said files.
1042             handles: if FILEHANDLES bit is set, this should be a list of
1043                 already opened filehandles you'd like to output into.  The
1044                 handles will remain open after the scope of the multiplexer.
1045         """
1046         if logger is None:
1047             logger = logging.getLogger(None)
1048         self.logger = logger
1049
1050         self.f: Optional[List[Any]] = None
1051         if filenames is not None:
1052             self.f = [open(filename, "wb", buffering=0) for filename in filenames]
1053         else:
1054             if destination_bitv & OutputMultiplexer.Destination.FILENAMES:
1055                 raise ValueError("Filenames argument is required if bitv & FILENAMES")
1056             self.f = None
1057
1058         self.h: Optional[List[Any]] = None
1059         if handles is not None:
1060             self.h = list(handles)
1061         else:
1062             if destination_bitv & OutputMultiplexer.Destination.FILEHANDLES:
1063                 raise ValueError("Handle argument is required if bitv & FILEHANDLES")
1064             self.h = None
1065
1066         self.set_destination_bitv(destination_bitv)
1067
1068     def get_destination_bitv(self):
1069         """Where are we outputting?"""
1070         return self.destination_bitv
1071
1072     def set_destination_bitv(self, destination_bitv: int):
1073         """Change the output destination_bitv to the one provided."""
1074         if destination_bitv & self.Destination.FILENAMES and self.f is None:
1075             raise ValueError("Filename argument is required if bitv & FILENAMES")
1076         if destination_bitv & self.Destination.FILEHANDLES and self.h is None:
1077             raise ValueError("Handle argument is required if bitv & FILEHANDLES")
1078         self.destination_bitv = destination_bitv
1079
1080     def print(self, *args, **kwargs):
1081         """Produce some output to all sinks."""
1082         from pyutils.string_utils import _sprintf, strip_escape_sequences
1083
1084         end = kwargs.pop("end", None)
1085         if end is not None:
1086             if not isinstance(end, str):
1087                 raise TypeError("end must be None or a string")
1088         sep = kwargs.pop("sep", None)
1089         if sep is not None:
1090             if not isinstance(sep, str):
1091                 raise TypeError("sep must be None or a string")
1092         if kwargs:
1093             raise TypeError("invalid keyword arguments to print()")
1094         buf = _sprintf(*args, end="", sep=sep)
1095         if sep is None:
1096             sep = " "
1097         if end is None:
1098             end = "\n"
1099         if end == "\n":
1100             buf += "\n"
1101         if self.destination_bitv & self.Destination.FILENAMES and self.f is not None:
1102             for _ in self.f:
1103                 _.write(buf.encode("utf-8"))
1104                 _.flush()
1105
1106         if self.destination_bitv & self.Destination.FILEHANDLES and self.h is not None:
1107             for _ in self.h:
1108                 _.write(buf)
1109                 _.flush()
1110
1111         buf = strip_escape_sequences(buf)
1112         if self.logger is not None:
1113             if self.destination_bitv & self.Destination.LOG_DEBUG:
1114                 self.logger.debug(buf)
1115             if self.destination_bitv & self.Destination.LOG_INFO:
1116                 self.logger.info(buf)
1117             if self.destination_bitv & self.Destination.LOG_WARNING:
1118                 self.logger.warning(buf)
1119             if self.destination_bitv & self.Destination.LOG_ERROR:
1120                 self.logger.error(buf)
1121             if self.destination_bitv & self.Destination.LOG_CRITICAL:
1122                 self.logger.critical(buf)
1123         if self.destination_bitv & self.Destination.HLOG:
1124             hlog(buf)
1125
1126     def close(self):
1127         """Close all open files."""
1128         if self.f is not None:
1129             for _ in self.f:
1130                 _.close()
1131
1132
1133 class OutputMultiplexerContext(OutputMultiplexer, contextlib.ContextDecorator):
1134     """
1135     A context that uses an :class:`OutputMultiplexer`.  e.g.::
1136
1137         with OutputMultiplexerContext(
1138                 OutputMultiplexer.LOG_INFO |
1139                 OutputMultiplexer.LOG_DEBUG |
1140                 OutputMultiplexer.FILENAMES |
1141                 OutputMultiplexer.FILEHANDLES,
1142                 filenames = [ '/tmp/foo.log', '/var/log/bar.log' ],
1143                 handles = [ f, g ]
1144             ) as mplex:
1145                 mplex.print("This is a log message!")
1146     """
1147
1148     def __init__(
1149         self,
1150         destination_bitv: OutputMultiplexer.Destination,
1151         *,
1152         logger=None,
1153         filenames=None,
1154         handles=None,
1155     ):
1156         """
1157         Args:
1158             destination_bitv: a bitvector that indicates where we should
1159                 send output.  See :class:`OutputMultiplexer` for options.
1160             logger: optional logger to use for log destination messages.
1161             filenames: optional filenames to write for filename destination
1162                 messages.
1163             handles: optional open filehandles to write for filehandle
1164                 destination messages.
1165         """
1166         super().__init__(
1167             destination_bitv,
1168             logger=logger,
1169             filenames=filenames,
1170             handles=handles,
1171         )
1172
1173     def __enter__(self):
1174         return self
1175
1176     def __exit__(self, etype, value, traceback) -> bool:
1177         super().close()
1178         if etype is not None:
1179             return False
1180         return True
1181
1182
1183 def hlog(message: str) -> None:
1184     """Write a message to the house log (syslog facility local7 priority
1185     info) by calling `/usr/bin/logger`.  This is pretty hacky but used
1186     by a bunch of (my) code.  Another way to do this would be to use
1187     :code:`--logging_syslog` and :code:`--logging_syslog_facility` but
1188     I can't actually say that's easier.
1189
1190     TODO: this needs to move.
1191     """
1192     message = message.replace("'", "'\"'\"'")
1193     os.system(f"/usr/bin/logger -p local7.info -- '{message}'")
1194
1195
1196 if __name__ == "__main__":
1197     import doctest
1198
1199     doctest.testmod()