mirror of
https://github.com/samsonjs/vdirsyncer.git
synced 2026-03-25 08:55:50 +00:00
Merge branch 'origin/master' into meta_description
This commit is contained in:
commit
e70e8c03e8
113 changed files with 3378 additions and 3314 deletions
39
.builds/archlinux.yaml
Normal file
39
.builds/archlinux.yaml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Run tests using the packaged dependencies on ArchLinux.
|
||||
|
||||
image: archlinux
|
||||
packages:
|
||||
- docker
|
||||
- docker-compose
|
||||
# Build dependencies:
|
||||
- python-pip
|
||||
- python-wheel
|
||||
# Runtime dependencies:
|
||||
- python-atomicwrites
|
||||
- python-click
|
||||
- python-click-log
|
||||
- python-click-threading
|
||||
- python-requests
|
||||
- python-requests-toolbelt
|
||||
# Test dependencies:
|
||||
- python-hypothesis
|
||||
- python-pytest-cov
|
||||
- python-pytest-localserver
|
||||
sources:
|
||||
- https://github.com/pimutils/vdirsyncer
|
||||
environment:
|
||||
BUILD: test
|
||||
CI: true
|
||||
CODECOV_TOKEN: b834a3c5-28fa-4808-9bdb-182210069c79
|
||||
DAV_SERVER: radicale xandikos
|
||||
REQUIREMENTS: release
|
||||
# TODO: ETESYNC_TESTS
|
||||
tasks:
|
||||
- setup: |
|
||||
sudo systemctl start docker
|
||||
cd vdirsyncer
|
||||
python setup.py build
|
||||
sudo pip install --no-index .
|
||||
- test: |
|
||||
cd vdirsyncer
|
||||
make -e ci-test
|
||||
make -e ci-test-storage
|
||||
30
.builds/tests-minimal.yaml
Normal file
30
.builds/tests-minimal.yaml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Run tests using oldest available dependency versions.
|
||||
#
|
||||
# TODO: It might make more sense to test with an older Ubuntu or Fedora version
|
||||
# here, and consider that our "oldest suppported environment".
|
||||
|
||||
image: archlinux
|
||||
packages:
|
||||
- docker
|
||||
- docker-compose
|
||||
- python-pip
|
||||
sources:
|
||||
- https://github.com/pimutils/vdirsyncer
|
||||
environment:
|
||||
BUILD: test
|
||||
CI: true
|
||||
CODECOV_TOKEN: b834a3c5-28fa-4808-9bdb-182210069c79
|
||||
DAV_SERVER: radicale xandikos
|
||||
REQUIREMENTS: minimal
|
||||
# TODO: ETESYNC_TESTS
|
||||
tasks:
|
||||
- setup: |
|
||||
sudo systemctl start docker
|
||||
cd vdirsyncer
|
||||
make -e install-dev
|
||||
- test: |
|
||||
cd vdirsyncer
|
||||
# Non-system python is used for packages:
|
||||
export PATH=$PATH:~/.local/bin/
|
||||
make -e ci-test
|
||||
make -e ci-test-storage
|
||||
32
.builds/tests-release.yaml
Normal file
32
.builds/tests-release.yaml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Run tests using latest dependencies from PyPI
|
||||
|
||||
image: archlinux
|
||||
packages:
|
||||
- docker
|
||||
- docker-compose
|
||||
- python-pip
|
||||
sources:
|
||||
- https://github.com/pimutils/vdirsyncer
|
||||
environment:
|
||||
BUILD: test
|
||||
CI: true
|
||||
CODECOV_TOKEN: b834a3c5-28fa-4808-9bdb-182210069c79
|
||||
DAV_SERVER: baikal radicale xandikos
|
||||
REQUIREMENTS: release
|
||||
# TODO: ETESYNC_TESTS
|
||||
tasks:
|
||||
- setup: |
|
||||
sudo systemctl start docker
|
||||
cd vdirsyncer
|
||||
make -e install-dev -e install-docs
|
||||
- test: |
|
||||
cd vdirsyncer
|
||||
# Non-system python is used for packages:
|
||||
export PATH=$PATH:~/.local/bin/
|
||||
make -e ci-test
|
||||
make -e ci-test-storage
|
||||
- style: |
|
||||
cd vdirsyncer
|
||||
# Non-system python is used for packages:
|
||||
export PATH=$PATH:~/.local/bin/
|
||||
make -e style
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -14,3 +14,4 @@ dist
|
|||
docs/_build/
|
||||
vdirsyncer/version.py
|
||||
.hypothesis
|
||||
coverage.xml
|
||||
|
|
|
|||
6
.gitmodules
vendored
6
.gitmodules
vendored
|
|
@ -1,6 +0,0 @@
|
|||
[submodule "tests/storage/servers/owncloud"]
|
||||
path = tests/storage/servers/owncloud
|
||||
url = https://github.com/vdirsyncer/owncloud-testserver
|
||||
[submodule "tests/storage/servers/nextcloud"]
|
||||
path = tests/storage/servers/nextcloud
|
||||
url = https://github.com/vdirsyncer/nextcloud-testserver
|
||||
|
|
@ -1,29 +1,23 @@
|
|||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v2.4.0
|
||||
rev: v4.0.1
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
args: [--markdown-linebreak-ext=md]
|
||||
- id: end-of-file-fixer
|
||||
exclude: '.travis.yml'
|
||||
- id: check-toml
|
||||
- id: check-added-large-files
|
||||
- id: debug-statements
|
||||
- repo: https://gitlab.com/pycqa/flake8
|
||||
rev: "master" # pick a git hash / tag to point to
|
||||
rev: "3.9.2"
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies: [flake8-import-order, flake8-bugbear]
|
||||
- repo: https://github.com/psf/black
|
||||
rev: "21.6b0"
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/asottile/reorder_python_imports
|
||||
rev: v2.3.0
|
||||
rev: v2.5.0
|
||||
hooks:
|
||||
- id: reorder-python-imports
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: update-travis
|
||||
name: Update travis job definition
|
||||
description: Ensures that travis job definition are up to date.
|
||||
entry: scripts/make_travisconf.py
|
||||
files: '.*travis.*'
|
||||
stages: [commit]
|
||||
language: script
|
||||
|
|
|
|||
89
.travis.yml
89
.travis.yml
|
|
@ -1,89 +0,0 @@
|
|||
{
|
||||
"branches": {
|
||||
"only": [
|
||||
"master"
|
||||
]
|
||||
},
|
||||
"cache": "pip",
|
||||
"dist": "bionic",
|
||||
"git": {
|
||||
"submodules": false
|
||||
},
|
||||
"install": [
|
||||
". scripts/travis-install.sh",
|
||||
"make -e install-$BUILD"
|
||||
],
|
||||
"language": "python",
|
||||
"matrix": {
|
||||
"fast_finish": true,
|
||||
"include": [
|
||||
{
|
||||
"env": "BUILD=style",
|
||||
"python": "3.7"
|
||||
},
|
||||
{
|
||||
"env": "BUILD=test REQUIREMENTS=release",
|
||||
"python": "3.7"
|
||||
},
|
||||
{
|
||||
"env": "BUILD=test-storage DAV_SERVER=radicale REQUIREMENTS=release ",
|
||||
"python": "3.7"
|
||||
},
|
||||
{
|
||||
"env": "BUILD=test-storage DAV_SERVER=xandikos REQUIREMENTS=release ",
|
||||
"python": "3.7"
|
||||
},
|
||||
{
|
||||
"env": "BUILD=test-storage DAV_SERVER=fastmail REQUIREMENTS=release ",
|
||||
"python": "3.7"
|
||||
},
|
||||
{
|
||||
"env": "BUILD=test REQUIREMENTS=minimal",
|
||||
"python": "3.7"
|
||||
},
|
||||
{
|
||||
"env": "BUILD=test-storage DAV_SERVER=radicale REQUIREMENTS=minimal ",
|
||||
"python": "3.7"
|
||||
},
|
||||
{
|
||||
"env": "BUILD=test-storage DAV_SERVER=xandikos REQUIREMENTS=minimal ",
|
||||
"python": "3.7"
|
||||
},
|
||||
{
|
||||
"env": "BUILD=test REQUIREMENTS=release",
|
||||
"python": "3.8"
|
||||
},
|
||||
{
|
||||
"env": "BUILD=test-storage DAV_SERVER=radicale REQUIREMENTS=release ",
|
||||
"python": "3.8"
|
||||
},
|
||||
{
|
||||
"env": "BUILD=test-storage DAV_SERVER=xandikos REQUIREMENTS=release ",
|
||||
"python": "3.8"
|
||||
},
|
||||
{
|
||||
"env": "BUILD=test REQUIREMENTS=minimal",
|
||||
"python": "3.8"
|
||||
},
|
||||
{
|
||||
"env": "BUILD=test-storage DAV_SERVER=radicale REQUIREMENTS=minimal ",
|
||||
"python": "3.8"
|
||||
},
|
||||
{
|
||||
"env": "BUILD=test-storage DAV_SERVER=xandikos REQUIREMENTS=minimal ",
|
||||
"python": "3.8"
|
||||
},
|
||||
{
|
||||
"env": "BUILD=test ETESYNC_TESTS=true REQUIREMENTS=latest",
|
||||
"python": "3.7"
|
||||
}
|
||||
]
|
||||
},
|
||||
"script": [
|
||||
"make -e $BUILD"
|
||||
],
|
||||
"services": [
|
||||
"docker"
|
||||
],
|
||||
"sudo": true
|
||||
}
|
||||
|
|
@ -9,6 +9,19 @@ Package maintainers and users who have to manually update their installation
|
|||
may want to subscribe to `GitHub's tag feed
|
||||
<https://github.com/pimutils/vdirsyncer/tags.atom>`_.
|
||||
|
||||
Version 0.18.0
|
||||
==============
|
||||
|
||||
Note: Version 0.17 has some alpha releases but ultimately was never finalised.
|
||||
0.18 actually continues where 0.16 left off.
|
||||
|
||||
- Support for Python 3.5 and 3.6 has been dropped. This release mostly focuses
|
||||
on keeping vdirsyncer compatible with newer environments.
|
||||
- click 8 and click-threading 0.5.0 are now required.
|
||||
- For those using ``pipsi``, we now recommend using ``pipx``, it's successor.
|
||||
- Python 3.9 is now supported.
|
||||
- Our Debian/Ubuntu build scripts have been updated. New versions should be
|
||||
pushed to those repositories soon.
|
||||
- Add "description" and "order" as metadata. These fetch the CalDAV:
|
||||
calendar-description, CardDAV:addressbook-description and apple-ns:calendar-order
|
||||
properties.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# setuptools-scm includes everything tracked by git
|
||||
prune contrib
|
||||
prune docker
|
||||
prune scripts
|
||||
prune tests/storage/servers
|
||||
prune tests/storage/etesync
|
||||
|
|
|
|||
66
Makefile
66
Makefile
|
|
@ -15,7 +15,7 @@ export DETERMINISTIC_TESTS := false
|
|||
# Run the etesync testsuite.
|
||||
export ETESYNC_TESTS := false
|
||||
|
||||
# Assume to run in Travis. Don't use this outside of a virtual machine. It will
|
||||
# Assume to run in CI. Don't use this outside of a virtual machine. It will
|
||||
# heavily "pollute" your system, such as attempting to install a new Python
|
||||
# systemwide.
|
||||
export CI := false
|
||||
|
|
@ -36,55 +36,29 @@ ifeq ($(ETESYNC_TESTS), true)
|
|||
endif
|
||||
|
||||
PYTEST = py.test $(PYTEST_ARGS)
|
||||
|
||||
export TESTSERVER_BASE := ./tests/storage/servers/
|
||||
CODECOV_PATH = /tmp/codecov.sh
|
||||
|
||||
ifeq ($(CI), true)
|
||||
test-storage:
|
||||
curl -s https://codecov.io/bash > $(CODECOV_PATH)
|
||||
$(PYTEST) tests/storage/
|
||||
bash $(CODECOV_PATH) -c -F storage
|
||||
test:
|
||||
all:
|
||||
$(error Take a look at https://vdirsyncer.pimutils.org/en/stable/tutorial.html#installation)
|
||||
|
||||
ci-test:
|
||||
curl -s https://codecov.io/bash > $(CODECOV_PATH)
|
||||
$(PYTEST) tests/unit/
|
||||
bash $(CODECOV_PATH) -c -F unit
|
||||
$(PYTEST) tests/system/
|
||||
bash $(CODECOV_PATH) -c -F system
|
||||
[ "$(ETESYNC_TESTS)" = "false" ] || make test-storage
|
||||
else
|
||||
test:
|
||||
$(PYTEST)
|
||||
endif
|
||||
|
||||
all:
|
||||
$(error Take a look at https://vdirsyncer.pimutils.org/en/stable/tutorial.html#installation)
|
||||
|
||||
install-servers:
|
||||
ci-test-storage:
|
||||
curl -s https://codecov.io/bash > $(CODECOV_PATH)
|
||||
set -ex; \
|
||||
for server in $(DAV_SERVER); do \
|
||||
if [ ! "$$(ls $(TESTSERVER_BASE)$$server/)" ]; then \
|
||||
git submodule update --init -- "$(TESTSERVER_BASE)$$server"; \
|
||||
fi; \
|
||||
(cd $(TESTSERVER_BASE)$$server && sh install.sh); \
|
||||
DAV_SERVER=$$server $(PYTEST) --cov-append tests/storage; \
|
||||
done
|
||||
bash $(CODECOV_PATH) -c -F storage
|
||||
|
||||
install-test: install-servers install-dev
|
||||
pip install -Ur test-requirements.txt
|
||||
set -xe && if [ "$$REQUIREMENTS" = "devel" ]; then \
|
||||
pip install -U --force-reinstall \
|
||||
git+https://github.com/DRMacIver/hypothesis \
|
||||
git+https://github.com/kennethreitz/requests \
|
||||
git+https://github.com/pytest-dev/pytest; \
|
||||
fi
|
||||
[ -z "$(TEST_EXTRA_PACKAGES)" ] || pip install $(TEST_EXTRA_PACKAGES)
|
||||
|
||||
install-test-storage: install-test
|
||||
# This is just an alias
|
||||
true
|
||||
|
||||
install-style: install-docs install-dev
|
||||
pip install pre-commit
|
||||
test:
|
||||
$(PYTEST)
|
||||
|
||||
style:
|
||||
pre-commit run --all
|
||||
|
|
@ -97,8 +71,6 @@ install-docs:
|
|||
|
||||
docs:
|
||||
cd docs && make html
|
||||
|
||||
linkcheck:
|
||||
sphinx-build -W -b linkcheck ./docs/ ./docs/_build/linkcheck/
|
||||
|
||||
release-deb:
|
||||
|
|
@ -111,21 +83,11 @@ release-deb:
|
|||
install-dev:
|
||||
pip install -U pip setuptools wheel
|
||||
pip install -e .
|
||||
pip install -Ur test-requirements.txt $(TEST_EXTRA_PACKAGES)
|
||||
pip install pre-commit
|
||||
[ "$(ETESYNC_TESTS)" = "false" ] || pip install -Ue .[etesync]
|
||||
set -xe && if [ "$(REQUIREMENTS)" = "devel" ]; then \
|
||||
pip install -U --force-reinstall \
|
||||
git+https://github.com/mitsuhiko/click \
|
||||
git+https://github.com/kennethreitz/requests; \
|
||||
elif [ "$(REQUIREMENTS)" = "minimal" ]; then \
|
||||
set -xe && if [ "$(REQUIREMENTS)" = "minimal" ]; then \
|
||||
pip install -U --force-reinstall $$(python setup.py --quiet minimal_requirements); \
|
||||
fi
|
||||
|
||||
ssh-submodule-urls:
|
||||
git submodule foreach "\
|
||||
echo -n 'Old: '; \
|
||||
git remote get-url origin; \
|
||||
git remote set-url origin \$$(git remote get-url origin | sed -e 's/https:\/\/github\.com\//git@github.com:/g'); \
|
||||
echo -n 'New URL: '; \
|
||||
git remote get-url origin"
|
||||
|
||||
.PHONY: docs
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
vdirsyncer
|
||||
==========
|
||||
|
||||
.. image:: https://travis-ci.org/pimutils/vdirsyncer.svg?branch=master
|
||||
:target: https://travis-ci.org/pimutils/vdirsyncer
|
||||
.. image:: https://builds.sr.ht/~whynothugo/vdirsyncer.svg
|
||||
:target: https://builds.sr.ht/~whynothugo/vdirsyncer
|
||||
:alt: CI status
|
||||
|
||||
.. image:: https://codecov.io/github/pimutils/vdirsyncer/coverage.svg?branch=master
|
||||
|
|
|
|||
|
|
@ -4,4 +4,5 @@ Documentation=https://vdirsyncer.readthedocs.org/
|
|||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/vdirsyncer sync
|
||||
Type=oneshot
|
||||
RuntimeMaxSec=3m
|
||||
Restart=on-failure
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
version: '3'
|
||||
|
||||
services:
|
||||
xandikos:
|
||||
build: docker/xandikos/
|
||||
ports:
|
||||
- '8000:8000'
|
||||
|
||||
radicale:
|
||||
build: docker/radicale/
|
||||
ports:
|
||||
- '8001:8001'
|
||||
|
||||
baikal:
|
||||
build: docker/baikal/
|
||||
ports:
|
||||
- '8002:80'
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
# Based on https://github.com/ckulka/baikal-docker
|
||||
# Sadly, we can't override the VOLUME it has set, and we want some static
|
||||
# config.
|
||||
FROM php:7.4-apache
|
||||
ENV VERSION 0.7.0
|
||||
|
||||
ADD https://github.com/sabre-io/Baikal/releases/download/$VERSION/baikal-$VERSION.zip .
|
||||
RUN apt-get update && apt-get install -y sqlite3 unzip
|
||||
RUN unzip -q baikal-$VERSION.zip -d /var/www/
|
||||
|
||||
RUN chown -R www-data:www-data /var/www/baikal && \
|
||||
docker-php-ext-install pdo pdo_mysql
|
||||
|
||||
COPY apache.conf /etc/apache2/sites-enabled/000-default.conf
|
||||
COPY start.sh /opt/
|
||||
RUN a2enmod rewrite
|
||||
|
||||
COPY baikal.yaml /var/www/baikal/config/baikal.yaml
|
||||
COPY configure.sql /configure.sql
|
||||
|
||||
RUN touch /var/www/baikal/Specific/INSTALL_DISABLED
|
||||
RUN cat /configure.sql | sqlite3 /var/www/baikal/Specific/db/db.sqlite
|
||||
|
||||
RUN chmod -R 777 /var/www/baikal/Specific/ /var/www/baikal/config/
|
||||
|
||||
CMD [ "sh", "/opt/start.sh" ]
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
# Shameless copied from https://github.com/ckulka/baikal-docker/blob/master/files/apache.conf
|
||||
|
||||
<VirtualHost *:80>
|
||||
|
||||
# InjectedServerAlias dav.example.org dav.example.io
|
||||
DocumentRoot /var/www/baikal/html
|
||||
|
||||
RewriteEngine On
|
||||
RewriteRule /.well-known/carddav /dav.php [R,L]
|
||||
RewriteRule /.well-known/caldav /dav.php [R,L]
|
||||
|
||||
<Directory "/var/www/baikal/html">
|
||||
Options None
|
||||
Options +FollowSymlinks
|
||||
AllowOverride All
|
||||
|
||||
# Confiugration for apache-2.2:
|
||||
Order allow,deny
|
||||
Allow from all
|
||||
|
||||
# Confiugration for apache-2.4:
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
</VirtualHost>
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
system:
|
||||
configured_version: 0.7.0
|
||||
timezone: Europe/Paris
|
||||
card_enabled: true
|
||||
cal_enabled: true
|
||||
dav_auth_type: Basic
|
||||
admin_passwordhash: 6a890c3aa185845a4bee1e1caed92e1faaf2dec6772291dca301cef6782e3bce
|
||||
auth_realm: BaikalDAV
|
||||
invite_from: noreply@localhost
|
||||
database:
|
||||
sqlite_file: /var/www/baikal/Specific/db/db.sqlite
|
||||
mysql: false
|
||||
mysql_host: ''
|
||||
mysql_dbname: ''
|
||||
mysql_username: ''
|
||||
mysql_password: ''
|
||||
encryption_key: bdf3bec969736e122e6d5f72c282c49e
|
||||
configured_version: ''
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN TRANSACTION;
|
||||
CREATE TABLE addressbooks (
|
||||
id integer primary key asc NOT NULL,
|
||||
principaluri text NOT NULL,
|
||||
displayname text,
|
||||
uri text NOT NULL,
|
||||
description text,
|
||||
synctoken integer DEFAULT 1 NOT NULL
|
||||
);
|
||||
INSERT INTO addressbooks VALUES(1,'principals/baikal','Default Address Book','default','Default Address Book for Baikal',1);
|
||||
CREATE TABLE cards (
|
||||
id integer primary key asc NOT NULL,
|
||||
addressbookid integer NOT NULL,
|
||||
carddata blob,
|
||||
uri text NOT NULL,
|
||||
lastmodified integer,
|
||||
etag text,
|
||||
size integer
|
||||
);
|
||||
CREATE TABLE addressbookchanges (
|
||||
id integer primary key asc NOT NULL,
|
||||
uri text,
|
||||
synctoken integer NOT NULL,
|
||||
addressbookid integer NOT NULL,
|
||||
operation integer NOT NULL
|
||||
);
|
||||
CREATE TABLE calendarobjects (
|
||||
id integer primary key asc NOT NULL,
|
||||
calendardata blob NOT NULL,
|
||||
uri text NOT NULL,
|
||||
calendarid integer NOT NULL,
|
||||
lastmodified integer NOT NULL,
|
||||
etag text NOT NULL,
|
||||
size integer NOT NULL,
|
||||
componenttype text,
|
||||
firstoccurence integer,
|
||||
lastoccurence integer,
|
||||
uid text
|
||||
);
|
||||
CREATE TABLE calendars (
|
||||
id integer primary key asc NOT NULL,
|
||||
synctoken integer DEFAULT 1 NOT NULL,
|
||||
components text NOT NULL
|
||||
);
|
||||
INSERT INTO calendars VALUES(1,1,'VEVENT,VTODO');
|
||||
CREATE TABLE calendarinstances (
|
||||
id integer primary key asc NOT NULL,
|
||||
calendarid integer,
|
||||
principaluri text,
|
||||
access integer,
|
||||
displayname text,
|
||||
uri text NOT NULL,
|
||||
description text,
|
||||
calendarorder integer,
|
||||
calendarcolor text,
|
||||
timezone text,
|
||||
transparent bool,
|
||||
share_href text,
|
||||
share_displayname text,
|
||||
share_invitestatus integer DEFAULT '2',
|
||||
UNIQUE (principaluri, uri),
|
||||
UNIQUE (calendarid, principaluri),
|
||||
UNIQUE (calendarid, share_href)
|
||||
);
|
||||
INSERT INTO calendarinstances VALUES(1,1,'principals/baikal',NULL,'Default calendar','default','Default calendar',0,'','Europe/Paris',NULL,NULL,NULL,2);
|
||||
CREATE TABLE calendarchanges (
|
||||
id integer primary key asc NOT NULL,
|
||||
uri text,
|
||||
synctoken integer NOT NULL,
|
||||
calendarid integer NOT NULL,
|
||||
operation integer NOT NULL
|
||||
);
|
||||
CREATE TABLE calendarsubscriptions (
|
||||
id integer primary key asc NOT NULL,
|
||||
uri text NOT NULL,
|
||||
principaluri text NOT NULL,
|
||||
source text NOT NULL,
|
||||
displayname text,
|
||||
refreshrate text,
|
||||
calendarorder integer,
|
||||
calendarcolor text,
|
||||
striptodos bool,
|
||||
stripalarms bool,
|
||||
stripattachments bool,
|
||||
lastmodified int
|
||||
);
|
||||
CREATE TABLE schedulingobjects (
|
||||
id integer primary key asc NOT NULL,
|
||||
principaluri text NOT NULL,
|
||||
calendardata blob,
|
||||
uri text NOT NULL,
|
||||
lastmodified integer,
|
||||
etag text NOT NULL,
|
||||
size integer NOT NULL
|
||||
);
|
||||
CREATE TABLE locks (
|
||||
id integer primary key asc NOT NULL,
|
||||
owner text,
|
||||
timeout integer,
|
||||
created integer,
|
||||
token text,
|
||||
scope integer,
|
||||
depth integer,
|
||||
uri text
|
||||
);
|
||||
CREATE TABLE principals (
|
||||
id INTEGER PRIMARY KEY ASC NOT NULL,
|
||||
uri TEXT NOT NULL,
|
||||
email TEXT,
|
||||
displayname TEXT,
|
||||
UNIQUE(uri)
|
||||
);
|
||||
INSERT INTO principals VALUES(1,'principals/baikal','baikal@example.com','Baikal');
|
||||
CREATE TABLE groupmembers (
|
||||
id INTEGER PRIMARY KEY ASC NOT NULL,
|
||||
principal_id INTEGER NOT NULL,
|
||||
member_id INTEGER NOT NULL,
|
||||
UNIQUE(principal_id, member_id)
|
||||
);
|
||||
CREATE TABLE propertystorage (
|
||||
id integer primary key asc NOT NULL,
|
||||
path text NOT NULL,
|
||||
name text NOT NULL,
|
||||
valuetype integer NOT NULL,
|
||||
value string
|
||||
);
|
||||
CREATE TABLE users (
|
||||
id integer primary key asc NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
digesta1 TEXT NOT NULL,
|
||||
UNIQUE(username)
|
||||
);
|
||||
INSERT INTO users VALUES(1,'baikal','3b0845b235b7e985ce5905ab8df45e1a');
|
||||
CREATE INDEX addressbookid_synctoken ON addressbookchanges (addressbookid, synctoken);
|
||||
CREATE INDEX calendarid_synctoken ON calendarchanges (calendarid, synctoken);
|
||||
CREATE INDEX principaluri_uri ON calendarsubscriptions (principaluri, uri);
|
||||
CREATE UNIQUE INDEX path_property ON propertystorage (path, name);
|
||||
COMMIT;
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
#!/bin/sh
|
||||
# Shameless copied from https://raw.githubusercontent.com/ckulka/baikal-docker/master/files/start.sh
|
||||
|
||||
# Inject ServerName and ServerAlias if specified
|
||||
APACHE_CONFIG="/etc/apache2/sites-available/000-default.conf"
|
||||
if [ ! -z ${BAIKAL_SERVERNAME+x} ]
|
||||
then
|
||||
sed -i "s/# InjectedServerName .*/ServerName $BAIKAL_SERVERNAME/g" $APACHE_CONFIG
|
||||
fi
|
||||
|
||||
if [ ! -z ${BAIKAL_SERVERALIAS+x} ]
|
||||
then
|
||||
sed -i "s/# InjectedServerAlias .*/ServerAlias $BAIKAL_SERVERALIAS/g" $APACHE_CONFIG
|
||||
fi
|
||||
|
||||
apache2-foreground
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
FROM python:3.8
|
||||
|
||||
RUN pip install radicale
|
||||
|
||||
CMD radicale --storage-filesystem-folder /tmp/dav -H 0.0.0.0:8001 -D
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
# Original file copyright 2017 Jelmer Vernooij
|
||||
|
||||
FROM ubuntu:bionic
|
||||
RUN apt-get update && apt-get -y install xandikos locales
|
||||
EXPOSE 8000
|
||||
|
||||
RUN locale-gen en_US.UTF-8
|
||||
ENV PYTHONIOENCODING=utf-8
|
||||
ENV LANG en_US.UTF-8
|
||||
ENV LANGUAGE en_US:en
|
||||
ENV LC_ALL en_US.UTF-8
|
||||
|
||||
CMD xandikos -d /tmp/dav -l 0.0.0.0 -p 8000 --autocreate
|
||||
103
docs/conf.py
103
docs/conf.py
|
|
@ -3,89 +3,104 @@ import os
|
|||
|
||||
from pkg_resources import get_distribution
|
||||
|
||||
extensions = ['sphinx.ext.autodoc']
|
||||
extensions = ["sphinx.ext.autodoc"]
|
||||
|
||||
templates_path = ['_templates']
|
||||
templates_path = ["_templates"]
|
||||
|
||||
source_suffix = '.rst'
|
||||
master_doc = 'index'
|
||||
source_suffix = ".rst"
|
||||
master_doc = "index"
|
||||
|
||||
project = 'vdirsyncer'
|
||||
copyright = ('2014-{}, Markus Unterwaditzer & contributors'
|
||||
.format(datetime.date.today().strftime('%Y')))
|
||||
project = "vdirsyncer"
|
||||
copyright = "2014-{}, Markus Unterwaditzer & contributors".format(
|
||||
datetime.date.today().strftime("%Y")
|
||||
)
|
||||
|
||||
release = get_distribution('vdirsyncer').version
|
||||
version = '.'.join(release.split('.')[:2]) # The short X.Y version.
|
||||
release = get_distribution("vdirsyncer").version
|
||||
version = ".".join(release.split(".")[:2]) # The short X.Y version.
|
||||
|
||||
rst_epilog = '.. |vdirsyncer_version| replace:: %s' % release
|
||||
rst_epilog = ".. |vdirsyncer_version| replace:: %s" % release
|
||||
|
||||
exclude_patterns = ['_build']
|
||||
exclude_patterns = ["_build"]
|
||||
|
||||
pygments_style = 'sphinx'
|
||||
pygments_style = "sphinx"
|
||||
|
||||
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
|
||||
on_rtd = os.environ.get("READTHEDOCS", None) == "True"
|
||||
|
||||
try:
|
||||
import sphinx_rtd_theme
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
|
||||
html_theme = "sphinx_rtd_theme"
|
||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
||||
except ImportError:
|
||||
html_theme = 'default'
|
||||
html_theme = "default"
|
||||
if not on_rtd:
|
||||
print('-' * 74)
|
||||
print('Warning: sphinx-rtd-theme not installed, building with default '
|
||||
'theme.')
|
||||
print('-' * 74)
|
||||
print("-" * 74)
|
||||
print(
|
||||
"Warning: sphinx-rtd-theme not installed, building with default " "theme."
|
||||
)
|
||||
print("-" * 74)
|
||||
|
||||
html_static_path = ['_static']
|
||||
htmlhelp_basename = 'vdirsyncerdoc'
|
||||
html_static_path = ["_static"]
|
||||
htmlhelp_basename = "vdirsyncerdoc"
|
||||
|
||||
latex_elements = {}
|
||||
latex_documents = [
|
||||
('index', 'vdirsyncer.tex', 'vdirsyncer Documentation',
|
||||
'Markus Unterwaditzer', 'manual'),
|
||||
(
|
||||
"index",
|
||||
"vdirsyncer.tex",
|
||||
"vdirsyncer Documentation",
|
||||
"Markus Unterwaditzer",
|
||||
"manual",
|
||||
),
|
||||
]
|
||||
|
||||
man_pages = [
|
||||
('index', 'vdirsyncer', 'vdirsyncer Documentation',
|
||||
['Markus Unterwaditzer'], 1)
|
||||
("index", "vdirsyncer", "vdirsyncer Documentation", ["Markus Unterwaditzer"], 1)
|
||||
]
|
||||
|
||||
texinfo_documents = [
|
||||
('index', 'vdirsyncer', 'vdirsyncer Documentation',
|
||||
'Markus Unterwaditzer', 'vdirsyncer',
|
||||
'Synchronize calendars and contacts.', 'Miscellaneous'),
|
||||
(
|
||||
"index",
|
||||
"vdirsyncer",
|
||||
"vdirsyncer Documentation",
|
||||
"Markus Unterwaditzer",
|
||||
"vdirsyncer",
|
||||
"Synchronize calendars and contacts.",
|
||||
"Miscellaneous",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def github_issue_role(name, rawtext, text, lineno, inliner,
|
||||
options={}, content=()): # noqa: B006
|
||||
def github_issue_role(name, rawtext, text, lineno, inliner, options=None, content=()):
|
||||
options = options or {}
|
||||
try:
|
||||
issue_num = int(text)
|
||||
if issue_num <= 0:
|
||||
raise ValueError()
|
||||
except ValueError:
|
||||
msg = inliner.reporter.error(f'Invalid GitHub issue: {text}',
|
||||
line=lineno)
|
||||
msg = inliner.reporter.error(f"Invalid GitHub issue: {text}", line=lineno)
|
||||
prb = inliner.problematic(rawtext, rawtext, msg)
|
||||
return [prb], [msg]
|
||||
|
||||
from docutils import nodes
|
||||
|
||||
PROJECT_HOME = 'https://github.com/pimutils/vdirsyncer'
|
||||
link = '{}/{}/{}'.format(PROJECT_HOME,
|
||||
'issues' if name == 'gh' else 'pull',
|
||||
issue_num)
|
||||
linktext = ('issue #{}' if name == 'gh'
|
||||
else 'pull request #{}').format(issue_num)
|
||||
node = nodes.reference(rawtext, linktext, refuri=link,
|
||||
**options)
|
||||
PROJECT_HOME = "https://github.com/pimutils/vdirsyncer"
|
||||
link = "{}/{}/{}".format(
|
||||
PROJECT_HOME, "issues" if name == "gh" else "pull", issue_num
|
||||
)
|
||||
linktext = ("issue #{}" if name == "gh" else "pull request #{}").format(issue_num)
|
||||
node = nodes.reference(rawtext, linktext, refuri=link, **options)
|
||||
return [node], []
|
||||
|
||||
|
||||
def setup(app):
|
||||
from sphinx.domains.python import PyObject
|
||||
app.add_object_type('storage', 'storage', 'pair: %s; storage',
|
||||
doc_field_types=PyObject.doc_field_types)
|
||||
app.add_role('gh', github_issue_role)
|
||||
app.add_role('ghpr', github_issue_role)
|
||||
|
||||
app.add_object_type(
|
||||
"storage",
|
||||
"storage",
|
||||
"pair: %s; storage",
|
||||
doc_field_types=PyObject.doc_field_types,
|
||||
)
|
||||
app.add_role("gh", github_issue_role)
|
||||
app.add_role("ghpr", github_issue_role)
|
||||
|
|
|
|||
|
|
@ -269,7 +269,7 @@ in terms of data safety**. See `this blog post
|
|||
<https://evertpot.com/google-carddav-issues/>`_ for the details. Always back
|
||||
up your data.
|
||||
|
||||
At first run you will be asked to authorize application for google account
|
||||
At first run you will be asked to authorize application for Google account
|
||||
access.
|
||||
|
||||
To use this storage type, you need to install some additional dependencies::
|
||||
|
|
@ -411,6 +411,7 @@ Local
|
|||
fileext = "..."
|
||||
#encoding = "utf-8"
|
||||
#post_hook = null
|
||||
#fileignoreext = ".tmp"
|
||||
|
||||
Can be used with `khal <http://lostpackets.de/khal/>`_. See :doc:`vdir` for
|
||||
a more formal description of the format.
|
||||
|
|
@ -424,11 +425,15 @@ Local
|
|||
: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).
|
||||
of any kind). To be compatible with the ``vset`` format you have
|
||||
to either use ``.vcf`` or ``.ics``. Note that metasync won't work
|
||||
if you use an empty string here.
|
||||
:param encoding: File encoding for items, both content and filename.
|
||||
: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.
|
||||
:param fileeignoreext: The file extention to ignore. It is only useful
|
||||
if fileext is set to the empty string. The default is ``.tmp``.
|
||||
|
||||
.. storage:: singlefile
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
Support and Contact
|
||||
===================
|
||||
|
||||
* The ``#pimutils`` `IRC channel on Freenode <https://pimutils.org/contact>`_
|
||||
* The ``#pimutils`` `IRC channel on Libera.Chat <https://pimutils.org/contact>`_
|
||||
might be active, depending on your timezone. Use it for support and general
|
||||
(including off-topic) discussion.
|
||||
|
||||
|
|
|
|||
|
|
@ -75,8 +75,8 @@ Submitting patches, pull requests
|
|||
Running tests, how to set up your development environment
|
||||
---------------------------------------------------------
|
||||
|
||||
For many patches, it might suffice to just let Travis run the tests. However,
|
||||
Travis is slow, so you might want to run them locally too. For this, set up a
|
||||
For many patches, it might suffice to just let CI run the tests. However,
|
||||
CI is slow, so you might want to run them locally too. For this, set up a
|
||||
virtualenv_ and run this inside of it::
|
||||
|
||||
# install:
|
||||
|
|
@ -87,8 +87,8 @@ virtualenv_ and run this inside of it::
|
|||
# Install git commit hook for some extra linting and checking
|
||||
pre-commit install
|
||||
|
||||
# install test dependencies
|
||||
make install-test
|
||||
# Install development dependencies
|
||||
make install-dev
|
||||
|
||||
Then you can run::
|
||||
|
||||
|
|
@ -100,15 +100,13 @@ The ``Makefile`` has a lot of options that allow you to control which tests are
|
|||
run, and which servers are tested. Take a look at its code where they are all
|
||||
initialized and documented.
|
||||
|
||||
For example, to test xandikos, first run the server itself::
|
||||
|
||||
docker-compose build xandikos
|
||||
docker-compose up -d xandikos
|
||||
|
||||
Then run the tests specifying this ``DAV_SERVER``, run::
|
||||
To tests against a specific DAV server, use ``DAV_SERVER``::
|
||||
|
||||
make DAV_SERVER=xandikos test
|
||||
|
||||
The server will be initialised in a docker container and terminated at the end
|
||||
of the test suite.
|
||||
|
||||
If you have any questions, feel free to open issues about it.
|
||||
|
||||
Structure of the testsuite
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ the simplest possible way would look something like::
|
|||
|
||||
virtualenv ~/vdirsyncer_env
|
||||
~/vdirsyncer_env/bin/pip install vdirsyncer
|
||||
alias vdirsyncer="~/vdirsyncer_env/bin/vdirsyncer
|
||||
alias vdirsyncer="~/vdirsyncer_env/bin/vdirsyncer"
|
||||
|
||||
You'll have to put the last line into your ``.bashrc`` or ``.bash_profile``.
|
||||
|
||||
|
|
@ -95,22 +95,22 @@ This method has two advantages:
|
|||
The clean, easy way
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
pipsi_ is a new package manager for Python-based software that automatically
|
||||
pipx_ is a new package manager for Python-based software that automatically
|
||||
sets up a virtualenv for each program you install. Assuming you have it
|
||||
installed on your operating system, you can do::
|
||||
|
||||
pipsi install --python python3 vdirsyncer
|
||||
pipx install vdirsyncer
|
||||
|
||||
and ``.local/bin/vdirsyncer`` will be your new vdirsyncer installation. To
|
||||
and ``~/.local/pipx/venvs/vdirsyncer`` will be your new vdirsyncer installation. To
|
||||
update vdirsyncer to the latest version::
|
||||
|
||||
pipsi upgrade vdirsyncer
|
||||
pipx upgrade vdirsyncer
|
||||
|
||||
If you're done with vdirsyncer, you can do::
|
||||
|
||||
pipsi uninstall vdirsyncer
|
||||
pipx uninstall vdirsyncer
|
||||
|
||||
and vdirsyncer will be uninstalled, including its dependencies.
|
||||
|
||||
.. _virtualenv: https://virtualenv.readthedocs.io/
|
||||
.. _pipsi: https://github.com/mitsuhiko/pipsi
|
||||
.. _pipx: https://github.com/pipxproject/pipx
|
||||
|
|
|
|||
|
|
@ -5,10 +5,9 @@ Packaging guidelines
|
|||
Thank you very much for packaging vdirsyncer! The following guidelines should
|
||||
help you to avoid some common pitfalls.
|
||||
|
||||
While they are called guidelines and therefore theoretically not mandatory, if
|
||||
you consider going a different direction, please first open an issue or contact
|
||||
me otherwise instead of just going ahead. These guidelines exist for my own
|
||||
convenience too.
|
||||
If you find yourself needing to patch anything, or going in a different direction,
|
||||
please open an issue so we can also address in a way that works for everyone. Otherwise
|
||||
we get bug reports for code or scenarios that don't exist in upstream vdirsycner.
|
||||
|
||||
Obtaining the source code
|
||||
=========================
|
||||
|
|
@ -17,8 +16,7 @@ The main distribution channel is `PyPI
|
|||
<https://pypi.python.org/pypi/vdirsyncer>`_, and source tarballs can be
|
||||
obtained there. We mirror the same package tarball and wheel as GitHub
|
||||
releases. Please do not confuse these with the auto-generated GitHub "Source
|
||||
Code" tarball; that one contains useless junk and are more of a distraction
|
||||
than anything else.
|
||||
Code" tarball. Those are missing some important metadata and your build will fail.
|
||||
|
||||
We give each release a tag in the git repo. If you want to get notified of new
|
||||
releases, `GitHub's feed
|
||||
|
|
@ -39,13 +37,13 @@ Testing
|
|||
=======
|
||||
|
||||
Everything testing-related goes through the ``Makefile`` in the root of the
|
||||
repository or PyPI package. Trying to e.g. run ``py.test`` directly will
|
||||
repository or PyPI package. Trying to e.g. run ``pytest`` directly will
|
||||
require a lot of environment variables to be set (for configuration) and you
|
||||
probably don't want to deal with that.
|
||||
|
||||
You can install the testing dependencies with::
|
||||
You can install the all development dependencies with::
|
||||
|
||||
make install-test
|
||||
make install-dev
|
||||
|
||||
You probably don't want this since it will use pip to download the
|
||||
dependencies. Alternatively you can find the testing dependencies in
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ It's quite possible that the default "every fifteen minutes" interval isn't to
|
|||
your liking. No default will suit everybody, but this is configurable by simply
|
||||
running::
|
||||
|
||||
systemctl --user edit vdirsyncer
|
||||
systemctl --user edit vdirsyncer.timer
|
||||
|
||||
This will open a blank editor, where you can override the timer by including::
|
||||
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ collections for faster search and lookup.
|
|||
|
||||
The reason items' filenames don't contain any extra information is simple: The
|
||||
solutions presented induced duplication of data, where one duplicate might
|
||||
become out of date because of bad implementations. As it stands right now, a
|
||||
become out of date because of bad implementations. As it stands right now, an
|
||||
index format could be formalized separately though.
|
||||
|
||||
vdirsyncer doesn't really have to bother about efficient item lookup, because
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ program chosen:
|
|||
|
||||
* Such a setup doesn't work at all with smartphones. Vdirsyncer, on the other
|
||||
hand, synchronizes with CardDAV/CalDAV servers, which can be accessed with
|
||||
e.g. DAVDroid_ or the apps by dmfs_.
|
||||
e.g. DAVx⁵_ or the apps by dmfs_.
|
||||
|
||||
.. _DAVDroid: http://davdroid.bitfire.at/
|
||||
.. _DAVx⁵: https://www.davx5.com/
|
||||
.. _dmfs: https://dmfs.org/
|
||||
|
|
|
|||
|
|
@ -3,35 +3,40 @@ ARG distrover
|
|||
|
||||
FROM $distro:$distrover
|
||||
|
||||
ARG distro
|
||||
ARG distrover
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y build-essential fakeroot debhelper git
|
||||
RUN apt-get install -y python3-all python3-pip
|
||||
RUN apt-get install -y python3-all python3-pip python3-venv
|
||||
RUN apt-get install -y ruby ruby-dev
|
||||
RUN apt-get install -y python-all python-pip
|
||||
|
||||
RUN gem install fpm
|
||||
RUN gem install fpm package_cloud
|
||||
|
||||
RUN pip2 install virtualenv-tools
|
||||
RUN pip3 install virtualenv
|
||||
RUN pip3 install virtualenv virtualenv-tools3
|
||||
RUN virtualenv -p python3 /vdirsyncer/env/
|
||||
|
||||
# See https://github.com/jordansissel/fpm/issues/1106#issuecomment-461678970
|
||||
RUN pip3 uninstall -y virtualenv
|
||||
RUN echo 'python3 -m venv "$@"' > /usr/local/bin/virtualenv
|
||||
RUN chmod +x /usr/local/bin/virtualenv
|
||||
|
||||
COPY . /vdirsyncer/vdirsyncer/
|
||||
WORKDIR /vdirsyncer/vdirsyncer/
|
||||
RUN mkdir /vdirsyncer/pkgs/
|
||||
|
||||
RUN basename *.tar.gz .tar.gz | cut -d'-' -f2 | sed -e 's/\.dev/~/g' | tee version
|
||||
RUN (echo -n *.tar.gz; echo '[google]') | tee requirements.txt
|
||||
RUN . /vdirsyncer/env/bin/activate; fpm -s virtualenv -t deb \
|
||||
-n "vdirsyncer-latest" \
|
||||
-v "$(cat version)" \
|
||||
--prefix /opt/venvs/vdirsyncer-latest \
|
||||
requirements.txt
|
||||
RUN fpm --verbose \
|
||||
--input-type virtualenv \
|
||||
--output-type deb \
|
||||
--name "vdirsyncer-latest" \
|
||||
--version "$(cat version)" \
|
||||
--prefix /opt/venvs/vdirsyncer-latest \
|
||||
--depends python3 \
|
||||
requirements.txt
|
||||
|
||||
RUN mv /vdirsyncer/vdirsyncer/*.deb /vdirsyncer/pkgs/
|
||||
|
||||
WORKDIR /vdirsyncer/pkgs/
|
||||
RUN dpkg -i *.deb
|
||||
|
||||
# Check that it works:
|
||||
RUN LC_ALL=C.UTF-8 LANG=C.UTF-8 /opt/venvs/vdirsyncer-latest/bin/vdirsyncer --version
|
||||
|
|
|
|||
|
|
@ -1,82 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
import itertools
|
||||
import json
|
||||
|
||||
python_versions = ["3.7", "3.8"]
|
||||
|
||||
cfg = {}
|
||||
|
||||
cfg['sudo'] = True
|
||||
cfg['dist'] = 'bionic'
|
||||
cfg['language'] = 'python'
|
||||
cfg['cache'] = 'pip'
|
||||
|
||||
cfg['services'] = ['docker']
|
||||
|
||||
cfg['git'] = {
|
||||
'submodules': False
|
||||
}
|
||||
|
||||
cfg['branches'] = {
|
||||
'only': ['master']
|
||||
}
|
||||
|
||||
cfg['install'] = """
|
||||
. scripts/travis-install.sh
|
||||
make -e install-$BUILD
|
||||
""".strip().splitlines()
|
||||
|
||||
cfg['script'] = ["make -e $BUILD"]
|
||||
|
||||
matrix = []
|
||||
cfg['matrix'] = {'include': matrix, 'fast_finish': True}
|
||||
|
||||
matrix.append({
|
||||
'python': python_versions[0],
|
||||
'env': 'BUILD=style'
|
||||
})
|
||||
|
||||
|
||||
for python, requirements in itertools.product(
|
||||
python_versions,
|
||||
# XXX: Use `devel` here for recent python versions:
|
||||
("release", "minimal")
|
||||
):
|
||||
dav_servers = ("radicale", "xandikos")
|
||||
|
||||
matrix.append({
|
||||
'python': python,
|
||||
'env': f"BUILD=test REQUIREMENTS={requirements}",
|
||||
})
|
||||
|
||||
if python == python_versions[0] and requirements == "release":
|
||||
dav_servers += ("fastmail",)
|
||||
|
||||
for dav_server in dav_servers:
|
||||
job = {
|
||||
'python': python,
|
||||
'env': ("BUILD=test-storage "
|
||||
f"DAV_SERVER={dav_server} "
|
||||
f"REQUIREMENTS={requirements} ")
|
||||
}
|
||||
|
||||
if dav_server in ("davical", "icloud"):
|
||||
job['if'] = 'NOT (type IN (pull_request))'
|
||||
|
||||
matrix.append(job)
|
||||
|
||||
matrix.append({
|
||||
'python': python_versions[0],
|
||||
'env': ("BUILD=test "
|
||||
"ETESYNC_TESTS=true "
|
||||
"REQUIREMENTS=latest")
|
||||
})
|
||||
|
||||
# matrix.append({
|
||||
# 'language': 'generic',
|
||||
# 'os': 'osx',
|
||||
# 'env': 'BUILD=test'
|
||||
# })
|
||||
|
||||
with open('.travis.yml', 'w') as output:
|
||||
json.dump(cfg, output, sort_keys=True, indent=2)
|
||||
|
|
@ -1,19 +1,26 @@
|
|||
#!/bin/sh
|
||||
|
||||
set -xe
|
||||
distro=$1
|
||||
distrover=$2
|
||||
name=vdirsyncer-$distro-$distrover:latest
|
||||
context="$(mktemp -d)"
|
||||
|
||||
python setup.py sdist -d "$context"
|
||||
cp scripts/dpkg.Dockerfile "$context/Dockerfile"
|
||||
DISTRO=$1
|
||||
DISTROVER=$2
|
||||
|
||||
NAME="vdirsyncer-${DISTRO}-${DISTROVER}:latest"
|
||||
CONTEXT="$(mktemp -d)"
|
||||
|
||||
python setup.py sdist -d "$CONTEXT"
|
||||
|
||||
# Build the package in a container with the right distro version.
|
||||
docker build \
|
||||
--build-arg distro=$distro \
|
||||
--build-arg distrover=$distrover \
|
||||
-t $name \
|
||||
"$context"
|
||||
--build-arg distro=$DISTRO \
|
||||
--build-arg distrover=$DISTROVER \
|
||||
-t $NAME \
|
||||
-f scripts/dpkg.Dockerfile \
|
||||
"$CONTEXT"
|
||||
|
||||
docker run $name tar -c -C /vdirsyncer pkgs | tar x -C "$context"
|
||||
package_cloud push pimutils/vdirsyncer/$distro/$distrover $context/pkgs/*.deb
|
||||
rm -rf "$context"
|
||||
# Push the package to packagecloud.
|
||||
# TODO: Use ~/.packagecloud for CI.
|
||||
docker run -e PACKAGECLOUD_TOKEN=$PACKAGECLOUD_TOKEN $NAME \
|
||||
bash -xec "package_cloud push pimutils/vdirsyncer/$DISTRO/$DISTROVER *.deb"
|
||||
|
||||
rm -rf "$CONTEXT"
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
# The OS X VM doesn't have any Python support at all
|
||||
# See https://github.com/travis-ci/travis-ci/issues/2312
|
||||
if [ "$TRAVIS_OS_NAME" = "osx" ]; then
|
||||
brew update
|
||||
brew install python3
|
||||
virtualenv -p python3 $HOME/osx-py3
|
||||
. $HOME/osx-py3/bin/activate
|
||||
fi
|
||||
17
setup.cfg
17
setup.cfg
|
|
@ -2,7 +2,6 @@
|
|||
universal = 1
|
||||
|
||||
[tool:pytest]
|
||||
norecursedirs = tests/storage/servers/*
|
||||
addopts =
|
||||
--tb=short
|
||||
--cov-config .coveragerc
|
||||
|
|
@ -11,12 +10,12 @@ addopts =
|
|||
--no-cov-on-fail
|
||||
|
||||
[flake8]
|
||||
# E731: Use a def instead of lambda expr
|
||||
# E743: Ambiguous function definition
|
||||
ignore = E731, E743
|
||||
# E503: Line break occurred before a binary operator
|
||||
extend-ignore = E203, W503
|
||||
application-import-names = tests,vdirsyncer
|
||||
extend-ignore =
|
||||
E203, # Black-incompatible colon spacing.
|
||||
W503, # Line jump before binary operator.
|
||||
I100,
|
||||
I202
|
||||
max-line-length = 88
|
||||
select = C,E,F,W,B,B9
|
||||
exclude = .eggs, tests/storage/servers/owncloud/, tests/storage/servers/nextcloud/, tests/storage/servers/baikal/, build/
|
||||
application-package-names = tests,vdirsyncer
|
||||
exclude = .eggs,build
|
||||
import-order-style = smarkets
|
||||
|
|
|
|||
79
setup.py
79
setup.py
|
|
@ -1,9 +1,9 @@
|
|||
'''
|
||||
"""
|
||||
Vdirsyncer synchronizes calendars and contacts.
|
||||
|
||||
Please refer to https://vdirsyncer.pimutils.org/en/stable/packaging.html for
|
||||
how to package vdirsyncer.
|
||||
'''
|
||||
"""
|
||||
from setuptools import Command
|
||||
from setuptools import find_packages
|
||||
from setuptools import setup
|
||||
|
|
@ -11,25 +11,21 @@ from setuptools import setup
|
|||
|
||||
requirements = [
|
||||
# https://github.com/mitsuhiko/click/issues/200
|
||||
'click>=5.0',
|
||||
'click-log>=0.3.0, <0.4.0',
|
||||
|
||||
"click>=5.0,<9.0",
|
||||
"click-log>=0.3.0, <0.4.0",
|
||||
# https://github.com/pimutils/vdirsyncer/issues/478
|
||||
'click-threading>=0.2',
|
||||
|
||||
'requests >=2.20.0',
|
||||
|
||||
"click-threading>=0.5",
|
||||
"requests >=2.20.0",
|
||||
# https://github.com/sigmavirus24/requests-toolbelt/pull/28
|
||||
# And https://github.com/sigmavirus24/requests-toolbelt/issues/54
|
||||
'requests_toolbelt >=0.4.0',
|
||||
|
||||
"requests_toolbelt >=0.4.0",
|
||||
# https://github.com/untitaker/python-atomicwrites/commit/4d12f23227b6a944ab1d99c507a69fdbc7c9ed6d # noqa
|
||||
'atomicwrites>=0.1.7'
|
||||
"atomicwrites>=0.1.7",
|
||||
]
|
||||
|
||||
|
||||
class PrintRequirements(Command):
|
||||
description = 'Prints minimal requirements'
|
||||
description = "Prints minimal requirements"
|
||||
user_options = []
|
||||
|
||||
def initialize_options(self):
|
||||
|
|
@ -43,53 +39,44 @@ class PrintRequirements(Command):
|
|||
print(requirement.replace(">", "=").replace(" ", ""))
|
||||
|
||||
|
||||
with open('README.rst') as f:
|
||||
with open("README.rst") as f:
|
||||
long_description = f.read()
|
||||
|
||||
|
||||
setup(
|
||||
# General metadata
|
||||
name='vdirsyncer',
|
||||
author='Markus Unterwaditzer',
|
||||
author_email='markus@unterwaditzer.net',
|
||||
url='https://github.com/pimutils/vdirsyncer',
|
||||
description='Synchronize calendars and contacts',
|
||||
license='BSD',
|
||||
name="vdirsyncer",
|
||||
author="Markus Unterwaditzer",
|
||||
author_email="markus@unterwaditzer.net",
|
||||
url="https://github.com/pimutils/vdirsyncer",
|
||||
description="Synchronize calendars and contacts",
|
||||
license="BSD",
|
||||
long_description=long_description,
|
||||
|
||||
# Runtime dependencies
|
||||
install_requires=requirements,
|
||||
|
||||
# Optional dependencies
|
||||
extras_require={
|
||||
'google': ['requests-oauthlib'],
|
||||
'etesync': ['etesync==0.5.2', 'django<2.0']
|
||||
"google": ["requests-oauthlib"],
|
||||
"etesync": ["etesync==0.5.2", "django<2.0"],
|
||||
},
|
||||
|
||||
# Build dependencies
|
||||
setup_requires=['setuptools_scm != 1.12.0'],
|
||||
|
||||
setup_requires=["setuptools_scm != 1.12.0"],
|
||||
# Other
|
||||
packages=find_packages(exclude=['tests.*', 'tests']),
|
||||
packages=find_packages(exclude=["tests.*", "tests"]),
|
||||
include_package_data=True,
|
||||
cmdclass={
|
||||
'minimal_requirements': PrintRequirements
|
||||
},
|
||||
use_scm_version={
|
||||
'write_to': 'vdirsyncer/version.py'
|
||||
},
|
||||
entry_points={
|
||||
'console_scripts': ['vdirsyncer = vdirsyncer.cli:main']
|
||||
},
|
||||
cmdclass={"minimal_requirements": PrintRequirements},
|
||||
use_scm_version={"write_to": "vdirsyncer/version.py"},
|
||||
entry_points={"console_scripts": ["vdirsyncer = vdirsyncer.cli:main"]},
|
||||
classifiers=[
|
||||
'Development Status :: 4 - Beta',
|
||||
'Environment :: Console',
|
||||
'License :: OSI Approved :: BSD License',
|
||||
'Operating System :: POSIX',
|
||||
'Programming Language :: Python :: 3',
|
||||
'Programming Language :: Python :: 3.7',
|
||||
'Programming Language :: Python :: 3.8',
|
||||
'Topic :: Internet',
|
||||
'Topic :: Utilities',
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Console",
|
||||
"License :: OSI Approved :: BSD License",
|
||||
"Operating System :: POSIX",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Topic :: Internet",
|
||||
"Topic :: Utilities",
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
hypothesis>=5.0.0
|
||||
hypothesis>=5.0.0,<7.0.0
|
||||
pytest
|
||||
pytest-cov
|
||||
pytest-localserver
|
||||
pytest-subtesthack
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'''
|
||||
"""
|
||||
Test suite for vdirsyncer.
|
||||
'''
|
||||
"""
|
||||
import hypothesis.strategies as st
|
||||
import urllib3.exceptions
|
||||
|
||||
|
|
@ -10,14 +10,14 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|||
|
||||
|
||||
def blow_up(*a, **kw):
|
||||
raise AssertionError('Did not expect to be called.')
|
||||
raise AssertionError("Did not expect to be called.")
|
||||
|
||||
|
||||
def assert_item_equals(a, b):
|
||||
assert normalize_item(a) == normalize_item(b)
|
||||
|
||||
|
||||
VCARD_TEMPLATE = '''BEGIN:VCARD
|
||||
VCARD_TEMPLATE = """BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:Cyrus Daboo
|
||||
N:Daboo;Cyrus;;;
|
||||
|
|
@ -31,9 +31,9 @@ TEL;TYPE=FAX:412 605 0705
|
|||
URL;VALUE=URI:http://www.example.com
|
||||
X-SOMETHING:{r}
|
||||
UID:{uid}
|
||||
END:VCARD'''
|
||||
END:VCARD"""
|
||||
|
||||
TASK_TEMPLATE = '''BEGIN:VCALENDAR
|
||||
TASK_TEMPLATE = """BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//dmfs.org//mimedir.icalendar//EN
|
||||
BEGIN:VTODO
|
||||
|
|
@ -45,25 +45,30 @@ SUMMARY:Book: Kowlani - Tödlicher Staub
|
|||
X-SOMETHING:{r}
|
||||
UID:{uid}
|
||||
END:VTODO
|
||||
END:VCALENDAR'''
|
||||
END:VCALENDAR"""
|
||||
|
||||
|
||||
BARE_EVENT_TEMPLATE = '''BEGIN:VEVENT
|
||||
BARE_EVENT_TEMPLATE = """BEGIN:VEVENT
|
||||
DTSTART:19970714T170000Z
|
||||
DTEND:19970715T035959Z
|
||||
SUMMARY:Bastille Day Party
|
||||
X-SOMETHING:{r}
|
||||
UID:{uid}
|
||||
END:VEVENT'''
|
||||
END:VEVENT"""
|
||||
|
||||
|
||||
EVENT_TEMPLATE = '''BEGIN:VCALENDAR
|
||||
EVENT_TEMPLATE = (
|
||||
"""BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//hacksw/handcal//NONSGML v1.0//EN
|
||||
''' + BARE_EVENT_TEMPLATE + '''
|
||||
END:VCALENDAR'''
|
||||
"""
|
||||
+ BARE_EVENT_TEMPLATE
|
||||
+ """
|
||||
END:VCALENDAR"""
|
||||
)
|
||||
|
||||
EVENT_WITH_TIMEZONE_TEMPLATE = '''BEGIN:VCALENDAR
|
||||
EVENT_WITH_TIMEZONE_TEMPLATE = (
|
||||
"""BEGIN:VCALENDAR
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Europe/Rome
|
||||
X-LIC-LOCATION:Europe/Rome
|
||||
|
|
@ -82,26 +87,23 @@ DTSTART:19701025T030000
|
|||
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
|
||||
END:STANDARD
|
||||
END:VTIMEZONE
|
||||
''' + BARE_EVENT_TEMPLATE + '''
|
||||
END:VCALENDAR'''
|
||||
"""
|
||||
+ BARE_EVENT_TEMPLATE
|
||||
+ """
|
||||
END:VCALENDAR"""
|
||||
)
|
||||
|
||||
|
||||
SIMPLE_TEMPLATE = '''BEGIN:FOO
|
||||
SIMPLE_TEMPLATE = """BEGIN:FOO
|
||||
UID:{uid}
|
||||
X-SOMETHING:{r}
|
||||
HAHA:YES
|
||||
END:FOO'''
|
||||
END:FOO"""
|
||||
|
||||
printable_characters_strategy = st.text(
|
||||
st.characters(blacklist_categories=(
|
||||
'Cc', 'Cs'
|
||||
))
|
||||
st.characters(blacklist_categories=("Cc", "Cs"))
|
||||
)
|
||||
|
||||
uid_strategy = st.text(
|
||||
st.characters(blacklist_categories=(
|
||||
'Zs', 'Zl', 'Zp',
|
||||
'Cc', 'Cs'
|
||||
)),
|
||||
min_size=1
|
||||
st.characters(blacklist_categories=("Zs", "Zl", "Zp", "Cc", "Cs")), min_size=1
|
||||
).filter(lambda x: x.strip() == x)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'''
|
||||
"""
|
||||
General-purpose fixtures for vdirsyncer's testsuite.
|
||||
'''
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
|
||||
|
|
@ -13,35 +13,42 @@ from hypothesis import Verbosity
|
|||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_logging():
|
||||
click_log.basic_config('vdirsyncer').setLevel(logging.DEBUG)
|
||||
click_log.basic_config("vdirsyncer").setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
try:
|
||||
import pytest_benchmark
|
||||
except ImportError:
|
||||
|
||||
@pytest.fixture
|
||||
def benchmark():
|
||||
return lambda x: x()
|
||||
|
||||
|
||||
else:
|
||||
del pytest_benchmark
|
||||
|
||||
|
||||
settings.register_profile("ci", settings(
|
||||
max_examples=1000,
|
||||
verbosity=Verbosity.verbose,
|
||||
suppress_health_check=[HealthCheck.too_slow],
|
||||
))
|
||||
settings.register_profile("deterministic", settings(
|
||||
derandomize=True,
|
||||
suppress_health_check=HealthCheck.all(),
|
||||
))
|
||||
settings.register_profile("dev", settings(
|
||||
suppress_health_check=[HealthCheck.too_slow]
|
||||
))
|
||||
settings.register_profile(
|
||||
"ci",
|
||||
settings(
|
||||
max_examples=1000,
|
||||
verbosity=Verbosity.verbose,
|
||||
suppress_health_check=[HealthCheck.too_slow],
|
||||
),
|
||||
)
|
||||
settings.register_profile(
|
||||
"deterministic",
|
||||
settings(
|
||||
derandomize=True,
|
||||
suppress_health_check=HealthCheck.all(),
|
||||
),
|
||||
)
|
||||
settings.register_profile("dev", settings(suppress_health_check=[HealthCheck.too_slow]))
|
||||
|
||||
if os.environ.get('DETERMINISTIC_TESTS', 'false').lower() == 'true':
|
||||
if os.environ.get("DETERMINISTIC_TESTS", "false").lower() == "true":
|
||||
settings.load_profile("deterministic")
|
||||
elif os.environ.get('CI', 'false').lower() == 'true':
|
||||
elif os.environ.get("CI", "false").lower() == "true":
|
||||
settings.load_profile("ci")
|
||||
else:
|
||||
settings.load_profile("dev")
|
||||
|
|
|
|||
|
|
@ -4,14 +4,11 @@ import uuid
|
|||
from urllib.parse import quote as urlquote
|
||||
from urllib.parse import unquote as urlunquote
|
||||
|
||||
import hypothesis.strategies as st
|
||||
import pytest
|
||||
from hypothesis import given
|
||||
|
||||
from .. import assert_item_equals
|
||||
from .. import EVENT_TEMPLATE
|
||||
from .. import normalize_item
|
||||
from .. import printable_characters_strategy
|
||||
from .. import TASK_TEMPLATE
|
||||
from .. import VCARD_TEMPLATE
|
||||
from vdirsyncer import exceptions
|
||||
|
|
@ -21,7 +18,8 @@ from vdirsyncer.vobject import Item
|
|||
|
||||
def get_server_mixin(server_name):
|
||||
from . import __name__ as base
|
||||
x = __import__(f'{base}.servers.{server_name}', fromlist=[''])
|
||||
|
||||
x = __import__(f"{base}.servers.{server_name}", fromlist=[""])
|
||||
return x.ServerMixin
|
||||
|
||||
|
||||
|
|
@ -36,18 +34,18 @@ class StorageTests:
|
|||
supports_collections = True
|
||||
supports_metadata = True
|
||||
|
||||
@pytest.fixture(params=['VEVENT', 'VTODO', 'VCARD'])
|
||||
@pytest.fixture(params=["VEVENT", "VTODO", "VCARD"])
|
||||
def item_type(self, request):
|
||||
'''Parametrize with all supported item types.'''
|
||||
"""Parametrize with all supported item types."""
|
||||
return request.param
|
||||
|
||||
@pytest.fixture
|
||||
def get_storage_args(self):
|
||||
'''
|
||||
"""
|
||||
Return a function with the following properties:
|
||||
|
||||
:param collection: The name of the collection to create and use.
|
||||
'''
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -57,9 +55,9 @@ class StorageTests:
|
|||
@pytest.fixture
|
||||
def get_item(self, item_type):
|
||||
template = {
|
||||
'VEVENT': EVENT_TEMPLATE,
|
||||
'VTODO': TASK_TEMPLATE,
|
||||
'VCARD': VCARD_TEMPLATE,
|
||||
"VEVENT": EVENT_TEMPLATE,
|
||||
"VTODO": TASK_TEMPLATE,
|
||||
"VCARD": VCARD_TEMPLATE,
|
||||
}[item_type]
|
||||
|
||||
return lambda **kw: format_item(template, **kw)
|
||||
|
|
@ -67,12 +65,12 @@ class StorageTests:
|
|||
@pytest.fixture
|
||||
def requires_collections(self):
|
||||
if not self.supports_collections:
|
||||
pytest.skip('This storage does not support 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.')
|
||||
pytest.skip("This storage does not support metadata.")
|
||||
|
||||
def test_generic(self, s, get_item):
|
||||
items = [get_item() for i in range(1, 10)]
|
||||
|
|
@ -98,7 +96,7 @@ class StorageTests:
|
|||
href, etag = s.upload(get_item())
|
||||
if etag is None:
|
||||
_, etag = s.get(href)
|
||||
(href2, item, etag2), = s.get_multi([href] * 2)
|
||||
((href2, item, etag2),) = s.get_multi([href] * 2)
|
||||
assert href2 == href
|
||||
assert etag2 == etag
|
||||
|
||||
|
|
@ -131,7 +129,7 @@ class StorageTests:
|
|||
def test_update_nonexisting(self, s, get_item):
|
||||
item = get_item()
|
||||
with pytest.raises(exceptions.PreconditionFailed):
|
||||
s.update('huehue', item, '"123"')
|
||||
s.update("huehue", item, '"123"')
|
||||
|
||||
def test_wrong_etag(self, s, get_item):
|
||||
item = get_item()
|
||||
|
|
@ -148,7 +146,7 @@ class StorageTests:
|
|||
|
||||
def test_delete_nonexisting(self, s, get_item):
|
||||
with pytest.raises(exceptions.PreconditionFailed):
|
||||
s.delete('1', '"123"')
|
||||
s.delete("1", '"123"')
|
||||
|
||||
def test_list(self, s, get_item):
|
||||
assert not list(s.list())
|
||||
|
|
@ -158,10 +156,10 @@ class StorageTests:
|
|||
assert list(s.list()) == [(href, etag)]
|
||||
|
||||
def test_has(self, s, get_item):
|
||||
assert not s.has('asd')
|
||||
assert not s.has("asd")
|
||||
href, etag = s.upload(get_item())
|
||||
assert s.has(href)
|
||||
assert not s.has('asd')
|
||||
assert not s.has("asd")
|
||||
s.delete(href, etag)
|
||||
assert not s.has(href)
|
||||
|
||||
|
|
@ -174,8 +172,8 @@ class StorageTests:
|
|||
info[href] = etag
|
||||
|
||||
assert {
|
||||
href: etag for href, item, etag
|
||||
in s.get_multi(href for href, etag in info.items())
|
||||
href: etag
|
||||
for href, item, etag in s.get_multi(href for href, etag in info.items())
|
||||
} == info
|
||||
|
||||
def test_repr(self, s, get_storage_args):
|
||||
|
|
@ -185,80 +183,76 @@ class StorageTests:
|
|||
def test_discover(self, requires_collections, get_storage_args, get_item):
|
||||
collections = set()
|
||||
for i in range(1, 5):
|
||||
collection = f'test{i}'
|
||||
collection = f"test{i}"
|
||||
s = self.storage_class(**get_storage_args(collection=collection))
|
||||
assert not list(s.list())
|
||||
s.upload(get_item())
|
||||
collections.add(s.collection)
|
||||
|
||||
actual = {
|
||||
c['collection'] for c in
|
||||
self.storage_class.discover(**get_storage_args(collection=None))
|
||||
c["collection"]
|
||||
for c in self.storage_class.discover(**get_storage_args(collection=None))
|
||||
}
|
||||
|
||||
assert actual >= collections
|
||||
|
||||
def test_create_collection(self, requires_collections, get_storage_args,
|
||||
get_item):
|
||||
if getattr(self, 'dav_server', '') in \
|
||||
('icloud', 'fastmail', 'davical'):
|
||||
pytest.skip('Manual cleanup would be necessary.')
|
||||
if getattr(self, 'dav_server', '') == "radicale":
|
||||
def test_create_collection(self, requires_collections, get_storage_args, get_item):
|
||||
if getattr(self, "dav_server", "") in ("icloud", "fastmail", "davical"):
|
||||
pytest.skip("Manual cleanup would be necessary.")
|
||||
if getattr(self, "dav_server", "") == "radicale":
|
||||
pytest.skip("Radicale does not support collection creation")
|
||||
|
||||
args = get_storage_args(collection=None)
|
||||
args['collection'] = 'test'
|
||||
args["collection"] = "test"
|
||||
|
||||
s = self.storage_class(
|
||||
**self.storage_class.create_collection(**args)
|
||||
)
|
||||
s = self.storage_class(**self.storage_class.create_collection(**args))
|
||||
|
||||
href = s.upload(get_item())[0]
|
||||
assert href in {href for href, etag in s.list()}
|
||||
assert href in (href for href, etag in s.list())
|
||||
|
||||
def test_discover_collection_arg(self, requires_collections,
|
||||
get_storage_args):
|
||||
args = get_storage_args(collection='test2')
|
||||
def test_discover_collection_arg(self, requires_collections, get_storage_args):
|
||||
args = get_storage_args(collection="test2")
|
||||
with pytest.raises(TypeError) as excinfo:
|
||||
list(self.storage_class.discover(**args))
|
||||
|
||||
assert 'collection argument must not be given' in str(excinfo.value)
|
||||
assert "collection argument must not be given" in str(excinfo.value)
|
||||
|
||||
def test_collection_arg(self, get_storage_args):
|
||||
if self.storage_class.storage_name.startswith('etesync'):
|
||||
pytest.skip('etesync uses UUIDs.')
|
||||
if self.storage_class.storage_name.startswith("etesync"):
|
||||
pytest.skip("etesync uses UUIDs.")
|
||||
|
||||
if self.supports_collections:
|
||||
s = self.storage_class(**get_storage_args(collection='test2'))
|
||||
s = self.storage_class(**get_storage_args(collection="test2"))
|
||||
# Can't do stronger assertion because of radicale, which needs a
|
||||
# fileextension to guess the collection type.
|
||||
assert 'test2' in s.collection
|
||||
assert "test2" in s.collection
|
||||
else:
|
||||
with pytest.raises(ValueError):
|
||||
self.storage_class(collection='ayy', **get_storage_args())
|
||||
self.storage_class(collection="ayy", **get_storage_args())
|
||||
|
||||
def test_case_sensitive_uids(self, s, get_item):
|
||||
if s.storage_name == 'filesystem':
|
||||
pytest.skip('Behavior depends on the filesystem.')
|
||||
if s.storage_name == "filesystem":
|
||||
pytest.skip("Behavior depends on the filesystem.")
|
||||
|
||||
uid = str(uuid.uuid4())
|
||||
s.upload(get_item(uid=uid.upper()))
|
||||
s.upload(get_item(uid=uid.lower()))
|
||||
items = list(href for href, etag in s.list())
|
||||
items = [href for href, etag in s.list()]
|
||||
assert len(items) == 2
|
||||
assert len(set(items)) == 2
|
||||
|
||||
def test_specialchars(self, monkeypatch, requires_collections,
|
||||
get_storage_args, get_item):
|
||||
if getattr(self, 'dav_server', '') == 'radicale':
|
||||
pytest.skip('Radicale is fundamentally broken.')
|
||||
if getattr(self, 'dav_server', '') in ('icloud', 'fastmail'):
|
||||
pytest.skip('iCloud and FastMail reject this name.')
|
||||
def test_specialchars(
|
||||
self, monkeypatch, requires_collections, get_storage_args, get_item
|
||||
):
|
||||
if getattr(self, "dav_server", "") == "radicale":
|
||||
pytest.skip("Radicale is fundamentally broken.")
|
||||
if getattr(self, "dav_server", "") in ("icloud", "fastmail"):
|
||||
pytest.skip("iCloud and FastMail reject this name.")
|
||||
|
||||
monkeypatch.setattr('vdirsyncer.utils.generate_href', lambda x: x)
|
||||
monkeypatch.setattr("vdirsyncer.utils.generate_href", lambda x: x)
|
||||
|
||||
uid = 'test @ foo ät bar град сатану'
|
||||
collection = 'test @ foo ät bar'
|
||||
uid = "test @ foo ät bar град сатану"
|
||||
collection = "test @ foo ät bar"
|
||||
|
||||
s = self.storage_class(**get_storage_args(collection=collection))
|
||||
item = get_item(uid=uid)
|
||||
|
|
@ -269,55 +263,66 @@ class StorageTests:
|
|||
assert etag2 == etag
|
||||
assert_item_equals(item2, item)
|
||||
|
||||
(_, etag3), = s.list()
|
||||
((_, etag3),) = s.list()
|
||||
assert etag2 == etag3
|
||||
|
||||
# etesync uses UUIDs for collection names
|
||||
if self.storage_class.storage_name.startswith('etesync'):
|
||||
if self.storage_class.storage_name.startswith("etesync"):
|
||||
return
|
||||
|
||||
assert collection in urlunquote(s.collection)
|
||||
if self.storage_class.storage_name.endswith('dav'):
|
||||
assert urlquote(uid, '/@:') in href
|
||||
if self.storage_class.storage_name.endswith("dav"):
|
||||
assert urlquote(uid, "/@:") in href
|
||||
|
||||
def test_metadata(self, requires_metadata, s):
|
||||
if not getattr(self, 'dav_server', ''):
|
||||
assert not s.get_meta('color')
|
||||
assert not s.get_meta('displayname')
|
||||
if not getattr(self, "dav_server", ""):
|
||||
assert not s.get_meta("color")
|
||||
assert not s.get_meta("displayname")
|
||||
|
||||
try:
|
||||
s.set_meta('color', None)
|
||||
assert not s.get_meta('color')
|
||||
s.set_meta('color', '#ff0000')
|
||||
assert s.get_meta('color') == '#ff0000'
|
||||
s.set_meta("color", None)
|
||||
assert not s.get_meta("color")
|
||||
s.set_meta("color", "#ff0000")
|
||||
assert s.get_meta("color") == "#ff0000"
|
||||
except exceptions.UnsupportedMetadataError:
|
||||
pass
|
||||
|
||||
for x in ('hello world', 'hello wörld'):
|
||||
s.set_meta('displayname', x)
|
||||
rv = s.get_meta('displayname')
|
||||
for x in ("hello world", "hello wörld"):
|
||||
s.set_meta("displayname", x)
|
||||
rv = s.get_meta("displayname")
|
||||
assert rv == x
|
||||
assert isinstance(rv, str)
|
||||
|
||||
@given(value=st.one_of(
|
||||
st.none(),
|
||||
printable_characters_strategy
|
||||
))
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
None,
|
||||
"",
|
||||
"Hello there!",
|
||||
"Österreich",
|
||||
"中国",
|
||||
"한글",
|
||||
"42a4ec99-b1c2-4859-b142-759112f2ca50",
|
||||
"فلسطين",
|
||||
],
|
||||
)
|
||||
def test_metadata_normalization(self, requires_metadata, s, value):
|
||||
x = s.get_meta('displayname')
|
||||
x = s.get_meta("displayname")
|
||||
assert x == normalize_meta_value(x)
|
||||
|
||||
if not getattr(self, 'dav_server', None):
|
||||
if not getattr(self, "dav_server", None):
|
||||
# ownCloud replaces "" with "unnamed"
|
||||
s.set_meta('displayname', value)
|
||||
assert s.get_meta('displayname') == normalize_meta_value(value)
|
||||
s.set_meta("displayname", value)
|
||||
assert s.get_meta("displayname") == normalize_meta_value(value)
|
||||
|
||||
def test_recurring_events(self, s, item_type):
|
||||
if item_type != 'VEVENT':
|
||||
pytest.skip('This storage instance doesn\'t support iCalendar.')
|
||||
if item_type != "VEVENT":
|
||||
pytest.skip("This storage instance doesn't support iCalendar.")
|
||||
|
||||
uid = str(uuid.uuid4())
|
||||
item = Item(textwrap.dedent('''
|
||||
item = Item(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
|
|
@ -351,7 +356,11 @@ class StorageTests:
|
|||
TRANSP:OPAQUE
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
'''.format(uid=uid)).strip())
|
||||
""".format(
|
||||
uid=uid
|
||||
)
|
||||
).strip()
|
||||
)
|
||||
|
||||
href, etag = s.upload(item)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,82 @@
|
|||
import contextlib
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
|
||||
def wait_for_container(url):
|
||||
"""Wait for a container to initialise.
|
||||
|
||||
Polls a URL every 100ms until the server responds.
|
||||
"""
|
||||
# give the server 5 seconds to settle
|
||||
for _ in range(50):
|
||||
print(_)
|
||||
|
||||
try:
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
except requests.ConnectionError:
|
||||
pass
|
||||
else:
|
||||
return
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
pytest.exit(
|
||||
"Server did not initialise in 5 seconds.\n"
|
||||
"WARNING: There may be a stale docker container still running."
|
||||
)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def dockerised_server(name, container_port, exposed_port):
|
||||
"""Run a dockerised DAV server as a contenxt manager."""
|
||||
container_id = None
|
||||
url = f"http://127.0.0.1:{exposed_port}/"
|
||||
|
||||
try:
|
||||
# Hint: This will block while the pull happends, and only return once
|
||||
# the container has actually started.
|
||||
output = subprocess.check_output(
|
||||
[
|
||||
"docker",
|
||||
"run",
|
||||
"--detach",
|
||||
"--publish",
|
||||
f"{exposed_port}:{container_port}",
|
||||
f"whynothugo/vdirsyncer-devkit-{name}",
|
||||
]
|
||||
)
|
||||
|
||||
container_id = output.decode().strip()
|
||||
wait_for_container(url)
|
||||
|
||||
yield url
|
||||
finally:
|
||||
if container_id:
|
||||
subprocess.check_output(["docker", "kill", container_id])
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def baikal_server():
|
||||
with dockerised_server("baikal", "80", "8002"):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def radicale_server():
|
||||
with dockerised_server("radicale", "8001", "8001"):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def xandikos_server():
|
||||
with dockerised_server("xandikos", "8000", "8000"):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -11,13 +87,13 @@ def slow_create_collection(request):
|
|||
|
||||
def delete_collections():
|
||||
for s in to_delete:
|
||||
s.session.request('DELETE', '')
|
||||
s.session.request("DELETE", "")
|
||||
|
||||
request.addfinalizer(delete_collections)
|
||||
|
||||
def inner(cls, args, collection):
|
||||
assert collection.startswith('test')
|
||||
collection += '-vdirsyncer-ci-' + str(uuid.uuid4())
|
||||
assert collection.startswith("test")
|
||||
collection += "-vdirsyncer-ci-" + str(uuid.uuid4())
|
||||
|
||||
args = cls.create_collection(collection, **args)
|
||||
s = cls(**args)
|
||||
|
|
|
|||
|
|
@ -11,26 +11,25 @@ from vdirsyncer import exceptions
|
|||
from vdirsyncer.vobject import Item
|
||||
|
||||
|
||||
dav_server = os.environ.get('DAV_SERVER', 'skip')
|
||||
dav_server = os.environ.get("DAV_SERVER", "skip")
|
||||
ServerMixin = get_server_mixin(dav_server)
|
||||
|
||||
|
||||
class DAVStorageTests(ServerMixin, StorageTests):
|
||||
dav_server = dav_server
|
||||
|
||||
@pytest.mark.skipif(dav_server == 'radicale',
|
||||
reason='Radicale is very tolerant.')
|
||||
@pytest.mark.skipif(dav_server == "radicale", reason="Radicale is very tolerant.")
|
||||
def test_dav_broken_item(self, s):
|
||||
item = Item('HAHA:YES')
|
||||
item = Item("HAHA:YES")
|
||||
with pytest.raises((exceptions.Error, requests.exceptions.HTTPError)):
|
||||
s.upload(item)
|
||||
assert not list(s.list())
|
||||
|
||||
def test_dav_empty_get_multi_performance(self, s, monkeypatch):
|
||||
def breakdown(*a, **kw):
|
||||
raise AssertionError('Expected not to be called.')
|
||||
raise AssertionError("Expected not to be called.")
|
||||
|
||||
monkeypatch.setattr('requests.sessions.Session.request', breakdown)
|
||||
monkeypatch.setattr("requests.sessions.Session.request", breakdown)
|
||||
|
||||
try:
|
||||
assert list(s.get_multi([])) == []
|
||||
|
|
@ -39,12 +38,11 @@ class DAVStorageTests(ServerMixin, StorageTests):
|
|||
monkeypatch.undo()
|
||||
|
||||
def test_dav_unicode_href(self, s, get_item, monkeypatch):
|
||||
if self.dav_server == 'radicale':
|
||||
pytest.skip('Radicale is unable to deal with unicode hrefs')
|
||||
if self.dav_server == "radicale":
|
||||
pytest.skip("Radicale is unable to deal with unicode hrefs")
|
||||
|
||||
monkeypatch.setattr(s, '_get_href',
|
||||
lambda item: item.ident + s.fileext)
|
||||
item = get_item(uid='град сатану' + str(uuid.uuid4()))
|
||||
monkeypatch.setattr(s, "_get_href", lambda item: item.ident + s.fileext)
|
||||
item = get_item(uid="град сатану" + str(uuid.uuid4()))
|
||||
href, etag = s.upload(item)
|
||||
item2, etag2 = s.get(href)
|
||||
assert_item_equals(item, item2)
|
||||
|
|
|
|||
|
|
@ -17,10 +17,11 @@ from vdirsyncer.storage.dav import CalDAVStorage
|
|||
class TestCalDAVStorage(DAVStorageTests):
|
||||
storage_class = CalDAVStorage
|
||||
|
||||
@pytest.fixture(params=['VTODO', 'VEVENT'])
|
||||
@pytest.fixture(params=["VTODO", "VEVENT"])
|
||||
def item_type(self, request):
|
||||
return request.param
|
||||
|
||||
@pytest.mark.xfail(dav_server == "baikal", reason="Baikal returns 500.")
|
||||
def test_doesnt_accept_vcard(self, item_type, get_storage_args):
|
||||
s = self.storage_class(item_types=(item_type,), **get_storage_args())
|
||||
|
||||
|
|
@ -32,15 +33,20 @@ class TestCalDAVStorage(DAVStorageTests):
|
|||
|
||||
# The `arg` param is not named `item_types` because that would hit
|
||||
# https://bitbucket.org/pytest-dev/pytest/issue/745/
|
||||
@pytest.mark.parametrize('arg,calls_num', [
|
||||
(('VTODO',), 1),
|
||||
(('VEVENT',), 1),
|
||||
(('VTODO', 'VEVENT'), 2),
|
||||
(('VTODO', 'VEVENT', 'VJOURNAL'), 3),
|
||||
((), 1)
|
||||
])
|
||||
def test_item_types_performance(self, get_storage_args, arg, calls_num,
|
||||
monkeypatch):
|
||||
@pytest.mark.parametrize(
|
||||
"arg,calls_num",
|
||||
[
|
||||
(("VTODO",), 1),
|
||||
(("VEVENT",), 1),
|
||||
(("VTODO", "VEVENT"), 2),
|
||||
(("VTODO", "VEVENT", "VJOURNAL"), 3),
|
||||
((), 1),
|
||||
],
|
||||
)
|
||||
@pytest.mark.xfail(dav_server == "baikal", reason="Baikal returns 500.")
|
||||
def test_item_types_performance(
|
||||
self, get_storage_args, arg, calls_num, monkeypatch
|
||||
):
|
||||
s = self.storage_class(item_types=arg, **get_storage_args())
|
||||
old_parse = s._parse_prop_responses
|
||||
calls = []
|
||||
|
|
@ -49,19 +55,23 @@ class TestCalDAVStorage(DAVStorageTests):
|
|||
calls.append(None)
|
||||
return old_parse(*a, **kw)
|
||||
|
||||
monkeypatch.setattr(s, '_parse_prop_responses', new_parse)
|
||||
monkeypatch.setattr(s, "_parse_prop_responses", new_parse)
|
||||
list(s.list())
|
||||
assert len(calls) == calls_num
|
||||
|
||||
@pytest.mark.xfail(dav_server == 'radicale',
|
||||
reason='Radicale doesn\'t support timeranges.')
|
||||
@pytest.mark.xfail(
|
||||
dav_server == "radicale", reason="Radicale doesn't support timeranges."
|
||||
)
|
||||
def test_timerange_correctness(self, get_storage_args):
|
||||
start_date = datetime.datetime(2013, 9, 10)
|
||||
end_date = datetime.datetime(2013, 9, 13)
|
||||
s = self.storage_class(start_date=start_date, end_date=end_date,
|
||||
**get_storage_args())
|
||||
s = self.storage_class(
|
||||
start_date=start_date, end_date=end_date, **get_storage_args()
|
||||
)
|
||||
|
||||
too_old_item = format_item(dedent('''
|
||||
too_old_item = format_item(
|
||||
dedent(
|
||||
"""
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//hacksw/handcal//NONSGML v1.0//EN
|
||||
|
|
@ -73,9 +83,13 @@ class TestCalDAVStorage(DAVStorageTests):
|
|||
UID:{r}
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
''').strip())
|
||||
"""
|
||||
).strip()
|
||||
)
|
||||
|
||||
too_new_item = format_item(dedent('''
|
||||
too_new_item = format_item(
|
||||
dedent(
|
||||
"""
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//hacksw/handcal//NONSGML v1.0//EN
|
||||
|
|
@ -87,9 +101,13 @@ class TestCalDAVStorage(DAVStorageTests):
|
|||
UID:{r}
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
''').strip())
|
||||
"""
|
||||
).strip()
|
||||
)
|
||||
|
||||
good_item = format_item(dedent('''
|
||||
good_item = format_item(
|
||||
dedent(
|
||||
"""
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//hacksw/handcal//NONSGML v1.0//EN
|
||||
|
|
@ -101,13 +119,15 @@ class TestCalDAVStorage(DAVStorageTests):
|
|||
UID:{r}
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
''').strip())
|
||||
"""
|
||||
).strip()
|
||||
)
|
||||
|
||||
s.upload(too_old_item)
|
||||
s.upload(too_new_item)
|
||||
expected_href, _ = s.upload(good_item)
|
||||
|
||||
(actual_href, _), = s.list()
|
||||
((actual_href, _),) = s.list()
|
||||
assert actual_href == expected_href
|
||||
|
||||
def test_invalid_resource(self, monkeypatch, get_storage_args):
|
||||
|
|
@ -115,37 +135,38 @@ class TestCalDAVStorage(DAVStorageTests):
|
|||
args = get_storage_args(collection=None)
|
||||
|
||||
def request(session, method, url, **kwargs):
|
||||
assert url == args['url']
|
||||
assert url == args["url"]
|
||||
calls.append(None)
|
||||
|
||||
r = requests.Response()
|
||||
r.status_code = 200
|
||||
r._content = b'Hello World.'
|
||||
r._content = b"Hello World."
|
||||
return r
|
||||
|
||||
monkeypatch.setattr('requests.sessions.Session.request', request)
|
||||
monkeypatch.setattr("requests.sessions.Session.request", request)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
s = self.storage_class(**args)
|
||||
list(s.list())
|
||||
assert len(calls) == 1
|
||||
|
||||
@pytest.mark.skipif(dav_server == 'icloud',
|
||||
reason='iCloud only accepts VEVENT')
|
||||
@pytest.mark.skipif(dav_server == 'fastmail',
|
||||
reason='Fastmail has non-standard hadling of VTODOs.')
|
||||
@pytest.mark.skipif(dav_server == "icloud", reason="iCloud only accepts VEVENT")
|
||||
@pytest.mark.skipif(
|
||||
dav_server == "fastmail", reason="Fastmail has non-standard hadling of VTODOs."
|
||||
)
|
||||
@pytest.mark.xfail(dav_server == "baikal", reason="Baikal returns 500.")
|
||||
def test_item_types_general(self, s):
|
||||
event = s.upload(format_item(EVENT_TEMPLATE))[0]
|
||||
task = s.upload(format_item(TASK_TEMPLATE))[0]
|
||||
s.item_types = ('VTODO', 'VEVENT')
|
||||
s.item_types = ("VTODO", "VEVENT")
|
||||
|
||||
def hrefs():
|
||||
return {href for href, etag in s.list()}
|
||||
|
||||
assert hrefs() == {event, task}
|
||||
s.item_types = ('VTODO',)
|
||||
s.item_types = ("VTODO",)
|
||||
assert hrefs() == {task}
|
||||
s.item_types = ('VEVENT',)
|
||||
s.item_types = ("VEVENT",)
|
||||
assert hrefs() == {event}
|
||||
s.item_types = ()
|
||||
assert hrefs() == {event, task}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,6 @@ from vdirsyncer.storage.dav import CardDAVStorage
|
|||
class TestCardDAVStorage(DAVStorageTests):
|
||||
storage_class = CardDAVStorage
|
||||
|
||||
@pytest.fixture(params=['VCARD'])
|
||||
@pytest.fixture(params=["VCARD"])
|
||||
def item_type(self, request):
|
||||
return request.param
|
||||
|
|
|
|||
|
|
@ -6,37 +6,41 @@ from vdirsyncer.storage.dav import _parse_xml
|
|||
|
||||
|
||||
def test_xml_utilities():
|
||||
x = _parse_xml(b'''<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<D:multistatus xmlns:D="DAV:">
|
||||
<D:response>
|
||||
<D:propstat>
|
||||
<D:status>HTTP/1.1 404 Not Found</D:status>
|
||||
<D:prop>
|
||||
<D:getcontenttype/>
|
||||
</D:prop>
|
||||
</D:propstat>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:resourcetype>
|
||||
<D:collection/>
|
||||
</D:resourcetype>
|
||||
</D:prop>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>
|
||||
''')
|
||||
x = _parse_xml(
|
||||
b"""<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<multistatus xmlns="DAV:">
|
||||
<response>
|
||||
<propstat>
|
||||
<status>HTTP/1.1 404 Not Found</status>
|
||||
<prop>
|
||||
<getcontenttype/>
|
||||
</prop>
|
||||
</propstat>
|
||||
<propstat>
|
||||
<prop>
|
||||
<resourcetype>
|
||||
<collection/>
|
||||
</resourcetype>
|
||||
</prop>
|
||||
</propstat>
|
||||
</response>
|
||||
</multistatus>
|
||||
"""
|
||||
)
|
||||
|
||||
response = x.find('{DAV:}response')
|
||||
props = _merge_xml(response.findall('{DAV:}propstat/{DAV:}prop'))
|
||||
assert props.find('{DAV:}resourcetype/{DAV:}collection') is not None
|
||||
assert props.find('{DAV:}getcontenttype') is not None
|
||||
response = x.find("{DAV:}response")
|
||||
props = _merge_xml(response.findall("{DAV:}propstat/{DAV:}prop"))
|
||||
assert props.find("{DAV:}resourcetype/{DAV:}collection") is not None
|
||||
assert props.find("{DAV:}getcontenttype") is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize('char', range(32))
|
||||
@pytest.mark.parametrize("char", range(32))
|
||||
def test_xml_specialchars(char):
|
||||
x = _parse_xml('<?xml version="1.0" encoding="UTF-8" ?>'
|
||||
'<foo>ye{}s\r\n'
|
||||
'hello</foo>'.format(chr(char)).encode('ascii'))
|
||||
x = _parse_xml(
|
||||
'<?xml version="1.0" encoding="UTF-8" ?>'
|
||||
"<foo>ye{}s\r\n"
|
||||
"hello</foo>".format(chr(char)).encode("ascii")
|
||||
)
|
||||
|
||||
if char in _BAD_XML_CHARS:
|
||||
assert x.text == 'yes\nhello'
|
||||
assert x.text == "yes\nhello"
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|||
# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = 'd7r(p-9=$3a@bbt%*+$p@4)cej13nzd0gmnt8+m0bitb=-umj#'
|
||||
SECRET_KEY = "d7r(p-9=$3a@bbt%*+$p@4)cej13nzd0gmnt8+m0bitb=-umj#"
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
|
@ -30,56 +30,55 @@ ALLOWED_HOSTS = []
|
|||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
'journal.apps.JournalConfig',
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"rest_framework",
|
||||
"rest_framework.authtoken",
|
||||
"journal.apps.JournalConfig",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'etesync_server.urls'
|
||||
ROOT_URLCONF = "etesync_server.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'etesync_server.wsgi.application'
|
||||
WSGI_APPLICATION = "etesync_server.wsgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': os.environ.get('ETESYNC_DB_PATH',
|
||||
os.path.join(BASE_DIR, 'db.sqlite3')),
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": os.environ.get("ETESYNC_DB_PATH", os.path.join(BASE_DIR, "db.sqlite3")),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -89,16 +88,16 @@ DATABASES = {
|
|||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', # noqa
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", # noqa
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', # noqa
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", # noqa
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', # noqa
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", # noqa
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', # noqa
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", # noqa
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -106,9 +105,9 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/1.10/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
TIME_ZONE = "UTC"
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
|
|
@ -120,4 +119,4 @@ USE_TZ = True
|
|||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/1.10/howto/static-files/
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_URL = "/static/"
|
||||
|
|
|
|||
|
|
@ -19,22 +19,19 @@ from journal import views
|
|||
from rest_framework_nested import routers
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
router.register(r'journals', views.JournalViewSet)
|
||||
router.register(r'journal/(?P<journal_uid>[^/]+)', views.EntryViewSet)
|
||||
router.register(r'user', views.UserInfoViewSet)
|
||||
router.register(r"journals", views.JournalViewSet)
|
||||
router.register(r"journal/(?P<journal_uid>[^/]+)", views.EntryViewSet)
|
||||
router.register(r"user", views.UserInfoViewSet)
|
||||
|
||||
journals_router = routers.NestedSimpleRouter(router, r'journals',
|
||||
lookup='journal')
|
||||
journals_router.register(r'members', views.MembersViewSet,
|
||||
base_name='journal-members')
|
||||
journals_router.register(r'entries', views.EntryViewSet,
|
||||
base_name='journal-entries')
|
||||
journals_router = routers.NestedSimpleRouter(router, r"journals", lookup="journal")
|
||||
journals_router.register(r"members", views.MembersViewSet, base_name="journal-members")
|
||||
journals_router.register(r"entries", views.EntryViewSet, base_name="journal-entries")
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^api/v1/', include(router.urls)),
|
||||
url(r'^api/v1/', include(journals_router.urls)),
|
||||
url(r"^api/v1/", include(router.urls)),
|
||||
url(r"^api/v1/", include(journals_router.urls)),
|
||||
]
|
||||
|
||||
# Adding this just for testing, this shouldn't be here normally
|
||||
urlpatterns += url(r'^reset/$', views.reset, name='reset_debug'),
|
||||
urlpatterns += (url(r"^reset/$", views.reset, name="reset_debug"),)
|
||||
|
|
|
|||
|
|
@ -10,24 +10,23 @@ from vdirsyncer.storage.etesync import EtesyncCalendars
|
|||
from vdirsyncer.storage.etesync import EtesyncContacts
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(os.getenv('ETESYNC_TESTS', '') != 'true',
|
||||
reason='etesync tests disabled')
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.getenv("ETESYNC_TESTS", "") != "true", reason="etesync tests disabled"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
@pytest.fixture(scope="session")
|
||||
def etesync_app(tmpdir_factory):
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__),
|
||||
'etesync_server'))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "etesync_server"))
|
||||
|
||||
db = tmpdir_factory.mktemp('etesync').join('etesync.sqlite')
|
||||
db = tmpdir_factory.mktemp("etesync").join("etesync.sqlite")
|
||||
shutil.copy(
|
||||
os.path.join(os.path.dirname(__file__), 'etesync_server',
|
||||
'db.sqlite3'),
|
||||
str(db)
|
||||
os.path.join(os.path.dirname(__file__), "etesync_server", "db.sqlite3"), str(db)
|
||||
)
|
||||
|
||||
os.environ['ETESYNC_DB_PATH'] = str(db)
|
||||
os.environ["ETESYNC_DB_PATH"] = str(db)
|
||||
from etesync_server.wsgi import application
|
||||
|
||||
return application
|
||||
|
||||
|
||||
|
|
@ -39,44 +38,44 @@ class EtesyncTests(StorageTests):
|
|||
def get_storage_args(self, request, get_item, tmpdir, etesync_app):
|
||||
import wsgi_intercept
|
||||
import wsgi_intercept.requests_intercept
|
||||
|
||||
wsgi_intercept.requests_intercept.install()
|
||||
wsgi_intercept.add_wsgi_intercept('127.0.0.1', 8000,
|
||||
lambda: etesync_app)
|
||||
wsgi_intercept.add_wsgi_intercept("127.0.0.1", 8000, lambda: etesync_app)
|
||||
|
||||
def teardown():
|
||||
wsgi_intercept.remove_wsgi_intercept('127.0.0.1', 8000)
|
||||
wsgi_intercept.remove_wsgi_intercept("127.0.0.1", 8000)
|
||||
wsgi_intercept.requests_intercept.uninstall()
|
||||
|
||||
request.addfinalizer(teardown)
|
||||
|
||||
with open(os.path.join(os.path.dirname(__file__),
|
||||
'test@localhost/auth_token')) as f:
|
||||
with open(
|
||||
os.path.join(os.path.dirname(__file__), "test@localhost/auth_token")
|
||||
) as f:
|
||||
token = f.read().strip()
|
||||
headers = {'Authorization': 'Token ' + token}
|
||||
r = requests.post('http://127.0.0.1:8000/reset/', headers=headers,
|
||||
allow_redirects=False)
|
||||
headers = {"Authorization": "Token " + token}
|
||||
r = requests.post(
|
||||
"http://127.0.0.1:8000/reset/", headers=headers, allow_redirects=False
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
def inner(collection='test'):
|
||||
def inner(collection="test"):
|
||||
rv = {
|
||||
'email': 'test@localhost',
|
||||
'db_path': str(tmpdir.join('etesync.db')),
|
||||
'secrets_dir': os.path.dirname(__file__),
|
||||
'server_url': 'http://127.0.0.1:8000/'
|
||||
"email": "test@localhost",
|
||||
"db_path": str(tmpdir.join("etesync.db")),
|
||||
"secrets_dir": os.path.dirname(__file__),
|
||||
"server_url": "http://127.0.0.1:8000/",
|
||||
}
|
||||
if collection is not None:
|
||||
rv = self.storage_class.create_collection(
|
||||
collection=collection,
|
||||
**rv
|
||||
)
|
||||
rv = self.storage_class.create_collection(collection=collection, **rv)
|
||||
return rv
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
class TestContacts(EtesyncTests):
|
||||
storage_class = EtesyncContacts
|
||||
|
||||
@pytest.fixture(params=['VCARD'])
|
||||
@pytest.fixture(params=["VCARD"])
|
||||
def item_type(self, request):
|
||||
return request.param
|
||||
|
||||
|
|
@ -84,6 +83,6 @@ class TestContacts(EtesyncTests):
|
|||
class TestCalendars(EtesyncTests):
|
||||
storage_class = EtesyncCalendars
|
||||
|
||||
@pytest.fixture(params=['VEVENT'])
|
||||
@pytest.fixture(params=["VEVENT"])
|
||||
def item_type(self, request):
|
||||
return request.param
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import pytest
|
|||
|
||||
class ServerMixin:
|
||||
@pytest.fixture
|
||||
def get_storage_args(self, request, tmpdir, slow_create_collection):
|
||||
def get_storage_args(self, request, tmpdir, slow_create_collection, baikal_server):
|
||||
def inner(collection="test"):
|
||||
base_url = "http://127.0.0.1:8002/"
|
||||
args = {
|
||||
|
|
@ -12,10 +12,10 @@ class ServerMixin:
|
|||
"password": "baikal",
|
||||
}
|
||||
|
||||
if self.storage_class.fileext == '.vcf':
|
||||
args['url'] = base_url + "card.php/"
|
||||
if self.storage_class.fileext == ".vcf":
|
||||
args["url"] = base_url + "card.php/"
|
||||
else:
|
||||
args['url'] = base_url + "cal.php/"
|
||||
args["url"] = base_url + "cal.php/"
|
||||
|
||||
if collection is not None:
|
||||
args = slow_create_collection(self.storage_class, args, collection)
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
docker-compose build baikal
|
||||
docker-compose up -d baikal
|
||||
|
|
@ -6,43 +6,42 @@ import pytest
|
|||
try:
|
||||
caldav_args = {
|
||||
# Those credentials are configured through the Travis UI
|
||||
'username': os.environ['DAVICAL_USERNAME'].strip(),
|
||||
'password': os.environ['DAVICAL_PASSWORD'].strip(),
|
||||
'url': 'https://brutus.lostpackets.de/davical-test/caldav.php/',
|
||||
"username": os.environ["DAVICAL_USERNAME"].strip(),
|
||||
"password": os.environ["DAVICAL_PASSWORD"].strip(),
|
||||
"url": "https://brutus.lostpackets.de/davical-test/caldav.php/",
|
||||
}
|
||||
except KeyError as e:
|
||||
pytestmark = pytest.mark.skip('Missing envkey: {}'.format(str(e)))
|
||||
pytestmark = pytest.mark.skip("Missing envkey: {}".format(str(e)))
|
||||
|
||||
|
||||
@pytest.mark.flaky(reruns=5)
|
||||
class ServerMixin:
|
||||
@pytest.fixture
|
||||
def davical_args(self):
|
||||
if self.storage_class.fileext == '.ics':
|
||||
if self.storage_class.fileext == ".ics":
|
||||
return dict(caldav_args)
|
||||
elif self.storage_class.fileext == '.vcf':
|
||||
pytest.skip('No carddav')
|
||||
elif self.storage_class.fileext == ".vcf":
|
||||
pytest.skip("No carddav")
|
||||
else:
|
||||
raise RuntimeError()
|
||||
|
||||
@pytest.fixture
|
||||
def get_storage_args(self, davical_args, request):
|
||||
def inner(collection='test'):
|
||||
def inner(collection="test"):
|
||||
if collection is None:
|
||||
return davical_args
|
||||
|
||||
assert collection.startswith('test')
|
||||
assert collection.startswith("test")
|
||||
|
||||
for _ in range(4):
|
||||
args = self.storage_class.create_collection(
|
||||
collection + str(uuid.uuid4()),
|
||||
**davical_args
|
||||
collection + str(uuid.uuid4()), **davical_args
|
||||
)
|
||||
s = self.storage_class(**args)
|
||||
if not list(s.list()):
|
||||
request.addfinalizer(
|
||||
lambda: s.session.request('DELETE', ''))
|
||||
request.addfinalizer(lambda: s.session.request("DELETE", ""))
|
||||
return args
|
||||
|
||||
raise RuntimeError('Failed to find free collection.')
|
||||
raise RuntimeError("Failed to find free collection.")
|
||||
|
||||
return inner
|
||||
|
|
|
|||
|
|
@ -4,29 +4,28 @@ import pytest
|
|||
|
||||
|
||||
class ServerMixin:
|
||||
|
||||
@pytest.fixture
|
||||
def get_storage_args(self, item_type, slow_create_collection):
|
||||
if item_type != 'VEVENT':
|
||||
if item_type != "VEVENT":
|
||||
# iCloud collections can either be calendars or task lists.
|
||||
# See https://github.com/pimutils/vdirsyncer/pull/593#issuecomment-285941615 # noqa
|
||||
pytest.skip('iCloud doesn\'t support anything else than VEVENT')
|
||||
pytest.skip("iCloud doesn't support anything else than VEVENT")
|
||||
|
||||
def inner(collection='test'):
|
||||
def inner(collection="test"):
|
||||
args = {
|
||||
'username': os.environ['ICLOUD_USERNAME'],
|
||||
'password': os.environ['ICLOUD_PASSWORD']
|
||||
"username": os.environ["ICLOUD_USERNAME"],
|
||||
"password": os.environ["ICLOUD_PASSWORD"],
|
||||
}
|
||||
|
||||
if self.storage_class.fileext == '.ics':
|
||||
args['url'] = 'https://caldav.icloud.com/'
|
||||
elif self.storage_class.fileext == '.vcf':
|
||||
args['url'] = 'https://contacts.icloud.com/'
|
||||
if self.storage_class.fileext == ".ics":
|
||||
args["url"] = "https://caldav.icloud.com/"
|
||||
elif self.storage_class.fileext == ".vcf":
|
||||
args["url"] = "https://contacts.icloud.com/"
|
||||
else:
|
||||
raise RuntimeError()
|
||||
|
||||
if collection is not None:
|
||||
args = slow_create_collection(self.storage_class, args,
|
||||
collection)
|
||||
args = slow_create_collection(self.storage_class, args, collection)
|
||||
return args
|
||||
|
||||
return inner
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
mysteryshack
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
testserver_repo = os.path.dirname(__file__)
|
||||
make_sh = os.path.abspath(os.path.join(testserver_repo, 'make.sh'))
|
||||
|
||||
|
||||
def wait():
|
||||
for i in range(100):
|
||||
try:
|
||||
requests.get('http://127.0.0.1:6767/', verify=False)
|
||||
except Exception as e:
|
||||
# Don't know exact exception class, don't care.
|
||||
# Also, https://github.com/kennethreitz/requests/issues/2192
|
||||
if 'connection refused' not in str(e).lower():
|
||||
raise
|
||||
time.sleep(2 ** i)
|
||||
else:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class ServerMixin:
|
||||
@pytest.fixture(scope='session')
|
||||
def setup_mysteryshack_server(self, xprocess):
|
||||
def preparefunc(cwd):
|
||||
return wait, ['sh', make_sh, 'testserver']
|
||||
|
||||
subprocess.check_call(['sh', make_sh, 'testserver-config'])
|
||||
xprocess.ensure('mysteryshack_server', preparefunc)
|
||||
|
||||
return subprocess.check_output([
|
||||
os.path.join(
|
||||
testserver_repo,
|
||||
'mysteryshack/target/debug/mysteryshack'
|
||||
),
|
||||
'-c', '/tmp/mysteryshack/config',
|
||||
'user',
|
||||
'authorize',
|
||||
'testuser',
|
||||
'https://example.com',
|
||||
self.storage_class.scope + ':rw'
|
||||
]).strip().decode()
|
||||
|
||||
@pytest.fixture
|
||||
def get_storage_args(self, monkeypatch, setup_mysteryshack_server):
|
||||
from requests import Session
|
||||
|
||||
monkeypatch.setitem(os.environ, 'OAUTHLIB_INSECURE_TRANSPORT', 'true')
|
||||
|
||||
old_request = Session.request
|
||||
|
||||
def request(self, method, url, **kw):
|
||||
url = url.replace('https://', 'http://')
|
||||
return old_request(self, method, url, **kw)
|
||||
|
||||
monkeypatch.setattr(Session, 'request', request)
|
||||
shutil.rmtree('/tmp/mysteryshack/testuser/data', ignore_errors=True)
|
||||
shutil.rmtree('/tmp/mysteryshack/testuser/meta', ignore_errors=True)
|
||||
|
||||
def inner(**kw):
|
||||
kw['account'] = 'testuser@127.0.0.1:6767'
|
||||
kw['access_token'] = setup_mysteryshack_server
|
||||
if self.storage_class.fileext == '.ics':
|
||||
kw.setdefault('collection', 'test')
|
||||
return kw
|
||||
return inner
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
#!/bin/sh
|
||||
set -ex
|
||||
cd "$(dirname "$0")"
|
||||
. ./variables.sh
|
||||
|
||||
if [ "$CI" = "true" ]; then
|
||||
curl -sL https://static.rust-lang.org/rustup.sh -o ~/rust-installer/rustup.sh
|
||||
sh ~/rust-installer/rustup.sh --prefix=~/rust --spec=stable -y --disable-sudo 2> /dev/null
|
||||
fi
|
||||
|
||||
if [ ! -d mysteryshack ]; then
|
||||
git clone https://github.com/untitaker/mysteryshack
|
||||
fi
|
||||
|
||||
pip install pytest-xprocess
|
||||
|
||||
cd mysteryshack
|
||||
make debug-build # such that first test doesn't hang too long w/o output
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# pytest-xprocess doesn't allow us to CD into a particular directory before
|
||||
# launching a command, so we do it here.
|
||||
cd "$(dirname "$0")"
|
||||
. ./variables.sh
|
||||
cd mysteryshack
|
||||
exec make "$@"
|
||||
|
|
@ -1 +0,0 @@
|
|||
export PATH="$PATH:$HOME/.cargo/bin/"
|
||||
|
|
@ -1 +0,0 @@
|
|||
Subproject commit a27144ddcf39a3283179a4f7ce1ab22b2e810205
|
||||
|
|
@ -1 +0,0 @@
|
|||
Subproject commit bb4fcc6f524467d58c95f1dcec8470fdfcd65adf
|
||||
|
|
@ -3,7 +3,13 @@ import pytest
|
|||
|
||||
class ServerMixin:
|
||||
@pytest.fixture
|
||||
def get_storage_args(self, request, tmpdir, slow_create_collection):
|
||||
def get_storage_args(
|
||||
self,
|
||||
request,
|
||||
tmpdir,
|
||||
slow_create_collection,
|
||||
radicale_server,
|
||||
):
|
||||
def inner(collection="test"):
|
||||
url = "http://127.0.0.1:8001/"
|
||||
args = {
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
docker-compose build radicale
|
||||
docker-compose up -d radicale
|
||||
|
|
@ -2,7 +2,6 @@ import pytest
|
|||
|
||||
|
||||
class ServerMixin:
|
||||
|
||||
@pytest.fixture
|
||||
def get_storage_args(self):
|
||||
pytest.skip('DAV tests disabled.')
|
||||
pytest.skip("DAV tests disabled.")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,13 @@ import pytest
|
|||
|
||||
class ServerMixin:
|
||||
@pytest.fixture
|
||||
def get_storage_args(self, request, tmpdir, slow_create_collection):
|
||||
def get_storage_args(
|
||||
self,
|
||||
request,
|
||||
tmpdir,
|
||||
slow_create_collection,
|
||||
xandikos_server,
|
||||
):
|
||||
def inner(collection="test"):
|
||||
url = "http://127.0.0.1:8000/"
|
||||
args = {"url": url}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
docker-compose build xandikos
|
||||
docker-compose up -d xandikos
|
||||
|
|
@ -12,72 +12,106 @@ class TestFilesystemStorage(StorageTests):
|
|||
|
||||
@pytest.fixture
|
||||
def get_storage_args(self, tmpdir):
|
||||
def inner(collection='test'):
|
||||
rv = {'path': str(tmpdir), 'fileext': '.txt', 'collection':
|
||||
collection}
|
||||
def inner(collection="test"):
|
||||
rv = {"path": str(tmpdir), "fileext": ".txt", "collection": collection}
|
||||
if collection is not None:
|
||||
rv = self.storage_class.create_collection(**rv)
|
||||
return rv
|
||||
|
||||
return inner
|
||||
|
||||
def test_is_not_directory(self, tmpdir):
|
||||
with pytest.raises(OSError):
|
||||
f = tmpdir.join('hue')
|
||||
f.write('stub')
|
||||
self.storage_class(str(tmpdir) + '/hue', '.txt')
|
||||
f = tmpdir.join("hue")
|
||||
f.write("stub")
|
||||
self.storage_class(str(tmpdir) + "/hue", ".txt")
|
||||
|
||||
def test_broken_data(self, tmpdir):
|
||||
s = self.storage_class(str(tmpdir), '.txt')
|
||||
s = self.storage_class(str(tmpdir), ".txt")
|
||||
|
||||
class BrokenItem:
|
||||
raw = 'Ц, Ш, Л, ж, Д, З, Ю'.encode()
|
||||
uid = 'jeezus'
|
||||
raw = "Ц, Ш, Л, ж, Д, З, Ю".encode()
|
||||
uid = "jeezus"
|
||||
ident = uid
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
s.upload(BrokenItem)
|
||||
assert not tmpdir.listdir()
|
||||
|
||||
def test_ident_with_slash(self, tmpdir):
|
||||
s = self.storage_class(str(tmpdir), '.txt')
|
||||
s.upload(Item('UID:a/b/c'))
|
||||
item_file, = tmpdir.listdir()
|
||||
assert '/' not in item_file.basename and item_file.isfile()
|
||||
s = self.storage_class(str(tmpdir), ".txt")
|
||||
s.upload(Item("UID:a/b/c"))
|
||||
(item_file,) = tmpdir.listdir()
|
||||
assert "/" not in item_file.basename and item_file.isfile()
|
||||
|
||||
def test_ignore_tmp_files(self, tmpdir):
|
||||
"""Test that files with .tmp suffix beside .ics files are ignored."""
|
||||
s = self.storage_class(str(tmpdir), ".ics")
|
||||
s.upload(Item("UID:xyzxyz"))
|
||||
(item_file,) = tmpdir.listdir()
|
||||
item_file.copy(item_file.new(ext="tmp"))
|
||||
assert len(tmpdir.listdir()) == 2
|
||||
assert len(list(s.list())) == 1
|
||||
|
||||
def test_ignore_tmp_files_empty_fileext(self, tmpdir):
|
||||
"""Test that files with .tmp suffix are ignored with empty fileext."""
|
||||
s = self.storage_class(str(tmpdir), "")
|
||||
s.upload(Item("UID:xyzxyz"))
|
||||
(item_file,) = tmpdir.listdir()
|
||||
item_file.copy(item_file.new(ext="tmp"))
|
||||
assert len(tmpdir.listdir()) == 2
|
||||
# assert False, tmpdir.listdir() # enable to see the created filename
|
||||
assert len(list(s.list())) == 1
|
||||
|
||||
def test_ignore_files_typical_backup(self, tmpdir):
|
||||
"""Test file-name ignorance with typical backup ending ~."""
|
||||
ignorext = "~" # without dot
|
||||
|
||||
storage = self.storage_class(str(tmpdir), "", fileignoreext=ignorext)
|
||||
storage.upload(Item("UID:xyzxyz"))
|
||||
(item_file,) = tmpdir.listdir()
|
||||
item_file.copy(item_file.new(basename=item_file.basename + ignorext))
|
||||
|
||||
assert len(tmpdir.listdir()) == 2
|
||||
assert len(list(storage.list())) == 1
|
||||
|
||||
def test_too_long_uid(self, tmpdir):
|
||||
s = self.storage_class(str(tmpdir), '.txt')
|
||||
item = Item('UID:' + 'hue' * 600)
|
||||
href, etag = s.upload(item)
|
||||
storage = self.storage_class(str(tmpdir), ".txt")
|
||||
item = Item("UID:" + "hue" * 600)
|
||||
|
||||
href, etag = storage.upload(item)
|
||||
assert item.uid not in href
|
||||
|
||||
def test_post_hook_inactive(self, tmpdir, monkeypatch):
|
||||
|
||||
def check_call_mock(*args, **kwargs):
|
||||
raise AssertionError()
|
||||
|
||||
monkeypatch.setattr(subprocess, 'call', check_call_mock)
|
||||
monkeypatch.setattr(subprocess, "call", check_call_mock)
|
||||
|
||||
s = self.storage_class(str(tmpdir), '.txt', post_hook=None)
|
||||
s.upload(Item('UID:a/b/c'))
|
||||
s = self.storage_class(str(tmpdir), ".txt", post_hook=None)
|
||||
s.upload(Item("UID:a/b/c"))
|
||||
|
||||
def test_post_hook_active(self, tmpdir, monkeypatch):
|
||||
|
||||
calls = []
|
||||
exe = 'foo'
|
||||
exe = "foo"
|
||||
|
||||
def check_call_mock(call, *args, **kwargs):
|
||||
calls.append(True)
|
||||
assert len(call) == 2
|
||||
assert call[0] == exe
|
||||
|
||||
monkeypatch.setattr(subprocess, 'call', check_call_mock)
|
||||
monkeypatch.setattr(subprocess, "call", check_call_mock)
|
||||
|
||||
s = self.storage_class(str(tmpdir), '.txt', post_hook=exe)
|
||||
s.upload(Item('UID:a/b/c'))
|
||||
s = self.storage_class(str(tmpdir), ".txt", post_hook=exe)
|
||||
s.upload(Item("UID:a/b/c"))
|
||||
assert calls
|
||||
|
||||
def test_ignore_git_dirs(self, tmpdir):
|
||||
tmpdir.mkdir('.git').mkdir('foo')
|
||||
tmpdir.mkdir('a')
|
||||
tmpdir.mkdir('b')
|
||||
assert {c['collection'] for c
|
||||
in self.storage_class.discover(str(tmpdir))} == {'a', 'b'}
|
||||
tmpdir.mkdir(".git").mkdir("foo")
|
||||
tmpdir.mkdir("a")
|
||||
tmpdir.mkdir("b")
|
||||
assert {c["collection"] for c in self.storage_class.discover(str(tmpdir))} == {
|
||||
"a",
|
||||
"b",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,42 +8,44 @@ from vdirsyncer.storage.http import prepare_auth
|
|||
|
||||
|
||||
def test_list(monkeypatch):
|
||||
collection_url = 'http://127.0.0.1/calendar/collection.ics'
|
||||
collection_url = "http://127.0.0.1/calendar/collection.ics"
|
||||
|
||||
items = [
|
||||
('BEGIN:VEVENT\n'
|
||||
'SUMMARY:Eine Kurzinfo\n'
|
||||
'DESCRIPTION:Beschreibung des Termines\n'
|
||||
'END:VEVENT'),
|
||||
('BEGIN:VEVENT\n'
|
||||
'SUMMARY:Eine zweite Küèrzinfo\n'
|
||||
'DESCRIPTION:Beschreibung des anderen Termines\n'
|
||||
'BEGIN:VALARM\n'
|
||||
'ACTION:AUDIO\n'
|
||||
'TRIGGER:19980403T120000\n'
|
||||
'ATTACH;FMTTYPE=audio/basic:http://host.com/pub/ssbanner.aud\n'
|
||||
'REPEAT:4\n'
|
||||
'DURATION:PT1H\n'
|
||||
'END:VALARM\n'
|
||||
'END:VEVENT')
|
||||
(
|
||||
"BEGIN:VEVENT\n"
|
||||
"SUMMARY:Eine Kurzinfo\n"
|
||||
"DESCRIPTION:Beschreibung des Termines\n"
|
||||
"END:VEVENT"
|
||||
),
|
||||
(
|
||||
"BEGIN:VEVENT\n"
|
||||
"SUMMARY:Eine zweite Küèrzinfo\n"
|
||||
"DESCRIPTION:Beschreibung des anderen Termines\n"
|
||||
"BEGIN:VALARM\n"
|
||||
"ACTION:AUDIO\n"
|
||||
"TRIGGER:19980403T120000\n"
|
||||
"ATTACH;FMTTYPE=audio/basic:http://host.com/pub/ssbanner.aud\n"
|
||||
"REPEAT:4\n"
|
||||
"DURATION:PT1H\n"
|
||||
"END:VALARM\n"
|
||||
"END:VEVENT"
|
||||
),
|
||||
]
|
||||
|
||||
responses = [
|
||||
'\n'.join(['BEGIN:VCALENDAR'] + items + ['END:VCALENDAR'])
|
||||
] * 2
|
||||
responses = ["\n".join(["BEGIN:VCALENDAR"] + items + ["END:VCALENDAR"])] * 2
|
||||
|
||||
def get(self, method, url, *a, **kw):
|
||||
assert method == 'GET'
|
||||
assert method == "GET"
|
||||
assert url == collection_url
|
||||
r = Response()
|
||||
r.status_code = 200
|
||||
assert responses
|
||||
r._content = responses.pop().encode('utf-8')
|
||||
r.headers['Content-Type'] = 'text/calendar'
|
||||
r.encoding = 'ISO-8859-1'
|
||||
r._content = responses.pop().encode("utf-8")
|
||||
r.headers["Content-Type"] = "text/calendar"
|
||||
r.encoding = "ISO-8859-1"
|
||||
return r
|
||||
|
||||
monkeypatch.setattr('requests.sessions.Session.request', get)
|
||||
monkeypatch.setattr("requests.sessions.Session.request", get)
|
||||
|
||||
s = HttpStorage(url=collection_url)
|
||||
|
||||
|
|
@ -55,8 +57,9 @@ def test_list(monkeypatch):
|
|||
assert etag2 == etag
|
||||
found_items[normalize_item(item)] = href
|
||||
|
||||
expected = {normalize_item('BEGIN:VCALENDAR\n' + x + '\nEND:VCALENDAR')
|
||||
for x in items}
|
||||
expected = {
|
||||
normalize_item("BEGIN:VCALENDAR\n" + x + "\nEND:VCALENDAR") for x in items
|
||||
}
|
||||
|
||||
assert set(found_items) == expected
|
||||
|
||||
|
|
@ -68,7 +71,7 @@ def test_list(monkeypatch):
|
|||
|
||||
|
||||
def test_readonly_param():
|
||||
url = 'http://example.com/'
|
||||
url = "http://example.com/"
|
||||
with pytest.raises(ValueError):
|
||||
HttpStorage(url=url, read_only=False)
|
||||
|
||||
|
|
@ -78,43 +81,43 @@ def test_readonly_param():
|
|||
|
||||
|
||||
def test_prepare_auth():
|
||||
assert prepare_auth(None, '', '') is None
|
||||
assert prepare_auth(None, "", "") is None
|
||||
|
||||
assert prepare_auth(None, 'user', 'pwd') == ('user', 'pwd')
|
||||
assert prepare_auth('basic', 'user', 'pwd') == ('user', 'pwd')
|
||||
assert prepare_auth(None, "user", "pwd") == ("user", "pwd")
|
||||
assert prepare_auth("basic", "user", "pwd") == ("user", "pwd")
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
assert prepare_auth('basic', '', 'pwd')
|
||||
assert 'you need to specify username and password' in \
|
||||
str(excinfo.value).lower()
|
||||
assert prepare_auth("basic", "", "pwd")
|
||||
assert "you need to specify username and password" in str(excinfo.value).lower()
|
||||
|
||||
from requests.auth import HTTPDigestAuth
|
||||
assert isinstance(prepare_auth('digest', 'user', 'pwd'),
|
||||
HTTPDigestAuth)
|
||||
|
||||
assert isinstance(prepare_auth("digest", "user", "pwd"), HTTPDigestAuth)
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
prepare_auth('ladida', 'user', 'pwd')
|
||||
prepare_auth("ladida", "user", "pwd")
|
||||
|
||||
assert 'unknown authentication method' in str(excinfo.value).lower()
|
||||
assert "unknown authentication method" in str(excinfo.value).lower()
|
||||
|
||||
|
||||
def test_prepare_auth_guess(monkeypatch):
|
||||
import requests_toolbelt.auth.guess
|
||||
|
||||
assert isinstance(prepare_auth('guess', 'user', 'pwd'),
|
||||
requests_toolbelt.auth.guess.GuessAuth)
|
||||
assert isinstance(
|
||||
prepare_auth("guess", "user", "pwd"), requests_toolbelt.auth.guess.GuessAuth
|
||||
)
|
||||
|
||||
monkeypatch.delattr(requests_toolbelt.auth.guess, 'GuessAuth')
|
||||
monkeypatch.delattr(requests_toolbelt.auth.guess, "GuessAuth")
|
||||
|
||||
with pytest.raises(UserError) as excinfo:
|
||||
prepare_auth('guess', 'user', 'pwd')
|
||||
prepare_auth("guess", "user", "pwd")
|
||||
|
||||
assert 'requests_toolbelt is too old' in str(excinfo.value).lower()
|
||||
assert "requests_toolbelt is too old" in str(excinfo.value).lower()
|
||||
|
||||
|
||||
def test_verify_false_disallowed():
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
HttpStorage(url='http://example.com', verify=False)
|
||||
HttpStorage(url="http://example.com", verify=False)
|
||||
|
||||
assert 'forbidden' in str(excinfo.value).lower()
|
||||
assert 'consider setting verify_fingerprint' in str(excinfo.value).lower()
|
||||
assert "forbidden" in str(excinfo.value).lower()
|
||||
assert "consider setting verify_fingerprint" in str(excinfo.value).lower()
|
||||
|
|
|
|||
|
|
@ -8,13 +8,14 @@ from vdirsyncer.storage.singlefile import SingleFileStorage
|
|||
|
||||
|
||||
class CombinedStorage(Storage):
|
||||
'''A subclass of HttpStorage to make testing easier. It supports writes via
|
||||
SingleFileStorage.'''
|
||||
_repr_attributes = ('url', 'path')
|
||||
storage_name = 'http_and_singlefile'
|
||||
"""A subclass of HttpStorage to make testing easier. It supports writes via
|
||||
SingleFileStorage."""
|
||||
|
||||
_repr_attributes = ("url", "path")
|
||||
storage_name = "http_and_singlefile"
|
||||
|
||||
def __init__(self, url, path, **kwargs):
|
||||
if kwargs.get('collection', None) is not None:
|
||||
if kwargs.get("collection", None) is not None:
|
||||
raise ValueError()
|
||||
|
||||
super().__init__(**kwargs)
|
||||
|
|
@ -48,30 +49,30 @@ class TestHttpStorage(StorageTests):
|
|||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_tmpdir(self, tmpdir, monkeypatch):
|
||||
self.tmpfile = str(tmpdir.ensure('collection.txt'))
|
||||
self.tmpfile = str(tmpdir.ensure("collection.txt"))
|
||||
|
||||
def _request(method, url, *args, **kwargs):
|
||||
assert method == 'GET'
|
||||
assert url == 'http://localhost:123/collection.txt'
|
||||
assert 'vdirsyncer' in kwargs['headers']['User-Agent']
|
||||
assert method == "GET"
|
||||
assert url == "http://localhost:123/collection.txt"
|
||||
assert "vdirsyncer" in kwargs["headers"]["User-Agent"]
|
||||
r = Response()
|
||||
r.status_code = 200
|
||||
try:
|
||||
with open(self.tmpfile, 'rb') as f:
|
||||
with open(self.tmpfile, "rb") as f:
|
||||
r._content = f.read()
|
||||
except OSError:
|
||||
r._content = b''
|
||||
r._content = b""
|
||||
|
||||
r.headers['Content-Type'] = 'text/calendar'
|
||||
r.encoding = 'utf-8'
|
||||
r.headers["Content-Type"] = "text/calendar"
|
||||
r.encoding = "utf-8"
|
||||
return r
|
||||
|
||||
monkeypatch.setattr(vdirsyncer.storage.http, 'request', _request)
|
||||
monkeypatch.setattr(vdirsyncer.storage.http, "request", _request)
|
||||
|
||||
@pytest.fixture
|
||||
def get_storage_args(self):
|
||||
def inner(collection=None):
|
||||
assert collection is None
|
||||
return {'url': 'http://localhost:123/collection.txt',
|
||||
'path': self.tmpfile}
|
||||
return {"url": "http://localhost:123/collection.txt", "path": self.tmpfile}
|
||||
|
||||
return inner
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@ class TestSingleFileStorage(StorageTests):
|
|||
|
||||
@pytest.fixture
|
||||
def get_storage_args(self, tmpdir):
|
||||
def inner(collection='test'):
|
||||
rv = {'path': str(tmpdir.join('%s.txt')),
|
||||
'collection': collection}
|
||||
def inner(collection="test"):
|
||||
rv = {"path": str(tmpdir.join("%s.txt")), "collection": collection}
|
||||
if collection is not None:
|
||||
rv = self.storage_class.create_collection(**rv)
|
||||
return rv
|
||||
|
||||
return inner
|
||||
|
|
|
|||
|
|
@ -9,20 +9,24 @@ import vdirsyncer.cli as cli
|
|||
class _CustomRunner:
|
||||
def __init__(self, tmpdir):
|
||||
self.tmpdir = tmpdir
|
||||
self.cfg = tmpdir.join('config')
|
||||
self.cfg = tmpdir.join("config")
|
||||
self.runner = CliRunner()
|
||||
|
||||
def invoke(self, args, env=None, **kwargs):
|
||||
env = env or {}
|
||||
env.setdefault('VDIRSYNCER_CONFIG', str(self.cfg))
|
||||
env.setdefault("VDIRSYNCER_CONFIG", str(self.cfg))
|
||||
return self.runner.invoke(cli.app, args, env=env, **kwargs)
|
||||
|
||||
def write_with_general(self, data):
|
||||
self.cfg.write(dedent('''
|
||||
self.cfg.write(
|
||||
dedent(
|
||||
"""
|
||||
[general]
|
||||
status_path = "{}/status/"
|
||||
''').format(str(self.tmpdir)))
|
||||
self.cfg.write(data, mode='a')
|
||||
"""
|
||||
).format(str(self.tmpdir))
|
||||
)
|
||||
self.cfg.write(data, mode="a")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
|
|
@ -15,16 +15,18 @@ invalid = object()
|
|||
def read_config(tmpdir, monkeypatch):
|
||||
def inner(cfg):
|
||||
errors = []
|
||||
monkeypatch.setattr('vdirsyncer.cli.cli_logger.error', errors.append)
|
||||
monkeypatch.setattr("vdirsyncer.cli.cli_logger.error", errors.append)
|
||||
f = io.StringIO(dedent(cfg.format(base=str(tmpdir))))
|
||||
rv = Config.from_fileobject(f)
|
||||
monkeypatch.undo()
|
||||
return errors, rv
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
def test_read_config(read_config):
|
||||
errors, c = read_config('''
|
||||
errors, c = read_config(
|
||||
"""
|
||||
[general]
|
||||
status_path = "/tmp/status/"
|
||||
|
||||
|
|
@ -42,25 +44,32 @@ def test_read_config(read_config):
|
|||
|
||||
[storage bob_b]
|
||||
type = "carddav"
|
||||
''')
|
||||
"""
|
||||
)
|
||||
|
||||
assert c.general == {'status_path': '/tmp/status/'}
|
||||
assert c.general == {"status_path": "/tmp/status/"}
|
||||
|
||||
assert set(c.pairs) == {'bob'}
|
||||
bob = c.pairs['bob']
|
||||
assert set(c.pairs) == {"bob"}
|
||||
bob = c.pairs["bob"]
|
||||
assert bob.collections is None
|
||||
|
||||
assert c.storages == {
|
||||
'bob_a': {'type': 'filesystem', 'path': '/tmp/contacts/', 'fileext':
|
||||
'.vcf', 'yesno': False, 'number': 42,
|
||||
'instance_name': 'bob_a'},
|
||||
'bob_b': {'type': 'carddav', 'instance_name': 'bob_b'}
|
||||
"bob_a": {
|
||||
"type": "filesystem",
|
||||
"path": "/tmp/contacts/",
|
||||
"fileext": ".vcf",
|
||||
"yesno": False,
|
||||
"number": 42,
|
||||
"instance_name": "bob_a",
|
||||
},
|
||||
"bob_b": {"type": "carddav", "instance_name": "bob_b"},
|
||||
}
|
||||
|
||||
|
||||
def test_missing_collections_param(read_config):
|
||||
with pytest.raises(exceptions.UserError) as excinfo:
|
||||
read_config('''
|
||||
read_config(
|
||||
"""
|
||||
[general]
|
||||
status_path = "/tmp/status/"
|
||||
|
||||
|
|
@ -73,27 +82,31 @@ def test_missing_collections_param(read_config):
|
|||
|
||||
[storage bob_b]
|
||||
type = "lmao"
|
||||
''')
|
||||
"""
|
||||
)
|
||||
|
||||
assert 'collections parameter missing' in str(excinfo.value)
|
||||
assert "collections parameter missing" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_invalid_section_type(read_config):
|
||||
with pytest.raises(exceptions.UserError) as excinfo:
|
||||
read_config('''
|
||||
read_config(
|
||||
"""
|
||||
[general]
|
||||
status_path = "/tmp/status/"
|
||||
|
||||
[bogus]
|
||||
''')
|
||||
"""
|
||||
)
|
||||
|
||||
assert 'Unknown section' in str(excinfo.value)
|
||||
assert 'bogus' in str(excinfo.value)
|
||||
assert "Unknown section" in str(excinfo.value)
|
||||
assert "bogus" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_missing_general_section(read_config):
|
||||
with pytest.raises(exceptions.UserError) as excinfo:
|
||||
read_config('''
|
||||
read_config(
|
||||
"""
|
||||
[pair my_pair]
|
||||
a = "my_a"
|
||||
b = "my_b"
|
||||
|
|
@ -108,40 +121,46 @@ def test_missing_general_section(read_config):
|
|||
type = "filesystem"
|
||||
path = "{base}/path_b/"
|
||||
fileext = ".txt"
|
||||
''')
|
||||
"""
|
||||
)
|
||||
|
||||
assert 'Invalid general section.' in str(excinfo.value)
|
||||
assert "Invalid general section." in str(excinfo.value)
|
||||
|
||||
|
||||
def test_wrong_general_section(read_config):
|
||||
with pytest.raises(exceptions.UserError) as excinfo:
|
||||
read_config('''
|
||||
read_config(
|
||||
"""
|
||||
[general]
|
||||
wrong = true
|
||||
''')
|
||||
"""
|
||||
)
|
||||
|
||||
assert 'Invalid general section.' in str(excinfo.value)
|
||||
assert "Invalid general section." in str(excinfo.value)
|
||||
assert excinfo.value.problems == [
|
||||
'general section doesn\'t take the parameters: wrong',
|
||||
'general section is missing the parameters: status_path'
|
||||
"general section doesn't take the parameters: wrong",
|
||||
"general section is missing the parameters: status_path",
|
||||
]
|
||||
|
||||
|
||||
def test_invalid_storage_name(read_config):
|
||||
with pytest.raises(exceptions.UserError) as excinfo:
|
||||
read_config('''
|
||||
read_config(
|
||||
"""
|
||||
[general]
|
||||
status_path = "{base}/status/"
|
||||
|
||||
[storage foo.bar]
|
||||
''')
|
||||
"""
|
||||
)
|
||||
|
||||
assert 'invalid characters' in str(excinfo.value).lower()
|
||||
assert "invalid characters" in str(excinfo.value).lower()
|
||||
|
||||
|
||||
def test_invalid_collections_arg(read_config):
|
||||
with pytest.raises(exceptions.UserError) as excinfo:
|
||||
read_config('''
|
||||
read_config(
|
||||
"""
|
||||
[general]
|
||||
status_path = "/tmp/status/"
|
||||
|
||||
|
|
@ -159,14 +178,16 @@ def test_invalid_collections_arg(read_config):
|
|||
type = "filesystem"
|
||||
path = "/tmp/bar/"
|
||||
fileext = ".txt"
|
||||
''')
|
||||
"""
|
||||
)
|
||||
|
||||
assert 'Expected string' in str(excinfo.value)
|
||||
assert "Expected string" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_duplicate_sections(read_config):
|
||||
with pytest.raises(exceptions.UserError) as excinfo:
|
||||
read_config('''
|
||||
read_config(
|
||||
"""
|
||||
[general]
|
||||
status_path = "/tmp/status/"
|
||||
|
||||
|
|
@ -184,7 +205,8 @@ def test_duplicate_sections(read_config):
|
|||
type = "filesystem"
|
||||
path = "/tmp/bar/"
|
||||
fileext = ".txt"
|
||||
''')
|
||||
"""
|
||||
)
|
||||
|
||||
assert 'Name "foobar" already used' in str(excinfo.value)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import json
|
||||
from textwrap import dedent
|
||||
|
||||
import hypothesis.strategies as st
|
||||
from hypothesis import given
|
||||
import pytest
|
||||
|
||||
from vdirsyncer import exceptions
|
||||
from vdirsyncer.storage.base import Storage
|
||||
|
||||
|
||||
def test_discover_command(tmpdir, runner):
|
||||
runner.write_with_general(dedent('''
|
||||
runner.write_with_general(
|
||||
dedent(
|
||||
"""
|
||||
[storage foo]
|
||||
type = "filesystem"
|
||||
path = "{0}/foo/"
|
||||
|
|
@ -24,50 +25,51 @@ def test_discover_command(tmpdir, runner):
|
|||
a = "foo"
|
||||
b = "bar"
|
||||
collections = ["from a"]
|
||||
''').format(str(tmpdir)))
|
||||
"""
|
||||
).format(str(tmpdir))
|
||||
)
|
||||
|
||||
foo = tmpdir.mkdir('foo')
|
||||
bar = tmpdir.mkdir('bar')
|
||||
foo = tmpdir.mkdir("foo")
|
||||
bar = tmpdir.mkdir("bar")
|
||||
|
||||
for x in 'abc':
|
||||
for x in "abc":
|
||||
foo.mkdir(x)
|
||||
bar.mkdir(x)
|
||||
bar.mkdir('d')
|
||||
bar.mkdir("d")
|
||||
|
||||
result = runner.invoke(['discover'])
|
||||
result = runner.invoke(["discover"])
|
||||
assert not result.exception
|
||||
|
||||
foo.mkdir('d')
|
||||
result = runner.invoke(['sync'])
|
||||
foo.mkdir("d")
|
||||
result = runner.invoke(["sync"])
|
||||
assert not result.exception
|
||||
lines = result.output.splitlines()
|
||||
assert 'Syncing foobar/a' in lines
|
||||
assert 'Syncing foobar/b' in lines
|
||||
assert 'Syncing foobar/c' in lines
|
||||
assert 'Syncing foobar/d' not in result.output
|
||||
assert "Syncing foobar/a" in lines
|
||||
assert "Syncing foobar/b" in lines
|
||||
assert "Syncing foobar/c" in lines
|
||||
assert "Syncing foobar/d" not in result.output
|
||||
|
||||
result = runner.invoke(['discover'])
|
||||
result = runner.invoke(["discover"])
|
||||
assert not result.exception
|
||||
|
||||
result = runner.invoke(['sync'])
|
||||
result = runner.invoke(["sync"])
|
||||
assert not result.exception
|
||||
assert 'Syncing foobar/a' in lines
|
||||
assert 'Syncing foobar/b' in lines
|
||||
assert 'Syncing foobar/c' in lines
|
||||
assert 'Syncing foobar/d' in result.output
|
||||
assert "Syncing foobar/a" in lines
|
||||
assert "Syncing foobar/b" in lines
|
||||
assert "Syncing foobar/c" in lines
|
||||
assert "Syncing foobar/d" in result.output
|
||||
|
||||
# Check for redundant data that is already in the config. This avoids
|
||||
# copying passwords from the config too.
|
||||
assert 'fileext' not in tmpdir \
|
||||
.join('status') \
|
||||
.join('foobar.collections') \
|
||||
.read()
|
||||
assert "fileext" not in tmpdir.join("status").join("foobar.collections").read()
|
||||
|
||||
|
||||
def test_discover_different_collection_names(tmpdir, runner):
|
||||
foo = tmpdir.mkdir('foo')
|
||||
bar = tmpdir.mkdir('bar')
|
||||
runner.write_with_general(dedent('''
|
||||
foo = tmpdir.mkdir("foo")
|
||||
bar = tmpdir.mkdir("bar")
|
||||
runner.write_with_general(
|
||||
dedent(
|
||||
"""
|
||||
[storage foo]
|
||||
type = "filesystem"
|
||||
fileext = ".txt"
|
||||
|
|
@ -85,35 +87,39 @@ def test_discover_different_collection_names(tmpdir, runner):
|
|||
["coll1", "coll_a1", "coll_b1"],
|
||||
"coll2"
|
||||
]
|
||||
''').format(foo=str(foo), bar=str(bar)))
|
||||
"""
|
||||
).format(foo=str(foo), bar=str(bar))
|
||||
)
|
||||
|
||||
result = runner.invoke(['discover'], input='y\n' * 6)
|
||||
result = runner.invoke(["discover"], input="y\n" * 6)
|
||||
assert not result.exception
|
||||
|
||||
coll_a1 = foo.join('coll_a1')
|
||||
coll_b1 = bar.join('coll_b1')
|
||||
coll_a1 = foo.join("coll_a1")
|
||||
coll_b1 = bar.join("coll_b1")
|
||||
|
||||
assert coll_a1.exists()
|
||||
assert coll_b1.exists()
|
||||
|
||||
result = runner.invoke(['sync'])
|
||||
result = runner.invoke(["sync"])
|
||||
assert not result.exception
|
||||
|
||||
foo_txt = coll_a1.join('foo.txt')
|
||||
foo_txt.write('BEGIN:VCALENDAR\nUID:foo\nEND:VCALENDAR')
|
||||
foo_txt = coll_a1.join("foo.txt")
|
||||
foo_txt.write("BEGIN:VCALENDAR\nUID:foo\nEND:VCALENDAR")
|
||||
|
||||
result = runner.invoke(['sync'])
|
||||
result = runner.invoke(["sync"])
|
||||
assert not result.exception
|
||||
|
||||
assert foo_txt.exists()
|
||||
assert coll_b1.join('foo.txt').exists()
|
||||
assert coll_b1.join("foo.txt").exists()
|
||||
|
||||
|
||||
def test_discover_direct_path(tmpdir, runner):
|
||||
foo = tmpdir.join('foo')
|
||||
bar = tmpdir.join('bar')
|
||||
foo = tmpdir.join("foo")
|
||||
bar = tmpdir.join("bar")
|
||||
|
||||
runner.write_with_general(dedent('''
|
||||
runner.write_with_general(
|
||||
dedent(
|
||||
"""
|
||||
[storage foo]
|
||||
type = "filesystem"
|
||||
fileext = ".txt"
|
||||
|
|
@ -128,12 +134,14 @@ def test_discover_direct_path(tmpdir, runner):
|
|||
a = "foo"
|
||||
b = "bar"
|
||||
collections = null
|
||||
''').format(foo=str(foo), bar=str(bar)))
|
||||
"""
|
||||
).format(foo=str(foo), bar=str(bar))
|
||||
)
|
||||
|
||||
result = runner.invoke(['discover'], input='y\n' * 2)
|
||||
result = runner.invoke(["discover"], input="y\n" * 2)
|
||||
assert not result.exception
|
||||
|
||||
result = runner.invoke(['sync'])
|
||||
result = runner.invoke(["sync"])
|
||||
assert not result.exception
|
||||
|
||||
assert foo.exists()
|
||||
|
|
@ -141,7 +149,9 @@ def test_discover_direct_path(tmpdir, runner):
|
|||
|
||||
|
||||
def test_null_collection_with_named_collection(tmpdir, runner):
|
||||
runner.write_with_general(dedent('''
|
||||
runner.write_with_general(
|
||||
dedent(
|
||||
"""
|
||||
[pair foobar]
|
||||
a = "foo"
|
||||
b = "bar"
|
||||
|
|
@ -155,43 +165,56 @@ def test_null_collection_with_named_collection(tmpdir, runner):
|
|||
[storage bar]
|
||||
type = "singlefile"
|
||||
path = "{base}/bar.txt"
|
||||
'''.format(base=str(tmpdir))))
|
||||
""".format(
|
||||
base=str(tmpdir)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
result = runner.invoke(['discover'], input='y\n' * 2)
|
||||
result = runner.invoke(["discover"], input="y\n" * 2)
|
||||
assert not result.exception
|
||||
|
||||
foo = tmpdir.join('foo')
|
||||
foobaz = foo.join('baz')
|
||||
foo = tmpdir.join("foo")
|
||||
foobaz = foo.join("baz")
|
||||
assert foo.exists()
|
||||
assert foobaz.exists()
|
||||
|
||||
bar = tmpdir.join('bar.txt')
|
||||
bar = tmpdir.join("bar.txt")
|
||||
assert bar.exists()
|
||||
|
||||
foobaz.join('lol.txt').write('BEGIN:VCARD\nUID:HAHA\nEND:VCARD')
|
||||
foobaz.join("lol.txt").write("BEGIN:VCARD\nUID:HAHA\nEND:VCARD")
|
||||
|
||||
result = runner.invoke(['sync'])
|
||||
result = runner.invoke(["sync"])
|
||||
assert not result.exception
|
||||
|
||||
assert 'HAHA' in bar.read()
|
||||
assert "HAHA" in bar.read()
|
||||
|
||||
|
||||
@given(a_requires=st.booleans(), b_requires=st.booleans())
|
||||
def test_collection_required(a_requires, b_requires, tmpdir, runner,
|
||||
monkeypatch):
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"a_requires,b_requires",
|
||||
[
|
||||
(True, True),
|
||||
(True, False),
|
||||
(False, True),
|
||||
(False, False),
|
||||
],
|
||||
)
|
||||
def test_collection_required(a_requires, b_requires, tmpdir, runner, monkeypatch):
|
||||
class TestStorage(Storage):
|
||||
storage_name = 'test'
|
||||
storage_name = "test"
|
||||
|
||||
def __init__(self, require_collection, **kw):
|
||||
if require_collection:
|
||||
assert not kw.get('collection')
|
||||
assert not kw.get("collection")
|
||||
raise exceptions.CollectionRequired()
|
||||
|
||||
from vdirsyncer.cli.utils import storage_names
|
||||
monkeypatch.setitem(storage_names._storages, 'test', TestStorage)
|
||||
|
||||
runner.write_with_general(dedent('''
|
||||
monkeypatch.setitem(storage_names._storages, "test", TestStorage)
|
||||
|
||||
runner.write_with_general(
|
||||
dedent(
|
||||
"""
|
||||
[pair foobar]
|
||||
a = "foo"
|
||||
b = "bar"
|
||||
|
|
@ -204,11 +227,15 @@ def test_collection_required(a_requires, b_requires, tmpdir, runner,
|
|||
[storage bar]
|
||||
type = "test"
|
||||
require_collection = {b}
|
||||
'''.format(a=json.dumps(a_requires), b=json.dumps(b_requires))))
|
||||
""".format(
|
||||
a=json.dumps(a_requires), b=json.dumps(b_requires)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
result = runner.invoke(['discover'])
|
||||
result = runner.invoke(["discover"])
|
||||
if a_requires or b_requires:
|
||||
assert result.exception
|
||||
assert \
|
||||
'One or more storages don\'t support `collections = null`.' in \
|
||||
result.output
|
||||
assert (
|
||||
"One or more storages don't support `collections = null`." in result.output
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ from textwrap import dedent
|
|||
|
||||
|
||||
def test_get_password_from_command(tmpdir, runner):
|
||||
runner.write_with_general(dedent('''
|
||||
runner.write_with_general(
|
||||
dedent(
|
||||
"""
|
||||
[pair foobar]
|
||||
a = "foo"
|
||||
b = "bar"
|
||||
|
|
@ -17,26 +19,30 @@ def test_get_password_from_command(tmpdir, runner):
|
|||
type = "filesystem"
|
||||
path = "{base}/bar/"
|
||||
fileext.fetch = ["prompt", "Fileext for bar"]
|
||||
'''.format(base=str(tmpdir))))
|
||||
""".format(
|
||||
base=str(tmpdir)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
foo = tmpdir.ensure('foo', dir=True)
|
||||
foo.ensure('a', dir=True)
|
||||
foo.ensure('b', dir=True)
|
||||
foo.ensure('c', dir=True)
|
||||
bar = tmpdir.ensure('bar', dir=True)
|
||||
bar.ensure('a', dir=True)
|
||||
bar.ensure('b', dir=True)
|
||||
bar.ensure('c', dir=True)
|
||||
foo = tmpdir.ensure("foo", dir=True)
|
||||
foo.ensure("a", dir=True)
|
||||
foo.ensure("b", dir=True)
|
||||
foo.ensure("c", dir=True)
|
||||
bar = tmpdir.ensure("bar", dir=True)
|
||||
bar.ensure("a", dir=True)
|
||||
bar.ensure("b", dir=True)
|
||||
bar.ensure("c", dir=True)
|
||||
|
||||
result = runner.invoke(['discover'], input='.asdf\n')
|
||||
result = runner.invoke(["discover"], input=".asdf\n")
|
||||
assert not result.exception
|
||||
status = tmpdir.join('status').join('foobar.collections').read()
|
||||
assert 'foo' in status
|
||||
assert 'bar' in status
|
||||
assert 'asdf' not in status
|
||||
assert 'txt' not in status
|
||||
status = tmpdir.join("status").join("foobar.collections").read()
|
||||
assert "foo" in status
|
||||
assert "bar" in status
|
||||
assert "asdf" not in status
|
||||
assert "txt" not in status
|
||||
|
||||
foo.join('a').join('foo.txt').write('BEGIN:VCARD\nUID:foo\nEND:VCARD')
|
||||
result = runner.invoke(['sync'], input='.asdf\n')
|
||||
foo.join("a").join("foo.txt").write("BEGIN:VCARD\nUID:foo\nEND:VCARD")
|
||||
result = runner.invoke(["sync"], input=".asdf\n")
|
||||
assert not result.exception
|
||||
assert [x.basename for x in bar.join('a').listdir()] == ['foo.asdf']
|
||||
assert [x.basename for x in bar.join("a").listdir()] == ["foo.asdf"]
|
||||
|
|
|
|||
|
|
@ -5,67 +5,72 @@ import pytest
|
|||
|
||||
@pytest.fixture
|
||||
def storage(tmpdir, runner):
|
||||
runner.write_with_general(dedent('''
|
||||
runner.write_with_general(
|
||||
dedent(
|
||||
"""
|
||||
[storage foo]
|
||||
type = "filesystem"
|
||||
path = "{base}/foo/"
|
||||
fileext = ".txt"
|
||||
''').format(base=str(tmpdir)))
|
||||
"""
|
||||
).format(base=str(tmpdir))
|
||||
)
|
||||
|
||||
return tmpdir.mkdir('foo')
|
||||
return tmpdir.mkdir("foo")
|
||||
|
||||
|
||||
@pytest.mark.parametrize('collection', [None, "foocoll"])
|
||||
@pytest.mark.parametrize("collection", [None, "foocoll"])
|
||||
def test_basic(storage, runner, collection):
|
||||
if collection is not None:
|
||||
storage = storage.mkdir(collection)
|
||||
collection_arg = f'foo/{collection}'
|
||||
collection_arg = f"foo/{collection}"
|
||||
else:
|
||||
collection_arg = 'foo'
|
||||
collection_arg = "foo"
|
||||
|
||||
argv = ['repair', collection_arg]
|
||||
argv = ["repair", collection_arg]
|
||||
|
||||
result = runner.invoke(argv, input='y')
|
||||
result = runner.invoke(argv, input="y")
|
||||
assert not result.exception
|
||||
|
||||
storage.join('item.txt').write('BEGIN:VCARD\nEND:VCARD')
|
||||
storage.join('toobroken.txt').write('')
|
||||
storage.join("item.txt").write("BEGIN:VCARD\nEND:VCARD")
|
||||
storage.join("toobroken.txt").write("")
|
||||
|
||||
result = runner.invoke(argv, input='y')
|
||||
result = runner.invoke(argv, input="y")
|
||||
assert not result.exception
|
||||
assert 'No UID' in result.output
|
||||
assert '\'toobroken.txt\' is malformed beyond repair' \
|
||||
in result.output
|
||||
new_fname, = [x for x in storage.listdir() if 'toobroken' not in str(x)]
|
||||
assert 'UID:' in new_fname.read()
|
||||
assert "No UID" in result.output
|
||||
assert "'toobroken.txt' is malformed beyond repair" in result.output
|
||||
(new_fname,) = [x for x in storage.listdir() if "toobroken" not in str(x)]
|
||||
assert "UID:" in new_fname.read()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('repair_uids', [None, True, False])
|
||||
@pytest.mark.parametrize("repair_uids", [None, True, False])
|
||||
def test_repair_uids(storage, runner, repair_uids):
|
||||
f = storage.join('baduid.txt')
|
||||
orig_f = 'BEGIN:VCARD\nUID:!!!!!\nEND:VCARD'
|
||||
f = storage.join("baduid.txt")
|
||||
orig_f = "BEGIN:VCARD\nUID:!!!!!\nEND:VCARD"
|
||||
f.write(orig_f)
|
||||
|
||||
if repair_uids is None:
|
||||
opt = []
|
||||
elif repair_uids:
|
||||
opt = ['--repair-unsafe-uid']
|
||||
opt = ["--repair-unsafe-uid"]
|
||||
else:
|
||||
opt = ['--no-repair-unsafe-uid']
|
||||
opt = ["--no-repair-unsafe-uid"]
|
||||
|
||||
result = runner.invoke(['repair'] + opt + ['foo'], input='y')
|
||||
result = runner.invoke(["repair"] + opt + ["foo"], input="y")
|
||||
assert not result.exception
|
||||
|
||||
if repair_uids:
|
||||
assert 'UID or href is unsafe, assigning random UID' in result.output
|
||||
assert "UID or href is unsafe, assigning random UID" in result.output
|
||||
assert not f.exists()
|
||||
new_f, = storage.listdir()
|
||||
(new_f,) = storage.listdir()
|
||||
s = new_f.read()
|
||||
|
||||
assert s.startswith('BEGIN:VCARD')
|
||||
assert s.endswith('END:VCARD')
|
||||
assert s.startswith("BEGIN:VCARD")
|
||||
assert s.endswith("END:VCARD")
|
||||
assert s != orig_f
|
||||
else:
|
||||
assert 'UID may cause problems, add --repair-unsafe-uid to repair.' \
|
||||
assert (
|
||||
"UID may cause problems, add --repair-unsafe-uid to repair."
|
||||
in result.output
|
||||
)
|
||||
assert f.read() == orig_f
|
||||
|
|
|
|||
|
|
@ -2,14 +2,13 @@ import json
|
|||
import sys
|
||||
from textwrap import dedent
|
||||
|
||||
import hypothesis.strategies as st
|
||||
import pytest
|
||||
from hypothesis import example
|
||||
from hypothesis import given
|
||||
|
||||
|
||||
def test_simple_run(tmpdir, runner):
|
||||
runner.write_with_general(dedent('''
|
||||
runner.write_with_general(
|
||||
dedent(
|
||||
"""
|
||||
[pair my_pair]
|
||||
a = "my_a"
|
||||
b = "my_b"
|
||||
|
|
@ -24,33 +23,37 @@ def test_simple_run(tmpdir, runner):
|
|||
type = "filesystem"
|
||||
path = "{0}/path_b/"
|
||||
fileext = ".txt"
|
||||
''').format(str(tmpdir)))
|
||||
"""
|
||||
).format(str(tmpdir))
|
||||
)
|
||||
|
||||
tmpdir.mkdir('path_a')
|
||||
tmpdir.mkdir('path_b')
|
||||
tmpdir.mkdir("path_a")
|
||||
tmpdir.mkdir("path_b")
|
||||
|
||||
result = runner.invoke(['discover'])
|
||||
result = runner.invoke(["discover"])
|
||||
assert not result.exception
|
||||
|
||||
result = runner.invoke(['sync'])
|
||||
result = runner.invoke(["sync"])
|
||||
assert not result.exception
|
||||
|
||||
tmpdir.join('path_a/haha.txt').write('UID:haha')
|
||||
result = runner.invoke(['sync'])
|
||||
assert 'Copying (uploading) item haha to my_b' in result.output
|
||||
assert tmpdir.join('path_b/haha.txt').read() == 'UID:haha'
|
||||
tmpdir.join("path_a/haha.txt").write("UID:haha")
|
||||
result = runner.invoke(["sync"])
|
||||
assert "Copying (uploading) item haha to my_b" in result.output
|
||||
assert tmpdir.join("path_b/haha.txt").read() == "UID:haha"
|
||||
|
||||
|
||||
def test_sync_inexistant_pair(tmpdir, runner):
|
||||
runner.write_with_general("")
|
||||
|
||||
result = runner.invoke(['sync', 'foo'])
|
||||
result = runner.invoke(["sync", "foo"])
|
||||
assert result.exception
|
||||
assert 'pair foo does not exist.' in result.output.lower()
|
||||
assert "pair foo does not exist." in result.output.lower()
|
||||
|
||||
|
||||
def test_debug_connections(tmpdir, runner):
|
||||
runner.write_with_general(dedent('''
|
||||
runner.write_with_general(
|
||||
dedent(
|
||||
"""
|
||||
[pair my_pair]
|
||||
a = "my_a"
|
||||
b = "my_b"
|
||||
|
|
@ -65,23 +68,27 @@ def test_debug_connections(tmpdir, runner):
|
|||
type = "filesystem"
|
||||
path = "{0}/path_b/"
|
||||
fileext = ".txt"
|
||||
''').format(str(tmpdir)))
|
||||
"""
|
||||
).format(str(tmpdir))
|
||||
)
|
||||
|
||||
tmpdir.mkdir('path_a')
|
||||
tmpdir.mkdir('path_b')
|
||||
tmpdir.mkdir("path_a")
|
||||
tmpdir.mkdir("path_b")
|
||||
|
||||
result = runner.invoke(['discover'])
|
||||
result = runner.invoke(["discover"])
|
||||
assert not result.exception
|
||||
|
||||
result = runner.invoke(['-vdebug', 'sync', '--max-workers=3'])
|
||||
assert 'using 3 maximal workers' in result.output.lower()
|
||||
result = runner.invoke(["-vdebug", "sync", "--max-workers=3"])
|
||||
assert "using 3 maximal workers" in result.output.lower()
|
||||
|
||||
result = runner.invoke(['-vdebug', 'sync'])
|
||||
assert 'using 1 maximal workers' in result.output.lower()
|
||||
result = runner.invoke(["-vdebug", "sync"])
|
||||
assert "using 1 maximal workers" in result.output.lower()
|
||||
|
||||
|
||||
def test_empty_storage(tmpdir, runner):
|
||||
runner.write_with_general(dedent('''
|
||||
runner.write_with_general(
|
||||
dedent(
|
||||
"""
|
||||
[pair my_pair]
|
||||
a = "my_a"
|
||||
b = "my_b"
|
||||
|
|
@ -96,32 +103,35 @@ def test_empty_storage(tmpdir, runner):
|
|||
type = "filesystem"
|
||||
path = "{0}/path_b/"
|
||||
fileext = ".txt"
|
||||
''').format(str(tmpdir)))
|
||||
"""
|
||||
).format(str(tmpdir))
|
||||
)
|
||||
|
||||
tmpdir.mkdir('path_a')
|
||||
tmpdir.mkdir('path_b')
|
||||
tmpdir.mkdir("path_a")
|
||||
tmpdir.mkdir("path_b")
|
||||
|
||||
result = runner.invoke(['discover'])
|
||||
result = runner.invoke(["discover"])
|
||||
assert not result.exception
|
||||
|
||||
result = runner.invoke(['sync'])
|
||||
result = runner.invoke(["sync"])
|
||||
assert not result.exception
|
||||
|
||||
tmpdir.join('path_a/haha.txt').write('UID:haha')
|
||||
result = runner.invoke(['sync'])
|
||||
tmpdir.join("path_a/haha.txt").write("UID:haha")
|
||||
result = runner.invoke(["sync"])
|
||||
assert not result.exception
|
||||
tmpdir.join('path_b/haha.txt').remove()
|
||||
result = runner.invoke(['sync'])
|
||||
tmpdir.join("path_b/haha.txt").remove()
|
||||
result = runner.invoke(["sync"])
|
||||
lines = result.output.splitlines()
|
||||
assert lines[0] == 'Syncing my_pair'
|
||||
assert lines[1].startswith('error: my_pair: '
|
||||
'Storage "my_b" was completely emptied.')
|
||||
assert lines[0] == "Syncing my_pair"
|
||||
assert lines[1].startswith(
|
||||
"error: my_pair: " 'Storage "my_b" was completely emptied.'
|
||||
)
|
||||
assert result.exception
|
||||
|
||||
|
||||
def test_verbosity(tmpdir, runner):
|
||||
runner.write_with_general('')
|
||||
result = runner.invoke(['--verbosity=HAHA', 'sync'])
|
||||
runner.write_with_general("")
|
||||
result = runner.invoke(["--verbosity=HAHA", "sync"])
|
||||
assert result.exception
|
||||
assert (
|
||||
'invalid value for "--verbosity"' in result.output.lower()
|
||||
|
|
@ -130,13 +140,15 @@ def test_verbosity(tmpdir, runner):
|
|||
|
||||
|
||||
def test_collections_cache_invalidation(tmpdir, runner):
|
||||
foo = tmpdir.mkdir('foo')
|
||||
bar = tmpdir.mkdir('bar')
|
||||
for x in 'abc':
|
||||
foo = tmpdir.mkdir("foo")
|
||||
bar = tmpdir.mkdir("bar")
|
||||
for x in "abc":
|
||||
foo.mkdir(x)
|
||||
bar.mkdir(x)
|
||||
|
||||
runner.write_with_general(dedent('''
|
||||
runner.write_with_general(
|
||||
dedent(
|
||||
"""
|
||||
[storage foo]
|
||||
type = "filesystem"
|
||||
path = "{0}/foo/"
|
||||
|
|
@ -151,22 +163,26 @@ def test_collections_cache_invalidation(tmpdir, runner):
|
|||
a = "foo"
|
||||
b = "bar"
|
||||
collections = ["a", "b", "c"]
|
||||
''').format(str(tmpdir)))
|
||||
"""
|
||||
).format(str(tmpdir))
|
||||
)
|
||||
|
||||
foo.join('a/itemone.txt').write('UID:itemone')
|
||||
foo.join("a/itemone.txt").write("UID:itemone")
|
||||
|
||||
result = runner.invoke(['discover'])
|
||||
result = runner.invoke(["discover"])
|
||||
assert not result.exception
|
||||
|
||||
result = runner.invoke(['sync'])
|
||||
result = runner.invoke(["sync"])
|
||||
assert not result.exception
|
||||
assert 'detected change in config file' not in result.output.lower()
|
||||
assert "detected change in config file" not in result.output.lower()
|
||||
|
||||
rv = bar.join('a').listdir()
|
||||
rv = bar.join("a").listdir()
|
||||
assert len(rv) == 1
|
||||
assert rv[0].basename == 'itemone.txt'
|
||||
assert rv[0].basename == "itemone.txt"
|
||||
|
||||
runner.write_with_general(dedent('''
|
||||
runner.write_with_general(
|
||||
dedent(
|
||||
"""
|
||||
[storage foo]
|
||||
type = "filesystem"
|
||||
path = "{0}/foo/"
|
||||
|
|
@ -181,32 +197,36 @@ def test_collections_cache_invalidation(tmpdir, runner):
|
|||
a = "foo"
|
||||
b = "bar"
|
||||
collections = ["a", "b", "c"]
|
||||
''').format(str(tmpdir)))
|
||||
"""
|
||||
).format(str(tmpdir))
|
||||
)
|
||||
|
||||
for entry in tmpdir.join('status').listdir():
|
||||
if not str(entry).endswith('.collections'):
|
||||
for entry in tmpdir.join("status").listdir():
|
||||
if not str(entry).endswith(".collections"):
|
||||
entry.remove()
|
||||
bar2 = tmpdir.mkdir('bar2')
|
||||
for x in 'abc':
|
||||
bar2 = tmpdir.mkdir("bar2")
|
||||
for x in "abc":
|
||||
bar2.mkdir(x)
|
||||
result = runner.invoke(['sync'])
|
||||
assert 'detected change in config file' in result.output.lower()
|
||||
result = runner.invoke(["sync"])
|
||||
assert "detected change in config file" in result.output.lower()
|
||||
assert result.exception
|
||||
|
||||
result = runner.invoke(['discover'])
|
||||
result = runner.invoke(["discover"])
|
||||
assert not result.exception
|
||||
|
||||
result = runner.invoke(['sync'])
|
||||
result = runner.invoke(["sync"])
|
||||
assert not result.exception
|
||||
|
||||
rv = bar.join('a').listdir()
|
||||
rv2 = bar2.join('a').listdir()
|
||||
rv = bar.join("a").listdir()
|
||||
rv2 = bar2.join("a").listdir()
|
||||
assert len(rv) == len(rv2) == 1
|
||||
assert rv[0].basename == rv2[0].basename == 'itemone.txt'
|
||||
assert rv[0].basename == rv2[0].basename == "itemone.txt"
|
||||
|
||||
|
||||
def test_invalid_pairs_as_cli_arg(tmpdir, runner):
|
||||
runner.write_with_general(dedent('''
|
||||
runner.write_with_general(
|
||||
dedent(
|
||||
"""
|
||||
[storage foo]
|
||||
type = "filesystem"
|
||||
path = "{0}/foo/"
|
||||
|
|
@ -221,114 +241,117 @@ def test_invalid_pairs_as_cli_arg(tmpdir, runner):
|
|||
a = "foo"
|
||||
b = "bar"
|
||||
collections = ["a", "b", "c"]
|
||||
''').format(str(tmpdir)))
|
||||
"""
|
||||
).format(str(tmpdir))
|
||||
)
|
||||
|
||||
for base in ('foo', 'bar'):
|
||||
for base in ("foo", "bar"):
|
||||
base = tmpdir.mkdir(base)
|
||||
for c in 'abc':
|
||||
for c in "abc":
|
||||
base.mkdir(c)
|
||||
|
||||
result = runner.invoke(['discover'])
|
||||
result = runner.invoke(["discover"])
|
||||
assert not result.exception
|
||||
|
||||
result = runner.invoke(['sync', 'foobar/d'])
|
||||
result = runner.invoke(["sync", "foobar/d"])
|
||||
assert result.exception
|
||||
assert 'pair foobar: collection "d" not found' in result.output.lower()
|
||||
|
||||
|
||||
def test_multiple_pairs(tmpdir, runner):
|
||||
def get_cfg():
|
||||
for name_a, name_b in ('foo', 'bar'), ('bam', 'baz'):
|
||||
yield dedent('''
|
||||
for name_a, name_b in ("foo", "bar"), ("bam", "baz"):
|
||||
yield dedent(
|
||||
"""
|
||||
[pair {a}{b}]
|
||||
a = "{a}"
|
||||
b = "{b}"
|
||||
collections = null
|
||||
''').format(a=name_a, b=name_b)
|
||||
"""
|
||||
).format(a=name_a, b=name_b)
|
||||
|
||||
for name in name_a, name_b:
|
||||
yield dedent('''
|
||||
yield dedent(
|
||||
"""
|
||||
[storage {name}]
|
||||
type = "filesystem"
|
||||
path = "{path}"
|
||||
fileext = ".txt"
|
||||
''').format(name=name, path=str(tmpdir.mkdir(name)))
|
||||
"""
|
||||
).format(name=name, path=str(tmpdir.mkdir(name)))
|
||||
|
||||
runner.write_with_general(''.join(get_cfg()))
|
||||
runner.write_with_general("".join(get_cfg()))
|
||||
|
||||
result = runner.invoke(['discover'])
|
||||
result = runner.invoke(["discover"])
|
||||
assert not result.exception
|
||||
assert set(result.output.splitlines()) > {
|
||||
'Discovering collections for pair bambaz',
|
||||
'Discovering collections for pair foobar'
|
||||
"Discovering collections for pair bambaz",
|
||||
"Discovering collections for pair foobar",
|
||||
}
|
||||
|
||||
result = runner.invoke(['sync'])
|
||||
result = runner.invoke(["sync"])
|
||||
assert not result.exception
|
||||
assert set(result.output.splitlines()) == {
|
||||
'Syncing bambaz',
|
||||
'Syncing foobar',
|
||||
"Syncing bambaz",
|
||||
"Syncing foobar",
|
||||
}
|
||||
|
||||
|
||||
# XXX: https://github.com/pimutils/vdirsyncer/issues/617
|
||||
@pytest.mark.skipif(sys.platform == 'darwin',
|
||||
reason='This test inexplicably fails')
|
||||
@given(collections=st.sets(
|
||||
st.text(
|
||||
st.characters(
|
||||
blacklist_characters=set(
|
||||
'./\x00' # Invalid chars on POSIX filesystems
|
||||
),
|
||||
# Surrogates can't be encoded to utf-8 in Python
|
||||
blacklist_categories={'Cs'}
|
||||
),
|
||||
min_size=1,
|
||||
max_size=50
|
||||
),
|
||||
min_size=1
|
||||
))
|
||||
@example(collections=['persönlich'])
|
||||
@example(collections={'a', 'A'})
|
||||
@example(collections={'\ufffe'})
|
||||
def test_create_collections(subtest, collections):
|
||||
@pytest.mark.skipif(sys.platform == "darwin", reason="This test inexplicably fails")
|
||||
@pytest.mark.parametrize(
|
||||
"collections",
|
||||
[
|
||||
("a", "A"),
|
||||
("\ufffe",),
|
||||
("Hello there!",),
|
||||
("Österreich",),
|
||||
("中国", "x1"),
|
||||
("한글",),
|
||||
("42a4ec99-b1c2-4859-b142-759112f2ca50",),
|
||||
("فلسطين",),
|
||||
],
|
||||
)
|
||||
def test_create_collections(collections, tmpdir, runner):
|
||||
|
||||
@subtest
|
||||
def test_inner(tmpdir, runner):
|
||||
runner.write_with_general(dedent('''
|
||||
[pair foobar]
|
||||
a = "foo"
|
||||
b = "bar"
|
||||
collections = {colls}
|
||||
runner.write_with_general(
|
||||
dedent(
|
||||
"""
|
||||
[pair foobar]
|
||||
a = "foo"
|
||||
b = "bar"
|
||||
collections = {colls}
|
||||
|
||||
[storage foo]
|
||||
type = "filesystem"
|
||||
path = "{base}/foo/"
|
||||
fileext = ".txt"
|
||||
[storage foo]
|
||||
type = "filesystem"
|
||||
path = "{base}/foo/"
|
||||
fileext = ".txt"
|
||||
|
||||
[storage bar]
|
||||
type = "filesystem"
|
||||
path = "{base}/bar/"
|
||||
fileext = ".txt"
|
||||
'''.format(base=str(tmpdir), colls=json.dumps(list(collections)))))
|
||||
|
||||
result = runner.invoke(
|
||||
['discover'],
|
||||
input='y\n' * 2 * (len(collections) + 1)
|
||||
[storage bar]
|
||||
type = "filesystem"
|
||||
path = "{base}/bar/"
|
||||
fileext = ".txt"
|
||||
""".format(
|
||||
base=str(tmpdir), colls=json.dumps(list(collections))
|
||||
)
|
||||
)
|
||||
assert not result.exception, result.output
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
['sync'] + ['foobar/' + x for x in collections]
|
||||
)
|
||||
assert not result.exception, result.output
|
||||
result = runner.invoke(["discover"], input="y\n" * 2 * (len(collections) + 1))
|
||||
assert not result.exception, result.output
|
||||
|
||||
assert {x.basename for x in tmpdir.join('foo').listdir()} == \
|
||||
{x.basename for x in tmpdir.join('bar').listdir()}
|
||||
result = runner.invoke(["sync"] + ["foobar/" + x for x in collections])
|
||||
assert not result.exception, result.output
|
||||
|
||||
assert {x.basename for x in tmpdir.join("foo").listdir()} == {
|
||||
x.basename for x in tmpdir.join("bar").listdir()
|
||||
}
|
||||
|
||||
|
||||
def test_ident_conflict(tmpdir, runner):
|
||||
runner.write_with_general(dedent('''
|
||||
runner.write_with_general(
|
||||
dedent(
|
||||
"""
|
||||
[pair foobar]
|
||||
a = "foo"
|
||||
b = "bar"
|
||||
|
|
@ -343,35 +366,51 @@ def test_ident_conflict(tmpdir, runner):
|
|||
type = "filesystem"
|
||||
path = "{base}/bar/"
|
||||
fileext = ".txt"
|
||||
'''.format(base=str(tmpdir))))
|
||||
""".format(
|
||||
base=str(tmpdir)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
foo = tmpdir.mkdir('foo')
|
||||
tmpdir.mkdir('bar')
|
||||
foo = tmpdir.mkdir("foo")
|
||||
tmpdir.mkdir("bar")
|
||||
|
||||
foo.join('one.txt').write('UID:1')
|
||||
foo.join('two.txt').write('UID:1')
|
||||
foo.join('three.txt').write('UID:1')
|
||||
foo.join("one.txt").write("UID:1")
|
||||
foo.join("two.txt").write("UID:1")
|
||||
foo.join("three.txt").write("UID:1")
|
||||
|
||||
result = runner.invoke(['discover'])
|
||||
result = runner.invoke(["discover"])
|
||||
assert not result.exception
|
||||
|
||||
result = runner.invoke(['sync'])
|
||||
result = runner.invoke(["sync"])
|
||||
assert result.exception
|
||||
assert ('error: foobar: Storage "foo" contains multiple items with the '
|
||||
'same UID or even content') in result.output
|
||||
assert sorted([
|
||||
'one.txt' in result.output,
|
||||
'two.txt' in result.output,
|
||||
'three.txt' in result.output,
|
||||
]) == [False, True, True]
|
||||
assert (
|
||||
'error: foobar: Storage "foo" contains multiple items with the '
|
||||
"same UID or even content"
|
||||
) in result.output
|
||||
assert (
|
||||
sorted(
|
||||
[
|
||||
"one.txt" in result.output,
|
||||
"two.txt" in result.output,
|
||||
"three.txt" in result.output,
|
||||
]
|
||||
)
|
||||
== [False, True, True]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('existing,missing', [
|
||||
('foo', 'bar'),
|
||||
('bar', 'foo'),
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"existing,missing",
|
||||
[
|
||||
("foo", "bar"),
|
||||
("bar", "foo"),
|
||||
],
|
||||
)
|
||||
def test_unknown_storage(tmpdir, runner, existing, missing):
|
||||
runner.write_with_general(dedent('''
|
||||
runner.write_with_general(
|
||||
dedent(
|
||||
"""
|
||||
[pair foobar]
|
||||
a = "foo"
|
||||
b = "bar"
|
||||
|
|
@ -381,35 +420,42 @@ def test_unknown_storage(tmpdir, runner, existing, missing):
|
|||
type = "filesystem"
|
||||
path = "{base}/{existing}/"
|
||||
fileext = ".txt"
|
||||
'''.format(base=str(tmpdir), existing=existing)))
|
||||
""".format(
|
||||
base=str(tmpdir), existing=existing
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
tmpdir.mkdir(existing)
|
||||
|
||||
result = runner.invoke(['discover'])
|
||||
result = runner.invoke(["discover"])
|
||||
assert result.exception
|
||||
|
||||
assert (
|
||||
"Storage '{missing}' not found. "
|
||||
"These are the configured storages: ['{existing}']"
|
||||
.format(missing=missing, existing=existing)
|
||||
"These are the configured storages: ['{existing}']".format(
|
||||
missing=missing, existing=existing
|
||||
)
|
||||
) in result.output
|
||||
|
||||
|
||||
@pytest.mark.parametrize('cmd', ['sync', 'metasync'])
|
||||
@pytest.mark.parametrize("cmd", ["sync", "metasync"])
|
||||
def test_no_configured_pairs(tmpdir, runner, cmd):
|
||||
runner.write_with_general('')
|
||||
runner.write_with_general("")
|
||||
|
||||
result = runner.invoke([cmd])
|
||||
assert result.output == 'critical: Nothing to do.\n'
|
||||
assert result.output == "critical: Nothing to do.\n"
|
||||
assert result.exception.code == 5
|
||||
|
||||
|
||||
@pytest.mark.parametrize('resolution,expect_foo,expect_bar', [
|
||||
(['command', 'cp'], 'UID:lol\nfööcontent', 'UID:lol\nfööcontent')
|
||||
])
|
||||
def test_conflict_resolution(tmpdir, runner, resolution, expect_foo,
|
||||
expect_bar):
|
||||
runner.write_with_general(dedent('''
|
||||
@pytest.mark.parametrize(
|
||||
"resolution,expect_foo,expect_bar",
|
||||
[(["command", "cp"], "UID:lol\nfööcontent", "UID:lol\nfööcontent")],
|
||||
)
|
||||
def test_conflict_resolution(tmpdir, runner, resolution, expect_foo, expect_bar):
|
||||
runner.write_with_general(
|
||||
dedent(
|
||||
"""
|
||||
[pair foobar]
|
||||
a = "foo"
|
||||
b = "bar"
|
||||
|
|
@ -425,28 +471,34 @@ def test_conflict_resolution(tmpdir, runner, resolution, expect_foo,
|
|||
type = "filesystem"
|
||||
fileext = ".txt"
|
||||
path = "{base}/bar"
|
||||
'''.format(base=str(tmpdir), val=json.dumps(resolution))))
|
||||
""".format(
|
||||
base=str(tmpdir), val=json.dumps(resolution)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
foo = tmpdir.join('foo')
|
||||
bar = tmpdir.join('bar')
|
||||
fooitem = foo.join('lol.txt').ensure()
|
||||
fooitem.write('UID:lol\nfööcontent')
|
||||
baritem = bar.join('lol.txt').ensure()
|
||||
baritem.write('UID:lol\nbööcontent')
|
||||
foo = tmpdir.join("foo")
|
||||
bar = tmpdir.join("bar")
|
||||
fooitem = foo.join("lol.txt").ensure()
|
||||
fooitem.write("UID:lol\nfööcontent")
|
||||
baritem = bar.join("lol.txt").ensure()
|
||||
baritem.write("UID:lol\nbööcontent")
|
||||
|
||||
r = runner.invoke(['discover'])
|
||||
r = runner.invoke(["discover"])
|
||||
assert not r.exception
|
||||
|
||||
r = runner.invoke(['sync'])
|
||||
r = runner.invoke(["sync"])
|
||||
assert not r.exception
|
||||
|
||||
assert fooitem.read() == expect_foo
|
||||
assert baritem.read() == expect_bar
|
||||
|
||||
|
||||
@pytest.mark.parametrize('partial_sync', ['error', 'ignore', 'revert', None])
|
||||
@pytest.mark.parametrize("partial_sync", ["error", "ignore", "revert", None])
|
||||
def test_partial_sync(tmpdir, runner, partial_sync):
|
||||
runner.write_with_general(dedent('''
|
||||
runner.write_with_general(
|
||||
dedent(
|
||||
"""
|
||||
[pair foobar]
|
||||
a = "foo"
|
||||
b = "bar"
|
||||
|
|
@ -463,58 +515,69 @@ def test_partial_sync(tmpdir, runner, partial_sync):
|
|||
read_only = true
|
||||
fileext = ".txt"
|
||||
path = "{base}/bar"
|
||||
'''.format(
|
||||
partial_sync=(f'partial_sync = "{partial_sync}"\n'
|
||||
if partial_sync else ''),
|
||||
base=str(tmpdir)
|
||||
)))
|
||||
""".format(
|
||||
partial_sync=(
|
||||
f'partial_sync = "{partial_sync}"\n' if partial_sync else ""
|
||||
),
|
||||
base=str(tmpdir),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
foo = tmpdir.mkdir('foo')
|
||||
bar = tmpdir.mkdir('bar')
|
||||
foo = tmpdir.mkdir("foo")
|
||||
bar = tmpdir.mkdir("bar")
|
||||
|
||||
foo.join('other.txt').write('UID:other')
|
||||
bar.join('other.txt').write('UID:other')
|
||||
foo.join("other.txt").write("UID:other")
|
||||
bar.join("other.txt").write("UID:other")
|
||||
|
||||
baritem = bar.join('lol.txt')
|
||||
baritem.write('UID:lol')
|
||||
baritem = bar.join("lol.txt")
|
||||
baritem.write("UID:lol")
|
||||
|
||||
r = runner.invoke(['discover'])
|
||||
r = runner.invoke(["discover"])
|
||||
assert not r.exception
|
||||
|
||||
r = runner.invoke(['sync'])
|
||||
r = runner.invoke(["sync"])
|
||||
assert not r.exception
|
||||
|
||||
fooitem = foo.join('lol.txt')
|
||||
fooitem = foo.join("lol.txt")
|
||||
fooitem.remove()
|
||||
|
||||
r = runner.invoke(['sync'])
|
||||
r = runner.invoke(["sync"])
|
||||
|
||||
if partial_sync == 'error':
|
||||
if partial_sync == "error":
|
||||
assert r.exception
|
||||
assert 'Attempted change' in r.output
|
||||
elif partial_sync == 'ignore':
|
||||
assert "Attempted change" in r.output
|
||||
elif partial_sync == "ignore":
|
||||
assert baritem.exists()
|
||||
r = runner.invoke(['sync'])
|
||||
r = runner.invoke(["sync"])
|
||||
assert not r.exception
|
||||
assert baritem.exists()
|
||||
else:
|
||||
assert baritem.exists()
|
||||
r = runner.invoke(['sync'])
|
||||
r = runner.invoke(["sync"])
|
||||
assert not r.exception
|
||||
assert baritem.exists()
|
||||
assert fooitem.exists()
|
||||
|
||||
|
||||
def test_fetch_only_necessary_params(tmpdir, runner):
|
||||
fetched_file = tmpdir.join('fetched_flag')
|
||||
fetch_script = tmpdir.join('fetch_script')
|
||||
fetch_script.write(dedent('''
|
||||
fetched_file = tmpdir.join("fetched_flag")
|
||||
fetch_script = tmpdir.join("fetch_script")
|
||||
fetch_script.write(
|
||||
dedent(
|
||||
"""
|
||||
set -e
|
||||
touch "{}"
|
||||
echo ".txt"
|
||||
'''.format(str(fetched_file))))
|
||||
""".format(
|
||||
str(fetched_file)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
runner.write_with_general(dedent('''
|
||||
runner.write_with_general(
|
||||
dedent(
|
||||
"""
|
||||
[pair foobar]
|
||||
a = "foo"
|
||||
b = "bar"
|
||||
|
|
@ -539,7 +602,11 @@ def test_fetch_only_necessary_params(tmpdir, runner):
|
|||
type = "filesystem"
|
||||
path = "{path}"
|
||||
fileext.fetch = ["command", "sh", "{script}"]
|
||||
'''.format(path=str(tmpdir.mkdir('bogus')), script=str(fetch_script))))
|
||||
""".format(
|
||||
path=str(tmpdir.mkdir("bogus")), script=str(fetch_script)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def fetched():
|
||||
try:
|
||||
|
|
@ -548,18 +615,18 @@ def test_fetch_only_necessary_params(tmpdir, runner):
|
|||
except Exception:
|
||||
return False
|
||||
|
||||
r = runner.invoke(['discover'])
|
||||
r = runner.invoke(["discover"])
|
||||
assert not r.exception
|
||||
assert fetched()
|
||||
|
||||
r = runner.invoke(['sync', 'foobar'])
|
||||
r = runner.invoke(["sync", "foobar"])
|
||||
assert not r.exception
|
||||
assert not fetched()
|
||||
|
||||
r = runner.invoke(['sync'])
|
||||
r = runner.invoke(["sync"])
|
||||
assert not r.exception
|
||||
assert fetched()
|
||||
|
||||
r = runner.invoke(['sync', 'bambar'])
|
||||
r = runner.invoke(["sync", "bambar"])
|
||||
assert not r.exception
|
||||
assert fetched()
|
||||
|
|
|
|||
|
|
@ -6,20 +6,20 @@ from vdirsyncer.cli.utils import storage_names
|
|||
|
||||
def test_handle_cli_error(capsys):
|
||||
try:
|
||||
raise exceptions.InvalidResponse('ayy lmao')
|
||||
raise exceptions.InvalidResponse("ayy lmao")
|
||||
except BaseException:
|
||||
handle_cli_error()
|
||||
|
||||
out, err = capsys.readouterr()
|
||||
assert 'returned something vdirsyncer doesn\'t understand' in err
|
||||
assert 'ayy lmao' in err
|
||||
assert "returned something vdirsyncer doesn't understand" in err
|
||||
assert "ayy lmao" in err
|
||||
|
||||
|
||||
def test_storage_instance_from_config(monkeypatch):
|
||||
def lol(**kw):
|
||||
assert kw == {'foo': 'bar', 'baz': 1}
|
||||
return 'OK'
|
||||
assert kw == {"foo": "bar", "baz": 1}
|
||||
return "OK"
|
||||
|
||||
monkeypatch.setitem(storage_names._storages, 'lol', lol)
|
||||
config = {'type': 'lol', 'foo': 'bar', 'baz': 1}
|
||||
assert storage_instance_from_config(config) == 'OK'
|
||||
monkeypatch.setitem(storage_names._storages, "lol", lol)
|
||||
config = {"type": "lol", "foo": "bar", "baz": 1}
|
||||
assert storage_instance_from_config(config) == "OK"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from vdirsyncer import utils
|
|||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_debug_output(request):
|
||||
logger = click_log.basic_config('vdirsyncer')
|
||||
logger = click_log.basic_config("vdirsyncer")
|
||||
logger.setLevel(logging.WARNING)
|
||||
|
||||
|
||||
|
|
@ -19,49 +19,55 @@ def test_get_storage_init_args():
|
|||
from vdirsyncer.storage.memory import MemoryStorage
|
||||
|
||||
all, required = utils.get_storage_init_args(MemoryStorage)
|
||||
assert all == {'fileext', 'collection', 'read_only', 'instance_name'}
|
||||
assert all == {"fileext", "collection", "read_only", "instance_name"}
|
||||
assert not required
|
||||
|
||||
|
||||
def test_request_ssl(httpsserver):
|
||||
httpsserver.serve_content('') # we need to serve something
|
||||
|
||||
def test_request_ssl():
|
||||
with pytest.raises(requests.exceptions.ConnectionError) as excinfo:
|
||||
http.request('GET', httpsserver.url)
|
||||
assert 'certificate verify failed' in str(excinfo.value)
|
||||
http.request("GET", "https://self-signed.badssl.com/")
|
||||
assert "certificate verify failed" in str(excinfo.value)
|
||||
|
||||
http.request('GET', httpsserver.url, verify=False)
|
||||
http.request("GET", "https://self-signed.badssl.com/", verify=False)
|
||||
|
||||
|
||||
def _fingerprints_broken():
|
||||
from pkg_resources import parse_version as ver
|
||||
broken_urllib3 = ver(requests.__version__) <= ver('2.5.1')
|
||||
|
||||
broken_urllib3 = ver(requests.__version__) <= ver("2.5.1")
|
||||
return broken_urllib3
|
||||
|
||||
|
||||
@pytest.mark.skipif(_fingerprints_broken(),
|
||||
reason='https://github.com/shazow/urllib3/issues/529')
|
||||
@pytest.mark.parametrize('fingerprint', [
|
||||
'94:FD:7A:CB:50:75:A4:69:82:0A:F8:23:DF:07:FC:69:3E:CD:90:CA',
|
||||
'19:90:F7:23:94:F2:EF:AB:2B:64:2D:57:3D:25:95:2D'
|
||||
])
|
||||
@pytest.mark.skipif(
|
||||
_fingerprints_broken(), reason="https://github.com/shazow/urllib3/issues/529"
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"fingerprint",
|
||||
[
|
||||
"94:FD:7A:CB:50:75:A4:69:82:0A:F8:23:DF:07:FC:69:3E:CD:90:CA",
|
||||
"19:90:F7:23:94:F2:EF:AB:2B:64:2D:57:3D:25:95:2D",
|
||||
],
|
||||
)
|
||||
def test_request_ssl_fingerprints(httpsserver, fingerprint):
|
||||
httpsserver.serve_content('') # we need to serve something
|
||||
httpsserver.serve_content("") # we need to serve something
|
||||
|
||||
http.request('GET', httpsserver.url, verify=False,
|
||||
verify_fingerprint=fingerprint)
|
||||
http.request("GET", httpsserver.url, verify=False, verify_fingerprint=fingerprint)
|
||||
with pytest.raises(requests.exceptions.ConnectionError) as excinfo:
|
||||
http.request('GET', httpsserver.url,
|
||||
verify_fingerprint=fingerprint)
|
||||
http.request("GET", httpsserver.url, verify_fingerprint=fingerprint)
|
||||
|
||||
with pytest.raises(requests.exceptions.ConnectionError) as excinfo:
|
||||
http.request('GET', httpsserver.url, verify=False,
|
||||
verify_fingerprint=''.join(reversed(fingerprint)))
|
||||
assert 'Fingerprints did not match' in str(excinfo.value)
|
||||
http.request(
|
||||
"GET",
|
||||
httpsserver.url,
|
||||
verify=False,
|
||||
verify_fingerprint="".join(reversed(fingerprint)),
|
||||
)
|
||||
assert "Fingerprints did not match" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_open_graphical_browser(monkeypatch):
|
||||
import webbrowser
|
||||
|
||||
# Just assert that this internal attribute still exists and behaves the way
|
||||
# expected
|
||||
if sys.version_info < (3, 7):
|
||||
|
|
@ -69,9 +75,9 @@ def test_open_graphical_browser(monkeypatch):
|
|||
else:
|
||||
assert webbrowser._tryorder is None
|
||||
|
||||
monkeypatch.setattr('webbrowser._tryorder', [])
|
||||
monkeypatch.setattr("webbrowser._tryorder", [])
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
utils.open_graphical_browser('http://example.com')
|
||||
utils.open_graphical_browser("http://example.com")
|
||||
|
||||
assert 'No graphical browser found' in str(excinfo.value)
|
||||
assert "No graphical browser found" in str(excinfo.value)
|
||||
|
|
|
|||
|
|
@ -7,18 +7,20 @@ from vdirsyncer.vobject import Item
|
|||
def test_conflict_resolution_command():
|
||||
def check_call(command):
|
||||
command, a_tmp, b_tmp = command
|
||||
assert command == os.path.expanduser('~/command')
|
||||
assert command == os.path.expanduser("~/command")
|
||||
with open(a_tmp) as f:
|
||||
assert f.read() == a.raw
|
||||
with open(b_tmp) as f:
|
||||
assert f.read() == b.raw
|
||||
|
||||
with open(b_tmp, 'w') as f:
|
||||
with open(b_tmp, "w") as f:
|
||||
f.write(a.raw)
|
||||
|
||||
a = Item('UID:AAAAAAA')
|
||||
b = Item('UID:BBBBBBB')
|
||||
assert _resolve_conflict_via_command(
|
||||
a, b, ['~/command'], 'a', 'b',
|
||||
_check_call=check_call
|
||||
).raw == a.raw
|
||||
a = Item("UID:AAAAAAA")
|
||||
b = Item("UID:BBBBBBB")
|
||||
assert (
|
||||
_resolve_conflict_via_command(
|
||||
a, b, ["~/command"], "a", "b", _check_call=check_call
|
||||
).raw
|
||||
== a.raw
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,74 +6,161 @@ from vdirsyncer.cli.discover import expand_collections
|
|||
missing = object()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('shortcuts,expected', [
|
||||
(['from a'], [
|
||||
('c1', ({'type': 'fooboo', 'custom_arg': 'a1', 'collection': 'c1'},
|
||||
{'type': 'fooboo', 'custom_arg': 'b1', 'collection': 'c1'})),
|
||||
('c2', ({'type': 'fooboo', 'custom_arg': 'a2', 'collection': 'c2'},
|
||||
{'type': 'fooboo', 'custom_arg': 'b2', 'collection': 'c2'})),
|
||||
('a3', ({'type': 'fooboo', 'custom_arg': 'a3', 'collection': 'a3'},
|
||||
missing))
|
||||
]),
|
||||
(['from b'], [
|
||||
('c1', ({'type': 'fooboo', 'custom_arg': 'a1', 'collection': 'c1'},
|
||||
{'type': 'fooboo', 'custom_arg': 'b1', 'collection': 'c1'})),
|
||||
('c2', ({'type': 'fooboo', 'custom_arg': 'a2', 'collection': 'c2'},
|
||||
{'type': 'fooboo', 'custom_arg': 'b2', 'collection': 'c2'})),
|
||||
('b3', (missing,
|
||||
{'type': 'fooboo', 'custom_arg': 'b3', 'collection': 'b3'}))
|
||||
]),
|
||||
(['from a', 'from b'], [
|
||||
('c1', ({'type': 'fooboo', 'custom_arg': 'a1', 'collection': 'c1'},
|
||||
{'type': 'fooboo', 'custom_arg': 'b1', 'collection': 'c1'})),
|
||||
('c2', ({'type': 'fooboo', 'custom_arg': 'a2', 'collection': 'c2'},
|
||||
{'type': 'fooboo', 'custom_arg': 'b2', 'collection': 'c2'})),
|
||||
('a3', ({'type': 'fooboo', 'custom_arg': 'a3', 'collection': 'a3'},
|
||||
missing)),
|
||||
('b3', (missing,
|
||||
{'type': 'fooboo', 'custom_arg': 'b3', 'collection': 'b3'}))
|
||||
]),
|
||||
([['c12', 'c1', 'c2']], [
|
||||
('c12', ({'type': 'fooboo', 'custom_arg': 'a1', 'collection': 'c1'},
|
||||
{'type': 'fooboo', 'custom_arg': 'b2', 'collection': 'c2'})),
|
||||
]),
|
||||
(None, [
|
||||
(None, ({'type': 'fooboo', 'storage_side': 'a', 'collection': None},
|
||||
{'type': 'fooboo', 'storage_side': 'b', 'collection': None}))
|
||||
]),
|
||||
([None], [
|
||||
(None, ({'type': 'fooboo', 'storage_side': 'a', 'collection': None},
|
||||
{'type': 'fooboo', 'storage_side': 'b', 'collection': None}))
|
||||
]),
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"shortcuts,expected",
|
||||
[
|
||||
(
|
||||
["from a"],
|
||||
[
|
||||
(
|
||||
"c1",
|
||||
(
|
||||
{"type": "fooboo", "custom_arg": "a1", "collection": "c1"},
|
||||
{"type": "fooboo", "custom_arg": "b1", "collection": "c1"},
|
||||
),
|
||||
),
|
||||
(
|
||||
"c2",
|
||||
(
|
||||
{"type": "fooboo", "custom_arg": "a2", "collection": "c2"},
|
||||
{"type": "fooboo", "custom_arg": "b2", "collection": "c2"},
|
||||
),
|
||||
),
|
||||
(
|
||||
"a3",
|
||||
(
|
||||
{"type": "fooboo", "custom_arg": "a3", "collection": "a3"},
|
||||
missing,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
["from b"],
|
||||
[
|
||||
(
|
||||
"c1",
|
||||
(
|
||||
{"type": "fooboo", "custom_arg": "a1", "collection": "c1"},
|
||||
{"type": "fooboo", "custom_arg": "b1", "collection": "c1"},
|
||||
),
|
||||
),
|
||||
(
|
||||
"c2",
|
||||
(
|
||||
{"type": "fooboo", "custom_arg": "a2", "collection": "c2"},
|
||||
{"type": "fooboo", "custom_arg": "b2", "collection": "c2"},
|
||||
),
|
||||
),
|
||||
(
|
||||
"b3",
|
||||
(
|
||||
missing,
|
||||
{"type": "fooboo", "custom_arg": "b3", "collection": "b3"},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
["from a", "from b"],
|
||||
[
|
||||
(
|
||||
"c1",
|
||||
(
|
||||
{"type": "fooboo", "custom_arg": "a1", "collection": "c1"},
|
||||
{"type": "fooboo", "custom_arg": "b1", "collection": "c1"},
|
||||
),
|
||||
),
|
||||
(
|
||||
"c2",
|
||||
(
|
||||
{"type": "fooboo", "custom_arg": "a2", "collection": "c2"},
|
||||
{"type": "fooboo", "custom_arg": "b2", "collection": "c2"},
|
||||
),
|
||||
),
|
||||
(
|
||||
"a3",
|
||||
(
|
||||
{"type": "fooboo", "custom_arg": "a3", "collection": "a3"},
|
||||
missing,
|
||||
),
|
||||
),
|
||||
(
|
||||
"b3",
|
||||
(
|
||||
missing,
|
||||
{"type": "fooboo", "custom_arg": "b3", "collection": "b3"},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
[["c12", "c1", "c2"]],
|
||||
[
|
||||
(
|
||||
"c12",
|
||||
(
|
||||
{"type": "fooboo", "custom_arg": "a1", "collection": "c1"},
|
||||
{"type": "fooboo", "custom_arg": "b2", "collection": "c2"},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
None,
|
||||
[
|
||||
(
|
||||
None,
|
||||
(
|
||||
{"type": "fooboo", "storage_side": "a", "collection": None},
|
||||
{"type": "fooboo", "storage_side": "b", "collection": None},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
(
|
||||
[None],
|
||||
[
|
||||
(
|
||||
None,
|
||||
(
|
||||
{"type": "fooboo", "storage_side": "a", "collection": None},
|
||||
{"type": "fooboo", "storage_side": "b", "collection": None},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_expand_collections(shortcuts, expected):
|
||||
config_a = {
|
||||
'type': 'fooboo',
|
||||
'storage_side': 'a'
|
||||
}
|
||||
config_a = {"type": "fooboo", "storage_side": "a"}
|
||||
|
||||
config_b = {
|
||||
'type': 'fooboo',
|
||||
'storage_side': 'b'
|
||||
}
|
||||
config_b = {"type": "fooboo", "storage_side": "b"}
|
||||
|
||||
def get_discovered_a():
|
||||
return {
|
||||
'c1': {'type': 'fooboo', 'custom_arg': 'a1', 'collection': 'c1'},
|
||||
'c2': {'type': 'fooboo', 'custom_arg': 'a2', 'collection': 'c2'},
|
||||
'a3': {'type': 'fooboo', 'custom_arg': 'a3', 'collection': 'a3'}
|
||||
"c1": {"type": "fooboo", "custom_arg": "a1", "collection": "c1"},
|
||||
"c2": {"type": "fooboo", "custom_arg": "a2", "collection": "c2"},
|
||||
"a3": {"type": "fooboo", "custom_arg": "a3", "collection": "a3"},
|
||||
}
|
||||
|
||||
def get_discovered_b():
|
||||
return {
|
||||
'c1': {'type': 'fooboo', 'custom_arg': 'b1', 'collection': 'c1'},
|
||||
'c2': {'type': 'fooboo', 'custom_arg': 'b2', 'collection': 'c2'},
|
||||
'b3': {'type': 'fooboo', 'custom_arg': 'b3', 'collection': 'b3'}
|
||||
"c1": {"type": "fooboo", "custom_arg": "b1", "collection": "c1"},
|
||||
"c2": {"type": "fooboo", "custom_arg": "b2", "collection": "c2"},
|
||||
"b3": {"type": "fooboo", "custom_arg": "b3", "collection": "b3"},
|
||||
}
|
||||
|
||||
assert sorted(expand_collections(
|
||||
shortcuts,
|
||||
config_a, config_b,
|
||||
get_discovered_a, get_discovered_b,
|
||||
lambda config, collection: missing
|
||||
)) == sorted(expected)
|
||||
assert (
|
||||
sorted(
|
||||
expand_collections(
|
||||
shortcuts,
|
||||
config_a,
|
||||
config_b,
|
||||
get_discovered_a,
|
||||
get_discovered_b,
|
||||
lambda config, collection: missing,
|
||||
)
|
||||
)
|
||||
== sorted(expected)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
import hypothesis.strategies as st
|
||||
import pytest
|
||||
from hypothesis import given
|
||||
|
|
@ -12,11 +15,23 @@ def mystrategy(monkeypatch):
|
|||
def strategy(x):
|
||||
calls.append(x)
|
||||
return x
|
||||
|
||||
calls = []
|
||||
monkeypatch.setitem(STRATEGIES, 'mystrategy', strategy)
|
||||
monkeypatch.setitem(STRATEGIES, "mystrategy", strategy)
|
||||
return calls
|
||||
|
||||
|
||||
@contextmanager
|
||||
def dummy_strategy():
|
||||
def strategy(x):
|
||||
calls.append(x)
|
||||
return x
|
||||
|
||||
calls = []
|
||||
with patch.dict(STRATEGIES, {"mystrategy": strategy}):
|
||||
yield calls
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def value_cache(monkeypatch):
|
||||
_cache = {}
|
||||
|
|
@ -30,70 +45,59 @@ def value_cache(monkeypatch):
|
|||
def get_context(*a, **kw):
|
||||
return FakeContext()
|
||||
|
||||
monkeypatch.setattr('click.get_current_context', get_context)
|
||||
monkeypatch.setattr("click.get_current_context", get_context)
|
||||
return _cache
|
||||
|
||||
|
||||
def test_key_conflict(monkeypatch, mystrategy):
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
expand_fetch_params({
|
||||
'foo': 'bar',
|
||||
'foo.fetch': ['mystrategy', 'baz']
|
||||
})
|
||||
expand_fetch_params({"foo": "bar", "foo.fetch": ["mystrategy", "baz"]})
|
||||
|
||||
assert 'Can\'t set foo.fetch and foo.' in str(excinfo.value)
|
||||
assert "Can't set foo.fetch and foo." in str(excinfo.value)
|
||||
|
||||
|
||||
@given(s=st.text(), t=st.text(min_size=1))
|
||||
def test_fuzzing(s, t, mystrategy):
|
||||
config = expand_fetch_params({
|
||||
f'{s}.fetch': ['mystrategy', t]
|
||||
})
|
||||
def test_fuzzing(s, t):
|
||||
with dummy_strategy():
|
||||
config = expand_fetch_params({f"{s}.fetch": ["mystrategy", t]})
|
||||
|
||||
assert config[s] == t
|
||||
|
||||
|
||||
@pytest.mark.parametrize('value', [
|
||||
[],
|
||||
'lol',
|
||||
42
|
||||
])
|
||||
@pytest.mark.parametrize("value", [[], "lol", 42])
|
||||
def test_invalid_fetch_value(mystrategy, value):
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
expand_fetch_params({
|
||||
'foo.fetch': value
|
||||
})
|
||||
expand_fetch_params({"foo.fetch": value})
|
||||
|
||||
assert 'Expected a list' in str(excinfo.value) or \
|
||||
'Expected list of length > 0' in str(excinfo.value)
|
||||
assert "Expected a list" in str(
|
||||
excinfo.value
|
||||
) or "Expected list of length > 0" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_unknown_strategy():
|
||||
with pytest.raises(exceptions.UserError) as excinfo:
|
||||
expand_fetch_params({
|
||||
'foo.fetch': ['unreal', 'asdf']
|
||||
})
|
||||
expand_fetch_params({"foo.fetch": ["unreal", "asdf"]})
|
||||
|
||||
assert 'Unknown strategy' in str(excinfo.value)
|
||||
assert "Unknown strategy" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_caching(monkeypatch, mystrategy, value_cache):
|
||||
orig_cfg = {'foo.fetch': ['mystrategy', 'asdf']}
|
||||
orig_cfg = {"foo.fetch": ["mystrategy", "asdf"]}
|
||||
|
||||
rv = expand_fetch_params(orig_cfg)
|
||||
assert rv['foo'] == 'asdf'
|
||||
assert mystrategy == ['asdf']
|
||||
assert rv["foo"] == "asdf"
|
||||
assert mystrategy == ["asdf"]
|
||||
assert len(value_cache) == 1
|
||||
|
||||
rv = expand_fetch_params(orig_cfg)
|
||||
assert rv['foo'] == 'asdf'
|
||||
assert mystrategy == ['asdf']
|
||||
assert rv["foo"] == "asdf"
|
||||
assert mystrategy == ["asdf"]
|
||||
assert len(value_cache) == 1
|
||||
|
||||
value_cache.clear()
|
||||
rv = expand_fetch_params(orig_cfg)
|
||||
assert rv['foo'] == 'asdf'
|
||||
assert mystrategy == ['asdf'] * 2
|
||||
assert rv["foo"] == "asdf"
|
||||
assert mystrategy == ["asdf"] * 2
|
||||
assert len(value_cache) == 1
|
||||
|
||||
|
||||
|
|
@ -104,9 +108,9 @@ def test_failed_strategy(monkeypatch, value_cache):
|
|||
calls.append(x)
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
monkeypatch.setitem(STRATEGIES, 'mystrategy', strategy)
|
||||
monkeypatch.setitem(STRATEGIES, "mystrategy", strategy)
|
||||
|
||||
orig_cfg = {'foo.fetch': ['mystrategy', 'asdf']}
|
||||
orig_cfg = {"foo.fetch": ["mystrategy", "asdf"]}
|
||||
|
||||
for _ in range(2):
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
|
|
@ -118,9 +122,8 @@ def test_failed_strategy(monkeypatch, value_cache):
|
|||
|
||||
def test_empty_value(monkeypatch, mystrategy):
|
||||
with pytest.raises(exceptions.UserError) as excinfo:
|
||||
expand_fetch_params({
|
||||
'foo.fetch': ['mystrategy', '']
|
||||
})
|
||||
expand_fetch_params({"foo.fetch": ["mystrategy", ""]})
|
||||
|
||||
assert 'Empty value for foo.fetch, this most likely indicates an error' \
|
||||
in str(excinfo.value)
|
||||
assert "Empty value for foo.fetch, this most likely indicates an error" in str(
|
||||
excinfo.value
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,42 +1,35 @@
|
|||
import hypothesis.strategies as st
|
||||
import pytest
|
||||
from hypothesis import assume
|
||||
from hypothesis import given
|
||||
|
||||
from vdirsyncer.sync.status import SqliteStatus
|
||||
|
||||
|
||||
@pytest.fixture(params=[
|
||||
SqliteStatus
|
||||
])
|
||||
def new_status(request):
|
||||
return request.param
|
||||
|
||||
|
||||
status_dict_strategy = st.dictionaries(
|
||||
st.text(),
|
||||
st.tuples(*(
|
||||
st.fixed_dictionaries({
|
||||
'href': st.text(),
|
||||
'hash': st.text(),
|
||||
'etag': st.text()
|
||||
}) for _ in range(2)
|
||||
))
|
||||
st.tuples(
|
||||
*(
|
||||
st.fixed_dictionaries(
|
||||
{"href": st.text(), "hash": st.text(), "etag": st.text()}
|
||||
)
|
||||
for _ in range(2)
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@given(status_dict=status_dict_strategy)
|
||||
def test_legacy_status(new_status, status_dict):
|
||||
hrefs_a = {meta_a['href'] for meta_a, meta_b in status_dict.values()}
|
||||
hrefs_b = {meta_b['href'] for meta_a, meta_b in status_dict.values()}
|
||||
def test_legacy_status(status_dict):
|
||||
hrefs_a = {meta_a["href"] for meta_a, meta_b in status_dict.values()}
|
||||
hrefs_b = {meta_b["href"] for meta_a, meta_b in status_dict.values()}
|
||||
assume(len(hrefs_a) == len(status_dict) == len(hrefs_b))
|
||||
status = new_status()
|
||||
status = SqliteStatus()
|
||||
status.load_legacy_status(status_dict)
|
||||
assert dict(status.to_legacy_status()) == status_dict
|
||||
|
||||
for ident, (meta_a, meta_b) in status_dict.items():
|
||||
ident_a, meta2_a = status.get_by_href_a(meta_a['href'])
|
||||
ident_b, meta2_b = status.get_by_href_b(meta_b['href'])
|
||||
ident_a, meta2_a = status.get_by_href_a(meta_a["href"])
|
||||
ident_b, meta2_b = status.get_by_href_b(meta_b["href"])
|
||||
assert meta2_a.to_status() == meta_a
|
||||
assert meta2_b.to_status() == meta_b
|
||||
assert ident_a == ident_b == ident
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ from vdirsyncer.vobject import Item
|
|||
|
||||
|
||||
def sync(a, b, status, *args, **kwargs):
|
||||
new_status = SqliteStatus(':memory:')
|
||||
new_status = SqliteStatus(":memory:")
|
||||
new_status.load_legacy_status(status)
|
||||
rv = _sync(a, b, new_status, *args, **kwargs)
|
||||
status.clear()
|
||||
|
|
@ -41,7 +41,7 @@ def items(s):
|
|||
def test_irrelevant_status():
|
||||
a = MemoryStorage()
|
||||
b = MemoryStorage()
|
||||
status = {'1': ('1', 1234, '1.ics', 2345)}
|
||||
status = {"1": ("1", 1234, "1.ics", 2345)}
|
||||
sync(a, b, status)
|
||||
assert not status
|
||||
assert not items(a)
|
||||
|
|
@ -52,7 +52,7 @@ def test_missing_status():
|
|||
a = MemoryStorage()
|
||||
b = MemoryStorage()
|
||||
status = {}
|
||||
item = Item('asdf')
|
||||
item = Item("asdf")
|
||||
a.upload(item)
|
||||
b.upload(item)
|
||||
sync(a, b, status)
|
||||
|
|
@ -65,14 +65,14 @@ def test_missing_status_and_different_items():
|
|||
b = MemoryStorage()
|
||||
|
||||
status = {}
|
||||
item1 = Item('UID:1\nhaha')
|
||||
item2 = Item('UID:1\nhoho')
|
||||
item1 = Item("UID:1\nhaha")
|
||||
item2 = Item("UID:1\nhoho")
|
||||
a.upload(item1)
|
||||
b.upload(item2)
|
||||
with pytest.raises(SyncConflict):
|
||||
sync(a, b, status)
|
||||
assert not status
|
||||
sync(a, b, status, conflict_resolution='a wins')
|
||||
sync(a, b, status, conflict_resolution="a wins")
|
||||
assert items(a) == items(b) == {item1.raw}
|
||||
|
||||
|
||||
|
|
@ -82,8 +82,8 @@ def test_read_only_and_prefetch():
|
|||
b.read_only = True
|
||||
|
||||
status = {}
|
||||
item1 = Item('UID:1\nhaha')
|
||||
item2 = Item('UID:2\nhoho')
|
||||
item1 = Item("UID:1\nhaha")
|
||||
item2 = Item("UID:2\nhoho")
|
||||
a.upload(item1)
|
||||
a.upload(item2)
|
||||
|
||||
|
|
@ -98,11 +98,11 @@ def test_partial_sync_error():
|
|||
b = MemoryStorage()
|
||||
status = {}
|
||||
|
||||
a.upload(Item('UID:0'))
|
||||
a.upload(Item("UID:0"))
|
||||
b.read_only = True
|
||||
|
||||
with pytest.raises(PartialSync):
|
||||
sync(a, b, status, partial_sync='error')
|
||||
sync(a, b, status, partial_sync="error")
|
||||
|
||||
|
||||
def test_partial_sync_ignore():
|
||||
|
|
@ -110,17 +110,17 @@ def test_partial_sync_ignore():
|
|||
b = MemoryStorage()
|
||||
status = {}
|
||||
|
||||
item0 = Item('UID:0\nhehe')
|
||||
item0 = Item("UID:0\nhehe")
|
||||
a.upload(item0)
|
||||
b.upload(item0)
|
||||
|
||||
b.read_only = True
|
||||
|
||||
item1 = Item('UID:1\nhaha')
|
||||
item1 = Item("UID:1\nhaha")
|
||||
a.upload(item1)
|
||||
|
||||
sync(a, b, status, partial_sync='ignore')
|
||||
sync(a, b, status, partial_sync='ignore')
|
||||
sync(a, b, status, partial_sync="ignore")
|
||||
sync(a, b, status, partial_sync="ignore")
|
||||
|
||||
assert items(a) == {item0.raw, item1.raw}
|
||||
assert items(b) == {item0.raw}
|
||||
|
|
@ -131,69 +131,69 @@ def test_partial_sync_ignore2():
|
|||
b = MemoryStorage()
|
||||
status = {}
|
||||
|
||||
href, etag = a.upload(Item('UID:0'))
|
||||
href, etag = a.upload(Item("UID:0"))
|
||||
a.read_only = True
|
||||
|
||||
sync(a, b, status, partial_sync='ignore', force_delete=True)
|
||||
assert items(b) == items(a) == {'UID:0'}
|
||||
sync(a, b, status, partial_sync="ignore", force_delete=True)
|
||||
assert items(b) == items(a) == {"UID:0"}
|
||||
|
||||
b.items.clear()
|
||||
sync(a, b, status, partial_sync='ignore', force_delete=True)
|
||||
sync(a, b, status, partial_sync='ignore', force_delete=True)
|
||||
assert items(a) == {'UID:0'}
|
||||
sync(a, b, status, partial_sync="ignore", force_delete=True)
|
||||
sync(a, b, status, partial_sync="ignore", force_delete=True)
|
||||
assert items(a) == {"UID:0"}
|
||||
assert not b.items
|
||||
|
||||
a.read_only = False
|
||||
a.update(href, Item('UID:0\nupdated'), etag)
|
||||
a.update(href, Item("UID:0\nupdated"), etag)
|
||||
a.read_only = True
|
||||
sync(a, b, status, partial_sync='ignore', force_delete=True)
|
||||
assert items(b) == items(a) == {'UID:0\nupdated'}
|
||||
sync(a, b, status, partial_sync="ignore", force_delete=True)
|
||||
assert items(b) == items(a) == {"UID:0\nupdated"}
|
||||
|
||||
|
||||
def test_upload_and_update():
|
||||
a = MemoryStorage(fileext='.a')
|
||||
b = MemoryStorage(fileext='.b')
|
||||
a = MemoryStorage(fileext=".a")
|
||||
b = MemoryStorage(fileext=".b")
|
||||
status = {}
|
||||
|
||||
item = Item('UID:1') # new item 1 in a
|
||||
item = Item("UID:1") # new item 1 in a
|
||||
a.upload(item)
|
||||
sync(a, b, status)
|
||||
assert items(b) == items(a) == {item.raw}
|
||||
|
||||
item = Item('UID:1\nASDF:YES') # update of item 1 in b
|
||||
b.update('1.b', item, b.get('1.b')[1])
|
||||
item = Item("UID:1\nASDF:YES") # update of item 1 in b
|
||||
b.update("1.b", item, b.get("1.b")[1])
|
||||
sync(a, b, status)
|
||||
assert items(b) == items(a) == {item.raw}
|
||||
|
||||
item2 = Item('UID:2') # new item 2 in b
|
||||
item2 = Item("UID:2") # new item 2 in b
|
||||
b.upload(item2)
|
||||
sync(a, b, status)
|
||||
assert items(b) == items(a) == {item.raw, item2.raw}
|
||||
|
||||
item2 = Item('UID:2\nASDF:YES') # update of item 2 in a
|
||||
a.update('2.a', item2, a.get('2.a')[1])
|
||||
item2 = Item("UID:2\nASDF:YES") # update of item 2 in a
|
||||
a.update("2.a", item2, a.get("2.a")[1])
|
||||
sync(a, b, status)
|
||||
assert items(b) == items(a) == {item.raw, item2.raw}
|
||||
|
||||
|
||||
def test_deletion():
|
||||
a = MemoryStorage(fileext='.a')
|
||||
b = MemoryStorage(fileext='.b')
|
||||
a = MemoryStorage(fileext=".a")
|
||||
b = MemoryStorage(fileext=".b")
|
||||
status = {}
|
||||
|
||||
item = Item('UID:1')
|
||||
item = Item("UID:1")
|
||||
a.upload(item)
|
||||
item2 = Item('UID:2')
|
||||
item2 = Item("UID:2")
|
||||
a.upload(item2)
|
||||
sync(a, b, status)
|
||||
b.delete('1.b', b.get('1.b')[1])
|
||||
b.delete("1.b", b.get("1.b")[1])
|
||||
sync(a, b, status)
|
||||
assert items(a) == items(b) == {item2.raw}
|
||||
|
||||
a.upload(item)
|
||||
sync(a, b, status)
|
||||
assert items(a) == items(b) == {item.raw, item2.raw}
|
||||
a.delete('1.a', a.get('1.a')[1])
|
||||
a.delete("1.a", a.get("1.a")[1])
|
||||
sync(a, b, status)
|
||||
assert items(a) == items(b) == {item2.raw}
|
||||
|
||||
|
|
@ -203,38 +203,34 @@ def test_insert_hash():
|
|||
b = MemoryStorage()
|
||||
status = {}
|
||||
|
||||
item = Item('UID:1')
|
||||
item = Item("UID:1")
|
||||
href, etag = a.upload(item)
|
||||
sync(a, b, status)
|
||||
|
||||
for d in status['1']:
|
||||
del d['hash']
|
||||
for d in status["1"]:
|
||||
del d["hash"]
|
||||
|
||||
a.update(href, Item('UID:1\nHAHA:YES'), etag)
|
||||
a.update(href, Item("UID:1\nHAHA:YES"), etag)
|
||||
sync(a, b, status)
|
||||
assert 'hash' in status['1'][0] and 'hash' in status['1'][1]
|
||||
assert "hash" in status["1"][0] and "hash" in status["1"][1]
|
||||
|
||||
|
||||
def test_already_synced():
|
||||
a = MemoryStorage(fileext='.a')
|
||||
b = MemoryStorage(fileext='.b')
|
||||
item = Item('UID:1')
|
||||
a = MemoryStorage(fileext=".a")
|
||||
b = MemoryStorage(fileext=".b")
|
||||
item = Item("UID:1")
|
||||
a.upload(item)
|
||||
b.upload(item)
|
||||
status = {
|
||||
'1': ({
|
||||
'href': '1.a',
|
||||
'hash': item.hash,
|
||||
'etag': a.get('1.a')[1]
|
||||
}, {
|
||||
'href': '1.b',
|
||||
'hash': item.hash,
|
||||
'etag': b.get('1.b')[1]
|
||||
})
|
||||
"1": (
|
||||
{"href": "1.a", "hash": item.hash, "etag": a.get("1.a")[1]},
|
||||
{"href": "1.b", "hash": item.hash, "etag": b.get("1.b")[1]},
|
||||
)
|
||||
}
|
||||
old_status = deepcopy(status)
|
||||
a.update = b.update = a.upload = b.upload = \
|
||||
lambda *a, **kw: pytest.fail('Method shouldn\'t have been called.')
|
||||
a.update = b.update = a.upload = b.upload = lambda *a, **kw: pytest.fail(
|
||||
"Method shouldn't have been called."
|
||||
)
|
||||
|
||||
for _ in (1, 2):
|
||||
sync(a, b, status)
|
||||
|
|
@ -242,38 +238,38 @@ def test_already_synced():
|
|||
assert items(a) == items(b) == {item.raw}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('winning_storage', 'ab')
|
||||
@pytest.mark.parametrize("winning_storage", "ab")
|
||||
def test_conflict_resolution_both_etags_new(winning_storage):
|
||||
a = MemoryStorage()
|
||||
b = MemoryStorage()
|
||||
item = Item('UID:1')
|
||||
item = Item("UID:1")
|
||||
href_a, etag_a = a.upload(item)
|
||||
href_b, etag_b = b.upload(item)
|
||||
status = {}
|
||||
sync(a, b, status)
|
||||
assert status
|
||||
item_a = Item('UID:1\nitem a')
|
||||
item_b = Item('UID:1\nitem b')
|
||||
item_a = Item("UID:1\nitem a")
|
||||
item_b = Item("UID:1\nitem b")
|
||||
a.update(href_a, item_a, etag_a)
|
||||
b.update(href_b, item_b, etag_b)
|
||||
with pytest.raises(SyncConflict):
|
||||
sync(a, b, status)
|
||||
sync(a, b, status, conflict_resolution=f'{winning_storage} wins')
|
||||
assert items(a) == items(b) == {
|
||||
item_a.raw if winning_storage == 'a' else item_b.raw
|
||||
}
|
||||
sync(a, b, status, conflict_resolution=f"{winning_storage} wins")
|
||||
assert (
|
||||
items(a) == items(b) == {item_a.raw if winning_storage == "a" else item_b.raw}
|
||||
)
|
||||
|
||||
|
||||
def test_updated_and_deleted():
|
||||
a = MemoryStorage()
|
||||
b = MemoryStorage()
|
||||
href_a, etag_a = a.upload(Item('UID:1'))
|
||||
href_a, etag_a = a.upload(Item("UID:1"))
|
||||
status = {}
|
||||
sync(a, b, status, force_delete=True)
|
||||
|
||||
(href_b, etag_b), = b.list()
|
||||
((href_b, etag_b),) = b.list()
|
||||
b.delete(href_b, etag_b)
|
||||
updated = Item('UID:1\nupdated')
|
||||
updated = Item("UID:1\nupdated")
|
||||
a.update(href_a, updated, etag_a)
|
||||
sync(a, b, status, force_delete=True)
|
||||
|
||||
|
|
@ -283,35 +279,35 @@ def test_updated_and_deleted():
|
|||
def test_conflict_resolution_invalid_mode():
|
||||
a = MemoryStorage()
|
||||
b = MemoryStorage()
|
||||
item_a = Item('UID:1\nitem a')
|
||||
item_b = Item('UID:1\nitem b')
|
||||
item_a = Item("UID:1\nitem a")
|
||||
item_b = Item("UID:1\nitem b")
|
||||
a.upload(item_a)
|
||||
b.upload(item_b)
|
||||
with pytest.raises(ValueError):
|
||||
sync(a, b, {}, conflict_resolution='yolo')
|
||||
sync(a, b, {}, conflict_resolution="yolo")
|
||||
|
||||
|
||||
def test_conflict_resolution_new_etags_without_changes():
|
||||
a = MemoryStorage()
|
||||
b = MemoryStorage()
|
||||
item = Item('UID:1')
|
||||
item = Item("UID:1")
|
||||
href_a, etag_a = a.upload(item)
|
||||
href_b, etag_b = b.upload(item)
|
||||
status = {'1': (href_a, 'BOGUS_a', href_b, 'BOGUS_b')}
|
||||
status = {"1": (href_a, "BOGUS_a", href_b, "BOGUS_b")}
|
||||
|
||||
sync(a, b, status)
|
||||
|
||||
(ident, (status_a, status_b)), = status.items()
|
||||
assert ident == '1'
|
||||
assert status_a['href'] == href_a
|
||||
assert status_a['etag'] == etag_a
|
||||
assert status_b['href'] == href_b
|
||||
assert status_b['etag'] == etag_b
|
||||
((ident, (status_a, status_b)),) = status.items()
|
||||
assert ident == "1"
|
||||
assert status_a["href"] == href_a
|
||||
assert status_a["etag"] == etag_a
|
||||
assert status_b["href"] == href_b
|
||||
assert status_b["etag"] == etag_b
|
||||
|
||||
|
||||
def test_uses_get_multi(monkeypatch):
|
||||
def breakdown(*a, **kw):
|
||||
raise AssertionError('Expected use of get_multi')
|
||||
raise AssertionError("Expected use of get_multi")
|
||||
|
||||
get_multi_calls = []
|
||||
|
||||
|
|
@ -324,12 +320,12 @@ def test_uses_get_multi(monkeypatch):
|
|||
item, etag = old_get(self, href)
|
||||
yield href, item, etag
|
||||
|
||||
monkeypatch.setattr(MemoryStorage, 'get', breakdown)
|
||||
monkeypatch.setattr(MemoryStorage, 'get_multi', get_multi)
|
||||
monkeypatch.setattr(MemoryStorage, "get", breakdown)
|
||||
monkeypatch.setattr(MemoryStorage, "get_multi", get_multi)
|
||||
|
||||
a = MemoryStorage()
|
||||
b = MemoryStorage()
|
||||
item = Item('UID:1')
|
||||
item = Item("UID:1")
|
||||
expected_href, etag = a.upload(item)
|
||||
|
||||
sync(a, b, {})
|
||||
|
|
@ -339,8 +335,8 @@ def test_uses_get_multi(monkeypatch):
|
|||
def test_empty_storage_dataloss():
|
||||
a = MemoryStorage()
|
||||
b = MemoryStorage()
|
||||
a.upload(Item('UID:1'))
|
||||
a.upload(Item('UID:2'))
|
||||
a.upload(Item("UID:1"))
|
||||
a.upload(Item("UID:2"))
|
||||
status = {}
|
||||
sync(a, b, status)
|
||||
with pytest.raises(StorageEmpty):
|
||||
|
|
@ -353,22 +349,22 @@ def test_empty_storage_dataloss():
|
|||
def test_no_uids():
|
||||
a = MemoryStorage()
|
||||
b = MemoryStorage()
|
||||
a.upload(Item('ASDF'))
|
||||
b.upload(Item('FOOBAR'))
|
||||
a.upload(Item("ASDF"))
|
||||
b.upload(Item("FOOBAR"))
|
||||
status = {}
|
||||
sync(a, b, status)
|
||||
assert items(a) == items(b) == {'ASDF', 'FOOBAR'}
|
||||
assert items(a) == items(b) == {"ASDF", "FOOBAR"}
|
||||
|
||||
|
||||
def test_changed_uids():
|
||||
a = MemoryStorage()
|
||||
b = MemoryStorage()
|
||||
href_a, etag_a = a.upload(Item('UID:A-ONE'))
|
||||
href_b, etag_b = b.upload(Item('UID:B-ONE'))
|
||||
href_a, etag_a = a.upload(Item("UID:A-ONE"))
|
||||
href_b, etag_b = b.upload(Item("UID:B-ONE"))
|
||||
status = {}
|
||||
sync(a, b, status)
|
||||
|
||||
a.update(href_a, Item('UID:A-TWO'), etag_a)
|
||||
a.update(href_a, Item("UID:A-TWO"), etag_a)
|
||||
sync(a, b, status)
|
||||
|
||||
|
||||
|
|
@ -383,71 +379,71 @@ def test_both_readonly():
|
|||
|
||||
|
||||
def test_partial_sync_revert():
|
||||
a = MemoryStorage(instance_name='a')
|
||||
b = MemoryStorage(instance_name='b')
|
||||
a = MemoryStorage(instance_name="a")
|
||||
b = MemoryStorage(instance_name="b")
|
||||
status = {}
|
||||
a.upload(Item('UID:1'))
|
||||
b.upload(Item('UID:2'))
|
||||
a.upload(Item("UID:1"))
|
||||
b.upload(Item("UID:2"))
|
||||
b.read_only = True
|
||||
|
||||
sync(a, b, status, partial_sync='revert')
|
||||
sync(a, b, status, partial_sync="revert")
|
||||
assert len(status) == 2
|
||||
assert items(a) == {'UID:1', 'UID:2'}
|
||||
assert items(b) == {'UID:2'}
|
||||
assert items(a) == {"UID:1", "UID:2"}
|
||||
assert items(b) == {"UID:2"}
|
||||
|
||||
sync(a, b, status, partial_sync='revert')
|
||||
sync(a, b, status, partial_sync="revert")
|
||||
assert len(status) == 1
|
||||
assert items(a) == {'UID:2'}
|
||||
assert items(b) == {'UID:2'}
|
||||
assert items(a) == {"UID:2"}
|
||||
assert items(b) == {"UID:2"}
|
||||
|
||||
# Check that updates get reverted
|
||||
a.items[next(iter(a.items))] = ('foo', Item('UID:2\nupdated'))
|
||||
assert items(a) == {'UID:2\nupdated'}
|
||||
sync(a, b, status, partial_sync='revert')
|
||||
a.items[next(iter(a.items))] = ("foo", Item("UID:2\nupdated"))
|
||||
assert items(a) == {"UID:2\nupdated"}
|
||||
sync(a, b, status, partial_sync="revert")
|
||||
assert len(status) == 1
|
||||
assert items(a) == {'UID:2\nupdated'}
|
||||
sync(a, b, status, partial_sync='revert')
|
||||
assert items(a) == {'UID:2'}
|
||||
assert items(a) == {"UID:2\nupdated"}
|
||||
sync(a, b, status, partial_sync="revert")
|
||||
assert items(a) == {"UID:2"}
|
||||
|
||||
# Check that deletions get reverted
|
||||
a.items.clear()
|
||||
sync(a, b, status, partial_sync='revert', force_delete=True)
|
||||
sync(a, b, status, partial_sync='revert', force_delete=True)
|
||||
assert items(a) == {'UID:2'}
|
||||
sync(a, b, status, partial_sync="revert", force_delete=True)
|
||||
sync(a, b, status, partial_sync="revert", force_delete=True)
|
||||
assert items(a) == {"UID:2"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('sync_inbetween', (True, False))
|
||||
@pytest.mark.parametrize("sync_inbetween", (True, False))
|
||||
def test_ident_conflict(sync_inbetween):
|
||||
a = MemoryStorage()
|
||||
b = MemoryStorage()
|
||||
status = {}
|
||||
href_a, etag_a = a.upload(Item('UID:aaa'))
|
||||
href_b, etag_b = a.upload(Item('UID:bbb'))
|
||||
href_a, etag_a = a.upload(Item("UID:aaa"))
|
||||
href_b, etag_b = a.upload(Item("UID:bbb"))
|
||||
if sync_inbetween:
|
||||
sync(a, b, status)
|
||||
|
||||
a.update(href_a, Item('UID:xxx'), etag_a)
|
||||
a.update(href_b, Item('UID:xxx'), etag_b)
|
||||
a.update(href_a, Item("UID:xxx"), etag_a)
|
||||
a.update(href_b, Item("UID:xxx"), etag_b)
|
||||
|
||||
with pytest.raises(IdentConflict):
|
||||
sync(a, b, status)
|
||||
|
||||
|
||||
def test_moved_href():
|
||||
'''
|
||||
"""
|
||||
Concrete application: ppl_ stores contact aliases in filenames, which means
|
||||
item's hrefs get changed. Vdirsyncer doesn't synchronize this data, but
|
||||
also shouldn't do things like deleting and re-uploading to the server.
|
||||
|
||||
.. _ppl: http://ppladdressbook.org/
|
||||
'''
|
||||
"""
|
||||
a = MemoryStorage()
|
||||
b = MemoryStorage()
|
||||
status = {}
|
||||
href, etag = a.upload(Item('UID:haha'))
|
||||
href, etag = a.upload(Item("UID:haha"))
|
||||
sync(a, b, status)
|
||||
|
||||
b.items['lol'] = b.items.pop('haha')
|
||||
b.items["lol"] = b.items.pop("haha")
|
||||
|
||||
# The sync algorithm should prefetch `lol`, see that it's the same ident
|
||||
# and not do anything else.
|
||||
|
|
@ -457,8 +453,8 @@ def test_moved_href():
|
|||
|
||||
sync(a, b, status)
|
||||
assert len(status) == 1
|
||||
assert items(a) == items(b) == {'UID:haha'}
|
||||
assert status['haha'][1]['href'] == 'lol'
|
||||
assert items(a) == items(b) == {"UID:haha"}
|
||||
assert status["haha"][1]["href"] == "lol"
|
||||
old_status = deepcopy(status)
|
||||
|
||||
# Further sync should be a noop. Not even prefetching should occur.
|
||||
|
|
@ -466,39 +462,39 @@ def test_moved_href():
|
|||
|
||||
sync(a, b, status)
|
||||
assert old_status == status
|
||||
assert items(a) == items(b) == {'UID:haha'}
|
||||
assert items(a) == items(b) == {"UID:haha"}
|
||||
|
||||
|
||||
def test_bogus_etag_change():
|
||||
'''Assert that sync algorithm is resilient against etag changes if content
|
||||
"""Assert that sync algorithm is resilient against etag changes if content
|
||||
didn\'t change.
|
||||
|
||||
In this particular case we test a scenario where both etags have been
|
||||
updated, but only one side actually changed its item content.
|
||||
'''
|
||||
"""
|
||||
a = MemoryStorage()
|
||||
b = MemoryStorage()
|
||||
status = {}
|
||||
href_a, etag_a = a.upload(Item('UID:ASDASD'))
|
||||
href_a, etag_a = a.upload(Item("UID:ASDASD"))
|
||||
sync(a, b, status)
|
||||
assert len(status) == len(list(a.list())) == len(list(b.list())) == 1
|
||||
|
||||
(href_b, etag_b), = b.list()
|
||||
a.update(href_a, Item('UID:ASDASD'), etag_a)
|
||||
b.update(href_b, Item('UID:ASDASD\nACTUALCHANGE:YES'), etag_b)
|
||||
((href_b, etag_b),) = b.list()
|
||||
a.update(href_a, Item("UID:ASDASD"), etag_a)
|
||||
b.update(href_b, Item("UID:ASDASD\nACTUALCHANGE:YES"), etag_b)
|
||||
|
||||
b.delete = b.update = b.upload = blow_up
|
||||
|
||||
sync(a, b, status)
|
||||
assert len(status) == 1
|
||||
assert items(a) == items(b) == {'UID:ASDASD\nACTUALCHANGE:YES'}
|
||||
assert items(a) == items(b) == {"UID:ASDASD\nACTUALCHANGE:YES"}
|
||||
|
||||
|
||||
def test_unicode_hrefs():
|
||||
a = MemoryStorage()
|
||||
b = MemoryStorage()
|
||||
status = {}
|
||||
href, etag = a.upload(Item('UID:äää'))
|
||||
href, etag = a.upload(Item("UID:äää"))
|
||||
sync(a, b, status)
|
||||
|
||||
|
||||
|
|
@ -511,27 +507,27 @@ def action_failure(*a, **kw):
|
|||
|
||||
|
||||
class SyncMachine(RuleBasedStateMachine):
|
||||
Status = Bundle('status')
|
||||
Storage = Bundle('storage')
|
||||
Status = Bundle("status")
|
||||
Storage = Bundle("storage")
|
||||
|
||||
@rule(target=Storage,
|
||||
flaky_etags=st.booleans(),
|
||||
null_etag_on_upload=st.booleans())
|
||||
@rule(target=Storage, flaky_etags=st.booleans(), null_etag_on_upload=st.booleans())
|
||||
def newstorage(self, flaky_etags, null_etag_on_upload):
|
||||
s = MemoryStorage()
|
||||
if flaky_etags:
|
||||
|
||||
def get(href):
|
||||
old_etag, item = s.items[href]
|
||||
etag = _random_string()
|
||||
s.items[href] = etag, item
|
||||
return item, etag
|
||||
|
||||
s.get = get
|
||||
|
||||
if null_etag_on_upload:
|
||||
_old_upload = s.upload
|
||||
_old_update = s.update
|
||||
s.upload = lambda item: (_old_upload(item)[0], 'NULL')
|
||||
s.update = lambda h, i, e: _old_update(h, i, e) and 'NULL'
|
||||
s.upload = lambda item: (_old_upload(item)[0], "NULL")
|
||||
s.update = lambda h, i, e: _old_update(h, i, e) and "NULL"
|
||||
|
||||
return s
|
||||
|
||||
|
|
@ -564,11 +560,9 @@ class SyncMachine(RuleBasedStateMachine):
|
|||
def newstatus(self):
|
||||
return {}
|
||||
|
||||
@rule(storage=Storage,
|
||||
uid=uid_strategy,
|
||||
etag=st.text())
|
||||
@rule(storage=Storage, uid=uid_strategy, etag=st.text())
|
||||
def upload(self, storage, uid, etag):
|
||||
item = Item(f'UID:{uid}')
|
||||
item = Item(f"UID:{uid}")
|
||||
storage.items[uid] = (etag, item)
|
||||
|
||||
@rule(storage=Storage, href=st.text())
|
||||
|
|
@ -577,22 +571,31 @@ class SyncMachine(RuleBasedStateMachine):
|
|||
|
||||
@rule(
|
||||
status=Status,
|
||||
a=Storage, b=Storage,
|
||||
a=Storage,
|
||||
b=Storage,
|
||||
force_delete=st.booleans(),
|
||||
conflict_resolution=st.one_of((st.just('a wins'), st.just('b wins'))),
|
||||
conflict_resolution=st.one_of((st.just("a wins"), st.just("b wins"))),
|
||||
with_error_callback=st.booleans(),
|
||||
partial_sync=st.one_of((
|
||||
st.just('ignore'), st.just('revert'), st.just('error')
|
||||
))
|
||||
partial_sync=st.one_of(
|
||||
(st.just("ignore"), st.just("revert"), st.just("error"))
|
||||
),
|
||||
)
|
||||
def sync(self, status, a, b, force_delete, conflict_resolution,
|
||||
with_error_callback, partial_sync):
|
||||
def sync(
|
||||
self,
|
||||
status,
|
||||
a,
|
||||
b,
|
||||
force_delete,
|
||||
conflict_resolution,
|
||||
with_error_callback,
|
||||
partial_sync,
|
||||
):
|
||||
assume(a is not b)
|
||||
old_items_a = items(a)
|
||||
old_items_b = items(b)
|
||||
|
||||
a.instance_name = 'a'
|
||||
b.instance_name = 'b'
|
||||
a.instance_name = "a"
|
||||
b.instance_name = "b"
|
||||
|
||||
errors = []
|
||||
|
||||
|
|
@ -605,16 +608,20 @@ class SyncMachine(RuleBasedStateMachine):
|
|||
# If one storage is read-only, double-sync because changes don't
|
||||
# get reverted immediately.
|
||||
for _ in range(2 if a.read_only or b.read_only else 1):
|
||||
sync(a, b, status,
|
||||
force_delete=force_delete,
|
||||
conflict_resolution=conflict_resolution,
|
||||
error_callback=error_callback,
|
||||
partial_sync=partial_sync)
|
||||
sync(
|
||||
a,
|
||||
b,
|
||||
status,
|
||||
force_delete=force_delete,
|
||||
conflict_resolution=conflict_resolution,
|
||||
error_callback=error_callback,
|
||||
partial_sync=partial_sync,
|
||||
)
|
||||
|
||||
for e in errors:
|
||||
raise e
|
||||
except PartialSync:
|
||||
assert partial_sync == 'error'
|
||||
assert partial_sync == "error"
|
||||
except ActionIntentionallyFailed:
|
||||
pass
|
||||
except BothReadOnly:
|
||||
|
|
@ -629,49 +636,55 @@ class SyncMachine(RuleBasedStateMachine):
|
|||
items_a = items(a)
|
||||
items_b = items(b)
|
||||
|
||||
assert items_a == items_b or partial_sync == 'ignore'
|
||||
assert items_a == items_b or partial_sync == "ignore"
|
||||
assert items_a == old_items_a or not a.read_only
|
||||
assert items_b == old_items_b or not b.read_only
|
||||
|
||||
assert set(a.items) | set(b.items) == set(status) or \
|
||||
partial_sync == 'ignore'
|
||||
assert (
|
||||
set(a.items) | set(b.items) == set(status) or partial_sync == "ignore"
|
||||
)
|
||||
|
||||
|
||||
TestSyncMachine = SyncMachine.TestCase
|
||||
|
||||
|
||||
@pytest.mark.parametrize('error_callback', [True, False])
|
||||
@pytest.mark.parametrize("error_callback", [True, False])
|
||||
def test_rollback(error_callback):
|
||||
a = MemoryStorage()
|
||||
b = MemoryStorage()
|
||||
status = {}
|
||||
|
||||
a.items['0'] = ('', Item('UID:0'))
|
||||
b.items['1'] = ('', Item('UID:1'))
|
||||
a.items["0"] = ("", Item("UID:0"))
|
||||
b.items["1"] = ("", Item("UID:1"))
|
||||
|
||||
b.upload = b.update = b.delete = action_failure
|
||||
|
||||
if error_callback:
|
||||
errors = []
|
||||
|
||||
sync(a, b, status=status, conflict_resolution='a wins',
|
||||
error_callback=errors.append)
|
||||
sync(
|
||||
a,
|
||||
b,
|
||||
status=status,
|
||||
conflict_resolution="a wins",
|
||||
error_callback=errors.append,
|
||||
)
|
||||
|
||||
assert len(errors) == 1
|
||||
assert isinstance(errors[0], ActionIntentionallyFailed)
|
||||
|
||||
assert len(status) == 1
|
||||
assert status['1']
|
||||
assert status["1"]
|
||||
else:
|
||||
with pytest.raises(ActionIntentionallyFailed):
|
||||
sync(a, b, status=status, conflict_resolution='a wins')
|
||||
sync(a, b, status=status, conflict_resolution="a wins")
|
||||
|
||||
|
||||
def test_duplicate_hrefs():
|
||||
a = MemoryStorage()
|
||||
b = MemoryStorage()
|
||||
a.list = lambda: [('a', 'a')] * 3
|
||||
a.items['a'] = ('a', Item('UID:a'))
|
||||
a.list = lambda: [("a", "a")] * 3
|
||||
a.items["a"] = ("a", Item("UID:a"))
|
||||
|
||||
status = {}
|
||||
sync(a, b, status)
|
||||
|
|
|
|||
|
|
@ -2,13 +2,12 @@ from vdirsyncer import exceptions
|
|||
|
||||
|
||||
def test_user_error_problems():
|
||||
e = exceptions.UserError('A few problems occurred', problems=[
|
||||
'Problem one',
|
||||
'Problem two',
|
||||
'Problem three'
|
||||
])
|
||||
e = exceptions.UserError(
|
||||
"A few problems occurred",
|
||||
problems=["Problem one", "Problem two", "Problem three"],
|
||||
)
|
||||
|
||||
assert 'one' in str(e)
|
||||
assert 'two' in str(e)
|
||||
assert 'three' in str(e)
|
||||
assert 'problems occurred' in str(e)
|
||||
assert "one" in str(e)
|
||||
assert "two" in str(e)
|
||||
assert "three" in str(e)
|
||||
assert "problems occurred" in str(e)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from vdirsyncer.storage.memory import MemoryStorage
|
|||
def test_irrelevant_status():
|
||||
a = MemoryStorage()
|
||||
b = MemoryStorage()
|
||||
status = {'foo': 'bar'}
|
||||
status = {"foo": "bar"}
|
||||
|
||||
metasync(a, b, status, keys=())
|
||||
assert not status
|
||||
|
|
@ -26,24 +26,24 @@ def test_basic(monkeypatch):
|
|||
b = MemoryStorage()
|
||||
status = {}
|
||||
|
||||
a.set_meta('foo', 'bar')
|
||||
metasync(a, b, status, keys=['foo'])
|
||||
assert a.get_meta('foo') == b.get_meta('foo') == 'bar'
|
||||
a.set_meta("foo", "bar")
|
||||
metasync(a, b, status, keys=["foo"])
|
||||
assert a.get_meta("foo") == b.get_meta("foo") == "bar"
|
||||
|
||||
a.set_meta('foo', 'baz')
|
||||
metasync(a, b, status, keys=['foo'])
|
||||
assert a.get_meta('foo') == b.get_meta('foo') == 'baz'
|
||||
a.set_meta("foo", "baz")
|
||||
metasync(a, b, status, keys=["foo"])
|
||||
assert a.get_meta("foo") == b.get_meta("foo") == "baz"
|
||||
|
||||
monkeypatch.setattr(a, 'set_meta', blow_up)
|
||||
monkeypatch.setattr(b, 'set_meta', blow_up)
|
||||
metasync(a, b, status, keys=['foo'])
|
||||
assert a.get_meta('foo') == b.get_meta('foo') == 'baz'
|
||||
monkeypatch.setattr(a, "set_meta", blow_up)
|
||||
monkeypatch.setattr(b, "set_meta", blow_up)
|
||||
metasync(a, b, status, keys=["foo"])
|
||||
assert a.get_meta("foo") == b.get_meta("foo") == "baz"
|
||||
monkeypatch.undo()
|
||||
monkeypatch.undo()
|
||||
|
||||
b.set_meta('foo', None)
|
||||
metasync(a, b, status, keys=['foo'])
|
||||
assert not a.get_meta('foo') and not b.get_meta('foo')
|
||||
b.set_meta("foo", None)
|
||||
metasync(a, b, status, keys=["foo"])
|
||||
assert not a.get_meta("foo") and not b.get_meta("foo")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -51,12 +51,12 @@ def conflict_state(request):
|
|||
a = MemoryStorage()
|
||||
b = MemoryStorage()
|
||||
status = {}
|
||||
a.set_meta('foo', 'bar')
|
||||
b.set_meta('foo', 'baz')
|
||||
a.set_meta("foo", "bar")
|
||||
b.set_meta("foo", "baz")
|
||||
|
||||
def cleanup():
|
||||
assert a.get_meta('foo') == 'bar'
|
||||
assert b.get_meta('foo') == 'baz'
|
||||
assert a.get_meta("foo") == "bar"
|
||||
assert b.get_meta("foo") == "baz"
|
||||
assert not status
|
||||
|
||||
request.addfinalizer(cleanup)
|
||||
|
|
@ -68,54 +68,61 @@ def test_conflict(conflict_state):
|
|||
a, b, status = conflict_state
|
||||
|
||||
with pytest.raises(MetaSyncConflict):
|
||||
metasync(a, b, status, keys=['foo'])
|
||||
metasync(a, b, status, keys=["foo"])
|
||||
|
||||
|
||||
def test_invalid_conflict_resolution(conflict_state):
|
||||
a, b, status = conflict_state
|
||||
|
||||
with pytest.raises(UserError) as excinfo:
|
||||
metasync(a, b, status, keys=['foo'], conflict_resolution='foo')
|
||||
metasync(a, b, status, keys=["foo"], conflict_resolution="foo")
|
||||
|
||||
assert 'Invalid conflict resolution setting' in str(excinfo.value)
|
||||
assert "Invalid conflict resolution setting" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_warning_on_custom_conflict_commands(conflict_state, monkeypatch):
|
||||
a, b, status = conflict_state
|
||||
warnings = []
|
||||
monkeypatch.setattr(logger, 'warning', warnings.append)
|
||||
monkeypatch.setattr(logger, "warning", warnings.append)
|
||||
|
||||
with pytest.raises(MetaSyncConflict):
|
||||
metasync(a, b, status, keys=['foo'],
|
||||
conflict_resolution=lambda *a, **kw: None)
|
||||
metasync(a, b, status, keys=["foo"], conflict_resolution=lambda *a, **kw: None)
|
||||
|
||||
assert warnings == ['Custom commands don\'t work on metasync.']
|
||||
assert warnings == ["Custom commands don't work on metasync."]
|
||||
|
||||
|
||||
def test_conflict_same_content():
|
||||
a = MemoryStorage()
|
||||
b = MemoryStorage()
|
||||
status = {}
|
||||
a.set_meta('foo', 'bar')
|
||||
b.set_meta('foo', 'bar')
|
||||
a.set_meta("foo", "bar")
|
||||
b.set_meta("foo", "bar")
|
||||
|
||||
metasync(a, b, status, keys=['foo'])
|
||||
assert a.get_meta('foo') == b.get_meta('foo') == status['foo'] == 'bar'
|
||||
metasync(a, b, status, keys=["foo"])
|
||||
assert a.get_meta("foo") == b.get_meta("foo") == status["foo"] == "bar"
|
||||
|
||||
|
||||
@pytest.mark.parametrize('wins', 'ab')
|
||||
@pytest.mark.parametrize("wins", "ab")
|
||||
def test_conflict_x_wins(wins):
|
||||
a = MemoryStorage()
|
||||
b = MemoryStorage()
|
||||
status = {}
|
||||
a.set_meta('foo', 'bar')
|
||||
b.set_meta('foo', 'baz')
|
||||
a.set_meta("foo", "bar")
|
||||
b.set_meta("foo", "baz")
|
||||
|
||||
metasync(a, b, status, keys=['foo'],
|
||||
conflict_resolution='a wins' if wins == 'a' else 'b wins')
|
||||
metasync(
|
||||
a,
|
||||
b,
|
||||
status,
|
||||
keys=["foo"],
|
||||
conflict_resolution="a wins" if wins == "a" else "b wins",
|
||||
)
|
||||
|
||||
assert a.get_meta('foo') == b.get_meta('foo') == status['foo'] == (
|
||||
'bar' if wins == 'a' else 'baz'
|
||||
assert (
|
||||
a.get_meta("foo")
|
||||
== b.get_meta("foo")
|
||||
== status["foo"]
|
||||
== ("bar" if wins == "a" else "baz")
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -125,33 +132,40 @@ metadata = st.dictionaries(keys, values)
|
|||
|
||||
|
||||
@given(
|
||||
a=metadata, b=metadata,
|
||||
status=metadata, keys=st.sets(keys),
|
||||
conflict_resolution=st.just('a wins') | st.just('b wins')
|
||||
a=metadata,
|
||||
b=metadata,
|
||||
status=metadata,
|
||||
keys=st.sets(keys),
|
||||
conflict_resolution=st.just("a wins") | st.just("b wins"),
|
||||
)
|
||||
@example(
|
||||
a={"0": "0"}, b={}, status={"0": "0"}, keys={"0"}, conflict_resolution="a wins"
|
||||
)
|
||||
@example(
|
||||
a={"0": "0"},
|
||||
b={"0": "1"},
|
||||
status={"0": "0"},
|
||||
keys={"0"},
|
||||
conflict_resolution="a wins",
|
||||
)
|
||||
@example(a={'0': '0'}, b={}, status={'0': '0'}, keys={'0'},
|
||||
conflict_resolution='a wins')
|
||||
@example(a={'0': '0'}, b={'0': '1'}, status={'0': '0'}, keys={'0'},
|
||||
conflict_resolution='a wins')
|
||||
def test_fuzzing(a, b, status, keys, conflict_resolution):
|
||||
def _get_storage(m, instance_name):
|
||||
s = MemoryStorage(instance_name=instance_name)
|
||||
s.metadata = m
|
||||
return s
|
||||
|
||||
a = _get_storage(a, 'A')
|
||||
b = _get_storage(b, 'B')
|
||||
a = _get_storage(a, "A")
|
||||
b = _get_storage(b, "B")
|
||||
|
||||
winning_storage = (a if conflict_resolution == 'a wins' else b)
|
||||
expected_values = {key: winning_storage.get_meta(key)
|
||||
for key in keys
|
||||
if key not in status}
|
||||
winning_storage = a if conflict_resolution == "a wins" else b
|
||||
expected_values = {
|
||||
key: winning_storage.get_meta(key) for key in keys if key not in status
|
||||
}
|
||||
|
||||
metasync(a, b, status,
|
||||
keys=keys, conflict_resolution=conflict_resolution)
|
||||
metasync(a, b, status, keys=keys, conflict_resolution=conflict_resolution)
|
||||
|
||||
for key in keys:
|
||||
s = status.get(key, '')
|
||||
s = status.get(key, "")
|
||||
assert a.get_meta(key) == b.get_meta(key) == s
|
||||
if expected_values.get(key, '') and s:
|
||||
if expected_values.get(key, "") and s:
|
||||
assert s == expected_values[key]
|
||||
|
|
|
|||
|
|
@ -18,14 +18,8 @@ from vdirsyncer.vobject import Item
|
|||
def test_repair_uids(uid):
|
||||
s = MemoryStorage()
|
||||
s.items = {
|
||||
'one': (
|
||||
'asdf',
|
||||
Item(f'BEGIN:VCARD\nFN:Hans\nUID:{uid}\nEND:VCARD')
|
||||
),
|
||||
'two': (
|
||||
'asdf',
|
||||
Item(f'BEGIN:VCARD\nFN:Peppi\nUID:{uid}\nEND:VCARD')
|
||||
)
|
||||
"one": ("asdf", Item(f"BEGIN:VCARD\nFN:Hans\nUID:{uid}\nEND:VCARD")),
|
||||
"two": ("asdf", Item(f"BEGIN:VCARD\nFN:Peppi\nUID:{uid}\nEND:VCARD")),
|
||||
}
|
||||
|
||||
uid1, uid2 = [s.get(href)[0].uid for href, etag in s.list()]
|
||||
|
|
@ -42,7 +36,7 @@ def test_repair_uids(uid):
|
|||
@settings(suppress_health_check=HealthCheck.all())
|
||||
def test_repair_unsafe_uids(uid):
|
||||
s = MemoryStorage()
|
||||
item = Item(f'BEGIN:VCARD\nUID:{uid}\nEND:VCARD')
|
||||
item = Item(f"BEGIN:VCARD\nUID:{uid}\nEND:VCARD")
|
||||
href, etag = s.upload(item)
|
||||
assert s.get(href)[0].uid == uid
|
||||
assert not href_safe(uid)
|
||||
|
|
@ -55,12 +49,11 @@ def test_repair_unsafe_uids(uid):
|
|||
assert href_safe(newuid)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('uid,href', [
|
||||
('b@dh0mbr3', 'perfectly-fine'),
|
||||
('perfectly-fine', 'b@dh0mbr3')
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"uid,href", [("b@dh0mbr3", "perfectly-fine"), ("perfectly-fine", "b@dh0mbr3")]
|
||||
)
|
||||
def test_repair_unsafe_href(uid, href):
|
||||
item = Item(f'BEGIN:VCARD\nUID:{uid}\nEND:VCARD')
|
||||
item = Item(f"BEGIN:VCARD\nUID:{uid}\nEND:VCARD")
|
||||
new_item = repair_item(href, item, set(), True)
|
||||
assert new_item.raw != item.raw
|
||||
assert new_item.uid != item.uid
|
||||
|
|
@ -68,18 +61,14 @@ def test_repair_unsafe_href(uid, href):
|
|||
|
||||
|
||||
def test_repair_do_nothing():
|
||||
item = Item('BEGIN:VCARD\nUID:justfine\nEND:VCARD')
|
||||
assert repair_item('fine', item, set(), True) is item
|
||||
assert repair_item('@@@@/fine', item, set(), True) is item
|
||||
item = Item("BEGIN:VCARD\nUID:justfine\nEND:VCARD")
|
||||
assert repair_item("fine", item, set(), True) is item
|
||||
assert repair_item("@@@@/fine", item, set(), True) is item
|
||||
|
||||
|
||||
@pytest.mark.parametrize('raw', [
|
||||
'AYYY',
|
||||
'',
|
||||
'@@@@',
|
||||
'BEGIN:VCARD',
|
||||
'BEGIN:FOO\nEND:FOO'
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"raw", ["AYYY", "", "@@@@", "BEGIN:VCARD", "BEGIN:FOO\nEND:FOO"]
|
||||
)
|
||||
def test_repair_irreparable(raw):
|
||||
with pytest.raises(IrreparableItem):
|
||||
repair_item('fine', Item(raw), set(), True)
|
||||
repair_item("fine", Item(raw), set(), True)
|
||||
|
|
|
|||
|
|
@ -20,40 +20,35 @@ from tests import VCARD_TEMPLATE
|
|||
_simple_split = [
|
||||
VCARD_TEMPLATE.format(r=123, uid=123),
|
||||
VCARD_TEMPLATE.format(r=345, uid=345),
|
||||
VCARD_TEMPLATE.format(r=678, uid=678)
|
||||
VCARD_TEMPLATE.format(r=678, uid=678),
|
||||
]
|
||||
|
||||
_simple_joined = '\r\n'.join(
|
||||
['BEGIN:VADDRESSBOOK']
|
||||
+ _simple_split
|
||||
+ ['END:VADDRESSBOOK\r\n']
|
||||
_simple_joined = "\r\n".join(
|
||||
["BEGIN:VADDRESSBOOK"] + _simple_split + ["END:VADDRESSBOOK\r\n"]
|
||||
)
|
||||
|
||||
|
||||
def test_split_collection_simple(benchmark):
|
||||
given = benchmark(lambda: list(vobject.split_collection(_simple_joined)))
|
||||
|
||||
assert [normalize_item(item) for item in given] == \
|
||||
[normalize_item(item) for item in _simple_split]
|
||||
assert [normalize_item(item) for item in given] == [
|
||||
normalize_item(item) for item in _simple_split
|
||||
]
|
||||
|
||||
assert [x.splitlines() for x in given] == \
|
||||
[x.splitlines() for x in _simple_split]
|
||||
assert [x.splitlines() for x in given] == [x.splitlines() for x in _simple_split]
|
||||
|
||||
|
||||
def test_split_collection_multiple_wrappers(benchmark):
|
||||
joined = '\r\n'.join(
|
||||
'BEGIN:VADDRESSBOOK\r\n'
|
||||
+ x
|
||||
+ '\r\nEND:VADDRESSBOOK\r\n'
|
||||
for x in _simple_split
|
||||
joined = "\r\n".join(
|
||||
"BEGIN:VADDRESSBOOK\r\n" + x + "\r\nEND:VADDRESSBOOK\r\n" for x in _simple_split
|
||||
)
|
||||
given = benchmark(lambda: list(vobject.split_collection(joined)))
|
||||
|
||||
assert [normalize_item(item) for item in given] == \
|
||||
[normalize_item(item) for item in _simple_split]
|
||||
assert [normalize_item(item) for item in given] == [
|
||||
normalize_item(item) for item in _simple_split
|
||||
]
|
||||
|
||||
assert [x.splitlines() for x in given] == \
|
||||
[x.splitlines() for x in _simple_split]
|
||||
assert [x.splitlines() for x in given] == [x.splitlines() for x in _simple_split]
|
||||
|
||||
|
||||
def test_join_collection_simple(benchmark):
|
||||
|
|
@ -63,8 +58,11 @@ def test_join_collection_simple(benchmark):
|
|||
|
||||
|
||||
def test_join_collection_vevents(benchmark):
|
||||
actual = benchmark(lambda: vobject.join_collection([
|
||||
dedent("""
|
||||
actual = benchmark(
|
||||
lambda: vobject.join_collection(
|
||||
[
|
||||
dedent(
|
||||
"""
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:HUEHUE
|
||||
|
|
@ -75,10 +73,15 @@ def test_join_collection_vevents(benchmark):
|
|||
VALUE:Event {}
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
""").format(i) for i in range(3)
|
||||
]))
|
||||
"""
|
||||
).format(i)
|
||||
for i in range(3)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
expected = dedent("""
|
||||
expected = dedent(
|
||||
"""
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:HUEHUE
|
||||
|
|
@ -95,7 +98,8 @@ def test_join_collection_vevents(benchmark):
|
|||
VALUE:Event 2
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
""").lstrip()
|
||||
"""
|
||||
).lstrip()
|
||||
|
||||
assert actual.splitlines() == expected.splitlines()
|
||||
|
||||
|
|
@ -103,34 +107,29 @@ def test_join_collection_vevents(benchmark):
|
|||
def test_split_collection_timezones():
|
||||
items = [
|
||||
BARE_EVENT_TEMPLATE.format(r=123, uid=123),
|
||||
BARE_EVENT_TEMPLATE.format(r=345, uid=345)
|
||||
BARE_EVENT_TEMPLATE.format(r=345, uid=345),
|
||||
]
|
||||
|
||||
timezone = (
|
||||
'BEGIN:VTIMEZONE\r\n'
|
||||
'TZID:/mozilla.org/20070129_1/Asia/Tokyo\r\n'
|
||||
'X-LIC-LOCATION:Asia/Tokyo\r\n'
|
||||
'BEGIN:STANDARD\r\n'
|
||||
'TZOFFSETFROM:+0900\r\n'
|
||||
'TZOFFSETTO:+0900\r\n'
|
||||
'TZNAME:JST\r\n'
|
||||
'DTSTART:19700101T000000\r\n'
|
||||
'END:STANDARD\r\n'
|
||||
'END:VTIMEZONE'
|
||||
"BEGIN:VTIMEZONE\r\n"
|
||||
"TZID:/mozilla.org/20070129_1/Asia/Tokyo\r\n"
|
||||
"X-LIC-LOCATION:Asia/Tokyo\r\n"
|
||||
"BEGIN:STANDARD\r\n"
|
||||
"TZOFFSETFROM:+0900\r\n"
|
||||
"TZOFFSETTO:+0900\r\n"
|
||||
"TZNAME:JST\r\n"
|
||||
"DTSTART:19700101T000000\r\n"
|
||||
"END:STANDARD\r\n"
|
||||
"END:VTIMEZONE"
|
||||
)
|
||||
|
||||
full = '\r\n'.join(
|
||||
['BEGIN:VCALENDAR']
|
||||
+ items
|
||||
+ [timezone, 'END:VCALENDAR']
|
||||
)
|
||||
full = "\r\n".join(["BEGIN:VCALENDAR"] + items + [timezone, "END:VCALENDAR"])
|
||||
|
||||
given = {normalize_item(item)
|
||||
for item in vobject.split_collection(full)}
|
||||
given = {normalize_item(item) for item in vobject.split_collection(full)}
|
||||
expected = {
|
||||
normalize_item('\r\n'.join((
|
||||
'BEGIN:VCALENDAR', item, timezone, 'END:VCALENDAR'
|
||||
)))
|
||||
normalize_item(
|
||||
"\r\n".join(("BEGIN:VCALENDAR", item, timezone, "END:VCALENDAR"))
|
||||
)
|
||||
for item in items
|
||||
}
|
||||
|
||||
|
|
@ -138,32 +137,28 @@ def test_split_collection_timezones():
|
|||
|
||||
|
||||
def test_split_contacts():
|
||||
bare = '\r\n'.join([VCARD_TEMPLATE.format(r=x, uid=x) for x in range(4)])
|
||||
with_wrapper = 'BEGIN:VADDRESSBOOK\r\n' + bare + '\nEND:VADDRESSBOOK\r\n'
|
||||
bare = "\r\n".join([VCARD_TEMPLATE.format(r=x, uid=x) for x in range(4)])
|
||||
with_wrapper = "BEGIN:VADDRESSBOOK\r\n" + bare + "\nEND:VADDRESSBOOK\r\n"
|
||||
|
||||
for _ in (bare, with_wrapper):
|
||||
split = list(vobject.split_collection(bare))
|
||||
assert len(split) == 4
|
||||
assert vobject.join_collection(split).splitlines() == \
|
||||
with_wrapper.splitlines()
|
||||
assert vobject.join_collection(split).splitlines() == with_wrapper.splitlines()
|
||||
|
||||
|
||||
def test_hash_item():
|
||||
a = EVENT_TEMPLATE.format(r=1, uid=1)
|
||||
b = '\n'.join(line for line in a.splitlines()
|
||||
if 'PRODID' not in line)
|
||||
b = "\n".join(line for line in a.splitlines() if "PRODID" not in line)
|
||||
assert vobject.hash_item(a) == vobject.hash_item(b)
|
||||
|
||||
|
||||
def test_multiline_uid(benchmark):
|
||||
a = ('BEGIN:FOO\r\n'
|
||||
'UID:123456789abcd\r\n'
|
||||
' efgh\r\n'
|
||||
'END:FOO\r\n')
|
||||
assert benchmark(lambda: vobject.Item(a).uid) == '123456789abcdefgh'
|
||||
a = "BEGIN:FOO\r\n" "UID:123456789abcd\r\n" " efgh\r\n" "END:FOO\r\n"
|
||||
assert benchmark(lambda: vobject.Item(a).uid) == "123456789abcdefgh"
|
||||
|
||||
|
||||
complex_uid_item = dedent('''
|
||||
complex_uid_item = dedent(
|
||||
"""
|
||||
BEGIN:VCALENDAR
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Europe/Rome
|
||||
|
|
@ -199,99 +194,102 @@ complex_uid_item = dedent('''
|
|||
TRANSP:OPAQUE
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
''').strip()
|
||||
"""
|
||||
).strip()
|
||||
|
||||
|
||||
def test_multiline_uid_complex(benchmark):
|
||||
assert benchmark(lambda: vobject.Item(complex_uid_item).uid) == (
|
||||
'040000008200E00074C5B7101A82E008000000005'
|
||||
'0AAABEEF50DCF001000000062548482FA830A46B9'
|
||||
'EA62114AC9F0EF'
|
||||
"040000008200E00074C5B7101A82E008000000005"
|
||||
"0AAABEEF50DCF001000000062548482FA830A46B9"
|
||||
"EA62114AC9F0EF"
|
||||
)
|
||||
|
||||
|
||||
def test_replace_multiline_uid(benchmark):
|
||||
def inner():
|
||||
return vobject.Item(complex_uid_item).with_uid('a').uid
|
||||
return vobject.Item(complex_uid_item).with_uid("a").uid
|
||||
|
||||
assert benchmark(inner) == 'a'
|
||||
assert benchmark(inner) == "a"
|
||||
|
||||
|
||||
@pytest.mark.parametrize('template', [EVENT_TEMPLATE,
|
||||
EVENT_WITH_TIMEZONE_TEMPLATE,
|
||||
VCARD_TEMPLATE])
|
||||
@pytest.mark.parametrize(
|
||||
"template", [EVENT_TEMPLATE, EVENT_WITH_TIMEZONE_TEMPLATE, VCARD_TEMPLATE]
|
||||
)
|
||||
@given(uid=st.one_of(st.none(), uid_strategy))
|
||||
def test_replace_uid(template, uid):
|
||||
item = vobject.Item(template.format(r=123, uid=123)).with_uid(uid)
|
||||
assert item.uid == uid
|
||||
if uid:
|
||||
assert item.raw.count(f'\nUID:{uid}') == 1
|
||||
assert item.raw.count(f"\nUID:{uid}") == 1
|
||||
else:
|
||||
assert '\nUID:' not in item.raw
|
||||
assert "\nUID:" not in item.raw
|
||||
|
||||
|
||||
def test_broken_item():
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
vobject._Component.parse('END:FOO')
|
||||
vobject._Component.parse("END:FOO")
|
||||
|
||||
assert 'Parsing error at line 1' in str(excinfo.value)
|
||||
assert "Parsing error at line 1" in str(excinfo.value)
|
||||
|
||||
item = vobject.Item('END:FOO')
|
||||
item = vobject.Item("END:FOO")
|
||||
assert item.parsed is None
|
||||
|
||||
|
||||
def test_multiple_items():
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
vobject._Component.parse([
|
||||
'BEGIN:FOO',
|
||||
'END:FOO',
|
||||
'BEGIN:FOO',
|
||||
'END:FOO',
|
||||
])
|
||||
vobject._Component.parse(
|
||||
[
|
||||
"BEGIN:FOO",
|
||||
"END:FOO",
|
||||
"BEGIN:FOO",
|
||||
"END:FOO",
|
||||
]
|
||||
)
|
||||
|
||||
assert 'Found 2 components, expected one' in str(excinfo.value)
|
||||
assert "Found 2 components, expected one" in str(excinfo.value)
|
||||
|
||||
c1, c2 = vobject._Component.parse([
|
||||
'BEGIN:FOO',
|
||||
'END:FOO',
|
||||
'BEGIN:FOO',
|
||||
'END:FOO',
|
||||
], multiple=True)
|
||||
assert c1.name == c2.name == 'FOO'
|
||||
c1, c2 = vobject._Component.parse(
|
||||
[
|
||||
"BEGIN:FOO",
|
||||
"END:FOO",
|
||||
"BEGIN:FOO",
|
||||
"END:FOO",
|
||||
],
|
||||
multiple=True,
|
||||
)
|
||||
assert c1.name == c2.name == "FOO"
|
||||
|
||||
|
||||
def test_input_types():
|
||||
lines = ['BEGIN:FOO', 'FOO:BAR', 'END:FOO']
|
||||
lines = ["BEGIN:FOO", "FOO:BAR", "END:FOO"]
|
||||
|
||||
for x in (lines, '\r\n'.join(lines), '\r\n'.join(lines).encode('ascii')):
|
||||
for x in (lines, "\r\n".join(lines), "\r\n".join(lines).encode("ascii")):
|
||||
c = vobject._Component.parse(x)
|
||||
assert c.name == 'FOO'
|
||||
assert c.props == ['FOO:BAR']
|
||||
assert c.name == "FOO"
|
||||
assert c.props == ["FOO:BAR"]
|
||||
assert not c.subcomponents
|
||||
|
||||
|
||||
value_strategy = st.text(
|
||||
st.characters(blacklist_categories=(
|
||||
'Zs', 'Zl', 'Zp',
|
||||
'Cc', 'Cs'
|
||||
), blacklist_characters=':='),
|
||||
min_size=1
|
||||
st.characters(
|
||||
blacklist_categories=("Zs", "Zl", "Zp", "Cc", "Cs"), blacklist_characters=":="
|
||||
),
|
||||
min_size=1,
|
||||
).filter(lambda x: x.strip() == x)
|
||||
|
||||
|
||||
class VobjectMachine(RuleBasedStateMachine):
|
||||
Unparsed = Bundle('unparsed')
|
||||
Parsed = Bundle('parsed')
|
||||
Unparsed = Bundle("unparsed")
|
||||
Parsed = Bundle("parsed")
|
||||
|
||||
@rule(target=Unparsed,
|
||||
joined=st.booleans(),
|
||||
encoded=st.booleans())
|
||||
@rule(target=Unparsed, joined=st.booleans(), encoded=st.booleans())
|
||||
def get_unparsed_lines(self, joined, encoded):
|
||||
rv = ['BEGIN:FOO', 'FOO:YES', 'END:FOO']
|
||||
rv = ["BEGIN:FOO", "FOO:YES", "END:FOO"]
|
||||
if joined:
|
||||
rv = '\r\n'.join(rv)
|
||||
rv = "\r\n".join(rv)
|
||||
if encoded:
|
||||
rv = rv.encode('utf-8')
|
||||
rv = rv.encode("utf-8")
|
||||
elif encoded:
|
||||
assume(False)
|
||||
return rv
|
||||
|
|
@ -304,24 +302,24 @@ class VobjectMachine(RuleBasedStateMachine):
|
|||
def serialize(self, parsed):
|
||||
return list(parsed.dump_lines())
|
||||
|
||||
@rule(c=Parsed,
|
||||
key=uid_strategy,
|
||||
value=uid_strategy)
|
||||
@rule(c=Parsed, key=uid_strategy, value=uid_strategy)
|
||||
def add_prop(self, c, key, value):
|
||||
c[key] = value
|
||||
assert c[key] == value
|
||||
assert key in c
|
||||
assert c.get(key) == value
|
||||
dump = '\r\n'.join(c.dump_lines())
|
||||
dump = "\r\n".join(c.dump_lines())
|
||||
assert key in dump and value in dump
|
||||
|
||||
@rule(c=Parsed,
|
||||
key=uid_strategy,
|
||||
value=uid_strategy,
|
||||
params=st.lists(st.tuples(value_strategy, value_strategy)))
|
||||
@rule(
|
||||
c=Parsed,
|
||||
key=uid_strategy,
|
||||
value=uid_strategy,
|
||||
params=st.lists(st.tuples(value_strategy, value_strategy)),
|
||||
)
|
||||
def add_prop_raw(self, c, key, value, params):
|
||||
params_str = ','.join(k + '=' + v for k, v in params)
|
||||
c.props.insert(0, f'{key};{params_str}:{value}')
|
||||
params_str = ",".join(k + "=" + v for k, v in params)
|
||||
c.props.insert(0, f"{key};{params_str}:{value}")
|
||||
assert c[key] == value
|
||||
assert key in c
|
||||
assert c.get(key) == value
|
||||
|
|
@ -330,7 +328,7 @@ class VobjectMachine(RuleBasedStateMachine):
|
|||
def add_component(self, c, sub_c):
|
||||
assume(sub_c is not c and sub_c not in c)
|
||||
c.subcomponents.append(sub_c)
|
||||
assert '\r\n'.join(sub_c.dump_lines()) in '\r\n'.join(c.dump_lines())
|
||||
assert "\r\n".join(sub_c.dump_lines()) in "\r\n".join(c.dump_lines())
|
||||
|
||||
@rule(c=Parsed)
|
||||
def sanity_check(self, c):
|
||||
|
|
@ -342,14 +340,10 @@ TestVobjectMachine = VobjectMachine.TestCase
|
|||
|
||||
|
||||
def test_component_contains():
|
||||
item = vobject._Component.parse([
|
||||
'BEGIN:FOO',
|
||||
'FOO:YES',
|
||||
'END:FOO'
|
||||
])
|
||||
item = vobject._Component.parse(["BEGIN:FOO", "FOO:YES", "END:FOO"])
|
||||
|
||||
assert 'FOO' in item
|
||||
assert 'BAZ' not in item
|
||||
assert "FOO" in item
|
||||
assert "BAZ" not in item
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
42 in item
|
||||
42 in item # noqa: B015
|
||||
|
|
|
|||
|
|
@ -1,26 +1,27 @@
|
|||
'''
|
||||
"""
|
||||
Vdirsyncer synchronizes calendars and contacts.
|
||||
'''
|
||||
"""
|
||||
|
||||
|
||||
PROJECT_HOME = 'https://github.com/pimutils/vdirsyncer'
|
||||
BUGTRACKER_HOME = PROJECT_HOME + '/issues'
|
||||
DOCS_HOME = 'https://vdirsyncer.pimutils.org/en/stable'
|
||||
PROJECT_HOME = "https://github.com/pimutils/vdirsyncer"
|
||||
BUGTRACKER_HOME = PROJECT_HOME + "/issues"
|
||||
DOCS_HOME = "https://vdirsyncer.pimutils.org/en/stable"
|
||||
|
||||
try:
|
||||
from .version import version as __version__ # noqa
|
||||
except ImportError: # pragma: no cover
|
||||
raise ImportError(
|
||||
'Failed to find (autogenerated) version.py. '
|
||||
'This might be because you are installing from GitHub\'s tarballs, '
|
||||
'use the PyPI ones.'
|
||||
"Failed to find (autogenerated) version.py. "
|
||||
"This might be because you are installing from GitHub's tarballs, "
|
||||
"use the PyPI ones."
|
||||
)
|
||||
|
||||
|
||||
def _check_python_version(): # pragma: no cover
|
||||
import sys
|
||||
|
||||
if sys.version_info < (3, 7, 0):
|
||||
print('vdirsyncer requires at least Python 3.7.')
|
||||
print("vdirsyncer requires at least Python 3.7.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
from vdirsyncer.cli import app
|
||||
|
||||
app()
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from .. import BUGTRACKER_HOME
|
|||
|
||||
|
||||
cli_logger = logging.getLogger(__name__)
|
||||
click_log.basic_config('vdirsyncer')
|
||||
click_log.basic_config("vdirsyncer")
|
||||
|
||||
|
||||
class AppContext:
|
||||
|
|
@ -30,6 +30,7 @@ def catch_errors(f):
|
|||
f(*a, **kw)
|
||||
except BaseException:
|
||||
from .utils import handle_cli_error
|
||||
|
||||
handle_cli_error()
|
||||
sys.exit(1)
|
||||
|
||||
|
|
@ -37,24 +38,26 @@ def catch_errors(f):
|
|||
|
||||
|
||||
@click.group()
|
||||
@click_log.simple_verbosity_option('vdirsyncer')
|
||||
@click_log.simple_verbosity_option("vdirsyncer")
|
||||
@click.version_option(version=__version__)
|
||||
@click.option('--config', '-c', metavar='FILE', help='Config file to use.')
|
||||
@click.option("--config", "-c", metavar="FILE", help="Config file to use.")
|
||||
@pass_context
|
||||
@catch_errors
|
||||
def app(ctx, config):
|
||||
'''
|
||||
"""
|
||||
Synchronize calendars and contacts
|
||||
'''
|
||||
"""
|
||||
|
||||
if sys.platform == 'win32':
|
||||
cli_logger.warning('Vdirsyncer currently does not support Windows. '
|
||||
'You will likely encounter bugs. '
|
||||
'See {}/535 for more information.'
|
||||
.format(BUGTRACKER_HOME))
|
||||
if sys.platform == "win32":
|
||||
cli_logger.warning(
|
||||
"Vdirsyncer currently does not support Windows. "
|
||||
"You will likely encounter bugs. "
|
||||
"See {}/535 for more information.".format(BUGTRACKER_HOME)
|
||||
)
|
||||
|
||||
if not ctx.config:
|
||||
from .config import load_config
|
||||
|
||||
ctx.config = load_config(config)
|
||||
|
||||
|
||||
|
|
@ -62,40 +65,44 @@ main = app
|
|||
|
||||
|
||||
def max_workers_callback(ctx, param, value):
|
||||
if value == 0 and logging.getLogger('vdirsyncer').level == logging.DEBUG:
|
||||
if value == 0 and logging.getLogger("vdirsyncer").level == logging.DEBUG:
|
||||
value = 1
|
||||
|
||||
cli_logger.debug(f'Using {value} maximal workers.')
|
||||
cli_logger.debug(f"Using {value} maximal workers.")
|
||||
return value
|
||||
|
||||
|
||||
def max_workers_option(default=0):
|
||||
help = 'Use at most this many connections. '
|
||||
help = "Use at most this many connections. "
|
||||
if default == 0:
|
||||
help += 'The default is 0, which means "as many as necessary". ' \
|
||||
'With -vdebug enabled, the default is 1.'
|
||||
help += (
|
||||
'The default is 0, which means "as many as necessary". '
|
||||
"With -vdebug enabled, the default is 1."
|
||||
)
|
||||
else:
|
||||
help += f'The default is {default}.'
|
||||
help += f"The default is {default}."
|
||||
|
||||
return click.option(
|
||||
'--max-workers', default=default, type=click.IntRange(min=0, max=None),
|
||||
"--max-workers",
|
||||
default=default,
|
||||
type=click.IntRange(min=0, max=None),
|
||||
callback=max_workers_callback,
|
||||
help=help
|
||||
help=help,
|
||||
)
|
||||
|
||||
|
||||
def collections_arg_callback(ctx, param, value):
|
||||
'''
|
||||
"""
|
||||
Expand the various CLI shortforms ("pair, pair/collection") to an iterable
|
||||
of (pair, collections).
|
||||
'''
|
||||
"""
|
||||
# XXX: Ugly! pass_context should work everywhere.
|
||||
config = ctx.find_object(AppContext).config
|
||||
rv = {}
|
||||
for pair_and_collection in (value or config.pairs):
|
||||
for pair_and_collection in value or config.pairs:
|
||||
pair, collection = pair_and_collection, None
|
||||
if '/' in pair:
|
||||
pair, collection = pair.split('/')
|
||||
if "/" in pair:
|
||||
pair, collection = pair.split("/")
|
||||
|
||||
collections = rv.setdefault(pair, set())
|
||||
if collection:
|
||||
|
|
@ -104,20 +111,25 @@ def collections_arg_callback(ctx, param, value):
|
|||
return rv.items()
|
||||
|
||||
|
||||
collections_arg = click.argument('collections', nargs=-1,
|
||||
callback=collections_arg_callback)
|
||||
collections_arg = click.argument(
|
||||
"collections", nargs=-1, callback=collections_arg_callback
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
@collections_arg
|
||||
@click.option('--force-delete/--no-force-delete',
|
||||
help=('Do/Don\'t abort synchronization when all items are about '
|
||||
'to be deleted from both sides.'))
|
||||
@click.option(
|
||||
"--force-delete/--no-force-delete",
|
||||
help=(
|
||||
"Do/Don't abort synchronization when all items are about "
|
||||
"to be deleted from both sides."
|
||||
),
|
||||
)
|
||||
@max_workers_option()
|
||||
@pass_context
|
||||
@catch_errors
|
||||
def sync(ctx, collections, force_delete, max_workers):
|
||||
'''
|
||||
"""
|
||||
Synchronize the given collections or pairs. If no arguments are given, all
|
||||
will be synchronized.
|
||||
|
||||
|
|
@ -136,7 +148,7 @@ def sync(ctx, collections, force_delete, max_workers):
|
|||
\b
|
||||
# Sync only "first_collection" from the pair "bob"
|
||||
vdirsyncer sync bob/first_collection
|
||||
'''
|
||||
"""
|
||||
from .tasks import prepare_pair, sync_collection
|
||||
from .utils import WorkerQueue
|
||||
|
||||
|
|
@ -144,11 +156,16 @@ def sync(ctx, collections, force_delete, max_workers):
|
|||
|
||||
with wq.join():
|
||||
for pair_name, collections in collections:
|
||||
wq.put(functools.partial(prepare_pair, pair_name=pair_name,
|
||||
collections=collections,
|
||||
config=ctx.config,
|
||||
force_delete=force_delete,
|
||||
callback=sync_collection))
|
||||
wq.put(
|
||||
functools.partial(
|
||||
prepare_pair,
|
||||
pair_name=pair_name,
|
||||
collections=collections,
|
||||
config=ctx.config,
|
||||
force_delete=force_delete,
|
||||
callback=sync_collection,
|
||||
)
|
||||
)
|
||||
wq.spawn_worker()
|
||||
|
||||
|
||||
|
|
@ -158,11 +175,11 @@ def sync(ctx, collections, force_delete, max_workers):
|
|||
@pass_context
|
||||
@catch_errors
|
||||
def metasync(ctx, collections, max_workers):
|
||||
'''
|
||||
"""
|
||||
Synchronize metadata of the given collections or pairs.
|
||||
|
||||
See the `sync` command for usage.
|
||||
'''
|
||||
"""
|
||||
from .tasks import prepare_pair, metasync_collection
|
||||
from .utils import WorkerQueue
|
||||
|
||||
|
|
@ -170,59 +187,73 @@ def metasync(ctx, collections, max_workers):
|
|||
|
||||
with wq.join():
|
||||
for pair_name, collections in collections:
|
||||
wq.put(functools.partial(prepare_pair, pair_name=pair_name,
|
||||
collections=collections,
|
||||
config=ctx.config,
|
||||
callback=metasync_collection))
|
||||
wq.put(
|
||||
functools.partial(
|
||||
prepare_pair,
|
||||
pair_name=pair_name,
|
||||
collections=collections,
|
||||
config=ctx.config,
|
||||
callback=metasync_collection,
|
||||
)
|
||||
)
|
||||
wq.spawn_worker()
|
||||
|
||||
|
||||
@app.command()
|
||||
@click.argument('pairs', nargs=-1)
|
||||
@click.argument("pairs", nargs=-1)
|
||||
@click.option(
|
||||
'--list/--no-list', default=True,
|
||||
"--list/--no-list",
|
||||
default=True,
|
||||
help=(
|
||||
'Whether to list all collections from both sides during discovery, '
|
||||
'for debugging. This is slow and may crash for broken servers.'
|
||||
)
|
||||
"Whether to list all collections from both sides during discovery, "
|
||||
"for debugging. This is slow and may crash for broken servers."
|
||||
),
|
||||
)
|
||||
@max_workers_option(default=1)
|
||||
@pass_context
|
||||
@catch_errors
|
||||
def discover(ctx, pairs, max_workers, list):
|
||||
'''
|
||||
"""
|
||||
Refresh collection cache for the given pairs.
|
||||
'''
|
||||
"""
|
||||
from .tasks import discover_collections
|
||||
from .utils import WorkerQueue
|
||||
|
||||
config = ctx.config
|
||||
wq = WorkerQueue(max_workers)
|
||||
|
||||
with wq.join():
|
||||
for pair_name in (pairs or config.pairs):
|
||||
for pair_name in pairs or config.pairs:
|
||||
pair = config.get_pair(pair_name)
|
||||
|
||||
wq.put(functools.partial(
|
||||
discover_collections,
|
||||
status_path=config.general['status_path'],
|
||||
pair=pair,
|
||||
from_cache=False,
|
||||
list_collections=list,
|
||||
))
|
||||
wq.put(
|
||||
functools.partial(
|
||||
discover_collections,
|
||||
status_path=config.general["status_path"],
|
||||
pair=pair,
|
||||
from_cache=False,
|
||||
list_collections=list,
|
||||
)
|
||||
)
|
||||
wq.spawn_worker()
|
||||
|
||||
|
||||
@app.command()
|
||||
@click.argument('collection')
|
||||
@click.option('--repair-unsafe-uid/--no-repair-unsafe-uid', default=False,
|
||||
help=('Some characters in item UIDs and URLs may cause problems '
|
||||
'with buggy software. Adding this option will reassign '
|
||||
'new UIDs to those items. This is disabled by default, '
|
||||
'which is equivalent to `--no-repair-unsafe-uid`.'))
|
||||
@click.argument("collection")
|
||||
@click.option(
|
||||
"--repair-unsafe-uid/--no-repair-unsafe-uid",
|
||||
default=False,
|
||||
help=(
|
||||
"Some characters in item UIDs and URLs may cause problems "
|
||||
"with buggy software. Adding this option will reassign "
|
||||
"new UIDs to those items. This is disabled by default, "
|
||||
"which is equivalent to `--no-repair-unsafe-uid`."
|
||||
),
|
||||
)
|
||||
@pass_context
|
||||
@catch_errors
|
||||
def repair(ctx, collection, repair_unsafe_uid):
|
||||
'''
|
||||
"""
|
||||
Repair a given collection.
|
||||
|
||||
Runs a few checks on the collection and applies some fixes to individual
|
||||
|
|
@ -234,12 +265,13 @@ def repair(ctx, collection, repair_unsafe_uid):
|
|||
\b\bExamples:
|
||||
# Repair the `foo` collection of the `calendars_local` storage
|
||||
vdirsyncer repair calendars_local/foo
|
||||
'''
|
||||
"""
|
||||
from .tasks import repair_collection
|
||||
|
||||
cli_logger.warning('This operation will take a very long time.')
|
||||
cli_logger.warning('It\'s recommended to make a backup and '
|
||||
'turn off other client\'s synchronization features.')
|
||||
click.confirm('Do you want to continue?', abort=True)
|
||||
repair_collection(ctx.config, collection,
|
||||
repair_unsafe_uid=repair_unsafe_uid)
|
||||
cli_logger.warning("This operation will take a very long time.")
|
||||
cli_logger.warning(
|
||||
"It's recommended to make a backup and "
|
||||
"turn off other client's synchronization features."
|
||||
)
|
||||
click.confirm("Do you want to continue?", abort=True)
|
||||
repair_collection(ctx.config, collection, repair_unsafe_uid=repair_unsafe_uid)
|
||||
|
|
|
|||
|
|
@ -14,19 +14,20 @@ from .fetchparams import expand_fetch_params
|
|||
from .utils import storage_class_from_config
|
||||
|
||||
|
||||
GENERAL_ALL = frozenset(['status_path'])
|
||||
GENERAL_REQUIRED = frozenset(['status_path'])
|
||||
SECTION_NAME_CHARS = frozenset(chain(string.ascii_letters, string.digits, '_'))
|
||||
GENERAL_ALL = frozenset(["status_path"])
|
||||
GENERAL_REQUIRED = frozenset(["status_path"])
|
||||
SECTION_NAME_CHARS = frozenset(chain(string.ascii_letters, string.digits, "_"))
|
||||
|
||||
|
||||
def validate_section_name(name, section_type):
|
||||
invalid = set(name) - SECTION_NAME_CHARS
|
||||
if invalid:
|
||||
chars_display = ''.join(sorted(SECTION_NAME_CHARS))
|
||||
chars_display = "".join(sorted(SECTION_NAME_CHARS))
|
||||
raise exceptions.UserError(
|
||||
'The {}-section "{}" contains invalid characters. Only '
|
||||
'the following characters are allowed for storage and '
|
||||
'pair names:\n{}'.format(section_type, name, chars_display))
|
||||
"the following characters are allowed for storage and "
|
||||
"pair names:\n{}".format(section_type, name, chars_display)
|
||||
)
|
||||
|
||||
|
||||
def _validate_general_section(general_config):
|
||||
|
|
@ -35,18 +36,21 @@ def _validate_general_section(general_config):
|
|||
problems = []
|
||||
|
||||
if invalid:
|
||||
problems.append('general section doesn\'t take the parameters: {}'
|
||||
.format(', '.join(invalid)))
|
||||
problems.append(
|
||||
"general section doesn't take the parameters: {}".format(", ".join(invalid))
|
||||
)
|
||||
|
||||
if missing:
|
||||
problems.append('general section is missing the parameters: {}'
|
||||
.format(', '.join(missing)))
|
||||
problems.append(
|
||||
"general section is missing the parameters: {}".format(", ".join(missing))
|
||||
)
|
||||
|
||||
if problems:
|
||||
raise exceptions.UserError(
|
||||
'Invalid general section. Copy the example '
|
||||
'config from the repository and edit it: {}'
|
||||
.format(PROJECT_HOME), problems=problems)
|
||||
"Invalid general section. Copy the example "
|
||||
"config from the repository and edit it: {}".format(PROJECT_HOME),
|
||||
problems=problems,
|
||||
)
|
||||
|
||||
|
||||
def _validate_collections_param(collections):
|
||||
|
|
@ -54,7 +58,7 @@ def _validate_collections_param(collections):
|
|||
return
|
||||
|
||||
if not isinstance(collections, list):
|
||||
raise ValueError('`collections` parameter must be a list or `null`.')
|
||||
raise ValueError("`collections` parameter must be a list or `null`.")
|
||||
|
||||
collection_names = set()
|
||||
|
||||
|
|
@ -64,7 +68,7 @@ def _validate_collections_param(collections):
|
|||
collection_name = collection
|
||||
elif isinstance(collection, list):
|
||||
e = ValueError(
|
||||
'Expected list of format '
|
||||
"Expected list of format "
|
||||
'["config_name", "storage_a_name", "storage_b_name"]'
|
||||
)
|
||||
if len(collection) != 3:
|
||||
|
|
@ -79,14 +83,15 @@ def _validate_collections_param(collections):
|
|||
|
||||
collection_name = collection[0]
|
||||
else:
|
||||
raise ValueError('Expected string or list of three strings.')
|
||||
raise ValueError("Expected string or list of three strings.")
|
||||
|
||||
if collection_name in collection_names:
|
||||
raise ValueError('Duplicate value.')
|
||||
raise ValueError("Duplicate value.")
|
||||
collection_names.add(collection_name)
|
||||
except ValueError as e:
|
||||
raise ValueError('`collections` parameter, position {i}: {e}'
|
||||
.format(i=i, e=str(e)))
|
||||
raise ValueError(
|
||||
"`collections` parameter, position {i}: {e}".format(i=i, e=str(e))
|
||||
)
|
||||
|
||||
|
||||
class _ConfigReader:
|
||||
|
|
@ -106,39 +111,38 @@ class _ConfigReader:
|
|||
raise ValueError(f'Name "{name}" already used.')
|
||||
self._seen_names.add(name)
|
||||
|
||||
if section_type == 'general':
|
||||
if section_type == "general":
|
||||
if self._general:
|
||||
raise ValueError('More than one general section.')
|
||||
raise ValueError("More than one general section.")
|
||||
self._general = options
|
||||
elif section_type == 'storage':
|
||||
elif section_type == "storage":
|
||||
self._storages[name] = options
|
||||
elif section_type == 'pair':
|
||||
elif section_type == "pair":
|
||||
self._pairs[name] = options
|
||||
else:
|
||||
raise ValueError('Unknown section type.')
|
||||
raise ValueError("Unknown section type.")
|
||||
|
||||
def parse(self):
|
||||
for section in self._parser.sections():
|
||||
if ' ' in section:
|
||||
section_type, name = section.split(' ', 1)
|
||||
if " " in section:
|
||||
section_type, name = section.split(" ", 1)
|
||||
else:
|
||||
section_type = name = section
|
||||
|
||||
try:
|
||||
self._parse_section(
|
||||
section_type, name,
|
||||
dict(_parse_options(self._parser.items(section),
|
||||
section=section))
|
||||
section_type,
|
||||
name,
|
||||
dict(_parse_options(self._parser.items(section), section=section)),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise exceptions.UserError(
|
||||
'Section "{}": {}'.format(section, str(e)))
|
||||
raise exceptions.UserError('Section "{}": {}'.format(section, str(e)))
|
||||
|
||||
_validate_general_section(self._general)
|
||||
if getattr(self._file, 'name', None):
|
||||
self._general['status_path'] = os.path.join(
|
||||
if getattr(self._file, "name", None):
|
||||
self._general["status_path"] = os.path.join(
|
||||
os.path.dirname(self._file.name),
|
||||
expand_path(self._general['status_path'])
|
||||
expand_path(self._general["status_path"]),
|
||||
)
|
||||
|
||||
return self._general, self._pairs, self._storages
|
||||
|
|
@ -149,8 +153,7 @@ def _parse_options(items, section=None):
|
|||
try:
|
||||
yield key, json.loads(value)
|
||||
except ValueError as e:
|
||||
raise ValueError('Section "{}", option "{}": {}'
|
||||
.format(section, key, e))
|
||||
raise ValueError('Section "{}", option "{}": {}'.format(section, key, e))
|
||||
|
||||
|
||||
class Config:
|
||||
|
|
@ -158,14 +161,14 @@ class Config:
|
|||
self.general = general
|
||||
self.storages = storages
|
||||
for name, options in storages.items():
|
||||
options['instance_name'] = name
|
||||
options["instance_name"] = name
|
||||
|
||||
self.pairs = {}
|
||||
for name, options in pairs.items():
|
||||
try:
|
||||
self.pairs[name] = PairConfig(self, name, options)
|
||||
except ValueError as e:
|
||||
raise exceptions.UserError(f'Pair {name}: {e}')
|
||||
raise exceptions.UserError(f"Pair {name}: {e}")
|
||||
|
||||
@classmethod
|
||||
def from_fileobject(cls, f):
|
||||
|
|
@ -175,21 +178,21 @@ class Config:
|
|||
@classmethod
|
||||
def from_filename_or_environment(cls, fname=None):
|
||||
if fname is None:
|
||||
fname = os.environ.get('VDIRSYNCER_CONFIG', None)
|
||||
fname = os.environ.get("VDIRSYNCER_CONFIG", None)
|
||||
if fname is None:
|
||||
fname = expand_path('~/.vdirsyncer/config')
|
||||
fname = expand_path("~/.vdirsyncer/config")
|
||||
if not os.path.exists(fname):
|
||||
xdg_config_dir = os.environ.get('XDG_CONFIG_HOME',
|
||||
expand_path('~/.config/'))
|
||||
fname = os.path.join(xdg_config_dir, 'vdirsyncer/config')
|
||||
xdg_config_dir = os.environ.get(
|
||||
"XDG_CONFIG_HOME", expand_path("~/.config/")
|
||||
)
|
||||
fname = os.path.join(xdg_config_dir, "vdirsyncer/config")
|
||||
|
||||
try:
|
||||
with open(fname) as f:
|
||||
return cls.from_fileobject(f)
|
||||
except Exception as e:
|
||||
raise exceptions.UserError(
|
||||
'Error during reading config {}: {}'
|
||||
.format(fname, e)
|
||||
"Error during reading config {}: {}".format(fname, e)
|
||||
)
|
||||
|
||||
def get_storage_args(self, storage_name):
|
||||
|
|
@ -197,9 +200,10 @@ class Config:
|
|||
args = self.storages[storage_name]
|
||||
except KeyError:
|
||||
raise exceptions.UserError(
|
||||
'Storage {!r} not found. '
|
||||
'These are the configured storages: {}'
|
||||
.format(storage_name, list(self.storages))
|
||||
"Storage {!r} not found. "
|
||||
"These are the configured storages: {}".format(
|
||||
storage_name, list(self.storages)
|
||||
)
|
||||
)
|
||||
else:
|
||||
return expand_fetch_params(args)
|
||||
|
|
@ -215,50 +219,53 @@ class PairConfig:
|
|||
def __init__(self, full_config, name, options):
|
||||
self._config = full_config
|
||||
self.name = name
|
||||
self.name_a = options.pop('a')
|
||||
self.name_b = options.pop('b')
|
||||
self.name_a = options.pop("a")
|
||||
self.name_b = options.pop("b")
|
||||
|
||||
self._partial_sync = options.pop('partial_sync', None)
|
||||
self.metadata = options.pop('metadata', None) or ()
|
||||
self._partial_sync = options.pop("partial_sync", None)
|
||||
self.metadata = options.pop("metadata", None) or ()
|
||||
|
||||
self.conflict_resolution = \
|
||||
self._process_conflict_resolution_param(
|
||||
options.pop('conflict_resolution', None))
|
||||
self.conflict_resolution = self._process_conflict_resolution_param(
|
||||
options.pop("conflict_resolution", None)
|
||||
)
|
||||
|
||||
try:
|
||||
self.collections = options.pop('collections')
|
||||
self.collections = options.pop("collections")
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
'collections parameter missing.\n\n'
|
||||
'As of 0.9.0 this parameter has no default anymore. '
|
||||
'Set `collections = null` explicitly in your pair config.'
|
||||
"collections parameter missing.\n\n"
|
||||
"As of 0.9.0 this parameter has no default anymore. "
|
||||
"Set `collections = null` explicitly in your pair config."
|
||||
)
|
||||
else:
|
||||
_validate_collections_param(self.collections)
|
||||
|
||||
if options:
|
||||
raise ValueError('Unknown options: {}'.format(', '.join(options)))
|
||||
raise ValueError("Unknown options: {}".format(", ".join(options)))
|
||||
|
||||
def _process_conflict_resolution_param(self, conflict_resolution):
|
||||
if conflict_resolution in (None, 'a wins', 'b wins'):
|
||||
if conflict_resolution in (None, "a wins", "b wins"):
|
||||
return conflict_resolution
|
||||
elif isinstance(conflict_resolution, list) and \
|
||||
len(conflict_resolution) > 1 and \
|
||||
conflict_resolution[0] == 'command':
|
||||
elif (
|
||||
isinstance(conflict_resolution, list)
|
||||
and len(conflict_resolution) > 1
|
||||
and conflict_resolution[0] == "command"
|
||||
):
|
||||
|
||||
def resolve(a, b):
|
||||
a_name = self.config_a['instance_name']
|
||||
b_name = self.config_b['instance_name']
|
||||
a_name = self.config_a["instance_name"]
|
||||
b_name = self.config_b["instance_name"]
|
||||
command = conflict_resolution[1:]
|
||||
|
||||
def inner():
|
||||
return _resolve_conflict_via_command(a, b, command, a_name,
|
||||
b_name)
|
||||
return _resolve_conflict_via_command(a, b, command, a_name, b_name)
|
||||
|
||||
ui_worker = get_ui_worker()
|
||||
return ui_worker.put(inner)
|
||||
|
||||
return resolve
|
||||
else:
|
||||
raise ValueError('Invalid value for `conflict_resolution`.')
|
||||
raise ValueError("Invalid value for `conflict_resolution`.")
|
||||
|
||||
# The following parameters are lazily evaluated because evaluating
|
||||
# self.config_a would expand all `x.fetch` parameters. This is costly and
|
||||
|
|
@ -282,21 +289,23 @@ class PairConfig:
|
|||
cls_a, _ = storage_class_from_config(self.config_a)
|
||||
cls_b, _ = storage_class_from_config(self.config_b)
|
||||
|
||||
if not cls_a.read_only and \
|
||||
not self.config_a.get('read_only', False) and \
|
||||
not cls_b.read_only and \
|
||||
not self.config_b.get('read_only', False):
|
||||
if (
|
||||
not cls_a.read_only
|
||||
and not self.config_a.get("read_only", False)
|
||||
and not cls_b.read_only
|
||||
and not self.config_b.get("read_only", False)
|
||||
):
|
||||
raise exceptions.UserError(
|
||||
'`partial_sync` is only effective if one storage is '
|
||||
'read-only. Use `read_only = true` in exactly one storage '
|
||||
'section.'
|
||||
"`partial_sync` is only effective if one storage is "
|
||||
"read-only. Use `read_only = true` in exactly one storage "
|
||||
"section."
|
||||
)
|
||||
|
||||
if partial_sync is None:
|
||||
partial_sync = 'revert'
|
||||
partial_sync = "revert"
|
||||
|
||||
if partial_sync not in ('ignore', 'revert', 'error'):
|
||||
raise exceptions.UserError('Invalid value for `partial_sync`.')
|
||||
if partial_sync not in ("ignore", "revert", "error"):
|
||||
raise exceptions.UserError("Invalid value for `partial_sync`.")
|
||||
|
||||
return partial_sync
|
||||
|
||||
|
|
@ -314,8 +323,7 @@ class CollectionConfig:
|
|||
load_config = Config.from_filename_or_environment
|
||||
|
||||
|
||||
def _resolve_conflict_via_command(a, b, command, a_name, b_name,
|
||||
_check_call=None):
|
||||
def _resolve_conflict_via_command(a, b, command, a_name, b_name, _check_call=None):
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
|
|
@ -324,14 +332,14 @@ def _resolve_conflict_via_command(a, b, command, a_name, b_name,
|
|||
|
||||
from ..vobject import Item
|
||||
|
||||
dir = tempfile.mkdtemp(prefix='vdirsyncer-conflict.')
|
||||
dir = tempfile.mkdtemp(prefix="vdirsyncer-conflict.")
|
||||
try:
|
||||
a_tmp = os.path.join(dir, a_name)
|
||||
b_tmp = os.path.join(dir, b_name)
|
||||
|
||||
with open(a_tmp, 'w') as f:
|
||||
with open(a_tmp, "w") as f:
|
||||
f.write(a.raw)
|
||||
with open(b_tmp, 'w') as f:
|
||||
with open(b_tmp, "w") as f:
|
||||
f.write(b.raw)
|
||||
|
||||
command[0] = expand_path(command[0])
|
||||
|
|
@ -343,8 +351,7 @@ def _resolve_conflict_via_command(a, b, command, a_name, b_name,
|
|||
new_b = f.read()
|
||||
|
||||
if new_a != new_b:
|
||||
raise exceptions.UserError('The two files are not completely '
|
||||
'equal.')
|
||||
raise exceptions.UserError("The two files are not completely " "equal.")
|
||||
return Item(new_a)
|
||||
finally:
|
||||
shutil.rmtree(dir)
|
||||
|
|
|
|||
|
|
@ -22,19 +22,21 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
def _get_collections_cache_key(pair):
|
||||
m = hashlib.sha256()
|
||||
j = json.dumps([
|
||||
DISCOVERY_CACHE_VERSION,
|
||||
pair.collections,
|
||||
pair.config_a,
|
||||
pair.config_b,
|
||||
], sort_keys=True)
|
||||
m.update(j.encode('utf-8'))
|
||||
j = json.dumps(
|
||||
[
|
||||
DISCOVERY_CACHE_VERSION,
|
||||
pair.collections,
|
||||
pair.config_a,
|
||||
pair.config_b,
|
||||
],
|
||||
sort_keys=True,
|
||||
)
|
||||
m.update(j.encode("utf-8"))
|
||||
return m.hexdigest()
|
||||
|
||||
|
||||
def collections_for_pair(status_path, pair, from_cache=True,
|
||||
list_collections=False):
|
||||
'''Determine all configured collections for a given pair. Takes care of
|
||||
def collections_for_pair(status_path, pair, from_cache=True, list_collections=False):
|
||||
"""Determine all configured collections for a given pair. Takes care of
|
||||
shortcut expansion and result caching.
|
||||
|
||||
:param status_path: The path to the status directory.
|
||||
|
|
@ -42,55 +44,62 @@ def collections_for_pair(status_path, pair, from_cache=True,
|
|||
discover and save to cache.
|
||||
|
||||
:returns: iterable of (collection, (a_args, b_args))
|
||||
'''
|
||||
"""
|
||||
cache_key = _get_collections_cache_key(pair)
|
||||
if from_cache:
|
||||
rv = load_status(status_path, pair.name, data_type='collections')
|
||||
if rv and rv.get('cache_key', None) == cache_key:
|
||||
return list(_expand_collections_cache(
|
||||
rv['collections'], pair.config_a, pair.config_b
|
||||
))
|
||||
rv = load_status(status_path, pair.name, data_type="collections")
|
||||
if rv and rv.get("cache_key", None) == cache_key:
|
||||
return list(
|
||||
_expand_collections_cache(
|
||||
rv["collections"], pair.config_a, pair.config_b
|
||||
)
|
||||
)
|
||||
elif rv:
|
||||
raise exceptions.UserError('Detected change in config file, '
|
||||
'please run `vdirsyncer discover {}`.'
|
||||
.format(pair.name))
|
||||
raise exceptions.UserError(
|
||||
"Detected change in config file, "
|
||||
"please run `vdirsyncer discover {}`.".format(pair.name)
|
||||
)
|
||||
else:
|
||||
raise exceptions.UserError('Please run `vdirsyncer discover {}` '
|
||||
' before synchronization.'
|
||||
.format(pair.name))
|
||||
raise exceptions.UserError(
|
||||
"Please run `vdirsyncer discover {}` "
|
||||
" before synchronization.".format(pair.name)
|
||||
)
|
||||
|
||||
logger.info('Discovering collections for pair {}' .format(pair.name))
|
||||
logger.info("Discovering collections for pair {}".format(pair.name))
|
||||
|
||||
a_discovered = _DiscoverResult(pair.config_a)
|
||||
b_discovered = _DiscoverResult(pair.config_b)
|
||||
|
||||
if list_collections:
|
||||
_print_collections(pair.config_a['instance_name'],
|
||||
a_discovered.get_self)
|
||||
_print_collections(pair.config_b['instance_name'],
|
||||
b_discovered.get_self)
|
||||
_print_collections(pair.config_a["instance_name"], a_discovered.get_self)
|
||||
_print_collections(pair.config_b["instance_name"], b_discovered.get_self)
|
||||
|
||||
# We have to use a list here because the special None/null value would get
|
||||
# mangled to string (because JSON objects always have string keys).
|
||||
rv = list(expand_collections(
|
||||
shortcuts=pair.collections,
|
||||
config_a=pair.config_a,
|
||||
config_b=pair.config_b,
|
||||
get_a_discovered=a_discovered.get_self,
|
||||
get_b_discovered=b_discovered.get_self,
|
||||
_handle_collection_not_found=handle_collection_not_found
|
||||
))
|
||||
rv = list(
|
||||
expand_collections(
|
||||
shortcuts=pair.collections,
|
||||
config_a=pair.config_a,
|
||||
config_b=pair.config_b,
|
||||
get_a_discovered=a_discovered.get_self,
|
||||
get_b_discovered=b_discovered.get_self,
|
||||
_handle_collection_not_found=handle_collection_not_found,
|
||||
)
|
||||
)
|
||||
|
||||
_sanity_check_collections(rv)
|
||||
|
||||
save_status(status_path, pair.name, data_type='collections',
|
||||
data={
|
||||
'collections': list(
|
||||
_compress_collections_cache(rv, pair.config_a,
|
||||
pair.config_b)
|
||||
),
|
||||
'cache_key': cache_key
|
||||
})
|
||||
save_status(
|
||||
status_path,
|
||||
pair.name,
|
||||
data_type="collections",
|
||||
data={
|
||||
"collections": list(
|
||||
_compress_collections_cache(rv, pair.config_a, pair.config_b)
|
||||
),
|
||||
"cache_key": cache_key,
|
||||
},
|
||||
)
|
||||
return rv
|
||||
|
||||
|
||||
|
|
@ -141,25 +150,31 @@ class _DiscoverResult:
|
|||
except Exception:
|
||||
return handle_storage_init_error(self._cls, self._config)
|
||||
else:
|
||||
storage_type = self._config['type']
|
||||
storage_type = self._config["type"]
|
||||
rv = {}
|
||||
for args in discovered:
|
||||
args['type'] = storage_type
|
||||
rv[args['collection']] = args
|
||||
args["type"] = storage_type
|
||||
rv[args["collection"]] = args
|
||||
return rv
|
||||
|
||||
|
||||
def expand_collections(shortcuts, config_a, config_b, get_a_discovered,
|
||||
get_b_discovered, _handle_collection_not_found):
|
||||
def expand_collections(
|
||||
shortcuts,
|
||||
config_a,
|
||||
config_b,
|
||||
get_a_discovered,
|
||||
get_b_discovered,
|
||||
_handle_collection_not_found,
|
||||
):
|
||||
handled_collections = set()
|
||||
|
||||
if shortcuts is None:
|
||||
shortcuts = [None]
|
||||
|
||||
for shortcut in shortcuts:
|
||||
if shortcut == 'from a':
|
||||
if shortcut == "from a":
|
||||
collections = get_a_discovered()
|
||||
elif shortcut == 'from b':
|
||||
elif shortcut == "from b":
|
||||
collections = get_b_discovered()
|
||||
else:
|
||||
collections = [shortcut]
|
||||
|
|
@ -175,22 +190,21 @@ def expand_collections(shortcuts, config_a, config_b, get_a_discovered,
|
|||
handled_collections.add(collection)
|
||||
|
||||
a_args = _collection_from_discovered(
|
||||
get_a_discovered, collection_a, config_a,
|
||||
_handle_collection_not_found
|
||||
get_a_discovered, collection_a, config_a, _handle_collection_not_found
|
||||
)
|
||||
b_args = _collection_from_discovered(
|
||||
get_b_discovered, collection_b, config_b,
|
||||
_handle_collection_not_found
|
||||
get_b_discovered, collection_b, config_b, _handle_collection_not_found
|
||||
)
|
||||
|
||||
yield collection, (a_args, b_args)
|
||||
|
||||
|
||||
def _collection_from_discovered(get_discovered, collection, config,
|
||||
_handle_collection_not_found):
|
||||
def _collection_from_discovered(
|
||||
get_discovered, collection, config, _handle_collection_not_found
|
||||
):
|
||||
if collection is None:
|
||||
args = dict(config)
|
||||
args['collection'] = None
|
||||
args["collection"] = None
|
||||
return args
|
||||
|
||||
try:
|
||||
|
|
@ -209,26 +223,31 @@ def _print_collections(instance_name, get_discovered):
|
|||
# UserError), we don't even know if the storage supports discovery
|
||||
# properly. So we can't abort.
|
||||
import traceback
|
||||
logger.debug(''.join(traceback.format_tb(sys.exc_info()[2])))
|
||||
logger.warning('Failed to discover collections for {}, use `-vdebug` '
|
||||
'to see the full traceback.'.format(instance_name))
|
||||
|
||||
logger.debug("".join(traceback.format_tb(sys.exc_info()[2])))
|
||||
logger.warning(
|
||||
"Failed to discover collections for {}, use `-vdebug` "
|
||||
"to see the full traceback.".format(instance_name)
|
||||
)
|
||||
return
|
||||
logger.info(f'{instance_name}:')
|
||||
logger.info(f"{instance_name}:")
|
||||
for args in discovered.values():
|
||||
collection = args['collection']
|
||||
collection = args["collection"]
|
||||
if collection is None:
|
||||
continue
|
||||
|
||||
args['instance_name'] = instance_name
|
||||
args["instance_name"] = instance_name
|
||||
try:
|
||||
storage = storage_instance_from_config(args, create=False)
|
||||
displayname = storage.get_meta('displayname')
|
||||
displayname = storage.get_meta("displayname")
|
||||
except Exception:
|
||||
displayname = ''
|
||||
displayname = ""
|
||||
|
||||
logger.info(' - {}{}'.format(
|
||||
json.dumps(collection),
|
||||
f' ("{displayname}")'
|
||||
if displayname and displayname != collection
|
||||
else ''
|
||||
))
|
||||
logger.info(
|
||||
" - {}{}".format(
|
||||
json.dumps(collection),
|
||||
f' ("{displayname}")'
|
||||
if displayname and displayname != collection
|
||||
else "",
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from .. import exceptions
|
|||
from ..utils import expand_path
|
||||
from ..utils import synchronized
|
||||
|
||||
SUFFIX = '.fetch'
|
||||
SUFFIX = ".fetch"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -18,9 +18,9 @@ def expand_fetch_params(config):
|
|||
if not key.endswith(SUFFIX):
|
||||
continue
|
||||
|
||||
newkey = key[:-len(SUFFIX)]
|
||||
newkey = key[: -len(SUFFIX)]
|
||||
if newkey in config:
|
||||
raise ValueError(f'Can\'t set {key} and {newkey}.')
|
||||
raise ValueError(f"Can't set {key} and {newkey}.")
|
||||
config[newkey] = _fetch_value(config[key], key)
|
||||
del config[key]
|
||||
|
||||
|
|
@ -30,10 +30,11 @@ def expand_fetch_params(config):
|
|||
@synchronized()
|
||||
def _fetch_value(opts, key):
|
||||
if not isinstance(opts, list):
|
||||
raise ValueError('Invalid value for {}: Expected a list, found {!r}.'
|
||||
.format(key, opts))
|
||||
raise ValueError(
|
||||
"Invalid value for {}: Expected a list, found {!r}.".format(key, opts)
|
||||
)
|
||||
if not opts:
|
||||
raise ValueError('Expected list of length > 0.')
|
||||
raise ValueError("Expected list of length > 0.")
|
||||
|
||||
try:
|
||||
ctx = click.get_current_context().find_object(AppContext)
|
||||
|
|
@ -46,7 +47,7 @@ def _fetch_value(opts, key):
|
|||
cache_key = tuple(opts)
|
||||
if cache_key in password_cache:
|
||||
rv = password_cache[cache_key]
|
||||
logger.debug(f'Found cached value for {opts!r}.')
|
||||
logger.debug(f"Found cached value for {opts!r}.")
|
||||
if isinstance(rv, BaseException):
|
||||
raise rv
|
||||
return rv
|
||||
|
|
@ -55,10 +56,9 @@ def _fetch_value(opts, key):
|
|||
try:
|
||||
strategy_fn = STRATEGIES[strategy]
|
||||
except KeyError:
|
||||
raise exceptions.UserError(f'Unknown strategy: {strategy}')
|
||||
raise exceptions.UserError(f"Unknown strategy: {strategy}")
|
||||
|
||||
logger.debug('Fetching value for {} with {} strategy.'
|
||||
.format(key, strategy))
|
||||
logger.debug("Fetching value for {} with {} strategy.".format(key, strategy))
|
||||
try:
|
||||
rv = strategy_fn(*opts[1:])
|
||||
except (click.Abort, KeyboardInterrupt) as e:
|
||||
|
|
@ -66,22 +66,25 @@ def _fetch_value(opts, key):
|
|||
raise
|
||||
else:
|
||||
if not rv:
|
||||
raise exceptions.UserError('Empty value for {}, this most likely '
|
||||
'indicates an error.'
|
||||
.format(key))
|
||||
raise exceptions.UserError(
|
||||
"Empty value for {}, this most likely "
|
||||
"indicates an error.".format(key)
|
||||
)
|
||||
password_cache[cache_key] = rv
|
||||
return rv
|
||||
|
||||
|
||||
def _strategy_command(*command):
|
||||
import subprocess
|
||||
|
||||
command = (expand_path(command[0]),) + command[1:]
|
||||
try:
|
||||
stdout = subprocess.check_output(command, universal_newlines=True)
|
||||
return stdout.strip('\n')
|
||||
return stdout.strip("\n")
|
||||
except OSError as e:
|
||||
raise exceptions.UserError('Failed to execute command: {}\n{}'
|
||||
.format(' '.join(command), str(e)))
|
||||
raise exceptions.UserError(
|
||||
"Failed to execute command: {}\n{}".format(" ".join(command), str(e))
|
||||
)
|
||||
|
||||
|
||||
def _strategy_prompt(text):
|
||||
|
|
@ -89,6 +92,6 @@ def _strategy_prompt(text):
|
|||
|
||||
|
||||
STRATEGIES = {
|
||||
'command': _strategy_command,
|
||||
'prompt': _strategy_prompt,
|
||||
"command": _strategy_command,
|
||||
"prompt": _strategy_prompt,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,28 +19,30 @@ from .utils import save_status
|
|||
def prepare_pair(wq, pair_name, collections, config, callback, **kwargs):
|
||||
pair = config.get_pair(pair_name)
|
||||
|
||||
all_collections = dict(collections_for_pair(
|
||||
status_path=config.general['status_path'], pair=pair
|
||||
))
|
||||
all_collections = dict(
|
||||
collections_for_pair(status_path=config.general["status_path"], pair=pair)
|
||||
)
|
||||
|
||||
# spawn one worker less because we can reuse the current one
|
||||
new_workers = -1
|
||||
for collection_name in (collections or all_collections):
|
||||
for collection_name in collections or all_collections:
|
||||
try:
|
||||
config_a, config_b = all_collections[collection_name]
|
||||
except KeyError:
|
||||
raise exceptions.UserError(
|
||||
'Pair {}: Collection {} not found. These are the '
|
||||
'configured collections:\n{}'
|
||||
.format(pair_name,
|
||||
json.dumps(collection_name),
|
||||
list(all_collections)))
|
||||
"Pair {}: Collection {} not found. These are the "
|
||||
"configured collections:\n{}".format(
|
||||
pair_name, json.dumps(collection_name), list(all_collections)
|
||||
)
|
||||
)
|
||||
new_workers += 1
|
||||
|
||||
collection = CollectionConfig(pair, collection_name, config_a,
|
||||
config_b)
|
||||
wq.put(functools.partial(callback, collection=collection,
|
||||
general=config.general, **kwargs))
|
||||
collection = CollectionConfig(pair, collection_name, config_a, config_b)
|
||||
wq.put(
|
||||
functools.partial(
|
||||
callback, collection=collection, general=config.general, **kwargs
|
||||
)
|
||||
)
|
||||
|
||||
for _ in range(new_workers):
|
||||
wq.spawn_worker()
|
||||
|
|
@ -51,7 +53,7 @@ def sync_collection(wq, collection, general, force_delete):
|
|||
status_name = get_status_name(pair.name, collection.name)
|
||||
|
||||
try:
|
||||
cli_logger.info(f'Syncing {status_name}')
|
||||
cli_logger.info(f"Syncing {status_name}")
|
||||
|
||||
a = storage_instance_from_config(collection.config_a)
|
||||
b = storage_instance_from_config(collection.config_b)
|
||||
|
|
@ -63,14 +65,17 @@ def sync_collection(wq, collection, general, force_delete):
|
|||
sync_failed = True
|
||||
handle_cli_error(status_name, e)
|
||||
|
||||
with manage_sync_status(general['status_path'], pair.name,
|
||||
collection.name) as status:
|
||||
with manage_sync_status(
|
||||
general["status_path"], pair.name, collection.name
|
||||
) as status:
|
||||
sync.sync(
|
||||
a, b, status,
|
||||
a,
|
||||
b,
|
||||
status,
|
||||
conflict_resolution=pair.conflict_resolution,
|
||||
force_delete=force_delete,
|
||||
error_callback=error_callback,
|
||||
partial_sync=pair.partial_sync
|
||||
partial_sync=pair.partial_sync,
|
||||
)
|
||||
|
||||
if sync_failed:
|
||||
|
|
@ -87,62 +92,76 @@ def discover_collections(wq, pair, **kwargs):
|
|||
collections = list(c for c, (a, b) in rv)
|
||||
if collections == [None]:
|
||||
collections = None
|
||||
cli_logger.info('Saved for {}: collections = {}'
|
||||
.format(pair.name, json.dumps(collections)))
|
||||
cli_logger.info(
|
||||
"Saved for {}: collections = {}".format(pair.name, json.dumps(collections))
|
||||
)
|
||||
|
||||
|
||||
def repair_collection(config, collection, repair_unsafe_uid):
|
||||
from ..repair import repair_storage
|
||||
|
||||
storage_name, collection = collection, None
|
||||
if '/' in storage_name:
|
||||
storage_name, collection = storage_name.split('/')
|
||||
if "/" in storage_name:
|
||||
storage_name, collection = storage_name.split("/")
|
||||
|
||||
config = config.get_storage_args(storage_name)
|
||||
storage_type = config['type']
|
||||
storage_type = config["type"]
|
||||
|
||||
if collection is not None:
|
||||
cli_logger.info('Discovering collections (skipping cache).')
|
||||
cli_logger.info("Discovering collections (skipping cache).")
|
||||
cls, config = storage_class_from_config(config)
|
||||
for config in cls.discover(**config):
|
||||
if config['collection'] == collection:
|
||||
if config["collection"] == collection:
|
||||
break
|
||||
else:
|
||||
raise exceptions.UserError(
|
||||
'Couldn\'t find collection {} for storage {}.'
|
||||
.format(collection, storage_name)
|
||||
"Couldn't find collection {} for storage {}.".format(
|
||||
collection, storage_name
|
||||
)
|
||||
)
|
||||
|
||||
config['type'] = storage_type
|
||||
config["type"] = storage_type
|
||||
storage = storage_instance_from_config(config)
|
||||
|
||||
cli_logger.info(f'Repairing {storage_name}/{collection}')
|
||||
cli_logger.warning('Make sure no other program is talking to the server.')
|
||||
cli_logger.info(f"Repairing {storage_name}/{collection}")
|
||||
cli_logger.warning("Make sure no other program is talking to the server.")
|
||||
repair_storage(storage, repair_unsafe_uid=repair_unsafe_uid)
|
||||
|
||||
|
||||
def metasync_collection(wq, collection, general):
|
||||
from ..metasync import metasync
|
||||
|
||||
pair = collection.pair
|
||||
status_name = get_status_name(pair.name, collection.name)
|
||||
|
||||
try:
|
||||
cli_logger.info(f'Metasyncing {status_name}')
|
||||
cli_logger.info(f"Metasyncing {status_name}")
|
||||
|
||||
status = load_status(general['status_path'], pair.name,
|
||||
collection.name, data_type='metadata') or {}
|
||||
status = (
|
||||
load_status(
|
||||
general["status_path"], pair.name, collection.name, data_type="metadata"
|
||||
)
|
||||
or {}
|
||||
)
|
||||
|
||||
a = storage_instance_from_config(collection.config_a)
|
||||
b = storage_instance_from_config(collection.config_b)
|
||||
|
||||
metasync(
|
||||
a, b, status,
|
||||
a,
|
||||
b,
|
||||
status,
|
||||
conflict_resolution=pair.conflict_resolution,
|
||||
keys=pair.metadata
|
||||
keys=pair.metadata,
|
||||
)
|
||||
except BaseException:
|
||||
handle_cli_error(status_name)
|
||||
raise JobFailed()
|
||||
|
||||
save_status(general['status_path'], pair.name, collection.name,
|
||||
data_type='metadata', data=status)
|
||||
save_status(
|
||||
general["status_path"],
|
||||
pair.name,
|
||||
collection.name,
|
||||
data_type="metadata",
|
||||
data=status,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,15 +31,15 @@ STATUS_DIR_PERMISSIONS = 0o700
|
|||
class _StorageIndex:
|
||||
def __init__(self):
|
||||
self._storages = dict(
|
||||
caldav='vdirsyncer.storage.dav.CalDAVStorage',
|
||||
carddav='vdirsyncer.storage.dav.CardDAVStorage',
|
||||
filesystem='vdirsyncer.storage.filesystem.FilesystemStorage',
|
||||
http='vdirsyncer.storage.http.HttpStorage',
|
||||
singlefile='vdirsyncer.storage.singlefile.SingleFileStorage',
|
||||
google_calendar='vdirsyncer.storage.google.GoogleCalendarStorage',
|
||||
google_contacts='vdirsyncer.storage.google.GoogleContactsStorage',
|
||||
etesync_calendars='vdirsyncer.storage.etesync.EtesyncCalendars',
|
||||
etesync_contacts='vdirsyncer.storage.etesync.EtesyncContacts'
|
||||
caldav="vdirsyncer.storage.dav.CalDAVStorage",
|
||||
carddav="vdirsyncer.storage.dav.CardDAVStorage",
|
||||
filesystem="vdirsyncer.storage.filesystem.FilesystemStorage",
|
||||
http="vdirsyncer.storage.http.HttpStorage",
|
||||
singlefile="vdirsyncer.storage.singlefile.SingleFileStorage",
|
||||
google_calendar="vdirsyncer.storage.google.GoogleCalendarStorage",
|
||||
google_contacts="vdirsyncer.storage.google.GoogleContactsStorage",
|
||||
etesync_calendars="vdirsyncer.storage.etesync.EtesyncCalendars",
|
||||
etesync_contacts="vdirsyncer.storage.etesync.EtesyncContacts",
|
||||
)
|
||||
|
||||
def __getitem__(self, name):
|
||||
|
|
@ -47,7 +47,7 @@ class _StorageIndex:
|
|||
if not isinstance(item, str):
|
||||
return item
|
||||
|
||||
modname, clsname = item.rsplit('.', 1)
|
||||
modname, clsname = item.rsplit(".", 1)
|
||||
mod = importlib.import_module(modname)
|
||||
self._storages[name] = rv = getattr(mod, clsname)
|
||||
assert rv.storage_name == name
|
||||
|
|
@ -63,12 +63,12 @@ class JobFailed(RuntimeError):
|
|||
|
||||
|
||||
def handle_cli_error(status_name=None, e=None):
|
||||
'''
|
||||
"""
|
||||
Print a useful error message for the current exception.
|
||||
|
||||
This is supposed to catch all exceptions, and should never raise any
|
||||
exceptions itself.
|
||||
'''
|
||||
"""
|
||||
|
||||
try:
|
||||
if e is not None:
|
||||
|
|
@ -80,101 +80,104 @@ def handle_cli_error(status_name=None, e=None):
|
|||
except StorageEmpty as e:
|
||||
cli_logger.error(
|
||||
'{status_name}: Storage "{name}" was completely emptied. If you '
|
||||
'want to delete ALL entries on BOTH sides, then use '
|
||||
'`vdirsyncer sync --force-delete {status_name}`. '
|
||||
'Otherwise delete the files for {status_name} in your status '
|
||||
'directory.'.format(
|
||||
name=e.empty_storage.instance_name,
|
||||
status_name=status_name
|
||||
"want to delete ALL entries on BOTH sides, then use "
|
||||
"`vdirsyncer sync --force-delete {status_name}`. "
|
||||
"Otherwise delete the files for {status_name} in your status "
|
||||
"directory.".format(
|
||||
name=e.empty_storage.instance_name, status_name=status_name
|
||||
)
|
||||
)
|
||||
except PartialSync as e:
|
||||
cli_logger.error(
|
||||
'{status_name}: Attempted change on {storage}, which is read-only'
|
||||
'. Set `partial_sync` in your pair section to `ignore` to ignore '
|
||||
'those changes, or `revert` to revert them on the other side.'
|
||||
.format(status_name=status_name, storage=e.storage)
|
||||
"{status_name}: Attempted change on {storage}, which is read-only"
|
||||
". Set `partial_sync` in your pair section to `ignore` to ignore "
|
||||
"those changes, or `revert` to revert them on the other side.".format(
|
||||
status_name=status_name, storage=e.storage
|
||||
)
|
||||
)
|
||||
except SyncConflict as e:
|
||||
cli_logger.error(
|
||||
'{status_name}: One item changed on both sides. Resolve this '
|
||||
'conflict manually, or by setting the `conflict_resolution` '
|
||||
'parameter in your config file.\n'
|
||||
'See also {docs}/config.html#pair-section\n'
|
||||
'Item ID: {e.ident}\n'
|
||||
'Item href on side A: {e.href_a}\n'
|
||||
'Item href on side B: {e.href_b}\n'
|
||||
.format(status_name=status_name, e=e, docs=DOCS_HOME)
|
||||
"{status_name}: One item changed on both sides. Resolve this "
|
||||
"conflict manually, or by setting the `conflict_resolution` "
|
||||
"parameter in your config file.\n"
|
||||
"See also {docs}/config.html#pair-section\n"
|
||||
"Item ID: {e.ident}\n"
|
||||
"Item href on side A: {e.href_a}\n"
|
||||
"Item href on side B: {e.href_b}\n".format(
|
||||
status_name=status_name, e=e, docs=DOCS_HOME
|
||||
)
|
||||
)
|
||||
except IdentConflict as e:
|
||||
cli_logger.error(
|
||||
'{status_name}: Storage "{storage.instance_name}" contains '
|
||||
'multiple items with the same UID or even content. Vdirsyncer '
|
||||
'will now abort the synchronization of this collection, because '
|
||||
'the fix for this is not clear; It could be the result of a badly '
|
||||
'behaving server. You can try running:\n\n'
|
||||
' vdirsyncer repair {storage.instance_name}\n\n'
|
||||
'But make sure to have a backup of your data in some form. The '
|
||||
'offending hrefs are:\n\n{href_list}\n'
|
||||
.format(status_name=status_name,
|
||||
storage=e.storage,
|
||||
href_list='\n'.join(map(repr, e.hrefs)))
|
||||
"multiple items with the same UID or even content. Vdirsyncer "
|
||||
"will now abort the synchronization of this collection, because "
|
||||
"the fix for this is not clear; It could be the result of a badly "
|
||||
"behaving server. You can try running:\n\n"
|
||||
" vdirsyncer repair {storage.instance_name}\n\n"
|
||||
"But make sure to have a backup of your data in some form. The "
|
||||
"offending hrefs are:\n\n{href_list}\n".format(
|
||||
status_name=status_name,
|
||||
storage=e.storage,
|
||||
href_list="\n".join(map(repr, e.hrefs)),
|
||||
)
|
||||
)
|
||||
except (click.Abort, KeyboardInterrupt, JobFailed):
|
||||
pass
|
||||
except exceptions.PairNotFound as e:
|
||||
cli_logger.error(
|
||||
'Pair {pair_name} does not exist. Please check your '
|
||||
'configuration file and make sure you\'ve typed the pair name '
|
||||
'correctly'.format(pair_name=e.pair_name)
|
||||
"Pair {pair_name} does not exist. Please check your "
|
||||
"configuration file and make sure you've typed the pair name "
|
||||
"correctly".format(pair_name=e.pair_name)
|
||||
)
|
||||
except exceptions.InvalidResponse as e:
|
||||
cli_logger.error(
|
||||
'The server returned something vdirsyncer doesn\'t understand. '
|
||||
'Error message: {!r}\n'
|
||||
'While this is most likely a serverside problem, the vdirsyncer '
|
||||
'devs are generally interested in such bugs. Please report it in '
|
||||
'the issue tracker at {}'
|
||||
.format(e, BUGTRACKER_HOME)
|
||||
"The server returned something vdirsyncer doesn't understand. "
|
||||
"Error message: {!r}\n"
|
||||
"While this is most likely a serverside problem, the vdirsyncer "
|
||||
"devs are generally interested in such bugs. Please report it in "
|
||||
"the issue tracker at {}".format(e, BUGTRACKER_HOME)
|
||||
)
|
||||
except exceptions.CollectionRequired:
|
||||
cli_logger.error(
|
||||
'One or more storages don\'t support `collections = null`. '
|
||||
"One or more storages don't support `collections = null`. "
|
||||
'You probably want to set `collections = ["from a", "from b"]`.'
|
||||
)
|
||||
except Exception as e:
|
||||
tb = sys.exc_info()[2]
|
||||
import traceback
|
||||
|
||||
tb = traceback.format_tb(tb)
|
||||
if status_name:
|
||||
msg = f'Unknown error occurred for {status_name}'
|
||||
msg = f"Unknown error occurred for {status_name}"
|
||||
else:
|
||||
msg = 'Unknown error occurred'
|
||||
msg = "Unknown error occurred"
|
||||
|
||||
msg += f': {e}\nUse `-vdebug` to see the full traceback.'
|
||||
msg += f": {e}\nUse `-vdebug` to see the full traceback."
|
||||
|
||||
cli_logger.error(msg)
|
||||
cli_logger.debug(''.join(tb))
|
||||
cli_logger.debug("".join(tb))
|
||||
|
||||
|
||||
def get_status_name(pair, collection):
|
||||
if collection is None:
|
||||
return pair
|
||||
return pair + '/' + collection
|
||||
return pair + "/" + collection
|
||||
|
||||
|
||||
def get_status_path(base_path, pair, collection=None, data_type=None):
|
||||
assert data_type is not None
|
||||
status_name = get_status_name(pair, collection)
|
||||
path = expand_path(os.path.join(base_path, status_name))
|
||||
if os.path.isfile(path) and data_type == 'items':
|
||||
new_path = path + '.items'
|
||||
if os.path.isfile(path) and data_type == "items":
|
||||
new_path = path + ".items"
|
||||
# XXX: Legacy migration
|
||||
cli_logger.warning('Migrating statuses: Renaming {} to {}'
|
||||
.format(path, new_path))
|
||||
cli_logger.warning(
|
||||
"Migrating statuses: Renaming {} to {}".format(path, new_path)
|
||||
)
|
||||
os.rename(path, new_path)
|
||||
|
||||
path += '.' + data_type
|
||||
path += "." + data_type
|
||||
return path
|
||||
|
||||
|
||||
|
|
@ -205,20 +208,20 @@ def prepare_status_path(path):
|
|||
|
||||
@contextlib.contextmanager
|
||||
def manage_sync_status(base_path, pair_name, collection_name):
|
||||
path = get_status_path(base_path, pair_name, collection_name, 'items')
|
||||
path = get_status_path(base_path, pair_name, collection_name, "items")
|
||||
status = None
|
||||
legacy_status = None
|
||||
try:
|
||||
# XXX: Legacy migration
|
||||
with open(path, 'rb') as f:
|
||||
if f.read(1) == b'{':
|
||||
with open(path, "rb") as f:
|
||||
if f.read(1) == b"{":
|
||||
f.seek(0)
|
||||
legacy_status = dict(json.load(f))
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
if legacy_status is not None:
|
||||
cli_logger.warning('Migrating legacy status to sqlite')
|
||||
cli_logger.warning("Migrating legacy status to sqlite")
|
||||
os.remove(path)
|
||||
status = SqliteStatus(path)
|
||||
status.load_legacy_status(legacy_status)
|
||||
|
|
@ -233,10 +236,10 @@ def save_status(base_path, pair, collection=None, data_type=None, data=None):
|
|||
assert data_type is not None
|
||||
assert data is not None
|
||||
status_name = get_status_name(pair, collection)
|
||||
path = expand_path(os.path.join(base_path, status_name)) + '.' + data_type
|
||||
path = expand_path(os.path.join(base_path, status_name)) + "." + data_type
|
||||
prepare_status_path(path)
|
||||
|
||||
with atomic_write(path, mode='w', overwrite=True) as f:
|
||||
with atomic_write(path, mode="w", overwrite=True) as f:
|
||||
json.dump(data, f)
|
||||
|
||||
os.chmod(path, STATUS_PERMISSIONS)
|
||||
|
|
@ -244,20 +247,19 @@ def save_status(base_path, pair, collection=None, data_type=None, data=None):
|
|||
|
||||
def storage_class_from_config(config):
|
||||
config = dict(config)
|
||||
storage_name = config.pop('type')
|
||||
storage_name = config.pop("type")
|
||||
try:
|
||||
cls = storage_names[storage_name]
|
||||
except KeyError:
|
||||
raise exceptions.UserError(
|
||||
f'Unknown storage type: {storage_name}')
|
||||
raise exceptions.UserError(f"Unknown storage type: {storage_name}")
|
||||
return cls, config
|
||||
|
||||
|
||||
def storage_instance_from_config(config, create=True):
|
||||
'''
|
||||
"""
|
||||
:param config: A configuration dictionary to pass as kwargs to the class
|
||||
corresponding to config['type']
|
||||
'''
|
||||
"""
|
||||
|
||||
cls, new_config = storage_class_from_config(config)
|
||||
|
||||
|
|
@ -266,7 +268,8 @@ def storage_instance_from_config(config, create=True):
|
|||
except exceptions.CollectionNotFound as e:
|
||||
if create:
|
||||
config = handle_collection_not_found(
|
||||
config, config.get('collection', None), e=str(e))
|
||||
config, config.get("collection", None), e=str(e)
|
||||
)
|
||||
return storage_instance_from_config(config, create=False)
|
||||
else:
|
||||
raise
|
||||
|
|
@ -276,7 +279,7 @@ def storage_instance_from_config(config, create=True):
|
|||
|
||||
def handle_storage_init_error(cls, config):
|
||||
e = sys.exc_info()[1]
|
||||
if not isinstance(e, TypeError) or '__init__' not in repr(e):
|
||||
if not isinstance(e, TypeError) or "__init__" not in repr(e):
|
||||
raise
|
||||
|
||||
all, required = get_storage_init_args(cls)
|
||||
|
|
@ -288,30 +291,34 @@ def handle_storage_init_error(cls, config):
|
|||
|
||||
if missing:
|
||||
problems.append(
|
||||
'{} storage requires the parameters: {}'
|
||||
.format(cls.storage_name, ', '.join(missing)))
|
||||
"{} storage requires the parameters: {}".format(
|
||||
cls.storage_name, ", ".join(missing)
|
||||
)
|
||||
)
|
||||
|
||||
if invalid:
|
||||
problems.append(
|
||||
'{} storage doesn\'t take the parameters: {}'
|
||||
.format(cls.storage_name, ', '.join(invalid)))
|
||||
"{} storage doesn't take the parameters: {}".format(
|
||||
cls.storage_name, ", ".join(invalid)
|
||||
)
|
||||
)
|
||||
|
||||
if not problems:
|
||||
raise e
|
||||
|
||||
raise exceptions.UserError(
|
||||
'Failed to initialize {}'.format(config['instance_name']),
|
||||
problems=problems
|
||||
"Failed to initialize {}".format(config["instance_name"]), problems=problems
|
||||
)
|
||||
|
||||
|
||||
class WorkerQueue:
|
||||
'''
|
||||
"""
|
||||
A simple worker-queue setup.
|
||||
|
||||
Note that workers quit if queue is empty. That means you have to first put
|
||||
things into the queue before spawning the worker!
|
||||
'''
|
||||
"""
|
||||
|
||||
def __init__(self, max_workers):
|
||||
self._queue = queue.Queue()
|
||||
self._workers = []
|
||||
|
|
@ -369,7 +376,7 @@ class WorkerQueue:
|
|||
if not self._workers:
|
||||
# Ugly hack, needed because ui_worker is not running.
|
||||
click.echo = _echo
|
||||
cli_logger.critical('Nothing to do.')
|
||||
cli_logger.critical("Nothing to do.")
|
||||
sys.exit(5)
|
||||
|
||||
ui_worker.run()
|
||||
|
|
@ -381,8 +388,9 @@ class WorkerQueue:
|
|||
tasks_done = next(self.num_done_tasks)
|
||||
|
||||
if tasks_failed > 0:
|
||||
cli_logger.error('{} out of {} tasks failed.'
|
||||
.format(tasks_failed, tasks_done))
|
||||
cli_logger.error(
|
||||
"{} out of {} tasks failed.".format(tasks_failed, tasks_done)
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
def put(self, f):
|
||||
|
|
@ -392,25 +400,30 @@ class WorkerQueue:
|
|||
def assert_permissions(path, wanted):
|
||||
permissions = os.stat(path).st_mode & 0o777
|
||||
if permissions > wanted:
|
||||
cli_logger.warning('Correcting permissions of {} from {:o} to {:o}'
|
||||
.format(path, permissions, wanted))
|
||||
cli_logger.warning(
|
||||
"Correcting permissions of {} from {:o} to {:o}".format(
|
||||
path, permissions, wanted
|
||||
)
|
||||
)
|
||||
os.chmod(path, wanted)
|
||||
|
||||
|
||||
def handle_collection_not_found(config, collection, e=None):
|
||||
storage_name = config.get('instance_name', None)
|
||||
storage_name = config.get("instance_name", None)
|
||||
|
||||
cli_logger.warning('{}No collection {} found for storage {}.'
|
||||
.format(f'{e}\n' if e else '',
|
||||
json.dumps(collection), storage_name))
|
||||
cli_logger.warning(
|
||||
"{}No collection {} found for storage {}.".format(
|
||||
f"{e}\n" if e else "", json.dumps(collection), storage_name
|
||||
)
|
||||
)
|
||||
|
||||
if click.confirm('Should vdirsyncer attempt to create it?'):
|
||||
storage_type = config['type']
|
||||
if click.confirm("Should vdirsyncer attempt to create it?"):
|
||||
storage_type = config["type"]
|
||||
cls, config = storage_class_from_config(config)
|
||||
config['collection'] = collection
|
||||
config["collection"] = collection
|
||||
try:
|
||||
args = cls.create_collection(**config)
|
||||
args['type'] = storage_type
|
||||
args["type"] = storage_type
|
||||
return args
|
||||
except NotImplementedError as e:
|
||||
cli_logger.error(e)
|
||||
|
|
@ -418,5 +431,5 @@ def handle_collection_not_found(config, collection, e=None):
|
|||
raise exceptions.UserError(
|
||||
'Unable to find or create collection "{collection}" for '
|
||||
'storage "{storage}". Please create the collection '
|
||||
'yourself.'.format(collection=collection,
|
||||
storage=storage_name))
|
||||
"yourself.".format(collection=collection, storage=storage_name)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,80 +1,81 @@
|
|||
'''
|
||||
"""
|
||||
Contains exception classes used by vdirsyncer. Not all exceptions are here,
|
||||
only the most commonly used ones.
|
||||
'''
|
||||
"""
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
'''Baseclass for all errors.'''
|
||||
"""Baseclass for all errors."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
for key, value in kwargs.items():
|
||||
if getattr(self, key, object()) is not None: # pragma: no cover
|
||||
raise TypeError(f'Invalid argument: {key}')
|
||||
raise TypeError(f"Invalid argument: {key}")
|
||||
setattr(self, key, value)
|
||||
|
||||
super().__init__(*args)
|
||||
|
||||
|
||||
class UserError(Error, ValueError):
|
||||
'''Wrapper exception to be used to signify the traceback should not be
|
||||
shown to the user.'''
|
||||
"""Wrapper exception to be used to signify the traceback should not be
|
||||
shown to the user."""
|
||||
|
||||
problems = None
|
||||
|
||||
def __str__(self):
|
||||
msg = Error.__str__(self)
|
||||
for problem in self.problems or ():
|
||||
msg += f'\n - {problem}'
|
||||
msg += f"\n - {problem}"
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
class CollectionNotFound(Error):
|
||||
'''Collection not found'''
|
||||
"""Collection not found"""
|
||||
|
||||
|
||||
class PairNotFound(Error):
|
||||
'''Pair not found'''
|
||||
"""Pair not found"""
|
||||
|
||||
pair_name = None
|
||||
|
||||
|
||||
class PreconditionFailed(Error):
|
||||
'''
|
||||
"""
|
||||
- The item doesn't exist although it should
|
||||
- The item exists although it shouldn't
|
||||
- The etags don't match.
|
||||
|
||||
Due to CalDAV we can't actually say which error it is.
|
||||
This error may indicate race conditions.
|
||||
'''
|
||||
"""
|
||||
|
||||
|
||||
class NotFoundError(PreconditionFailed):
|
||||
'''Item not found'''
|
||||
"""Item not found"""
|
||||
|
||||
|
||||
class AlreadyExistingError(PreconditionFailed):
|
||||
'''Item already exists.'''
|
||||
"""Item already exists."""
|
||||
|
||||
existing_href = None
|
||||
|
||||
|
||||
class WrongEtagError(PreconditionFailed):
|
||||
'''Wrong etag'''
|
||||
"""Wrong etag"""
|
||||
|
||||
|
||||
class ReadOnlyError(Error):
|
||||
'''Storage is read-only.'''
|
||||
"""Storage is read-only."""
|
||||
|
||||
|
||||
class InvalidResponse(Error, ValueError):
|
||||
'''The backend returned an invalid result.'''
|
||||
"""The backend returned an invalid result."""
|
||||
|
||||
|
||||
class UnsupportedMetadataError(Error, NotImplementedError):
|
||||
'''The storage doesn't support this type of metadata.'''
|
||||
"""The storage doesn't support this type of metadata."""
|
||||
|
||||
|
||||
class CollectionRequired(Error):
|
||||
'''`collection = null` is not allowed.'''
|
||||
"""`collection = null` is not allowed."""
|
||||
|
|
|
|||
|
|
@ -9,22 +9,23 @@ from .utils import expand_path
|
|||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
USERAGENT = f'vdirsyncer/{__version__}'
|
||||
USERAGENT = f"vdirsyncer/{__version__}"
|
||||
|
||||
|
||||
def _detect_faulty_requests(): # pragma: no cover
|
||||
text = (
|
||||
'Error during import: {e}\n\n'
|
||||
'If you have installed vdirsyncer from a distro package, please file '
|
||||
'a bug against that package, not vdirsyncer.\n\n'
|
||||
'Consult {d}/problems.html#requests-related-importerrors'
|
||||
'-based-distributions on how to work around this.'
|
||||
"Error during import: {e}\n\n"
|
||||
"If you have installed vdirsyncer from a distro package, please file "
|
||||
"a bug against that package, not vdirsyncer.\n\n"
|
||||
"Consult {d}/problems.html#requests-related-importerrors"
|
||||
"-based-distributions on how to work around this."
|
||||
)
|
||||
|
||||
try:
|
||||
from requests_toolbelt.auth.guess import GuessAuth # noqa
|
||||
except ImportError as e:
|
||||
import sys
|
||||
|
||||
print(text.format(e=str(e), d=DOCS_HOME), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
|
@ -35,28 +36,30 @@ del _detect_faulty_requests
|
|||
|
||||
def prepare_auth(auth, username, password):
|
||||
if username and password:
|
||||
if auth == 'basic' or auth is None:
|
||||
if auth == "basic" or auth is None:
|
||||
return (username, password)
|
||||
elif auth == 'digest':
|
||||
elif auth == "digest":
|
||||
from requests.auth import HTTPDigestAuth
|
||||
|
||||
return HTTPDigestAuth(username, password)
|
||||
elif auth == 'guess':
|
||||
elif auth == "guess":
|
||||
try:
|
||||
from requests_toolbelt.auth.guess import GuessAuth
|
||||
except ImportError:
|
||||
raise exceptions.UserError(
|
||||
'Your version of requests_toolbelt is too '
|
||||
'old for `guess` authentication. At least '
|
||||
'version 0.4.0 is required.'
|
||||
"Your version of requests_toolbelt is too "
|
||||
"old for `guess` authentication. At least "
|
||||
"version 0.4.0 is required."
|
||||
)
|
||||
else:
|
||||
return GuessAuth(username, password)
|
||||
else:
|
||||
raise exceptions.UserError('Unknown authentication method: {}'
|
||||
.format(auth))
|
||||
raise exceptions.UserError("Unknown authentication method: {}".format(auth))
|
||||
elif auth:
|
||||
raise exceptions.UserError('You need to specify username and password '
|
||||
'for {} authentication.'.format(auth))
|
||||
raise exceptions.UserError(
|
||||
"You need to specify username and password "
|
||||
"for {} authentication.".format(auth)
|
||||
)
|
||||
else:
|
||||
return None
|
||||
|
||||
|
|
@ -65,24 +68,26 @@ def prepare_verify(verify, verify_fingerprint):
|
|||
if isinstance(verify, (str, bytes)):
|
||||
verify = expand_path(verify)
|
||||
elif not isinstance(verify, bool):
|
||||
raise exceptions.UserError('Invalid value for verify ({}), '
|
||||
'must be a path to a PEM-file or boolean.'
|
||||
.format(verify))
|
||||
raise exceptions.UserError(
|
||||
"Invalid value for verify ({}), "
|
||||
"must be a path to a PEM-file or boolean.".format(verify)
|
||||
)
|
||||
|
||||
if verify_fingerprint is not None:
|
||||
if not isinstance(verify_fingerprint, (bytes, str)):
|
||||
raise exceptions.UserError('Invalid value for verify_fingerprint '
|
||||
'({}), must be a string or null.'
|
||||
.format(verify_fingerprint))
|
||||
raise exceptions.UserError(
|
||||
"Invalid value for verify_fingerprint "
|
||||
"({}), must be a string or null.".format(verify_fingerprint)
|
||||
)
|
||||
elif not verify:
|
||||
raise exceptions.UserError(
|
||||
'Disabling all SSL validation is forbidden. Consider setting '
|
||||
'verify_fingerprint if you have a broken or self-signed cert.'
|
||||
"Disabling all SSL validation is forbidden. Consider setting "
|
||||
"verify_fingerprint if you have a broken or self-signed cert."
|
||||
)
|
||||
|
||||
return {
|
||||
'verify': verify,
|
||||
'verify_fingerprint': verify_fingerprint,
|
||||
"verify": verify,
|
||||
"verify_fingerprint": verify_fingerprint,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -95,22 +100,24 @@ def prepare_client_cert(cert):
|
|||
|
||||
|
||||
def _install_fingerprint_adapter(session, fingerprint):
|
||||
prefix = 'https://'
|
||||
prefix = "https://"
|
||||
try:
|
||||
from requests_toolbelt.adapters.fingerprint import \
|
||||
FingerprintAdapter
|
||||
from requests_toolbelt.adapters.fingerprint import FingerprintAdapter
|
||||
except ImportError:
|
||||
raise RuntimeError('`verify_fingerprint` can only be used with '
|
||||
'requests-toolbelt versions >= 0.4.0')
|
||||
raise RuntimeError(
|
||||
"`verify_fingerprint` can only be used with "
|
||||
"requests-toolbelt versions >= 0.4.0"
|
||||
)
|
||||
|
||||
if not isinstance(session.adapters[prefix], FingerprintAdapter):
|
||||
fingerprint_adapter = FingerprintAdapter(fingerprint)
|
||||
session.mount(prefix, fingerprint_adapter)
|
||||
|
||||
|
||||
def request(method, url, session=None, latin1_fallback=True,
|
||||
verify_fingerprint=None, **kwargs):
|
||||
'''
|
||||
def request(
|
||||
method, url, session=None, latin1_fallback=True, verify_fingerprint=None, **kwargs
|
||||
):
|
||||
"""
|
||||
Wrapper method for requests, to ease logging and mocking. Parameters should
|
||||
be the same as for ``requests.request``, except:
|
||||
|
||||
|
|
@ -123,7 +130,7 @@ def request(method, url, session=None, latin1_fallback=True,
|
|||
autodetection (usually ending up with utf8) instead of plainly falling
|
||||
back to this silly default. See
|
||||
https://github.com/kennethreitz/requests/issues/2042
|
||||
'''
|
||||
"""
|
||||
|
||||
if session is None:
|
||||
session = requests.Session()
|
||||
|
|
@ -131,25 +138,28 @@ def request(method, url, session=None, latin1_fallback=True,
|
|||
if verify_fingerprint is not None:
|
||||
_install_fingerprint_adapter(session, verify_fingerprint)
|
||||
|
||||
session.hooks = dict(response=_fix_redirects)
|
||||
session.hooks = {"response": _fix_redirects}
|
||||
|
||||
func = session.request
|
||||
|
||||
logger.debug(f'{method} {url}')
|
||||
logger.debug(kwargs.get('headers', {}))
|
||||
logger.debug(kwargs.get('data', None))
|
||||
logger.debug('Sending request...')
|
||||
logger.debug("=" * 20)
|
||||
logger.debug(f"{method} {url}")
|
||||
logger.debug(kwargs.get("headers", {}))
|
||||
logger.debug(kwargs.get("data", None))
|
||||
logger.debug("Sending request...")
|
||||
|
||||
assert isinstance(kwargs.get('data', b''), bytes)
|
||||
assert isinstance(kwargs.get("data", b""), bytes)
|
||||
|
||||
r = func(method, url, **kwargs)
|
||||
|
||||
# See https://github.com/kennethreitz/requests/issues/2042
|
||||
content_type = r.headers.get('Content-Type', '')
|
||||
if not latin1_fallback and \
|
||||
'charset' not in content_type and \
|
||||
content_type.startswith('text/'):
|
||||
logger.debug('Removing latin1 fallback')
|
||||
content_type = r.headers.get("Content-Type", "")
|
||||
if (
|
||||
not latin1_fallback
|
||||
and "charset" not in content_type
|
||||
and content_type.startswith("text/")
|
||||
):
|
||||
logger.debug("Removing latin1 fallback")
|
||||
r.encoding = None
|
||||
|
||||
logger.debug(r.status_code)
|
||||
|
|
@ -166,7 +176,7 @@ def request(method, url, session=None, latin1_fallback=True,
|
|||
|
||||
|
||||
def _fix_redirects(r, *args, **kwargs):
|
||||
'''
|
||||
"""
|
||||
Requests discards of the body content when it is following a redirect that
|
||||
is not a 307 or 308. We never want that to happen.
|
||||
|
||||
|
|
@ -177,7 +187,7 @@ def _fix_redirects(r, *args, **kwargs):
|
|||
|
||||
FIXME: This solution isn't very nice. A new hook in requests would be
|
||||
better.
|
||||
'''
|
||||
"""
|
||||
if r.is_redirect:
|
||||
logger.debug('Rewriting status code from %s to 307', r.status_code)
|
||||
logger.debug("Rewriting status code from %s to 307", r.status_code)
|
||||
r.status_code = 307
|
||||
|
|
|
|||
|
|
@ -16,39 +16,37 @@ class MetaSyncConflict(MetaSyncError):
|
|||
|
||||
def metasync(storage_a, storage_b, status, keys, conflict_resolution=None):
|
||||
def _a_to_b():
|
||||
logger.info(f'Copying {key} to {storage_b}')
|
||||
logger.info(f"Copying {key} to {storage_b}")
|
||||
storage_b.set_meta(key, a)
|
||||
status[key] = a
|
||||
|
||||
def _b_to_a():
|
||||
logger.info(f'Copying {key} to {storage_a}')
|
||||
logger.info(f"Copying {key} to {storage_a}")
|
||||
storage_a.set_meta(key, b)
|
||||
status[key] = b
|
||||
|
||||
def _resolve_conflict():
|
||||
if a == b:
|
||||
status[key] = a
|
||||
elif conflict_resolution == 'a wins':
|
||||
elif conflict_resolution == "a wins":
|
||||
_a_to_b()
|
||||
elif conflict_resolution == 'b wins':
|
||||
elif conflict_resolution == "b wins":
|
||||
_b_to_a()
|
||||
else:
|
||||
if callable(conflict_resolution):
|
||||
logger.warning('Custom commands don\'t work on metasync.')
|
||||
logger.warning("Custom commands don't work on metasync.")
|
||||
elif conflict_resolution is not None:
|
||||
raise exceptions.UserError(
|
||||
'Invalid conflict resolution setting.'
|
||||
)
|
||||
raise exceptions.UserError("Invalid conflict resolution setting.")
|
||||
raise MetaSyncConflict(key)
|
||||
|
||||
for key in keys:
|
||||
a = storage_a.get_meta(key)
|
||||
b = storage_b.get_meta(key)
|
||||
s = normalize_meta_value(status.get(key))
|
||||
logger.debug(f'Key: {key}')
|
||||
logger.debug(f'A: {a}')
|
||||
logger.debug(f'B: {b}')
|
||||
logger.debug(f'S: {s}')
|
||||
logger.debug(f"Key: {key}")
|
||||
logger.debug(f"A: {a}")
|
||||
logger.debug(f"B: {b}")
|
||||
logger.debug(f"S: {s}")
|
||||
|
||||
if a != s and b != s:
|
||||
_resolve_conflict()
|
||||
|
|
|
|||
|
|
@ -16,17 +16,17 @@ def repair_storage(storage, repair_unsafe_uid):
|
|||
all_hrefs = list(storage.list())
|
||||
for i, (href, _) in enumerate(all_hrefs):
|
||||
item, etag = storage.get(href)
|
||||
logger.info('[{}/{}] Processing {}'
|
||||
.format(i, len(all_hrefs), href))
|
||||
logger.info("[{}/{}] Processing {}".format(i, len(all_hrefs), href))
|
||||
|
||||
try:
|
||||
new_item = repair_item(href, item, seen_uids, repair_unsafe_uid)
|
||||
except IrreparableItem:
|
||||
logger.error('Item {!r} is malformed beyond repair. '
|
||||
'The PRODID property may indicate which software '
|
||||
'created this item.'
|
||||
.format(href))
|
||||
logger.error(f'Item content: {item.raw!r}')
|
||||
logger.error(
|
||||
"Item {!r} is malformed beyond repair. "
|
||||
"The PRODID property may indicate which software "
|
||||
"created this item.".format(href)
|
||||
)
|
||||
logger.error(f"Item content: {item.raw!r}")
|
||||
continue
|
||||
|
||||
seen_uids.add(new_item.uid)
|
||||
|
|
@ -45,17 +45,18 @@ def repair_item(href, item, seen_uids, repair_unsafe_uid):
|
|||
new_item = item
|
||||
|
||||
if not item.uid:
|
||||
logger.warning('No UID, assigning random UID.')
|
||||
logger.warning("No UID, assigning random UID.")
|
||||
new_item = item.with_uid(generate_href())
|
||||
elif item.uid in seen_uids:
|
||||
logger.warning('Duplicate UID, assigning random UID.')
|
||||
logger.warning("Duplicate UID, assigning random UID.")
|
||||
new_item = item.with_uid(generate_href())
|
||||
elif not href_safe(item.uid) or not href_safe(basename(href)):
|
||||
if not repair_unsafe_uid:
|
||||
logger.warning('UID may cause problems, add '
|
||||
'--repair-unsafe-uid to repair.')
|
||||
logger.warning(
|
||||
"UID may cause problems, add " "--repair-unsafe-uid to repair."
|
||||
)
|
||||
else:
|
||||
logger.warning('UID or href is unsafe, assigning random UID.')
|
||||
logger.warning("UID or href is unsafe, assigning random UID.")
|
||||
new_item = item.with_uid(generate_href())
|
||||
|
||||
if not new_item.uid:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'''
|
||||
"""
|
||||
There are storage classes which control the access to one vdir-collection and
|
||||
offer basic CRUD-ish methods for modifying those collections. The exact
|
||||
interface is described in `vdirsyncer.storage.base`, the `Storage` class should
|
||||
be a superclass of all storage classes.
|
||||
'''
|
||||
"""
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue