Simple metadata interface

This commit is contained in:
Markus Unterwaditzer 2015-04-28 12:57:38 +02:00
parent f087ec599e
commit a007828f87
9 changed files with 155 additions and 1 deletions

View file

@ -43,10 +43,28 @@ the client, which are free to choose a different scheme for filenames instead.
.. _CardDAV: http://tools.ietf.org/html/rfc6352
.. _CalDAV: http://tools.ietf.org/search/rfc4791
Metadata
========
Any of the below metadata files may be absent. None of the files listed below
have any file extensions.
- A file called ``color`` inside the vdir indicates the vdir's color, a
property that is only relevant in UI design.
Its content is an ASCII-encoded hex-RGB value of the form ``#RRGGBB``. For
example, a file content of ``#FF0000`` indicates that the vdir has a red
(user-visible) color. No short forms or informal values such as ``red`` (as
known from CSS, for example) are allowed. The prefixing ``#`` must be
present.
- A file called ``displayname`` contains a UTF-8 encoded label that may be used
to represent the vdir in UIs.
Writing to vdirs
================
Creating and modifying items *should* happen atomically_.
Creating and modifying items or metadata files *should* happen atomically_.
Writing to a temporary file on the same physical device, and then moving it to
the appropriate location is usually a very effective solution. For this

View file

@ -21,6 +21,7 @@ def format_item(item_template, uid=None):
class StorageTests(object):
storage_class = None
supports_collections = True
supports_metadata = True
@pytest.fixture(params=['VEVENT', 'VTODO', 'VCARD'])
def item_type(self, request):
@ -55,6 +56,11 @@ class StorageTests(object):
if not self.supports_collections:
pytest.skip('This storage does not support collections.')
@pytest.fixture
def requires_metadata(self):
if not self.supports_metadata:
pytest.skip('This storage does not support metadata.')
def test_generic(self, s, get_item):
items = [get_item() for i in range(1, 10)]
hrefs = []
@ -227,3 +233,13 @@ class StorageTests(object):
s = self.storage_class(**get_storage_args(collection=collname))
href, etag = s.upload(get_item())
s.get(href)
def test_metadata(self, requires_metadata, s):
try:
s.set_meta('color', u'#ff0000')
assert s.get_meta('color') == u'#ff0000'
except exceptions.UnsupportedMetadataError:
pass
s.set_meta('displayname', u'hello world')
assert s.get_meta('displayname') == u'hello world'

View file

@ -43,6 +43,7 @@ class CombinedStorage(Storage):
class TestHttpStorage(StorageTests):
storage_class = CombinedStorage
supports_collections = False
supports_metadata = False
@pytest.fixture(autouse=True)
def setup_tmpdir(self, tmpdir, monkeypatch):

View file

@ -11,6 +11,7 @@ class TestSingleFileStorage(StorageTests):
storage_class = SingleFileStorage
supports_collections = False
supports_metadata = False
@pytest.fixture(autouse=True)
def setup(self, tmpdir):

View file

@ -56,3 +56,7 @@ class ReadOnlyError(Error):
class InvalidResponse(Error, ValueError):
'''The backend returned an invalid result.'''
class UnsupportedMetadataError(Error, NotImplementedError):
'''The storage doesn't support this type of metadata.'''

View file

@ -208,3 +208,25 @@ class Storage(with_metaclass(StorageMeta)):
when.
'''
yield
def get_meta(self, key):
'''Get metadata value for collection/storage.
See the vdir specification for the keys that *have* to be accepted.
:param key: The metadata key.
:type key: unicode
'''
raise NotImplementedError('This storage does not support metadata.')
def set_meta(self, key, value):
'''Get metadata value for collection/storage.
:param key: The metadata key.
:type key: unicode
:param value: The value.
:type value: unicode
'''
raise NotImplementedError('This storage does not support metadata.')

View file

@ -336,6 +336,10 @@ class DavStorage(Storage):
_session = None
_repr_attributes = ('username', 'url')
_property_table = {
'displayname': ('displayname', 'DAV:'),
}
def __init__(self, url, username='', password='', verify=True, auth=None,
useragent=USERAGENT, unsafe_href_chars='@',
verify_fingerprint=None, auth_cert=None, **kwargs):
@ -546,6 +550,65 @@ class DavStorage(Storage):
for href, etag, prop in rv:
yield utils.compat.urlunquote(href), etag
def get_meta(self, key):
try:
tagname, namespace = self._property_table[key]
except KeyError:
raise exceptions.UnsupportedMetadataError()
lxml_selector = '{%s}%s' % (namespace, tagname)
data = '''<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:">
<D:prop>
{}
</D:prop>
</D:propfind>
'''.format(
to_native(etree.tostring(etree.Element(lxml_selector)))
)
headers = self.session.get_default_headers()
headers['Depth'] = 0
response = self.session.request(
'PROPFIND', '',
data=data, headers=headers
)
root = _parse_xml(response.content)
for prop in root.findall('.//' + lxml_selector):
text = getattr(prop, 'text', None)
if text:
return text
def set_meta(self, key, value):
try:
tagname, namespace = self._property_table[key]
except KeyError:
raise exceptions.UnsupportedMetadataError()
lxml_selector = '{%s}%s' % (namespace, tagname)
element = etree.Element(lxml_selector)
element.text = value
data = '''<?xml version="1.0" encoding="utf-8" ?>
<D:propertyupdate xmlns:D="DAV:">
<D:set>
<D:prop>
{}
</D:prop>
</D:set>
</D:propertyupdate>
'''.format(to_native(etree.tostring(element)))
self.session.request(
'PROPPATCH', '',
data=data, headers=self.session.get_default_headers()
)
# FIXME: Deal with response
class CaldavStorage(DavStorage):
@ -598,6 +661,11 @@ class CaldavStorage(DavStorage):
get_multi_data_query = '{urn:ietf:params:xml:ns:caldav}calendar-data'
_property_table = dict(DavStorage._property_table)
_property_table.update({
'color': ('calendar-color', 'http://apple.com/ns/ical/'),
})
def __init__(self, start_date=None, end_date=None,
item_types=(), **kwargs):
super(CaldavStorage, self).__init__(**kwargs)

View file

@ -180,3 +180,20 @@ class FilesystemStorage(Storage):
subprocess.call([self.post_hook, fpath])
except OSError as e:
logger.warning('Error executing external hook: {}'.format(str(e)))
def get_meta(self, key):
fpath = os.path.join(self.path, key)
try:
with open(fpath, 'rb') as f:
return f.read().decode(self.encoding)
except IOError as e:
if e.errno == errno.ENOENT:
return None
else:
raise
def set_meta(self, key, value):
assert isinstance(value, text_type)
fpath = os.path.join(self.path, key)
with atomic_write(fpath, mode='wb', overwrite=True) as f:
f.write(value.encode(self.encoding))

View file

@ -21,6 +21,7 @@ class MemoryStorage(Storage):
raise exceptions.UserError('MemoryStorage does not support '
'collections.')
self.items = {} # href => (etag, item)
self.metadata = {}
self.fileext = fileext
super(MemoryStorage, self).__init__(**kwargs)
@ -63,3 +64,9 @@ class MemoryStorage(Storage):
if etag != self.items[href][0]:
raise exceptions.WrongEtagError(etag)
del self.items[href]
def get_meta(self, key):
return self.metadata[key]
def set_meta(self, key, value):
self.metadata[key] = value