Source code for masterpiece.core.masterpiece

"""
    masterpiece.py

    This module defines the foundational classes for the framework. These elementary
    base classes form the core upon which all other classes in the framework are built.

    Classes:
        MasterPiece: A base class representing a fundamental component in the framework.
        These objects can be copied, serialized, instantiated through class 
        id (factory method pattern).

        Args: Base class for per-class startup arguments

    The `MasterPiece` class can be described as a named object with a payload and parent object.
    In other words, the object can be linked to any other 'MasterPiece' object as a children,
    and it can carry any other object with it. It is up to the sub classes define the payload
    objects.

    Example:
        from masterpiece import MasterPiece

        obj = MasterPiece(name="livingroom", payload = TemperatureSensor(t))

    Note:
        Ensure `from __future__ import annotations` is included at the top of this module
        to avoid issues with forward references in type hints.

"""

from __future__ import annotations
import os
import sys
import json
import logging
from typing import Any, Callable, Dict, Optional
import atexit
import argparse


class MasterPiece:
    """An object with a name. Base class of everything. Serves as the
    foundational base class for any real-world object.

    Logging
    -------

    All objects have logging methods e.g. info() and error() at their fingertips, for
    centralized logging.
    ::

        if (err := self.do_good()) < 0:
            self.error(f"Damn, did bad {err}")

    Factory Method Pattern
    ----------------------

    Instantiation via class identifiers, adhering to the factory method pattern.
    This allows for the dynamic creation of instances based on class identifiers,
    promoting decoupled and extensible design required by plugin architecture.
    ::

        # fixed implementation
        car = Ferrari()

        # decoupled implementation from the interface
        car = Object.instantiate(car_class_id)


    Serialization
    -------------

    Serialization of both class and instance attributes serves as a means of configuration.

    Class attributes should follow a consistent naming convention where an underscore prefix
    ('_' or '__') implies the attribute is private and transient, meaning it is not serialized.
    Class attributes without an underscore prefix are initialized from configuration files named
    '~/.masterpiece/[appname]/[classname].json', if present. If the class-specific configuration
    files do not already exist, they are automatically created upon the first run.


    Instance attributes can be serialized and deserialized using the `serialize()`
    and `deserialize()` methods:
    ::

        # serialize to json file
        with open("foo.json", "w") as f:
            foo.serialize(f)

        # deserialize
        foo = F()
        with open("foo.json", "r") as f:
            foo.deserialize(f)

    Deserialization must restore the object's state to what it was when it was serialized.
    As Python does not have 'transient' keyword to tag attributes that should be serialized, all
    classes must explicitely describe information for the serialization. This is done with
    `to_dict()` and `from_dict()` methods:
    ::

        def to_dict(self):
            data = super().to_dict()
            data["_foo"] = {
                "topic": self.topic,
                "temperature": self.temperature,
            }
            return data

        def from_dict(self, data):
            super().from_dict(data)
            for key, value in data["_foo"].items():
                setattr(self, key, value)


    Copying Objects
    ---------------

    Any object can be copied using the `copy()` method. This feature is based on serialization, so
    typically, subclasses don't need to implement the `copy()` method; everything is taken care of
    by the base class.
    ::

        foo2 = foo.copy()

    """

    # non-serializable private class attributes
    _app_id = "masterpiece"
    _config: str = "config"
    _log: Optional[logging.Logger] = None
    _factory: dict = {}
    _init: bool = False

    def __init_subclass__(cls, **kwargs: dict[str, Any]) -> None:
        """Called when a new sub-class is created.

        Automatically registers the sub class by calling its register()
        method. For more information on this method consult Python
        documentation.
        """
        super().__init_subclass__(**kwargs)
        cls.register()

    @classmethod
    def register(cls) -> None:
        """Register the class.

        Called immediately upon class initialization, right before the class attributes
        are loaded from the class specific configuration files.

        Subclasses can extend this with custom register functionality:

        .. code-block:: python

            class MyMasterPiece(MasterPiece):

                @classmethod
                def register(cls):
                    super().register()  # Don't forget
                    cls._custom_field = True
        """
        cls.init_class(cls)

    @classmethod
    def init_class(cls, clazz):
        """Initialize class.  Updates the class factory and sets up exit hook
        to create class configuration file on program exit.

        Args:
            clazz (class): class to be initialized
        """
        if clazz.__name__ not in cls._factory:
            cls._factory[clazz.__name__] = None
            if not clazz.is_abstract():
                cls._factory[clazz.__name__] = clazz
                if cls._init:
                    atexit.register(clazz.save_to_json)
                print(f"Class {clazz.__name__} initialized")
            else:
                print(f"abstract {clazz.__name__} initialized")

    @classmethod
    def is_abstract(cls) -> bool:
        """Check whether the class is abstract or real. Override in the derived
        sub-classes. The default is False.

        Returns:
            True (bool) if abstract
        """
        return False

    @classmethod
    def set_log(cls, l: logging.Logger) -> None:
        """Set logger.

        Args:
            l (logger): logger object
        """

        cls._log = l

    @classmethod
    def get_class_id(cls) -> str:
        """Return the class id of the class. Each class has an unique
        identifier that can be used for instantiating the class via
        :meth:`Object.instantiate` method.

        Args:
            cls (class): class

        Returns:
            id (int) unique class identifier through which the class can be
            instantiated by factory method pattern.
        """
        return cls.__name__

    def __init__(
        self, name: str = "noname", payload: Optional[MasterPiece] = None
    ) -> None:
        """Creates object with the given name and  payload. The payload object
        must be of type  `MasterPiece` as well.

        Example:
            ```python
            obj = MasterPiece('foo', Foo("downstairs"))
            obj.info('Yippee, object created')
            ```
        """
        self.name = name
        self.payload = payload

    @classmethod
    def init_app_id(cls, app_id: str = "myapp") -> None:
        """
        Initialize application id. Parses initial startup that depend on application id

        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.
        """
        MasterPiece._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(
            "-i",
            "--init",
            action="store_true",
            help="Create class configuration files if not already created",
        )
        args, remaining_argv = parser.parse_known_args()
        sys.argv = [sys.argv[0]] + remaining_argv

        if args.config:
            MasterPiece._config = args.config
        if args.app:
            MasterPiece._app_id = args.app
        if args.init:
            MasterPiece._init = args.init
            print("Start with init=True, creating class configuration files at exit")
        print(
            f"Initializing classes {cls._app_id} configuration ~/.{cls._app_id}/{cls._config}"
        )
        for c, clazz in cls._factory.items():
            clazz.load_from_json()

    def debug(self, msg: str, details: str = "") -> None:
        """Logs the given debug message to the application log.

        Args:
            msg (str): The information message to be logged.
            details (str): Additional detailed information for the message to be logged
        """
        if self._log is not None:
            self._log.debug(f"{self.name} : {msg} - {details}")

    def info(self, msg: str, details: str = "") -> None:
        """Logs the given information message to the application log.

        Args:
            msg (str): The information message to be logged.
            details (str): Additional detailed information for the message to be logged
        """
        if self._log is not None:
            self._log.info(f"{self.name} : {msg} - {details}")

    def warning(self, msg: str, details: str = "") -> None:
        """Logs the given warning message to the application log.

        Args:
            msg (str): The message to be logged.
            details (str): Additional detailed information for the message to be logged
        """
        if self._log is not None:
            self._log.warn(f"{self.name} : {msg} - {details}")

    def error(self, msg: str, details: str = "") -> None:
        """Logs the given error message to the application log.

        Args:
            msg (str): The message to be logged.
            details (str): Additional detailed information for the message to be logged
        """
        if self._log is not None:
            self._log.error(f"{self.name} : {msg} - {details}")

    @classmethod
    def get_json_file(cls):
        """Generate the JSON file name based on the class name.

        The file is created into users home folder.
        """
        return os.path.join(
            os.path.expanduser("~"),
            "." + cls._app_id,
            cls._config,
            cls.__name__ + ".json",
        )

    def to_dict(self):
        """Convert instance attributes to a dictionary."""

        return {
            "_class": self.get_class_id(),  # the real class
            "_version:": 0,
            "_object": {
                "name": self.name,
                "payload": (
                    self.payload.to_dict() if self.payload is not None else None
                ),
            },
        }

    def from_dict(self, data):
        """Update instance attributes from a dictionary."""

        if self.get_class_id() != data["_class"]:
            raise ValueError(
                f"Class mismatch, expected:{self.get_class_id()}, actual:{data['_class']}"
            )
        for key, value in data["_object"].items():
            if key == "payload":
                if value is not None:
                    self.payload = MasterPiece.instantiate(value["_class"])
                    self.payload.from_dict(value)
                else:
                    self.payload = None
            else:
                setattr(self, key, value)

    def serialize_to_json(self, f):
        """Serialize the object to given JSON file"""
        json.dump(self.to_dict(), f, indent=4)

    def deserialize_from_json(self, f):
        """Load  attributes from the given JSON file."""
        attributes = json.load(f)
        self.from_dict(attributes)

    def copy(self) -> MasterPiece:
        """Create and return a copy of the current object.

        This method serializes the current object to a dictionary using the `to_dict` method,
        creates a new instance of the object's class, and populates it with the serialized data
        using the `from_dict` method.

        This method uses class identifier based instantiation (see factory method pattern) to
        create a new instance of the object, and 'to_dict' and 'from_dict'  methods to initialize
        object's state.

        Returns:
            A new instance of the object's class with the same state as the original object.

        Example:
        ::

            clone_of_john = john.copy()
        """

        data = self.to_dict()
        copy_of_self = MasterPiece.instantiate(self.get_class_id())
        copy_of_self.from_dict(data)
        return copy_of_self

    def do(
        self,
        action: Callable[["MasterPiece", Dict[str, Any]], bool],
        context: Dict[str, Any],
    ) -> bool:
        """
        Execute the given action to the object, by calling the provided `action` on each node.

        Args:
            action(Callable[["MasterPiece", Dict[str, Any]], bool]): A callable that takes (node, context) and returns a boolean.
            context (Dict[str, Any]) Any context data that the action may use.

        Returns:
            The return value from the executed action.
        """

        return action(self, context)

    def run(self) -> None:
        """Run the masterpiece.  Dispatches the call to `payload` object and
        returns  the control to the caller.
        """
        if self.payload is not None:
            self.payload.run()

    def run_forever(self) -> None:
        """Run the masterpiece forever. This method will return only when violently
        terminated.
        """
        if self.payload is not None:
            try:
                self.payload.run_forever()
                print("Newtorking loop exit without exception")
            except BaseException as e:
                print(f"Networking loop terminated with exception {e}")

    def shutdown(self) -> None:
        """Shutdown the masterpiece. It is up to the sub classes to implement this method.
        Dispatches the call to `payload` object.
        """
        if self.payload is not None:
            self.payload.shutdown()

    @classmethod
    def classattrs_to_dict(cls):
        """Convert class attributes to a dictionary."""
        return {
            attr: getattr(cls, attr)
            for attr in cls.__dict__
            if not callable(getattr(cls, attr))
            and not attr.startswith("__")
            and not attr.startswith(("_"))
        }

    @classmethod
    def classattrs_from_dict(cls, attributes):
        """Set class attributes from a dictionary."""
        for key, value in attributes.items():
            if key not in cls.__dict__:
                continue  # Skip attributes that are not in the class's own __dict__
            setattr(cls, key, value)
            print(f"Setting {key} to {value}")

    @classmethod
    def save_to_json(cls):
        """Create class configuration file, if configuration is enabled and
        if the file does not exist yet. See --config startup argument.
        """
        if len(cls._config) > 0:
            filename = cls.get_json_file()
            if not os.path.exists(filename):
                with open(cls.get_json_file(), "w", encoding="utf-8") as f:
                    json.dump(cls.classattrs_to_dict(), f)
                    if cls._log is not None:
                        cls._log.info(f"Configuration file {filename} created")

    @classmethod
    def load_from_json(cls):
        """Load class attributes from a JSON file."""
        try:
            filename = cls.get_json_file()
            if cls._log is not None:
                cls._log.info(f"Loading configuration file {filename}")
            else:
                print(f"Loading class attributes from {filename}")
            with open(filename, "r", encoding="utf-8") as f:
                attributes = json.load(f)
                cls.classattrs_from_dict(attributes)
        except FileNotFoundError:
            if cls._log is not None:
                cls._log.info(f"No configuration file {filename} found")

    @classmethod
    def get_registered_classes(cls) -> dict:
        """Get the dictionary holding the registered class identifiers and
        the corresponding classes.

        Returns:
            dict: dictionary of class identifier - class pairs
        """
        return cls._factory

    @classmethod
    def instantiate(cls, class_id: str) -> MasterPiece:
        """Create an instance of the class corresponding to the given class identifier.
        This method implements the factory method pattern, which is essential for a
        plugin architecture.

        Args:
            class_id (int): Identifier of the class to instantiate.

        Returns:
            obj: An instance of the class corresponding to the given class identifier.
        """
        if class_id in cls._factory:
            return cls._factory[class_id]()
        else:
            raise ValueError(f"Attempting to instantiate unregistered class {class_id}")

    @classmethod
    def find_class(cls, class_id: str) -> object:
        """Given class identifier find the registered class. If no class with
        the give identifier exists return None.

        Args:
            class_id (int): class identifier

        Returns:
            obj (obj): class or null if not registered
        """
        if class_id in cls._factory:
            return cls._factory[class_id]
        else:
            return None

    @classmethod
    def instantiate_with_param(cls, class_id: str, param: Any) -> MasterPiece:
        """Given class identifier and one constructor argument create the
        corresponding object.

        Args:
            class_id : class identifier
            param : class specific constructor parameter

        Returns:
            obj : instance of the given class.
        """
        return cls._factory[class_id](param)

    @classmethod
    def has_class_method_directly(cls, method_name: str) -> bool:
        """
        Check if the method is in the class's own dictionary
        """
        if method_name in cls.__dict__:
            method = cls.__dict__[method_name]
            # Check if it's a method and if it's a class method
            if isinstance(method, classmethod):
                return True
        return False


# Register MasterPiece manually since __init_subclass__() won't be called on it.
print("Registering and initializing masterpiece")
MasterPiece.register()