changeset 799:fcf95ff23cc5

PersistentSubmaster split. keyboardcomposer now notices submaster changes Ignore-this: 2ea847e25af9b784cfec6aa4335dcc70
author drewp@bigasterisk.com
date Mon, 16 Jul 2012 21:51:04 +0000
parents 5c158d37f1ce
children 1274e041b579
files bin/keyboardcomposer bin/rdfdb light9/Submaster.py light9/rdfdb/graphfile.py light9/rdfdb/syncedgraph.py
diffstat 5 files changed, 310 insertions(+), 247 deletions(-) [+]
line wrap: on
line diff
--- a/bin/keyboardcomposer	Mon Jul 16 00:49:57 2012 +0000
+++ b/bin/keyboardcomposer	Mon Jul 16 21:51:04 2012 +0000
@@ -4,7 +4,7 @@
 import cgi, os, sys, time, subprocess, logging
 from optparse import OptionParser
 import webcolors, colorsys
-
+from louie import dispatcher
 from twisted.internet import reactor, tksupport
 from twisted.web import xmlrpc, server, resource
 from Tix import *
@@ -19,6 +19,8 @@
 from light9.uihelpers import toplevelat, bindkeys
 from light9.namespaces import L9
 from light9.tkdnd import initTkdnd, dragSourceRegister
+from light9.rdfdb.syncedgraph import SyncedGraph
+
 from bcf2000 import BCF2000
 
 nudge_keys = {
@@ -55,6 +57,7 @@
 
 class SubmasterTk(Frame):
     def __init__(self, master, sub, current_level):
+        self.sub = sub
         bg = sub.graph.value(sub.uri, L9.color, default='#000000')
         rgb = webcolors.hex_to_rgb(bg)
         hsv = colorsys.rgb_to_hsv(*[x/255 for x in rgb])
@@ -65,28 +68,34 @@
         self.slider_var = DoubleVar()
         self.slider_var.set(current_level)
         self.scale = SubScale(self, variable=self.slider_var, width=20)
-        namelabel = Label(self, text=sub.name, font="Arial 7", bg=darkBg,
+        
+        self.namelabel = Label(self, font="Arial 7", bg=darkBg,
             fg='white', pady=0)
-        namelabel.pack(side=TOP)
+        self.sub.graph.addHandler(self.updateName)
+        
+        self.namelabel.pack(side=TOP)
         levellabel = Label(self, textvariable=self.slider_var, font="Arial 7",
             bg='black', fg='white', pady=0)
         levellabel.pack(side=TOP)
         self.scale.pack(side=BOTTOM, expand=1, fill=BOTH)
         bindkeys(self, "<Control-Key-l>", self.launch_subcomposer)
 
-        for w in [self, namelabel, levellabel]:
+        for w in [self, self.namelabel, levellabel]:
             dragSourceRegister(w, 'copy', 'text/uri-list', sub.uri)
 
+    def updateName(self):
+        self.namelabel.config(text=self.sub.graph.label(self.sub.uri))
+
     def launch_subcomposer(self, *args):
         subprocess.Popen(["bin/subcomposer", "--no-geometry", self.name])
 
 class KeyboardComposer(Frame, SubClient):
-    def __init__(self, root, graph, submasters, current_sub_levels=None,
+    def __init__(self, root, graph, current_sub_levels=None,
                  hw_sliders=True):
         Frame.__init__(self, root, bg='black')
         SubClient.__init__(self)
         self.graph = graph
-        self.submasters = submasters
+        self.submasters = Submasters(graph)
         self.name_to_subtk = {}
         self.current_sub_levels = {}
         self.current_row = 0
@@ -101,21 +110,14 @@
 
         self.use_hw_sliders = hw_sliders
         self.connect_to_hw(hw_sliders)
-        self.draw_ui()
+
+        self.make_key_hints()
+        self.make_buttons()
+
+        self.graph.addHandler(self.redraw_sliders)
         self.send_levels_loop()
 
-    def draw_ui(self):
-        self.rows = [] # this holds Tk Frames for each row
-        self.slider_vars = {} # this holds subname:sub Tk vars
-        self.slider_table = {} # this holds coords:sub Tk vars
-        self.name_to_subtk.clear() # subname : SubmasterTk instance
-
-        self.make_key_hints()
-        self.draw_sliders()
-        if len(self.rows):
-            self.change_row(self.current_row)
-            self.rows[self.current_row].focus()
-
+    def make_buttons(self):
         self.buttonframe = Frame(self, bg='black')
         self.buttonframe.pack(side=BOTTOM)
 
@@ -131,10 +133,6 @@
             command=self.alltozero, bg='black', fg='white')
         self.alltozerobutton.pack(side='left')
 
-        self.refreshbutton = Button(self.buttonframe, text="Refresh", 
-            command=self.refresh, bg='black', fg='white')
-        self.refreshbutton.pack(side=LEFT)
-
         self.save_stage_button = Button(self.buttonframe, text="Save", 
             command=lambda: self.save_current_stage(self.sub_name.get()), 
             bg='black', fg='white')
@@ -142,18 +140,53 @@
         self.sub_name = Entry(self.buttonframe, bg='black', fg='white')
         self.sub_name.pack(side=LEFT)
 
+
+    def redraw_sliders(self):
+        self.slider_vars = {} # this holds subname:sub Tk vars
+        self.slider_table = {} # this holds coords:sub Tk vars
+        self.name_to_subtk.clear() # subname : SubmasterTk instance
+
+        self.graph.addHandler(self.draw_sliders)
+        if len(self.rows):
+            self.change_row(self.current_row)
+            self.rows[self.current_row].focus()
+
         self.stop_frequent_update_time = 0
-                
+
+    def onNewSub(self, sub):
+        log.info("new %s", sub)
+        self.graph.addHandler(self.draw_sliders)
+
+    def onLostSub(self, subUri):
+        log.info("lost %s", subUri)
+        self.graph.addHandler(self.draw_sliders)
+    
     def draw_sliders(self):
+
+
+        if hasattr(self, 'rows'):
+            for r in self.rows:
+                r.destroy()
+        self.rows = [] # this holds Tk Frames for each row
+
+        
         self.tk_focusFollowsMouse()
 
         rowcount = -1
         col = 0
         last_group = None
+
+        # there are unlikely to be any subs at startup because we
+        # probably haven't been called back with the graph data yet
+        
+        #read get_all_subs then watch 'new submaster' 'lost submaster' signals
         withgroups = sorted((self.graph.value(sub.uri, L9['group']), 
                              self.graph.value(sub.uri, L9['order']), 
                              sub)
             for sub in self.submasters.get_all_subs())
+        dispatcher.connect(self.onNewSub, "new submaster")
+        dispatcher.connect(self.onLostSub, "lost submaster")
+        log.info("withgroups %s", withgroups)
 
         for group, order, sub in withgroups:
             group = self.graph.value(sub.uri, L9['group'])
@@ -203,7 +236,7 @@
             try:
                 self.sliders = Sliders(self)
             except IOError:
-                print "Couldn't actually find any sliders (but really, it's no problem)"
+                log.info("no hardware sliders")
                 self.sliders = DummySliders()
                 self.use_hw_sliders = False
         else:
@@ -256,6 +289,7 @@
         if event.keysym in ('Prior', 'p', 'bracketright'):
             diff = -1
         self.change_row(self.current_row + diff)
+
     def change_row(self, row):
         old_row = self.current_row
         self.current_row = row
@@ -328,20 +362,24 @@
     def highlight_row(self, row):
         row = self.rows[row]
         row['bg'] = 'red'
+
     def unhighlight_row(self, row):
         row = self.rows[row]
         row['bg'] = 'black'
+
     def get_levels(self):
         return dict([(name, slidervar.get()) 
             for name, slidervar in self.slider_vars.items()])
+
     def get_levels_as_sub(self):
         scaledsubs = [self.submasters.get_sub_by_name(sub) * level \
             for sub, level in self.get_levels().items() if level > 0.0]
 
         maxes = sub_maxes(*scaledsubs)
         return maxes
+
     def save_current_stage(self, subname):
-        print "saving current levels as", subname
+        log.info("saving current levels as %s", subname)
         sub = self.get_levels_as_sub()
         sub.name = subname
         sub.temporary = 0
@@ -356,20 +394,6 @@
             self.send_levels()
             self.after(10, self.send_frequent_updates)
 
-    def refresh(self):
-        self.save()
-        graph = showconfig.getGraph()
-        self.submasters = Submasters(graph)
-        self.current_sub_levels, self.current_row = \
-            pickle.load(file('.keyboardcomposer.savedlevels'))
-        for r in self.rows:
-            r.destroy()
-        self.keyhints.destroy()
-        self.buttonframe.destroy()
-        self.draw_ui()
-        # possibly paranoia (but possibly not)
-        self.change_row(self.current_row)
-
     def alltozero(self):
         for name, subtk in self.name_to_subtk.items():
             if subtk.scale.scale_var.get() != 0:
@@ -463,10 +487,9 @@
     opts, args = parser.parse_args()
 
     logging.basicConfig(level=logging.INFO if opts.v else logging.WARN)
-    log = logging.getLogger()
+    log = logging.getLogger('keyboardcomposer')
 
-    graph = showconfig.getGraph()
-    s = Submasters(graph)
+    graph = SyncedGraph("keyboardcomposer")
 
     root = Tk()
     initTkdnd(root.tk, 'tkdnd/trunk/')
@@ -476,7 +499,7 @@
     startLevels = None
     if opts.nonpersistent:
         startLevels = {}
-    kc = KeyboardComposer(tl, graph, s, startLevels,
+    kc = KeyboardComposer(tl, graph, startLevels,
                           hw_sliders=not opts.no_sliders)
     kc.pack(fill=BOTH, expand=1)
 
@@ -489,8 +512,8 @@
         reactor.listenTCP(networking.keyboardComposer.port,
                           server.Site(LevelServerHttp(kc.name_to_subtk)))
     except twisted.internet.error.CannotListenError, e:
-        print "Can't (and won't!) start level server:"
-        print e
+        log.warn("Can't (and won't!) start level server:")
+        log.warn(e)
 
     root.protocol('WM_DELETE_WINDOW', reactor.stop)
     if not opts.nonpersistent:
@@ -500,5 +523,5 @@
 
 
 #    prof.watchPoint("/usr/lib/python2.4/site-packages/rdflib-2.3.3-py2.4-linux-i686.egg/rdflib/Graph.py", 615)
-    
+
     prof.run(reactor.run, profile=False)
--- a/bin/rdfdb	Mon Jul 16 00:49:57 2012 +0000
+++ b/bin/rdfdb	Mon Jul 16 21:51:04 2012 +0000
@@ -70,7 +70,9 @@
 registered clients
 
 recent edits, each one says what client it came from. You can reverse
-them here.
+them here. We should be able to take patches that are close in time
+and keep updating the same data (e.g. a stream of changes as the user
+drags a slider) and collapse them into a single edit for clarity.
 
 """
 from twisted.internet import reactor
@@ -131,7 +133,9 @@
 
         for inFile in [#"show/dance2012/config.n3",
                        "show/dance2012/subs/bcools",
-                       #"demo.n3",
+                       "show/dance2012/subs/bwarm",
+                       "show/dance2012/subs/house",
+                       "demo.n3",
                        ]:
             self.g = GraphFile(notifier,
                                inFile,
--- a/light9/Submaster.py	Mon Jul 16 00:49:57 2012 +0000
+++ b/light9/Submaster.py	Mon Jul 16 21:51:04 2012 +0000
@@ -1,100 +1,166 @@
 from __future__ import division
 import os, logging, time
-from rdflib import Graph
+from rdflib import Graph, RDF
 from rdflib import RDFS, Literal, BNode
 from light9.namespaces import L9, XSD
 from light9.TLUtility import dict_scale, dict_max
 from light9 import Patch, showconfig
-try:
-    import dispatch.dispatcher as dispatcher
-except ImportError:
-    from louie import dispatcher
-log = logging.getLogger()
+from louie import dispatcher
+log = logging.getLogger('submaster')
 
-class Submaster:
+class Submaster(object):
     "Contain a dictionary of levels, but you didn't need to know that"
-    def __init__(self,
-                 name=None,
-                 graph=None, sub=None,
-                 leveldict=None, temporary=False):
-        """sub is the URI for this submaster, graph is a graph where
-        we can learn about the sub. If graph is not provided, we look
-        in a file named name.
+    def __init__(self, name, levels):
+        """this sub has a name just for debugging. It doesn't get persisted. 
+        See PersistentSubmaster.
+
+        levels is a dict
+        """
+        self.name = name
+        self.levels = levels
+
+        self.temporary = True
+        
+        if not self.temporary:
+            # obsolete
+            dispatcher.connect(log.error, 'reload all subs')
+            
+        log.debug("%s initial levels %s", self.name, self.levels)
+
+    def _editedLevels(self):
+        pass
+    
+    def set_level(self, channelname, level, save=True):
+        self.levels[Patch.resolve_name(channelname)] = level
+        self._editedLevels()
+
+    def set_all_levels(self, leveldict):
+        self.levels.clear()
+        for k, v in leveldict.items():
+            # this may call _editedLevels too many times
+            self.set_level(k, v, save=0)
 
-        name is the filename where we can load a graph about this URI
-        (see showconfig.subFile)
+    def get_levels(self):
+        return self.levels
+    
+    def no_nonzero(self):
+        return (not self.levels.values()) or not (max(self.levels.values()) > 0)
+
+    def __mul__(self, scalar):
+        return Submaster("%s*%s" % (self.name, scalar), 
+                         levels=dict_scale(self.levels, scalar))
+    __rmul__ = __mul__
+    def max(self, *othersubs):
+        return sub_maxes(self, *othersubs)
+
+    def __add__(self, other):
+        return self.max(other)
 
-        passing name alone makes a new empty sub
+    def ident(self):
+        return (self.name, tuple(sorted(self.levels.items())))
 
-        temporary means the sub won't get saved or loaded
+    def __repr__(self):
+        items = getattr(self, 'levels', {}).items()
+        items.sort()
+        levels = ' '.join(["%s:%.2f" % item for item in items])
+        return "<'%s': [%s]>" % (getattr(self, 'name', 'no name yet'), levels)
+    
+    def __cmp__(self, other):
+        # not sure how useful this is
+        return cmp(self.ident(), other.ident())
+    
+    def __hash__(self):
+        return hash(self.ident())
+
+    def get_dmx_list(self):
+        leveldict = self.get_levels() # gets levels of sub contents
 
+        levels = []
+        for k, v in leveldict.items():
+            if v == 0:
+                continue
+            try:
+                dmxchan = Patch.get_dmx_channel(k) - 1
+            except ValueError:
+                log.error("error trying to compute dmx levels for submaster %s" % self.name)
+                raise
+            if dmxchan >= len(levels):
+                levels.extend([0] * (dmxchan - len(levels) + 1))
+            levels[dmxchan] = max(v, levels[dmxchan])
 
-        pass:
-          name, temporary=True  -  no rdf involved
-          sub, filename         -  read sub URI from graph at filename
-          
-          name - new sub
-          sub - n
-          name, sub - new 
+        return levels
+    
+    def normalize_patch_names(self):
+        """Use only the primary patch names."""
+        # possibly busted -- don't use unless you know what you're doing
+        self.set_all_levels(self.levels.copy())
+
+    def get_normalized_copy(self):
+        """Get a copy of this sumbaster that only uses the primary patch 
+        names.  The levels will be the same."""
+        newsub = Submaster("%s (normalized)" % self.name, {})
+        newsub.set_all_levels(self.levels)
+        return newsub
+    
+    def crossfade(self, othersub, amount):
+        """Returns a new sub that is a crossfade between this sub and
+        another submaster.  
         
-        """
-        if name is sub is leveldict is None:
-            raise TypeError("more args are needed")
-        if sub is not None and name is None:
-            name = graph.label(sub)
-        if graph is not None:
-            # old code was passing leveldict as second positional arg
-            assert isinstance(graph, Graph)
-        self.name = name
-        self.uri = sub
+        NOTE: You should only crossfade between normalized submasters."""
+        otherlevels = othersub.get_levels()
+        keys_set = {}
+        for k in self.levels.keys() + otherlevels.keys():
+            keys_set[k] = 1
+        all_keys = keys_set.keys()
+
+        xfaded_sub = Submaster("xfade", {})
+        for k in all_keys:
+            xfaded_sub.set_level(k, 
+                                 linear_fade(self.levels.get(k, 0),
+                                             otherlevels.get(k, 0),
+                                             amount))
+
+        return xfaded_sub
+
+class PersistentSubmaster(Submaster):
+    def __init__(self, graph, uri):
+        self.graph, self.uri = graph, uri
+        self.graph.addHandler(self.setName)
+        self.graph.addHandler(self.setLevels)
+        Submaster.__init__(self, self.name, self.levels)
         self.graph = graph
-        self.temporary = temporary
-        if leveldict:
-            self.levels = leveldict
+        self.uri = uri
+        self.temporary = False
+
+    def ident(self):
+        return self.uri
+        
+    def _editedLevels(self):
+        self.save()
+        
+    def setName(self):
+        log.info("sub update name %s %s", self.uri, self.graph.label(self.uri))
+        self.name = self.graph.label(self.uri)
+        
+    def setLevels(self):
+        log.info("sub update levels")
+        oldLevels = getattr(self, 'levels', {}).copy()
+        self.setLevelsFromGraph()
+        if oldLevels != self.levels:
+            log.info("sub %s changed" % self.name)
+        
+    def setLevelsFromGraph(self):
+        patchGraph = showconfig.getGraph() # going away
+        if hasattr(self, 'levels'):
+            self.levels.clear()
         else:
             self.levels = {}
-            self.reload(quiet=True, graph=graph)
-        if not self.temporary:
-            dispatcher.connect(self.reload, 'reload all subs')
-        log.debug("%s initial levels %s", self.name, self.levels)
-        
-    def reload(self, quiet=False, graph=None):
-        if self.temporary:
-            return
-        try:
-            oldlevels = self.levels.copy()
-            self.levels.clear()
-            patchGraph = showconfig.getGraph()
-            if 1 or graph is None:
-                # need to read the sub graph to build the levels, not
-                # use the main one! The sub graphs will eventually
-                # just be part of the one and only shared graph
-                graph = Graph()
-                if not self.name:
-                    # anon sub, maybe for a chase
-                    pass
-                else:
-                    inFile = showconfig.subFile(self.name)
-
-                    t1 = time.time()
-                    graph.parse(inFile, format="n3")
-                    log.info("reading %s in %.1fms", inFile, 1000 * (time.time() - t1))
-                    
-                self.setLevelsFromGraph(graph, patchGraph)
-                
-            if (not quiet) and (oldlevels != self.levels):
-                log.info("sub %s changed" % self.name)
-        except IOError, e:
-            log.error("Can't read file for sub: %r (%s)" % (self.name, e))
-
-    def setLevelsFromGraph(self, graph, patchGraph):
-        self.uri = L9['sub/%s' % self.name]
-        for lev in graph.objects(self.uri, L9['lightLevel']):
-            chan = graph.value(lev, L9['channel'])
-            val = graph.value(lev, L9['level'])
+        for lev in self.graph.objects(self.uri, L9['lightLevel']):
+            chan = self.graph.value(lev, L9['channel'])
+            val = self.graph.value(lev, L9['level'])
             name = patchGraph.label(chan)
             if not name:
-                log.error("sub %r has channel %r with no name- leaving out that channel" % (self.name, chan))
+                #log.error("sub %r has channel %r with no name- leaving out that channel" % (self.name, chan))
                 continue
             self.levels[name] = float(val)
 
@@ -120,87 +186,6 @@
 
         graph.serialize(showconfig.subFile(self.name), format="nt")
 
-    def set_level(self, channelname, level, save=True):
-        self.levels[Patch.resolve_name(channelname)] = level
-        if save:
-            self.save()
-    def set_all_levels(self, leveldict):
-        self.levels.clear()
-        for k, v in leveldict.items():
-            self.set_level(k, v, save=0)
-        self.save()
-    def get_levels(self):
-        return self.levels
-    def no_nonzero(self):
-        return (not self.levels.values()) or not (max(self.levels.values()) > 0)
-    def __mul__(self, scalar):
-        return Submaster("%s*%s" % (self.name, scalar), 
-                         leveldict=dict_scale(self.levels, scalar),
-                         temporary=True)
-    __rmul__ = __mul__
-    def max(self, *othersubs):
-        return sub_maxes(self, *othersubs)
-
-    def __add__(self, other):
-        return self.max(other)
-
-    def __repr__(self):
-        items = self.levels.items()
-        items.sort()
-        levels = ' '.join(["%s:%.2f" % item for item in items])
-        return "<'%s': [%s]>" % (self.name, levels)
-    def get_dmx_list(self):
-        leveldict = self.get_levels() # gets levels of sub contents
-
-        levels = []
-        for k, v in leveldict.items():
-            if v == 0:
-                continue
-            try:
-                dmxchan = Patch.get_dmx_channel(k) - 1
-            except ValueError:
-                log.error("error trying to compute dmx levels for submaster %s" % self.name)
-                raise
-            if dmxchan >= len(levels):
-                levels.extend([0] * (dmxchan - len(levels) + 1))
-            levels[dmxchan] = max(v, levels[dmxchan])
-
-        return levels
-    def normalize_patch_names(self):
-        """Use only the primary patch names."""
-        # possibly busted -- don't use unless you know what you're doing
-        self.set_all_levels(self.levels.copy())
-    def get_normalized_copy(self):
-        """Get a copy of this sumbaster that only uses the primary patch 
-        names.  The levels will be the same."""
-        newsub = Submaster("%s (normalized)" % self.name, temporary=1)
-        newsub.set_all_levels(self.levels)
-        return newsub
-    def crossfade(self, othersub, amount):
-        """Returns a new sub that is a crossfade between this sub and
-        another submaster.  
-        
-        NOTE: You should only crossfade between normalized submasters."""
-        otherlevels = othersub.get_levels()
-        keys_set = {}
-        for k in self.levels.keys() + otherlevels.keys():
-            keys_set[k] = 1
-        all_keys = keys_set.keys()
-
-        xfaded_sub = Submaster("xfade", temporary=1)
-        for k in all_keys:
-            xfaded_sub.set_level(k, 
-                                 linear_fade(self.levels.get(k, 0),
-                                             otherlevels.get(k, 0),
-                                             amount))
-
-        return xfaded_sub
-    def __cmp__(self, other):
-        """Compare by sub repr (name, hopefully)"""
-        return cmp(repr(self), repr(other))
-    def __hash__(self):
-        raise NotImplementedError
-        return hash(repr(self))
                                             
 def linear_fade(start, end, amount):
     """Fades between two floats by an amount.  amount is a float between
@@ -213,8 +198,7 @@
     nonzero_subs = [s for s in subs if not s.no_nonzero()]
     name = "max(%s)" % ", ".join([repr(s) for s in nonzero_subs])
     return Submaster(name,
-                     leveldict=dict_max(*[sub.levels for sub in nonzero_subs]),
-                     temporary=1)
+                     levels=dict_max(*[sub.levels for sub in nonzero_subs]))
 
 def combine_subdict(subdict, name=None, permanent=False):
     """A subdict is { Submaster objects : levels }.  We combine all
@@ -236,17 +220,24 @@
     "Collection o' Submaster objects"
     def __init__(self, graph):
         self.submasters = {}
+        self.graph = graph
+        
+        graph.addHandler(self.findSubs)
 
-        files = os.listdir(showconfig.subsDir())
-        t1 = time.time()
-        for filename in files:
-            # we don't want these files
-            if filename.startswith('.') or filename.endswith('~') or \
-               filename.startswith('CVS'):
-                continue
-            self.submasters[filename] = Submaster(filename, graph=graph)
-        log.info("loaded all submasters in %.1fms" % ((time.time() - t1) * 1000))
-        
+    def findSubs(self):
+        current = set()
+
+        for s in self.graph.subjects(RDF.type, L9['Submaster']):
+            log.info("found sub %s", s)
+            if s not in self.submasters:
+                sub = self.submasters[s] = PersistentSubmaster(self.graph, s)
+                dispatcher.send("new submaster", sub=sub)
+                current.add(s)
+        for s in set(self.submasters.keys()) - current:
+            del self.submasters[s]
+            dispatcher.send("lost submaster", subUri=s)
+        log.info("findSubs finished %s", self.submasters)
+
     def get_all_subs(self):
         "All Submaster objects"
         l = self.submasters.items()
@@ -255,7 +246,7 @@
         songs = []
         notsongs = []
         for s in l:
-            if s.name.startswith('song'):
+            if s.name and s.name.startswith('song'):
                 songs.append(s)
             else:
                 notsongs.append(s)
--- a/light9/rdfdb/graphfile.py	Mon Jul 16 00:49:57 2012 +0000
+++ b/light9/rdfdb/graphfile.py	Mon Jul 16 21:51:04 2012 +0000
@@ -1,4 +1,4 @@
-import logging
+import logging, traceback
 from twisted.python.filepath import FilePath
 from rdflib import Graph
 from light9.rdfdb.patch import Patch
@@ -18,7 +18,10 @@
       
     def notify(self, notifier, filepath, mask):
         log.info("file %s changed" % filepath)
-        self.reread()
+        try:
+            self.reread()
+        except Exception:
+            traceback.print_exc()
 
     def reread(self):
         """update the graph with any diffs from this file"""
--- a/light9/rdfdb/syncedgraph.py	Mon Jul 16 00:49:57 2012 +0000
+++ b/light9/rdfdb/syncedgraph.py	Mon Jul 16 21:51:04 2012 +0000
@@ -1,7 +1,7 @@
-from rdflib import ConjunctiveGraph, RDFS
+from rdflib import ConjunctiveGraph, RDFS, RDF
 import logging, cyclone.httpclient, traceback, urllib
 from twisted.internet import reactor
-log = logging.getLogger()
+log = logging.getLogger('syncedgraph')
 from light9.rdfdb.patch import Patch, ALLSTMTS
 from light9.rdfdb.rdflibpatch import patchQuads
 
@@ -44,6 +44,7 @@
     """
     def __init__(self):
         self._handlersSp = {} # (s,p): set(handlers)
+        self._handlersPo = {} # (p,o): set(handlers)
 
     def addSubjPredWatcher(self, func, s, p):
         if func is None:
@@ -52,19 +53,34 @@
         try:
             self._handlersSp.setdefault(key, set()).add(func)
         except Exception:
-            print "with key %r and func %r" % (key, func)
+            log.error("with key %r and func %r" % (key, func))
             raise
 
+    def addPredObjWatcher(self, func, p, o):
+        self._handlersPo.setdefault((p, o), set()).add(func)
+
     def whoCares(self, patch):
-        """what handler functions would care about the changes in this patch"""
+        """what handler functions would care about the changes in this patch?
+
+        this removes the handlers that it gives you
+        """
         self.dependencies()
         affectedSubjPreds = set([(s, p) for s, p, o, c in patch.addQuads]+
                                 [(s, p) for s, p, o, c in patch.delQuads])
+        affectedPredObjs = set([(p, o) for s, p, o, c in patch.addQuads]+
+                                [(p, o) for s, p, o, c in patch.delQuads])
         
         ret = set()
-        for (s,p), func in self._handlersSp.iteritems():
-            if (s,p) in affectedSubjPreds:
-                ret.update(func)
+        for (s, p), funcs in self._handlersSp.iteritems():
+            if (s, p) in affectedSubjPreds:
+                ret.update(funcs)
+                funcs.clear()
+                
+        for (p, o), funcs in self._handlersPo.iteritems():
+            if (p, o) in affectedPredObjs:
+                ret.update(funcs)
+                funcs.clear()
+
         return ret
 
     def dependencies(self):
@@ -73,7 +89,7 @@
         data they depend on. This is meant for showing on the web ui
         for browsing.
         """
-        print "whocares"
+        log.info("whocares:")
         from pprint import pprint
         pprint(self._handlersSp)
         
@@ -111,12 +127,12 @@
         self.updateResource = 'http://localhost:%s/update' % port
         log.info("listening on %s" % port)
         self.register(label)
-        self.currentFunc = None
+        self.currentFuncs = [] # stack of addHandler callers
 
     def register(self, label):
 
         def done(x):
-            print "registered", x.body
+            log.debug("registered with rdfdb")
 
         cyclone.httpclient.fetch(
             url='http://localhost:8051/graphClients',
@@ -152,12 +168,12 @@
         # reveals all their statement fetches? Just make them always
         # new? Cache their results, so if i make the query again and
         # it gives the same result, I don't call the handler?
-        
-        self.currentFunc = func
+
+        self.currentFuncs.append(func)
         try:
             func()
         finally:
-            self.currentFunc = None
+            self.currentFuncs.pop()
 
     def updateOnPatch(self, p):
         """
@@ -165,26 +181,52 @@
         might care, and then notice what data they depend on now
         """
         for func in self._watchers.whoCares(p):
-            # and forget the old one!
+            # todo: forget the old handlers for this func
             self.addHandler(func)
 
-    def _assertCurrent(self):
-        if self.currentFunc is None:
+    def _getCurrentFunc(self):
+        if not self.currentFuncs:
             # this may become a warning later
             raise ValueError("asked for graph data outside of a handler")
 
+        # we add the watcher to the deepest function, since that
+        # should be the cheapest way to update when this part of the
+        # data changes
+        return self.currentFuncs[-1]
+
     # these just call through to triples() so it might be possible to
-    # watch just that one
-    def value(self, subj, pred):
-        self._assertCurrent()
-        self._watchers.addSubjPredWatcher(self.currentFunc, subj, pred)
-        return self._graph.value(subj, pred)
+    # watch just that one.
+
+    # if you get a bnode in your response, maybe the answer to
+    # dependency tracking is to say that you depended on the triple
+    # that got you that bnode, since it is likely to change to another
+    # bnode later. This won't work if the receiver stores bnodes
+    # between calls, but probably most of them don't do that (they
+    # work from a starting uri)
+    
+    def value(self, subject=None, predicate=RDF.value, object=None,
+              default=None, any=True):
+        if object is not None:
+            raise NotImplementedError()
+        func = self._getCurrentFunc()
+        self._watchers.addSubjPredWatcher(func, subject, predicate)
+        return self._graph.value(subject, predicate, object=object,
+                                 default=default, any=any)
 
     def objects(self, subject=None, predicate=None):
-        self._assertCurrent()
-        self._watchers.addSubjPredWatcher(self.currentFunc, subject, predicate)
+        func = self._getCurrentFunc()
+        self._watchers.addSubjPredWatcher(func, subject, predicate)
         return self._graph.objects(subject, predicate)
     
     def label(self, uri):
-        self._assertCurrent()
         return self.value(uri, RDFS.label)
+
+    def subjects(self, predicate=None, object=None):
+        func = self._getCurrentFunc()
+        self._watchers.addPredObjWatcher(func, predicate, object)
+        return self._graph.subjects(predicate, object)
+
+    # i find myself wanting 'patch' versions of these calls that tell
+    # you only what results have just appeared or disappeared. I think
+    # I'm going to be repeating that logic a lot. Maybe just for the
+    # subjects(RDF.type, t) call