ACL uses enums, some more tests, other stuff.
[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 # This module is commonly used by others in here and should avoid
14 # taking any unnecessary dependencies back on them.
15 import argparse_utils
16 import config
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         from thread_utils import current_thread_id
203         print(f'{current_thread_id()}', end="")
204         print(*args, **kwargs)
205     else:
206         pass
207
208
209 def dprint(*args, **kwargs) -> None:
210     print(*args, file=sys.stderr, **kwargs)
211
212
213 class OutputSink(object):
214
215     # Bits in the destination_bitv bitvector.  Used to indicate the
216     # output destination.
217     STDOUT = 0x1
218     STDERR = 0x2
219     LOG_DEBUG = 0x4          # -\
220     LOG_INFO = 0x8           #  |
221     LOG_WARNING = 0x10       #   > Should provide logger to the c'tor.
222     LOG_ERROR = 0x20         #  |
223     LOG_CRITICAL = 0x40      # _/
224     FILENAME = 0x80          # Must provide a filename to the c'tor.
225     HLOG = 0x100
226
227     ALL_LOG_DESTINATIONS = (
228         LOG_DEBUG | LOG_INFO | LOG_WARNING | LOG_ERROR | LOG_CRITICAL
229     )
230     ALL_OUTPUT_DESTINATIONS = 0x1FF
231
232     def __init__(self,
233                  destination_bitv: int,
234                  *,
235                  logger=None,
236                  filename=None):
237         if logger is None:
238             logger = logging.getLogger(None)
239         self.logger = logger
240
241         if filename is not None:
242             self.f = open(filename, "wb", buffering=0)
243         else:
244             if self.destination_bitv & OutputSink.FILENAME:
245                 raise ValueError(
246                     "Filename argument is required if bitv & FILENAME"
247                 )
248             self.f = None
249         self.set_destination_bitv(destination_bitv)
250
251     def get_destination_bitv(self):
252         return self.destination_bitv
253
254     def set_destination_bitv(self, destination_bitv: int):
255         if destination_bitv & self.FILENAME and self.f is None:
256             raise ValueError(
257                 "Filename argument is required if bitv & FILENAME"
258             )
259         self.destination_bitv = destination_bitv
260
261     def print(self, *args, **kwargs):
262         from string_utils import sprintf, strip_escape_sequences
263         end = kwargs.pop("end", None)
264         if end is not None:
265             if not isinstance(end, str):
266                 raise TypeError("end must be None or a string")
267         sep = kwargs.pop("sep", None)
268         if sep is not None:
269             if not isinstance(sep, str):
270                 raise TypeError("sep must be None or a string")
271         if kwargs:
272             raise TypeError("invalid keyword arguments to print()")
273         buf = sprintf(*args, end="", sep=sep)
274         if sep is None:
275             sep = " "
276         if end is None:
277             end = "\n"
278         if self.destination_bitv & self.STDOUT:
279             print(buf, file=sys.stdout, sep=sep, end=end)
280         if self.destination_bitv & self.STDERR:
281             print(buf, file=sys.stderr, sep=sep, end=end)
282         if end == '\n':
283             buf += '\n'
284         if self.destination_bitv & self.FILENAME and self.f is not None:
285             self.f.write(buf.encode('utf-8'))
286             self.f.flush()
287         buf = strip_escape_sequences(buf)
288         if self.logger is not None:
289             if self.destination_bitv & self.LOG_DEBUG:
290                 self.logger.debug(buf)
291             if self.destination_bitv & self.LOG_INFO:
292                 self.logger.info(buf)
293             if self.destination_bitv & self.LOG_WARNING:
294                 self.logger.warning(buf)
295             if self.destination_bitv & self.LOG_ERROR:
296                 self.logger.error(buf)
297             if self.destination_bitv & self.LOG_CRITICAL:
298                 self.logger.critical(buf)
299         if self.destination_bitv & self.HLOG:
300             hlog(buf)
301
302     def close(self):
303         if self.f is not None:
304             self.f.close()
305
306
307 class OutputContext(OutputSink, contextlib.ContextDecorator):
308     def __init__(self,
309                  destination_bitv: int,
310                  *,
311                  logger=None,
312                  filename=None):
313         super().__init__(destination_bitv, logger=logger, filename=filename)
314
315     def __enter__(self):
316         return self
317
318     def __exit__(self, etype, value, traceback):
319         super().close()
320         if etype is not None:
321             return False
322         return True
323
324
325 def hlog(message: str) -> None:
326     message = message.replace("'", "'\"'\"'")
327     os.system(f"/usr/bin/logger -p local7.info -- '{message}'")