diff --git a/IoTuring/ClassManager/ClassManager.py b/IoTuring/ClassManager/ClassManager.py index a522fc0de..751acc27c 100644 --- a/IoTuring/ClassManager/ClassManager.py +++ b/IoTuring/ClassManager/ClassManager.py @@ -51,7 +51,7 @@ def GetModuleFilePaths(self) -> list[Path]: if not classesRootPath.exists: raise Exception(f"Path does not exist: {classesRootPath}") - self.Log(self.LOG_DEVELOPMENT, + self.Log(self.LOG_DEBUG, f'Looking for python files in "{classesRootPath}"...') python_files = classesRootPath.rglob("*.py") @@ -63,7 +63,7 @@ def GetModuleFilePaths(self) -> list[Path]: raise FileNotFoundError( f"No module files found in {classesRootPath}") - self.Log(self.LOG_DEVELOPMENT, + self.Log(self.LOG_DEBUG, f"Found {str(len(filepaths))} modules files") return filepaths diff --git a/IoTuring/Configurator/Configurator.py b/IoTuring/Configurator/Configurator.py index 41c8bf72e..532b10f64 100644 --- a/IoTuring/Configurator/Configurator.py +++ b/IoTuring/Configurator/Configurator.py @@ -1,6 +1,5 @@ import os import subprocess -import shutil import sys from IoTuring.Configurator.MenuPreset import QuestionPreset @@ -13,6 +12,7 @@ from IoTuring.Logger.LogObject import LogObject from IoTuring.Exceptions.Exceptions import UserCancelledException from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD +from IoTuring.MyApp.SystemConsts import TerminalDetection from InquirerPy import inquirer from InquirerPy.separator import Separator @@ -457,16 +457,13 @@ def DisplayMenu(self, choices: list, message: str = "", add_back_choice=True, ** # Default max_height: kwargs["max_height"] = "100%" - # Actual lines in the terminal. fallback to 0 on error: - terminal_lines = shutil.get_terminal_size(fallback=(0, 0)).lines - # Check for pinned messages: - if terminal_lines > 0 and self.pinned_lines > 0: + if TerminalDetection.CheckTerminalSupportsSize() and self.pinned_lines > 0: # Lines of message and instruction if too long: if "instruction" in kwargs: - message_lines = ((len(kwargs["instruction"]) + len(message) + 3) - / shutil.get_terminal_size().columns) // 1 + message_lines = TerminalDetection.CalculateNumberOfLines( + len(kwargs["instruction"]) + len(message) + 3) # Add only the line of the message: else: message_lines = 1 @@ -475,6 +472,8 @@ def DisplayMenu(self, choices: list, message: str = "", add_back_choice=True, ** required_lines = len(choices) + \ self.pinned_lines + message_lines + terminal_lines = TerminalDetection.GetTerminalLines() + # Set the calculated height: if required_lines > terminal_lines: kwargs["max_height"] = terminal_lines \ diff --git a/IoTuring/Configurator/ConfiguratorIO.py b/IoTuring/Configurator/ConfiguratorIO.py index a06688135..c2becf3d0 100644 --- a/IoTuring/Configurator/ConfiguratorIO.py +++ b/IoTuring/Configurator/ConfiguratorIO.py @@ -33,7 +33,7 @@ def readConfigurations(self): try: with open(self.getFilePath(), "r", encoding="utf-8") as f: config = json.loads(f.read()) - self.Log(self.LOG_MESSAGE, f"Loaded \"{self.getFilePath()}\"") + self.Log(self.LOG_INFO, f"Loaded \"{self.getFilePath()}\"") except FileNotFoundError: self.Log(self.LOG_WARNING, f"It seems you don't have a configuration yet. Use configuration mode (-c) to enable your favourite entities and warehouses.\ \nConfigurations will be saved in \"{str(self.getFolderPath())}\"") @@ -48,7 +48,7 @@ def writeConfigurations(self, data): self.createFolderPathIfDoesNotExist() with open(self.getFilePath(), "w", encoding="utf-8") as f: f.write(json.dumps(data, indent=4, ensure_ascii=False)) - self.Log(self.LOG_MESSAGE, f"Saved \"{str(self.getFilePath())}\"") + self.Log(self.LOG_INFO, f"Saved \"{str(self.getFilePath())}\"") except Exception as e: self.Log(self.LOG_ERROR, f"Error saving configuration file: {str(e)}") sys.exit(str(e)) @@ -152,7 +152,7 @@ def manageOldConfig(self, moveFile: bool) -> None: self.createFolderPathIfDoesNotExist() # copy file from old to new location self.oldFolderPath().joinpath(CONFIGURATION_FILE_NAME).rename(self.getFilePath()) - self.Log(self.LOG_MESSAGE, + self.Log(self.LOG_INFO, f"Copied to \"{str(self.getFilePath())}\"") else: # create dont move file @@ -161,7 +161,7 @@ def manageOldConfig(self, moveFile: bool) -> None: "This file is here to remember you that you don't want to move the configuration file into the new location.", "If you want to move it, delete this file and run the script in -c mode." ])) - self.Log(self.LOG_MESSAGE, " ".join([ + self.Log(self.LOG_INFO, " ".join([ "You won't be asked again. A new blank configuration will be used;", f"if you want to move the existing configuration file, delete \"{self.oldFolderPath().joinpath(DONT_MOVE_FILE_FILENAME)}", "and run the script in -c mode." diff --git a/IoTuring/Configurator/ConfiguratorLoader.py b/IoTuring/Configurator/ConfiguratorLoader.py index dbb9094b6..bd79502c1 100644 --- a/IoTuring/Configurator/ConfiguratorLoader.py +++ b/IoTuring/Configurator/ConfiguratorLoader.py @@ -68,7 +68,16 @@ def LoadEntities(self) -> list[Entity]: # - pass the configuration to the warehouse function that uses the configuration to init the Warehouse # - append the Warehouse to the list - def LoadSettings(self) -> list[Settings]: + def LoadSettings(self, early_init:bool = False) -> list[Settings]: + """ Load all Settings classes + + Args: + early_init (bool, optional): True when loaded before configurator menu, False when added to SettingsManager. Defaults to False. + + Returns: + list[Settings]: Loaded classes + """ + settings = [] scm = ClassManager(KEY_SETTINGS) settingsClasses = scm.ListAvailableClasses() @@ -80,11 +89,11 @@ def LoadSettings(self) -> list[Settings]: .GetConfigsOfType(sClass.NAME) if savedConfigs: - sc = sClass(savedConfigs[0]) + sc = sClass(savedConfigs[0], early_init) # Fallback to default: else: - sc = sClass(sClass.GetDefaultConfigurations()) + sc = sClass(sClass.GetDefaultConfigurations(), early_init) settings.append(sc) return settings diff --git a/IoTuring/Logger/LogLevel.py b/IoTuring/Logger/LogLevel.py index 01575055e..c4919ec9a 100644 --- a/IoTuring/Logger/LogLevel.py +++ b/IoTuring/Logger/LogLevel.py @@ -1,4 +1,5 @@ -from IoTuring.Logger import consts +from __future__ import annotations +import logging from IoTuring.Logger.Colors import Colors from IoTuring.Exceptions.Exceptions import UnknownLoglevelException @@ -7,19 +8,24 @@ class LogLevel: """ A loglevel with numeric and string values""" def __init__(self, level_const: str) -> None: - level_dict = next( - (l for l in consts.LOG_LEVELS if l["const"] == level_const), None) - if not level_dict: + self.string = level_const.upper() + if self.string.startswith("LOG_"): + self.string = self.string[4:] + + try: + self.number = getattr(logging, self.string) + except AttributeError: raise UnknownLoglevelException(level_const) - self.const = level_const - self.string = level_dict["string"] - self.number = int(level_dict["number"]) - if "color" in level_dict.keys(): - self.color = getattr(Colors, level_dict["color"]) + # WARNING is yellow: + if self.number == 30: + self.color = Colors.yellow + # ERROR and CRITICAL red: + elif self.number > 30: + self.color = Colors.red else: - self.color = None + self.color = "" def __str__(self) -> str: return self.string @@ -27,22 +33,24 @@ def __str__(self) -> str: def __int__(self) -> int: return self.number - def get_colored_string(self, string: str) -> str: - """ Get colored text according to LogLevel """ - if self.color: - out_string = self.color + string + Colors.reset - else: - out_string = string - return out_string - - class LogLevelObject: """ Base class for loglevel properties """ - LOG_MESSAGE = LogLevel("LOG_MESSAGE") - LOG_ERROR = LogLevel("LOG_ERROR") - LOG_WARNING = LogLevel("LOG_WARNING") - LOG_INFO = LogLevel("LOG_INFO") - LOG_DEBUG = LogLevel("LOG_DEBUG") - LOG_DEVELOPMENT = LogLevel("LOG_DEVELOPMENT") + LOG_DEBUG = LogLevel("DEBUG") + LOG_INFO = LogLevel("INFO") + LOG_WARNING = LogLevel("WARNING") + LOG_ERROR = LogLevel("ERROR") + LOG_CRITICAL = LogLevel("CRITICAL") + + LOGTARGET_FILE = "file" + LOGTARGET_CONSOLE = "console" + + @classmethod + def GetLoglevels(cls) -> list[LogLevel]: + """Get all available log levels + + Returns: + list[LogLevel]: List of LogLevel objects + """ + return [getattr(cls, l) for l in dir(cls) if isinstance(getattr(cls, l), LogLevel)] diff --git a/IoTuring/Logger/LogObject.py b/IoTuring/Logger/LogObject.py index bc452dc64..09536f8ef 100644 --- a/IoTuring/Logger/LogObject.py +++ b/IoTuring/Logger/LogObject.py @@ -3,11 +3,13 @@ class LogObject(LogLevelObject): - def Log(self, loglevel: LogLevel, message): - Logger().Log( + def Log(self, loglevel: LogLevel, message, **kwargs): + logger = Logger() + logger.Log( source=self.LogSource(), message=message, - loglevel=loglevel + loglevel=loglevel, + **kwargs ) # to override in classes where I want a source different from class name diff --git a/IoTuring/Logger/Logger.py b/IoTuring/Logger/Logger.py index 4cc098b45..e8f6b350a 100644 --- a/IoTuring/Logger/Logger.py +++ b/IoTuring/Logger/Logger.py @@ -1,14 +1,16 @@ -import sys -import os -import inspect -from datetime import datetime -import json -import threading -from io import TextIOWrapper +from __future__ import annotations +import logging +import logging.handlers +from pathlib import Path + +from IoTuring.Logger.Colors import Colors from IoTuring.Logger import consts from IoTuring.Logger.LogLevel import LogLevelObject, LogLevel from IoTuring.Exceptions.Exceptions import UnknownLoglevelException +from IoTuring.MyApp.SystemConsts import TerminalDetection +from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD +from IoTuring.MyApp.App import App class Singleton(type): @@ -18,161 +20,337 @@ class Singleton(type): _instances = {} - def __call__(cls): + def __call__(cls, *args, **kwargs): if cls not in cls._instances: cls._instances[cls] = super(Singleton, cls).__call__() return cls._instances[cls] -class Logger(LogLevelObject, metaclass=Singleton): +class LogTargetFilter(logging.Filter): + """ Log filter for log target (console or file) """ + + def __init__(self, target: str) -> None: + self.target = target - lock = threading.Lock() + def filter(self, record) -> bool: + if not getattr(record, "logtarget") or self.target in getattr(record, "logtarget"): + return True + else: + return False - log_filename = "" - log_file_descriptor = None - # Default log levels: - console_log_level = LogLevel(consts.CONSOLE_LOG_LEVEL) - file_log_level = LogLevel(consts.FILE_LOG_LEVEL) +class LogLevelFilter(logging.Filter): + """ Log filter for loglevel, for file logging from buffer""" - def __init__(self) -> None: + def __init__(self, loglevel: LogLevel) -> None: + self.loglevel = loglevel - self.terminalSupportsColors = Logger.checkTerminalSupportsColors() - - # Prepare the log - self.SetLogFilename() - # Open the file descriptor - self.GetLogFileDescriptor() - - # Override log level from envvar: - try: - if os.getenv("IOTURING_LOG_LEVEL"): - level_override = LogLevel(str(os.getenv("IOTURING_LOG_LEVEL"))) - self.console_log_level = level_override - except UnknownLoglevelException as e: - self.Log(self.LOG_ERROR, "Logger", - f"Unknown Loglevel: {e.loglevel}") - - def SetLogFilename(self) -> str: - """ Set filename with timestamp and also call setup folder """ - dateTimeObj = datetime.now() - self.log_filename = os.path.join( - self.SetupFolder(), dateTimeObj.strftime(consts.LOG_FILENAME_FORMAT).replace(":", "_")) - return self.log_filename - - def SetupFolder(self) -> str: - """ Check if exists (or create) the folder of logs inside this file's folder """ - thisFolder = os.path.dirname(inspect.getfile(Logger)) - newFolder = os.path.join(thisFolder, consts.LOGS_FOLDER) - if not os.path.exists(newFolder): - os.mkdir(newFolder) - - return newFolder - - def GetMessageDatetimeString(self) -> str: - now = datetime.now() - return now.strftime(consts.MESSAGE_DATETIME_FORMAT) - - # LOG - - def Log(self, loglevel: LogLevel, source: str, message, printToConsole=True, writeToFile=True) -> None: - if type(message) == dict: - self.LogDict(loglevel, source, message, - printToConsole, writeToFile) - return # Log dict will call this function so I don't need to go down at the moment - elif type(message) == list: - self.LogList(loglevel, source, message, - printToConsole, writeToFile) - return # Log list will call this function so I don't need to go down at the moment - - message = str(message) - # Call this function for each line of the message if there are more than one line. - messageLines = message.split("\n") - if len(messageLines) > 1: - for line in messageLines: - self.Log(loglevel, source, line, - printToConsole, writeToFile) - return # Stop the function then because I've already called this function from each line so I don't have to go down here - - prestring = f"[ {self.GetMessageDatetimeString()} | {str(loglevel).center(consts.STRINGS_LENGTH[0])} | " \ - + f"{source.center(consts.STRINGS_LENGTH[1])}]{consts.PRESTRING_MESSAGE_SEPARATOR_LEN*' '}" # justify - - # Manage string to print in more lines if it's too long - while len(message) > 0: - string = prestring+message[:consts.MESSAGE_WIDTH] - # Cut for next iteration if message is longer than a line - message = message[consts.MESSAGE_WIDTH:] - # then I add the dash to the row - if (len(message) > 0 and string[-1] != " " and string[-1] != "." and string[-1] != ","): - string = string + '-' # Print new line indicator if I will go down in the next iteration - self.PrintAndSave(string, loglevel, printToConsole, writeToFile) - # -1 + space cause if the char in the prestring isn't a space, it will be directly attached to my message without a space - - prestring = (len(prestring)-consts.PRESTRING_MESSAGE_SEPARATOR_LEN) * \ - consts.LONG_MESSAGE_PRESTRING_CHAR+consts.PRESTRING_MESSAGE_SEPARATOR_LEN*' ' - - def LogDict(self, loglevel, source, message_dict: dict, *args): - try: - string = json.dumps(message_dict, indent=4, sort_keys=False, - default=lambda o: '') - lines = string.splitlines() - for line in lines: - self.Log(loglevel, source, "> "+line, *args) - except Exception as e: - self.Log(self.LOG_ERROR, source, "Can't print dictionary content") - - def LogList(self, loglevel, source, message_list: list, *args): - try: - for index, item in enumerate(message_list): - if type(item) == dict or type(item) == list: - self.Log(loglevel, source, "Item #"+str(index), *args) - self.Log(loglevel, source, item, *args) - else: - self.Log(loglevel, source, - f"{str(index)}: {str(item)}", *args) - - except: - self.Log(self.LOG_ERROR, source, "Can't print dictionary content") - - # Both print and save to file - def PrintAndSave(self, string, loglevel: LogLevel, printToConsole=True, writeToFile=True) -> None: - - if printToConsole and int(loglevel) <= int(self.console_log_level): - self.ColoredPrint(string, loglevel) - - if writeToFile and int(loglevel) <= int(self.file_log_level): - # acquire the lock - with self.lock: - self.GetLogFileDescriptor().write(string+' \n') - # so I can see the log in real time from a reader - self.GetLogFileDescriptor().flush() - - def ColoredPrint(self, string, loglevel: LogLevel) -> None: - if not self.terminalSupportsColors: - print(string) + def filter(self, record) -> bool: + if int(self.loglevel) > int(record.levelno): + return False else: - print(loglevel.get_colored_string(string)) + return True + + +class LogMessage(LogLevelObject): + """Class for formatting log messages""" + + def __init__(self, source: str, message, color: str = "", logtarget: str = "") -> None: + + self.source = source + self.color = color + self.logtarget = logtarget + + self.msg = " ".join(self.MessageToList(message)) + + self.extra = {"source": self.source, + "file_message": self.msg, + "console_message": self.GetConsoleMessage(self.MessageToList(message)), + "color_prefix": self.GetColors()["prefix"], + "color_suffix": self.GetColors()["suffix"], + "logtarget": self.logtarget + } + + def GetColors(self) -> dict: + """Get color prefix and suffix""" + if TerminalDetection.CheckTerminalSupportsColors(): + return {"prefix": self.color, "suffix": Colors.reset} + else: + return {"prefix": "", "suffix": ""} + + def SetPrefixLength(self) -> None: + """Calculate the length of the log prefix""" + default_source_len = next( + (l for s, l in consts.LOG_PREFIX_LENGTHS.items() if s.startswith("source"))) + extra_len = max(len(self.source) - default_source_len, 0) + self.console_prefix_length = Logger().console_prefix_length + self.prefix_length = self.console_prefix_length + extra_len + + def GetConsoleMessage(self, messagelines: list[str]) -> str: + """Get the formatted message for console logging + + Args: + messagelines (list[str]): Message as separate lines + + Returns: + str: the formatted message + """ + + # Return single line if unsupported or too small terminal, or console logging disabled: + if not TerminalDetection.CheckTerminalSupportsSize() \ + or TerminalDetection.GetTerminalColumns() < consts.MIN_CONSOLE_WIDTH \ + or self.logtarget == self.LOGTARGET_FILE: + return self.msg + + # Calculate the length of the prefix: + self.SetPrefixLength() + + # Single line log, and it can be displayed without linebreaks, next to the prefix: + if len(messagelines) == 1 \ + and TerminalDetection.CalculateNumberOfLines(len(messagelines[0]) + self.prefix_length) == 1: + return messagelines[0] - def GetLogFileDescriptor(self) -> TextIOWrapper: - if self.log_file_descriptor is None: - self.log_file_descriptor = open(self.log_filename, "a", encoding="utf-8") + # Available space for the message + line_length = TerminalDetection.GetTerminalColumns() - \ + self.console_prefix_length - return self.log_file_descriptor + final_lines = [] - def CloseFile(self) -> None: - if self.log_file_descriptor is not None: - self.log_file_descriptor.close() - self.log_file_descriptor = None + # If the prefix longer in this line than de default, make the first line shorter: + if self.prefix_length > self.console_prefix_length: + first_line_len = TerminalDetection.GetTerminalColumns() - \ + self.prefix_length + final_lines.append(messagelines[0][:first_line_len]) + messagelines[0] = messagelines[0][first_line_len:] + + # Cut the to the correct length: + for l in messagelines: + final_lines.extend([l[i:i+line_length] + for i in range(0, len(l), line_length)]) + + # Linebrakes and spaces: + line_prefix = "\n" + " " * self.console_prefix_length + return line_prefix.join(final_lines) @staticmethod - def checkTerminalSupportsColors(): + def MessageToList(message) -> list[str]: + """Convert message to a nice list of strings + + Args: + message (Any): The message + + Returns: + list[str]: Formatted lines of the message as a list + """ + if isinstance(message, list): + messagelines = [str(i) for i in message] + elif isinstance(message, dict): + messagelines = [f"{k}: {v}" for k, v in message.items()] + else: + messagelines = [str(message)] + + lines = [] + + # replace and split by newlines + for m in messagelines: + lines.extend(m.splitlines()) + + return [l.strip() for l in lines] + + +class Logger(LogLevelObject, metaclass=Singleton): + + log_dir_path = "" + file_handler = None + console_prefix_length = 0 + + def __init__(self) -> None: + + # Start root logger: + self.logger = logging.getLogger(__name__) + self.logger.setLevel(10) + + # Init console logger handler: + self.console_handler = logging.StreamHandler() + self.SetupConsoleLogging() + self.console_handler.addFilter(LogTargetFilter(self.LOGTARGET_CONSOLE)) + self.logger.addHandler(self.console_handler) + + # Init file logger buffer handler: + self.memory_handler = logging.handlers.MemoryHandler( + capacity=100, flushOnClose=False) + self.logger.addHandler(self.memory_handler) + + def SetupConsoleLogging(self, loglevel: LogLevel = LogLevel(consts.DEFAULT_LOG_LEVEL), include_time: bool = True) -> None: + """Change settings of console logging. This is called from LogSettings init. + + Args: + loglevel (LogLevel, optional): Loglevel to use. ENVVAR owerwrites thi. Defaults to LogLevel(consts.DEFAULT_LOG_LEVEL). + include_time (bool, optional): If the time should be included in the log. Defaults to True. """ - Returns True if the running system's terminal supports color, and False - otherwise. + self.console_handler.setFormatter( + self.GetFormatter(self.LOGTARGET_CONSOLE, include_time)) + + if OsD.GetEnv("IOTURING_LOG_LEVEL"): + try: + env_level = LogLevel(OsD.GetEnv("IOTURING_LOG_LEVEL")) + self.console_handler.setLevel(int(env_level)) + return + except UnknownLoglevelException: + pass + self.console_handler.setLevel(int(loglevel)) + + def SetupFileLogging(self, enabled: bool, loglevel: LogLevel, log_dir_path: Path, early_init: bool) -> None: + """Manage file logging. This is called from LogSettings init + + Args: + enabled (bool): If File logging enabled or disabled + loglevel (LogLevel): Loglevel to use + log_dir_path (Path): Path to directory containing log files + early_init (bool): If this is the early or late init. """ - plat = sys.platform - supported_platform = plat != 'Pocket PC' and (plat != 'win32' or - 'ANSICON' in os.environ) - # isatty is not always implemented, #6223. - is_a_tty = hasattr(sys.stdout, 'isatty') and sys.stdout.isatty() - return supported_platform and is_a_tty + + if enabled: + if self.file_handler: + self.UpdateFileLogging(loglevel, log_dir_path) + else: + self.StartFileLogging(loglevel, log_dir_path) + else: + # Disable file logging: + if self.file_handler: + self.logger.removeHandler(self.file_handler) + self.file_handler.close() + self.file_handler = None + + if not early_init: + self.DisableFileLogBuffer() + + def StartFileLogging(self, loglevel: LogLevel, log_dir_path: Path) -> None: + """Start and setup file logging + + Args: + loglevel (LogLevel): Loglevel to use + log_dir_path (Path): Path to directory containing log files + """ + + filepath = log_dir_path.joinpath(App.getName() + ".log") + self.log_dir_path = log_dir_path + + self.file_handler = logging.handlers.RotatingFileHandler( + filepath, backupCount=5) + + self.Log(self.LOG_DEBUG, "FileLogger", f"Started file logging: {filepath.absolute()}", + logtarget=self.LOGTARGET_CONSOLE) + + if filepath.exists(): + self.file_handler.doRollover() + + self.file_handler.setFormatter(self.GetFormatter(self.LOGTARGET_FILE)) + self.file_handler.addFilter(LogLevelFilter(loglevel)) + self.file_handler.addFilter(LogTargetFilter(self.LOGTARGET_FILE)) + self.file_handler.setLevel(int(loglevel)) + + self.logger.addHandler(self.file_handler) + + self.memory_handler.setTarget(self.file_handler) + self.memory_handler.flush() + + def UpdateFileLogging(self, loglevel: LogLevel, log_dir_path: Path) -> None: + """Change settings of enabled file logging + + Args: + loglevel (LogLevel): Loglevel to use + log_dir_path (Path): Path to directory containing log files + """ + if not self.file_handler: + raise Exception("File logger not initialized!") + + if log_dir_path.samefile(self.log_dir_path): + if not self.file_handler.level == int(loglevel): + + # Update loglevel: + old_filter = next( + (f for f in self.file_handler.filters if isinstance(f, LogLevelFilter))) + self.file_handler.removeFilter(old_filter) + + self.file_handler.addFilter(LogLevelFilter(loglevel)) + self.file_handler.setLevel(int(loglevel)) + + else: + # Update path and loglevel + self.logger.removeHandler(self.file_handler) + self.StartFileLogging(loglevel, log_dir_path) + + def DisableFileLogBuffer(self) -> None: + """Disable the buffer after file logger was finally disabled or enabled""" + + self.Log(self.LOG_DEBUG, "FileLogger", "File log buffer disabled", + logtarget=self.LOGTARGET_CONSOLE) + + if self.memory_handler: + self.logger.removeHandler(self.memory_handler) + self.memory_handler.close() + + def GetFormatter(self, logtarget: str, include_time: bool = True) -> logging.Formatter: + """Get the formatter for this logging handle + + Args: + logtarget (str): self.LOGTARGET_CONSOLE or self.LOGTARGET_FILE + include_time (bool, optional): If the time should be included in the log, only affects console logging. Defaults to True. + + Raises: + Exception: invalid logtarget + + Returns: + logging.Formatter: Forrmatter for logging handler + """ + + prefix_lengths = consts.LOG_PREFIX_LENGTHS.copy() + + if not include_time: + prefix_lengths.pop("asctime") + + prefix_strings = [f"{{{s}}}" for s in prefix_lengths] + prefix_string = consts.LOG_PREFIX_ENDS[0] +\ + consts.LOG_PREFIX_SEPARATOR.join(prefix_strings) +\ + consts.LOG_PREFIX_ENDS[1] + + prefix_length = sum([len(s) for s in consts.LOG_PREFIX_ENDS]) + \ + len(consts.LOG_PREFIX_SEPARATOR) * (len(prefix_lengths) - 1) + \ + sum([l for l in prefix_lengths.values()]) + + if logtarget == self.LOGTARGET_CONSOLE: + fmt = "{color_prefix}" + prefix_string + \ + "{console_message}{color_suffix}" + self.console_prefix_length = prefix_length + + elif logtarget == self.LOGTARGET_FILE: + fmt = prefix_string + "{file_message}" + + else: + raise Exception(f"Unknown logtarget: {logtarget}") + + return logging.Formatter( + fmt=fmt, + datefmt="%Y-%m-%d %H:%M:%S", + style="{" + ) + + def Log(self, loglevel: LogLevel, source: str, message, color: str = "", logtarget: str = "") -> None: + """Log a message + + Args: + loglevel (LogLevel): The loglevel + source (str): Source module name + message (any): The message to log + color (str, optional): Override log color. Defaults to "". + logtarget (str, optional): self.LOGTARGET_CONSOLE or self.LOGTARGET_FILE. Defaults to "". + """ + + log_message = LogMessage( + source=source, message=message, color=color or loglevel.color, logtarget=logtarget) + + l = logging.getLogger(__name__).getChild(source) + + l.log(int(loglevel), + msg=log_message.msg, extra=log_message.extra) diff --git a/IoTuring/Logger/consts.py b/IoTuring/Logger/consts.py index 8678c2e5a..0fd30b3ae 100644 --- a/IoTuring/Logger/consts.py +++ b/IoTuring/Logger/consts.py @@ -1,62 +1,21 @@ LOGS_FOLDER = "Logs" -LOG_FILENAME_FORMAT = "Log_%Y-%m-%d_%H:%M:%S.log" -MESSAGE_DATETIME_FORMAT = '%Y-%m-%d %H:%M:%S' +DEFAULT_LOG_LEVEL = "INFO" +MIN_CONSOLE_WIDTH = 95 +LOG_PREFIX_LENGTHS = { + "asctime": 19, + "levelname:^8s": 8, + "source:^30s": 30 +} -LOG_LEVELS = [ - { - "const": "LOG_MESSAGE", - "string": "Message", - "number": 0, - "color": "green" - }, - { - "const": "LOG_ERROR", - "string": "Error", - "number": 1, - "color": "red" - }, - { - "const": "LOG_WARNING", - "string": "Warning", - "number": 2, - "color": "yellow" - }, - { - "const": "LOG_INFO", - "string": "Info", - "number": 3, - - }, - { - "const": "LOG_DEBUG", - "string": "Debug", - "number": 4, +LOG_PREFIX_ENDS = [ + "[ ", " ] " +] - }, - { - "const": "LOG_DEVELOPMENT", - "string": "Dev", - "number": 5, - } -] +LOG_PREFIX_SEPARATOR = " | " # On/off states as strings: STATE_ON = "ON" STATE_OFF = "OFF" - -# Fill start of string with spaces to jusitfy the message (0: no padding) -# First for type, second for source -STRINGS_LENGTH = [8, 30] - -# number of spaces to separe the message from the previuos part of the row -PRESTRING_MESSAGE_SEPARATOR_LEN = 2 -# before those spaces I add this string -LONG_MESSAGE_PRESTRING_CHAR = ' ' - -CONSOLE_LOG_LEVEL = "LOG_INFO" -FILE_LOG_LEVEL = "LOG_INFO" - -MESSAGE_WIDTH = 95 diff --git a/IoTuring/MyApp/SystemConsts/TerminalDetection.py b/IoTuring/MyApp/SystemConsts/TerminalDetection.py new file mode 100644 index 000000000..b04083241 --- /dev/null +++ b/IoTuring/MyApp/SystemConsts/TerminalDetection.py @@ -0,0 +1,44 @@ +import sys +import os +import shutil + + +class TerminalDetection: + @staticmethod + def CheckTerminalSupportsColors() -> bool: + """ + Returns True if the running system's terminal supports color, and False + otherwise. + """ + plat = sys.platform + supported_platform = plat != 'Pocket PC' and (plat != 'win32' or + 'ANSICON' in os.environ) + # isatty is not always implemented, #6223. + is_a_tty = hasattr(sys.stdout, 'isatty') and sys.stdout.isatty() + return supported_platform and is_a_tty + + @staticmethod + def CheckTerminalSupportsSize() -> bool: + return any(shutil.get_terminal_size(fallback=(0,0))) + + + @staticmethod + def GetTerminalLines() -> int: + return shutil.get_terminal_size().lines + + @staticmethod + def GetTerminalColumns() -> int: + return shutil.get_terminal_size().columns + + + @staticmethod + def CalculateNumberOfLines(string_length: int) -> int: + """Get the number of lines required to display a text with the given length + + Args: + string_length (int): Length of the text + + Returns: + int: Number of lines required + """ + return (string_length // shutil.get_terminal_size().columns) + 1 diff --git a/IoTuring/MyApp/SystemConsts/__init__.py b/IoTuring/MyApp/SystemConsts/__init__.py index dc709cddb..d30843129 100644 --- a/IoTuring/MyApp/SystemConsts/__init__.py +++ b/IoTuring/MyApp/SystemConsts/__init__.py @@ -1,2 +1,3 @@ from .DesktopEnvironmentDetection import DesktopEnvironmentDetection -from .OperatingSystemDetection import OperatingSystemDetection \ No newline at end of file +from .OperatingSystemDetection import OperatingSystemDetection +from .TerminalDetection import TerminalDetection \ No newline at end of file diff --git a/IoTuring/Settings/Deployments/LogSettings/LogSettings.py b/IoTuring/Settings/Deployments/LogSettings/LogSettings.py new file mode 100644 index 000000000..72ddcaf7a --- /dev/null +++ b/IoTuring/Settings/Deployments/LogSettings/LogSettings.py @@ -0,0 +1,110 @@ +from pathlib import Path + +from IoTuring.Configurator.MenuPreset import MenuPreset +from IoTuring.Settings.Settings import Settings +from IoTuring.Logger.Logger import Logger +from IoTuring.Configurator.Configuration import SingleConfiguration +from IoTuring.MyApp.App import App +from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD +from IoTuring.Logger.LogLevel import LogLevel +from IoTuring.Logger import consts + + +# macOS dep (in PyObjC) +try: + from AppKit import * # type:ignore + from Foundation import * # type:ignore + macos_support = True +except: + macos_support = False + + +CONFIG_KEY_CONSOLE_LOG_LEVEL = "console_log_level" +CONFIG_KEY_CONSOLE_LOG_TIME = "console_log_time" +CONFIG_KEY_FILE_LOG_LEVEL = "file_log_level" +CONFIG_KEY_FILE_LOG_ENABLED = "file_log_enabled" +CONFIG_KEY_FILE_LOG_PATH = "file_log_path" + + +all_loglevels = sorted(Logger.GetLoglevels(), key=lambda l: int(l)) + +loglevel_choices = [{"name": str(l).capitalize(), "value": str(l)} + for l in all_loglevels] + + +class LogSettings(Settings): + NAME = "Log" + + def __init__(self, single_configuration: SingleConfiguration, early_init: bool) -> None: + super().__init__(single_configuration, early_init) + + # Load settings to logger: + logger = Logger() + + logger.SetupConsoleLogging( + loglevel=LogLevel( + self.GetFromConfigurations(CONFIG_KEY_CONSOLE_LOG_LEVEL)), + include_time=self.GetTrueOrFalseFromConfigurations( + CONFIG_KEY_CONSOLE_LOG_TIME) + ) + + logger.SetupFileLogging( + enabled=self.GetTrueOrFalseFromConfigurations( + CONFIG_KEY_FILE_LOG_ENABLED), + loglevel=LogLevel(self.GetFromConfigurations( + CONFIG_KEY_FILE_LOG_LEVEL)), + log_dir_path=Path(self.GetFromConfigurations( + CONFIG_KEY_FILE_LOG_PATH)), + early_init=early_init + ) + + @classmethod + def ConfigurationPreset(cls): + preset = MenuPreset() + + preset.AddEntry(name="Console log level", key=CONFIG_KEY_CONSOLE_LOG_LEVEL, + question_type="select", mandatory=True, default=str(LogLevel(consts.DEFAULT_LOG_LEVEL)), + instruction="IOTURING_LOG_LEVEL envvar overwrites this setting!", + choices=loglevel_choices) + + preset.AddEntry(name="Display time in console log", key=CONFIG_KEY_CONSOLE_LOG_TIME, + question_type="yesno", default="Y") + + preset.AddEntry(name="Enable file logging", key=CONFIG_KEY_FILE_LOG_ENABLED, + question_type="yesno", default="N") + + preset.AddEntry(name="File log level", key=CONFIG_KEY_FILE_LOG_LEVEL, + question_type="select", mandatory=True, default=str(LogLevel(consts.DEFAULT_LOG_LEVEL)), + choices=loglevel_choices, display_if_key_value={CONFIG_KEY_FILE_LOG_ENABLED: "Y"}) + + preset.AddEntry(name="File log path", key=CONFIG_KEY_FILE_LOG_PATH, + question_type="filepath", mandatory=True, default=cls.GetDefaultLogPath(), + instruction="Directory where log files will be saved", + display_if_key_value={CONFIG_KEY_FILE_LOG_ENABLED: "Y"}) + + return preset + + @staticmethod + def GetDefaultLogPath() -> str: + + default_path = App.getRootPath().joinpath( + "Logger").joinpath(consts.LOGS_FOLDER) + base_path = None + + if OsD.IsMacos() and macos_support: + base_path = \ + Path(NSSearchPathForDirectoriesInDomains( # type: ignore + NSLibraryDirectory, # type: ignore + NSUserDomainMask, True)[0]) # type: ignore + elif OsD.IsWindows(): + base_path = Path(OsD.GetEnv("LOCALAPPDATA")) + elif OsD.IsLinux(): + if OsD.GetEnv("XDG_CACHE_HOME"): + base_path = Path(OsD.GetEnv("XDG_CACHE_HOME")) + elif OsD.GetEnv("HOME"): + base_path = Path(OsD.GetEnv("HOME")).joinpath(".cache") + + if base_path: + default_path = base_path.joinpath(App.getName()) + + return str(default_path) diff --git a/IoTuring/Settings/Settings.py b/IoTuring/Settings/Settings.py index ffe851967..21e1ac008 100644 --- a/IoTuring/Settings/Settings.py +++ b/IoTuring/Settings/Settings.py @@ -1,12 +1,22 @@ from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject from IoTuring.Settings.SettingsManager import SettingsManager from IoTuring.Configurator.MenuPreset import BooleanAnswers +from IoTuring.Configurator.Configuration import SingleConfiguration class Settings(ConfiguratorObject): """Base class for settings""" NAME = "Settings" + def __init__(self, single_configuration: SingleConfiguration, early_init: bool) -> None: + """Initialize a settings class + + Args: + single_configuration (SingleConfiguration): The configuration + early_init (bool): True when loaded before configurator menu, False when added to SettingsManager + """ + super().__init__(single_configuration) + @classmethod def GetFromSettingsConfigurations(cls, key: str): """Get value from settings' saved configurations from SettingsManager diff --git a/IoTuring/Warehouse/Deployments/ConsoleWarehouse/ConsoleWarehouse.py b/IoTuring/Warehouse/Deployments/ConsoleWarehouse/ConsoleWarehouse.py index 1299bd3be..375a231d4 100644 --- a/IoTuring/Warehouse/Deployments/ConsoleWarehouse/ConsoleWarehouse.py +++ b/IoTuring/Warehouse/Deployments/ConsoleWarehouse/ConsoleWarehouse.py @@ -10,7 +10,7 @@ def Loop(self): for entity in self.GetEntities(): for entitySensor in entity.GetEntitySensors(): if(entitySensor.HasValue()): - self.Log(Logger.LOG_MESSAGE, entitySensor.GetId() + + self.Log(self.LOG_INFO, entitySensor.GetId() + ": " + self.FormatValue(entitySensor)) def FormatValue(self, entitySensor: EntitySensor): diff --git a/IoTuring/__init__.py b/IoTuring/__init__.py index d90d75246..cc1590b89 100644 --- a/IoTuring/__init__.py +++ b/IoTuring/__init__.py @@ -53,6 +53,9 @@ def loop(): logger = Logger() configurator = Configurator() + # Early log settings: + ConfiguratorLoader(configurator).LoadSettings(early_init=True) + logger.Log(Logger.LOG_DEBUG, "App", f"Selected options: {vars(args)}") if args.configurator: @@ -115,20 +118,13 @@ def loop(): def Exit_SIGINT_handler(sig=None, frame=None): logger = Logger() logger.Log(Logger.LOG_INFO, "Main", "Application closed by SigInt", - printToConsole=False) # to file + logtarget=Logger.LOGTARGET_FILE) # to file messages = ["Exiting...", "Thanks for using IoTuring !"] + print() # New line - for message in messages: - text = "" - if (Logger.checkTerminalSupportsColors()): - text += Colors.cyan - text += message - if (Logger.checkTerminalSupportsColors()): - text += Colors.reset - logger.Log(Logger.LOG_INFO, "Main", text, - writeToFile=False) # to terminal - - logger.CloseFile() + logger.Log(Logger.LOG_INFO, "Main", messages, + color=Colors.cyan, logtarget=Logger.LOGTARGET_CONSOLE) + sys.exit(0)