From 51320040bae99580efd07e67412c9cffce190ee4 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sun, 3 Dec 2023 02:12:57 +0100 Subject: [PATCH 01/22] Python3.8, docker in readme --- IoTuring/MyApp/App.py | 7 ++++--- README.md | 10 +++++----- docker-compose.yaml | 4 +++- pyproject.toml | 5 ++--- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/IoTuring/MyApp/App.py b/IoTuring/MyApp/App.py index 102dc944f..0467394ce 100644 --- a/IoTuring/MyApp/App.py +++ b/IoTuring/MyApp/App.py @@ -1,4 +1,4 @@ -from importlib_metadata import metadata +from importlib.metadata import metadata class App(): METADATA = metadata('IoTuring') @@ -12,8 +12,9 @@ class App(): # "Project-URL": "documentation, https://github.com/richibrics/IoTuring", # "Project-URL": "repository, https://github.com/richibrics/IoTuring", # "Project-URL": "changelog, https://github.com/richibrics/IoTuring/releases", - URL_HOMEPAGE = METADATA.get_all("Project-URL")[0].split(', ')[1].strip() - URL_RELEASES = METADATA.get_all("Project-URL")[-1].split(', ')[1].strip() + URLS = METADATA.get_all("Project-URL") or "" + URL_HOMEPAGE = URLS[0].split(', ')[1].strip() + URL_RELEASES = URLS[-1].split(', ')[1].strip() @staticmethod def getName() -> str: diff --git a/README.md b/README.md index 9532dbd30..0ba876e98 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ But the most important thing: **works on all OSs and all architectures ! Windows ### Who knows how it works -Using pip (on Python >= 3.7) install the IoTuring package +Using pip (on Python >= 3.8) install the IoTuring package ```shell pip install IoTuring @@ -38,7 +38,7 @@ Configure with `IoTuring -c` or `python -m IoTuring -c` #### Requirements -- [Python 3.7+](https://www.python.org/downloads/) +- [Python 3.8+](https://www.python.org/downloads/) - [Pip](https://www.makeuseof.com/tag/install-pip-for-python/) Some platforms may need other software for some entities. @@ -109,7 +109,7 @@ python -m IoTuring Run the configurator: ```shell -docker run -it -v ./.config/IoTuring/:/config ghcr.io/richibrics/ioturing:latest IoTuring -c +docker run -it -v ./.config/IoTuring/:/config richibrics/ioturing:latest IoTuring -c ``` Enable the `Console Warehouse` to see logs! @@ -117,7 +117,7 @@ Enable the `Console Warehouse` to see logs! Run detached after configuration: ```shell -docker run -d -v ./.config/IoTuring/:/config ghcr.io/richibrics/ioturing:latest +docker run -d -v ./.config/IoTuring/:/config richibrics/ioturing:latest ``` For a docker compose example see [docker-compose.yaml](./docker-compose.yaml). Create configuration manually or with the command above! @@ -163,7 +163,7 @@ All sensors and switches will be available to be added to your dashboard in your | Volume | control audio volume | ![mac](https://raw.githubusercontent.com/richibrics/IoTuring/main/docs/images/mac.png) ![linux](https://raw.githubusercontent.com/richibrics/IoTuring/main/docs/images/linux.png) | -\* To use the features from Power entity on Linux and macOS you need to give permissions to your user to shutdown and reboot without sudo password. +\* To use the features from Power entity on macOS and on some Linux distros you need to give permissions to your user to shutdown and reboot without sudo password. You can easily do that by using the following terminal command: ```shell diff --git a/docker-compose.yaml b/docker-compose.yaml index dc397de37..334e7a6c3 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,6 +1,8 @@ services: ioturing: - image: ghcr.io/richibrics/ioturing:latest + image: richibrics/ioturing:latest + # Or from Github: + # image: ghcr.io/richibrics/ioturing:latest container_name: ioturing volumes: - ~/.config/IoTuring/:/config diff --git a/pyproject.toml b/pyproject.toml index d53224ea9..de654e72e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "IoTuring" version = "2023.11.1" description = "Simple and powerful cross-platform script to control your pc and share statistics using communication protocols like MQTT and home control hubs like HomeAssistant." readme = "README.md" -requires-python = ">=3.7" +requires-python = ">=3.8" license = {file = "COPYING"} keywords = ["iot","mqtt","monitor"] authors = [ @@ -24,8 +24,7 @@ dependencies = [ "paho-mqtt", "psutil", "PyYAML", - "importlib_metadata", - "requests", + "requests", "InquirerPy", "PyObjC; sys_platform == 'darwin'", "IoTuring-applesmc; sys_platform == 'darwin'", From a0787c04f09ab7db0999fbbf42710c4e8de5a72a Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sun, 3 Dec 2023 02:13:43 +0100 Subject: [PATCH 02/22] Open config in editor --- IoTuring/Configurator/Configurator.py | 63 +++++++- IoTuring/Configurator/ConfiguratorIO.py | 198 ++++++++++++------------ IoTuring/__init__.py | 73 ++++++--- 3 files changed, 215 insertions(+), 119 deletions(-) diff --git a/IoTuring/Configurator/Configurator.py b/IoTuring/Configurator/Configurator.py index a04282022..415b8526d 100644 --- a/IoTuring/Configurator/Configurator.py +++ b/IoTuring/Configurator/Configurator.py @@ -1,15 +1,18 @@ import os +import subprocess + from IoTuring.Logger.LogObject import LogObject from IoTuring.Exceptions.Exceptions import UserCancelledException - from IoTuring.ClassManager.EntityClassManager import EntityClassManager from IoTuring.ClassManager.WarehouseClassManager import WarehouseClassManager from IoTuring.Configurator import ConfiguratorIO from IoTuring.Configurator import messages +from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD + from InquirerPy import inquirer from InquirerPy.separator import Separator @@ -30,10 +33,14 @@ class Configurator(LogObject): - def __init__(self) -> None: - # Clean the screen before first logs: - self.pinned_message = False - self.ClearScreen(pin_next_message=True) + def __init__(self, clear_screen: bool = True) -> None: + + if clear_screen: + # Clean the screen before first logs: + self.pinned_message = False + self.ClearScreen(pin_next_message=True) + else: + self.pinned_message = True self.configuratorIO = ConfiguratorIO.ConfiguratorIO() self.config = self.LoadConfigurations() @@ -42,6 +49,52 @@ def GetConfigurations(self) -> dict: """ Return a copy of the configurations dict""" return self.config.copy() # Safe return + def CheckFile(self) -> None: + """ Make sure config file exists or can be created """ + if not self.configuratorIO.checkConfigurationFileExists(): + if self.configuratorIO.shouldMoveOldConfig(): + + moveFile = inquirer.confirm( + message=" ".join([ + "A configuration file was found in the old location.", + "Do you want to move it to the new location?", + "If not, a new blank configuration will be used."]), + default=True + ).execute() + + self.configuratorIO.manageOldConfig(moveFile) + + # Reload config if it was moved: + if moveFile: + self.__init__(clear_screen=False) + + def OpenConfigInEditor(self): + """ Open configuration file in an editor """ + if not self.configuratorIO.checkConfigurationFileExists(): + self.Log(self.LOG_ERROR, + "Configuration file not found, run with -c to create one!") + else: + config_path = str(self.configuratorIO.getFilePath()) + self.Log(self.LOG_INFO, f"Opening file: \"{config_path}\"") + + if OsD.IsWindows(): + os.startfile(config_path) + return + else: + editors = [ + OsD.GetEnv("EDITOR"), + "nano", + "vim" + ] + editor_command = next( + (e for e in editors if OsD.CommandExists(e)), "") + if editor_command: + subprocess.run(f'{editor_command} "{config_path}"', + shell=True, close_fds=True) + return + + self.Log(self.LOG_WARNING, "No editor found") + def Menu(self) -> None: """ UI for Entities and Warehouses settings """ diff --git a/IoTuring/Configurator/ConfiguratorIO.py b/IoTuring/Configurator/ConfiguratorIO.py index 7c02d1ce5..06dfc69da 100644 --- a/IoTuring/Configurator/ConfiguratorIO.py +++ b/IoTuring/Configurator/ConfiguratorIO.py @@ -1,16 +1,15 @@ -import platform # Platform detection - import json -import os # Configurations file path manipulation, environment variables -import inspect # Configurations file path manipulation +from pathlib import Path +import inspect from IoTuring.Logger.LogObject import LogObject -from IoTuring.MyApp.App import App # App name +from IoTuring.MyApp.App import App # App name +from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD # macOS dep (in PyObjC) try: - from AppKit import * # type:ignore - from Foundation import * # type:ignore + from AppKit import * # type:ignore + from Foundation import * # type:ignore macos_support = True except: macos_support = False @@ -22,128 +21,137 @@ # read ConfiguratorIO.checkConfigurationFileInOldLocation for more information DONT_MOVE_FILE_FILENAME = "dontmoveconf.itg" + class ConfiguratorIO(LogObject): def __init__(self): self.directoryName = App.getName() - + def readConfigurations(self): """ Returns configurations dictionary. If does not exist the file where it should be stored, return None. """ config = None try: with open(self.getFilePath(), "r") as f: config = json.loads(f.read()) - self.Log(self.LOG_MESSAGE, "Loaded \"" + self.getFilePath() + "\"") + self.Log(self.LOG_MESSAGE, f"Loaded \"{self.getFilePath()}\"") except: - self.Log(self.LOG_WARNING, "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 \"" + self.getFolderPath() + "\"") + 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())}\"") return config - + def writeConfigurations(self, data): """ Writes configuration data in its file """ self.createFolderPathIfDoesNotExist() with open(self.getFilePath(), "w") as f: f.write(json.dumps(data, indent=4)) - self.Log(self.LOG_MESSAGE, "Saved \"" + self.getFilePath() + "\"") - - def checkConfigurationFileExists(self): + self.Log(self.LOG_MESSAGE, f"Saved \"{str(self.getFilePath())}\"") + + def checkConfigurationFileExists(self) -> bool: """ Returns True if the configuration file exists in the correct folder, False otherwise. """ - return os.path.exists(self.getFilePath()) and os.path.isfile(self.getFilePath()) - - def getFilePath(self): + return self.getFilePath().exists() and self.getFilePath().is_file() + + def getFilePath(self) -> Path: """ Returns the path to the configurations file. """ - return os.path.join(self.getFolderPath(), CONFIGURATION_FILE_NAME) - - def createFolderPathIfDoesNotExist(self): + return self.getFolderPath().joinpath(CONFIGURATION_FILE_NAME) + + def createFolderPathIfDoesNotExist(self): """ Check if file exists, if not check if path exists: if not create both folder and file otherwise just the file """ - if not os.path.exists(self.getFolderPath()): - if not os.path.exists(self.getFolderPath()): - os.makedirs(self.getFolderPath()) - - def getFolderPath(self): + if not self.getFolderPath().exists(): + self.getFolderPath().mkdir() + + def getFolderPath(self) -> Path: """ Returns the path to the configurations file. If the directory where the file will be stored doesn't exist, it will be created. """ - + folderPath = self.defaultFolderPath() try: # Use path from environment variable if present, otherwise os specific folders, otherwise use default path - envvarPath = self.envvarFolderPath() - if envvarPath is not None: - folderPath = envvarPath + envvarPath = OsD.GetEnv(CONFIG_PATH_ENV_VAR) + if envvarPath: + folderPath = Path(envvarPath) + else: - _os = platform.system() - if _os == 'Darwin' and macos_support: - folderPath = self.macOSFolderPath() - folderPath = os.path.join(folderPath, self.directoryName) - elif _os == "Windows": - folderPath = self.windowsFolderPath() - folderPath = os.path.join(folderPath, self.directoryName) - elif _os == "Linux": - folderPath = self.linuxFolderPath() - folderPath = os.path.join(folderPath, self.directoryName) + if OsD.IsMacos() and macos_support: + folderPath = self.macOSFolderPath().joinpath(self.directoryName) + elif OsD.IsWindows(): + folderPath = self.windowsFolderPath().joinpath(self.directoryName) + elif OsD.IsLinux(): + folderPath = self.linuxFolderPath().joinpath(self.directoryName) + except: - pass # default folder path will be used - - # add slash if missing (for log reasons) - if not folderPath.endswith(os.sep): - folderPath += os.sep - + pass # default folder path will be used + return folderPath - def defaultFolderPath(self): - return os.path.dirname(inspect.getfile(ConfiguratorIO)) - + def defaultFolderPath(self) -> Path: + return Path(inspect.getfile(ConfiguratorIO)).parent + # https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/MacOSXDirectories/MacOSXDirectories.html - def macOSFolderPath(self): - paths = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory,NSUserDomainMask,True) # type: ignore - basePath = (len(paths) > 0 and paths[0]) or NSTemporaryDirectory() # type: ignore - return basePath - + def macOSFolderPath(self) -> Path: + paths = NSSearchPathForDirectoriesInDomains( # type: ignore + NSApplicationSupportDirectory, NSUserDomainMask, True) # type: ignore + basePath = (len(paths) > 0 and paths[0]) \ + or NSTemporaryDirectory() # type: ignore + return Path(basePath) + # https://docs.microsoft.com/en-us/windows/win32/shell/knownfolderid - def windowsFolderPath(self): - return os.environ["APPDATA"] - + def windowsFolderPath(self) -> Path: + return Path(OsD.GetEnv("APPDATA")) + # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html - def linuxFolderPath(self): - return os.environ["XDG_CONFIG_HOME"] if "XDG_CONFIG_HOME" in os.environ else os.path.join(os.environ["HOME"], ".config") - - def envvarFolderPath(self): - return os.getenv(CONFIG_PATH_ENV_VAR) - - # In versions prior to 2022.12.2, the configurations file was stored in the same folder as this file - def oldFolderPath(self): - return os.path.dirname(inspect.getfile(ConfiguratorIO)) - - def checkConfigurationFileInOldLocation(self): - """ Should be called at the beginning of the program when in -c mode. - Checks if a config. file is in the old folder path; if so ask if user wants to copy it - into new location, instead of rewriting it from zero. - If yes, copy it; otherwise leave it there and write a don't move file to remmeber the - choose in the next times. - This check is not done if old path = current chosen path + def linuxFolderPath(self) -> Path: + if OsD.GetEnv("XDG_CONFIG_HOME"): + path = Path(OsD.GetEnv("XDG_CONFIG_HOME")) + else: + path = Path(OsD.GetEnv("HOME")).joinpath(".config") + return path + + # In versions prior to 2022.12.2, the configurations file was stored in the same folder as this file: + def oldFolderPath(self) -> Path: + return self.defaultFolderPath() + + def shouldMoveOldConfig(self) -> bool: + """ Checks if a config file is in the old folder path and should be moved to the new location. + + Returns: + bool: True if the config should be moved. False if old path = current chosen path or should not be moved """ - - # This check is not done if old path = current chosen path (also check if ending slash is present) - if self.oldFolderPath() == self.getFolderPath() or self.oldFolderPath() == self.getFolderPath()[:-1]: - return - - # Exit check if no config. in old directory or if there is also the dont move file with it. - configuration_file_exists = os.path.exists(os.path.join(self.oldFolderPath(), CONFIGURATION_FILE_NAME)) - dont_move_file_exists = os.path.exists(os.path.join(self.oldFolderPath(), DONT_MOVE_FILE_FILENAME)) - if not configuration_file_exists or (configuration_file_exists and dont_move_file_exists): - return - - response = input("A configuration file was found in the old location. Do you want to move it to the new location ? if not, a new blank configuration will be used (y/n): ") - response = bool( response.lower() == "y") - # Then ask to move it - if response: + + # This check is not done if old path = current chosen path: + if self.oldFolderPath() == self.getFolderPath(): + return False + + # Old config does not exist: + if not self.oldFolderPath().joinpath(CONFIGURATION_FILE_NAME).exists(): + return False + # Old config and dont move file exists: + elif self.oldFolderPath().joinpath(DONT_MOVE_FILE_FILENAME).exists(): + return False + + return True + + def manageOldConfig(self, moveFile: bool) -> None: + """Move config file from old location, or create dontmove file + + Args: + moveFile (bool): True: move the file. False: Create dontmove file + """ + + if moveFile: # create folder if not exists self.createFolderPathIfDoesNotExist() # copy file from old to new location - os.rename(os.path.join(self.oldFolderPath(), CONFIGURATION_FILE_NAME), os.path.join(self.getFolderPath(), CONFIGURATION_FILE_NAME)) - self.Log(self.LOG_MESSAGE, "Copied into \"" + os.path.join(self.getFolderPath(), CONFIGURATION_FILE_NAME) + "\"") + self.oldFolderPath().joinpath(CONFIGURATION_FILE_NAME).rename(self.getFilePath()) + self.Log(self.LOG_MESSAGE, + f"Copied to \"{str(self.getFilePath())}\"") else: # create dont move file - with open(os.path.join(self.oldFolderPath(), DONT_MOVE_FILE_FILENAME), "w") as f: - f.write("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, "You won't be asked again. A new blank configuration will be used; if you want to move the existing configuration file, delete \"" + os.path.join(self.oldFolderPath(), DONT_MOVE_FILE_FILENAME) + "\" and run the script in -c mode.") \ No newline at end of file + with open(self.oldFolderPath().joinpath(DONT_MOVE_FILE_FILENAME), "w") as f: + f.write(" ".join([ + "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([ + "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/__init__.py b/IoTuring/__init__.py index 9686c3bea..e05fba575 100644 --- a/IoTuring/__init__.py +++ b/IoTuring/__init__.py @@ -6,32 +6,65 @@ from IoTuring.Entity.EntityManager import EntityManager from IoTuring.Logger.Logger import Logger from IoTuring.Logger.Colors import Colors -import sys import signal import os import time +import argparse warehouses = [] entities = [] def loop(): - + + parser = argparse.ArgumentParser( + prog=App.getName(), + description=App.getDescription(), + epilog="Start without argument for normal use" + ) + + parser.add_argument("-v", "--version", + action="version", + version=f"{App.getName()} {App.getVersion()}" + ) + + parser.add_argument("-c", "--configurator", + help="enter configuration mode", + action="store_true") + + parser.add_argument("-o", "--open-config", + help="open config file", + action="store_true") + + args = parser.parse_args() + + # Only one argument should be used: + if all(vars(args).values()): + print("error: use only one option!", end="\n\n") + parser.print_help() + os._exit(0) + # Start logger: logger = Logger() configurator = Configurator() - # add -c to configure with the menu - if len(sys.argv) > 1 and sys.argv[1] == "-c": - if not configurator.configuratorIO.checkConfigurationFileExists(): - # If the file doesn't exist, check if it's in the old location - configurator.configuratorIO.checkConfigurationFileInOldLocation() + logger.Log(Logger.LOG_DEBUG, "App", f"Selected options: {vars(args)}") + + if args.configurator: try: + # Check old location: + configurator.CheckFile() + configurator.Menu() except KeyboardInterrupt: - logger.Log(Logger.LOG_WARNING, "Configurator", "Configuration NOT saved") + logger.Log(Logger.LOG_WARNING, "Configurator", + "Configuration NOT saved") Exit_SIGINT_handler() + elif args.open_config: + configurator.OpenConfigInEditor() + os._exit(0) + # This have to start after configurator.Menu(), otherwise won't work starting from the menu signal.signal(signal.SIGINT, Exit_SIGINT_handler) @@ -39,7 +72,6 @@ def loop(): logger.Log(Logger.LOG_INFO, "Configurator", "Run the script with -c to enter configuration mode") - eM = EntityManager() # These will be done from the configuration reader @@ -63,24 +95,27 @@ def loop(): # on Windows a SIGINT signal can't be catched otherwise. # Daemon mode involves thread exit when main ends. So # I need main to never end - while(True): + while (True): time.sleep(1) + def Exit_SIGINT_handler(sig=None, frame=None): logger = Logger() - logger.Log(Logger.LOG_INFO, "Main", "Application closed by SigInt", printToConsole=False) # to file - + logger.Log(Logger.LOG_INFO, "Main", "Application closed by SigInt", + printToConsole=False) # to file + messages = ["Exiting...", "Thanks for using IoTuring !"] - print("") # New line + print() # New line for message in messages: text = "" - if(Logger.checkTerminalSupportsColors()): + if (Logger.checkTerminalSupportsColors()): text += Colors.cyan - text += message - if(Logger.checkTerminalSupportsColors()): + text += message + if (Logger.checkTerminalSupportsColors()): text += Colors.reset - logger.Log(Logger.LOG_INFO, "Main", text, writeToFile=False) # to terminal - + logger.Log(Logger.LOG_INFO, "Main", text, + writeToFile=False) # to terminal + logger.CloseFile() - os._exit(0) \ No newline at end of file + os._exit(0) From 6cf6978003db4c5798cbc9b2679f931a5de8700e Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sun, 3 Dec 2023 03:35:07 +0100 Subject: [PATCH 03/22] Fix OsD.GetEnv --- .../SystemConsts/OperatingSystemDetection.py | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/IoTuring/MyApp/SystemConsts/OperatingSystemDetection.py b/IoTuring/MyApp/SystemConsts/OperatingSystemDetection.py index 232e2c373..f75b2ddf0 100644 --- a/IoTuring/MyApp/SystemConsts/OperatingSystemDetection.py +++ b/IoTuring/MyApp/SystemConsts/OperatingSystemDetection.py @@ -39,22 +39,17 @@ def IsMacos(cls) -> bool: @classmethod def GetEnv(cls, envvar) -> str: """Get envvar, also from different tty on linux""" - env_value = "" - if cls.IsLinux(): - e = os.environ.get(envvar) - if not e: - try: - # Try if there is another tty with gui: - session_pid = next((u.pid for u in psutil.users() if u.host and "tty" in u.host), None) - if session_pid: - p = psutil.Process(int(session_pid)) - with p.oneshot(): - env_value = p.environ()[envvar] - except KeyError: - env_value = "" - else: - env_value = e - + env_value = os.environ.get(envvar) or "" + if cls.IsLinux() and not env_value: + try: + # Try if there is another tty with gui: + session_pid = next((u.pid for u in psutil.users() if u.host and "tty" in u.host), None) + if session_pid: + p = psutil.Process(int(session_pid)) + with p.oneshot(): + env_value = p.environ()[envvar] + except KeyError: + env_value = "" return env_value @staticmethod From 19dcb5958fbf8ebfbf617429e8ca7c626a9f15ff Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sun, 3 Dec 2023 03:36:07 +0100 Subject: [PATCH 04/22] LogLevel exception, small Configurator changes --- IoTuring/Configurator/Configurator.py | 25 ++++++++++++--------- IoTuring/Exceptions/Exceptions.py | 8 ++++++- IoTuring/Logger/LogLevel.py | 3 ++- IoTuring/Logger/Logger.py | 32 +++++++++++++++++---------- IoTuring/__init__.py | 5 ++++- 5 files changed, 47 insertions(+), 26 deletions(-) diff --git a/IoTuring/Configurator/Configurator.py b/IoTuring/Configurator/Configurator.py index 415b8526d..c47db04b6 100644 --- a/IoTuring/Configurator/Configurator.py +++ b/IoTuring/Configurator/Configurator.py @@ -33,14 +33,9 @@ class Configurator(LogObject): - def __init__(self, clear_screen: bool = True) -> None: + def __init__(self) -> None: - if clear_screen: - # Clean the screen before first logs: - self.pinned_message = False - self.ClearScreen(pin_next_message=True) - else: - self.pinned_message = True + self.pinned_message = False self.configuratorIO = ConfiguratorIO.ConfiguratorIO() self.config = self.LoadConfigurations() @@ -66,7 +61,7 @@ def CheckFile(self) -> None: # Reload config if it was moved: if moveFile: - self.__init__(clear_screen=False) + self.__init__() def OpenConfigInEditor(self): """ Open configuration file in an editor """ @@ -78,7 +73,7 @@ def OpenConfigInEditor(self): self.Log(self.LOG_INFO, f"Opening file: \"{config_path}\"") if OsD.IsWindows(): - os.startfile(config_path) + os.startfile(config_path) # type: ignore return else: editors = [ @@ -95,9 +90,12 @@ def OpenConfigInEditor(self): self.Log(self.LOG_WARNING, "No editor found") - def Menu(self) -> None: + def Menu(self, clear_screen: bool = True) -> None: """ UI for Entities and Warehouses settings """ + if not clear_screen: + self.pinned_message = True + mainMenuChoices = [ {"name": "Manage entities", "value": self.ManageEntities}, {"name": "Manage warehouses", "value": self.ManageWarehouses}, @@ -419,7 +417,7 @@ def ClearScreen(self, pin_next_message=False): """ if not self.pinned_message: - os.system("cls" if os.name == "nt" else "clear") + self.ClearTerminal() if pin_next_message: self.pinned_message = True @@ -471,3 +469,8 @@ def DisplayMessage(self, message: str, force_clear=False): self.ClearScreen(pin_next_message=True) print(message) print() + + @staticmethod + def ClearTerminal(): + """Clear the terminal screen on any platform""" + os.system("cls" if os.name == "nt" else "clear") diff --git a/IoTuring/Exceptions/Exceptions.py b/IoTuring/Exceptions/Exceptions.py index 9c1590814..7a139da93 100644 --- a/IoTuring/Exceptions/Exceptions.py +++ b/IoTuring/Exceptions/Exceptions.py @@ -7,4 +7,10 @@ def __init__(self, *args: object) -> None: class UserCancelledException(Exception): def __init__(self, *args: object) -> None: super().__init__(*args) - self.message = "User cancelled the ongoing process" \ No newline at end of file + self.message = "User cancelled the ongoing process" + + +class UnknownLoglevelException(Exception): + def __init__(self, loglevel: str) -> None: + super().__init__(f"Unknown log level: {loglevel}") + self.loglevel = loglevel \ No newline at end of file diff --git a/IoTuring/Logger/LogLevel.py b/IoTuring/Logger/LogLevel.py index 16d1a4844..01575055e 100644 --- a/IoTuring/Logger/LogLevel.py +++ b/IoTuring/Logger/LogLevel.py @@ -1,5 +1,6 @@ from IoTuring.Logger import consts from IoTuring.Logger.Colors import Colors +from IoTuring.Exceptions.Exceptions import UnknownLoglevelException class LogLevel: @@ -10,7 +11,7 @@ def __init__(self, level_const: str) -> None: (l for l in consts.LOG_LEVELS if l["const"] == level_const), None) if not level_dict: - raise Exception(f"Unknown log level: {level_const}") + raise UnknownLoglevelException(level_const) self.const = level_const self.string = level_dict["string"] diff --git a/IoTuring/Logger/Logger.py b/IoTuring/Logger/Logger.py index f3ceea78d..c6a07bc45 100644 --- a/IoTuring/Logger/Logger.py +++ b/IoTuring/Logger/Logger.py @@ -1,12 +1,14 @@ -from io import TextIOWrapper -from IoTuring.Logger import consts -from IoTuring.Logger.LogLevel import LogLevelObject, LogLevel import sys import os import inspect from datetime import datetime import json import threading +from io import TextIOWrapper + +from IoTuring.Logger import consts +from IoTuring.Logger.LogLevel import LogLevelObject, LogLevel +from IoTuring.Exceptions.Exceptions import UnknownLoglevelException class Singleton(type): @@ -29,6 +31,10 @@ class Logger(LogLevelObject, metaclass=Singleton): 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) + def __init__(self) -> None: self.terminalSupportsColors = Logger.checkTerminalSupportsColors() @@ -38,6 +44,15 @@ def __init__(self) -> None: # 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() @@ -121,18 +136,11 @@ def LogList(self, loglevel, source, message_list: list, *args): # Both print and save to file def PrintAndSave(self, string, loglevel: LogLevel, printToConsole=True, writeToFile=True) -> None: - # Override log level from envvar: - console_level = consts.CONSOLE_LOG_LEVEL - if os.getenv("IOTURING_LOG_LEVEL"): - console_level = str(os.getenv("IOTURING_LOG_LEVEL")) - - console_log_level = LogLevel(console_level) - file_log_level = LogLevel(consts.FILE_LOG_LEVEL) - if printToConsole and int(loglevel) <= int(console_log_level): + if printToConsole and int(loglevel) <= int(self.console_log_level): self.ColoredPrint(string, loglevel) - if writeToFile and int(loglevel) <= int(file_log_level): + if writeToFile and int(loglevel) <= int(self.file_log_level): # acquire the lock with self.lock: self.GetLogFileDescriptor().write(string+' \n') diff --git a/IoTuring/__init__.py b/IoTuring/__init__.py index e05fba575..22dd3003a 100644 --- a/IoTuring/__init__.py +++ b/IoTuring/__init__.py @@ -44,6 +44,9 @@ def loop(): parser.print_help() os._exit(0) + # Clear the terminal + Configurator.ClearTerminal() + # Start logger: logger = Logger() configurator = Configurator() @@ -55,7 +58,7 @@ def loop(): # Check old location: configurator.CheckFile() - configurator.Menu() + configurator.Menu(clear_screen=False) except KeyboardInterrupt: logger.Log(Logger.LOG_WARNING, "Configurator", "Configuration NOT saved") From 344ae1ecf29dc8eebf44b257cc8bc20c76b93ec1 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Wed, 13 Dec 2023 02:08:45 +0100 Subject: [PATCH 05/22] Check if entity supported in configurator and before initialize --- IoTuring/ClassManager/ClassManager.py | 3 ++ IoTuring/Configurator/Configurator.py | 15 +++++--- .../Deployments/DisplayMode/DisplayMode.py | 37 ++++++++++--------- .../Entity/Deployments/Monitor/Monitor.py | 37 +++++++++++-------- .../Deployments/Temperature/Temperature.py | 18 ++++++--- IoTuring/Entity/Deployments/Volume/Volume.py | 25 +++++++------ IoTuring/Entity/Entity.py | 26 ++++++++++++- 7 files changed, 104 insertions(+), 57 deletions(-) diff --git a/IoTuring/ClassManager/ClassManager.py b/IoTuring/ClassManager/ClassManager.py index e7f4dc9f8..ea30c2763 100644 --- a/IoTuring/ClassManager/ClassManager.py +++ b/IoTuring/ClassManager/ClassManager.py @@ -102,3 +102,6 @@ def ListAvailableClassesNames(self) -> list: for py in self.modulesFilename: res.append(path.basename(py).split(".py")[0]) return res + + def ListAvailableClasses(self) -> list: + return [self.GetClassFromName(n) for n in self.ListAvailableClassesNames()] diff --git a/IoTuring/Configurator/Configurator.py b/IoTuring/Configurator/Configurator.py index c47db04b6..d79c679b5 100644 --- a/IoTuring/Configurator/Configurator.py +++ b/IoTuring/Configurator/Configurator.py @@ -251,7 +251,10 @@ def ManageSingleEntity(self, entityConfig, ecm: EntityClassManager): def SelectNewEntity(self, ecm: EntityClassManager): """ UI to add a new Entity """ - entityList = ecm.ListAvailableClassesNames() + # entity classnames without unsupported entities: + entityList = [ + e.NAME for e in ecm.ListAvailableClasses() if e.SystemSupported()] + # Now I remove the entities that are active and that do not allow multi instances for activeEntity in self.config[KEY_ACTIVE_ENTITIES]: entityClass = ecm.GetClassFromName( @@ -272,8 +275,7 @@ def SelectNewEntity(self, ecm: EntityClassManager): choice = self.DisplayMenu( choices=sorted(entityList), message="Available entities:", - instruction="if you don't see the entity you want, it may be already active and may not accept another version of itself" - + instruction="if you don't see the entity, it may be already active and not accept another version, or not supported by your system" ) if choice == CHOICE_GO_BACK: @@ -285,11 +287,14 @@ def AddActiveEntity(self, entityName, ecm: EntityClassManager): """ From entity name, get its class and retrieve the configuration preset, then add to configuration dict """ entityClass = ecm.GetClassFromName(entityName) try: - preset = entityClass.ConfigurationPreset() # type: ignore + if not entityClass: + raise Exception(f"Entityclass not found: {entityName}") + + preset = entityClass.ConfigurationPreset() if preset.HasQuestions(): # Ask for Tag if the entity allows multi-instance - multi-instance has sense only if a preset is available - if entityClass.AllowMultiInstance(): # type: ignore + if entityClass.AllowMultiInstance(): preset.AddTagQuestion() self.DisplayMessage(messages.PRESET_RULES) diff --git a/IoTuring/Entity/Deployments/DisplayMode/DisplayMode.py b/IoTuring/Entity/Deployments/DisplayMode/DisplayMode.py index 7eea923ca..0c265fd49 100644 --- a/IoTuring/Entity/Deployments/DisplayMode/DisplayMode.py +++ b/IoTuring/Entity/Deployments/DisplayMode/DisplayMode.py @@ -11,34 +11,37 @@ SELECT_CLONE_MONITOR = "Clone displays" KEY_MODE = "mode" + class DisplayMode(Entity): NAME = "DisplayMode" def Initialize(self): - callback = None - if OsD.IsWindows(): - sr = OsD.GetEnv('SystemRoot') - if sys_os.path.exists(r'{}\System32\DisplaySwitch.exe'.format(sr)): - callback = self.Callback_Win - else: - self.Log(self.LOG_ERROR, "Error log:\nOperating system: {}, sr: {}, path exists: {}".format(OsD.GetOs(), sr, sys_os.path.exists('{}\System32\DisplaySwitch.exe'.format(sr)))) - raise Exception("Unsupported software, report this log to the developer") - else: - raise Exception("Unsupported operating system for this entity") - + + callback = self.Callback_Win + self.RegisterEntityCommand(EntityCommand( self, KEY_MODE, callback)) def Callback_Win(self, message): parse_select_command = {SELECT_INTERNAL_MONITOR: "internal", - SELECT_EXTERNAL_MONITOR: "external", - SELECT_CLONE_MONITOR: "clone", - SELECT_EXTEND_MONITOR: "extend"} - + SELECT_EXTERNAL_MONITOR: "external", + SELECT_CLONE_MONITOR: "clone", + SELECT_EXTEND_MONITOR: "extend"} + if message.payload.decode('utf-8') not in parse_select_command: - self.Log(self.LOG_WARNING, f"Invalid command: {message.payload.decode('utf-8')}") + self.Log(self.LOG_WARNING, + f"Invalid command: {message.payload.decode('utf-8')}") else: sr = OsD.GetEnv('SystemRoot') - command = r'{}\System32\DisplaySwitch.exe /{}'.format(sr, parse_select_command[message.payload.decode('utf-8')]) + command = r'{}\System32\DisplaySwitch.exe /{}'.format( + sr, parse_select_command[message.payload.decode('utf-8')]) self.RunCommand(command=command) + @classmethod + def CheckSystemSupport(cls): + if OsD.IsWindows(): + sr = OsD.GetEnv('SystemRoot') + if not sys_os.path.exists(r'{}\System32\DisplaySwitch.exe'.format(sr)): + raise Exception("DisplaySwitch.exe not found!") + else: + raise cls.UnsupportedOsException() diff --git a/IoTuring/Entity/Deployments/Monitor/Monitor.py b/IoTuring/Entity/Deployments/Monitor/Monitor.py index ea436ddd8..a0c629e5e 100644 --- a/IoTuring/Entity/Deployments/Monitor/Monitor.py +++ b/IoTuring/Entity/Deployments/Monitor/Monitor.py @@ -18,37 +18,28 @@ class Monitor(Entity): def Initialize(self): if OsD.IsLinux(): - if De.IsWayland(): - raise Exception("Wayland is not supported") - else: - try: - De.CheckXsetSupport() - except Exception as e: - raise Exception(f'Xset not supported: {str(e)}') - - self.RegisterEntitySensor(EntitySensor(self, KEY_STATE)) - self.RegisterEntityCommand(EntityCommand( - self, KEY_CMD, self.Callback, KEY_STATE)) + self.RegisterEntitySensor(EntitySensor(self, KEY_STATE)) + self.RegisterEntityCommand(EntityCommand( + self, KEY_CMD, self.Callback, KEY_STATE)) elif OsD.IsWindows(): self.RegisterEntityCommand(EntityCommand( self, KEY_CMD, self.Callback)) - else: - raise Exception("Operating System not supported!") - def Callback(self, message): payloadString = message.payload.decode('utf-8') if payloadString == STATE_ON: if OsD.IsWindows(): - ctypes.windll.user32.SendMessageA(0xFFFF, 0x0112, 0xF170, -1) # type:ignore + ctypes.windll.user32.\ + SendMessageA(0xFFFF, 0x0112, 0xF170, -1) # type:ignore elif OsD.IsLinux(): self.RunCommand(command='xset dpms force on') elif payloadString == STATE_OFF: if OsD.IsWindows(): - ctypes.windll.user32.SendMessageA(0xFFFF, 0x0112, 0xF170, 2) # type:ignore + ctypes.windll.user32\ + .SendMessageA(0xFFFF, 0x0112, 0xF170, 2) # type:ignore elif OsD.IsLinux(): self.RunCommand(command='xset dpms force off') else: @@ -63,3 +54,17 @@ def Update(self): self.SetEntitySensorValue(KEY_STATE, monitorState) else: raise Exception(f'Incorrect monitor state: {monitorState}') + + @classmethod + def CheckSystemSupport(cls): + if OsD.IsLinux(): + if De.IsWayland(): + raise Exception("Wayland is not supported") + else: + try: + De.CheckXsetSupport() + except Exception as e: + raise Exception(f'Xset not supported: {str(e)}') + + elif not OsD.IsWindows(): + raise cls.UnsupportedOsException() diff --git a/IoTuring/Entity/Deployments/Temperature/Temperature.py b/IoTuring/Entity/Deployments/Temperature/Temperature.py index 72671d5df..cbe305c11 100644 --- a/IoTuring/Entity/Deployments/Temperature/Temperature.py +++ b/IoTuring/Entity/Deployments/Temperature/Temperature.py @@ -48,15 +48,16 @@ def Initialize(self): elif OsD.IsMacos(): self.specificInitialize = self.InitmacOS self.specificUpdate = self.UpdatemacOS - else: - raise Exception("Unsupported operating system.") - self.specificInitialize() + + if self.specificInitialize: + self.specificInitialize() def Update(self): - self.specificUpdate() + if self.specificUpdate: + self.specificUpdate() def InitmacOS(self): - import ioturing_applesmc + import ioturing_applesmc # type:ignore self.RegisterEntitySensor(EntitySensor(self, "cpu", supportsExtraAttributes=True, valueFormatterOptions=self.temperatureFormatOptions)) self.valid_keys = [] # get smc values for all the keys I have in the dictionary and remember @@ -72,7 +73,7 @@ def InitmacOS(self): # get smc values for all valid keys previously found, then set the sensor value # to the highest value found. Save also the other values in the extra attributes. def UpdatemacOS(self): - import ioturing_applesmc + import ioturing_applesmc # type:ignore values = {} # key = description, value = smc key for key, value in MACOS_SMC_TEMPERATURE_KEYS.items(): @@ -123,6 +124,11 @@ def packageNameToEntitySensorKey(self, packageName): return KEY_SENSOR_FORMAT.format(packageName) + @classmethod + def CheckSystemSupport(cls): + if not OsD.IsLinux() or not OsD.IsMacos(): + raise cls.UnsupportedOsException() + class psutilTemperaturePackage(): def __init__(self, packageName, packageData) -> None: """ packageData is the value of the the dict returned by psutil.sensors_temperatures() """ diff --git a/IoTuring/Entity/Deployments/Volume/Volume.py b/IoTuring/Entity/Deployments/Volume/Volume.py index 8d5df3496..7870d715e 100644 --- a/IoTuring/Entity/Deployments/Volume/Volume.py +++ b/IoTuring/Entity/Deployments/Volume/Volume.py @@ -25,21 +25,14 @@ class Volume(Entity): NAME = "Volume" def Initialize(self): - extra_attributes = False - if OsD.IsLinux(): - if not OsD.CommandExists("pactl"): - raise Exception( - "Only PulseAudio is supported on Linux! Please open an issue on Github!") - elif OsD.IsMacos(): - extra_attributes = True - else: - raise Exception("System not supported!") + # Extra attributes only on macos: + extra_attributes = OsD.IsMacos() # Register: self.RegisterEntitySensor(EntitySensor( self, KEY_STATE, - supportsExtraAttributes=extra_attributes, # Extra attributes only on macos + supportsExtraAttributes=extra_attributes, valueFormatterOptions=VALUEFORMATTEROPTIONS_PERCENTAGE_ROUND0)) self.RegisterEntityCommand(EntityCommand( self, KEY_CMD, self.Callback, KEY_STATE)) @@ -70,7 +63,8 @@ def Callback(self, message): def UpdateMac(self): # result like: output volume:44, input volume:89, alert volume:100, output muted:false - command = self.RunCommand(command=['osascript', '-e', 'get volume settings']) + command = self.RunCommand( + command=['osascript', '-e', 'get volume settings']) result = command.stdout.strip().split(',') output_volume = result[0].split(':')[1] @@ -87,3 +81,12 @@ def UpdateMac(self): KEY_STATE, EXTRA_KEY_ALERT_VOLUME, alert_volume, valueFormatterOptions=VALUEFORMATTEROPTIONS_PERCENTAGE_ROUND0) self.SetEntitySensorExtraAttribute( KEY_STATE, EXTRA_KEY_MUTED_OUTPUT, output_muted) + + @classmethod + def CheckSystemSupport(cls): + if OsD.IsLinux(): + if not OsD.CommandExists("pactl"): + raise Exception( + "Only PulseAudio is supported on Linux! Please open an issue on Github!") + elif not OsD.IsMacos(): + raise cls.UnsupportedOsException() diff --git a/IoTuring/Entity/Entity.py b/IoTuring/Entity/Entity.py index b63f22c32..e8a992e30 100644 --- a/IoTuring/Entity/Entity.py +++ b/IoTuring/Entity/Entity.py @@ -6,6 +6,7 @@ from IoTuring.Exceptions.Exceptions import UnknownEntityKeyException from IoTuring.Logger.LogObject import LogObject from IoTuring.Entity.EntityData import EntityData, EntitySensor, EntityCommand, ExtraAttribute +from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD KEY_ENTITY_TAG = 'tag' # from Configurator.Configurator @@ -24,7 +25,6 @@ def __init__(self, configurations) -> None: self.configurations = configurations self.SetTagFromConfiguration() - self.initializeState = False # When I update the values this number changes (randomly) so each warehouse knows I have updated self.valuesID = 0 self.updateTimeout = DEFAULT_UPDATE_TIMEOUT @@ -36,8 +36,8 @@ def Initialize(self): def CallInitialize(self) -> bool: """ Safe method to run the Initialize function. Returns True if no error occcured. """ try: + self.CheckSystemSupport() self.Initialize() - self.initializeState = True self.Log(self.LOG_INFO, "Initialization successfully completed") except Exception as e: self.Log(self.LOG_ERROR, @@ -231,3 +231,25 @@ def AllowMultiInstance(cls): """ Return True if this Entity can have multiple instances, useful for customizable entities These entities are the ones that must have a tag to be recognized """ return cls.ALLOW_MULTI_INSTANCE + + @classmethod + def CheckSystemSupport(cls): + """Must be implemented in subclasses. Raise an exception if system not supported.""" + return + + @classmethod + def SystemSupported(cls) -> bool: + """Check if the sysytem supported by this entity. + + Returns: + bool: True if supported + """ + try: + cls.CheckSystemSupport() + return True + except: + return False + + class UnsupportedOsException(Exception): + def __init__(self) -> None: + super().__init__(f"Unsupported operating system: {OsD.GetOs()}") From 444274a91b2aea5887b33ded09c056ca1a3c71b8 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Fri, 22 Dec 2023 03:04:53 +0100 Subject: [PATCH 06/22] ActiveWindow SystemSupport --- .../Deployments/ActiveWindow/ActiveWindow.py | 51 +++++++++---------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/IoTuring/Entity/Deployments/ActiveWindow/ActiveWindow.py b/IoTuring/Entity/Deployments/ActiveWindow/ActiveWindow.py index a59e2c68c..4f4366433 100644 --- a/IoTuring/Entity/Deployments/ActiveWindow/ActiveWindow.py +++ b/IoTuring/Entity/Deployments/ActiveWindow/ActiveWindow.py @@ -27,35 +27,15 @@ class ActiveWindow(Entity): def Initialize(self): - # Specific function for this os/de, set this here to avoid all OS - # filters on Update - self.UpdateSpecificFunction = None + UpdateFunction = { + OsD.LINUX: self.GetActiveWindow_Linux, + OsD.WINDOWS: self.GetActiveWindow_Windows, + OsD.MACOS: self.GetActiveWindow_macOS + } - if OsD.IsLinux(): - if De.IsWayland(): - raise Exception("Wayland is not supported") - elif not OsD.CommandExists("xprop"): - raise Exception("No xprop command found!") - else: - self.UpdateSpecificFunction = self.GetActiveWindow_Linux + self.UpdateSpecificFunction = UpdateFunction[OsD.GetOs()] - elif OsD.IsWindows(): - if windows_support: - self.UpdateSpecificFunction = self.GetActiveWindow_Windows - else: - raise Exception("Unsatisfied dependencies for this entity") - - elif OsD.IsMacos(): - if macos_support: - self.UpdateSpecificFunction = self.GetActiveWindow_macOS - else: - raise Exception("Unsatisfied dependencies for this entity") - else: - raise Exception( - 'Entity not available for this operating system') - - if self.UpdateSpecificFunction: - self.RegisterEntitySensor(EntitySensor(self, KEY)) + self.RegisterEntitySensor(EntitySensor(self, KEY)) def Update(self): if self.UpdateSpecificFunction: @@ -96,3 +76,20 @@ def GetActiveWindow_Linux(self) -> str: return match.group('name').strip('"') return 'Inactive' + + @classmethod + def CheckSystemSupport(cls): + if OsD.IsLinux(): + if De.IsWayland(): + raise Exception("Wayland is not supported") + elif not OsD.CommandExists("xprop"): + raise Exception("No xprop command found!") + + elif OsD.IsWindows() or OsD.IsMacos(): + + if (OsD.IsWindows() and not windows_support) or\ + (OsD.IsMacos() and not macos_support): + raise Exception("Unsatisfied dependencies for this entity") + + else: + raise cls.UnsupportedOsException() From 4973500cdf50b341de20f0306c44135e88c0bd0f Mon Sep 17 00:00:00 2001 From: infeeeee Date: Fri, 22 Dec 2023 04:12:19 +0100 Subject: [PATCH 07/22] Update Fanspeed, Lock, Notify --- .../Entity/Deployments/Fanspeed/Fanspeed.py | 34 ++++++++----- IoTuring/Entity/Deployments/Lock/Lock.py | 42 ++++++++------- IoTuring/Entity/Deployments/Notify/Notify.py | 51 +++++++++---------- 3 files changed, 69 insertions(+), 58 deletions(-) diff --git a/IoTuring/Entity/Deployments/Fanspeed/Fanspeed.py b/IoTuring/Entity/Deployments/Fanspeed/Fanspeed.py index d208020c9..e877fce9d 100644 --- a/IoTuring/Entity/Deployments/Fanspeed/Fanspeed.py +++ b/IoTuring/Entity/Deployments/Fanspeed/Fanspeed.py @@ -20,21 +20,16 @@ class Fanspeed(Entity): def Initialize(self) -> None: """Initialize the Class, setup Formatter, determin specificInitialize and specificUpdate depending on OS""" - self.specificInitialize = None - self.specificUpdate = None + InitFunction = { + OsD.LINUX: self.InitLinux + } - if OsD.IsLinux(): - # psutil docs: no attribute -> system not supported - if not hasattr(psutil, "sensors_fans"): - raise Exception("System not supported by psutil") - # psutil docs: empty dict -> no fancontrollers reporting - if not bool(psutil.sensors_fans()): - raise Exception("No fan found in system") - self.specificInitialize = self.InitLinux - self.specificUpdate = self.UpdateLinux + UpdateFunction = { + OsD.LINUX: self.UpdateLinux + } - else: - raise NotImplementedError + self.specificInitialize = InitFunction[OsD.GetOs()] + self.specificUpdate = UpdateFunction[OsD.GetOs()] self.specificInitialize() @@ -93,3 +88,16 @@ def UpdateLinux(self) -> None: fan.current, valueFormatterOptions=VALUEFORMATTEROPTIONS_FANSPEED_RPM, ) + + @classmethod + def CheckSystemSupport(cls): + if OsD.IsLinux(): + # psutil docs: no attribute -> system not supported + if not hasattr(psutil, "sensors_fans"): + raise Exception("System not supported by psutil") + # psutil docs: empty dict -> no fancontrollers reporting + if not bool(psutil.sensors_fans()): + raise Exception("No fan found in system") + + else: + raise cls.UnsupportedOsException() diff --git a/IoTuring/Entity/Deployments/Lock/Lock.py b/IoTuring/Entity/Deployments/Lock/Lock.py index bc170c32e..3ca0c5cbb 100644 --- a/IoTuring/Entity/Deployments/Lock/Lock.py +++ b/IoTuring/Entity/Deployments/Lock/Lock.py @@ -25,27 +25,12 @@ class Lock(Entity): NAME = "Lock" def Initialize(self): - self.os = OsD.GetOs() - self.de = De.GetDesktopEnvironment() - if self.os not in commands: - raise Exception("Unsupported operating system for this entity") + if OsD.IsLinux(): + self.command = self.GetLinuxCommand() - # Fallback to base, if unsupported de: - if self.de == "base" or self.de not in commands[self.os]: - desktops = ["base"] - - # supported de, add base as fallback: else: - desktops = [self.de, "base"] - - # Check if command works: - try: - self.command = next((commands[self.os][de] for de in desktops - if OsD.CommandExists(commands[self.os][de].split()[0]))) - - except StopIteration: - raise Exception(f"No lock command found for this system") + self.command = commands[OsD.GetOs()][De.GetDesktopEnvironment()] self.Log(self.LOG_DEBUG, f"Found lock command: {self.command}") @@ -54,3 +39,24 @@ def Initialize(self): def Callback_Lock(self, message): self.RunCommand(command=self.command) + + @classmethod + def GetLinuxCommand(cls) -> str: + """ Get lock command for this DesktopEnvironment. Raises Exception if not found """ + try: + cmd = next((commands[OsD.GetOs()][de] for de in [De.GetDesktopEnvironment(), "base"] + if OsD.CommandExists(commands[OsD.GetOs()][de].split()[0]))) + return cmd + except StopIteration: + raise Exception(f"No lock command found for this system") + + @classmethod + def CheckSystemSupport(cls): + if OsD.GetOs() not in commands: + raise cls.UnsupportedOsException() + + if OsD.IsLinux(): + try: + cls.GetLinuxCommand() + except Exception as e: + raise Exception("Lock command error: " + str(e)) diff --git a/IoTuring/Entity/Deployments/Notify/Notify.py b/IoTuring/Entity/Deployments/Notify/Notify.py index 5beba78a7..d26a3ecba 100644 --- a/IoTuring/Entity/Deployments/Notify/Notify.py +++ b/IoTuring/Entity/Deployments/Notify/Notify.py @@ -14,8 +14,8 @@ supports_win = False commands = { - OsD.LINUX: 'notify-send "{}" "{}" --icon="ICON_PATH"', - OsD.MACOS: 'osascript -e \'display notification "{}" with title "{}"\'' + OsD.LINUX: 'notify-send "{}" "{}" --icon="{}"', # title, message, icon path + OsD.MACOS: 'osascript -e \'display notification "{}" with title "{}"\'' # message, title } @@ -80,29 +80,6 @@ def Initialize(self): self.NAME = self.NAME + \ ("Payload" if self.data_mode == MODE_DATA_VIA_PAYLOAD else "") - # Prepare the notification system - if OsD.IsWindows(): - if not supports_win: - raise Exception( - 'Notify not available, have you installed \'tinyWinToast\' on pip ?') - - elif OsD.GetOs() in commands: - if not OsD.CommandExists(commands[OsD.GetOs()].split(" ")[0]): - raise Exception( - f'Command not found {commands[OsD.GetOs()].split(" ")[0]}!' - ) - - # Add icon to command: - if "ICON_PATH" in commands[OsD.GetOs()]: - self.command = commands[OsD.GetOs()].replace( - "ICON_PATH", self.config_icon_path) - else: - self.command = commands[OsD.GetOs()] - - else: - raise Exception( - 'Notify not available for this platorm!') - self.RegisterEntityCommand(EntityCommand(self, KEY, self.Callback)) def Callback(self, message): @@ -133,9 +110,13 @@ def Callback(self, message): isMute=False).show() else: + if OsD.IsMacos(): + command = commands[OsD.GetOs()].format( + self.notification_message, self.notification_title) - command = self.command.format( - self.notification_title, self.notification_message) + else: # Linux: + command = commands[OsD.GetOs()].format( + self.notification_title, self.notification_message, self.config_icon_path) self.RunCommand(command=command, shell=True) @@ -152,3 +133,19 @@ def ConfigurationPreset(cls) -> MenuPreset: mandatory=False, default=DEFAULT_ICON_PATH, question_type="filepath") return preset + + @classmethod + def CheckSystemSupport(cls): + if OsD.IsWindows(): + if not supports_win: + raise Exception( + 'Notify not available, have you installed \'tinyWinToast\' on pip ?') + + elif OsD.GetOs() in commands: + if not OsD.CommandExists(commands[OsD.GetOs()].split(" ")[0]): + raise Exception( + f'Command not found {commands[OsD.GetOs()].split(" ")[0]}!' + ) + + else: + raise cls.UnsupportedOsException() From 118f3d315a14d333fe6916f6ca57a745641a2c5c Mon Sep 17 00:00:00 2001 From: Riccardo Briccola Date: Sat, 23 Dec 2023 09:58:47 +0100 Subject: [PATCH 08/22] Fix temperature OS check, change configurator message --- IoTuring/Configurator/Configurator.py | 2 +- IoTuring/Entity/Deployments/Temperature/Temperature.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/IoTuring/Configurator/Configurator.py b/IoTuring/Configurator/Configurator.py index d79c679b5..63af10404 100644 --- a/IoTuring/Configurator/Configurator.py +++ b/IoTuring/Configurator/Configurator.py @@ -275,7 +275,7 @@ def SelectNewEntity(self, ecm: EntityClassManager): choice = self.DisplayMenu( choices=sorted(entityList), message="Available entities:", - instruction="if you don't see the entity, it may be already active and not accept another version, or not supported by your system" + instruction="if you don't see the entity, it may be already active and not accept another activation, or not supported by your system" ) if choice == CHOICE_GO_BACK: diff --git a/IoTuring/Entity/Deployments/Temperature/Temperature.py b/IoTuring/Entity/Deployments/Temperature/Temperature.py index cbe305c11..5965f1fef 100644 --- a/IoTuring/Entity/Deployments/Temperature/Temperature.py +++ b/IoTuring/Entity/Deployments/Temperature/Temperature.py @@ -126,7 +126,7 @@ def packageNameToEntitySensorKey(self, packageName): @classmethod def CheckSystemSupport(cls): - if not OsD.IsLinux() or not OsD.IsMacos(): + if not OsD.IsLinux() and not OsD.IsMacos(): raise cls.UnsupportedOsException() class psutilTemperaturePackage(): From 9b12a964ee387e932109a656c0c5b481f0098c45 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Wed, 27 Dec 2023 22:32:39 +0100 Subject: [PATCH 09/22] Power entity systemsupport --- IoTuring/Entity/Deployments/Power/Power.py | 115 +++++++++++---------- 1 file changed, 63 insertions(+), 52 deletions(-) diff --git a/IoTuring/Entity/Deployments/Power/Power.py b/IoTuring/Entity/Deployments/Power/Power.py index 99c301363..bd3895eb5 100644 --- a/IoTuring/Entity/Deployments/Power/Power.py +++ b/IoTuring/Entity/Deployments/Power/Power.py @@ -8,23 +8,25 @@ KEY_REBOOT = 'reboot' KEY_SLEEP = 'sleep' -commands_shutdown = { - OsD.WINDOWS: 'shutdown /s /t 0', - OsD.MACOS: 'sudo shutdown -h now', - OsD.LINUX: 'poweroff' +commands = { + OsD.WINDOWS: { + KEY_SHUTDOWN: 'shutdown /s /t 0', + KEY_REBOOT: 'shutdown /r', + KEY_SLEEP: 'rundll32.exe powrprof.dll,SetSuspendState 0,1,0' + }, + OsD.MACOS: { + KEY_SHUTDOWN: 'sudo shutdown -h now', + KEY_REBOOT: 'sudo reboot' + }, + OsD.LINUX: { + KEY_SHUTDOWN: 'poweroff', + KEY_REBOOT: 'reboot', + KEY_SLEEP: 'systemctl suspend' + } } - -commands_reboot = { - OsD.WINDOWS: 'shutdown /r', - OsD.MACOS: 'sudo reboot', - OsD.LINUX: 'reboot' -} - -commands_sleep = { - OsD.WINDOWS: 'rundll32.exe powrprof.dll,SetSuspendState 0,1,0', - OsD.LINUX: 'systemctl suspend', - 'Linux_X11': 'xset dpms force standby', +linux_optional_sleep_commands = { + 'X11': 'xset dpms force standby' } @@ -34,43 +36,11 @@ class Power(Entity): def Initialize(self): self.commands = {} - self.os = OsD.GetOs() - # Check if commands are available for this OS/DE combo, then register them - - # Shutdown - if self.os in commands_shutdown: - self.commands[KEY_SHUTDOWN] = commands_shutdown[self.os] - self.RegisterEntityCommand(EntityCommand( - self, KEY_SHUTDOWN, self.Callback)) - - # Reboot - if self.os in commands_reboot: - self.commands[KEY_REBOOT] = commands_reboot[self.os] - self.RegisterEntityCommand(EntityCommand( - self, KEY_REBOOT, self.Callback)) - - # Try if command works without sudo, add if it's not working: - if OsD.IsLinux(): - for commandtype in self.commands: - testcommand = self.commands[commandtype] + " --wtmp-only" - if not self.RunCommand(testcommand).returncode == 0: - self.commands[commandtype] = "sudo " + \ - self.commands[commandtype] - - # Sleep - if self.os in commands_sleep: - self.commands[KEY_SLEEP] = commands_sleep[self.os] - - # Fallback to xset, if supported: - if OsD.IsLinux() and not De.IsWayland(): - try: - De.CheckXsetSupport() - self.commands[KEY_SLEEP] = commands_sleep["Linux_X11"] - except Exception as e: - self.Log(self.LOG_DEBUG, f'Xset not supported: {str(e)}') - - self.RegisterEntityCommand(EntityCommand( - self, KEY_SLEEP, self.Callback)) + for command_key in [KEY_SHUTDOWN, KEY_REBOOT, KEY_SLEEP]: + if command_key in commands[OsD.GetOs()]: + self.commands[command_key] = self.GetCommand(command_key) + self.RegisterEntityCommand(EntityCommand( + self, command_key, self.Callback)) def Callback(self, message): # From the topic we can find the command: @@ -79,3 +49,44 @@ def Callback(self, message): command=self.commands[key], command_name=key ) + + def GetCommand(self, command_key: str) -> str: + """Get the command for this command_key + + Args: + command_key (str): KEY_SHUTDOWN, KEY_REBOOT or KEY_SLEEP + + Returns: + str: The command string + """ + + command = commands[OsD.GetOs()][command_key] + + if OsD.IsLinux(): + + if command_key == KEY_SLEEP: + # Fallback to xset, if supported: + if not De.IsWayland(): + try: + De.CheckXsetSupport() + command = linux_optional_sleep_commands['X11'] + except Exception as e: + self.Log(self.LOG_DEBUG, + f'Xset not supported: {str(e)}') + + else: + # Try if command works without sudo, add if it's not working: + testcommand = command + " --wtmp-only" + + if not self.RunCommand(testcommand).returncode == 0: + command = "sudo " + command + + self.Log(self.LOG_DEBUG, + f'Found {command_key} command: {command}') + + return command + + @classmethod + def CheckSystemSupport(cls): + if OsD.GetOs() not in commands: + raise cls.UnsupportedOsException() From 21276dd3bbe79aa79556730f88b1fd10c25e30bf Mon Sep 17 00:00:00 2001 From: infeeeee Date: Thu, 28 Dec 2023 02:39:32 +0100 Subject: [PATCH 10/22] Show unsupported entities in menu, terminal size handling --- IoTuring/Configurator/Configurator.py | 88 +++++++++++++++++++++------ IoTuring/Configurator/messages.py | 6 +- IoTuring/__init__.py | 2 +- 3 files changed, 71 insertions(+), 25 deletions(-) diff --git a/IoTuring/Configurator/Configurator.py b/IoTuring/Configurator/Configurator.py index 63af10404..fb34824af 100644 --- a/IoTuring/Configurator/Configurator.py +++ b/IoTuring/Configurator/Configurator.py @@ -1,6 +1,6 @@ import os import subprocess - +import shutil from IoTuring.Logger.LogObject import LogObject from IoTuring.Exceptions.Exceptions import UserCancelledException @@ -35,7 +35,7 @@ class Configurator(LogObject): def __init__(self) -> None: - self.pinned_message = False + self.pinned_lines = 1 self.configuratorIO = ConfiguratorIO.ConfiguratorIO() self.config = self.LoadConfigurations() @@ -90,12 +90,9 @@ def OpenConfigInEditor(self): self.Log(self.LOG_WARNING, "No editor found") - def Menu(self, clear_screen: bool = True) -> None: + def Menu(self) -> None: """ UI for Entities and Warehouses settings """ - if not clear_screen: - self.pinned_message = True - mainMenuChoices = [ {"name": "Manage entities", "value": self.ManageEntities}, {"name": "Manage warehouses", "value": self.ManageWarehouses}, @@ -128,6 +125,7 @@ def ManageEntities(self) -> None: manageEntitiesChoices = [ CHOICE_GO_BACK, {"name": "+ Add a new entity", "value": "AddNewEntity"}, + {"name": "? Unsupported entities", "value": "UnsupportedEntities"}, Separator() ] + manageEntitiesChoices @@ -138,6 +136,8 @@ def ManageEntities(self) -> None: if choice == "AddNewEntity": self.SelectNewEntity(ecm) + elif choice == "UnsupportedEntities": + self.ShowUnsupportedEntities(ecm) elif choice == CHOICE_GO_BACK: self.Menu() else: @@ -173,6 +173,8 @@ def ManageWarehouses(self) -> None: def DisplayHelp(self) -> None: self.DisplayMessage(messages.HELP_MESSAGE) + # Help message is too long: + self.pinned_lines = 1 self.Menu() def Quit(self) -> None: @@ -283,6 +285,26 @@ def SelectNewEntity(self, ecm: EntityClassManager): else: self.AddActiveEntity(choice, ecm) + def ShowUnsupportedEntities(self, ecm: EntityClassManager): + """ UI to show unsupported entities """ + + # entity classnames without unsupported entities: + unsupportedEntityList = [] + for eClass in ecm.ListAvailableClasses(): + try: + eClass.CheckSystemSupport() + except Exception as e: + unsupportedEntityList.append(f"\t{eClass.NAME}: {str(e)}") + + if not unsupportedEntityList: + self.DisplayMessage("No unsupported entities :)") + + else: + msg = "\n".join(sorted(unsupportedEntityList)) + self.DisplayMessage("Unsupported entities:\n" + msg) + + self.ManageEntities() + def AddActiveEntity(self, entityName, ecm: EntityClassManager): """ From entity name, get its class and retrieve the configuration preset, then add to configuration dict """ entityClass = ecm.GetClassFromName(entityName) @@ -300,6 +322,7 @@ def AddActiveEntity(self, entityName, ecm: EntityClassManager): self.DisplayMessage(messages.PRESET_RULES) self.DisplayMessage(f"Configure {entityName} Entity") preset.AskQuestions() + self.ClearScreen(force_clear=True) else: self.DisplayMessage( @@ -354,6 +377,7 @@ def AddActiveWarehouse(self, warehouseName, wcm: WarehouseClassManager) -> None: if preset.HasQuestions(): self.DisplayMessage(messages.PRESET_RULES) preset.AskQuestions() + self.ClearScreen(force_clear=True) else: self.DisplayMessage( @@ -405,29 +429,25 @@ def WarehouseMenuPresetToConfiguration(self, whName, preset) -> None: _dict = preset.GetDict() _dict[KEY_WAREHOUSE_TYPE] = whName.replace("Warehouse", "") self.config[KEY_ACTIVE_WAREHOUSES].append(_dict) - print("Configuration added for \""+whName+"\" :)") + self.DisplayMessage("Configuration added for \""+whName+"\" :)") def EntityMenuPresetToConfiguration(self, entityName, preset) -> None: """ Get a MenuPreset with responses and add the entries to the configurations dict in entity part """ _dict = preset.GetDict() _dict[KEY_ENTITY_TYPE] = entityName self.config[KEY_ACTIVE_ENTITIES].append(_dict) - print("Configuration added for \""+entityName+"\" :)") + self.DisplayMessage("Configuration added for \""+entityName+"\" :)") - def ClearScreen(self, pin_next_message=False): - """ Clear the screen on any platform. If self.pinned_message is True, it won't be cleared. + def ClearScreen(self, force_clear=False): + """ Clear the screen on any platform. If self.pinned_lines greater than zero, it won't be cleared. Args: - pin_next_message (bool, optional): Set self.pinned_message after clear. Defaults to False. + force_clear (bool, optional): Clear even pinned messages. Defaults to False. """ - if not self.pinned_message: + if self.pinned_lines == 0 or force_clear: self.ClearTerminal() - - if pin_next_message: - self.pinned_message = True - else: - self.pinned_message = False + self.pinned_lines = 0 def DisplayMenu(self, choices: list, message: str = "", add_back_choice=True, **kwargs): """ Wrapper for inquirer.select @@ -447,9 +467,37 @@ def DisplayMenu(self, choices: list, message: str = "", add_back_choice=True, ** ] + choices if "max_height" not in kwargs: + + # 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: + + # 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 + # Add only the line of the message: + else: + message_lines = 1 + + # Calculate nr of lines required to display: + required_lines = len(choices) + \ + self.pinned_lines + message_lines + + # Set the calculated height: + if required_lines > terminal_lines: + kwargs["max_height"] = terminal_lines \ + - self.pinned_lines - message_lines + self.ClearScreen() + # Reset pinned lines: + self.pinned_lines = 0 + prompt = inquirer.select( message=message, choices=choices, **kwargs) @@ -469,9 +517,9 @@ def DisplayMessage(self, message: str, force_clear=False): message (str): The message to display force_clear (bool): clear screen regardless previous """ - if force_clear: - self.pinned_message = False - self.ClearScreen(pin_next_message=True) + + self.ClearScreen(force_clear) + self.pinned_lines += message.count('\n') + 2 print(message) print() diff --git a/IoTuring/Configurator/messages.py b/IoTuring/Configurator/messages.py index e42f246c1..3e67ea22b 100644 --- a/IoTuring/Configurator/messages.py +++ b/IoTuring/Configurator/messages.py @@ -18,7 +18,5 @@ \tUse ctrl+C to exit without saving """ -PRESET_RULES = """ -Options with this sign are compulsory: {!} -Use Escape to cancel -""" \ No newline at end of file +PRESET_RULES = """Options with this sign are compulsory: {!} +Use Escape to cancel""" \ No newline at end of file diff --git a/IoTuring/__init__.py b/IoTuring/__init__.py index 22dd3003a..513cfc75c 100644 --- a/IoTuring/__init__.py +++ b/IoTuring/__init__.py @@ -58,7 +58,7 @@ def loop(): # Check old location: configurator.CheckFile() - configurator.Menu(clear_screen=False) + configurator.Menu() except KeyboardInterrupt: logger.Log(Logger.LOG_WARNING, "Configurator", "Configuration NOT saved") From 264ab80226f1923836500dae1e5ea222efbb58ba Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sat, 30 Dec 2023 21:49:14 +0100 Subject: [PATCH 11/22] AppSettings retry --- IoTuring/Configurator/Configurator.py | 65 ++++++++- IoTuring/Configurator/ConfiguratorLoader.py | 16 ++- IoTuring/Configurator/MenuPreset.py | 148 +++++++++++--------- IoTuring/MyApp/AppSettings.py | 48 +++++++ IoTuring/Warehouse/Warehouse.py | 5 +- IoTuring/__init__.py | 3 + 6 files changed, 210 insertions(+), 75 deletions(-) create mode 100644 IoTuring/MyApp/AppSettings.py diff --git a/IoTuring/Configurator/Configurator.py b/IoTuring/Configurator/Configurator.py index fb34824af..3592f3d00 100644 --- a/IoTuring/Configurator/Configurator.py +++ b/IoTuring/Configurator/Configurator.py @@ -2,6 +2,8 @@ import subprocess import shutil +from IoTuring.Configurator.MenuPreset import QuestionPreset + from IoTuring.Logger.LogObject import LogObject from IoTuring.Exceptions.Exceptions import UserCancelledException @@ -12,16 +14,21 @@ from IoTuring.Configurator import messages from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD +from IoTuring.MyApp.AppSettings import AppSettings from InquirerPy import inquirer from InquirerPy.separator import Separator -BLANK_CONFIGURATION = {'active_entities': [ - {"type": "AppInfo"}], 'active_warehouses': []} +BLANK_CONFIGURATION = { + 'active_entities': [{"type": "AppInfo"}], + 'active_warehouses': [], + 'app_settings': {} +} KEY_ACTIVE_ENTITIES = "active_entities" KEY_ACTIVE_WAREHOUSES = "active_warehouses" +KEY_APP_SETTINGS = "app_settings" KEY_WAREHOUSE_TYPE = "type" @@ -96,6 +103,7 @@ def Menu(self) -> None: mainMenuChoices = [ {"name": "Manage entities", "value": self.ManageEntities}, {"name": "Manage warehouses", "value": self.ManageWarehouses}, + {"name": "App Settings", "value": self.ManageSettings}, {"name": "Start IoTuring", "value": self.WriteConfigurations}, {"name": "Help", "value": self.DisplayHelp}, {"name": "Quit", "value": self.Quit}, @@ -171,6 +179,55 @@ def ManageWarehouses(self) -> None: else: self.ManageSingleWarehouse(choice, wcm) + def ManageSettings(self): + preset = AppSettings.ConfigurationPreset() + + settingsChoices = [] + + for entry in preset.presets: + # Load config instead of default: + if entry.key in self.config[KEY_APP_SETTINGS]: + value = self.config[KEY_APP_SETTINGS][entry.key] + else: + value = entry.default + + settingsChoices.append({ + "name": f"{entry.name}: {value}", + "value": entry.key + }) + + choice = self.DisplayMenu( + choices=settingsChoices, + message="Select setting to edit", + add_back_choice=True) + + if choice == CHOICE_GO_BACK: + self.Menu() + else: + q_preset = preset.GetPresetByKey(choice) + if q_preset: + self.ManageSingleSetting(q_preset) + else: + self.DisplayMessage(f"Question preset not found: {choice}") + self.ManageSettings() + + + def ManageSingleSetting(self, q_preset:QuestionPreset): + + # Load config as default: + if q_preset.key in self.config[KEY_APP_SETTINGS]: + q_preset.default = self.config[KEY_APP_SETTINGS][q_preset.key] + + value = q_preset.Ask() + + + if value: + # Add to config: + self.config[KEY_APP_SETTINGS][q_preset.key] = value + + self.ManageSettings() + + def DisplayHelp(self) -> None: self.DisplayMessage(messages.HELP_MESSAGE) # Help message is too long: @@ -188,6 +245,10 @@ def LoadConfigurations(self) -> dict: read_config = self.configuratorIO.readConfigurations() if read_config is None: read_config = BLANK_CONFIGURATION + + # Add AppSettings to old configurations: + if not KEY_APP_SETTINGS in read_config: + read_config[KEY_APP_SETTINGS] = {} return read_config def WriteConfigurations(self) -> None: diff --git a/IoTuring/Configurator/ConfiguratorLoader.py b/IoTuring/Configurator/ConfiguratorLoader.py index 728ffbba5..1c0fe1f01 100644 --- a/IoTuring/Configurator/ConfiguratorLoader.py +++ b/IoTuring/Configurator/ConfiguratorLoader.py @@ -1,10 +1,11 @@ from __future__ import annotations from IoTuring.Entity.Entity import Entity from IoTuring.Logger.LogObject import LogObject -from IoTuring.Configurator.Configurator import KEY_ENTITY_TYPE, Configurator, KEY_ACTIVE_ENTITIES, KEY_ACTIVE_WAREHOUSES, KEY_WAREHOUSE_TYPE +from IoTuring.Configurator.Configurator import KEY_ENTITY_TYPE, Configurator, KEY_ACTIVE_ENTITIES, KEY_ACTIVE_WAREHOUSES, KEY_WAREHOUSE_TYPE, KEY_APP_SETTINGS from IoTuring.ClassManager.WarehouseClassManager import WarehouseClassManager from IoTuring.ClassManager.EntityClassManager import EntityClassManager from IoTuring.Warehouse.Warehouse import Warehouse +from IoTuring.MyApp.AppSettings import AppSettings class ConfiguratorLoader(LogObject): @@ -62,3 +63,16 @@ def LoadEntities(self) -> list[Entity]: # - for each one: # - pass the configuration to the warehouse function that uses the configuration to init the Warehouse # - append the Warehouse to the list + + + def LoadAppSettings(self) -> None: + """ Load app settings from config and defafults to AppSettings.Settings class attribute """ + if not KEY_APP_SETTINGS in self.configurations: + self.configurations[KEY_APP_SETTINGS] = {} + + appSettings = AppSettings(self.configurations[KEY_APP_SETTINGS]) + appSettings.AddMissingDefaultConfigs() + + # Add configs to class: + AppSettings.Settings = appSettings.GetConfigurations() + \ No newline at end of file diff --git a/IoTuring/Configurator/MenuPreset.py b/IoTuring/Configurator/MenuPreset.py index 72c709a03..309b66303 100644 --- a/IoTuring/Configurator/MenuPreset.py +++ b/IoTuring/Configurator/MenuPreset.py @@ -63,6 +63,81 @@ def ShouldDisplay(self, menupreset: "MenuPreset") -> bool: return should_display + def Ask(self, menupreset: "MenuPreset | None" = None): + """Ask a single question preset""" + + question_options = {} + + if self.mandatory: + def validate(x): return bool(x) + question_options.update({ + "validate": validate, + "invalid_message": "You must provide a value for this key" + }) + + question_options["message"] = self.question + ":" + + if self.default is not None: + # yesno questions need boolean default: + if self.question_type == "yesno": + question_options["default"] = \ + bool(str(self.default).lower() + in BooleanAnswers.TRUE_ANSWERS) + elif self.question_type == "integer": + question_options["default"] = int(self.default) + else: + question_options["default"] = self.default + else: + if self.question_type == "integer": + # The default default is 0, overwrite to None: + question_options["default"] = None + + # text: + prompt_function = inquirer.text + + if self.question_type == "secret": + prompt_function = inquirer.secret + + elif self.question_type == "yesno": + prompt_function = inquirer.confirm + question_options.update({ + "filter": lambda x: "Y" if x else "N" + }) + + elif self.question_type == "select": + prompt_function = inquirer.select + question_options.update({ + "choices": self.choices + }) + + elif self.question_type == "integer": + prompt_function = inquirer.number + question_options["float_allowed"] = False + + elif self.question_type == "filepath": + prompt_function = inquirer.filepath + + prompt = prompt_function( + instruction=self.instruction, + **question_options + ) + + cancelled = False + + @prompt.register_kb("escape") + def _handle_esc(event): + prompt._mandatory = False + prompt._handle_skip(event) + # exception raised here catched by inquirer. + cancelled = True + + value = prompt.execute() + + if cancelled: + raise UserCancelledException + + return value + class MenuPreset(): @@ -132,75 +207,7 @@ def AskQuestions(self) -> None: # It should be displayed, ask question: if q_preset.ShouldDisplay(self): - question_options = {} - - if q_preset.mandatory: - def validate(x): return bool(x) - question_options.update({ - "validate": validate, - "invalid_message": "You must provide a value for this key" - }) - - question_options["message"] = q_preset.question + ":" - - if q_preset.default is not None: - # yesno questions need boolean default: - if q_preset.question_type == "yesno": - question_options["default"] = \ - bool(str(q_preset.default).lower() - in BooleanAnswers.TRUE_ANSWERS) - elif q_preset.question_type == "integer": - question_options["default"] = int(q_preset.default) - else: - question_options["default"] = q_preset.default - else: - if q_preset.question_type == "integer": - # The default default is 0, overwrite to None: - question_options["default"] = None - - # text: - prompt_function = inquirer.text - - if q_preset.question_type == "secret": - prompt_function = inquirer.secret - - elif q_preset.question_type == "yesno": - prompt_function = inquirer.confirm - question_options.update({ - "filter": lambda x: "Y" if x else "N" - }) - - elif q_preset.question_type == "select": - prompt_function = inquirer.select - question_options.update({ - "choices": q_preset.choices - }) - - elif q_preset.question_type == "integer": - prompt_function = inquirer.number - question_options["float_allowed"] = False - - elif q_preset.question_type == "filepath": - prompt_function = inquirer.filepath - - # Create the prompt: - prompt = prompt_function( - instruction=q_preset.instruction, - **question_options - ) - - # Handle escape keypress: - @prompt.register_kb("escape") - def _handle_esc(event): - prompt._mandatory = False - prompt._handle_skip(event) - # exception raised here catched by inquirer. - self.cancelled = True - - value = prompt.execute() - - if self.cancelled: - raise UserCancelledException + value = q_preset.Ask(self) if value: q_preset.value = value @@ -215,6 +222,9 @@ def _handle_esc(event): def GetAnsweredPresetByKey(self, key: str) -> QuestionPreset | None: return next((entry for entry in self.results if entry.key == key), None) + def GetPresetByKey(self, key: str) -> QuestionPreset | None: + return next((entry for entry in self.presets if entry.key == key), None) + def GetDict(self) -> dict: """ Get a dict with keys and responses""" return {entry.key: entry.value for entry in self.results} diff --git a/IoTuring/MyApp/AppSettings.py b/IoTuring/MyApp/AppSettings.py new file mode 100644 index 000000000..cc3f3b74c --- /dev/null +++ b/IoTuring/MyApp/AppSettings.py @@ -0,0 +1,48 @@ +from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject +from IoTuring.Configurator.MenuPreset import MenuPreset + +from IoTuring.Logger import consts + +CONFIG_KEY_CONSOLE_LOG_LEVEL = "console_log_level" +CONFIG_KEY_FILE_LOG_LEVEL = "console_file_level" + + +CONFIG_KEY_UPDATE_INTERVAL = "update_interval" +CONFIG_KEY_SLOW_INTERVAL = "slow_interval" + +DEFAULT_LOG_LEVEL = "LOG_INFO" + +LogLevelChoices = [{"name": l["string"], "value": l["const"]} + for l in consts.LOG_LEVELS] +# LogLevelChoices = [l["const"] for l in consts.LOG_LEVELS] + + +class AppSettings(ConfiguratorObject): + # Default log levels, so Logging can start before configuration is loaded + Settings = { + CONFIG_KEY_CONSOLE_LOG_LEVEL: DEFAULT_LOG_LEVEL, + CONFIG_KEY_FILE_LOG_LEVEL: DEFAULT_LOG_LEVEL + } + + @classmethod + def ConfigurationPreset(cls): + preset = MenuPreset() + + preset.AddEntry(name="Console log level", key=CONFIG_KEY_CONSOLE_LOG_LEVEL, + question_type="select", mandatory=True, default=DEFAULT_LOG_LEVEL, + instruction="IOTURING_LOG_LEVEL envvar overwrites this setting!", + choices=LogLevelChoices) + + preset.AddEntry(name="File log level", key=CONFIG_KEY_FILE_LOG_LEVEL, + question_type="select", mandatory=True, default=DEFAULT_LOG_LEVEL, + choices=LogLevelChoices) + + preset.AddEntry(name="Main update interval in seconds", + key=CONFIG_KEY_UPDATE_INTERVAL, mandatory=True, + question_type="text", default="10") + + preset.AddEntry(name="Secondary update interval in minutes", + key=CONFIG_KEY_SLOW_INTERVAL, mandatory=True, + question_type="text", default="10") + + return preset diff --git a/IoTuring/Warehouse/Warehouse.py b/IoTuring/Warehouse/Warehouse.py index 82a84855e..4994f5f9d 100644 --- a/IoTuring/Warehouse/Warehouse.py +++ b/IoTuring/Warehouse/Warehouse.py @@ -3,18 +3,17 @@ from IoTuring.Logger.LogObject import LogObject from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject from IoTuring.Entity.EntityManager import EntityManager +from IoTuring.MyApp.AppSettings import AppSettings, CONFIG_KEY_UPDATE_INTERVAL from threading import Thread import time -DEFAULT_LOOP_TIMEOUT = 10 - class Warehouse(LogObject, ConfiguratorObject): NAME = "Unnamed" def __init__(self, configurations) -> None: - self.loopTimeout = DEFAULT_LOOP_TIMEOUT + self.loopTimeout = float(AppSettings.Settings[CONFIG_KEY_UPDATE_INTERVAL]) self.configurations = configurations def Start(self) -> None: diff --git a/IoTuring/__init__.py b/IoTuring/__init__.py index 513cfc75c..9d2ae010c 100644 --- a/IoTuring/__init__.py +++ b/IoTuring/__init__.py @@ -71,6 +71,9 @@ def loop(): # This have to start after configurator.Menu(), otherwise won't work starting from the menu signal.signal(signal.SIGINT, Exit_SIGINT_handler) + # Load AppSettings: + ConfiguratorLoader(configurator).LoadAppSettings() + logger.Log(Logger.LOG_INFO, "App", App()) # Print App info logger.Log(Logger.LOG_INFO, "Configurator", "Run the script with -c to enter configuration mode") From 254d0e60955e2243a2eeb950de9e658f3d31362a Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sun, 31 Dec 2023 18:25:49 +0100 Subject: [PATCH 12/22] Configuration classes, related changes --- IoTuring/Configurator/Configuration.py | 181 ++++++++++++ IoTuring/Configurator/Configurator.py | 271 ++++++------------ IoTuring/Configurator/ConfiguratorLoader.py | 42 ++- IoTuring/Configurator/ConfiguratorObject.py | 27 +- IoTuring/Configurator/MenuPreset.py | 10 +- .../Deployments/FileSwitch/FileSwitch.py | 2 +- IoTuring/Entity/Deployments/Notify/Notify.py | 8 +- .../Entity/Deployments/Terminal/Terminal.py | 40 +-- IoTuring/Entity/Entity.py | 25 +- IoTuring/Warehouse/Warehouse.py | 7 +- 10 files changed, 348 insertions(+), 265 deletions(-) create mode 100644 IoTuring/Configurator/Configuration.py diff --git a/IoTuring/Configurator/Configuration.py b/IoTuring/Configurator/Configuration.py new file mode 100644 index 000000000..4095f5702 --- /dev/null +++ b/IoTuring/Configurator/Configuration.py @@ -0,0 +1,181 @@ +# config categories: +KEY_ACTIVE_ENTITIES = "active_entities" +KEY_ACTIVE_WAREHOUSES = "active_warehouses" +KEY_APP_SETTINGS = "app_settings" + +CONFIG_CATEGORY_NAME = { + KEY_ACTIVE_ENTITIES: "Entity", + KEY_ACTIVE_WAREHOUSES: "Warehouse", + KEY_APP_SETTINGS: "AppSetting" +} + + +BLANK_CONFIGURATION = { + KEY_ACTIVE_ENTITIES: [{"type": "AppInfo"}], + KEY_ACTIVE_WAREHOUSES: [], + KEY_APP_SETTINGS: [] +} + +KEY_ENTITY_TAG = "tag" +KEY_ENTITY_TYPE = "type" + + +class FullConfiguration: + + def __init__(self, config_dict: dict = BLANK_CONFIGURATION) -> None: + + self.configs = [] + + for config_category in config_dict: + for single_config_dict in config_dict[config_category]: + self.configs.append(SingleConfiguration( + config_category, single_config_dict)) + + def GetConfigsInCategory(self, config_category: str) -> list["SingleConfiguration"]: + """Return all configurations in a category + + Args: + config_category (str): KEY_ACTIVE_ENTITIES, KEY_ACTIVE_WAREHOUSES or KEY_APP_SETTINGS + + Returns: + list: Configurations in the category. Empty list if none found. + """ + return [config for config in self.configs if config.config_category == config_category] + + def GetConfigsOfType(self, config_type: str, config_category: str = "") -> list["SingleConfiguration"]: + """Return all configs with the given type, from the given category + + Args: + config_type (str): The type of config to return + config_category (str, optional): Optional filter for the category. Defaults to no filter. + + Returns: + list: Configurations of the given type. Empty list if none found. + """ + if config_category: + config_list = self.GetConfigsInCategory(config_category) + else: + config_list = self.configs + + return [config for config in config_list if config.GetType() == config_type] + + def RemoveActiveConfiguration(self, config: "SingleConfiguration") -> None: + """Remove a configuration from the list of active configurations""" + if config in self.configs: + self.configs.remove(config) + else: + raise ValueError("Configuration not found") + + def AddConfiguration(self, config_category: str, single_config_dict: dict, config_type: str = "") -> None: + """Add a new configuration to the list of active configurations + + Args: + config_category (str): KEY_ACTIVE_ENTITIES or KEY_ACTIVE_WAREHOUSES + single_config_dict (dict): all settings as a dict + config_type (str, optional): The type of the configuration, if not included in the dict. + + Raises: + ValueError: Config type not defined in the dict nor in the function call + """ + + if KEY_ENTITY_TYPE not in single_config_dict: + if config_type: + single_config_dict[KEY_ENTITY_TYPE] = config_type + else: + raise ValueError("Configuration type not specified") + + self.configs.append(SingleConfiguration( + config_category, single_config_dict)) + + def GetAppSettings(self) -> "SingleConfiguration": + if self.GetConfigsInCategory(KEY_APP_SETTINGS): + return self.GetConfigsInCategory(KEY_APP_SETTINGS)[0] + else: + appconfig = SingleConfiguration(KEY_APP_SETTINGS, {}) + self.configs.append(appconfig) + return appconfig + + def ToDict(self) -> dict: + config_dict = {} + for config_category in BLANK_CONFIGURATION: + config_dict[config_category] = [] + for single_config in self.GetConfigsInCategory(config_category): + config_dict[config_category].append(single_config.ToDict()) + + return config_dict + + +class SingleConfiguration: + + config_category: str + type: str + tag: str + configurations: dict + + def __init__(self, config_category: str, config_dict: dict) -> None: + self.config_category = config_category + + if KEY_ENTITY_TYPE in config_dict: + config_type = config_dict.pop(KEY_ENTITY_TYPE) + setattr(self, KEY_ENTITY_TYPE, config_type) + + if KEY_ENTITY_TAG in config_dict: + config_tag = config_dict.pop(KEY_ENTITY_TAG) + setattr(self, KEY_ENTITY_TAG, config_tag) + + self.configurations = config_dict + + def GetType(self) -> str: + """ Get the type name of entity""" + if hasattr(self, KEY_ENTITY_TYPE): + return getattr(self, KEY_ENTITY_TYPE) + else: + return self.config_category + + def GetTag(self) -> str: + """ Get the tag of entity""" + if hasattr(self, KEY_ENTITY_TAG): + return getattr(self, KEY_ENTITY_TAG) + else: + return "" + + def GetLabel(self) -> str: + """ Get the type name of this configuration, add tag if multi""" + + label = self.GetType() + + if hasattr(self, KEY_ENTITY_TAG): + label += f" with tag {getattr(self, KEY_ENTITY_TAG)}" + + return label + + def GetLongName(self) -> str: + """ Add category name to the end """ + return str(self.GetType() + self.GetCategoryName()) + + def GetCategoryName(self) -> str: + return CONFIG_CATEGORY_NAME[self.config_category] + + def GetConfigValue(self, config_key: str): + if config_key in self.configurations: + return self.configurations[config_key] + else: + raise ValueError("Config key not set") + + def UpdateConfigValue(self, config_key: str, config_value: str) -> None: + self.configurations[config_key] = config_value + + def UpdateConfigDict(self, config_dict: dict) -> None: + self.configurations.update(config_dict) + + def HasConfigKey(self, config_key: str) -> bool: + return bool(config_key in self.configurations) + + def ToDict(self) -> dict: + full_dict = self.configurations + if hasattr(self, KEY_ENTITY_TYPE): + full_dict[KEY_ENTITY_TYPE] = getattr(self, KEY_ENTITY_TYPE) + if hasattr(self, KEY_ENTITY_TAG): + full_dict[KEY_ENTITY_TAG] = getattr(self, KEY_ENTITY_TAG) + + return full_dict diff --git a/IoTuring/Configurator/Configurator.py b/IoTuring/Configurator/Configurator.py index 3592f3d00..681468458 100644 --- a/IoTuring/Configurator/Configurator.py +++ b/IoTuring/Configurator/Configurator.py @@ -3,6 +3,7 @@ import shutil from IoTuring.Configurator.MenuPreset import QuestionPreset +from IoTuring.Configurator.Configuration import FullConfiguration, SingleConfiguration, KEY_ACTIVE_ENTITIES, KEY_ACTIVE_WAREHOUSES from IoTuring.Logger.LogObject import LogObject from IoTuring.Exceptions.Exceptions import UserCancelledException @@ -20,21 +21,6 @@ from InquirerPy.separator import Separator -BLANK_CONFIGURATION = { - 'active_entities': [{"type": "AppInfo"}], - 'active_warehouses': [], - 'app_settings': {} -} - -KEY_ACTIVE_ENTITIES = "active_entities" -KEY_ACTIVE_WAREHOUSES = "active_warehouses" -KEY_APP_SETTINGS = "app_settings" - -KEY_WAREHOUSE_TYPE = "type" - -KEY_ENTITY_TYPE = "type" -KEY_ENTITY_TAG = "tag" - CHOICE_GO_BACK = "< Go back" @@ -45,11 +31,14 @@ def __init__(self) -> None: self.pinned_lines = 1 self.configuratorIO = ConfiguratorIO.ConfiguratorIO() - self.config = self.LoadConfigurations() - def GetConfigurations(self) -> dict: - """ Return a copy of the configurations dict""" - return self.config.copy() # Safe return + # Load configuration from file, create Configuration object: + config_dict_from_file = self.configuratorIO.readConfigurations() + if config_dict_from_file: + self.config = FullConfiguration(config_dict_from_file) + else: + # Create blank config: + self.config = FullConfiguration() def CheckFile(self) -> None: """ Make sure config file exists or can be created """ @@ -122,13 +111,13 @@ def ManageEntities(self) -> None: manageEntitiesChoices = [] - for entityConfig in self.config[KEY_ACTIVE_ENTITIES]: + for entityConfig in self.config.GetConfigsInCategory(KEY_ACTIVE_ENTITIES): manageEntitiesChoices.append( - {"name": self.GetEntityLabel(entityConfig), + {"name": entityConfig.GetLabel(), "value": entityConfig} ) - manageEntitiesChoices.sort(key=lambda d: d['name']) + manageEntitiesChoices.sort(key=lambda d: d['name']) manageEntitiesChoices = [ CHOICE_GO_BACK, @@ -149,7 +138,7 @@ def ManageEntities(self) -> None: elif choice == CHOICE_GO_BACK: self.Menu() else: - self.ManageSingleEntity(choice, ecm) + self.ManageSingleEntity(choice) def ManageWarehouses(self) -> None: """ UI for Warehouses settings """ @@ -157,17 +146,16 @@ def ManageWarehouses(self) -> None: manageWhChoices = [] - availableWarehouses = wcm.ListAvailableClassesNames() - for whName in availableWarehouses: - short_wh_name = whName.replace("Warehouse", "") + availableWarehouses = wcm.ListAvailableClasses() + for whClass in availableWarehouses: enabled_sign = " " - if self.IsWarehouseActive(short_wh_name): + if self.IsWarehouseActive(whClass): enabled_sign = "X" manageWhChoices.append( - {"name": f"[{enabled_sign}] - {short_wh_name}", - "value": short_wh_name}) + {"name": f"[{enabled_sign}] - {whClass.NAME}", + "value": whClass}) choice = self.DisplayMenu( choices=manageWhChoices, @@ -177,7 +165,7 @@ def ManageWarehouses(self) -> None: if choice == CHOICE_GO_BACK: self.Menu() else: - self.ManageSingleWarehouse(choice, wcm) + self.ManageSingleWarehouse(choice) def ManageSettings(self): preset = AppSettings.ConfigurationPreset() @@ -186,8 +174,8 @@ def ManageSettings(self): for entry in preset.presets: # Load config instead of default: - if entry.key in self.config[KEY_APP_SETTINGS]: - value = self.config[KEY_APP_SETTINGS][entry.key] + if self.config.GetAppSettings().HasConfigKey(entry.key): + value = self.config.GetAppSettings().GetConfigValue(entry.key) else: value = entry.default @@ -198,8 +186,7 @@ def ManageSettings(self): choice = self.DisplayMenu( choices=settingsChoices, - message="Select setting to edit", - add_back_choice=True) + message="Select setting to edit") if choice == CHOICE_GO_BACK: self.Menu() @@ -211,22 +198,21 @@ def ManageSettings(self): self.DisplayMessage(f"Question preset not found: {choice}") self.ManageSettings() + def ManageSingleSetting(self, q_preset: QuestionPreset): - def ManageSingleSetting(self, q_preset:QuestionPreset): + appSettingsConfig = self.config.GetAppSettings() # Load config as default: - if q_preset.key in self.config[KEY_APP_SETTINGS]: - q_preset.default = self.config[KEY_APP_SETTINGS][q_preset.key] + if appSettingsConfig.HasConfigKey(q_preset.key): + q_preset.default = appSettingsConfig.GetConfigValue(q_preset.key) value = q_preset.Ask() - if value: # Add to config: - self.config[KEY_APP_SETTINGS][q_preset.key] = value + appSettingsConfig.UpdateConfigValue(q_preset.key, value) self.ManageSettings() - def DisplayHelp(self) -> None: self.DisplayMessage(messages.HELP_MESSAGE) @@ -239,26 +225,14 @@ def Quit(self) -> None: self.WriteConfigurations() exit(0) - def LoadConfigurations(self) -> dict: - """ Reads the configuration file and returns configuration dictionary. - If not available, returns the blank configuration """ - read_config = self.configuratorIO.readConfigurations() - if read_config is None: - read_config = BLANK_CONFIGURATION - - # Add AppSettings to old configurations: - if not KEY_APP_SETTINGS in read_config: - read_config[KEY_APP_SETTINGS] = {} - return read_config - def WriteConfigurations(self) -> None: """ Save to configurations file """ - self.configuratorIO.writeConfigurations(self.config) + self.configuratorIO.writeConfigurations(self.config.ToDict()) - def ManageSingleWarehouse(self, warehouseName, wcm: WarehouseClassManager): + def ManageSingleWarehouse(self, whClass): """UI for single Warehouse settings""" - if self.IsWarehouseActive(warehouseName): + if self.IsWarehouseActive(whClass): manageWhChoices = [ {"name": "Edit the warehouse settings", "value": "Edit"}, {"name": "Remove the warehouse", "value": "Remove"} @@ -269,25 +243,28 @@ def ManageSingleWarehouse(self, warehouseName, wcm: WarehouseClassManager): choice = self.DisplayMenu( choices=manageWhChoices, - message=f"Manage warehouse {warehouseName}" + message=f"Manage warehouse {whClass.NAME}" ) if choice == CHOICE_GO_BACK: self.ManageWarehouses() - elif choice == "Edit": - self.EditActiveWarehouse(warehouseName, wcm) elif choice == "Add": - self.AddActiveWarehouse(warehouseName, wcm) - elif choice == "Remove": - confirm = inquirer.confirm(message="Are you sure?").execute() + self.AddActiveClass(whClass, KEY_ACTIVE_WAREHOUSES) + self.ManageWarehouses() + else: + whConfig = self.config.GetConfigsOfType(whClass.NAME)[0] + if choice == "Edit": + self.EditActiveWarehouse(whConfig) + elif choice == "Remove": + confirm = inquirer.confirm(message="Are you sure?").execute() + + if confirm: + self.RemoveActiveConfiguration(whConfig) - if confirm: - self.RemoveActiveWarehouse(warehouseName) - else: self.ManageWarehouses() - def ManageSingleEntity(self, entityConfig, ecm: EntityClassManager): - """ UI to manage an active warehouse (edit config/remove) """ + def ManageSingleEntity(self, entityConfig: SingleConfiguration): + """ UI to manage an active entity (edit config/remove) """ manageEntityChoices = [ {"name": "Edit the entity settings", "value": "Edit"}, @@ -296,47 +273,42 @@ def ManageSingleEntity(self, entityConfig, ecm: EntityClassManager): choice = self.DisplayMenu( choices=manageEntityChoices, - message=f"Manage entity {self.GetEntityLabel(entityConfig)}" + message=f"Manage entity {entityConfig.GetLabel()}" ) if choice == CHOICE_GO_BACK: self.ManageEntities() elif choice == "Edit": - self.EditActiveEntity(entityConfig, ecm) # type: ignore + self.EditActiveEntity(entityConfig) # type: ignore elif choice == "Remove": confirm = inquirer.confirm(message="Are you sure?").execute() if confirm: - self.RemoveActiveEntity(entityConfig) - else: - self.ManageEntities() + self.RemoveActiveConfiguration(entityConfig) + + self.ManageEntities() def SelectNewEntity(self, ecm: EntityClassManager): """ UI to add a new Entity """ - # entity classnames without unsupported entities: - entityList = [ - e.NAME for e in ecm.ListAvailableClasses() if e.SystemSupported()] - - # Now I remove the entities that are active and that do not allow multi instances - for activeEntity in self.config[KEY_ACTIVE_ENTITIES]: - entityClass = ecm.GetClassFromName( - activeEntity[KEY_ENTITY_TYPE]) + # entity classes without unsupported entities: + entityClasses = [ + e for e in ecm.ListAvailableClasses() if e.SystemSupported()] - # Malformed entities, from different versions in config, just skip: - if entityClass is None: - continue + entityChoices = [] - # If the Allow Multi Instance option was changed: - if activeEntity[KEY_ENTITY_TYPE] not in entityList: - continue + for entityClass in entityClasses: + if self.config.GetConfigsOfType(entityClass.NAME): + if not entityClass.AllowMultiInstance(): + continue + entityChoices.append( + {"name": entityClass.NAME, "value": entityClass} + ) - # not multi, remove: - if not entityClass.AllowMultiInstance(): # type: ignore - entityList.remove(activeEntity[KEY_ENTITY_TYPE]) + entityChoices.sort(key=lambda d: d['name']) choice = self.DisplayMenu( - choices=sorted(entityList), + choices=entityChoices, message="Available entities:", instruction="if you don't see the entity, it may be already active and not accept another activation, or not supported by your system" ) @@ -344,7 +316,8 @@ def SelectNewEntity(self, ecm: EntityClassManager): if choice == CHOICE_GO_BACK: self.ManageEntities() else: - self.AddActiveEntity(choice, ecm) + self.AddActiveClass(choice, KEY_ACTIVE_ENTITIES) + self.ManageEntities() def ShowUnsupportedEntities(self, ecm: EntityClassManager): """ UI to show unsupported entities """ @@ -366,96 +339,48 @@ def ShowUnsupportedEntities(self, ecm: EntityClassManager): self.ManageEntities() - def AddActiveEntity(self, entityName, ecm: EntityClassManager): - """ From entity name, get its class and retrieve the configuration preset, then add to configuration dict """ - entityClass = ecm.GetClassFromName(entityName) - try: - if not entityClass: - raise Exception(f"Entityclass not found: {entityName}") - - preset = entityClass.ConfigurationPreset() - - if preset.HasQuestions(): - # Ask for Tag if the entity allows multi-instance - multi-instance has sense only if a preset is available - if entityClass.AllowMultiInstance(): - preset.AddTagQuestion() - - self.DisplayMessage(messages.PRESET_RULES) - self.DisplayMessage(f"Configure {entityName} Entity") - preset.AskQuestions() - self.ClearScreen(force_clear=True) - - else: - self.DisplayMessage( - "No configuration needed for this Entity :)") - - self.EntityMenuPresetToConfiguration(entityName, preset) - except UserCancelledException: - self.DisplayMessage("Configuration cancelled", force_clear=True) - - except Exception as e: - print("Error during entity preset loading: " + str(e)) - - self.ManageEntities() - - def IsEntityActive(self, entityName) -> bool: - """ Return True if an Entity is active """ - for entity in self.config[KEY_ACTIVE_ENTITIES]: - if entityName == entity[KEY_ENTITY_TYPE]: - return True - return False - - def GetEntityLabel(self, entityConfig) -> str: - """ Get the type name of entity, add tag if multi""" - entityLabel = entityConfig[KEY_ENTITY_TYPE] - if KEY_ENTITY_TAG in entityConfig: - entityLabel += f" with tag {entityConfig[KEY_ENTITY_TAG]}" - return entityLabel - - def RemoveActiveEntity(self, entityConfig) -> None: - """ Remove entity name from the list of active entities if present """ - if entityConfig in self.config[KEY_ACTIVE_ENTITIES]: - self.config[KEY_ACTIVE_ENTITIES].remove(entityConfig) - + def RemoveActiveConfiguration(self, singleConfig: SingleConfiguration) -> None: + """ Remove configuration (wh or entity) """ + self.config.RemoveActiveConfiguration(singleConfig) self.DisplayMessage( - f"Entity removed: {self.GetEntityLabel(entityConfig)}") - self.ManageEntities() + f"{singleConfig.GetCategoryName()} removed: {singleConfig.GetLabel()}") - def IsWarehouseActive(self, warehouseName) -> bool: + def IsWarehouseActive(self, whClass) -> bool: """ Return True if a warehouse is active """ - for wh in self.config[KEY_ACTIVE_WAREHOUSES]: - if warehouseName == wh[KEY_WAREHOUSE_TYPE]: - return True - return False - - def AddActiveWarehouse(self, warehouseName, wcm: WarehouseClassManager) -> None: - """ Add warehouse to the preferences using a menu with the warehouse preset if available """ + return bool(self.config.GetConfigsOfType(whClass.NAME)) - whClass = wcm.GetClassFromName(warehouseName + "Warehouse") + def AddActiveClass(self, ioClass, config_category: str) -> None: try: - preset = whClass.ConfigurationPreset() # type: ignore + preset = ioClass.ConfigurationPreset() if preset.HasQuestions(): + + if config_category == KEY_ACTIVE_ENTITIES: + # Ask for Tag if the entity allows multi-instance - multi-instance has sense only if a preset is available + if ioClass.AllowMultiInstance(): + preset.AddTagQuestion() + self.DisplayMessage(messages.PRESET_RULES) + self.DisplayMessage( + f"Configure {ioClass.NAME} {ioClass.CATEGORY_NAME}") preset.AskQuestions() self.ClearScreen(force_clear=True) else: self.DisplayMessage( - "No configuration needed for this Warehouse :)") + f"No configuration needed for this {ioClass.CATEGORY_NAME} :)") - # Save added settings - self.WarehouseMenuPresetToConfiguration(warehouseName, preset) + self.config.AddConfiguration( + config_category, preset.GetDict(), ioClass.NAME) except UserCancelledException: self.DisplayMessage("Configuration cancelled", force_clear=True) except Exception as e: - print("Error during warehouse preset loading: " + str(e)) + print( + f"Error during {ioClass.CATEGORY_NAME} preset loading: {str(e)}") - self.ManageWarehouses() - - def EditActiveWarehouse(self, warehouseName, wcm: WarehouseClassManager) -> None: + def EditActiveWarehouse(self, whConfig: SingleConfiguration) -> None: """ UI for single Warehouse settings edit """ self.DisplayMessage( "You can't do that at the moment, change the configuration file manually. Sorry for the inconvenience") @@ -466,7 +391,7 @@ def EditActiveWarehouse(self, warehouseName, wcm: WarehouseClassManager) -> None # WarehouseMenuPresetToConfiguration appends a warehosue to the conf so here I should remove it to read it later # TO implement only when I know how to add removable value while editing configurations - def EditActiveEntity(self, entityConfig, ecm: WarehouseClassManager) -> None: + def EditActiveEntity(self, entityConfig: SingleConfiguration) -> None: """ UI for single Entity settings edit """ self.DisplayMessage( "You can't do that at the moment, change the configuration file manually. Sorry for the inconvenience") @@ -475,30 +400,6 @@ def EditActiveEntity(self, entityConfig, ecm: WarehouseClassManager) -> None: # TODO Implement - def RemoveActiveWarehouse(self, warehouseName) -> None: - """ Remove warehouse name from the list of active warehouses if present """ - for wh in self.config[KEY_ACTIVE_WAREHOUSES]: - if warehouseName == wh[KEY_WAREHOUSE_TYPE]: - # I remove this wh from the list - self.config[KEY_ACTIVE_WAREHOUSES].remove(wh) - - self.DisplayMessage(f"Warehouse removed: {warehouseName}") - self.ManageWarehouses() - - def WarehouseMenuPresetToConfiguration(self, whName, preset) -> None: - """ Get a MenuPreset with responses and add the entries to the configurations dict in warehouse part """ - _dict = preset.GetDict() - _dict[KEY_WAREHOUSE_TYPE] = whName.replace("Warehouse", "") - self.config[KEY_ACTIVE_WAREHOUSES].append(_dict) - self.DisplayMessage("Configuration added for \""+whName+"\" :)") - - def EntityMenuPresetToConfiguration(self, entityName, preset) -> None: - """ Get a MenuPreset with responses and add the entries to the configurations dict in entity part """ - _dict = preset.GetDict() - _dict[KEY_ENTITY_TYPE] = entityName - self.config[KEY_ACTIVE_ENTITIES].append(_dict) - self.DisplayMessage("Configuration added for \""+entityName+"\" :)") - def ClearScreen(self, force_clear=False): """ Clear the screen on any platform. If self.pinned_lines greater than zero, it won't be cleared. diff --git a/IoTuring/Configurator/ConfiguratorLoader.py b/IoTuring/Configurator/ConfiguratorLoader.py index 1c0fe1f01..91b12c737 100644 --- a/IoTuring/Configurator/ConfiguratorLoader.py +++ b/IoTuring/Configurator/ConfiguratorLoader.py @@ -1,7 +1,7 @@ from __future__ import annotations from IoTuring.Entity.Entity import Entity from IoTuring.Logger.LogObject import LogObject -from IoTuring.Configurator.Configurator import KEY_ENTITY_TYPE, Configurator, KEY_ACTIVE_ENTITIES, KEY_ACTIVE_WAREHOUSES, KEY_WAREHOUSE_TYPE, KEY_APP_SETTINGS +from IoTuring.Configurator.Configurator import Configurator, KEY_ACTIVE_ENTITIES, KEY_ACTIVE_WAREHOUSES from IoTuring.ClassManager.WarehouseClassManager import WarehouseClassManager from IoTuring.ClassManager.EntityClassManager import EntityClassManager from IoTuring.Warehouse.Warehouse import Warehouse @@ -12,28 +12,27 @@ class ConfiguratorLoader(LogObject): configurator = None def __init__(self, configurator: Configurator) -> None: - self.configurations = configurator.GetConfigurations() + self.configurations = configurator.config # Return list of instances initialized using their configurations def LoadWarehouses(self) -> list[Warehouse]: warehouses = [] wcm = WarehouseClassManager() - if not KEY_ACTIVE_WAREHOUSES in self.configurations: + if not self.configurations.GetConfigsInCategory(KEY_ACTIVE_WAREHOUSES): self.Log( self.LOG_ERROR, "You have to enable at least one warehouse: configure it using -c argument") exit(1) - for activeWarehouse in self.configurations[KEY_ACTIVE_WAREHOUSES]: + for whConfig in self.configurations.GetConfigsInCategory(KEY_ACTIVE_WAREHOUSES): # Get WareHouse named like in config type field, then init it with configs and add it to warehouses list - whClass = wcm.GetClassFromName( - activeWarehouse[KEY_WAREHOUSE_TYPE]+"Warehouse") + whClass = wcm.GetClassFromName(whConfig.GetLongName()) if whClass is None: - self.Log(self.LOG_ERROR, "Can't find " + - activeWarehouse[KEY_WAREHOUSE_TYPE] + " warehouse, check your configurations.") + self.Log(self.LOG_ERROR, f"Can't find {whConfig.GetType()} warehouse, check your configurations.") else: - whClass(activeWarehouse).AddMissingDefaultConfigs() - self.Log(self.LOG_DEBUG, f"Full configuration with defaults: {whClass(activeWarehouse).configurations}") - warehouses.append(whClass(activeWarehouse)) + wh = whClass(whConfig) + wh.AddMissingDefaultConfigs() + self.Log(self.LOG_DEBUG, f"Full configuration with defaults: {wh.configurations}") + warehouses.append(wh) return warehouses # warehouses[0].AddEntity(eM.NewEntity(eM.EntityNameToClass("Username")).getInstance()): may be useful @@ -41,19 +40,19 @@ def LoadWarehouses(self) -> list[Warehouse]: def LoadEntities(self) -> list[Entity]: entities = [] ecm = EntityClassManager() - if not KEY_ACTIVE_ENTITIES in self.configurations: + if not self.configurations.GetConfigsInCategory(KEY_ACTIVE_ENTITIES): self.Log( self.LOG_ERROR, "You have to enable at least one entity: configure it using -c argument") exit(1) - for activeEntity in self.configurations[KEY_ACTIVE_ENTITIES]: - entityClass = ecm.GetClassFromName(activeEntity[KEY_ENTITY_TYPE]) + for entityConfig in self.configurations.GetConfigsInCategory(KEY_ACTIVE_ENTITIES): + entityClass = ecm.GetClassFromName(entityConfig.GetType()) if entityClass is None: - self.Log(self.LOG_ERROR, "Can't find " + - activeEntity[KEY_ENTITY_TYPE] + " entity, check your configurations.") + self.Log(self.LOG_ERROR, f"Can't find {entityConfig.GetType()} entity, check your configurations.") else: - entityClass(activeEntity).AddMissingDefaultConfigs() - self.Log(self.LOG_DEBUG, f"Full configuration with defaults: {entityClass(activeEntity).configurations}") - entities.append(entityClass(activeEntity)) # Entity instance + ec = entityClass(entityConfig) + ec.AddMissingDefaultConfigs() + self.Log(self.LOG_DEBUG, f"Full configuration with defaults: {ec.configurations}") + entities.append(ec) # Entity instance return entities # How Warehouse configurations works: @@ -67,10 +66,9 @@ def LoadEntities(self) -> list[Entity]: def LoadAppSettings(self) -> None: """ Load app settings from config and defafults to AppSettings.Settings class attribute """ - if not KEY_APP_SETTINGS in self.configurations: - self.configurations[KEY_APP_SETTINGS] = {} + - appSettings = AppSettings(self.configurations[KEY_APP_SETTINGS]) + appSettings = AppSettings(self.configurations.GetAppSettings()) appSettings.AddMissingDefaultConfigs() # Add configs to class: diff --git a/IoTuring/Configurator/ConfiguratorObject.py b/IoTuring/Configurator/ConfiguratorObject.py index 6dfbf9b18..7ed443e9b 100644 --- a/IoTuring/Configurator/ConfiguratorObject.py +++ b/IoTuring/Configurator/ConfiguratorObject.py @@ -1,24 +1,24 @@ -from IoTuring.Configurator.MenuPreset import BooleanAnswers -from IoTuring.Configurator.MenuPreset import MenuPreset +from IoTuring.Configurator.MenuPreset import BooleanAnswers, MenuPreset +from IoTuring.Configurator.Configuration import SingleConfiguration class ConfiguratorObject: """ Base class for configurable classes """ - def __init__(self, configurations) -> None: - self.configurations = configurations + def __init__(self, single_configuration: SingleConfiguration) -> None: + self.configurations = single_configuration def GetConfigurations(self) -> dict: - """ Safe return configurations dict """ - return self.configurations.copy() + """ return configuration as dict """ + return self.configurations.ToDict() def GetFromConfigurations(self, key): - """ Get value from confiugurations with key (if not present raise Exception) """ - if key in self.GetConfigurations(): - return self.GetConfigurations()[key] + """ Get value from confiugurations with key (if not present raise Exception).""" + if self.configurations.HasConfigKey(key): + return self.configurations.GetConfigValue(key) else: raise Exception("Can't find key " + key + " in configurations") - + def GetTrueOrFalseFromConfigurations(self, key) -> bool: """ Get boolean value from confiugurations with key (if not present raise Exception) """ value = self.GetFromConfigurations(key).lower() @@ -33,9 +33,10 @@ def AddMissingDefaultConfigs(self) -> None: defaults = preset.GetDefaults() if defaults: - for default_key in defaults: - if default_key not in self.GetConfigurations(): - self.configurations[default_key] = defaults[default_key] + for default_key, default_value in defaults.items(): + if not self.configurations.HasConfigKey(default_key): + self.configurations.UpdateConfigValue(default_key, default_value) + @classmethod def ConfigurationPreset(cls) -> MenuPreset: diff --git a/IoTuring/Configurator/MenuPreset.py b/IoTuring/Configurator/MenuPreset.py index 309b66303..44b28bb02 100644 --- a/IoTuring/Configurator/MenuPreset.py +++ b/IoTuring/Configurator/MenuPreset.py @@ -63,7 +63,7 @@ def ShouldDisplay(self, menupreset: "MenuPreset") -> bool: return should_display - def Ask(self, menupreset: "MenuPreset | None" = None): + def Ask(self): """Ask a single question preset""" question_options = {} @@ -122,18 +122,18 @@ def validate(x): return bool(x) **question_options ) - cancelled = False + self.cancelled = False @prompt.register_kb("escape") def _handle_esc(event): prompt._mandatory = False prompt._handle_skip(event) # exception raised here catched by inquirer. - cancelled = True + self.cancelled = True value = prompt.execute() - if cancelled: + if self.cancelled: raise UserCancelledException return value @@ -207,7 +207,7 @@ def AskQuestions(self) -> None: # It should be displayed, ask question: if q_preset.ShouldDisplay(self): - value = q_preset.Ask(self) + value = q_preset.Ask() if value: q_preset.value = value diff --git a/IoTuring/Entity/Deployments/FileSwitch/FileSwitch.py b/IoTuring/Entity/Deployments/FileSwitch/FileSwitch.py index c12c5c7c6..a440e6d74 100644 --- a/IoTuring/Entity/Deployments/FileSwitch/FileSwitch.py +++ b/IoTuring/Entity/Deployments/FileSwitch/FileSwitch.py @@ -17,7 +17,7 @@ class FileSwitch(Entity): def Initialize(self): try: - self.config_path = self.GetConfigurations()[CONFIG_KEY_PATH] + self.config_path = self.GetFromConfigurations(CONFIG_KEY_PATH) except Exception as e: raise Exception("Configuration error: " + str(e)) diff --git a/IoTuring/Entity/Deployments/Notify/Notify.py b/IoTuring/Entity/Deployments/Notify/Notify.py index d26a3ecba..7b3105088 100644 --- a/IoTuring/Entity/Deployments/Notify/Notify.py +++ b/IoTuring/Entity/Deployments/Notify/Notify.py @@ -46,13 +46,13 @@ class Notify(Entity): def Initialize(self): # Check if both config is defined or both is empty: - if not bool(self.GetConfigurations()[CONFIG_KEY_TITLE]) == bool(self.GetConfigurations()[CONFIG_KEY_MESSAGE]): + if not bool(self.GetFromConfigurations(CONFIG_KEY_TITLE)) == bool(self.GetFromConfigurations(CONFIG_KEY_MESSAGE)): raise Exception( "Configuration error: Both title and message should be defined, or both should be empty!") try: - self.config_title = self.GetConfigurations()[CONFIG_KEY_TITLE] - self.config_message = self.GetConfigurations()[CONFIG_KEY_MESSAGE] + self.config_title = self.GetFromConfigurations(CONFIG_KEY_TITLE) + self.config_message = self.GetFromConfigurations(CONFIG_KEY_MESSAGE) self.data_mode = MODE_DATA_VIA_CONFIG except Exception as e: self.data_mode = MODE_DATA_VIA_PAYLOAD @@ -67,7 +67,7 @@ def Initialize(self): self.Log(self.LOG_INFO, "Using data from payload") # Set and check icon path: - self.config_icon_path = self.GetConfigurations()[CONFIG_KEY_ICON_PATH] + self.config_icon_path = self.GetFromConfigurations(CONFIG_KEY_ICON_PATH) if not os.path.exists(self.config_icon_path): self.Log( diff --git a/IoTuring/Entity/Deployments/Terminal/Terminal.py b/IoTuring/Entity/Deployments/Terminal/Terminal.py index b2212c87d..dd048e885 100644 --- a/IoTuring/Entity/Deployments/Terminal/Terminal.py +++ b/IoTuring/Entity/Deployments/Terminal/Terminal.py @@ -61,8 +61,8 @@ class Terminal(Entity): def Initialize(self): - self.config_entity_type = self.GetConfigurations()[ - CONFIG_KEY_ENTITY_TYPE] + self.config_entity_type = self.GetFromConfigurations( + CONFIG_KEY_ENTITY_TYPE) # sanitize entity type: self.entity_type = str( @@ -86,16 +86,16 @@ def Initialize(self): # payload_command if self.entity_type == ENTITY_TYPE_KEYS["PAYLOAD_COMMAND"]: self.config_command_regex = \ - self.GetConfigurations()[CONFIG_KEY_COMMAND_REGEX] + self.GetFromConfigurations(CONFIG_KEY_COMMAND_REGEX) # Check if it's a correct regex: if not re.search(r"^\^.*\$$", self.config_command_regex): raise Exception( f"Configuration error: Invalid regex: {self.config_command_regex}") # Get max length: - if self.GetConfigurations()[CONFIG_KEY_LENGTH]: + if self.GetFromConfigurations(CONFIG_KEY_LENGTH): self.config_length = int( - self.GetConfigurations()[CONFIG_KEY_LENGTH]) + self.GetFromConfigurations(CONFIG_KEY_LENGTH)) else: # Fall back to infinite: self.config_length = float("inf") @@ -104,52 +104,52 @@ def Initialize(self): # button elif self.entity_type == ENTITY_TYPE_KEYS["BUTTON"]: - self.config_command = self.GetConfigurations()[ - CONFIG_KEY_COMMAND_ON] + self.config_command = self.GetFromConfigurations( + CONFIG_KEY_COMMAND_ON) self.has_command = True # switch elif self.entity_type == ENTITY_TYPE_KEYS["SWITCH"]: self.config_command_on = \ - self.GetConfigurations()[CONFIG_KEY_COMMAND_ON] + self.GetFromConfigurations(CONFIG_KEY_COMMAND_ON) self.config_command_off = \ - self.GetConfigurations()[CONFIG_KEY_COMMAND_OFF] + self.GetFromConfigurations(CONFIG_KEY_COMMAND_OFF) self.has_command = True - if self.GetConfigurations()[CONFIG_KEY_COMMAND_STATE]: + if self.GetFromConfigurations(CONFIG_KEY_COMMAND_STATE): self.config_command_state = \ - self.GetConfigurations()[CONFIG_KEY_COMMAND_STATE] + self.GetFromConfigurations(CONFIG_KEY_COMMAND_STATE) self.has_binary_state = True # sensor elif self.entity_type == ENTITY_TYPE_KEYS["SENSOR"]: self.config_command_state = \ - self.GetConfigurations()[CONFIG_KEY_COMMAND_STATE] + self.GetFromConfigurations(CONFIG_KEY_COMMAND_STATE) self.has_state = True - self.config_unit = self.GetConfigurations()[CONFIG_KEY_UNIT] + self.config_unit = self.GetFromConfigurations(CONFIG_KEY_UNIT) if self.config_unit: self.custom_payload["unit_of_measurement"] = self.config_unit - if self.GetConfigurations()[CONFIG_KEY_DECIMALS]: + if self.GetFromConfigurations(CONFIG_KEY_DECIMALS): self.value_formatter_options = \ ValueFormatterOptions(value_type=ValueFormatterOptions.TYPE_NONE, - decimals=int(self.GetConfigurations()[CONFIG_KEY_DECIMALS])) + decimals=int(self.GetFromConfigurations(CONFIG_KEY_DECIMALS))) # binary sensor elif self.entity_type == ENTITY_TYPE_KEYS["BINARY_SENSOR"]: self.config_command_state = \ - self.GetConfigurations()[CONFIG_KEY_COMMAND_STATE] + self.GetFromConfigurations(CONFIG_KEY_COMMAND_STATE) self.has_binary_state = True # cover elif self.entity_type == ENTITY_TYPE_KEYS["COVER"]: self.config_cover_commands = { - "OPEN": self.GetConfigurations()[CONFIG_KEY_COMMAND_OPEN], - "CLOSE": self.GetConfigurations()[CONFIG_KEY_COMMAND_CLOSE] + "OPEN": self.GetFromConfigurations(CONFIG_KEY_COMMAND_OPEN), + "CLOSE": self.GetFromConfigurations(CONFIG_KEY_COMMAND_CLOSE) } - stop_command = self.GetConfigurations()[CONFIG_KEY_COMMAND_STOP] + stop_command = self.GetFromConfigurations(CONFIG_KEY_COMMAND_STOP) if stop_command: self.config_cover_commands["STOP"] = stop_command @@ -159,7 +159,7 @@ def Initialize(self): self.has_command = True self.config_command_state = \ - self.GetConfigurations()[CONFIG_KEY_COMMAND_STATE] + self.GetFromConfigurations(CONFIG_KEY_COMMAND_STATE) if self.config_command_state: self.has_state = True diff --git a/IoTuring/Entity/Entity.py b/IoTuring/Entity/Entity.py index e8a992e30..2b99352d0 100644 --- a/IoTuring/Entity/Entity.py +++ b/IoTuring/Entity/Entity.py @@ -3,31 +3,36 @@ import subprocess from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject +from IoTuring.Configurator.Configuration import SingleConfiguration, CONFIG_CATEGORY_NAME, KEY_ACTIVE_ENTITIES +from IoTuring.MyApp.AppSettings import AppSettings, CONFIG_KEY_UPDATE_INTERVAL from IoTuring.Exceptions.Exceptions import UnknownEntityKeyException from IoTuring.Logger.LogObject import LogObject from IoTuring.Entity.EntityData import EntityData, EntitySensor, EntityCommand, ExtraAttribute from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD -KEY_ENTITY_TAG = 'tag' # from Configurator.Configurator -DEFAULT_UPDATE_TIMEOUT = 10 class Entity(LogObject, ConfiguratorObject): NAME = "Unnamed" ALLOW_MULTI_INSTANCE = False + CATEGORY_NAME = CONFIG_CATEGORY_NAME[KEY_ACTIVE_ENTITIES] - def __init__(self, configurations) -> None: + + def __init__(self, single_configuration: SingleConfiguration) -> None: + # Prepare the entity self.entitySensors = [] self.entityCommands = [] - self.configurations = configurations - self.SetTagFromConfiguration() + self.configurations = single_configuration + self.tag = self.configurations.GetTag() + # When I update the values this number changes (randomly) so each warehouse knows I have updated self.valuesID = 0 - self.updateTimeout = DEFAULT_UPDATE_TIMEOUT + self.updateTimeout = float(AppSettings.Settings[CONFIG_KEY_UPDATE_INTERVAL]) + def Initialize(self): """ Must be implemented in sub-classes, may be useful here to use the configuration """ @@ -145,7 +150,7 @@ def GetEntityName(self) -> str: def GetEntityTag(self) -> str: """ Return entity identifier tag """ - return self.tag # Set from SetTagFromConfiguration on entity init + return self.tag def GetEntityNameWithTag(self) -> str: """ Return entity name and tag combined (or name alone if no tag is present) """ @@ -163,12 +168,6 @@ def GetEntityId(self) -> str: def LogSource(self): return self.GetEntityId() - def SetTagFromConfiguration(self): - """ Set tag from configuration or set it blank if not present there """ - if self.GetConfigurations() is not None and KEY_ENTITY_TAG in self.GetConfigurations(): - self.tag = self.GetConfigurations()[KEY_ENTITY_TAG] - else: - self.tag = "" def RunCommand(self, command: str | list, diff --git a/IoTuring/Warehouse/Warehouse.py b/IoTuring/Warehouse/Warehouse.py index 4994f5f9d..d56ac7001 100644 --- a/IoTuring/Warehouse/Warehouse.py +++ b/IoTuring/Warehouse/Warehouse.py @@ -4,6 +4,7 @@ from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject from IoTuring.Entity.EntityManager import EntityManager from IoTuring.MyApp.AppSettings import AppSettings, CONFIG_KEY_UPDATE_INTERVAL +from IoTuring.Configurator.Configuration import SingleConfiguration, CONFIG_CATEGORY_NAME, KEY_ACTIVE_WAREHOUSES from threading import Thread import time @@ -11,10 +12,12 @@ class Warehouse(LogObject, ConfiguratorObject): NAME = "Unnamed" + CATEGORY_NAME = CONFIG_CATEGORY_NAME[KEY_ACTIVE_WAREHOUSES] - def __init__(self, configurations) -> None: + def __init__(self, single_configuration: SingleConfiguration) -> None: self.loopTimeout = float(AppSettings.Settings[CONFIG_KEY_UPDATE_INTERVAL]) - self.configurations = configurations + self.configurations = single_configuration + def Start(self) -> None: """ Initial configuration and start the thread that will loop the Warehouse.Loop() function""" From 5318c78e5f5f5b996ecaf971a1ce381fb7954392 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sun, 31 Dec 2023 18:44:34 +0100 Subject: [PATCH 13/22] Fix test --- IoTuring/Configurator/Configuration.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/IoTuring/Configurator/Configuration.py b/IoTuring/Configurator/Configuration.py index 4095f5702..aa17d7b1a 100644 --- a/IoTuring/Configurator/Configuration.py +++ b/IoTuring/Configurator/Configuration.py @@ -1,3 +1,5 @@ +from __future__ import annotations + # config categories: KEY_ACTIVE_ENTITIES = "active_entities" KEY_ACTIVE_WAREHOUSES = "active_warehouses" From b9332f685d8b08a9409b90f094c47f9261f678a4 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sun, 31 Dec 2023 20:10:47 +0100 Subject: [PATCH 14/22] AppSettings Singleton --- IoTuring/Configurator/Configurator.py | 2 ++ IoTuring/Configurator/ConfiguratorLoader.py | 11 ------- IoTuring/Entity/Entity.py | 2 +- IoTuring/Logger/Logger.py | 2 +- IoTuring/Logger/consts.py | 1 + IoTuring/MyApp/AppSettings.py | 35 +++++++++++++-------- IoTuring/Warehouse/Warehouse.py | 2 +- IoTuring/__init__.py | 8 +++-- 8 files changed, 34 insertions(+), 29 deletions(-) diff --git a/IoTuring/Configurator/Configurator.py b/IoTuring/Configurator/Configurator.py index 681468458..60eed0b53 100644 --- a/IoTuring/Configurator/Configurator.py +++ b/IoTuring/Configurator/Configurator.py @@ -228,6 +228,8 @@ def Quit(self) -> None: def WriteConfigurations(self) -> None: """ Save to configurations file """ self.configuratorIO.writeConfigurations(self.config.ToDict()) + # Reload AppSettings + AppSettings().LoadConfiguration(self) def ManageSingleWarehouse(self, whClass): """UI for single Warehouse settings""" diff --git a/IoTuring/Configurator/ConfiguratorLoader.py b/IoTuring/Configurator/ConfiguratorLoader.py index 91b12c737..eee7e070b 100644 --- a/IoTuring/Configurator/ConfiguratorLoader.py +++ b/IoTuring/Configurator/ConfiguratorLoader.py @@ -5,7 +5,6 @@ from IoTuring.ClassManager.WarehouseClassManager import WarehouseClassManager from IoTuring.ClassManager.EntityClassManager import EntityClassManager from IoTuring.Warehouse.Warehouse import Warehouse -from IoTuring.MyApp.AppSettings import AppSettings class ConfiguratorLoader(LogObject): @@ -63,14 +62,4 @@ 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 LoadAppSettings(self) -> None: - """ Load app settings from config and defafults to AppSettings.Settings class attribute """ - - - appSettings = AppSettings(self.configurations.GetAppSettings()) - appSettings.AddMissingDefaultConfigs() - - # Add configs to class: - AppSettings.Settings = appSettings.GetConfigurations() \ No newline at end of file diff --git a/IoTuring/Entity/Entity.py b/IoTuring/Entity/Entity.py index 2b99352d0..4f9ec9637 100644 --- a/IoTuring/Entity/Entity.py +++ b/IoTuring/Entity/Entity.py @@ -31,7 +31,7 @@ def __init__(self, single_configuration: SingleConfiguration) -> None: # When I update the values this number changes (randomly) so each warehouse knows I have updated self.valuesID = 0 - self.updateTimeout = float(AppSettings.Settings[CONFIG_KEY_UPDATE_INTERVAL]) + self.updateTimeout = float(AppSettings().GetFromConfigurations(CONFIG_KEY_UPDATE_INTERVAL)) def Initialize(self): diff --git a/IoTuring/Logger/Logger.py b/IoTuring/Logger/Logger.py index c6a07bc45..b8fe20b64 100644 --- a/IoTuring/Logger/Logger.py +++ b/IoTuring/Logger/Logger.py @@ -18,7 +18,7 @@ class Singleton(type): _instances = {} - def __call__(cls): + def __call__(cls): # type: ignore if cls not in cls._instances: cls._instances[cls] = super(Singleton, cls).__call__() return cls._instances[cls] diff --git a/IoTuring/Logger/consts.py b/IoTuring/Logger/consts.py index 8678c2e5a..eeb2f5444 100644 --- a/IoTuring/Logger/consts.py +++ b/IoTuring/Logger/consts.py @@ -56,6 +56,7 @@ # before those spaces I add this string LONG_MESSAGE_PRESTRING_CHAR = ' ' +DEFAULT_LOG_LEVEL = "LOG_INFO" CONSOLE_LOG_LEVEL = "LOG_INFO" FILE_LOG_LEVEL = "LOG_INFO" diff --git a/IoTuring/MyApp/AppSettings.py b/IoTuring/MyApp/AppSettings.py index cc3f3b74c..ae3794f8a 100644 --- a/IoTuring/MyApp/AppSettings.py +++ b/IoTuring/MyApp/AppSettings.py @@ -1,28 +1,34 @@ +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from IoTuring.Configurator.Configurator import Configurator + from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject from IoTuring.Configurator.MenuPreset import MenuPreset +from IoTuring.Logger.Logger import Singleton -from IoTuring.Logger import consts +from IoTuring.Logger.consts import LOG_LEVELS, DEFAULT_LOG_LEVEL CONFIG_KEY_CONSOLE_LOG_LEVEL = "console_log_level" -CONFIG_KEY_FILE_LOG_LEVEL = "console_file_level" +CONFIG_KEY_FILE_LOG_LEVEL = "file_log_level" +CONFIG_KEY_FILE_LOG_ENABLED = "file_log_enabled" CONFIG_KEY_UPDATE_INTERVAL = "update_interval" CONFIG_KEY_SLOW_INTERVAL = "slow_interval" -DEFAULT_LOG_LEVEL = "LOG_INFO" LogLevelChoices = [{"name": l["string"], "value": l["const"]} - for l in consts.LOG_LEVELS] -# LogLevelChoices = [l["const"] for l in consts.LOG_LEVELS] + for l in LOG_LEVELS] + +class AppSettings(ConfiguratorObject, metaclass=Singleton): -class AppSettings(ConfiguratorObject): - # Default log levels, so Logging can start before configuration is loaded - Settings = { - CONFIG_KEY_CONSOLE_LOG_LEVEL: DEFAULT_LOG_LEVEL, - CONFIG_KEY_FILE_LOG_LEVEL: DEFAULT_LOG_LEVEL - } + def __init__(self) -> None: + pass + + def LoadConfiguration(self, configurator: "Configurator"): + self.configurations = configurator.config.GetAppSettings() + self.AddMissingDefaultConfigs() @classmethod def ConfigurationPreset(cls): @@ -33,16 +39,19 @@ def ConfigurationPreset(cls): instruction="IOTURING_LOG_LEVEL envvar overwrites this setting!", choices=LogLevelChoices) + preset.AddEntry(name="Enable file logging", key=CONFIG_KEY_FILE_LOG_ENABLED, + question_type="yesno", mandatory=True, default="Y") + preset.AddEntry(name="File log level", key=CONFIG_KEY_FILE_LOG_LEVEL, question_type="select", mandatory=True, default=DEFAULT_LOG_LEVEL, choices=LogLevelChoices) preset.AddEntry(name="Main update interval in seconds", key=CONFIG_KEY_UPDATE_INTERVAL, mandatory=True, - question_type="text", default="10") + question_type="integer", default=10) preset.AddEntry(name="Secondary update interval in minutes", key=CONFIG_KEY_SLOW_INTERVAL, mandatory=True, - question_type="text", default="10") + question_type="integer", default=10) return preset diff --git a/IoTuring/Warehouse/Warehouse.py b/IoTuring/Warehouse/Warehouse.py index d56ac7001..9ef6fb78c 100644 --- a/IoTuring/Warehouse/Warehouse.py +++ b/IoTuring/Warehouse/Warehouse.py @@ -15,7 +15,7 @@ class Warehouse(LogObject, ConfiguratorObject): CATEGORY_NAME = CONFIG_CATEGORY_NAME[KEY_ACTIVE_WAREHOUSES] def __init__(self, single_configuration: SingleConfiguration) -> None: - self.loopTimeout = float(AppSettings.Settings[CONFIG_KEY_UPDATE_INTERVAL]) + self.loopTimeout = float(AppSettings().GetFromConfigurations(CONFIG_KEY_UPDATE_INTERVAL)) self.configurations = single_configuration diff --git a/IoTuring/__init__.py b/IoTuring/__init__.py index 9d2ae010c..de5504c85 100644 --- a/IoTuring/__init__.py +++ b/IoTuring/__init__.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 from IoTuring.MyApp.App import App +from IoTuring.MyApp.AppSettings import AppSettings from IoTuring.Configurator.Configurator import Configurator from IoTuring.Configurator.ConfiguratorLoader import ConfiguratorLoader from IoTuring.Entity.EntityManager import EntityManager @@ -51,6 +52,10 @@ def loop(): logger = Logger() configurator = Configurator() + # Load AppSettings: + AppSettings().LoadConfiguration(configurator) + + logger.Log(Logger.LOG_DEBUG, "App", f"Selected options: {vars(args)}") if args.configurator: @@ -71,8 +76,7 @@ def loop(): # This have to start after configurator.Menu(), otherwise won't work starting from the menu signal.signal(signal.SIGINT, Exit_SIGINT_handler) - # Load AppSettings: - ConfiguratorLoader(configurator).LoadAppSettings() + logger.Log(Logger.LOG_INFO, "App", App()) # Print App info logger.Log(Logger.LOG_INFO, "Configurator", From fe3f9993ed8b4bf853e66630f0022333c3a54011 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Thu, 4 Jan 2024 19:10:15 +0100 Subject: [PATCH 15/22] Add documentation to new methods, update version to main --- IoTuring/Configurator/Configuration.py | 46 +++++++++++++++++++++ IoTuring/Configurator/Configurator.py | 11 ++++- IoTuring/Configurator/ConfiguratorLoader.py | 2 - IoTuring/Configurator/ConfiguratorObject.py | 2 +- IoTuring/Configurator/MenuPreset.py | 1 + IoTuring/MyApp/AppSettings.py | 2 + pyproject.toml | 2 +- 7 files changed, 61 insertions(+), 5 deletions(-) diff --git a/IoTuring/Configurator/Configuration.py b/IoTuring/Configurator/Configuration.py index aa17d7b1a..735b7fbe1 100644 --- a/IoTuring/Configurator/Configuration.py +++ b/IoTuring/Configurator/Configuration.py @@ -23,6 +23,7 @@ class FullConfiguration: + """Full configuration of all classes""" def __init__(self, config_dict: dict = BLANK_CONFIGURATION) -> None: @@ -90,6 +91,11 @@ def AddConfiguration(self, config_category: str, single_config_dict: dict, confi config_category, single_config_dict)) def GetAppSettings(self) -> "SingleConfiguration": + """Find the AppSettings single configuration + + Returns: + SingleConfiguration: The AppSettings as a SingleConfiguration + """ if self.GetConfigsInCategory(KEY_APP_SETTINGS): return self.GetConfigsInCategory(KEY_APP_SETTINGS)[0] else: @@ -98,6 +104,7 @@ def GetAppSettings(self) -> "SingleConfiguration": return appconfig def ToDict(self) -> dict: + """Full configuration as a dict, for saving to file """ config_dict = {} for config_category in BLANK_CONFIGURATION: config_dict[config_category] = [] @@ -108,6 +115,7 @@ def ToDict(self) -> dict: class SingleConfiguration: + """Single configuraiton of an entity or warehouse or AppSettings""" config_category: str type: str @@ -115,6 +123,12 @@ class SingleConfiguration: configurations: dict def __init__(self, config_category: str, config_dict: dict) -> None: + """Create a new SingleConfiguration + + Args: + config_category (str): KEY_ACTIVE_ENTITIES, KEY_ACTIVE_WAREHOUSES or KEY_APP_SETTINGS + config_dict (dict): All options as in config file + """ self.config_category = config_category if KEY_ENTITY_TYPE in config_dict: @@ -156,24 +170,56 @@ def GetLongName(self) -> str: return str(self.GetType() + self.GetCategoryName()) def GetCategoryName(self) -> str: + """ Get human readable singular name of the category of this configuration""" return CONFIG_CATEGORY_NAME[self.config_category] def GetConfigValue(self, config_key: str): + """Get the value of a config key + + Args: + config_key (str): The key of the configuration + + Raises: + ValueError: If the key is not found + + Returns: + The value of the key. + """ if config_key in self.configurations: return self.configurations[config_key] else: raise ValueError("Config key not set") def UpdateConfigValue(self, config_key: str, config_value: str) -> None: + """Update the value of the configuration. Overwrites existing value + + Args: + config_key (str): The key of the configuration + config_value (str): The preferred value + """ self.configurations[config_key] = config_value def UpdateConfigDict(self, config_dict: dict) -> None: + """Update all configurations with a dict. Overwrites existing values + + Args: + config_dict (dict): The dict of configurations + """ self.configurations.update(config_dict) def HasConfigKey(self, config_key: str) -> bool: + """Check if key has a value + + Args: + config_key (str): The key of the configuration + + Returns: + bool: If it has a value + """ return bool(config_key in self.configurations) def ToDict(self) -> dict: + """Full configuration as a dict, as it would be saved to a file """ full_dict = self.configurations if hasattr(self, KEY_ENTITY_TYPE): full_dict[KEY_ENTITY_TYPE] = getattr(self, KEY_ENTITY_TYPE) diff --git a/IoTuring/Configurator/Configurator.py b/IoTuring/Configurator/Configurator.py index 60eed0b53..7babfe024 100644 --- a/IoTuring/Configurator/Configurator.py +++ b/IoTuring/Configurator/Configurator.py @@ -167,7 +167,8 @@ def ManageWarehouses(self) -> None: else: self.ManageSingleWarehouse(choice) - def ManageSettings(self): + def ManageSettings(self) -> None: + """ UI for changing AppSettings """ preset = AppSettings.ConfigurationPreset() settingsChoices = [] @@ -199,6 +200,7 @@ def ManageSettings(self): self.ManageSettings() def ManageSingleSetting(self, q_preset: QuestionPreset): + """ UI for changing a single setting """ appSettingsConfig = self.config.GetAppSettings() @@ -215,6 +217,7 @@ def ManageSingleSetting(self, q_preset: QuestionPreset): self.ManageSettings() def DisplayHelp(self) -> None: + """" Display the help message, and load the main menu """ self.DisplayMessage(messages.HELP_MESSAGE) # Help message is too long: self.pinned_lines = 1 @@ -352,6 +355,12 @@ def IsWarehouseActive(self, whClass) -> bool: return bool(self.config.GetConfigsOfType(whClass.NAME)) def AddActiveClass(self, ioClass, config_category: str) -> None: + """Add a wh or Entity to configuration. + + Args: + ioClass: the WH or Entity class + config_category (str): KEY_ACTIVE_ENTITIES or KEY_ACTIVE_WAREHOUSES + """ try: preset = ioClass.ConfigurationPreset() diff --git a/IoTuring/Configurator/ConfiguratorLoader.py b/IoTuring/Configurator/ConfiguratorLoader.py index eee7e070b..0aea28c4c 100644 --- a/IoTuring/Configurator/ConfiguratorLoader.py +++ b/IoTuring/Configurator/ConfiguratorLoader.py @@ -61,5 +61,3 @@ def LoadEntities(self) -> list[Entity]: # - for each one: # - pass the configuration to the warehouse function that uses the configuration to init the Warehouse # - append the Warehouse to the list - - \ No newline at end of file diff --git a/IoTuring/Configurator/ConfiguratorObject.py b/IoTuring/Configurator/ConfiguratorObject.py index 7ed443e9b..9ba9ba459 100644 --- a/IoTuring/Configurator/ConfiguratorObject.py +++ b/IoTuring/Configurator/ConfiguratorObject.py @@ -9,7 +9,7 @@ def __init__(self, single_configuration: SingleConfiguration) -> None: self.configurations = single_configuration def GetConfigurations(self) -> dict: - """ return configuration as dict """ + """ Return configuration as dict """ return self.configurations.ToDict() def GetFromConfigurations(self, key): diff --git a/IoTuring/Configurator/MenuPreset.py b/IoTuring/Configurator/MenuPreset.py index 44b28bb02..6bbc7c35a 100644 --- a/IoTuring/Configurator/MenuPreset.py +++ b/IoTuring/Configurator/MenuPreset.py @@ -223,6 +223,7 @@ def GetAnsweredPresetByKey(self, key: str) -> QuestionPreset | None: return next((entry for entry in self.results if entry.key == key), None) def GetPresetByKey(self, key: str) -> QuestionPreset | None: + """Get the QuestionPreset of this key. Returns None if not found""" return next((entry for entry in self.presets if entry.key == key), None) def GetDict(self) -> dict: diff --git a/IoTuring/MyApp/AppSettings.py b/IoTuring/MyApp/AppSettings.py index ae3794f8a..621c3ed26 100644 --- a/IoTuring/MyApp/AppSettings.py +++ b/IoTuring/MyApp/AppSettings.py @@ -22,11 +22,13 @@ class AppSettings(ConfiguratorObject, metaclass=Singleton): + """Singleton for storing AppSettings, not related to Entites or Warehouses """ def __init__(self) -> None: pass def LoadConfiguration(self, configurator: "Configurator"): + """ Load/update configurations to the singleton """ self.configurations = configurator.config.GetAppSettings() self.AddMissingDefaultConfigs() diff --git a/pyproject.toml b/pyproject.toml index de654e72e..9eed1488d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "IoTuring" -version = "2023.11.1" +version = "2024.1.1" description = "Simple and powerful cross-platform script to control your pc and share statistics using communication protocols like MQTT and home control hubs like HomeAssistant." readme = "README.md" requires-python = ">=3.8" From 1d88b3e73c0b2f12e3e03d835021a121919582a0 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sat, 13 Jan 2024 07:46:17 +0100 Subject: [PATCH 16/22] Edit configurations, Separate LogSettings AppSettings --- IoTuring/Configurator/Configuration.py | 85 +++++------ IoTuring/Configurator/Configurator.py | 197 ++++++++++++++----------- IoTuring/Configurator/MenuPreset.py | 17 ++- IoTuring/Entity/Entity.py | 4 +- IoTuring/Logger/Logger.py | 42 +++++- IoTuring/MyApp/AppSettings.py | 30 +--- IoTuring/Warehouse/Warehouse.py | 3 +- IoTuring/__init__.py | 9 +- 8 files changed, 215 insertions(+), 172 deletions(-) diff --git a/IoTuring/Configurator/Configuration.py b/IoTuring/Configurator/Configuration.py index 735b7fbe1..483f96887 100644 --- a/IoTuring/Configurator/Configuration.py +++ b/IoTuring/Configurator/Configuration.py @@ -3,19 +3,19 @@ # config categories: KEY_ACTIVE_ENTITIES = "active_entities" KEY_ACTIVE_WAREHOUSES = "active_warehouses" -KEY_APP_SETTINGS = "app_settings" +KEY_SETTINGS = "settings" CONFIG_CATEGORY_NAME = { KEY_ACTIVE_ENTITIES: "Entity", KEY_ACTIVE_WAREHOUSES: "Warehouse", - KEY_APP_SETTINGS: "AppSetting" + KEY_SETTINGS: "Setting" } BLANK_CONFIGURATION = { KEY_ACTIVE_ENTITIES: [{"type": "AppInfo"}], KEY_ACTIVE_WAREHOUSES: [], - KEY_APP_SETTINGS: [] + KEY_SETTINGS: [] } KEY_ENTITY_TAG = "tag" @@ -38,14 +38,14 @@ def GetConfigsInCategory(self, config_category: str) -> list["SingleConfiguratio """Return all configurations in a category Args: - config_category (str): KEY_ACTIVE_ENTITIES, KEY_ACTIVE_WAREHOUSES or KEY_APP_SETTINGS + config_category (str): KEY_ACTIVE_ENTITIES, KEY_ACTIVE_WAREHOUSES or KEY_SETTINGS Returns: list: Configurations in the category. Empty list if none found. """ return [config for config in self.configs if config.config_category == config_category] - def GetConfigsOfType(self, config_type: str, config_category: str = "") -> list["SingleConfiguration"]: + def GetAllConfigsOfType(self, config_type: str, config_category: str = "") -> list["SingleConfiguration"]: """Return all configs with the given type, from the given category Args: @@ -62,6 +62,26 @@ def GetConfigsOfType(self, config_type: str, config_category: str = "") -> list[ return [config for config in config_list if config.GetType() == config_type] + def GetSingleConfigOfType(self, config_type: str) -> None | SingleConfiguration: + """Return the only configuration of the given type. Raises exception if multiple found. + + Args: + config_type (str): The type of config to return + + Raises: + Exception: Multiple config found + + Returns: + None | SingleConfiguration: The config, None if not found + """ + configs = self.GetAllConfigsOfType(config_type=config_type) + if not configs: + return None + if len(configs) > 1: + raise Exception("Multiple configs found!") + + return configs[0] + def RemoveActiveConfiguration(self, config: "SingleConfiguration") -> None: """Remove a configuration from the list of active configurations""" if config in self.configs: @@ -69,16 +89,19 @@ def RemoveActiveConfiguration(self, config: "SingleConfiguration") -> None: else: raise ValueError("Configuration not found") - def AddConfiguration(self, config_category: str, single_config_dict: dict, config_type: str = "") -> None: + def AddConfiguration(self, config_category: str, single_config_dict: dict, config_type: str = "") -> "SingleConfiguration": """Add a new configuration to the list of active configurations Args: - config_category (str): KEY_ACTIVE_ENTITIES or KEY_ACTIVE_WAREHOUSES + config_category (str): KEY_ACTIVE_ENTITIES, KEY_ACTIVE_WAREHOUSES or KEY_SETTINGS single_config_dict (dict): all settings as a dict config_type (str, optional): The type of the configuration, if not included in the dict. Raises: ValueError: Config type not defined in the dict nor in the function call + + Returns: + SingleConfiguration: The new single config """ if KEY_ENTITY_TYPE not in single_config_dict: @@ -87,21 +110,12 @@ def AddConfiguration(self, config_category: str, single_config_dict: dict, confi else: raise ValueError("Configuration type not specified") - self.configs.append(SingleConfiguration( - config_category, single_config_dict)) + single_config = SingleConfiguration( + config_category, single_config_dict) - def GetAppSettings(self) -> "SingleConfiguration": - """Find the AppSettings single configuration + self.configs.append(single_config) - Returns: - SingleConfiguration: The AppSettings as a SingleConfiguration - """ - if self.GetConfigsInCategory(KEY_APP_SETTINGS): - return self.GetConfigsInCategory(KEY_APP_SETTINGS)[0] - else: - appconfig = SingleConfiguration(KEY_APP_SETTINGS, {}) - self.configs.append(appconfig) - return appconfig + return single_config def ToDict(self) -> dict: """Full configuration as a dict, for saving to file """ @@ -119,39 +133,30 @@ class SingleConfiguration: config_category: str type: str - tag: str configurations: dict def __init__(self, config_category: str, config_dict: dict) -> None: """Create a new SingleConfiguration Args: - config_category (str): KEY_ACTIVE_ENTITIES, KEY_ACTIVE_WAREHOUSES or KEY_APP_SETTINGS + config_category (str): KEY_ACTIVE_ENTITIES, KEY_ACTIVE_WAREHOUSES or KEY_SETTINGS config_dict (dict): All options as in config file """ self.config_category = config_category - if KEY_ENTITY_TYPE in config_dict: - config_type = config_dict.pop(KEY_ENTITY_TYPE) - setattr(self, KEY_ENTITY_TYPE, config_type) - - if KEY_ENTITY_TAG in config_dict: - config_tag = config_dict.pop(KEY_ENTITY_TAG) - setattr(self, KEY_ENTITY_TAG, config_tag) + # self.type: + setattr(self, KEY_ENTITY_TYPE, config_dict.pop(KEY_ENTITY_TYPE)) self.configurations = config_dict def GetType(self) -> str: """ Get the type name of entity""" - if hasattr(self, KEY_ENTITY_TYPE): - return getattr(self, KEY_ENTITY_TYPE) - else: - return self.config_category + return getattr(self, KEY_ENTITY_TYPE) def GetTag(self) -> str: """ Get the tag of entity""" - if hasattr(self, KEY_ENTITY_TAG): - return getattr(self, KEY_ENTITY_TAG) + if KEY_ENTITY_TAG in self.configurations: + return self.configurations[KEY_ENTITY_TAG] else: return "" @@ -160,8 +165,8 @@ def GetLabel(self) -> str: label = self.GetType() - if hasattr(self, KEY_ENTITY_TAG): - label += f" with tag {getattr(self, KEY_ENTITY_TAG)}" + if self.GetTag(): + label += f" with tag {self.GetTag()}" return label @@ -221,9 +226,5 @@ def HasConfigKey(self, config_key: str) -> bool: def ToDict(self) -> dict: """Full configuration as a dict, as it would be saved to a file """ full_dict = self.configurations - if hasattr(self, KEY_ENTITY_TYPE): - full_dict[KEY_ENTITY_TYPE] = getattr(self, KEY_ENTITY_TYPE) - if hasattr(self, KEY_ENTITY_TAG): - full_dict[KEY_ENTITY_TAG] = getattr(self, KEY_ENTITY_TAG) - + full_dict[KEY_ENTITY_TYPE] = getattr(self, KEY_ENTITY_TYPE) return full_dict diff --git a/IoTuring/Configurator/Configurator.py b/IoTuring/Configurator/Configurator.py index 7babfe024..9c50e6551 100644 --- a/IoTuring/Configurator/Configurator.py +++ b/IoTuring/Configurator/Configurator.py @@ -3,9 +3,10 @@ import shutil from IoTuring.Configurator.MenuPreset import QuestionPreset -from IoTuring.Configurator.Configuration import FullConfiguration, SingleConfiguration, KEY_ACTIVE_ENTITIES, KEY_ACTIVE_WAREHOUSES +from IoTuring.Configurator.Configuration import FullConfiguration, SingleConfiguration, KEY_ACTIVE_ENTITIES, KEY_ACTIVE_WAREHOUSES, KEY_SETTINGS, CONFIG_CATEGORY_NAME from IoTuring.Logger.LogObject import LogObject +from IoTuring.Logger.Logger import Logger from IoTuring.Exceptions.Exceptions import UserCancelledException from IoTuring.ClassManager.EntityClassManager import EntityClassManager @@ -92,7 +93,7 @@ def Menu(self) -> None: mainMenuChoices = [ {"name": "Manage entities", "value": self.ManageEntities}, {"name": "Manage warehouses", "value": self.ManageWarehouses}, - {"name": "App Settings", "value": self.ManageSettings}, + {"name": "Settings", "value": self.ManageSettings}, {"name": "Start IoTuring", "value": self.WriteConfigurations}, {"name": "Help", "value": self.DisplayHelp}, {"name": "Quit", "value": self.Quit}, @@ -138,7 +139,7 @@ def ManageEntities(self) -> None: elif choice == CHOICE_GO_BACK: self.Menu() else: - self.ManageSingleEntity(choice) + self.ManageSingleEntity(choice, ecm) def ManageWarehouses(self) -> None: """ UI for Warehouses settings """ @@ -149,9 +150,8 @@ def ManageWarehouses(self) -> None: availableWarehouses = wcm.ListAvailableClasses() for whClass in availableWarehouses: - enabled_sign = " " - if self.IsWarehouseActive(whClass): - enabled_sign = "X" + enabled_sign = "X" \ + if self.config.GetSingleConfigOfType(whClass.NAME) else " " manageWhChoices.append( {"name": f"[{enabled_sign}] - {whClass.NAME}", @@ -168,53 +168,32 @@ def ManageWarehouses(self) -> None: self.ManageSingleWarehouse(choice) def ManageSettings(self) -> None: - """ UI for changing AppSettings """ - preset = AppSettings.ConfigurationPreset() - - settingsChoices = [] - - for entry in preset.presets: - # Load config instead of default: - if self.config.GetAppSettings().HasConfigKey(entry.key): - value = self.config.GetAppSettings().GetConfigValue(entry.key) - else: - value = entry.default - - settingsChoices.append({ - "name": f"{entry.name}: {value}", - "value": entry.key - }) + """ UI for App and Log Settings """ + choices = [ + {"name": "Log Settings", "value": Logger}, + {"name": "App Settings", "value": AppSettings} + ] choice = self.DisplayMenu( - choices=settingsChoices, - message="Select setting to edit") + choices=choices, + message=f"Select settings to edit" + ) if choice == CHOICE_GO_BACK: self.Menu() - else: - q_preset = preset.GetPresetByKey(choice) - if q_preset: - self.ManageSingleSetting(q_preset) - else: - self.DisplayMessage(f"Question preset not found: {choice}") - self.ManageSettings() - - def ManageSingleSetting(self, q_preset: QuestionPreset): - """ UI for changing a single setting """ - appSettingsConfig = self.config.GetAppSettings() - - # Load config as default: - if appSettingsConfig.HasConfigKey(q_preset.key): - q_preset.default = appSettingsConfig.GetConfigValue(q_preset.key) - - value = q_preset.Ask() + else: + settings_config = self.config.GetSingleConfigOfType( + choice.NAME) - if value: - # Add to config: - appSettingsConfig.UpdateConfigValue(q_preset.key, value) + # Add empty config if missing: + if not settings_config: + settings_config = self.config.AddConfiguration( + KEY_SETTINGS, {}, config_type=choice.NAME) - self.ManageSettings() + # Edit: + self.EditActiveClass(choice, settings_config) + self.ManageSettings() def DisplayHelp(self) -> None: """" Display the help message, and load the main menu """ @@ -231,13 +210,13 @@ def Quit(self) -> None: def WriteConfigurations(self) -> None: """ Save to configurations file """ self.configuratorIO.writeConfigurations(self.config.ToDict()) - # Reload AppSettings - AppSettings().LoadConfiguration(self) def ManageSingleWarehouse(self, whClass): """UI for single Warehouse settings""" - if self.IsWarehouseActive(whClass): + whConfig = self.config.GetSingleConfigOfType(whClass.NAME) + + if whConfig: manageWhChoices = [ {"name": "Edit the warehouse settings", "value": "Edit"}, {"name": "Remove the warehouse", "value": "Remove"} @@ -256,19 +235,15 @@ def ManageSingleWarehouse(self, whClass): elif choice == "Add": self.AddActiveClass(whClass, KEY_ACTIVE_WAREHOUSES) self.ManageWarehouses() - else: - whConfig = self.config.GetConfigsOfType(whClass.NAME)[0] + elif whConfig: if choice == "Edit": - self.EditActiveWarehouse(whConfig) + self.EditActiveClass(whClass, whConfig) + self.ManageSingleWarehouse(whClass) elif choice == "Remove": - confirm = inquirer.confirm(message="Are you sure?").execute() - - if confirm: - self.RemoveActiveConfiguration(whConfig) - + self.RemoveActiveConfiguration(whConfig) self.ManageWarehouses() - def ManageSingleEntity(self, entityConfig: SingleConfiguration): + def ManageSingleEntity(self, entityConfig: SingleConfiguration, ecm: EntityClassManager): """ UI to manage an active entity (edit config/remove) """ manageEntityChoices = [ @@ -284,13 +259,13 @@ def ManageSingleEntity(self, entityConfig: SingleConfiguration): if choice == CHOICE_GO_BACK: self.ManageEntities() elif choice == "Edit": - self.EditActiveEntity(entityConfig) # type: ignore - elif choice == "Remove": - confirm = inquirer.confirm(message="Are you sure?").execute() - - if confirm: - self.RemoveActiveConfiguration(entityConfig) + entityClass = ecm.GetClassFromName(entityConfig.GetType()) + self.EditActiveClass( + entityClass, entityConfig) + self.ManageSingleEntity(entityConfig, ecm) + elif choice == "Remove": + self.RemoveActiveConfiguration(entityConfig) self.ManageEntities() def SelectNewEntity(self, ecm: EntityClassManager): @@ -303,7 +278,7 @@ def SelectNewEntity(self, ecm: EntityClassManager): entityChoices = [] for entityClass in entityClasses: - if self.config.GetConfigsOfType(entityClass.NAME): + if self.config.GetAllConfigsOfType(entityClass.NAME): if not entityClass.AllowMultiInstance(): continue entityChoices.append( @@ -346,13 +321,11 @@ def ShowUnsupportedEntities(self, ecm: EntityClassManager): def RemoveActiveConfiguration(self, singleConfig: SingleConfiguration) -> None: """ Remove configuration (wh or entity) """ - self.config.RemoveActiveConfiguration(singleConfig) - self.DisplayMessage( - f"{singleConfig.GetCategoryName()} removed: {singleConfig.GetLabel()}") - - def IsWarehouseActive(self, whClass) -> bool: - """ Return True if a warehouse is active """ - return bool(self.config.GetConfigsOfType(whClass.NAME)) + confirm = inquirer.confirm(message="Are you sure?").execute() + if confirm: + self.config.RemoveActiveConfiguration(singleConfig) + self.DisplayMessage( + f"{singleConfig.GetCategoryName()} removed: {singleConfig.GetLabel()}") def AddActiveClass(self, ioClass, config_category: str) -> None: """Add a wh or Entity to configuration. @@ -373,13 +346,13 @@ def AddActiveClass(self, ioClass, config_category: str) -> None: self.DisplayMessage(messages.PRESET_RULES) self.DisplayMessage( - f"Configure {ioClass.NAME} {ioClass.CATEGORY_NAME}") + f"Configure {ioClass.NAME} {CONFIG_CATEGORY_NAME[config_category]}") preset.AskQuestions() self.ClearScreen(force_clear=True) else: self.DisplayMessage( - f"No configuration needed for this {ioClass.CATEGORY_NAME} :)") + f"No configuration needed for this {CONFIG_CATEGORY_NAME[config_category]} :)") self.config.AddConfiguration( config_category, preset.GetDict(), ioClass.NAME) @@ -389,27 +362,75 @@ def AddActiveClass(self, ioClass, config_category: str) -> None: except Exception as e: print( - f"Error during {ioClass.CATEGORY_NAME} preset loading: {str(e)}") + f"Error during {CONFIG_CATEGORY_NAME[config_category]} preset loading: {str(e)}") - def EditActiveWarehouse(self, whConfig: SingleConfiguration) -> None: - """ UI for single Warehouse settings edit """ - self.DisplayMessage( - "You can't do that at the moment, change the configuration file manually. Sorry for the inconvenience") + def EditActiveClass(self, ioClass, single_config: "SingleConfiguration") -> None: + """ UI for changing settings """ + preset = ioClass.ConfigurationPreset() - self.ManageWarehouses() + if preset.HasQuestions(): - # TODO Implement - # WarehouseMenuPresetToConfiguration appends a warehosue to the conf so here I should remove it to read it later - # TO implement only when I know how to add removable value while editing configurations + choices = [] - def EditActiveEntity(self, entityConfig: SingleConfiguration) -> None: - """ UI for single Entity settings edit """ - self.DisplayMessage( - "You can't do that at the moment, change the configuration file manually. Sorry for the inconvenience") + # Add tag: + if single_config.GetTag(): + preset.AddTagQuestion() - self.ManageEntities() + for entry in preset.presets: + # Load config instead of default: + if single_config.HasConfigKey(entry.key): + value = single_config.GetConfigValue(entry.key) + if entry.question_type == "secret": + value = "*" * len(value) + else: + value = entry.default + + # Nice display for None: + if value is None: + value = "" + + choices.append({ + "name": f"{entry.name}: {value}", + "value": entry.key + }) + + choice = self.DisplayMenu( + choices=choices, + message="Select config to edit") + + if choice == CHOICE_GO_BACK: + return + else: + q_preset = preset.GetPresetByKey(choice) + if q_preset: + self.EditSinglePreset(q_preset, single_config) + self.EditActiveClass(ioClass, single_config) + else: + self.DisplayMessage(f"Question preset not found: {choice}") - # TODO Implement + else: + self.DisplayMessage( + f"No configuration for this {single_config.GetCategoryName()} :)") + + def EditSinglePreset(self, q_preset: QuestionPreset, single_config: SingleConfiguration): + """ UI for changing a single setting """ + + try: + # Load config as default: + if single_config.HasConfigKey(q_preset.key): + if q_preset.default: + q_preset.instruction = f"Default: {q_preset.default}" + q_preset.default = single_config.GetConfigValue(q_preset.key) + + value = q_preset.Ask() + + # If no default and not changed, do not save: + if value or q_preset.default is not None: + # Add to config: + single_config.UpdateConfigValue(q_preset.key, value) + + except UserCancelledException: + self.DisplayMessage("Configuration cancelled", force_clear=True) def ClearScreen(self, force_clear=False): """ Clear the screen on any platform. If self.pinned_lines greater than zero, it won't be cleared. diff --git a/IoTuring/Configurator/MenuPreset.py b/IoTuring/Configurator/MenuPreset.py index 6bbc7c35a..1fe949751 100644 --- a/IoTuring/Configurator/MenuPreset.py +++ b/IoTuring/Configurator/MenuPreset.py @@ -28,7 +28,13 @@ def __init__(self, self.value = None self.question = self.name - if mandatory: + + # yesno question cannot be mandatory: + if self.question_type == "yesno": + self.mandatory = False + + # Add mandatory mark: + if self.mandatory: self.question += " {!}" def ShouldDisplay(self, menupreset: "MenuPreset") -> bool: @@ -76,22 +82,22 @@ def validate(x): return bool(x) }) question_options["message"] = self.question + ":" - + if self.default is not None: # yesno questions need boolean default: if self.question_type == "yesno": question_options["default"] = \ bool(str(self.default).lower() - in BooleanAnswers.TRUE_ANSWERS) + in BooleanAnswers.TRUE_ANSWERS) elif self.question_type == "integer": question_options["default"] = int(self.default) else: question_options["default"] = self.default else: if self.question_type == "integer": - # The default default is 0, overwrite to None: + # The default integer is 0, overwrite to None: question_options["default"] = None - + # text: prompt_function = inquirer.text @@ -117,6 +123,7 @@ def validate(x): return bool(x) elif self.question_type == "filepath": prompt_function = inquirer.filepath + # Ask the question: prompt = prompt_function( instruction=self.instruction, **question_options diff --git a/IoTuring/Entity/Entity.py b/IoTuring/Entity/Entity.py index 4f9ec9637..625928b9d 100644 --- a/IoTuring/Entity/Entity.py +++ b/IoTuring/Entity/Entity.py @@ -3,7 +3,7 @@ import subprocess from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject -from IoTuring.Configurator.Configuration import SingleConfiguration, CONFIG_CATEGORY_NAME, KEY_ACTIVE_ENTITIES +from IoTuring.Configurator.Configuration import SingleConfiguration from IoTuring.MyApp.AppSettings import AppSettings, CONFIG_KEY_UPDATE_INTERVAL from IoTuring.Exceptions.Exceptions import UnknownEntityKeyException from IoTuring.Logger.LogObject import LogObject @@ -16,8 +16,6 @@ class Entity(LogObject, ConfiguratorObject): NAME = "Unnamed" ALLOW_MULTI_INSTANCE = False - CATEGORY_NAME = CONFIG_CATEGORY_NAME[KEY_ACTIVE_ENTITIES] - def __init__(self, single_configuration: SingleConfiguration) -> None: diff --git a/IoTuring/Logger/Logger.py b/IoTuring/Logger/Logger.py index 14ae6840f..8ae443cd7 100644 --- a/IoTuring/Logger/Logger.py +++ b/IoTuring/Logger/Logger.py @@ -9,6 +9,21 @@ from IoTuring.Logger import consts from IoTuring.Logger.LogLevel import LogLevelObject, LogLevel from IoTuring.Exceptions.Exceptions import UnknownLoglevelException +from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject +from IoTuring.Configurator.MenuPreset import MenuPreset + + +CONFIG_KEY_CONSOLE_LOG_LEVEL = "console_log_level" +CONFIG_KEY_FILE_LOG_LEVEL = "file_log_level" +CONFIG_KEY_FILE_LOG_ENABLED = "file_log_enabled" +CONFIG_KEY_FILE_LOG_PATH = "file_log_path" + + +LogLevelChoices = [{"name": l["string"], "value": l["const"]} + for l in consts.LOG_LEVELS] + + + class Singleton(type): @@ -24,7 +39,9 @@ def __call__(cls): # type: ignore return cls._instances[cls] -class Logger(LogLevelObject, metaclass=Singleton): +class Logger(LogLevelObject, ConfiguratorObject, metaclass=Singleton): + + NAME = "Logger" lock = threading.Lock() @@ -176,3 +193,26 @@ def checkTerminalSupportsColors(): # isatty is not always implemented, #6223. is_a_tty = hasattr(sys.stdout, 'isatty') and sys.stdout.isatty() return supported_platform and is_a_tty + + + @classmethod + def ConfigurationPreset(cls): + preset = MenuPreset() + + preset.AddEntry(name="Console log level", key=CONFIG_KEY_CONSOLE_LOG_LEVEL, + question_type="select", mandatory=True, default=consts.DEFAULT_LOG_LEVEL, + instruction="IOTURING_LOG_LEVEL envvar overwrites this setting!", + choices=LogLevelChoices) + + preset.AddEntry(name="Enable file logging", key=CONFIG_KEY_FILE_LOG_ENABLED, + question_type="yesno", default="Y") + + preset.AddEntry(name="File log level", key=CONFIG_KEY_FILE_LOG_LEVEL, + question_type="select", mandatory=True, default=consts.DEFAULT_LOG_LEVEL, + choices=LogLevelChoices) + + preset.AddEntry(name="File log path", key=CONFIG_KEY_FILE_LOG_PATH, + question_type="filepath", mandatory=True, default=consts.DEFAULT_LOG_LEVEL, + instruction="Directory where log files will be saved") + + return preset \ No newline at end of file diff --git a/IoTuring/MyApp/AppSettings.py b/IoTuring/MyApp/AppSettings.py index 621c3ed26..332e9b334 100644 --- a/IoTuring/MyApp/AppSettings.py +++ b/IoTuring/MyApp/AppSettings.py @@ -6,54 +6,34 @@ from IoTuring.Configurator.MenuPreset import MenuPreset from IoTuring.Logger.Logger import Singleton -from IoTuring.Logger.consts import LOG_LEVELS, DEFAULT_LOG_LEVEL - -CONFIG_KEY_CONSOLE_LOG_LEVEL = "console_log_level" -CONFIG_KEY_FILE_LOG_LEVEL = "file_log_level" -CONFIG_KEY_FILE_LOG_ENABLED = "file_log_enabled" - CONFIG_KEY_UPDATE_INTERVAL = "update_interval" CONFIG_KEY_SLOW_INTERVAL = "slow_interval" -LogLevelChoices = [{"name": l["string"], "value": l["const"]} - for l in LOG_LEVELS] - class AppSettings(ConfiguratorObject, metaclass=Singleton): """Singleton for storing AppSettings, not related to Entites or Warehouses """ + NAME = "AppSettings" def __init__(self) -> None: pass def LoadConfiguration(self, configurator: "Configurator"): """ Load/update configurations to the singleton """ - self.configurations = configurator.config.GetAppSettings() + self.configurations = configurator.config.GetSingleConfigOfType(self.NAME) self.AddMissingDefaultConfigs() @classmethod def ConfigurationPreset(cls): preset = MenuPreset() - preset.AddEntry(name="Console log level", key=CONFIG_KEY_CONSOLE_LOG_LEVEL, - question_type="select", mandatory=True, default=DEFAULT_LOG_LEVEL, - instruction="IOTURING_LOG_LEVEL envvar overwrites this setting!", - choices=LogLevelChoices) - - preset.AddEntry(name="Enable file logging", key=CONFIG_KEY_FILE_LOG_ENABLED, - question_type="yesno", mandatory=True, default="Y") - - preset.AddEntry(name="File log level", key=CONFIG_KEY_FILE_LOG_LEVEL, - question_type="select", mandatory=True, default=DEFAULT_LOG_LEVEL, - choices=LogLevelChoices) - preset.AddEntry(name="Main update interval in seconds", key=CONFIG_KEY_UPDATE_INTERVAL, mandatory=True, question_type="integer", default=10) - preset.AddEntry(name="Secondary update interval in minutes", - key=CONFIG_KEY_SLOW_INTERVAL, mandatory=True, - question_type="integer", default=10) + # preset.AddEntry(name="Secondary update interval in minutes", + # key=CONFIG_KEY_SLOW_INTERVAL, mandatory=True, + # question_type="integer", default=10) return preset diff --git a/IoTuring/Warehouse/Warehouse.py b/IoTuring/Warehouse/Warehouse.py index 9ef6fb78c..8f02afe56 100644 --- a/IoTuring/Warehouse/Warehouse.py +++ b/IoTuring/Warehouse/Warehouse.py @@ -4,7 +4,7 @@ from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject from IoTuring.Entity.EntityManager import EntityManager from IoTuring.MyApp.AppSettings import AppSettings, CONFIG_KEY_UPDATE_INTERVAL -from IoTuring.Configurator.Configuration import SingleConfiguration, CONFIG_CATEGORY_NAME, KEY_ACTIVE_WAREHOUSES +from IoTuring.Configurator.Configuration import SingleConfiguration from threading import Thread import time @@ -12,7 +12,6 @@ class Warehouse(LogObject, ConfiguratorObject): NAME = "Unnamed" - CATEGORY_NAME = CONFIG_CATEGORY_NAME[KEY_ACTIVE_WAREHOUSES] def __init__(self, single_configuration: SingleConfiguration) -> None: self.loopTimeout = float(AppSettings().GetFromConfigurations(CONFIG_KEY_UPDATE_INTERVAL)) diff --git a/IoTuring/__init__.py b/IoTuring/__init__.py index de5504c85..9e9854df6 100644 --- a/IoTuring/__init__.py +++ b/IoTuring/__init__.py @@ -52,10 +52,6 @@ def loop(): logger = Logger() configurator = Configurator() - # Load AppSettings: - AppSettings().LoadConfiguration(configurator) - - logger.Log(Logger.LOG_DEBUG, "App", f"Selected options: {vars(args)}") if args.configurator: @@ -76,12 +72,13 @@ def loop(): # This have to start after configurator.Menu(), otherwise won't work starting from the menu signal.signal(signal.SIGINT, Exit_SIGINT_handler) - - logger.Log(Logger.LOG_INFO, "App", App()) # Print App info logger.Log(Logger.LOG_INFO, "Configurator", "Run the script with -c to enter configuration mode") + # Load AppSettings: + AppSettings().LoadConfiguration(configurator) + eM = EntityManager() # These will be done from the configuration reader From d5c09fd761aac958b4471b3a2366282b2e9407c8 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sun, 14 Jan 2024 22:05:41 +0100 Subject: [PATCH 17/22] Finished Logger settings --- IoTuring/Configurator/Configuration.py | 12 +- IoTuring/Configurator/Configurator.py | 19 +- IoTuring/Configurator/ConfiguratorLoader.py | 26 +- IoTuring/Logger/LogLevel.py | 9 - IoTuring/Logger/Logger.py | 286 ++++++++++++------ IoTuring/Logger/consts.py | 2 - IoTuring/MyApp/AppSettings.py | 8 - .../MyApp/SystemConsts/TerminalDetection.py | 16 + IoTuring/MyApp/SystemConsts/__init__.py | 3 +- IoTuring/__init__.py | 22 +- 10 files changed, 258 insertions(+), 145 deletions(-) create mode 100644 IoTuring/MyApp/SystemConsts/TerminalDetection.py diff --git a/IoTuring/Configurator/Configuration.py b/IoTuring/Configurator/Configuration.py index 483f96887..e2219ac18 100644 --- a/IoTuring/Configurator/Configuration.py +++ b/IoTuring/Configurator/Configuration.py @@ -62,21 +62,25 @@ def GetAllConfigsOfType(self, config_type: str, config_category: str = "") -> li return [config for config in config_list if config.GetType() == config_type] - def GetSingleConfigOfType(self, config_type: str) -> None | SingleConfiguration: - """Return the only configuration of the given type. Raises exception if multiple found. + def LoadSingleConfig(self, config_type: str, config_category: str) -> SingleConfiguration: + """ Return the only configuration of the given type. Raises exception if multiple found. + Add the config if not found. Args: config_type (str): The type of config to return + config_category (str): KEY_ACTIVE_ENTITIES, KEY_ACTIVE_WAREHOUSES or KEY_SETTINGS Raises: Exception: Multiple config found Returns: - None | SingleConfiguration: The config, None if not found + SingleConfiguration: The config """ configs = self.GetAllConfigsOfType(config_type=config_type) if not configs: - return None + return self.AddConfiguration( + config_category, {}, config_type) + if len(configs) > 1: raise Exception("Multiple configs found!") diff --git a/IoTuring/Configurator/Configurator.py b/IoTuring/Configurator/Configurator.py index ef9b67b45..9a72409ef 100644 --- a/IoTuring/Configurator/Configurator.py +++ b/IoTuring/Configurator/Configurator.py @@ -152,7 +152,7 @@ def ManageWarehouses(self) -> None: for whClass in availableWarehouses: enabled_sign = "X" \ - if self.config.GetSingleConfigOfType(whClass.NAME) else " " + if self.config.GetAllConfigsOfType(whClass.NAME) else " " manageWhChoices.append( {"name": f"[{enabled_sign}] - {whClass.NAME}", @@ -184,13 +184,7 @@ def ManageSettings(self) -> None: self.Menu() else: - settings_config = self.config.GetSingleConfigOfType( - choice.NAME) - - # Add empty config if missing: - if not settings_config: - settings_config = self.config.AddConfiguration( - KEY_SETTINGS, {}, config_type=choice.NAME) + settings_config = self.config.LoadSingleConfig(choice.NAME, KEY_SETTINGS) # Edit: self.EditActiveClass(choice, settings_config) @@ -215,9 +209,9 @@ def WriteConfigurations(self) -> None: def ManageSingleWarehouse(self, whClass): """UI for single Warehouse settings""" - whConfig = self.config.GetSingleConfigOfType(whClass.NAME) + whConfigList = self.config.GetAllConfigsOfType(whClass.NAME) - if whConfig: + if whConfigList: manageWhChoices = [ {"name": "Edit the warehouse settings", "value": "Edit"}, {"name": "Remove the warehouse", "value": "Remove"} @@ -236,7 +230,8 @@ def ManageSingleWarehouse(self, whClass): elif choice == "Add": self.AddActiveClass(whClass, KEY_ACTIVE_WAREHOUSES) self.ManageWarehouses() - elif whConfig: + elif whConfigList: + whConfig = whConfigList[0] if choice == "Edit": self.EditActiveClass(whClass, whConfig) self.ManageSingleWarehouse(whClass) @@ -419,7 +414,7 @@ def EditSinglePreset(self, q_preset: QuestionPreset, single_config: SingleConfig try: # Load config as default: if single_config.HasConfigKey(q_preset.key): - if q_preset.default: + if q_preset.default and q_preset.question_type != "yesno": q_preset.instruction = f"Default: {q_preset.default}" q_preset.default = single_config.GetConfigValue(q_preset.key) diff --git a/IoTuring/Configurator/ConfiguratorLoader.py b/IoTuring/Configurator/ConfiguratorLoader.py index 6b3bee028..b8f903d45 100644 --- a/IoTuring/Configurator/ConfiguratorLoader.py +++ b/IoTuring/Configurator/ConfiguratorLoader.py @@ -3,11 +3,14 @@ from IoTuring.Entity.Entity import Entity from IoTuring.Logger.LogObject import LogObject -from IoTuring.Configurator.Configurator import Configurator, KEY_ACTIVE_ENTITIES, KEY_ACTIVE_WAREHOUSES +from IoTuring.Configurator.Configurator import Configurator, KEY_ACTIVE_ENTITIES, KEY_ACTIVE_WAREHOUSES, KEY_SETTINGS from IoTuring.ClassManager.WarehouseClassManager import WarehouseClassManager from IoTuring.ClassManager.EntityClassManager import EntityClassManager from IoTuring.Warehouse.Warehouse import Warehouse +from IoTuring.MyApp.AppSettings import AppSettings +from IoTuring.Logger.Logger import Logger + class ConfiguratorLoader(LogObject): configurator = None @@ -28,11 +31,13 @@ def LoadWarehouses(self) -> list[Warehouse]: whClass = wcm.GetClassFromName(whConfig.GetLongName()) if whClass is None: - self.Log(self.LOG_ERROR, f"Can't find {whConfig.GetType()} warehouse, check your configurations.") + self.Log( + self.LOG_ERROR, f"Can't find {whConfig.GetType()} warehouse, check your configurations.") else: wh = whClass(whConfig) wh.AddMissingDefaultConfigs() - self.Log(self.LOG_DEBUG, f"Full configuration with defaults: {wh.configurations}") + self.Log( + self.LOG_DEBUG, f"Full configuration with defaults: {wh.configurations.ToDict()}") warehouses.append(wh) return warehouses @@ -48,11 +53,13 @@ def LoadEntities(self) -> list[Entity]: for entityConfig in self.configurations.GetConfigsInCategory(KEY_ACTIVE_ENTITIES): entityClass = ecm.GetClassFromName(entityConfig.GetType()) if entityClass is None: - self.Log(self.LOG_ERROR, f"Can't find {entityConfig.GetType()} entity, check your configurations.") + self.Log( + self.LOG_ERROR, f"Can't find {entityConfig.GetType()} entity, check your configurations.") else: ec = entityClass(entityConfig) ec.AddMissingDefaultConfigs() - self.Log(self.LOG_DEBUG, f"Full configuration with defaults: {ec.configurations}") + self.Log( + self.LOG_DEBUG, f"Full configuration with defaults: {ec.configurations.ToDict()}") entities.append(ec) # Entity instance return entities @@ -63,3 +70,12 @@ def LoadEntities(self) -> list[Entity]: # - for each one: # - pass the configuration to the warehouse function that uses the configuration to init the Warehouse # - append the Warehouse to the list + + def LoadSettings(self) -> None: + settingsClasses = [AppSettings, Logger] + for settingsClass in settingsClasses: + sc = settingsClass() + sc.configurations = self.configurations.LoadSingleConfig( + sc.NAME, KEY_SETTINGS) + sc.AddMissingDefaultConfigs() + sc.__init__() diff --git a/IoTuring/Logger/LogLevel.py b/IoTuring/Logger/LogLevel.py index 01575055e..14f6e7d91 100644 --- a/IoTuring/Logger/LogLevel.py +++ b/IoTuring/Logger/LogLevel.py @@ -27,15 +27,6 @@ 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 """ diff --git a/IoTuring/Logger/Logger.py b/IoTuring/Logger/Logger.py index 8ae443cd7..d0053be3a 100644 --- a/IoTuring/Logger/Logger.py +++ b/IoTuring/Logger/Logger.py @@ -1,16 +1,26 @@ -import sys -import os -import inspect from datetime import datetime import json import threading -from io import TextIOWrapper +from pathlib import Path + +from IoTuring.MyApp.App import App from IoTuring.Logger import consts from IoTuring.Logger.LogLevel import LogLevelObject, LogLevel +from IoTuring.Logger.Colors import Colors from IoTuring.Exceptions.Exceptions import UnknownLoglevelException from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject from IoTuring.Configurator.MenuPreset import MenuPreset +from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD +from IoTuring.MyApp.SystemConsts import TerminalDetection as TD + +# 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" @@ -23,9 +33,6 @@ for l in consts.LOG_LEVELS] - - - class Singleton(type): """ Metaclass for singleton classes """ @@ -33,58 +40,135 @@ class Singleton(type): _instances = {} - def __call__(cls): # type: ignore + def __call__(cls): # type: ignore if cls not in cls._instances: cls._instances[cls] = super(Singleton, cls).__call__() return cls._instances[cls] class Logger(LogLevelObject, ConfiguratorObject, metaclass=Singleton): - NAME = "Logger" - lock = threading.Lock() + startupTimeString = datetime.now().strftime( + consts.LOG_FILENAME_FORMAT).replace(":", "_") + + terminalSupportsColors = TD.CheckTerminalSupportsColors() - log_filename = "" - log_file_descriptor = None + # Logger starts before configurator + configurations = None # Default log levels: - console_log_level = LogLevel(consts.CONSOLE_LOG_LEVEL) - file_log_level = LogLevel(consts.FILE_LOG_LEVEL) + console_log_level = LogLevel(consts.DEFAULT_LOG_LEVEL) + file_log_level = LogLevel(consts.DEFAULT_LOG_LEVEL) + + # File logs stored here, before configurator loaded. + file_log_buffer = [] + + file_log_enabled = None + file_log_filename = None + file_log_descriptor = None + lock = None def __init__(self) -> None: - self.terminalSupportsColors = Logger.checkTerminalSupportsColors() + self.SetConsoleLogLevel() + + diag_strings = [ + "Logger Init", + f"Console Loglevel: {str(self.console_log_level)}", + f"File Loglevel: {str(self.file_log_level)}" + ] + if self.configurations: + diag_strings.extend([ + "Configurations:", + self.configurations.ToDict()]) + else: + diag_strings.append("Config not loaded yet") - # Prepare the log - self.SetLogFilename() - # Open the file descriptor - self.GetLogFileDescriptor() + self.Log(self.LOG_INFO, "Logger", diag_strings) + + + + + if self.configurations: + + try: + # set up and start file logging: + if self.GetTrueOrFalseFromConfigurations(CONFIG_KEY_FILE_LOG_ENABLED): + + # Update file log level: + new_file_loglevel = self.SanitizeLoglevel(self.GetFromConfigurations(CONFIG_KEY_FILE_LOG_LEVEL)) + if new_file_loglevel: + self.file_log_level = new_file_loglevel + + self.SetupFileLogging() + + self.WriteFileLogBuffer() + + self.file_log_enabled = True + else: + self.DisableFileLogging() + except: + self.DisableFileLogging() + + def SetConsoleLogLevel(self): + new_loglevel = None # Override log level from envvar: + if OsD.GetEnv("IOTURING_LOG_LEVEL"): + new_loglevel = self.SanitizeLoglevel(OsD.GetEnv("IOTURING_LOG_LEVEL")) + + # Read from config: + if not new_loglevel and self.configurations: + new_loglevel = self.SanitizeLoglevel( + self.GetFromConfigurations(CONFIG_KEY_CONSOLE_LOG_LEVEL)) + + if new_loglevel: + self.console_log_level = new_loglevel + self.Log(self.LOG_DEBUG, "Logger", + f"Set Console Loglevel to: {str(self.console_log_level)}") + else: + self.Log(self.LOG_DEBUG, "Logger", + f"Console Loglevel not changed.") + + def SanitizeLoglevel(self, loglevel_string) -> LogLevel | None: try: - if os.getenv("IOTURING_LOG_LEVEL"): - level_override = LogLevel(str(os.getenv("IOTURING_LOG_LEVEL"))) - self.console_log_level = level_override + l = LogLevel(str(loglevel_string)) + return l except UnknownLoglevelException as e: - self.Log(self.LOG_ERROR, "Logger", + self.Log(self.LOG_WARNING, "Logger", f"Unknown Loglevel: {e.loglevel}") + return None + + def SetupFileLogging(self) -> None: + log_dir_path = Path( + self.GetFromConfigurations(CONFIG_KEY_FILE_LOG_PATH)) + if not log_dir_path.exists(): + log_dir_path.mkdir(parents=True) + + self.file_log_filename = log_dir_path.joinpath(self.startupTimeString) - 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 + self.file_log_descriptor = \ + open(self.file_log_filename, "a", encoding="utf-8") - 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) + self.lock = threading.Lock() - return newFolder + self.Log(self.LOG_DEBUG, "Logger", + f"File Log setup finished.") + + + def WriteFileLogBuffer(self): + while self.file_log_buffer: + line = self.file_log_buffer[0] + self.WriteFileLogLine(line["string"], line["loglevel"]) + del self.file_log_buffer[0] + + def DisableFileLogging(self) -> None: + self.file_log_enabled = False + self.file_log_buffer = [] + self.CloseFile() + self.Log(self.LOG_DEBUG, "Logger", + f"File logging disabled.") def GetMessageDatetimeString(self) -> str: now = datetime.now() @@ -92,14 +176,12 @@ def GetMessageDatetimeString(self) -> str: # LOG - def Log(self, loglevel: LogLevel, source: str, message, printToConsole=True, writeToFile=True) -> None: + def Log(self, loglevel: LogLevel, source: str, message, **kwargs) -> None: if type(message) == dict: - self.LogDict(loglevel, source, message, - printToConsole, writeToFile) + self.LogDict(loglevel, source, message, **kwargs) 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) + self.LogList(loglevel, source, message, **kwargs) return # Log list will call this function so I don't need to go down at the moment message = str(message) @@ -107,8 +189,7 @@ def Log(self, loglevel: LogLevel, source: str, message, printToConsole=True, wri messageLines = message.split("\n") if len(messageLines) > 1: for line in messageLines: - self.Log(loglevel, source, line, - printToConsole, writeToFile) + self.Log(loglevel, source, line, **kwargs) 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])} | " \ @@ -122,77 +203,76 @@ def Log(self, loglevel: LogLevel, source: str, message, printToConsole=True, wri # 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) + self.PrintAndSave(string, loglevel, **kwargs) # -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): + def LogDict(self, loglevel, source, message_dict: dict, **kwargs): 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) + self.Log(loglevel, source, "> "+line, **kwargs) except Exception as e: - self.Log(self.LOG_ERROR, source, "Can't print dictionary content") + self.Log(self.LOG_ERROR, source, + "Can't print dictionary content", **kwargs) - def LogList(self, loglevel, source, message_list: list, *args): + def LogList(self, loglevel, source, message_list: list, **kwargs): 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) + self.Log(loglevel, source, "Item #"+str(index), **kwargs) + self.Log(loglevel, source, item, **kwargs) else: self.Log(loglevel, source, - f"{str(index)}: {str(item)}", *args) + f"{str(index)}: {str(item)}", **kwargs) except: - self.Log(self.LOG_ERROR, source, "Can't print dictionary content") + self.Log(self.LOG_ERROR, source, + "Can't print dictionary content", **kwargs) # 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): + def PrintAndSave(self, string: str, loglevel: LogLevel, **kwargs) -> None: + # kwargs defaults: + printToConsole = True if "printToConsole" not in kwargs else kwargs["printToConsole"] + writeToFile = True if "writeToFile" not in kwargs else kwargs["writeToFile"] + color = loglevel.color if "color" not in kwargs else kwargs["color"] + + if printToConsole and (int(loglevel) <= int(self.console_log_level)): + if self.terminalSupportsColors and color: + print(color + string + Colors.reset) + else: + print(string) + + # Config is not loaded, write to buffer: + if self.file_log_enabled is None: + self.file_log_buffer.append({ + "string": string, + "loglevel": loglevel + }) + + # Real file logging + elif self.file_log_enabled and writeToFile: + self.WriteFileLogLine(string, loglevel) + + def WriteFileLogLine(self, string: str, loglevel: LogLevel) -> None: + if not self.file_log_descriptor \ + or not self.lock: + raise Exception("File logging error! Descriptor or lock missing!") + if int(loglevel) <= int(self.file_log_level): # acquire the lock with self.lock: - self.GetLogFileDescriptor().write(string+' \n') + self.file_log_descriptor.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) - else: - print(loglevel.get_colored_string(string)) - - def GetLogFileDescriptor(self) -> TextIOWrapper: - if self.log_file_descriptor is None: - self.log_file_descriptor = open(self.log_filename, "a", encoding="utf-8") - - return self.log_file_descriptor + self.file_log_descriptor.flush() def CloseFile(self) -> None: - if self.log_file_descriptor is not None: - self.log_file_descriptor.close() - self.log_file_descriptor = None - - @staticmethod - def checkTerminalSupportsColors(): - """ - 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 + if self.file_log_descriptor is not None: + self.file_log_descriptor.close() + self.file_log_descriptor = None @classmethod @@ -205,14 +285,38 @@ def ConfigurationPreset(cls): choices=LogLevelChoices) preset.AddEntry(name="Enable file logging", key=CONFIG_KEY_FILE_LOG_ENABLED, - question_type="yesno", default="Y") + question_type="yesno", default="N") preset.AddEntry(name="File log level", key=CONFIG_KEY_FILE_LOG_LEVEL, question_type="select", mandatory=True, default=consts.DEFAULT_LOG_LEVEL, choices=LogLevelChoices) preset.AddEntry(name="File log path", key=CONFIG_KEY_FILE_LOG_PATH, - question_type="filepath", mandatory=True, default=consts.DEFAULT_LOG_LEVEL, + question_type="filepath", mandatory=True, default=cls.GetDefaultLogPath(), instruction="Directory where log files will be saved") - return preset \ No newline at end of file + return preset + + @staticmethod + def GetDefaultLogPath() -> str: + + default_path = Path(__file__).parent + 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.joinpath("Logs")) diff --git a/IoTuring/Logger/consts.py b/IoTuring/Logger/consts.py index eeb2f5444..416409d4c 100644 --- a/IoTuring/Logger/consts.py +++ b/IoTuring/Logger/consts.py @@ -57,7 +57,5 @@ LONG_MESSAGE_PRESTRING_CHAR = ' ' DEFAULT_LOG_LEVEL = "LOG_INFO" -CONSOLE_LOG_LEVEL = "LOG_INFO" -FILE_LOG_LEVEL = "LOG_INFO" MESSAGE_WIDTH = 95 diff --git a/IoTuring/MyApp/AppSettings.py b/IoTuring/MyApp/AppSettings.py index 332e9b334..9d97afc0e 100644 --- a/IoTuring/MyApp/AppSettings.py +++ b/IoTuring/MyApp/AppSettings.py @@ -1,7 +1,3 @@ -from typing import TYPE_CHECKING -if TYPE_CHECKING: - from IoTuring.Configurator.Configurator import Configurator - from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject from IoTuring.Configurator.MenuPreset import MenuPreset from IoTuring.Logger.Logger import Singleton @@ -19,10 +15,6 @@ class AppSettings(ConfiguratorObject, metaclass=Singleton): def __init__(self) -> None: pass - def LoadConfiguration(self, configurator: "Configurator"): - """ Load/update configurations to the singleton """ - self.configurations = configurator.config.GetSingleConfigOfType(self.NAME) - self.AddMissingDefaultConfigs() @classmethod def ConfigurationPreset(cls): diff --git a/IoTuring/MyApp/SystemConsts/TerminalDetection.py b/IoTuring/MyApp/SystemConsts/TerminalDetection.py new file mode 100644 index 000000000..7d0c9f5ef --- /dev/null +++ b/IoTuring/MyApp/SystemConsts/TerminalDetection.py @@ -0,0 +1,16 @@ +import sys +import os + +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 \ No newline at end of file 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/__init__.py b/IoTuring/__init__.py index 10b5f1e00..995958158 100644 --- a/IoTuring/__init__.py +++ b/IoTuring/__init__.py @@ -53,6 +53,9 @@ def loop(): logger = Logger() configurator = Configurator() + # Load Logger settings: + ConfiguratorLoader(configurator).LoadSettings() + logger.Log(Logger.LOG_DEBUG, "App", f"Selected options: {vars(args)}") if args.configurator: @@ -72,13 +75,14 @@ def loop(): # This have to start after configurator.Menu(), otherwise won't work starting from the menu signal.signal(signal.SIGINT, Exit_SIGINT_handler) + + # Reload Settings if they were changed: + ConfiguratorLoader(configurator).LoadSettings() logger.Log(Logger.LOG_INFO, "App", App()) # Print App info logger.Log(Logger.LOG_INFO, "Configurator", "Run the script with -c to enter configuration mode") - # Load AppSettings: - AppSettings().LoadConfiguration(configurator) eM = EntityManager() @@ -112,18 +116,10 @@ def Exit_SIGINT_handler(sig=None, frame=None): logger.Log(Logger.LOG_INFO, "Main", "Application closed by SigInt", printToConsole=False) # 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 + goodByeMessage = "Exiting...\nThanks for using IoTuring !" + logger.Log(Logger.LOG_INFO, "Main", goodByeMessage, + writeToFile=False, color=Colors.cyan) # to terminal logger.CloseFile() sys.exit(0) From 6756b676a2cc76de7e30c75880f815b8bf8d675a Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sun, 14 Jan 2024 22:08:56 +0100 Subject: [PATCH 18/22] Fix test, consts --- IoTuring/Logger/Logger.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/IoTuring/Logger/Logger.py b/IoTuring/Logger/Logger.py index d0053be3a..82fced8cf 100644 --- a/IoTuring/Logger/Logger.py +++ b/IoTuring/Logger/Logger.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from datetime import datetime import json import threading @@ -319,4 +321,4 @@ def GetDefaultLogPath() -> str: if base_path: default_path = base_path.joinpath(App.getName()) - return str(default_path.joinpath("Logs")) + return str(default_path.joinpath(consts.LOGS_FOLDER)) From 6b64308c4bc786bfd06266a89641fa8bf806421d Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sun, 14 Jan 2024 23:17:52 +0100 Subject: [PATCH 19/22] Remove unused import --- IoTuring/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/IoTuring/__init__.py b/IoTuring/__init__.py index 995958158..f1a8477e3 100644 --- a/IoTuring/__init__.py +++ b/IoTuring/__init__.py @@ -6,7 +6,6 @@ import argparse from IoTuring.MyApp.App import App -from IoTuring.MyApp.AppSettings import AppSettings from IoTuring.Configurator.Configurator import Configurator from IoTuring.Configurator.ConfiguratorLoader import ConfiguratorLoader from IoTuring.Entity.EntityManager import EntityManager From 1fd5522b9428a8e7b886818c6ac537a5ff55590c Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sat, 20 Jan 2024 15:20:02 +0100 Subject: [PATCH 20/22] Change loglevel on Log init --- IoTuring/Logger/Logger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/IoTuring/Logger/Logger.py b/IoTuring/Logger/Logger.py index 82fced8cf..d29a633f4 100644 --- a/IoTuring/Logger/Logger.py +++ b/IoTuring/Logger/Logger.py @@ -87,7 +87,7 @@ def __init__(self) -> None: else: diag_strings.append("Config not loaded yet") - self.Log(self.LOG_INFO, "Logger", diag_strings) + self.Log(self.LOG_DEVELOPMENT, "Logger", diag_strings) From a68fb8012bbd9a264b262c1895a839dbedcdfee5 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sat, 24 Feb 2024 01:30:49 +0100 Subject: [PATCH 21/22] SettingsClassManager and SettingsManager --- IoTuring/ClassManager/ClassManager.py | 142 +++++++++--------- IoTuring/ClassManager/EntityClassManager.py | 9 +- IoTuring/ClassManager/SettingsClassManager.py | 10 ++ .../ClassManager/WarehouseClassManager.py | 10 +- IoTuring/ClassManager/consts.py | 5 +- IoTuring/Configurator/Configuration.py | 15 +- IoTuring/Configurator/Configurator.py | 19 ++- IoTuring/Configurator/ConfiguratorLoader.py | 23 +-- IoTuring/Configurator/ConfiguratorObject.py | 36 ++--- IoTuring/Entity/Entity.py | 13 +- IoTuring/Logger/Logger.py | 138 +++-------------- IoTuring/MyApp/App.py | 5 + .../Deployments/AppSettings}/AppSettings.py | 12 +- .../Deployments/LogSettings/LogSettings.py | 96 ++++++++++++ IoTuring/Settings/SettingsManager.py | 16 ++ IoTuring/Warehouse/Warehouse.py | 5 +- IoTuring/__init__.py | 22 +-- 17 files changed, 309 insertions(+), 267 deletions(-) create mode 100644 IoTuring/ClassManager/SettingsClassManager.py rename IoTuring/{MyApp => Settings/Deployments/AppSettings}/AppSettings.py (82%) create mode 100644 IoTuring/Settings/Deployments/LogSettings/LogSettings.py create mode 100644 IoTuring/Settings/SettingsManager.py diff --git a/IoTuring/ClassManager/ClassManager.py b/IoTuring/ClassManager/ClassManager.py index ea30c2763..320735f35 100644 --- a/IoTuring/ClassManager/ClassManager.py +++ b/IoTuring/ClassManager/ClassManager.py @@ -1,56 +1,88 @@ from __future__ import annotations -import os from pathlib import Path -from os import path import importlib.util import importlib.machinery import sys import inspect from IoTuring.Logger.LogObject import LogObject -# from IoTuring.ClassManager import consts - # This is a parent class -# Implement subclasses in this way: - -# def __init__(self): -# ClassManager.__init__(self) -# self.baseClass = Entity : Select the class to find -# self.GetModulesFilename(consts.ENTITIES_PATH) : Select path where it should look for classes and add all classes to found list - # This class is used to find and load classes without importing them # The important this is that the class is inside a folder that exactly the same name of the Class and of the file (obviously not talking about extensions) class ClassManager(LogObject): - def __init__(self): - self.modulesFilename = [] - module_path = sys.modules[self.__class__.__module__].__file__ - if not module_path: - raise Exception("Error getting path: " + str(module_path)) + + # Set up these class variables in subclasses: + classesRelativePath = None # Change in subclasses + + + def __init__(self) -> None: + + classmanager_file_path = sys.modules[self.__class__.__module__].__file__ + if not classmanager_file_path: + raise Exception("Error getting path: " + + str(classmanager_file_path)) + + self.rootPath = Path(classmanager_file_path).parents[1] + + # Store loaded classes here: + self.loadedClasses = [] + + # Collect paths + self.moduleFilePaths = self.GetModuleFilePaths() + + + def GetModuleFilePaths(self) -> list: + if not self.classesRelativePath: + raise Exception("Path to deployments not defined") + + classesRootPath = self.rootPath.joinpath(self.classesRelativePath) + + if not classesRootPath.exists: + raise Exception(f"Path does not exist: {classesRootPath}") + + self.Log(self.LOG_DEVELOPMENT, + f'Looking for python files in "{classesRootPath}"...') + + python_files = classesRootPath.rglob("*.py") + + # TO check if a py files is in a folder !!!! with the same name !!! (same without extension) + filepaths = [f for f in python_files if f.stem == f.parent.stem] + + self.Log(self.LOG_DEVELOPMENT, + f"Found {str(len(filepaths))} modules files") + + return filepaths + + def GetClassFromName(self, wantedName: str) -> type | None: + + # Check from already loaded classes: + module_class = next( + (m for m in self.loadedClasses if m.__name__ == wantedName), None) + + if module_class: + return module_class + + modulePath = next( + (m for m in self.moduleFilePaths if m.stem == wantedName), None) + + if modulePath: + + loadedModule = self.LoadModule(modulePath) + loadedClass = self.GetClassFromModule(loadedModule) + self.loadedClasses.append(loadedClass) + return loadedClass + else: - self.mainPath = path.dirname(path.abspath(module_path)) - # THIS MUST BE IMPLEMENTED IN SUBCLASSES, IS THE CLASS I WANT TO SEARCH !!!! - self.baseClass = None - - def GetClassFromName(self, wantedName) -> type | None: - # From name, load the correct module and extract the class - for module in self.modulesFilename: # Search the module file - moduleName = self.ModuleNameFromPath(module) - # Check if the module name matches the given name - if wantedName == moduleName: - # Load the module - loadedModule = self.LoadModule(module) - # Now get the class - return self.GetClassFromModule(loadedModule) - return None - - def LoadModule(self, path): # Get module and load it from the path + return None + + def LoadModule(self, module_path: Path): # Get module and load it from the path try: loader = importlib.machinery.SourceFileLoader( - self.ModuleNameFromPath(path), path) + module_path.stem, str(module_path)) spec = importlib.util.spec_from_loader(loader.name, loader) if not spec: @@ -58,50 +90,20 @@ def LoadModule(self, path): # Get module and load it from the path module = importlib.util.module_from_spec(spec) loader.exec_module(module) - moduleName = os.path.split(path)[1][:-3] - sys.modules[moduleName] = module + sys.modules[module_path.stem] = module return module except Exception as e: - self.Log(self.LOG_ERROR, "Error while loading module " + - path + ": " + str(e)) + self.Log(self.LOG_ERROR, + f"Error while loading module {module_path.stem}: {str(e)}") # From the module passed, I search for a Class that has className=moduleName def GetClassFromModule(self, module): for name, obj in inspect.getmembers(module): if inspect.isclass(obj): - if(name == module.__name__): + if (name == module.__name__): return obj raise Exception(f"No class found: {module.__name__}") - # List files in the _path directory and get only files in subfolders - def GetModulesFilename(self, _path): - classesRootPath = path.join(self.mainPath, _path) - if os.path.exists(classesRootPath): - self.Log(self.LOG_DEVELOPMENT, - "Looking for python files in \"" + _path + os.sep + "\"...") - result = list(Path(classesRootPath).rglob("*.py")) - entities = [] - for file in result: - filename = str(file) - # TO check if a py files is in a folder !!!! with the same name !!! (same without extension) - pathList = filename.split(os.sep) - if len(pathList) >= 2: - if pathList[len(pathList)-1][:-3] == pathList[len(pathList)-2]: - entities.append(filename) - - self.modulesFilename = self.modulesFilename + entities - self.Log(self.LOG_DEVELOPMENT, "Found " + - str(len(entities)) + " modules files") - - def ModuleNameFromPath(self, path): - classname = os.path.split(path) - return classname[1][:-3] - - def ListAvailableClassesNames(self) -> list: - res = [] - for py in self.modulesFilename: - res.append(path.basename(py).split(".py")[0]) - return res - def ListAvailableClasses(self) -> list: - return [self.GetClassFromName(n) for n in self.ListAvailableClassesNames()] + + return [self.GetClassFromName(f.stem) for f in self.moduleFilePaths] diff --git a/IoTuring/ClassManager/EntityClassManager.py b/IoTuring/ClassManager/EntityClassManager.py index 5f6dee5e6..5c2328136 100644 --- a/IoTuring/ClassManager/EntityClassManager.py +++ b/IoTuring/ClassManager/EntityClassManager.py @@ -1,12 +1,7 @@ from IoTuring.ClassManager.ClassManager import ClassManager from IoTuring.ClassManager import consts -from IoTuring.Entity.Entity import Entity -# Class to load Entities from the Entitties dir and get them from name +# Class to load Entities from the Entitties dir class EntityClassManager(ClassManager): - def __init__(self): - ClassManager.__init__(self) - self.baseClass = Entity - self.GetModulesFilename(consts.ENTITIES_PATH) - # self.GetModulesFilename(consts.CUSTOM_ENTITIES_PATH) # TODO Decide if I'll use customs + classesRelativePath = consts.ENTITIES_PATH diff --git a/IoTuring/ClassManager/SettingsClassManager.py b/IoTuring/ClassManager/SettingsClassManager.py new file mode 100644 index 000000000..6e3064436 --- /dev/null +++ b/IoTuring/ClassManager/SettingsClassManager.py @@ -0,0 +1,10 @@ +from IoTuring.ClassManager.ClassManager import ClassManager +from IoTuring.ClassManager import consts + + +# Class to load Entities from the Entitties dir +class SettingsClassManager(ClassManager): + + classesRelativePath = consts.SETTINGS_PATH + + diff --git a/IoTuring/ClassManager/WarehouseClassManager.py b/IoTuring/ClassManager/WarehouseClassManager.py index 0bedb2ae6..ee295dd6c 100644 --- a/IoTuring/ClassManager/WarehouseClassManager.py +++ b/IoTuring/ClassManager/WarehouseClassManager.py @@ -1,11 +1,9 @@ from IoTuring.ClassManager.ClassManager import ClassManager from IoTuring.ClassManager import consts -from IoTuring.Warehouse.Warehouse import Warehouse -# Class to load Entities from the Entitties dir and get them from name +# Class to load Warehouses from the Warehouses dir + class WarehouseClassManager(ClassManager): - def __init__(self): - ClassManager.__init__(self) - self.baseClass = Warehouse - self.GetModulesFilename(consts.WAREHOUSES_PATH) + + classesRelativePath = consts.WAREHOUSES_PATH diff --git a/IoTuring/ClassManager/consts.py b/IoTuring/ClassManager/consts.py index 5a424e024..573a08771 100644 --- a/IoTuring/ClassManager/consts.py +++ b/IoTuring/ClassManager/consts.py @@ -1,2 +1,3 @@ -ENTITIES_PATH = "../Entity/Deployments/" -WAREHOUSES_PATH = "../Warehouse/Deployments/" +ENTITIES_PATH = "Entity/Deployments" +WAREHOUSES_PATH = "Warehouse/Deployments" +SETTINGS_PATH = "Settings/Deployments" diff --git a/IoTuring/Configurator/Configuration.py b/IoTuring/Configurator/Configuration.py index e2219ac18..ad1a7da1c 100644 --- a/IoTuring/Configurator/Configuration.py +++ b/IoTuring/Configurator/Configuration.py @@ -133,13 +133,13 @@ def ToDict(self) -> dict: class SingleConfiguration: - """Single configuraiton of an entity or warehouse or AppSettings""" + """Single configuration of an entity or warehouse or setting""" config_category: str type: str configurations: dict - def __init__(self, config_category: str, config_dict: dict) -> None: + def __init__(self, config_category: str = "", config_dict: dict = {}) -> None: """Create a new SingleConfiguration Args: @@ -149,7 +149,11 @@ def __init__(self, config_category: str, config_dict: dict) -> None: self.config_category = config_category # self.type: - setattr(self, KEY_ENTITY_TYPE, config_dict.pop(KEY_ENTITY_TYPE)) + type_name = "" + if KEY_ENTITY_TYPE in config_dict: + type_name = config_dict.pop(KEY_ENTITY_TYPE) + + setattr(self, KEY_ENTITY_TYPE, type_name) self.configurations = config_dict @@ -227,8 +231,9 @@ def HasConfigKey(self, config_key: str) -> bool: """ return bool(config_key in self.configurations) - def ToDict(self) -> dict: + def ToDict(self, include_type: bool = True) -> dict: """Full configuration as a dict, as it would be saved to a file """ full_dict = self.configurations - full_dict[KEY_ENTITY_TYPE] = getattr(self, KEY_ENTITY_TYPE) + if include_type: + full_dict[KEY_ENTITY_TYPE] = getattr(self, KEY_ENTITY_TYPE) return full_dict diff --git a/IoTuring/Configurator/Configurator.py b/IoTuring/Configurator/Configurator.py index 9a72409ef..259259ba0 100644 --- a/IoTuring/Configurator/Configurator.py +++ b/IoTuring/Configurator/Configurator.py @@ -12,12 +12,12 @@ from IoTuring.ClassManager.EntityClassManager import EntityClassManager from IoTuring.ClassManager.WarehouseClassManager import WarehouseClassManager +from IoTuring.ClassManager.SettingsClassManager import SettingsClassManager from IoTuring.Configurator import ConfiguratorIO from IoTuring.Configurator import messages from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD -from IoTuring.MyApp.AppSettings import AppSettings from InquirerPy import inquirer from InquirerPy.separator import Separator @@ -170,10 +170,19 @@ def ManageWarehouses(self) -> None: def ManageSettings(self) -> None: """ UI for App and Log Settings """ - choices = [ - {"name": "Log Settings", "value": Logger}, - {"name": "App Settings", "value": AppSettings} - ] + + scm = SettingsClassManager() + + choices = [] + + availableSettings = scm.ListAvailableClasses() + for sClass in availableSettings: + + + + choices.append( + {"name": sClass.NAME + " Settings", + "value": sClass}) choice = self.DisplayMenu( choices=choices, diff --git a/IoTuring/Configurator/ConfiguratorLoader.py b/IoTuring/Configurator/ConfiguratorLoader.py index b8f903d45..4621dc47a 100644 --- a/IoTuring/Configurator/ConfiguratorLoader.py +++ b/IoTuring/Configurator/ConfiguratorLoader.py @@ -6,10 +6,9 @@ from IoTuring.Configurator.Configurator import Configurator, KEY_ACTIVE_ENTITIES, KEY_ACTIVE_WAREHOUSES, KEY_SETTINGS from IoTuring.ClassManager.WarehouseClassManager import WarehouseClassManager from IoTuring.ClassManager.EntityClassManager import EntityClassManager +from IoTuring.ClassManager.SettingsClassManager import SettingsClassManager from IoTuring.Warehouse.Warehouse import Warehouse -from IoTuring.MyApp.AppSettings import AppSettings -from IoTuring.Logger.Logger import Logger class ConfiguratorLoader(LogObject): @@ -35,7 +34,6 @@ def LoadWarehouses(self) -> list[Warehouse]: self.LOG_ERROR, f"Can't find {whConfig.GetType()} warehouse, check your configurations.") else: wh = whClass(whConfig) - wh.AddMissingDefaultConfigs() self.Log( self.LOG_DEBUG, f"Full configuration with defaults: {wh.configurations.ToDict()}") warehouses.append(wh) @@ -57,7 +55,6 @@ def LoadEntities(self) -> list[Entity]: self.LOG_ERROR, f"Can't find {entityConfig.GetType()} entity, check your configurations.") else: ec = entityClass(entityConfig) - ec.AddMissingDefaultConfigs() self.Log( self.LOG_DEBUG, f"Full configuration with defaults: {ec.configurations.ToDict()}") entities.append(ec) # Entity instance @@ -71,11 +68,15 @@ 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) -> None: - settingsClasses = [AppSettings, Logger] + def LoadSettings(self) -> list: + scm = SettingsClassManager() + settings = [] + settingsClasses = scm.ListAvailableClasses() + for settingsClass in settingsClasses: - sc = settingsClass() - sc.configurations = self.configurations.LoadSingleConfig( - sc.NAME, KEY_SETTINGS) - sc.AddMissingDefaultConfigs() - sc.__init__() + settingsConfig = self.configurations.LoadSingleConfig( + settingsClass.NAME, KEY_SETTINGS) + sc = settingsClass(settingsConfig) + settings.append(sc) + return settings + \ No newline at end of file diff --git a/IoTuring/Configurator/ConfiguratorObject.py b/IoTuring/Configurator/ConfiguratorObject.py index 9ba9ba459..275aa376e 100644 --- a/IoTuring/Configurator/ConfiguratorObject.py +++ b/IoTuring/Configurator/ConfiguratorObject.py @@ -4,21 +4,34 @@ class ConfiguratorObject: """ Base class for configurable classes """ + NAME = "Unnamed" def __init__(self, single_configuration: SingleConfiguration) -> None: self.configurations = single_configuration - def GetConfigurations(self) -> dict: - """ Return configuration as dict """ - return self.configurations.ToDict() + # Add missing default values: + preset = self.ConfigurationPreset() + defaults = preset.GetDefaults() + + if defaults: + for default_key, default_value in defaults.items(): + if not self.GetConfigurations().HasConfigKey(default_key): + self.GetConfigurations().UpdateConfigValue(default_key, default_value) + + def GetConfigurations(self) -> SingleConfiguration: + """ Safe return single_configuration object """ + if self.configurations: + return self.configurations + else: + raise Exception(f"Configuration not loaded for {self.NAME}") def GetFromConfigurations(self, key): """ Get value from confiugurations with key (if not present raise Exception).""" - if self.configurations.HasConfigKey(key): - return self.configurations.GetConfigValue(key) + if self.GetConfigurations().HasConfigKey(key): + return self.GetConfigurations().GetConfigValue(key) else: raise Exception("Can't find key " + key + " in configurations") - + def GetTrueOrFalseFromConfigurations(self, key) -> bool: """ Get boolean value from confiugurations with key (if not present raise Exception) """ value = self.GetFromConfigurations(key).lower() @@ -27,17 +40,6 @@ def GetTrueOrFalseFromConfigurations(self, key) -> bool: else: return False - def AddMissingDefaultConfigs(self) -> None: - """ If some default values are missing add them to the running configuration""" - preset = self.ConfigurationPreset() - defaults = preset.GetDefaults() - - if defaults: - for default_key, default_value in defaults.items(): - if not self.configurations.HasConfigKey(default_key): - self.configurations.UpdateConfigValue(default_key, default_value) - - @classmethod def ConfigurationPreset(cls) -> MenuPreset: """ Prepare a preset to manage settings insert/edit for the warehouse or entity """ diff --git a/IoTuring/Entity/Entity.py b/IoTuring/Entity/Entity.py index 625928b9d..1213d0b5f 100644 --- a/IoTuring/Entity/Entity.py +++ b/IoTuring/Entity/Entity.py @@ -4,33 +4,31 @@ from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject from IoTuring.Configurator.Configuration import SingleConfiguration -from IoTuring.MyApp.AppSettings import AppSettings, CONFIG_KEY_UPDATE_INTERVAL +from IoTuring.Settings.Deployments.AppSettings.AppSettings import CONFIG_KEY_UPDATE_INTERVAL +from IoTuring.Settings.SettingsManager import SettingsManager from IoTuring.Exceptions.Exceptions import UnknownEntityKeyException from IoTuring.Logger.LogObject import LogObject from IoTuring.Entity.EntityData import EntityData, EntitySensor, EntityCommand, ExtraAttribute from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD - - class Entity(LogObject, ConfiguratorObject): NAME = "Unnamed" ALLOW_MULTI_INSTANCE = False def __init__(self, single_configuration: SingleConfiguration) -> None: - + # Prepare the entity self.entitySensors = [] self.entityCommands = [] self.configurations = single_configuration self.tag = self.configurations.GetTag() - # When I update the values this number changes (randomly) so each warehouse knows I have updated self.valuesID = 0 - self.updateTimeout = float(AppSettings().GetFromConfigurations(CONFIG_KEY_UPDATE_INTERVAL)) - + + self.updateTimeout = float(SettingsManager().GetFromConfigurations(CONFIG_KEY_UPDATE_INTERVAL)) def Initialize(self): """ Must be implemented in sub-classes, may be useful here to use the configuration """ @@ -166,7 +164,6 @@ def GetEntityId(self) -> str: def LogSource(self): return self.GetEntityId() - def RunCommand(self, command: str | list, command_name: str = "", diff --git a/IoTuring/Logger/Logger.py b/IoTuring/Logger/Logger.py index d29a633f4..8caaf2c5c 100644 --- a/IoTuring/Logger/Logger.py +++ b/IoTuring/Logger/Logger.py @@ -6,33 +6,15 @@ from pathlib import Path -from IoTuring.MyApp.App import App + from IoTuring.Logger import consts from IoTuring.Logger.LogLevel import LogLevelObject, LogLevel from IoTuring.Logger.Colors import Colors from IoTuring.Exceptions.Exceptions import UnknownLoglevelException -from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject -from IoTuring.Configurator.MenuPreset import MenuPreset + from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD from IoTuring.MyApp.SystemConsts import TerminalDetection as TD -# 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_FILE_LOG_LEVEL = "file_log_level" -CONFIG_KEY_FILE_LOG_ENABLED = "file_log_enabled" -CONFIG_KEY_FILE_LOG_PATH = "file_log_path" - - -LogLevelChoices = [{"name": l["string"], "value": l["const"]} - for l in consts.LOG_LEVELS] class Singleton(type): @@ -48,17 +30,13 @@ def __call__(cls): # type: ignore return cls._instances[cls] -class Logger(LogLevelObject, ConfiguratorObject, metaclass=Singleton): - NAME = "Logger" +class Logger(LogLevelObject, metaclass=Singleton): startupTimeString = datetime.now().strftime( consts.LOG_FILENAME_FORMAT).replace(":", "_") terminalSupportsColors = TD.CheckTerminalSupportsColors() - # Logger starts before configurator - configurations = None - # Default log levels: console_log_level = LogLevel(consts.DEFAULT_LOG_LEVEL) file_log_level = LogLevel(consts.DEFAULT_LOG_LEVEL) @@ -66,13 +44,13 @@ class Logger(LogLevelObject, ConfiguratorObject, metaclass=Singleton): # File logs stored here, before configurator loaded. file_log_buffer = [] - file_log_enabled = None - file_log_filename = None + # For writing to file: file_log_descriptor = None lock = None def __init__(self) -> None: + # Set loglevel from envvar: self.SetConsoleLogLevel() diag_strings = [ @@ -80,50 +58,21 @@ def __init__(self) -> None: f"Console Loglevel: {str(self.console_log_level)}", f"File Loglevel: {str(self.file_log_level)}" ] - if self.configurations: - diag_strings.extend([ - "Configurations:", - self.configurations.ToDict()]) - else: - diag_strings.append("Config not loaded yet") - - self.Log(self.LOG_DEVELOPMENT, "Logger", diag_strings) - - - - - if self.configurations: - - try: - # set up and start file logging: - if self.GetTrueOrFalseFromConfigurations(CONFIG_KEY_FILE_LOG_ENABLED): - - # Update file log level: - new_file_loglevel = self.SanitizeLoglevel(self.GetFromConfigurations(CONFIG_KEY_FILE_LOG_LEVEL)) - if new_file_loglevel: - self.file_log_level = new_file_loglevel - self.SetupFileLogging() - self.WriteFileLogBuffer() + self.Log(self.LOG_DEVELOPMENT, "Logger", diag_strings) - self.file_log_enabled = True - else: - self.DisableFileLogging() - except: - self.DisableFileLogging() - def SetConsoleLogLevel(self): + def SetConsoleLogLevel(self, loglevel_string:str = ""): new_loglevel = None # Override log level from envvar: if OsD.GetEnv("IOTURING_LOG_LEVEL"): new_loglevel = self.SanitizeLoglevel(OsD.GetEnv("IOTURING_LOG_LEVEL")) # Read from config: - if not new_loglevel and self.configurations: - new_loglevel = self.SanitizeLoglevel( - self.GetFromConfigurations(CONFIG_KEY_CONSOLE_LOG_LEVEL)) + if not new_loglevel and loglevel_string: + new_loglevel = self.SanitizeLoglevel(loglevel_string) if new_loglevel: self.console_log_level = new_loglevel @@ -142,32 +91,34 @@ def SanitizeLoglevel(self, loglevel_string) -> LogLevel | None: f"Unknown Loglevel: {e.loglevel}") return None - def SetupFileLogging(self) -> None: - log_dir_path = Path( - self.GetFromConfigurations(CONFIG_KEY_FILE_LOG_PATH)) + # Called from LogSettings + def StartFileLogging(self, loglevel_string:str, log_dir_path:Path) -> None: + + self.file_log_level = self.SanitizeLoglevel(loglevel_string) or self.file_log_level + + if not log_dir_path.exists(): log_dir_path.mkdir(parents=True) - self.file_log_filename = log_dir_path.joinpath(self.startupTimeString) + file_log_filename = log_dir_path.joinpath(self.startupTimeString) self.file_log_descriptor = \ - open(self.file_log_filename, "a", encoding="utf-8") + open(file_log_filename, "a", encoding="utf-8") self.lock = threading.Lock() self.Log(self.LOG_DEBUG, "Logger", f"File Log setup finished.") - - def WriteFileLogBuffer(self): + # Write buffer to disk: while self.file_log_buffer: line = self.file_log_buffer[0] self.WriteFileLogLine(line["string"], line["loglevel"]) del self.file_log_buffer[0] + # Called from LogSettings def DisableFileLogging(self) -> None: - self.file_log_enabled = False - self.file_log_buffer = [] + self.file_log_buffer = None self.CloseFile() self.Log(self.LOG_DEBUG, "Logger", f"File logging disabled.") @@ -250,14 +201,14 @@ def PrintAndSave(self, string: str, loglevel: LogLevel, **kwargs) -> None: print(string) # Config is not loaded, write to buffer: - if self.file_log_enabled is None: + if self.file_log_buffer is not None: self.file_log_buffer.append({ "string": string, "loglevel": loglevel }) # Real file logging - elif self.file_log_enabled and writeToFile: + elif self.file_log_descriptor and writeToFile: self.WriteFileLogLine(string, loglevel) def WriteFileLogLine(self, string: str, loglevel: LogLevel) -> None: @@ -277,48 +228,3 @@ def CloseFile(self) -> None: self.file_log_descriptor = None - @classmethod - def ConfigurationPreset(cls): - preset = MenuPreset() - - preset.AddEntry(name="Console log level", key=CONFIG_KEY_CONSOLE_LOG_LEVEL, - question_type="select", mandatory=True, default=consts.DEFAULT_LOG_LEVEL, - instruction="IOTURING_LOG_LEVEL envvar overwrites this setting!", - choices=LogLevelChoices) - - 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=consts.DEFAULT_LOG_LEVEL, - choices=LogLevelChoices) - - 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") - - return preset - - @staticmethod - def GetDefaultLogPath() -> str: - - default_path = Path(__file__).parent - 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.joinpath(consts.LOGS_FOLDER)) diff --git a/IoTuring/MyApp/App.py b/IoTuring/MyApp/App.py index 0467394ce..1442c9722 100644 --- a/IoTuring/MyApp/App.py +++ b/IoTuring/MyApp/App.py @@ -1,4 +1,5 @@ from importlib.metadata import metadata +from pathlib import Path class App(): METADATA = metadata('IoTuring') @@ -40,6 +41,10 @@ def getUrlHomepage() -> str: def getUrlReleases() -> str: return App.URL_RELEASES + @staticmethod + def getRootPath() -> Path: + return Path(__file__).parents[1] + def __str__(self) -> str: msg = "" msg += "Name: " + App.getName() + "\n" diff --git a/IoTuring/MyApp/AppSettings.py b/IoTuring/Settings/Deployments/AppSettings/AppSettings.py similarity index 82% rename from IoTuring/MyApp/AppSettings.py rename to IoTuring/Settings/Deployments/AppSettings/AppSettings.py index 9d97afc0e..575492bc6 100644 --- a/IoTuring/MyApp/AppSettings.py +++ b/IoTuring/Settings/Deployments/AppSettings/AppSettings.py @@ -1,20 +1,14 @@ -from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject from IoTuring.Configurator.MenuPreset import MenuPreset -from IoTuring.Logger.Logger import Singleton +from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject CONFIG_KEY_UPDATE_INTERVAL = "update_interval" CONFIG_KEY_SLOW_INTERVAL = "slow_interval" - -class AppSettings(ConfiguratorObject, metaclass=Singleton): +class AppSettings(ConfiguratorObject): """Singleton for storing AppSettings, not related to Entites or Warehouses """ - NAME = "AppSettings" - - def __init__(self) -> None: - pass - + NAME = "App" @classmethod def ConfigurationPreset(cls): diff --git a/IoTuring/Settings/Deployments/LogSettings/LogSettings.py b/IoTuring/Settings/Deployments/LogSettings/LogSettings.py new file mode 100644 index 000000000..d2c613913 --- /dev/null +++ b/IoTuring/Settings/Deployments/LogSettings/LogSettings.py @@ -0,0 +1,96 @@ +from pathlib import Path + +from IoTuring.Configurator.MenuPreset import MenuPreset +from IoTuring.Logger.Logger import Logger +from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject +from IoTuring.Configurator.Configuration import SingleConfiguration +from IoTuring.MyApp.App import App +from IoTuring.Logger import consts +from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD + + +# 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_FILE_LOG_LEVEL = "file_log_level" +CONFIG_KEY_FILE_LOG_ENABLED = "file_log_enabled" +CONFIG_KEY_FILE_LOG_PATH = "file_log_path" + + +LogLevelChoices = [{"name": l["string"], "value": l["const"]} + for l in consts.LOG_LEVELS] + + +class LogSettings(ConfiguratorObject): + NAME = "Log" + + def __init__(self, single_configuration: SingleConfiguration) -> None: + super().__init__(single_configuration) + + # Load settings to logger: + logger = Logger() + + logger.SetConsoleLogLevel( + self.GetFromConfigurations(CONFIG_KEY_CONSOLE_LOG_LEVEL)) + + if self.GetTrueOrFalseFromConfigurations(CONFIG_KEY_FILE_LOG_ENABLED): + try: + logger.StartFileLogging(self.GetFromConfigurations(CONFIG_KEY_FILE_LOG_LEVEL), + Path(self.GetFromConfigurations(CONFIG_KEY_FILE_LOG_PATH))) + except: + logger.DisableFileLogging() + else: + logger.DisableFileLogging() + + @classmethod + def ConfigurationPreset(cls): + preset = MenuPreset() + + preset.AddEntry(name="Console log level", key=CONFIG_KEY_CONSOLE_LOG_LEVEL, + question_type="select", mandatory=True, default=consts.DEFAULT_LOG_LEVEL, + instruction="IOTURING_LOG_LEVEL envvar overwrites this setting!", + choices=LogLevelChoices) + + 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=consts.DEFAULT_LOG_LEVEL, + choices=LogLevelChoices) + + 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") + + return preset + + @staticmethod + def GetDefaultLogPath() -> str: + + default_path = App.getRootPath().joinpath("Logger") + 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.joinpath(consts.LOGS_FOLDER)) diff --git a/IoTuring/Settings/SettingsManager.py b/IoTuring/Settings/SettingsManager.py new file mode 100644 index 000000000..6dc3436bc --- /dev/null +++ b/IoTuring/Settings/SettingsManager.py @@ -0,0 +1,16 @@ +from IoTuring.Logger.Logger import Singleton +from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject +from IoTuring.Configurator.Configuration import SingleConfiguration + + +class SettingsManager(ConfiguratorObject, metaclass=Singleton): + def __init__(self) -> None: + self.configurations = SingleConfiguration() + + def AddSettings(self, setting_entities: list[ConfiguratorObject]) -> None: + for setting_entity in setting_entities: + + conf_dict = setting_entity.GetConfigurations().ToDict(include_type=False) + + for conf_key, conf_value in conf_dict.items(): + self.GetConfigurations().UpdateConfigValue(conf_key, conf_value) diff --git a/IoTuring/Warehouse/Warehouse.py b/IoTuring/Warehouse/Warehouse.py index 8f02afe56..3178b5c58 100644 --- a/IoTuring/Warehouse/Warehouse.py +++ b/IoTuring/Warehouse/Warehouse.py @@ -3,7 +3,8 @@ from IoTuring.Logger.LogObject import LogObject from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject from IoTuring.Entity.EntityManager import EntityManager -from IoTuring.MyApp.AppSettings import AppSettings, CONFIG_KEY_UPDATE_INTERVAL +from IoTuring.Settings.Deployments.AppSettings.AppSettings import CONFIG_KEY_UPDATE_INTERVAL +from IoTuring.Settings.SettingsManager import SettingsManager from IoTuring.Configurator.Configuration import SingleConfiguration from threading import Thread @@ -14,7 +15,7 @@ class Warehouse(LogObject, ConfiguratorObject): NAME = "Unnamed" def __init__(self, single_configuration: SingleConfiguration) -> None: - self.loopTimeout = float(AppSettings().GetFromConfigurations(CONFIG_KEY_UPDATE_INTERVAL)) + self.loopTimeout = float(SettingsManager().GetFromConfigurations(CONFIG_KEY_UPDATE_INTERVAL)) self.configurations = single_configuration diff --git a/IoTuring/__init__.py b/IoTuring/__init__.py index f1a8477e3..c5ed87c45 100644 --- a/IoTuring/__init__.py +++ b/IoTuring/__init__.py @@ -11,6 +11,7 @@ from IoTuring.Entity.EntityManager import EntityManager from IoTuring.Logger.Logger import Logger from IoTuring.Logger.Colors import Colors +from IoTuring.Settings.SettingsManager import SettingsManager warehouses = [] entities = [] @@ -52,7 +53,7 @@ def loop(): logger = Logger() configurator = Configurator() - # Load Logger settings: + # Load Logger settings before everything: ConfiguratorLoader(configurator).LoadSettings() logger.Log(Logger.LOG_DEBUG, "App", f"Selected options: {vars(args)}") @@ -74,22 +75,25 @@ def loop(): # This have to start after configurator.Menu(), otherwise won't work starting from the menu signal.signal(signal.SIGINT, Exit_SIGINT_handler) - - # Reload Settings if they were changed: - ConfiguratorLoader(configurator).LoadSettings() + + # Load Settings: + settings = ConfiguratorLoader(configurator).LoadSettings() + sM = SettingsManager() + sM.AddSettings(settings) logger.Log(Logger.LOG_INFO, "App", App()) # Print App info - logger.Log(Logger.LOG_INFO, "Configurator", - "Run the script with -c to enter configuration mode") - - eM = EntityManager() + # Add help if not started from Configurator + if not args.configurator: + logger.Log(Logger.LOG_INFO, "Configurator", + "Run the script with -c to enter configuration mode") # These will be done from the configuration reader entities = ConfiguratorLoader(configurator).LoadEntities() warehouses = ConfiguratorLoader(configurator).LoadWarehouses() # Add entites to the EntityManager + eM = EntityManager() for entity in entities: eM.AddActiveEntity(entity) @@ -118,7 +122,7 @@ def Exit_SIGINT_handler(sig=None, frame=None): print() # New line goodByeMessage = "Exiting...\nThanks for using IoTuring !" logger.Log(Logger.LOG_INFO, "Main", goodByeMessage, - writeToFile=False, color=Colors.cyan) # to terminal + writeToFile=False, color=Colors.cyan) # to terminal logger.CloseFile() sys.exit(0) From e6cf14398e34eb04066e47ac724591fee270d556 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sat, 24 Feb 2024 02:00:13 +0100 Subject: [PATCH 22/22] Cleanup, documentation --- IoTuring/ClassManager/ClassManager.py | 32 +++++++++++++++------ IoTuring/Configurator/Configuration.py | 1 + IoTuring/Configurator/Configurator.py | 5 ++-- IoTuring/Configurator/ConfiguratorLoader.py | 7 ++++- IoTuring/Settings/SettingsManager.py | 4 +++ 5 files changed, 36 insertions(+), 13 deletions(-) diff --git a/IoTuring/ClassManager/ClassManager.py b/IoTuring/ClassManager/ClassManager.py index 320735f35..9d611f44f 100644 --- a/IoTuring/ClassManager/ClassManager.py +++ b/IoTuring/ClassManager/ClassManager.py @@ -1,24 +1,24 @@ from __future__ import annotations -from pathlib import Path import importlib.util import importlib.machinery import sys import inspect -from IoTuring.Logger.LogObject import LogObject - -# This is a parent class +from pathlib import Path -# This class is used to find and load classes without importing them -# The important this is that the class is inside a folder that exactly the same name of the Class and of the file (obviously not talking about extensions) +from IoTuring.Logger.LogObject import LogObject class ClassManager(LogObject): + """Base class for ClassManagers + + This class is used to find and load classes without importing them + The important this is that the class is inside a folder that exactly the same name of the Class and of the file (obviously not talking about extensions) + """ # Set up these class variables in subclasses: classesRelativePath = None # Change in subclasses - def __init__(self) -> None: classmanager_file_path = sys.modules[self.__class__.__module__].__file__ @@ -30,12 +30,13 @@ def __init__(self) -> None: # Store loaded classes here: self.loadedClasses = [] - + # Collect paths self.moduleFilePaths = self.GetModuleFilePaths() + def GetModuleFilePaths(self) -> list[Path]: + """Get the paths of of python files of this class""" - def GetModuleFilePaths(self) -> list: if not self.classesRelativePath: raise Exception("Path to deployments not defined") @@ -58,6 +59,14 @@ def GetModuleFilePaths(self) -> list: return filepaths def GetClassFromName(self, wantedName: str) -> type | None: + """Get the class of given name, and load it + + Args: + wantedName (str): The name to look for + + Returns: + type | None: The class if found, None if not found + """ # Check from already loaded classes: module_class = next( @@ -105,5 +114,10 @@ def GetClassFromModule(self, module): raise Exception(f"No class found: {module.__name__}") def ListAvailableClasses(self) -> list: + """Get all classes of this ClassManager + + Returns: + list: The list of classes + """ return [self.GetClassFromName(f.stem) for f in self.moduleFilePaths] diff --git a/IoTuring/Configurator/Configuration.py b/IoTuring/Configurator/Configuration.py index ad1a7da1c..f49f3d8ba 100644 --- a/IoTuring/Configurator/Configuration.py +++ b/IoTuring/Configurator/Configuration.py @@ -145,6 +145,7 @@ def __init__(self, config_category: str = "", config_dict: dict = {}) -> None: Args: config_category (str): KEY_ACTIVE_ENTITIES, KEY_ACTIVE_WAREHOUSES or KEY_SETTINGS config_dict (dict): All options as in config file + Both optional, it will create an empty configuration without them """ self.config_category = config_category diff --git a/IoTuring/Configurator/Configurator.py b/IoTuring/Configurator/Configurator.py index 259259ba0..8300dd509 100644 --- a/IoTuring/Configurator/Configurator.py +++ b/IoTuring/Configurator/Configurator.py @@ -178,8 +178,6 @@ def ManageSettings(self) -> None: availableSettings = scm.ListAvailableClasses() for sClass in availableSettings: - - choices.append( {"name": sClass.NAME + " Settings", "value": sClass}) @@ -193,7 +191,8 @@ def ManageSettings(self) -> None: self.Menu() else: - settings_config = self.config.LoadSingleConfig(choice.NAME, KEY_SETTINGS) + settings_config = self.config.LoadSingleConfig( + choice.NAME, KEY_SETTINGS) # Edit: self.EditActiveClass(choice, settings_config) diff --git a/IoTuring/Configurator/ConfiguratorLoader.py b/IoTuring/Configurator/ConfiguratorLoader.py index 4621dc47a..fccf855f0 100644 --- a/IoTuring/Configurator/ConfiguratorLoader.py +++ b/IoTuring/Configurator/ConfiguratorLoader.py @@ -79,4 +79,9 @@ def LoadSettings(self) -> list: sc = settingsClass(settingsConfig) settings.append(sc) return settings - \ No newline at end of file + + # How SettingsConfigurations work: + # - LogSettings are added to Logger in LogSettings.__init__() + # - Other settings' configs are added to SettingsManager singleton on main __init__ + # - Entities can get configurations from this singleton, e.g.: + # SettingsManager().GetFromConfigurations(CONFIG_KEY_UPDATE_INTERVAL) \ No newline at end of file diff --git a/IoTuring/Settings/SettingsManager.py b/IoTuring/Settings/SettingsManager.py index 6dc3436bc..e6ef5bd6a 100644 --- a/IoTuring/Settings/SettingsManager.py +++ b/IoTuring/Settings/SettingsManager.py @@ -1,9 +1,13 @@ +from __future__ import annotations + from IoTuring.Logger.Logger import Singleton from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject from IoTuring.Configurator.Configuration import SingleConfiguration class SettingsManager(ConfiguratorObject, metaclass=Singleton): + """Singleton for storing configurations of Settings""" + def __init__(self) -> None: self.configurations = SingleConfiguration()