Title: [commits] (pje) [11437] Replace 'afterChange' with '@schema.observer' decorator; see docs for details.

Diff

Modified: trunk/chandler/application/parcel-schema-guide.txt (11436 => 11437)

--- trunk/chandler/application/parcel-schema-guide.txt	2006-08-17 18:54:38 UTC (rev 11436)
+++ trunk/chandler/application/parcel-schema-guide.txt	2006-08-17 19:18:11 UTC (rev 11437)
@@ -340,6 +340,82 @@
 for more details.
 
 
+Observers and Change Notifications
+==================================
+
+Sometimes, you need to know when attributes have changed, in order to update
+dependent attributes.  The schema API allows you to register "observers":
+methods that will be called when one or more specified attributes have changed.
+For example::
+
+    >>> class Example(schema.Item):
+    ...     foo = schema.One(schema.Integer)
+    ...     bar = schema.One(schema.Text)
+    ...     @schema.observer(foo, bar)
+    ...     def something_changed(self, name):
+    ...         print name, "changed"
+
+Whenever the ``foo`` or ``bar`` attribute of any ``Example`` instance is
+changed, the instance's ``something_changed()`` method will be called, passing
+in the name of the changed attribute::
+
+    >>> e = Example(itsView = rv)
+    >>> e.foo = 1
+    foo changed
+
+    >>> e.bar = u"Test"
+    bar changed
+
+Observer methods can be overridden by methods of the same name in a subclass::
+
+    >>> class Example2(Example):
+    ...     def something_changed(self, name):
+    ...         print name, "changed in Example2"
+    >>> e2 = Example2(itsView = rv)
+    >>> e2.foo = 1
+    foo changed in Example2
+
+Notice that this happens even if you do not explicitly register the subclass'
+method as an observer.
+
+If you need to observe an attribute that is not defined in the current class,
+you can do so by obtaining it from the base class, e.g.::
+
+    >>> class Example3(Example):
+    ...     @schema.observer(Example.foo)
+    ...     def foo_changed(self, name):
+    ...         print name, "changed in Example3"
+    >>> e3 = Example3(itsView = rv)
+    >>> e3.foo = 1
+    foo changed
+    foo changed in Example3
+    >>> e3.bar = u"Test"
+    bar changed
+
+In the example above, the ``Example.something_changed()`` method was called,
+*as well as* the ``Example3.foo_changed()`` method, because they have different
+method names.  Notice also that changing the ``bar`` attribute still called the
+``something_changed`` method.
+
+
+Important Limitations
+---------------------
+
+Please note that the order of observer calls is not guaranteed -- even
+different installations of the same version of Chandler may have a different
+callback order!  Therefore, you must **always** write observer code so that it
+does not depend on the order in which other observers are invoked.
+
+Also, because callbacks are registered by method name, you should take care not
+to use the same method name for different things, unless you intend to override
+an existing method.  For the same reason, you should also give your observers
+names that are unlikely to be accidentally duplicated in other classes.
+Private method names -- that is, names beginning with a double-underscore
+(``__``) can be good for this purpose, although they cannot be overridden in a
+subclass unless you manually duplicate the name mangling that Python does
+when you use private names.
+
+
 Extending Existing Kinds
 ========================
 

Modified: trunk/chandler/application/schema.py (11436 => 11437)

--- trunk/chandler/application/schema.py	2006-08-17 18:54:38 UTC (rev 11436)
+++ trunk/chandler/application/schema.py	2006-08-17 19:18:11 UTC (rev 11437)
@@ -21,6 +21,7 @@
 from repository.schema import Types
 from repository.schema.Cloud import Cloud as _Cloud
 from repository.schema.Cloud import Endpoint as _Endpoint
+from zope.interface.advice import getFrameInfo, addClassAdvisor
 import __main__, repository, threading, os, sys
 
 __all__ = [
@@ -29,7 +30,7 @@
     'importString', 'parcel_for_module', 'TypeReference',
     'Enumeration', 'Cloud', 'Endpoint', 'addClouds', 'Struct',
     'assertResolved', 'Annotation', 'AnnotationItem',
-    'afterChange',
+    'observer',
 ]
 
 all_aspects = Attribute.valueAspects + Attribute.refAspects + ('description',)
@@ -538,18 +539,21 @@
                 if ai not in kind.attributes:
                     kind.attributes.append(ai,name)
 
-        for attrName, names in cls.__dict__.get('__after_change__',{}).items():
-            attr = kind.getAttribute(attrName, True)
-            if attr is not None:
-                if hasattr(attr, 'afterChange'):
-                    afterChange = attr.afterChange
-                    for name in names:
+        for name, attrNames in cls.__dict__.get('__after_change__',{}).items():
+            for attrName in attrNames:
+                if isinstance(attrName,Descriptor):
+                    attr = itemFor(attrName, view)
+                else:
+                    attr = kind.getAttribute(attrName, True)
+                if attr is not None:
+                    if hasattr(attr, 'afterChange'):
+                        afterChange = attr.afterChange
                         if name not in afterChange:
                             afterChange.append(name)
+                    else:
+                        attr.afterChange = [name]
                 else:
-                    attr.afterChange = names
-            else:
-                view.logger.warn("no attribute '%s' defined for kind for %s",
+                    view.logger.warn("no attribute '%s' defined for kind for %s",
                                  attrName, cls)
 
         def fixup():
@@ -941,9 +945,9 @@
     __metaclass__ = EnumerationClass
 
 
-def _update_info(name,attr,data):
-    from zope.interface.advice import getFrameInfo, addClassAdvisor
-    kind, module, _locals, _globals = getFrameInfo(sys._getframe(2))
+def _update_info(name,attr,data,frame=None,depth=2):
+    frame = frame or sys._getframe(depth)
+    kind, module, _locals, _globals = getFrameInfo(frame)
 
     if kind=='exec':
         # Fix for class-in-doctest-exec
@@ -983,6 +987,7 @@
     """
     _update_info('kindInfo','__kind_info__',attrs)
 
+    @addClassAdvisor
     def callback(cls):
         _get_nrv()
         for k,v in attrs.items():
@@ -993,10 +998,7 @@
                 )
         return cls
 
-    from zope.interface.advice import addClassAdvisor
-    addClassAdvisor(callback)
 
-
 def addClouds(**clouds):
     """Declare clouds for a class' Kind
 
@@ -1018,11 +1020,31 @@
     _update_info('addClouds','__kind_clouds__',clouds)
 
 
-def afterChange(**pairs):
-    """Update afterChange aspect on attributes"""
-    _update_info('afterChange', '__after_change__', pairs)
+def observer(*attrs):
+    """Decorator to create an observer method for `attrs`"""
+    attrs = list(attrs)
+    for attr in attrs:
+        if not isinstance(attr, Descriptor):
+            raise TypeError(
+                repr(attr)+
+                " is not a schema.Descriptor (One, Many, Sequence, etc.)"
+            )
+    def decorator(func):
+        _update_info('observer', '__after_change__', {func.__name__:attrs})
+        @addClassAdvisor
+        def callback(cls):
+            for attr in attrs:
+                if attr.owner is None or not issubclass(cls,attr.owner):
+                    raise TypeError(
+                        "%r does not belong to %r or its superclasses"
+                        % (attr, cls)
+                    )
+            return cls
+        return func
 
+    return decorator
 
+
 def importString(name, globalDict=__main__.__dict__):
     """Import an item specified by a string
 

Modified: trunk/chandler/application/schema_api.txt (11436 => 11437)

--- trunk/chandler/application/schema_api.txt	2006-08-17 18:54:38 UTC (rev 11436)
+++ trunk/chandler/application/schema_api.txt	2006-08-17 19:18:11 UTC (rev 11437)
@@ -269,6 +269,46 @@
     TypeError: Descriptor objects are immutable; can't change 'doc'... once set
 
 
+Change Notifications
+====================
+
+Test error messages:
+
+    >>> class Example(schema.Item):
+    ...     foo = schema.One(schema.Integer)
+    ...     @schema.observer(foo)
+    ...     def foo_changed(self, name):
+    ...         print name, "changed"
+    >>> rv = NullRepositoryView(verify=True)
+    >>> e = Example(itsView = rv)
+    >>> e.foo = 1
+    foo changed
+
+    >>> class Example2(schema.Item):
+    ...     @schema.observer(Example.foo)
+    ...     def foo_changed(self, name):
+    ...         print name, "changed"
+    Traceback (most recent call last):
+      ...
+    TypeError: ...foo... does not belong to ...Example2'> or its superclasses
+
+    >>> class Example2(Example):
+    ...     @schema.observer(Example.foo)
+    ...     def foo_changed(self, name):
+    ...         print name, "changed in Example2"
+    >>> e2 = Example2(itsView = rv)
+    >>> e2.foo = 1
+    foo changed in Example2
+
+    >>> class BadExample(schema.Item):
+    ...     @schema.observer("foo")
+    ...     def foo_changed(self, name):
+    ...         print name, "changed"
+    Traceback (most recent call last):
+      ...
+    TypeError: 'foo' is not a schema.Descriptor (One, Many, Sequence, etc.)
+
+
 ---------------
 Item Subclasses
 ---------------

Modified: trunk/chandler/parcels/osaf/app/__init__.py (11436 => 11437)

--- trunk/chandler/parcels/osaf/app/__init__.py	2006-08-17 18:54:38 UTC (rev 11436)
+++ trunk/chandler/parcels/osaf/app/__init__.py	2006-08-17 19:18:11 UTC (rev 11437)
@@ -26,16 +26,13 @@
 import version
 
 class TZPrefs(Preferences):
-    showUI = schema.One(schema.Boolean,
-                        initialValue = False,
-                        afterChange = ['onShowUIChanged'])
-    
-    def onShowUIChanged(self, attrName):
+    showUI = schema.One(schema.Boolean, initialValue = False)
 
+    @schema.observer(showUI)
+    def onShowUIChanged(self, attrName):
         from osaf.pim.calendar.TimeZone import TimeZoneInfo
-            
         timeZoneInfo = TimeZoneInfo.get(self.itsView)
-        
+
         # Sync up the default timezone (i.e. the one used when
         # creating new events).
         if self.showUI:

Modified: trunk/chandler/parcels/osaf/framework/scripting/script.py (11436 => 11437)

--- trunk/chandler/parcels/osaf/framework/scripting/script.py	2006-08-17 18:54:38 UTC (rev 11436)
+++ trunk/chandler/parcels/osaf/framework/scripting/script.py	2006-08-17 19:18:11 UTC (rev 11437)
@@ -52,8 +52,6 @@
     who = schema.One(redirectTo = 'creator')
     date = schema.One(redirectTo = 'lastRan')
 
-    # afterChange
-    schema.afterChange(body = ['onBodyChanged'])
 
     def __init__(self, itsName=None, itsParent=None, itsKind=None, itsView=None,
                  body=None, *args, **keys):
@@ -88,6 +86,7 @@
             self.body = newValue
             self._change_quietly = oldQuiet
 
+    @schema.observer(pim.ContentItem.body)
     def onBodyChanged(self, name):
         self.model_data_changed()
 
@@ -195,7 +194,7 @@
     for attr in __all__:
         builtIns[attr] = globals()[attr]
 
-    # Protect against scripts that don't stop, needed especially by 
+    # Protect against scripts that don't stop, needed especially by
     # automated tests.
     scriptTimeout = int(getattr(Globals.options, 'scriptTimeout', 0))
     if scriptTimeout > 0:
@@ -273,7 +272,7 @@
         testMask = Globals.options.chandlerTestMask
         from tools.cats.framework.runTests import run_perf_tests
         run_perf_tests(chandlerPerformanceTests, debug=testDebug, mask=testMask, logName=logFileName)
-        
+
     fileName = Globals.options.scriptFile
     if fileName:
         scriptFileText = script_file(fileName)
@@ -284,7 +283,7 @@
                 global_cats_profiler = profiler
                 profiler.runcall(run_script_with_symbols,
                                  scriptFileText,
-                                 fileName=fileName, 
+                                 fileName=fileName,
                                  profiler=profiler,
                                  builtIns=builtIns)
                 profiler.close()
@@ -292,7 +291,7 @@
             else:
                 run_script_with_symbols(scriptFileText, fileName = fileName,
                                         builtIns=builtIns)
-                
+
 global_cats_profiler = None # remember the CATS profiler here
 
 def cats_profiler():

Modified: trunk/chandler/parcels/osaf/pim/calendar/Calendar.py (11436 => 11437)

--- trunk/chandler/parcels/osaf/pim/calendar/Calendar.py	2006-08-17 18:54:38 UTC (rev 11436)
+++ trunk/chandler/parcels/osaf/pim/calendar/Calendar.py	2006-08-17 19:18:11 UTC (rev 11437)
@@ -1355,22 +1355,11 @@
         else:
             RecurrenceDialog.getProxy(u'ui', self).removeFromCollection(collection, cutting)
 
-    changeNames = ('displayName', 'startTime', 'duration', 'location', 'body',
-                   'lastModified', 'allDay')
 
-    # KLUDGE: 
-    #   replace the following with the proper schema API syntax
-    #   and possible per-attribute refactoring
-
-    schema.afterChange(displayName=['onEventChanged'],
-                       startTime=['onEventChanged'],
-                       duration=['onEventChanged'],
-                       location=['onEventChanged'],
-                       body=['onEventChanged'],
-                       lastModified=['onEventChanged'],
-                       allDay=['onEventChanged'],
-                       rruleset=['onEventChanged'])
-
+    @schema.observer(
+        ContentItem.displayName, ContentItem.body, ContentItem.lastModified,
+        startTime, duration, location, allDay, rruleset
+    )
     def onEventChanged(self, name):
         """
         Maintain coherence of the various recurring items associated with self
@@ -1379,10 +1368,9 @@
         """
         # allow initialization code to avoid triggering onEventChanged
         rruleset = name == 'rruleset'
-        changeName = not rruleset and name in CalendarEventMixin.changeNames
+        changeName = not rruleset
 
-        if (not (rruleset or changeName) or
-            self.rruleset is None or
+        if (self.rruleset is None or
             getattr(self, '_share_importing', False) or
             getattr(self, '_ignoreValueChanges', False)):
             return

Modified: trunk/chandler/parcels/osaf/pim/calendar/Recurrence.py (11436 => 11437)

--- trunk/chandler/parcels/osaf/pim/calendar/Recurrence.py	2006-08-17 18:54:38 UTC (rev 11436)
+++ trunk/chandler/parcels/osaf/pim/calendar/Recurrence.py	2006-08-17 19:18:11 UTC (rev 11437)
@@ -206,28 +206,17 @@
     notSpecialNames = ("interval", "until", "bysetpos", "bymonth", "bymonthday",
                        "byyearday","byweekno", "byhour", "byminute", "bysecond")
 
-    allNames = ("interval", "until", "bysetpos", "bymonth", "bymonthday",
-                "byyearday","byweekno", "byhour", "byminute", "bysecond",
-                "wkst", "byweekday", "freq")
+    @schema.observer(
+        interval, until, bysetpos, bymonth, bymonthday, byyearday, byweekno,
+        byhour, byminute, bysecond, wkst, byweekday, freq
+    )
+    def onRecurrenceChanged(self, name):
+        """If the rule changes, update any associated events."""
+        for ruletype in ('rruleFor', 'exruleFor'):
+            if self.hasLocalAttributeValue(ruletype):
+                getattr(self, ruletype).onRuleSetChanged('rrules')
 
-    # KLUDGE: 
-    #   replace the following with the proper schema API syntax
-    #   and possible per-attribute refactoring
 
-    schema.afterChange(interval = ['onRecurrenceChanged'],
-                       until = ['onRecurrenceChanged'],
-                       bysetpos = ['onRecurrenceChanged'],
-                       bymonth = ['onRecurrenceChanged'],
-                       bymonthday = ['onRecurrenceChanged'],
-                       byyearday = ['onRecurrenceChanged'],
-                       byweekno = ['onRecurrenceChanged'],
-                       byhour = ['onRecurrenceChanged'],
-                       byminute = ['onRecurrenceChanged'],
-                       bysecond = ['onRecurrenceChanged'],
-                       wkst = ['onRecurrenceChanged'],
-                       byweekday = ['onRecurrenceChanged'],
-                       freq = ['onRecurrenceChanged'])
-    
     # dateutil automatically sets these from dtstart, we don't want these
     # unless their length is greater than 1.
     interpretedNames = "byhour", "byminute", "bysecond"
@@ -401,14 +390,7 @@
         self.until = previous
         self.untilIsDate = False
 
-    def onRecurrenceChanged(self, name):
-        """If the rule changes, update any associated events."""
-        if name in self.allNames:
-            for ruletype in ('rruleFor', 'exruleFor'):
-                if self.hasLocalAttributeValue(ruletype):
-                    getattr(self, ruletype).onRuleSetChanged('rrules')
 
-
 class RecurrenceRuleSet(items.ContentItem):
     """
     A collection of recurrence and exclusion rules, dates, and exclusion dates.
@@ -439,6 +421,16 @@
         sharing = schema.Cloud(exdates, rdates, byCloud = [exrules, rrules])
     )
 
+    @schema.observer(rrules, exrules, rdates, exdates)
+    def onRuleSetChanged(self, name):
+        """If the RuleSet changes, update the associated event."""
+        if not getattr(self, '_ignoreValueChanges', False):
+            if self.hasLocalAttributeValue('events'):
+                for event in self.events:
+                    event.getFirstInRule().cleanRule()
+                    # assume we have only one conceptual event per rrule
+                    break
+
     def addRule(self, rule, rrulesorexrules='rrules'):
         """Add an rrule or exrule, defaults to rrule.
 
@@ -670,18 +662,4 @@
                 rule.moveUntilBefore(dtstart, end)
         self.removeDates(datetime.__ge__, end)
 
-    RULENAMES = ('rrules', 'exrules', 'rdates', 'exdates')
 
-    schema.afterChange(rrules = ['onRuleSetChanged'],
-                       exrules = ['onRuleSetChanged'],
-                       rdates = ['onRuleSetChanged'],
-                       exdates = ['onRuleSetChanged'])
-
-    def onRuleSetChanged(self, name):
-        """If the RuleSet changes, update the associated event."""
-        if not getattr(self, '_ignoreValueChanges', False):
-            if self.hasLocalAttributeValue('events'):
-                for event in self.events:
-                    event.getFirstInRule().cleanRule()
-                    # assume we have only one conceptual event per rrule
-                    break

Modified: trunk/chandler/parcels/osaf/pim/calendar/TimeZone.py (11436 => 11437)

--- trunk/chandler/parcels/osaf/pim/calendar/TimeZone.py	2006-08-17 18:54:38 UTC (rev 11436)
+++ trunk/chandler/parcels/osaf/pim/calendar/TimeZone.py	2006-08-17 19:18:11 UTC (rev 11437)
@@ -29,9 +29,23 @@
 
     default = schema.One(
         schema.TimeZone,
-        afterChange = ['onDefaultChanged']
     )
 
+    @schema.observer(default)
+    def onDefaultChanged(self, name):
+        # Repository hook for attribute changes.
+        default = self.default
+        canonicalDefault = self.canonicalTimeZone(default)
+        # Make sure that PyICU's default timezone is synched with
+        # ours
+        if (canonicalDefault is not None and
+            canonicalDefault is not PyICU.ICUtzinfo.floating):
+            PyICU.ICUtzinfo.default = canonicalDefault
+        # This next if is required to avoid an infinite recursion!
+        if canonicalDefault is not default:
+            self.default = canonicalDefault
+
+
     # List of well-known time zones (for populating drop-downs).
     # [i18n] Since ICU doesn't suitably localize strings like 'US/Pacific',
     # we'll have to provide our own translations.
@@ -120,18 +134,6 @@
         if tz is not None and view is not None:
             PyICU.TimeZone.setDefault(tz.timezone)
 
-    def onDefaultChanged(self, name):
-        # Repository hook for attribute changes.
-        default = self.default
-        canonicalDefault = self.canonicalTimeZone(default)
-        # Make sure that PyICU's default timezone is synched with
-        # ours
-        if (canonicalDefault is not None and
-            canonicalDefault is not PyICU.ICUtzinfo.floating):
-            PyICU.ICUtzinfo.default = canonicalDefault
-        # This next if is required to avoid an infinite recursion!
-        if canonicalDefault is not default:
-            self.default = canonicalDefault
 
 def installParcel(parcel, oldVersion = None):
     # Get our parcel's namespace
@@ -228,9 +230,9 @@
     """
     Return an empty string or the short timezone string for dt if dt.tzinfo
     doesn't match tzinfo (tzinfo defaults to PyICU.ICUtzinfo.default)
-    
+
     """
-    if tzinfo is None: tzinfo = PyICU.ICUtzinfo.default    
+    if tzinfo is None: tzinfo = PyICU.ICUtzinfo.default
 
     if dt.tzinfo is None or dt.tzinfo is PyICU.ICUtzinfo.floating:
         return u''

Modified: trunk/chandler/projects/Chandler-PhotoPlugin/photos/Photos.py (11436 => 11437)

--- trunk/chandler/projects/Chandler-PhotoPlugin/photos/Photos.py	2006-08-17 18:54:38 UTC (rev 11436)
+++ trunk/chandler/projects/Chandler-PhotoPlugin/photos/Photos.py	2006-08-17 19:18:11 UTC (rev 11437)
@@ -35,8 +35,12 @@
     dateTaken = schema.One(schema.DateTime)
     file = schema.One(schema.Text)
     exif = schema.Mapping(schema.Text, initialValue={})
-    photoBody = schema.One(schema.Lob, afterChange=['onPhotoBodyChanged'])
+    photoBody = schema.One(schema.Lob)
 
+    @schema.observer(photoBody)
+    def onPhotoBodyChanged(self, attribute):
+        self.processEXIF()
+
     about = schema.One(redirectTo = 'displayName')
     date = schema.One(redirectTo = 'dateTaken')
     who = schema.One(redirectTo = 'creator')
@@ -104,10 +108,7 @@
             logger.debug("Couldn't process EXIF of Photo %s (%s)" % \
                 (self.itsPath, e))
 
-    def onPhotoBodyChanged(self, attribute):
-        self.processEXIF()
 
-
 class Photo(PhotoMixin, pim.Note):
     pass
 
@@ -146,7 +147,7 @@
             photo.displayName = filename
             photo.creator = schema.ns("osaf.pim", self.itsView).currentContact.item
             photo.importFromFile(path)
-    
+
         theApp.CallItemMethodAsync("MainView",
                                    'setStatusMessage',"")
         return photo




_______________________________________________
Commits mailing list
[email protected]
http://lists.osafoundation.org/mailman/listinfo/commits

Reply via email to