Fix squelch repeated log messages to work with modules loaded via
[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 re
55 import sys
56 from logging.config import fileConfig
57 from logging.handlers import RotatingFileHandler, SysLogHandler
58 from typing import Any, Callable, Dict, Iterable, List, Optional, Union
59
60 import pytz
61 from overrides import overrides
62
63 # This module is commonly used by others in here and should avoid
64 # taking any unnecessary dependencies back on them.
65 from pyutils import argparse_utils, config, misc_utils
66
67 cfg = config.add_commandline_args(f"Logging ({__file__})", "Args related to logging")
68 cfg.add_argument(
69     "--logging_config_file",
70     type=argparse_utils.valid_filename,
71     default=None,
72     metavar="FILENAME",
73     help="Config file containing the logging setup, see: https://docs.python.org/3/howto/logging.html#logging-advanced-tutorial",
74 )
75 cfg.add_argument(
76     "--logging_level",
77     type=str,
78     default="INFO",
79     choices=["NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
80     metavar="LEVEL",
81     help="The global default level below which to squelch log messages",
82 )
83 cfg.add_argument(
84     "--logging_format",
85     type=str,
86     default=None,
87     help="The format for lines logged via the logger module.  See: https://docs.python.org/3/library/logging.html#formatter-objects",
88 )
89 cfg.add_argument(
90     "--logging_date_format",
91     type=str,
92     default="%Y/%m/%dT%H:%M:%S.%f%z",
93     metavar="DATEFMT",
94     help="The format of any dates in --logging_format.",
95 )
96 cfg.add_argument(
97     "--logging_console",
98     action=argparse_utils.ActionNoYes,
99     default=True,
100     help="Should we log to the console (stderr)",
101 )
102 cfg.add_argument(
103     "--logging_filename",
104     type=str,
105     default=None,
106     metavar="FILENAME",
107     help="The filename of the logfile to write.",
108 )
109 cfg.add_argument(
110     "--logging_filename_maxsize",
111     type=argparse_utils.valid_byte_count,
112     default=(1024 * 1024),
113     metavar="#BYTES",
114     help="The maximum size (in bytes) to write to the logging_filename.",
115 )
116 cfg.add_argument(
117     "--logging_filename_count",
118     type=int,
119     default=7,
120     metavar="COUNT",
121     help="The number of logging_filename copies to keep before deleting.",
122 )
123 cfg.add_argument(
124     "--logging_syslog",
125     action=argparse_utils.ActionNoYes,
126     default=False,
127     help="Should we log to localhost's syslog.",
128 )
129 cfg.add_argument(
130     "--logging_syslog_facility",
131     type=str,
132     default="USER",
133     choices=[
134         "NOTSET",
135         "AUTH",
136         "AUTH_PRIV",
137         "CRON",
138         "DAEMON",
139         "FTP",
140         "KERN",
141         "LPR",
142         "MAIL",
143         "NEWS",
144         "SYSLOG",
145         "USER",
146         "UUCP",
147         "LOCAL0",
148         "LOCAL1",
149         "LOCAL2",
150         "LOCAL3",
151         "LOCAL4",
152         "LOCAL5",
153         "LOCAL6",
154         "LOCAL7",
155     ],
156     metavar="SYSLOG_FACILITY_LIST",
157     help="The default syslog message facility identifier",
158 )
159 cfg.add_argument(
160     "--logging_debug_threads",
161     action=argparse_utils.ActionNoYes,
162     default=False,
163     help="Should we prepend pid/tid data to all log messages?",
164 )
165 cfg.add_argument(
166     "--logging_debug_modules",
167     action=argparse_utils.ActionNoYes,
168     default=False,
169     help="Should we prepend module/function data to all log messages?",
170 )
171 cfg.add_argument(
172     "--logging_info_is_print",
173     action=argparse_utils.ActionNoYes,
174     default=False,
175     help="logging.info also prints to stdout.",
176 )
177 cfg.add_argument(
178     "--logging_squelch_repeats",
179     action=argparse_utils.ActionNoYes,
180     default=True,
181     help="Do we allow code to indicate that it wants to squelch repeated logging messages or should we always log?",
182 )
183 cfg.add_argument(
184     "--logging_probabilistically",
185     action=argparse_utils.ActionNoYes,
186     default=True,
187     help="Do we allow probabilistic logging (for code that wants it) or should we always log?",
188 )
189 # See also: OutputMultiplexer
190 cfg.add_argument(
191     "--logging_captures_prints",
192     action=argparse_utils.ActionNoYes,
193     default=False,
194     help="When calling print, also log.info automatically.",
195 )
196 cfg.add_argument(
197     "--logging_clear_preexisting_handlers",
198     action=argparse_utils.ActionNoYes,
199     default=True,
200     help=(
201         "Should logging code clear preexisting global logging handlers and thus insist that is "
202         + "alone can add handlers.  Use this to work around annoying modules that insert global "
203         + "handlers with formats and logging levels you might now want.  Caveat emptor, this may "
204         + "cause you to miss logging messages."
205     ),
206 )
207
208 BUILT_IN_PRINT = print
209 LOGGING_INITIALIZED = False
210
211
212 # A map from logging_callsite_id -> count of logged messages.
213 squelched_logging_counts: Dict[str, int] = {}
214
215
216 def squelch_repeated_log_messages(squelch_after_n_repeats: int) -> Callable:
217     """
218     A decorator that marks a function as interested in having the logging
219     messages that it produces be squelched (ignored) after it logs the
220     same message more than N times.
221
222     .. note::
223
224         This decorator affects *ALL* logging messages produced
225         within the decorated function.  That said, messages must be
226         identical in order to be squelched.  For example, if the same line
227         of code produces different messages (because of, e.g., a format
228         string), the messages are considered to be different.
229
230     An example of this from the pyutils code itself can be found in
231     :meth:`pyutils.ansi.fg` and :meth:`pyutils.ansi.bg` methods::
232
233         @logging_utils.squelch_repeated_log_messages(1)
234         def fg(
235             name: Optional[str] = "",
236             red: Optional[int] = None,
237             green: Optional[int] = None,
238             blue: Optional[int] = None,
239             *,
240             force_16color: bool = False,
241             force_216color: bool = False,
242         ) -> str:
243             ...
244
245     These methods log stuff like "Using 24-bit color strategy" which
246     gets old really fast and fills up the logs.  By decorating the methods
247     with :code:`@logging_utils.squelch_repeated_log_messages(1)` the code
248     is requesting that its logged messages be dropped silently after the
249     first one is produced (note the argument 1).
250
251     Users can insist that all logged messages always be reflected in the
252     logs using the :code:`--no_logging_squelch_repeats` flag but the default
253     behavior is to allow code to request it be squelched.
254
255     :code:`--logging_squelch_repeats` only affects code with this decorator
256     on it; it ignores all other code.
257
258     Args:
259         squelch_after_n_repeats: the number of repeated messages allowed to
260             log before subsequent messages are silently dropped.
261     """
262
263     def squelch_logging_wrapper(f: Callable):
264         from pyutils import function_utils
265
266         identifier = function_utils.function_identifier(f)
267
268         # Get rid of module paths, e.g. pyutils.ansi:bg -> ansi:bg which
269         # is what we're going to need below.
270         identifier = re.sub(r"[^\.]+\.", "", identifier)
271         squelched_logging_counts[identifier] = squelch_after_n_repeats
272         return f
273
274     return squelch_logging_wrapper
275
276
277 class SquelchRepeatedMessagesFilter(logging.Filter):
278     """A filter that only logs messages from a given site with the same
279     (exact) message at the same logging level N times and ignores
280     subsequent attempts to log.
281
282     This filter only affects logging messages that repeat more than a
283     threshold number of times from functions that are tagged with the
284     :code:`@logging_utils.squelched_logging_ok` decorator (see above);
285     all others are ignored.
286
287     This functionality is enabled by default but can be disabled via
288     the :code:`--no_logging_squelch_repeats` commandline flag.
289     """
290
291     def __init__(self) -> None:
292         super().__init__()
293         self.counters: collections.Counter = collections.Counter()
294
295     @overrides
296     def filter(self, record: logging.LogRecord) -> bool:
297         """Should we drop this log message?"""
298         id1 = f"{record.module}:{record.funcName}"
299         if id1 not in squelched_logging_counts:
300             return True
301         threshold = squelched_logging_counts[id1]
302         logsite = f"{record.pathname}+{record.lineno}+{record.levelno}+{record.msg}"
303         count = self.counters[logsite]
304         self.counters[logsite] += 1
305         return count < threshold
306
307
308 # A map from function_identifier -> probability of logging (0.0%..100.0%)
309 probabilistic_logging_levels: Dict[str, float] = {}
310
311
312 def logging_is_probabilistic(probability_of_logging: float) -> Callable:
313     """A decorator that indicates that all logging statements within the
314     scope of a particular (marked via decorator) function are not
315     deterministic (i.e. they do not always unconditionally log) but rather
316     are probabilistic (i.e. they log N% of the time, randomly) when the
317     user passes the :code:`--logging_probabilistically` commandline flag
318     (which is enabled by default).
319
320     .. note::
321
322         This affects *ALL* logging statements within the marked function.
323         If you want it to only affect a subset of logging statements,
324         log those statements in a separate function that you invoke
325         from within the "too large" scope and mark that separate function
326         with the :code:`logging_is_probabilistic` decorator instead.
327
328     That this functionality can be disabled (forcing all logged
329     messages to produce output) via the
330     :code:`--no_logging_probabilistically` cmdline argument.
331     """
332
333     def probabilistic_logging_wrapper(f: Callable):
334         from pyutils import function_utils
335
336         identifier = function_utils.function_identifier(f)
337         probabilistic_logging_levels[identifier] = probability_of_logging
338         return f
339
340     return probabilistic_logging_wrapper
341
342
343 class ProbabilisticFilter(logging.Filter):
344     """
345     A filter that logs messages probabilistically (i.e. randomly at some
346     percent chance).  This filter is used with a decorator (see
347     :meth:`logging_is_probabilistic`) to implement the
348     :code:`--logging_probabilistically` commandline flag.
349
350     This filter only affects logging messages from functions that have
351     been tagged with the `@logging_utils.probabilistic_logging` decorator.
352     """
353
354     @overrides
355     def filter(self, record: logging.LogRecord) -> bool:
356         """Should the message be logged?"""
357         identifier = f"{record.module}:{record.funcName}"
358         threshold = probabilistic_logging_levels.get(identifier, 100.0)
359         return misc_utils.execute_probabilistically(threshold)
360
361
362 class OnlyInfoFilter(logging.Filter):
363     """A filter that only logs messages produced at the INFO logging
364     level.  This is used by the :code:`--logging_info_is_print`
365     commandline option to select a subset of the logging stream to
366     send to a stdout handler.
367     """
368
369     @overrides
370     def filter(self, record: logging.LogRecord):
371         return record.levelno == logging.INFO
372
373
374 class PrefixAddingFilter(logging.Filter):
375     """A filter that adds a string prefix to the log record for the
376     formatter to later fill in.  Requires a %(prefix)s in the format
377     string.
378     """
379
380     def __init__(self, prefix: str, klobber: bool = False):
381         """
382         Args:
383             prefix: the prefix string to add
384             klobber: should we overwrite other prefixes?
385         """
386         super().__init__()
387         if prefix:
388             self.prefix = prefix
389         else:
390             self.prefix = ""
391         self.klobber = klobber
392
393     @overrides
394     def filter(self, record: logging.LogRecord):
395         if self.klobber:
396             record.prefix = self.prefix
397         elif "prefix" not in record.__dict__:
398             record.prefix = self.prefix
399         return True
400
401
402 class SuffixAddingFilter(logging.Filter):
403     """A filter that adds a string suffix to the log record for the
404     formatter to later fill in.  Requires a %(suffix)s in the format
405     string.
406     """
407
408     def __init__(self, suffix: str, klobber: bool = False):
409         """
410         Args:
411             suffix: the suffix string to add
412             klobber: should we overwrite other suffixes?
413         """
414         super().__init__()
415         if suffix:
416             self.suffix = suffix
417         else:
418             self.suffix = ""
419         self.klobber = klobber
420
421     @overrides
422     def filter(self, record: logging.LogRecord):
423         if self.klobber:
424             record.suffix = self.suffix
425         elif "suffix" not in record.__dict__:
426             record.suffix = self.suffix
427         return True
428
429
430 class PrependingLogAdapter(logging.LoggerAdapter):
431     """:class:`LoggingContext` adds prefixes and suffixes using a
432     logging.Filter that must insert "prefix" or "suffix" members into
433     each log record by using :class:`PrefixAddingFilter` and
434     :class:`SuffixAddingFilter`.  This relies on the logging format
435     string containing a %(prefix)s and a %(suffix)s to work correctly.
436
437     This is an alternate approach that basically just wraps the logger
438     in a class that has the same interface and thunks most calls down
439     to the wrapped logger.  It might be useful if you don't want to use
440     :class:`LoggingContext` or its friends.
441
442         >>> logger = logging.getLogger(__name__ + ".PrependingLogAdapter")
443         >>> logger.setLevel(logging.INFO)
444         >>> logger.addHandler(logging.StreamHandler(sys.stdout))
445
446     At this point logger doesn't have any format string and so it is
447     missing %(prefix)s and %(suffix)s.  It also doesn't have a
448     :class:`PrefixAddingFilter` or :class:`SuffixAddingFilter` added.
449     So using it in a :class:`LoggingContext` will not work.
450
451     But we can still add a prefix or suffix by just wrapping it:
452
453         >>> logger.info("TEST")
454         TEST
455
456         >>> log = PrependingLogAdapter.wrap_logger('prefix> ', logger)
457         >>> log.info("TEST")
458         prefix> TEST
459     """
460
461     def process(self, msg, kwargs):
462         return f'{self.extra.get("prefix", "")}{msg}', kwargs
463
464     @staticmethod
465     def wrap_logger(prefix: str, logger: logging.Logger) -> logging.LoggerAdapter:
466         """Helper method around the creation of a LogAdapter that prepends
467         a given string to every log message produced.
468
469         Args:
470             prefix: the message to prepend to every log message.
471             logger: the logger whose messages to modify.
472
473         Returns:
474             A new logger wrapping the old one with the given behavior.
475             The old logger will continue to behave as usual; simply drop
476             the reference to this wrapper when it's no longer needed.
477         """
478         assert prefix
479         return PrependingLogAdapter(logger, {"prefix": prefix})
480
481
482 class AppendingLogAdapter(logging.LoggerAdapter):
483     """:class:`LoggingContext` adds prefixes and suffixes using a
484     logging.Filter that must insert "prefix" or "suffix" members into
485     each log record by using :class:`PrefixAddingFilter` and
486     :class:`SuffixAddingFilter`.  This relies on the logging format
487     string containing a %(prefix)s and a %(suffix)s to work correctly.
488
489     This is an alternate approach that basically just wraps the logger
490     in a class that has the same interface and thunks most calls down
491     to the wrapped logger.  It might be useful if you don't want to use
492     :class:`LoggingContext` or its friends.
493
494         >>> logger = logging.getLogger(__name__ + ".AppendingLogAdapter")
495         >>> logger.setLevel(logging.INFO)
496         >>> logger.addHandler(logging.StreamHandler(sys.stdout))
497
498     At this point logger doesn't have any format string and so it is
499     missing %(prefix)s and %(suffix)s.  It also doesn't have a
500     :class:`PrefixAddingFilter` or :class:`SuffixAddingFilter` added.
501     So using it in a :class:`LoggingContext` will not work.
502
503     But we can still add a prefix or suffix by just wrapping it:
504
505         >>> logger.info("TEST")
506         TEST
507
508         >>> log = AppendingLogAdapter.wrap_logger('!!!', logger)
509         >>> log.info("TEST")
510         TEST!!!
511     """
512
513     def process(self, msg, kwargs):
514         return f'{msg}{self.extra.get("suffix", "")}', kwargs
515
516     @staticmethod
517     def wrap_logger(suffix: str, logger: logging.Logger) -> logging.LoggerAdapter:
518         """Helper method around the creation of a LoggerAdapter that appends
519         a given string to every log message produced.
520
521         Args:
522             suffix: the message to prepend to every log message.
523             logger: the logger whose messages to modify.
524
525         Returns:
526             A new logger wrapping the old one with the given behavior.
527             The old logger will continue to behave as usual; simply drop
528             the reference to this wrapper when it's no longer needed.
529         """
530         assert suffix
531         return AppendingLogAdapter(logger, {"suffix": suffix})
532
533
534 class LoggingContext(contextlib.ContextDecorator):
535     def __init__(
536         self,
537         logger: logging.Logger,
538         *,
539         level: Optional[int] = None,
540         handlers: Optional[List[logging.Handler]] = None,
541         prefix: Optional[str] = None,
542         suffix: Optional[str] = None,
543     ):
544         """This is a logging context that can be used to temporarily change the
545         way we are logging within its scope.  Logging changes may include:
546
547             - Changing the logging level (e.g. from INFO to DEBUG)
548             - Adding a prefix or suffix to every log message produced
549             - Adding temporary Handlers to direct the logging output elsewhere
550
551         Setup for doctest / examples.  This will normally be taken care of
552         by code in :meth:`initialize_logging` so you don't have to worry
553         about it.
554
555             >>> logging_format = "%(prefix)s%(message)s%(suffix)s"
556             >>> logger = logging.getLogger(__name__ + ".LoggingContext")
557             >>> logger.setLevel(logging.INFO)
558             >>> handler = logging.StreamHandler(sys.stdout)
559             >>> handler.setFormatter(
560             ...     MillisecondAwareFormatter(
561             ...         fmt=logging_format,
562             ...         datefmt='',
563             ...     )
564             ... )
565             >>> logger.addHandler(handler)
566             >>> logger.addFilter(PrefixAddingFilter(None))
567             >>> logger.addFilter(SuffixAddingFilter(None))
568
569         First, this logger should be currently be configured to send
570         INFO+ messages to sys.stdout.  Let's see it in action:
571
572             >>> logger.info("Hello world!")
573             Hello world!
574             >>> logger.debug("You should not see this")
575
576         The first example is to simply change the level of the logger.
577         Here we temporarily change it to DEBUG within the body of the
578         :class:`LoggingContext`:
579
580             >>> with LoggingContext(logger, level=logging.DEBUG):
581             ...     logger.debug("You should see this now")
582             ...     logger.info("Of course you should still see this too")
583             You should see this now
584             Of course you should still see this too
585
586             >>> logger.debug("Outside of the context we are at INFO again")
587             >>> logger.debug("(which is why you don't see these)")
588             >>> logger.info("But you should see this at INFO level")
589             But you should see this at INFO level
590
591         The prefix and suffix argument prepend or append a message to
592         all log output.  To do this, you need %(prefix)s and
593         %(suffix)s placeholders in your logger format string
594         indicating where to insert the data.  This is useful, for
595         example, to add an active request identifier to the set of log
596         messages produced while processing it.
597
598             >>> logger.info("About to work on a new request")
599             About to work on a new request
600
601             >>> with LoggingContext(logger, prefix='10.0.0.13> '):
602             ...     logger.info("Working on it now")
603             10.0.0.13> Working on it now
604
605             logger.info("Done with that request")
606             Done with that request
607
608         LoggingContext can also be used to add temporary handler(s).
609         This code temporarily uses two stdout handlers to double the
610         output for testing purporses but you could also temporarily,
611         e.g., add a RotatingFileHandler or SysLogHandler etc...
612
613             >>> with LoggingContext(logger, handlers=[logging.StreamHandler(sys.stdout)]):
614             ...     logger.info("TEST")
615             TEST
616             TEST
617
618         Once leaving the context, logger's behavior is restored.  In
619         this case, the extra handler is removed so output will not
620         longer be doubled.
621
622             >>> logger.info("OUTSIDE")
623             OUTSIDE
624
625         LoggingContext can also be used as a decorator if that is more
626         convenient:
627
628             >>> @LoggingContext(logger, level=logging.DEBUG)
629             ... def log_stuff(logger):
630             ...     logger.debug("But inside, the decorator has changed us to DEBUG")
631
632             >>> logger.debug("Outside, we're at INFO level and you don't see this")
633             >>> log_stuff(logger)
634             But inside, the decorator has changed us to DEBUG
635             >>> logger.debug("And, of course, out here we're still at INFO afterwards")
636
637         """
638         self.logger = logger
639         self.level = level
640         self.old_level: Optional[int] = None
641         self.handlers = handlers
642         if prefix:
643             self.prefix: Optional[logging.Filter] = PrefixAddingFilter(prefix, True)
644         else:
645             self.prefix = None
646         if suffix:
647             self.suffix: Optional[logging.Filter] = SuffixAddingFilter(suffix, True)
648         else:
649             self.suffix = None
650
651     def __enter__(self) -> Union[logging.Logger, logging.LoggerAdapter]:
652         assert self.logger
653         if self.level:
654             self.old_level = self.logger.level
655             self.logger.setLevel(self.level)
656
657         if self.handlers:
658             for handler in self.handlers:
659                 self.logger.addHandler(handler)
660
661         if self.prefix:
662             self.logger.addFilter(self.prefix)
663
664         if self.suffix:
665             self.logger.addFilter(self.suffix)
666         return self.logger
667
668     def __exit__(self, et, ev, tb) -> None:
669         if self.suffix:
670             self.logger.removeFilter(self.suffix)
671
672         if self.prefix:
673             self.logger.removeFilter(self.prefix)
674
675         if self.handlers:
676             for handler in self.handlers:
677                 self.logger.removeHandler(handler)
678                 handler.close()
679
680         if self.old_level:
681             self.logger.setLevel(self.old_level)
682         return None  # propagate exceptions out
683
684
685 class MillisecondAwareFormatter(logging.Formatter):
686     """A formatter for adding milliseconds to log messages which, for
687     whatever reason, the default Python logger doesn't do.
688
689     .. note::
690
691         You probably don't need to use this directly but it is
692         wired in under :meth:`initialize_logging` so that the
693         timestamps in log messages have millisecond level
694         precision.
695     """
696
697     converter = datetime.datetime.fromtimestamp  # type: ignore
698
699     @overrides
700     def formatTime(self, record, datefmt=None):
701         ct = MillisecondAwareFormatter.converter(
702             record.created, pytz.timezone("US/Pacific")
703         )
704         if datefmt:
705             s = ct.strftime(datefmt)
706         else:
707             t = ct.strftime("%Y-%m-%d %H:%M:%S")
708             s = f"{t},{record.msecs:%03d}"
709         return s
710
711
712 def _log_about_logging(
713     logger,
714     default_logging_level,
715     preexisting_handlers_count,
716     fmt,
717     facility_name,
718 ):
719     """This is invoked automatically after logging is initialized such
720     that the first messages in the log are about how logging itself
721     was configured.
722     """
723     level_name = logging._levelToName.get(
724         default_logging_level, str(default_logging_level)
725     )
726     logger.debug("Initialized global logging; logging level is %s.", level_name)
727     if (
728         config.config["logging_clear_preexisting_handlers"]
729         and preexisting_handlers_count > 0
730     ):
731         logger.debug(
732             "Logging cleared %d global handlers (--logging_clear_preexisting_handlers)",
733             preexisting_handlers_count,
734         )
735     logger.debug('Logging format specification is "%s"', fmt)
736     if config.config["logging_debug_threads"]:
737         logger.debug(
738             "...Logging format spec captures tid/pid. (--logging_debug_threads)"
739         )
740     if config.config["logging_debug_modules"]:
741         logger.debug(
742             "...Logging format spec captures files/functions/lineno. (--logging_debug_modules)"
743         )
744     if config.config["logging_syslog"]:
745         logger.debug(
746             "Logging to syslog as %s with priority mapping based on level. (--logging_syslog)",
747             facility_name,
748         )
749     if config.config["logging_filename"]:
750         logger.debug(
751             'Logging to file "%s". (--logging_filename)',
752             config.config["logging_filename"],
753         )
754         logger.debug(
755             "...with %d bytes max file size. (--logging_filename_maxsize)",
756             config.config["logging_filename_maxsize"],
757         )
758         logger.debug(
759             "...and %d rotating backup file count. (--logging_filename_count)",
760             config.config["logging_filename_count"],
761         )
762     if config.config["logging_console"]:
763         logger.debug("Logging to the console (stderr). (--logging_console)")
764     if config.config["logging_info_is_print"]:
765         logger.debug(
766             "Logging logger.info messages will be repeated on stdout. (--logging_info_is_print)"
767         )
768     if config.config["logging_squelch_repeats"]:
769         logger.debug(
770             "Logging code allowed to request repeated messages be squelched. (--logging_squelch_repeats)"
771         )
772     else:
773         logger.debug(
774             "Logging code forbidden to request messages be squelched; all messages logged. (--no_logging_squelch_repeats)"
775         )
776     if config.config["logging_probabilistically"]:
777         logger.debug(
778             "Logging code is allowed to request probabilistic logging. (--logging_probabilistically)"
779         )
780     else:
781         logger.debug(
782             "Logging code is forbidden to request probabilistic logging; messages always logged. (--no_logging_probabilistically)"
783         )
784     if config.config["logging_captures_prints"]:
785         logger.debug(
786             "Logging will capture printed data as logger.info messages. (--logging_captures_prints)"
787         )
788
789
790 def _construct_logging_format() -> str:
791     """Figure out the root logging format string based on commandling args."""
792
793     # Custom or default --logging_format?
794     if config.config["logging_format"]:
795         fmt = config.config["logging_format"]
796     elif config.config["logging_syslog"]:
797         fmt = "%(levelname).1s:%(filename)s[%(process)d]: "
798     else:
799         fmt = "%(levelname).1s:%(asctime)s: "
800
801     fmt += "%(prefix)s%(message)s%(suffix)s"
802
803     # --logging_debug_threads and --logging_debug_modules both affect
804     # the format by prepending information about the pid/tid or
805     # file/function.
806     if config.config["logging_debug_threads"]:
807         fmt = f"%(process)d.%(thread)d|{fmt}"
808     if config.config["logging_debug_modules"]:
809         fmt = f"%(filename)s:%(funcName)s:%(lineno)s|{fmt}"
810     return fmt
811
812
813 def _construct_handlers_list(
814     logging_format: str, facility_name: Optional[str]
815 ) -> List[logging.Handler]:
816     """Create the list of handlers to be added to the root logger
817     based on commandline flags.
818     """
819
820     retval: List[logging.Handler] = []
821
822     # --logging_syslog (optionally with --logging_syslog_facility)
823     # sets up for logging to use the standard system syslogd as a
824     # sink.
825     if config.config["logging_syslog"]:
826         if sys.platform not in ("win32", "cygwin"):
827             assert facility_name
828             facility = SysLogHandler.__dict__.get(facility_name, SysLogHandler.LOG_USER)
829             assert facility
830             syslog_handler = SysLogHandler(facility=facility, address="/dev/log")
831             syslog_handler.setFormatter(
832                 MillisecondAwareFormatter(
833                     fmt=logging_format,
834                     datefmt=config.config["logging_date_format"],
835                 )
836             )
837             retval.append(syslog_handler)
838
839     # --logging_filename (with friends --logging_filename_count and
840     # --logging_filename_maxsize) set up logging to a file on the
841     # filesystem with automatic rotation when it gets too big.
842     if config.config["logging_filename"]:
843         max_bytes = config.config["logging_filename_maxsize"]
844         assert max_bytes and isinstance(max_bytes, int)
845         backup_count = config.config["logging_filename_count"]
846         assert backup_count and isinstance(backup_count, int)
847         file_handler = RotatingFileHandler(
848             config.config["logging_filename"],
849             maxBytes=max_bytes,
850             backupCount=backup_count,
851         )
852         file_handler.setFormatter(
853             MillisecondAwareFormatter(
854                 fmt=logging_format,
855                 datefmt=config.config["logging_date_format"],
856             )
857         )
858         retval.append(file_handler)
859
860     # --logging_console is, ahem, logging to the console.
861     if config.config["logging_console"]:
862         console_handler = logging.StreamHandler(sys.stderr)
863         console_handler.setFormatter(
864             MillisecondAwareFormatter(
865                 fmt=logging_format,
866                 datefmt=config.config["logging_date_format"],
867             )
868         )
869         retval.append(console_handler)
870
871     # --logging_info_is_print echoes any message to logger.info(x) as
872     # a print statement on stdout.
873     if config.config["logging_info_is_print"]:
874         print_handler = logging.StreamHandler(sys.stdout)
875         print_handler.addFilter(OnlyInfoFilter())
876         retval.append(print_handler)
877
878     if len(retval) == 0:
879         retval.append(logging.NullHandler())
880     return retval
881
882
883 def _add_logging_handlers_and_filters(
884     logger: logging.Logger, handlers: List[logging.Handler]
885 ) -> None:
886     """Adds a list of handlers to the root logger."""
887
888     for handler in handlers:
889         # These two are always present and set %(prefix) and %(suffix)
890         # if appropriate (see :class:`LoggingContext`) or make them
891         # empty string.
892         handler.addFilter(PrefixAddingFilter("", False))
893         handler.addFilter(SuffixAddingFilter("", False))
894
895         # --logging_squelch_repeats allows code to request repeat logging
896         # messages (identical log site and message contents) to be
897         # silenced.  Logging code must request this explicitly, it isn't
898         # automatic.  This option just allows the silencing to happen.
899         if config.config["logging_squelch_repeats"]:
900             handler.addFilter(SquelchRepeatedMessagesFilter())
901
902         # --logging_probabilistically allows code to request
903         # non-deterministic logging where messages have some probability
904         # of being produced.  Logging code must request this explicitly.
905         # This option just allows the non-deterministic behavior to
906         # happen.  Disabling it will cause every log message to be
907         # produced.
908         if config.config["logging_probabilistically"]:
909             handler.addFilter(ProbabilisticFilter())
910         logger.addHandler(handler)
911
912
913 def initialize_logging(logger=None) -> logging.Logger:
914     """Initialize logging for the program.  See module level comments
915     for information about what functionality this provides and how to
916     enable or disable functionality via the commandline.
917
918     If you use the
919     :meth:`bootstrap.initialize` decorator on your program's entry point,
920     it will call this for you.  See :meth:`pyutils.bootstrap.initialize`
921     for more details.
922     """
923
924     global LOGGING_INITIALIZED
925     if LOGGING_INITIALIZED:
926         return logging.getLogger()
927     LOGGING_INITIALIZED = True
928
929     clear_preexisting = config.config["logging_clear_preexisting_handlers"]
930     preexisting_handlers_count = 0
931     if logger is None:
932         logging.basicConfig(force=clear_preexisting)
933         logger = logging.getLogger()
934
935     # --logging_clear_preexisting_handlers removes logging handlers
936     # that were registered by global statements during imported module
937     # setup.
938     if clear_preexisting:
939         while logger.hasHandlers():
940             logger.removeHandler(logger.handlers[0])
941             preexisting_handlers_count += 1
942
943     # --logging_config_file pulls logging settings from a config file
944     # skipping the rest of this setup.
945     if config.config["logging_config_file"] is not None:
946         fileConfig(config.config["logging_config_file"])
947         return logger
948
949     # Global default logging level (--logging_level); messages below
950     # this level will be silenced.
951     logging_level = config.config["logging_level"]
952     assert logging_level
953     logging_level = logging_level.upper()
954     default_logging_level = getattr(logging, logging_level, None)
955     if not isinstance(default_logging_level, int):
956         raise ValueError(f'Invalid level: {config.config["logging_level"]}')
957     logger.setLevel(default_logging_level)
958
959     logging_format = _construct_logging_format()
960     facility_name = None
961     if config.config["logging_syslog"]:
962         if sys.platform not in ("win32", "cygwin"):
963             if config.config["logging_syslog_facility"]:
964                 facility_name = "LOG_" + config.config["logging_syslog_facility"]
965     handlers: List[logging.Handler] = _construct_handlers_list(
966         logging_format, facility_name
967     )
968     _add_logging_handlers_and_filters(logger, handlers)
969
970     # --logging_captures_prints, if set, will capture and log.info
971     # anything printed on stdout.
972     if config.config["logging_captures_prints"]:
973         import builtins
974
975         def print_and_also_log(*arg, **kwarg):
976             f = kwarg.get("file", None)
977             if f == sys.stderr:
978                 logger.warning(*arg)
979             else:
980                 logger.info(*arg)
981             BUILT_IN_PRINT(*arg, **kwarg)
982
983         builtins.print = print_and_also_log
984
985     # At this point the logger is ready, handlers are set up,
986     # etc... so log about the logging configuration.
987     _log_about_logging(
988         logger,
989         default_logging_level,
990         preexisting_handlers_count,
991         logging_format,
992         facility_name,
993     )
994     return logger
995
996
997 def get_logger(name: str = ""):
998     """Get the global logger"""
999     logger = logging.getLogger(name)
1000     return initialize_logging(logger)
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()