mirror of
https://github.com/samsonjs/vdirsyncer.git
synced 2026-04-27 14:57:41 +00:00
parent
bf79ac1748
commit
37a1eb2fdb
8 changed files with 10 additions and 337 deletions
|
|
@ -9,6 +9,11 @@ Package maintainers and users who have to manually update their installation
|
||||||
may want to subscribe to `GitHub's tag feed
|
may want to subscribe to `GitHub's tag feed
|
||||||
<https://github.com/pimutils/vdirsyncer/tags.atom>`_.
|
<https://github.com/pimutils/vdirsyncer/tags.atom>`_.
|
||||||
|
|
||||||
|
Version 0.16.1
|
||||||
|
==============
|
||||||
|
|
||||||
|
- Removed remoteStorage support, see :gh:`647`.
|
||||||
|
|
||||||
Version 0.16.0
|
Version 0.16.0
|
||||||
==============
|
==============
|
||||||
|
|
||||||
|
|
|
||||||
4
Makefile
4
Makefile
|
|
@ -1,7 +1,6 @@
|
||||||
# See the documentation on how to run the tests.
|
# See the documentation on how to run the tests.
|
||||||
|
|
||||||
export DAV_SERVER := skip
|
export DAV_SERVER := skip
|
||||||
export REMOTESTORAGE_SERVER := skip
|
|
||||||
export REQUIREMENTS := release
|
export REQUIREMENTS := release
|
||||||
export TESTSERVER_BASE := ./tests/storage/servers/
|
export TESTSERVER_BASE := ./tests/storage/servers/
|
||||||
export CI := false
|
export CI := false
|
||||||
|
|
@ -42,7 +41,7 @@ all:
|
||||||
|
|
||||||
install-servers:
|
install-servers:
|
||||||
set -ex; \
|
set -ex; \
|
||||||
for server in $(DAV_SERVER) $(REMOTESTORAGE_SERVER); do \
|
for server in $(DAV_SERVER); do \
|
||||||
if [ ! "$$(ls $(TESTSERVER_BASE)$$server/)" ]; then \
|
if [ ! "$$(ls $(TESTSERVER_BASE)$$server/)" ]; then \
|
||||||
git submodule update --init -- "$(TESTSERVER_BASE)$$server"; \
|
git submodule update --init -- "$(TESTSERVER_BASE)$$server"; \
|
||||||
fi; \
|
fi; \
|
||||||
|
|
@ -90,7 +89,6 @@ release:
|
||||||
|
|
||||||
install-dev:
|
install-dev:
|
||||||
pip install -e .
|
pip install -e .
|
||||||
[ "$(REMOTESTORAGE_SERVER)" = "skip" ] || pip install -e .[remotestorage]
|
|
||||||
[ "$(ETESYNC_TESTS)" = "false" ] || pip install -e .[etesync]
|
[ "$(ETESYNC_TESTS)" = "false" ] || pip install -e .[etesync]
|
||||||
set -xe && if [ "$(REQUIREMENTS)" = "devel" ]; then \
|
set -xe && if [ "$(REQUIREMENTS)" = "devel" ]; then \
|
||||||
pip install -U --force-reinstall \
|
pip install -U --force-reinstall \
|
||||||
|
|
|
||||||
|
|
@ -208,24 +208,6 @@ or write anything to it.
|
||||||
|
|
||||||
.. autostorage:: vdirsyncer.storage.google.GoogleContactsStorage
|
.. autostorage:: vdirsyncer.storage.google.GoogleContactsStorage
|
||||||
|
|
||||||
remoteStorage
|
|
||||||
+++++++++++++
|
|
||||||
|
|
||||||
`remoteStorage <https://remotestorage.io/>`_ is an open per-user data storage
|
|
||||||
protocol. Vdirsyncer contains **highly experimental support** for it.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
|
|
||||||
Do not use this storage if you're not prepared for data-loss and breakage.
|
|
||||||
|
|
||||||
To use them, you need to install some optional dependencies with::
|
|
||||||
|
|
||||||
pip install vdirsyncer[remotestorage]
|
|
||||||
|
|
||||||
.. autostorage:: vdirsyncer.storage.remotestorage.RemoteStorageContacts
|
|
||||||
|
|
||||||
.. autostorage:: vdirsyncer.storage.remotestorage.RemoteStorageCalendars
|
|
||||||
|
|
||||||
EteSync
|
EteSync
|
||||||
+++++++
|
+++++++
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,25 +51,19 @@ for python, requirements in itertools.product(python_versions,
|
||||||
else:
|
else:
|
||||||
dav_servers = ("radicale", "xandikos")
|
dav_servers = ("radicale", "xandikos")
|
||||||
|
|
||||||
rs_servers = ()
|
|
||||||
if python == latest_python and requirements == "release":
|
if python == latest_python and requirements == "release":
|
||||||
dav_servers += ("owncloud", "nextcloud", "baikal", "davical",
|
dav_servers += ("owncloud", "nextcloud", "baikal", "davical",
|
||||||
"fastmail")
|
"fastmail")
|
||||||
|
|
||||||
for server_type, server in itertools.chain(
|
for dav_server in dav_servers:
|
||||||
(("REMOTESTORAGE", x) for x in rs_servers),
|
build_prs = dav_server not in ("fastmail", "davical", "icloud")
|
||||||
(("DAV", x) for x in dav_servers)
|
|
||||||
):
|
|
||||||
|
|
||||||
build_prs = server not in ("fastmail", "davical", "icloud")
|
|
||||||
matrix.append({
|
matrix.append({
|
||||||
'python': python,
|
'python': python,
|
||||||
'env': ("BUILD=test "
|
'env': ("BUILD=test "
|
||||||
"{server_type}_SERVER={server} "
|
"DAV_SERVER={dav_server} "
|
||||||
"REQUIREMENTS={requirements} "
|
"REQUIREMENTS={requirements} "
|
||||||
"BUILD_PRS={build_prs} "
|
"BUILD_PRS={build_prs} "
|
||||||
.format(server_type=server_type,
|
.format(dav_server=dav_server,
|
||||||
server=server,
|
|
||||||
requirements=requirements,
|
requirements=requirements,
|
||||||
build_prs=build_prs and "true" or "false"))
|
build_prs=build_prs and "true" or "false"))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
1
setup.py
1
setup.py
|
|
@ -70,7 +70,6 @@ setup(
|
||||||
|
|
||||||
# Optional dependencies
|
# Optional dependencies
|
||||||
extras_require={
|
extras_require={
|
||||||
'remotestorage': ['requests-oauthlib'],
|
|
||||||
'google': ['requests-oauthlib'],
|
'google': ['requests-oauthlib'],
|
||||||
'etesync': ['etesync']
|
'etesync': ['etesync']
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from vdirsyncer.storage.remotestorage import \
|
|
||||||
RemoteStorageCalendars, RemoteStorageContacts
|
|
||||||
|
|
||||||
from . import StorageTests, get_server_mixin
|
|
||||||
|
|
||||||
remotestorage_server = os.environ['REMOTESTORAGE_SERVER']
|
|
||||||
ServerMixin = get_server_mixin(remotestorage_server)
|
|
||||||
|
|
||||||
|
|
||||||
class RemoteStorageTests(ServerMixin, StorageTests):
|
|
||||||
remotestorage_server = remotestorage_server
|
|
||||||
|
|
||||||
|
|
||||||
class TestCalendars(RemoteStorageTests):
|
|
||||||
storage_class = RemoteStorageCalendars
|
|
||||||
|
|
||||||
@pytest.fixture(params=['VTODO', 'VEVENT'])
|
|
||||||
def item_type(self, request):
|
|
||||||
return request.param
|
|
||||||
|
|
||||||
|
|
||||||
class TestContacts(RemoteStorageTests):
|
|
||||||
storage_class = RemoteStorageContacts
|
|
||||||
supports_collections = False
|
|
||||||
|
|
||||||
@pytest.fixture(params=['VCARD'])
|
|
||||||
def item_type(self, request):
|
|
||||||
return request.param
|
|
||||||
|
|
@ -34,10 +34,6 @@ class _StorageIndex(object):
|
||||||
filesystem='vdirsyncer.storage.filesystem.FilesystemStorage',
|
filesystem='vdirsyncer.storage.filesystem.FilesystemStorage',
|
||||||
http='vdirsyncer.storage.http.HttpStorage',
|
http='vdirsyncer.storage.http.HttpStorage',
|
||||||
singlefile='vdirsyncer.storage.singlefile.SingleFileStorage',
|
singlefile='vdirsyncer.storage.singlefile.SingleFileStorage',
|
||||||
remotestorage_contacts=(
|
|
||||||
'vdirsyncer.storage.remotestorage.RemoteStorageContacts'),
|
|
||||||
remotestorage_calendars=(
|
|
||||||
'vdirsyncer.storage.remotestorage.RemoteStorageCalendars'),
|
|
||||||
google_calendar='vdirsyncer.storage.google.GoogleCalendarStorage',
|
google_calendar='vdirsyncer.storage.google.GoogleCalendarStorage',
|
||||||
google_contacts='vdirsyncer.storage.google.GoogleContactsStorage',
|
google_contacts='vdirsyncer.storage.google.GoogleContactsStorage',
|
||||||
etesync_calendars='vdirsyncer.storage.etesync.EtesyncCalendars',
|
etesync_calendars='vdirsyncer.storage.etesync.EtesyncCalendars',
|
||||||
|
|
|
||||||
|
|
@ -1,266 +0,0 @@
|
||||||
'''
|
|
||||||
A storage type for accessing contact and calendar data from `remoteStorage
|
|
||||||
<https://remotestorage.io>`_. It is highly experimental.
|
|
||||||
|
|
||||||
A few things are hardcoded for now so the user doesn't have to specify those
|
|
||||||
things, and plugging in an account "just works".
|
|
||||||
'''
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from urllib.parse import quote as urlquote, urljoin
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
from .base import Storage, normalize_meta_value
|
|
||||||
from .http import HTTP_STORAGE_PARAMETERS, prepare_client_cert, \
|
|
||||||
prepare_verify
|
|
||||||
from .. import exceptions, utils, http
|
|
||||||
from ..vobject import Item
|
|
||||||
|
|
||||||
REDIRECT_URI = 'https://vdirsyncer.5apps.com/'
|
|
||||||
CLIENT_ID = 'https://vdirsyncer.5apps.com'
|
|
||||||
DRAFT_VERSION = '05'
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_slash(dir):
|
|
||||||
return dir.rstrip('/') + '/'
|
|
||||||
|
|
||||||
|
|
||||||
def _iter_listing(json):
|
|
||||||
new_listing = '@context' in json # draft-02 and beyond
|
|
||||||
if new_listing:
|
|
||||||
json = json['items']
|
|
||||||
for name, info in json.items():
|
|
||||||
if not new_listing:
|
|
||||||
info = {'ETag': info}
|
|
||||||
yield name, info
|
|
||||||
|
|
||||||
|
|
||||||
class Session(object):
|
|
||||||
|
|
||||||
def __init__(self, account, scope, verify=True, verify_fingerprint=None,
|
|
||||||
auth_cert=None, access_token=None, collection=None):
|
|
||||||
from oauthlib.oauth2 import MobileApplicationClient
|
|
||||||
from requests_oauthlib import OAuth2Session
|
|
||||||
|
|
||||||
self.user, self.host = account.split('@')
|
|
||||||
|
|
||||||
self._settings = {
|
|
||||||
'cert': prepare_client_cert(auth_cert)
|
|
||||||
}
|
|
||||||
self._settings.update(prepare_verify(verify, verify_fingerprint))
|
|
||||||
|
|
||||||
self.scope = scope + ':rw'
|
|
||||||
self._session = OAuth2Session(
|
|
||||||
CLIENT_ID, client=MobileApplicationClient(CLIENT_ID),
|
|
||||||
scope=self.scope,
|
|
||||||
redirect_uri=REDIRECT_URI,
|
|
||||||
token={'access_token': access_token},
|
|
||||||
)
|
|
||||||
|
|
||||||
subpath = scope
|
|
||||||
if collection:
|
|
||||||
subpath = urljoin(_ensure_slash(scope),
|
|
||||||
_ensure_slash(urlquote(collection)))
|
|
||||||
|
|
||||||
self._discover_endpoints(subpath)
|
|
||||||
|
|
||||||
if not access_token:
|
|
||||||
self._get_access_token()
|
|
||||||
|
|
||||||
def request(self, method, path, **kwargs):
|
|
||||||
url = self.endpoints['storage']
|
|
||||||
if path:
|
|
||||||
url = urljoin(url, path)
|
|
||||||
|
|
||||||
settings = dict(self._settings)
|
|
||||||
settings.update(kwargs)
|
|
||||||
|
|
||||||
return http.request(method, url, session=self._session, **settings)
|
|
||||||
|
|
||||||
def _get_access_token(self):
|
|
||||||
authorization_url, state = \
|
|
||||||
self._session.authorization_url(self.endpoints['oauth'])
|
|
||||||
|
|
||||||
click.echo('Opening {} ...'.format(authorization_url))
|
|
||||||
try:
|
|
||||||
utils.open_graphical_browser(authorization_url)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(str(e))
|
|
||||||
|
|
||||||
click.echo('Follow the instructions on the page.')
|
|
||||||
raise exceptions.UserError('Aborted!')
|
|
||||||
|
|
||||||
def _discover_endpoints(self, subpath):
|
|
||||||
r = http.request(
|
|
||||||
'GET', 'https://{host}/.well-known/webfinger?resource=acct:{user}'
|
|
||||||
.format(host=self.host, user=self.user),
|
|
||||||
**self._settings
|
|
||||||
)
|
|
||||||
j = r.json()
|
|
||||||
for link in j['links']:
|
|
||||||
if 'remotestorage' in link['rel']:
|
|
||||||
break
|
|
||||||
|
|
||||||
storage = urljoin(_ensure_slash(link['href']),
|
|
||||||
_ensure_slash(subpath))
|
|
||||||
props = link['properties']
|
|
||||||
oauth = props['http://tools.ietf.org/html/rfc6749#section-4.2']
|
|
||||||
self.endpoints = dict(storage=storage, oauth=oauth)
|
|
||||||
|
|
||||||
|
|
||||||
class RemoteStorage(Storage):
|
|
||||||
__doc__ = '''
|
|
||||||
:param account: remoteStorage account, ``"user@example.com"``.
|
|
||||||
''' + HTTP_STORAGE_PARAMETERS + '''
|
|
||||||
'''
|
|
||||||
|
|
||||||
storage_name = None
|
|
||||||
item_mimetype = None
|
|
||||||
fileext = None
|
|
||||||
|
|
||||||
def __init__(self, account, verify=True, verify_fingerprint=None,
|
|
||||||
auth_cert=None, access_token=None, **kwargs):
|
|
||||||
super(RemoteStorage, self).__init__(**kwargs)
|
|
||||||
self.session = Session(
|
|
||||||
account=account,
|
|
||||||
verify=verify,
|
|
||||||
verify_fingerprint=verify_fingerprint,
|
|
||||||
auth_cert=auth_cert,
|
|
||||||
access_token=access_token,
|
|
||||||
collection=self.collection,
|
|
||||||
scope=self.scope)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def discover(cls, **base_args):
|
|
||||||
if base_args.pop('collection', None) is not None:
|
|
||||||
raise TypeError('collection argument must not be given.')
|
|
||||||
|
|
||||||
session_args, _ = utils.split_dict(base_args, lambda key: key in (
|
|
||||||
'account', 'verify', 'auth', 'verify_fingerprint', 'auth_cert',
|
|
||||||
'access_token'
|
|
||||||
))
|
|
||||||
|
|
||||||
session = Session(scope=cls.scope, **session_args)
|
|
||||||
|
|
||||||
try:
|
|
||||||
r = session.request('GET', '')
|
|
||||||
except exceptions.NotFoundError:
|
|
||||||
return
|
|
||||||
|
|
||||||
for name, _info in _iter_listing(r.json()):
|
|
||||||
if not name.endswith('/'):
|
|
||||||
continue # not a folder
|
|
||||||
|
|
||||||
newargs = dict(base_args)
|
|
||||||
newargs['collection'] = name.rstrip('/')
|
|
||||||
yield newargs
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create_collection(cls, collection, **kwargs):
|
|
||||||
# remoteStorage folders are autocreated.
|
|
||||||
assert collection
|
|
||||||
assert '/' not in collection
|
|
||||||
kwargs['collection'] = collection
|
|
||||||
return kwargs
|
|
||||||
|
|
||||||
def list(self):
|
|
||||||
try:
|
|
||||||
r = self.session.request('GET', '')
|
|
||||||
except exceptions.NotFoundError:
|
|
||||||
return
|
|
||||||
|
|
||||||
for name, info in _iter_listing(r.json()):
|
|
||||||
if not name.endswith(self.fileext):
|
|
||||||
continue
|
|
||||||
|
|
||||||
etag = info['ETag']
|
|
||||||
etag = '"' + etag + '"'
|
|
||||||
yield name, etag
|
|
||||||
|
|
||||||
def _put(self, href, item, etag):
|
|
||||||
headers = {'Content-Type': self.item_mimetype + '; charset=UTF-8'}
|
|
||||||
if etag is None:
|
|
||||||
headers['If-None-Match'] = '*'
|
|
||||||
else:
|
|
||||||
headers['If-Match'] = etag
|
|
||||||
|
|
||||||
response = self.session.request(
|
|
||||||
'PUT',
|
|
||||||
href,
|
|
||||||
data=item.raw.encode('utf-8'),
|
|
||||||
headers=headers
|
|
||||||
)
|
|
||||||
return href, response.headers['etag']
|
|
||||||
|
|
||||||
def update(self, href, item, etag):
|
|
||||||
assert etag
|
|
||||||
href, etag = self._put(href, item, etag)
|
|
||||||
return etag
|
|
||||||
|
|
||||||
def upload(self, item):
|
|
||||||
href = utils.generate_href(item.ident) + self.fileext
|
|
||||||
return self._put(href, item, None)
|
|
||||||
|
|
||||||
def delete(self, href, etag):
|
|
||||||
headers = {'If-Match': etag}
|
|
||||||
self.session.request('DELETE', href, headers=headers)
|
|
||||||
|
|
||||||
def get(self, href):
|
|
||||||
response = self.session.request('GET', href)
|
|
||||||
return Item(response.text), response.headers['etag']
|
|
||||||
|
|
||||||
def get_meta(self, key):
|
|
||||||
try:
|
|
||||||
return normalize_meta_value(self.session.request('GET', key).text)
|
|
||||||
except exceptions.NotFoundError:
|
|
||||||
return u''
|
|
||||||
|
|
||||||
def set_meta(self, key, value):
|
|
||||||
self.session.request(
|
|
||||||
'PUT',
|
|
||||||
key,
|
|
||||||
data=normalize_meta_value(value).encode('utf-8'),
|
|
||||||
headers={'Content-Type': 'text/plain; charset=utf-8'}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RemoteStorageContacts(RemoteStorage):
|
|
||||||
__doc__ = '''
|
|
||||||
remoteStorage contacts. Uses the `vdir_contacts` scope.
|
|
||||||
''' + RemoteStorage.__doc__
|
|
||||||
|
|
||||||
storage_name = 'remotestorage_contacts'
|
|
||||||
fileext = '.vcf'
|
|
||||||
item_mimetype = 'text/vcard'
|
|
||||||
scope = 'vdir_contacts'
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
if kwargs.get('collection'):
|
|
||||||
raise ValueError(
|
|
||||||
'No collections allowed for contacts, '
|
|
||||||
'there is only one addressbook. '
|
|
||||||
'Use the vcard groups construct to categorize your contacts '
|
|
||||||
'into groups.'
|
|
||||||
)
|
|
||||||
|
|
||||||
super(RemoteStorageContacts, self).__init__(**kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class RemoteStorageCalendars(RemoteStorage):
|
|
||||||
__doc__ = '''
|
|
||||||
remoteStorage calendars. Uses the `vdir_calendars` scope.
|
|
||||||
''' + RemoteStorage.__doc__
|
|
||||||
|
|
||||||
storage_name = 'remotestorage_calendars'
|
|
||||||
fileext = '.ics'
|
|
||||||
item_mimetype = 'text/calendar'
|
|
||||||
scope = 'vdir_calendars'
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
if not kwargs.get('collection'):
|
|
||||||
raise exceptions.CollectionRequired()
|
|
||||||
|
|
||||||
super(RemoteStorageCalendars, self).__init__(**kwargs)
|
|
||||||
Loading…
Reference in a new issue