Index: app.yaml
===================================================================
--- app.yaml	(revision 3)
+++ app.yaml	(working copy)
@@ -16,8 +16,11 @@
 - url: /play/.*
   script: play.py
 
+# Google Wave interface
+- url: /_wave/.*
+  script: waveapp.py
+
 # The main pages are served here
 - url: /.*
   script: pages.py
 
-
Index: waveapp.py
===================================================================
--- waveapp.py	(revision 0)
+++ waveapp.py	(revision 0)
@@ -0,0 +1,77 @@
+#!/usr/bin/python2.4
+
+import waveapi
+import re
+
+from waveapi.events import *
+from waveapi.robot import *
+
+from state import *
+
+def SendMessageToWave(context, text):
+  # Strip out unprintables:
+
+  text = re.sub(r'[\x07\<\>]', " ", text)
+
+  root_wavelet = context.GetRootWavelet()
+  root_wavelet.CreateBlip().GetDocument().SetText(text)
+
+def JoinedWave(properties, context):
+  root_wavelet = context.GetRootWavelet()
+
+  # Create a new game, using the wave ID as the "username"
+
+  waveId = root_wavelet.GetWaveId()
+  state = State.newForUser(waveId)
+  state.saveForUser()
+
+  # Send opening text
+
+  logEntries = state.allLogEntries()
+  logEntries = [entry.output or "" for entry in logEntries]
+  text = " ".join(logEntries)
+
+  SendMessageToWave(context, text)
+
+def RemovedFromWave(properties, context):
+  root_wavelet = context.GetRootWavelet()
+  waveId = root_wavelet.GetWaveId()
+
+  # Removed from wave?  Delete the saved state data.
+
+  State.deleteForUser(waveId)
+
+def BlipSubmitted(properties, context):
+  root_wavelet = context.GetRootWavelet()
+  blip = context.GetBlipById(properties["blipId"])
+
+  # Restore the game, from the wave ID:
+
+  waveId = root_wavelet.GetWaveId()
+  state = State.loadForUser(waveId, 'save')
+
+  if not state:
+    SendMessageToWave(context, "Can't find the game for this wave..")
+    return
+
+  # Get the command text:
+
+  blipText = blip.content
+
+  # Play!
+
+  state.play(blipText.strip())
+  state.saveForUser()
+
+  # Send response back
+
+  responseText = state.last_log_entry.output
+  SendMessageToWave(context, responseText)
+
+if __name__ == "__main__":
+  robot = Robot("Colossal Wave Adventure", 99)
+  robot.RegisterHandler(WAVELET_SELF_ADDED, JoinedWave)
+  robot.RegisterHandler(WAVELET_SELF_REMOVED, RemovedFromWave)
+  robot.RegisterHandler(BLIP_SUBMITTED, BlipSubmitted)
+  robot.Run()
+
Index: waveapi/simplejson/scanner.py
===================================================================
--- waveapi/simplejson/scanner.py	(revision 0)
+++ waveapi/simplejson/scanner.py	(revision 0)
@@ -0,0 +1,67 @@
+"""JSON token scanner
+"""
+import re
+try:
+    from simplejson._speedups import make_scanner as c_make_scanner
+except ImportError:
+    c_make_scanner = None
+
+__all__ = ['make_scanner']
+
+NUMBER_RE = re.compile(
+    r'(-?(?:0|[1-9]\d*))(\.\d+)?([eE][-+]?\d+)?',
+    (re.VERBOSE | re.MULTILINE | re.DOTALL))
+
+def py_make_scanner(context):
+    parse_object = context.parse_object
+    parse_array = context.parse_array
+    parse_string = context.parse_string
+    match_number = NUMBER_RE.match
+    encoding = context.encoding
+    strict = context.strict
+    parse_float = context.parse_float
+    parse_int = context.parse_int
+    parse_constant = context.parse_constant
+    object_hook = context.object_hook
+    object_pairs_hook = context.object_pairs_hook
+
+    def _scan_once(string, idx):
+        try:
+            nextchar = string[idx]
+        except IndexError:
+            raise StopIteration
+
+        if nextchar == '"':
+            return parse_string(string, idx + 1, encoding, strict)
+        elif nextchar == '{':
+            return parse_object((string, idx + 1), encoding, strict,
+                _scan_once, object_hook, object_pairs_hook)
+        elif nextchar == '[':
+            return parse_array((string, idx + 1), _scan_once)
+        elif nextchar == 'n' and string[idx:idx + 4] == 'null':
+            return None, idx + 4
+        elif nextchar == 't' and string[idx:idx + 4] == 'true':
+            return True, idx + 4
+        elif nextchar == 'f' and string[idx:idx + 5] == 'false':
+            return False, idx + 5
+
+        m = match_number(string, idx)
+        if m is not None:
+            integer, frac, exp = m.groups()
+            if frac or exp:
+                res = parse_float(integer + (frac or '') + (exp or ''))
+            else:
+                res = parse_int(integer)
+            return res, m.end()
+        elif nextchar == 'N' and string[idx:idx + 3] == 'NaN':
+            return parse_constant('NaN'), idx + 3
+        elif nextchar == 'I' and string[idx:idx + 8] == 'Infinity':
+            return parse_constant('Infinity'), idx + 8
+        elif nextchar == '-' and string[idx:idx + 9] == '-Infinity':
+            return parse_constant('-Infinity'), idx + 9
+        else:
+            raise StopIteration
+
+    return _scan_once
+
+make_scanner = c_make_scanner or py_make_scanner
Index: waveapi/simplejson/LICENSE
===================================================================
--- waveapi/simplejson/LICENSE	(revision 0)
+++ waveapi/simplejson/LICENSE	(revision 0)
@@ -0,0 +1,19 @@
+Copyright (c) 2006 Bob Ippolito
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
Index: waveapi/simplejson/tool.py
===================================================================
--- waveapi/simplejson/tool.py	(revision 0)
+++ waveapi/simplejson/tool.py	(revision 0)
@@ -0,0 +1,37 @@
+r"""Command-line tool to validate and pretty-print JSON
+
+Usage::
+
+    $ echo '{"json":"obj"}' | python -m simplejson.tool
+    {
+        "json": "obj"
+    }
+    $ echo '{ 1.2:3.4}' | python -m simplejson.tool
+    Expecting property name: line 1 column 2 (char 2)
+
+"""
+import sys
+import simplejson as json
+
+def main():
+    if len(sys.argv) == 1:
+        infile = sys.stdin
+        outfile = sys.stdout
+    elif len(sys.argv) == 2:
+        infile = open(sys.argv[1], 'rb')
+        outfile = sys.stdout
+    elif len(sys.argv) == 3:
+        infile = open(sys.argv[1], 'rb')
+        outfile = open(sys.argv[2], 'wb')
+    else:
+        raise SystemExit(sys.argv[0] + " [infile [outfile]]")
+    try:
+        obj = json.load(infile, object_pairs_hook=json.OrderedDict)
+    except ValueError, e:
+        raise SystemExit(e)
+    json.dump(obj, outfile, sort_keys=True, indent='    ')
+    outfile.write('\n')
+
+
+if __name__ == '__main__':
+    main()
Index: waveapi/simplejson/encoder.py
===================================================================
--- waveapi/simplejson/encoder.py	(revision 0)
+++ waveapi/simplejson/encoder.py	(revision 0)
@@ -0,0 +1,456 @@
+"""Implementation of JSONEncoder
+"""
+import re
+
+try:
+    from _speedups import encode_basestring_ascii as \
+        c_encode_basestring_ascii
+except ImportError:
+    c_encode_basestring_ascii = None
+try:
+    from _speedups import make_encoder as c_make_encoder
+except ImportError:
+    c_make_encoder = None
+
+from decoder import PosInf
+
+ESCAPE = re.compile(r'[\x00-\x1f\\"\b\f\n\r\t]')
+ESCAPE_ASCII = re.compile(r'([\\"]|[^\ -~])')
+HAS_UTF8 = re.compile(r'[\x80-\xff]')
+ESCAPE_DCT = {
+    '\\': '\\\\',
+    '"': '\\"',
+    '\b': '\\b',
+    '\f': '\\f',
+    '\n': '\\n',
+    '\r': '\\r',
+    '\t': '\\t',
+}
+for i in range(0x20):
+    #ESCAPE_DCT.setdefault(chr(i), '\\u{0:04x}'.format(i))
+    ESCAPE_DCT.setdefault(chr(i), '\\u%04x' % (i,))
+
+FLOAT_REPR = repr
+
+def encode_basestring(s):
+    """Return a JSON representation of a Python string
+
+    """
+    if isinstance(s, str) and HAS_UTF8.search(s) is not None:
+        s = s.decode('utf-8')
+    def replace(match):
+        return ESCAPE_DCT[match.group(0)]
+    return u'"' + ESCAPE.sub(replace, s) + u'"'
+
+
+def py_encode_basestring_ascii(s):
+    """Return an ASCII-only JSON representation of a Python string
+
+    """
+    if isinstance(s, str) and HAS_UTF8.search(s) is not None:
+        s = s.decode('utf-8')
+    def replace(match):
+        s = match.group(0)
+        try:
+            return ESCAPE_DCT[s]
+        except KeyError:
+            n = ord(s)
+            if n < 0x10000:
+                #return '\\u{0:04x}'.format(n)
+                return '\\u%04x' % (n,)
+            else:
+                # surrogate pair
+                n -= 0x10000
+                s1 = 0xd800 | ((n >> 10) & 0x3ff)
+                s2 = 0xdc00 | (n & 0x3ff)
+                #return '\\u{0:04x}\\u{1:04x}'.format(s1, s2)
+                return '\\u%04x\\u%04x' % (s1, s2)
+    return '"' + str(ESCAPE_ASCII.sub(replace, s)) + '"'
+
+
+encode_basestring_ascii = (
+    c_encode_basestring_ascii or py_encode_basestring_ascii)
+
+class JSONEncoder(object):
+    """Extensible JSON <http://json.org> encoder for Python data structures.
+
+    Supports the following objects and types by default:
+
+    +-------------------+---------------+
+    | Python            | JSON          |
+    +===================+===============+
+    | dict              | object        |
+    +-------------------+---------------+
+    | list, tuple       | array         |
+    +-------------------+---------------+
+    | str, unicode      | string        |
+    +-------------------+---------------+
+    | int, long, float  | number        |
+    +-------------------+---------------+
+    | True              | true          |
+    +-------------------+---------------+
+    | False             | false         |
+    +-------------------+---------------+
+    | None              | null          |
+    +-------------------+---------------+
+
+    To extend this to recognize other objects, subclass and implement a
+    ``.default()`` method with another method that returns a serializable
+    object for ``o`` if possible, otherwise it should call the superclass
+    implementation (to raise ``TypeError``).
+
+    """
+    item_separator = ', '
+    key_separator = ': '
+    def __init__(self, skipkeys=False, ensure_ascii=True,
+            check_circular=True, allow_nan=True, sort_keys=False,
+            indent=None, separators=None, encoding='utf-8', default=None):
+        """Constructor for JSONEncoder, with sensible defaults.
+
+        If skipkeys is false, then it is a TypeError to attempt
+        encoding of keys that are not str, int, long, float or None.  If
+        skipkeys is True, such items are simply skipped.
+
+        If ensure_ascii is true, the output is guaranteed to be str
+        objects with all incoming unicode characters escaped.  If
+        ensure_ascii is false, the output will be unicode object.
+
+        If check_circular is true, then lists, dicts, and custom encoded
+        objects will be checked for circular references during encoding to
+        prevent an infinite recursion (which would cause an OverflowError).
+        Otherwise, no such check takes place.
+
+        If allow_nan is true, then NaN, Infinity, and -Infinity will be
+        encoded as such.  This behavior is not JSON specification compliant,
+        but is consistent with most JavaScript based encoders and decoders.
+        Otherwise, it will be a ValueError to encode such floats.
+
+        If sort_keys is true, then the output of dictionaries will be
+        sorted by key; this is useful for regression tests to ensure
+        that JSON serializations can be compared on a day-to-day basis.
+
+        If indent is a string, then JSON array elements and object members
+        will be pretty-printed with a newline followed by that string repeated
+        for each level of nesting. ``None`` (the default) selects the most compact
+        representation without any newlines. For backwards compatibility with
+        versions of simplejson earlier than 2.1.0, an integer is also accepted
+        and is converted to a string with that many spaces.
+
+        If specified, separators should be a (item_separator, key_separator)
+        tuple.  The default is (', ', ': ').  To get the most compact JSON
+        representation you should specify (',', ':') to eliminate whitespace.
+
+        If specified, default is a function that gets called for objects
+        that can't otherwise be serialized.  It should return a JSON encodable
+        version of the object or raise a ``TypeError``.
+
+        If encoding is not None, then all input strings will be
+        transformed into unicode using that encoding prior to JSON-encoding.
+        The default is UTF-8.
+
+        """
+
+        self.skipkeys = skipkeys
+        self.ensure_ascii = ensure_ascii
+        self.check_circular = check_circular
+        self.allow_nan = allow_nan
+        self.sort_keys = sort_keys
+        if isinstance(indent, (int, long)):
+            indent = ' ' * indent
+        self.indent = indent
+        if separators is not None:
+            self.item_separator, self.key_separator = separators
+        if default is not None:
+            self.default = default
+        self.encoding = encoding
+
+    def default(self, o):
+        """Implement this method in a subclass such that it returns
+        a serializable object for ``o``, or calls the base implementation
+        (to raise a ``TypeError``).
+
+        For example, to support arbitrary iterators, you could
+        implement default like this::
+
+            def default(self, o):
+                try:
+                    iterable = iter(o)
+                except TypeError:
+                    pass
+                else:
+                    return list(iterable)
+                return JSONEncoder.default(self, o)
+
+        """
+        raise TypeError(repr(o) + " is not JSON serializable")
+
+    def encode(self, o):
+        """Return a JSON string representation of a Python data structure.
+
+        >>> from simplejson import JSONEncoder
+        >>> JSONEncoder().encode({"foo": ["bar", "baz"]})
+        '{"foo": ["bar", "baz"]}'
+
+        """
+        # This is for extremely simple cases and benchmarks.
+        if isinstance(o, basestring):
+            if isinstance(o, str):
+                _encoding = self.encoding
+                if (_encoding is not None
+                        and not (_encoding == 'utf-8')):
+                    o = o.decode(_encoding)
+            if self.ensure_ascii:
+                return encode_basestring_ascii(o)
+            else:
+                return encode_basestring(o)
+        # This doesn't pass the iterator directly to ''.join() because the
+        # exceptions aren't as detailed.  The list call should be roughly
+        # equivalent to the PySequence_Fast that ''.join() would do.
+        chunks = self.iterencode(o, _one_shot=True)
+        if not isinstance(chunks, (list, tuple)):
+            chunks = list(chunks)
+        if self.ensure_ascii:
+            return ''.join(chunks)
+        else:
+            return u''.join(chunks)
+
+    def iterencode(self, o, _one_shot=False):
+        """Encode the given object and yield each string
+        representation as available.
+
+        For example::
+
+            for chunk in JSONEncoder().iterencode(bigobject):
+                mysocket.write(chunk)
+
+        """
+        if self.check_circular:
+            markers = {}
+        else:
+            markers = None
+        if self.ensure_ascii:
+            _encoder = encode_basestring_ascii
+        else:
+            _encoder = encode_basestring
+        if self.encoding != 'utf-8':
+            def _encoder(o, _orig_encoder=_encoder, _encoding=self.encoding):
+                if isinstance(o, str):
+                    o = o.decode(_encoding)
+                return _orig_encoder(o)
+
+        def floatstr(o, allow_nan=self.allow_nan,
+                _repr=FLOAT_REPR, _inf=PosInf, _neginf=-PosInf):
+            # Check for specials. Note that this type of test is processor
+            # and/or platform-specific, so do tests which don't depend on
+            # the internals.
+
+            if o != o:
+                text = 'NaN'
+            elif o == _inf:
+                text = 'Infinity'
+            elif o == _neginf:
+                text = '-Infinity'
+            else:
+                return _repr(o)
+
+            if not allow_nan:
+                raise ValueError(
+                    "Out of range float values are not JSON compliant: " +
+                    repr(o))
+
+            return text
+
+
+        if (_one_shot and c_make_encoder is not None
+                and not self.indent and not self.sort_keys):
+            _iterencode = c_make_encoder(
+                markers, self.default, _encoder, self.indent,
+                self.key_separator, self.item_separator, self.sort_keys,
+                self.skipkeys, self.allow_nan)
+        else:
+            _iterencode = _make_iterencode(
+                markers, self.default, _encoder, self.indent, floatstr,
+                self.key_separator, self.item_separator, self.sort_keys,
+                self.skipkeys, _one_shot)
+        return _iterencode(o, 0)
+
+def _make_iterencode(markers, _default, _encoder, _indent, _floatstr,
+        _key_separator, _item_separator, _sort_keys, _skipkeys, _one_shot,
+        ## HACK: hand-optimized bytecode; turn globals into locals
+        False=False,
+        True=True,
+        ValueError=ValueError,
+        basestring=basestring,
+        dict=dict,
+        float=float,
+        id=id,
+        int=int,
+        isinstance=isinstance,
+        list=list,
+        long=long,
+        str=str,
+        tuple=tuple,
+    ):
+
+    def _iterencode_list(lst, _current_indent_level):
+        if not lst:
+            yield '[]'
+            return
+        if markers is not None:
+            markerid = id(lst)
+            if markerid in markers:
+                raise ValueError("Circular reference detected")
+            markers[markerid] = lst
+        buf = '['
+        if _indent is not None:
+            _current_indent_level += 1
+            newline_indent = '\n' + (_indent * _current_indent_level)
+            separator = _item_separator + newline_indent
+            buf += newline_indent
+        else:
+            newline_indent = None
+            separator = _item_separator
+        first = True
+        for value in lst:
+            if first:
+                first = False
+            else:
+                buf = separator
+            if isinstance(value, basestring):
+                yield buf + _encoder(value)
+            elif value is None:
+                yield buf + 'null'
+            elif value is True:
+                yield buf + 'true'
+            elif value is False:
+                yield buf + 'false'
+            elif isinstance(value, (int, long)):
+                yield buf + str(value)
+            elif isinstance(value, float):
+                yield buf + _floatstr(value)
+            else:
+                yield buf
+                if isinstance(value, (list, tuple)):
+                    chunks = _iterencode_list(value, _current_indent_level)
+                elif isinstance(value, dict):
+                    chunks = _iterencode_dict(value, _current_indent_level)
+                else:
+                    chunks = _iterencode(value, _current_indent_level)
+                for chunk in chunks:
+                    yield chunk
+        if newline_indent is not None:
+            _current_indent_level -= 1
+            yield '\n' + (_indent * _current_indent_level)
+        yield ']'
+        if markers is not None:
+            del markers[markerid]
+
+    def _iterencode_dict(dct, _current_indent_level):
+        if not dct:
+            yield '{}'
+            return
+        if markers is not None:
+            markerid = id(dct)
+            if markerid in markers:
+                raise ValueError("Circular reference detected")
+            markers[markerid] = dct
+        yield '{'
+        if _indent is not None:
+            _current_indent_level += 1
+            newline_indent = '\n' + (_indent * _current_indent_level)
+            item_separator = _item_separator + newline_indent
+            yield newline_indent
+        else:
+            newline_indent = None
+            item_separator = _item_separator
+        first = True
+        if _sort_keys:
+            items = dct.items()
+            items.sort(key=lambda kv: kv[0])
+        else:
+            items = dct.iteritems()
+        for key, value in items:
+            if isinstance(key, basestring):
+                pass
+            # JavaScript is weakly typed for these, so it makes sense to
+            # also allow them.  Many encoders seem to do something like this.
+            elif isinstance(key, float):
+                key = _floatstr(key)
+            elif key is True:
+                key = 'true'
+            elif key is False:
+                key = 'false'
+            elif key is None:
+                key = 'null'
+            elif isinstance(key, (int, long)):
+                key = str(key)
+            elif _skipkeys:
+                continue
+            else:
+                raise TypeError("key " + repr(key) + " is not a string")
+            if first:
+                first = False
+            else:
+                yield item_separator
+            yield _encoder(key)
+            yield _key_separator
+            if isinstance(value, basestring):
+                yield _encoder(value)
+            elif value is None:
+                yield 'null'
+            elif value is True:
+                yield 'true'
+            elif value is False:
+                yield 'false'
+            elif isinstance(value, (int, long)):
+                yield str(value)
+            elif isinstance(value, float):
+                yield _floatstr(value)
+            else:
+                if isinstance(value, (list, tuple)):
+                    chunks = _iterencode_list(value, _current_indent_level)
+                elif isinstance(value, dict):
+                    chunks = _iterencode_dict(value, _current_indent_level)
+                else:
+                    chunks = _iterencode(value, _current_indent_level)
+                for chunk in chunks:
+                    yield chunk
+        if newline_indent is not None:
+            _current_indent_level -= 1
+            yield '\n' + (_indent * _current_indent_level)
+        yield '}'
+        if markers is not None:
+            del markers[markerid]
+
+    def _iterencode(o, _current_indent_level):
+        if isinstance(o, basestring):
+            yield _encoder(o)
+        elif o is None:
+            yield 'null'
+        elif o is True:
+            yield 'true'
+        elif o is False:
+            yield 'false'
+        elif isinstance(o, (int, long)):
+            yield str(o)
+        elif isinstance(o, float):
+            yield _floatstr(o)
+        elif isinstance(o, (list, tuple)):
+            for chunk in _iterencode_list(o, _current_indent_level):
+                yield chunk
+        elif isinstance(o, dict):
+            for chunk in _iterencode_dict(o, _current_indent_level):
+                yield chunk
+        else:
+            if markers is not None:
+                markerid = id(o)
+                if markerid in markers:
+                    raise ValueError("Circular reference detected")
+                markers[markerid] = o
+            o = _default(o)
+            for chunk in _iterencode(o, _current_indent_level):
+                yield chunk
+            if markers is not None:
+                del markers[markerid]
+
+    return _iterencode
Index: waveapi/simplejson/__init__.py
===================================================================
--- waveapi/simplejson/__init__.py	(revision 0)
+++ waveapi/simplejson/__init__.py	(revision 0)
@@ -0,0 +1,406 @@
+r"""JSON (JavaScript Object Notation) <http://json.org> is a subset of
+JavaScript syntax (ECMA-262 3rd edition) used as a lightweight data
+interchange format.
+
+:mod:`simplejson` exposes an API familiar to users of the standard library
+:mod:`marshal` and :mod:`pickle` modules. It is the externally maintained
+version of the :mod:`json` library contained in Python 2.6, but maintains
+compatibility with Python 2.4 and Python 2.5 and (currently) has
+significant performance advantages, even without using the optional C
+extension for speedups.
+
+Encoding basic Python object hierarchies::
+
+    >>> import simplejson as json
+    >>> json.dumps(['foo', {'bar': ('baz', None, 1.0, 2)}])
+    '["foo", {"bar": ["baz", null, 1.0, 2]}]'
+    >>> print json.dumps("\"foo\bar")
+    "\"foo\bar"
+    >>> print json.dumps(u'\u1234')
+    "\u1234"
+    >>> print json.dumps('\\')
+    "\\"
+    >>> print json.dumps({"c": 0, "b": 0, "a": 0}, sort_keys=True)
+    {"a": 0, "b": 0, "c": 0}
+    >>> from StringIO import StringIO
+    >>> io = StringIO()
+    >>> json.dump(['streaming API'], io)
+    >>> io.getvalue()
+    '["streaming API"]'
+
+Compact encoding::
+
+    >>> import simplejson as json
+    >>> json.dumps([1,2,3,{'4': 5, '6': 7}], separators=(',',':'))
+    '[1,2,3,{"4":5,"6":7}]'
+
+Pretty printing::
+
+    >>> import simplejson as json
+    >>> s = json.dumps({'4': 5, '6': 7}, sort_keys=True, indent='    ')
+    >>> print '\n'.join([l.rstrip() for l in  s.splitlines()])
+    {
+        "4": 5,
+        "6": 7
+    }
+
+Decoding JSON::
+
+    >>> import simplejson as json
+    >>> obj = [u'foo', {u'bar': [u'baz', None, 1.0, 2]}]
+    >>> json.loads('["foo", {"bar":["baz", null, 1.0, 2]}]') == obj
+    True
+    >>> json.loads('"\\"foo\\bar"') == u'"foo\x08ar'
+    True
+    >>> from StringIO import StringIO
+    >>> io = StringIO('["streaming API"]')
+    >>> json.load(io)[0] == 'streaming API'
+    True
+
+Specializing JSON object decoding::
+
+    >>> import simplejson as json
+    >>> def as_complex(dct):
+    ...     if '__complex__' in dct:
+    ...         return complex(dct['real'], dct['imag'])
+    ...     return dct
+    ...
+    >>> json.loads('{"__complex__": true, "real": 1, "imag": 2}',
+    ...     object_hook=as_complex)
+    (1+2j)
+    >>> from decimal import Decimal
+    >>> json.loads('1.1', parse_float=Decimal) == Decimal('1.1')
+    True
+
+Specializing JSON object encoding::
+
+    >>> import simplejson as json
+    >>> def encode_complex(obj):
+    ...     if isinstance(obj, complex):
+    ...         return [obj.real, obj.imag]
+    ...     raise TypeError(repr(o) + " is not JSON serializable")
+    ...
+    >>> json.dumps(2 + 1j, default=encode_complex)
+    '[2.0, 1.0]'
+    >>> json.JSONEncoder(default=encode_complex).encode(2 + 1j)
+    '[2.0, 1.0]'
+    >>> ''.join(json.JSONEncoder(default=encode_complex).iterencode(2 + 1j))
+    '[2.0, 1.0]'
+
+
+Using simplejson.tool from the shell to validate and pretty-print::
+
+    $ echo '{"json":"obj"}' | python -m simplejson.tool
+    {
+        "json": "obj"
+    }
+    $ echo '{ 1.2:3.4}' | python -m simplejson.tool
+    Expecting property name: line 1 column 2 (char 2)
+"""
+__version__ = '2.1.0'
+__all__ = [
+    'dump', 'dumps', 'load', 'loads',
+    'JSONDecoder', 'JSONDecodeError', 'JSONEncoder',
+    'OrderedDict',
+]
+
+__author__ = 'Bob Ippolito <bob@redivi.com>'
+
+from decoder import JSONDecoder, JSONDecodeError
+from encoder import JSONEncoder
+try:
+    from collections import OrderedDict
+except ImportError:
+    from ordered_dict import OrderedDict
+
+_default_encoder = JSONEncoder(
+    skipkeys=False,
+    ensure_ascii=True,
+    check_circular=True,
+    allow_nan=True,
+    indent=None,
+    separators=None,
+    encoding='utf-8',
+    default=None,
+)
+
+def dump(obj, fp, skipkeys=False, ensure_ascii=True, check_circular=True,
+        allow_nan=True, cls=None, indent=None, separators=None,
+        encoding='utf-8', default=None, **kw):
+    """Serialize ``obj`` as a JSON formatted stream to ``fp`` (a
+    ``.write()``-supporting file-like object).
+
+    If ``skipkeys`` is true then ``dict`` keys that are not basic types
+    (``str``, ``unicode``, ``int``, ``long``, ``float``, ``bool``, ``None``)
+    will be skipped instead of raising a ``TypeError``.
+
+    If ``ensure_ascii`` is false, then the some chunks written to ``fp``
+    may be ``unicode`` instances, subject to normal Python ``str`` to
+    ``unicode`` coercion rules. Unless ``fp.write()`` explicitly
+    understands ``unicode`` (as in ``codecs.getwriter()``) this is likely
+    to cause an error.
+
+    If ``check_circular`` is false, then the circular reference check
+    for container types will be skipped and a circular reference will
+    result in an ``OverflowError`` (or worse).
+
+    If ``allow_nan`` is false, then it will be a ``ValueError`` to
+    serialize out of range ``float`` values (``nan``, ``inf``, ``-inf``)
+    in strict compliance of the JSON specification, instead of using the
+    JavaScript equivalents (``NaN``, ``Infinity``, ``-Infinity``).
+
+    If *indent* is a string, then JSON array elements and object members
+    will be pretty-printed with a newline followed by that string repeated
+    for each level of nesting. ``None`` (the default) selects the most compact
+    representation without any newlines. For backwards compatibility with
+    versions of simplejson earlier than 2.1.0, an integer is also accepted
+    and is converted to a string with that many spaces.
+
+    If ``separators`` is an ``(item_separator, dict_separator)`` tuple
+    then it will be used instead of the default ``(', ', ': ')`` separators.
+    ``(',', ':')`` is the most compact JSON representation.
+
+    ``encoding`` is the character encoding for str instances, default is UTF-8.
+
+    ``default(obj)`` is a function that should return a serializable version
+    of obj or raise TypeError. The default simply raises TypeError.
+
+    To use a custom ``JSONEncoder`` subclass (e.g. one that overrides the
+    ``.default()`` method to serialize additional types), specify it with
+    the ``cls`` kwarg.
+
+    """
+    # cached encoder
+    if (not skipkeys and ensure_ascii and
+        check_circular and allow_nan and
+        cls is None and indent is None and separators is None and
+        encoding == 'utf-8' and default is None and not kw):
+        iterable = _default_encoder.iterencode(obj)
+    else:
+        if cls is None:
+            cls = JSONEncoder
+        iterable = cls(skipkeys=skipkeys, ensure_ascii=ensure_ascii,
+            check_circular=check_circular, allow_nan=allow_nan, indent=indent,
+            separators=separators, encoding=encoding,
+            default=default, **kw).iterencode(obj)
+    # could accelerate with writelines in some versions of Python, at
+    # a debuggability cost
+    for chunk in iterable:
+        fp.write(chunk)
+
+
+def dumps(obj, skipkeys=False, ensure_ascii=True, check_circular=True,
+        allow_nan=True, cls=None, indent=None, separators=None,
+        encoding='utf-8', default=None, **kw):
+    """Serialize ``obj`` to a JSON formatted ``str``.
+
+    If ``skipkeys`` is false then ``dict`` keys that are not basic types
+    (``str``, ``unicode``, ``int``, ``long``, ``float``, ``bool``, ``None``)
+    will be skipped instead of raising a ``TypeError``.
+
+    If ``ensure_ascii`` is false, then the return value will be a
+    ``unicode`` instance subject to normal Python ``str`` to ``unicode``
+    coercion rules instead of being escaped to an ASCII ``str``.
+
+    If ``check_circular`` is false, then the circular reference check
+    for container types will be skipped and a circular reference will
+    result in an ``OverflowError`` (or worse).
+
+    If ``allow_nan`` is false, then it will be a ``ValueError`` to
+    serialize out of range ``float`` values (``nan``, ``inf``, ``-inf``) in
+    strict compliance of the JSON specification, instead of using the
+    JavaScript equivalents (``NaN``, ``Infinity``, ``-Infinity``).
+
+    If ``indent`` is a string, then JSON array elements and object members
+    will be pretty-printed with a newline followed by that string repeated
+    for each level of nesting. ``None`` (the default) selects the most compact
+    representation without any newlines. For backwards compatibility with
+    versions of simplejson earlier than 2.1.0, an integer is also accepted
+    and is converted to a string with that many spaces.
+
+    If ``separators`` is an ``(item_separator, dict_separator)`` tuple
+    then it will be used instead of the default ``(', ', ': ')`` separators.
+    ``(',', ':')`` is the most compact JSON representation.
+
+    ``encoding`` is the character encoding for str instances, default is UTF-8.
+
+    ``default(obj)`` is a function that should return a serializable version
+    of obj or raise TypeError. The default simply raises TypeError.
+
+    To use a custom ``JSONEncoder`` subclass (e.g. one that overrides the
+    ``.default()`` method to serialize additional types), specify it with
+    the ``cls`` kwarg.
+
+    """
+    # cached encoder
+    if (not skipkeys and ensure_ascii and
+        check_circular and allow_nan and
+        cls is None and indent is None and separators is None and
+        encoding == 'utf-8' and default is None and not kw):
+        return _default_encoder.encode(obj)
+    if cls is None:
+        cls = JSONEncoder
+    return cls(
+        skipkeys=skipkeys, ensure_ascii=ensure_ascii,
+        check_circular=check_circular, allow_nan=allow_nan, indent=indent,
+        separators=separators, encoding=encoding, default=default,
+        **kw).encode(obj)
+
+
+_default_decoder = JSONDecoder(encoding=None, object_hook=None,
+                               object_pairs_hook=None)
+
+
+def load(fp, encoding=None, cls=None, object_hook=None, parse_float=None,
+        parse_int=None, parse_constant=None, object_pairs_hook=None, **kw):
+    """Deserialize ``fp`` (a ``.read()``-supporting file-like object containing
+    a JSON document) to a Python object.
+
+    *encoding* determines the encoding used to interpret any
+    :class:`str` objects decoded by this instance (``'utf-8'`` by
+    default).  It has no effect when decoding :class:`unicode` objects.
+
+    Note that currently only encodings that are a superset of ASCII work,
+    strings of other encodings should be passed in as :class:`unicode`.
+
+    *object_hook*, if specified, will be called with the result of every
+    JSON object decoded and its return value will be used in place of the
+    given :class:`dict`.  This can be used to provide custom
+    deserializations (e.g. to support JSON-RPC class hinting).
+
+    *object_pairs_hook* is an optional function that will be called with
+    the result of any object literal decode with an ordered list of pairs.
+    The return value of *object_pairs_hook* will be used instead of the
+    :class:`dict`.  This feature can be used to implement custom decoders
+    that rely on the order that the key and value pairs are decoded (for
+    example, :func:`collections.OrderedDict` will remember the order of
+    insertion). If *object_hook* is also defined, the *object_pairs_hook*
+    takes priority.
+
+    *parse_float*, if specified, will be called with the string of every
+    JSON float to be decoded.  By default, this is equivalent to
+    ``float(num_str)``. This can be used to use another datatype or parser
+    for JSON floats (e.g. :class:`decimal.Decimal`).
+
+    *parse_int*, if specified, will be called with the string of every
+    JSON int to be decoded.  By default, this is equivalent to
+    ``int(num_str)``.  This can be used to use another datatype or parser
+    for JSON integers (e.g. :class:`float`).
+
+    *parse_constant*, if specified, will be called with one of the
+    following strings: ``'-Infinity'``, ``'Infinity'``, ``'NaN'``.  This
+    can be used to raise an exception if invalid JSON numbers are
+    encountered.
+
+    To use a custom ``JSONDecoder`` subclass, specify it with the ``cls``
+    kwarg.
+
+    """
+    return loads(fp.read(),
+        encoding=encoding, cls=cls, object_hook=object_hook,
+        parse_float=parse_float, parse_int=parse_int,
+        parse_constant=parse_constant, object_pairs_hook=object_pairs_hook,
+        **kw)
+
+
+def loads(s, encoding=None, cls=None, object_hook=None, parse_float=None,
+        parse_int=None, parse_constant=None, object_pairs_hook=None, **kw):
+    """Deserialize ``s`` (a ``str`` or ``unicode`` instance containing a JSON
+    document) to a Python object.
+
+    *encoding* determines the encoding used to interpret any
+    :class:`str` objects decoded by this instance (``'utf-8'`` by
+    default).  It has no effect when decoding :class:`unicode` objects.
+
+    Note that currently only encodings that are a superset of ASCII work,
+    strings of other encodings should be passed in as :class:`unicode`.
+
+    *object_hook*, if specified, will be called with the result of every
+    JSON object decoded and its return value will be used in place of the
+    given :class:`dict`.  This can be used to provide custom
+    deserializations (e.g. to support JSON-RPC class hinting).
+
+    *object_pairs_hook* is an optional function that will be called with
+    the result of any object literal decode with an ordered list of pairs.
+    The return value of *object_pairs_hook* will be used instead of the
+    :class:`dict`.  This feature can be used to implement custom decoders
+    that rely on the order that the key and value pairs are decoded (for
+    example, :func:`collections.OrderedDict` will remember the order of
+    insertion). If *object_hook* is also defined, the *object_pairs_hook*
+    takes priority.
+
+    *parse_float*, if specified, will be called with the string of every
+    JSON float to be decoded.  By default, this is equivalent to
+    ``float(num_str)``. This can be used to use another datatype or parser
+    for JSON floats (e.g. :class:`decimal.Decimal`).
+
+    *parse_int*, if specified, will be called with the string of every
+    JSON int to be decoded.  By default, this is equivalent to
+    ``int(num_str)``.  This can be used to use another datatype or parser
+    for JSON integers (e.g. :class:`float`).
+
+    *parse_constant*, if specified, will be called with one of the
+    following strings: ``'-Infinity'``, ``'Infinity'``, ``'NaN'``.  This
+    can be used to raise an exception if invalid JSON numbers are
+    encountered.
+
+    To use a custom ``JSONDecoder`` subclass, specify it with the ``cls``
+    kwarg.
+
+    """
+    if (cls is None and encoding is None and object_hook is None and
+            parse_int is None and parse_float is None and
+            parse_constant is None and object_pairs_hook is None and not kw):
+        return _default_decoder.decode(s)
+    if cls is None:
+        cls = JSONDecoder
+    if object_hook is not None:
+        kw['object_hook'] = object_hook
+    if object_pairs_hook is not None:
+        kw['object_pairs_hook'] = object_pairs_hook
+    if parse_float is not None:
+        kw['parse_float'] = parse_float
+    if parse_int is not None:
+        kw['parse_int'] = parse_int
+    if parse_constant is not None:
+        kw['parse_constant'] = parse_constant
+    return cls(encoding=encoding, **kw).decode(s)
+
+
+def _toggle_speedups(enabled):
+    import simplejson.decoder as dec
+    import simplejson.encoder as enc
+    import simplejson.scanner as scan
+    try:
+        from simplejson._speedups import make_encoder as c_make_encoder
+    except ImportError:
+        c_make_encoder = None
+    if enabled:
+        dec.scanstring = dec.c_scanstring or dec.py_scanstring
+        enc.c_make_encoder = c_make_encoder
+        enc.encode_basestring_ascii = (enc.c_encode_basestring_ascii or 
+            enc.py_encode_basestring_ascii)
+        scan.make_scanner = scan.c_make_scanner or scan.py_make_scanner
+    else:
+        dec.scanstring = dec.py_scanstring
+        enc.c_make_encoder = None
+        enc.encode_basestring_ascii = enc.py_encode_basestring_ascii
+        scan.make_scanner = scan.py_make_scanner
+    dec.make_scanner = scan.make_scanner
+    global _default_decoder
+    _default_decoder = JSONDecoder(
+        encoding=None,
+        object_hook=None,
+        object_pairs_hook=None,
+    )
+    global _default_encoder
+    _default_encoder = JSONEncoder(
+       skipkeys=False,
+       ensure_ascii=True,
+       check_circular=True,
+       allow_nan=True,
+       indent=None,
+       separators=None,
+       encoding='utf-8',
+       default=None,
+   )
\ No newline at end of file
Index: waveapi/simplejson/jsonfilter.py
===================================================================
--- waveapi/simplejson/jsonfilter.py	(revision 0)
+++ waveapi/simplejson/jsonfilter.py	(revision 0)
@@ -0,0 +1,40 @@
+import simplejson
+import cgi
+
+class JSONFilter(object):
+    def __init__(self, app, mime_type='text/x-json'):
+        self.app = app
+        self.mime_type = mime_type
+
+    def __call__(self, environ, start_response):
+        # Read JSON POST input to jsonfilter.json if matching mime type
+        response = {'status': '200 OK', 'headers': []}
+        def json_start_response(status, headers):
+            response['status'] = status
+            response['headers'].extend(headers)
+        environ['jsonfilter.mime_type'] = self.mime_type
+        if environ.get('REQUEST_METHOD', '') == 'POST':
+            if environ.get('CONTENT_TYPE', '') == self.mime_type:
+                args = [_ for _ in [environ.get('CONTENT_LENGTH')] if _]
+                data = environ['wsgi.input'].read(*map(int, args))
+                environ['jsonfilter.json'] = simplejson.loads(data)
+        res = simplejson.dumps(self.app(environ, json_start_response))
+        jsonp = cgi.parse_qs(environ.get('QUERY_STRING', '')).get('jsonp')
+        if jsonp:
+            content_type = 'text/javascript'
+            res = ''.join(jsonp + ['(', res, ')'])
+        elif 'Opera' in environ.get('HTTP_USER_AGENT', ''):
+            # Opera has bunk XMLHttpRequest support for most mime types
+            content_type = 'text/plain'
+        else:
+            content_type = self.mime_type
+        headers = [
+            ('Content-type', content_type),
+            ('Content-length', len(res)),
+        ]
+        headers.extend(response['headers'])
+        start_response(response['status'], headers)
+        return [res]
+
+def factory(app, global_conf, **kw):
+    return JSONFilter(app, **kw)
Index: waveapi/simplejson/decoder.py
===================================================================
--- waveapi/simplejson/decoder.py	(revision 0)
+++ waveapi/simplejson/decoder.py	(revision 0)
@@ -0,0 +1,411 @@
+"""Implementation of JSONDecoder
+"""
+import re
+import sys
+import struct
+
+from scanner import make_scanner
+try:
+    from _speedups import scanstring as c_scanstring
+except ImportError:
+    c_scanstring = None
+
+__all__ = ['JSONDecoder']
+
+FLAGS = re.VERBOSE | re.MULTILINE | re.DOTALL
+
+def _floatconstants():
+    _BYTES = '7FF80000000000007FF0000000000000'.decode('hex')
+    # The struct module in Python 2.4 would get frexp() out of range here
+    # when an endian is specified in the format string. Fixed in Python 2.5+
+    if sys.byteorder != 'big':
+        _BYTES = _BYTES[:8][::-1] + _BYTES[8:][::-1]
+    nan, inf = struct.unpack('dd', _BYTES)
+    return nan, inf, -inf
+
+NaN, PosInf, NegInf = _floatconstants()
+
+
+class JSONDecodeError(ValueError):
+    """Subclass of ValueError with the following additional properties:
+    
+    msg: The unformatted error message
+    doc: The JSON document being parsed
+    pos: The start index of doc where parsing failed
+    end: The end index of doc where parsing failed (may be None)
+    lineno: The line corresponding to pos
+    colno: The column corresponding to pos
+    endlineno: The line corresponding to end (may be None)
+    endcolno: The column corresponding to end (may be None)
+    
+    """
+    def __init__(self, msg, doc, pos, end=None):
+        ValueError.__init__(self, errmsg(msg, doc, pos, end=end))
+        self.msg = msg
+        self.doc = doc
+        self.pos = pos
+        self.end = end
+        self.lineno, self.colno = linecol(doc, pos)
+        if end is not None:
+            self.endlineno, self.endcolno = linecol(doc, pos)
+        else:
+            self.endlineno, self.endcolno = None, None
+
+
+def linecol(doc, pos):
+    lineno = doc.count('\n', 0, pos) + 1
+    if lineno == 1:
+        colno = pos
+    else:
+        colno = pos - doc.rindex('\n', 0, pos)
+    return lineno, colno
+
+
+def errmsg(msg, doc, pos, end=None):
+    # Note that this function is called from _speedups
+    lineno, colno = linecol(doc, pos)
+    if end is None:
+        #fmt = '{0}: line {1} column {2} (char {3})'
+        #return fmt.format(msg, lineno, colno, pos)
+        fmt = '%s: line %d column %d (char %d)'
+        return fmt % (msg, lineno, colno, pos)
+    endlineno, endcolno = linecol(doc, end)
+    #fmt = '{0}: line {1} column {2} - line {3} column {4} (char {5} - {6})'
+    #return fmt.format(msg, lineno, colno, endlineno, endcolno, pos, end)
+    fmt = '%s: line %d column %d - line %d column %d (char %d - %d)'
+    return fmt % (msg, lineno, colno, endlineno, endcolno, pos, end)
+
+
+_CONSTANTS = {
+    '-Infinity': NegInf,
+    'Infinity': PosInf,
+    'NaN': NaN,
+}
+
+STRINGCHUNK = re.compile(r'(.*?)(["\\\x00-\x1f])', FLAGS)
+BACKSLASH = {
+    '"': u'"', '\\': u'\\', '/': u'/',
+    'b': u'\b', 'f': u'\f', 'n': u'\n', 'r': u'\r', 't': u'\t',
+}
+
+DEFAULT_ENCODING = "utf-8"
+
+def py_scanstring(s, end, encoding=None, strict=True,
+        _b=BACKSLASH, _m=STRINGCHUNK.match):
+    """Scan the string s for a JSON string. End is the index of the
+    character in s after the quote that started the JSON string.
+    Unescapes all valid JSON string escape sequences and raises ValueError
+    on attempt to decode an invalid string. If strict is False then literal
+    control characters are allowed in the string.
+
+    Returns a tuple of the decoded string and the index of the character in s
+    after the end quote."""
+    if encoding is None:
+        encoding = DEFAULT_ENCODING
+    chunks = []
+    _append = chunks.append
+    begin = end - 1
+    while 1:
+        chunk = _m(s, end)
+        if chunk is None:
+            raise JSONDecodeError(
+                "Unterminated string starting at", s, begin)
+        end = chunk.end()
+        content, terminator = chunk.groups()
+        # Content is contains zero or more unescaped string characters
+        if content:
+            if not isinstance(content, unicode):
+                content = unicode(content, encoding)
+            _append(content)
+        # Terminator is the end of string, a literal control character,
+        # or a backslash denoting that an escape sequence follows
+        if terminator == '"':
+            break
+        elif terminator != '\\':
+            if strict:
+                msg = "Invalid control character %r at" % (terminator,)
+                #msg = "Invalid control character {0!r} at".format(terminator)
+                raise JSONDecodeError(msg, s, end)
+            else:
+                _append(terminator)
+                continue
+        try:
+            esc = s[end]
+        except IndexError:
+            raise JSONDecodeError(
+                "Unterminated string starting at", s, begin)
+        # If not a unicode escape sequence, must be in the lookup table
+        if esc != 'u':
+            try:
+                char = _b[esc]
+            except KeyError:
+                msg = "Invalid \\escape: " + repr(esc)
+                raise JSONDecodeError(msg, s, end)
+            end += 1
+        else:
+            # Unicode escape sequence
+            esc = s[end + 1:end + 5]
+            next_end = end + 5
+            if len(esc) != 4:
+                msg = "Invalid \\uXXXX escape"
+                raise JSONDecodeError(msg, s, end)
+            uni = int(esc, 16)
+            # Check for surrogate pair on UCS-4 systems
+            if 0xd800 <= uni <= 0xdbff and sys.maxunicode > 65535:
+                msg = "Invalid \\uXXXX\\uXXXX surrogate pair"
+                if not s[end + 5:end + 7] == '\\u':
+                    raise JSONDecodeError(msg, s, end)
+                esc2 = s[end + 7:end + 11]
+                if len(esc2) != 4:
+                    raise JSONDecodeError(msg, s, end)
+                uni2 = int(esc2, 16)
+                uni = 0x10000 + (((uni - 0xd800) << 10) | (uni2 - 0xdc00))
+                next_end += 6
+            char = unichr(uni)
+            end = next_end
+        # Append the unescaped character
+        _append(char)
+    return u''.join(chunks), end
+
+
+# Use speedup if available
+scanstring = c_scanstring or py_scanstring
+
+WHITESPACE = re.compile(r'[ \t\n\r]*', FLAGS)
+WHITESPACE_STR = ' \t\n\r'
+
+def JSONObject((s, end), encoding, strict, scan_once, object_hook,
+        object_pairs_hook, _w=WHITESPACE.match, _ws=WHITESPACE_STR):
+    pairs = []
+    # Use a slice to prevent IndexError from being raised, the following
+    # check will raise a more specific ValueError if the string is empty
+    nextchar = s[end:end + 1]
+    # Normally we expect nextchar == '"'
+    if nextchar != '"':
+        if nextchar in _ws:
+            end = _w(s, end).end()
+            nextchar = s[end:end + 1]
+        # Trivial empty object
+        if nextchar == '}':
+            if object_pairs_hook is not None:
+                result = object_pairs_hook(pairs)
+                return result, end
+            pairs = {}
+            if object_hook is not None:
+                pairs = object_hook(pairs)
+            return pairs, end + 1
+        elif nextchar != '"':
+            raise JSONDecodeError("Expecting property name", s, end)
+    end += 1
+    while True:
+        key, end = scanstring(s, end, encoding, strict)
+
+        # To skip some function call overhead we optimize the fast paths where
+        # the JSON key separator is ": " or just ":".
+        if s[end:end + 1] != ':':
+            end = _w(s, end).end()
+            if s[end:end + 1] != ':':
+                raise JSONDecodeError("Expecting : delimiter", s, end)
+
+        end += 1
+
+        try:
+            if s[end] in _ws:
+                end += 1
+                if s[end] in _ws:
+                    end = _w(s, end + 1).end()
+        except IndexError:
+            pass
+
+        try:
+            value, end = scan_once(s, end)
+        except StopIteration:
+            raise JSONDecodeError("Expecting object", s, end)
+        pairs.append((key, value))
+
+        try:
+            nextchar = s[end]
+            if nextchar in _ws:
+                end = _w(s, end + 1).end()
+                nextchar = s[end]
+        except IndexError:
+            nextchar = ''
+        end += 1
+
+        if nextchar == '}':
+            break
+        elif nextchar != ',':
+            raise JSONDecodeError("Expecting , delimiter", s, end - 1)
+
+        try:
+            nextchar = s[end]
+            if nextchar in _ws:
+                end += 1
+                nextchar = s[end]
+                if nextchar in _ws:
+                    end = _w(s, end + 1).end()
+                    nextchar = s[end]
+        except IndexError:
+            nextchar = ''
+
+        end += 1
+        if nextchar != '"':
+            raise JSONDecodeError("Expecting property name", s, end - 1)
+
+    if object_pairs_hook is not None:
+        result = object_pairs_hook(pairs)
+        return result, end
+    pairs = dict(pairs)
+    if object_hook is not None:
+        pairs = object_hook(pairs)
+    return pairs, end
+
+def JSONArray((s, end), scan_once, _w=WHITESPACE.match, _ws=WHITESPACE_STR):
+    values = []
+    nextchar = s[end:end + 1]
+    if nextchar in _ws:
+        end = _w(s, end + 1).end()
+        nextchar = s[end:end + 1]
+    # Look-ahead for trivial empty array
+    if nextchar == ']':
+        return values, end + 1
+    _append = values.append
+    while True:
+        try:
+            value, end = scan_once(s, end)
+        except StopIteration:
+            raise JSONDecodeError("Expecting object", s, end)
+        _append(value)
+        nextchar = s[end:end + 1]
+        if nextchar in _ws:
+            end = _w(s, end + 1).end()
+            nextchar = s[end:end + 1]
+        end += 1
+        if nextchar == ']':
+            break
+        elif nextchar != ',':
+            raise JSONDecodeError("Expecting , delimiter", s, end)
+
+        try:
+            if s[end] in _ws:
+                end += 1
+                if s[end] in _ws:
+                    end = _w(s, end + 1).end()
+        except IndexError:
+            pass
+
+    return values, end
+
+class JSONDecoder(object):
+    """Simple JSON <http://json.org> decoder
+
+    Performs the following translations in decoding by default:
+
+    +---------------+-------------------+
+    | JSON          | Python            |
+    +===============+===================+
+    | object        | dict              |
+    +---------------+-------------------+
+    | array         | list              |
+    +---------------+-------------------+
+    | string        | unicode           |
+    +---------------+-------------------+
+    | number (int)  | int, long         |
+    +---------------+-------------------+
+    | number (real) | float             |
+    +---------------+-------------------+
+    | true          | True              |
+    +---------------+-------------------+
+    | false         | False             |
+    +---------------+-------------------+
+    | null          | None              |
+    +---------------+-------------------+
+
+    It also understands ``NaN``, ``Infinity``, and ``-Infinity`` as
+    their corresponding ``float`` values, which is outside the JSON spec.
+
+    """
+
+    def __init__(self, encoding=None, object_hook=None, parse_float=None,
+            parse_int=None, parse_constant=None, strict=True,
+            object_pairs_hook=None):
+        """
+        *encoding* determines the encoding used to interpret any
+        :class:`str` objects decoded by this instance (``'utf-8'`` by
+        default).  It has no effect when decoding :class:`unicode` objects.
+
+        Note that currently only encodings that are a superset of ASCII work,
+        strings of other encodings should be passed in as :class:`unicode`.
+
+        *object_hook*, if specified, will be called with the result of every
+        JSON object decoded and its return value will be used in place of the
+        given :class:`dict`.  This can be used to provide custom
+        deserializations (e.g. to support JSON-RPC class hinting).
+
+        *object_pairs_hook* is an optional function that will be called with
+        the result of any object literal decode with an ordered list of pairs.
+        The return value of *object_pairs_hook* will be used instead of the
+        :class:`dict`.  This feature can be used to implement custom decoders
+        that rely on the order that the key and value pairs are decoded (for
+        example, :func:`collections.OrderedDict` will remember the order of
+        insertion). If *object_hook* is also defined, the *object_pairs_hook*
+        takes priority.
+
+        *parse_float*, if specified, will be called with the string of every
+        JSON float to be decoded.  By default, this is equivalent to
+        ``float(num_str)``. This can be used to use another datatype or parser
+        for JSON floats (e.g. :class:`decimal.Decimal`).
+
+        *parse_int*, if specified, will be called with the string of every
+        JSON int to be decoded.  By default, this is equivalent to
+        ``int(num_str)``.  This can be used to use another datatype or parser
+        for JSON integers (e.g. :class:`float`).
+
+        *parse_constant*, if specified, will be called with one of the
+        following strings: ``'-Infinity'``, ``'Infinity'``, ``'NaN'``.  This
+        can be used to raise an exception if invalid JSON numbers are
+        encountered.
+
+        *strict* controls the parser's behavior when it encounters an
+        invalid control character in a string. The default setting of
+        ``True`` means that unescaped control characters are parse errors, if
+        ``False`` then control characters will be allowed in strings.
+
+        """
+        self.encoding = encoding
+        self.object_hook = object_hook
+        self.object_pairs_hook = object_pairs_hook
+        self.parse_float = parse_float or float
+        self.parse_int = parse_int or int
+        self.parse_constant = parse_constant or _CONSTANTS.__getitem__
+        self.strict = strict
+        self.parse_object = JSONObject
+        self.parse_array = JSONArray
+        self.parse_string = scanstring
+        self.scan_once = make_scanner(self)
+
+    def decode(self, s, _w=WHITESPACE.match):
+        """Return the Python representation of ``s`` (a ``str`` or ``unicode``
+        instance containing a JSON document)
+
+        """
+        obj, end = self.raw_decode(s, idx=_w(s, 0).end())
+        end = _w(s, end).end()
+        if end != len(s):
+            raise JSONDecodeError("Extra data", s, end, len(s))
+        return obj
+
+    def raw_decode(self, s, idx=0):
+        """Decode a JSON document from ``s`` (a ``str`` or ``unicode``
+        beginning with a JSON document) and return a 2-tuple of the Python
+        representation and the index in ``s`` where the document ended.
+
+        This can be used to decode a JSON document from a string that may
+        have extraneous data at the end.
+
+        """
+        try:
+            obj, end = self.scan_once(s, idx)
+        except StopIteration:
+            raise JSONDecodeError("No JSON object could be decoded", s, idx)
+        return obj, end
Index: waveapi/simplejson/ordered_dict.py
===================================================================
--- waveapi/simplejson/ordered_dict.py	(revision 0)
+++ waveapi/simplejson/ordered_dict.py	(revision 0)
@@ -0,0 +1,119 @@
+"""Drop-in replacement for collections.OrderedDict by Raymond Hettinger
+
+http://code.activestate.com/recipes/576693/
+
+"""
+from UserDict import DictMixin
+
+# Modified from original to support Python 2.4, see
+# http://code.google.com/p/simplejson/issues/detail?id=53
+try:
+    all
+except NameError:
+    def all(seq):
+        for elem in seq:
+            if not elem:
+                return False
+        return True
+
+class OrderedDict(dict, DictMixin):
+
+    def __init__(self, *args, **kwds):
+        if len(args) > 1:
+            raise TypeError('expected at most 1 arguments, got %d' % len(args))
+        try:
+            self.__end
+        except AttributeError:
+            self.clear()
+        self.update(*args, **kwds)
+
+    def clear(self):
+        self.__end = end = []
+        end += [None, end, end]         # sentinel node for doubly linked list
+        self.__map = {}                 # key --> [key, prev, next]
+        dict.clear(self)
+
+    def __setitem__(self, key, value):
+        if key not in self:
+            end = self.__end
+            curr = end[1]
+            curr[2] = end[1] = self.__map[key] = [key, curr, end]
+        dict.__setitem__(self, key, value)
+
+    def __delitem__(self, key):
+        dict.__delitem__(self, key)
+        key, prev, next = self.__map.pop(key)
+        prev[2] = next
+        next[1] = prev
+
+    def __iter__(self):
+        end = self.__end
+        curr = end[2]
+        while curr is not end:
+            yield curr[0]
+            curr = curr[2]
+
+    def __reversed__(self):
+        end = self.__end
+        curr = end[1]
+        while curr is not end:
+            yield curr[0]
+            curr = curr[1]
+
+    def popitem(self, last=True):
+        if not self:
+            raise KeyError('dictionary is empty')
+        # Modified from original to support Python 2.4, see
+        # http://code.google.com/p/simplejson/issues/detail?id=53
+        if last:
+            key = reversed(self).next()
+        else:
+            key = iter(self).next()
+        value = self.pop(key)
+        return key, value
+
+    def __reduce__(self):
+        items = [[k, self[k]] for k in self]
+        tmp = self.__map, self.__end
+        del self.__map, self.__end
+        inst_dict = vars(self).copy()
+        self.__map, self.__end = tmp
+        if inst_dict:
+            return (self.__class__, (items,), inst_dict)
+        return self.__class__, (items,)
+
+    def keys(self):
+        return list(self)
+
+    setdefault = DictMixin.setdefault
+    update = DictMixin.update
+    pop = DictMixin.pop
+    values = DictMixin.values
+    items = DictMixin.items
+    iterkeys = DictMixin.iterkeys
+    itervalues = DictMixin.itervalues
+    iteritems = DictMixin.iteritems
+
+    def __repr__(self):
+        if not self:
+            return '%s()' % (self.__class__.__name__,)
+        return '%s(%r)' % (self.__class__.__name__, self.items())
+
+    def copy(self):
+        return self.__class__(self)
+
+    @classmethod
+    def fromkeys(cls, iterable, value=None):
+        d = cls()
+        for key in iterable:
+            d[key] = value
+        return d
+
+    def __eq__(self, other):
+        if isinstance(other, OrderedDict):
+            return len(self)==len(other) and \
+                   all(p==q for p, q in  zip(self.items(), other.items()))
+        return dict.__eq__(self, other)
+
+    def __ne__(self, other):
+        return not self == other
Index: waveapi/model_test.py
===================================================================
--- waveapi/model_test.py	(revision 0)
+++ waveapi/model_test.py	(revision 0)
@@ -0,0 +1,199 @@
+#!/usr/bin/python2.4
+#
+# Copyright (C) 2009 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Unit tests for the model module."""
+
+
+__author__ = 'davidbyttow@google.com (David Byttow)'
+
+
+import unittest
+
+import model
+
+TEST_WAVE_DATA = {
+    'waveId': 'test-wave',
+    'waveletIds': ['wavelet-1'],
+}
+
+TEST_WAVELET_DATA = {
+    'creator': 'creator@google.com',
+    'creationTime': 100,
+    'lastModifiedTime': 101,
+    'participants': ['robot@google.com'],
+    'rootBlipId': 'blip-1',
+    'title': 'Title',
+    'waveId': TEST_WAVE_DATA['waveId'],
+    'waveletId': 'test.com' + model.ROOT_WAVELET_ID_SUFFIX,
+}
+
+TEST_GADGET_URL = 'http://test.com/gadget.xml'
+
+TEST_GADGET = {
+    'type': 'GADGET',
+    'properties': {'url': TEST_GADGET_URL,
+                   'prop': 'value'}
+}
+
+TEST_BLIP_DATA = {
+    'blipId': TEST_WAVELET_DATA['rootBlipId'],
+    'childBlipIds': [],
+    'content': '<p>testing</p>',
+    'contributors': [TEST_WAVELET_DATA['creator'], 'robot@google.com'],
+    'creator': TEST_WAVELET_DATA['creator'],
+    'lastModifiedTime': TEST_WAVELET_DATA['lastModifiedTime'],
+    'parentBlipId': None,
+    'waveId': TEST_WAVE_DATA['waveId'],
+    'elements': {'15': TEST_GADGET},
+    'waveletId': TEST_WAVELET_DATA['waveletId'],
+}
+
+
+class TestWaveModel(unittest.TestCase):
+  """Tests the primary data structures for the wave model."""
+
+  def setUp(self):
+    self.test_wave_data = TEST_WAVE_DATA.copy()
+    self.test_wavelet_data = TEST_WAVELET_DATA.copy()
+    self.test_blip_data = TEST_BLIP_DATA.copy()
+
+    self.wave = model.Wave(self.test_wave_data)
+    self.wavelet = model.Wavelet(self.test_wavelet_data)
+    self.blip = model.Blip(self.test_blip_data)
+
+    self.test_context = model.Context()
+    self.test_context.waves[self.wave.waveId] = self.wave
+    self.test_context.wavelets[self.wavelet.waveletId] = self.wavelet
+    self.test_context.blips[self.blip.blipId] = self.blip
+  
+  def verifySameAttributes(self, source, target):
+    for attr_name in dir(source):
+      self.assertTrue(hasattr(target, attr_name))
+
+  def testDefaults(self):
+    empty_json = {}
+    self.verifySameAttributes(self.blip, model.Blip(empty_json))
+    self.verifySameAttributes(self.wave, model.Wave(empty_json))
+    self.verifySameAttributes(self.wavelet, model.Wavelet(empty_json))
+ 
+  def testWaveFields(self):
+    w = self.wave
+    self.assertEquals(self.test_wave_data['waveId'], w.waveId)
+    self.assertEquals(set(self.test_wave_data['waveletIds']), w.waveletIds)
+
+  def testWaveMethods(self):
+    w = self.wave
+    self.assertEquals(self.test_wave_data['waveId'], w.GetId())
+    self.assertEquals(set(self.test_wave_data['waveletIds']), w.GetWaveletIds())
+
+  def testWaveletFields(self):
+    w = self.wavelet
+    self.assertEquals(self.test_wavelet_data['creator'], w.creator)
+    self.assertEquals(self.test_wavelet_data['creationTime'], w.creationTime)
+    self.assertEquals(self.test_wavelet_data['lastModifiedTime'],
+                      w.lastModifiedTime)
+    self.assertEquals(set(self.test_wavelet_data['participants']),
+                      w.participants)
+    self.assertEquals(self.test_wavelet_data['rootBlipId'], w.rootBlipId)
+    self.assertEquals(self.test_wavelet_data['title'], w.title)
+    self.assertEquals(self.test_wavelet_data['waveId'], w.waveId)
+    self.assertEquals(self.test_wavelet_data['waveletId'], w.waveletId)
+
+  def testWaveletMethods(self):
+    w = self.wavelet
+    self.assertEquals(self.test_wavelet_data['creator'], w.GetCreator())
+    self.assertEquals(self.test_wavelet_data['creationTime'],
+                      w.GetCreationTime())
+    self.assertEquals(None, w.GetDataDocument('foo'))
+    self.assertEquals(42, w.GetDataDocument('foo', 42))
+    self.assertEquals(self.test_wavelet_data['lastModifiedTime'],
+                      w.GetLastModifiedTime())
+    self.assertEquals(set(self.test_wavelet_data['participants']),
+                      w.GetParticipants())
+    self.assertEquals(self.test_wavelet_data['rootBlipId'], w.GetRootBlipId())
+    self.assertEquals(self.test_wavelet_data['title'], w.GetTitle())
+    self.assertEquals(self.test_wavelet_data['waveId'], w.GetWaveId())
+    self.assertEquals(self.test_wavelet_data['waveletId'], w.GetId())
+
+  def testBlipFields(self):
+    b = self.blip
+    self.assertEquals(self.test_blip_data['blipId'], b.blipId)
+    self.assertEquals(set(self.test_blip_data['childBlipIds']), b.childBlipIds)
+    self.assertEquals(set(self.test_blip_data['contributors']), b.contributors)
+    self.assertEquals(self.test_blip_data['creator'], b.creator)
+    self.assertEquals(self.test_blip_data['content'], b.document.GetText())
+    self.assertEquals(self.test_blip_data['lastModifiedTime'],
+                      b.lastModifiedTime)
+    self.assertEquals(self.test_blip_data['parentBlipId'], b.parentBlipId)
+    self.assertEquals(self.test_blip_data['waveId'], b.waveId)
+    self.assertEquals(self.test_blip_data['waveletId'], b.waveletId)
+    self.assertEquals(self.test_blip_data['waveletId'], b.waveletId)
+    self.assertTrue(b.IsRoot())
+
+  def testBlipMethods(self):
+    b = self.blip
+    self.assertEquals(self.test_blip_data['blipId'], b.GetId())
+    self.assertEquals(set(self.test_blip_data['childBlipIds']),
+                      b.GetChildBlipIds())
+    self.assertEquals(set(self.test_blip_data['contributors']),
+                      b.GetContributors())
+    self.assertEquals(self.test_blip_data['creator'], b.GetCreator())
+    self.assertEquals(self.test_blip_data['content'], b.GetDocument().GetText())
+    self.assertEquals(self.test_blip_data['lastModifiedTime'],
+                      b.GetLastModifiedTime())
+    self.assertEquals(self.test_blip_data['parentBlipId'], b.GetParentBlipId())
+    self.assertEquals(self.test_blip_data['waveId'], b.GetWaveId())
+    self.assertEquals(len(self.test_blip_data['elements']), len(b.GetElements()))
+    self.assertTrue(b.IsRoot())
+    self.assertEquals(b.GetGadgetByUrl(TEST_GADGET_URL).url, TEST_GADGET_URL)
+
+  def testBlipIsNotRoot(self):
+    self.test_blip_data['parentBlipId'] = 'blip-parent'
+    b = model.Blip(self.test_blip_data)
+    self.assertFalse(b.IsRoot())
+
+  def testDocument(self):
+    b = model.Blip(self.test_blip_data)
+    doc = model.Document(b)
+    self.assertEquals(b.content, doc.GetText())
+
+  def testEvent(self):
+    data = {'type': 'WAVELET_PARTICIPANTS_CHANGED',
+            'properties': {'blipId': 'blip-1'},
+            'timestamp': 123,
+            'modifiedBy': 'modifier@google.com'}
+    event_data = model.Event(data)
+    self.assertEquals(data['type'], event_data.type)
+    self.assertEquals(data['properties'], event_data.properties)
+    self.assertEquals(data['timestamp'], event_data.timestamp)
+    self.assertEquals(data['modifiedBy'], event_data.modifiedBy)
+
+  def testContext(self):
+    self.assertEquals(self.blip,
+                      self.test_context.GetBlipById(self.blip.blipId))
+    self.assertEquals(self.blip, self.test_context.blips[self.blip.blipId])
+    self.assertEquals(self.wave,
+                      self.test_context.GetWaveById(self.wave.waveId))
+    self.assertEquals(self.wave, self.test_context.waves[self.wave.waveId])
+    self.assertEquals(self.wavelet,
+                      self.test_context.GetWaveletById(self.wavelet.waveletId))
+    self.assertEquals(self.wavelet,
+                      self.test_context.wavelets[self.wavelet.waveletId])
+    self.assertEquals(self.wavelet, self.test_context.GetRootWavelet())
+    
+
+if __name__ == '__main__':
+  unittest.main()
Index: waveapi/__init__.py
===================================================================
--- waveapi/__init__.py	(revision 0)
+++ waveapi/__init__.py	(revision 0)
@@ -0,0 +1,19 @@
+#!/usr/bin/python2.4
+#
+# Copyright (C) 2009 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Declares the api package."""
+
+__author__ = 'davidbyttow@google.com (David Byttow)'
Index: waveapi/ops_test.py
===================================================================
--- waveapi/ops_test.py	(revision 0)
+++ waveapi/ops_test.py	(revision 0)
@@ -0,0 +1,284 @@
+#!/usr/bin/python2.4
+#
+# Copyright (C) 2009 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Unit tests for the ops module."""
+
+
+__author__ = 'davidbyttow@google.com (David Byttow)'
+
+
+import unittest
+
+import document
+import model
+import model_test
+import ops
+
+
+TEST_WAVE_DATA = model_test.TEST_WAVE_DATA
+
+TEST_WAVELET_DATA = model_test.TEST_WAVELET_DATA
+
+TEST_BLIP_DATA = model_test.TEST_BLIP_DATA
+
+
+class TestOperation(unittest.TestCase):
+  """Test case for Operation class."""
+
+  def testDefaults(self):
+    op = ops.Operation(ops.WAVELET_APPEND_BLIP, 'wave-id', 'wavelet-id')
+    self.assertEquals(ops.WAVELET_APPEND_BLIP, op.type)
+    self.assertEquals('wave-id', op.wave_id)
+    self.assertEquals('wavelet-id', op.wavelet_id)
+    self.assertEquals('', op.blip_id)
+    self.assertEquals(-1, op.index)
+    self.assertEquals(None, op.property)
+
+  def testFields(self):
+    op = ops.Operation(ops.DOCUMENT_INSERT, 'wave-id', 'wavelet-id',
+                       blip_id='blip-id',
+                       index=1,
+                       prop='foo')
+    self.assertEquals(ops.DOCUMENT_INSERT, op.type)
+    self.assertEquals('wave-id', op.wave_id)
+    self.assertEquals('wavelet-id', op.wavelet_id)
+    self.assertEquals('blip-id', op.blip_id)
+    self.assertEquals(1, op.index)
+    self.assertEquals('foo', op.property)
+
+
+class TestOpBasedClasses(unittest.TestCase):
+  """Base class for op-based test classes. Sets up some test data."""
+
+  def setUp(self):
+    self.test_context = ops._ContextImpl()
+
+    self.test_wave_data = TEST_WAVE_DATA
+    self.test_wave = self.test_context.AddWave(self.test_wave_data)
+
+    self.test_wavelet_data = TEST_WAVELET_DATA
+    self.test_wavelet = self.test_context.AddWavelet(self.test_wavelet_data)
+
+    self.test_blip_data = TEST_BLIP_DATA
+    self.test_blip = self.test_context.AddBlip(self.test_blip_data)
+
+
+class TestOpBasedContext(TestOpBasedClasses):
+  """Test case for testing the operation-based context class, _ContextImpl."""
+
+  def testRemove(self):
+    self.test_context.RemoveWave(TEST_WAVE_DATA['waveId'])
+    self.assertEquals(None,
+                      self.test_context.GetWaveById(TEST_WAVE_DATA['waveId']))
+    wavelet_id = TEST_WAVELET_DATA['waveletId']
+    self.test_context.RemoveWavelet(wavelet_id)
+    self.assertEquals(None, self.test_context.GetWaveletById(wavelet_id))
+    self.test_context.RemoveBlip('blip-1')
+    self.assertEquals(None, self.test_context.GetBlipById('blip-1'))
+
+
+class TestOpBasedWave(TestOpBasedClasses):
+  """Test case for OpBasedWave class."""
+
+  def testCreateWavelet(self):
+    wavelet = self.test_wave.CreateWavelet(participants=['bob'])
+    #uncomment the next line once we do the right thing
+    #self.assertEquals(wavelet.GetWaveId(), TEST_WAVE_DATA['waveId'])
+    self.assertTrue('bob' in wavelet.GetParticipants())
+
+
+class TestOpBasedWavelet(TestOpBasedClasses):
+  """Test case for OpBasedWavelet class."""
+
+  def testCreateBlip(self):
+    blip = self.test_wavelet.CreateBlip()
+    self.assertEquals(TEST_WAVE_DATA['waveId'], blip.GetWaveId())
+    self.assertEquals(TEST_WAVELET_DATA['waveletId'], blip.GetWaveletId())
+    self.assertTrue(blip.GetId().startswith('TBD'))
+    self.assertEquals(blip, self.test_context.GetBlipById(blip.GetId()))
+
+  def testAddParticipant(self):
+    p = 'newguy@google.com'
+    self.test_wavelet.AddParticipant(p)
+    self.assertTrue(p in self.test_wavelet.GetParticipants())
+
+  def testRemoveSelf(self):
+    self.assertRaises(NotImplementedError,
+                      self.test_wavelet.RemoveSelf)
+
+  def testSetDataDocument(self):
+    self.test_wavelet.SetDataDocument('key', 'value')
+    self.assertEquals('value', self.test_wavelet.GetDataDocument('key'))
+
+  def testSetTitle(self):
+    self.test_wavelet.SetTitle('foobar')
+    self.assertEquals('foobar', self.test_wavelet.GetTitle())
+
+
+class TestOpBasedBlip(TestOpBasedClasses):
+  """Test case for OpBasedBlip class."""
+
+  def testCreateChild(self):
+    blip = self.test_blip.CreateChild()
+    self.assertEquals(TEST_WAVE_DATA['waveId'], blip.GetWaveId())
+    self.assertEquals(TEST_WAVELET_DATA['waveletId'], blip.GetWaveletId())
+    self.assertTrue(blip.GetId().startswith('TBD'))
+    self.assertEquals(blip, self.test_context.GetBlipById(blip.GetId()))
+
+  def testDelete(self):
+    self.test_blip.Delete()
+    self.assertEquals(None,
+                      self.test_context.GetBlipById(self.test_blip.GetId()))
+
+
+class TestOpBasedDocument(TestOpBasedClasses):
+  """Test case for OpBasedDocument class."""
+
+  def setUp(self):
+    super(TestOpBasedDocument, self).setUp()
+    self.test_doc = self.test_blip.GetDocument()
+    self.test_doc.SetText('123456')
+
+  def testSetText(self):
+    text = 'Hello test.'
+    self.assertTrue(self.test_doc.GetText() != text)
+    self.test_doc.SetText(text)
+    self.assertEquals(text, self.test_doc.GetText())
+
+  def testSetTextInRange(self):
+    text = 'abc'
+    self.test_doc.SetTextInRange(document.Range(0, 2), text)
+    self.assertEquals('abc456', self.test_doc.GetText())
+    self.test_doc.SetTextInRange(document.Range(2, 2), text)
+    self.assertEquals('ababc456', self.test_doc.GetText())
+
+  def testAppendText(self):
+    text = '789'
+    self.test_doc.AppendText(text)
+    self.assertEquals('123456789', self.test_doc.GetText())
+
+  def testClear(self):
+    self.test_doc.Clear()
+    self.assertEquals('', self.test_doc.GetText())
+
+  def testDeleteRange(self):
+    self.test_doc.DeleteRange(document.Range(0, 1))
+    self.assertEquals('3456', self.test_doc.GetText())
+    self.test_doc.DeleteRange(document.Range(0, 0))
+    self.assertEquals('456', self.test_doc.GetText())
+
+  def testAnnotateDocument(self):
+    self.test_doc.AnnotateDocument('key', 'value')
+    self.assertTrue(self.test_doc.HasAnnotation('key'))
+    self.assertFalse(self.test_doc.HasAnnotation('non-existent-key'))
+
+  def testSetAnnotation(self):
+    self.test_doc.SetAnnotation(document.Range(0, 1), 'key', 'value')
+    self.assertTrue(self.test_doc.HasAnnotation('key'))
+
+  def testDeleteAnnotationByName(self):
+    self.test_doc.SetAnnotation(document.Range(0, 1), 'key', 'value')
+    self.test_doc.SetAnnotation(document.Range(0, 1), 'key2', 'value')
+    self.test_doc.SetAnnotation(document.Range(10, 11), 'key', 'value')
+    self.test_doc.SetAnnotation(document.Range(00, 11), 'key2', 'value')
+    self.test_doc.SetAnnotation(document.Range(20, 21), 'key', 'value')
+    self.test_doc.DeleteAnnotationsByName('key')
+    self.assertFalse(self.test_doc.HasAnnotation('key'))
+
+  def testDeleteAnnotationInRange(self):
+    self.test_doc.SetAnnotation(document.Range(0, 10), 'key', 'value')
+    self.test_doc.DeleteAnnotationsInRange(document.Range(2, 6), 'key')
+    self.assertTrue(self.test_doc.HasAnnotation('key'))
+    l = [x for x in self.test_doc.RangesForAnnotation('key')]
+    self.assertEqual(len(l), 2)
+    self.test_doc.DeleteAnnotationsInRange(document.Range(0, 2), 'key')
+    l = [x for x in self.test_doc.RangesForAnnotation('key')]
+    print [str(x) for x in l]
+    self.assertEqual(len(l), 1)
+    self.test_doc.DeleteAnnotationsInRange(document.Range(5, 8), 'key')
+    l = [x for x in self.test_doc.RangesForAnnotation('key')]
+    self.assertEqual(len(l), 1)
+    self.test_doc.DeleteAnnotationsInRange(document.Range(7, 12), 'key')
+    self.assertFalse(self.test_doc.HasAnnotation('key'))
+
+  def testRangesForAnnotation(self):
+    self.assertEqual([x for x in self.test_doc.RangesForAnnotation('key')], [])
+    self.test_doc.SetAnnotation(document.Range(1, 10), 'key', 'value')
+    l = [x for x in self.test_doc.RangesForAnnotation('key')]
+    self.assertTrue(l[0].start, 1)
+
+  def testAppendInlineBlip(self):
+    blip = self.test_doc.AppendInlineBlip()
+    self.assertEquals(TEST_WAVE_DATA['waveId'], blip.GetWaveId())
+    self.assertEquals(TEST_WAVELET_DATA['waveletId'], blip.GetWaveletId())
+    self.assertTrue(blip.GetId().startswith('TBD'))
+    self.assertEquals(self.test_blip.GetId(), blip.GetParentBlipId())
+    self.assertEquals(blip, self.test_context.GetBlipById(blip.GetId()))
+
+  def testDeleteInlineBlip(self):
+    blip = self.test_doc.AppendInlineBlip()
+    self.test_doc.DeleteInlineBlip(blip.GetId())
+    self.assertEquals(None, self.test_context.GetBlipById(blip.GetId()))
+
+  def testInsertInlineBlip(self):
+    blip = self.test_doc.InsertInlineBlip(1)
+    self.assertEquals(TEST_WAVE_DATA['waveId'], blip.GetWaveId())
+    self.assertEquals(TEST_WAVELET_DATA['waveletId'], blip.GetWaveletId())
+    self.assertTrue(blip.GetId().startswith('TBD'))
+    self.assertEquals(self.test_blip.GetId(), blip.GetParentBlipId())
+    self.assertEquals(blip, self.test_context.GetBlipById(blip.GetId()))
+
+  def testGadget(self):
+    gadget = document.Gadget('http://kitchensinky.appspot.com/public/embed.xml')
+    self.test_doc.AppendElement(gadget)
+    self.test_doc.GadgetSubmitDelta(gadget, {'foo': 'bar'})
+    self.assertEquals('bar', gadget.get('foo'))
+
+
+class TestOpBuilder(TestOpBasedClasses):
+  """Test case for OpBuilder class."""
+
+  def setUp(self):
+    super(TestOpBuilder, self).setUp()
+    self.builder = self.test_context.builder
+
+  def testWaveletAppendBlip(self):
+    blip_data = self.builder.WaveletAppendBlip('a', 'b')
+    self.assertEquals(blip_data['waveId'], 'a')
+    self.assertEquals(blip_data['waveletId'], 'b')
+    self.assertTrue(blip_data['blipId'].startswith('TBD'))
+
+  def testBlipCreateChild(self):
+    blip_data = self.builder.BlipCreateChild('a', 'b', 'c')
+    self.assertEquals(blip_data['waveId'], 'a')
+    self.assertEquals(blip_data['waveletId'], 'b')
+    self.assertTrue(blip_data['blipId'].startswith('TBD'))
+
+  def testDocumentInlineBlipAppend(self):
+    blip_data = self.builder.DocumentInlineBlipAppend('a', 'b', 'c')
+    self.assertEquals(blip_data['waveId'], 'a')
+    self.assertEquals(blip_data['waveletId'], 'b')
+    self.assertTrue(blip_data['blipId'].startswith('TBD'))
+
+  def testDocumentInlineBlipInsert(self):
+    blip_data = self.builder.DocumentInlineBlipInsert('a', 'b', 'c', 0)
+    self.assertEquals(blip_data['waveId'], 'a')
+    self.assertEquals(blip_data['waveletId'], 'b')
+    self.assertTrue(blip_data['blipId'].startswith('TBD'))
+
+
+if __name__ == '__main__':
+  unittest.main()
Index: waveapi/model.py
===================================================================
--- waveapi/model.py	(revision 0)
+++ waveapi/model.py	(revision 0)
@@ -0,0 +1,343 @@
+#!/usr/bin/python2.4
+#
+# Copyright (C) 2009 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Defines classes that represent parts of the common wave model.
+
+Defines the core data structures for the common wave model. At this level,
+models are read-only but can be modified through operations.
+
+Note that model attributes break the typical style by providing lower
+camel-cased characters to match the wire protocol format.
+"""
+
+__author__ = 'davidbyttow@google.com (David Byttow)'
+
+import logging
+import document
+
+
+ROOT_WAVELET_ID_SUFFIX = '!conv+root'
+
+class Wave(object):
+  """Models a single wave instance.
+
+  A single wave is composed of its id and any wavelet ids that belong to it.
+
+  Attributes:
+    waveId: This wave's id.
+    waveletIds: Set of wavelet id's on this wave.
+  """
+
+  def __init__(self, json):
+    """Inits this wave with JSON data.
+
+    Args:
+      json: JSON data dictionary from Wave server.
+
+    Attributes:
+      raw_data: Dictionary of incoming raw JSON data.
+      waveId: String id of this wave.
+      waveletId: String id of this wavelet.
+    """
+    self.waveId = json.get('waveId')
+    self.waveletIds = set(json.get('waveletIds', []))
+    self.raw_data = json
+
+  def GetId(self):
+    """Returns this wave's id."""
+    return self.waveId
+
+  def GetWaveletIds(self):
+    """Returns a set of wavelet ids."""
+    return self.waveletIds
+
+
+class Wavelet(object):
+  """Models a single wavelet instance.
+
+  A single wavelet is composed of metadata, participants and the blips it
+  contains.
+
+  Attributes:
+    creator: Participant id string of the creator of this wavelet.
+    creationTime: Time this wavelet was created on the server.
+    dataDocuments: Dictionary of data documents.
+    lastModifiedTime: Time this wavelet was last modified.
+    participants: Set of participant ids on this wavelet.
+    raw_data: Dictionary of incoming raw JSON data.
+    rootBlipId: String id of the root blip.
+    waveId: String id of the parent wave.
+    waveletId: This wavelet's string id.
+  """
+
+  def __init__(self, json):
+    """Inits this wavelet with JSON data.
+
+    Args:
+      json: JSON data dictionary from Wave server.
+    """
+    self.creator = json.get('creator')
+    self.creationTime = json.get('creationTime', 0)
+    self.dataDocuments = json.get('dataDocuments', {})
+    self.lastModifiedTime = json.get('lastModifiedTime')
+    self.participants = set(json.get('participants', []))
+    self.rootBlipId = json.get('rootBlipId')
+    self.title = json.get('title', '')
+    self.waveId = json.get('waveId')
+    self.waveletId = json.get('waveletId')
+    self.raw_data = json
+
+  def GetCreator(self):
+    """Returns the participant id of the creator of this wavelet."""
+    return self.creator
+
+  def GetCreationTime(self):
+    """Returns the time that this wavelet was first created in milliseconds."""
+    return self.creationTime
+
+  def GetDataDocument(self, name, default=None):
+    """Returns a data document for this wavelet based on key name."""
+    if self.dataDocuments:
+      return self.dataDocuments.get(name, default)
+    return default
+
+  def GetId(self):
+    """Returns this wavelet's id."""
+    return self.waveletId
+
+  def GetLastModifiedTime(self):
+    """Returns the time that this wavelet was last modified in ms."""
+    return self.lastModifiedTime
+
+  def GetParticipants(self):
+    """Returns a set of participants on this wavelet."""
+    return self.participants
+
+  def GetRootBlipId(self):
+    """Returns this wavelet's root blip id."""
+    return self.rootBlipId
+
+  def GetTitle(self):
+    """Returns the title of this wavelet."""
+    return self.title
+
+  def GetWaveId(self):
+    """Returns this wavelet's parent wave id."""
+    return self.waveId
+
+
+class Blip(object):
+  """Models a single blip instance.
+
+  Blips are essentially elements of conversation. Blips can live in a
+  hierarchy of blips. A root blip has no parent blip id, but all blips
+  have the ids of the wave and wavelet that they are associated with.
+
+  Blips also contain annotations, content and elements, which are accessed via
+  the Document object.
+
+  Attributes:
+    annotations: List of Annotation objects on this blip.
+    blipId: String id of this blip.
+    childBlipIds: Set of child blip ids.
+    content: Raw text content contained by this blip.
+    contributors: Set of contributor ids that have contributed to this blip.
+    creator: Participant string id of the creator.
+    raw_data: Dictionary of incoming raw JSON data.
+    document: Document object for this blip.
+    lastModifiedTime: Time that this blip was last modified on the server.
+    parentBlipId: String id of the parent blip or None if this is the root.
+    waveId: String id of the wave that this blip belongs to.
+    waveletId: String id of the wavelet that this belongs to.
+  """
+
+  def __init__(self, json):
+    """Inits this blip with JSON data.
+
+    Args:
+      json: JSON data dictionary from Wave server.
+    """
+    self.blipId = json.get('blipId')
+    self.childBlipIds = set(json.get('childBlipIds', []))
+    self.content = json.get('content', '')
+    self.contributors = set(json.get('contributors', []))
+    self.creator = json.get('creator')
+    self.lastModifiedTime = json.get('lastModifiedTime', 0)
+    self.parentBlipId = json.get('parentBlipId')
+    self.waveId = json.get('waveId')
+    self.waveletId = json.get('waveletId')
+    self.annotations = []
+    for annotation in json.get('annotations', []):
+      r = document.Range(annotation['range']['start'],
+                         annotation['range']['end'])
+      self.annotations.append(document.Annotation(
+          annotation['name'], annotation['value'], r=r))
+    self.document = Document(self)
+    self.elements = {}
+    json_elements = json.get('elements', {})
+    for elem in json_elements:
+      self.elements[elem] = document.ElementFromJson(json_elements[elem])
+    self.raw_data = json
+
+  def GetChildBlipIds(self):
+    """Returns a set of blip ids that are children of this blip."""
+    return self.childBlipIds
+
+  def GetContributors(self):
+    """Returns a set of participant ids that contributed to this blip."""
+    return self.contributors
+
+  def GetCreator(self):
+    """Returns the id of the participant that created this blip."""
+    return self.creator
+
+  def GetDocument(self):
+    """Returns the Document of this blip, which contains content data."""
+    return self.document
+
+  def GetId(self):
+    """Returns the id of this blip."""
+    return self.blipId
+
+  def GetLastModifiedTime(self):
+    """Returns the time that this blip was last modified by the server."""
+    return self.lastModifiedTime
+
+  def GetParentBlipId(self):
+    """Returns the id of this blips parent or None if it is the root."""
+    return self.parentBlipId
+
+  def GetWaveId(self):
+    """Returns the id of the wave that this blip belongs to."""
+    return self.waveId
+
+  def GetWaveletId(self):
+    """Returns the id of the wavelet that this blip belongs to."""
+    return self.waveletId
+
+  def IsRoot(self):
+    """Returns True if this is the root blip of a wavelet."""
+    return self.parentBlipId is None
+
+  def GetAnnotations(self):
+    """Returns the annotations for this document."""
+    return self.annotations
+
+  def GetElements(self):
+    """Returns the elements for this document."""
+    return self.elements
+
+  def GetGadgetByUrl(self, url):
+    """Return the (first) gadget that has the specified url.
+
+    If no matching gadget can be found, return None. If url
+    is None, return the first gadget that can be found.
+    """
+    for el in self.elements.values():
+      if (el.type == document.ELEMENT_TYPE.GADGET
+          and getattr(el, 'url', None) == url):
+        return el
+    return None
+
+class Document(object):
+  """Base representation of a document of a blip."""
+
+  def __init__(self, blip):
+    """Inits this document with the data of the blip it is representing.
+
+    Args:
+      blip: Blip instance that owns this document.
+    """
+    self._blip = blip
+
+  def GetText(self):
+    """Returns the raw text content of this document."""
+    return self._blip.content
+
+
+class Event(object):
+  """Data describing a single event.
+
+  Attributes:
+    modifiedBy: Participant id that caused this event.
+    properties: Dictionary of properties specific to this event type.
+    raw_data: Dictionary of incoming raw JSON data.
+    timestamp: Timestamp that this event occurred on the server.
+    type: Type string of this event.
+  """
+
+  def __init__(self, json):
+    """Inits this event with JSON data.
+
+    Args:
+      json: JSON data from Wave server.
+    """
+    self.modifiedBy = json.get('modifiedBy')
+    self.properties = json.get('properties', {})
+    self.timestamp = json.get('timestamp', 0)
+    self.type = json.get('type')
+    self.raw_data = json
+
+
+class Context(object):
+  """Contains information associated with a single request from the server.
+
+  This includes the current waves in this session
+  and any operations that have been enqueued during request processing.
+
+  Attributes:
+    blips: Dictionary of Blips keyed by blipId.
+    wavelets: Dictionary of Wavelets keyed by waveletId.
+    waves: Dictionary of Waves keyed by waveId.
+  """
+
+  def __init__(self):
+    self.blips = {}
+    self.wavelets = {}
+    self.waves = {}
+    self._operations = []
+
+  def GetBlipById(self, blip_id):
+    """Returns a blip by id or None if it does not exist."""
+    return self.blips.get(blip_id, None)
+
+  def GetWaveletById(self, wavelet_id):
+    """Returns a wavelet by id or None if it does not exist."""
+    return self.wavelets.get(wavelet_id, None)
+
+  def GetWaveById(self, wave_id):
+    """Returns a wave by id or None if it does not exist."""
+    return self.waves.get(wave_id, None)
+
+  def GetRootWavelet(self):
+    """Returns the root wavelet or None if it is not in this context."""
+    for wavelet_id, wavelet in self.wavelets.items():
+      if wavelet_id.endswith(ROOT_WAVELET_ID_SUFFIX):
+        return wavelet
+    logging.warning('Could not retrieve root wavelet.')
+    return None
+
+  def GetWaves(self):
+    """Returns the list of waves associated with this session."""
+    return self.waves.values()
+
+  def GetWavelets(self):
+    """Returns the list of wavelets associated with this session."""
+    return self.wavelets.values()
+
+  def GetBlips(self):
+    """Returns the list of blips associated with this session."""
+    return self.blips.values()
Index: waveapi/ops.py
===================================================================
--- waveapi/ops.py	(revision 0)
+++ waveapi/ops.py	(revision 0)
@@ -0,0 +1,1110 @@
+#!/usr/bin/python2.4
+#
+# Copyright (C) 2009 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Support for operations that can be applied to the server.
+
+Contains classes and utilities for creating operations that are to be
+applied on the server.
+"""
+
+__author__ = 'davidbyttow@google.com (David Byttow)'
+
+
+import document
+import logging
+import model
+import util
+
+
+# Operation Types
+WAVELET_APPEND_BLIP = 'WAVELET_APPEND_BLIP'
+WAVELET_ADD_PARTICIPANT = 'WAVELET_ADD_PARTICIPANT'
+WAVELET_CREATE = 'WAVELET_CREATE'
+WAVELET_REMOVE_SELF = 'WAVELET_REMOVE_SELF'
+WAVELET_DATADOC_SET = 'WAVELET_DATADOC_SET'
+WAVELET_SET_TITLE = 'WAVELET_SET_TITLE'
+BLIP_CREATE_CHILD = 'BLIP_CREATE_CHILD'
+BLIP_DELETE = 'BLIP_DELETE'
+DOCUMENT_ANNOTATION_DELETE = 'DOCUMENT_ANNOTATION_DELETE'
+DOCUMENT_ANNOTATION_SET = 'DOCUMENT_ANNOTATION_SET'
+DOCUMENT_ANNOTATION_SET_NORANGE = 'DOCUMENT_ANNOTATION_SET_NORANGE'
+DOCUMENT_APPEND = 'DOCUMENT_APPEND'
+DOCUMENT_APPEND_MARKUP = 'DOCUMENT_APPEND_MARKUP'
+DOCUMENT_APPEND_STYLED_TEXT = 'DOCUMENT_APPEND_STYLED_TEXT'
+DOCUMENT_INSERT = 'DOCUMENT_INSERT'
+DOCUMENT_DELETE = 'DOCUMENT_DELETE'
+DOCUMENT_REPLACE = 'DOCUMENT_REPLACE'
+DOCUMENT_ELEMENT_APPEND = 'DOCUMENT_ELEMENT_APPEND'
+DOCUMENT_ELEMENT_DELETE = 'DOCUMENT_ELEMENT_DELETE'
+DOCUMENT_ELEMENT_MODIFY_ATTRS = 'DOCUMENT_ELEMENT_MODIFY_ATTRS'
+DOCUMENT_ELEMENT_INSERT = 'DOCUMENT_ELEMENT_INSERT'
+DOCUMENT_ELEMENT_INSERT_AFTER = 'DOCUMENT_ELEMENT_INSERT_AFTER'
+DOCUMENT_ELEMENT_INSERT_BEFORE = 'DOCUMENT_ELEMENT_INSERT_BEFORE'
+DOCUMENT_ELEMENT_REPLACE = 'DOCUMENT_ELEMENT_REPLACE'
+DOCUMENT_INLINE_BLIP_APPEND = 'DOCUMENT_INLINE_BLIP_APPEND'
+DOCUMENT_INLINE_BLIP_DELETE = 'DOCUMENT_INLINE_BLIP_DELETE'
+DOCUMENT_INLINE_BLIP_INSERT = 'DOCUMENT_INLINE_BLIP_INSERT'
+DOCUMENT_INLINE_BLIP_INSERT_AFTER_ELEMENT = ('DOCUMENT_INLINE_BLIP_INSERT_'
+                                             'AFTER_ELEMENT')
+
+
+class Operation(object):
+  """Represents a generic operation applied on the server.
+
+  This operation class contains data that is filled in depending on the
+  operation type.
+
+  It can be used directly, but doing so will not result
+  in local, transient reflection of state on the blips. In other words,
+  creating a "delete blip" operation will not remove the blip from the local
+  context for the duration of this session. It is better to use the OpBased
+  model classes directly instead.
+  """
+
+  java_class = 'com.google.wave.api.impl.OperationImpl'
+
+  def __init__(self, op_type, wave_id, wavelet_id, blip_id='', index=-1,
+               prop=None):
+    """Initializes this operation with contextual data.
+
+    Args:
+      op_type: Type of operation.
+      wave_id: The id of the wave that this operation is to be applied.
+      wavelet_id: The id of the wavelet that this operation is to be applied.
+      blip_id: The optional id of the blip that this operation is to be applied.
+      index: Optional integer index for content-based operations.
+      prop: A weakly typed property object is based on the context of this
+          operation.
+    """
+    self.type = op_type
+    self.wave_id = wave_id
+    self.wavelet_id = wavelet_id
+    self.blip_id = blip_id
+    self.index = index
+    self.property = prop
+
+
+class OpBasedWave(model.Wave):
+  """Subclass of the wave model capable of generating operations.
+
+  Any mutation-based methods will likely result in one or more operations
+  being applied locally and sent to the server.
+  """
+
+  def __init__(self, json, context):
+    """Initializes this wave with the session context."""
+    super(OpBasedWave, self).__init__(json)
+    self.__context = context
+
+  def CreateWavelet(self, participants=None):
+    """Creates a new wavelet on this wave."""
+    return self.__context.builder.WaveletCreate(
+        self.GetId(), '', participants)
+
+
+class OpBasedWavelet(model.Wavelet):
+  """Subclass of the wavelet model capable of generating operations.
+
+  Any mutation-based methods will likely result in one or more operations
+  being applied locally and sent to the server.
+  """
+
+  def __init__(self, json, context):
+    """Initializes this wavelet with the session context."""
+    super(OpBasedWavelet, self).__init__(json)
+    self.__context = context
+
+  def CreateBlip(self):
+    """Creates and appends a blip to this wavelet and returns it.
+
+    Returns:
+      A transient version of the blip that was created.
+    """
+    blip_data = self.__context.builder.WaveletAppendBlip(self.GetWaveId(),
+                                                         self.GetId())
+    return self.__context.AddBlip(blip_data)
+
+  def AddParticipant(self, participant_id):
+    """Adds a participant to a wavelet.
+
+    Args:
+      participant_id: Id of the participant that is to be added.
+    """
+    self.__context.builder.WaveletAddParticipant(self.GetWaveId(), self.GetId(),
+                                                 participant_id)
+    self.participants.add(participant_id)
+
+  def RemoveSelf(self):
+    """Removes this robot from the wavelet."""
+    self.__context.builder.WaveletRemoveSelf(self.GetWaveId(), self.GetId())
+    # TODO(davidbyttow): Locally remove the robot.
+
+  def SetDataDocument(self, name, data):
+    """Sets a key/value pair on the wavelet data document.
+
+    Args:
+      name: The string key.
+      data: The value associated with this key.
+    """
+    self.__context.builder.WaveletSetDataDoc(self.GetWaveId(), self.GetId(),
+                                             name, data)
+    self.dataDocuments[name] = data
+
+  def SetTitle(self, title):
+    """Sets the title of this wavelet.
+
+    Args:
+      title: String title to for this wave.
+    """
+    self.__context.builder.WaveletSetTitle(self.GetWaveId(), self.GetId(),
+                                           title)
+    self.title = title
+
+
+class OpBasedBlip(model.Blip):
+  """Subclass of the blip model capable of generating operations.
+
+  Any mutation-based methods will likely result in one or more operations
+  being applied locally and sent to the server.
+  """
+
+  def __init__(self, json, context):
+    """Initializes this blip with the session context."""
+    super(OpBasedBlip, self).__init__(json)
+    self.__context = context
+    self.document = OpBasedDocument(self, context)
+
+  def CreateChild(self):
+    """Creates a child blip of this blip."""
+    blip_data = self.__context.builder.BlipCreateChild(self.GetWaveId(),
+                                                       self.GetWaveletId(),
+                                                       self.GetId())
+    return self.__context.AddBlip(blip_data)
+
+  def Delete(self):
+    """Deletes this blip from the wavelet."""
+    self.__context.builder.BlipDelete(self.GetWaveId(),
+                                      self.GetWaveletId(),
+                                      self.GetId())
+    return self.__context.RemoveBlip(self.GetId())
+
+
+class OpBasedDocument(model.Document):
+  """Subclass of the document model capable of generating operations.
+
+  Any mutation-based methods will likely result in one or more operations
+  being applied locally and sent to the server.
+
+  TODO(davidbyttow): Manage annotations and elements as content is updated.
+  """
+
+  def __init__(self, blip, context):
+    """Initializes this document with its owning blip and session context."""
+    super(OpBasedDocument, self).__init__(blip)
+    self.__context = context
+
+  def HasAnnotation(self, name):
+    """Determines if given named annotation is anywhere on this document.
+
+    Args:
+      name: The key name of the annotation.
+
+    Returns:
+      True if the annotation exists.
+    """
+    for annotation in self._blip.annotations:
+      if annotation.name == name:
+        return True
+    return False
+
+  def RangesForAnnotation(self, name):
+    """Iterate through the ranges defined for name.
+
+    Args:
+      name: The name of the annotation.
+
+    Returns:
+      the matching ranges.
+    """
+    for annotation in self._blip.annotations:
+      if annotation.name == name:
+        yield annotation.range
+
+  def SetText(self, text):
+    """Clears and sets the text of this document.
+
+    Args:
+      text: The text content to replace this document with.
+    """
+    self.Clear()
+    self.__context.builder.DocumentInsert(self._blip.waveId,
+                                          self._blip.waveletId,
+                                          self._blip.blipId,
+                                          text)
+    self._blip.content = text
+
+  def SetTextInRange(self, r, text):
+    """Deletes text within a range and sets the supplied text in its place.
+
+    Args:
+      r: Range to delete and where to set the new text.
+      text: The text to set at the range start position.
+    """
+    self.DeleteRange(r)
+    self.InsertText(r.start, text)
+
+  def InsertText(self, start, text):
+    """Inserts text at a specific position.
+
+    Args:
+      start: The index position where to set the text.
+      text: The text to set.
+    """
+    self.__context.builder.DocumentInsert(self._blip.waveId,
+                                          self._blip.waveletId,
+                                          self._blip.blipId,
+                                          text, index=start)
+    left = self._blip.content[:start]
+    right = self._blip.content[start:]
+    self._blip.content = left + text + right
+
+  def AppendText(self, text):
+    """Appends text to the end of this document.
+
+    Args:
+      text: The text to append.
+    """
+    self.__context.builder.DocumentAppend(self._blip.waveId,
+                                          self._blip.waveletId,
+                                          self._blip.blipId,
+                                          text)
+    self._blip.content += text
+
+  def Clear(self):
+    """Clears the content of this document."""
+    self.__context.builder.DocumentDelete(self._blip.waveId,
+                                          self._blip.waveletId,
+                                          self._blip.blipId)
+    self._blip.content = ''
+
+  def DeleteRange(self, r):
+    """Deletes the content in the specified range.
+
+    Args:
+      r: A Range instance specifying the range to delete.
+    """
+    self.__context.builder.DocumentDelete(self._blip.waveId,
+                                          self._blip.waveletId,
+                                          self._blip.blipId,
+                                          r.start, r.end)
+    left = self._blip.content[:r.start]
+    right = self._blip.content[r.end + 1:]
+    self._blip.content = left + right
+
+  def AnnotateDocument(self, name, value):
+    """Annotates the entire document.
+
+    Args:
+      name: A string as the key for this annotation.
+      value: The value of this annotation.
+    """
+    b = self.__context.builder
+    b.DocumentAnnotationSetNoRange(self._blip.waveId,
+                                   self._blip.waveletId,
+                                   self._blip.blipId,
+                                   name, value)
+    r = document.Range(0, len(self._blip.content))
+    self._blip.annotations.append(document.Annotation(name, value, r))
+
+  def SetAnnotation(self, r, name, value):
+    """Sets an annotation on a given range.
+
+    Args:
+      r: A Range specifying the range to set the annotation.
+      name: A string as the key for this annotation.
+      value: The value of this annotaton.
+    """
+    self.__context.builder.DocumentAnnotationSet(self._blip.waveId,
+                                                 self._blip.waveletId,
+                                                 self._blip.blipId,
+                                                 r.start, r.end,
+                                                 name, value)
+    self._blip.annotations.append(document.Annotation(name, value, r))
+
+  def DeleteAnnotationsByName(self, name):
+    """Deletes all annotations with a given key name.
+
+    Args:
+      name: A string as the key for the annotation to delete.
+    """
+    size = len(self._blip.content)
+    self.__context.builder.DocumentAnnotationDelete(self._blip.waveId,
+                                                    self._blip.waveletId,
+                                                    self._blip.blipId,
+                                                    0, size, name)
+    self._blip.annotations = [a
+        for a in self._blip.annotations if a.name != name]
+
+  def DeleteAnnotationsInRange(self, r, name):
+    """Clears all of the annotations within a given range with a given key.
+
+    Args:
+      r: A Range specifying the range to delete.
+      name: Annotation key type to clear.
+    """
+    self.__context.builder.DocumentAnnotationDelete(self._blip.waveId,
+                                                    self._blip.waveletId,
+                                                    self._blip.blipId,
+                                                    r.start, r.end,
+                                                    name)
+    res = []
+    for a in self._blip.annotations:
+      if a.name != name or r.start > a.range.end or r.end < a.range.start:
+        res.append(a)
+      elif r.start < a.range.start and r.end > a.range.end:
+        continue
+      else:
+        if a.range.start < r.start:
+          res.append(document.Annotation(
+              name, a.value, document.Range(a.range.start, r.start)))
+        if a.range.end > r.end:
+          a.range.start = r.end
+          res.append(a)
+    self._blip.annotations = res
+
+
+  def AppendInlineBlip(self):
+    """Appends an inline blip to this blip.
+
+    Returns:
+      The local blip that was appended.
+    """
+    blip_data = self.__context.builder.DocumentInlineBlipAppend(
+        self._blip.waveId, self._blip.waveletId,
+        self._blip.blipId)
+    return self.__context.AddBlip(blip_data)
+
+  def DeleteInlineBlip(self, inline_blipId):
+    """Deletes an inline blip from this blip.
+
+    Args:
+      inline_blipId: The id of the blip to remove.
+    """
+    self.__context.builder.DocumentInlineBlipDelete(self._blip.waveId,
+                                                    self._blip.waveletId,
+                                                    self._blip.blipId,
+                                                    inline_blipId)
+    self.__context.RemoveBlip(inline_blipId)
+
+  def InsertInlineBlip(self, position):
+    """Inserts an inline blip into this blip at a specific position.
+
+    Args:
+      position: Position to insert the blip at.
+
+    Returns:
+      The JSON data of the blip that was created.
+    """
+    blip_data = self.__context.builder.DocumentInlineBlipInsert(
+        self._blip.waveId,
+        self._blip.waveletId,
+        self._blip.blipId,
+        position)
+    # TODO(davidbyttow): Add local blip element.
+    return self.__context.AddBlip(blip_data)
+
+  def DeleteElement(self, position):
+    """Deletes an Element at a given position.
+
+    Args:
+      position: Position of the Element to delete.
+    """
+    self.__context.builder.DocumentElementDelete(self._blip.waveId,
+                                                 self._blip.waveletId,
+                                                 self._blip.blipId,
+                                                 position)
+
+  def InsertElement(self, position, element):
+    """Inserts an Element at a given position.
+
+    Args:
+      position: Position of the element to replace.
+      element: The Element to replace with.
+    """
+    self.__context.builder.DocumentElementInsert(self._blip.waveId,
+                                                 self._blip.waveletId,
+                                                 self._blip.blipId,
+                                                 position, element)
+
+  def ReplaceElement(self, position, element):
+    """Replaces an Element at a given position with a new element.
+
+    Args:
+      position: Position of the element to replace.
+      element: The Element to replace with.
+    """
+    self.__context.builder.DocumentElementReplace(self._blip.waveId,
+                                                  self._blip.waveletId,
+                                                  self._blip.blipId,
+                                                  position, element)
+
+  def AppendElement(self, element):
+    self.__context.builder.DocumentElementAppend(self._blip.waveId,
+                                                 self._blip.waveletId,
+                                                 self._blip.blipId,
+                                                 element)
+
+  def GadgetSubmitDelta(self, gadget, delta):
+    """Submit a delta for the specified gadget.
+
+    Currently submit delta is keyed on the url of the gadget and
+    won't work if two gadgets with the same url are present in the
+    document.
+
+    Args:
+      gadget: the gadget to submit the delta too
+      delta: a dictionary with the key/values that need to be updated.
+    """
+    dummy = document.Gadget(url=gadget.url, props=delta)
+    self.__context.builder.DocumentModifyAttributes(self._blip.waveId,
+                                                    self._blip.waveletId,
+                                                    self._blip.blipId,
+                                                    dummy)
+    gadget.SubmitDelta(delta)
+
+
+class _ContextImpl(model.Context):
+  """An internal implementation of the Context class.
+
+  This implementation of the context is capable of adding waves, wavelets
+  and blips to itself. This is useful when applying operations locally
+  in a single session. Through this, clients can access waves, wavelets and
+  blips and add operations to be applied to those objects by the server.
+
+  Operations are applied in the order that they are received. Adding
+  operations manually will not be reflected in the state of the context.
+  """
+
+  def __init__(self):
+    super(_ContextImpl, self).__init__()
+    self.builder = OpBuilder(self)
+
+  def AddOperation(self, op):
+    """Adds an operation to the list of operations to applied by the server.
+
+    After all events are handled, the operation list is sent back to the server
+    and applied in order. Adding an operation this way will have no effect
+    on the state of the context or its entities.
+
+    Args:
+      op: An instance of an Operation.
+    """
+    self._operations.append(op)
+
+  def AddWave(self, wave_data):
+    """Adds a transient wave based on the data supplied.
+
+    Args:
+      wave_data: JSON data describing this wave.
+
+    Returns:
+      An OpBasedWave that may have operations applied to it.
+    """
+    wave = OpBasedWave(wave_data, self)
+    self.waves[wave.GetId()] = wave
+    return wave
+
+  def AddWavelet(self, wavelet_data):
+    """Adds a transient wavelet based on the data supplied.
+
+    Args:
+      wavelet_data: JSON data describing this wavelet.
+
+    Returns:
+      An OpBasedWavelet that may have operations applied to it.
+    """
+    wavelet = OpBasedWavelet(wavelet_data, self)
+    self.wavelets[wavelet.GetId()] = wavelet
+    return wavelet
+
+  def AddBlip(self, blip_data):
+    """Adds a transient blip based on the data supplied.
+
+    Args:
+      blip_data: JSON data describing this blip.
+
+    Returns:
+      An OpBasedBlip that may have operations applied to it.
+    """
+    blip = OpBasedBlip(blip_data, self)
+    self.blips[blip.GetId()] = blip
+    return blip
+
+  def RemoveWave(self, wave_id):
+    """Removes a wave locally."""
+    if wave_id in self.waves:
+      del self.waves[wave_id]
+
+  def RemoveWavelet(self, wavelet_id):
+    """Removes a wavelet locally."""
+    if wavelet_id in self.wavelets:
+      del self.wavelets[wavelet_id]
+
+  def RemoveBlip(self, blip_id):
+    """Removes a blip locally."""
+    if blip_id in self.blips:
+      del self.blips[blip_id]
+
+  def Serialize(self):
+    """Serialize the operation bundle.
+
+    Returns:
+      Dict representing this object.
+    """
+    data = {
+        'javaClass': 'com.google.wave.api.impl.OperationMessageBundle',
+        'operations': util.Serialize(self._operations)
+    }
+    return data
+
+
+def CreateContext(data):
+  """Creates a Context instance from raw data supplied by the server.
+
+  Args:
+    data: Raw data decoded from JSON sent by the server.
+
+  Returns:
+    A Context instance for this session.
+  """
+  context = _ContextImpl()
+  for raw_blip_data in data['blips'].values():
+    context.AddBlip(raw_blip_data)
+
+  # Currently only one wavelet is sent.
+  context.AddWavelet(data['wavelet'])
+
+  # Waves are not sent over the wire, but we can build the list based on the
+  # wave ids of the wavelets.
+  wave_wavelet_map = {}
+  wavelets = context.GetWavelets()
+  for wavelet in wavelets:
+    wave_id = wavelet.GetWaveId()
+    wavelet_id = wavelet.GetId()
+    if wave_id not in wave_wavelet_map:
+      wave_wavelet_map[wave_id] = []
+    wave_wavelet_map[wave_id].append(wavelet_id)
+
+  for wave_id, wavelet_ids in wave_wavelet_map.iteritems():
+    wave_data = {
+        'waveId': wave_id,
+        'waveletIds': wavelet_ids,
+    }
+    context.AddWave(wave_data)
+
+  return context
+
+
+class BlipData(dict):
+  """Temporary class for storing ephemeral blip data.
+
+  This should be removed once the Java API no longer requires javaClass
+  objects, at which point, this method should just return a dict.
+  """
+  java_class = 'com.google.wave.api.impl.BlipData'
+
+  def __init__(self, wave_id, wavelet_id, blip_id):
+    super(BlipData, self).__init__()
+    self.waveId = wave_id
+    self.waveletId = wavelet_id
+    self.blipId = blip_id
+    self['waveId'] = wave_id
+    self['waveletId'] = wavelet_id
+    self['blipId'] = blip_id
+
+
+class WaveletData(dict):
+  """Temporary class for storing ephemeral blip data.
+
+  This should be removed once the Java API no longer requires javaClass
+  objects, at which point, this method should just return a dict.
+  """
+  java_class = 'com.google.wave.api.impl.WaveletData'
+
+  def __init__(self, wave_id, wavelet_id, participants):
+    super(WaveletData, self).__init__()
+    self.waveId = wave_id
+    self.waveletId = wavelet_id
+    self.participants = participants
+    self['waveId'] = wave_id
+    self['waveletId'] = wavelet_id
+    self['participants'] = participants
+
+  def SetRootBlipId(self, blip_id):
+    self['rootBlipId'] = blip_id
+    self.rootBlipId = blip_id
+
+
+class OpBuilder(object):
+  """Wraps all currently supportable operations as functions.
+
+  The operation builder wraps single operations as functions and generates
+  operations in-order on its context. This should only be used when the context
+  is not available on a specific entity. For example, to modify a blip that
+  does not exist in the current context, you might specify the wave, wavelet
+  and blip id to generate an operation.
+
+  Any calls to this will not reflect the local context state in any way.
+  For example, calling WaveletAppendBlip will not result in a new blip
+  being added to the local context, only an operation to be applied on the
+  server.
+  """
+
+  def __init__(self, context):
+    """Initializes the op builder with the context.
+
+    Args:
+      context: A Context instance to generate operations on.
+    """
+    self.__context = context
+    self.__nextBlipId = 1
+    self.__nextWaveId = 1
+
+  def __CreateNewBlipData(self, wave_id, wavelet_id):
+    """Creates JSON of the blip used for this session."""
+    temp_blip_id = 'TBD_' + wavelet_id + '_' + str(self.__nextBlipId)
+    self.__nextBlipId += 1
+    return BlipData(wave_id, wavelet_id, temp_blip_id)
+
+  def __CreateNewWaveletData(self, participants):
+    """Creates an ephemeral BlipData instance used for this session."""
+    wave_id = 'TBD_' + str(self.__nextWaveId)
+    self.__nextWaveId += 1
+    wavelet_id = "conv+root"
+    participants = set(participants)
+    return WaveletData(wave_id, wavelet_id, participants)
+
+  def AddNewOperation(self, op_type, wave_id, wavelet_id, blip_id='', index=-1,
+                      prop=None):
+    """Creates and adds a new operation to the operation list."""
+    self.__context.AddOperation(
+        Operation(op_type, wave_id, wavelet_id,
+                  blip_id=blip_id,
+                  index=index,
+                  prop=prop))
+
+  def WaveletAppendBlip(self, wave_id, wavelet_id):
+    """Requests to append a blip to a wavelet.
+
+    Args:
+      wave_id: The wave id owning the containing wavelet.
+      wavelet_id: The wavelet id that this blip should be appended to.
+
+    Returns:
+      JSON representing the id information of the new blip.
+    """
+    blip_data = self.__CreateNewBlipData(wave_id, wavelet_id)
+    self.AddNewOperation(WAVELET_APPEND_BLIP, wave_id, wavelet_id,
+                         prop=blip_data)
+    return blip_data
+
+  def WaveletAddParticipant(self, wave_id, wavelet_id, participant_id):
+    """Requests to add a participant to a wavelet.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+      participant_id: Id of the participant to add.
+    """
+    self.AddNewOperation(WAVELET_ADD_PARTICIPANT, wave_id, wavelet_id,
+                         prop=participant_id)
+
+  def WaveletCreate(self, wave_id, wavelet_id, participants=None):
+    """Requests to create a wavelet in a wave.
+
+    Not yet implemented.
+
+    Args:
+      participants: initial participants on this wavelet or None if none
+
+    """
+    if participants is None:
+      participants = []
+    wavelet_data = self.__CreateNewWaveletData(participants)
+    blip_data = self.__CreateNewBlipData(
+        wavelet_data.waveId, wavelet_data.waveletId)
+    self.__context.AddBlip(blip_data)
+    wavelet_data.SetRootBlipId(blip_data.blipId)
+    logging.info('rootblip=' + blip_data.blipId)
+    wavelet = self.__context.AddWavelet(wavelet_data)
+    op = Operation(WAVELET_CREATE, wave_id, wavelet_id, prop=wavelet_data)
+    self.__context.AddOperation(op)
+    return wavelet
+
+  def WaveletRemoveSelf(self, wave_id, wavelet_id):
+    """Requests to remove this robot from a wavelet.
+
+    Not yet implemented.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+
+    Raises:
+      NotImplementedError: Function not yet implemented.
+    """
+    raise NotImplementedError()
+
+  def WaveletSetDataDoc(self, wave_id, wavelet_id, name, data):
+    """Requests set a key/value pair on the data document of a wavelet.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+      name: The key name for this data.
+      data: The value of the data to set.
+    """
+    self.AddNewOperation(WAVELET_DATADOC_SET, wave_id, wavelet_id,
+                         blip_id=name, prop=data)
+
+  def WaveletSetTitle(self, wave_id, wavelet_id, title):
+    """Requests to set the title of a wavelet.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+      title: The title to set.
+    """
+    self.AddNewOperation(WAVELET_SET_TITLE, wave_id, wavelet_id,
+                         prop=title)
+
+  def BlipCreateChild(self, wave_id, wavelet_id, blip_id):
+    """Requests to create a child blip of another blip.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+      blip_id: The blip id that this operation is applied to.
+
+    Returns:
+      JSON of blip for which further operations can be applied.
+    """
+    blip_data = self.__CreateNewBlipData(wave_id, wavelet_id)
+    self.AddNewOperation(BLIP_CREATE_CHILD, wave_id, wavelet_id,
+                         blip_id=blip_id,
+                         prop=blip_data)
+    return blip_data
+
+  def BlipDelete(self, wave_id, wavelet_id, blip_id):
+    """Requests to delete (tombstone) a blip.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+      blip_id: The blip id that this operation is applied to.
+    """
+    self.AddNewOperation(BLIP_DELETE, wave_id, wavelet_id, blip_id=blip_id)
+
+  def DocumentAnnotationDelete(self, wave_id, wavelet_id, blip_id, start, end,
+                               name):
+    """Deletes a specified annotation of a given range with a specific key.
+
+    Not yet implemented.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+      blip_id: The blip id that this operation is applied to.
+      start: Start position of the range.
+      end: End position of the range.
+      name: Annotation key name to clear.
+    """
+    self.AddNewOperation(DOCUMENT_ANNOTATION_DELETE, wave_id, wavelet_id,
+        blip_id=blip_id, prop=document.Range(start, end))
+
+  def DocumentAnnotationSet(self, wave_id, wavelet_id, blip_id, start, end,
+                            name, value):
+    """Set a specified annotation of a given range with a specific key.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+      blip_id: The blip id that this operation is applied to.
+      start: Start position of the range.
+      end: End position of the range.
+      name: Annotation key name to clear.
+      value: The value of the annotation across this range.
+    """
+    annotation = document.Annotation(name, value, document.Range(start, end))
+    self.AddNewOperation(DOCUMENT_ANNOTATION_SET, wave_id, wavelet_id,
+                         blip_id=blip_id,
+                         prop=annotation)
+
+  def DocumentAnnotationSetNoRange(self, wave_id, wavelet_id, blip_id,
+                                   name, value):
+    """Requests to set an annotation on an entire document.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+      blip_id: The blip id that this operation is applied to.
+      name: Annotation key name to clear.
+      value: The value of the annotation.
+    """
+    annotation = document.Annotation(name, value, None)
+    self.AddNewOperation(DOCUMENT_ANNOTATION_SET_NORANGE, wave_id, wavelet_id,
+                         blip_id=blip_id,
+                         prop=annotation)
+
+  def DocumentAppend(self, wave_id, wavelet_id, blip_id, content):
+    """Requests to append content to a document.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+      blip_id: The blip id that this operation is applied to.
+      content: The content to append.
+    """
+    self.AddNewOperation(DOCUMENT_APPEND, wave_id, wavelet_id,
+                         blip_id=blip_id,
+                         prop=content)
+
+  def DocumentAppendMarkup(self, wave_id, wavelet_id, blip_id, content):
+    """Requests to append content with markup to a document.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+      blip_id: The blip id that this operation is applied to.
+      content: The markup content to append.
+    """
+    op = Operation(DOCUMENT_APPEND_MARKUP, wave_id, wavelet_id,
+                   blip_id=blip_id,
+                   prop=content)
+    self.__context.AddOperation(op)
+
+  def DocumentAppendStyledText(self, wave_id, wavelet_id, blip_id, text, style):
+    """Requests to append styled text to the document.
+
+    Not yet implemented.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+      blip_id: The blip id that this operation is applied to.
+      text: The text ot append..
+      style: The style to apply.
+
+    Raises:
+      NotImplementedError: Function not yet implemented.
+    """
+    raise NotImplementedError()
+
+  def DocumentDelete(self, wave_id, wavelet_id, blip_id, start=None, end=None):
+    """Requests to delete content in a given range.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+      blip_id: The blip id that this operation is applied to.
+      start: Start of the range.
+      end: End of the range.
+    """
+    if start is None or end is None:
+      range = None
+    else:
+      range = document.Range(start, end)
+    self.AddNewOperation(DOCUMENT_DELETE, wave_id, wavelet_id, blip_id,
+                         prop=range)
+
+  def DocumentInsert(self, wave_id, wavelet_id, blip_id, content, index=0):
+    """Requests to insert content into a document at a specific location.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+      blip_id: The blip id that this operation is applied to.
+      content: The content to insert.
+      index: The position insert the content at in ths document.
+    """
+    self.AddNewOperation(DOCUMENT_INSERT, wave_id, wavelet_id, blip_id,
+                         index=index, prop=content)
+
+  def DocumentReplace(self, wave_id, wavelet_id, blip_id, content):
+    """Requests to replace all content in a document.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+      blip_id: The blip id that this operation is applied to.
+      content: Content that will replace the current document.
+    """
+    self.AddNewOperation(DOCUMENT_REPLACE, wave_id, wavelet_id, blip_id,
+                         prop=content)
+
+  def DocumentElementAppend(self, wave_id, wavelet_id, blip_id, element):
+    """Requests to append an element to the document.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+      blip_id: The blip id that this operation is applied to.
+      element: Element instance to append.
+    """
+    self.AddNewOperation(DOCUMENT_ELEMENT_APPEND, wave_id, wavelet_id, blip_id,
+                         prop=element)
+
+  def DocumentElementDelete(self, wave_id, wavelet_id, blip_id, position):
+    """Requests to delete an element from the document at a specific position.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+      blip_id: The blip id that this operation is applied to.
+      position: Position of the element to delete.
+    """
+    self.AddNewOperation(DOCUMENT_ELEMENT_DELETE, wave_id, wavelet_id, blip_id,
+                         index=position)
+
+  def DocumentElementInsert(self, wave_id, wavelet_id, blip_id, position,
+                            element):
+    """Requests to insert an element to the document at a specific position.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+      blip_id: The blip id that this operation is applied to.
+      position: Position of the element to delete.
+      element: Element instance to insert.
+    """
+    self.AddNewOperation(DOCUMENT_ELEMENT_INSERT, wave_id, wavelet_id, blip_id,
+                         index=position,
+                         prop=element)
+
+  def DocumentElementInsertAfter(self):
+    """Requests to insert an element after the specified location.
+
+    Not yet implemented.
+
+    Raises:
+      NotImplementedError: Function not yet implemented.
+    """
+    raise NotImplementedError()
+
+  def DocumentElementInsertBefore(self):
+    """Requests to insert an element before the specified location.
+
+    Not yet implemented.
+
+    Raises:
+      NotImplementedError: Function not yet implemented.
+    """
+    raise NotImplementedError()
+
+  def DocumentElementReplace(self, wave_id, wavelet_id, blip_id, position,
+                             element):
+    """Requests to replace an element.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+      blip_id: The blip id that this operation is applied to.
+      position: Position of the element to replace.
+      element: Element instance to replace.
+    """
+    self.AddNewOperation(DOCUMENT_ELEMENT_REPLACE, wave_id, wavelet_id, blip_id,
+                         index=position,
+                         prop=element)
+
+  def DocumentModifyAttributes(self, wave_id, wavelet_id, blip_id,
+                               element):
+    """Modifies the attributes of an element.
+
+    This is done by passing the a new element that is matched against
+    existing elements and the attributes are copied without the element
+    actually being deleted and reinserted. This is especially useful for
+    gadgets.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+      blip_id: The blip id that this operation is applied to.
+      element: Element instance to take the attributes from an to
+               match.
+    """
+    self.AddNewOperation(DOCUMENT_ELEMENT_MODIFY_ATTRS, wave_id, wavelet_id, blip_id,
+                         index=-1,
+                         prop=element)
+
+  def DocumentInlineBlipAppend(self, wave_id, wavelet_id, blip_id):
+    """Requests to create and append a new inline blip to another blip.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+      blip_id: The blip id that this operation is applied to.
+
+    Returns:
+      JSON of blip containing the id information.
+    """
+    inline_blip_data = self.__CreateNewBlipData(wave_id, wavelet_id)
+    self.AddNewOperation(DOCUMENT_INLINE_BLIP_APPEND, wave_id, wavelet_id,
+                         blip_id=blip_id,
+                         prop=inline_blip_data)
+    inline_blip_data['parentBlipId'] = blip_id
+    return inline_blip_data
+
+  def DocumentInlineBlipDelete(self, wave_id, wavelet_id, blip_id,
+                               inline_blip_id):
+    """Requests to delete an inline blip from its parent.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+      blip_id: The blip id that this operation is applied to.
+      inline_blip_id: The blip to be deleted.
+    """
+    self.AddNewOperation(DOCUMENT_INLINE_BLIP_DELETE, wave_id, wavelet_id,
+                         blip_id=blip_id,
+                         prop=inline_blip_id)
+
+  def DocumentInlineBlipInsert(self, wave_id, wavelet_id, blip_id, position):
+    """Requests to insert an inline blip at a specific location.
+
+    Args:
+      wave_id: The wave id owning that this operation is applied to.
+      wavelet_id: The wavelet id that this operation is applied to.
+      blip_id: The blip id that this operation is applied to.
+      position: The position in the document to insert the blip.
+
+    Returns:
+      JSON data for the blip that was created for further operations.
+    """
+    inline_blip_data = self.__CreateNewBlipData(wave_id, wavelet_id)
+    inline_blip_data['parentBlipId'] = blip_id
+    self.AddNewOperation(DOCUMENT_INLINE_BLIP_INSERT, wave_id, wavelet_id,
+                         blip_id=blip_id,
+                         index=position,
+                         prop=inline_blip_data)
+    return inline_blip_data
+
+  def DocumentInlineBlipInsertAfterElement(self):
+    """Requests to insert an inline blip after an element.
+
+    Raises:
+      NotImplementedError: Function not yet implemented.
+    """
+    raise NotImplementedError()
Index: waveapi/events.py
===================================================================
--- waveapi/events.py	(revision 0)
+++ waveapi/events.py	(revision 0)
@@ -0,0 +1,54 @@
+#!/usr/bin/python2.4
+#
+# Copyright (C) 2009 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Defines event types that are sent from the wave server.
+
+This module defines all of the event types currently supported by the wave
+server.
+"""
+
+__author__ = 'davidbyttow@google.com (David Byttow)'
+
+
+# Event Types
+WAVELET_BLIP_CREATED = 'WAVELET_BLIP_CREATED'
+WAVELET_BLIP_REMOVED = 'WAVELET_BLIP_REMOVED'
+WAVELET_PARTICIPANTS_CHANGED = 'WAVELET_PARTICIPANTS_CHANGED'
+WAVELET_SELF_ADDED = 'WAVELET_SELF_ADDED'
+WAVELET_SELF_REMOVED = 'WAVELET_SELF_REMOVED'
+WAVELET_TIMESTAMP_CHANGED = 'WAVELET_TIMESTAMP_CHANGED'
+WAVELET_TITLE_CHANGED = 'WAVELET_TITLE_CHANGED'
+WAVELET_VERSION_CHANGED = 'WAVELET_VERSION_CHANGED'
+BLIP_CONTRIBUTORS_CHANGED = 'BLIP_CONTRIBUTORS_CHANGED'
+BLIP_DELETED = 'BLIP_DELETED'
+BLIP_SUBMITTED = 'BLIP_SUBMITTED'
+BLIP_TIMESTAMP_CHANGED = 'BLIP_TIMESTAMP_CHANGED'
+BLIP_VERSION_CHANGED = 'BLIP_VERSION_CHANGED'
+DOCUMENT_CHANGED = 'DOCUMENT_CHANGED'
+FORM_BUTTON_CLICKED = 'FORM_BUTTON_CLICKED'
+
+# Event Properties
+
+# Properties for WAVELET_PARTICIPANTS_CHANGED, WAVELET_SELF_ADDED and
+# WAVELET_SELF_REMOVED
+PARTICIPANTS_ADDED = 'participantsAdded'
+PARTICIPANTS_REMOVED = 'participantsRemoved'
+
+# Properties for WAVELET_TITLE_CHANGED
+TITLE = 'title'
+
+# Properties for WAVELET_VERSION_CHANGED
+VERSION = 'version'
Index: waveapi/robot_abstract_test.py
===================================================================
--- waveapi/robot_abstract_test.py	(revision 0)
+++ waveapi/robot_abstract_test.py	(revision 0)
@@ -0,0 +1,177 @@
+#!/usr/bin/python2.4
+#
+# Copyright (C) 2009 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Unit tests for the robot_abstract module."""
+
+__author__ = 'jacobly@google.com (Jacob Lee)'
+
+import unittest
+
+import robot_abstract
+
+DEBUG_DATA = r'{"blips":{"map":{"wdykLROk*13":{"lastModifiedTime":1242079608457,"contributors":{"javaClass":"java.util.ArrayList","list":["davidbyttow@google.com"]},"waveletId":"test.com!conv+root","waveId":"test.com!wdykLROk*11","parentBlipId":null,"version":3,"creator":"davidbyttow@google.com","content":"\n","blipId":"wdykLROk*13","javaClass":"com.google.wave.api.impl.BlipData","annotations":{"javaClass":"java.util.ArrayList","list":[{"range":{"start":0,"javaClass":"com.google.wave.api.Range","end":1},"name":"user/e/davidbyttow@google.com","value":"David","javaClass":"com.google.wave.api.Annotation"}]},"elements":{"map":{},"javaClass":"java.util.HashMap"},"childBlipIds":{"javaClass":"java.util.ArrayList","list":[]}}},"javaClass":"java.util.HashMap"},"events":{"javaClass":"java.util.ArrayList","list":[{"timestamp":1242079611003,"modifiedBy":"davidbyttow@google.com","javaClass":"com.google.wave.api.impl.EventData","properties":{"map":{"participantsRemoved":{"javaClass":"java.util.ArrayList","list":[]},"participantsAdded":{"javaClass":"java.util.ArrayList","list":["monty@appspot.com"]}},"javaClass":"java.util.HashMap"},"type":"WAVELET_PARTICIPANTS_CHANGED"}]},"wavelet":{"lastModifiedTime":1242079611003,"title":"","waveletId":"test.com!conv+root","rootBlipId":"wdykLROk*13","javaClass":"com.google.wave.api.impl.WaveletData","dataDocuments":null,"creationTime":1242079608457,"waveId":"test.com!wdykLROk*11","participants":{"javaClass":"java.util.ArrayList","list":["davidbyttow@google.com","monty@appspot.com"]},"creator":"davidbyttow@google.com","version":5}}'
+
+
+class TestHelpers(unittest.TestCase):
+  """Tests for the web helper functions in abstract_robot."""
+
+  def testParseJSONBody(self):
+    context, events = robot_abstract.ParseJSONBody(DEBUG_DATA)
+
+    # Test some basic properties; the rest should be covered by
+    # ops.CreateContext.
+    blips = context.GetBlips()
+    self.assertEqual(1, len(blips))
+    self.assertEqual('wdykLROk*13', blips[0].GetId())
+    self.assertEqual('test.com!wdykLROk*11', blips[0].GetWaveId())
+    self.assertEqual('test.com!conv+root', blips[0].GetWaveletId())
+
+    self.assertEqual(1, len(events))
+    event = events[0]
+    self.assertEqual('WAVELET_PARTICIPANTS_CHANGED', event.type)
+    self.assertEqual({'participantsRemoved': [],
+                      'participantsAdded': ['monty@appspot.com']},
+                     event.properties)
+
+  def testSerializeContextSansOps(self):
+    context, _ = robot_abstract.ParseJSONBody(DEBUG_DATA)
+    serialized = robot_abstract.SerializeContext(context, '1')
+    self.assertEqual(
+        '{"operations": {"javaClass": "java.util.ArrayList", "list": []}, '
+        '"javaClass": "com.google.wave.api.impl.OperationMessageBundle", '
+        '"version": "1"}',
+        serialized)
+
+  def testSerializeContextWithOps(self):
+    context, _ = robot_abstract.ParseJSONBody(DEBUG_DATA)
+    wavelet = context.GetWavelets()[0]
+    blip = context.GetBlipById(wavelet.GetRootBlipId())
+    blip.GetDocument().SetText('Hello, wave!')
+    serialized = robot_abstract.SerializeContext(context, '1')
+    self.assertEquals(
+        '{"operations": {"javaClass": "java.util.ArrayList", "list": ['
+        '{"blipId": "wdykLROk*13", "index": -1, "waveletId": "test.com!conv+root", "javaClass": "com.google.wave.api.impl.OperationImpl", "waveId": "test.com!wdykLROk*11", "type": "DOCUMENT_DELETE"}, '
+        '{"blipId": "wdykLROk*13", "index": 0, "waveletId": "test.com!conv+root", "javaClass": "com.google.wave.api.impl.OperationImpl", "waveId": "test.com!wdykLROk*11", "property": "Hello, wave!", "type": "DOCUMENT_INSERT"}'
+        ']}, "javaClass": "com.google.wave.api.impl.OperationMessageBundle", '
+        '"version": "1"}',
+        serialized)
+
+  def testSerializeContextWithOps2(self):
+    context, _ = robot_abstract.ParseJSONBody(DEBUG_DATA)
+    wavelet = context.GetRootWavelet()
+    wavelet.CreateBlip().GetDocument().SetText("Hello there!")
+    serialized = robot_abstract.SerializeContext(context, '1')
+    expected_json  = ('{"operations": {"javaClass": "java.util.ArrayList", "list": ['
+        '{"blipId": "", "index": -1, "waveletId": "test.com!conv+root", "javaClass": "com.google.wave.api.impl.OperationImpl", "waveId": "test.com!wdykLROk*11", "property": {"blipId": "TBD_test.com!conv+root_1", "javaClass": "com.google.wave.api.impl.BlipData", "waveId": "test.com!wdykLROk*11", "waveletId": "test.com!conv+root"}, "type": "WAVELET_APPEND_BLIP"}, '
+        '{"blipId": "TBD_test.com!conv+root_1", "index": -1, "waveletId": "test.com!conv+root", "javaClass": "com.google.wave.api.impl.OperationImpl", "waveId": "test.com!wdykLROk*11", "type": "DOCUMENT_DELETE"}, '
+        '{"blipId": "TBD_test.com!conv+root_1", "index": 0, "waveletId": "test.com!conv+root", "javaClass": "com.google.wave.api.impl.OperationImpl", "waveId": "test.com!wdykLROk*11", "property": "Hello there!", "type": "DOCUMENT_INSERT"}'
+        ']}, "javaClass": "com.google.wave.api.impl.OperationMessageBundle", "version": "1"}')
+    self.assertEqual(expected_json, serialized)
+
+
+class TestGetCapabilitiesXml(unittest.TestCase):
+
+  def setUp(self):
+    self.robot = robot_abstract.Robot('Testy', '1')
+
+  def assertStringsEqual(self, s1, s2):
+    self.assertEqual(s1, s2, 'Strings differ:\n%s--\n%s' % (s1, s2))
+
+  def testDefault(self):
+    expected = (
+        '<?xml version="1.0"?>\n'
+        '<w:robot xmlns:w="http://wave.google.com/extensions/robots/1.0">\n'
+        '<w:version>1</w:version>\n'
+        '<w:capabilities>\n</w:capabilities>\n'
+        '<w:profile name="Testy"/>\n'
+        '</w:robot>\n')
+    xml = self.robot.GetCapabilitiesXml()
+    self.assertStringsEqual(expected, xml)
+
+  def testUrls(self):
+    profile_robot = robot_abstract.Robot(
+        'Testy',
+        '1',
+        image_url='http://example.com/image.png',
+        profile_url='http://example.com/profile.xml')
+    expected = (
+        '<?xml version="1.0"?>\n'
+        '<w:robot xmlns:w="http://wave.google.com/extensions/robots/1.0">\n'
+        '<w:version>1</w:version>\n'
+        '<w:capabilities>\n</w:capabilities>\n'
+        '<w:profile name="Testy"'
+        ' imageurl="http://example.com/image.png"'
+        ' profileurl="http://example.com/profile.xml"/>\n'
+        '</w:robot>\n')
+    xml = profile_robot.GetCapabilitiesXml()
+    self.assertStringsEqual(expected, xml)
+
+  def testCapsAndEvents(self):
+    self.robot.RegisterHandler('myevent', None)
+    self.robot.RegisterCronJob('/ping', 20)
+    expected = (
+        '<?xml version="1.0"?>\n'
+        '<w:robot xmlns:w="http://wave.google.com/extensions/robots/1.0">\n'
+        '<w:version>1</w:version>\n'
+        '<w:capabilities>\n'
+        '  <w:capability name="myevent"/>\n'
+        '</w:capabilities>\n'
+        '<w:crons>\n  <w:cron path="/ping" timerinseconds="20"/>\n</w:crons>\n'
+        '<w:profile name="Testy"/>\n'
+        '</w:robot>\n')
+    xml = self.robot.GetCapabilitiesXml()
+    self.assertStringsEqual(expected, xml)
+
+
+class SampleListener(object):
+  """Example event listener that exposes some inconsistently-named methods."""
+
+  OnDocumentChanged = 'Non-callable decoy attribute'
+
+  def on_wavelet_blip_created(self, props, context):
+    pass
+
+  def OnBlipSubmitted(self, props, context):
+    pass
+
+  def OnBogusEvent(self, props, context):
+    pass
+
+  def some_other_method(self, props, context):
+    pass
+
+  def _on_document_changed(self, props, context):
+    pass
+
+
+class TestRegisterListener(unittest.TestCase):
+  """Tests for the RegisterListener robot method."""
+
+  def setUp(self):
+    self.robot = robot_abstract.Robot('listener', '1')
+
+  def testRegisterListener(self):
+    listener = SampleListener()
+    self.robot.RegisterListener(listener)
+    self.assertEqual(len(self.robot._handlers), 2)
+    self.assertEqual(self.robot._handlers['BLIP_SUBMITTED'],
+                     [listener.OnBlipSubmitted])
+    self.assertEqual(self.robot._handlers['WAVELET_BLIP_CREATED'],
+                     [listener.on_wavelet_blip_created])
+
+
+if __name__ == '__main__':
+  unittest.main()
Index: waveapi/robot_abstract.py
===================================================================
--- waveapi/robot_abstract.py	(revision 0)
+++ waveapi/robot_abstract.py	(revision 0)
@@ -0,0 +1,163 @@
+#!/usr/bin/python2.4
+#
+# Copyright (C) 2009 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Defines the generic robot classes.
+
+This module provides the Robot class and RobotListener interface,
+as well as some helper functions for web requests and responses.
+"""
+
+__author__ = 'davidbyttow@google.com (David Byttow)'
+
+import events
+import model
+import ops
+import simplejson
+import util
+
+
+def ParseJSONBody(json_body):
+  """Parse a JSON string and return a context and an event list."""
+  json = simplejson.loads(json_body)
+  # TODO(davidbyttow): Remove this once no longer needed.
+  data = util.CollapseJavaCollections(json)
+  context = ops.CreateContext(data)
+  event_list = [model.Event(event_data) for event_data in data['events']]
+  return context, event_list
+
+
+def SerializeContext(context, version):
+  """Return a JSON string representing the given context."""
+  context_dict = util.Serialize(context)
+  context_dict['version'] = str(version)
+  return simplejson.dumps(context_dict)
+
+
+def NewWave(context, participants=None):
+  """Create a new wave with the initial participants on it."""
+  # we shouldn't need a wave/wavelet id here, but we do
+  wavelet = context.GetRootWavelet()
+  return context.builder.WaveletCreate(wavelet.GetWaveId(), wavelet.GetId(), participants)
+
+
+class Robot(object):
+  """Robot metadata class.
+
+  This class holds on to basic robot information like the name and profile.
+  It also maintains the list of event handlers and cron jobs and
+  dispatches events to the appropriate handlers.
+  """
+
+  def __init__(self, name, version, image_url='', profile_url=''):
+    """Initializes self with robot information."""
+    self._handlers = {}
+    self.name = name
+    self.version = version
+    self.image_url = image_url
+    self.profile_url = profile_url
+    self.cron_jobs = []
+
+  def RegisterListener(self, listener):
+    """Registers all event handlers exported by the given object.
+
+    Args:
+      listener: an object with methods corresponding to wave events.
+        Methods should be named either in camel case, e.g. 'OnBlipSubmitted',
+        or in lowercase, e.g. 'on_blip_submitted', with names corresponding
+        to the event names in the events module.
+    """
+    for event in dir(events):
+      if event.startswith('_'):
+        continue
+      lowercase_method_name = 'on_' + event.lower()
+      camelcase_method_name = 'On' + util.ToUpperCamelCase(event)
+      if hasattr(listener, lowercase_method_name):
+        handler = getattr(listener, lowercase_method_name)
+      elif hasattr(listener, camelcase_method_name):
+        handler = getattr(listener, camelcase_method_name)
+      else:
+        continue
+      if callable(handler):
+        self.RegisterHandler(event, handler)
+
+  def RegisterHandler(self, event_type, handler):
+    """Registers a handler on a specific event type.
+
+    Multiple handlers may be registered on a single event type and are
+    guaranteed to be called in order.
+
+    The handler takes two arguments, the event properties and the Context of
+    this session. For example:
+
+    def OnParticipantsChanged(properties, context):
+      pass
+
+    Args:
+      event_type: An event type to listen for.
+      handler: A function handler which takes two arguments, event properties
+          and the Context of this session.
+    """
+    self._handlers.setdefault(event_type, []).append(handler)
+
+  def RegisterCronJob(self, path, seconds):
+    """Registers a cron job to surface in capabilities.xml."""
+    self.cron_jobs.append((path, seconds))
+
+  def HandleEvent(self, event, context):
+    """Calls all of the handlers associated with an event."""
+    for handler in self._handlers.get(event.type, []):
+      # TODO(jacobly): pass the event in to the handlers directly
+      # instead of passing the properties dictionary.
+      handler(event.properties, context)
+
+  def GetCapabilitiesXml(self):
+    """Return this robot's capabilities as an XML string."""
+    lines = ['<w:version>%s</w:version>' % self.version]
+
+    lines.append('<w:capabilities>')
+    for capability in self._handlers:
+      lines.append('  <w:capability name="%s"/>' % capability)
+    lines.append('</w:capabilities>')
+
+    if self.cron_jobs:
+      lines.append('<w:crons>')
+      for job in self.cron_jobs:
+        lines.append('  <w:cron path="%s" timerinseconds="%s"/>' % job)
+      lines.append('</w:crons>')
+
+    robot_attrs = ' name="%s"' % self.name
+    if self.image_url:
+      robot_attrs += ' imageurl="%s"' % self.image_url
+    if self.profile_url:
+      robot_attrs += ' profileurl="%s"' % self.profile_url
+    lines.append('<w:profile%s/>' % robot_attrs)
+    return ('<?xml version="1.0"?>\n'
+            '<w:robot xmlns:w="http://wave.google.com/extensions/robots/1.0">\n'
+            '%s\n</w:robot>\n') % ('\n'.join(lines))
+
+  def GetProfileJson(self):
+    """Returns JSON body for any profile handler.
+
+    Returns:
+      String of JSON to be sent as a response.
+    """
+    data = {}
+    data['name'] = self.name
+    data['imageUrl'] = self.image_url
+    data['profileUrl'] = self.profile_url
+    # TODO(davidbyttow): Remove this java nonsense.
+    data['javaClass'] = 'com.google.wave.api.ParticipantProfile'
+    return simplejson.dumps(data)
Index: waveapi/errors.py
===================================================================
--- waveapi/errors.py	(revision 0)
+++ waveapi/errors.py	(revision 0)
@@ -0,0 +1,27 @@
+#!/usr/bin/python2.4
+#
+# Copyright (C) 2009 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Contains various API-specific exception classes.
+
+This module contains various specific exception classes that are raised by
+the library back to the client.
+"""
+
+__author__ = 'davidbyttow@google.com (David Byttow)'
+
+
+class Error(Exception):
+  """Base library error type."""
Index: waveapi/util_test.py
===================================================================
--- waveapi/util_test.py	(revision 0)
+++ waveapi/util_test.py	(revision 0)
@@ -0,0 +1,191 @@
+#!/usr/bin/python2.4
+#
+# Copyright (C) 2009 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Unit tests for the util module."""
+
+
+__author__ = 'davidbyttow@google.com (David Byttow)'
+
+
+import unittest
+
+import document
+import ops
+import util
+
+
+class TestUtils(unittest.TestCase):
+  """Tests utility functions."""
+
+  def testIsIterable(self):
+    self.assertTrue(util.IsIterable([]))
+    self.assertTrue(util.IsIterable({}))
+    self.assertTrue(util.IsIterable(set()))
+    self.assertTrue(util.IsIterable(()))
+    self.assertFalse(util.IsIterable(42))
+    self.assertFalse(util.IsIterable('list?'))
+    self.assertFalse(util.IsIterable(object))
+
+  def testIsDict(self):
+    self.assertFalse(util.IsDict([]))
+    self.assertTrue(util.IsDict({}))
+    self.assertFalse(util.IsDict(set()))
+    self.assertFalse(util.IsDict(()))
+    self.assertFalse(util.IsDict(42))
+    self.assertFalse(util.IsDict('dict?'))
+    self.assertFalse(util.IsDict(object))
+
+  def testIsUserDefinedNewStyleClass(self):
+    class OldClass:
+      pass
+
+    class NewClass(object):
+      pass
+
+    self.assertFalse(util.IsUserDefinedNewStyleClass(OldClass()))
+    self.assertTrue(util.IsUserDefinedNewStyleClass(NewClass()))
+    self.assertFalse(util.IsUserDefinedNewStyleClass({}))
+    self.assertFalse(util.IsUserDefinedNewStyleClass(()))
+    self.assertFalse(util.IsUserDefinedNewStyleClass(42))
+    self.assertFalse(util.IsUserDefinedNewStyleClass('instance?'))
+
+  def testCollapseJavaCollections(self):
+    def MakeList(e0=1):
+      return {
+          'javaClass': 'java.util.ArrayList',
+          'list': [e0, 2, 3]
+      }
+
+    def MakeMap(v='value'):
+      return {
+          'javaClass': 'java.util.HashMap',
+          'map': {'key': v}
+      }
+
+    l = util.CollapseJavaCollections(MakeList())
+    self.assertEquals(2, l[1])
+
+    m = util.CollapseJavaCollections(MakeMap())
+    self.assertEquals('value', m['key'])
+
+    nested = util.CollapseJavaCollections(MakeMap(MakeList(MakeMap())))
+    self.assertEquals('value', nested['key'][0]['key'])
+
+  def testToLowerCamelCase(self):
+    self.assertEquals('foo', util.ToLowerCamelCase('foo'))
+    self.assertEquals('fooBar', util.ToLowerCamelCase('foo_bar'))
+    self.assertEquals('fooBar', util.ToLowerCamelCase('fooBar'))
+    self.assertEquals('blipId', util.ToLowerCamelCase('blip_id'))
+    self.assertEquals('fooBar', util.ToLowerCamelCase('foo__bar'))
+    self.assertEquals('fooBarBaz', util.ToLowerCamelCase('foo_bar_baz'))
+    self.assertEquals('f', util.ToLowerCamelCase('f'))
+    self.assertEquals('f', util.ToLowerCamelCase('f_'))
+    self.assertEquals('', util.ToLowerCamelCase(''))
+    self.assertEquals('', util.ToLowerCamelCase('_'))
+    self.assertEquals('aBCDEF', util.ToLowerCamelCase('_a_b_c_d_e_f_'))
+
+  def testToUpperCamelCase(self):
+    self.assertEquals('Foo', util.ToUpperCamelCase('foo'))
+    self.assertEquals('FooBar', util.ToUpperCamelCase('foo_bar'))
+    self.assertEquals('FooBar', util.ToUpperCamelCase('foo__bar'))
+    self.assertEquals('FooBarBaz', util.ToUpperCamelCase('foo_bar_baz'))
+    self.assertEquals('F', util.ToUpperCamelCase('f'))
+    self.assertEquals('F', util.ToUpperCamelCase('f_'))
+    self.assertEquals('', util.ToUpperCamelCase(''))
+    self.assertEquals('', util.ToUpperCamelCase('_'))
+    self.assertEquals('ABCDEF', util.ToUpperCamelCase('_a_b_c_d_e_f_'))
+
+  def assertListsEqual(self, a, b):
+    self.assertEquals(len(a), len(b))
+    for i in range(len(a)):
+      self.assertEquals(a[i], b[i])
+
+  def assertDictsEqual(self, a, b):
+    self.assertEquals(len(a.keys()), len(b.keys()))
+    for k, v in a.iteritems():
+      self.assertEquals(v, b[k])
+
+  def testSerializeList(self):
+    data = [1, 2, 3]
+    output = util.Serialize(data)
+    self.assertEquals('java.util.ArrayList', output['javaClass'])
+    self.assertListsEqual(data, output['list'])
+
+  def testSerializeDict(self):
+    data = {'key': 'value'}
+    output = util.Serialize(data)
+    self.assertEquals('java.util.HashMap', output['javaClass'])
+    self.assertDictsEqual(data, output['map'])
+
+  def testSerializeAttributes(self):
+
+    class Data(object):
+      java_class = 'json.org.JSONObject'
+
+      def __init__(self):
+        self.public = 1
+        self._protected = 2
+        self.__private = 3
+
+      def Func(self):
+        pass
+
+    data = Data()
+    output = util.Serialize(data)
+    # Functions and non-public fields should not be serialized.
+    self.assertEquals(2, len(output.keys()))
+    self.assertEquals(Data.java_class, output['javaClass'])
+    self.assertEquals(data.public, output['public'])
+  
+  def testStringEnum(self):
+    empty = util.StringEnum()
+    single = util.StringEnum('foo')
+    self.assertEquals('foo', single.foo)
+    multi = util.StringEnum('foo', 'bar')
+    self.assertEquals('foo', multi.foo)
+    self.assertEquals('bar', multi.bar)
+
+  def testClipRange(self):
+    def R(x, y):
+      return document.Range(x, y)
+
+    def Test(test_range, clipping_range, expected):
+      ret = util.ClipRange(test_range, clipping_range)
+      self.assertEquals(len(expected), len(ret))
+      for i in range(len(ret)):
+        self.assertEquals(expected[i].start, ret[i].start)
+        self.assertEquals(expected[i].end, ret[i].end)
+
+    # completely out
+    Test(R(0, 1), R(2, 3), [R(0, 1)])
+    # completely out
+    Test(R(3, 4), R(2, 3), [R(3, 4)])
+    # completely in
+    Test(R(2, 3), R(1, 4), [])
+    # completely in
+    Test(R(1, 4), R(1, 4), [])
+    # tRim left
+    Test(R(1, 3), R(2, 4), [R(1, 2)])
+    # tRim Right
+    Test(R(2, 4), R(1, 3), [R(3, 4)])
+    # split with two
+    Test(R(1, 4), R(2, 3), [R(1, 2), R(3, 4)])
+    # split with one
+    Test(R(1, 4), R(1, 3), [R(3, 4)])
+
+
+if __name__ == '__main__':
+  unittest.main()
Index: waveapi/module_test_runner.py
===================================================================
--- waveapi/module_test_runner.py	(revision 0)
+++ waveapi/module_test_runner.py	(revision 0)
@@ -0,0 +1,43 @@
+#!/usr/bin/python2.4
+#
+# Copyright (C) 2009 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Module defines the ModuleTestRunnerClass."""
+
+
+__author__ = 'davidbyttow@google.com (David Byttow)'
+
+
+import unittest
+
+
+class ModuleTestRunner(object):
+  """Responsible for executing all test cases in a list of modules."""
+
+  def __init__(self, module_list=None, module_test_settings=None):
+    self.modules = module_list or []
+    self.settings = module_test_settings or {}
+
+  def RunAllTests(self):
+    """Executes all tests present in the list of modules."""
+    runner = unittest.TextTestRunner()
+    for module in self.modules:
+      for setting, value in self.settings.iteritems():
+        try:
+          setattr(module, setting, value)
+        except AttributeError:
+          print '\nError running ' + str(setting)
+      print '\nRunning all tests in module', module.__name__
+      runner.run(unittest.defaultTestLoader.loadTestsFromModule(module))
Index: waveapi/util.py
===================================================================
--- waveapi/util.py	(revision 0)
+++ waveapi/util.py	(revision 0)
@@ -0,0 +1,272 @@
+#!/usr/bin/python2.4
+#
+# Copyright (C) 2009 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Utility library containing various helpers used by the API.
+
+Contains miscellaneous functions used internally by the API.
+"""
+
+__author__ = 'davidbyttow@google.com (David Byttow)'
+
+
+import document
+
+
+CUSTOM_SERIALIZE_METHOD_NAME = 'Serialize'
+
+
+def IsIterable(inst):
+  """Returns whether or not this is a list, tuple, set or dict .
+
+  Note that this does not return true for strings.
+  """
+  return hasattr(inst, '__iter__')
+
+
+def IsDict(inst):
+  """Returns whether or not the specified instance is a dict."""
+  return hasattr(inst, 'iteritems')
+
+
+def IsUserDefinedNewStyleClass(obj):
+  """Returns whether or not the specified instance is a user-defined type."""
+  # NOTE(davidbyttow): This seems like a reasonably safe hack for now...
+  # I'm not exactly sure how to test if something is a subclass of object.
+  # And no, "is InstanceType" does not work here. :(
+  return type(obj).__module__ != '__builtin__'
+
+
+def CollapseJavaCollections(data):
+  """Collapses the unnecessary extra data structures in the wire format.
+
+  Currently the wire format is built from marshalling of Java objects. This
+  introduces overhead of extra key/value pairs with respect to collections and
+  superfluous fields. As such, this method attempts to collapse those structures
+  out of the data format by collapsing the collection objects and removing
+  the java class fields.
+
+  This preserves the data that is passed in and only removes the collection
+  types.
+
+  Args:
+    data: Some arbitrary dict, list or primitive type.
+
+  Returns:
+    The same data structure with the collapsed and unnecessary objects
+    removed.
+  """
+  if IsDict(data):
+    java_class = data.get('javaClass')
+    if java_class:
+      del data['javaClass']
+    if java_class == 'java.util.HashMap':
+      return CollapseJavaCollections(data['map'])
+    elif java_class == 'java.util.ArrayList':
+      return CollapseJavaCollections(data['list'])
+    for key, val in data.iteritems():
+      data[key] = CollapseJavaCollections(val)
+  elif IsIterable(data):
+    for index in range(len(data)):
+      data[index] = CollapseJavaCollections(data[index])
+  return data
+
+
+def ToLowerCamelCase(s):
+  """Converts a string to lower camel case.
+
+  Examples:
+    foo => foo
+    foo_bar => fooBar
+    foo__bar => fooBar
+    foo_bar_baz => fooBarBaz
+
+  Args:
+    s: The string to convert to lower camel case.
+
+  Returns:
+    The lower camel cased string.
+  """
+  return reduce(lambda a, b: a + (a and b.capitalize() or b), s.split('_'))
+
+
+def ToUpperCamelCase(s):
+  """Converts a string to upper camel case.
+
+  Examples:
+    foo => Foo
+    foo_bar => FooBar
+    foo__bar => FooBar
+    foo_bar_baz => FooBarBaz
+
+  Args:
+    s: The string to convert to upper camel case.
+
+  Returns:
+    The upper camel cased string.
+  """
+  return ''.join(fragment.capitalize() for fragment in s.split('_'))
+
+
+def DefaultKeyWriter(key_name):
+  """This key writer rewrites keys as lower camel case.
+
+  Expects that the input is formed by '_' delimited words.
+
+  Args:
+    key_name: Name of the key to serialize.
+
+  Returns:
+    Key name in lower camel-cased form.
+  """
+  return ToLowerCamelCase(key_name)
+
+
+def _SerializeAttributes(obj, key_writer=DefaultKeyWriter):
+  """Serializes attributes of an instance.
+
+  Iterates all attributes of an object and invokes serialize if they are
+  public and not callable.
+
+  Args:
+    obj: The instance to serialize.
+    key_writer: Optional function that takes a string key and optionally mutates
+        it before serialization. For example:
+
+        def randomize(key_name):
+          return key_name += str(random.random())
+
+  Returns:
+    The serialized object.
+  """
+  data = {}
+  for attr_name in dir(obj):
+    if attr_name.startswith('_'):
+      continue
+    attr = getattr(obj, attr_name)
+    if attr is None or callable(attr):
+      continue
+    # Looks okay, serialize it.
+    data[key_writer(attr_name)] = Serialize(attr)
+  return data
+
+
+def _SerializeList(l):
+  """Invokes Serialize on all of its elements.
+
+  Args:
+    l: The list object to serialize.
+
+  Returns:
+    The serialized list.
+  """
+  data = [Serialize(v) for v in l]
+  return {
+      'javaClass': 'java.util.ArrayList',
+      'list': data
+  }
+
+
+def _SerializeDict(d, key_writer=DefaultKeyWriter):
+  """Invokes serialize on all of its key/value pairs.
+
+  Args:
+    d: The dict instance to serialize.
+    key_writer: Optional key writer function.
+
+  Returns:
+    The serialized dict.
+  """
+  data = {}
+  for k, v in d.iteritems():
+    data[key_writer(k)] = Serialize(v)
+  return {
+      'javaClass': 'java.util.HashMap',
+      'map': data
+  }
+
+
+def Serialize(obj, key_writer=DefaultKeyWriter):
+  """Serializes any instance.
+
+  If this is a user-defined instance
+  type, it will first check for a custom Serialize() function and use that
+  if it exists. Otherwise, it will invoke serialize all of its public
+  attributes. Lists and dicts are serialized trivially.
+
+  Args:
+    obj: The instance to serialize.
+    key_writer: Optional key writer function.
+
+  Returns:
+    The serialized object.
+  """
+  if IsUserDefinedNewStyleClass(obj):
+    if obj and hasattr(obj, CUSTOM_SERIALIZE_METHOD_NAME):
+      method = getattr(obj, CUSTOM_SERIALIZE_METHOD_NAME)
+      if callable(method):
+        return method()
+    return _SerializeAttributes(obj, key_writer)
+  elif IsDict(obj):
+    return _SerializeDict(obj, key_writer)
+  elif IsIterable(obj):
+    return _SerializeList(obj)
+  return obj
+
+
+class StringEnum(object):
+  """Enum like class that is configured with a list of values.
+
+  This class effectively implements an enum for Elements, except for that
+  the actual values of the enums will be the string values."""
+
+  def __init__(self, *values):
+    for name in values:
+      setattr(self, name, name)
+
+
+def ClipRange(r, clip_range):
+  """Clips one range to another.
+
+  Given a range to be clipped and a clipping range, will result in a list
+  of 0-2 new ranges. If the range is completely inside of the clipping range
+  then an empty list will be returned. If it is completely outside, then
+  a list with only the same range will be returned.
+
+  Otherwise, other permutations may result in a single clipped range or
+  two ranges that were the result of a split.
+
+  Args:
+    r: The range to be clipped.
+    clip_range: The range that is clipping the other.
+
+  Returns:
+    A list of 0-2 ranges as a result of performing the clip.
+  """
+  # Check if completely outside the clipping range.
+  if r.end <= clip_range.start or r.start >= clip_range.end:
+    return [r]
+  # Check if completely clipped.
+  if r.start >= clip_range.start and r.end <= clip_range.end:
+    return []
+  # Check if split.
+  if clip_range.start > r.start and clip_range.end < r.end:
+    return [document.Range(r.start, clip_range.start),
+            document.Range(clip_range.end, r.end)]
+  # Check if start trimmed.
+  if clip_range.start <= r.start:
+    return [document.Range(clip_range.end, r.end)]
+  # End is trimmed.
+  return [document.Range(r.start, clip_range.start)]
Index: waveapi/document_test.py
===================================================================
--- waveapi/document_test.py	(revision 0)
+++ waveapi/document_test.py	(revision 0)
@@ -0,0 +1,171 @@
+#!/usr/bin/python2.4
+#
+# Copyright (C) 2009 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Unit tests for the document module."""
+
+
+__author__ = 'davidbyttow@google.com (David Byttow)'
+
+
+import unittest
+
+import document
+import util
+
+
+class TestRange(unittest.TestCase):
+  """Tests for the document.Range class."""
+
+  def testDefaults(self):
+    r = document.Range()
+    self.assertEquals(0, r.start)
+    self.assertEquals(1, r.end)
+
+  def testValidRanges(self):
+    r = document.Range(1, 2)
+    self.assertEquals(1, r.start)
+    self.assertEquals(2, r.end)
+
+  def testInvalidRanges(self):
+    self.assertRaises(ValueError, document.Range, 1, 0)
+    self.assertRaises(ValueError, document.Range, 0, -1)
+    self.assertRaises(ValueError, document.Range, 3, 1)
+
+  def testCollapsedRanges(self):
+    self.assertTrue(document.Range(0, 0).IsCollapsed())
+    self.assertTrue(document.Range(1, 1).IsCollapsed())
+
+
+class TestAnnotation(unittest.TestCase):
+  """Tests for the document.Annotation class."""
+
+  def testDefaults(self):
+    annotation = document.Annotation('key', 'value')
+    self.assertEquals(document.Range().start, annotation.range.start)
+    self.assertEquals(document.Range().end, annotation.range.end)
+
+  def testFields(self):
+    annotation = document.Annotation('key', 'value', document.Range(2, 3))
+    self.assertEquals('key', annotation.name)
+    self.assertEquals('value', annotation.value)
+    self.assertEquals(2, annotation.range.start)
+    self.assertEquals(3, annotation.range.end)
+
+
+class TestElement(unittest.TestCase):
+  """Tests for the document.Element class."""
+
+  def testProperties(self):
+    element = document.Element(document.ELEMENT_TYPE.GADGET,
+                               key='value')
+    self.assertEquals('value', element.key)
+
+  def testFormElement(self):
+    element = document.FormElement(document.ELEMENT_TYPE.INPUT, 'input', label='label')
+    self.assertEquals(document.ELEMENT_TYPE.INPUT, element.type)
+    self.assertEquals(element.value, '')
+    self.assertEquals(element.name, 'input')
+    self.assertEquals(element.label, 'label')
+
+  def testImage(self):
+    image = document.Image('http://test.com/image.png', width=100, height=100)
+    self.assertEquals(document.ELEMENT_TYPE.IMAGE, image.type)
+    self.assertEquals(image.url, 'http://test.com/image.png')
+    self.assertEquals(image.width, 100)
+    self.assertEquals(image.height, 100)
+
+  def testGadget(self):
+    gadget = document.Gadget('http://test.com/gadget.xml')
+    self.assertEquals(document.ELEMENT_TYPE.GADGET, gadget.type)
+    self.assertEquals(gadget.url, 'http://test.com/gadget.xml')
+    gadget.SubmitDelta({'foo': 'bar'})
+    self.assertEquals('joop', gadget.get('bar', 'joop'))
+    self.assertEquals('bar', gadget.get('foo', 'joop'))
+
+  def testSerialize(self):
+    image = document.Image('http://test.com/image.png', width=100, height=100)
+    s = util.Serialize(image)
+    k = s.keys()
+    k.sort()
+    # we should really only have three things to serialize
+    self.assertEquals(['java_class', 'properties', 'type'], k)
+    self.assertEquals(s['properties']['javaClass'], 'java.util.HashMap')
+    props = s['properties']['map']
+    self.assertEquals(len(props), 3)
+    self.assertEquals(props['url'], 'http://test.com/image.png')
+    self.assertEquals(props['width'], 100)
+    self.assertEquals(props['height'], 100)
+
+  def testGadgetElementFromJson(self):
+    url = 'http://www.foo.com/gadget.xml'
+    json = {
+      'type': document.ELEMENT_TYPE.GADGET,
+      'properties': {
+        'url': url,
+      }
+    }
+    gadget = document.ElementFromJson(json)
+    self.assertEquals(document.ELEMENT_TYPE.GADGET, gadget.type)
+    self.assertEquals(url, gadget.url)
+
+  def testImageElementFromJson(self):
+    url = 'http://www.foo.com/image.png'
+    width = '32'
+    height = '32'
+    attachment_id = '2'
+    caption = 'Test Image'
+    json = {
+      'type': document.ELEMENT_TYPE.IMAGE,
+      'properties': {
+        'url': url,
+        'width': width,
+        'height': height,
+        'attachmentId': attachment_id,
+        'caption': caption,
+      }
+    }
+    image = document.ElementFromJson(json)
+    self.assertEquals(document.ELEMENT_TYPE.IMAGE, image.type)
+    self.assertEquals(url, image.url)
+    self.assertEquals(width, image.width)
+    self.assertEquals(height, image.height)
+    self.assertEquals(attachment_id, image.attachment_id)
+    self.assertEquals(caption, image.caption)
+
+  def testFormElementFromJson(self):
+    name = 'button'
+    value = 'value'
+    default_value = 'foo'
+    label = 'test'
+    json = {
+      'type': document.ELEMENT_TYPE.LABEL,
+      'properties': {
+        'name': name,
+        'value': value,
+        'defaultValue': default_value,
+        'label': label,
+      }
+    }
+    element = document.ElementFromJson(json)
+    self.assertEquals(document.ELEMENT_TYPE.LABEL, element.type)
+    self.assertEquals(name, element.name)
+    self.assertEquals(value, element.value)
+    self.assertEquals(default_value, element.default_value)
+    self.assertEquals(label, element.label)
+
+
+if __name__ == '__main__':
+  unittest.main()
Index: waveapi/document.py
===================================================================
--- waveapi/document.py	(revision 0)
+++ waveapi/document.py	(revision 0)
@@ -0,0 +1,216 @@
+#!/usr/bin/python2.4
+#
+# Copyright (C) 2009 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Defines document-based classes.
+
+This module defines classes that are used to modify and describe documents and
+their operations.
+"""
+
+__author__ = 'davidbyttow@google.com (David Byttow)'
+
+import logging
+
+import util
+
+
+class Range(object):
+  """Represents a start and end range with integers.
+
+  Ranges map positions in the document. A range must have at least a length
+  of zero. If zero, the range is considered to be a single point (collapsed).
+  """
+
+  java_class = 'com.google.wave.api.Range'
+
+  def __init__(self, start=0, end=1):
+    """Initializes the range with a start and end position.
+
+    Args:
+      start: Start index of the range.
+      end: End index of the range.
+
+    Raises:
+      ValueError: Value error if the range is invalid (less than zero).
+    """
+    self.start = start
+    self.end = end
+    if self.end - self.start < 0:
+      raise ValueError('Range cannot be less than 0')
+
+  def __str__(self):
+    return 'Range(' + str(self.start) + ', ' + str(self.end) + ')'
+
+  def IsCollapsed(self):
+    """"Returns true if this represents a single point as opposed to a range."""
+    return self.end == self.start
+
+
+class Annotation(object):
+  """Represents an annotation on a document.
+
+  Annotations are key/value pairs over a range of content. Annotations
+  can be used to store data or to be interpreted by a client when displaying
+  the data.
+  """
+
+  java_class = 'com.google.wave.api.Annotation'
+
+  def __init__(self, name, value, r=None):
+    """Initializes this annotation with a name and value pair and a range.
+
+    Args:
+      name: Key name for this annotation.
+      value: Value of this annotation.
+      r: Range that this annotation is valid over.
+    """
+    self.name = name
+    self.value = value
+    self.range = r or Range()
+
+
+ELEMENT_TYPE = util.StringEnum('INLINE_BLIP', 'INPUT', 'CHECK', 'LABEL', 'BUTTON',
+    'RADIO_BUTTON', 'RADIO_BUTTON_GROUP','PASSWORD', 'TEXTAREA',
+    'GADGET', 'IMAGE')
+
+
+class Element(object):
+  """Elements are non-text content within a document.
+
+  These are generally abstracted from the Robot. Although a Robot can query the
+  properties of an element it can only interact with the specific types that
+  the element represents.
+
+  Properties of elements are both accesible directly (image.url) and through
+  the properties dictionary (image.properties['url']). In general Element
+  should not be instantiated by robots, but rather rely on the derrived classes.
+  """
+
+  java_class = 'com.google.wave.api.Element'
+
+  def __init__(self, element_type, **properties):
+    """Initializes self with the specified type and any properties.
+
+    Args:
+      element_type: string typed member of ELEMENT_TYPE
+      properties: either a dictionary of initial properties, or a dictionary
+          with just one member properties that is itself a dictionary of
+          properties. This allows us to both use
+          e = Element(atype, prop1=val1, prop2=prop2...)
+          and
+          e = Element(atype, properties={prop1:val1, prop2:prop2..})
+    """
+    if len(properties) == 1 and 'properties' in properties:
+      properties = properties['properties']
+    self.type = element_type
+    for key, val in properties.items():
+      setattr(self, key, val)
+
+  def Serialize(self):
+    """Custom serializer for Elements.
+
+    Element need their non standard attributes returned in a dict named
+    properties.
+    """
+    props = {}
+    data = {}
+    for attr in dir(self):
+      if attr.startswith('_'):
+        continue
+      val = getattr(self, attr)
+      if val is None or callable(val):
+        continue
+      val = util.Serialize(val)
+      if attr == 'type' or attr == 'java_class':
+        data[attr] = val
+      else:
+        props[attr] = val
+    data['properties'] = util.Serialize(props)
+    return data
+
+
+class FormElement(Element):
+
+  java_class = 'com.google.wave.api.FormElement'
+
+  def __init__(self, element_type, name, value='', default_value='', label=''):
+    super(FormElement, self).__init__(element_type,
+        name=name, value=value, default_value=default_value, label=label)
+
+
+class Gadget(Element):
+  """Represents a Gadget element within the content of a document."""
+
+  java_class = 'com.google.wave.api.Gadget'
+
+  def __init__(self, url='', props=None):
+    if props is None:
+      props = {}
+    props['url'] = url
+    logging.info('CONSTRUCTING gadget with:' + str(props))
+    super(Gadget, self).__init__(ELEMENT_TYPE.GADGET, properties=props)
+
+  def get(self, key, default=None):
+    """Standard get interface for gadgets"""
+    if hasattr(self, key):
+      return getattr(self, key)
+    else:
+      return default
+
+  def SubmitDelta(self, delta):
+    """Submits the passed delta to the gadget.
+
+    This does not send the delta to the server, but only modifies the
+    local state. The send the delto the server, go through the
+    document.GadgetSubmitDelta interface.
+    """
+    for k, v in delta.items():
+      setattr(self, k, v)
+
+
+class Image(Element):
+  """Represents an Image element within the context of a document."""
+
+  java_class = 'com.google.wave.api.Image'
+
+  def __init__(self, url='', width=None, height=None,
+      attachment_id=None, caption=None):
+    super(Image, self).__init__(ELEMENT_TYPE.IMAGE, url=url, width=width,
+        height=height, attachment_id=attachment_id, caption=caption)
+
+
+def ElementFromJson(json):
+  """Construct one of the type of elements given a json object."""
+  etype = json['type']
+  logging.info('constructing: ' + str(json))
+  props = json['properties'].copy()
+
+  if etype == ELEMENT_TYPE.GADGET:
+    url = props['url']
+    del props['url']
+    return Gadget(url=url, props=props)
+  elif etype == ELEMENT_TYPE.IMAGE:
+    return Image(url=props.get('url', ''),
+                 width=props.get('width'),
+                 height=props.get('height'),
+                 attachment_id=props.get('attachmentId'),
+                 caption=props.get('caption'))
+
+  return FormElement(element_type=etype,
+                     name=props.get('name', ''),
+                     value=props.get('value', ''),
+                     default_value=props.get('defaultValue', ''),
+                     label=props.get('label', ''))
Index: waveapi/run_unit_tests.py
===================================================================
--- waveapi/run_unit_tests.py	(revision 0)
+++ waveapi/run_unit_tests.py	(revision 0)
@@ -0,0 +1,42 @@
+#!/usr/bin/python2.4
+#
+# Copyright (C) 2009 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Script to run all unit tests in this package."""
+
+
+import document_test
+import model_test
+import module_test_runner
+import ops_test
+import robot_abstract_test
+import util_test
+
+
+def RunUnitTests():
+  """Runs all registered unit tests."""
+  test_runner = module_test_runner.ModuleTestRunner()
+  test_runner.modules = [
+      document_test,
+      model_test,
+      ops_test,
+      robot_abstract_test,
+      util_test,
+  ]
+  test_runner.RunAllTests()
+
+
+if __name__ == "__main__":
+  RunUnitTests()
Index: waveapi/robot.py
===================================================================
--- waveapi/robot.py	(revision 0)
+++ waveapi/robot.py	(revision 0)
@@ -0,0 +1,149 @@
+#!/usr/bin/python2.4
+#
+# Copyright (C) 2009 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Defines the App Engine-specific robot class and associated handlers."""
+
+__author__ = 'davidbyttow@google.com (David Byttow)'
+
+
+import logging
+import traceback
+
+from google.appengine.ext import webapp
+from google.appengine.ext.webapp.util import run_wsgi_app
+
+import robot_abstract
+
+
+class RobotHelloWorldHandler(webapp.RequestHandler):
+  def __init__(self, robot):
+    self._robot = robot
+
+  def get(self):
+    """Handles HTTP GET request."""
+    self.response.headers['Content-Type'] = 'text/html'
+    self.response.out.write("hello, world")
+
+class RobotCapabilitiesHandler(webapp.RequestHandler):
+  """Handler for serving capabilities.xml given a robot."""
+
+  def __init__(self, robot):
+    """Initializes this handler with a specific robot."""
+    self._robot = robot
+
+  def get(self):
+    """Handles HTTP GET request."""
+    xml = self._robot.GetCapabilitiesXml()
+    self.response.headers['Content-Type'] = 'text/xml'
+    self.response.out.write(xml)
+
+
+class RobotProfileHandler(webapp.RequestHandler):
+  """Handler for serving the robot's profile information."""
+
+  def __init__(self, robot):
+    """Initializes this handler with a specific robot."""
+    self._robot = robot
+
+  def get(self):
+    """Handles HTTP GET request."""
+    self.response.headers['Content-Type'] = 'application/json'
+    self.response.out.write(self._robot.GetProfileJson())
+
+
+class RobotEventHandler(webapp.RequestHandler):
+  """Handler for the dispatching of events to various handlers to a robot.
+
+  This handler only responds to post events with a JSON post body. Its primary
+  task is to separate out the context data from the events in the post body
+  and dispatch all events in order. Once all events have been dispatched
+  it serializes the context data and its associated operations as a response.
+  """
+
+  def __init__(self, robot):
+    """Initializes self with a specific robot."""
+    self._robot = robot
+
+  def get(self):
+    """Handles the get event for debugging. Ops usually too long."""
+    ops = self.request.get('ops')
+    logging.info('get: ' + ops)
+    if ops:
+      self.request.body = ops
+      self.post()
+      self.response.headers['Content-Type'] = 'text/html'
+
+  def post(self):
+    """Handles HTTP POST requests."""
+    json_body = self.request.body
+    if not json_body:
+      # TODO(davidbyttow): Log error?
+      return
+
+    json_body = unicode(json_body, 'utf8')
+    logging.info('Incoming: ' + json_body)
+
+    context, events = robot_abstract.ParseJSONBody(json_body)
+    for event in events:
+      try:
+        self._robot.HandleEvent(event, context)
+      except:
+        logging.error(traceback.format_exc())
+
+    json_response = robot_abstract.SerializeContext(context,
+                                                    self._robot.version)
+    logging.info('Outgoing: ' + json_response)
+
+    # Build the response.
+    self.response.headers['Content-Type'] = 'application/json; charset=utf-8'
+    self.response.out.write(json_response.encode('utf-8'))
+
+
+class Robot(robot_abstract.Robot):
+  """Adds an AppEngine setup method to the base robot class.
+
+  A robot is typically setup in the following steps:
+    1. Instantiate and define robot.
+    2. Register various handlers that it is interested in.
+    3. Call Run, which will setup the handlers for the app.
+
+  For example:
+    robot = Robot('Terminator',
+                  image_url='http://www.sky.net/models/t800.png',
+                  profile_url='http://www.sky.net/models/t800.html')
+    robot.RegisterHandler(WAVELET_PARTICIPANTS_CHANGED, KillParticipant)
+    robot.Run()
+  """
+
+  def Run(self, debug=False):
+    """Sets up the webapp handlers for this robot and starts listening.
+
+    Args:
+      debug: Optional variable that defaults to False and is passed through
+          to the webapp application to determine if it should show debug info.
+    """
+    debug=True
+    # App Engine expects to construct a class with no arguments, so we
+    # pass a lambda that constructs the appropriate handler with
+    # arguments from the enclosing scope.
+    app = webapp.WSGIApplication([
+        ('/hello.html', lambda: RobotHelloWorldHandler(self)),
+        ('/_wave/capabilities.xml', lambda: RobotCapabilitiesHandler(self)),
+        ('/_wave/robot/profile', lambda: RobotProfileHandler(self)),
+        ('/_wave/robot/jsonrpc', lambda: RobotEventHandler(self)),
+    ], debug=debug)
+    run_wsgi_app(app)
+
Index: play.py
===================================================================
--- play.py	(revision 3)
+++ play.py	(working copy)
@@ -2,115 +2,13 @@
 #
 # Json interface to the adventure API.
 
-from datetime import datetime
-
-import pickle
-import sys
 import wsgiref.handlers
 
-from adv5.python.stepper import Stepper
 from google.appengine.api import users
-from google.appengine.ext import db
 from google.appengine.ext import webapp
 
-class LogEntry:
-  def __init__(self, input, output):
-    self.input = input
-    self.output = output
-    
-  def json(self):
-    output_lines = self.output.replace("\"", "\\\"");
-    output_lines = ["\"" + x + "\"" for x in output_lines.split("\n")]
-    input = ""
-    if self.input:
-      input = ">&nbsp;" + self.input.replace("\"", "\\\"");
-    return "\"" + input + "\", " + ", ".join(output_lines)
+from state import *
 
-
-MAX_LOG_SIZE=100
-
-class State(db.Model):
-  username = db.StringProperty()   # Name of the user
-  saved_state = db.BlobProperty()  # Saved state from stepper
-  log = db.BlobProperty()          # pickled Array of LogEntry
-  save_name = db.StringProperty()  # Name under which the game is saved
-  # Location of the explorer in the cave
-  location = db.StringProperty(default="nowhere")
-  # Date and time of last move
-  last_played = db.DateTimeProperty(auto_now=True, default=datetime.now())
-  score = db.IntegerProperty(default=0)
-
-  def newForUser(username):
-    state = State()
-    state.username = username
-    state.saved_state = None
-    state.stppr = Stepper(None)
-    state.log_entries = [LogEntry("", state.stppr.get_output())]
-    state.save_name = 'save'
-    return state
-  newForUser = staticmethod(newForUser)
-
-  def loadForUser(username, save_name):
-    query = State.gql("WHERE username = :1", username)
-    states = query.fetch(2)
-    found_state = None
-    for state in states:
-      if state.save_name == save_name:
-        state.stppr = Stepper(state.saved_state)
-        state.log_entries = pickle.loads(state.log)
-        return state
-    return None
-  loadForUser = staticmethod(loadForUser)
-
-  def saveForUser(self):
-    self.location = self.stppr.get_location()
-    self.saved_state = self.stppr.get_saved_state()
-    if len(self.log_entries) > MAX_LOG_SIZE:
-      self.log_entries = self.log_entries[-MAX_LOG_SIZE:]
-    self.log = pickle.dumps(self.log_entries)
-    self.put()
-
-  def copyFrom(self, other_state):
-    self.username = other_state.username
-    self.saved_state = other_state.saved_state
-    self.log = other_state.log
-
-  def saveToBackup(self):
-    backup_state = State.loadForUser(self.username, 'backup')
-    if not backup_state:
-      backup_state = State()
-    backup_state.copyFrom(self)
-    backup_state.save_name = 'backup'
-    backup_state.put()
-
-  def restoreFromBackup(self):
-    backup_state = State.loadForUser(self.username, 'backup')
-    if not backup_state:
-      return False
-    self.copyFrom(backup_state)
-    self.put()
-    self.log_entries = pickle.loads(self.log)
-
-  def deleteForUser(username):
-    query = State.gql("WHERE username = :1", username)
-    states = query.fetch(2)
-    for state in states:
-      if state.save_name == 'save':
-        state.delete()
-  deleteForUser = staticmethod(deleteForUser)
-
-  def play(self, input):
-    self.stppr.step(input)
-    self.last_log_entry = LogEntry(input, self.stppr.get_output())
-    self.log_entries.append(self.last_log_entry)
-
-  def lastLogEntry(self):
-    return self.last_log_entry
-
-  def allLogEntries(self):
-    return self.log_entries
-
-
 class JsonStart(webapp.RequestHandler):
   def get(self):
     self.response.headers['Content-Type'] = 'text/plain'
Index: state.py
===================================================================
--- state.py	(revision 0)
+++ state.py	(revision 0)
@@ -0,0 +1,111 @@
+
+from datetime import datetime
+
+import pickle
+import sys
+import wsgiref.handlers
+
+from adv5.python.stepper import Stepper
+from google.appengine.api import users
+from google.appengine.ext import db
+from google.appengine.ext import webapp
+
+class LogEntry:
+  def __init__(self, input, output):
+    self.input = input
+    self.output = output
+    
+  def json(self):
+    output_lines = self.output.replace("\"", "\\\"");
+    output_lines = ["\"" + x + "\"" for x in output_lines.split("\n")]
+    input = ""
+    if self.input:
+      input = ">&nbsp;" + self.input.replace("\"", "\\\"");
+    return "\"" + input + "\", " + ", ".join(output_lines)
+
+
+MAX_LOG_SIZE=100
+
+class State(db.Model):
+  username = db.StringProperty()   # Name of the user
+  saved_state = db.BlobProperty()  # Saved state from stepper
+  log = db.BlobProperty()          # pickled Array of LogEntry
+  save_name = db.StringProperty()  # Name under which the game is saved
+  # Location of the explorer in the cave
+  location = db.StringProperty(default="nowhere")
+  # Date and time of last move
+  last_played = db.DateTimeProperty(auto_now=True, default=datetime.now())
+  score = db.IntegerProperty(default=0)
+
+  def newForUser(username):
+    state = State()
+    state.username = username
+    state.saved_state = None
+    state.stppr = Stepper(None)
+    state.log_entries = [LogEntry("", state.stppr.get_output())]
+    state.save_name = 'save'
+    return state
+  newForUser = staticmethod(newForUser)
+
+  def loadForUser(username, save_name):
+    query = State.gql("WHERE username = :1", username)
+    states = query.fetch(2)
+    found_state = None
+    for state in states:
+      if state.save_name == save_name:
+        state.stppr = Stepper(state.saved_state)
+        state.log_entries = pickle.loads(state.log)
+        return state
+    return None
+  loadForUser = staticmethod(loadForUser)
+
+  def saveForUser(self):
+    self.location = self.stppr.get_location()
+    self.saved_state = self.stppr.get_saved_state()
+    if len(self.log_entries) > MAX_LOG_SIZE:
+      self.log_entries = self.log_entries[-MAX_LOG_SIZE:]
+    self.log = pickle.dumps(self.log_entries)
+    self.put()
+
+  def copyFrom(self, other_state):
+    self.username = other_state.username
+    self.saved_state = other_state.saved_state
+    self.log = other_state.log
+
+  def saveToBackup(self):
+    backup_state = State.loadForUser(self.username, 'backup')
+    if not backup_state:
+      backup_state = State()
+    backup_state.copyFrom(self)
+    backup_state.save_name = 'backup'
+    backup_state.put()
+
+  def restoreFromBackup(self):
+    backup_state = State.loadForUser(self.username, 'backup')
+    if not backup_state:
+      return False
+    self.copyFrom(backup_state)
+    self.put()
+    self.log_entries = pickle.loads(self.log)
+
+  def deleteForUser(username):
+    query = State.gql("WHERE username = :1", username)
+    states = query.fetch(2)
+    for state in states:
+      if state.save_name == 'save':
+        state.delete()
+  deleteForUser = staticmethod(deleteForUser)
+
+  def play(self, input):
+    self.stppr.step(input)
+    self.last_log_entry = LogEntry(input, self.stppr.get_output())
+    self.log_entries.append(self.last_log_entry)
+
+  def lastLogEntry(self):
+    return self.last_log_entry
+
+  def allLogEntries(self):
+    return self.log_entries
+
+
+
