vdirsyncer/vdirsyncer/storage/filesystem.py
2015-02-22 14:29:31 +01:00

176 lines
6 KiB
Python

# -*- coding: utf-8 -*-
import errno
import os
import subprocess
import uuid
from atomicwrites import atomic_write
from .base import Item, Storage
from .. import exceptions, log
from ..utils import checkdir, expand_path, get_etag_from_file, \
get_etag_from_fileobject
from ..utils.compat import text_type
logger = log.get(__name__)
class FilesystemStorage(Storage):
'''
Saves each item in its own file, given a directory.
Can be used with `khal <http://lostpackets.de/khal/>`_. See :doc:`vdir` for
a more formal description of the format.
:param path: Absolute path to a vdir or collection, depending on the
collection parameter (see :py:class:`vdirsyncer.storage.base.Storage`).
:param fileext: The file extension to use (e.g. ``.txt``). Contained in the
href, so if you change the file extension after a sync, this will
trigger a re-download of everything (but *should* not cause data-loss
of any kind).
:param encoding: File encoding for items.
:param post_hook: A command to call for each item creation and
modification. The command will be called with the path of the
new/updated file.
'''
storage_name = 'filesystem'
_repr_attributes = ('path',)
def __init__(self, path, fileext, encoding='utf-8', post_hook=None,
**kwargs):
super(FilesystemStorage, self).__init__(**kwargs)
path = expand_path(path)
checkdir(path, create=False)
self.path = path
self.encoding = encoding
self.fileext = fileext
self.post_hook = post_hook
@classmethod
def discover(cls, path, **kwargs):
if kwargs.pop('collection', None) is not None:
raise TypeError('collection argument must not be given.')
path = expand_path(path)
try:
collections = os.listdir(path)
except OSError as e:
if e.errno != errno.ENOENT:
raise
else:
for collection in collections:
collection_path = os.path.join(path, collection)
if os.path.isdir(collection_path):
args = dict(collection=collection, path=collection_path,
**kwargs)
yield args
@classmethod
def create_collection(cls, collection, **kwargs):
if collection is not None:
kwargs['path'] = os.path.join(kwargs['path'], collection)
checkdir(kwargs['path'], create=True)
kwargs['collection'] = collection
return kwargs
def _get_filepath(self, href):
return os.path.join(self.path, href)
def _deterministic_href(self, item):
# XXX: POSIX only defines / and \0 as invalid chars, but we should make
# this work crossplatform.
return item.ident.replace('/', '_') + self.fileext
def _random_href(self):
return str(uuid.uuid4()) + self.fileext
def list(self):
for fname in os.listdir(self.path):
fpath = os.path.join(self.path, fname)
if os.path.isfile(fpath) and fname.endswith(self.fileext):
yield fname, get_etag_from_file(fpath)
def get(self, href):
fpath = self._get_filepath(href)
try:
with open(fpath, 'rb') as f:
return (Item(f.read().decode(self.encoding)),
get_etag_from_file(fpath))
except IOError as e:
if e.errno == errno.ENOENT:
raise exceptions.NotFoundError(href)
else:
raise
def upload(self, item):
if not isinstance(item.raw, text_type):
raise TypeError('item.raw must be a unicode string.')
try:
href = self._deterministic_href(item)
fpath, etag = self._upload_impl(item, href)
except OSError as e:
if e.errno in (
errno.ENAMETOOLONG, # Unix
errno.ENOENT # Windows
):
logger.debug('UID as filename rejected, trying with random '
'one.')
href = self._random_href()
fpath, etag = self._upload_impl(item, href)
else:
raise
if self.post_hook:
self._run_post_hook(fpath)
return href, etag
def _upload_impl(self, item, href):
fpath = self._get_filepath(href)
try:
with atomic_write(fpath, mode='wb', overwrite=False) as f:
f.write(item.raw.encode(self.encoding))
return fpath, get_etag_from_fileobject(f)
except OSError as e:
if e.errno == errno.EEXIST:
raise exceptions.AlreadyExistingError(item)
else:
raise
def update(self, href, item, etag):
fpath = self._get_filepath(href)
if not os.path.exists(fpath):
raise exceptions.NotFoundError(item.uid)
actual_etag = get_etag_from_file(fpath)
if etag != actual_etag:
raise exceptions.WrongEtagError(etag, actual_etag)
if not isinstance(item.raw, text_type):
raise TypeError('item.raw must be a unicode string.')
with atomic_write(fpath, mode='wb', overwrite=True) as f:
f.write(item.raw.encode(self.encoding))
etag = get_etag_from_fileobject(f)
if self.post_hook:
self._run_post_hook(fpath)
return etag
def delete(self, href, etag):
fpath = self._get_filepath(href)
if not os.path.isfile(fpath):
raise exceptions.NotFoundError(href)
actual_etag = get_etag_from_file(fpath)
if etag != actual_etag:
raise exceptions.WrongEtagError(etag, actual_etag)
os.remove(fpath)
def _run_post_hook(self, fpath):
logger.info('Calling post_hook={} with argument={}'.format(
self.post_hook, fpath))
try:
subprocess.call([self.post_hook, fpath])
except OSError as e:
logger.warning('Error executing external hook: {}'.format(str(e)))