#!/usr/bin/env python
#
# syncable.py - An extension to the HasProperties class which allows
# a master-slave relationship to exist between instances.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the :class:`SyncableHasProperties` class, an extension
to the :class:`.HasProperties` class which allows a parent-child relationship
to exist between instances. A one-to-many synchronisation relationship is
possible between one parent, and many children. Property values are
synchronised between a parent and its children, using the functionality
provided by the :mod:`bindable` module.
All that is needed to make use of this functionality is to extend the
``SyncableHasProperties`` class instead of the ``HasProperties`` class::
>>> import fsleyes_props as props
>>> class MyObj(props.SyncableHasProperties):
myint = props.Int()
def __init__(self, parent=None):
props.SyncableHasProperties.__init__(self, parent=parent)
Given a class definition such as the above, a parent-child relationship
between two instances can be set up as follows::
>>> myParent = MyObj()
>>> myChild = MyObj(myParent)
The ``myint`` properties of both instances are now bound to each other - when
it changes in one instance, that change is propagated to the other instance::
>>> def parentPropChanged(*a):
print('myParent.myint changed: {}'.format(myParent.myint))
>>>
>>> def childPropChanged(*a):
print('myChild.myint changed: {}'.format(myChild.myint))
>>> myParent.addListener('myint', 'parentPropChanged', parentPropChanged)
>>> myChild.addListener( 'myint', 'childPropChanged', childPropChanged)
>>> myParent.myint = 12345
myParent.myint changed: 12345
myChild.myint changed: 12345
>>> myChild.myint = 54321
myParent.myint changed: 54321
myChild.myint changed: 54321
This synchronisation can be toggled on the child instance, via the
:meth:`unsyncFromParent` and :meth:`syncToParent` methods of the
:class:`SyncableHasProperties` class. Listeners to sync state changes may
be registered on the child instance via the :meth:`addSyncChangeListener`
method (and de-registered via the :meth:`removeSyncChangeListener` method).
"""
import weakref
import logging
from . import properties as props
from . import properties_types as types
log = logging.getLogger(__name__)
_SYNC_SALT_ = '_sync_'
"""Constant string added to sync-related property names and listeners."""
[docs]
class SyncError(Exception):
"""Exception type raised when an illegal attempt is made to synchronise
or unsynchronise a property. See the ``nobind`` and ``nounbind`` parameters
to :meth:`SyncableHasProperties.__init__`.
"""
pass
[docs]
class SyncableHasProperties(props.HasProperties):
"""An extension to the ``HasProperties`` class which supports parent-child
relationships between instances.
"""
@classmethod
def __saltSyncPropertyName(cls, propName):
"""Adds a prefix to the given property name, to be used as the
name for the corresponding boolean sync property.
"""
return '{}{}'.format(_SYNC_SALT_, propName)
@classmethod
def __unsaltSyncPropertyName(cls, propName):
"""Removes a prefix from the given property name, which was
added by the :meth:`_saltSyncPropertyName` method.
"""
return propName[len(_SYNC_SALT_):]
[docs]
@classmethod
def getSyncPropertyName(cls, propName):
"""Returns the name of the boolean property which can be used to
toggle binding of the given property to the parent property of
this instance.
"""
return cls.__saltSyncPropertyName(propName)
[docs]
@classmethod
def getSyncProperty(cls, propName):
"""Returns the :class:`.PropertyBase` instance of the boolean property
which can be used to toggle binding of the given property to the parent
property of this instance.
"""
return cls.getProp(cls.getSyncPropertyName(propName))
def __init__(self, **kwargs):
"""Create a ``SyncableHasProperties`` instance.
If this ``SyncableHasProperties`` instance does not have a parent,
there is no need to call this constructor explicitly. Otherwise, the
parent must be an instance of the same class to which this instance's
properties should be bound.
:arg parent: Another ``SyncableHasProperties`` instance, which has
the same type as this instance.
:arg nobind: A sequence of property names which should not be bound
with the parent.
:arg nounbind: A sequence of property names which cannot be unbound
from the parent.
:arg state: Initial synchronised state. Can be either ``True`` or
``False``, in which case all properties will initially
be either synced or unsynced. Or can be a dictionary
of ``{propName : boolean}`` mappings, defining the sync
state for each property.
:arg direction: Initial binding direction. Not applicable if this
instance does not have a parent. If ``True``, when a
property is bound to the parent, this instance will
inherit the parent's value. Otherwise, when a property
is bound, the parent will inherit this instance's
value.
:arg kwargs: All other arguments are passed to the
:meth:`.HasProperties.__init__` method.
"""
parent = kwargs.pop('parent', None)
nobind = kwargs.pop('nobind', [])
nounbind = kwargs.pop('nounbind', [])
state = kwargs.pop('state', True)
direction = kwargs.pop('direction', True)
props.HasProperties.__init__(self, **kwargs)
self.__nobind = list(set(nobind))
self.__nounbind = list(set(nounbind))
# If parent is none, then this instance
# is a 'parent' instance, and doesn't need
# to worry about being bound. So we've got
# nothing to do.
if parent is None:
# This array maintains a list of
# all the children synced to this
# parent
self.__children = []
self.__parent = None
return
# Otherwise, this instance is a 'child'
# instance - make sure the parent is
# valid (of the same type)
if not isinstance(parent, self.__class__):
raise TypeError('parent is of a different type '
'({} != {})'.format(parent.__class__,
self.__class__))
# Set up a binding between this
# instance and its parent
self.__parent = weakref.ref(parent)
parent.__children.append(weakref.ref(self))
# This dictionary contains
#
# { propName : boolean }
#
# mappings, indicating the binding
# direction that should be used
# when a property is synchronised
# to the parent. A value of True
# implies a parent -> child binding
# direction (i.e. the child will
# inherit the value of the parent),
# and a value of False implies a
# child -> parent binding direction.
self.__bindDirections = {}
log.debug('Binding properties of {} ({}) to parent ({})'.format(
self.__class__.__name__, id(self), id(parent)))
# Get a list of all the
# properties of this class
propNames, _ = self.getAllProperties()
for pn in propNames:
# Add a boolean sync property
# for this regular property.
bindProp = types.Boolean(default=True)
saltpn = self.__saltSyncPropertyName(pn)
# the sync property may have already
# been added by a previous instance,
# so only add it if needed. See the
# HP.addProperty method.
if not hasattr(self, saltpn):
self.addProperty(saltpn, bindProp)
# Initialise the binding direction
# and initial state
self.__bindDirections[pn] = direction
if isinstance(state, dict): pState = state.get(pn, True)
else: pState = state
if not self.canBeSyncedToParent( pn): pState = False
elif not self.canBeUnsyncedFromParent(pn): pState = True
self.__initSyncProperty(pn, pState)
[docs]
def getParent(self):
"""Returns the parent of this instance, or ``None`` if there is no
parent.
On child ``SyncableHasProperties`` instances, this method must not
be called before :meth:`__init__` has been called. If this happens,
an :exc:`AttributeError` will be raised.
"""
if self.__parent is None: return None
else: return self.__parent()
[docs]
def getChildren(self):
"""Returns a list of all children that are synced to this parent
instance, or ``None`` if this instance is not a parent.
"""
if self.__parent is not None:
return None
children = [c() for c in self.__children]
children = [c for c in children if c is not None]
return children
def __saltSyncListenerName(self, propName):
"""Adds a prefix and a suffix to the given property name, to be used
as the name for an internal listener on the corresponding boolean sync
property.
"""
return '{}{}_{}'.format(_SYNC_SALT_, propName, id(self))
def __initSyncProperty(self, propName, initState):
"""Called by child instances from :meth:`__init__`.
Configures a binding between this instance and its parent for the
specified property.
"""
bindPropName = self.__saltSyncPropertyName(propName)
bindPropObj = self.getProp(bindPropName)
bindPropVal = bindPropObj.getPropVal(self)
direction = self.__bindDirections[propName]
if initState and not self.canBeSyncedToParent(propName):
raise ValueError('Invalid initial state for '
'nobind property {}'.format(propName))
if (not initState) and (not self.canBeUnsyncedFromParent(propName)):
raise ValueError('Invalid initial state for '
'nounbindproperty {}'.format(propName))
if not self.canBeSyncedToParent(propName):
bindPropVal.set(False)
return
bindPropVal.set(initState)
if self.canBeUnsyncedFromParent(propName):
bindPropVal.addListener(self.__saltSyncListenerName(propName),
self.__syncPropChanged,
immediate=True)
if initState:
if direction: slave, master = self, self.__parent()
else: slave, master = self.__parent(), self
slave.bindProps(propName, master)
def __syncPropChanged(self, value, valid, ctx, bindPropName):
"""Called when a hidden boolean property controlling the sync state of
the specified real property changes. Calls :meth:`__changeSyncState`
"""
propName = self.__unsaltSyncPropertyName(bindPropName)
bindPropVal = getattr(self, bindPropName)
log.debug('Sync property changed for {} - '
'changing binding state'.format(propName))
self.__changeSyncState(propName, bindPropVal)
def __changeSyncState(self, propName, sync):
"""Changes the sync state of ``propName``to ``sync``. """
bindPropName = self.__saltSyncPropertyName(propName)
bindPropVal = getattr(self, bindPropName)
direction = self.__bindDirections[propName]
if bindPropVal and (propName in self.__nobind):
raise SyncError('{} cannot be bound to '
'parent'.format(propName))
if (not bindPropVal) and (propName in self.__nounbind):
raise SyncError('{} cannot be unbound from '
'parent'.format(propName))
parent = self.__parent()
# parent may have already been GC'd
if parent is not None:
if direction: slave, master = self, parent
else: slave, master = parent, self
slave.bindProps(propName, master, unbind=(not bindPropVal))
[docs]
def getBindingDirection(self, propName):
"""Returns the current binding direction for the given property. See
the :meth:`setBindingDirection` method.
"""
return self.__bindDirections[propName]
[docs]
def setBindingDirection(self, direction, propName=None):
"""Set the current binding direction for the named property. If the
direction is ``True``, when this property is bound, this instance
will inherit the parent's value. Otherwise, when this property
is bound, the parent will inherit the value from this instance.
If a property is not specified, the binding direction of all
properties will be changed.
"""
if propName is None: propNames = self.__bindDirections.keys()
else: propNames = [propName]
for pn in propNames:
self.__bindDirections[pn] = direction
[docs]
def syncToParent(self, propName):
"""Synchronise the given property with the parent instance.
If this ``SyncableHasProperties`` instance has no parent, a
:exc:`RuntimeError` is raised. If the specified property is in the
``nobind`` list (see :meth:`__init__`), a :exc:`SyncError` is
raised.
..note:: The ``nobind`` check can be avoided by calling
:func:`.bindable.bindProps` directly. But don't do that.
"""
if propName in self.__nobind:
raise SyncError('{} cannot be bound to '
'parent'.format(propName))
bindPropName = self.__saltSyncPropertyName(propName)
if getattr(self, bindPropName):
return
setattr(self, bindPropName, True)
[docs]
def unsyncFromParent(self, propName):
"""Unsynchronise the given property from the parent instance.
If this :class:`SyncableHasProperties` instance has no parent, a
:exc:`RuntimeError` is raised. If the specified property is in the
`nounbind` list (see :meth:`__init__`), a :exc:`SyncError` is
raised.
..note:: The ``nounbind`` check can be avoided by calling
:func:`bindable.bindProps` directly. But don't do that.
"""
if propName in self.__nounbind:
raise SyncError('{} cannot be unbound from '
'parent'.format(propName))
bindPropName = self.__saltSyncPropertyName(propName)
if not getattr(self, bindPropName):
return
setattr(self, bindPropName, False)
[docs]
def syncAllToParent(self):
"""Synchronises all properties to the parent instance.
Does not attempt to synchronise properties in the ``nobind`` list.
"""
propNames = self.getAllProperties()[0]
for propName in propNames:
if propName in self.__nounbind or \
propName in self.__nobind:
continue
self.syncToParent(propName)
[docs]
def unsyncAllFromParent(self):
"""Unynchronises all properties from the parent instance.
Does not attempt to synchronise properties in the ``nounbind`` list.
"""
propNames = self.getAllProperties()[0]
for propName in propNames:
if propName in self.__nounbind or \
propName in self.__nobind:
continue
self.unsyncFromParent(propName)
[docs]
def detachFromParent(self, propName):
"""If this is a child ``SyncableHasProperties`` instance, it
detaches the specified property from its parent. This is an
irreversible operation.
"""
if self.__parent is None: return
if propName in self.__nobind: return
if propName in self.__nounbind: self.__nounbind.remove(propName)
if propName not in self.__nobind: self.__nobind .append(propName)
syncPropName = self.__saltSyncPropertyName(propName)
lName = self.__saltSyncListenerName(propName)
if self.hasListener(syncPropName, lName):
self.removeListener(syncPropName, lName)
if self.isSyncedToParent(propName):
self.unsyncFromParent(propName)
self.__changeSyncState(propName, False)
[docs]
def detachAllFromParent(self):
"""If this is a child ``SyncableHasProperties`` instance, it
detaches itself from its parent. This is an irreversible operation.
TODO: Add the ability to dynamically set/clear the parent
SHP instance.
"""
# This is a parent instance -
# nothing to detach from
if self.__parent is None:
return
parent = self.__parent()
propNames = self.getAllProperties()[0]
for propName in propNames:
self.detachFromParent(propName)
if parent is not None:
for c in list(parent.__children):
if c() is self:
parent.__children.remove(c)
self.__parent = None
[docs]
def isSyncedToParent(self, propName):
"""Returns ``True`` if the specified property is synced to the parent
of this ``SyncableHasProperties`` instance, ``False`` otherwise.
"""
return getattr(self, self.__saltSyncPropertyName(propName))
[docs]
def anySyncedToParent(self):
"""Returns ``True`` if any properties are synced to the parent
of this ``SyncableHasProperties`` instance, ``False`` otherwise.
"""
propNames = self.getAllProperties()[0]
return any([self.isSyncedToParent(p) for p in propNames])
[docs]
def allSyncedToParent(self):
"""Returns ``True`` if all properties are synced to the parent
of this ``SyncableHasProperties`` instance, ``False`` otherwise.
"""
propNames = self.getAllProperties()[0]
return all([self.isSyncedToParent(p) for p in propNames])
[docs]
def canBeSyncedToParent(self, propName):
"""Returns ``True`` if the given property can be synced between this
``SyncableHasProperties`` instance and its parent (see the ``nobind``
parameter in :meth:`__init__`).
"""
return propName not in self.__nobind
[docs]
def canBeUnsyncedFromParent(self, propName):
"""Returns ``True`` if the given property can be unsynced between this
``SyncableHasProperties`` instance and its parent (see the
``nounbind`` parameter in :meth:`__init__`).
"""
return propName not in self.__nounbind
[docs]
def addSyncChangeListener(self,
propName,
listenerName,
callback,
overwrite=False,
weak=True):
"""Registers the given callback function to be called when
the sync state of the specified property changes.
"""
bindPropName = self.__saltSyncPropertyName(propName)
self.addListener(bindPropName,
listenerName,
callback,
overwrite=overwrite,
weak=weak)
[docs]
def removeSyncChangeListener(self, propName, listenerName):
"""De-registers the given listener from receiving sync
state changes.
"""
bindPropName = self.__saltSyncPropertyName(propName)
self.removeListener(bindPropName, listenerName)