Source code for masterpiece.masterpiece

"""
The elementary base class of everything.

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

from __future__ import annotations

import logging
from typing import Any, Callable, Dict, Optional, Type, Union


class classproperty:
    """
    Decorator addressing the deprecation of combining
    @classmethod with @property in Python. Its purpose aligns with a fundamental
    principle of object-oriented programming: any software is essentially composed
    of code and data. Within object-oriented paradigm - attributes and methods,
    which can be either class-specific or instance-specific. Given this, it is logical
    to have @property for instance-specific attributes and @classproperty for
    class-specific attributes.
    """

    def __init__(self, func):
        self.func = func

    def __get__(self, instance, owner):
        return self.func(owner)


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

    Logging
    -------

    MasterPiece classes and objects have logging methods e.g. info() and error() at their fingertips, for
    centralized logging. Class methods are prefixed with 'log_' e.g. cls.log_error(), whereas  instance methods
    are named without 'log_' prefix, e.g. self.error().

    ::

        cls.log_warning(f"Some warning {foo}", details)
        self.warning("f"Some warning {foo}", details)


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

    Instantiation via class identifiers (class name), 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.
    ::

        foo = MasterPiece.instantiate("Foo")


    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.


    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()


    Event processing
    ---------------

    All MasterPiece objects have been designed to actually do something and therefore
    provided with 'run()' method. By default the run() method just dispacthes the call
    to its payload' object, if provided. It is up to the sub classes to implement the
    method. Typically the method spawns a thread to execute something, enter event or network
    loop, etc. using the following methods:
    ::

        foo.run();
        foo.run_forwever();
        foo.shutdown() #


    Decoupling Data and Code
    -------------------------
    Instead of adding new methods (code) to MasterPiece class the MasterPiece implements
    'do()' method through which any code can be executed.
    ::
        def someCoolProcedure(...):
            ...
        rc : bool = foo.do(someProcedure, someContext)

    """

    # non-serializable private class attributes
    _log: Optional[logging.Logger] = None
    _factory: dict = {}

    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 classattrs_to_dict(cls) -> dict:
        """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: dict) -> None:
        """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)

    @classmethod
    def has_class_method_directly(cls, method_name: str) -> bool:
        """
        Check if the method is defined directly in the class (not inherited).
        """
        method = getattr(cls, method_name, None)
        # Ensure that the method is callable and check its defining class
        if callable(method):
            # Check if the method's class is the same as the one we are querying
            return method.__qualname__.split(".")[0] == cls.__name__
        return False

    @classmethod
    def factory(cls) -> Dict[str, Type[MasterPiece]]:
        """Fetch the dictionary holding class names and associated classes.

        Returns:
            factory: with class names and associated classes
        """
        return cls._factory

    @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) -> None:
        """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
                cls.log_info(f"Class {clazz.__name__} initialized")
            else:
                cls.log_info(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
        name that can be used for instantiating the class via
        :meth:`Object.instantiate` method.

        Args:
            cls (class): class

        Returns:
            id (str): 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`.

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

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

        Args:
            msg (str): The  message to be logged.
            details (str): Additional detailed information for the message to be logged
        """
        full_message = f"{cls.__name__} : {msg}"
        if details:
            full_message += f" - {details}"
        if cls._log is not None:
            cls._log.debug(full_message)
        else:
            print(full_message)

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

        Args:
            msg (str): The  message to be logged.
            details (str): Additional detailed information for the message to be logged
        """
        full_message = f"{cls.__name__} : {msg}"
        if details:
            full_message += f" - {details}"
        if cls._log is not None:
            cls._log.warning(full_message)
        else:
            print(full_message)

    @classmethod
    def log_info(cls, msg: str, details: str = "") -> None:
        """Logs the given  message to the application log.

        Args:
            msg (str): The  message to be logged.
            details (str): Additional detailed information for the message to be logged
        """
        full_message = f"{cls.__name__} : {msg}"
        if details:
            full_message += f" - {details}"
        if cls._log is not None:
            cls._log.info(full_message)
        else:
            print(full_message)

    @classmethod
    def log_error(cls, msg: str, details: str = "") -> None:
        """Logs the given  message to the application log.

        Args:
            msg (str): The  message to be logged.
            details (str): Additional detailed information for the message to be logged
        """
        full_message = f"{cls.__name__} : {msg}"
        if details:
            full_message += f" - {details}"
        if cls._log is not None:
            cls._log.error(full_message)
        else:
            print(full_message)

    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
        """
        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
        """
        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
        """
        self.log_warning(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
        """
        self.log_error(f"{self.name} : {msg}", details)

    def to_dict(self) -> dict:
        """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) -> None:
        """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 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`.

        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 and isinstance(self.payload, MasterPiece):
            self.payload.run()

    def run_forever(self) -> None:
        """Run the payload forever. This method will return only when violently
        terminated. If the object does not have playload object, or it is not
        instance of 'MasterPiece' class then returns immediately and this method has
        no effect.
        """
        if self.payload is not None and isinstance(self.payload, MasterPiece):
            try:
                self.payload.run_forever()
                print("Newtorking loop exit without exception")
            except KeyboardInterrupt:
                print("Application interrupted by user.")
            except (ValueError, IOError) as e:
                print(f"Specific error occurred: {e}")
            except Exception as e:
                print(f"An error occurred: {e}")

    def shutdown(self) -> None:
        """Shutdown the payload object. If the payload object is None, or is not instance of MasterPiece,
        then the call has no effect.
        """
        if self.payload is not None and isinstance(self.payload, MasterPiece):
            self.payload.shutdown()

    @classmethod
    def instantiate(cls, class_id: str, *args: Any) -> MasterPiece:
        """Create an instance of the class corresponding to the given class identifier.

        Args:
            class_id (str): Identifier of the class to instantiate.
            *args: Optional arguments to pass to the class constructor.

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

    @classmethod
    def find_class(cls, class_id: str) -> Union[Type[MasterPiece], None]:
        """Create an instance of the class corresponding to the given class identifier.

        Args:
            class_id (str): Identifier of the class to instantiate.
            *args: Optional arguments to pass to the class constructor.

        Returns:
            MasterPiece: An instance of the class corresponding to the given class identifier.
        """
        if class_id in cls._factory:
            return cls._factory[class_id]
        return None


# Register MasterPiece manually since __init_subclass__() won't be called on it.
MasterPiece.register()