import functools
import inspect
from typing import Optional, Dict, Any, List, Tuple
from PySide6 import QtCore
from PySide6.QtCore import QObject, Slot
from pydantic import BaseModel
from pywebchannel.Utils import Logger
[docs]
class Controller(QObject):
"""A base class for controllers that provides common functionality.
Attributes:
_controllerName: A private instance attribute that stores the name of the controller.
"""
def __init__(self, controllerName: str, parent: Optional[QObject] = None) -> None:
"""Initializes the controller with a name and an optional parent.
Args:
controllerName: A string that represents the name of the controller.
parent: An optional QObject that is the parent of the controller.
"""
super().__init__(parent)
self._controllerName = controllerName
__signalArgsMap__: Dict[str, Dict[str, type]] = dict()
__propsTypes__: Dict[str, type | Tuple[type, QtCore.Signal]] = dict()
__actionNotifications__: Dict[str, Dict[str, type]] = dict()
def __init_subclass__(cls, **kwargs):
"""A special method that is called when a subclass of Controller is created.
This method performs some operations on the class attributes to prepare them for the subclass.
Args:
**kwargs: Arbitrary keyword arguments that are passed to the method.
"""
def move_from_base_to_cls(base_attr_name: str, cls_attr_name: str):
"""A helper function that moves the items from a base class attribute to a subclass attribute.
This function is used to separate the signal arguments and prop types that belong to the subclass from the
ones that belong to the base class.
Args:
base_attr_name: A string that represents the name of the base class attribute.
cls_attr_name: A string that represents the name of the subclass attribute.
"""
result = dict()
for key, value in [*getattr(cls, base_attr_name).items()]:
if key.__contains__(cls.__name__):
result[key.removeprefix(f"{cls.__name__}.")] = value
del getattr(cls, base_attr_name)[key]
setattr(cls, cls_attr_name, result)
# Generate notifier signals
for name, signalArgs in [*cls.__actionNotifications__.items()]:
if name.__contains__(cls.__name__):
signalName = name.split(".")[1]
signal = Signal(signalArgs, cls.__name__, signalName)
setattr(cls, signalName, signal)
del cls.__actionNotifications__[name]
# Move signal arguments from base class into the concrete class
move_from_base_to_cls("__signalArgsMap__", "signalArgsMap")
# Move prop types from base class into the concrete class
move_from_base_to_cls("__propsTypes__", "propsTypes")
# Create 'Changed' signals of properties
# noinspection PyUnresolvedReferences
for propName, (propType, propChangedSignal) in cls.propsTypes.items():
setattr(cls, f"{propName}Changed", propChangedSignal)
# noinspection PyUnresolvedReferences
cls.propsTypes[propName] = propType
# Call super init_subclass
super().__init_subclass__(**kwargs)
[docs]
def name(self) -> str:
"""Returns the name of the controller.
Returns:
A string that represents the name of the controller.
"""
return self._controllerName
[docs]
def cleanup(self) -> None:
"""Performs any necessary cleanup actions before the controller is destroyed.
This method can be overridden by subclasses to implement their own cleanup logic.
"""
pass
[docs]
class Response(BaseModel):
"""A Pydantic model that represents the outcome of some operation.
"""
success: Optional[str] = None
"""A string that indicates whether the operation was successful or not. It can be None or any string value. For
example, "yes", "no", "ok", "error", etc."""
error: Optional[str] = None
"""A string that provides an error message if something went wrong during the operation. It can be None or any
string value. For example, "Invalid input", "Connection timeout", "Database error"etc."""
data: Optional[Any] = None
"""Any Python object that stores the result of the operation. It can be of any type, such as a dict, a list,
a tuple, a string, a number, etc. Pydantic will not perform any validation or conversion on this field."""
[docs]
class Helper:
[docs]
@staticmethod
def infer_caller_info(stack: List[inspect.FrameInfo]) -> Tuple[str, str]:
"""
A method that infers the name of the controller and the variable that called this method from the stack trace.
Parameters
----------
stack : List[inspect.FrameInfo]
A list of frame information objects representing the current call stack.
Returns
-------
Tuple[str, str]
A tuple of two strings: the name of the controller and the name of the variable that called this method.
If the variable name cannot be inferred, an empty string is returned as the second element of the tuple.
"""
# The name of the controller is the name of the function in the previous frame
controllerName: str = stack[1][3]
# The line of code that defines the variable in the previous frame
definition_split = stack[1][4][0].strip(" \n").replace(" ", "").split("=")
# If the line of code has an assignment operator
if len(definition_split) >= 2:
# The name of the variable is the left-hand side of the assignment
varName = definition_split[0]
else:
# Otherwise, the variable name cannot be inferred
varName = ""
# Return the tuple of controller name and variable name
return controllerName, varName
[docs]
class Type:
"""
This class provides some utility methods to check the type of variable.
Methods:
is_primitive(var_type: type) -> bool:
Returns True if the given type is a primitive type, False otherwise.
is_list(var_type: type) -> bool:
Returns True if the given type is a list type, False otherwise.
is_pydantic(var_type: type) -> bool:
Returns True if the given type is a subclass of pydantic.BaseModel, False otherwise.
"""
primitives = (bool, str, int, float, type(None))
"""
A tuple of primitive types in Python, such as bool, str, int, float, and NoneType.
"""
[docs]
@staticmethod
def is_primitive(var_type: type):
return var_type in Type.primitives
[docs]
@staticmethod
def is_list(var_type: type):
return var_type.__name__.lower() == "list"
[docs]
@staticmethod
def is_pydantic(var_type: type):
return issubclass(var_type, BaseModel)
[docs]
class Convert:
"""
This class provides some utility methods to convert data types between Python, Qt, and web formats.
"""
[docs]
@staticmethod
def from_py_to_qt(argDict: Dict[str, type]) -> Tuple[List[str], List[type]]:
"""Converts a dictionary of argument names and types from Python to Qt format.
- Primitive types are kept as they are.
- List types are converted to list type.
- Pydantic types are converted to dict type.
- Other types are converted to dict type.
Returns:
Tuple[List[str], List[type]] - argument names and argument types.
"""
arg_names = []
arg_types = []
for arg_name, arg_type in argDict.items():
if arg_name == "return":
continue
arg_names.append(arg_name)
if Type.is_primitive(arg_type):
arg_types.append(arg_type)
elif Type.is_list(arg_type):
arg_types.append(list)
elif Type.is_pydantic(arg_type):
arg_types.append(dict)
else:
arg_types.append(dict)
return arg_names, arg_types
[docs]
@staticmethod
def from_web_to_py(arg, paramType) -> Any:
"""Converts a web format argument to a Python format argument according to the given parameter type.
Returns:
- Primitive types are kept as they are.
- List types are recursively converted using the inner type.
- Pydantic types are instantiated using the argument as a keyword dictionary.
- Other types are kept as they are.
"""
if Type.is_primitive(paramType):
return arg
elif Type.is_list(paramType):
innerType = paramType.__args__[0]
for i in range(len(arg)):
arg[i] = Convert.from_web_to_py(arg[i], innerType)
return arg
elif Type.is_pydantic(paramType):
return paramType(**arg)
else:
return arg
[docs]
@staticmethod
def from_py_to_web(arg) -> Any:
"""Converts a Python format argument to a web format argument.
Returns:
- Primitive types are kept as they are.
- List types are recursively converted using the inner type.
- Pydantic types are converted to a dictionary using the dict() method.
- Other types are converted to a dictionary using the dict() method.
"""
if Type.is_primitive(type(arg)):
return arg
if Type.is_list(type(arg)):
for i in range(len(arg)):
arg[i] = Convert.from_py_to_web(arg[i])
return arg
if Type.is_pydantic(type(arg)):
return arg.model_dump()
return arg
[docs]
@staticmethod
def from_py_to_web_response(result) -> Dict[str, Any]:
"""Converts a Python format result to a web format response.
- String types are wrapped in a Response object with success attribute.
- Response types are converted to a dictionary using the dict() method.
- Other types are wrapped in a Response object with data attribute.
Returns:
Dict[str, Any] - a dictionary that represents the response.
"""
if isinstance(result, str):
return Response(success=result).model_dump()
if isinstance(result, Response):
return result.model_dump()
return Response(data=result).model_dump()
[docs]
class EmitBy:
"""A class to represent the source of a notification."""
Auto = 0
User = 1
[docs]
class Notify:
"""A class to represent a notification object.
Attributes:
name (str): The name of the notification.
arguments (Dict[str, type]): A dictionary of the arguments that the notification expects, with the argument
name as the key and the argument type as the value.
emitBy (EmitBy): The source of the notification, either EmitBy.Auto or EmitBy.User.
The default value is EmitBy.Auto.
"""
def __init__(
self,
arguments: Dict[str, type] | List[type],
name: str = None,
emitBy: EmitBy = EmitBy.Auto,
):
"""
The constructor method for the Notify class.
Args:
name (str): The name of the notification.
arguments (Dict[str, type] or List[type]): A dictionary of the arguments that the notification expects,
with the argument name as the key and the argument type as the value.
emitBy (EmitBy, optional): The source of the notification, either EmitBy.Auto or EmitBy.User.
The default value is EmitBy.Auto.
"""
self.name = name
self.arguments = arguments
self.emitBy = emitBy
[docs]
def Action(notify: Notify = None):
"""
A decorator that converts a Python function into a Qt slot. The notify argument is used to emit after the function
is executed. Defaults to None. If it is specified, a signal with the given name will be created and attached
into the class. You don't need to create that signal yourself. The signal will be emitted with the result of the
function. EmitBy is used to specify the source of the notification. If it is set to EmitBy.Auto, the notification
will be emitted automatically after the function is executed. If it is set to EmitBy.User, the notification will
be emitted only if the function explicitly emits it.
Args:
notify (Notify, optional): A Notify object that specifies the name and arguments of a notification signal
Returns:
A wrapper function that is a Qt slot with the same arguments and return type as the original function.
The slot also handles serialization and deserialization of inputs and outputs, exception handling,and optionally
emits a notification signal with the result.
References:
https://doc.qt.io/qtforpython-6/tutorials/basictutorial/signals_and_slots.html
See Also:
Signal, Property
"""
# The decorator function takes the function to be wrapped as an argument
def ActionWrapper(func):
# Get the annotations of the function, which are the types of the arguments and return value
annots: dict = func.__annotations__
# Convert the Python types to Qt types
arg_names, arg_types = Convert.from_py_to_qt(annots)
# Define the slot with the Qt types and the result type as a dict
@Slot(*arg_types, result=dict)
# Preserve the name and docstring of the original function
@functools.wraps(func)
def wrapper(*args):
try:
# Deserialize inputs
params = [*args]
for i, paramName in enumerate(annots):
paramType = annots[paramName]
# Convert the input from web format to Python format
params[i + 1] = Convert.from_web_to_py(params[i + 1], paramType)
# Call the original function
result = func(*params)
# If a notification signal is specified
if notify is not None and notify.emitBy == EmitBy.Auto:
# Convert the result from Python format to web format
result = Convert.from_py_to_web(result)
# Get the signal object from the first argument, which is the controller
signal = getattr(params[0], notify.name, None)
# If the signal object exists, emit it with the result
if signal is not None:
signal.emit(result)
# Serialize response
# Convert the result from Python format to web response format
return Convert.from_py_to_web_response(result)
# Handle any exceptions
except Exception as e:
Logger.error(str(e))
# Return a response with the error message
return Response(error=str(e)).model_dump()
# If a notification signal is specified
if notify is not None:
# Get the name of the controller that defines the function
controllerName, _ = Helper.infer_caller_info(inspect.stack())
if notify.name is None:
funcName = func.__name__
# Convert first letter to upper case
notify.name = "on" + str(funcName[0]).upper() + funcName[1:]
# Store the notification information in a class attribute of Controller
Controller.__actionNotifications__[
f"{controllerName}.{notify.name}"
] = notify.arguments
return wrapper
return ActionWrapper
[docs]
def Signal(
args: Dict[str, type] | List[type],
controllerName: str = None,
signalName: str = None,
) -> QtCore.Signal:
"""
A function that creates a Qt signal with the given arguments by making necessary type conversions to keep Qt
and serialization process happy.
Args:
args (Dict[str, type] or List[type]): A dictionary that maps the names and types of the signal arguments.
controllerName (str, optional): The name of the controller that defines the signal. Defaults to None.
signalName (str, optional): The name of the signal. Defaults to None.
Returns:
A QtCore.Signal object with the specified arguments, name, and arguments names.
Raises:
Exception: If the controller name or signal name cannot be inferred from the caller information,
or if the signal name is empty.
References:
https://doc.qt.io/qtforpython-6/PySide6/QtCore/Signal.html
See Also:
Property, Action
"""
# Convert type list into map
argsMap: Dict[str, type] = dict()
if isinstance(args, list):
for i in range(len(args)):
argsMap[f"arg{i + 1}"] = args[i]
args = argsMap
# Rearrange variable types so that Qt Signal will be happy
arg_names, arg_types = Convert.from_py_to_qt(args)
# Register signal arguments for proper type-script generation
# If the controller name or signal name are not given, infer them from the caller information
if controllerName is None or signalName is None:
controllerName, signalName = Helper.infer_caller_info(inspect.stack())
# If the signal name is empty, raise an exception
if signalName == "":
raise Exception(
f"Signal definition at {controllerName} is missing output bound variable"
)
# Store the signal arguments in a class attribute of Controller
Controller.__signalArgsMap__[f"{controllerName}.{signalName}"] = args
# Create a new signal with the Qt types, name, and arguments names
signal = QtCore.Signal(*arg_types, name=signalName, arguments=arg_names)
# Return the signal object
return signal
[docs]
def Property(p_type: type, init_val=None, get_f=None, set_f=None) -> QtCore.Property:
"""
A function that creates a Qt property and a corresponding signal. The function is responsible for creating
the backend variable, getter and setter functions, and the signal object related with the property.
Args:
p_type (type): The type of the property value.
init_val: The initial value of the property. Defaults to None
get_f (function, optional): A custom getter function for the property. Defaults to None.
set_f (function, optional): A custom setter function for the property. Defaults to None.
Returns:
The prop which is a QtCore.Property object.
Raises:
Exception: If the property name cannot be inferred from the caller information
References:
https://doc.qt.io/qtforpython-6/PySide6/QtCore/Property.html
See Also:
Signal, Action
"""
# Get call stack and infer:
controllerName, propName = Helper.infer_caller_info(inspect.stack())
# If the variable names are empty, raise an exception
if propName == "":
raise Exception(
f"Property definition at {controllerName} is missing output bound variable 'property'"
)
signalName = f"{propName}Changed"
# Define default get & set behavior
# Define a getter function that returns the property value
def getter(self):
# If the property value is not set, set it to the initial value
if not hasattr(self, f"_{propName}"):
setattr(self, f"_{propName}", init_val)
# Return the property value
return getattr(self, f"_{propName}")
# Define a setter function that sets the property value
def setter(self, new_value):
# If the new value is different from the current value
if getattr(self, f"_{propName}") != new_value:
# Set the property value to the new value
setattr(self, f"_{propName}", new_value)
# Get the signal object from the self attribute
s = getattr(self, f"{signalName}", None)
# If the signal object exists, emit it with the new value
if s is not None:
s.emit(new_value)
# Rearrange variable types so that Qt Property will be happy
# Convert the Python type to a Qt type
_, prop_type_adjusted = Convert.from_py_to_qt({propName: p_type})
# Call the Signal function with the property name and type, and the controller name and signal name
signal = Signal({propName: p_type}, controllerName, signalName)
# Create property
# Create a Qt property object with the Qt type, the getter and setter functions, and the signal
# noinspection PyTypeChecker
prop = QtCore.Property(
prop_type_adjusted[0],
# Use the custom getter function if given, otherwise use the default one
fget=get_f if get_f is not None else getter,
# Use the custom setter function if given, otherwise use the default one
fset=set_f if set_f is not None else setter,
notify=signal,
)
# Store the property type and relevant changed signal in a class attribute of Controller
Controller.__propsTypes__[f"{controllerName}.{propName}"] = (p_type, signal)
# Return the property and signal objects
return prop