Compare commits

..

53 commits

Author SHA1 Message Date
Markus Unterwaditzer
535911c9fd Remove unsupported zesty 2018-02-14 19:44:53 +01:00
Markus Unterwaditzer
8f2734c33e
Singlefile storage in rust (#698)
* Singlefile storage in rust

* add NOW

* Avoid global item
2018-02-14 19:15:11 +01:00
Markus Unterwaditzer
4d3860d449
Test radicale and xandikos again (#715) 2018-02-10 16:11:06 +01:00
Markus Unterwaditzer
9c3a2b48e9 Unify badges 2018-02-09 20:53:14 +01:00
Markus Unterwaditzer
2a2457e364
CI refactor (#713)
* Switch to CircleCI

* add circleci badge
2018-02-09 20:50:48 +01:00
Hugo Osvaldo Barrera
855f29cc35 Update link to official Arch package (#710)
There's now an official Arch package
2018-02-06 09:25:33 +01:00
Markus Unterwaditzer
cc37e6a312 Merge branch '0.16-maintenance' 2018-02-05 17:01:46 +01:00
Markus Unterwaditzer
01573f0d66 Merge branch '0.16-maintenance' 2018-02-05 15:54:17 +01:00
Markus Unterwaditzer
c1aec4527c Remove useless path change 2018-01-23 23:16:37 +01:00
Markus Unterwaditzer
b1ec9c26c7 Fix unused formatting string 2018-01-22 01:02:44 +01:00
Markus Unterwaditzer
82f47737a0 Revert use of hypothesis 2018-01-21 23:23:08 +01:00
Markus Unterwaditzer
45d76c889c Remove remotestorage leftovers 2018-01-21 20:51:30 +01:00
Markus Unterwaditzer
c92b4f38eb Update copyright year 2018-01-21 00:11:24 +01:00
Markus Unterwaditzer
47b2a43a0e Disable davical 2018-01-19 11:18:46 +01:00
Markus Unterwaditzer
2d0527ecf0 Skip davical test skipper 2018-01-19 11:17:58 +01:00
Markus Unterwaditzer
991076d12a stylefixes 2018-01-18 23:30:47 +01:00
Markus Unterwaditzer
f58f06d2b5 Remove hypothesis from system test 2018-01-18 23:25:49 +01:00
Markus Unterwaditzer
b1cddde635 Remove baikal and owncloud from docs, see #489 2018-01-18 23:18:42 +01:00
Markus Unterwaditzer
41f64e2dca
Dockerize nextcloud (#704)
* Dockerize nextcloud

* Remove ownCloud and baikal, fix #489

* Remove branch from travis conf
2018-01-18 23:10:53 +01:00
Markus Unterwaditzer
401c441acb Add slowest tests to testrun 2018-01-15 21:23:09 +01:00
Markus Unterwaditzer
f1310883b9 Screw git hooks 2018-01-05 18:25:00 +01:00
Markus Unterwaditzer
afa8031eec Improve handling of malformed items 2018-01-05 18:14:32 +01:00
Markus Unterwaditzer
50604f24f1 Add simple doc for todoman 2018-01-05 16:34:26 +01:00
Amanda Hickman
cd6cb92b59 Little spelling fix (#695)
* Fixed spelling of "occurred"

* Fix spelling of occurred.

* fixed one lingering misspelling
2018-01-03 15:52:55 +01:00
Markus Unterwaditzer
39c2df99eb Update legalities 2017-12-25 21:50:29 +01:00
Markus Unterwaditzer
7fdff404e6 No wheels 2017-12-04 20:16:29 +01:00
Markus Unterwaditzer
1bdde25c0c Fix etesync build 2017-12-04 19:52:02 +01:00
Markus Unterwaditzer
b32932bd13 Relax recurrence tests 2017-12-03 14:00:21 +01:00
Markus Unterwaditzer
22d009b824 Remove unnecessary filter 2017-11-27 19:52:15 +01:00
Markus Unterwaditzer
792dbc171f Fix missing XML header, see #688 2017-11-25 14:15:14 +01:00
Markus Unterwaditzer
5700c4688b
rustup (#686)
* rustup

* rust-vobject upgrade
2017-11-07 21:58:17 +01:00
Markus Unterwaditzer
3984f547ce
Update nextcloud (#684) 2017-11-05 15:59:42 +01:00
Markus Unterwaditzer
9769dab02e
Update owncloud (#685) 2017-11-05 15:59:34 +01:00
Markus Unterwaditzer
bd2e09a84b Small refactor in dav.py 2017-10-26 02:22:18 +02:00
Markus Unterwaditzer
f7b6e67095 Ignore new flake8 linters 2017-10-26 01:41:43 +02:00
Markus Unterwaditzer
a2c509adf5 rustup, fix broken struct export 2017-10-25 22:36:28 +02:00
Markus Unterwaditzer
28fdf42238 Fix #681 2017-10-21 17:23:41 +02:00
Markus Unterwaditzer
0d3b028b17 Cache rust artifacts 2017-10-19 23:47:20 +02:00
Markus Unterwaditzer
f8e65878d8 Update rust installation instructions 2017-10-19 23:41:43 +02:00
Markus Unterwaditzer
75e83cd0f6 Commit cargo.lock 2017-10-19 23:27:29 +02:00
Malte Kiefer
96a8ab35c3 fixed typo (#678)
fixed typo
2017-10-13 19:34:37 +02:00
Markus Unterwaditzer
619373a8e8 Rust: new item module 2017-10-11 13:53:10 +02:00
Markus Unterwaditzer
cbb15e1895 Move all target to top again 2017-10-11 13:28:00 +02:00
Markus Unterwaditzer
325304c50f Lazy-load component in item 2017-10-11 12:01:52 +02:00
Markus Unterwaditzer
bdbfc360ff Move item hashing into rust 2017-10-10 00:52:58 +02:00
Markus Unterwaditzer
c17fa308fb Adapt virtualenv steps to always select python3 2017-10-06 18:32:17 +02:00
Markus Unterwaditzer
81f7472e3a Update installation instructions for Rust dependencies 2017-10-06 18:30:10 +02:00
Markus Unterwaditzer
69543b8615 Install rust on readthedocs 2017-10-05 17:45:19 +02:00
Markus Unterwaditzer
1b7cb4e656 Use rust-vobject (#675)
Use rust-vobject
2017-10-04 22:41:18 +02:00
Markus Unterwaditzer
7bdb22a207 Fix Ubuntu package name of Python 3. 2017-10-03 22:48:13 +02:00
Markus Unterwaditzer
cb41a9df28 Add fast_finish to Travis 2017-10-03 20:59:43 +02:00
Markus Unterwaditzer
33f96f5eca Fix broken link 2017-10-03 13:13:44 +02:00
Markus Unterwaditzer
178ac237ad Fix installation link 2017-10-03 11:29:51 +02:00
151 changed files with 7476 additions and 7116 deletions

View file

@ -1,49 +0,0 @@
# Run tests using the packaged dependencies on ArchLinux.
image: archlinux
packages:
- docker
- docker-compose
# Build dependencies:
- python-wheel
- python-build
- python-installer
- python-setuptools-scm
# Runtime dependencies:
- python-click
- python-click-log
- python-click-threading
- python-requests
- python-aiohttp-oauthlib
- python-tenacity
# Test dependencies:
- python-hypothesis
- python-pytest-cov
- python-pytest-httpserver
- python-trustme
- python-pytest-asyncio
- python-aiohttp
- python-aiostream
- python-aioresponses
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:
- check-python:
python --version | grep 'Python 3.13'
- docker: |
sudo systemctl start docker
- setup: |
cd vdirsyncer
python -m build --wheel --skip-dependency-check --no-isolation
sudo python -m installer dist/*.whl
- test: |
cd vdirsyncer
make -e ci-test
make -e ci-test-storage

View file

@ -1,36 +0,0 @@
# 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: alpine/3.19 # python 3.11
packages:
- docker
- docker-cli
- docker-compose
- py3-pip
- python3-dev
sources:
- https://github.com/pimutils/vdirsyncer
environment:
BUILD: test
CI: true
CODECOV_TOKEN: b834a3c5-28fa-4808-9bdb-182210069c79
DAV_SERVER: radicale xandikos
REQUIREMENTS: minimal
tasks:
- venv: |
python3 -m venv $HOME/venv
echo "export PATH=$HOME/venv/bin:$PATH" >> $HOME/.buildenv
- docker: |
sudo addgroup $(whoami) docker
sudo service docker start
- setup: |
cd vdirsyncer
# Hack, no idea why it's needed
sudo ln -s /usr/include/python3.11/cpython/longintrepr.h /usr/include/python3.11/longintrepr.h
make -e install-dev
- test: |
cd vdirsyncer
make -e ci-test
make -e ci-test-storage

View file

@ -1,45 +0,0 @@
# Run tests using latest dependencies from PyPI
image: archlinux
packages:
- docker
- docker-compose
- python-pip
sources:
- https://github.com/pimutils/vdirsyncer
secrets:
- 4d9a6dfe-5c8d-48bd-b864-a2f5d772c536
environment:
BUILD: test
CI: true
CODECOV_TOKEN: b834a3c5-28fa-4808-9bdb-182210069c79
DAV_SERVER: baikal radicale xandikos
REQUIREMENTS: release
# TODO: ETESYNC_TESTS
tasks:
- venv: |
python -m venv $HOME/venv
echo "export PATH=$HOME/venv/bin:$PATH" >> $HOME/.buildenv
- docker: |
sudo systemctl start docker
- setup: |
cd vdirsyncer
make -e install-dev
- test: |
cd vdirsyncer
make -e ci-test
make -e ci-test-storage
- check: |
cd vdirsyncer
make check
- check-secrets: |
# Stop here if this is a PR. PRs can't run with the below secrets.
[ -f ~/fastmail-secrets ] || complete-build
- extra-storages: |
set +x
source ~/fastmail-secrets
set -x
cd vdirsyncer
export PATH=$PATH:~/.local/bin/
DAV_SERVER=fastmail pytest tests/storage

199
.circleci/config.yml Normal file
View file

@ -0,0 +1,199 @@
version: 2
references:
basic_env: &basic_env
CI: true
DAV_SERVER: xandikos
restore_caches: &restore_caches
restore_cache:
keys:
- cache-{{ arch }}-{{ .Branch }}
save_caches: &save_caches
save_cache:
key: cache-{{ arch }}-{{ .Branch }}
paths:
- "rust/target/"
- "~/.cargo/"
- "~/.cache/pip/"
- "~/.rustup/"
basic_setup: &basic_setup
run: . scripts/circleci-install.sh
jobs:
nextcloud:
docker:
- image: circleci/python:3.6
environment:
<<: *basic_env
NEXTCLOUD_HOST: localhost:80
DAV_SERVER: nextcloud
- image: nextcloud
environment:
SQLITE_DATABASE: nextcloud
NEXTCLOUD_ADMIN_USER: asdf
NEXTCLOUD_ADMIN_PASSWORD: asdf
steps:
- checkout
- *restore_caches
- *basic_setup
- *save_caches
- run: wget -O - --retry-connrefused http://localhost:80/
- run: make -e storage-test
fastmail:
docker:
- image: circleci/python:3.6
environment:
<<: *basic_env
DAV_SERVER: fastmail
steps:
- checkout
- *restore_caches
- *basic_setup
- *save_caches
- run: make -e storage-test
style:
docker:
- image: circleci/python:3.6
environment:
<<: *basic_env
steps:
- checkout
- *restore_caches
- *basic_setup
- *save_caches
- run: make -e style
py34-minimal:
docker:
- image: circleci/python:3.4
environment:
<<: *basic_env
REQUIREMENTS: minimal
steps:
- checkout
- *restore_caches
- *basic_setup
- *save_caches
- run: make -e test
py34-release:
docker:
- image: circleci/python:3.4
environment:
<<: *basic_env
REQUIREMENTS: release
steps:
- checkout
- *restore_caches
- *basic_setup
- *save_caches
- run: make -e test
py34-devel:
docker:
- image: circleci/python:3.4
environment:
<<: *basic_env
REQUIREMENTS: devel
steps:
- checkout
- *restore_caches
- *basic_setup
- *save_caches
- run: make -e test
py36-minimal:
docker:
- image: circleci/python:3.6
environment:
<<: *basic_env
REQUIREMENTS: minimal
steps:
- checkout
- *restore_caches
- *basic_setup
- *save_caches
- run: make -e test
py36-release:
docker:
- image: circleci/python:3.6
environment:
<<: *basic_env
REQUIREMENTS: release
steps:
- checkout
- *restore_caches
- *basic_setup
- *save_caches
- run: make -e test
py36-release-radicale:
docker:
- image: circleci/python:3.6
environment:
<<: *basic_env
REQUIREMENTS: release
DAV_SERVER: radicale
steps:
- checkout
- *restore_caches
- *basic_setup
- *save_caches
- run: make -e test
py36-devel:
docker:
- image: circleci/python:3.6
environment:
<<: *basic_env
REQUIREMENTS: devel
steps:
- checkout
- *restore_caches
- *basic_setup
- *save_caches
- run: make -e test
rust:
docker:
- image: circleci/python:3.6
environment:
<<: *basic_env
REQUIREMENTS: release
steps:
- checkout
- *restore_caches
- *basic_setup
- *save_caches
- run: make -e rust-test
workflows:
version: 2
test_all:
jobs:
- nextcloud
- fastmail
- style
- py34-minimal
- py34-release
- py34-devel
- py36-minimal
- py36-release
- py36-devel
- rust

View file

@ -2,3 +2,10 @@ comment: false
coverage:
status:
patch: false
project:
unit:
flags: unit
system:
flags: system
storage:
flags: storage

1
.envrc
View file

@ -1 +0,0 @@
layout python3

3
.gitignore vendored
View file

@ -13,5 +13,6 @@ env
dist
docs/_build/
vdirsyncer/version.py
vdirsyncer/_native*
.hypothesis
coverage.xml
codecov.sh

0
.gitmodules vendored Normal file
View file

View file

@ -1,39 +0,0 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
- id: end-of-file-fixer
- id: check-toml
- id: check-added-large-files
- id: debug-statements
- repo: https://github.com/pre-commit/mirrors-mypy
rev: "v1.15.0"
hooks:
- id: mypy
files: vdirsyncer/.*
additional_dependencies:
- types-setuptools
- types-docutils
- types-requests
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: 'v0.11.4'
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
- repo: local
hooks:
- id: typos-syncroniz
name: typos-syncroniz
language: system
# Not how you spell "synchronise"
entry: sh -c "git grep -i syncroniz"
files: ".*/.*"
- id: typos-text-icalendar
name: typos-text-icalendar
language: system
# It's "text/calendar", no "i".
entry: sh -c "git grep -i 'text/icalendar'"
files: ".*/.*"

View file

@ -1,16 +0,0 @@
version: 2
sphinx:
configuration: docs/conf.py
build:
os: "ubuntu-22.04"
tools:
python: "3.9"
python:
install:
- method: pip
path: .
extra_requirements:
- docs

View file

@ -4,26 +4,15 @@ Contributors
In alphabetical order:
- Ben Boeckel
- Bleala
- Christian Geier
- Clément Mondon
- Corey Hinshaw
- Kai Herlemann
- Hugo Osvaldo Barrera
- Jason Cox
- Julian Mehne
- Malte Kiefer
- Marek Marczykowski-Górecki
- Markus Unterwaditzer
- Michael Adler
- rEnr3n
- Thomas Weißschuh
- Witcher01
- samm81
Special thanks goes to:
* `FastMail <https://github.com/pimutils/vdirsyncer/issues/571>`_ sponsors a
paid account for testing their servers.
* `Packagecloud <https://packagecloud.io/>`_ provide repositories for
vdirsyncer's Debian packages.
Additionally `FastMail sponsored a paid account for testing
<https://github.com/pimutils/vdirsyncer/issues/571>`_. Thanks!

View file

@ -9,158 +9,12 @@ 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.21.0
Version 0.17.0
==============
- Implement retrying for ``google`` storage type when a rate limit is reached.
- ``tenacity`` is now a required dependency.
- Drop support for Python 3.8.
- Retry transient network errors for nullipotent requests.
Version 0.20.0
==============
- Remove dependency on abandoned ``atomicwrites`` library.
- Implement ``filter_hook`` for the HTTP storage.
- Drop support for Python 3.7.
- Add support for Python 3.12 and Python 3.13.
- Properly close the status database after using. This especially affects tests,
where we were leaking a large amount of file descriptors.
- Extend supported versions of ``aiostream`` to include 0.7.x.
Version 0.19.3
==============
- Added a no_delete option to the storage configuration. :gh:`1090`
- Fix crash when running ``vdirsyncer repair`` on a collection. :gh:`1019`
- Add an option to request vCard v4.0. :gh:`1066`
- Require matching ``BEGIN`` and ``END`` lines in vobjects. :gh:`1103`
- A Docker environment for Vdirsyncer has been added `Vdirsyncer DOCKERIZED <https://github.com/Bleala/Vdirsyncer-DOCKERIZED>`_.
- Implement digest auth. :gh:`1137`
- Add ``filter_hook`` parameter to :storage:`http`. :gh:`1136`
Version 0.19.2
==============
- Improve the performance of ``SingleFileStorage``. :gh:`818`
- Properly document some caveats of the Google Contacts storage.
- Fix crash when using auth certs. :gh:`1033`
- The ``filesystem`` storage can be specified with ``type =
"filesystem/icalendar"`` or ``type = "filesystem/vcard"``. This has not
functional impact, and is merely for forward compatibility with the Rust
implementation of vdirsyncer.
- Python 3.10 and 3.11 are officially supported.
- Instructions for integrating with Google CalDav/CardDav have changed.
Applications now need to be registered as "Desktop applications". Using "Web
application" no longer works due to changes on Google's side. :gh:`1078`
Version 0.19.1
==============
- Fixed crash when operating on Google Contacts. :gh:`994`
- The ``HTTP_PROXY`` and ``HTTPS_PROXY`` are now respected. :gh:`1031`
- Instructions for integrating with Google CalDav/CardDav have changed.
Applications now need to be registered as "Web Application". :gh:`975`
- Various documentation updates.
Version 0.19.0
==============
- Add "shell" password fetch strategy to pass command string to a shell.
- Add "description" and "order" as metadata. These fetch the CalDAV:
calendar-description, ``CardDAV:addressbook-description`` and
``apple-ns:calendar-order`` properties respectively.
- Add a new ``showconfig`` status. This prints *some* configuration values as
JSON. This is intended to be used by external tools and helpers that interact
with ``vdirsyncer``, and considered experimental.
- Add ``implicit`` option to the :ref:`pair section <pair_config>`. When set to
"create", it implicitly creates missing collections during sync without user
prompts. This simplifies workflows where collections should be automatically
created on both sides.
- Update TLS-related tests that were failing due to weak MDs. :gh:`903`
- ``pytest-httpserver`` and ``trustme`` are now required for tests.
- ``pytest-localserver`` is no longer required for tests.
- Multithreaded support has been dropped. The ``"--max-workers`` has been removed.
- A new ``asyncio`` backend is now used. So far, this shows substantial speed
improvements in ``discovery`` and ``metasync``, but little change in `sync`.
This will likely continue improving over time. :gh:`906`
- The ``google`` storage types no longer require ``requests-oauthlib``, but
require ``python-aiohttp-oauthlib`` instead.
- Vdirsyncer no longer includes experimental support for `EteSync
<https://www.etesync.com/>`_. The existing integration had not been supported
for a long time and no longer worked. Support for external storages may be
added if anyone is interested in maintaining an EteSync plugin. EteSync
users should consider using `etesync-dav`_.
- The ``plist`` for macOS has been dropped. It was broken and homebrew
generates their own based on package metadata. macOS users are encouraged to
use that as a reference.
.. _etesync-dav: https://github.com/etesync/etesync-dav
Changes to SSL configuration
----------------------------
Support for ``md5`` and ``sha1`` certificate fingerprints has been dropped. If
you're validating certificate fingerprints, use ``sha256`` instead.
When using a custom ``verify_fingerprint``, CA validation is always disabled.
If ``verify_fingerprint`` is unset, CA verification is always active. Disabling
both features is insecure and no longer supported.
The ``verify`` parameter no longer takes boolean values, it is now optional and
only takes a string to a custom CA for verification.
The ``verify`` and ``verify_fingerprint`` will likely be merged into a single
parameter in future.
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.
Version 0.16.8
==============
*released 09 June 2020*
- Support Python 3.7 and 3.8.
This release is functionally identical to 0.16.7.
It's been tested with recent Python versions, and has been marked as supporting
them. It will also be the final release supporting Python 3.5 and 3.6.
Version 0.16.7
==============
*released on 19 July 2018*
- Fixes for Python 3.7
Version 0.16.6
==============
*released on 13 June 2018*
- **Packagers:** Documentation building no longer needs a working installation
of vdirsyncer.
Version 0.16.5
==============
*released on 13 June 2018*
- **Packagers:** click-log 0.3 is required.
- All output will now happen on stderr (because of the upgrade of ``click-log``).
- Fix bug where collection discovery under DAV-storages would produce invalid
XML. See :gh:`688`.
- ownCloud and Baikal are no longer tested.
Version 0.16.4
==============
@ -250,7 +104,7 @@ Version 0.14.0
exit code in such situations is still non-zero.
- Add ``partial_sync`` option to pair section. See :ref:`the config docs
<partial_sync_def>`.
- Vdirsyncer will now warn if there's a string without quotes in your config.
- Vdirsyner will now warn if there's a string without quotes in your config.
Please file issues if you find documentation that uses unquoted strings.
- Fix an issue that would break khal's config setup wizard.

View file

@ -1,4 +1,4 @@
Copyright (c) 2014-2020 by Markus Unterwaditzer & contributors. See
Copyright (c) 2014-2018 by Markus Unterwaditzer & contributors. See
AUTHORS.rst for more details.
Some rights reserved.
@ -31,3 +31,10 @@ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE AND DOCUMENTATION, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH
DAMAGE.
== etesync ==
I, Tom Hacohen, hereby grant a license for EteSync's journal-manager
(https://github.com/etesync/journal-manager) to be used as a dependency in
vdirsyncer's test suite for the purpose of testing vdirsyncer without having
the copyleft section of the AGPL apply to it (vdirsyncer).

View file

@ -1,7 +1,8 @@
# setuptools-scm includes everything tracked by git
prune docker
prune contrib
prune scripts
prune tests/storage/servers
prune tests/storage/etesync
recursive-include tests/storage/servers/radicale *
recursive-include tests/storage/servers/skip *

130
Makefile
View file

@ -1,7 +1,7 @@
# See the documentation on how to run the tests:
# https://vdirsyncer.pimutils.org/en/stable/contributing.html
# Which DAV server to run the tests against (radicale, xandikos, skip, owncloud, nextcloud, ...)
# Which DAV server to run the tests against (radicale, xandikos, skip, nextcloud, ...)
export DAV_SERVER := skip
# release (install release versions of dependencies)
@ -12,7 +12,10 @@ export REQUIREMENTS := release
# Set this to true if you run vdirsyncer's test as part of e.g. packaging.
export DETERMINISTIC_TESTS := false
# Assume to run in CI. Don't use this outside of a virtual machine. It will
# Run the etesync testsuite.
export ETESYNC_TESTS := false
# Assume to run in Travis. 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
@ -20,44 +23,131 @@ export CI := false
# Whether to generate coverage data while running tests.
export COVERAGE := $(CI)
# Additional arguments that should be passed to py.test.
PYTEST_ARGS =
# Variables below this line are not very interesting for getting started.
TEST_EXTRA_PACKAGES =
ifeq ($(COVERAGE), true)
TEST_EXTRA_PACKAGES += pytest-cov
PYTEST_ARGS += --cov-config .coveragerc --cov vdirsyncer
endif
ifeq ($(ETESYNC_TESTS), true)
TEST_EXTRA_PACKAGES += django-etesync-journal django djangorestframework wsgi_intercept drf-nested-routers
endif
PYTEST = py.test $(PYTEST_ARGS)
export TESTSERVER_BASE := ./tests/storage/servers/
CODECOV_PATH = /tmp/codecov.sh
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 --cov vdirsyncer --cov-append tests/unit/ tests/system/
bash $(CODECOV_PATH) -c
ifeq ($(CI), true)
codecov.sh:
curl -s https://codecov.io/bash > $@
else
codecov.sh:
echo > $@
endif
ci-test-storage:
curl -s https://codecov.io/bash > $(CODECOV_PATH)
rust-test:
cd rust/ && cargo test --release
test: unit-test system-test storage-test
unit-test: codecov.sh
$(PYTEST) tests/unit/
bash codecov.sh -c -F unit
system-test: codecov.sh
$(PYTEST) tests/system/
bash codecov.sh -c -F system
storage-test: codecov.sh
$(PYTEST) tests/storage/
bash codecov.sh -c -F storage
install-servers:
set -ex; \
for server in $(DAV_SERVER); do \
DAV_SERVER=$$server pytest --cov vdirsyncer --cov-append tests/storage; \
if [ ! "$$(ls $(TESTSERVER_BASE)$$server/)" ]; then \
git submodule update --init -- "$(TESTSERVER_BASE)$$server"; \
fi; \
(cd $(TESTSERVER_BASE)$$server && sh install.sh); \
done
bash $(CODECOV_PATH) -c
check:
ruff check
ruff format --diff
#mypy vdirsyncer
install-test: install-servers
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-style: install-docs
pip install -U flake8 flake8-import-order 'flake8-bugbear>=17.3.0'
which cargo-install-update || cargo +nightly install cargo-update
cargo +nightly install-update -i clippy
cargo +nightly install-update -i rustfmt-nightly
cargo +nightly install-update -i cargo-update
style:
flake8
! git grep -i syncroniz */*
! git grep -i 'text/icalendar' */*
sphinx-build -W -b html ./docs/ ./docs/_build/html/
cd rust/ && cargo +nightly clippy
cd rust/ && cargo fmt
install-docs:
pip install -Ur docs-requirements.txt
docs:
cd docs && make html
linkcheck:
sphinx-build -W -b linkcheck ./docs/ ./docs/_build/linkcheck/
release:
python setup.py sdist upload
release-deb:
sh scripts/release-deb.sh debian jessie
sh scripts/release-deb.sh debian stretch
sh scripts/release-deb.sh ubuntu trusty
sh scripts/release-deb.sh ubuntu xenial
sh scripts/release-deb.sh ubuntu zesty
install-dev:
pip install -U pip setuptools wheel
pip install -e '.[test,check,docs]'
set -xe && if [ "$(REQUIREMENTS)" = "minimal" ]; then \
pip install pyproject-dependencies && \
pip install -U --force-reinstall $$(pyproject-dependencies . | sed 's/>/=/'); \
pip install -ve .
[ "$(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 \
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"
install-rust:
curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain nightly
rust-ext:
[ "$$READTHEDOCS" != "True" ] || $(MAKE) install-rust
cd ./rust && cargo build --release
.PHONY: docs

View file

@ -2,30 +2,6 @@
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=main
:target: https://codecov.io/github/pimutils/vdirsyncer?branch=main
:alt: Codecov coverage report
.. image:: https://readthedocs.org/projects/vdirsyncer/badge/
:target: https://vdirsyncer.rtfd.org/
:alt: documentation
.. image:: https://img.shields.io/pypi/v/vdirsyncer.svg
:target: https://pypi.python.org/pypi/vdirsyncer
:alt: version on pypi
.. image:: https://img.shields.io/badge/deb-packagecloud.io-844fec.svg
:target: https://packagecloud.io/pimutils/vdirsyncer
:alt: Debian packages
.. image:: https://img.shields.io/pypi/l/vdirsyncer.svg
:target: https://github.com/pimutils/vdirsyncer/blob/main/LICENCE
:alt: licence: BSD
- `Documentation <https://vdirsyncer.pimutils.org/en/stable/>`_
- `Source code <https://github.com/pimutils/vdirsyncer>`_
@ -40,10 +16,19 @@ servers. It can also be used to synchronize calendars and/or addressbooks
between two servers directly.
It aims to be for calendars and contacts what `OfflineIMAP
<https://www.offlineimap.org/>`_ is for emails.
<http://offlineimap.org/>`_ is for emails.
.. _programs: https://vdirsyncer.pimutils.org/en/latest/tutorials/
.. image:: https://circleci.com/gh/pimutils/vdirsyncer.svg?style=shield
:target: https://circleci.com/gh/pimutils/vdirsyncer
.. image:: https://codecov.io/github/pimutils/vdirsyncer/coverage.svg?branch=master
:target: https://codecov.io/github/pimutils/vdirsyncer?branch=master
.. image:: https://badge.waffle.io/pimutils/vdirsyncer.svg?label=ready&title=Ready
:target: https://waffle.io/pimutils/vdirsyncer
Links of interest
=================
@ -59,15 +44,6 @@ Links of interest
* `Donations <https://vdirsyncer.pimutils.org/en/stable/donations.html>`_
Dockerized
=================
If you want to run `Vdirsyncer <https://vdirsyncer.pimutils.org/en/stable/>`_ in a
Docker environment, you can check out the following GitHub Repository:
* `Vdirsyncer DOCKERIZED <https://github.com/Bleala/Vdirsyncer-DOCKERIZED>`_
Note: This is an unofficial Docker build, it is maintained by `Bleala <https://github.com/Bleala>`_.
License
=======

View file

@ -43,7 +43,7 @@ fileext = ".vcf"
[storage bob_contacts_remote]
type = "carddav"
url = "https://owncloud.example.com/remote.php/carddav/"
url = "https://nextcloud.example.com/"
#username =
# The password can also be fetched from the system password storage, netrc or a
# custom command. See http://vdirsyncer.pimutils.org/en/stable/keyring.html
@ -65,6 +65,6 @@ fileext = ".ics"
[storage bob_calendar_remote]
type = "caldav"
url = "https://owncloud.example.com/remote.php/caldav/"
url = "https://nextcloud.example.com/"
#username =
#password =

View file

@ -1,75 +0,0 @@
#!/usr/bin/env python3
"""Ask user to resolve a vdirsyncer sync conflict interactively.
Needs a way to ask the user.
The use of https://apps.kde.org/kdialog/ for GNU/Linix is hardcoded.
Depends on python>3.5 and KDialog.
Usage:
Ensure the file executable and use it in the vdirsyncer.conf file, e.g.
conflict_resolution = ["command", "/home/bern/vdirsyncer/resolve_interactively.py"]
This file is Free Software under the following license:
SPDX-License-Identifier: BSD-3-Clause
SPDX-FileCopyrightText: 2021 Intevation GmbH <https://intevation.de>
Author: <bernhard.reiter@intevation.de>
"""
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
KDIALOG = "/usr/bin/kdialog"
SUMMARY_PATTERN = re.compile("^(SUMMARY:.*)$", re.MULTILINE)
def get_summary(icalendar_text: str):
"""Get the first SUMMARY: line from an iCalendar text.
Do not care about the line being continued.
"""
match = re.search(SUMMARY_PATTERN, icalendar_text)
return match[1]
def main(ical1_filename, ical2_filename):
ical1 = ical1_filename.read_text()
ical2 = ical2_filename.read_text()
additional_args = ["--yes-label", "take first"] # return code == 0
additional_args += ["--no-label", "take second"] # return code == 1
additional_args += ["--cancel-label", "do not resolve"] # return code == 2
r = subprocess.run(
args=[
KDIALOG,
"--warningyesnocancel",
"There was a sync conflict, do you prefer the first entry: \n"
f"{get_summary(ical1)}...\n(full contents: {ical1_filename})\n\n"
"or the second entry:\n"
f"{get_summary(ical2)}...\n(full contents: {ical2_filename})?",
*additional_args,
]
)
if r.returncode == 2:
# cancel was pressed
return # shall lead to items not changed, because not copied
if r.returncode == 0:
# we want to take the first item, so overwrite the second
ical2_filename.write_text(ical1)
else: # r.returncode == 1, we want the second item, so overwrite the first
ical1_filename.write_text(ical2)
if len(sys.argv) != 3:
sys.stdout.write(__doc__)
else:
main(Path(sys.argv[1]), Path(sys.argv[2]))

43
contrib/vdirsyncer.plist Normal file
View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- Blueprint for cron-like launchd plist -->
<!-- Replace @@PLACEHOLDERS@@ with appropriate values for your system/settings! -->
<plist version="1.0">
<dict>
<key>EnvironmentVariables</key>
<dict>
<!-- Locale to use for vdirsyncer, e.g. en_US.UTF-8 -->
<key>LANG</key>
<string>@@LOCALE@@</string>
<key>LC_ALL</key>
<string>@@LOCALE@@</string>
</dict>
<key>Label</key>
<string>vdirsyncer</string>
<key>WorkingDirectory</key>
<!-- working directory for vdirsyncer, usually the base directory where
vdirsyncer is installed, e.g. /usr/local/ -->
<string>@@WORKINGDIRECTORY@@</string>
<key>ProgramArguments</key>
<array>
<!-- full path to vdirsyncer binary -->
<string>@@VDIRSYNCER@@</string>
<!-- only log errors -->
<string>-v</string>
<string>ERROR</string>
<string>sync</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<!-- Sync intervall in seconds -->
<integer>@@SYNCINTERVALL@@</integer>
<!-- For logging, redirect stdout & stderr -->
<!-- <key>StandardErrorPath</key> -->
<!-- Full path to stderr logfile, e.g. /tmp/vdirsyncer_err.log -->
<!-- <string>@@STDERRFILE@@</string> -->
<!-- Full path to stdout logfile, e.g. /tmp/vdirsyncer_out.log -->
<!-- <key>StandardOutPath</key> -->
<!-- <string>@@STDOUTFILE@@</string> -->
</dict>
</plist>

View file

@ -1,9 +1,7 @@
[Unit]
Description=Synchronize calendars and contacts
Documentation=https://vdirsyncer.readthedocs.org/
StartLimitBurst=2
[Service]
ExecStart=/usr/bin/vdirsyncer sync
RuntimeMaxSec=3m
Restart=on-failure
Type=oneshot

11
docker-compose.yml Normal file
View file

@ -0,0 +1,11 @@
version: '2'
services:
nextcloud:
image: nextcloud
ports:
- '8080:80'
environment:
- SQLITE_DATABASE=nextcloud
- NEXTCLOUD_ADMIN_USER=asdf
- NEXTCLOUD_ADMIN_PASSWORD=asdf

2
docs-requirements.txt Normal file
View file

@ -0,0 +1,2 @@
sphinx != 1.4.7
sphinx_rtd_theme

View file

@ -1 +1 @@
.. include:: ../CHANGELOG.rst
.. include:: ../CHANGELOG.rst

View file

@ -1,106 +1,150 @@
from __future__ import annotations
# -*- coding: utf-8 -*-
import datetime
import json
import os
from pkg_resources import get_distribution
from sphinx.ext import autodoc
extensions = ["sphinx.ext.autodoc"]
import vdirsyncer
templates_path = ["_templates"]
extensions = ['sphinx.ext.autodoc']
source_suffix = ".rst"
master_doc = "index"
templates_path = ['_templates']
project = "vdirsyncer"
copyright = "2014-{}, Markus Unterwaditzer & contributors".format(
datetime.date.today().strftime("%Y")
)
source_suffix = '.rst'
master_doc = 'index'
release = get_distribution("vdirsyncer").version
version = ".".join(release.split(".")[:2]) # The short X.Y version.
project = u'vdirsyncer'
copyright = (u'2014-{}, Markus Unterwaditzer & contributors'
.format(datetime.date.today().strftime('%Y')))
rst_epilog = f".. |vdirsyncer_version| replace:: {release}"
release = vdirsyncer.__version__
version = '.'.join(release.split('.')[:2]) # The short X.Y version.
exclude_patterns = ["_build"]
rst_epilog = '.. |vdirsyncer_version| replace:: %s' % release
pygments_style = "sphinx"
exclude_patterns = ['_build']
on_rtd = os.environ.get("READTHEDOCS", None) == "True"
pygments_style = 'sphinx'
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', u'vdirsyncer Documentation',
u'Markus Unterwaditzer', 'manual'),
]
man_pages = [
("index", "vdirsyncer", "vdirsyncer Documentation", ["Markus Unterwaditzer"], 1)
('index', 'vdirsyncer', u'vdirsyncer Documentation',
[u'Markus Unterwaditzer'], 1)
]
texinfo_documents = [
(
"index",
"vdirsyncer",
"vdirsyncer Documentation",
"Markus Unterwaditzer",
"vdirsyncer",
"Synchronize calendars and contacts.",
"Miscellaneous",
),
('index', 'vdirsyncer', u'vdirsyncer Documentation',
u'Markus Unterwaditzer', 'vdirsyncer',
'Synchronize calendars and contacts.', 'Miscellaneous'),
]
def github_issue_role(name, rawtext, text, lineno, inliner, options=None, content=()):
options = options or {}
def github_issue_role(name, rawtext, text, lineno, inliner,
options={}, content=()): # noqa: B006
try:
issue_num = int(text)
if issue_num <= 0:
raise ValueError
raise ValueError()
except ValueError:
msg = inliner.reporter.error(f"Invalid GitHub issue: {text}", line=lineno)
msg = inliner.reporter.error('Invalid GitHub issue: {}'.format(text),
line=lineno)
prb = inliner.problematic(rawtext, rawtext, msg)
return [prb], [msg]
import vdirsyncer
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)
link = '{}/{}/{}'.format(vdirsyncer.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 format_storage_config(cls, header=True):
if header is True:
yield '[storage example_for_{}]'.format(cls.storage_name)
yield 'type = "{}"'.format(cls.storage_name)
from vdirsyncer.storage.base import Storage
from vdirsyncer.utils import get_storage_init_specs
handled = set()
for spec in get_storage_init_specs(cls, stop_at=Storage):
defaults = spec.defaults or ()
defaults = dict(zip(spec.args[-len(defaults):], defaults))
for key in spec.args[1:]:
if key in handled:
continue
handled.add(key)
comment = '' if key not in defaults else '#'
value = defaults.get(key, '...')
yield '{}{} = {}'.format(comment, key, json.dumps(value))
class StorageDocumenter(autodoc.ClassDocumenter):
'''Custom formatter for auto-documenting storage classes. It assumes that
the first line of the class' docstring is its own paragraph.
After that first paragraph, an example configuration will be inserted and
Sphinx' __init__ signature removed.'''
objtype = 'storage'
domain = None
directivetype = 'storage'
option_spec = {}
@classmethod
def can_document_member(cls, member, membername, isattr, parent):
from vdirsyncer.storage.base import Storage
return isinstance(member, Storage)
def format_signature(self):
return ''
def add_directive_header(self, sig):
directive = getattr(self, 'directivetype', self.objtype)
name = self.object.storage_name
self.add_line(u'.. %s:: %s%s' % (directive, name, sig),
'<autodoc>')
def get_doc(self, encoding=None, ignore=1):
rv = autodoc.ClassDocumenter.get_doc(self, encoding, ignore)
config = [u' ' + x for x in format_storage_config(self.object)]
rv[0] = rv[0][:1] + [u'::', u''] + config + [u''] + rv[0][1:]
return rv
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)
app.add_autodocumenter(StorageDocumenter)

View file

@ -61,8 +61,7 @@ Pair Section
sync`` is executed. See also :ref:`collections_tutorial`.
The special values ``"from a"`` and ``"from b"``, tell vdirsyncer to try
autodiscovery on a specific storage. It means all the collections on side A /
side B.
autodiscovery on a specific storage.
If the collection you want to sync doesn't have the same name on each side,
you may also use a value of the form ``["config_name", "name_a", "name_b"]``.
@ -72,8 +71,8 @@ Pair Section
Examples:
- ``collections = ["from b", "foo", "bar"]`` makes vdirsyncer synchronize all
the collections from side B, and also the collections named "foo" and "bar".
- ``collections = ["from b", "foo", "bar"]`` makes vdirsyncer synchronize the
collections from side B, and also the collections named "foo" and "bar".
- ``collections = ["from b", "from a"]`` makes vdirsyncer synchronize all
existing collections on either side.
@ -117,26 +116,10 @@ Pair Section
- ``metadata``: Metadata keys that should be synchronized when ``vdirsyncer
metasync`` is executed. Example::
metadata = ["color", "displayname", "description", "order"]
metadata = ["color", "displayname"]
This synchronizes the following properties:
- color: ``http://apple.com/ns/ical/:calendar-color``
- displayname: ``DAV:displayname``
- description: ``CalDAV:calendar-description`` and ``CardDAV:addressbook-description``
- order: ``http://apple.com/ns/ical/:calendar-order``
The ``conflict_resolution`` parameter applies for these properties too.
.. _implicit_def:
- ``implicit``: Opt into implicitly creating collections. Example::
implicit = "create"
When set to "create", missing collections are automatically created on both
sides during sync without prompting the user. This simplifies workflows where
all collections should be synchronized bidirectionally.
This synchronizes the ``color`` and the ``displayname`` properties. The
``conflict_resolution`` parameter applies here as well.
.. _storage_config:
@ -167,107 +150,9 @@ Supported Storages
CalDAV and CardDAV
++++++++++++++++++
.. note::
.. autostorage:: vdirsyncer.storage.dav.CalDAVStorage
Please also see :ref:`supported-servers`, as some servers may not work
well.
.. storage:: caldav
CalDAV.
::
[storage example_for_caldav]
type = "caldav"
#start_date = null
#end_date = null
#item_types = []
url = "..."
#username = ""
#password = ""
#verify = /path/to/custom_ca.pem
#auth = null
#useragent = "vdirsyncer/0.16.4"
#verify_fingerprint = null
#auth_cert = null
You can set a timerange to synchronize with the parameters ``start_date``
and ``end_date``. Inside those parameters, you can use any Python
expression to return a valid :py:class:`datetime.datetime` object. For
example, the following would synchronize the timerange from one year in the
past to one year in the future::
start_date = "datetime.now() - timedelta(days=365)"
end_date = "datetime.now() + timedelta(days=365)"
Either both or none have to be specified. The default is to synchronize
everything.
You can set ``item_types`` to restrict the *kind of items* you want to
synchronize. For example, if you want to only synchronize events (but don't
download any tasks from the server), set ``item_types = ["VEVENT"]``. If
you want to synchronize events and tasks, but have some ``VJOURNAL`` items
on the server you don't want to synchronize, use ``item_types = ["VEVENT",
"VTODO"]``.
:param start_date: Start date of timerange to show, default -inf.
:param end_date: End date of timerange to show, default +inf.
:param item_types: Kind of items to show. The default, the empty list, is
to show all. This depends on particular features on the server, the
results are not validated.
:param url: Base URL or an URL to a calendar.
:param username: Username for authentication.
:param password: Password for authentication.
:param verify: Optional. Local path to a self-signed SSL certificate.
See :ref:`ssl-tutorial` for more information.
:param verify_fingerprint: Optional. SHA256 fingerprint of the expected
server certificate. See :ref:`ssl-tutorial` for more information.
:param auth: Optional. Either ``basic``, ``digest`` or ``guess``. The
default is preemptive Basic auth, sending credentials even if server
didn't request them. This saves from an additional roundtrip per
request. Consider setting ``guess`` if this causes issues with your
server.
:param auth_cert: Optional. Either a path to a certificate with a client
certificate and the key or a list of paths to the files with them.
:param useragent: Default ``vdirsyncer``.
.. storage:: carddav
CardDAV.
::
[storage example_for_carddav]
type = "carddav"
url = "..."
#username = ""
#password = ""
#verify = /path/to/custom_ca.pem
#auth = null
#useragent = "vdirsyncer/0.16.4"
#verify_fingerprint = null
#auth_cert = null
#use_vcard_4 = false
:param url: Base URL or an URL to an addressbook.
:param username: Username for authentication.
:param password: Password for authentication.
:param verify: Optional. Local path to a self-signed SSL certificate.
See :ref:`ssl-tutorial` for more information.
:param verify_fingerprint: Optional. SHA256 fingerprint of the expected
server certificate. See :ref:`ssl-tutorial` for more information.
:param auth: Optional. Either ``basic``, ``digest`` or ``guess``. The
default is preemptive Basic auth, sending credentials even if
server didn't request them. This saves from an additional
roundtrip per request. Consider setting ``guess`` if this
causes issues with your server.
:param auth_cert: Optional. Either a path to a certificate with a client
certificate and the key or a list of paths to the files
with them.
:param useragent: Default ``vdirsyncer``.
:param use_vcard_4: Whether the server use vCard 4.0.
.. autostorage:: vdirsyncer.storage.dav.CardDAVStorage
Google
++++++
@ -281,15 +166,7 @@ in terms of data safety**. See `this blog post
<https://evertpot.com/google-carddav-issues/>`_ for the details. Always back
up your data.
Another caveat is that Google group labels are not synced with vCard's
`CATEGORIES <https://www.rfc-editor.org/rfc/rfc6350#section-6.7.1>`_ property
(also see :gh:`814` and
`upstream issue #36761530 <https://issuetracker.google.com/issues/36761530>`_
for reference) and the
`BDAY <https://www.rfc-editor.org/rfc/rfc6350#section-6.2.5>`_ property is not
synced when only partial date information is present (e.g. the year is missing).
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::
@ -300,29 +177,25 @@ Furthermore you need to register vdirsyncer as an application yourself to
obtain ``client_id`` and ``client_secret``, as it is against Google's Terms of
Service to hardcode those into opensource software [googleterms]_:
1. Go to the `Google API Manager <https://console.developers.google.com>`_
2. Create a new project under any name.
1. Go to the `Google API Manager <https://console.developers.google.com>`_ and
create a new project under any name.
2. Within that project, enable the "CalDAV" and "CardDAV" APIs (**not** the
Calendar and Contacts APIs, those are different and won't work). There should
be a search box where you can just enter those terms.
3. In the sidebar, select "Credentials", then "Create Credentials" and create a
new "OAuth Client ID".
be a searchbox where you can just enter those terms.
3. In the sidebar, select "Credentials" and create a new "OAuth Client ID". The
application type is "Other".
You'll be prompted to create a OAuth consent screen first. Fill out that
form however you like.
After setting up the consent screen, finish creating the new "OAuth Client
ID'. The correct application type is "Desktop application".
4. Finally you should have a Client ID and a Client secret. Provide these in
your storage config.
The ``token_file`` parameter should be a path to a file where vdirsyncer can
later store authentication-related data. You do not need to create the file
itself or write anything to it.
The ``token_file`` parameter should be a filepath where vdirsyncer can later
store authentication-related data. You do not need to create the file itself
or write anything to it.
.. [googleterms] See `ToS <https://developers.google.com/terms/?hl=th>`_,
section "Confidential Matters".
@ -330,141 +203,38 @@ itself or write anything to it.
.. note::
You need to configure which calendars Google should offer vdirsyncer using
a secret `settings page
a rather hidden `settings page
<https://calendar.google.com/calendar/syncselect>`_.
.. storage:: google_calendar
.. autostorage:: vdirsyncer.storage.google.GoogleCalendarStorage
Google calendar.
.. autostorage:: vdirsyncer.storage.google.GoogleContactsStorage
::
EteSync
+++++++
[storage example_for_google_calendar]
type = "google_calendar"
token_file = "..."
client_id = "..."
client_secret = "..."
#start_date = null
#end_date = null
#item_types = []
`EteSync <https://www.etesync.com/>`_ is a new cloud provider for end to end
encrypted contacts and calendar storage. Vdirsyncer contains **experimental**
support for it.
Please refer to :storage:`caldav` regarding the ``item_types`` and timerange parameters.
To use it, you need to install some optional dependencies::
:param token_file: A filepath where access tokens are stored.
:param client_id/client_secret: OAuth credentials, obtained from the Google
API Manager.
pip install vdirsyncer[etesync]
.. storage:: google_contacts
On first usage you will be prompted for the service password and the encryption
password. Neither are stored.
Google contacts.
.. autostorage:: vdirsyncer.storage.etesync.EtesyncContacts
::
[storage example_for_google_contacts]
type = "google_contacts"
token_file = "..."
client_id = "..."
client_secret = "..."
:param token_file: A filepath where access tokens are stored.
:param client_id/client_secret: OAuth credentials, obtained from the Google
API Manager.
The current flow is not ideal, but Google has deprecated the previous APIs used
for this without providing a suitable replacement. See :gh:`975` for discussion
on the topic.
.. autostorage:: vdirsyncer.storage.etesync.EtesyncCalendars
Local
+++++
.. storage:: filesystem
.. autostorage:: vdirsyncer.storage.filesystem.FilesystemStorage
Saves each item in its own file, given a directory.
.. autostorage:: vdirsyncer.storage.singlefile.SingleFileStorage
::
[storage example_for_filesystem]
type = "filesystem"
path = "..."
fileext = "..."
#encoding = "utf-8"
#post_hook = null
#pre_deletion_hook = null
#fileignoreext = ".tmp"
Can be used with `khal <http://lostpackets.de/khal/>`_. See :doc:`vdir` for
a more formal description of the format.
Directories with a leading dot are ignored to make usage of e.g. version
control easier.
:param path: Absolute path to a vdir/collection. If this is used in
combination with the ``collections`` parameter in a pair-section, this
should point to a directory of vdirs instead.
: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). 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 pre_deletion_hook: A command to call for each item deletion.
The command will be called with the path of the deleted 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
Save data in single local ``.vcf`` or ``.ics`` file.
The storage basically guesses how items should be joined in the file.
.. versionadded:: 0.1.6
.. note::
This storage is very slow, and that is unlikely to change. You should
consider using :storage:`filesystem` if it fits your usecase.
:param path: The filepath to the file to be written to. If collections are
used, this should contain ``%s`` as a placeholder for the collection
name.
:param encoding: Which encoding the file should use. Defaults to UTF-8.
Example for syncing with :storage:`caldav`::
[pair my_calendar]
a = my_calendar_local
b = my_calendar_remote
collections = ["from a", "from b"]
[storage my_calendar_local]
type = "singlefile"
path = ~/.calendars/%s.ics
[storage my_calendar_remote]
type = "caldav"
url = https://caldav.example.org/
#username =
#password =
Example for syncing with :storage:`caldav` using a ``null`` collection::
[pair my_calendar]
a = my_calendar_local
b = my_calendar_remote
[storage my_calendar_local]
type = "singlefile"
path = ~/my_calendar.ics
[storage my_calendar_remote]
type = "caldav"
url = https://caldav.example.org/username/my_calendar/
#username =
#password =
Read-only storages
++++++++++++++++++
@ -473,54 +243,4 @@ These storages don't support writing of their items, consequently ``read_only``
is set to ``true`` by default. Changing ``read_only`` to ``false`` on them
leads to an error.
.. storage:: http
Use a simple ``.ics`` file (or similar) from the web.
``webcal://``-calendars are supposed to be used with this, but you have to
replace ``webcal://`` with ``http://``, or better, ``https://``.
::
[pair holidays]
a = holidays_local
b = holidays_remote
collections = null
[storage holidays_local]
type = "filesystem"
path = ~/.config/vdir/calendars/holidays/
fileext = .ics
[storage holidays_remote]
type = "http"
url = https://example.com/holidays_from_hicksville.ics
#filter_hook = null
Too many WebCAL providers generate UIDs of all ``VEVENT``-components
on-the-fly, i.e. all UIDs change every time the calendar is downloaded.
This leads many synchronization programs to believe that all events have
been deleted and new ones created, and accordingly causes a lot of
unnecessary uploads and deletions on the other side. Vdirsyncer completely
ignores UIDs coming from :storage:`http` and will replace them with a hash
of the normalized item content.
:param url: URL to the ``.ics`` file.
:param username: Username for authentication.
:param password: Password for authentication.
:param verify: Optional. Local path to a self-signed SSL certificate.
See :ref:`ssl-tutorial` for more information.
:param verify_fingerprint: Optional. SHA256 fingerprint of the expected
server certificate. See :ref:`ssl-tutorial` for more information.
:param auth: Optional. Either ``basic``, ``digest`` or ``guess``. The
default is preemptive Basic auth, sending credentials even if server
didn't request them. This saves from an additional roundtrip per
request. Consider setting ``guess`` if this causes issues with your
server.
:param auth_cert: Optional. Either a path to a certificate with a client
certificate and the key or a list of paths to the files with them.
:param useragent: Default ``vdirsyncer``.
:param filter_hook: Optional. A filter command to call for each fetched
item, passed in raw form to stdin and returned via stdout.
If nothing is returned by the filter command, the item is skipped.
This can be used to alter fields as needed when dealing with providers
generating malformed events.
.. autostorage:: vdirsyncer.storage.http.HttpStorage

View file

@ -2,11 +2,14 @@
Support and Contact
===================
* The ``#pimutils`` `IRC channel on Libera.Chat <https://pimutils.org/contact>`_
* The ``#pimutils`` `IRC channel on Freenode <https://pimutils.org/contact>`_
might be active, depending on your timezone. Use it for support and general
(including off-topic) discussion.
* Open `a GitHub issue <https://github.com/pimutils/vdirsyncer/issues/>`_ for
concrete bug reports and feature requests.
* For security issues, contact ``contact@pimutils.org``.
* Lastly, you can also `contact the author directly
<https://unterwaditzer.net/contact.html>`_. Do this for security issues. If
that doesn't work out (i.e. if I don't respond within one week), use
``contact@pimutils.org``.

View file

@ -75,36 +75,36 @@ Submitting patches, pull requests
Running tests, how to set up your development environment
---------------------------------------------------------
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
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
virtualenv_ and run this inside of it::
# Install development dependencies, including:
# install:
# - vdirsyncer from the repo into the virtualenv
# - style checks and formatting (ruff)
# - stylecheckers (flake8) and code formatters (autopep8)
make install-dev
# Install git commit hook for some extra linting and checking
pre-commit install
# Install git commit hook for the stylechecker
make install-git-hooks
# install test dependencies
make install-test
Then you can run::
pytest # The normal testsuite
pre-commit run --all # Run all linters (which also run via pre-commit)
make -C docs html # Build the HTML docs, output is at docs/_build/html/
make -C docs linkcheck # Check docs for any broken links
make test # The normal testsuite
make style # Stylechecker
make docs # Build the HTML docs, output is at docs/_build/html/
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.
To tests against a specific DAV server, use ``DAV_SERVER``::
For example, to test xandikos, run::
make DAV_SERVER=xandikos install-test
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

View file

@ -2,14 +2,23 @@
Donations
=========
vdirsyncer is and will always be free and open source software. We appreciate
sponsors willing to fund our continued work on it.
If you found my work useful, please consider donating. Thank you!
- Bitcoin: ``13p42uWDL62bNRH3KWA6cSpSgvnHy1fs2E``.
- Sponsor via one-time tips or recurring donations `via Ko-fi`_.
- Sponsor via recurring donations `via liberapay`_.
- Bitcoin: ``16sSHxZm263WHR9P9PJjCxp64jp9ooXKVt``
.. _via Ko-fi: https://ko-fi.com/whynothugo
.. _via liberapay: https://liberapay.com/WhyNotHugo/
- `PayPal.me <https://www.paypal.me/untitaker>`_
- `Bountysource <https://www.bountysource.com/teams/vdirsyncer>`_ is useful for
funding work on a specific GitHub issue.
- There's also `Bountysource Salt
<https://salt.bountysource.com/teams/vdirsyncer>`_, for one-time and
recurring donations.
- Donations via Bountysource are publicly listed. Use PayPal if you dislike
that.
- `Flattr
<https://flattr.com/submit/auto?user_id=untitaker&url=https%3A%2F%2Fgithub.com%2Fpimutils%2Fvdirsyncer>`_
or `Gratipay <https://gratipay.com/vdirsyncer/>`_ can be used for
recurring donations.

View file

@ -7,18 +7,17 @@ Installation
OS/distro packages
------------------
The following packages are community-contributed and were up-to-date at the
time of writing:
The following packages are user-contributed and were up-to-date at the time of
writing:
- `Arch Linux <https://archlinux.org/packages/extra/any/vdirsyncer/>`_
- `ArchLinux <https://www.archlinux.org/packages/community/any/vdirsyncer/>`_
- `Ubuntu and Debian, x86_64-only
<https://packagecloud.io/pimutils/vdirsyncer>`_ (packages also exist
in the official repositories but may be out of date)
- `GNU Guix <https://packages.guix.gnu.org/packages/vdirsyncer/>`_
- `macOS (homebrew) <https://formulae.brew.sh/formula/vdirsyncer>`_
- `NetBSD <https://ftp.netbsd.org/pub/pkgsrc/current/pkgsrc/time/py-vdirsyncer/index.html>`_
- `GNU Guix <https://www.gnu.org/software/guix/package-list.html#vdirsyncer>`_
- `OS X (homebrew) <http://braumeister.org/formula/vdirsyncer>`_
- `BSD (pkgsrc) <http://pkgsrc.se/time/py-vdirsyncer>`_
- `OpenBSD <http://ports.su/productivity/vdirsyncer>`_
- `Slackware (SlackBuild at Slackbuilds.org) <https://slackbuilds.org/repository/15.0/network/vdirsyncer/>`_
We only support the latest version of vdirsyncer, which is at the time of this
writing |vdirsyncer_version|. Please **do not file bugs if you use an older
@ -42,55 +41,37 @@ If your distribution doesn't provide a package for vdirsyncer, you still can
use Python's package manager "pip". First, you'll have to check that the
following things are installed:
- Python 3.9 to 3.13 and pip.
- Python 3.4+ and pip.
- ``libxml`` and ``libxslt``
- ``zlib``
- Linux or macOS. **Windows is not supported**, see :gh:`535`.
- `Rust <https://www.rust-lang.org/>`, the programming language, together with
its package manager ``cargo``.
- Linux or OS X. **Windows is not supported**, see :gh:`535`.
On Linux systems, using the distro's package manager is the best
way to do this, for example, using Ubuntu::
On Linux systems, using the distro's package manager is the best way to do
this, for example, using Ubuntu (last tried on Trusty)::
sudo apt-get install libxml2 libxslt1.1 zlib1g python3
sudo apt-get install python3 python3-pip libffi-dev
Rust may need to be installed separately, as the packages in Ubuntu are usually
out-of-date. I recommend `rustup <https://rustup.rs/>`_ for that.
Then you have several options. The following text applies for most Python
software by the way.
pipx: The clean, easy way
~~~~~~~~~~~~~~~~~~~~~~~~~
pipx_ is a new package manager for Python-based software that automatically
sets up a virtual environment for each program it installs. Please note that
installing via pipx will not include manual pages nor systemd services.
pipx will install vdirsyncer into ``~/.local/pipx/venvs/vdirsyncer``
Assuming that pipx is installed, vdirsyncer can be installed with::
pipx install vdirsyncer
It can later be updated to the latest version with::
pipx upgrade vdirsyncer
And can be uninstalled with::
pipx uninstall vdirsyncer
This last command will remove vdirsyncer and any dependencies installed into
the above location.
.. _pipx: https://github.com/pipxproject/pipx
The dirty, easy way
~~~~~~~~~~~~~~~~~~~
If pipx is not available on your distribution, the easiest way to install
vdirsyncer at this point would be to run::
The easiest way to install vdirsyncer at this point would be to run::
pip install --ignore-installed vdirsyncer
pip3 install -v --user --ignore-installed vdirsyncer
- ``--user`` is to install without root rights (into your home directory)
- ``--ignore-installed`` is to work around Debian's potentially broken packages
(see :ref:`debian-urllib3`).
(see :ref:`debian-urllib3`). You can try to omit it if you run into other
problems related to certificates, for example.
Your executable is then in ``~/.local/bin/``.
This method has a major flaw though: Pip doesn't keep track of the files it
installs. Vdirsyncer's files would be located somewhere in
@ -106,9 +87,9 @@ There is a way to install Python software without scattering stuff across
your filesystem: virtualenv_. There are a lot of resources on how to use it,
the simplest possible way would look something like::
virtualenv ~/vdirsyncer_env
~/vdirsyncer_env/bin/pip install vdirsyncer
alias vdirsyncer="~/vdirsyncer_env/bin/vdirsyncer"
virtualenv --python python3 ~/vdirsyncer_env
~/vdirsyncer_env/bin/pip install -v vdirsyncer
alias vdirsyncer="$HOME/vdirsyncer_env/bin/vdirsyncer"
You'll have to put the last line into your ``.bashrc`` or ``.bash_profile``.
@ -119,4 +100,25 @@ This method has two advantages:
distro-specific issues.
- You can delete ``~/vdirsyncer_env/`` to uninstall vdirsyncer entirely.
The clean, easy way
~~~~~~~~~~~~~~~~~~~
pipsi_ 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
and ``.local/bin/vdirsyncer`` will be your new vdirsyncer installation. To
update vdirsyncer to the latest version::
pipsi upgrade vdirsyncer
If you're done with vdirsyncer, you can do::
pipsi uninstall vdirsyncer
and vdirsyncer will be uninstalled, including its dependencies.
.. _virtualenv: https://virtualenv.readthedocs.io/
.. _pipsi: https://github.com/mitsuhiko/pipsi

View file

@ -38,12 +38,6 @@ You can fetch the username as well::
Or really any kind of parameter in a storage section.
You can also pass the command as a string to be executed in a shell::
[storage foo]
...
password.fetch = ["shell", "~/.local/bin/get-my-password | head -n1"]
With pass_ for example, you might find yourself writing something like this in
your configuration file::
@ -66,7 +60,7 @@ passwords from the OS's password store. Installation::
Basic usage::
password.fetch = ["command", "keyring", "get", "example.com", "foouser"]
.. _keyring: https://github.com/jaraco/keyring/
Password Prompt
@ -78,19 +72,3 @@ You can also simply prompt for the password::
type = "caldav"
username = "myusername"
password.fetch = ["prompt", "Password for CalDAV"]
Environment variable
===============
To read the password from an environment variable::
[storage foo]
type = "caldav"
username = "myusername"
password.fetch = ["command", "printenv", "DAV_PW"]
This is especially handy if you use the same password multiple times
(say, for a CardDAV and a CalDAV storage).
On bash, you can read and export the password without printing::
read -s DAV_PW "DAV Password: " && export DAV_PW

View file

@ -5,27 +5,23 @@ Packaging guidelines
Thank you very much for packaging vdirsyncer! The following guidelines should
help you to avoid some common pitfalls.
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.
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.
Obtaining the source code
=========================
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. Those are missing some important metadata and your build will fail.
obtained there. Do not use the ones from GitHub: Their tarballs contain useless
junk and are more of a distraction than anything else.
We give each release a tag in the git repo. If you want to get notified of new
I give each release a tag in the git repo. If you want to get notified of new
releases, `GitHub's feed
<https://github.com/pimutils/vdirsyncer/releases.atom>`_ is a good way.
Tags will be signed by the maintainer who is doing the release (starting with
0.16.8), and generation of the tarball and wheel is done by CI. Hence, only the
tag itself is signed.
Dependency versions
===================
@ -37,25 +33,24 @@ Testing
=======
Everything testing-related goes through the ``Makefile`` in the root of the
repository or PyPI package. Trying to e.g. run ``pytest`` directly will
repository or PyPI package. Trying to e.g. run ``py.test`` 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 all development dependencies with::
You can install the testing dependencies with::
make install-dev
make install-test
You probably don't want this since it will use pip to download the
dependencies. Alternatively test dependencies are listed as ``test`` optional
dependencies in ``pyproject.toml``, again with lower-bound version
requirements.
dependencies. Alternatively you can find the testing dependencies in
``test-requirements.txt``, again with lower-bound version requirements.
You also have to have vdirsyncer fully installed at this point. Merely
``cd``-ing into the tarball will not be sufficient.
Running the tests happens with::
pytest
make test
Hypothesis will randomly generate test input. If you care about deterministic
tests, set the ``DETERMINISTIC_TESTS`` variable to ``"true"``::
@ -74,11 +69,10 @@ Using Sphinx_ you can generate the documentation you're reading right now in a
variety of formats, such as HTML, PDF, or even as a manpage. That said, I only
take care of the HTML docs' formatting.
You can find a list of dependencies in ``pyproject.toml``, in the
``project.optional-dependencies`` section as ``docs``. Again, you can install
those using pip with::
You can find a list of dependencies in ``docs-requirements.txt``. Again, you
can install those using pip with::
pip install '.[docs]'
make install-docs
Then change into the ``docs/`` directory and build whatever format you want
using the ``Makefile`` in there (run ``make`` for the formats you can build).

View file

@ -32,15 +32,15 @@ Paste this into your vdirsyncer config::
[storage holidays_public]
type = "http"
# The URL to your iCalendar file.
url = "..."
url = ...
[storage holidays_private]
type = "caldav"
# The direct URL to your calendar.
url = "..."
url = ...
# The credentials to your CalDAV server
username = "..."
password = "..."
username = ...
password = ...
Then run ``vdirsyncer discover holidays`` and ``vdirsyncer sync holidays``, and
your previously created calendar should be filled with events.
@ -66,7 +66,3 @@ For such purposes you can set the ``partial_sync`` parameter to ``ignore``::
partial_sync = ignore
See :ref:`the config docs <partial_sync_def>` for more information.
.. _nextCloud: https://nextcloud.com/
.. _Baikal: http://sabre.io/baikal/
.. _DAViCal: http://www.davical.org/

View file

@ -18,5 +18,5 @@ package that don't play well with packages assuming a normal ``requests``. This
is due to stubbornness on both sides.
See :gh:`82` and :gh:`140` for past discussions. You have one option to work
around this, that is, to install vdirsyncer in a virtual environment, see
around this, that is, to install vdirsyncer in a virtualenv, see
:ref:`manual-installation`.

View file

@ -14,14 +14,21 @@ To pin the certificate by fingerprint::
[storage foo]
type = "caldav"
...
verify_fingerprint = "6D:83:EA:32:6C:39:BA:08:ED:EB:C9:BC:BE:12:BB:BF:0F:D9:83:00:CC:89:7E:C7:32:05:94:96:CA:C5:59:5E"
verify_fingerprint = "94:FD:7A:CB:50:75:A4:69:82:0A:F8:23:DF:07:FC:69:3E:CD:90:CA"
#verify = false # Optional: Disable CA validation, useful for self-signed certs
SHA256-Fingerprints must be used, MD5 and SHA-1 are insecure and not supported.
CA validation is disabled when pinning a fingerprint.
SHA1-, SHA256- or MD5-Fingerprints can be used. They're detected by their
length.
You can use the following command for obtaining a SHA256 fingerprint::
You can use the following command for obtaining a SHA-1 fingerprint::
echo -n | openssl s_client -connect unterwaditzer.net:443 | openssl x509 -noout -fingerprint -sha256
echo -n | openssl s_client -connect unterwaditzer.net:443 | openssl x509 -noout -fingerprint
Note that ``verify_fingerprint`` doesn't suffice for vdirsyncer to work with
self-signed certificates (or certificates that are not in your trust store). You
most likely need to set ``verify = false`` as well. This disables verification
of the SSL certificate's expiration time and the existence of it in your trust
store, all that's verified now is the fingerprint.
However, please consider using `Let's Encrypt <https://letsencrypt.org/>`_ such
that you can forget about all of that. It is easier to deploy a free
@ -40,16 +47,22 @@ To point vdirsyncer to a custom set of root CAs::
...
verify = "/path/to/cert.pem"
Vdirsyncer uses the aiohttp_ library, which uses the default `ssl.SSLContext
https://docs.python.org/3/library/ssl.html#ssl.SSLContext`_ by default.
Vdirsyncer uses the requests_ library, which, by default, `uses its own set of
trusted CAs
<http://www.python-requests.org/en/latest/user/advanced/#ca-certificates>`_.
There are cases where certificate validation fails even though you can access
the server fine through e.g. your browser. This usually indicates that your
installation of ``python`` or the ``aiohttp`` or library is somehow broken. In
such cases, it makes sense to explicitly set ``verify`` or
``verify_fingerprint`` as shown above.
However, the actual behavior depends on how you have installed it. Many Linux
distributions patch their ``python-requests`` package to use the system
certificate CAs. Normally these two stores are similar enough for you to not
care.
.. _aiohttp: https://docs.aiohttp.org/en/stable/index.html
But there are cases where certificate validation fails even though you can
access the server fine through e.g. your browser. This usually indicates that
your installation of the ``requests`` library is somehow broken. In such cases,
it makes sense to explicitly set ``verify`` or ``verify_fingerprint`` as shown
above.
.. _requests: http://www.python-requests.org/
.. _ssl-client-certs:

View file

@ -16,7 +16,7 @@ Configuration
.. note::
- The `config.example from the repository
<https://github.com/pimutils/vdirsyncer/blob/main/config.example>`_
<https://github.com/pimutils/vdirsyncer/blob/master/config.example>`_
contains a very terse version of this.
- In this example we set up contacts synchronization, but calendar sync
@ -53,7 +53,8 @@ pairs of storages should actually be synchronized is defined in :ref:`pair
section <pair_config>`. This format is copied from OfflineIMAP, where storages
are called repositories and pairs are called accounts.
The following example synchronizes ownCloud's addressbooks to ``~/.contacts/``::
The following example synchronizes addressbooks from a :doc:`NextCloud
<tutorials/nextcloud>` to ``~/.contacts/``::
[pair my_contacts]
@ -70,7 +71,7 @@ The following example synchronizes ownCloud's addressbooks to ``~/.contacts/``::
type = "carddav"
# We can simplify this URL here as well. In theory it shouldn't matter.
url = "https://owncloud.example.com/remote.php/carddav/"
url = "https://nextcloud.example.com/"
username = "bob"
password = "asdf"
@ -162,13 +163,13 @@ let's switch to a different base example. This time we'll synchronize calendars:
[storage my_calendars_remote]
type = "caldav"
url = "https://owncloud.example.com/remote.php/caldav/"
url = "https://nextcloud.example.com/"
username = "bob"
password = "asdf"
Run ``vdirsyncer discover`` for discovery. Then you can use ``vdirsyncer
metasync`` to synchronize the ``color`` property between your local calendars
in ``~/.calendars/`` and your ownCloud. Locally the color is just represented
in ``~/.calendars/`` and your NextCloud. Locally the color is just represented
as a file called ``color`` within the calendar folder.
.. _collections_tutorial:
@ -176,11 +177,8 @@ as a file called ``color`` within the calendar folder.
More information about collections
----------------------------------
"Collection" is a collective term for addressbooks and calendars. A Cardav or
Caldav server can contains several "collections" which correspond to several
addressbooks or calendar.
Each collection from a storage has a "collection name", a unique identifier for each
"Collection" is a collective term for addressbooks and calendars. Each
collection from a storage has a "collection name", a unique identifier for each
collection. In the case of :storage:`filesystem`-storage, this is the name of the
directory that represents the collection, in the case of the DAV-storages this
is the last segment of the URL. We use this identifier in the ``collections``

View file

@ -1,10 +0,0 @@
======
Baikal
======
Vdirsyncer is continuously tested against the latest version of Baikal_.
- Baikal up to ``0.2.7`` also uses an old version of SabreDAV, with the same
issue as ownCloud, see :gh:`160`. This issue is fixed in later versions.
.. _Baikal: http://sabre.io/baikal/

View file

@ -52,7 +52,7 @@ this:
setup. We also set the storage to read-only such that no changes get
synchronized back. Claws-Mail should not be able to do any changes anyway,
but this is one extra safety step in case files get corrupted or vdirsyncer
behaves erratically. You can leave that part out if you want to be able to
behaves eratically. You can leave that part out if you want to be able to
edit those files locally.
- In the last section we configure that online contacts win in a conflict
situation. Configure this part however you like. A correct value depends on
@ -69,7 +69,7 @@ Now we discover and sync our contacts::
Claws Mail
----------
Open Claws-Mail. Go to **Tools** => **Addressbook**.
Open Claws-Mail. Got to **Tools** => **Addressbook**.
Click on **Addressbook** => **New vCard**. Choose a name for the book.
@ -77,7 +77,7 @@ Then search for the for the vCard in the folder **~/.contacts/**. Click
ok, and you we will see your contacts.
.. note::
Claws-Mail shows only contacts that have a mail address.
Crontab

View file

@ -17,7 +17,7 @@ Exchange server you might get confronted with weird errors of all sorts
type = "caldav"
url = "http://localhost:1080/users/user@example.com/calendar/"
username = "user@example.com"
password = "..."
password = ...
- Older versions of DavMail handle URLs case-insensitively. See :gh:`144`.
- DavMail is handling malformed data on the Exchange server very poorly. In

View file

@ -10,14 +10,14 @@ the settings to use::
[storage cal]
type = "caldav"
url = "https://caldav.fastmail.com/"
username = "..."
password = "..."
url = "https://caldav.messagingengine.com/"
username = ...
password = ...
[storage card]
type = "carddav"
url = "https://carddav.fastmail.com/"
username = "..."
password = "..."
url = "https://carddav.messagingengine.com/"
username = ...
password = ...
.. _FastMail: https://www.fastmail.com/

View file

@ -11,14 +11,14 @@ Vdirsyncer is regularly tested against iCloud_.
[storage cal]
type = "caldav"
url = "https://caldav.icloud.com/"
username = "..."
password = "..."
username = ...
password = ...
[storage card]
type = "carddav"
url = "https://contacts.icloud.com/"
username = "..."
password = "..."
username = ...
password = ...
Problems:

View file

@ -37,7 +37,7 @@ Further applications, with missing pages:
.. _khal: http://lostpackets.de/khal/
.. _dayplanner: http://www.day-planner.org/
.. _Orage: https://gitlab.xfce.org/apps/orage
.. _Orage: http://www.kolumbus.fi/~w408237/orage/
.. _rainlendar: http://www.rainlendar.net/
.. _khard: https://github.com/scheibler/khard/
.. _contactquery.c: https://github.com/t-8ch/snippets/blob/master/contactquery.c
@ -52,12 +52,10 @@ Servers
.. toctree::
:maxdepth: 1
baikal
davmail
fastmail
google
icloud
nextcloud
owncloud
radicale
xandikos

View file

@ -1,14 +1,14 @@
=========
nextCloud
NextCloud
=========
Vdirsyncer is continuously tested against the latest version of nextCloud_::
Vdirsyncer is continuously tested against the latest version of NextCloud_::
[storage cal]
type = "caldav"
url = "https://nextcloud.example.com/"
username = "..."
password = "..."
username = ...
password = ...
[storage card]
type = "carddav"
@ -17,4 +17,4 @@ Vdirsyncer is continuously tested against the latest version of nextCloud_::
- WebCAL-subscriptions can't be discovered by vdirsyncer. See `this relevant
issue <https://github.com/nextcloud/calendar/issues/63>`_.
.. _nextCloud: https://nextcloud.com/
.. _NextCloud: https://nextcloud.com/

View file

@ -1,26 +0,0 @@
.. _owncloud_setup:
========
ownCloud
========
Vdirsyncer is continuously tested against the latest version of ownCloud_::
[storage cal]
type = "caldav"
url = "https://example.com/remote.php/dav/"
username = ...
password = ...
[storage card]
type = "carddav"
url = "https://example.com/remote.php/dav/"
username = ...
password = ...
- *Versions older than 7.0.0:* ownCloud uses SabreDAV, which had problems
detecting collisions and race-conditions. The problems were reported and are
fixed in SabreDAV's repo, and the corresponding fix is also in ownCloud since
7.0.0. See :gh:`16` for more information.
.. _ownCloud: https://owncloud.org/

View file

@ -13,8 +13,8 @@ minutes).
unit files, you'll need to download vdirsyncer.service_ and vdirsyncer.timer_
into either ``/etc/systemd/user/`` or ``~/.local/share/systemd/user``.
.. _vdirsyncer.service: https://raw.githubusercontent.com/pimutils/vdirsyncer/main/contrib/vdirsyncer.service
.. _vdirsyncer.timer: https://raw.githubusercontent.com/pimutils/vdirsyncer/main/contrib/vdirsyncer.timer
.. _vdirsyncer.service: https://raw.githubusercontent.com/pimutils/vdirsyncer/master/contrib/vdirsyncer.service
.. _vdirsyncer.timer: https://raw.githubusercontent.com/pimutils/vdirsyncer/master/contrib/vdirsyncer.timer
Activation
----------
@ -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.timer
systemctl --user edit vdirsyncer
This will open a blank editor, where you can override the timer by including::

View file

@ -37,8 +37,8 @@ Assuming a config like this::
[storage calendars_dav]
type = "caldav"
url = "https://nextcloud.example.net/"
username = "..."
password = "..."
username = ...
password = ...
``vdirsyncer sync`` will then synchronize the calendars of your NextCloud_
instance to subfolders of ``~/.calendar/``.
@ -48,9 +48,10 @@ instance to subfolders of ``~/.calendar/``.
Setting up todoman
==================
Write this to ``~/.config/todoman/config.py``::
Write this to ``~/.config/todoman/todoman.conf``::
path = "~/.calendars/*"
[main]
path = ~/.calendars/*
The glob_ pattern in ``path`` will match all subfolders in ``~/.calendars/``,
which is exactly the tasklists we want. Now you can use ``todoman`` as

View file

@ -11,13 +11,13 @@ point vdirsyncer against the root of Xandikos like this::
[storage cal]
type = "caldav"
url = "https://xandikos.example.com/"
username = "..."
password = "..."
username = ...
password = ...
[storage card]
type = "carddav"
url = "https://xandikos.example.com/"
username = "..."
password = "..."
username = ...
password = ...
.. _Xandikos: https://github.com/jelmer/xandikos

View file

@ -56,11 +56,8 @@ have any file extensions.
known from CSS, for example) are allowed. The prefixing ``#`` must be
present.
- Files called ``displayname`` and ``description`` contain a UTF-8 encoded label/
description, that may be used to represent the vdir in UIs.
- A file called ``order`` inside the vdir includes the relative order
of the calendar, a property that is only relevant in UI design.
- A file called ``displayname`` contains a UTF-8 encoded label that may be used
to represent the vdir in UIs.
Writing to vdirs
================
@ -99,7 +96,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, an
become out of date because of bad implementations. As it stands right now, a
index format could be formalized separately though.
vdirsyncer doesn't really have to bother about efficient item lookup, because

View file

@ -39,7 +39,7 @@ program chosen:
* Like with ``todo.txt``, Dropbox and friends are obviously agnostic/unaware of
the files' contents. If a file has changed on both sides, Dropbox just copies
both versions to both sides.
This is a good idea if the user is directly interfacing with the file system
and is able to resolve conflicts themselves. Here it might lead to
erroneous behavior with e.g. ``khal``, since there are now two events with
@ -50,6 +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. DAVx⁵_ or other apps bundled with smartphones.
e.g. DAVDroid_ or the apps by dmfs_.
.. _DAVx⁵: https://www.davx5.com/
.. _DAVDroid: http://davdroid.bitfire.at/
.. _dmfs: https://dmfs.org/

View file

@ -1,29 +0,0 @@
# Push new version to PyPI.
#
# Usage: hut builds submit publish-release.yaml --follow
image: alpine/edge
packages:
- py3-build
- py3-pip
- py3-setuptools
- py3-setuptools_scm
- py3-wheel
- twine
sources:
- https://github.com/pimutils/vdirsyncer
secrets:
- a36c8ba3-fba0-4338-b402-6aea0fbe771e # PyPI token.
environment:
CI: true
tasks:
- check-tag: |
cd vdirsyncer
git fetch --tags
# Stop here unless this is a tag.
git describe --exact-match --tags || complete-build
- publish: |
cd vdirsyncer
python -m build --no-isolation
twine upload --non-interactive dist/*

View file

@ -1,114 +0,0 @@
# Vdirsyncer synchronizes calendars and contacts.
#
# Please refer to https://vdirsyncer.pimutils.org/en/stable/packaging.html for
# how to package vdirsyncer.
[build-system]
requires = ["setuptools>=64", "setuptools_scm>=8"]
build-backend = "setuptools.build_meta"
[project]
name = "vdirsyncer"
authors = [
{name = "Markus Unterwaditzer", email = "markus@unterwaditzer.net"},
]
description = "Synchronize calendars and contacts"
readme = "README.rst"
requires-python = ">=3.9"
keywords = ["todo", "task", "icalendar", "cli"]
license = "BSD-3-Clause"
license-files = ["LICENSE"]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Operating System :: POSIX",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.9",
"Topic :: Internet",
"Topic :: Office/Business :: Scheduling",
"Topic :: Utilities",
]
dependencies = [
"click>=5.0,<9.0",
"click-log>=0.3.0,<0.5.0",
"requests>=2.20.0",
"aiohttp>=3.8.2,<4.0.0",
"aiostream>=0.4.3,<0.8.0",
"tenacity>=9.0.0",
]
dynamic = ["version"]
[project.optional-dependencies]
google = ["aiohttp-oauthlib"]
test = [
"hypothesis>=6.72.0,<7.0.0",
"pytest",
"pytest-cov",
"pytest-httpserver",
"trustme",
"pytest-asyncio",
"aioresponses",
]
docs = [
"sphinx!=1.4.7",
"sphinx_rtd_theme",
"setuptools_scm",
]
check = [
"mypy",
"ruff",
"types-docutils",
"types-requests",
"types-setuptools",
]
[project.scripts]
vdirsyncer = "vdirsyncer.cli:app"
[tool.ruff.lint]
extend-select = [
"B0",
"C4",
"E",
"I",
"RSE",
"SIM",
"TID",
"UP",
"W",
]
[tool.ruff.lint.isort]
force-single-line = true
required-imports = ["from __future__ import annotations"]
[tool.pytest.ini_options]
addopts = """
--tb=short
--cov-config .coveragerc
--cov=vdirsyncer
--cov-report=term-missing:skip-covered
--no-cov-on-fail
--color=yes
"""
# filterwarnings=error
asyncio_default_fixture_loop_scope = "function"
[tool.mypy]
ignore_missing_imports = true
[tool.coverage.report]
exclude_lines = [
"if TYPE_CHECKING:",
]
[tool.setuptools.packages.find]
include = ["vdirsyncer*"]
[tool.setuptools_scm]
write_to = "vdirsyncer/version.py"
version_scheme = "no-guess-dev"

2
rust/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
target/
src/storage/exports.rs

501
rust/Cargo.lock generated Normal file
View file

@ -0,0 +1,501 @@
[[package]]
name = "ansi_term"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "atomicwrites"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"kernel32-sys 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)",
"nix 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)",
"tempdir 0.3.5 (registry+https://github.com/rust-lang/crates.io-index)",
"winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "atty"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"libc 0.2.35 (registry+https://github.com/rust-lang/crates.io-index)",
"termion 1.5.1 (registry+https://github.com/rust-lang/crates.io-index)",
"winapi 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "backtrace"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"backtrace-sys 0.1.16 (registry+https://github.com/rust-lang/crates.io-index)",
"cfg-if 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)",
"libc 0.2.35 (registry+https://github.com/rust-lang/crates.io-index)",
"rustc-demangle 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)",
"winapi 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "backtrace-sys"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"cc 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)",
"libc 0.2.35 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "bitflags"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "bitflags"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "cbindgen"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"clap 2.29.1 (registry+https://github.com/rust-lang/crates.io-index)",
"log 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)",
"serde 1.0.27 (registry+https://github.com/rust-lang/crates.io-index)",
"serde_json 1.0.9 (registry+https://github.com/rust-lang/crates.io-index)",
"syn 0.11.11 (registry+https://github.com/rust-lang/crates.io-index)",
"tempdir 0.3.5 (registry+https://github.com/rust-lang/crates.io-index)",
"toml 0.4.5 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "cc"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "cfg-if"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "clap"
version = "2.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"ansi_term 0.10.2 (registry+https://github.com/rust-lang/crates.io-index)",
"atty 0.2.6 (registry+https://github.com/rust-lang/crates.io-index)",
"bitflags 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)",
"strsim 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)",
"textwrap 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)",
"unicode-width 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)",
"vec_map 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "coco"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"either 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
"scopeguard 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "dtoa"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "either"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "error-chain"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"backtrace 0.3.5 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "fuchsia-zircon"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"bitflags 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)",
"fuchsia-zircon-sys 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "fuchsia-zircon-sys"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "gcc"
version = "0.3.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "itoa"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "kernel32-sys"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)",
"winapi-build 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "lazy_static"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "libc"
version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "log"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"log 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "log"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"cfg-if 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "nix"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"bitflags 0.9.1 (registry+https://github.com/rust-lang/crates.io-index)",
"cfg-if 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)",
"libc 0.2.35 (registry+https://github.com/rust-lang/crates.io-index)",
"void 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "num-traits"
version = "0.1.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "num_cpus"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"libc 0.2.35 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "quote"
version = "0.3.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "rand"
version = "0.3.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"fuchsia-zircon 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)",
"libc 0.2.35 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "rayon"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"rayon-core 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "rayon-core"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"coco 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
"lazy_static 0.2.11 (registry+https://github.com/rust-lang/crates.io-index)",
"libc 0.2.35 (registry+https://github.com/rust-lang/crates.io-index)",
"num_cpus 1.8.0 (registry+https://github.com/rust-lang/crates.io-index)",
"rand 0.3.20 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "redox_syscall"
version = "0.1.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "redox_termios"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"redox_syscall 0.1.37 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "ring"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"gcc 0.3.54 (registry+https://github.com/rust-lang/crates.io-index)",
"lazy_static 0.2.11 (registry+https://github.com/rust-lang/crates.io-index)",
"libc 0.2.35 (registry+https://github.com/rust-lang/crates.io-index)",
"rayon 0.8.2 (registry+https://github.com/rust-lang/crates.io-index)",
"untrusted 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "rustc-demangle"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "scopeguard"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "serde"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"serde_derive 1.0.27 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "serde_derive"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"quote 0.3.15 (registry+https://github.com/rust-lang/crates.io-index)",
"serde_derive_internals 0.19.0 (registry+https://github.com/rust-lang/crates.io-index)",
"syn 0.11.11 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "serde_derive_internals"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"syn 0.11.11 (registry+https://github.com/rust-lang/crates.io-index)",
"synom 0.11.3 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "serde_json"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"dtoa 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)",
"itoa 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)",
"num-traits 0.1.41 (registry+https://github.com/rust-lang/crates.io-index)",
"serde 1.0.27 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "strsim"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "syn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"quote 0.3.15 (registry+https://github.com/rust-lang/crates.io-index)",
"synom 0.11.3 (registry+https://github.com/rust-lang/crates.io-index)",
"unicode-xid 0.0.4 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "synom"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"unicode-xid 0.0.4 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "tempdir"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"rand 0.3.20 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "termion"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"libc 0.2.35 (registry+https://github.com/rust-lang/crates.io-index)",
"redox_syscall 0.1.37 (registry+https://github.com/rust-lang/crates.io-index)",
"redox_termios 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "textwrap"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"unicode-width 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "toml"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"serde 1.0.27 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "unicode-width"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "unicode-xid"
version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "untrusted"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "vdirsyncer_rustext"
version = "0.1.0"
dependencies = [
"atomicwrites 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)",
"cbindgen 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
"error-chain 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)",
"ring 0.12.1 (registry+https://github.com/rust-lang/crates.io-index)",
"vobject 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "vec_map"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "vobject"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"error-chain 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "void"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "winapi"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "winapi"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"winapi-i686-pc-windows-gnu 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)",
"winapi-x86_64-pc-windows-gnu 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "winapi-build"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
[metadata]
"checksum ansi_term 0.10.2 (registry+https://github.com/rust-lang/crates.io-index)" = "6b3568b48b7cefa6b8ce125f9bb4989e52fbcc29ebea88df04cc7c5f12f70455"
"checksum atomicwrites 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "4560dd4eadad8c80a88e25426f96a74ad62c95d4ee424226803013c0ba94f1cf"
"checksum atty 0.2.6 (registry+https://github.com/rust-lang/crates.io-index)" = "8352656fd42c30a0c3c89d26dea01e3b77c0ab2af18230835c15e2e13cd51859"
"checksum backtrace 0.3.5 (registry+https://github.com/rust-lang/crates.io-index)" = "ebbbf59b1c43eefa8c3ede390fcc36820b4999f7914104015be25025e0d62af2"
"checksum backtrace-sys 0.1.16 (registry+https://github.com/rust-lang/crates.io-index)" = "44585761d6161b0f57afc49482ab6bd067e4edef48c12a152c237eb0203f7661"
"checksum bitflags 0.9.1 (registry+https://github.com/rust-lang/crates.io-index)" = "4efd02e230a02e18f92fc2735f44597385ed02ad8f831e7c1c1156ee5e1ab3a5"
"checksum bitflags 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "b3c30d3802dfb7281680d6285f2ccdaa8c2d8fee41f93805dba5c4cf50dc23cf"
"checksum cbindgen 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "370c18a61741bd716377aba3fc42d78788df5d1af5e4bfbe22926013bd91d50a"
"checksum cc 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)" = "deaf9ec656256bb25b404c51ef50097207b9cbb29c933d31f92cae5a8a0ffee0"
"checksum cfg-if 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "d4c819a1287eb618df47cc647173c5c4c66ba19d888a6e50d605672aed3140de"
"checksum clap 2.29.1 (registry+https://github.com/rust-lang/crates.io-index)" = "8f4a2b3bb7ef3c672d7c13d15613211d5a6976b6892c598b0fcb5d40765f19c2"
"checksum coco 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "c06169f5beb7e31c7c67ebf5540b8b472d23e3eade3b2ec7d1f5b504a85f91bd"
"checksum dtoa 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)" = "09c3753c3db574d215cba4ea76018483895d7bff25a31b49ba45db21c48e50ab"
"checksum either 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "740178ddf48b1a9e878e6d6509a1442a2d42fd2928aae8e7a6f8a36fb01981b3"
"checksum error-chain 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ff511d5dc435d703f4971bc399647c9bc38e20cb41452e3b9feb4765419ed3f3"
"checksum fuchsia-zircon 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)" = "2e9763c69ebaae630ba35f74888db465e49e259ba1bc0eda7d06f4a067615d82"
"checksum fuchsia-zircon-sys 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)" = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7"
"checksum gcc 0.3.54 (registry+https://github.com/rust-lang/crates.io-index)" = "5e33ec290da0d127825013597dbdfc28bee4964690c7ce1166cbc2a7bd08b1bb"
"checksum itoa 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)" = "8324a32baf01e2ae060e9de58ed0bc2320c9a2833491ee36cd3b4c414de4db8c"
"checksum kernel32-sys 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d"
"checksum lazy_static 0.2.11 (registry+https://github.com/rust-lang/crates.io-index)" = "76f033c7ad61445c5b347c7382dd1237847eb1bce590fe50365dcb33d546be73"
"checksum libc 0.2.35 (registry+https://github.com/rust-lang/crates.io-index)" = "96264e9b293e95d25bfcbbf8a88ffd1aedc85b754eba8b7d78012f638ba220eb"
"checksum log 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)" = "e19e8d5c34a3e0e2223db8e060f9e8264aeeb5c5fc64a4ee9965c062211c024b"
"checksum log 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)" = "89f010e843f2b1a31dbd316b3b8d443758bc634bed37aabade59c686d644e0a2"
"checksum nix 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)" = "a2c5afeb0198ec7be8569d666644b574345aad2e95a53baf3a532da3e0f3fb32"
"checksum num-traits 0.1.41 (registry+https://github.com/rust-lang/crates.io-index)" = "cacfcab5eb48250ee7d0c7896b51a2c5eec99c1feea5f32025635f5ae4b00070"
"checksum num_cpus 1.8.0 (registry+https://github.com/rust-lang/crates.io-index)" = "c51a3322e4bca9d212ad9a158a02abc6934d005490c054a2778df73a70aa0a30"
"checksum quote 0.3.15 (registry+https://github.com/rust-lang/crates.io-index)" = "7a6e920b65c65f10b2ae65c831a81a073a89edd28c7cce89475bff467ab4167a"
"checksum rand 0.3.20 (registry+https://github.com/rust-lang/crates.io-index)" = "512870020642bb8c221bf68baa1b2573da814f6ccfe5c9699b1c303047abe9b1"
"checksum rayon 0.8.2 (registry+https://github.com/rust-lang/crates.io-index)" = "b614fe08b6665cb9a231d07ac1364b0ef3cb3698f1239ee0c4c3a88a524f54c8"
"checksum rayon-core 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "e64b609139d83da75902f88fd6c01820046840a18471e4dfcd5ac7c0f46bea53"
"checksum redox_syscall 0.1.37 (registry+https://github.com/rust-lang/crates.io-index)" = "0d92eecebad22b767915e4d529f89f28ee96dbbf5a4810d2b844373f136417fd"
"checksum redox_termios 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "7e891cfe48e9100a70a3b6eb652fef28920c117d366339687bd5576160db0f76"
"checksum ring 0.12.1 (registry+https://github.com/rust-lang/crates.io-index)" = "6f7d28b30a72c01b458428e0ae988d4149c20d902346902be881e3edc4bb325c"
"checksum rustc-demangle 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)" = "aee45432acc62f7b9a108cc054142dac51f979e69e71ddce7d6fc7adf29e817e"
"checksum scopeguard 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)" = "94258f53601af11e6a49f722422f6e3425c52b06245a5cf9bc09908b174f5e27"
"checksum serde 1.0.27 (registry+https://github.com/rust-lang/crates.io-index)" = "db99f3919e20faa51bb2996057f5031d8685019b5a06139b1ce761da671b8526"
"checksum serde_derive 1.0.27 (registry+https://github.com/rust-lang/crates.io-index)" = "f4ba7591cfe93755e89eeecdbcc668885624829b020050e6aec99c2a03bd3fd0"
"checksum serde_derive_internals 0.19.0 (registry+https://github.com/rust-lang/crates.io-index)" = "6e03f1c9530c3fb0a0a5c9b826bdd9246a5921ae995d75f512ac917fc4dd55b5"
"checksum serde_json 1.0.9 (registry+https://github.com/rust-lang/crates.io-index)" = "c9db7266c7d63a4c4b7fe8719656ccdd51acf1bed6124b174f933b009fb10bcb"
"checksum strsim 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "b4d15c810519a91cf877e7e36e63fe068815c678181439f2f29e2562147c3694"
"checksum syn 0.11.11 (registry+https://github.com/rust-lang/crates.io-index)" = "d3b891b9015c88c576343b9b3e41c2c11a51c219ef067b264bd9c8aa9b441dad"
"checksum synom 0.11.3 (registry+https://github.com/rust-lang/crates.io-index)" = "a393066ed9010ebaed60b9eafa373d4b1baac186dd7e008555b0f702b51945b6"
"checksum tempdir 0.3.5 (registry+https://github.com/rust-lang/crates.io-index)" = "87974a6f5c1dfb344d733055601650059a3363de2a6104819293baff662132d6"
"checksum termion 1.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "689a3bdfaab439fd92bc87df5c4c78417d3cbe537487274e9b0b2dce76e92096"
"checksum textwrap 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)" = "c0b59b6b4b44d867f1370ef1bd91bfb262bf07bf0ae65c202ea2fbc16153b693"
"checksum toml 0.4.5 (registry+https://github.com/rust-lang/crates.io-index)" = "a7540f4ffc193e0d3c94121edb19b055670d369f77d5804db11ae053a45b6e7e"
"checksum unicode-width 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "bf3a113775714a22dcb774d8ea3655c53a32debae63a063acc00a91cc586245f"
"checksum unicode-xid 0.0.4 (registry+https://github.com/rust-lang/crates.io-index)" = "8c1f860d7d29cf02cb2f3f359fd35991af3d30bac52c57d265a3c461074cb4dc"
"checksum untrusted 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "f392d7819dbe58833e26872f5f6f0d68b7bbbe90fc3667e98731c4a15ad9a7ae"
"checksum vec_map 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)" = "887b5b631c2ad01628bbbaa7dd4c869f80d3186688f8d0b6f58774fbe324988c"
"checksum vobject 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)" = "6041995691036270fabeb41975ca858f3b5113b82eea19a4f276bfb8b32e9ae4"
"checksum void 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)" = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
"checksum winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)" = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a"
"checksum winapi 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)" = "b09fb3b6f248ea4cd42c9a65113a847d612e17505d6ebd1f7357ad68a8bf8693"
"checksum winapi-build 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc"
"checksum winapi-i686-pc-windows-gnu 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)" = "ec6667f60c23eca65c561e63a13d81b44234c2e38a6b6c959025ee907ec614cc"
"checksum winapi-x86_64-pc-windows-gnu 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)" = "98f12c52b2630cd05d2c3ffd8e008f7f48252c042b4871c72aed9dc733b96668"

18
rust/Cargo.toml Normal file
View file

@ -0,0 +1,18 @@
[package]
name = "vdirsyncer_rustext"
version = "0.1.0"
authors = ["Markus Unterwaditzer <markus@unterwaditzer.net>"]
build = "build.rs"
[lib]
name = "vdirsyncer_rustext"
crate-type = ["cdylib"]
[dependencies]
vobject = "0.4.2"
ring = "0.12.1"
error-chain = "0.11.0"
atomicwrites = "0.1.4"
[build-dependencies]
cbindgen = "0.4"

161
rust/build.rs Normal file
View file

@ -0,0 +1,161 @@
extern crate cbindgen;
use std::env;
use std::fs::{remove_file, File};
use std::io::Write;
use std::path::Path;
const TEMPLATE_EACH: &'static str = r#"
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_{name}_list(
storage: *mut {path},
err: *mut VdirsyncerError
) -> *mut VdirsyncerStorageListing {
match (*storage).list() {
Ok(x) => Box::into_raw(Box::new(VdirsyncerStorageListing {
iterator: x,
href: None,
etag: None
})),
Err(e) => {
e.fill_c_err(err);
mem::zeroed()
}
}
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_{name}_get(
storage: *mut {path},
c_href: *const c_char,
err: *mut VdirsyncerError
) -> *mut VdirsyncerStorageGetResult {
let href = CStr::from_ptr(c_href);
match (*storage).get(href.to_str().unwrap()) {
Ok((item, href)) => {
Box::into_raw(Box::new(VdirsyncerStorageGetResult {
item: Box::into_raw(Box::new(item)),
etag: CString::new(href).unwrap().into_raw()
}))
},
Err(e) => {
e.fill_c_err(err);
mem::zeroed()
}
}
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_{name}_upload(
storage: *mut {path},
item: *mut Item,
err: *mut VdirsyncerError
) -> *mut VdirsyncerStorageUploadResult {
match (*storage).upload((*item).clone()) {
Ok((href, etag)) => {
Box::into_raw(Box::new(VdirsyncerStorageUploadResult {
href: CString::new(href).unwrap().into_raw(),
etag: CString::new(etag).unwrap().into_raw()
}))
},
Err(e) => {
e.fill_c_err(err);
mem::zeroed()
}
}
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_{name}_update(
storage: *mut {path},
c_href: *const c_char,
item: *mut Item,
c_etag: *const c_char,
err: *mut VdirsyncerError
) -> *const c_char {
let href = CStr::from_ptr(c_href);
let etag = CStr::from_ptr(c_etag);
match (*storage).update(href.to_str().unwrap(), (*item).clone(), etag.to_str().unwrap()) {
Ok(etag) => CString::new(etag).unwrap().into_raw(),
Err(e) => {
e.fill_c_err(err);
mem::zeroed()
}
}
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_{name}_delete(
storage: *mut {path},
c_href: *const c_char,
c_etag: *const c_char,
err: *mut VdirsyncerError
) {
let href = CStr::from_ptr(c_href);
let etag = CStr::from_ptr(c_etag);
match (*storage).delete(href.to_str().unwrap(), etag.to_str().unwrap()) {
Ok(()) => (),
Err(e) => e.fill_c_err(err)
}
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_{name}_buffered(storage: *mut {path}) {
(*storage).buffered();
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_{name}_flush(
storage: *mut {path},
err: *mut VdirsyncerError
) {
match (*storage).flush() {
Ok(_) => (),
Err(e) => e.fill_c_err(err)
}
}
"#;
fn export_storage(f: &mut File, name: &str, path: &str) {
// String formatting in rust is at compile time. That doesn't work well for our case.
write!(
f,
"{}",
TEMPLATE_EACH
.replace("{name}", name)
.replace("{path}", path)
).unwrap();
}
fn main() {
let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
let mut f = File::create(Path::new(&crate_dir).join("src/storage/exports.rs")).unwrap();
write!(f, "// Auto-generated, do not check in.\n").unwrap();
write!(f, "use std::os::raw::c_char;\n").unwrap();
write!(f, "use std::mem;\n").unwrap();
write!(f, "use std::ffi::{{CStr, CString}};\n").unwrap();
write!(f, "use errors::*;\n").unwrap();
write!(f, "use item::Item;\n").unwrap();
write!(f, "use super::VdirsyncerStorageListing;\n").unwrap();
write!(f, "use super::VdirsyncerStorageGetResult;\n").unwrap();
write!(f, "use super::VdirsyncerStorageUploadResult;\n").unwrap();
write!(f, "use super::Storage;\n").unwrap();
write!(f, "use super::singlefile;\n").unwrap();
export_storage(&mut f, "singlefile", "singlefile::SinglefileStorage");
drop(f);
let _ = remove_file(Path::new(&crate_dir).join("target/vdirsyncer_rustext.h"));
let res = cbindgen::Builder::new()
.with_crate(crate_dir)
.with_language(cbindgen::Language::C)
.generate();
match res {
Ok(x) => x.write_to_file("target/vdirsyncer_rustext.h"),
Err(e) => println!("FAILED TO GENERATE BINDINGS: {:?}", e),
}
}

258
rust/src/item.rs Normal file
View file

@ -0,0 +1,258 @@
use vobject;
use ring;
use std::fmt::Write;
use errors::*;
#[derive(Clone)]
pub enum Item {
Parsed(vobject::Component),
Unparseable(String), // FIXME: maybe use https://crates.io/crates/terminated
}
impl Item {
pub fn from_raw(raw: String) -> Self {
match vobject::parse_component(&raw) {
Ok(x) => Item::Parsed(x),
// Don't chain vobject error here because it cannot be stored/cloned FIXME
_ => Item::Unparseable(raw),
}
}
pub fn from_component(component: vobject::Component) -> Self {
Item::Parsed(component)
}
/// Global identifier of the item, across storages, doesn't change after a modification of the
/// item.
pub fn get_uid(&self) -> Option<String> {
// FIXME: Cache
if let Item::Parsed(ref c) = *self {
let mut stack: Vec<&vobject::Component> = vec![c];
while let Some(vobj) = stack.pop() {
if let Some(prop) = vobj.get_only("UID") {
return Some(prop.value_as_string());
}
stack.extend(vobj.subcomponents.iter());
}
}
None
}
pub fn with_uid(&self, uid: &str) -> Result<Self> {
if let Item::Parsed(ref component) = *self {
let mut new_component = component.clone();
change_uid(&mut new_component, uid);
Ok(Item::from_raw(vobject::write_component(&new_component)))
} else {
Err(ErrorKind::ItemUnparseable.into())
}
}
/// Raw unvalidated content of the item
pub fn get_raw(&self) -> String {
match *self {
Item::Parsed(ref component) => vobject::write_component(component),
Item::Unparseable(ref x) => x.to_owned(),
}
}
/// Component of item if parseable
pub fn get_component(&self) -> Result<&vobject::Component> {
match *self {
Item::Parsed(ref component) => Ok(component),
_ => Err(ErrorKind::ItemUnparseable.into()),
}
}
/// Component of item if parseable
pub fn into_component(self) -> Result<vobject::Component> {
match self {
Item::Parsed(component) => Ok(component),
_ => Err(ErrorKind::ItemUnparseable.into()),
}
}
/// Used for etags
pub fn get_hash(&self) -> Result<String> {
// FIXME: cache
if let Item::Parsed(ref component) = *self {
Ok(hash_component(component))
} else {
Err(ErrorKind::ItemUnparseable.into())
}
}
/// Used for generating hrefs and matching up items during synchronization. This is either the
/// UID or the hash of the item's content.
pub fn get_ident(&self) -> Result<String> {
if let Some(x) = self.get_uid() {
return Ok(x);
}
// We hash the item instead of directly using its raw content, because
// 1. The raw content might be really large, e.g. when it's a contact
// with a picture, which bloats the status file.
//
// 2. The status file would contain really sensitive information.
self.get_hash()
}
pub fn is_parseable(&self) -> bool {
if let Item::Parsed(_) = *self {
true
} else {
false
}
}
}
fn change_uid(c: &mut vobject::Component, uid: &str) {
let mut stack = vec![c];
while let Some(component) = stack.pop() {
match component.name.as_ref() {
"VEVENT" | "VTODO" | "VJOURNAL" | "VCARD" => {
if !uid.is_empty() {
component.set(vobject::Property::new("UID", uid));
} else {
component.remove("UID");
}
}
_ => (),
}
stack.extend(component.subcomponents.iter_mut());
}
}
fn hash_component(c: &vobject::Component) -> String {
let mut new_c = c.clone();
{
let mut stack = vec![&mut new_c];
while let Some(component) = stack.pop() {
// PRODID is changed by radicale for some reason after upload
component.remove("PRODID");
// Sometimes METHOD:PUBLISH is added by WebCAL providers, for us it doesn't make a difference
component.remove("METHOD");
// X-RADICALE-NAME is used by radicale, because hrefs don't really exist in their filesystem backend
component.remove("X-RADICALE-NAME");
// Those are from the VCARD specification and is supposed to change when the
// item does -- however, we can determine that ourselves
component.remove("REV");
component.remove("LAST-MODIFIED");
component.remove("CREATED");
// Some iCalendar HTTP calendars generate the DTSTAMP at request time, so
// this property always changes when the rest of the item didn't. Some do
// the same with the UID.
//
// - Google's read-only calendar links
// - http://www.feiertage-oesterreich.at/
component.remove("DTSTAMP");
component.remove("UID");
if component.name == "VCALENDAR" {
// CALSCALE's default value is gregorian
let calscale = component.get_only("CALSCALE").map(|x| x.value_as_string());
if let Some(x) = calscale {
if x == "GREGORIAN" {
component.remove("CALSCALE");
}
}
// Apparently this is set by Horde?
// https://github.com/pimutils/vdirsyncer/issues/318
// Also Google sets those properties
component.remove("X-WR-CALNAME");
component.remove("X-WR-TIMEZONE");
component.subcomponents.retain(|c| c.name != "VTIMEZONE");
}
stack.extend(component.subcomponents.iter_mut());
}
}
// FIXME: Possible optimization: Stream component to hasher instead of allocating new string
let raw = vobject::write_component(&new_c);
let mut lines: Vec<_> = raw.lines().collect();
lines.sort();
let digest = ring::digest::digest(&ring::digest::SHA256, lines.join("\r\n").as_bytes());
let mut rv = String::new();
for &byte in digest.as_ref() {
write!(&mut rv, "{:x}", byte).unwrap();
}
rv
}
pub mod exports {
use super::Item;
use std::mem;
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use errors::*;
const EMPTY_STRING: *const c_char = b"\0" as *const u8 as *const c_char;
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_get_uid(c: *mut Item) -> *const c_char {
match (*c).get_uid() {
Some(x) => CString::new(x).unwrap().into_raw(),
None => EMPTY_STRING,
}
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_get_raw(c: *mut Item) -> *const c_char {
CString::new((*c).get_raw()).unwrap().into_raw()
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_item_from_raw(s: *const c_char) -> *mut Item {
let cstring = CStr::from_ptr(s);
Box::into_raw(Box::new(Item::from_raw(
cstring.to_str().unwrap().to_owned(),
)))
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_free_item(c: *mut Item) {
let _: Box<Item> = Box::from_raw(c);
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_with_uid(
c: *mut Item,
uid: *const c_char,
err: *mut VdirsyncerError,
) -> *mut Item {
let uid_cstring = CStr::from_ptr(uid);
match (*c).with_uid(uid_cstring.to_str().unwrap()) {
Ok(x) => Box::into_raw(Box::new(x)),
Err(e) => {
e.fill_c_err(err);
mem::zeroed()
}
}
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_get_hash(
c: *mut Item,
err: *mut VdirsyncerError,
) -> *const c_char {
match (*c).get_hash() {
Ok(x) => CString::new(x).unwrap().into_raw(),
Err(e) => {
e.fill_c_err(err);
mem::zeroed()
}
}
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_item_is_parseable(c: *mut Item) -> bool {
(*c).is_parseable()
}
}

104
rust/src/lib.rs Normal file
View file

@ -0,0 +1,104 @@
extern crate atomicwrites;
#[macro_use]
extern crate error_chain;
extern crate ring;
extern crate vobject;
pub mod item;
pub mod storage;
mod errors {
use std::ffi::CString;
use std::os::raw::c_char;
use vobject;
use atomicwrites;
error_chain!{
links {
Vobject(vobject::error::VObjectError, vobject::error::VObjectErrorKind);
}
foreign_links {
Io(::std::io::Error);
}
errors {
ItemUnparseable {
description("ItemUnparseable: The item cannot be parsed."),
display("The item cannot be parsed."),
}
VobjectVersionMismatch(first: String, second: String) {
description("Incompatible vobject versions."),
display("Conflict between {} and {}", first, second),
}
UnexpectedVobject(found: String, expected: String) {
description("Unexpected component type"),
display("Found type {}, expected {}", found, expected),
}
ItemNotFound(href: String) {
description("ItemNotFound: The item could not be found"),
display("The item '{}' could not be found", href),
}
AlreadyExisting(href: String) {
description("AlreadyExisting: An item at this href already exists"),
display("The href '{}' is already taken", href),
}
WrongEtag(href: String) {
description("WrongEtag: A wrong etag was provided."),
display("A wrong etag for '{}' was provided. This indicates that two clients are writing data at the same time.", href),
}
MtimeMismatch(filepath: String) {
description("MtimeMismatch: Two programs access the same file."),
display("The mtime of {} has unexpectedly changed. Please close other programs accessing this file.", filepath),
}
}
}
impl From<atomicwrites::Error<Error>> for Error {
fn from(e: atomicwrites::Error<Error>) -> Error {
match e {
atomicwrites::Error::Internal(x) => x.into(),
atomicwrites::Error::User(x) => x,
}
}
}
pub trait ErrorExt: ::std::error::Error {
unsafe fn fill_c_err(&self, err: *mut VdirsyncerError) {
(*err).failed = true;
(*err).msg = CString::new(self.description()).unwrap().into_raw();
}
}
impl ErrorExt for Error {}
#[repr(C)]
pub struct VdirsyncerError {
pub failed: bool,
pub msg: *mut c_char,
}
}
pub mod exports {
use std::ffi::{CStr, CString};
use std::ptr;
use std::os::raw::c_char;
use errors::*;
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_free_str(s: *const c_char) {
CStr::from_ptr(s);
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_clear_err(e: *mut VdirsyncerError) {
CString::from_raw((*e).msg);
(*e).msg = ptr::null_mut();
}
}

174
rust/src/storage/mod.rs Normal file
View file

@ -0,0 +1,174 @@
pub mod singlefile;
pub mod exports;
use std::ffi::CString;
use std::os::raw::c_char;
use errors::*;
use item::Item;
type ItemAndEtag = (Item, String);
pub trait Storage: Sized {
/// returns an iterator of `(href, etag)`
fn list<'a>(&'a mut self) -> Result<Box<Iterator<Item = (String, String)> + 'a>>;
///Fetch a single item.
///
///:param href: href to fetch
///:returns: (item, etag)
///:raises: :exc:`vdirsyncer.exceptions.PreconditionFailed` if item can't be found.
fn get(&mut self, href: &str) -> Result<ItemAndEtag>;
/// Fetch multiple items. Duplicate hrefs must be ignored.
///
/// Functionally similar to `get`, but might bring performance benefits on some storages when
/// used cleverly.
///
/// # Parameters
/// - `hrefs`: list of hrefs to fetch
/// - returns an iterator of `(href, item, etag)`
fn get_multi<'a, I: Iterator<Item = String> + 'a>(
&'a mut self,
hrefs: I,
) -> Box<Iterator<Item = (String, Result<ItemAndEtag>)> + 'a> {
Box::new(DefaultGetMultiIterator {
storage: self,
href_iter: hrefs,
})
}
/// Upload a new item.
///
/// In cases where the new etag cannot be atomically determined (i.e. in the same
/// "transaction" as the upload itself), this method may return `None` as etag. This
/// special case only exists because of DAV. Avoid this situation whenever possible.
///
/// Returns `(href, etag)`
fn upload(&mut self, item: Item) -> Result<(String, String)>;
/// Update an item.
///
/// The etag may be none in some cases, see `upload`.
///
/// Returns `etag`
fn update(&mut self, href: &str, item: Item, etag: &str) -> Result<String>;
/// Delete an item by href.
fn delete(&mut self, href: &str, etag: &str) -> Result<()>;
/// Enter buffered mode for storages that support it.
///
/// Uploads, updates and deletions may not be effective until `flush` is explicitly called.
///
/// Use this if you will potentially write a lot of data to the storage, it improves
/// performance for storages that implement it.
fn buffered(&mut self) {}
/// Write back all changes to the collection.
fn flush(&mut self) -> Result<()> {
Ok(())
}
}
struct DefaultGetMultiIterator<'a, S: Storage + 'a, I: Iterator<Item = String>> {
storage: &'a mut S,
href_iter: I,
}
impl<'a, S, I> Iterator for DefaultGetMultiIterator<'a, S, I>
where
S: Storage,
I: Iterator<Item = String>,
{
type Item = (String, Result<ItemAndEtag>);
fn next(&mut self) -> Option<Self::Item> {
match self.href_iter.next() {
Some(x) => Some((x.to_owned(), self.storage.get(&x))),
None => None,
}
}
}
pub struct VdirsyncerStorageListing {
iterator: Box<Iterator<Item = (String, String)>>,
href: Option<String>,
etag: Option<String>,
}
impl VdirsyncerStorageListing {
pub fn advance(&mut self) -> bool {
match self.iterator.next() {
Some((href, etag)) => {
self.href = Some(href);
self.etag = Some(etag);
true
}
None => {
self.href = None;
self.etag = None;
false
}
}
}
pub fn get_href(&mut self) -> Option<String> {
self.href.take()
}
pub fn get_etag(&mut self) -> Option<String> {
self.etag.take()
}
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_free_storage_listing(listing: *mut VdirsyncerStorageListing) {
let _: Box<VdirsyncerStorageListing> = Box::from_raw(listing);
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_advance_storage_listing(
listing: *mut VdirsyncerStorageListing,
) -> bool {
(*listing).advance()
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_storage_listing_get_href(
listing: *mut VdirsyncerStorageListing,
) -> *const c_char {
CString::new((*listing).get_href().unwrap())
.unwrap()
.into_raw()
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_storage_listing_get_etag(
listing: *mut VdirsyncerStorageListing,
) -> *const c_char {
CString::new((*listing).get_etag().unwrap())
.unwrap()
.into_raw()
}
#[repr(C)]
pub struct VdirsyncerStorageGetResult {
pub item: *mut Item,
pub etag: *const c_char,
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_free_storage_get_result(res: *mut VdirsyncerStorageGetResult) {
let _: Box<VdirsyncerStorageGetResult> = Box::from_raw(res);
}
#[repr(C)]
pub struct VdirsyncerStorageUploadResult {
pub href: *const c_char,
pub etag: *const c_char,
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_free_storage_upload_result(
res: *mut VdirsyncerStorageUploadResult,
) {
let _: Box<VdirsyncerStorageUploadResult> = Box::from_raw(res);
}

View file

@ -0,0 +1,349 @@
use std::ffi::CStr;
use std::os::raw::c_char;
use std::path::{Path, PathBuf};
use std::collections::{BTreeMap, BTreeSet};
use std::collections::btree_map::Entry::*;
use std::fs::{metadata, File};
use std::io::{Read, Write};
use std::time::SystemTime;
use super::Storage;
use errors::*;
use vobject;
use atomicwrites::{AllowOverwrite, AtomicFile};
use item::Item;
type ItemCache = BTreeMap<String, (Item, String)>;
pub struct SinglefileStorage {
path: PathBuf,
// href -> (item, etag)
items_cache: Option<(ItemCache, SystemTime)>,
buffered_mode: bool,
dirty_cache: bool,
}
impl SinglefileStorage {
pub fn new<P: AsRef<Path>>(path: P) -> Self {
SinglefileStorage {
path: path.as_ref().to_owned(),
items_cache: None,
buffered_mode: false,
dirty_cache: false,
}
}
fn get_items(&mut self) -> Result<&mut ItemCache> {
if self.items_cache.is_none() {
self.list()?;
}
Ok(&mut self.items_cache.as_mut().unwrap().0)
}
fn write_back(&mut self) -> Result<()> {
self.dirty_cache = true;
if self.buffered_mode {
return Ok(());
}
self.flush()?;
Ok(())
}
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_init_singlefile(path: *const c_char) -> *mut SinglefileStorage {
let cstring = CStr::from_ptr(path);
Box::into_raw(Box::new(SinglefileStorage::new(cstring.to_str().unwrap())))
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_free_singlefile(s: *mut SinglefileStorage) {
let _: Box<SinglefileStorage> = Box::from_raw(s);
}
impl Storage for SinglefileStorage {
fn list<'a>(&'a mut self) -> Result<Box<Iterator<Item = (String, String)> + 'a>> {
let mut new_cache = BTreeMap::new();
let mtime = metadata(&self.path)?.modified()?;
let mut f = File::open(&self.path)?;
let mut s = String::new();
f.read_to_string(&mut s)?;
for component in split_collection(&s)? {
let item = Item::from_component(component);
let hash = item.get_hash()?;
let ident = item.get_ident()?;
new_cache.insert(ident, (item, hash));
}
self.items_cache = Some((new_cache, mtime));
self.dirty_cache = false;
Ok(Box::new(self.items_cache.as_ref().unwrap().0.iter().map(
|(href, &(_, ref etag))| (href.clone(), etag.clone()),
)))
}
fn get(&mut self, href: &str) -> Result<(Item, String)> {
match self.get_items()?.get(href) {
Some(&(ref href, ref etag)) => Ok((href.clone(), etag.clone())),
None => Err(ErrorKind::ItemNotFound(href.to_owned()))?,
}
}
fn upload(&mut self, item: Item) -> Result<(String, String)> {
let hash = item.get_hash()?;
let href = item.get_ident()?;
match self.get_items()?.entry(href.clone()) {
Occupied(_) => Err(ErrorKind::AlreadyExisting(href.clone()))?,
Vacant(vc) => vc.insert((item, hash.clone())),
};
self.write_back()?;
Ok((href, hash))
}
fn update(&mut self, href: &str, item: Item, etag: &str) -> Result<String> {
let hash = match self.get_items()?.entry(href.to_owned()) {
Occupied(mut oc) => {
if oc.get().1 == etag {
let hash = item.get_hash()?;
oc.insert((item, hash.clone()));
Ok(hash)
} else {
Err(ErrorKind::WrongEtag(href.to_owned()))
}
}
Vacant(_) => Err(ErrorKind::ItemNotFound(href.to_owned())),
}?;
self.write_back()?;
Ok(hash)
}
fn delete(&mut self, href: &str, etag: &str) -> Result<()> {
match self.get_items()?.entry(href.to_owned()) {
Occupied(oc) => {
if oc.get().1 == etag {
oc.remove();
} else {
Err(ErrorKind::WrongEtag(href.to_owned()))?
}
}
Vacant(_) => Err(ErrorKind::ItemNotFound(href.to_owned()))?,
}
self.write_back()?;
Ok(())
}
fn buffered(&mut self) {
self.buffered_mode = true;
}
fn flush(&mut self) -> Result<()> {
if !self.dirty_cache {
return Ok(());
}
let (items, mtime) = self.items_cache.take().unwrap();
let af = AtomicFile::new(&self.path, AllowOverwrite);
let content = join_collection(items.into_iter().map(|(_, (item, _))| item))?;
af.write::<(), Error, _>(|f| {
f.write_all(content.as_bytes())?;
let real_mtime = metadata(&self.path)?.modified()?;
if mtime != real_mtime {
Err(ErrorKind::MtimeMismatch(
self.path.to_string_lossy().into_owned(),
))?;
}
Ok(())
})?;
self.dirty_cache = false;
Ok(())
}
}
fn split_collection(mut input: &str) -> Result<Vec<vobject::Component>> {
let mut rv = vec![];
while !input.is_empty() {
let (component, remainder) = vobject::read_component(input)?;
input = remainder;
match component.name.as_ref() {
"VCALENDAR" => rv.extend(split_vcalendar(component)?),
"VCARD" => rv.push(component),
"VADDRESSBOOK" => for vcard in component.subcomponents {
if vcard.name != "VCARD" {
Err(ErrorKind::UnexpectedVobject(
vcard.name.clone(),
"VCARD".to_owned(),
))?;
}
rv.push(vcard);
},
_ => Err(ErrorKind::UnexpectedVobject(
component.name.clone(),
"VCALENDAR | VCARD | VADDRESSBOOK".to_owned(),
))?,
}
}
Ok(rv)
}
/// Split one VCALENDAR component into multiple VCALENDAR components
#[inline]
fn split_vcalendar(mut vcalendar: vobject::Component) -> Result<Vec<vobject::Component>> {
vcalendar.props.remove("METHOD");
let mut timezones = BTreeMap::new(); // tzid => component
let mut subcomponents = vec![];
for component in vcalendar.subcomponents.drain(..) {
match component.name.as_ref() {
"VTIMEZONE" => {
let tzid = match component.get_only("TZID") {
Some(x) => x.value_as_string().clone(),
None => continue,
};
timezones.insert(tzid, component);
}
"VTODO" | "VEVENT" | "VJOURNAL" => subcomponents.push(component),
_ => Err(ErrorKind::UnexpectedVobject(
component.name.clone(),
"VTIMEZONE | VTODO | VEVENT | VJOURNAL".to_owned(),
))?,
};
}
let mut by_uid = BTreeMap::new();
let mut no_uid = vec![];
for component in subcomponents {
let uid = component.get_only("UID").cloned();
let mut wrapper = match uid.as_ref()
.and_then(|u| by_uid.remove(&u.value_as_string()))
{
Some(x) => x,
None => vcalendar.clone(),
};
let mut required_tzids = BTreeSet::new();
for props in component.props.values() {
for prop in props {
if let Some(x) = prop.params.get("TZID") {
required_tzids.insert(x.to_owned());
}
}
}
for tzid in required_tzids {
if let Some(tz) = timezones.get(&tzid) {
wrapper.subcomponents.push(tz.clone());
}
}
wrapper.subcomponents.push(component);
match uid {
Some(p) => {
by_uid.insert(p.value_as_string(), wrapper);
}
None => no_uid.push(wrapper),
}
}
Ok(by_uid
.into_iter()
.map(|(_, v)| v)
.chain(no_uid.into_iter())
.collect())
}
fn join_collection<I: Iterator<Item = Item>>(item_iter: I) -> Result<String> {
let mut items = item_iter.peekable();
let item_name = match items.peek() {
Some(x) => x.get_component()?.name.clone(),
None => return Ok("".to_owned()),
};
let wrapper_name = match item_name.as_ref() {
"VCARD" => "VADDRESSBOOK",
"VCALENDAR" => "VCALENDAR",
_ => Err(ErrorKind::UnexpectedVobject(
item_name.clone(),
"VCARD | VCALENDAR".to_owned(),
))?,
};
let mut wrapper = vobject::Component::new(wrapper_name);
let mut version: Option<vobject::Property> = None;
for item in items {
let mut c = item.into_component()?;
if c.name != item_name {
return Err(ErrorKind::UnexpectedVobject(c.name, item_name.clone()).into());
}
if item_name == wrapper_name {
wrapper.subcomponents.extend(c.subcomponents.drain(..));
match (version.as_ref(), c.get_only("VERSION")) {
(Some(x), Some(y)) if x.raw_value != y.raw_value => {
return Err(ErrorKind::VobjectVersionMismatch(
x.raw_value.clone(),
y.raw_value.clone(),
).into());
}
(None, Some(_)) => (),
_ => continue,
}
version = c.get_only("VERSION").cloned();
} else {
wrapper.subcomponents.push(c);
}
}
if let Some(v) = version {
wrapper.set(v);
}
Ok(vobject::write_component(&wrapper))
}
#[cfg(test)]
mod tests {
use super::*;
fn check_roundtrip(raw: &str) {
let components = split_collection(raw).unwrap();
let raw2 = join_collection(components.into_iter().map(Item::from_component)).unwrap();
assert_eq!(
Item::from_raw(raw.to_owned()).get_hash().unwrap(),
Item::from_raw(raw2.to_owned()).get_hash().unwrap()
);
}
#[test]
fn test_wrapper_properties_roundtrip() {
let raw = r#"BEGIN:VCALENDAR
PRODID:-//Google Inc//Google Calendar 70.9054//EN
X-WR-CALNAME:markus.unterwaditzer@runtastic.com
X-WR-TIMEZONE:Europe/Vienna
VERSION:2.0
CALSCALE:GREGORIAN
BEGIN:VEVENT
DTSTART;TZID=Europe/Vienna:20171012T153000
DTEND;TZID=Europe/Vienna:20171012T170000
DTSTAMP:20171009T085029Z
UID:test@test.com
STATUS:CONFIRMED
SUMMARY:Test
TRANSP:OPAQUE
END:VEVENT
END:VCALENDAR"#;
check_roundtrip(raw);
}
}

View file

@ -1,49 +0,0 @@
#!/bin/bash
#
# This script is mean to be run inside a dedicated container,
# and not interatively.
set -ex
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y build-essential fakeroot debhelper git
apt-get install -y python3-all python3-pip python3-venv
apt-get install -y ruby ruby-dev
pip3 install virtualenv virtualenv-tools3
virtualenv -p python3 /vdirsyncer/env/
gem install fpm
# See https://github.com/jordansissel/fpm/issues/1106#issuecomment-461678970
pip3 uninstall -y virtualenv
echo 'python3 -m venv "$@"' > /usr/local/bin/virtualenv
chmod +x /usr/local/bin/virtualenv
cp -r /source/ /vdirsyncer/vdirsyncer/
cd /vdirsyncer/vdirsyncer/ || exit 2
mkdir /vdirsyncer/pkgs/
basename -- *.tar.gz .tar.gz | cut -d'-' -f2 | sed -e 's/\.dev/~/g' | tee version
# XXX: Do I really not want google support included?
(echo -n *.tar.gz; echo '[google]') | tee requirements.txt
fpm --verbose \
--input-type virtualenv \
--output-type deb \
--name "vdirsyncer-latest" \
--version "$(cat version)" \
--prefix /opt/venvs/vdirsyncer-latest \
--depends python3 \
requirements.txt
mv /vdirsyncer/vdirsyncer/*.deb /vdirsyncer/pkgs/
cd /vdirsyncer/pkgs/
dpkg -i -- *.deb
# Check that it works:
LC_ALL=C.UTF-8 LANG=C.UTF-8 /opt/venvs/vdirsyncer-latest/bin/vdirsyncer --version
cp -- *.deb /source/

View file

@ -0,0 +1,15 @@
make install-rust
echo "export PATH=$HOME/.cargo/bin/:$PATH" >> $BASH_ENV
sudo apt-get install -y cmake
pip install --user virtualenv
~/.local/bin/virtualenv ~/env
echo ". ~/env/bin/activate" >> $BASH_ENV
. $BASH_ENV
pip install docker-compose
make -e install-dev install-test
if python --version | grep -q 'Python 3.6'; then
make -e install-style
fi

40
scripts/dpkg.Dockerfile Normal file
View file

@ -0,0 +1,40 @@
ARG distro
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-dev python3-pip
RUN apt-get install -y ruby ruby-dev
RUN apt-get install -y python-all python-pip
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
RUN apt-get install -y libssl-dev libffi-dev
ENV PATH="/root/.cargo/bin/:${PATH}"
RUN gem install fpm
RUN pip2 install virtualenv-tools
RUN pip3 install virtualenv
RUN virtualenv -p python3 /vdirsyncer/env/
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 --verbose -s virtualenv -t deb \
-n "vdirsyncer-latest" \
-v "$(cat version)" \
--prefix /opt/venvs/vdirsyncer-latest \
requirements.txt
RUN mv /vdirsyncer/vdirsyncer/*.deb /vdirsyncer/pkgs/
WORKDIR /vdirsyncer/pkgs/
RUN dpkg -i *.deb
RUN LC_ALL=C.UTF-8 LANG=C.UTF-8 /opt/venvs/vdirsyncer-latest/bin/vdirsyncer --version

View file

@ -1,56 +1,19 @@
#!/bin/sh
set -xe
distro=$1
distrover=$2
name=vdirsyncer-$distro-$distrover:latest
context="$(mktemp -d)"
set -xeu
python setup.py sdist -d "$context"
cp scripts/dpkg.Dockerfile "$context/Dockerfile"
SCRIPT_PATH=$(realpath "$0")
SCRIPT_DIR=$(dirname "$SCRIPT_PATH")
docker build \
--build-arg distro=$distro \
--build-arg distrover=$distrover \
-t $name \
"$context"
# E.g.: debian, ubuntu
DISTRO=${DISTRO:1}
# E.g.: bullseye, bookwork
DISTROVER=${DISTROVER:2}
CONTAINER_NAME="vdirsyncer-${DISTRO}-${DISTROVER}"
CONTEXT="$(mktemp -d)"
DEST_DIR="$SCRIPT_DIR/../$DISTRO-$DISTROVER"
cleanup() {
rm -rf "$CONTEXT"
}
trap cleanup EXIT
# Prepare files.
cp scripts/_build_deb_in_container.bash "$CONTEXT"
python setup.py sdist -d "$CONTEXT"
docker run -it \
--name "$CONTAINER_NAME" \
--volume "$CONTEXT:/source" \
"$DISTRO:$DISTROVER" \
bash /source/_build_deb_in_container.bash
# Keep around the package filename.
PACKAGE=$(ls "$CONTEXT"/*.deb)
PACKAGE=$(basename "$PACKAGE")
# Save the build deb files.
mkdir -p "$DEST_DIR"
cp "$CONTEXT"/*.deb "$DEST_DIR"
echo Build complete! 🤖
# Packagecloud uses some internal IDs for each distro.
# Extract the one for the distro we're publishing.
DISTRO_ID=$(
curl -s \
https://"$PACKAGECLOUD_TOKEN":@packagecloud.io/api/v1/distributions.json | \
jq '.deb | .[] | select(.index_name=="'"$DISTRO"'") | .versions | .[] | select(.index_name=="'"$DISTROVER"'") | .id'
)
# Actually push the package.
curl \
-F "package[distro_version_id]=$DISTRO_ID" \
-F "package[package_file]=@$DEST_DIR/$PACKAGE" \
https://"$PACKAGECLOUD_TOKEN":@packagecloud.io/api/v1/repos/pimutils/vdirsyncer/packages.json
echo Done! ✨
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"

11
setup.cfg Normal file
View file

@ -0,0 +1,11 @@
[tool:pytest]
norecursedirs = tests/storage/servers/*
addopts = --tb=short --duration 3
[flake8]
# E731: Use a def instead of lambda expr
# E743: Ambiguous function definition
ignore = E731, E743
select = C,E,F,W,B,B9
exclude = .eggs/, tests/storage/servers/nextcloud/, build/, vdirsyncer/_native*
application-package-names = tests,vdirsyncer

130
setup.py Normal file
View file

@ -0,0 +1,130 @@
# -*- coding: utf-8 -*-
'''
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, find_packages, setup
milksnake = 'milksnake'
requirements = [
# https://github.com/mitsuhiko/click/issues/200
'click>=5.0',
'click-log>=0.2.0, <0.3.0',
# https://github.com/pimutils/vdirsyncer/issues/478
'click-threading>=0.2',
# !=2.9.0: https://github.com/kennethreitz/requests/issues/2930
#
# >=2.4.1: https://github.com/shazow/urllib3/pull/444
# Without the above pull request, `verify=False` also disables fingerprint
# validation. This is *not* what we want, and it's not possible to
# replicate vdirsyncer's current behavior (verifying fingerprints without
# verifying against CAs) with older versions of urllib3.
'requests >=2.4.1, !=2.9.0',
# https://github.com/sigmavirus24/requests-toolbelt/pull/28
# And https://github.com/sigmavirus24/requests-toolbelt/issues/54
'requests_toolbelt >=0.4.0',
# https://github.com/untitaker/python-atomicwrites/commit/4d12f23227b6a944ab1d99c507a69fdbc7c9ed6d # noqa
'atomicwrites>=0.1.7',
milksnake
]
def build_native(spec):
build = spec.add_external_build(
cmd=['make', 'rust-ext'],
path='.'
)
spec.add_cffi_module(
module_path='vdirsyncer._native',
dylib=lambda: build.find_dylib(
'vdirsyncer_rustext', in_path='rust/target/release'),
header_filename=lambda: build.find_header(
'vdirsyncer_rustext.h', in_path='rust/target'),
# Rust bug: If thread-local storage is used, this flag is necessary
# (mitsuhiko)
rtld_flags=['NOW', 'NODELETE']
)
class PrintRequirements(Command):
description = 'Prints minimal requirements'
user_options = []
def initialize_options(self):
pass
def finalize_options(self):
pass
def run(self):
for requirement in requirements:
print(requirement.replace(">", "=").replace(" ", ""))
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',
long_description=long_description,
# Runtime dependencies
install_requires=requirements,
# Optional dependencies
extras_require={
'google': ['requests-oauthlib'],
'etesync': ['etesync']
},
# Build dependencies
setup_requires=[
'setuptools_scm != 1.12.0',
milksnake,
],
# Other
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']
},
classifiers=[
'Development Status :: 4 - Beta',
'Environment :: Console',
'License :: OSI Approved :: BSD License',
'Operating System :: POSIX',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Topic :: Internet',
'Topic :: Utilities',
],
milksnake_tasks=[build_native],
zip_safe=False,
platforms='any'
)

4
test-requirements.txt Normal file
View file

@ -0,0 +1,4 @@
hypothesis>=3.1
pytest
pytest-localserver
pytest-subtesthack

View file

@ -1,26 +1,29 @@
"""
# -*- coding: utf-8 -*-
'''
Test suite for vdirsyncer.
"""
'''
from __future__ import annotations
import random
import hypothesis.strategies as st
import urllib3.exceptions
from vdirsyncer.vobject import normalize_item
from vdirsyncer.vobject import Item
import urllib3
import urllib3.exceptions
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)
assert a.hash == b.hash
VCARD_TEMPLATE = """BEGIN:VCARD
VCARD_TEMPLATE = u'''BEGIN:VCARD
VERSION:3.0
FN:Cyrus Daboo
N:Daboo;Cyrus;;;
@ -34,9 +37,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 = u'''BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//dmfs.org//mimedir.icalendar//EN
BEGIN:VTODO
@ -48,30 +51,25 @@ 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 = u'''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 = u'''BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//hacksw/handcal//NONSGML v1.0//EN
"""
+ BARE_EVENT_TEMPLATE
+ """
END:VCALENDAR"""
)
''' + BARE_EVENT_TEMPLATE + u'''
END:VCALENDAR'''
EVENT_WITH_TIMEZONE_TEMPLATE = (
"""BEGIN:VCALENDAR
EVENT_WITH_TIMEZONE_TEMPLATE = '''BEGIN:VCALENDAR
BEGIN:VTIMEZONE
TZID:Europe/Rome
X-LIC-LOCATION:Europe/Rome
@ -90,21 +88,33 @@ 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 = u'''BEGIN:FOO
UID:{uid}
X-SOMETHING:{r}
HAHA:YES
END:FOO"""
END:FOO'''
printable_characters_strategy = st.text(st.characters(exclude_categories=("Cc", "Cs")))
printable_characters_strategy = st.text(
st.characters(blacklist_categories=(
'Cc', 'Cs'
))
)
uid_strategy = st.text(
st.characters(exclude_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)
def format_item(uid=None, item_template=VCARD_TEMPLATE):
# assert that special chars are handled correctly.
r = random.random()
uid = uid or r
return Item(item_template.format(r=r, uid=uid))

View file

@ -1,70 +1,44 @@
"""
# -*- coding: utf-8 -*-
'''
General-purpose fixtures for vdirsyncer's testsuite.
"""
from __future__ import annotations
'''
import logging
import os
import aiohttp
import click_log
from hypothesis import HealthCheck, Verbosity, settings
import pytest
import pytest_asyncio
from hypothesis import HealthCheck
from hypothesis import Verbosity
from hypothesis import settings
@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=list(HealthCheck),
),
)
settings.register_profile("dev", settings(suppress_health_check=[HealthCheck.too_slow]))
settings.suppress_health_check = [HealthCheck.too_slow]
if os.environ.get("DETERMINISTIC_TESTS", "false").lower() == "true":
settings.register_profile("ci", settings(
max_examples=1000,
verbosity=Verbosity.verbose,
))
settings.register_profile("deterministic", settings(
derandomize=True,
perform_health_check=False
))
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")
@pytest_asyncio.fixture
async def aio_session():
async with aiohttp.ClientSession() as session:
yield session
@pytest_asyncio.fixture
async def aio_connector():
async with aiohttp.TCPConnector(limit_per_host=16) as conn:
yield conn

View file

@ -1,390 +1,309 @@
from __future__ import annotations
# -*- coding: utf-8 -*-
import random
import textwrap
import uuid
from urllib.parse import quote as urlquote
from urllib.parse import unquote as urlunquote
import aiostream
import textwrap
from urllib.parse import quote as urlquote, unquote as urlunquote
import pytest
import pytest_asyncio
from tests import EVENT_TEMPLATE
from tests import TASK_TEMPLATE
from tests import VCARD_TEMPLATE
from tests import assert_item_equals
from tests import normalize_item
from vdirsyncer import exceptions
from vdirsyncer.storage.base import normalize_meta_value
from vdirsyncer.vobject import Item
from .. import EVENT_TEMPLATE, TASK_TEMPLATE, VCARD_TEMPLATE, \
assert_item_equals, format_item
def get_server_mixin(server_name):
from . import __name__ as base
x = __import__(f"{base}.servers.{server_name}", fromlist=[""])
x = __import__('{}.servers.{}'.format(base, server_name), fromlist=[''])
return x.ServerMixin
def format_item(item_template, uid=None):
# assert that special chars are handled correctly.
r = random.random()
return Item(item_template.format(r=r, uid=uid or r))
class StorageTests:
class StorageTests(object):
storage_class = None
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
'''
raise NotImplementedError()
@pytest_asyncio.fixture
async def s(self, get_storage_args):
rv = self.storage_class(**await get_storage_args())
return rv
@pytest.fixture
def s(self, get_storage_args):
return self.storage_class(**get_storage_args())
@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)
return lambda **kw: format_item(item_template=template, **kw)
@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.')
@pytest.mark.asyncio
async def test_generic(self, s, get_item):
def test_generic(self, s, get_item):
items = [get_item() for i in range(1, 10)]
hrefs = []
for item in items:
href, etag = await s.upload(item)
href, etag = s.upload(item)
if etag is None:
_, etag = await s.get(href)
_, etag = s.get(href)
hrefs.append((href, etag))
hrefs.sort()
assert hrefs == sorted(await aiostream.stream.list(s.list()))
assert hrefs == sorted(s.list())
for href, etag in hrefs:
assert isinstance(href, (str, bytes))
assert isinstance(etag, (str, bytes))
assert await s.has(href)
item, etag2 = await s.get(href)
assert s.has(href)
item, etag2 = s.get(href)
assert etag == etag2
@pytest.mark.asyncio
async def test_empty_get_multi(self, s):
assert await aiostream.stream.list(s.get_multi([])) == []
def test_empty_get_multi(self, s):
assert list(s.get_multi([])) == []
@pytest.mark.asyncio
async def test_get_multi_duplicates(self, s, get_item):
href, etag = await s.upload(get_item())
def test_get_multi_duplicates(self, s, get_item):
href, etag = s.upload(get_item())
if etag is None:
_, etag = await s.get(href)
((href2, _item, etag2),) = await aiostream.stream.list(s.get_multi([href] * 2))
_, etag = s.get(href)
(href2, item, etag2), = s.get_multi([href] * 2)
assert href2 == href
assert etag2 == etag
@pytest.mark.asyncio
async def test_upload_already_existing(self, s, get_item):
def test_upload_already_existing(self, s, get_item):
item = get_item()
await s.upload(item)
s.upload(item)
with pytest.raises(exceptions.PreconditionFailed):
await s.upload(item)
s.upload(item)
@pytest.mark.asyncio
async def test_upload(self, s, get_item):
def test_upload(self, s, get_item):
item = get_item()
href, _etag = await s.upload(item)
assert_item_equals((await s.get(href))[0], item)
href, etag = s.upload(item)
assert_item_equals(s.get(href)[0], item)
@pytest.mark.asyncio
async def test_update(self, s, get_item):
def test_update(self, s, get_item):
item = get_item()
href, etag = await s.upload(item)
href, etag = s.upload(item)
if etag is None:
_, etag = await s.get(href)
assert_item_equals((await s.get(href))[0], item)
_, etag = s.get(href)
assert_item_equals(s.get(href)[0], item)
new_item = get_item(uid=item.uid)
new_etag = await s.update(href, new_item, etag)
new_etag = s.update(href, new_item, etag)
if new_etag is None:
_, new_etag = await s.get(href)
_, new_etag = s.get(href)
# See https://github.com/pimutils/vdirsyncer/issues/48
assert isinstance(new_etag, (bytes, str))
assert_item_equals((await s.get(href))[0], new_item)
assert_item_equals(s.get(href)[0], new_item)
@pytest.mark.asyncio
async def test_update_nonexisting(self, s, get_item):
def test_update_nonexisting(self, s, get_item):
item = get_item()
with pytest.raises(exceptions.PreconditionFailed):
await s.update("huehue", item, '"123"')
s.update('huehue', item, '"123"')
@pytest.mark.asyncio
async def test_wrong_etag(self, s, get_item):
def test_wrong_etag(self, s, get_item):
item = get_item()
href, _etag = await s.upload(item)
href, etag = s.upload(item)
with pytest.raises(exceptions.PreconditionFailed):
await s.update(href, item, '"lolnope"')
s.update(href, item, '"lolnope"')
with pytest.raises(exceptions.PreconditionFailed):
await s.delete(href, '"lolnope"')
s.delete(href, '"lolnope"')
@pytest.mark.asyncio
async def test_delete(self, s, get_item):
href, etag = await s.upload(get_item())
await s.delete(href, etag)
assert not await aiostream.stream.list(s.list())
def test_delete(self, s, get_item):
href, etag = s.upload(get_item())
s.delete(href, etag)
assert not list(s.list())
@pytest.mark.asyncio
async def test_delete_nonexisting(self, s, get_item):
def test_delete_nonexisting(self, s, get_item):
with pytest.raises(exceptions.PreconditionFailed):
await s.delete("1", '"123"')
s.delete('1', '"123"')
@pytest.mark.asyncio
async def test_list(self, s, get_item):
assert not await aiostream.stream.list(s.list())
href, etag = await s.upload(get_item())
def test_list(self, s, get_item):
assert not list(s.list())
href, etag = s.upload(get_item())
if etag is None:
_, etag = await s.get(href)
assert await aiostream.stream.list(s.list()) == [(href, etag)]
_, etag = s.get(href)
assert list(s.list()) == [(href, etag)]
@pytest.mark.asyncio
async def test_has(self, s, get_item):
assert not await s.has("asd")
href, etag = await s.upload(get_item())
assert await s.has(href)
assert not await s.has("asd")
await s.delete(href, etag)
assert not await s.has(href)
def test_has(self, s, get_item):
assert not s.has('asd')
href, etag = s.upload(get_item())
assert s.has(href)
assert not s.has('asd')
s.delete(href, etag)
assert not s.has(href)
@pytest.mark.asyncio
async def test_update_others_stay_the_same(self, s, get_item):
def test_update_others_stay_the_same(self, s, get_item):
info = {}
for _ in range(4):
href, etag = await s.upload(get_item())
href, etag = s.upload(get_item())
if etag is None:
_, etag = await s.get(href)
_, etag = s.get(href)
info[href] = etag
items = await aiostream.stream.list(
s.get_multi(href for href, etag in info.items())
)
assert {href: etag for href, item, etag in items} == info
assert dict(
(href, etag) for href, item, etag
in s.get_multi(href for href, etag in info.items())
) == info
def test_repr(self, s):
def test_repr(self, s, get_storage_args):
assert self.storage_class.__name__ in repr(s)
assert s.instance_name is None
@pytest.mark.asyncio
async def test_discover(
self,
requires_collections,
get_storage_args,
get_item,
aio_connector,
):
def test_discover(self, requires_collections, get_storage_args, get_item):
collections = set()
for i in range(1, 5):
collection = f"test{i}"
s = self.storage_class(**await get_storage_args(collection=collection))
assert not await aiostream.stream.list(s.list())
await s.upload(get_item())
collection = 'test{}'.format(i)
s = self.storage_class(**get_storage_args(collection=collection))
assert not list(s.list())
s.upload(get_item())
collections.add(s.collection)
discovered = await aiostream.stream.list(
self.storage_class.discover(**await get_storage_args(collection=None))
actual = set(
c['collection'] for c in
self.storage_class.discover(**get_storage_args(collection=None))
)
actual = {c["collection"] for c in discovered}
assert actual >= collections
@pytest.mark.asyncio
async 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")
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.')
args = await get_storage_args(collection=None)
args["collection"] = "test"
args = get_storage_args(collection=None)
args['collection'] = 'test'
s = self.storage_class(**await self.storage_class.create_collection(**args))
href = (await s.upload(get_item()))[0]
assert href in await aiostream.stream.list(
(href async for href, etag in s.list())
s = self.storage_class(
**self.storage_class.create_collection(**args)
)
@pytest.mark.asyncio
async def test_discover_collection_arg(
self, requires_collections, get_storage_args
):
args = await get_storage_args(collection="test2")
href = s.upload(get_item())[0]
assert href in set(href for href, etag in s.list())
def test_discover_collection_arg(self, requires_collections,
get_storage_args):
args = get_storage_args(collection='test2')
with pytest.raises(TypeError) as excinfo:
await aiostream.stream.list(self.storage_class.discover(**args))
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.')
@pytest.mark.asyncio
async def test_collection_arg(self, get_storage_args):
if self.supports_collections:
s = self.storage_class(**await 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", **await get_storage_args())
self.storage_class(collection='ayy', **get_storage_args())
@pytest.mark.asyncio
async def test_case_sensitive_uids(self, s, get_item):
if s.storage_name == "filesystem":
pytest.skip("Behavior depends on the filesystem.")
def test_case_sensitive_uids(self, s, get_item):
if s.storage_name == 'filesystem':
pytest.skip('Behavior depends on the filesystem.')
uid = str(uuid.uuid4())
await s.upload(get_item(uid=uid.upper()))
await s.upload(get_item(uid=uid.lower()))
items = [href async for href, etag in s.list()]
s.upload(get_item(uid=uid.upper()))
s.upload(get_item(uid=uid.lower()))
items = list(href for href, etag in s.list())
assert len(items) == 2
assert len(set(items)) == 2
@pytest.mark.asyncio
async def test_specialchars(
self, monkeypatch, requires_collections, get_storage_args, get_item
):
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 = u'test @ foo ät bar град сатану'
collection = 'test @ foo ät bar'
s = self.storage_class(**await get_storage_args(collection=collection))
s = self.storage_class(**get_storage_args(collection=collection))
item = get_item(uid=uid)
href, etag = await s.upload(item)
item2, etag2 = await s.get(href)
href, etag = s.upload(item)
item2, etag2 = s.get(href)
if etag is not None:
assert etag2 == etag
assert_item_equals(item2, item)
((_, etag3),) = await aiostream.stream.list(s.list())
(_, etag3), = s.list()
assert etag2 == etag3
# etesync uses UUIDs for collection names
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
@pytest.mark.asyncio
async def test_newline_in_uid(
self, monkeypatch, requires_collections, get_storage_args, get_item
):
monkeypatch.setattr("vdirsyncer.utils.generate_href", lambda x: x)
uid = "UID:20210609T084907Z-@synaps-web-54fddfdf7-7kcfm%0A.ics"
s = self.storage_class(**await get_storage_args())
item = get_item(uid=uid)
href, etag = await s.upload(item)
item2, etag2 = await s.get(href)
if etag is not None:
assert etag2 == etag
assert_item_equals(item2, item)
((_, etag3),) = await aiostream.stream.list(s.list())
assert etag2 == etag3
@pytest.mark.asyncio
async def test_empty_metadata(self, requires_metadata, s):
if getattr(self, "dav_server", ""):
pytest.skip()
assert await s.get_meta("color") is None
assert await s.get_meta("displayname") is None
@pytest.mark.asyncio
async def test_metadata(self, requires_metadata, s):
if getattr(self, "dav_server", "") == "xandikos":
pytest.skip("xandikos does not support removing metadata.")
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')
try:
await s.set_meta("color", None)
assert await s.get_meta("color") is None
await s.set_meta("color", "#ff0000")
assert await s.get_meta("color") == "#ff0000"
s.set_meta('color', None)
assert not s.get_meta('color')
s.set_meta('color', u'#ff0000')
assert s.get_meta('color') == u'#ff0000'
except exceptions.UnsupportedMetadataError:
pass
@pytest.mark.asyncio
async def test_encoding_metadata(self, requires_metadata, s):
for x in ("hello world", "hello wörld"):
await s.set_meta("displayname", x)
rv = await s.get_meta("displayname")
for x in (u'hello world', u'hello wörld'):
s.set_meta('displayname', x)
rv = s.get_meta('displayname')
assert rv == x
assert isinstance(rv, str)
@pytest.mark.parametrize(
"value",
[
None,
"",
"Hello there!",
"Österreich",
"中国",
"한글",
"42a4ec99-b1c2-4859-b142-759112f2ca50",
"فلسطين",
],
)
@pytest.mark.asyncio
async def test_metadata_normalization(self, requires_metadata, s, value):
x = await s.get_meta("displayname")
@pytest.mark.parametrize('value', [
'fööbör',
'ананасовое перо'
])
def test_metadata_normalization(self, requires_metadata, s, value):
x = s.get_meta('displayname')
assert x == normalize_meta_value(x)
if not getattr(self, "dav_server", None):
# ownCloud replaces "" with "unnamed"
await s.set_meta("displayname", value)
assert await s.get_meta("displayname") == normalize_meta_value(value)
s.set_meta('displayname', value)
assert s.get_meta('displayname') == normalize_meta_value(value)
@pytest.mark.asyncio
async def test_recurring_events(self, s, item_type):
if item_type != "VEVENT":
pytest.skip("This storage instance doesn't support iCalendar.")
def test_recurring_events(self, s, item_type):
if item_type != 'VEVENT':
pytest.skip('This storage instance doesn\'t support iCalendar.')
uid = str(uuid.uuid4())
item = Item(
textwrap.dedent(
f"""
item = Item(textwrap.dedent(u'''
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
@ -405,7 +324,7 @@ class StorageTests:
BEGIN:VEVENT
DTSTART;TZID=UTC:20140128T083000Z
DTEND;TZID=UTC:20140128T100000Z
RRULE:FREQ=WEEKLY;BYDAY=TU;UNTIL=20141208T213000Z
RRULE:FREQ=WEEKLY;UNTIL=20141208T213000Z;BYDAY=TU
DTSTAMP:20140327T060506Z
UID:{uid}
CREATED:20131216T033331Z
@ -418,11 +337,65 @@ class StorageTests:
TRANSP:OPAQUE
END:VEVENT
END:VCALENDAR
"""
).strip()
)
'''.format(uid=uid)).strip())
href, _etag = await s.upload(item)
href, etag = s.upload(item)
item2, _etag2 = await s.get(href)
assert normalize_item(item) == normalize_item(item2)
item2, etag2 = s.get(href)
assert item2.raw.count('BEGIN:VEVENT') == 2
assert 'RRULE' in item2.raw
def test_buffered(self, get_storage_args, get_item, requires_collections):
args = get_storage_args()
s1 = self.storage_class(**args)
s2 = self.storage_class(**args)
s1.upload(get_item())
assert sorted(list(s1.list())) == sorted(list(s2.list()))
s1.buffered()
s1.upload(get_item())
s1.flush()
assert sorted(list(s1.list())) == sorted(list(s2.list()))
def test_retain_timezones(self, item_type, s):
if item_type != 'VEVENT':
pytest.skip('This storage instance doesn\'t support iCalendar.')
item = Item(textwrap.dedent('''
BEGIN:VCALENDAR
PRODID:-//ownCloud calendar v1.4.0
VERSION:2.0
CALSCALE:GREGORIAN
BEGIN:VEVENT
CREATED:20161004T110533
DTSTAMP:20161004T110533
LAST-MODIFIED:20161004T110533
UID:y2lmgz48mg
SUMMARY:Test
CLASS:PUBLIC
STATUS:CONFIRMED
DTSTART;TZID=Europe/Berlin:20161014T101500
DTEND;TZID=Europe/Berlin:20161014T114500
END:VEVENT
BEGIN:VTIMEZONE
TZID:Europe/Berlin
BEGIN:DAYLIGHT
DTSTART:20160327T030000
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20161030T020000
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
END:STANDARD
END:VTIMEZONE
END:VCALENDAR
''').strip())
href, etag = s.upload(item)
item2, _ = s.get(href)
assert 'VTIMEZONE' in item2.raw
assert item2.hash == item.hash

View file

@ -1,116 +1,36 @@
from __future__ import annotations
# -*- coding: utf-8 -*-
import pytest
import asyncio
import contextlib
import subprocess
import time
import uuid
import aiostream
import pytest
import pytest_asyncio
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",
"--rm",
"--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_asyncio.fixture
async def slow_create_collection(request, aio_connector):
@pytest.fixture
def slow_create_collection(request):
# We need to properly clean up because otherwise we might run into
# storage limits.
to_delete = []
async def inner(cls: type, args: dict, collection_name: str) -> dict:
"""Create a collection
def delete_collections():
for s in to_delete:
s.session.request('DELETE', '')
Returns args necessary to create a Storage instance pointing to it.
"""
assert collection_name.startswith("test")
request.addfinalizer(delete_collections)
# Make each name unique
collection_name = f"{collection_name}-vdirsyncer-ci-{uuid.uuid4()}"
def inner(cls, args, collection):
assert collection.startswith('test')
collection += '-vdirsyncer-ci-' + str(uuid.uuid4())
# Create the collection:
args = await cls.create_collection(collection_name, **args)
collection = cls(**args)
# Keep collection in a list to be deleted once tests end:
to_delete.append(collection)
assert not await aiostream.stream.list(collection.list())
args = cls.create_collection(collection, **args)
s = cls(**args)
_clear_collection(s)
assert not list(s.list())
to_delete.append(s)
return args
yield inner
return inner
await asyncio.gather(*(c.session.request("DELETE", "") for c in to_delete))
def _clear_collection(s):
for href, etag in s.list():
s.delete(href, etag)

View file

@ -1,53 +1,42 @@
from __future__ import annotations
# -*- coding: utf-8 -*-
import os
import uuid
import aiohttp
import aiostream
import os
import pytest
from tests import assert_item_equals
from tests.storage import StorageTests
from tests.storage import get_server_mixin
from vdirsyncer import exceptions
from vdirsyncer.vobject import Item
dav_server = os.environ.get("DAV_SERVER", "skip")
from .. import StorageTests, get_server_mixin
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.asyncio
async def test_dav_broken_item(self, s):
item = Item("HAHA:YES")
with pytest.raises((exceptions.Error, aiohttp.ClientResponseError)):
await s.upload(item)
assert not await aiostream.stream.list(s.list())
@pytest.mark.asyncio
async def test_dav_empty_get_multi_performance(self, s, monkeypatch):
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(await aiostream.stream.list(s.get_multi([]))) == []
assert list(s.get_multi([])) == []
finally:
# Make sure monkeypatch doesn't interfere with DAV server teardown
monkeypatch.undo()
@pytest.mark.asyncio
async 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")
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')
monkeypatch.setattr(s, "_get_href", lambda item: item.ident + s.fileext)
item = get_item(uid="град сатану" + str(uuid.uuid4()))
href, _etag = await s.upload(item)
item2, _etag2 = await s.get(href)
monkeypatch.setattr(s, '_get_href',
lambda item: item.ident + s.fileext)
item = get_item(uid=u'град сатану' + str(uuid.uuid4()))
href, etag = s.upload(item)
item2, etag2 = s.get(href)
assert_item_equals(item, item2)

View file

@ -1,60 +1,50 @@
from __future__ import annotations
# -*- coding: utf-8 -*-
import contextlib
import datetime
from textwrap import dedent
import aiohttp
import aiostream
import pytest
from aioresponses import aioresponses
from tests import EVENT_TEMPLATE
from tests import TASK_TEMPLATE
from tests import VCARD_TEMPLATE
from tests.storage import format_item
import requests
import requests.exceptions
from tests import EVENT_TEMPLATE, TASK_TEMPLATE, VCARD_TEMPLATE
from vdirsyncer import exceptions
from vdirsyncer.storage.dav import CalDAVStorage
from . import DAVStorageTests
from . import dav_server
from . import DAVStorageTests, dav_server
from .. import format_item
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.asyncio
async def test_doesnt_accept_vcard(self, item_type, get_storage_args):
s = self.storage_class(item_types=(item_type,), **await get_storage_args())
def test_doesnt_accept_vcard(self, item_type, get_storage_args):
s = self.storage_class(item_types=(item_type,), **get_storage_args())
# Most storages hard-fail, but xandikos doesn't.
with contextlib.suppress(exceptions.Error, aiohttp.ClientResponseError):
await s.upload(format_item(VCARD_TEMPLATE))
assert not await aiostream.stream.list(s.list())
try:
s.upload(format_item(item_template=VCARD_TEMPLATE))
except (exceptions.Error, requests.exceptions.HTTPError):
pass
assert not list(s.list())
# 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),
],
)
@pytest.mark.xfail(dav_server == "baikal", reason="Baikal returns 500.")
@pytest.mark.asyncio
async def test_item_types_performance(
self, get_storage_args, arg, calls_num, monkeypatch
):
s = self.storage_class(item_types=arg, **await get_storage_args())
@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):
s = self.storage_class(item_types=arg, **get_storage_args())
old_parse = s._parse_prop_responses
calls = []
@ -62,24 +52,19 @@ class TestCalDAVStorage(DAVStorageTests):
calls.append(None)
return old_parse(*a, **kw)
monkeypatch.setattr(s, "_parse_prop_responses", new_parse)
await aiostream.stream.list(s.list())
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.asyncio
async def test_timerange_correctness(self, get_storage_args):
@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, **await 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(item_template=dedent(u'''
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//hacksw/handcal//NONSGML v1.0//EN
@ -91,13 +76,9 @@ class TestCalDAVStorage(DAVStorageTests):
UID:{r}
END:VEVENT
END:VCALENDAR
"""
).strip()
)
''').strip())
too_new_item = format_item(
dedent(
"""
too_new_item = format_item(item_template=dedent(u'''
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//hacksw/handcal//NONSGML v1.0//EN
@ -109,13 +90,9 @@ class TestCalDAVStorage(DAVStorageTests):
UID:{r}
END:VEVENT
END:VCALENDAR
"""
).strip()
)
''').strip())
good_item = format_item(
dedent(
"""
good_item = format_item(item_template=dedent(u'''
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//hacksw/handcal//NONSGML v1.0//EN
@ -127,48 +104,49 @@ class TestCalDAVStorage(DAVStorageTests):
UID:{r}
END:VEVENT
END:VCALENDAR
"""
).strip()
)
''').strip())
await s.upload(too_old_item)
await s.upload(too_new_item)
expected_href, _ = await s.upload(good_item)
s.upload(too_old_item)
s.upload(too_new_item)
expected_href, _ = s.upload(good_item)
((actual_href, _),) = await aiostream.stream.list(s.list())
(actual_href, _), = s.list()
assert actual_href == expected_href
@pytest.mark.asyncio
async def test_invalid_resource(self, monkeypatch, get_storage_args):
args = await get_storage_args(collection=None)
def test_invalid_resource(self, monkeypatch, get_storage_args):
calls = []
args = get_storage_args(collection=None)
with aioresponses() as m:
m.add(args["url"], method="PROPFIND", status=200, body="Hello world")
def request(session, method, url, **kwargs):
assert url == args['url']
calls.append(None)
with pytest.raises(ValueError):
s = self.storage_class(**args)
await aiostream.stream.list(s.list())
r = requests.Response()
r.status_code = 200
r._content = b'Hello World.'
return r
assert len(m.requests) == 1
monkeypatch.setattr('requests.sessions.Session.request', request)
@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.")
@pytest.mark.asyncio
async def test_item_types_general(self, s):
event = (await s.upload(format_item(EVENT_TEMPLATE)))[0]
task = (await s.upload(format_item(TASK_TEMPLATE)))[0]
s.item_types = ("VTODO", "VEVENT")
with pytest.raises(ValueError):
s = self.storage_class(**args)
list(s.list())
assert len(calls) == 1
async def hrefs():
return {href async for href, etag in s.list()}
@pytest.mark.skipif(dav_server == 'icloud',
reason='iCloud only accepts VEVENT')
def test_item_types_general(self, s):
event = s.upload(format_item(item_template=EVENT_TEMPLATE))[0]
task = s.upload(format_item(item_template=TASK_TEMPLATE))[0]
s.item_types = ('VTODO', 'VEVENT')
assert await hrefs() == {event, task}
s.item_types = ("VTODO",)
assert await hrefs() == {task}
s.item_types = ("VEVENT",)
assert await hrefs() == {event}
def l():
return set(href for href, etag in s.list())
assert l() == {event, task}
s.item_types = ('VTODO',)
assert l() == {task}
s.item_types = ('VEVENT',)
assert l() == {event}
s.item_types = ()
assert await hrefs() == {event, task}
assert l() == {event, task}

View file

@ -1,4 +1,4 @@
from __future__ import annotations
# -*- coding: utf-8 -*-
import pytest
@ -10,6 +10,6 @@ from . import DAVStorageTests
class TestCardDAVStorage(DAVStorageTests):
storage_class = CardDAVStorage
@pytest.fixture(params=["VCARD"])
@pytest.fixture(params=['VCARD'])
def item_type(self, request):
return request.param

View file

@ -1,59 +1,40 @@
from __future__ import annotations
import pytest
from vdirsyncer.storage.dav import _BAD_XML_CHARS
from vdirsyncer.storage.dav import _merge_xml
from vdirsyncer.storage.dav import _normalize_href
from vdirsyncer.storage.dav import _parse_xml
from vdirsyncer.storage.dav import _BAD_XML_CHARS, _merge_xml, _parse_xml
def test_xml_utilities():
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>
"""
)
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>
''')
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" ?>'
f"<foo>ye{chr(char)}s\r\n"
"hello</foo>".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"
@pytest.mark.parametrize(
"href",
[
"/dav/calendars/user/testuser/123/UID%253A20210609T084907Z-@synaps-web-54fddfdf7-7kcfm%250A.ics",
],
)
def test_normalize_href(href):
assert href == _normalize_href("https://example.com", href)
assert x.text == 'yes\nhello'

View file

Binary file not shown.

View file

@ -0,0 +1,124 @@
"""
Django settings for etesync_server project.
Generated by 'django-admin startproject' using Django 1.10.6.
For more information on this file, see
https://docs.djangoproject.com/en/1.10/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.10/ref/settings/
"""
import os
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Quick-start development settings - unsuitable for production
# 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#'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
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',
]
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',
]
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',
],
},
},
]
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')),
}
}
# Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', # noqa
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', # noqa
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', # noqa
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', # noqa
},
]
# Internationalization
# https://docs.djangoproject.com/en/1.10/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.10/howto/static-files/
STATIC_URL = '/static/'

View file

@ -0,0 +1,41 @@
"""etesync_server URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/1.10/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: url(r'^$', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.conf.urls import url, include
2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls'))
"""
from django.conf.urls import include, url
from rest_framework_nested import routers
from journal import views
router = routers.DefaultRouter()
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')
urlpatterns = [
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'),

View file

@ -0,0 +1,16 @@
"""
WSGI config for etesync_server project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "etesync_server.settings")
application = get_wsgi_application()

View file

@ -0,0 +1,22 @@
#!/usr/bin/env python
import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "etesync_server.settings")
try:
from django.core.management import execute_from_command_line
except ImportError:
# The above import may fail for some other reason. Ensure that the
# issue is really that Django is missing to avoid masking other
# exceptions on Python 2.
try:
import django # noqa
except ImportError:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
)
raise
execute_from_command_line(sys.argv)

View file

@ -0,0 +1 @@
63ae6eec45b592d5c511f79b7b0c312d2c5f7d6a

Binary file not shown.

View file

@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-
import shutil
import os
import sys
import pytest
import requests
from vdirsyncer.storage.etesync import EtesyncContacts, EtesyncCalendars
from .. import StorageTests
pytestmark = pytest.mark.skipif(os.getenv('ETESYNC_TESTS', '') != 'true',
reason='etesync tests disabled')
@pytest.fixture(scope='session')
def etesync_app(tmpdir_factory):
sys.path.insert(0, os.path.join(os.path.dirname(__file__),
'etesync_server'))
db = tmpdir_factory.mktemp('etesync').join('etesync.sqlite')
shutil.copy(
os.path.join(os.path.dirname(__file__), 'etesync_server',
'db.sqlite3'),
str(db)
)
os.environ['ETESYNC_DB_PATH'] = str(db)
from etesync_server.wsgi import application
return application
class EtesyncTests(StorageTests):
supports_metadata = False
@pytest.fixture
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)
def teardown():
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:
token = f.read().strip()
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'):
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/'
}
if collection is not None:
rv = self.storage_class.create_collection(
collection=collection,
**rv
)
return rv
return inner
class TestContacts(EtesyncTests):
storage_class = EtesyncContacts
@pytest.fixture(params=['VCARD'])
def item_type(self, request):
return request.param
class TestCalendars(EtesyncTests):
storage_class = EtesyncCalendars
@pytest.fixture(params=['VEVENT'])
def item_type(self, request):
return request.param

View file

@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View file

@ -1,38 +0,0 @@
from __future__ import annotations
import pytest
class ServerMixin:
@pytest.fixture
def get_storage_args(
self,
request,
tmpdir,
slow_create_collection,
baikal_server,
aio_connector,
):
async def inner(collection="test"):
base_url = "http://127.0.0.1:8002/"
args = {
"url": base_url,
"username": "baikal",
"password": "baikal",
"connector": aio_connector,
}
if self.storage_class.fileext == ".vcf":
args["url"] = base_url + "card.php/"
else:
args["url"] = base_url + "cal.php/"
if collection is not None:
args = await slow_create_collection(
self.storage_class,
args,
collection,
)
return args
return inner

View file

@ -1,50 +1,49 @@
from __future__ import annotations
import os
import uuid
import pytest
import uuid
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(f"Missing envkey: {e!s}")
caldav_args = None
@pytest.mark.flaky(reruns=5)
class ServerMixin:
class ServerMixin(object):
@pytest.fixture
def davical_args(self):
if self.storage_class.fileext == ".ics":
if caldav_args is None:
pytest.skip('Missing envkeys for davical')
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
raise RuntimeError()
@pytest.fixture
def get_storage_args(self, davical_args, request):
async 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()):
# See: https://stackoverflow.com/a/33984811
request.addfinalizer(lambda x=s: x.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

View file

@ -1,42 +1,27 @@
from __future__ import annotations
import os
import pytest
class ServerMixin:
@pytest.fixture
def get_storage_args(self, slow_create_collection, aio_connector, request):
if (
"item_type" in request.fixturenames
and request.getfixturevalue("item_type") == "VTODO"
):
# Fastmail has non-standard support for TODOs
# See https://github.com/pimutils/vdirsyncer/issues/824
pytest.skip("Fastmail has non-standard VTODO support.")
class ServerMixin(object):
async def inner(collection="test"):
@pytest.fixture
def get_storage_args(self, slow_create_collection):
def inner(collection='test'):
args = {
"username": os.environ["FASTMAIL_USERNAME"],
"password": os.environ["FASTMAIL_PASSWORD"],
"connector": aio_connector,
'username': os.environ['FASTMAIL_USERNAME'],
'password': os.environ['FASTMAIL_PASSWORD']
}
if self.storage_class.fileext == ".ics":
args["url"] = "https://caldav.fastmail.com/"
elif self.storage_class.fileext == ".vcf":
args["url"] = "https://carddav.fastmail.com/"
if self.storage_class.fileext == '.ics':
args['url'] = 'https://caldav.messagingengine.com/'
elif self.storage_class.fileext == '.vcf':
args['url'] = 'https://carddav.messagingengine.com/'
else:
raise RuntimeError
raise RuntimeError()
if collection is not None:
args = await slow_create_collection(
self.storage_class,
args,
collection,
)
args = slow_create_collection(self.storage_class, args,
collection)
return args
return inner

View file

@ -1,33 +1,32 @@
from __future__ import annotations
import os
import pytest
class ServerMixin:
class ServerMixin(object):
@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
pytest.skip("iCloud doesn't support anything else than VEVENT")
# See https://github.com/pimutils/vdirsyncer/pull/593#issuecomment-285941615 # noqa
pytest.skip('iCloud doesn\'t support anything else than VEVENT')
async 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
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

View file

View file

@ -0,0 +1,29 @@
import os
import requests
import pytest
port = os.environ.get('NEXTCLOUD_HOST', None) or 'localhost:8080'
user = os.environ.get('NEXTCLOUD_USER', None) or 'asdf'
pwd = os.environ.get('NEXTCLOUD_PASS', None) or 'asdf'
class ServerMixin(object):
storage_class = None
wsgi_teardown = None
@pytest.fixture
def get_storage_args(self, item_type,
slow_create_collection):
def inner(collection='test'):
args = {
'username': user,
'password': pwd,
'url': 'http://{}/remote.php/dav/'.format(port)
}
if collection is not None:
args = slow_create_collection(self.storage_class, args,
collection)
return args
return inner

View file

@ -1,33 +1,59 @@
from __future__ import annotations
# -*- coding: utf-8 -*-
import logging
import pytest
import radicale
import radicale.config
from pkg_resources import parse_version as ver
import wsgi_intercept
import wsgi_intercept.requests_intercept
logger = logging.getLogger(__name__)
class ServerMixin(object):
@pytest.fixture(autouse=True)
def setup(self, request, tmpdir):
if ver(radicale.VERSION) < ver('2.0.0-pre'):
raise RuntimeError('Testing against Radicale only works with '
'Radicale >= 2.0.0')
def get_app():
config = radicale.config.load(())
config.set('storage', 'filesystem_folder', str(tmpdir))
config.set('rights', 'type', 'owner_only')
app = radicale.Application(config, logger)
def is_authenticated(user, password):
return user == 'bob' and password == 'bob'
app.is_authenticated = is_authenticated
return app
wsgi_intercept.requests_intercept.install()
wsgi_intercept.add_wsgi_intercept('127.0.0.1', 80, get_app)
def teardown():
wsgi_intercept.remove_wsgi_intercept('127.0.0.1', 80)
wsgi_intercept.requests_intercept.uninstall()
request.addfinalizer(teardown)
class ServerMixin:
@pytest.fixture
def get_storage_args(
self,
request,
tmpdir,
slow_create_collection,
radicale_server,
aio_connector,
):
async def inner(collection="test"):
url = "http://127.0.0.1:8001/"
args = {
"url": url,
"username": "radicale",
"password": "radicale",
"connector": aio_connector,
}
def get_storage_args(self, get_item):
def inner(collection='test'):
url = 'http://127.0.0.1/'
rv = {'url': url, 'username': 'bob', 'password': 'bob'}
if collection is not None:
args = await slow_create_collection(
self.storage_class,
args,
collection,
)
return args
collection = collection + self.storage_class.fileext
rv = self.storage_class.create_collection(collection, **rv)
s = self.storage_class(**rv)
assert not list(s.list())
return rv
return inner

View file

@ -0,0 +1,12 @@
#!/bin/sh
set -e
if [ "$REQUIREMENTS" = "release" ] || [ "$REQUIREMENTS" = "minimal" ]; then
radicale_pkg="radicale"
elif [ "$REQUIREMENTS" = "devel" ]; then
radicale_pkg="git+https://github.com/Kozea/Radicale.git"
else
echo "Invalid requirements envvar"
false
fi
pip install wsgi_intercept $radicale_pkg

View file

@ -1,9 +1,8 @@
from __future__ import annotations
import pytest
class ServerMixin:
class ServerMixin(object):
@pytest.fixture
def get_storage_args(self):
pytest.skip("DAV tests disabled.")
pytest.skip('DAV tests disabled.')

View file

@ -0,0 +1 @@
#!/bin/sh

View file

@ -1,29 +1,35 @@
from __future__ import annotations
import pytest
from xandikos.web import XandikosApp, XandikosBackend, WellknownRedirector
class ServerMixin:
import wsgi_intercept
import wsgi_intercept.requests_intercept
class ServerMixin(object):
@pytest.fixture
def get_storage_args(
self,
request,
tmpdir,
slow_create_collection,
xandikos_server,
aio_connector,
):
async def inner(collection="test"):
url = "http://127.0.0.1:8000/"
args = {"url": url, "connector": aio_connector}
def get_storage_args(self, request, tmpdir, slow_create_collection):
tmpdir.mkdir('xandikos')
backend = XandikosBackend(path=str(tmpdir))
cup = '/user/'
backend.create_principal(cup, create_defaults=True)
app = XandikosApp(backend, cup)
app = WellknownRedirector(app, '/')
wsgi_intercept.requests_intercept.install()
wsgi_intercept.add_wsgi_intercept('127.0.0.1', 8080, lambda: app)
def teardown():
wsgi_intercept.remove_wsgi_intercept('127.0.0.1', 8080)
wsgi_intercept.requests_intercept.uninstall()
request.addfinalizer(teardown)
def inner(collection='test'):
url = 'http://127.0.0.1:8080/'
args = {'url': url, 'collection': collection}
if collection is not None:
args = await slow_create_collection(
self.storage_class,
args,
collection,
)
args = self.storage_class.create_collection(**args)
return args
return inner

View file

@ -0,0 +1,13 @@
#!/bin/sh
set -e
pip install wsgi_intercept
if [ "$REQUIREMENTS" = "release" ] || [ "$REQUIREMENTS" = "minimal" ]; then
pip install -U xandikos
elif [ "$REQUIREMENTS" = "devel" ]; then
pip install -U git+https://github.com/jelmer/xandikos
else
echo "Invalid REQUIREMENTS value"
false
fi

View file

@ -1,14 +1,13 @@
from __future__ import annotations
# -*- coding: utf-8 -*-
import subprocess
import aiostream
import pytest
from vdirsyncer.storage.filesystem import FilesystemStorage
from vdirsyncer.vobject import Item
from . import StorageTests
from tests import format_item
class TestFilesystemStorage(StorageTests):
@ -16,117 +15,72 @@ class TestFilesystemStorage(StorageTests):
@pytest.fixture
def get_storage_args(self, tmpdir):
async 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 = await self.storage_class.create_collection(**rv)
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")
with pytest.raises(IOError):
f = tmpdir.join('hue')
f.write('stub')
self.storage_class(str(tmpdir) + '/hue', '.txt')
@pytest.mark.asyncio
async def test_broken_data(self, tmpdir):
s = self.storage_class(str(tmpdir), ".txt")
def test_broken_data(self, tmpdir):
s = self.storage_class(str(tmpdir), '.txt')
class BrokenItem:
raw = "Ц, Ш, Л, ж, Д, З, Ю".encode()
uid = "jeezus"
class BrokenItem(object):
raw = u'Ц, Ш, Л, ж, Д, З, Ю'.encode('utf-8')
uid = 'jeezus'
ident = uid
with pytest.raises(TypeError):
await s.upload(BrokenItem)
s.upload(BrokenItem)
assert not tmpdir.listdir()
@pytest.mark.asyncio
async def test_ident_with_slash(self, tmpdir):
s = self.storage_class(str(tmpdir), ".txt")
await s.upload(Item("UID:a/b/c"))
(item_file,) = tmpdir.listdir()
assert "/" not in item_file.basename
assert item_file.isfile()
def test_ident_with_slash(self, tmpdir):
s = self.storage_class(str(tmpdir), '.txt')
s.upload(format_item('a/b/c'))
item_file, = tmpdir.listdir()
assert '/' not in item_file.basename and item_file.isfile()
@pytest.mark.asyncio
async 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")
await s.upload(Item("UID:xyzxyz"))
(item_file,) = tmpdir.listdir()
item_file.copy(item_file.new(ext="tmp"))
assert len(tmpdir.listdir()) == 2
assert len(await aiostream.stream.list(s.list())) == 1
@pytest.mark.asyncio
async 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), "")
await 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(await aiostream.stream.list(s.list())) == 1
@pytest.mark.asyncio
async 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)
await 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(await aiostream.stream.list(storage.list())) == 1
@pytest.mark.asyncio
async def test_too_long_uid(self, tmpdir):
storage = self.storage_class(str(tmpdir), ".txt")
item = Item("UID:" + "hue" * 600)
href, _etag = await storage.upload(item)
def test_too_long_uid(self, tmpdir):
s = self.storage_class(str(tmpdir), '.txt')
item = format_item('hue' * 600)
href, etag = s.upload(item)
assert item.uid not in href
@pytest.mark.asyncio
async def test_post_hook_inactive(self, tmpdir, monkeypatch):
def test_post_hook_inactive(self, tmpdir, monkeypatch):
def check_call_mock(*args, **kwargs):
raise AssertionError
assert False
monkeypatch.setattr(subprocess, "call", check_call_mock)
monkeypatch.setattr(subprocess, 'call', check_call_mock)
s = self.storage_class(str(tmpdir), ".txt", post_hook=None)
await s.upload(Item("UID:a/b/c"))
s = self.storage_class(str(tmpdir), '.txt', post_hook=None)
s.upload(format_item('a/b/c'))
def test_post_hook_active(self, tmpdir, monkeypatch):
@pytest.mark.asyncio
async def test_post_hook_active(self, tmpdir, monkeypatch):
calls = []
exe = "foo"
exe = 'foo'
def check_call_mock(call, *args, **kwargs):
def check_call_mock(l, *args, **kwargs):
calls.append(True)
assert len(call) == 2
assert call[0] == exe
assert len(l) == 2
assert l[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)
await s.upload(Item("UID:a/b/c"))
s = self.storage_class(str(tmpdir), '.txt', post_hook=exe)
s.upload(format_item('a/b/c'))
assert calls
@pytest.mark.asyncio
async def test_ignore_git_dirs(self, tmpdir):
tmpdir.mkdir(".git").mkdir("foo")
tmpdir.mkdir("a")
tmpdir.mkdir("b")
expected = {"a", "b"}
actual = {
c["collection"] async for c in self.storage_class.discover(str(tmpdir))
}
assert actual == expected
def test_ignore_git_dirs(self, tmpdir):
tmpdir.mkdir('.git').mkdir('foo')
tmpdir.mkdir('a')
tmpdir.mkdir('b')
assert set(c['collection'] for c
in self.storage_class.discover(str(tmpdir))) == {'a', 'b'}

View file

@ -1,163 +1,122 @@
from __future__ import annotations
# -*- coding: utf-8 -*-
import aiohttp
import pytest
from aioresponses import CallbackResult
from aioresponses import aioresponses
from tests import normalize_item
from requests import Response
from vdirsyncer.exceptions import UserError
from vdirsyncer.http import BasicAuthMethod
from vdirsyncer.http import DigestAuthMethod
from vdirsyncer.http import UsageLimitReached
from vdirsyncer.http import request
from vdirsyncer.storage.http import HttpStorage
from vdirsyncer.storage.http import prepare_auth
from vdirsyncer.storage.http import HttpStorage, prepare_auth
from vdirsyncer.vobject import Item
@pytest.mark.asyncio
async def test_list(aio_connector):
collection_url = "http://127.0.0.1/calendar/collection.ics"
def test_list(monkeypatch):
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"
),
(u'BEGIN:VEVENT\n'
u'SUMMARY:Eine Kurzinfo\n'
u'DESCRIPTION:Beschreibung des Termines\n'
u'END:VEVENT'),
(u'BEGIN:VEVENT\n'
u'SUMMARY:Eine zweite Küèrzinfo\n'
u'DESCRIPTION:Beschreibung des anderen Termines\n'
u'BEGIN:VALARM\n'
u'ACTION:AUDIO\n'
u'TRIGGER:19980403T120000\n'
u'ATTACH;FMTTYPE=audio/basic:http://host.com/pub/ssbanner.aud\n'
u'REPEAT:4\n'
u'DURATION:PT1H\n'
u'END:VALARM\n'
u'END:VEVENT')
]
responses = ["\n".join(["BEGIN:VCALENDAR", *items, "END:VCALENDAR"])] * 2
responses = [
u'\n'.join([u'BEGIN:VCALENDAR'] + items + [u'END:VCALENDAR'])
] * 2
def callback(url, headers, **kwargs):
assert headers["User-Agent"].startswith("vdirsyncer/")
def get(self, method, url, *a, **kw):
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'
return r
return CallbackResult(
status=200,
body=responses.pop().encode("utf-8"),
headers={"Content-Type": "text/calendar; charset=iso-8859-1"},
)
monkeypatch.setattr('requests.sessions.Session.request', get)
with aioresponses() as m:
m.get(collection_url, callback=callback, repeat=True)
s = HttpStorage(url=collection_url)
s = HttpStorage(url=collection_url, connector=aio_connector)
found_items = {}
found_items = {}
for href, etag in s.list():
item, etag2 = s.get(href)
assert item.uid is not None
assert etag2 == etag
found_items[item.hash] = href
async for href, etag in s.list():
item, etag2 = await s.get(href)
assert item.uid is not None
assert etag2 == etag
found_items[normalize_item(item)] = href
expected = set(Item(u'BEGIN:VCALENDAR\n' + x + '\nEND:VCALENDAR').hash
for x in items)
expected = {
normalize_item("BEGIN:VCALENDAR\n" + x + "\nEND:VCALENDAR") for x in items
}
assert set(found_items) == expected
assert set(found_items) == expected
async for href, etag in s.list():
item, etag2 = await s.get(href)
assert item.uid is not None
assert etag2 == etag
assert found_items[normalize_item(item)] == href
for href, etag in s.list():
item, etag2 = s.get(href)
assert item.uid is not None
assert etag2 == etag
assert found_items[item.hash] == href
def test_readonly_param(aio_connector):
"""The ``readonly`` param cannot be ``False``."""
url = "http://example.com/"
def test_readonly_param():
url = 'http://example.com/'
with pytest.raises(ValueError):
HttpStorage(url=url, read_only=False, connector=aio_connector)
HttpStorage(url=url, read_only=False)
a = HttpStorage(url=url, read_only=True, connector=aio_connector)
b = HttpStorage(url=url, read_only=None, connector=aio_connector)
assert a.read_only is b.read_only is True
a = HttpStorage(url=url, read_only=True).read_only
b = HttpStorage(url=url, read_only=None).read_only
assert a is b is True
def test_prepare_auth():
assert prepare_auth(None, "", "") is None
assert prepare_auth(None, '', '') is None
assert prepare_auth(None, "user", "pwd") == BasicAuthMethod("user", "pwd")
assert prepare_auth("basic", "user", "pwd") == BasicAuthMethod("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()
assert isinstance(prepare_auth("digest", "user", "pwd"), DigestAuthMethod)
from requests.auth import 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():
# guess auth is currently not supported
def test_prepare_auth_guess(monkeypatch):
import requests_toolbelt.auth.guess
assert isinstance(prepare_auth('guess', 'user', 'pwd'),
requests_toolbelt.auth.guess.GuessAuth)
monkeypatch.delattr(requests_toolbelt.auth.guess, 'GuessAuth')
with pytest.raises(UserError) as excinfo:
prepare_auth("guess", "usr", "pwd")
prepare_auth('guess', 'user', 'pwd')
assert "not supported" in str(excinfo.value).lower()
assert 'requests_toolbelt is too old' in str(excinfo.value).lower()
def test_verify_false_disallowed(aio_connector):
def test_verify_false_disallowed():
with pytest.raises(ValueError) as excinfo:
HttpStorage(url="http://example.com", verify=False, connector=aio_connector)
HttpStorage(url='http://example.com', verify=False)
assert "must be a path to a pem-file." in str(excinfo.value).lower()
@pytest.mark.asyncio
async def test_403_usage_limit_exceeded(aio_connector):
url = "http://127.0.0.1/test_403"
error_body = {
"error": {
"errors": [
{
"domain": "usageLimits",
"message": "Calendar usage limits exceeded.",
"reason": "quotaExceeded",
}
],
"code": 403,
"message": "Calendar usage limits exceeded.",
}
}
async with aiohttp.ClientSession(connector=aio_connector) as session:
with aioresponses() as m:
m.get(url, status=403, payload=error_body, repeat=True)
with pytest.raises(UsageLimitReached):
await request("GET", url, session)
@pytest.mark.asyncio
async def test_403_without_usage_limits_domain(aio_connector):
"""A 403 JSON error without the Google 'usageLimits' domain should not be
treated as UsageLimitReached and should surface as ClientResponseError.
"""
url = "http://127.0.0.1/test_403_no_usage_limits"
async with aiohttp.ClientSession(connector=aio_connector) as session:
with aioresponses() as m:
m.get(url, status=403, repeat=True)
with pytest.raises(aiohttp.ClientResponseError):
await request("GET", url, session)
assert 'forbidden' in str(excinfo.value).lower()
assert 'consider setting verify_fingerprint' in str(excinfo.value).lower()

Some files were not shown because too many files have changed in this diff Show more