Lots of changes.
[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 import string_utils as su
16 import thread_utils as tu
17
18 cfg = config.add_commandline_args(
19     f'Logging ({__file__})',
20     'Args related to logging')
21 cfg.add_argument(
22     '--logging_config_file',
23     type=argparse_utils.valid_filename,
24     default=None,
25     metavar='FILENAME',
26     help='Config file containing the logging setup, see: https://docs.python.org/3/howto/logging.html#logging-advanced-tutorial',
27 )
28 cfg.add_argument(
29     '--logging_level',
30     type=str,
31     default='INFO',
32     choices=['NOTSET', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
33     metavar='LEVEL',
34     help='The level below which to squelch log messages.',
35 )
36 cfg.add_argument(
37     '--logging_format',
38     type=str,
39     default='%(levelname)s:%(asctime)s: %(message)s',
40     help='The format for lines logged via the logger module.'
41 )
42 cfg.add_argument(
43     '--logging_date_format',
44     type=str,
45     default='%Y/%m/%dT%H:%M:%S.%f%z',
46     metavar='DATEFMT',
47     help='The format of any dates in --logging_format.'
48 )
49 cfg.add_argument(
50     '--logging_console',
51     action=argparse_utils.ActionNoYes,
52     default=True,
53     help='Should we log to the console (stderr)',
54 )
55 cfg.add_argument(
56     '--logging_filename',
57     type=str,
58     default=None,
59     metavar='FILENAME',
60     help='The filename of the logfile to write.'
61 )
62 cfg.add_argument(
63     '--logging_filename_maxsize',
64     type=int,
65     default=(1024*1024),
66     metavar='#BYTES',
67     help='The maximum size (in bytes) to write to the logging_filename.'
68 )
69 cfg.add_argument(
70     '--logging_filename_count',
71     type=int,
72     default=2,
73     metavar='COUNT',
74     help='The number of logging_filename copies to keep before deleting.'
75 )
76 cfg.add_argument(
77     '--logging_syslog',
78     action=argparse_utils.ActionNoYes,
79     default=False,
80     help='Should we log to localhost\'s syslog.'
81 )
82 cfg.add_argument(
83     '--logging_debug_threads',
84     action=argparse_utils.ActionNoYes,
85     default=False,
86     help='Should we prepend pid/tid data to all log messages?'
87 )
88 cfg.add_argument(
89     '--logging_info_is_print',
90     action=argparse_utils.ActionNoYes,
91     default=False,
92     help='logging.info also prints to stdout.'
93 )
94
95
96 class OnlyInfoFilter(logging.Filter):
97     def filter(self, record):
98         return record.levelno == logging.INFO
99
100
101 class MillisecondAwareFormatter(logging.Formatter):
102     converter = datetime.datetime.fromtimestamp
103
104     def formatTime(self, record, datefmt=None):
105         ct = self.converter(record.created, pytz.timezone("US/Pacific"))
106         if datefmt:
107             s = ct.strftime(datefmt)
108         else:
109             t = ct.strftime("%Y-%m-%d %H:%M:%S")
110             s = "%s,%03d" % (t, record.msecs)
111         return s
112
113
114 def initialize_logging(logger=None) -> logging.Logger:
115     assert config.has_been_parsed()
116     if logger is None:
117         logger = logging.getLogger()       # Root logger
118
119     if config.config['logging_config_file'] is not None:
120         logging.config.fileConfig('logging.conf')
121         return logger
122
123     handlers = []
124     numeric_level = getattr(
125         logging,
126         config.config['logging_level'].upper(),
127         None
128     )
129     if not isinstance(numeric_level, int):
130         raise ValueError('Invalid level: %s' % config.config['logging_level'])
131
132     fmt = config.config['logging_format']
133     if config.config['logging_debug_threads']:
134         fmt = f'%(process)d.%(thread)d|{fmt}'
135
136     if config.config['logging_syslog']:
137         if sys.platform in ('win32', 'cygwin'):
138             print(
139                 "WARNING: Current platform does not support syslog; IGNORING.",
140                 file=sys.stderr
141             )
142         else:
143             handler = SysLogHandler()
144 #            for k, v in encoded_priorities.items():
145 #                handler.encodePriority(k, v)
146             handler.setFormatter(
147                 MillisecondAwareFormatter(
148                     fmt=fmt,
149                     datefmt=config.config['logging_date_format'],
150                 )
151             )
152             handler.setLevel(numeric_level)
153             handlers.append(handler)
154
155     if config.config['logging_filename'] is not None:
156         handler = RotatingFileHandler(
157             config.config['logging_filename'],
158             maxBytes = config.config['logging_filename_maxsize'],
159             backupCount = config.config['logging_filename_count'],
160         )
161         handler.setLevel(numeric_level)
162         handler.setFormatter(
163             MillisecondAwareFormatter(
164                 fmt=fmt,
165                 datefmt=config.config['logging_date_format'],
166             )
167         )
168         handlers.append(handler)
169
170     if config.config['logging_console']:
171         handler = logging.StreamHandler(sys.stderr)
172         handler.setLevel(numeric_level)
173         handler.setFormatter(
174             MillisecondAwareFormatter(
175                 fmt=fmt,
176                 datefmt=config.config['logging_date_format'],
177             )
178         )
179         handlers.append(handler)
180
181     if len(handlers) == 0:
182         handlers.append(logging.NullHandler())
183
184     for handler in handlers:
185         logger.addHandler(handler)
186     if config.config['logging_info_is_print']:
187         handler = logging.StreamHandler(sys.stdout)
188         handler.addFilter(OnlyInfoFilter())
189         logger.addHandler(handler)
190     logger.setLevel(numeric_level)
191     logger.propagate = False
192     return logger
193
194
195 def get_logger(name: str = ""):
196     logger = logging.getLogger(name)
197     return initialize_logging(logger)
198
199
200 def tprint(*args, **kwargs) -> None:
201     if config.config['logging_debug_threads']:
202         print(f'{tu.current_thread_id()}', end="")
203         print(*args, **kwargs)
204     else:
205         pass
206
207
208 def dprint(*args, **kwargs) -> None:
209     print(*args, file=sys.stderr, **kwargs)
210
211
212 class OutputSink(object):
213
214     # Bits in the destination_bitv bitvector.  Used to indicate the
215     # output destination.
216     STDOUT = 0x1
217     STDERR = 0x2
218     LOG_DEBUG = 0x4          # -\
219     LOG_INFO = 0x8           #  |
220     LOG_WARNING = 0x10       #   > Should provide logger to the c'tor.
221     LOG_ERROR = 0x20         #  |
222     LOG_CRITICAL = 0x40      # _/
223     FILENAME = 0x80          # Must provide a filename to the c'tor.
224     HLOG = 0x100
225
226     ALL_LOG_DESTINATIONS = (
227         LOG_DEBUG | LOG_INFO | LOG_WARNING | LOG_ERROR | LOG_CRITICAL
228     )
229     ALL_OUTPUT_DESTINATIONS = 0x1FF
230
231     def __init__(self,
232                  destination_bitv: int,
233                  *,
234                  logger=None,
235                  filename=None):
236         if logger is None:
237             logger = logging.getLogger(None)
238         self.logger = logger
239
240         if filename is not None:
241             self.f = open(filename, "wb", buffering=0)
242         else:
243             if self.destination_bitv & OutputSink.FILENAME:
244                 raise ValueError(
245                     "Filename argument is required if bitv & FILENAME"
246                 )
247             self.f = None
248         self.set_destination_bitv(destination_bitv)
249
250     def get_destination_bitv(self):
251         return self.destination_bitv
252
253     def set_destination_bitv(self, destination_bitv: int):
254         if destination_bitv & self.FILENAME and self.f is None:
255             raise ValueError(
256                 "Filename argument is required if bitv & FILENAME"
257             )
258         self.destination_bitv = destination_bitv
259
260     def print(self, *args, **kwargs):
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 = su.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 = su.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}'")