Source code for fsleyes_props.bindable

#!/usr/bin/env python
#
# bindable.py - This module adds functionality to the HasProperties class
# to allow properties from different instances to be bound to each other.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module adds functionality to the :class:`.HasProperties` class to
allow properties from different instances to be bound to each other.  This
module also contains the core event loop notification logic that forms
the foundation of the ``fsleyes_props`` library.


The logic defined in this module is separated purely to keep the
:mod:`.properties` and :mod:`.properties_value` module file sizes down.


The following functions are defined in this module and are used by the
the :class:`.HasProperties` class.


 .. autosummary::
    :nosignatures:

    bindProps
    unbindProps
    isBound


These functions use the following functions, which work directly with
:class:`.PropertyValue` instances, and are available for advanced usage:


 .. autosummary::
    :nosignatures:

    bindPropVals
    propValsAreBound


:class:`.PropertyValue` instances use the following methods for
synchronisation of attribute and values, notification of their listeners, and
access to other ``PropertyValue`` instances to which they are bound:

 .. autosummary::
    :nosignatures:

    syncAndNotify
    syncAndNotifyAtts
    buildBPVList


-------------
Example usage
-------------

::

    >>> import fsleyes_props as props

    >>> class MyObj(props.HasProperties):
            myint  = props.Int()
            myreal = props.Real()

    >>> myobj1 = MyObj()
    >>> myobj2 = MyObj()

    # Set some initial values
    >>> myobj1.myint  = 1
    >>> myobj1.myreal = 0.1
    >>> myobj2.myint  = 2
    >>> myobj2.myreal = 0.12

    # Bind myobj1.myint and myobj2.myint.
    # The instance on which bindProps is
    # called will inherit the value of the
    # other instance
    >>> myobj2.bindProps('myint', myobj1)

    >>> print myobj2
    MyObj
       myint = 1
      myreal = 0.2

    # Changing a bound property value on either
    # instance will result in the value being
    # propagated to the other instance.
    >>> myobj2.myint = 8
    >>> print myobj1
    MyObj
       myint = 8
      myreal = 0.1


-------
Details
-------


When a ``HasProperties`` property value is changed, the associated
``PropertyValue`` instance does two things:

  1. Casts and validates the new value and updates its stored value.
  2. Calls the :func:`syncAndNotify` function.

The ``syncAndNotify`` function then does the following:

  3. Updates the value on all bound ``PropertyValue`` instances.
  4. Notifies all listeners, registered on the source ``PV`` instance, of the
     value change.
  5. Notifies all listeners, registered on the bound ``PV`` instances, of the
     value change.

An important point to note regarding step 2 above is that *all* PV instances
which are bound, either directly or indirectly, to the source PV instance,
will be updated. In other words, there are no restrictions on the ways in
which ``PropertyValue`` instances may be bound.  A tree, chain, or even a
network of ``PV`` instances can be bound together - the above process will
still work.
"""


import logging
import weakref

import fsl.utils.weakfuncref as weakfuncref


log = logging.getLogger(__name__)


[docs] class Bidict(object): """A bare-bones bi-directional dictionary, used for binding :class:`.PropertyValueList` instances - see the :func:`_bindListProps` and :func:`_syncPropValLists` functions. """ def __init__(self): self._thedict = {} def __setitem__(self, key, value): self._thedict[key] = value self._thedict[value] = key def __delitem__(self, key): val = self._thedict.pop(key) self ._thedict.pop(val)
[docs] def get(self, key, default=None): return self._thedict.get(key, default)
def __getitem__(self, key): return self._thedict.__getitem__(key) def __repr__( self): return self._thedict.__repr__() def __str__( self): return self._thedict.__str__()
[docs] def bindProps(self, propName, other, otherPropName=None, bindval=True, bindatt=True, unbind=False): """Binds the properties specified by ``propName`` and ``otherPropName`` such that changes to one are applied to the other. If the properties are :class:`.List` properties, the :func:`_bindListProps` function is called. Otherwise the :func:`_bindProps` function is called. :arg str propName: The name of a property on this ``HasProperties`` instance. :arg other: Another ``HasProperties`` instance. :arg otherPropName: The name of a property on ``other`` to bind to. If ``None`` it is assumed that there is a property on ``other`` called ``propName``. :arg bindval: If ``True`` (the default), property values are bound. This parameter is ignored for list properties. :arg bindatt: If ``True`` (the default), property attributes are bound. For :class:`.List` properties, this parameter applies to the list values, not to the list itself. :arg unbind: If ``True``, the properties are unbound. See the :meth:`unbindProps` method. """ from . import properties if otherPropName is None: otherPropName = propName myProp = self .getProp(propName) otherProp = other.getProp(otherPropName) if type(myProp) != type(otherProp): raise ValueError('Properties must be of the ' 'same type to be bound') if isinstance(myProp, properties.ListPropertyBase): _bindListProps(self, myProp, other, otherProp, bindatt=bindatt, unbind=unbind) else: _bindProps(self, myProp, other, otherProp, bindval=bindval, bindatt=bindatt, unbind=unbind)
[docs] def unbindProps(self, propName, other, otherPropName=None, bindval=True, bindatt=True): """Unbinds two properties previously bound via a call to :func:`bindProps`. """ self.bindProps(propName, other, otherPropName, bindval=bindval, bindatt=bindatt, unbind=True)
[docs] def isBound(self, propName, other, otherPropName=None): """Returns ``True`` if the specified property is bound to the other ``HasProperties`` instance, ``False`` otherwise. """ if otherPropName is None: otherPropName = propName myProp = self .getProp( propName) otherProp = other .getProp( otherPropName) myPropVal = myProp .getPropVal(self) otherPropVal = otherProp.getPropVal(other) return propValsAreBound(myPropVal, otherPropVal)
[docs] def syncAndNotifyAtts(self, name, value): """This method is called by the :meth:`.PropertyValue.notifyAttributeListeners` method. It ensures that the attributes of any bound :class:`.PropertyValue` instances are synchronised, and then notifies all attribute listeners. """ boundPropVals = _sync(self, True, name, value) boundPropVals = [self] + [bpv[0] for bpv in boundPropVals] _callAllListeners(boundPropVals, True, name, value)
[docs] def syncAndNotify(self): """Synchronises the value contained in all bound :class:`.PropertyValue` instances with the value contained in this instance, and notifies all registered listeners of the value change. This method is called by the :meth:`.PropertyValue.propNotify` method. """ from . import properties_value bpvs = _sync(self) allBpvs = [] for bpv, listItems in bpvs: if isinstance(bpv, properties_value.PropertyValueList): allBpvs.extend(listItems) allBpvs.append(bpv) _callAllListeners([self] + allBpvs, False)
def _bindProps(self, myProp, other, otherProp, bindval=True, bindatt=True, unbind=False): """Binds the :class:`.PropertyValue` instances of two :class:`.PropertyBase` instances together. See the :func:`bindProps` function for details on the parameters. """ myPropVal = myProp .getPropVal(self) otherPropVal = otherProp.getPropVal(other) if not unbind: allow = myPropVal.allowInvalid() myPropVal.allowInvalid(True) if bindatt: myPropVal.setAttributes(otherPropVal.getAttributes()) if bindval: myPropVal.set( otherPropVal.get()) myPropVal.allowInvalid(allow) bindPropVals(myPropVal, otherPropVal, bindval=bindval, bindatt=bindatt, unbind=unbind) def _bindListProps(self, myProp, other, otherProp, bindatt=True, unbind=False): """Binds the :class:`.PropertyValueList` instances of two :class:`.ListPropertyBase` instances together. See the :func:`bindProps` function for details on the parameters. """ myPropVal = myProp .getPropVal(self) otherPropVal = otherProp.getPropVal(other) # The unbinding case is easy if unbind: myPropValList = myPropVal .getPropertyValueList() otherPropValList = otherPropVal.getPropertyValueList() myPropVal ._listPropValMaps.pop(id(otherPropVal)) otherPropVal._listPropValMaps.pop(id(myPropVal)) for myItem, otherItem in zip(myPropValList, otherPropValList): bindPropVals(myItem, otherItem, bindval=False, bindatt=bindatt, unbind=True) bindPropVals(myPropVal, otherPropVal, unbind=True) return # Binding two lists is a # bit more complicated ... # Inhibit list-level notification due to item # changes during the initial sync - we'll # manually do a list-level notification after # all the list values have been synced notifState = myPropVal.getNotificationState() myPropVal.disableNotification() # Force the two lists to have # the same number of elements if len(myPropVal) > len(otherPropVal): del myPropVal[len(otherPropVal):] elif len(myPropVal) < len(otherPropVal): myPropVal.extend(otherPropVal[len(myPropVal):]) # Create a mapping between the # PropertyValue pairs across # the two lists myPropValList = myPropVal .getPropertyValueList() otherPropValList = otherPropVal.getPropertyValueList() propValMap = Bidict() # Copy item values from the master list # to the slave list, and save the mapping for myItem, otherItem in zip(myPropValList, otherPropValList): log.debug('Binding list item %s.%s (%s) <- %s.%s (%s)', self.__class__.__name__, myProp.getLabel(self), myItem.get(), other.__class__.__name__, otherProp.getLabel(other), otherItem.get()) # Disable item notification - we'll # manually force a notify after the # sync itemNotifState = myItem.getNotificationState() myItem.disableNotification() # Bind attributes between PV item pairs, # but not value - value change of items # in a list is handled at the list level bindPropVals(myItem, otherItem, bindval=False, bindatt=bindatt) propValMap[myItem] = otherItem atts = otherItem.getAttributes() # Set attributes first, because the attribute # values may influence/modify the property value if bindatt: myItem.setAttributes(atts) myItem.set(otherItem.get()) # Notify item level listeners of the value # change (if notification was enabled). # # TODO This notification occurs even # if the two PV objects had the same # value before the sync - you should # notify only if the myItem PV value # has changed. myItem.setNotificationState(itemNotifState) if itemNotifState: # notify attribute listeners first if bindatt: for name, val in atts.items(): syncAndNotifyAtts(myItem, name, val) syncAndNotify(myItem) # This mapping is stored on the PVL objects, # and used by the _syncListPropVals function myPropValMaps = getattr(myPropVal, '_listPropValMaps', {}) otherPropValMaps = getattr(otherPropVal, '_listPropValMaps', {}) # We can't use the PropValList objects as # keys, because they are not hashable. myPropValMaps[ id(otherPropVal)] = propValMap otherPropValMaps[id(myPropVal)] = propValMap myPropVal ._listPropValMaps = myPropValMaps otherPropVal._listPropValMaps = otherPropValMaps # Bind list-level value/attributes # between the PropertyValueList objects atts = otherPropVal.getAttributes() myPropVal.setAttributes(atts) bindPropVals(myPropVal, otherPropVal) # Manually notify list-level listeners # # TODO This notification will occur # even if the two lists had the same # value before being bound. It might # be worth only performing the # notification if the list has changed # value myPropVal.setNotificationState(notifState) # Sync the PVS, ensure that the sync # is propagated to other bound PVs, # and notify all listeners. for name, val in atts.items(): syncAndNotifyAtts(myPropVal, name, val) syncAndNotify(myPropVal)
[docs] def propValsAreBound(pv1, pv2): """Returns ``True`` if the given :class:`.PropertyValue` instances are bound to each other, ``False`` otherwise. """ pv1BoundPropVals = pv1.__dict__.get('boundPropVals', {}) pv2BoundPropVals = pv2.__dict__.get('boundPropVals', {}) return (id(pv2) in pv1BoundPropVals and id(pv1) in pv2BoundPropVals)
[docs] def bindPropVals(myPropVal, otherPropVal, bindval=True, bindatt=True, unbind=False): """Binds two :class:`.PropertyValue` instances together such that when the value of one changes, the other is changed. Note that the values are not immediately synchronised - they will become synchronised on the next change to either ``PropertyValue``. See :func:`bindProps` for details on the parameters. """ mine = myPropVal other = otherPropVal # A dict containing { id(PV) : PV } mappings is stored # on each PV, and used to maintain references to bound # PVs. We use a WeakValueDictionary (instead of just a # set) so that these references do not prevent PVs # which are no longer in use from being GC'd. wvd = weakref.WeakValueDictionary myBoundPropVals = mine .__dict__.get('boundPropVals', wvd()) myBoundAttPropVals = mine .__dict__.get('boundAttPropVals', wvd()) otherBoundPropVals = other.__dict__.get('boundPropVals', wvd()) otherBoundAttPropVals = other.__dict__.get('boundAttPropVals', wvd()) if unbind: action = 'Unbinding' else: action = 'Binding' log.debug('%s property values ' '(val=%s, att=%s) %s.%s (%s) <-> %s.%s (%s)', action, bindval, bindatt, myPropVal._context.__class__.__name__, myPropVal._name, id(myPropVal), otherPropVal._context.__class__.__name__, otherPropVal._name, id(otherPropVal)) if bindval: if unbind: myBoundPropVals .pop(id(other)) otherBoundPropVals.pop(id(mine)) else: myBoundPropVals[ id(other)] = other otherBoundPropVals[id(mine)] = mine if bindatt: if unbind: myBoundAttPropVals .pop(id(other)) otherBoundAttPropVals.pop(id(mine)) else: myBoundAttPropVals[ id(other)] = other otherBoundAttPropVals[id(mine)] = mine mine .boundPropVals = myBoundPropVals mine .boundAttPropVals = myBoundAttPropVals other.boundPropVals = otherBoundPropVals other.boundAttPropVals = otherBoundAttPropVals # When a master PV is synchronised to a slave PV, # it stores a flag on the slave PV which is checked # before starting a sync. If the flag is True, # the sync is inhibited. See the _sync function below. mine ._syncing = getattr(mine, '_syncing', False) other._syncing = getattr(other, '_syncing', False)
def _syncPropValLists(masterList, slaveList): """Called by the :func:`_sync` function when one of a pair of bound :class:`.PropertyValueList` instances changes. Propagates the change on the ``masterList`` (either an addition, a removal, or a re-ordering) to the ``slaveList``. """ propValMap = masterList._listPropValMaps[id(slaveList)] # If the change was due to the values of one or more PV # items changing (as opposed to a list modification - # addition/removal/reorder), the PV objects which # changed are stored in this list and returned changed = [] # one or more items have been # added to the master list if len(masterList) > len(slaveList): # Loop through the PV objects in the master # list, and search for any which do not have # a paired PV object in the slave list for i, mpv in enumerate(masterList.getPropertyValueList()): spv = propValMap.get(mpv, None) # we've found a value in the master # list which is not in the slave list if spv is None: # add a new value to the slave list slaveList.insert(i, mpv.get()) # retrieve the corresponding PV # object that was created by # the slave list spvs = slaveList.getPropertyValueList() spv = spvs[i] # register a mapping between the # new master and slave PV objects propValMap[mpv] = spv # Bind the attributes of # the two new PV objects bindPropVals(mpv, spv, bindval=False) # one or more items have been # removed from the master list elif len(masterList) < len(slaveList): mpvs = masterList.getPropertyValueList() # Loop through the PV objects in the slave # list, and check to see if their mapped # master PV object has been removed from # the master list. Loop backwards so we can # delete items from the slave list as we go, # without having to offset the list index. for i, spv in reversed( list(enumerate(slaveList.getPropertyValueList()))): # If this raises an error, there's a bug # in somebody's code ... probably mine. mpv = propValMap[spv] # we've found a value in the slave list # which is no longer in the master list if mpv not in mpvs: # Delete the item from the slave # list, and delete the PV mapping del slaveList[ i] del propValMap[mpv] # list re-order, or individual # value change else: mpvs = masterList.getPropertyValueList() mpvids = [id(m) for m in mpvs] newOrder = [] # loop through the PV objects in the slave list, # and build a list of indices of the corresponding # PV objects in the master list for i, spv in enumerate(slaveList.getPropertyValueList()): mpv = propValMap[spv] newOrder.append(mpvids.index(id(mpv))) # If the master list order has been # changed, re-order the slave list if newOrder != list(range(len(slaveList))): slaveList.reorder(newOrder) # The list order hasn't changed, so # this call must have been triggered # by a value change. Find the items # which have changed, and copy the # new value across to the slave list else: for i, (masterVal, slaveVal) in \ enumerate( zip(masterList.getPropertyValueList(), slaveList .getPropertyValueList())): if masterVal == slaveVal: continue notifState = slaveVal.getNotificationState() validState = slaveVal.allowInvalid() slaveVal.disableNotification() slaveVal.allowInvalid(True) log.debug('Syncing bound PV list item ' '[%s] %s.%s[%s](%s) -> %s.%s[%s](%s)', i, masterList._context().__class__.__name__, masterList._name, id(masterList._context()), masterVal.get(), slaveList._context().__class__.__name__, slaveList._name, id(slaveList._context()), slaveList.get()) slaveList._ignoreListItems = True try: slaveVal.set(masterVal.get()) changed.append(slaveVal) finally: slaveList._ignoreListItems = False slaveVal.allowInvalid(validState) slaveVal.setNotificationState(notifState) return changed
[docs] def buildBPVList(self, key, node=None, bpvSet=None): """Recursively builds a list of all PVs that are bound to this one, either directly or indirectly. For each PV, we also store a reference to the 'parent' PV, i.e. the PV to which it is directly bound, as the direct bindings are needed to synchronise list PVs. Returns two lists - the first containing bound PVs, and the second containing the parent for each bound PV. :arg self: The root PV. :arg key: A string, either ``boundPropVals`` or ``boundAttPropVals``. :arg node: The current PV to begin this step of the recursive search from (do not pass in on the non-recursive call). :arg bpvSet: A set used to prevent cycles in the depth-first search (do not pass in on the non-recursive call). """ boundPropVals = [] bpvParents = [] if node is None: node = self # A recursive depth-first search from this # PV through the network of all directly # or indirectly bound PVs. # # We use a set of PV ids to make sure # that we don't add duplicates to the # list of PVs that need to be synced if bpvSet is None: bpvSet = set() bpvs = node.__dict__.get(key, {}).values() bpvs = [b for b in bpvs if b is not self and id(b) not in bpvSet] for b in bpvs: bpvSet.add(id(b)) boundPropVals.extend(bpvs) bpvParents .extend([node] * len(bpvs)) for bpv in bpvs: childBpvs, childBpvps = buildBPVList(self, key, bpv, bpvSet) boundPropVals.extend(childBpvs) bpvParents .extend(childBpvps) return boundPropVals, bpvParents
def _sync(self, atts=False, attName=None, attValue=None): """Called by :func:`_notify`. Synchronises the value or attributes of all bound ``PropertyValue`` instances to the the value or attributes of this one. :arg atts: If ``True``, the attribute with ``attName`` and ``attValue`` is synchronised. Otherwise, the property values are synchronised. :arg attName: If ``att=True``, the name of the attribute to synchronise. :arg attValue: If ``att=True``, the value of the attribute to synchronise. """ from . import properties_value # This PV is already being synced # to some other PV - don't sync back if getattr(self, '_syncing', False): return [] if atts: key = 'boundAttPropVals' else: key = 'boundPropVals' boundPropVals, bpvParents = buildBPVList(self, key) # Sync all the values that need syncing. Store # a ref to each PV which was synced, but not # to PVs which already had the same value. changedPropVals = [] # Set the syncing flag on all # slave PVs to prevent recursive # syncs back to this PV for bpv in boundPropVals: bpv._syncing = True try: for i, bpv in enumerate(boundPropVals): # Don't bother if the values are already equal if atts: try: if bpv.getAttribute(attName) == attValue: continue except KeyError: pass elif self == bpv: continue # Disable notification on the PV, as we # manually trigger notifications in the # _notify function below. notifState = bpv.getNotificationState() bpv.disableNotification() log.debug('Syncing bound property values (%s) ' '%s.%s (%s) - %s.%s (%s)', 'attributes: {} = {}'.format(attName, attValue) if atts else 'values', self._context.__class__.__name__, self._name, id(self._context()), bpv._context.__class__.__name__, bpv._name, id(bpv._context())) # Normal PropertyValue object (i.e. not a PropertyValueList) if atts or \ not isinstance(self, properties_value.PropertyValueList): # Store a reference to this PV changedPropVals.append((bpv, None)) # Allow invalid values, as otherwise # an error may be raised. validState = bpv.allowInvalid() bpv.allowInvalid(True) # Sync the attribute value if atts: bpv.setAttribute(attName, attValue) # Or sync the property value else: bpv.set(self.get()) bpv.allowInvalid(validState) # PropertyValueList instances - # store a reference ot the PV list, # and to all list items that changed else: listItems = _syncPropValLists(bpvParents[i], bpv) changedPropVals.append((bpv, listItems)) # Restore the notification state bpv.setNotificationState(notifState) finally: # Clear the syncing flag # on all slave PVs for bpv in boundPropVals: bpv._syncing = False # Return a list of all changed PVs back # to the _notify function, so it can # trigger notification on all of them. return changedPropVals def _callAllListeners(propVals, att, name=None, value=None): """Calls all listeners of the given list of :class:`.PropertyValue` instances. :arg att: If ``True``, attribute listeners are notified, otherwise value listeners are notified. :arg name: If ``att == True``, the attribute name. :arg value: If ``att == True``, the attribute value. """ from . import properties_value queued = [] q = properties_value.PropertyValue.queue # Hold the queue to inhibit any callbacks which # are triggered by immediate listener calls q.hold() # Get the function from a # properties_value.Listener # instance. def getFunc(listener): func = listener.function if isinstance(func, weakfuncref.WeakFunctionRef): func = func.function() return func try: for i, pv in enumerate(propVals): cListeners, cArgs = pv.prepareListeners(att, name, value) # If a bound PV is an item in a PV list, # then the listeners on the owning PV list # need to be called as well. We skip # the first PV in the propVals list, as it # is the source of the change. if (i > 0) and (not att) and (pv.getParent() is not None): pListeners, pArgs = pv.getParent().prepareListeners(False) else: pListeners = [] pArgs = [] for listeners, args in [(cListeners, cArgs), (pListeners, pArgs)]: for l, a in zip(listeners, args): # The listener may have been removed/disabled # due to another immediate listener if not l.enabled: continue # Call the listener function directly if l.immediate: log.debug('Calling immediate mode ' 'listener %s', l.name) getFunc(l)(*a) # Or add it to the queue else: queued.append((l, a)) # Make sure the queue is freed finally: q.release() # Some listeners may have been disabled/removed # as the result of the execution of another # listener, so we only want to re-queue the ones # that are still active. queued = [(getFunc(l), l.makeQueueName(), a, {}) for l, a in queued if l.enabled] # Some listeners referred to by weakrefs # may have been GC-d, in which case the # function reference will be None queued = [(f, n, a, kw) for f, n, a, kw in queued if f is not None] # Append any held functions on to the # end of the call list, so they are # executed after all of the listeners # for the original property value change held = q.clearHeld() q.callAll(queued + held)