Updated _logger.py

Major changes to the structure behaviour when logging.

- Class no longer implements pseudologs as class methods. These are now
  instance methods but return the class instance to allow for method
  chaining.

- `class@Logger` is no longer a singleton pattern. Class may now either
  return a new logger instance, or return an existing logger if matched.
  A user may recreate an existing logger using the `Logger@new()` method
  instance.

- An existing logger may be returned either by creating a new logger
  with the same `name=` attribute, or by using the `Logger@get()` static
  method and passing the name of an existing logger.

- A context manager, `class@LoggerStore`, has been implemented. This is to
  allow for multiple loggers to be created and kept track of during the
  lifetime of a program. This class is fully static and only accessed by
  internal processes.

- The `Logger@printLog2File` method has been renamed to `writeLog()`. In
  the case a user wishes to write to console, `Logger@printLog` may be
  used. Note, pseudolog methods still print to log as default behaviour.

- Other changes to class methods to be appropriate for the amended
  changes outlined above.

- Bug fixes.
This commit is contained in:
Ethan Smith-Coss 2023-07-29 21:15:23 +01:00
parent a03342d81c
commit 8e6613412b
Signed by: TheOnePath
GPG Key ID: 4E7D436CE1A0BAF1

View File

@ -1,109 +1,179 @@
from __future__ import annotations from __future__ import annotations
import os import os
import sys import sys
import random
import string
from io import TextIOWrapper from io import TextIOWrapper
from typing import IO, Any, Type, Union from typing import IO, Any, Union
from collections import namedtuple from collections import namedtuple
from .utils import common from clog.utils import common
from .utils import printfmt from clog.utils import printfmt
#class A:
# """A simple logging class to write messages directly to the console
# or to a log file.
#
# Class contains a variety of methods to perform logging, all of which
# invoke a private wrapper method over the built-in `print()` function,
# with enhanced features built into the class methods.
#
# Make use of pseudolog methods (`Logger` pseudonyms) to quickly, and
# effectively write a message to a log file. These pseudolog methods
# modify the class state to remember the last message logged out to a
# file, in addition to its formatting, which can then be written to
# the console using the `Logger.withConsole()` method.
#
# Example of using pseudolog methods:
# ```py
# >>> import clog
# >>> logger = clog.Logger()
# >>>
# >>> logger.debug("A debug message with stacktrace!")
# >>> logger.error("Whoops! This should not be here.").withConsole()
# \033[91mWhoops! This should not be here.\033[0m
# >>>
# >>> msg = "Checking if 1 + 1 = 2..."
# >>> logger.debug(msg, end="\r").withConsole()
# >>> if 1 + 1 != 2:
# ... logger.error(msg + "failed.").withConsole()
# ... else:
# ... logger.debug(msg + "ok.").withConsole()
# ```
#
# Logger can output text to a console with colour, depending on its
# associated log level given. Different standard `PIPE`s can also
# be written to depending on the level of the log, or if a file
# redirect descriptor has been given. Note, colour is omitted when
# not writing to console on STDOUT or STDERR.
#"""
class Logger: # class redeclaration & initialisation
"""A simple logging class to write messages directly to the console
or to a log file.
Class contains a variety of methods to perform logging, all of which
invoke a private wrapper method over the built-in `print()` function,
with enhanced features built into the class methods.
Make use of pseudolog methods (`Logger` pseudonyms) to quickly, and
effectively write a message to a log file. These pseudolog methods
modify the class state to remember the last message logged out to a
file, in addition to its formatting, which can then be written to
the console using the `Logger.withConsole()` method.
Example of using pseudolog methods:
```py
>>> import clog
>>> logger = clog.Logger()
>>>
>>> logger.debug("A debug message with stacktrace!")
>>> logger.error("Whoops! This should not be here.").withConsole()
\033[91mWhoops! This should not be here.\033[0m
>>>
>>> msg = "Checking if 1 + 1 = 2..."
>>> logger.debug(msg, end="\r").withConsole()
>>> if 1 + 1 != 2:
... logger.error(msg + "failed.").withConsole()
... else:
... logger.debug(msg + "ok.").withConsole()
```
Logger can output text to a console with colour, depending on its
associated log level given. Different standard `PIPE`s can also
be written to depending on the level of the log, or if a file
redirect descriptor has been given. Note, colour is omitted when
not writing to console on STDOUT or STDERR.
"""
## set the PIPE to STDOUT by default ## set the PIPE to STDOUT by default
__stdpipe: IO[Any] = sys.stdout STD_PIPE: IO[Any] = sys.stdout
## detect if there's a redirect ## detect if there's a redirect
__IS_STDOUT_REDIR: bool = os.isatty(sys.stdout.fileno()) IS_STDOUT_REDIR: bool = os.isatty(sys.stdout.fileno())
__IS_STDERR_REDIR: bool = os.isatty(sys.stderr.fileno()) IS_STDERR_REDIR: bool = os.isatty(sys.stderr.fileno())
## the default log out file ## the default log out file
__DEFAULT_OUT_FILE: str = os.path.realpath("dump.log")
## log info namedtuple for storing class states ## log info namedtuple for storing class states
__LOG_INFO_TUPLE = namedtuple('__LOG_INFO_TUPLE', LOG_INFO_TUPLE = namedtuple('__LOG_INFO_TUPLE', ['isatty', 'lv', 'msg', 'sep', 'end'])
['isatty', 'lv', 'msg', 'sep', 'end'])
## create instance attribute for class singleton
__instance__ = None
## create default file instance which can change on construct
__default_out_file = __DEFAULT_OUT_FILE
## create instance attribute as read-only for log location
log = __default_out_file
def __new__(cls, *, out_f: Union[str, None] = None) -> Logger:
class LoggerStore(dict):
__logger_store: dict[str, Logger] = {}
def __getitem__(self, __key: str) -> Logger:
logger = __class__.__logger_store[__key]
# perform the lookup and allow for Python to raise errors appropriately
return logger
@staticmethod
def add(name: str, instance: Logger, *, _force = False) -> Logger:
if not name in __class__.__logger_store or _force:
__class__.__logger_store[name] = instance
return instance
@staticmethod
def rm(name: str, *, _all = False) -> None:
if name in __class__.__logger_store:
del __class__.__logger_store[name]
if _all:
for key in __class__.__logger_store:
del __class__.__logger_store[key]
@staticmethod
def lookup(name: str) -> bool:
return True if name in __class__.__logger_store else False
@staticmethod
def getitem(name: str) -> Logger:
return __class__()[name]
class Logger:
def __new__(cls, *_, **kwargs):
"""Construct a new instance of the class and initialise it. """Construct a new instance of the class and initialise it.
Constructor method is used to establish the class as a Constructor method is used to establish the class as a
Singleton+Factory pattern. A new instance is returned from Factory pattern. A new instance is returned from
the constructor, or if an existing instance is present, the constructor, or if an existing instance is present,
return the object of that instance. return the object of that instance.
""" """
if cls.__instance__ is None: if 'name' in kwargs and LoggerStore.lookup(kwargs['name']):
cls.__instance__ = super(Logger, cls).__new__(cls) # establish singleton instance return LoggerStore.getitem(kwargs['name'])
cls.__tmp_store_id = str(id(cls))
LoggerStore.add(cls.__tmp_store_id, super(Logger, cls).__new__(cls))
return LoggerStore.getitem(cls.__tmp_store_id)
def __init__(self, *, out_file: Union[str, None] = None, name: str = "", **_) -> None:
# if the temporary ID attribute isn't set, then we were accessing an existing logger, we can exit init
try:
__class__.__tmp_store_id
except AttributeError:
return
# if a name wasn't specified, use a random sequence of length 16
self._name = name or ''.join(random.choices(string.ascii_lowercase + string.digits, k=16))
# fetch the new logger and delete the temporary ID
store = LoggerStore.getitem(__class__.__tmp_store_id)
LoggerStore.rm(__class__.__tmp_store_id)
# add the logger back using the new name
LoggerStore.add(self._name, store)
# remove the temporary ID attribute
del __class__.__tmp_store_id
self._log = os.path.realpath(f".{self._name}.log")
## handle if a custom file pathspec was given ## handle if a custom file pathspec was given
if out_f is not None and isinstance(out_f, str): if out_file is not None and isinstance(out_file, str):
## verify path and convert to real pathspec. ## verify path and convert to real pathspec.
if common.is_path_spec(out_f): if common.is_path_spec(out_file):
## redefine the default log out attribute and create ## redefine the default log out attribute and create
## public attribute for the currect log location ## public attribute for the currect log location
cls.log = cls.__default_out_file =\ self._log = os.path.realpath(out_file)
os.path.realpath(out_f).strip('"')
cls.__loginfo: Logger.__LOG_INFO_TUPLE = Logger.__LOG_INFO_TUPLE( # default the namedtuple to None on first instance
*([None] * 5) self.__log_info = LOG_INFO_TUPLE(*([None] * 5))
) # default the namedtuple to None on first instance self.__std_pipe = STD_PIPE
cls.printLog2File("----[New instance of script has been started]----", self.writeLog("----[New instance of script has been started]----", mode='w')
file=cls.log, mode='w')
return cls.__instance__
@classmethod @property
def new(cls, *, out_f: Union[str, None] = None) -> Logger: def log(self) -> str:
cls.__instance__ = None # destroy the instance return self._log
# reset all attributes to use the default file out
cls.log = cls.__default_out_file =\
cls.__DEFAULT_OUT_FILE
return Logger(out_f=out_f) # construct new instance and return it
@classmethod @property
def debug(cls, *value: object, sep: Union[str, None] = None, def name(self) -> str:
end: Union[str, None] = None, wrapping: bool = True, return self._name
strace: bool = True) -> Type[Logger]:
def new(self, *, out_file: Union[str, None] = None) -> Logger:
del LoggerStore()[self.name]
return type(self)(out_file=out_file, name=self.name) # construct new instance and return it
@staticmethod
def get(name) -> Logger:
if not LoggerStore.lookup(name):
raise LookupError(f"Cannot find logger {repr(name)} in store.")
return LoggerStore()[name]
def info(self, *value: object, sep: Union[str, None] = None, end: Union[str, None] = None, wrapping: bool = True,
strace: bool = False) -> Logger:
"""Pseudolog for writing to log file with level `LogLevel.DEBUG`. """Pseudolog for writing to log file with level `LogLevel.DEBUG`.
Method is invoked on a `Logger` instance and will directly write Method is invoked on a `Logger` instance and will directly write
@ -114,20 +184,36 @@ class Logger: # class redeclaration & initialisation
method can be invoked directly afterwards (or later) to write method can be invoked directly afterwards (or later) to write
the same message to the console. the same message to the console.
""" """
Logger.printLog2File(*value, sep=sep, end=end, self.writeLog(*value, sep=sep, end=end, wrapping=wrapping, strace=strace)
wrapping=wrapping, strace=strace)
Logger.__stdpipe = sys.stderr self.__std_pipe = sys.stdout
cls.__loginfo = Logger.__LOG_INFO_TUPLE( self.__log_info = LOG_INFO_TUPLE(IS_STDOUT_REDIR, common.LogLevel.INFO, value, sep, end)
Logger.__IS_STDERR_REDIR, common.LogLevel.DEBUG, value, sep, end
)
return cls return self
@classmethod
def warn(cls, *value: object, sep: Union[str, None] = None, def debug(self, *value: object, sep: Union[str, None] = None, end: Union[str, None] = None, wrapping: bool = True,
end: Union[str, None] = None, wrapping: bool = True, strace: bool = True) -> Logger:
strace: bool = True) -> Type[Logger]: """Pseudolog for writing to log file with level `LogLevel.DEBUG`.
Method is invoked on a `Logger` instance and will directly write
out to a log file using the built-in `printLog2File` helper method.
The returned object is a modified instance of the `Logger` class
which stores information regarding what was written to the log
file, and what formatting was applied. The `Logger.withConsole()`
method can be invoked directly afterwards (or later) to write
the same message to the console.
"""
self.writeLog(*value, sep=sep, end=end, wrapping=wrapping, strace=strace)
self.__std_pipe = sys.stderr
self.__log_info = LOG_INFO_TUPLE(IS_STDERR_REDIR, common.LogLevel.DEBUG, value, sep, end)
return self
def warn(self, *value: object, sep: Union[str, None] = None, end: Union[str, None] = None, wrapping: bool = True,
strace: bool = True) -> Logger:
"""Pseudolog for writing to log file with level `LogLevel.WARN`. """Pseudolog for writing to log file with level `LogLevel.WARN`.
Method is invoked on a `Logger` instance and will directly write Method is invoked on a `Logger` instance and will directly write
@ -138,20 +224,17 @@ class Logger: # class redeclaration & initialisation
method can be invoked directly afterwards (or later) to write method can be invoked directly afterwards (or later) to write
the same message to the console. the same message to the console.
""" """
Logger.printLog2File(*value, level=common.LogLevel.WARN, sep=sep, self.writeLog(*value, level=common.LogLevel.WARN, sep=sep,
end=end, wrapping=wrapping, strace=strace) end=end, wrapping=wrapping, strace=strace)
Logger.__stdpipe = sys.stderr self.__std_pipe = sys.stderr
cls.__loginfo = Logger.__LOG_INFO_TUPLE( self.__log_info = LOG_INFO_TUPLE(IS_STDERR_REDIR, common.LogLevel.WARN, value, sep, end)
Logger.__IS_STDERR_REDIR, common.LogLevel.WARN, value, sep, end
)
return cls return self
@classmethod
def error(cls, *value: object, sep: Union[str, None] = None, def error(self, *value: object, sep: Union[str, None] = None, end: Union[str, None] = None, wrapping: bool = True,
end: Union[str, None] = None, wrapping: bool = True, strace: bool = True) -> Logger:
strace: bool = True) -> Type[Logger]:
"""Pseudolog for writing to log file with level `LogLevel.ERROR`. """Pseudolog for writing to log file with level `LogLevel.ERROR`.
Method is invoked on a `Logger` instance and will directly write Method is invoked on a `Logger` instance and will directly write
@ -162,18 +245,15 @@ class Logger: # class redeclaration & initialisation
method can be invoked directly afterwards (or later) to write method can be invoked directly afterwards (or later) to write
the same message to the console. the same message to the console.
""" """
Logger.printLog2File(*value, level=common.LogLevel.ERROR, sep=sep, self.writeLog(*value, level=common.LogLevel.ERROR, sep=sep, end=end, wrapping=wrapping, strace=strace)
end=end, wrapping=wrapping, strace=strace)
Logger.__stdpipe = sys.stderr self.__std_pipe = sys.stderr
cls.__loginfo = Logger.__LOG_INFO_TUPLE( self.__log_info = LOG_INFO_TUPLE(IS_STDERR_REDIR, common.LogLevel.ERROR, value, sep, end)
Logger.__IS_STDERR_REDIR, common.LogLevel.ERROR, value, sep, end
)
return cls return self
@staticmethod
def withConsole() -> None: def withConsole(self) -> None:
"""Write the last message logged to a file to the console as """Write the last message logged to a file to the console as
well. That is, any message written using a pseudolog method. well. That is, any message written using a pseudolog method.
The last message is determined by a pseudolog, which modifies The last message is determined by a pseudolog, which modifies
@ -187,17 +267,15 @@ class Logger: # class redeclaration & initialisation
information regarding the formatting of the message is directly information regarding the formatting of the message is directly
associated to the formatting used when writing to a log file. associated to the formatting used when writing to a log file.
""" """
if Logger.__loginfo is not None: if self.__log_info is not None:
Logger.__printLog__(Logger.__loginfo.isatty, self.__printLog__(self.__log_info.isatty, self.__log_info.lv, self.__log_info.msg,
Logger.__loginfo.lv, Logger.__loginfo.msg, self.__log_info.sep, self.__log_info.end
Logger.__loginfo.sep, Logger.__loginfo.end) )
@staticmethod
def printLog2File(*value: object, def writeLog(self, *value: object, level: Union[common.LogLevel, int] = common.LogLevel.INFO, mode: str = 'a',
level: Union[common.LogLevel, int] = common.LogLevel.DEBUG, sep: Union[str, None] = None, end: Union[str, None] = None, wrapping: bool = True, strace: bool = True,
mode: str = 'a', file: Union[TextIOWrapper, str, None] = None, header: bool = True) -> None:
sep: Union[str, None] = None, end: Union[str, None] = None,
wrapping: bool = True, strace: bool = True, header: bool = True) -> None:
"""Wrapper method over the built-in `print()` function defined """Wrapper method over the built-in `print()` function defined
using 3.x syntax. All Familiar functionality can be passed to using 3.x syntax. All Familiar functionality can be passed to
the method as found when calling `print()`, but comes with added the method as found when calling `print()`, but comes with added
@ -228,15 +306,15 @@ class Logger: # class redeclaration & initialisation
>>> import clog >>> import clog
>>> >>>
>>> log_file = ".dump.log" >>> log_file = ".dump.log"
>>> clog.Logger.printLog2File("Hello from log file!", file=log_file) >>> clog.self.writeLog("Hello from log file!", file=log_file)
>>> # note, we can still pass a TextIOWrapper object >>> # note, we can still pass a TextIOWrapper object
>>> with open(log_file, 'a') as f: >>> with open(log_file, 'a') as f:
... clog.Logger.printLog2File("Using own wapper.", file=f) ... clog.self.writeLog("Using own wapper.", file=f)
... ...
>>> # process controlled logging >>> # process controlled logging
>>> clog.Logger.printLog2File("Establishing OS...", end="") >>> clog.self.writeLog("Establishing OS...", end="")
>>> import os >>> import os
>>> clog.Logger.printLog2File(os.name, header=False) >>> clog.self.writeLog(os.name, header=False)
``` ```
NOTE: if `file` is omitted when invoking method, the default NOTE: if `file` is omitted when invoking method, the default
@ -244,16 +322,6 @@ class Logger: # class redeclaration & initialisation
If a new `Logger` instance was established, when `file` is obmitted, If a new `Logger` instance was established, when `file` is obmitted,
the default pathspec used is defined by the `Logger` instance. the default pathspec used is defined by the `Logger` instance.
""" """
## handle if no file parameter was given
if file is None or not isinstance(file, (TextIOWrapper, str)):
file = Logger.__default_out_file
if not isinstance(file, str):
file = file.name
if not os.path.exists(file):
file = Logger.__default_out_file
_frame = sys._getframe(2) if sys._getframe(1).f_code.co_name in \ _frame = sys._getframe(2) if sys._getframe(1).f_code.co_name in \
dir(Logger) else sys._getframe(1) dir(Logger) else sys._getframe(1)
## get the executing filename of where log was called ## get the executing filename of where log was called
@ -274,15 +342,13 @@ class Logger: # class redeclaration & initialisation
if wrapping: if wrapping:
msg = printfmt.wrap(msg).replace('\n', '\n\t') msg = printfmt.wrap(msg).replace('\n', '\n\t')
with open(file, mode, encoding="utf-8") as log: with open(self.log, mode, encoding="utf-8") as fp:
Logger.__stdpipe = log # pre-requisite to write PIPE to file self.__std_pipe = fp # pre-requisite to write PIPE to file
Logger.__printLog__(False, level, (msg,), sep, end, False) self.__printLog__(False, level, (msg,), sep, end, False)
@staticmethod
def printLog(*value: object, def printLog(self, *value: object, level: Union[int, common.LogLevel] = common.LogLevel.INFO,
level: Union[int, common.LogLevel] = common.LogLevel.NORMAL, sep: Union[str, None] = None, end: Union[str, None] = None, file=None, flush: bool = True) -> None:
sep: Union[str, None] = None, end: Union[str, None] = None,
file=None, flush: bool = True) -> None:
"""Wrapper method over the built-in `print()` function defined """Wrapper method over the built-in `print()` function defined
using 3.x syntax. All Familiar functionality can be passed to using 3.x syntax. All Familiar functionality can be passed to
the method as found when calling `print()`, but comes with added the method as found when calling `print()`, but comes with added
@ -327,15 +393,15 @@ class Logger: # class redeclaration & initialisation
## configure PIPE to STDERR if logging is high enough ## configure PIPE to STDERR if logging is high enough
if file is None: if file is None:
if level >= common.LogLevel.DEBUG: # type: ignore if level >= common.LogLevel.DEBUG: # type: ignore
Logger.__stdpipe = sys.stderr self.__std_pipe = sys.stderr
else: else:
Logger.__stdpipe = sys.stdout self.__std_pipe = sys.stdout
## handle if the file is a TextIOWrapper ## handle if the file is a TextIOWrapper
elif isinstance(file, TextIOWrapper): elif isinstance(file, TextIOWrapper):
Logger.__stdpipe = file # `print` will handle this as is. self.__std_pipe = file # `print` will handle this as is.
## otherwise the method was given an invalid argument ## otherwise the method was given an invalid argument
else: else:
Logger.printLog("Warning: logging function was called with a", self.printLog("Warning: logging function was called with a",
"file specifier parameter which is not a valid option.", "file specifier parameter which is not a valid option.",
level=common.LogLevel.WARN) level=common.LogLevel.WARN)
return return
@ -343,29 +409,22 @@ class Logger: # class redeclaration & initialisation
## display message to console with appropriate colouring ## display message to console with appropriate colouring
### :@NOTE: if there's a PIPE redirect, don't use colour ### :@NOTE: if there's a PIPE redirect, don't use colour
### for that redirect PIPE ### for that redirect PIPE
self.__printLog__(IS_STDOUT_REDIR or IS_STDERR_REDIR, level, value, sep, end, flush)
if level < common.LogLevel.WARN: # type: ignore # handle output for STDOUT
Logger.__printLog__(Logger.__IS_STDOUT_REDIR, level, value,
sep, end, flush)
else: # handle output for STDERR
Logger.__printLog__(Logger.__IS_STDERR_REDIR, level, value,
sep, end, flush)
@staticmethod def __printLog__(self, isatty: bool, lv: Union[common.LogLevel, int], msg: object, s: Union[str, None] = None,
def __printLog__(isatty: bool, lv: Union[common.LogLevel, int],
msg: object, s: Union[str, None] = None,
e: Union[str, None] = None, flsh: bool = True) -> None: e: Union[str, None] = None, flsh: bool = True) -> None:
"""Private helper method responsible for invoking the built-in """Private helper method responsible for invoking the built-in
`print` function with appropriate keyword arugments. Method `print` function with appropriate keyword arugments. Method
identifies the PIPE used and provide text highlighting accordingly. identifies the PIPE used and provide text highlighting accordingly.
""" """
## handle if we have a redirect ## handle if we have a redirect
if isatty and (Logger.__stdpipe is sys.stdout or Logger.__stdpipe is sys.stderr): if isatty and (self.__std_pipe is sys.stdout or self.__std_pipe is sys.stderr):
## write ANSI code to start coloured text ## write ANSI code to start coloured text
print(printfmt.log_as_col(lv), end="", file=Logger.__stdpipe, flush=flsh) print(printfmt.log_as_col(lv), end="", file=self.__std_pipe, flush=flsh)
## unpack the object and pass to print ## unpack the object and pass to print
print(*msg, sep=s, end="", file=Logger.__stdpipe, flush=flsh) # type: ignore print(*msg, sep=s, end="", file=self.__std_pipe, flush=flsh) # type: ignore
## reset the colour sequence back to normal ## reset the colour sequence back to normal
print(printfmt.Colours.NORMAL, end=e, file=Logger.__stdpipe, flush=flsh) print(printfmt.Colours.NORMAL, end=e, file=self.__std_pipe, flush=flsh)
else: else:
print(*msg, sep=s, end=e, file=Logger.__stdpipe, flush=flsh) # type: ignore print(*msg, sep=s, end=e, file=self.__std_pipe, flush=flsh) # type: ignore