Document this logging stuff.
[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=int,
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         super().__init__()
377         if prefix:
378             self.prefix = prefix
379         else:
380             self.prefix = ""
381         self.klobber = klobber
382
383     @overrides
384     def filter(self, record: logging.LogRecord):
385         if self.klobber:
386             record.prefix = self.prefix
387         elif "prefix" not in record.__dict__:
388             record.prefix = self.prefix
389         return True
390
391
392 class SuffixAddingFilter(logging.Filter):
393     """A filter that adds a string suffix to the log record for the
394     formatter to later fill in.  Requires a %(suffix)s in the format
395     string.
396     """
397
398     def __init__(self, suffix: str, klobber: bool = False):
399         super().__init__()
400         if suffix:
401             self.suffix = suffix
402         else:
403             self.suffix = ""
404         self.klobber = klobber
405
406     @overrides
407     def filter(self, record: logging.LogRecord):
408         if self.klobber:
409             record.suffix = self.suffix
410         elif "suffix" not in record.__dict__:
411             record.suffix = self.suffix
412         return True
413
414
415 class PrependingLogAdapter(logging.LoggerAdapter):
416     """:class:`LoggingContext` adds prefixes and suffixes using a
417     logging.Filter that must insert "prefix" or "suffix" members into
418     each log record by using :class:`PrefixAddingFilter` and
419     :class:`SuffixAddingFilter`.  This relies on the logging format
420     string containing a %(prefix)s and a %(suffix)s to work correctly.
421
422     This is an alternate approach that basically just wraps the logger
423     in a class that has the same interface and thunks most calls down
424     to the wrapped logger.  It might be useful if you don't want to use
425     :class:`LoggingContext` or its friends.
426
427         >>> logger = logging.getLogger(__name__ + ".PrependingLogAdapter")
428         >>> logger.setLevel(logging.INFO)
429         >>> logger.addHandler(logging.StreamHandler(sys.stdout))
430
431     At this point logger doesn't have any format string and so it is
432     missing %(prefix)s and %(suffix)s.  It also doesn't have a
433     :class:`PrefixAddingFilter` or :class:`SuffixAddingFilter` added.
434     So using it in a :class:`LoggingContext` will not work.
435
436     But we can still add a prefix or suffix by just wrapping it:
437
438         >>> logger.info("TEST")
439         TEST
440
441         >>> log = PrependingLogAdapter.wrap_logger('prefix> ', logger)
442         >>> log.info("TEST")
443         prefix> TEST
444     """
445
446     def process(self, msg, kwargs):
447         return f'{self.extra.get("prefix", "")}{msg}', kwargs
448
449     @staticmethod
450     def wrap_logger(prefix: str, logger: logging.Logger) -> logging.LoggerAdapter:
451         """Helper method around the creation of a LogAdapter that prepends
452         a given string to every log message produced.
453
454         Args:
455             prefix: the message to prepend to every log message.
456             logger: the logger whose messages to modify.
457
458         Returns:
459             A new logger wrapping the old one with the given behavior.
460             The old logger will continue to behave as usual; simply drop
461             the reference to this wrapper when it's no longer needed.
462         """
463         assert prefix
464         return PrependingLogAdapter(logger, {"prefix": prefix})
465
466
467 class AppendingLogAdapter(logging.LoggerAdapter):
468     """:class:`LoggingContext` adds prefixes and suffixes using a
469     logging.Filter that must insert "prefix" or "suffix" members into
470     each log record by using :class:`PrefixAddingFilter` and
471     :class:`SuffixAddingFilter`.  This relies on the logging format
472     string containing a %(prefix)s and a %(suffix)s to work correctly.
473
474     This is an alternate approach that basically just wraps the logger
475     in a class that has the same interface and thunks most calls down
476     to the wrapped logger.  It might be useful if you don't want to use
477     :class:`LoggingContext` or its friends.
478
479         >>> logger = logging.getLogger(__name__ + ".AppendingLogAdapter")
480         >>> logger.setLevel(logging.INFO)
481         >>> logger.addHandler(logging.StreamHandler(sys.stdout))
482
483     At this point logger doesn't have any format string and so it is
484     missing %(prefix)s and %(suffix)s.  It also doesn't have a
485     :class:`PrefixAddingFilter` or :class:`SuffixAddingFilter` added.
486     So using it in a :class:`LoggingContext` will not work.
487
488     But we can still add a prefix or suffix by just wrapping it:
489
490         >>> logger.info("TEST")
491         TEST
492
493         >>> log = AppendingLogAdapter.wrap_logger('!!!', logger)
494         >>> log.info("TEST")
495         TEST!!!
496     """
497
498     def process(self, msg, kwargs):
499         return f'{msg}{self.extra.get("suffix", "")}', kwargs
500
501     @staticmethod
502     def wrap_logger(suffix: str, logger: logging.Logger) -> logging.LoggerAdapter:
503         """Helper method around the creation of a LoggerAdapter that appends
504         a given string to every log message produced.
505
506         Args:
507             suffix: the message to prepend to every log message.
508             logger: the logger whose messages to modify.
509
510         Returns:
511             A new logger wrapping the old one with the given behavior.
512             The old logger will continue to behave as usual; simply drop
513             the reference to this wrapper when it's no longer needed.
514         """
515         assert suffix
516         return AppendingLogAdapter(logger, {"suffix": suffix})
517
518
519 class LoggingContext(contextlib.ContextDecorator):
520     def __init__(
521         self,
522         logger: logging.Logger,
523         *,
524         level: Optional[int] = None,
525         handlers: Optional[List[logging.Handler]] = None,
526         prefix: Optional[str] = None,
527         suffix: Optional[str] = None,
528     ):
529         """This is a logging context that can be used to temporarily change the
530         way we are logging within its scope.  Logging changes may include:
531
532             - Changing the logging level (e.g. from INFO to DEBUG)
533             - Adding a prefix or suffix to every log message produced
534             - Adding temporary Handlers to direct the logging output elsewhere
535
536         Setup for doctest / examples.  This will normally be taken care of
537         by code in :meth:`initialize_logging` so you don't have to worry
538         about it.
539
540             >>> logging_format = "%(prefix)s%(message)s%(suffix)s"
541             >>> logger = logging.getLogger(__name__ + ".LoggingContext")
542             >>> logger.setLevel(logging.INFO)
543             >>> handler = logging.StreamHandler(sys.stdout)
544             >>> handler.setFormatter(
545             ...     MillisecondAwareFormatter(
546             ...         fmt=logging_format,
547             ...         datefmt='',
548             ...     )
549             ... )
550             >>> logger.addHandler(handler)
551             >>> logger.addFilter(PrefixAddingFilter(None))
552             >>> logger.addFilter(SuffixAddingFilter(None))
553
554         First, this logger should be currently be configured to send
555         INFO+ messages to sys.stdout.  Let's see it in action:
556
557             >>> logger.info("Hello world!")
558             Hello world!
559             >>> logger.debug("You should not see this")
560
561         The first example is to simply change the level of the logger.
562         Here we temporarily change it to DEBUG within the body of the
563         :class:`LoggingContext`:
564
565             >>> with LoggingContext(logger, level=logging.DEBUG):
566             ...     logger.debug("You should see this now")
567             ...     logger.info("Of course you should still see this too")
568             You should see this now
569             Of course you should still see this too
570
571             >>> logger.debug("Outside of the context we are at INFO again")
572             >>> logger.debug("(which is why you don't see these)")
573             >>> logger.info("But you should see this at INFO level")
574             But you should see this at INFO level
575
576         The prefix and suffix argument prepend or append a message to
577         all log output.  To do this, you need %(prefix)s and
578         %(suffix)s placeholders in your logger format string
579         indicating where to insert the data.  This is useful, for
580         example, to add an active request identifier to the set of log
581         messages produced while processing it.
582
583             >>> logger.info("About to work on a new request")
584             About to work on a new request
585
586             >>> with LoggingContext(logger, prefix='10.0.0.13> '):
587             ...     logger.info("Working on it now")
588             10.0.0.13> Working on it now
589
590             logger.info("Done with that request")
591             Done with that request
592
593         LoggingContext can also be used to add temporary handler(s).
594         This code temporarily uses two stdout handlers to double the
595         output for testing purporses but you could also temporarily,
596         e.g., add a RotatingFileHandler or SysLogHandler etc...
597
598             >>> with LoggingContext(logger, handlers=[logging.StreamHandler(sys.stdout)]):
599             ...     logger.info("TEST")
600             TEST
601             TEST
602
603         Once leaving the context, logger's behavior is restored.  In
604         this case, the extra handler is removed so output will not
605         longer be doubled.
606
607             >>> logger.info("OUTSIDE")
608             OUTSIDE
609
610         LoggingContext can also be used as a decorator if that is more
611         convenient:
612
613             >>> @LoggingContext(logger, level=logging.DEBUG)
614             ... def log_stuff(logger):
615             ...     logger.debug("But inside, the decorator has changed us to DEBUG")
616
617             >>> logger.debug("Outside, we're at INFO level and you don't see this")
618             >>> log_stuff(logger)
619             But inside, the decorator has changed us to DEBUG
620             >>> logger.debug("And, of course, out here we're still at INFO afterwards")
621
622         """
623         self.logger = logger
624         self.level = level
625         self.old_level: Optional[int] = None
626         self.handlers = handlers
627         if prefix:
628             self.prefix: Optional[logging.Filter] = PrefixAddingFilter(prefix, True)
629         else:
630             self.prefix = None
631         if suffix:
632             self.suffix: Optional[logging.Filter] = SuffixAddingFilter(suffix, True)
633         else:
634             self.suffix = None
635
636     def __enter__(self) -> Union[logging.Logger, logging.LoggerAdapter]:
637         assert self.logger
638         if self.level:
639             self.old_level = self.logger.level
640             self.logger.setLevel(self.level)
641
642         if self.handlers:
643             for handler in self.handlers:
644                 self.logger.addHandler(handler)
645
646         if self.prefix:
647             self.logger.addFilter(self.prefix)
648
649         if self.suffix:
650             self.logger.addFilter(self.suffix)
651         return self.logger
652
653     def __exit__(self, et, ev, tb) -> None:
654         if self.suffix:
655             self.logger.removeFilter(self.suffix)
656
657         if self.prefix:
658             self.logger.removeFilter(self.prefix)
659
660         if self.handlers:
661             for handler in self.handlers:
662                 self.logger.removeHandler(handler)
663                 handler.close()
664
665         if self.old_level:
666             self.logger.setLevel(self.old_level)
667         return None  # propagate exceptions out
668
669
670 class MillisecondAwareFormatter(logging.Formatter):
671     """A formatter for adding milliseconds to log messages which, for
672     whatever reason, the default Python logger doesn't do.
673
674     .. note::
675
676         You probably don't need to use this directly but it is
677         wired in under :meth:`initialize_logging` so that the
678         timestamps in log messages have millisecond level
679         precision.
680     """
681
682     converter = datetime.datetime.fromtimestamp  # type: ignore
683
684     @overrides
685     def formatTime(self, record, datefmt=None):
686         ct = MillisecondAwareFormatter.converter(
687             record.created, pytz.timezone("US/Pacific")
688         )
689         if datefmt:
690             s = ct.strftime(datefmt)
691         else:
692             t = ct.strftime("%Y-%m-%d %H:%M:%S")
693             s = f"{t},{record.msecs:%03d}"
694         return s
695
696
697 def _log_about_logging(
698     logger,
699     default_logging_level,
700     preexisting_handlers_count,
701     fmt,
702     facility_name,
703 ):
704     """This is invoked automatically after logging is initialized such
705     that the first messages in the log are about how logging itself
706     was configured.
707     """
708     level_name = logging._levelToName.get(
709         default_logging_level, str(default_logging_level)
710     )
711     logger.debug("Initialized global logging; logging level is %s.", level_name)
712     if (
713         config.config["logging_clear_preexisting_handlers"]
714         and preexisting_handlers_count > 0
715     ):
716         logger.debug(
717             "Logging cleared %d global handlers (--logging_clear_preexisting_handlers)",
718             preexisting_handlers_count,
719         )
720     logger.debug('Logging format specification is "%s"', fmt)
721     if config.config["logging_debug_threads"]:
722         logger.debug(
723             "...Logging format spec captures tid/pid. (--logging_debug_threads)"
724         )
725     if config.config["logging_debug_modules"]:
726         logger.debug(
727             "...Logging format spec captures files/functions/lineno. (--logging_debug_modules)"
728         )
729     if config.config["logging_syslog"]:
730         logger.debug(
731             "Logging to syslog as %s with priority mapping based on level. (--logging_syslog)",
732             facility_name,
733         )
734     if config.config["logging_filename"]:
735         logger.debug(
736             'Logging to file "%s". (--logging_filename)',
737             config.config["logging_filename"],
738         )
739         logger.debug(
740             "...with %d bytes max file size. (--logging_filename_maxsize)",
741             config.config["logging_filename_maxsize"],
742         )
743         logger.debug(
744             "...and %d rotating backup file count. (--logging_filename_count)",
745             config.config["logging_filename_count"],
746         )
747     if config.config["logging_console"]:
748         logger.debug("Logging to the console (stderr). (--logging_console)")
749     if config.config["logging_info_is_print"]:
750         logger.debug(
751             "Logging logger.info messages will be repeated on stdout. (--logging_info_is_print)"
752         )
753     if config.config["logging_squelch_repeats"]:
754         logger.debug(
755             "Logging code allowed to request repeated messages be squelched. (--logging_squelch_repeats)"
756         )
757     else:
758         logger.debug(
759             "Logging code forbidden to request messages be squelched; all messages logged. (--no_logging_squelch_repeats)"
760         )
761     if config.config["logging_probabilistically"]:
762         logger.debug(
763             "Logging code is allowed to request probabilistic logging. (--logging_probabilistically)"
764         )
765     else:
766         logger.debug(
767             "Logging code is forbidden to request probabilistic logging; messages always logged. (--no_logging_probabilistically)"
768         )
769     if config.config["logging_captures_prints"]:
770         logger.debug(
771             "Logging will capture printed data as logger.info messages. (--logging_captures_prints)"
772         )
773
774
775 def _construct_logging_format() -> str:
776     """Figure out the root logging format string based on commandling args."""
777
778     # Custom or default --logging_format?
779     if config.config["logging_format"]:
780         fmt = config.config["logging_format"]
781     elif config.config["logging_syslog"]:
782         fmt = "%(levelname).1s:%(filename)s[%(process)d]: "
783     else:
784         fmt = "%(levelname).1s:%(asctime)s: "
785
786     fmt += "%(prefix)s%(message)s%(suffix)s"
787
788     # --logging_debug_threads and --logging_debug_modules both affect
789     # the format by prepending information about the pid/tid or
790     # file/function.
791     if config.config["logging_debug_threads"]:
792         fmt = f"%(process)d.%(thread)d|{fmt}"
793     if config.config["logging_debug_modules"]:
794         fmt = f"%(filename)s:%(funcName)s:%(lineno)s|{fmt}"
795     return fmt
796
797
798 def _construct_handlers_list(
799     logging_format: str, facility_name: Optional[str]
800 ) -> List[logging.Handler]:
801     """Create the list of handlers to be added to the root logger
802     based on commandline flags.
803     """
804
805     retval: List[logging.Handler] = []
806
807     # --logging_syslog (optionally with --logging_syslog_facility)
808     # sets up for logging to use the standard system syslogd as a
809     # sink.
810     if config.config["logging_syslog"]:
811         if sys.platform not in ("win32", "cygwin"):
812             assert facility_name
813             facility = SysLogHandler.__dict__.get(facility_name, SysLogHandler.LOG_USER)
814             assert facility
815             syslog_handler = SysLogHandler(facility=facility, address="/dev/log")
816             syslog_handler.setFormatter(
817                 MillisecondAwareFormatter(
818                     fmt=logging_format,
819                     datefmt=config.config["logging_date_format"],
820                 )
821             )
822             retval.append(syslog_handler)
823
824     # --logging_filename (with friends --logging_filename_count and
825     # --logging_filename_maxsize) set up logging to a file on the
826     # filesystem with automatic rotation when it gets too big.
827     if config.config["logging_filename"]:
828         max_bytes = config.config["logging_filename_maxsize"]
829         assert max_bytes and isinstance(max_bytes, int)
830         backup_count = config.config["logging_filename_count"]
831         assert backup_count and isinstance(backup_count, int)
832         file_handler = RotatingFileHandler(
833             config.config["logging_filename"],
834             maxBytes=max_bytes,
835             backupCount=backup_count,
836         )
837         file_handler.setFormatter(
838             MillisecondAwareFormatter(
839                 fmt=logging_format,
840                 datefmt=config.config["logging_date_format"],
841             )
842         )
843         retval.append(file_handler)
844
845     # --logging_console is, ahem, logging to the console.
846     if config.config["logging_console"]:
847         console_handler = logging.StreamHandler(sys.stderr)
848         console_handler.setFormatter(
849             MillisecondAwareFormatter(
850                 fmt=logging_format,
851                 datefmt=config.config["logging_date_format"],
852             )
853         )
854         retval.append(console_handler)
855
856     # --logging_info_is_print echoes any message to logger.info(x) as
857     # a print statement on stdout.
858     if config.config["logging_info_is_print"]:
859         print_handler = logging.StreamHandler(sys.stdout)
860         print_handler.addFilter(OnlyInfoFilter())
861         retval.append(print_handler)
862
863     if len(retval) == 0:
864         retval.append(logging.NullHandler())
865     return retval
866
867
868 def _add_logging_handlers_and_filters(
869     logger: logging.Logger, handlers: List[logging.Handler]
870 ) -> None:
871     """Adds a list of handlers to the root logger."""
872
873     for handler in handlers:
874         # These two are always present and set %(prefix) and %(suffix)
875         # if appropriate (see :class:`LoggingContext`) or make them
876         # empty string.
877         handler.addFilter(PrefixAddingFilter("", False))
878         handler.addFilter(SuffixAddingFilter("", False))
879
880         # --logging_squelch_repeats allows code to request repeat logging
881         # messages (identical log site and message contents) to be
882         # silenced.  Logging code must request this explicitly, it isn't
883         # automatic.  This option just allows the silencing to happen.
884         if config.config["logging_squelch_repeats"]:
885             handler.addFilter(SquelchRepeatedMessagesFilter())
886
887         # --logging_probabilistically allows code to request
888         # non-deterministic logging where messages have some probability
889         # of being produced.  Logging code must request this explicitly.
890         # This option just allows the non-deterministic behavior to
891         # happen.  Disabling it will cause every log message to be
892         # produced.
893         if config.config["logging_probabilistically"]:
894             handler.addFilter(ProbabilisticFilter())
895         logger.addHandler(handler)
896
897
898 def initialize_logging(logger=None) -> logging.Logger:
899     """Initialize logging for the program.  See module level comments
900     for information about what functionality this provides and how to
901     enable or disable functionality via the commandline.
902
903     If you use the
904     :meth:`bootstrap.initialize` decorator on your program's entry point,
905     it will call this for you.  See :meth:`pyutils.bootstrap.initialize`
906     for more details.
907     """
908
909     global LOGGING_INITIALIZED
910     if LOGGING_INITIALIZED:
911         return logging.getLogger()
912     LOGGING_INITIALIZED = True
913
914     preexisting_handlers_count = 0
915     if logger is None:
916         # --logging_clear_preexisting_handlers removes logging handlers
917         # that were registered by global statements during imported module
918         # setup.
919         if config.config["logging_clear_preexisting_handlers"]:
920             logging.basicConfig(force=True)
921             logger = logging.getLogger()
922             while logger.hasHandlers():
923                 logger.removeHandler(logger.handlers[0])
924                 preexisting_handlers_count += 1
925         else:
926             logging.basicConfig()
927             logger = logging.getLogger()
928
929     # --logging_config_file pulls logging settings from a config file
930     # skipping the rest of this setup.
931     if config.config["logging_config_file"] is not None:
932         fileConfig(config.config["logging_config_file"])
933         return logger
934
935     # Global default logging level (--logging_level); messages below
936     # this level will be silenced.
937     logging_level = config.config["logging_level"]
938     assert logging_level
939     logging_level = logging_level.upper()
940     default_logging_level = getattr(logging, logging_level, None)
941     if not isinstance(default_logging_level, int):
942         raise ValueError(f'Invalid level: {config.config["logging_level"]}')
943     logger.setLevel(default_logging_level)
944
945     logging_format = _construct_logging_format()
946     facility_name = None
947     if config.config["logging_syslog"]:
948         if sys.platform not in ("win32", "cygwin"):
949             if config.config["logging_syslog_facility"]:
950                 facility_name = "LOG_" + config.config["logging_syslog_facility"]
951     handlers: List[logging.Handler] = _construct_handlers_list(
952         logging_format, facility_name
953     )
954     _add_logging_handlers_and_filters(logger, handlers)
955
956     # --logging_captures_prints, if set, will capture and log.info
957     # anything printed on stdout.
958     if config.config["logging_captures_prints"]:
959         import builtins
960
961         def print_and_also_log(*arg, **kwarg):
962             f = kwarg.get("file", None)
963             if f == sys.stderr:
964                 logger.warning(*arg)
965             else:
966                 logger.info(*arg)
967             BUILT_IN_PRINT(*arg, **kwarg)
968
969         builtins.print = print_and_also_log
970
971     # At this point the logger is ready, handlers are set up,
972     # etc... so log about the logging configuration.
973     _log_about_logging(
974         logger,
975         default_logging_level,
976         preexisting_handlers_count,
977         logging_format,
978         facility_name,
979     )
980     return logger
981
982
983 def get_logger(name: str = ""):
984     """Get the global logger"""
985     logger = logging.getLogger(name)
986     return initialize_logging(logger)
987
988
989 def tprint(*args, **kwargs) -> None:
990     """Legacy function for printing a message augmented with thread id
991     still needed by some code.  Please use --logging_debug_threads in
992     new code.
993     """
994     if config.config["logging_debug_threads"]:
995         from pyutils.parallelize.thread_utils import current_thread_id
996
997         print(f"{current_thread_id()}", end="")
998         print(*args, **kwargs)
999     else:
1000         pass
1001
1002
1003 class OutputMultiplexer(object):
1004     """A class that broadcasts printed messages to several sinks
1005     (including various logging levels, different files, different file
1006     handles, the house log, etc...).  See also
1007     :class:`OutputMultiplexerContext` for an easy usage pattern.
1008     """
1009
1010     class Destination(enum.IntEnum):
1011         """Bits in the destination_bitv bitvector.  Used to indicate the
1012         output destination."""
1013
1014         # fmt: off
1015         LOG_DEBUG = 0x01     #  ⎫
1016         LOG_INFO = 0x02      #  ⎪
1017         LOG_WARNING = 0x04   #  ⎬ Must provide logger to the c'tor.
1018         LOG_ERROR = 0x08     #  ⎪
1019         LOG_CRITICAL = 0x10  #  ⎭
1020         FILENAMES = 0x20     # Must provide a filename to the c'tor.
1021         FILEHANDLES = 0x40   # Must provide a handle to the c'tor.
1022         HLOG = 0x80
1023         ALL_LOG_DESTINATIONS = (
1024             LOG_DEBUG | LOG_INFO | LOG_WARNING | LOG_ERROR | LOG_CRITICAL
1025         )
1026         ALL_OUTPUT_DESTINATIONS = 0x8F
1027         # fmt: on
1028
1029     def __init__(
1030         self,
1031         destination_bitv: int,
1032         *,
1033         logger=None,
1034         filenames: Optional[Iterable[str]] = None,
1035         handles: Optional[Iterable[io.TextIOWrapper]] = None,
1036     ):
1037         """
1038         Constructs the OutputMultiplexer instance.
1039
1040         Args:
1041             destination_bitv: a bitvector where each bit represents an
1042                 output destination.  Multiple bits may be set.
1043             logger: if LOG_* bits are set, you must pass a logger here.
1044             filenames: if FILENAMES bit is set, this should be a list of
1045                 files you'd like to output into.  This code handles opening
1046                 and closing said files.
1047             handles: if FILEHANDLES bit is set, this should be a list of
1048                 already opened filehandles you'd like to output into.  The
1049                 handles will remain open after the scope of the multiplexer.
1050         """
1051         if logger is None:
1052             logger = logging.getLogger(None)
1053         self.logger = logger
1054
1055         self.f: Optional[List[Any]] = None
1056         if filenames is not None:
1057             self.f = [open(filename, "wb", buffering=0) for filename in filenames]
1058         else:
1059             if destination_bitv & OutputMultiplexer.Destination.FILENAMES:
1060                 raise ValueError("Filenames argument is required if bitv & FILENAMES")
1061             self.f = None
1062
1063         self.h: Optional[List[Any]] = None
1064         if handles is not None:
1065             self.h = list(handles)
1066         else:
1067             if destination_bitv & OutputMultiplexer.Destination.FILEHANDLES:
1068                 raise ValueError("Handle argument is required if bitv & FILEHANDLES")
1069             self.h = None
1070
1071         self.set_destination_bitv(destination_bitv)
1072
1073     def get_destination_bitv(self):
1074         """Where are we outputting?"""
1075         return self.destination_bitv
1076
1077     def set_destination_bitv(self, destination_bitv: int):
1078         """Change the output destination_bitv to the one provided."""
1079         if destination_bitv & self.Destination.FILENAMES and self.f is None:
1080             raise ValueError("Filename argument is required if bitv & FILENAMES")
1081         if destination_bitv & self.Destination.FILEHANDLES and self.h is None:
1082             raise ValueError("Handle argument is required if bitv & FILEHANDLES")
1083         self.destination_bitv = destination_bitv
1084
1085     def print(self, *args, **kwargs):
1086         """Produce some output to all sinks."""
1087         from pyutils.string_utils import _sprintf, strip_escape_sequences
1088
1089         end = kwargs.pop("end", None)
1090         if end is not None:
1091             if not isinstance(end, str):
1092                 raise TypeError("end must be None or a string")
1093         sep = kwargs.pop("sep", None)
1094         if sep is not None:
1095             if not isinstance(sep, str):
1096                 raise TypeError("sep must be None or a string")
1097         if kwargs:
1098             raise TypeError("invalid keyword arguments to print()")
1099         buf = _sprintf(*args, end="", sep=sep)
1100         if sep is None:
1101             sep = " "
1102         if end is None:
1103             end = "\n"
1104         if end == "\n":
1105             buf += "\n"
1106         if self.destination_bitv & self.Destination.FILENAMES and self.f is not None:
1107             for _ in self.f:
1108                 _.write(buf.encode("utf-8"))
1109                 _.flush()
1110
1111         if self.destination_bitv & self.Destination.FILEHANDLES and self.h is not None:
1112             for _ in self.h:
1113                 _.write(buf)
1114                 _.flush()
1115
1116         buf = strip_escape_sequences(buf)
1117         if self.logger is not None:
1118             if self.destination_bitv & self.Destination.LOG_DEBUG:
1119                 self.logger.debug(buf)
1120             if self.destination_bitv & self.Destination.LOG_INFO:
1121                 self.logger.info(buf)
1122             if self.destination_bitv & self.Destination.LOG_WARNING:
1123                 self.logger.warning(buf)
1124             if self.destination_bitv & self.Destination.LOG_ERROR:
1125                 self.logger.error(buf)
1126             if self.destination_bitv & self.Destination.LOG_CRITICAL:
1127                 self.logger.critical(buf)
1128         if self.destination_bitv & self.Destination.HLOG:
1129             hlog(buf)
1130
1131     def close(self):
1132         """Close all open files."""
1133         if self.f is not None:
1134             for _ in self.f:
1135                 _.close()
1136
1137
1138 class OutputMultiplexerContext(OutputMultiplexer, contextlib.ContextDecorator):
1139     """
1140     A context that uses an :class:`OutputMultiplexer`.  e.g.::
1141
1142         with OutputMultiplexerContext(
1143                 OutputMultiplexer.LOG_INFO |
1144                 OutputMultiplexer.LOG_DEBUG |
1145                 OutputMultiplexer.FILENAMES |
1146                 OutputMultiplexer.FILEHANDLES,
1147                 filenames = [ '/tmp/foo.log', '/var/log/bar.log' ],
1148                 handles = [ f, g ]
1149             ) as mplex:
1150                 mplex.print("This is a log message!")
1151     """
1152
1153     def __init__(
1154         self,
1155         destination_bitv: OutputMultiplexer.Destination,
1156         *,
1157         logger=None,
1158         filenames=None,
1159         handles=None,
1160     ):
1161         """
1162         Args:
1163             destination_bitv: a bitvector that indicates where we should
1164                 send output.  See :class:`OutputMultiplexer` for options.
1165             logger: optional logger to use for log destination messages.
1166             filenames: optional filenames to write for filename destination
1167                 messages.
1168             handles: optional open filehandles to write for filehandle
1169                 destination messages.
1170         """
1171         super().__init__(
1172             destination_bitv,
1173             logger=logger,
1174             filenames=filenames,
1175             handles=handles,
1176         )
1177
1178     def __enter__(self):
1179         return self
1180
1181     def __exit__(self, etype, value, traceback) -> bool:
1182         super().close()
1183         if etype is not None:
1184             return False
1185         return True
1186
1187
1188 def hlog(message: str) -> None:
1189     """Write a message to the house log (syslog facility local7 priority
1190     info) by calling `/usr/bin/logger`.  This is pretty hacky but used
1191     by a bunch of (my) code.  Another way to do this would be to use
1192     :code:`--logging_syslog` and :code:`--logging_syslog_facility` but
1193     I can't actually say that's easier.
1194
1195     TODO: this needs to move.
1196     """
1197     message = message.replace("'", "'\"'\"'")
1198     os.system(f"/usr/bin/logger -p local7.info -- '{message}'")
1199
1200
1201 if __name__ == "__main__":
1202     import doctest
1203
1204     doctest.testmod()