Open gentlegiantJGC opened 9 months ago
@jevexendo I would like your thoughts on this when you get a chance.
So I'm not sure why we would need a fallback system. If PySide6 isn't installed, wouldn't there be bigger problems than a broken event notification system? As in, Amulet shouldn't even be able to load right?
As soon as I wrote that I realized this was for Amulet Core, not Amulet Editor.
I'll take a look at this when I get home from work later today.
The core library can be used independently of the GUI.
I think an event system is a good approach, but I'm not sure if I'm on board using PySide as an event backend since it seems a little bloated to install a whole UI package in what's supposed to be a non-UI library just for an event system. Instead it looks like this package accomplishes the same goal and we can still keep our fallback implementation just in case: https://pypi.org/project/Events/
I agree that if you aren't already using PySide6 it is unnecessary bloat but the system I wrote isn't dependent on PySide6. It is compatible with the PySide6 signal system and if enabled will directly use it. If you don't enable PySide6 support it just uses a simple callback system which looks similar to what you linked. It theoretically also allows hooking into other GUI signal systems.
Oh yep I stand corrected, I see now that the fallback is just mimicking the PySide event functions, I initially didn't catch that in my first look through and was seeing the imports in the try/catch and thought that was the default behavior. I think this is a really good way to approach it
One concern I have is that PySide signals and slots only work in QObjects when the Qt main application thread is running. With that being the case, I think it would be better if we required the user to explicitly define which version they would like to use. It would probably help avoid confusion since a custom signal/slot implementation isn't going to be a drop-in replacement for Qt's system.
It would be the job of the application using the library to request the Signal provider that is used or provide their own.
If this isn't set then it defaults to the simple callback system.
The first line in the main
function is where that is set.
If undefined in Signal.__get__
it initialises the default
Another concern I have relates to object inheritance. If the user says they want to use PySide6 Signals and Slots, all Amulet classes that emit Signals would need to inherit from QObject. I'm not particularly sure how one dynamically modifies class inheritance, but it doesn't sound like a particularly good idea.
Also, since QObject is a metaclass, any classes in Amulet that inherit from an abstract base class cannot also directly inherit from QObject or you'll get an error like:
TypeError: metaclass conflict: the metaclass of a derived class must be a (non-strict) subclass of the metaclasses of all its bases
I did manage to find a workaround for this issue on Stack Overflow, but I think that allowing the dynamically swapping between simple callbacks and signals/slots may be more annoying to implement than it's worth.
from abc import ABC, ABCMeta
from PySide6.QtCore import QObject
from PySide6.QtWidgets import QWidget
QObjectMeta = type(QObject)
QWidgetMeta = type(QWidget)
class _ABCQObjectMeta(QObjectMeta, ABCMeta):...
class _ABCQWidgetMeta(QObjectMeta, ABCMeta):...
class ABCQObject(QObject, ABC, metaclass=_ABCQObjectMeta):...
class ABCQWidget(QWidget, ABC, metaclass=_ABCQWidgetMeta):...
Now admittedly, I must just be misunderstanding something about your sample code since the PySide6 signal instance appears to be working fine despite being used within an abstract base class that doesn't inherit from QObject. Especially since it clearly thinks there's a QObject since this code exists:
if not isinstance(obj, QObject):
raise RuntimeError
What am I missing here?
MyAbstractObject
isn't an instance of QObject and it doesn't have to be.
In PySide6 Signal
isn't actually a signal it is a getter for a signal. SignalInstance
is the actual signal.
In the code above my Signal
is a custom class that doesn't touch PySide6 it just calls out to the implementation.
The PySide6 SignalInstance constructor needs a QObject to exist but the class we end up setting it on doesn't need to be an instance of QObject.
Here is the important part
def get_pyside6_signal_instance_constructor() -> SignalInstanceConstructor:
try:
from PySide6.QtCore import QObject, Signal as PySide6_Signal, SignalInstance as PySide6_SignalInstance
except ImportError as e:
raise ImportError("Could not import PySide6") from e
def pyside6_signal_instance_constructor(
*,
types: tuple[type, ...],
name: Optional[str],
arguments: Optional[str],
signal: Signal,
instance: Any,
owner: Any
) -> PySide6_SignalInstance:
if isinstance(instance, QObject):
return PySide6_Signal(*types, name=name, arguments=arguments).__get__(instance, QObject)
else:
try:
obj = instance._qobject
except AttributeError:
obj = instance._qobject = QObject()
if not isinstance(obj, QObject):
raise RuntimeError
return PySide6_Signal(*types, name=name, arguments=arguments).__get__(obj, QObject)
return pyside6_signal_instance_constructor
I would suggest looking into the descriptor protocol if you don't know what it is. It is the mechanic how properties work.
instance
is the instance of MyAbstractObject
If instance is an instance of QObject (you inherited directly from QObject) we just create a PySide6 SignalInstance
on the instance.
If it isn't an instance we need a QObject so we create a new one and set it as an attribute on instance and create a SignalInstance
on that instead.
The RuntimeError
you commented on is just in case you already defined _qobject
on the instance as something other than a QObject.
I have just spotted a bug in the code that caches the
Edit: Just fixed the above in the original post.SignalInstance
that means that all instance of MyAbstractObject
share the same SignalInstance
. I will need to fix that.
There are aspects of the library where it would be useful to get a push notification when something happens. For example when using the library with a 3D renderer it would be useful to know when a chunk changed. Currently we a system storing modification time and a thread that regularly queries the chunks but that is inefficient.
I would like to be able to directly use the PySide6 signal system and have a fallback when it is not installed.
Here is my current prototype which allows registering a signal back-end to enable hooking into other compatible signal systems.
Edit: cache the SignalInstance on the instance using the Signal as a lookup. This was previously cached on the Signal but there is one Signal instance for all instances of the class which meant they all shared the same SignalInstance. Switched storage variables to more obscure names.