mirror of
https://github.com/samsonjs/vdirsyncer.git
synced 2026-04-12 11:55:53 +00:00
176 lines
6 KiB
Python
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)))
|