xbmcbackup/resources/lib/dropbox/datastore.py
2017-01-05 10:37:41 -06:00

2724 lines
98 KiB
Python

"""
Datastores are an easy way to keep an app's per-user data -- like
settings, bookmarks, or game state -- in sync across multiple devices
and operating systems. Datastores are simple embedded databases,
which are synced to Dropbox.
This reference details the full set of classes needed when working
with datastores. You can also read the Datastore API tutorial for a
detailed example of how to use them.
Unlike the client-side datastore APIs (for e.g. iOS, Android and
JavaScript), the Python datastore API does not implement automatic
conflict resolution. Instead, if commit() fails, you must start over.
You can use the transaction() method for this, which allows you to
retry the transaction several times before giving up.
The Python API is not thread-safe. If you want to use the same
Datastore object from multiple threads you should manage your own
locking. The exception is the :class:`DatastoreManager` class; all
its methods are thread-safe. Also, static methods are thread-safe.
"""
__all__ = ['DatastoreError', 'DatastoreNotFoundError', 'DatastoreConflictError',
'DatastorePermissionError',
'DatastoreManager', 'DatastoreInfo', 'Datastore', 'Table', 'Record',
'Date', 'Bytes', 'List',
]
import array
import base64
import collections
import datetime
import hashlib
import json
import math
import re
import sys
import time
import uuid
# The port to Python 3 is not yet finished.
PY3 = sys.version_info[0] == 3
# Polyfill a few things for Bytes().
if PY3: # pragma: no cover
buffer = memoryview
basestring = str
long = int
else:
bytearray = bytes
# Internal values for roles, used by the HTTP protocol.
ROLE_OWNER = 3000
ROLE_EDITOR = 2000
ROLE_VIEWER = 1000
ROLE_NONE = 0
def _dbase64_encode(b):
"""Internal helper to encode bytes using our base64 variant.
This is like urlsafe base64 encode but strips the trailing '='
padding. Also, it returns a string, not a bytes object.
"""
bb = base64.urlsafe_b64encode(b)
ss = str(bb.decode('ascii'))
s = ss.rstrip('=') # Remove padding.
return s
def _dbase64_decode(s):
"""Internal helper to decode bytes using our base64 variant.
This is the inverse of _dbase64_encode(), taking a string,
returning bytes.
"""
ss = s + '=' * (4 - len(s) % 4) # Add padding back.
bb = ss.encode('ascii')
b = base64.urlsafe_b64decode(bb)
return b
def _generate_shareable_dsid():
"""Internal helper to generate a random shareable (dsid, key) pair."""
# Start with 32 random bytes so the encoded key will be at least 32 characters in length.
bkey = uuid.uuid4().bytes + uuid.uuid4().bytes
key = _dbase64_encode(bkey)
# Use the sha256 of the *encoded* key.
keyhash = hashlib.sha256(key.encode('ascii')).digest()
dsid = '.' + _dbase64_encode(keyhash)
return dsid, key
class DatastoreError(Exception):
"""Exception raised for datastore-specific error conditions.
This is the base class for more specific exception classes.
"""
_resp__doc__ = """
The JSON dict that was returned by the server.
"""
def __init__(self, message, resp=None):
super(DatastoreError, self).__init__(message)
self.resp = resp
class DatastoreNotFoundError(DatastoreError):
"""Exception raised when attempting to open a non-existent datastore.
Derives from :class:`DatastoreError`.
"""
class DatastoreConflictError(DatastoreError):
"""Exception raised when the server reports a conflict.
Derives from :class:`DatastoreError`.
"""
class DatastorePermissionError(DatastoreError):
"""Exception raised when the server denies access.
Derives from :class:`DatastoreError`.
"""
class _DatastoreOperations(object):
"""Low-level datastore operations.
The methods here map 1:1 to endpoints in the HTTP API.
Also, the parameter names exactly match the HTTP protocol, and the
return value is the JSON dict returned by the request.
The exception is create_datastore(), which takes no parameters and
adds the generated datastore ID to the JSON dict.
Exceptions that may be raised:
- :class:`dropbox.rest.ErrorResponse` if the server returned an
error
- :class:`dropbox.rest.HTTPSocketError` if there was a
network problem
- :class:`DatastoreNotFoundError` if a specified datastore
does not exist
- :class:`DatastoreConflictError` if the server reports a write
conflict
- :class:`DatastoreError` if an unanticipated JSON response is
received
"""
def __init__(self, client):
self._client = client
def _check_access_errors(self, resp):
if 'access_denied' in resp:
raise DatastorePermissionError(resp['access_denied'], resp)
if 'notfound' in resp:
raise DatastoreNotFoundError(resp['notfound'], resp)
return resp
def _check_rev(self, resp):
resp = self._check_access_errors(resp)
if 'rev' not in resp:
raise DatastoreError('rev missing from response: %r' % (resp,), resp)
return resp
def _check_handle(self, resp):
resp = self._check_rev(resp)
if 'handle' not in resp:
raise DatastoreError('handle missing from response: %r' % (resp,), resp)
return resp
def _check_ok(self, resp):
resp = self._check_access_errors(resp)
if 'ok' not in resp:
raise DatastoreError('ok missing from response: %r' % (resp,), resp)
return resp
def _check_conflict(self, resp):
if 'conflict' in resp:
raise DatastoreConflictError(resp['conflict'], resp)
resp = self._check_rev(resp)
return resp
def _check_list_datastores(self, resp):
if 'datastores' not in resp or 'token' not in resp:
raise DatastoreError('token or datastores missing from response: %r' % (resp,),
resp)
return resp
def _check_get_snapshot(self, resp):
resp = self._check_rev(resp)
if 'rows' not in resp:
raise DatastoreError('rows missing from response: %r' % (resp,), resp)
return resp
def _check_await(self, resp):
# Nothing to do here -- it may or may not have keys 'list_datastores' and 'get_deltas'.
return resp
def _check_get_deltas(self, resp):
resp = self._check_access_errors(resp)
# If there are no new deltas the response is empty.
if resp and 'deltas' not in resp:
raise DatastoreError('deltas missing from response: %r' % (resp,), resp)
return resp
def get_datastore(self, dsid):
url, params, headers = self._client.request('/datastores/get_datastore',
{'dsid': dsid}, method='GET')
resp = self._client.rest_client.GET(url, headers)
return self._check_handle(resp)
def get_or_create_datastore(self, dsid):
url, params, headers = self._client.request('/datastores/get_or_create_datastore',
{'dsid': dsid})
resp = self._client.rest_client.POST(url, params, headers)
return self._check_handle(resp)
def create_datastore(self):
# NOTE: This generates a dsid locally and adds it to the returned response.
dsid, key = _generate_shareable_dsid()
url, params, headers = self._client.request('/datastores/create_datastore',
{'dsid': dsid, 'key': key})
resp = self._client.rest_client.POST(url, params, headers)
resp = self._check_handle(resp)
if 'dsid' not in resp:
resp['dsid'] = dsid
return resp
def delete_datastore(self, handle):
url, params, headers = self._client.request('/datastores/delete_datastore',
{'handle': handle})
resp = self._client.rest_client.POST(url, params, headers)
return self._check_ok(resp)
def list_datastores(self):
url, params, headers = self._client.request('/datastores/list_datastores', method='GET')
resp = self._client.rest_client.GET(url, headers)
return self._check_list_datastores(resp)
def get_snapshot(self, handle):
url, params, headers = self._client.request('/datastores/get_snapshot',
{'handle': handle}, method='GET')
resp = self._client.rest_client.GET(url, headers)
return self._check_get_snapshot(resp)
def get_deltas(self, handle, rev):
url, params, headers = self._client.request('/datastores/get_deltas',
{'handle': handle, 'rev': rev},
method='GET')
resp = self._client.rest_client.GET(url, headers)
return self._check_get_deltas(resp)
def put_delta(self, handle, rev, changes, nonce=None):
args = {'handle': handle,
'rev': str(rev),
'changes': json.dumps(changes),
}
if nonce:
args['nonce'] = nonce
url, params, headers = self._client.request('/datastores/put_delta', args)
resp = self._client.rest_client.POST(url, params, headers)
return self._check_conflict(resp)
def await(self, token=None, cursors=None):
params = {}
if token:
params['list_datastores'] = json.dumps({'token': token})
if cursors:
params['get_deltas'] = json.dumps({'cursors': cursors})
url, params, headers = self._client.request('/datastores/await', params, method='POST')
resp = self._client.rest_client.POST(url, params, headers)
return self._check_await(resp)
def get_client(self):
return self._client
class DatastoreManager(object):
"""A manager for datastores.
In order to work with datastores you must first create an instance
of this class, passing its constructor a
:class:`dropbox.client.DropboxClient` instance.
The methods here let you open or create datastores and retrieve
the list of datastores.
This class has no state except for a reference to the
:class:`dropbox.client.DropboxClient`, which itself is thread-safe;
hence, all methods of this class are thread-safe.
"""
DEFAULT_DATASTORE_ID = 'default' #: The default datastore ID.
_DEFAULT_DATASTORE_ID__doc__ = """
The default datastore ID used by :meth:`open_default_datastore()`.
"""
def __init__(self, client):
"""Construct a ``DatastoreManager`` using a :class:`dropbox.client.DropboxClient`."""
self._dsops = _DatastoreOperations(client)
def __repr__(self):
return '%s(%r)' % (self.__class__.__name__, self._dsops._client if self._dsops else None)
def close(self):
# This doesn't do anything to the _DatastoreOperations object.
self._dsops = None
def get_client(self):
"""Return the :class:`dropbox.client.DropboxClient` object used."""
return self._dsops.get_client()
def open_default_datastore(self):
"""Open the default datastore for this account, creating it if needed.
This is a shorthand for :meth:`open_or_create_datastore`\(:const:`DEFAULT_DATASTORE_ID`).
Returns
A :class:`Datastore` instance.
"""
return self.open_or_create_datastore(self.DEFAULT_DATASTORE_ID)
def open_datastore(self, id):
"""Open an existing datastore given its ID (a string).
Returns
A :class:`Datastore` instance.
"""
if not Datastore.is_valid_id(id):
raise ValueError('Invalid datastore ID %r' % (id,))
resp = self._dsops.get_datastore(id)
return self._datastore_from_response(resp, id)
def open_or_create_datastore(self, id):
"""Open a private datastore, creating it if it does not yet exist.
The ID must not start with a dot.
Returns
A :class:`Datastore` instance.
"""
if not Datastore.is_valid_id(id):
raise ValueError('Invalid datastore ID %r' % (id,))
if id.startswith('.'):
raise ValueError('Datastore ID must not start with dot')
resp = self._dsops.get_or_create_datastore(id)
return self._datastore_from_response(resp, id)
def create_datastore(self):
"""Create a new datastore with a randomly assigned ID.
The assigned ID will start with a dot.
Returns
A :class:`Datastore` instance.
"""
resp = self._dsops.create_datastore()
return self._datastore_from_response(resp, resp['dsid'])
def open_raw_datastore(self, id, handle, role=None):
"""Create a new :class:`Datastore` object without going to the server.
You can use this to save a server roundtrip when opening a
datastore given a :class:`DatastoreInfo` object returned by
:meth:`list_datastores()`::
def open_from_info(mgr, info):
ds = mgr.open_raw_datastore(info.id, info.handle, info.role)
ds.load_snapshot()
return ds
"""
if role is None:
role = Datastore.OWNER # Our best guess.
else:
if not isinstance(role, basestring):
raise TypeError('Role must be a string: %r' % (role,))
if role not in (Datastore.OWNER, Datastore.EDITOR, Datastore.VIEWER):
raise ValueError('invalid role (%r)' % (role,))
if not id.startswith('.') and role != Datastore.OWNER:
raise ValueError('private datastore role must be owner: %r' % (role,))
return Datastore(self, id=id, handle=handle, role=role)
def delete_datastore(self, id):
"""Delete a datastore given its ID."""
resp = self._dsops.get_datastore(id)
self._dsops.delete_datastore(resp['handle'])
def _datastore_from_response(self, resp, id):
handle = resp['handle']
rev = resp['rev']
role = _make_role(resp.get('role'))
ds = Datastore(self, id=id, handle=handle, role=role)
if rev > 0:
ds.load_snapshot()
return ds
def list_datastores(self):
"""List the existing datastores for this account.
Returns
A list of :class:`DatastoreInfo` objects.
"""
resp = self._dsops.list_datastores()
return [_make_dsinfo(item) for item in resp['datastores']]
def await(self, token=None, datastores=None):
"""Wait until certain changes occur.
This methods implements a flexible and efficient long-polling
mechanism that can be used to be notified of changes to
specific datastores and/or to the list of datastores itself
(for the current account).
Parameters
token
An optional token that represents a hash of the list of
datastores, computed by the server. If this parameter is
present and non-empty, ``await()`` will return when the
list of datastores has changed in a way that would cause a
different token to be computed, such as when a datastore
is created or deleted. The token should be obtained from
the previous ``await()`` call; as a special case, the
value ``'.'`` forces the call to return immediately with a
fresh token (as does any outdated token).
datastores
An optional list of :class:`Datastore` instances or dict
mapping such instances to revision numbers. The instances
represents currently open datastores for which you are
interested in receiving updates. If this parameter is a
list of instances, the revision to compare is retrieved
from each instance using :meth:`Datastore.get_rev()`. If
this parameter is present and non-empty, ``await()`` will
return whenever a new revision is available for any of
those datastores.
The call also returns after a certain amount of time passes
without activity. The timeout is controlled by the server; it
is currently approximately one minute.
Returns
A ``(token, dsinfos, deltamap)`` tuple. The items are as follows:
token
A new token, or the value of the ``token`` parameter if
there are no changes to the list of datastores. You
should pass this to the next ``await()`` call.
dsinfo
The full list of :class:`DatastoreInfo` objects (as
returned by :meth:`list_datastores()`) if there is a new
token, otherwise ``None``.
deltamap
Either a mapping indicating which of the given datastores
were changed, or ``None`` if there are no changes to
datastores to report. If it is a mapping, each key is a
:meth:`Datastore`, and the corresponding value is either a
non-empty list of deltas, or ``None`` if that datastore is
deleted or is otherwise invalid. Datastores that were
not changed (and are still valid) will not be present.
Unlike :meth:`Datastore.load_deltas()` and
:meth:`Datastore.await_deltas()`, ``await()`` does not apply
the deltas returned in ``deltamap`` to the respective
datastores; that is the caller's responsibility. For
example::
for ds, deltas in deltamap.items():
if deltas is not None:
ds.apply_deltas(deltas)
else:
# ds has been deleted
"""
cursors = None
if datastores is not None:
cursors = {}
if isinstance(datastores, collections.Mapping):
for ds, rev in datastores.items():
cursors[ds._handle] = rev
else:
for ds in datastores:
cursors[ds._handle] = ds._rev
resp = self._dsops.await(token=token, cursors=cursors)
dsinfos = None
deltamap = None
if 'list_datastores' in resp:
subresp = resp['list_datastores']
token = subresp['token']
dsinfos = [_make_dsinfo(item) for item in subresp['datastores']]
if 'get_deltas' in resp:
subresp = resp['get_deltas']
rawmap = subresp['deltas']
deltamap = {}
for ds in datastores:
if ds._handle in rawmap:
value = rawmap[ds._handle]
# If this assert triggers, the server sent us bogus data.
assert isinstance(value, dict), repr(value)
if 'deltas' in value:
deltamap[ds] = value['deltas']
elif 'notfound' in value:
# This datastore is invalid.
deltamap[ds] = None
# Else the server sent us a response we don't
# understand; ignore it.
return token, dsinfos, deltamap
@staticmethod
def make_cursor_map(datastores, deltamap):
"""Utility to construct a ``datastores`` argument for :meth:`await()`.
Parameters
datastores
A list of :class:`Datastore` objects.
deltamap
An data structure as returned by :meth:`await()` in its
``deltamap`` return value. This may be None or it may be
a dict mapping :class:`Datastore` objects to values that
are either lists of deltas or ``None``.
Returns
A dict mapping :class:`Datastore` objects to revisions,
suitable to pass as the ``datastores`` parameter to
:meth:`await()`. This will normally just map the datastores
from the ``datastores`` parameter to their current revision;
however, datastores that are deleted or invalid according to
``deltamap`` are excluded from the dict, and for datastores
that have one or more deltas in ``deltamap``, the revision
is set to one higher than the revision of the last delta.
Using this function will reduce redundant server roundtrips in
multi-threaded apps that call :meth:`await()` in a background
thread and then pass the received deltas to the main thread
through some kind of queue.
"""
if deltamap is None:
deltamap = {}
cursor_map = {}
for ds in datastores:
if ds not in deltamap:
cursor_map[ds] = ds._rev
else:
deltas = deltamap[ds]
# If this is None, the datastore is known to be
# invalid, and we do not put it in the map.
if deltas is not None:
assert deltas, 'Unexpected empty list of deltas in deltamap'
cursor_map[ds] = deltas[-1]['rev'] + 1
return cursor_map
DatastoreInfo = collections.namedtuple('DatastoreInfo', 'id handle rev title mtime effective_role')
# Dummy class for docstrings, see doco.py.
class _DatastoreInfo__doc__(object):
"""A read-only record of information about a :class:`Datastore`.
Instances of this class are returned by
:meth:`DatastoreManager.list_datastores()`.
"""
_id__doc__ = """The datastore ID (a string)."""
_handle__doc__ = """The datastore handle (a string)."""
_rev__doc__ = """The datastore revision (an integer >= 0)."""
_title__doc__ = """The datastore title (string or None)."""
_mtime__doc__ = """The time of last modification (:class:`Date` or None)."""
_effective_role__doc__ = """
The current user's effective role (:const:`Datastore.OWNER`,
:const:`Datastore.EDITOR` or :const:`Datastore.VIEWER`).
"""
def _make_dsinfo(item):
title = mtime = None
info = item.get('info')
if info:
title = info.get('title')
raw_mtime = info.get('mtime')
if raw_mtime is not None:
mtime = Date.from_json(raw_mtime)
dsid = item['dsid']
role = _make_role(item.get('role'))
assert role is not None, repr(role)
return DatastoreInfo(id=dsid, handle=item['handle'], rev=item['rev'],
title=title, mtime=mtime, effective_role=role)
def _make_role(irole):
if irole is None:
return Datastore.OWNER # Backward compatible default.
if not isinstance(irole, (int, long)):
raise TypeError('irole must be an integer: %r', irole)
# Unknown roles are truncated down to the nearest known role.
if irole >= ROLE_OWNER:
return Datastore.OWNER
if irole >= ROLE_EDITOR:
return Datastore.EDITOR
if irole >= ROLE_VIEWER:
return Datastore.VIEWER
return Datastore.NONE
def _parse_role(role, owner_ok=False):
if role == Datastore.OWNER and owner_ok:
return ROLE_OWNER
if role == Datastore.EDITOR:
return ROLE_EDITOR
if role == Datastore.VIEWER:
return ROLE_VIEWER
if role == Datastore.NONE:
return ROLE_NONE
if not isinstance(role, basestring):
raise TypeError('invalid role type: %r' % (role,))
raise ValueError('invalid role: %r' % (role,))
_DBASE64_VALID_CHARS = '-_A-Za-z0-9'
_VALID_PRIVATE_DSID_RE = r'[a-z0-9_-]([a-z0-9._-]{0,62}[a-z0-9_-])?'
_VALID_SHAREABLE_DSID_RE = r'\.[%s]{1,63}' % _DBASE64_VALID_CHARS
_VALID_DSID_RE = r'\A(%s|%s)\Z' % (_VALID_PRIVATE_DSID_RE, _VALID_SHAREABLE_DSID_RE)
class Principal(object):
"""A principal used in the access control list (ACL).
Currently the only valid principals are the predefined objects
:const:`Datastore.TEAM` and :const:`Datastore.PUBLIC`.
"""
def __init__(self, key):
assert self.__class__ is not Principal, 'Cannot directly instantiate Principal'
self._key = key
@property
def key(self):
return self._key
def __hash__(self):
return hash(self._key)
def __eq__(self, other):
if not isinstance(other, Principal):
return NotImplemented
return self._key == other._key
def __ne__(self, other):
if not isinstance(other, Principal):
return NotImplemented
return self._key != other._key
class User(Principal):
"""A user is identified by a numeric user ID (uid).
The uid may be either an integer or a string of digits.
"""
def __init__(self, uid):
if not isinstance(uid, (int, long, basestring)):
raise TypeError('Invalid uid type: %r' % (uid,))
if not str(uid).isdigit():
raise ValueError('Invalid uid: %r' % (uid,))
if str(int(uid)) != str(uid):
raise ValueError('Leading zeros or sign not allowed in uid: %r' % (uid,))
if int(uid) <= 0:
raise ValueError('Zero or negative uid not allowed: %r' % (uid,))
super(User, self).__init__('u%s' % uid)
def __repr__(self):
return 'User(%s)' % self._key[1:]
class TeamPrincipal(Principal):
""":const:`Datastore.TEAM` is a special principal to set team permissions.
Don't instantiate this class, use the predefined :const:`Datastore.TEAM` variable.
"""
def __init__(self):
super(TeamPrincipal, self).__init__('team')
def __repr__(self):
return 'TEAM'
class PublicPrincipal(Principal):
""":const:`Datastore.PUBLIC` is a special principal to set public permissions.
Don't instantiate this class, use the predefined :const:`Datastore.PUBLIC` variable.
"""
def __init__(self):
super(PublicPrincipal, self).__init__('public')
def __repr__(self):
return 'PUBLIC'
class Datastore(object):
"""An object representing a datastore.
A datastore holds a set of tables identified by table IDs, each of
which holds a set of records identified by record IDs. A record
holds a set of field values identified by field names. The
``Datastore`` object keeps a snapshot of the current content (all
tables, records and fields) in memory and supports simple queries.
Changes to a datastore are made through methods on the
:class:`Table` and :class:`Record` classes, as well as the
:class:`List` class (which represents a composite field value).
Changes are not immediately sent to the server. Instead, the
datastore keeps a list of changes in memory; these are sent to the
server by the :meth:`commit()` method. The :meth:`load_deltas()`
method retrieves new changes from the server and incorporates them
into the current snapshot. Those changes that have not yet been
sent to the server can be undone using the :meth:`rollback()`
method. Finally, the :meth:`transaction()` method combines the
functionality of these into a more powerful operation that can
retry sets of changes specified by a callback function.
**Do not instantiate this class directly**. Use the methods on
:class:`DatastoreManager` instead.
"""
DATASTORE_SIZE_LIMIT = 10 * 1024 * 1024 #: Datastore size limit placeholder for sphinx.
_DATASTORE_SIZE_LIMIT__doc__ = """
The maximum size in bytes of a datastore.
"""
PENDING_CHANGES_SIZE_LIMIT = 2 * 1024 * 1024 #: Delta size limit placeholder for sphinx.
_PENDING_CHANGES_SIZE_LIMIT__doc__ = """
The maximum size in bytes of changes that can be queued up between calls to
:meth:`commit()`.
"""
RECORD_COUNT_LIMIT = 100000 #: Record count limit placeholder for sphinx.
_RECORD_COUNT_LIMIT__doc__ = """
The maximum number of records in a datastore.
"""
BASE_DATASTORE_SIZE = 1000 #: Base datastore size placeholder for sphinx.
_BASE_DATASTORE_SIZE__doc__ = """
The size in bytes of a datastore before accounting for the size of its records.
The overall size of a datastore is this value plus the size of all records.
"""
BASE_DELTA_SIZE = 100 #: Base delta size placeholder for sphinx.
_BASE_DELTA_SIZE__doc__ = """
The size in bytes of a delta before accounting for the size of each change.
The overall size of a delta is this value plus the size of each change.
"""
BASE_CHANGE_SIZE = 100 #: Base change size placeholder for sphinx.
_BASE_CHANGE_SIZE__doc__ = """
The size in bytes of a change before including the size of its values.
The overall size of a change is this value plus the size of the values in the change.
"""
TEAM = TeamPrincipal() #: Team placeholder for sphinx.
_TEAM__doc__ = """
The principal used to get or modify the team role for a datastore.
"""
PUBLIC = PublicPrincipal() #: Public placeholder for sphinx.
_PUBLIC__doc__ = """
The principal used to get or modify the public role for a datastore.
"""
OWNER = 'owner' #: Owner placeholder for sphinx.
_OWNER__doc__ = """
The role indicating ownership of a datastore. Owners have
full access and their role cannot be changed or removed.
"""
EDITOR = 'editor' #: Editor placeholder for sphinx.
_EDITOR__doc__ = """
The role indicating edit (i.e., read-write) access. Editors
can also modify the role for other principals (except owners).
"""
VIEWER = 'viewer' #: Viewer placeholder for sphinx.
_VIEWER__doc__ = """
The role indicating view (i.e. read-only) access. Viewers
cannot change any aspect of a datastore.
"""
NONE = 'none' #: Viewer placeholder for sphinx.
_NONE__doc__ = """
The role indicating no access at all.
"""
def __init__(self, manager, id=None, handle=None, role=None):
if role is not None:
# Should've been caught earlier.
assert isinstance(role, str), repr(role)
assert role in (Datastore.OWNER, Datastore.EDITOR, Datastore.VIEWER), repr(role)
self._manager = manager
self._id = id
self._handle = handle
self._role = role
self._rev = 0
self._tables = {}
self._changes = []
self._record_count = 0
self._size = self.BASE_DATASTORE_SIZE
self._pending_changes_size = 0
def __repr__(self):
return 'Datastore(<rev=%d>, id=%r, handle=%r, role=%r)' % (self._rev, self._id,
self._handle, self._role)
def _check_edit_permission(self):
if self.is_shareable() and self._role not in (Datastore.OWNER, Datastore.EDITOR):
raise DatastorePermissionError('This datastore is read-only')
def _check_shareable(self):
if not self.is_shareable():
raise DatastoreError('Access control is only supported for shareable datastores')
def _check_principal(self, principal):
if not isinstance(principal, Principal):
raise TypeError('A Principal is expected')
@staticmethod
def is_valid_id(id):
"""A helper method to check for a valid datastore ID.
There are actually two types of datastore IDs, which
are called private IDs and shareable IDs.
Private datastores are created with
:meth:`DatastoreManager.open_default_datastore()` or
:meth:`DatastoreManager.open_or_create_datastore()`,
and the app has control over the name.
Valid private datastore IDs are 1-64 characters long and
may contain the following characters: ``a-z 0-9 . - _`` .
However the first and last character cannot be dots. Note
that upper case is not allowed.
Shareable datastores are created with
:meth:`DatastoreManager.create_datastore()`; the
name is a dot followed by a random-looking sequence of
characters assigned by the SDK. Valid shareable datastore IDs
are a dot followed by 1-63 dbase64 characters (which are
``a-z A-Z 0-9 - _``). Note that upper case *is* allowed.
The :meth:`DatastoreManager.open_datastore()` and
:meth:`DatastoreManager.open_raw_datastore()` methods
can open either type of datastores.
"""
return bool(re.match(_VALID_DSID_RE, id))
@staticmethod
def is_valid_shareable_id(id):
"""A helper method to check for a valid shareable datastore ID.
This is a valid datastore ID starting with a '.'.
"""
return Datastore.is_valid_id(id) and id.startswith('.')
def get_id(self):
"""Return the ID of this datastore (a string)."""
return self._id
def is_shareable(self):
"""Return whether this is a shareable datastore."""
return self._id.startswith('.')
def is_writable(self):
"""Return whether this datastore is writable.
Always true for private datastores.
False iff role==:const:`VIEWER` for shareable datastores.
"""
return self._role != Datastore.VIEWER
def get_handle(self):
"""Return the handle of this datastore (a string)."""
return self._handle
def get_rev(self):
"""Return the current revision of this datastore (an integer >= 0)."""
return self._rev
def get_manager(self):
"""Return the :class:`DatastoreManager` to which this datastore belongs."""
return self._manager
def get_mtime(self):
"""Return time this datastore was last modified, if known.
This value is automatically set to the current time by
:meth:`commit()`.
Returns
A :class:`Date` or None.
"""
return self._get_info_field('mtime')
def get_title(self):
"""Return the title of this datastore (a string or None).
The title is primarily useful for apps that use shareable
datastores to represent documents created by the user. Using
:meth:`set_title()` the title can be set to a string chosen by
the user, and :meth:`DatastoreManager.list_datastores()` will
return the titles (see :class:`DatastoreInfo`). The app can
then show the user a list of documents containing the title
and time of last modification for each document without
needing to open all datastores.
"""
return self._get_info_field('title')
def set_title(self, title):
"""Set the title of this datastore (a string or None).
Since this operation is implemented by updating a reserved
table, you must call :meth:`commit()` to send this change to
the server.
"""
if title is not None and not isinstance(title, basestring):
raise TypeError('Title must be a string, not %s' % type(title).__name__)
self._set_info_field('title', title)
def _set_mtime(self):
now = time.time()
mtime = Date(now)
self._set_info_field('mtime', mtime)
def _get_info_field(self, field):
info_table = self.get_table(':info')
info_record = info_table.get('info')
if info_record is None:
return None
return info_record.get(field)
def _set_info_field(self, field, value):
info_table = self.get_table(':info')
info_record = info_table.get_or_insert('info')
info_record.set(field, value)
def get_record_count(self):
"""Return the number of records in this datastore."""
return self._record_count
def get_size(self):
"""Return the size in bytes of this datastore.
The overall size of a datastore is calculated by summing the
size of all records, plus the base size of an empty datastore itself.
"""
return self._size
def get_pending_changes_size(self):
"""Return the size in bytes of changes made since the last :meth:`commit()`.
If there are any pending changes, the total size is given by summing the size
of those changes and :const:`BASE_DELTA_SIZE`. If there are no pending
changes, the total size is zero.
"""
if self._changes:
return Datastore.BASE_DELTA_SIZE + self._pending_changes_size
else:
return 0
def _add_pending_change(self, change):
self._changes.append(change)
self._pending_changes_size += change.size()
def get_effective_role(self):
"""Return the effective role for the current user.
This can return :const:`OWNER`, :const:`EDITOR` or
:const:`VIEWER`.
For a private datastore this always returns :const:`OWNER`.
"""
if self.is_shareable():
return self._role
else:
return Datastore.OWNER
def list_roles(self):
"""Return the full ACL, as a dict mapping principals to roles.
This is only supported for shareable datastores.
"""
self._check_shareable()
acl_table = self.get_table(':acl')
acl = {}
for rec in acl_table.query():
id = rec.get_id()
if id == 'team':
principal = Datastore.TEAM
elif id == 'public':
principal = Datastore.PUBLIC
elif id.startswith('u') and id[1:].isdigit():
principal = User(id[1:])
else:
continue # pragma: nocover.
acl[principal] = _make_role(rec.get('role'))
return acl
def get_role(self, principal):
"""Return the role for a principal.
This can return :const:`OWNER`, :const:`EDITOR`,
:const:`VIEWER`, or ``None``.
The principal must be :const:`TEAM` or :const:`PUBLIC`.
This is only supported for shareable datastores.
This method only returns the role explicitly set for the given
principal in the ACL; it is equivalent to
``ds.list_roles().get(principal)``. The effective role for a
principal may be different; it is affected by the full ACL as
well as by team membership and ownership.
To get the effective role for the current user, use
:meth:`get_effective_role()`.
"""
self._check_shareable()
self._check_principal(principal)
acl_table = self.get_table(':acl')
rec = acl_table.get(principal.key)
if rec is None:
return Datastore.NONE
else:
return _make_role(rec.get('role'))
def set_role(self, principal, role):
"""Set a principal's role.
The principal must be :const:`TEAM` or :const:`PUBLIC`.
The role must be :const:`EDITOR` or :const:`VIEWER`.
If the principal already has a role it is updated.
This is only supported for writable, shareable datastores.
"""
if role == Datastore.NONE:
return self.delete_role(principal)
self._check_shareable()
self._check_principal(principal)
irole = _parse_role(role, owner_ok=False)
acl_table = self.get_table(':acl')
rec = acl_table.get(principal.key)
if rec is None:
acl_table.get_or_insert(principal.key, role=irole)
else:
rec.update(role=irole)
def delete_role(self, principal):
"""Delete a principal's role.
The principal must be :const:`TEAM` or :const:`PUBLIC`.
The principal may but need not have a role.
This is only supported for writable, shareable datastores.
"""
self._check_shareable()
self._check_principal(principal)
acl_table = self.get_table(':acl')
rec = acl_table.get(principal.key)
if rec is not None:
rec.delete_record()
def load_snapshot(self):
"""Load the datastore with a snapshot retrieved from the server.
All previously loaded content of the datastore is discarded,
including pending changes.
This is automatically called by most of the ``open_*()``
methods, so there is normally no reason to call this.
"""
resp = self._manager._dsops.get_snapshot(self._handle)
rev = resp['rev']
snapshot = resp['rows']
self.apply_snapshot(rev, snapshot)
def apply_snapshot(self, rev, snapshot):
"""Restore the datastore from a revision and a snapshot.
All previously loaded content of the ``Datastore`` object is
discarded, including pending changes.
Normally this method is called internally by
:meth:`load_snapshot()`. It may also be called with a
revision and snapshot obtained previously from
:meth:`get_rev()` and :meth:`get_snapshot()`.
"""
self._rev = 0
self._tables = {}
self._changes = []
for row in snapshot:
tid = row['tid']
recordid = row['rowid']
data = dict((field, _value_from_json(v)) for field, v in row['data'].items())
table = self.get_table(tid)
table._update_record_fields(recordid, data, _compute_record_size_for_fields(data))
self._rev = rev
def get_snapshot(self):
"""Return a snapshot of the datastore.
A snapshot is a list of dicts with keys ``'tid'``,
``'rowid'``, and ``'data'``, where ``'tid'`` maps to the table
ID, ``'rowid'`` maps to a record ID, and ``'data'`` maps to a
JSON-encoded record, i.e. a dict mapping field names to
JSON-encoded values.
Together with the revision (which you can obtain from
:meth:`get_rev()`) this comprises the mutable state of a
datastore. You may restore a ``Datastore`` object to a given
state using :meth:`apply_snapshot()`.
"""
snapshot = []
for table_id, table in self._tables.items():
for record_id, fields in table._records.items():
data = {}
for field, value in fields.items():
data[field] = _value_to_json(value)
snapshot.append({'tid': table_id, 'rowid': record_id, 'data': data})
return snapshot
def await_deltas(self):
"""Wait for and incorporate changes to this datastore.
It is an error to call this method if the datastore has
pending changes.
Returns
A dict mapping table IDs to sets of records,
see :meth:`apply_deltas()`.
"""
if self._changes:
raise DatastoreError('Cannot call await_deltas() with pending changes')
resp = self._manager._dsops.await(cursors={self._handle: self._rev})
if 'get_deltas' not in resp:
return {}
subresp = resp['get_deltas']
if self._handle not in subresp['deltas']:
return {}
myresp = subresp['deltas'][self._handle]
myresp = self._manager._dsops._check_access_errors(myresp)
deltas = myresp.get('deltas')
return self.apply_deltas(deltas)
def load_deltas(self):
"""Load new changes retrieved from the server into the datastore.
All previously loaded content is preserved, unless explicitly
deleted or modified by one of the loaded changes.
It is an error to call this method if the datastore has
pending changes.
Calling ``ds.load_deltas()`` is equivalent to::
deltas = ds.fetch_deltas()
ds.apply_deltas(deltas)
Returns
A dict mapping table IDs to sets of records,
see :meth:`apply_deltas()`.
"""
if self._changes:
raise DatastoreError('Cannot call load_deltas() with pending changes')
deltas = self.fetch_deltas()
return self.apply_deltas(deltas)
def fetch_deltas(self):
"""Retrieve new changes from the server without applying them.
This is one of the building blocks of :meth:`load_deltas()`;
you probably want to use that instead.
Returns
A list of deltas suitable to be passed directly to
:meth:`apply_deltas()`.
"""
resp = self._manager._dsops.get_deltas(self._handle, self._rev)
return resp.get('deltas')
def apply_deltas(self, deltas):
"""Apply deltas retrieved by some other means.
It is an error to call this method if the datastore has
pending changes.
Normally this method is called internally by
:meth:`await_deltas()` or :meth:`load_deltas()`.
The deltas should be received from the server. Under certain
conditions (e.g. when :meth:`DatastoreManager.await()` is
called in a background thread) it is possible that the server
sends a delta that has already been applied locally. Such
deltas are silently ignored.
Returns
A dict mapping table IDs to sets of records,
indicating the records that were inserted, updated or deleted
by the loaded deltas.
"""
if self._changes:
raise DatastoreError('Cannot call apply_deltas() with pending changes')
if deltas is None:
return {}
raw_changed_records = set() # Set of (tid, recordid) tuples.
for delta in deltas:
rev = delta['rev']
changes = delta['changes']
if rev < self._rev:
continue # We've already seen this revision, or it is ours.
if rev != self._rev:
# Either the server sent us bad data or our state is mixed up.
raise DatastoreError('Revision out of sequence (expected %d, actual %d)' %
(self._rev, rev))
for c in changes:
ch = _Change.from_json(c)
tid, recordid = self._apply_change(ch)
raw_changed_records.add((tid, recordid))
self._rev = rev + 1
changed_records = {} # Map of tid to set of Record objects.
for tid, recordid in raw_changed_records:
record = Record(self._tables[tid], recordid)
if tid in changed_records:
changed_records[tid].add(record)
else:
changed_records[tid] = set([record])
return changed_records
def get_table(self, tid):
"""Get a :class:`Table` object with the given table ID."""
t = self._tables.get(tid)
if t is None:
if not Table.is_valid_id(tid):
raise ValueError('Invalid table ID %r' % (tid,))
t = Table(self, tid)
self._tables[tid] = t
return t
def list_table_ids(self):
"""List the non-empty tables for this datastore.
Returns
A set of strings table IDs (strings).
"""
tids = set()
for tid, table in self._tables.items():
if table._records:
tids.add(tid)
return tids
def rollback(self):
"""Discard all pending changes since the last successful :meth:`commit()`."""
while self._changes:
ch = self._changes.pop()
inv = ch.invert()
self._apply_change(inv)
def commit(self):
"""Attempt to commit all pending changes.
Pending changes are all mutations to a datastore made through
:meth:`Table.insert()`, :meth:`Record.set()` and similar
methods (inluding mutating :class:`List` methods).
To upload pending changes to the server you must use
:meth:`commit()`, or :meth:`transaction()`, which calls it.
This method raises :class:`DatastoreConflictError` when the
server detects a conflict and refuses to accept the changes.
The proper response to this exception is to call
:meth:`rollback()`, then :meth:`load_deltas()`, and then retry
the transaction from the top, or give up and report an error
to the user. (The :meth:`transaction()` method implements
this higher-level control flow.)
If there are any changes, this method adds a change that
updates the datastore's mtime. If there are no changes, this
method is a no-op (and no empty delta will be sent to the
server).
"""
self._check_edit_permission()
if not self._changes:
return
self._set_mtime()
changes = [ch.to_json() for ch in self._changes]
nonce = _new_uuid()
resp = self._manager._dsops.put_delta(self._handle, self._rev, changes, nonce)
self._rev = resp['rev']
self._changes = []
def transaction(self, callback, *args, **kwds):
"""transaction(callback, *args, max_tries=1)
Call a callback function and commit changes, with retries.
When multiple clients try to update a datastore concurrently,
it is possible for :meth:`commit()` to raise
:class:`DatastoreConflictError`, indicating a conflict. This
function handles the details of handling such failures and
retrying the updates. You pass it a callback function which
will be called repeatedly until :meth:`commit()` succeeds, or
the maximum number of tries is reached.
The keyword-only parameter ``max_tries`` specifies how many
times the callback is called before giving up. The default is
1, i.e. call it only once; the recommended value is 4.
Generally, if you plan to modify a datastore, you should do
all your reads and writes in a transaction. On entry, there
should be no pending changes.
Example::
def do_stuff(record_id):
record = tasks_table.get(record_id)
user_count = record.get('user_count')
record.update(user_count=user_count+1)
datastore.transaction(do_stuff, some_record_id, max_tries=4)
Extra positional arguments are passed on to the callback
function. On success, the return value of the callback is
returned.
When a commit attempt fails, uncommitted changes are rolled
back using :meth:`rollback()`, and new changes are retrieved
from the server and loaded into the datastore using
:meth:`load_deltas()`. This is done before checking whether
we are out of tries.
When giving up, :meth:`DatastoreError` is raised.
When any other exception occurs (either in the callback or in
the commit), uncommitted changes are rolled back and the last
exception is re-raised.
"""
# Hack: max_tries is a keyword-only parameter.
max_tries = kwds.pop('max_tries', 1)
if kwds:
raise TypeError('Unexpected kwargs %r' % (kwds,))
if max_tries < 1:
raise ValueError('max_tries must be >= 1')
# Note that catching BaseException is generally not advised.
if self._changes:
raise DatastoreError('There should be no pending changes')
for _ in range(max_tries):
try:
rv = callback(*args)
except Exception:
# The callback failed; give up completely.
self.rollback()
raise
try:
self.commit()
except DatastoreConflictError:
# It's a conflict; update content and maybe try again.
self.rollback()
# If loading deltas fails, that's too bad.
self.load_deltas()
except Exception:
# Some other error; give up completely.
self.rollback()
raise
else:
# Success!
return rv
# We ran out of tries. But we've loaded new deltas.
if max_tries == 1:
raise DatastoreError('Failed to commit; set max_tries to a value > 1 to retry')
else:
raise DatastoreError('Failed to commit %d times in a row' % (max_tries,))
# NOTE: The asserts below can only fire if the server sends bogus data.
def _apply_change(self, change):
op = change.op
tid = change.tid
recordid = change.recordid
data = change.data
table = self.get_table(tid)
if op == INSERT:
assert recordid not in table._records, repr((tid, recordid))
table._update_record_fields(recordid, data, _compute_record_size_for_fields(data))
elif op == DELETE:
old_fields = table._records.get(recordid)
table._update_record_fields(recordid, None,
-_compute_record_size_for_fields(old_fields))
change.undo = dict(old_fields)
elif op == UPDATE:
fields = dict(table._records[recordid])
undo = {}
old_size, new_size = 0, 0
for field, val in data.items():
old_value = fields.get(field)
undo[field] = old_value
if old_value is not None:
old_size += _compute_field_size(old_value)
assert _is_op(val), repr(val)
op = val[0]
if op == ValuePut:
fields[field] = val[1]
new_size += _compute_field_size(val[1])
elif op == ValueDelete:
# Silently ignore deletions for non-existing fields.
if field in data:
del fields[field]
elif _is_listop(val):
new_list = self._apply_listop(fields.get(field), val)
fields[field] = new_list
new_size += _compute_field_size(new_list)
else:
assert False, repr((field, val)) # pragma: no cover
table._update_record_fields(recordid, fields, new_size - old_size)
change.undo = undo
else:
assert False, repr(change) # pragma: no cover
return tid, recordid
def _apply_listop(self, oldval, val):
op = val[0]
if op == ListCreate:
assert oldval is None or oldval == (), repr(oldval)
return ()
assert isinstance(oldval, tuple), repr(oldval)
if op == ListPut:
index, newval = val[1:]
return oldval[:index] + (newval,) + oldval[index+1:]
if op == ListInsert:
index, newval = val[1:]
return oldval[:index] + (newval,) + oldval[index:]
if op == ListDelete:
index = val[1]
return oldval[:index] + oldval[index+1:]
if op == ListMove:
return _list_move(oldval, *val[1:])
assert False, repr(val) # pragma: no cover
def close(self):
"""Close the datastore.
The datastore should not be used after this call.
All pending changes are lost.
"""
# Make essential stuff fail.
self._manager = None
self._changes = None
_VALID_ID_RE = r'([a-zA-Z0-9_\-/.+=]{1,64}|:[a-zA-Z0-9_\-/.+=]{1,63})\Z'
class Table(object):
"""An object representing a table in a datastore.
You need a ``Table`` in order to query or modify the content of the datastore.
**Do not instantiate this class directly**. Use
:meth:`Datastore.get_table()` instead. Calls with the same ID will return
the same object.
"""
def __init__(self, datastore, tid):
self._datastore = datastore
self._tid = tid
self._records = {} # Map {recordid: fields}
self._record_sizes = {} # Map {recordid: int size}
def __repr__(self):
return 'Table(<%s>, %r)' % (self._datastore._id, self._tid)
@staticmethod
def is_valid_id(id):
"""A helper method to check for a valid table ID.
Valid table IDs are 1-64 characters long and may contain the
following characters: ``a-z A-Z 0-9 _ - / . + =`` . Reserved
IDs start with a colon followed by 1-63 characters from that set.
"""
return bool(re.match(_VALID_ID_RE, id))
def get_id(self):
"""Return the ID of this table (a string)."""
return self._tid
def get_datastore(self):
"""Return the :class:`Datastore` to which this table belongs."""
return self._datastore
def get(self, recordid):
"""Return the record with the given record ID.
If no such record exists, return None.
"""
if recordid in self._records:
return Record(self, recordid)
if not Record.is_valid_id(recordid):
raise ValueError('Invalid record ID %r' % (recordid,))
return None
def get_or_insert(self, recordid, **fields):
"""Return the record with the given record ID, or create it.
If a record with the given record ID already exists, it is
returned, and the keyword arguments are ignored. If no such
record exists, this inserts a record with the given record ID,
setting its fields from the keyword arguments.
"""
rec = self.get(recordid)
if rec is not None:
return rec
return self._insert_with_id(recordid, fields)
def insert(self, **fields):
"""Insert a new record into the table and return it.
The new record's fields are set from the keyword arguments.
A unique record ID is assigned automatically.
"""
return self._insert_with_id(_new_uuid(), fields)
def _insert_with_id(self, recordid, fields):
self._datastore._check_edit_permission()
value_size = 0
for field, value in fields.items():
if not Record.is_valid_field(field):
raise ValueError('Invalid field name %r' % (field,))
if value is None:
raise TypeError('Cannot set field %r to None in insert' % (field,))
value = _typecheck_value(value, field)
value_size += _compute_field_size(value)
fields[field] = value
self._datastore._add_pending_change(_Change(INSERT, self._tid, recordid, dict(fields)))
self._update_record_fields(recordid, fields, Record.BASE_RECORD_SIZE + value_size)
return Record(self, recordid)
def query(self, **kwds):
"""Query the records in the table.
If called without arguments, this returns a set of all
records in the table.
If called with keyword arguments, each keyword argument
specifies a required value for the corresponding field;
only records that have the required field values for all
keyword arguments are returned.
The following example retrieves all records in the 'tasks'
table that have a 'done' field whose type is ``bool`` and
whose value is ``False``::
to_do = tasks.query(done=False)
For the purpose of queries, integers and floats are compared
using the standard Python equality comparisons.
Tip: specifying multiple keyword arguments implements a
logical 'AND' operation; to implement a logical 'OR'
operation, use the union of multiple queries. For example::
# Assume priority can be 1 (low), 2 (normal), 3 (high)
urgent = tasks.query(done=False, priority=3)
normal = tasks.query(done=False, priority=2)
to_do = urgent | normal
"""
filter = []
for field, value in kwds.items():
if not Record.is_valid_field(field):
raise ValueError('Invalid field name %r' % (field,))
value = _typecheck_value(value, field)
filter.append((field, value))
results = set()
for recordid, fields in self._records.items():
for field, value in filter:
if field not in fields:
break
rfv = fields[field]
if rfv != value:
break
# If the values match but the type don't, the filter
# fails unless both types are numeric.
trfv = type(rfv)
tv = type(value)
if trfv is not tv and not set((trfv, tv)) <= set((int, long, float)):
break
else:
results.add(Record(self, recordid))
return results
def _update_record_fields(self, recordid, fields, change_in_size):
"""Update the fields of the record, or delete the record if fields is None.
This method updates the fields for the recordid and also updates its cached size in bytes
and the cached size of the datastore.
"""
curr_size = self._get_record_size(recordid)
is_new_record = (curr_size == 0)
curr_size += change_in_size
assert curr_size >= 0, 'Invalid size %d for table %s, record %s' % (curr_size, self._tid,
recordid)
assert (self._datastore._size + change_in_size >=
Datastore.BASE_DATASTORE_SIZE), 'Invalid datastore size %d' % (self._size,)
if curr_size:
self._record_sizes[recordid] = curr_size
self._records[recordid] = fields
if is_new_record:
self._datastore._record_count += 1
else:
del self._record_sizes[recordid]
del self._records[recordid]
self._datastore._record_count -= 1
self._datastore._size += change_in_size
def _get_record_size(self, recordid):
record_size = self._record_sizes.get(recordid)
if not record_size:
fields = self._records.get(recordid)
# The values in this cache are maintained through _update_record_fields. There is no
# case in which a record with fields exists without having its size set properly in
# the cache.
assert fields is None, 'Record %r exists %r but has no cached size' % (recordid,
fields)
record_size = 0
return record_size
class Record(object):
"""An object representing a record in a table in a datastore.
A record has a record ID and zero or more fields. A record
belongs to a specific table. Two records are considered equal
when they belong to the same table and have the same record ID;
equal records by definition have the same fields. Records are
hashable.
A field value can be an atomic type or a list of atomic types.
Atomic types are ``bool``, integer (``int`` or ``long``), ``float``, string
(``unicode`` or 8-bit ``str``; the latter must be a valid UTF-8 string), or an
instance of the special classes :class:`Date` or :class:`Bytes`. Note that ``None`` is
not a valid field value.
**Do not instantiate this class directly**. Use
:meth:`Table.get()`, :meth:`Table.insert()`,
:meth:`Table.get_or_insert()` or :meth:`Table.query()` instead.
"""
RECORD_SIZE_LIMIT = 100 * 1024 #: Record size limit placeholder for sphinx.
_RECORD_SIZE_LIMIT__doc__ = """
The maximum size in bytes of a record.
"""
BASE_RECORD_SIZE = 100 #: Base record size placeholder for sphinx.
_BASE_RECORD_SIZE__doc__ = """
The size in bytes of a record before accounting for the sizes of its fields.
The overall size of a record is this value plus the sum of the sizes of its fields.
"""
BASE_FIELD_SIZE = 100 #: Base field size placeholder for sphinx.
_BASE_FIELD_SIZE__doc__ = """
The size in bytes of a field before accounting for the sizes of its values.
The overall size of a field is this value plus:
- For string and :class:`Bytes`: the length in bytes of the value.
- For :class:`List`: the sum of the size of each list item, where each item's size
is computed as the size of the item value plus :const:`List.BASE_ITEM_SIZE`.
- For other atomic types: no additional contribution to the size of the field.
"""
def __init__(self, table, recordid):
self._table = table
self._datastore = table._datastore
self._recordid = recordid
def __repr__(self):
fields = self._table._records.get(self._recordid)
if fields is None:
return 'Record(<%s>, %r, <deleted>)' % (self._table._tid, self._recordid)
else:
return 'Record(<%s>, %r, %r)' % (self._table._tid, self._recordid, fields)
def __eq__(self, other):
if not isinstance(other, Record):
return NotImplemented
return self._table is other._table and self._recordid == other._recordid
def __ne__(self, other):
r = self.__eq__(other)
if r is not NotImplemented:
r = not r
return r
def __hash__(self):
return hash((self._table._tid, self._recordid))
@staticmethod
def is_valid_id(id):
"""A helper method to check for a valid record ID.
Valid record IDs are 1-64 characters long and may contain the
following characters: ``a-z A-Z 0-9 _ - / . + =`` . Reserved
IDs start with a colon followed by 1-63 characters from that set.
"""
return bool(re.match(_VALID_ID_RE, id))
@staticmethod
def is_valid_field(field):
"""A helper method to check for a valid field name.
Valid field names are 1-64 characters long and may contain the
following characters: ``a-z A-Z 0-9 _ - / . + =`` . Reserved
field names start with a colon followed by 1-63 characters
from that set.
"""
return bool(re.match(_VALID_ID_RE, field))
def get_id(self):
"""Return the ID of this record (a string)."""
return self._recordid
def get_table(self):
"""Return the :class:`Table` to which this record belongs."""
return self._table
def get_size(self):
"""Return the size in bytes of this record.
The overall size of a record is calculated by summing the
size of all values in all fields, plus the base size of an empty
record itself. A deleted record has a size of zero.
"""
return self._table._get_record_size(self._recordid)
def get(self, field):
"""Return the value of a field in the record.
If the record does not have a field by that name, return ``None``.
If the field value is a list, this returns a :class:`List` object;
mutating that object will modify the field's value in the record.
"""
fields = self._table._records.get(self._recordid)
if fields is None:
v = None
else:
v = fields.get(field)
if isinstance(v, tuple):
v = List(self, field)
# Skip field validation if we actually have a value.
if v is None and not Record.is_valid_field(field):
raise ValueError('Invalid field name %r' % (field,))
return v
def set(self, field, value):
"""Set the value of a field in the record.
Setting the value to ``None`` deletes the field.
"""
self.update(**{field: value})
def delete(self, field):
"""Delete the value of a field in the record.
If the field does not exist this is a no-op.
"""
self.update(**{field: None})
def get_fields(self):
"""Return a dict mapping all the fields in the record to their values.
Modifying the dict will not affect the record in the datastore.
To enforce this, list values are returned as tuples.
"""
fields = self._table._records.get(self._recordid)
if fields is None:
return {}
return dict(fields)
def update(self, **kwds):
"""Set the value of multiple fields in the record.
For each keyword argument, the field by that name is set to
the corresponding value, except that if the value is ``None``, the
field is deleted.
"""
self._datastore._check_edit_permission()
fields = self._table._records.get(self._recordid)
if fields is None:
raise DatastoreError('Cannot update a deleted record')
fields = dict(fields)
data = {}
undo = {}
old_size, new_size = 0, 0
for field, value in kwds.items():
if not Record.is_valid_field(field):
raise ValueError('Invalid field name %r' % (field,))
if value is None:
old_value = fields.get(field)
if old_value:
undo[field] = old_value
old_size += _compute_field_size(old_value)
del fields[field]
data[field] = [ValueDelete]
else:
old_value = fields.get(field)
undo[field] = old_value
old_size += _compute_field_size(old_value)
value = _typecheck_value(value, field)
fields[field] = value
new_size += _compute_field_size(value)
data[field] = [ValuePut, value]
if data:
change = _Change(UPDATE, self._table._tid, self._recordid, data=data, undo=undo)
self._table._datastore._add_pending_change(change)
self._table._update_record_fields(self._recordid, fields, new_size - old_size)
def delete_record(self):
"""Delete the record from the table.
If the record is already marked as deleted, this is a no-op.
A record marked as deleted cannot be re-inserted, cannot be
modified, and no longer has any fields. To check for a
deleted record, use :meth:`is_deleted()`.
"""
self._datastore._check_edit_permission()
fields = self._table._records.get(self._recordid)
if fields is None:
return
change = _Change(DELETE, self._table._tid, self._recordid, data=None, undo=fields)
self._table._datastore._add_pending_change(change)
self._table._update_record_fields(self._recordid, None, -self.get_size())
def get_or_create_list(self, field):
"""Get a list field, possibly setting it to an empty list.
If the field exists, it must be a list. If it does not exist,
it is set to an empty list. In either case, a :class:`List`
object representing the field is returned.
"""
fields = self._table._records.get(self._recordid)
if fields is None:
raise DatastoreError('Cannot update a deleted record')
v = fields.get(field)
if isinstance(v, tuple):
return List(self, field)
if v is not None:
raise TypeError('Field %r already exists but is a %s instead of a list' %
(field, type(v).__name__))
if not Record.is_valid_field(field):
raise ValueError('Invalid field name %r' % (field,))
self._datastore._check_edit_permission()
# Produce a ListCreate op.
data = {field: _make_list_create()}
change = _Change(UPDATE, self._table._tid, self._recordid, data=data, undo={field: None})
self._table._datastore._add_pending_change(change)
fields = dict(fields)
fields[field] = ()
self._table._update_record_fields(self._recordid, fields, self.BASE_FIELD_SIZE)
return List(self, field)
def has(self, field):
"""Inquire whether the record has a given field.
Return ``True`` if the field exists, ``False`` if not.
"""
fields = self._table._records.get(self._recordid)
found = fields is not None and field in fields
if not found and not Record.is_valid_field(field):
raise ValueError('Invalid field name %r' % (field,))
return found
def is_deleted(self):
"""Inquire whether the record is marked as deleted.
Return ``True`` if the record has been deleted, ``False`` if not.
"""
return self._recordid not in self._table._records
class Date(object):
"""A simple immutable object representing a timestamp.
Datastores store timestamps as milliseconds since the Epoch
(1/1/1970) in UTC.
To store a timestamp, you must set a field to a ``Date``
object; if a field value is a timestamp, getting the value will
return a ``Date``.
To construct a ``Date``, pass the constructor a POSIX
timestamp as returned by ``time.time()`` (and many other standard
Python APIs).
You can convert a ``Date`` back to a POSIX timestamp by
calling ``float()`` or ``int()`` on it. These conversions take
care of the conversion between seconds and milliseconds;
milliseconds map to fractions when converting to/from ``float``,
and are truncated when converting to ``int``.
You can also convert between Date and naive (``tzinfo``-less) ``datetime``
objects using a choice of UTC or local time, using
:meth:`to_datetime_utc()`, :meth:`from_datetime_utc()`,
:meth:`to_datetime_local()`, and :meth:`from_datetime_local()`.
Note that ``datetime`` objects using an explicit ``tzinfo`` field are not
supported; if you need to work with those you must convert to/from
naive ``datetime`` objects yourself.
"""
def __init__(self, timestamp=None):
"""Construct a ``Date`` from a timestamp.
The timestamp is an integer or float specifying seconds since
the epoch. It defaults to the current time.
"""
if timestamp is None:
timestamp = time.time()
else:
if not isinstance(timestamp, (float, int, long)):
raise TypeError('Timestamp must be a float or integer, not %s' %
type(timestamp).__name__)
self._timestamp = int(timestamp*1000.0) / 1000.0
def __repr__(self):
dt = datetime.datetime.utcfromtimestamp(int(self._timestamp))
ms = (self._timestamp * 1000) % 1000
return 'Date<%s.%03d UTC>' % (str(dt), ms)
def __float__(self):
return self._timestamp
def __int__(self):
return int(self._timestamp)
def __long__(self):
return long(self._timestamp)
def __eq__(self, other):
if not isinstance(other, Date):
return NotImplemented
return self._timestamp == other._timestamp
def __ne__(self, other):
if not isinstance(other, Date):
return NotImplemented
return self._timestamp != other._timestamp
def __lt__(self, other):
if not isinstance(other, Date):
return NotImplemented
return self._timestamp < other._timestamp
def __le__(self, other):
if not isinstance(other, Date):
return NotImplemented
return self._timestamp <= other._timestamp
def __gt__(self, other):
if not isinstance(other, Date):
return NotImplemented
return self._timestamp > other._timestamp
def __ge__(self, other):
if not isinstance(other, Date):
return NotImplemented
return self._timestamp >= other._timestamp
def to_datetime_utc(self):
"""Convert a ``Date`` to a ``datetime.datetime`` object in UTC.
This sets the ``tzinfo`` field to ``None``.
"""
return datetime.datetime.utcfromtimestamp(self._timestamp)
@classmethod
def from_datetime_utc(cls, dt):
"""Convert a ``datetime.datetime`` object in UTC to a ``Date``.
The ``tzinfo`` field must be ``None``.
"""
if dt.tzinfo is not None:
raise TypeError('The argument datetime must not have a timezone')
delta = dt - datetime.datetime.utcfromtimestamp(0)
return cls(delta.days * 24*3600 + delta.seconds + delta.microseconds * 0.000001)
def to_datetime_local(self):
"""Convert a ``Date`` to a ``datetime.datetime`` object in local time.
This set the ``tzinfo`` field to ``None``.
"""
return datetime.datetime.fromtimestamp(self._timestamp)
@classmethod
def from_datetime_local(cls, dt):
"""Convert a ``datetime.datetime`` object in UTC to a ``Date``.
The ``tzinfo`` field must be ``None``.
"""
if dt.tzinfo is not None:
raise TypeError('The argument datetime must not have a timezone')
# Keep the fraction separate because timetuple() doesn't store it.
fraction = dt.microsecond * 0.000001
return cls(time.mktime(dt.timetuple()) + fraction)
# JSON encoding used by protocol.
def to_json(self):
return {TIMESTAMP: str(int(self._timestamp * 1000))}
@classmethod
def from_json(cls, j):
# If this assert fires the server sent us bad data.
assert (isinstance(j, dict) and
list(j) == [TIMESTAMP] and
isinstance(j[TIMESTAMP], basestring)), repr(j)
timestamp = int(j[TIMESTAMP]) / 1000.0
return cls(timestamp)
class Bytes(object):
"""A simple immutable object representing a binary string.
Datastores transmit binary strings using a base64 encoding.
Because Python 2 uses ambiguous representations of binary strings,
you must wrap binary strings in this class in order to store them
in a datastore. 8-bit strings not wrapped this way are assumed to
represent text and must use the UTF-8 encoding.
To construct a :class:`Bytes`, pass the constructor a ``str``
instance, a ``buffer`` instance, or an ``array.array`` instance
whose typecode indicate a one-byte-wide data type (i.e. ``'c'``, ``'b'``
or ``'B'``).
To convert a :class:`Bytes` to a raw byte string, call ``bytes()``
on it.
"""
def __init__(self, blob):
"""Construct a Bytes from an 8-bit string."""
if not (isinstance(blob, (bytes, bytearray, buffer)) or
isinstance(blob, array.array) and blob.typecode in ('c', 'b', 'B')):
raise TypeError('Bytes must be a bytes-compatible type, not %s' %
type(blob).__name__)
self._bytes = bytes(blob) # Make a copy in case the argument is mutable.
def __repr__(self):
return 'Bytes(%r)' % self._bytes
if PY3: # pragma: no cover
def __bytes__(self):
return self._bytes
def __str__(self):
return repr(self)
else:
def __str__(self):
return self._bytes
def __unicode__(self):
return repr(self)
def __eq__(self, other):
if isinstance(other, bytes):
return self._bytes == other
if isinstance(other, Bytes):
return self._bytes == other._bytes
return NotImplemented
def __ne__(self, other):
if isinstance(other, bytes):
return self._bytes != other
if isinstance(other, Bytes):
return self._bytes != other._bytes
return NotImplemented
def __lt__(self, other):
if isinstance(other, bytes):
return self._bytes < other
if isinstance(other, Bytes):
return self._bytes < other._bytes
return NotImplemented
def __le__(self, other):
if isinstance(other, bytes):
return self._bytes <= other
if isinstance(other, Bytes):
return self._bytes <= other._bytes
return NotImplemented
def __gt__(self, other):
if isinstance(other, bytes):
return self._bytes > other
if isinstance(other, Bytes):
return self._bytes > other._bytes
return NotImplemented
def __ge__(self, other):
if isinstance(other, bytes):
return self._bytes >= other
if isinstance(other, Bytes):
return self._bytes >= other._bytes
return NotImplemented
def __len__(self):
return len(self._bytes)
# JSON encoding used by protocol.
def to_json(self):
s = _dbase64_encode(self._bytes)
return {BLOB: s}
@classmethod
def from_json(cls, j):
# If this assert fires the server sent us bad data.
assert (isinstance(j, dict) and
list(j) == [BLOB] and
isinstance(j[BLOB], basestring)), repr(j)
b = _dbase64_decode(j[BLOB])
return cls(b)
class List(collections.MutableSequence):
"""A wrapper for a list value.
When a field contains a list value, retrieving the field using
:meth:`Record.get()` returns a ``List`` object. This object
behaves like a mutable sequence, but mutating it (e.g., replacing
an item with a new value) will mutate the list value in the
record.
A ``List`` object knows the record and field to which it
refers. Multiple ``List`` objects may refer to the same record and
field.
``List`` objects are compared by value (i.e., the sequence of
items they contain, not the record and field to which they refer).
They can also be compared to regular tuples and lists.
Several methods available for regular lists are available for
``List`` objects, when in doubt, consult the documentation
below. Some methods unique to ``List`` objects also exist.
Negative indices are supported in the usual fashion.
**Do not instantiate this class directly**. Use
:meth:`Record.get()` or :meth:`Record.get_or_create_list()` instead.
"""
BASE_ITEM_SIZE = 20 #: Base list item size placeholder for sphinx.
_BASE_ITEM_SIZE__doc__ = """
The size in bytes of a list item.
The overall size of a list item is this value plus the size of the item value.
"""
def __init__(self, record, field):
self._table = record._table
self._recordid = record._recordid
self._field = field
self._check()
def __repr__(self):
return 'List(<%s>, %r)' % (self._recordid, self._field)
def __eq__(self, other):
if not isinstance(other, (List, list, tuple)):
return NotImplemented
return tuple(self) == _typecheck_list(other, self._field)
def __ne__(self, other):
if not isinstance(other, (List, list, tuple)):
return NotImplemented
return tuple(self) != _typecheck_list(other, self._field)
def __lt__(self, other):
if not isinstance(other, (List, list, tuple)):
return NotImplemented
return tuple(self) < _typecheck_list(other, self._field)
def __le__(self, other):
if not isinstance(other, (List, list, tuple)):
return NotImplemented
return tuple(self) <= _typecheck_list(other, self._field)
def __gt__(self, other):
if not isinstance(other, (List, list, tuple)):
return NotImplemented
return tuple(self) > _typecheck_list(other, self._field)
def __ge__(self, other):
if not isinstance(other, (List, list, tuple)):
return NotImplemented
return tuple(self) >= _typecheck_list(other, self._field)
def get_record(self):
"""Return the :class:`Record` to which this ``List`` refers."""
return self._table.get(self._recordid)
def get_field(self):
"""Return the field name (a string) to which this ``List`` refers."""
return self._field
def _check(self):
fields = self._table._records.get(self._recordid)
if fields is None:
raise TypeError('Cannot use a List referring to a deleted record')
v = fields.get(self._field)
if not isinstance(v, tuple):
raise TypeError('Cannot use a List referring to a non-list field')
return v
def __len__(self):
v = self._check()
return len(v)
def __iter__(self):
v = self._check()
return iter(v)
def __contains__(self, value):
v = self._check()
return value in v
def __getitem__(self, index):
v = self._check()
return v[index]
def __setitem__(self, index, value):
if isinstance(index, slice):
raise TypeError('Cannot set List slices')
value = _typecheck_atom(value, self.get_field(), True)
v = self._check()
if index < 0:
index += len(v)
if not 0 <= index < len(v):
raise IndexError
v = v[:index] + (value,) + v[index+1:]
self._update(v, _make_list_put(index, value))
def __delitem__(self, index):
if isinstance(index, slice):
raise TypeError('Cannot delete List slices')
v = self._check()
if index < 0:
index += len(v)
if not 0 <= index < len(v):
raise IndexError
v = v[:index] + v[index+1:]
self._update(v, _make_list_delete(index))
def insert(self, index, value):
"""Insert a value into the list at a given index."""
value = _typecheck_atom(value, self.get_field(), True)
v = self._check()
n = len(v)
if index < 0:
index += n
if index < 0:
index = 0
elif index > n:
index = n
v = v[:index] + (value,) + v[index:]
self._update(v, _make_list_insert(index, value))
def append(self, value):
"""Append a value to the end of the list."""
value = _typecheck_atom(value, self.get_field(), True)
v = self._check()
index = len(v)
v = v + (value,)
self._update(v, _make_list_insert(index, value))
def move(self, index, newindex):
"""Move the list item at ``index`` to position ``newindex``.
This is most easily explained as follows: first delete the
item at position ``index``; then re-insert it at position
``newindex``.
"""
v = self._check()
n = len(v)
if index < 0:
index += n
if not 0 <= index < len(v):
raise IndexError
if newindex < 0:
newindex += n
if not 0 <= newindex < len(v):
raise IndexError
v = _list_move(v, index, newindex)
self._update(v, _make_list_move(index, newindex))
def _update(self, v, op):
self._table._datastore._check_edit_permission()
table = self._table
recordid = self._recordid
field = self._field
fields = table._records[recordid]
old_v = fields.get(field)
change = _Change(UPDATE, table._tid, recordid,
data={field: op}, undo={field: old_v})
table._datastore._add_pending_change(change)
fields = dict(fields)
fields[field] = v
table._update_record_fields(recordid, fields,
_compute_value_size(v) - _compute_value_size(old_v))
VALID_ATOM_TYPES = frozenset([
int,
bool,
float,
str,
Date,
Bytes,
List,
] + ([bytes] if PY3 else [long, unicode]))
def _typecheck_value(value, field):
if isinstance(value, (List, list, tuple)):
return _typecheck_list(value, field)
else:
return _typecheck_atom(value, field)
def _typecheck_list(value, field):
return tuple(_typecheck_atom(item, field, is_list=True)
for item in value)
def _typecheck_atom(value, field, is_list=False):
if type(value) not in VALID_ATOM_TYPES:
if is_list:
format = 'Type %s is not an acceptable list item type (field %r)'
else:
format = 'Type %s is not an acceptable value type (field %r)'
raise TypeError(format % (type(value).__name__, field))
if isinstance(value, str) and not PY3:
# Convert 8-bit strings to Unicode using UTF-8.
# If this raises UnicodeDecodeError your data is not in UTF-8 format.
value = value.decode('utf-8')
return value
def _compute_record_size_for_fields(fields):
"""Compute the size in bytes of a record containing the given fields."""
return Record.BASE_RECORD_SIZE + sum(map(_compute_field_size, fields.itervalues()))
def _compute_field_size(value):
"""Compute the size in bytes of a field with the given value.
Returns 0 when field is None.
"""
if value is None:
return 0
return Record.BASE_FIELD_SIZE + _compute_value_size(value)
def _compute_value_size(value):
"""Compute the size in bytes of the value.
Sizes are computed as follows:
String: length of the (utf-8) string.
Bytes: length in bytes.
List: sum of (:const:`List.LIST_VALUE_SIZE` + atom value) for each value in the list.
Others: free
"""
if isinstance(value, (List, list, tuple)):
return _compute_list_size(value)
else:
return _compute_atom_size(value)
def _compute_list_size(value):
return (len(value) * List.BASE_ITEM_SIZE) + sum(map(_compute_atom_size, value))
def _compute_atom_size(value):
if value is None:
return 0
if isinstance(value, (int, long, bool, float, Date)):
return 0
if PY3: # pragma: no cover
if isinstance(value, str):
value = value.encode('utf-8')
if isinstance(value, bytes):
return len(value)
else:
if isinstance(value, unicode):
value = value.encode('utf-8')
if isinstance(value, str):
return len(value)
if isinstance(value, Bytes):
return len(value)
assert False, 'Type %r is not a valid atom (value: %r)' % (type(value), value)
# Change ops.
INSERT, UPDATE, DELETE = 'I', 'U', 'D'
class _Change(object):
REVERSED_OPS = {INSERT: DELETE, UPDATE: UPDATE, DELETE: INSERT}
def __init__(self, op, tid, recordid, data=None, undo=None):
assert op in (INSERT, UPDATE, DELETE), repr(op)
assert isinstance(tid, basestring), repr(tid)
assert isinstance(recordid, basestring), repr(recordid)
if data is None:
assert op == DELETE, repr(op)
else:
assert op != DELETE, repr(op)
assert isinstance(data, dict), repr(data)
if undo is not None:
assert op != INSERT, repr(op)
assert isinstance(undo, dict), repr(undo)
self.op = op
self.tid = tid
self.recordid = recordid
self.data = data
self.undo = undo
def __repr__(self):
args = [self.op, self.tid, self.recordid]
if self.data is not None or self.undo is not None:
args.append(self.data)
if self.undo is not None:
args.append(self.undo)
return '_Change(%s)' % (', '.join(map(repr, args)))
def __eq__(self, other):
if not isinstance(other, _Change):
return NotImplemented
return (self.op == other.op and
self.tid == other.tid and
self.recordid == other.recordid and
self.data == other.data and
self.undo == other.undo)
def __ne__(self, other):
eq = self.__eq__(other)
if eq is not NotImplemented:
eq = not eq
return eq
def without_undo(self):
return _Change(self.op, self.tid, self.recordid, self.data)
def size(self):
change_size = Datastore.BASE_CHANGE_SIZE
if self.op == INSERT:
change_size += sum((Record.BASE_FIELD_SIZE + _compute_value_size(val))
for val in self.data.itervalues())
elif self.op == UPDATE:
for field_op in self.data.itervalues():
change_size += Record.BASE_FIELD_SIZE
op_value = _get_op_value(field_op)
if op_value is not None:
change_size += _compute_value_size(op_value)
return change_size
def invert(self):
if self.op == UPDATE:
newdata = {}
newundo = {}
for name, op in self.data.items():
assert _is_op(op), repr((name, op))
if _is_listop(op):
newdata[name], newundo[name] = self._invert_listop(name, op)
else:
# Before and after are from op's POV.
before = self.undo.get(name)
opid = op[0]
if opid == ValuePut:
after = op[1]
if before is None:
newdata[name] = [ValueDelete]
newundo[name] = after
else:
newdata[name] = [ValuePut, before]
newundo[name] = after
elif opid == ValueDelete:
newdata[name] = [ValuePut, before]
newundo[name] = None
else:
assert False, repr((name, op)) # pragma: no cover
return _Change(UPDATE, self.tid, self.recordid, newdata, newundo)
else:
return _Change(self.REVERSED_OPS[self.op], self.tid, self.recordid,
data=self.undo, undo=self.data)
def _invert_listop(self, name, op):
assert _is_listop(op), repr(op)
# Before and after are from op's POV.
before = self.undo[name]
opid = op[0]
if opid == ListCreate:
after = ()
invop = [ValueDelete]
return invop, after
index = op[1]
assert isinstance(before, tuple), repr((name, before))
if opid == ListPut:
assert 0 <= index < len(before), repr((name, index, len(before)))
opvalue = op[2]
after = before[:index] + (opvalue,) + before[index+1:]
invop = _make_list_put(index, before[index])
elif opid == ListInsert:
assert 0 <= index <= len(before), repr((name, index, len(before)))
opvalue = op[2]
after = before[:index] + (opvalue,) + before[index:]
invop = _make_list_delete(index)
elif opid == ListDelete:
assert 0 <= index < len(before), repr((name, index, len(before)))
after = before[:index] + before[index+1:]
invop = _make_list_insert(index, before[index])
elif opid == ListMove:
assert 0 <= index < len(before), repr((name, index, len(before)))
newindex = op[2]
assert 0 <= newindex < len(before), repr((name, index, len(before)))
after = _list_move(before, index, newindex)
invop = _make_list_move(newindex, index)
else:
assert False, repr((name, op)) # pragma: no cover
return invop, after
@classmethod
def from_json(cls, val):
assert isinstance(val, list) and len(val) >= 3, repr(val)
op, tid, recordid = val[:3]
if op == INSERT:
assert len(val) == 4, repr(val)
data = dict((field, _value_from_json(v)) for field, v in val[3].items())
elif op == UPDATE:
assert len(val) == 4, repr(val)
data = dict((field, _op_from_json(v)) for field, v in val[3].items())
elif op == DELETE:
assert len(val) == 3, repr(val)
data = None
else:
assert False, repr(val) # pragma: no cover
return cls(op, tid, recordid, data)
def to_json(self):
# We never serialize the undo info.
if self.op == INSERT:
data = dict(self.data)
for k, v in data.items():
data[k] = _value_to_json(v)
return [self.op, self.tid, self.recordid, data]
if self.op == UPDATE:
data = {}
for k, v in self.data.items():
assert _is_op(v), repr(v)
data[k] = _op_to_json(v)
return [self.op, self.tid, self.recordid, data]
if self.op == DELETE:
return [DELETE, self.tid, self.recordid]
assert False, repr(self) # pragma: no cover
# Field ops.
ValuePut, ValueDelete = VALUE_OPS = 'P', 'D'
ListCreate, ListPut, ListInsert, ListDelete, ListMove = LIST_OPS = 'LC', 'LP', 'LI', 'LD', 'LM'
# Sets of field ops.
VALUE_OPS = frozenset(VALUE_OPS)
LIST_OPS = frozenset(LIST_OPS)
ALL_OPS = VALUE_OPS | LIST_OPS
# Codes for encoding special values.
INTEGER = 'I'
NUMBER = 'N'
TIMESTAMP = 'T'
BLOB = 'B'
# Special floating point representations.
PLUS_INFINITY = {NUMBER: '+inf'}
MINUS_INFINITY = {NUMBER: '-inf'}
NOT_A_NUMBER = {NUMBER: 'nan'}
# Special floating point values.
INF_VALUE = 1e1000
NINF_VALUE = -INF_VALUE
NAN_VALUE = INF_VALUE / INF_VALUE
def _new_uuid():
return base64.urlsafe_b64encode(uuid.uuid4().bytes).decode('ascii').rstrip('=')
def _value_from_json(v):
if isinstance(v, (int, long)) and not isinstance(v, bool):
return float(v) # Plain JSON "numbers" are only used to encode floats.
if isinstance(v, dict):
assert len(v) == 1, repr(v)
# This slightly awkward spelling is needed to support Python 2 and 3.
key = next(iter(v))
val = v[key]
if key == INTEGER:
return int(val)
if key == NUMBER:
if v == NOT_A_NUMBER:
return NAN_VALUE
if v == PLUS_INFINITY:
return INF_VALUE
if v == MINUS_INFINITY:
return NINF_VALUE
assert False, repr(v) # pragma: no cover
if key == TIMESTAMP:
return Date.from_json(v)
if key == BLOB:
return Bytes.from_json(v)
assert False, repr(v) # pragma: no cover
return v
def _value_to_json(v):
if isinstance(v, (int, long)) and not isinstance(v, bool):
return {INTEGER: str(v)}
if isinstance(v, float):
if math.isinf(v):
if v > 0:
return PLUS_INFINITY
else:
return MINUS_INFINITY
if math.isnan(v):
return NOT_A_NUMBER
if isinstance(v, (Bytes, Date)):
return v.to_json()
return v
def _op_from_json(val):
assert _is_op(val), repr(val)
opid = val[0]
if opid == ValuePut:
return [opid, _value_from_json(val[1])]
if opid in (ListPut, ListInsert):
return [opid, val[1], _value_from_json(val[2])]
return list(val)
def _op_to_json(val):
assert _is_op(val), repr(val)
opid = val[0]
if opid == ValuePut:
return [opid, _value_to_json(val[1])]
if opid in (ListPut, ListInsert):
return [opid, val[1], _value_to_json(val[2])]
return list(val)
def _get_op_value(op):
assert _is_op(op), repr(op)
opid = op[0]
if opid == ValuePut:
return op[1]
if opid in (ListPut, ListInsert):
return op[2]
return None
def _is_op(val):
return isinstance(val, list) and val and val[0] in ALL_OPS
def _is_listop(val):
return isinstance(val, list) and val and val[0] in LIST_OPS
def _list_move(old, index, newindex):
if index <= newindex:
return (old[:index] + old[index+1:newindex+1] +
old[index:index+1] + old[newindex+1:])
else:
return(old[:newindex] + old[index:index+1] +
old[newindex:index] + old[index+1:])
def _make_list_create():
return [ListCreate]
def _make_list_put(index, value):
return [ListPut, index, value]
def _make_list_insert(index, value):
return [ListInsert, index, value]
def _make_list_delete(index):
return [ListDelete, index]
def _make_list_move(index, newindex):
return [ListMove, index, newindex]