changeset 808:a631e075a5bf

KC big rewrites, now multiple KC instances can sync with rdfdb Ignore-this: 8c75ec3e2bd360c6eb87f7f4d4b3dcc4
author drewp@bigasterisk.com
date Thu, 19 Jul 2012 04:23:06 +0000
parents 3fe281a3be70
children 7f1aef5fbddb
files bin/keyboardcomposer bin/rdfdb light9/Submaster.py light9/rdfdb/patch.py light9/rdfdb/rdflibpatch.py light9/rdfdb/syncedgraph.py
diffstat 6 files changed, 321 insertions(+), 120 deletions(-) [+]
line wrap: on
line diff
--- a/bin/keyboardcomposer	Wed Jul 18 18:08:12 2012 +0000
+++ b/bin/keyboardcomposer	Thu Jul 19 04:23:06 2012 +0000
@@ -1,12 +1,13 @@
 #!bin/python
 
 from __future__ import division, nested_scopes
-import cgi, os, sys, time, subprocess, logging
+import cgi, os, sys, time, subprocess, logging, random
 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 rdflib import URIRef, Literal, RDF
 from Tix import *
 import Tix as tk
 import pickle
@@ -20,6 +21,7 @@
 from light9.namespaces import L9
 from light9.tkdnd import initTkdnd, dragSourceRegister
 from light9.rdfdb.syncedgraph import SyncedGraph
+from light9.rdfdb.patch import Patch
 
 from bcf2000 import BCF2000
 
@@ -55,8 +57,12 @@
         else:
             self['troughcolor'] = 'blue'
 
-class SubmasterTk(Frame):
-    def __init__(self, master, sub, current_level):
+class SubmasterBox(Frame):
+    """
+    this object owns the level of the submaster (the rdf graph is the
+    real authority)
+    """
+    def __init__(self, master, sub):
         self.sub = sub
         bg = sub.graph.value(sub.uri, L9.color, default='#000000')
         rgb = webcolors.hex_to_rgb(bg)
@@ -66,7 +72,7 @@
         Frame.__init__(self, master, bd=1, relief='raised', bg=bg)
         self.name = sub.name
         self.slider_var = DoubleVar()
-        self.slider_var.set(current_level)
+        self.pauseTrace = False
         self.scale = SubScale(self, variable=self.slider_var, width=20)
         
         self.namelabel = Label(self, font="Arial 7", bg=darkBg,
@@ -83,6 +89,66 @@
         for w in [self, self.namelabel, levellabel]:
             dragSourceRegister(w, 'copy', 'text/uri-list', sub.uri)
 
+        self._slider_var_trace = self.slider_var.trace('w', self.slider_changed)
+
+        sub.graph.addHandler(self.updateLevelFromGraph)
+
+        # initial position
+#        self.send_to_hw(sub.name, col + 1) # needs fix
+
+    def cleanup(self):
+        self.slider_var.trace_vdelete('w', self._slider_var_trace)
+    
+    def slider_changed(self, *args):
+        self.scale.draw_indicator_colors()
+
+        if self.pauseTrace:
+            return
+        self.updateGraphWithLevel(self.sub.uri, self.slider_var.get())
+        # dispatcher.send("level changed") # in progress
+        ###self.send_levels() # use dispatcher? 
+
+        # needs fixing: plan is to use dispatcher or a method call to tell a hardware-mapping object who changed, and then it can make io if that's a current hw slider
+        #if rowcount == self.current_row:
+        #    self.send_to_hw(sub.name, col + 1)
+
+    def updateGraphWithLevel(self, uri, level):
+        """in our per-session graph, we maintain SubSetting objects like this:
+
+           [a :SubSetting; :sub ?s; :level ?l]
+        """
+        ctx = URIRef("http://example.com/kc/session1")
+        with self.sub.graph.currentState(context=ctx) as graph:
+            adds = set([])
+            for setting in graph.subjects(RDF.type, L9['SubSetting']):
+                if graph.value(setting, L9['sub']) == uri:
+                    break
+            else:
+                setting = URIRef("http://example.com/subsetting/%s" %
+                                 random.randrange(999999))
+                adds.update([
+                    (setting, RDF.type, L9['SubSetting'], ctx),
+                    (setting, L9['sub'], uri, ctx),
+                    ])
+            dels = set([])
+            for prev in graph.objects(setting, L9['level']):
+                dels.add((setting, L9['level'], prev, ctx))
+            adds.add((setting, L9['level'], Literal(level), ctx))
+
+            if adds != dels:
+                self.sub.graph.patch(Patch(delQuads=dels, addQuads=adds))
+
+    def updateLevelFromGraph(self):
+        """read rdf level, write it to subbox.slider_var"""
+        graph = self.sub.graph
+        for setting in graph.subjects(RDF.type, L9['SubSetting']):
+            if graph.value(setting, L9['sub']) == self.sub.uri:
+                self.pauseTrace = True # don't bounce this update back to server
+                try:
+                    self.slider_var.set(graph.value(setting, L9['level']))
+                finally:
+                    self.pauseTrace = False
+
     def updateName(self):
         self.namelabel.config(text=self.sub.graph.label(self.sub.uri))
 
@@ -90,23 +156,17 @@
         subprocess.Popen(["bin/subcomposer", "--no-geometry", self.name])
 
 class KeyboardComposer(Frame, SubClient):
-    def __init__(self, root, graph, current_sub_levels=None,
+    def __init__(self, root, graph,
                  hw_sliders=True):
         Frame.__init__(self, root, bg='black')
         SubClient.__init__(self)
         self.graph = graph
         self.submasters = Submasters(graph)
-        self.name_to_subtk = {}
-        self.current_sub_levels = {}
-        self.current_row = 0
-        if current_sub_levels is not None:
-            self.current_sub_levels = current_sub_levels
-        else:
-            try:
-                self.current_sub_levels, self.current_row = \
-                    pickle.load(file('.keyboardcomposer.savedlevels'))
-            except IOError:
-                pass
+        self.subbox = {} # sub uri : SubmasterBox
+        self.slider_table = {} # coords : SubmasterBox
+        self.rows = [] # this holds Tk Frames for each row
+        
+        self.current_row = 0 # should come from session graph
 
         self.use_hw_sliders = hw_sliders
         self.connect_to_hw(hw_sliders)
@@ -141,10 +201,6 @@
         self.sub_name.pack(side=LEFT)
 
     def redraw_sliders(self):
-        self.slider_vars = {} # this holds suburi : 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)
@@ -161,13 +217,13 @@
         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
-
+        for r in self.rows:
+            r.destroy()
+        self.rows = []
+        for b in self.subbox.values():
+            b.cleanup()
+        self.subbox.clear()
+        self.slider_table.clear()
         
         self.tk_focusFollowsMouse()
 
@@ -178,7 +234,6 @@
         # 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)
@@ -190,37 +245,20 @@
         for group, order, sub in withgroups:
             group = self.graph.value(sub.uri, L9['group'])
 
-            if col == 0 or group != last_group: # make new row
+            if col == 0 or group != last_group:
                 row = self.make_row()
                 rowcount += 1
                 col = 0
-            current_level = self.current_sub_levels.get(sub.name, 0)
-            subtk = self.draw_sub_slider(row, col, sub, current_level)
-            self.slider_table[(rowcount, col)] = subtk
-            self.name_to_subtk[sub.name] = subtk
 
-            def slider_changed(x, y, z, subtk=subtk,
-                               col=col, sub=sub, rowcount=rowcount):
-                subtk.scale.draw_indicator_colors()
-                self.send_levels()
-                if rowcount == self.current_row:
-                    self.send_to_hw(sub.name, col + 1)
+            subbox = SubmasterBox(row, sub)
+            subbox.place(relx=col / 8, rely=0, relwidth=1 / 8, relheight=1)
+            self.subbox[sub.uri] = self.slider_table[(rowcount, col)] = subbox
 
-            subtk.slider_var.trace('w', slider_changed)
+            self.setup_key_nudgers(subbox.scale)
 
-            # initial position
-            self.send_to_hw(sub.name, col + 1)
             col = (col + 1) % 8
             last_group = group
-
-    def draw_sub_slider(self, row, col, sub, current_level):
-        subtk = SubmasterTk(row, sub, current_level)
-        subtk.place(relx=col / 8, rely=0, relwidth=1 / 8, relheight=1)
-        self.setup_key_nudgers(subtk.scale)
-
-        self.slider_vars[sub.uri] = subtk.slider_var
-        return subtk
-
+            
     def toggle_slider_connectedness(self):
         self.use_hw_sliders = not self.use_hw_sliders
         if self.use_hw_sliders:
@@ -301,7 +339,7 @@
 
         for col in range(1, 9):
             try:
-                subtk = self.slider_table[(self.current_row, col - 1)]
+                subbox = self.slider_table[(self.current_row, col - 1)]
                 self.sliders.valueOut("button-upper%d" % col, True)
             except KeyError:
                 # unfilled bottom row has holes (plus rows with incomplete
@@ -309,32 +347,32 @@
                 self.sliders.valueOut("button-upper%d" % col, False)
                 self.sliders.valueOut("slider%d" % col, 0)
                 continue
-            self.send_to_hw(subtk.name, col)
+            self.send_to_hw(subbox.name, col)
             
     def got_nudger(self, number, direction, full=0):
         try:
-            subtk = self.slider_table[(self.current_row, number)]
+            subbox = self.slider_table[(self.current_row, number)]
         except KeyError:
             return
 
         if direction == 'up':
             if full:
-                subtk.scale.fade(1)
+                subbox.scale.fade(1)
             else:
-                subtk.scale.increase()
+                subbox.scale.increase()
         else:
             if full:
-                subtk.scale.fade(0)
+                subbox.scale.fade(0)
             else:
-                subtk.scale.decrease()
+                subbox.scale.decrease()
 
     def hw_slider_moved(self, col, value):
         value = int(value * 100) / 100
         try:
-            subtk = self.slider_table[(self.current_row, col)]
+            subbox = self.slider_table[(self.current_row, col)]
         except KeyError:
             return # no slider assigned at that column
-        subtk.scale.set(value)
+        subbox.scale.set(value)
 
     def send_to_hw(self, subUri, hwNum):
         if isinstance(self.sliders, DummySliders):
@@ -367,8 +405,8 @@
         row['bg'] = 'black'
 
     def get_levels(self):
-        return dict([(uri, slidervar.get()) 
-            for uri, slidervar in self.slider_vars.items()])
+        return dict([(uri, box.slider_var.get()) 
+            for uri, box in self.subbox.items()])
 
     def get_levels_as_sub(self):
         scaledsubs = [self.submasters.get_sub_by_uri(sub) * level
@@ -386,6 +424,7 @@
     def save(self):
         pickle.dump((self.get_levels(), self.current_row),
                     file('.keyboardcomposer.savedlevels', 'w'))
+
     def send_frequent_updates(self):
         """called when we get a fade -- send events as quickly as possible"""
         if time.time() <= self.stop_frequent_update_time:
@@ -393,9 +432,9 @@
             self.after(10, self.send_frequent_updates)
 
     def alltozero(self):
-        for name, subtk in self.name_to_subtk.items():
-            if subtk.scale.scale_var.get() != 0:
-                subtk.scale.fade(value=0.0, length=0)
+        for uri, subbox in self.subbox.items():
+            if subbox.scale.scale_var.get() != 0:
+                subbox.scale.fade(value=0.0, length=0)
 
 # move to web lib
 def postArgGetter(request):
@@ -416,15 +455,15 @@
 
 class LevelServerHttp(resource.Resource):
     isLeaf = True
-    def __init__(self,name_to_subtk):
-        self.name_to_subtk = name_to_subtk
+    def __init__(self,name_to_subbox):
+        self.name_to_subbox = name_to_subbox
 
     def render_POST(self, request):
         arg = postArgGetter(request)
         
         if request.path == '/fadesub':
             # fadesub?subname=scoop&level=0&secs=.2
-            self.name_to_subtk[arg('subname')].scale.fade(
+            self.name_to_subbox[arg('subname')].scale.fade(
                 float(arg('level')),
                 float(arg('secs')))
             return "set %s to %s" % (arg('subname'), arg('level'))
@@ -477,8 +516,6 @@
 
 if __name__ == "__main__":
     parser = OptionParser()
-    parser.add_option('--nonpersistent', action="store_true",
-                      help="don't load or save levels")
     parser.add_option('--no-sliders', action='store_true',
                       help="don't attach to hardware sliders")
     parser.add_option('-v', action='store_true', help="log info level")
@@ -494,10 +531,7 @@
     
     tl = toplevelat("Keyboard Composer", existingtoplevel=root)
 
-    startLevels = None
-    if opts.nonpersistent:
-        startLevels = {}
-    kc = KeyboardComposer(tl, graph, startLevels,
+    kc = KeyboardComposer(tl, graph, 
                           hw_sliders=not opts.no_sliders)
     kc.pack(fill=BOTH, expand=1)
 
@@ -505,17 +539,16 @@
         tk.Label(root,text=helpline, font="Helvetica -12 italic",
                  anchor='w').pack(side='top',fill='x')
 
-    import twisted.internet
-    try:
-        reactor.listenTCP(networking.keyboardComposer.port,
-                          server.Site(LevelServerHttp(kc.name_to_subtk)))
-    except twisted.internet.error.CannotListenError, e:
-        log.warn("Can't (and won't!) start level server:")
-        log.warn(e)
+    if 0: # needs fixing, or maybe it's obsolete because other progs can just patch the rdf graph
+        import twisted.internet
+        try:
+            reactor.listenTCP(networking.keyboardComposer.port,
+                              server.Site(LevelServerHttp(kc.name_to_subbox)))
+        except twisted.internet.error.CannotListenError, 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:
-        reactor.addSystemEventTrigger('after', 'shutdown', kc.save)
     
     tksupport.install(root,ms=10)
 
--- a/bin/rdfdb	Wed Jul 18 18:08:12 2012 +0000
+++ b/bin/rdfdb	Thu Jul 19 04:23:06 2012 +0000
@@ -95,7 +95,7 @@
 import sys, optparse, logging, json, os
 import cyclone.web, cyclone.httpclient, cyclone.websocket
 sys.path.append(".")
-from light9 import networking, showconfig
+from light9 import networking, showconfig, prof
 from rdflib import ConjunctiveGraph, URIRef, Graph
 from light9.rdfdb.graphfile import GraphFile
 from light9.rdfdb.patch import Patch, ALLSTMTS
@@ -167,9 +167,13 @@
         log.info("patching graph -%d +%d" % (len(p.delQuads), len(p.addQuads)))
 
         patchQuads(self.graph, p.delQuads, p.addQuads, perfect=True)
-        
+        senderUpdateUri = getattr(p, 'senderUpdateUri', None)
         self.summarizeToLog()
         for c in self.clients:
+            print "send to %s? %s %s" % (c, c.updateUri, senderUpdateUri)
+            if c.updateUri == senderUpdateUri:
+                # this client has self-applied the patch already
+                continue
             d = c.sendPatch(p)
             d.addErrback(self.clientErrored, c)
         sendToLiveClients(asJson=p.jsonRepr)
@@ -293,4 +297,4 @@
         
         ], db=db))
     log.info("serving on %s" % port)
-    reactor.run()
+    prof.run(reactor.run, profile=False)
--- a/light9/Submaster.py	Wed Jul 18 18:08:12 2012 +0000
+++ b/light9/Submaster.py	Thu Jul 19 04:23:06 2012 +0000
@@ -150,18 +150,18 @@
             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 = {}
         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)
+
+            name = self.graph.label(chan)
             if not name:
                 log.error("sub %r has channel %r with no name- leaving out that channel" % (self.name, chan))
                 continue
+            val = self.graph.value(lev, L9['level'])
             self.levels[name] = float(val)
 
     def save(self):
--- a/light9/rdfdb/patch.py	Wed Jul 18 18:08:12 2012 +0000
+++ b/light9/rdfdb/patch.py	Thu Jul 19 04:23:06 2012 +0000
@@ -1,18 +1,13 @@
-import json
-from rdflib import ConjunctiveGraph
+import json, unittest
+from rdflib import ConjunctiveGraph, URIRef
+from light9.rdfdb.rdflibpatch import graphFromNQuad, graphFromQuads, serializeQuad
 
 ALLSTMTS = (None, None, None)
 
-def graphFromQuads(q):
-    g = ConjunctiveGraph()
-    #g.addN(q) # no effect on nquad output
-    for s,p,o,c in q:
-        g.get_context(c).add((s,p,o))
-        #g.store.add((s,p,o), c) # no effect on nquad output
-    return g
-
 class Patch(object):
     """
+    immutable
+    
     the json representation includes the {"patch":...} wrapper
     """
     def __init__(self, jsonRepr=None, addQuads=None, delQuads=None,
@@ -23,10 +18,10 @@
 
         if self._jsonRepr is not None:
             body = json.loads(self._jsonRepr)
-            self._delGraph = ConjunctiveGraph()
-            self._delGraph.parse(data=body['patch']['deletes'], format='nquads')
-            self._addGraph = ConjunctiveGraph()
-            self._addGraph.parse(data=body['patch']['adds'], format='nquads')
+            self._delGraph = graphFromNQuad(body['patch']['deletes'])
+            self._addGraph = graphFromNQuad(body['patch']['adds'])
+            if 'senderUpdateUri' in body:
+                self.senderUpdateUri = body['senderUpdateUri']
 
     @property
     def addQuads(self):
@@ -61,8 +56,39 @@
     @property
     def jsonRepr(self):
         if self._jsonRepr is None:
-            self._jsonRepr = json.dumps({"patch": {
-                'adds':self.addGraph.serialize(format='nquads'),
-                'deletes':self.delGraph.serialize(format='nquads'),
-                }})
+            self._jsonRepr = self.makeJsonRepr()
         return self._jsonRepr
+
+    def makeJsonRepr(self, extraAttrs={}):
+        d = {"patch" : {
+            'adds' : serializeQuad(self.addGraph),
+            'deletes' : serializeQuad(self.delGraph),
+            }}
+        if len(self.addGraph) > 0 and d['patch']['adds'].strip() == "":
+            # this is the bug that graphFromNQuad works around
+            raise ValueError("nquads serialization failure")
+        if '[<' in d['patch']['adds']:
+            raise ValueError("[< found in %s" % d['patch']['adds'])
+        d.update(extraAttrs)
+        return json.dumps(d)
+
+    def concat(self, more):
+        """
+        new Patch with the result of applying this patch and the
+        sequence of other Patches
+        """
+        # not working yet
+        adds = set(self.addQuads)
+        dels = set(self.delQuads)
+        for p2 in more:
+            for q in p2.delQuads:
+                if q in adds:
+                    adds.remove(q)
+                else:
+                    dels.add(q)
+            for q in p2.addQuads:
+                if q in dels:
+                    dels.remove(q)
+                else:
+                    adds.add(q)
+        return Patch(delQuads=dels, addQuads=adds)
--- a/light9/rdfdb/rdflibpatch.py	Wed Jul 18 18:08:12 2012 +0000
+++ b/light9/rdfdb/rdflibpatch.py	Thu Jul 19 04:23:06 2012 +0000
@@ -1,6 +1,8 @@
 """
 this is a proposal for a ConjunctiveGraph method in rdflib
 """
+import unittest
+from rdflib import ConjunctiveGraph, URIRef as U
 
 def patchQuads(graph, deleteQuads, addQuads, perfect=False):
     """
@@ -29,8 +31,57 @@
                 raise ValueError("%r already in %r" % (spoc[:3], spoc[3]))
     graph.addN(addQuads)
 
-import unittest
-from rdflib import ConjunctiveGraph, URIRef as U
+
+
+def graphFromQuads(q):
+    g = ConjunctiveGraph()
+    #g.addN(q) # no effect on nquad output
+    for s,p,o,c in q:
+        #g.get_context(c).add((s,p,o)) # kind of works with broken rdflib nquad serializer code
+        g.store.add((s,p,o), c) # no effect on nquad output
+    return g
+
+def graphFromNQuad(text):
+    """
+    g.parse(data=self.nqOut, format='nquads')
+    makes a graph that serializes to nothing
+    """
+    g1 = ConjunctiveGraph()
+    g1.parse(data=text, format='nquads')
+    g2 = ConjunctiveGraph()
+    for s,p,o,c in g1.quads((None,None,None)):
+        #g2.get_context(c).add((s,p,o))
+        g2.store.add((s,p,o), c)
+    #import pprint; pprint.pprint(g2.store.__dict__)
+    return g2
+
+from rdflib.plugins.serializers.nt import _xmlcharref_encode
+def serializeQuad(g):
+    """replacement for graph.serialize(format='nquads')"""
+    out = ""
+    for s,p,o,c in g.quads((None,None,None)):
+        out += u"%s %s %s %s .\n" % (s.n3(),
+                                p.n3(),
+                                _xmlcharref_encode(o.n3()), 
+                                c.n3())
+    return out
+
+class TestGraphFromQuads(unittest.TestCase):
+    nqOut = '<http://example.com/> <http://example.com/> <http://example.com/> <http://example.com/> .\n'
+    def testSerializes(self):
+        n = U("http://example.com/")
+        g = graphFromQuads([(n,n,n,n)])
+        out = serializeQuad(g)
+        self.assertEqual(out.strip(), self.nqOut.strip())
+
+    def testNquadParserSerializes(self):
+        g = graphFromNQuad(self.nqOut)
+        self.assertEqual(len(g), 1)
+        out = serializeQuad(g)
+        self.assertEqual(out.strip(), self.nqOut.strip())
+        
+
+
 stmt1 = U('http://a'), U('http://b'), U('http://c'), U('http://ctx1')
 stmt2 = U('http://a'), U('http://b'), U('http://c'), U('http://ctx2')
 class TestPatchQuads(unittest.TestCase):
--- a/light9/rdfdb/syncedgraph.py	Wed Jul 18 18:08:12 2012 +0000
+++ b/light9/rdfdb/syncedgraph.py	Thu Jul 19 04:23:06 2012 +0000
@@ -1,14 +1,16 @@
-from rdflib import ConjunctiveGraph, RDFS, RDF
+from rdflib import ConjunctiveGraph, RDFS, RDF, Graph
 import logging, cyclone.httpclient, traceback, urllib
 from twisted.internet import reactor
 log = logging.getLogger('syncedgraph')
 from light9.rdfdb.patch import Patch, ALLSTMTS
 from light9.rdfdb.rdflibpatch import patchQuads
 
-def sendPatch(putUri, patch):
-    # this will take args for sender, etc
-    body = patch.jsonRepr
-    log.debug("send body: %r" % body)
+def sendPatch(putUri, patch, **kw):
+    """
+    kwargs will become extra attributes in the toplevel json object
+    """
+    body = patch.makeJsonRepr(kw)
+    log.debug("send body: %r", body)
     def ok(done):
         if not str(done.code).startswith('2'):
             raise ValueError("sendPatch request failed %s: %s" % (done.code, done.body))
@@ -64,7 +66,7 @@
 
         this removes the handlers that it gives you
         """
-        self.dependencies()
+        #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]+
@@ -94,6 +96,58 @@
         pprint(self._handlersSp)
         
 
+class PatchSender(object):
+    """
+    SyncedGraph may generate patches faster than we can send
+    them. This object buffers and may even collapse patches before
+    they go the server
+    """
+    def __init__(self, target, myUpdateResource):
+        self.target = target
+        self.myUpdateResource = myUpdateResource
+        self._patchesToSend = []
+        self._currentSendPatchRequest = None
+
+    def sendPatch(self, p):
+        self._patchesToSend.append(p)
+        self._continueSending()
+
+    def _continueSending(self):
+        if not self._patchesToSend or self._currentSendPatchRequest:
+            return
+        if len(self._patchesToSend) > 1:
+            log.info("%s patches left to send", len(self._patchesToSend))
+            # this is where we could concatenate little patches into a
+            # bigger one. Often, many statements will cancel each
+            # other out. not working yet:
+            if 0:
+                p = self._patchesToSend[0].concat(self._patchesToSend[1:])
+                print "concat down to"
+                print 'dels'
+                for q in p.delQuads: print q
+                print 'adds'
+                for q in p.addQuads: print q
+                print "----"
+            else:
+                p = self._patchesToSend.pop(0)
+        else:
+            p = self._patchesToSend.pop(0)
+            
+        self._currentSendPatchRequest = sendPatch(
+            self.target, p, senderUpdateUri=self.myUpdateResource)
+        self._currentSendPatchRequest.addCallbacks(self._sendPatchDone,
+                                                   self._sendPatchErr)
+
+    def _sendPatchDone(self, result):
+        self._currentSendPatchRequest = None
+        self._continueSending()
+
+    def _sendPatchErr(self, e):
+        self._currentSendPatchRequest = None
+        log.error(e)
+        self._continueSending()
+        
+
 class SyncedGraph(object):
     """
     graph for clients to use. Changes are synced with the master graph
@@ -111,7 +165,10 @@
         self._watchers = GraphWatchers()
         
         def onPatch(p):
-            patchQuads(_graph, p.delQuads, p.addQuads)
+            """
+            central server has sent us a patch
+            """
+            patchQuads(_graph, p.delQuads, p.addQuads, perfect=True)
             log.info("graph now has %s statements" % len(_graph))
             try:
                 self.updateOnPatch(p)
@@ -128,6 +185,8 @@
         log.info("listening on %s" % port)
         self.register(label)
         self.currentFuncs = [] # stack of addHandler callers
+        self._sender = PatchSender('http://localhost:8051/patches',
+                                   self.updateResource)
 
     def register(self, label):
 
@@ -144,10 +203,21 @@
         log.info("registering with rdfdb")
 
     def patch(self, p):
-        """send this patch to the server and apply it to our local graph and run handlers"""
-        # currently this has to round-trip. But I could apply the
-        # patch here and have the server not bounce it back to me
-        return sendPatch('http://localhost:8051/patches', p)
+        """send this patch to the server and apply it to our local
+        graph and run handlers"""
+        patchQuads(self._graph, p.delQuads, p.addQuads, perfect=True)
+        self.updateOnPatch(p)
+        self._sender.sendPatch(p)
+
+    def patchObject(self, context, subject, predicate, newObject):
+        """send a patch which removes existing values for (s,p,*,c)
+        and adds (s,p,newObject,c). Values in other graphs are not affected"""
+        raise NotImplementedError
+        existing = []
+        
+        self.patch(Patch(
+            delQuads=[],
+            addQuads=[(subject, predicate, newObject, context)]))
 
     def addHandler(self, func):
         """
@@ -184,6 +254,23 @@
             # todo: forget the old handlers for this func
             self.addHandler(func)
 
+    def currentState(self, context=None):
+        """
+        a graph you can read without being in an addHandler
+        """
+        class Mgr(object):
+            def __enter__(self2):
+                # this should be a readonly view of the existing graph
+                g = Graph()
+                for s in self._graph.triples((None, None, None), context):
+                    g.add(s)
+                return g
+            
+            def __exit__(self, type, val, tb):
+                return
+
+        return Mgr()
+
     def _getCurrentFunc(self):
         if not self.currentFuncs:
             # this may become a warning later