diff --git a/src/clog/_logger.py b/src/clog/_logger.py index fd2085b..21179e9 100644 --- a/src/clog/_logger.py +++ b/src/clog/_logger.py @@ -1,109 +1,179 @@ from __future__ import annotations import os import sys +import random +import string from io import TextIOWrapper -from typing import IO, Any, Type, Union +from typing import IO, Any, Union from collections import namedtuple -from .utils import common -from .utils import printfmt +from clog.utils import common +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. +## set the PIPE to STDOUT by default +STD_PIPE: IO[Any] = sys.stdout +## detect if there's a redirect +IS_STDOUT_REDIR: bool = os.isatty(sys.stdout.fileno()) +IS_STDERR_REDIR: bool = os.isatty(sys.stderr.fileno()) +## the default log out file +## log info namedtuple for storing class states +LOG_INFO_TUPLE = namedtuple('__LOG_INFO_TUPLE', ['isatty', 'lv', 'msg', 'sep', 'end']) - 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. +class LoggerStore(dict): + __logger_store: dict[str, Logger] = {} - 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 - __stdpipe: IO[Any] = sys.stdout - ## detect if there's a redirect - __IS_STDOUT_REDIR: bool = os.isatty(sys.stdout.fileno()) - __IS_STDERR_REDIR: bool = os.isatty(sys.stderr.fileno()) - ## the default log out file - __DEFAULT_OUT_FILE: str = os.path.realpath("dump.log") - ## log info namedtuple for storing class states - __LOG_INFO_TUPLE = namedtuple('__LOG_INFO_TUPLE', - ['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 __getitem__(self, __key: str) -> Logger: + logger = __class__.__logger_store[__key] + # perform the lookup and allow for Python to raise errors appropriately + return logger - def __new__(cls, *, out_f: Union[str, None] = None) -> 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. 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, return the object of that instance. """ - if cls.__instance__ is None: - cls.__instance__ = super(Logger, cls).__new__(cls) # establish singleton instance - ## handle if a custom file pathspec was given - if out_f is not None and isinstance(out_f, str): - ## verify path and convert to real pathspec. - if common.is_path_spec(out_f): - ## redefine the default log out attribute and create - ## public attribute for the currect log location - cls.log = cls.__default_out_file =\ - os.path.realpath(out_f).strip('"') + if 'name' in kwargs and LoggerStore.lookup(kwargs['name']): + return LoggerStore.getitem(kwargs['name']) - cls.__loginfo: Logger.__LOG_INFO_TUPLE = Logger.__LOG_INFO_TUPLE( - *([None] * 5) - ) # default the namedtuple to None on first instance + cls.__tmp_store_id = str(id(cls)) + LoggerStore.add(cls.__tmp_store_id, super(Logger, cls).__new__(cls)) - cls.printLog2File("----[New instance of script has been started]----", - file=cls.log, mode='w') + return LoggerStore.getitem(cls.__tmp_store_id) - return cls.__instance__ - @classmethod - def new(cls, *, out_f: Union[str, None] = None) -> Logger: - cls.__instance__ = None # destroy the instance - # reset all attributes to use the default file out - cls.log = cls.__default_out_file =\ - cls.__DEFAULT_OUT_FILE + 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 - return Logger(out_f=out_f) # construct new instance and return it + # 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)) - @classmethod - def debug(cls, *value: object, sep: Union[str, None] = None, - end: Union[str, None] = None, wrapping: bool = True, - strace: bool = True) -> Type[Logger]: + # 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 + if out_file is not None and isinstance(out_file, str): + ## verify path and convert to real pathspec. + if common.is_path_spec(out_file): + ## redefine the default log out attribute and create + ## public attribute for the currect log location + self._log = os.path.realpath(out_file) + + # default the namedtuple to None on first instance + self.__log_info = LOG_INFO_TUPLE(*([None] * 5)) + self.__std_pipe = STD_PIPE + + self.writeLog("----[New instance of script has been started]----", mode='w') + + + @property + def log(self) -> str: + return self._log + + + @property + def name(self) -> str: + return self._name + + + 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`. 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 the same message to the console. """ - Logger.printLog2File(*value, sep=sep, end=end, - wrapping=wrapping, strace=strace) + self.writeLog(*value, sep=sep, end=end, wrapping=wrapping, strace=strace) - Logger.__stdpipe = sys.stderr - cls.__loginfo = Logger.__LOG_INFO_TUPLE( - Logger.__IS_STDERR_REDIR, common.LogLevel.DEBUG, value, sep, end - ) + self.__std_pipe = sys.stdout + self.__log_info = LOG_INFO_TUPLE(IS_STDOUT_REDIR, common.LogLevel.INFO, value, sep, end) - return cls + return self - @classmethod - def warn(cls, *value: object, sep: Union[str, None] = None, - end: Union[str, None] = None, wrapping: bool = True, - strace: bool = True) -> Type[Logger]: + + def debug(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.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`. 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 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) - Logger.__stdpipe = sys.stderr - cls.__loginfo = Logger.__LOG_INFO_TUPLE( - Logger.__IS_STDERR_REDIR, common.LogLevel.WARN, value, sep, end - ) + self.__std_pipe = sys.stderr + self.__log_info = LOG_INFO_TUPLE(IS_STDERR_REDIR, common.LogLevel.WARN, value, sep, end) - return cls + return self - @classmethod - def error(cls, *value: object, sep: Union[str, None] = None, - end: Union[str, None] = None, wrapping: bool = True, - strace: bool = True) -> Type[Logger]: + + def error(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.ERROR`. 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 the same message to the console. """ - Logger.printLog2File(*value, level=common.LogLevel.ERROR, sep=sep, - end=end, wrapping=wrapping, strace=strace) + self.writeLog(*value, level=common.LogLevel.ERROR, sep=sep, end=end, wrapping=wrapping, strace=strace) - Logger.__stdpipe = sys.stderr - cls.__loginfo = Logger.__LOG_INFO_TUPLE( - Logger.__IS_STDERR_REDIR, common.LogLevel.ERROR, value, sep, end - ) + self.__std_pipe = sys.stderr + self.__log_info = LOG_INFO_TUPLE(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 well. That is, any message written using a pseudolog method. 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 associated to the formatting used when writing to a log file. """ - if Logger.__loginfo is not None: - Logger.__printLog__(Logger.__loginfo.isatty, - Logger.__loginfo.lv, Logger.__loginfo.msg, - Logger.__loginfo.sep, Logger.__loginfo.end) + if self.__log_info is not None: + self.__printLog__(self.__log_info.isatty, self.__log_info.lv, self.__log_info.msg, + self.__log_info.sep, self.__log_info.end + ) - @staticmethod - def printLog2File(*value: object, - level: Union[common.LogLevel, int] = common.LogLevel.DEBUG, - mode: str = 'a', file: Union[TextIOWrapper, str, None] = None, - sep: Union[str, None] = None, end: Union[str, None] = None, - wrapping: bool = True, strace: bool = True, header: bool = True) -> None: + + def writeLog(self, *value: object, level: Union[common.LogLevel, int] = common.LogLevel.INFO, mode: str = 'a', + 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 using 3.x syntax. All Familiar functionality can be passed to the method as found when calling `print()`, but comes with added @@ -228,15 +306,15 @@ class Logger: # class redeclaration & initialisation >>> import clog >>> >>> 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 >>> 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 - >>> clog.Logger.printLog2File("Establishing OS...", end="") + >>> clog.self.writeLog("Establishing OS...", end="") >>> 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 @@ -244,16 +322,6 @@ class Logger: # class redeclaration & initialisation If a new `Logger` instance was established, when `file` is obmitted, 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 \ dir(Logger) else sys._getframe(1) ## get the executing filename of where log was called @@ -274,15 +342,13 @@ class Logger: # class redeclaration & initialisation if wrapping: msg = printfmt.wrap(msg).replace('\n', '\n\t') - with open(file, mode, encoding="utf-8") as log: - Logger.__stdpipe = log # pre-requisite to write PIPE to file - Logger.__printLog__(False, level, (msg,), sep, end, False) + with open(self.log, mode, encoding="utf-8") as fp: + self.__std_pipe = fp # pre-requisite to write PIPE to file + self.__printLog__(False, level, (msg,), sep, end, False) - @staticmethod - def printLog(*value: object, - level: Union[int, common.LogLevel] = common.LogLevel.NORMAL, - sep: Union[str, None] = None, end: Union[str, None] = None, - file=None, flush: bool = True) -> None: + + def printLog(self, *value: object, level: Union[int, common.LogLevel] = common.LogLevel.INFO, + 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 using 3.x syntax. All Familiar functionality can be passed to the method as found when calling `print()`, but comes with added @@ -327,45 +393,38 @@ class Logger: # class redeclaration & initialisation ## configure PIPE to STDERR if logging is high enough if file is None: if level >= common.LogLevel.DEBUG: # type: ignore - Logger.__stdpipe = sys.stderr + self.__std_pipe = sys.stderr else: - Logger.__stdpipe = sys.stdout + self.__std_pipe = sys.stdout ## handle if the file is a 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 else: - Logger.printLog("Warning: logging function was called with a", - "file specifier parameter which is not a valid option.", + self.printLog("Warning: logging function was called with a", + "file specifier parameter which is not a valid option.", level=common.LogLevel.WARN) return ## display message to console with appropriate colouring ### :@NOTE: if there's a PIPE redirect, don't use colour ### 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__(isatty: bool, lv: Union[common.LogLevel, int], - msg: object, s: Union[str, None] = None, + def __printLog__(self, isatty: bool, lv: Union[common.LogLevel, int], msg: object, s: Union[str, None] = None, e: Union[str, None] = None, flsh: bool = True) -> None: """Private helper method responsible for invoking the built-in `print` function with appropriate keyword arugments. Method identifies the PIPE used and provide text highlighting accordingly. """ ## 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 - 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 - 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 - print(printfmt.Colours.NORMAL, end=e, file=Logger.__stdpipe, flush=flsh) + print(printfmt.Colours.NORMAL, end=e, file=self.__std_pipe, flush=flsh) 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