Source code for masterpiece.application

"""
Author: Juha Meskanen
Date: 2024-10-26
"""

import argparse
import os
import sys
from typing import List, Type, Optional, Union

from typing_extensions import override

from .format import Format
from .argmaestro import ArgMaestro
from .plugmaster import PlugMaster
from .composite import Composite
from .masterpiece import MasterPiece
from .log import Log
from .treevisualizer import TreeVisualizer


class Application(Composite):
    """Masterpiece application class. Implements startup argument parsing,
    plugin management and initialization of class attributes through
    class specific configuration files.

    """

    plugins: List[Type[MasterPiece]] = []
    serialization_file: str = ""
    serialization_format: str = "JsonFormat"
    plugin_groups = ["masterpiece"]  # plugin group, for Python's module discovery
    color: str = "yellow"
    log_level: int = 0

    _plugmaster: Optional[PlugMaster] = None
    _argmaestro: Optional[ArgMaestro] = None
    _init: bool = False  # initialize class configuration files
    _app_id: str = "masterpiece"  # default application id
    _config = "config"  # default configuration

    def __init__(self, name: str, payload: Optional[MasterPiece] = None) -> None:
        """Instantiates and initializes. By default, the application log
        filename is set to the same as the application name.

        Args:
            name (str): The name of the application, determining the default log filename.
            payload (MasterPiece): Playload object associated with this object.
        """
        super().__init__(name, payload)

    @classmethod
    def get_plugmaster(cls) -> Optional[PlugMaster]:
        """Fetch the plugmaster object reponsible for plugin management.

        Returns:
            PlugMaster: object
        """
        return cls._plugmaster

    @classmethod
    def set_plugmaster(cls, plugmaster: PlugMaster) -> None:
        """Set the plugmaster object reponsible for plugin management.

        Args:
            plugmaster (PlugMaster): object managing plugins
        """
        cls._plugmaster = plugmaster

    @classmethod
    def get_argmaestro(cls) -> Optional[ArgMaestro]:
        """Fetch the plugmaster object reponsible for plugin management.

        Returns:
            PlugMaster: object
        """
        return cls._argmaestro

    @classmethod
    def set_argmaestro(cls, argmaestro: ArgMaestro) -> None:
        """Set the argmaestro object reponsible for plugin management.

        Args:
            argmaestro (PlugMaster): object managing plugins
        """
        cls._argmaestro = argmaestro

    @classmethod
    def get_configuration_filename(cls, name: str) -> str:
        """Generate the user specific file name of the configuration file based on the class name.

        Args:
            name (str): object managing plugins

        """
        return os.path.join(
            os.path.expanduser("~"), "." + cls._app_id, cls._config, name
        )

    @classmethod
    def save_configuration(cls) -> None:
        """Create class configuration file, if configuration is enabled and
        if the file does not exist yet. See --config startup argument.
        """
        filename: str = "undefined"
        if cls.serialization_format == "":
            cls.log_warning(f"No serialization format specified, configuration skipped")
            return
        cls.log_info(f"Saving configuration")

        format_class: Type[MasterPiece] = MasterPiece.factory()[
            cls.serialization_format
        ]
        if format_class is not None and issubclass(format_class, Format):
            file_ext: str = format_class.file_extension
            for name, ctor in MasterPiece.factory().items():
                if ctor is not None:
                    try:
                        filename = cls.get_configuration_filename(name) + file_ext
                        with open(filename, "w", encoding="utf-8") as f:
                            format = format_class(f)
                            format.save_configuration(ctor)
                            cls.log_info(f"Configuration file {filename} saved")
                    except Exception as e:
                        cls.log_error(f"Error in saving {name}:{filename} {e}")

    @classmethod
    def load_configuration(cls) -> None:
        """Load class attributes from a configuration file."""
        filename: str = "undefined"
        if cls.serialization_format == "":
            cls.log_warning(f"No serialization format specified, configuration skipped")
            return
        cls.log_info(f"Loading configuration using {cls.serialization_format}")
        cls.parse_args()
        format_class: Type[MasterPiece] = MasterPiece.factory()[
            cls.serialization_format
        ]
        if format_class is not None and issubclass(format_class, Format):
            file_ext: str = format_class.file_extension
            for name, ctor in MasterPiece.factory().items():
                if ctor is not None:
                    filename = cls.get_configuration_filename(name) + file_ext
                    try:
                        with open(filename, "r", encoding="utf-8") as f:
                            format = format_class(f)
                            format.load_configuration(ctor)
                            cls.log_info(f"Configuration file {filename} loaded")
                    except Exception as e:
                        cls.log_error(f"Error reading {filename}, {e}")

    @classmethod
    def parse_args(cls) -> None:
        """Register classes with ArgMaestro."""
        cls._argmaestro = ArgMaestro()
        for c, clazz in MasterPiece.factory().items():
            if clazz is not None:
                cls._argmaestro.add_class_arguments(clazz)
            else:
                cls.log_error(f"None entry in the class factory for {c}")
        cls._argmaestro.parse_args()

    @classmethod
    def init_app_id(cls, app_id: str = "myapp") -> None:
        """
        Initialize application id. Parses initial startup that depend on application id.
        Must be called before any classes are instanced.

        Arguments:
            -a, --app (str): Application ID.
            -c, --config (str): Configuration name, empty string for no configuration
            -i, --init (bool): Whether to create class configuration files if not already created.
        """
        Application._app_id = app_id
        parser = argparse.ArgumentParser(add_help=False)
        parser.add_argument("-a", "--app", type=str, help="Application ID")
        parser.add_argument("-c", "--config", type=str, help="Configuration")
        parser.add_argument(
            "-l",
            "--log_level",
            type=str,
            help="Log level - DEBUG, INFO,  WARNING, ERROR",
        )
        parser.add_argument(
            "-i",
            "--init",
            action="store_true",
            help="Create class configuration files",
        )
        args, remaining_argv = parser.parse_known_args()
        sys.argv = [sys.argv[0]] + remaining_argv
        MasterPiece.set_log(Log(app_id, Log.parse_level(args.log_level)))
        if args.config:
            Application._config = args.config
        if args.app:
            Application._app_id = args.app
        if args.init:
            Application._init = args.init
            cls.log_info("--init requested, creating class configuration files")
        cls.log_info(f"Configuration files in  ~/.{cls._app_id}/{cls._config}")

        if cls._init:
            cls.log_info(
                "--init specified, class configuration files will be created upon exit"
            )

    @classmethod
    def register_plugin_group(cls, name: str) -> None:
        """Registers a new plugin group within the application. Only plugins that match
        the registered groups will be loaded. By default, all 'masterpiece' plugins
        are included. Frameworks and apps built on the MasterPiece framework can define
        more group names, enabling plugins to be developed for any
        those as well.


        Args:
            name (str): The name of the plugin group to be registered
        """

        if not name in cls.plugin_groups:
            cls.plugin_groups.append(name)

    @classmethod
    def load_plugins(cls) -> None:
        """Loads and initializes all plugins for instantiation. This method
        corresponds to importing Python modules with import clauses."""
        if cls._plugmaster is None:
            cls._plugmaster = PlugMaster(cls.get_app_id())
        for g in cls.plugin_groups:
            cls._plugmaster.load(g)

    def instantiate_plugin_by_name(self, name: str) -> Union[MasterPiece, None]:
        """Installs the plugin by name, that is, instantiates the plugin class
        and inserts the instance as child to the application.
        Args:
            name (str): name of the plugin class
        """
        if self._plugmaster is None:
            return None
        return self._plugmaster.instantiate_class_by_name(self, name)

    def install_plugins(self) -> None:
        """Installs plugins into the application by invoking the `install()` method
        of each loaded plugin module.
        **Note:** This method is intended for testing and debugging purposes only.
        In a typical use case, the application should handle the instantiation of classes and
        manage their attributes as needed.
        """
        if self._plugmaster is None:
            self._plugmaster = PlugMaster(self.get_app_id())
        self._plugmaster.install(self)

    def deserialize(self) -> None:
        """Deserialize instances from the startup file specified by 'serialization_file'
        class attribute, or '--file' startup argument.
        """
        if self.serialization_file != "" and self.serialization_format != "":
            self.info(
                f"Deserializing masterpieces from {self.serialization_file} using {self.serialization_format}"
            )
            format_class: Type[MasterPiece] = MasterPiece.factory()[
                self.serialization_format
            ]
            if issubclass(format_class, Format):
                with open(self.serialization_file, "r", encoding="utf-8") as f:
                    format = format_class(f)
                    format.deserialize(self)
                    self.info(f"File {self.serialization_file} successfully read")
            else:
                raise TypeError(
                    f"{self.serialization_format} is not a subclass of Format"
                )
        else:
            self.warning(
                f"No deserialization this time, --serialization_file not specified"
            )

    def serialize(self) -> None:
        """Serialize application state to the file specified by 'serialization_file'
        class attribute'.
        """
        if self.serialization_file != "":
            self.info(
                f"Saving masterpieces to {self.serialization_file} of type {self.serialization_format}"
            )
            format_class: Type[MasterPiece] = MasterPiece.factory()[
                self.serialization_format
            ]
            if issubclass(format_class, Format):
                with open(self.serialization_file, "w", encoding="utf-8") as f:
                    format = format_class(f)
                    format.serialize(self)
                    self.info(f"File {self.serialization_file} successfully written")
            else:
                raise TypeError(
                    f"{self.serialization_format} is not a subclass of Format"
                )
        else:
            self.warning("No serialization this time, --serialization_file not set")

    @classmethod
    def get_app_id(cls) -> str:
        """Fetch the application id.  Application id determines the folder
        in which the configuration files for classes are held. Note that
        a single package can ship with more than just one executable application,
        all with the same application id.

        ..todo: Application id '_app_id' is prefixed with '_' to signal that it is a
        private attribute (python) and that should not be serialized (masterpiece).
        Isn't there something like @transient in Python? App id needs to be accessed
        outside, which is why this get_app_id() method is needed.

        Returns:
            str: application id determign application registry for class attribute serialization
        """
        return cls._app_id

    @override
    def run(self) -> None:
        if self._init:
            self.save_configuration()
        else:
            super().run()

    @override
    def run_forever(self) -> None:
        if self._init:
            self.save_configuration()
        else:
            self.load_configuration()
            super().run_forever()

    def print(self) -> None:
        """
        Print the instance hierarchy of the application using
        `TreeVisualizer`
        """
        visualizer1 = TreeVisualizer(self.color)
        visualizer1.print_tree(self)