Source code for annalist.annalist
"""Main module."""
import inspect
import logging
import re
from os import PathLike
LOGGER_LEVELS = {
"DEBUG": logging.DEBUG,
10: logging.DEBUG,
"INFO": logging.INFO,
20: logging.INFO,
"WARNING": logging.WARNING,
30: logging.WARNING,
"ERROR": logging.ERROR,
40: logging.ERROR,
"CRITICAL": logging.CRITICAL,
50: logging.CRITICAL,
}
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
logger.addHandler(logging.StreamHandler())
[docs]
class AnnalistLogger(logging.Logger):
"""Custom Logger class to add contextual information."""
def __init__(self, name, extra_attributes):
"""Construct a AnnalistLogger.
Extends the functionality of the Logger class to accept user-defined
fields as attributes.
"""
if extra_attributes:
self.extra_attributes = extra_attributes
else:
self.extra_attributes = []
logging.Logger.__init__(self, name)
logging.Logger.setLevel(self, logging.INFO)
self.propagate = True
[docs]
def add_attributes(self, extra_attributes: list):
"""Add user-defined fields as attributes."""
self.extra_attributes += extra_attributes
[docs]
def makeRecord(self, *args, **kwargs): # type: ignore
"""Override Logger.makeRecord to accept user-defined fields."""
rv = super().makeRecord(*args, **kwargs)
for attr in self.extra_attributes:
rv.__dict__[attr] = rv.__dict__.get(attr, None)
return rv
[docs]
class Singleton(type):
"""Singleton Metaclass.
Ensures that only one instance of the inheriting class is created.
"""
def __init__(self, name, bases, mmbs):
"""Enforce singleton upon new object creation."""
super().__init__(name, bases, mmbs)
self._instance = super().__call__()
def __call__(self, *args, **kw):
"""Retrieve singleton object."""
return self._instance
[docs]
class Annalist(metaclass=Singleton):
"""Annalist Class.
Attributes
----------
logger : AnnalistLogger
A custom subclass of logging.Logger which allows additional
user-defined variables to be parsed from the formatter and added
dynamically.
stream_handler : logging.StreamHandler
Logging handler that sends output to streams such as `sys.stdout`,
`sys.stderr`, etc. Will be passed the `stream_formatter` attribute.
See documentation for logging.StreamHandler for more info.
file_handler : logging.FileHandler
Logging handler that sends output to the logfile defined by the
`logfile` attribute. Will be passed the `file_formatter` attribute.
See `logging.FileHandler documentation`_ for more info.
stream_formatter : str
Stream formatting string to be parsed by `logging.Formatter`.
Used to set up `stream_handler`.
See `logging.Formatter documentation`_ for more info.
file_formatter : str
File formatting string to be parsed by `logging.Formatter`.
See `logging.Formatter documentation`_ for more info.
"""
_configured = False
def __init__(self):
"""Annalist Constructor.
Construsts an "unconfigured" instance of Annalist. However, since
annalist is a singleton, it will simply retrieve a configured annalist
if one exists somewhere in the namespace.
"""
self.logger = AnnalistLogger("TempLogger", None)
self.stream_handler = logging.StreamHandler() # Log to console
[docs]
def configure(
self,
logfile: str | PathLike[str] | None = None,
analyst_name: str | None = None,
file_format_str: str | None = None,
stream_format_str: str | None = None,
level_filter: str = "INFO",
default_level: str = "INFO",
):
"""Configure the Annalist."""
self._analyst_name = analyst_name
extra_attributes = []
if file_format_str:
file_format_attrs = self.parse_formatter(file_format_str)
extra_attributes += file_format_attrs
if stream_format_str:
stream_format_attrs = self.parse_formatter(stream_format_str)
extra_attributes += stream_format_attrs
self.date_format = "%Y-%m-%d %H:%M:%S"
default_formatter = logging.Formatter(
"%(asctime)s | %(levelname)s | %(name)s | %(analyst_name)s",
self.date_format,
)
# Set up formatters
if file_format_str:
self.file_formatter = logging.Formatter(file_format_str, self.date_format)
else:
self.file_formatter = default_formatter
if stream_format_str:
self.stream_formatter = logging.Formatter(
stream_format_str, self.date_format
)
else:
self.stream_formatter = default_formatter
self.logfile = logfile
# Set up handlers
if self.logfile:
self.file_handler = logging.FileHandler(self.logfile, mode="w")
self.stream_handler = logging.StreamHandler() # Log to console
default_attributes = [
"analyst_name",
"function_name",
"function_doc",
"ret_annotation",
"params",
"ret_val",
"ret_val_type",
]
self.all_attributes = default_attributes + extra_attributes
self._default_level = LOGGER_LEVELS[default_level]
self.logger = AnnalistLogger("auditor", self.all_attributes)
if self.logfile:
self.logger.addHandler(self.file_handler)
self.logger.addHandler(self.stream_handler)
if self.logfile:
self.file_handler.setFormatter(self.file_formatter)
self.stream_handler.setFormatter(self.stream_formatter)
# self.set_stream_formatter(self.stream_formatter)
self._level_filter = LOGGER_LEVELS[level_filter]
self.logger.setLevel(self._level_filter)
# Adding some more fields to the logger this way
self._configured = True
@property
def analyst_name(self):
"""The analyst_name property."""
if not self._configured:
raise ValueError(
"Annalist not configured. Configure object after retrieval."
)
return self._analyst_name
@analyst_name.setter
def analyst_name(self, value):
"""Set the analyst_name property.
Name of the analyst who is invoking the script.
Parameters
----------
value : str
The first
"""
if not self._configured:
raise ValueError(
"Annalist not configured. Configure object after retrieval."
)
self._analyst_name = value
@property
def level_filter(self):
"""The level_filter property."""
return self._level_filter
@level_filter.setter
def level_filter(self, value):
self._level_filter = value
self.logger.setLevel(self._level_filter)
@property
def default_level(self):
"""The default_level property."""
return self._default_level
@default_level.setter
def default_level(self, value):
"""Set the default_level property."""
self._default_level = value
[docs]
@staticmethod
def parse_formatter(format_string):
"""Parse a formatting string.
Extracts field names from a formatting string.
Only `printf-style` (%-style) strings are supported.
Parameters
----------
format_string : str
A `printf-style` (%-style) string.
Returns
-------
list
A list of parameter names in the order that they appear in the
format string.
.. _printf-style:
https://docs.python.org/3/library/stdtypes.html#old-string-formatting`
"""
return re.findall(r"%\((.*?)\)", format_string)
[docs]
def set_file_formatter(self, formatter, logfile: str | PathLike[str] | None = None):
"""Change the file formatter of the logger."""
if self.logfile is None:
if logfile is None:
raise ValueError("Cannot set up file formatter, no log file specified.")
else:
self.logfile = logfile
self.file_handler = logging.FileHandler(self.logfile)
else:
self.logger.removeHandler(self.file_handler)
self.file_handler = logging.FileHandler(self.logfile)
file_format_attrs = self.parse_formatter(formatter)
self.logger.add_attributes(file_format_attrs)
self.file_formatter = logging.Formatter(formatter, self.date_format)
self.file_handler.setFormatter(self.file_formatter)
self.logger.addHandler(self.file_handler)
[docs]
def set_stream_formatter(self, formatter):
"""Change the stream formatter of the logger."""
stream_format_attrs = self.parse_formatter(formatter)
self.logger.add_attributes(stream_format_attrs)
self.logger.removeHandler(self.stream_handler)
self.stream_formatter = logging.Formatter(formatter, self.date_format)
self.stream_handler = logging.StreamHandler()
self.stream_handler.setFormatter(self.stream_formatter)
self.logger.addHandler(self.stream_handler)
[docs]
def log_call(self, message, level, func, ret_val, extra_data, *args, **kwargs):
"""Log function call."""
if not self._configured:
raise ValueError(
"Annalist not configured. Configure object after retrieval."
)
report = {}
signature = inspect.signature(func)
report["function_name"] = func.__name__
report["function_doc"] = clean_str(func.__doc__)
if signature.return_annotation == inspect._empty:
report["ret_annotation"] = None
else:
report["ret_annotation"] = signature.return_annotation
params = {}
all_args = list(args) + list(kwargs.values())
for i, ((name, param), arg) in enumerate(
zip(signature.parameters.items(), all_args)
):
if param.default == inspect._empty:
default_val = None
else:
default_val = param.default
if param.annotation == inspect._empty:
annotation = None
else:
annotation = param.annotation
if i > len(args):
kind = "positional"
value = arg
else:
kind = "keyword"
value = arg
params[name] = {
"default": default_val,
"annotation": annotation,
"kind": kind,
"value": value,
}
report["params"] = clean_str(params)
report["analyst_name"] = clean_str(self.analyst_name)
report["ret_val_type"] = type(ret_val)
report["ret_val"] = clean_str(ret_val)
if extra_data:
for key, val in extra_data.items():
report[key] = val
if level:
logger_level = LOGGER_LEVELS[level]
else:
logger_level = self.default_level
self.logger.log(
logger_level,
clean_str(message),
extra=report,
)
[docs]
def clean_str(s):
"""Clean a string for nice clean logging."""
process = {
ord("\t"): None,
ord("\f"): " ",
ord("\r"): None,
ord(","): ";",
ord("\n"): None,
}
s = str(s).translate(process)
return s