hipwsgi.py
changeset 303: 7cdbe18e3161
parent 302:03c221a1f62a
child 306:69bd2af4967e
manifest: 7cdbe18e3161
author: Dan Connolly http://www.w3.org/People/Connolly/
date: Wed Sep 12 15:38:21 2007 -0500 (8 months ago)
permissions: -rw-r--r--
handle deletion of recrods we never heard of
        1 #!/usr/bin/python
        2 """hipwgi -- hiptop sync as WSGI app
        3 
        4 Usage:
        5 
        6   1. mkdir /tmp/danger_me
        7   2. use dangerSync --prod --user=xxx --pgp >/tmp/danger_me/session-id
        8   3. start the server: python hipwsgi.py
        9   4. visit http://127.0.0.1:8080/sync/me
       10   5. use Pull button to sync danger data to localcopy
       11   6. use event/task/note/contact links to browse records
       12   7. edit raw JSON, Save back to cache
       13   8. go back and use Push button to commit cached writes to danger/t-mobile
       14 
       15 TODO:
       16   * migrate project from cvs on dev.w3.org to hg on homer
       17   * add note editing support
       18   * control access using openid
       19   * add hcalendar view of events/tasks
       20   ** with ETAG/last-modified support
       21   * add .ics view of events/tasks
       22   * add hcard view of contacts
       23 
       24 parts cribbed from http://lukearno.com/projects/selector/
       25 and from some bitworking clues.
       26 
       27 """
       28 
       29 import os, time
       30 from stat import ST_MTIME
       31 import cgi
       32 from cgi import escape
       33 
       34 from selector import Selector
       35 from genshi.template import MarkupTemplate # http://genshi.edgewall.org/
       36 import simplejson
       37 
       38 from dangerSync import Anchored as DangerServer
       39 import xmlrpclib
       40 from hipstore import FilePerRecord
       41 
       42 HTML8 = 'text/html; charset=utf-8'
       43 
       44 Kinds = ("event", "contact", "task", "note")
       45 
       46 
       47 def hiptop_app():
       48     s = Selector()
       49     s.add('/', GET=clues)
       50     s.add('/sync/{account}', GET=sync_options)
       51     s.add('/pull/{account}', POST=pull)
       52     s.add('/push/{account}', POST=push)
       53     s.add('/info/{kind}/{account}', GET=list_records)
       54     s.add('/info/{kind}/{account}/{key}', GET=show_record, POST=save_record)
       55     #@@s.add('/info/event/{account}/hcalendar', GET=show_record)
       56     return s
       57 
       58 def clues(environ, start_response):
       59     start_response("200 ok", [('Content-type', HTML8)])
       60     yield CluesPage
       61 
       62 def sync_options(environ, start_response):
       63     acctname = environ['selector.vars']['account']
       64     acct = AccountData(acctname)
       65     
       66     start_response('200 OK', [('Content-type', HTML8)])
       67 
       68     stream = MarkupTemplate(SyncHomePage).generate(acct=acct, Kinds=Kinds)
       69     for chunk in stream.serialize():
       70         yield str(chunk)
       71 
       72 def login(environ, start_response):
       73     start_response('500 @@not implemented', [('Content-type','text/plain')])
       74     yield "@@not implemented yet"
       75 
       76 
       77 def pull(environ, start_response):
       78     acct = AccountData(environ['selector.vars']['account'])
       79     try:
       80         s = acct.session_creds()
       81     except EnvironmentError, e:
       82         start_response('403 no session found', [("content-type","text/plain")])
       83         yield "no session. @@perhaps give clues on how to start one"
       84         yield str(e)
       85         return
       86 
       87     ds = DangerServer()
       88     ds.useProduction()
       89     ds.resume(s)
       90 
       91     try:
       92         testing = ds.get(Kinds[0], [])
       93     except xmlrpclib.Fault, e:
       94         start_response('401 session expired', [("content-type","text/plain")])
       95         yield "session expired?\n"
       96         yield e.faultString
       97         return
       98 
       99     start_response('200 OK', [("content-type", HTML8)])
      100 
      101     stream = MarkupTemplate(PullPage).generate(acct=acct,
      102                                                Kinds=Kinds,
      103                                                ds=ds,
      104                                                summary=summary)
      105     for chunk in stream.serialize():
      106         yield str(chunk)
      107 
      108 
      109 def summary(record):
      110     """summary of record; e.g. for link label
      111     """
      112     return record.get('title', '') or \
      113            record.get('last_name', '') or \
      114            record.get('first_name', '') or \
      115            record.get('nick_name', '') or \
      116            record.get('company', '') or \
      117            record.get('body', '').split("\n")[0]
      118 
      119 
      120 def list_records(environ, start_response):
      121     acct = AccountData(environ['selector.vars']['account'])
      122     os.chdir(acct.path()) #@@ catch errors
      123 
      124     kind = environ['selector.vars']['kind']
      125     rep = FilePerRecord(kind)
      126     start_response('200 OK', [('Content-type', HTML8)])
      127 
      128     stream = MarkupTemplate(RecordListPage).generate(acct=acct,
      129                                                      kind=kind,
      130                                                      rep=rep,
      131                                                      summary=summary)
      132     for chunk in stream.serialize():
      133         yield chunk.encode('utf-8')
      134 
      135 
      136 def show_record(environ, start_response):
      137     acct = AccountData(environ['selector.vars']['account'])
      138 
      139     os.chdir(acct.path()) #@@ catch errors
      140 
      141     kind = environ['selector.vars']['kind']
      142     key = int(environ['selector.vars']['key'])
      143 
      144     rep = FilePerRecord(kind)
      145     start_response('200 OK', [('Content-type', HTML8)])
      146 
      147     record = rep[key]
      148     tpl = MarkupTemplate(RecordDetailPage)
      149     stream = tpl.generate(acct = acct,
      150                           kind = kind,
      151                           record = record,
      152                           edit = json_lines(record))
      153     for chunk in stream.serialize():
      154         yield chunk.encode('utf-8')
      155 
      156 
      157 def json_lines(record):
      158     """pretty print json object a bit for editing
      159     """
      160     lines = [ simplejson.dumps(k) + ": " + simplejson.dumps(v) + "," 
      161               for k, v in record.iteritems()]
      162     if lines:
      163         lines[-1] = lines[-1][:-1] # chop last ,
      164     return ["{"] + lines + ["}"]
      165 
      166 
      167 
      168 def save_record(environ, start_response):
      169     acct = AccountData(environ['selector.vars']['account'])
      170     os.chdir(acct.path()) #@@ catch errors
      171 
      172     kind = environ['selector.vars']['kind']
      173     key = int(environ['selector.vars']['key'])
      174 
      175     # http://pythonpaste.org/do-it-yourself-framework.html
      176     # http://trac.pythonpaste.org/pythonpaste/browser/Paste/trunk/paste/request.py
      177     fields = cgi.FieldStorage(environ['wsgi.input'],
      178                               environ=environ,
      179                               keep_blank_values=True)
      180     record = simplejson.loads(fields['record'].value.decode('utf-8'))
      181     record['last_modified'] = timestrg(time.time())
      182     rep = FilePerRecord(kind)
      183 
      184     rep.cache(key, record, dirty=True)
      185     start_response('200 OK', [('Content-type', 'text/plain')])
      186 
      187     yield "saved %s %s from %s; don't forget to push.\n" % \
      188           (kind, key, acct.name)
      189 
      190 
      191 def timestr(secs):
      192     """
      193     >>> timestr(1183583183)
      194     '2007-07-04T16:06:23'
      195     """
      196     
      197     return time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(secs))
      198 
      199 def timestrg(secs):
      200     """convert seconds-since-the-epoch to danger time format
      201     
      202     >>> timestrg(1183583183)
      203     '20070704T210623Z'
      204     """
      205     
      206     return time.strftime("%Y%m%dT%H%M%SZ", time.gmtime(secs))
      207 
      208 def form_field(environ, n):
      209     fields = cgi.FieldStorage(environ['wsgi.input'],
      210                               environ=environ,
      211                               keep_blank_values=True)
      212     return fields[n].value.decode('utf-8')
      213     
      214 def push(environ, start_response):
      215     acct = AccountData(environ['selector.vars']['account'])
      216     client = form_field(environ, 'client')
      217 
      218     try:
      219         s = acct.session_creds()
      220     except EnvironmentError, e:
      221         start_response('403 no session found', [("content-type","text/plain")])
      222         yield "no session. @@perhaps give clues on how to start one"
      223         yield str(e)
      224         return
      225 
      226     ds = DangerServer()
      227     ds.useProduction()
      228     ds.resume(s)
      229 
      230     try:
      231         testing = ds.get(Kinds[0], [])
      232     except xmlrpclib.Fault, e:
      233         start_response('401 session expired', [("content-type","text/plain")])
      234         yield "session expired?\n"
      235         yield e.faultString
      236         return
      237 
      238     start_response('200 OK', [("content-type", 'text/plain')])
      239 
      240     for kind in Kinds:
      241         acct.focus(kind)
      242         ids = acct.pending()
      243         yield "%s to push: %s\n" % (kind, ids)
      244         yield ("%s committed:" % (kind, )) + str(acct.commit(ds))
      245         yield "\n\n"
      246 
      247 ########
      248 
      249 class AccountData(object):
      250     session_fn = 'session-id'
      251     def __init__(self, name):
      252         self.name = name
      253         self._dirty = False
      254         self._rep = None
      255 
      256     def path(self, part=None):
      257         d = '/tmp/danger_%s' % self.name
      258         if part:
      259             return os.path.join(d, part)
      260         else:
      261             return d
      262     
      263     def session_creds(self):
      264         return file(self.path(self.session_fn)).read().strip()
      265 
      266     def session_time(self, minutes=0):
      267         """raises EnvironmentError
      268         """
      269         return timestr(os.stat(self.path(self.session_fn))[ST_MTIME] +
      270                        minutes * 60)
      271 
      272     def focus(self, kind):
      273         self._rep = FilePerRecord(kind)
      274         self._kind = kind
      275         os.chdir(self.path())
      276 
      277     def anchor(self, ds):
      278         a = self._rep.getAnchor()
      279         ds.anchor = a
      280         return a
      281 
      282     def deleteAll(self, keys):
      283         if not keys: return
      284 
      285         self._dirty = True
      286 
      287         for k in keys:
      288             try:
      289                 self._rep.delete(k)
      290             except EnvironmentError:
      291                 # perhaps a record got deleted before we pulled it?
      292                 # seems to be harmless
      293                 # OSError: [Errno 2] No such file or directory: 'event/6250'
      294                 pass
      295 
      296     def get(self, keys, ds):
      297         """fetch current kind of records corresponding to keys from ds;
      298         store; synch anchor if needed.
      299         """
      300         if keys:
      301             records = ds.get(self._kind, keys)
      302             for record in records:
      303                 self._rep.cache(record['id'],
      304                                 record)
      305             self._dirty = True
      306         else:
      307             records = []
      308         if self._dirty:
      309             self._rep.setAnchor(ds.resetAnchor())
      310         return records
      311 
      312     def pending(self):
      313         return self._rep.pending()
      314 
      315     def commit(self, ds):
      316         ids = self._rep.pending()
      317 
      318         #@@ split into groups of 30 as in dangerSync.py
      319         ids_new = ds.put(self._kind, [self._rep.dirtyitem(i) for i in ids])
      320 
      321         self._rep.commit(ids)
      322 
      323         return zip(ids, ids_new)
      324 
      325 #########
      326 # Genshi templates
      327 
      328 CluesPage="""
      329 <!DOCTYPE html>
      330 <html xmlns="http://www.w3.org/1999/xhtml">
      331 <head><title>Hiptop Sync</title></head>
      332 <body>
      333 <h1>Hiptop Sync</h1>
      334 
      335 <p>Try: /sync/<var>account_name</var>.</p>
      336 
      337 <address>Dan Connolly</address>
      338 </body>
      339 </html>
      340 """
      341 
      342 SyncHomePage = """
      343 <!DOCTYPE html>
      344 <html
      345   xmlns:py="http://genshi.edgewall.org/"
      346   xmlns="http://www.w3.org/1999/xhtml">
      347 <head><title>%(account)s Hiptop Sync</title></head>
      348 <style type="text/css">
      349 dl dt { font-weight: bold }
      350 </style>
      351 <body>
      352 <h1>Hiptop Sync for ${acct.name}</h1>
      353 
      354 <dl>
      355 <dt>account</dt>
      356  <dd>${acct.name}</dd>
      357 <dt>dbdir</dt><dd>${acct.path()}</dd>
      358 <dt>session</dt><dd>${acct.session_time()} - ${acct.session_time(30)}</dd>
      359 </dl>
      360 
      361 <ul>
      362 <li py:for="k in Kinds"
      363     py:with="dummy = acct.focus(k); ids = acct.pending()">
      364   <a href="/info/$k/${acct.name}">$k</a>
      365   <b py:if="ids">${len(ids)} edited records to push</b>
      366 </li>
      367 </ul>
      368 
      369 <form method="POST" action="/pull/${acct.name}">
      370 <input type="submit" value="Pull" />
      371 </form>
      372 
      373 <form method="POST" action="/push/${acct.name}">
      374 <input type="submit" value="Push" />
      375 <input name="client" value="Missing Sync" />
      376 </form>
      377 
      378 <hr />
      379 <address>hipwsgi by Dan Connolly</address>
      380 </body>
      381 </html>
      382 """
      383 
      384 PullPage = """
      385 <!DOCTYPE html>
      386 <html
      387   xmlns:py="http://genshi.edgewall.org/"
      388   xmlns="http://www.w3.org/1999/xhtml">
      389 <head><title>Pull %(account)s</title></head>
      390 <style type="text/css">
      391 dl dt { font-weight: bold }
      392 </style>
      393 <body>
      394 <h1>Pull ${acct.name} from danger hiptop service</h1>
      395 
      396 <p>back to <a href='/sync/${acct.name}'>sync options</a></p>
      397 
      398 <div py:for="kind in Kinds"
      399      py:with="dummy = acct.focus(kind); a = acct.anchor(ds)">
      400  <h2>$kind</h2>
      401  <div py:choose="" py:strip="1">
      402   <div py:when="a"
      403        py:with="dkeys = ds.deletedItemIds(kind)">
      404     <p><small>anchor: $a</small></p>
      405     <p py:if="dkeys">Deleting: ${",".join([str(k) for k in dkeys])}
      406       <span py:strip="1" py:with="dummy=acct.deleteAll(dkeys)"></span>
      407     </p>
      408     <ul py:with="ckeys = ds.changedItemIds(kind)">
      409      <li py:for="record in acct.get(ckeys, ds)"
      410       >${record.id}: ${summary(record)}</li>
      411     </ul>
      412   </div>
      413   <div py:otherwise="">
      414    <p>no anchor; getting all...</p>
      415     <ul py:with="keys = ds.allItemIds(kind)">
      416      <li py:for="record in acct.get(keys, ds)"
      417       >${record.id}: ${summary(record)}</li>
      418     </ul>
      419   </div>
      420  </div>        
      421 </div>
      422 
      423 <hr />
      424 <address>hipwsgi by Dan Connolly</address>
      425 </body>
      426 </html>
      427 """
      428 
      429 RecordListPage = """
      430 <!DOCTYPE html>
      431 <html
      432   xmlns:py="http://genshi.edgewall.org/"
      433   xmlns="http://www.w3.org/1999/xhtml">
      434 <head><title>$kind from ${acct.name}</title></head>
      435 <style type="text/css">
      436 dl dt { font-weight: bold }
      437 </style>
      438 <body>
      439 <h1>$kind</h1>
      440 <p>back to <a href='/sync/${acct.name}'>sync options</a></p>
      441 
      442 <ul>
      443 <li py:for="k in rep.keys()">
      444  <a href='/info/$kind/${acct.name}/$k'>${summary(rep[k])}</a></li>
      445 </ul>
      446 <hr />
      447 <address>hipwsgi by Dan Connolly</address>
      448 </body>
      449 </html>
      450 """
      451 
      452 RecordDetailPage = """
      453 <!DOCTYPE html>
      454 <html
      455   xmlns:py="http://genshi.edgewall.org/"
      456   xmlns="http://www.w3.org/1999/xhtml">
      457 <head><title>$kind $key from ${acct.name}</title></head>
      458 <style type="text/css">
      459 dl dt { font-weight: bold }
      460 </style>
      461 <body>
      462 <h1>$kind $key</h1>
      463 
      464 <p>back to <a href='/sync/${acct.name}'>sync options</a></p>
      465 
      466 <py:def function="xoxo(item)">
      467  <div py:strip="1" py:choose="">
      468   <ul py:when="type(item) is type([])">
      469    <li py:for="i in item">${xoxo(i)}</li>
      470   </ul>
      471   <dl py:when="type(item) is type({})">
      472     <div py:strip="1" py:for="k, v in item.iteritems()">
      473     <dt>$k</dt>
      474     <dd>${xoxo(v)}</dd>
      475     </div>
      476   </dl>
      477   <span py:when="type(item) is type(1)">$item</span>
      478   <span py:when="type(item) is type(True)">$item</span>
      479   <pre py:when="'\\n' in item">$item</pre>
      480   <span py:otherwise="">$item</span>
      481  </div>
      482 </py:def>
      483 
      484 ${xoxo(record)}
      485 
      486 <form method='POST'>
      487 <textarea rows='${len(edit)}' cols='70' name='record'>
      488 ${"\\n".join(edit)}
      489 </textarea>
      490 <br />
      491 <input type="submit" value="Save" />
      492 </form>
      493 
      494 <hr />
      495 <address>hipwsgi by Dan Connolly</address>
      496 </body>
      497 </html>
      498 """
      499 
      500 
      501 ####################
      502 
      503 def server_main():
      504     from wsgiref.simple_server import WSGIServer, WSGIRequestHandler
      505     httpd = WSGIServer(("", 8080), WSGIRequestHandler)
      506     httpd.set_app(hiptop_app())
      507 
      508     print "Serving HTTP on %s port %s ..." % httpd.socket.getsockname()
      509     httpd.serve_forever()
      510 
      511 def _test():
      512     import doctest
      513     doctest.testmod()
      514 
      515 if __name__ == '__main__':
      516     import sys, os
      517     if '--test' in sys.argv:
      518         _test()
      519     elif os.environ.has_key('SCRIPT_NAME'):
      520         import cgitb; cgitb.enable()
      521         from wsgiref.handlers import CGIHandler
      522 
      523         # thttpd seems to not supply PATH_INFO.
      524         # The selector module seems to assume it's given.
      525         # I'm not sure which is in error, but this is a work-around...
      526         if not os.environ.has_key('PATH_INFO'):
      527             os.environ['PATH_INFO'] = '/'
      528         CGIHandler().run(hiptop_app())
      529     else:
      530         server_main()