ba5270fad87a1e62ccc8be99fe7eaba786583dc8
[python_utils.git] / logging_utils.py
1 #!/usr/bin/env python3
2
3 """Utilities related to logging."""
4
5 import contextlib
6 import datetime
7 import logging
8 from logging.handlers import RotatingFileHandler, SysLogHandler
9 import os
10 import pytz
11 import sys
12
13 import argparse_utils
14 import config
15
16 cfg = config.add_commandline_args(
17     f'Logging ({__file__})',
18     'Args related to logging')
19 cfg.add_argument(
20     '--logging_config_file',
21     type=argparse_utils.valid_filename,
22     default=None,
23     metavar='FILENAME',
24     help='Config file containing the logging setup, see: https://docs.python.org/3/howto/logging.html#logging-advanced-tutorial',
25 )
26 cfg.add_argument(
27     '--logging_level',
28     type=str,
29     default='INFO',
30     choices=['NOTSET', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
31     metavar='LEVEL',
32     help='The level below which to squelch log messages.',
33 )
34 cfg.add_argument(
35     '--logging_format',
36     type=str,
37     default='%(levelname)s:%(asctime)s: %(message)s',
38     help='The format for lines logged via the logger module.'
39 )
40 cfg.add_argument(
41     '--logging_date_format',
42     type=str,
43     default='%Y/%m/%dT%H:%M:%S.%f%z',
44     metavar='DATEFMT',
45     help='The format of any dates in --logging_format.'
46 )
47 cfg.add_argument(
48     '--logging_console',
49     action=argparse_utils.ActionNoYes,
50     default=True,
51     help='Should we log to the console (stderr)',
52 )
53 cfg.add_argument(
54     '--logging_filename',
55     type=str,
56     default=None,
57     metavar='FILENAME',
58     help='The filename of the logfile to write.'
59 )
60 cfg.add_argument(
61     '--logging_filename_maxsize',
62     type=int,
63     default=(1024*1024),
64     metavar='#BYTES',
65     help='The maximum size (in bytes) to write to the logging_filename.'
66 )
67 cfg.add_argument(
68     '--logging_filename_count',
69     type=int,
70     default=2,
71     metavar='COUNT',
72     help='The number of logging_filename copies to keep before deleting.'
73 )
74 cfg.add_argument(
75     '--logging_syslog',
76     action=argparse_utils.ActionNoYes,
77     default=False,
78     help='Should we log to localhost\'s syslog.'
79 )
80 cfg.add_argument(
81     '--logging_debug_threads',
82     action=argparse_utils.ActionNoYes,
83     default=False,
84     help='Should we prepend pid/tid data to all log messages?'
85 )
86 cfg.add_argument(
87     '--logging_info_is_print',
88     action=argparse_utils.ActionNoYes,
89     default=False,
90     help='logging.info also prints to stdout.'
91 )
92
93
94 class OnlyInfoFilter(logging.Filter):
95     def filter(self, record):
96         return record.levelno == logging.INFO
97
98
99 class MillisecondAwareFormatter(logging.Formatter):
100     converter = datetime.datetime.fromtimestamp
101
102     def formatTime(self, record, datefmt=None):
103         ct = self.converter(record.created, pytz.timezone("US/Pacific"))
104         if datefmt:
105             s = ct.strftime(datefmt)
106         else:
107             t = ct.strftime("%Y-%m-%d %H:%M:%S")
108             s = "%s,%03d" % (t, record.msecs)
109         return s
110
111
112 def initialize_logging(logger=None) -> logging.Logger:
113     assert config.has_been_parsed()
114     if logger is None:
115         logger = logging.getLogger()       # Root logger
116
117     if config.config['logging_config_file'] is not None:
118         logging.config.fileConfig('logging.conf')
119         return logger
120
121     handlers = []
122     numeric_level = getattr(
123         logging,
124         config.config['logging_level'].upper(),
125         None
126     )
127     if not isinstance(numeric_level, int):
128         raise ValueError('Invalid level: %s' % config.config['logging_level'])
129
130     fmt = config.config['logging_format']
131     if config.config['logging_debug_threads']:
132         fmt = f'%(process)d.%(thread)d|{fmt}'
133
134     if config.config['logging_syslog']:
135         if sys.platform in ('win32', 'cygwin'):
136             print(
137                 "WARNING: Current platform does not support syslog; IGNORING.",
138                 file=sys.stderr
139             )
140         else:
141             handler = SysLogHandler()
142 #            for k, v in encoded_priorities.items():
143 #                handler.encodePriority(k, v)
144             handler.setFormatter(
145                 MillisecondAwareFormatter(
146                     fmt=fmt,
147                     datefmt=config.config['logging_date_format'],
148                 )
149             )
150             handler.setLevel(numeric_level)
151             handlers.append(handler)
152
153     if config.config['logging_filename'] is not None:
154         handler = RotatingFileHandler(
155             config.config['logging_filename'],
156             maxBytes = config.config['logging_filename_maxsize'],
157             backupCount = config.config['logging_filename_count'],
158         )
159         handler.setLevel(numeric_level)
160         handler.setFormatter(
161             MillisecondAwareFormatter(
162                 fmt=fmt,
163                 datefmt=config.config['logging_date_format'],
164             )
165         )
166         handlers.append(handler)
167
168     if config.config['logging_console']:
169         handler = logging.StreamHandler(sys.stderr)
170         handler.setLevel(numeric_level)
171         handler.setFormatter(
172             MillisecondAwareFormatter(
173                 fmt=fmt,
174                 datefmt=config.config['logging_date_format'],
175             )
176         )
177         handlers.append(handler)
178
179     if len(handlers) == 0:
180         handlers.append(logging.NullHandler())
181
182     for handler in handlers:
183         logger.addHandler(handler)
184     if config.config['logging_info_is_print']:
185         handler = logging.StreamHandler(sys.stdout)
186         handler.addFilter(OnlyInfoFilter())
187         logger.addHandler(handler)
188     logger.setLevel(numeric_level)
189     logger.propagate = False
190     return logger
191
192
193 def get_logger(name: str = ""):
194     logger = logging.getLogger(name)
195     return initialize_logging(logger)
196
197
198 def tprint(*args, **kwargs) -> None:
199     if config.config['logging_debug_threads']:
200         from thread_utils import current_thread_id
201         print(f'{current_thread_id()}', end="")
202         print(*args, **kwargs)
203     else:
204         pass
205
206
207 def dprint(*args, **kwargs) -> None:
208     print(*args, file=sys.stderr, **kwargs)
209
210
211 class OutputSink(object):
212
213     # Bits in the destination_bitv bitvector.  Used to indicate the
214     # output destination.
215     STDOUT = 0x1
216     STDERR = 0x2
217     LOG_DEBUG = 0x4          # -\
218     LOG_INFO = 0x8           #  |
219     LOG_WARNING = 0x10       #   > Should provide logger to the c'tor.
220     LOG_ERROR = 0x20         #  |
221     LOG_CRITICAL = 0x40      # _/
222     FILENAME = 0x80          # Must provide a filename to the c'tor.
223     HLOG = 0x100
224
225     ALL_LOG_DESTINATIONS = (
226         LOG_DEBUG | LOG_INFO | LOG_WARNING | LOG_ERROR | LOG_CRITICAL
227     )
228     ALL_OUTPUT_DESTINATIONS = 0x1FF
229
230     def __init__(self,
231                  destination_bitv: int,
232                  *,
233                  logger=None,
234                  filename=None):
235         if logger is None:
236             logger = logging.getLogger(None)
237         self.logger = logger
238
239         if filename is not None:
240             self.f = open(filename, "wb", buffering=0)
241         else:
242             if self.destination_bitv & OutputSink.FILENAME:
243                 raise ValueError(
244                     "Filename argument is required if bitv & FILENAME"
245                 )
246             self.f = None
247         self.set_destination_bitv(destination_bitv)
248
249     def get_destination_bitv(self):
250         return self.destination_bitv
251
252     def set_destination_bitv(self, destination_bitv: int):
253         if destination_bitv & self.FILENAME and self.f is None:
254             raise ValueError(
255                 "Filename argument is required if bitv & FILENAME"
256             )
257         self.destination_bitv = destination_bitv
258
259     def print(self, *args, **kwargs):
260         from string_utils import sprintf, strip_escape_sequences
261         end = kwargs.pop("end", None)
262         if end is not None:
263             if not isinstance(end, str):
264                 raise TypeError("end must be None or a string")
265         sep = kwargs.pop("sep", None)
266         if sep is not None:
267             if not isinstance(sep, str):
268                 raise TypeError("sep must be None or a string")
269         if kwargs:
270             raise TypeError("invalid keyword arguments to print()")
271         buf = sprintf(*args, end="", sep=sep)
272         if sep is None:
273             sep = " "
274         if end is None:
275             end = "\n"
276         if self.destination_bitv & self.STDOUT:
277             print(buf, file=sys.stdout, sep=sep, end=end)
278         if self.destination_bitv & self.STDERR:
279             print(buf, file=sys.stderr, sep=sep, end=end)
280         if end == '\n':
281             buf += '\n'
282         if self.destination_bitv & self.FILENAME and self.f is not None:
283             self.f.write(buf.encode('utf-8'))
284             self.f.flush()
285         buf = strip_escape_sequences(buf)
286         if self.logger is not None:
287             if self.destination_bitv & self.LOG_DEBUG:
288                 self.logger.debug(buf)
289             if self.destination_bitv & self.LOG_INFO:
290                 self.logger.info(buf)
291             if self.destination_bitv & self.LOG_WARNING:
292                 self.logger.warning(buf)
293             if self.destination_bitv & self.LOG_ERROR:
294                 self.logger.error(buf)
295             if self.destination_bitv & self.LOG_CRITICAL:
296                 self.logger.critical(buf)
297         if self.destination_bitv & self.HLOG:
298             hlog(buf)
299
300     def close(self):
301         if self.f is not None:
302             self.f.close()
303
304
305 class OutputContext(OutputSink, contextlib.ContextDecorator):
306     def __init__(self,
307                  destination_bitv: int,
308                  *,
309                  logger=None,
310                  filename=None):
311         super().__init__(destination_bitv, logger=logger, filename=filename)
312
313     def __enter__(self):
314         return self
315
316     def __exit__(self, etype, value, traceback):
317         super().close()
318         if etype is not None:
319             return False
320         return True
321
322
323 def hlog(message: str) -> None:
324     message = message.replace("'", "'\"'\"'")
325     os.system(f"/usr/bin/logger -p local7.info -- '{message}'")