Use rust-vobject (#675)

Use rust-vobject
This commit is contained in:
Markus Unterwaditzer 2017-10-04 22:41:18 +02:00 committed by GitHub
parent 7bdb22a207
commit 1b7cb4e656
22 changed files with 375 additions and 131 deletions

1
.gitignore vendored
View file

@ -12,4 +12,5 @@ env
dist dist
docs/_build/ docs/_build/
vdirsyncer/version.py vdirsyncer/version.py
vdirsyncer/_native*
.hypothesis .hypothesis

View file

@ -44,12 +44,14 @@ following things are installed:
- Python 3.4+ and pip. - Python 3.4+ and pip.
- ``libxml`` and ``libxslt`` - ``libxml`` and ``libxslt``
- ``zlib`` - ``zlib``
- `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`. - Linux or OS X. **Windows is not supported**, see :gh:`535`.
On Linux systems, using the distro's package manager is the best On Linux systems, using the distro's package manager is the best
way to do this, for example, using Ubuntu:: way to do this, for example, using Ubuntu::
sudo apt-get install libxml2 libxslt1.1 zlib1g python3 sudo apt-get install libxml2 libxslt1.1 zlib1g python3 rustc cargo
Then you have several options. The following text applies for most Python Then you have several options. The following text applies for most Python
software by the way. software by the way.

2
rust/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
Cargo.lock
target/

15
rust/Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[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.2.0"
[build-dependencies]
cbindgen = "0.1"

12
rust/build.rs Normal file
View file

@ -0,0 +1,12 @@
extern crate cbindgen;
use std::env;
fn main() {
let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
let mut config: cbindgen::Config = Default::default();
config.language = cbindgen::Language::C;
cbindgen::generate_with_config(&crate_dir, config)
.unwrap()
.write_to_file("target/vdirsyncer_rustext.h");
}

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

@ -0,0 +1,102 @@
extern crate vobject;
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::mem;
use std::ptr;
const EMPTY_STRING: *const c_char = b"\0" as *const u8 as *const c_char;
#[repr(C)]
pub struct VdirsyncerError {
pub failed: bool,
pub msg: *mut c_char,
}
// Workaround to be able to use opaque pointer
#[repr(C)]
pub struct VdirsyncerComponent(vobject::Component);
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_get_uid(c: *mut VdirsyncerComponent) -> *const c_char {
match safe_get_uid(&(*c).0) {
Some(x) => CString::new(x).unwrap().into_raw(),
None => EMPTY_STRING
}
}
#[inline]
fn safe_get_uid(c: &vobject::Component) -> Option<String> {
let mut stack = 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
}
#[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_parse_component(s: *const c_char, err: *mut VdirsyncerError) -> *mut VdirsyncerComponent {
let cstring = CStr::from_ptr(s);
match vobject::parse_component(cstring.to_str().unwrap()) {
Ok(x) => mem::transmute(Box::new(VdirsyncerComponent(x))),
Err(e) => {
(*err).failed = true;
(*err).msg = CString::new(e.into_string()).unwrap().into_raw();
mem::zeroed()
}
}
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_free_component(c: *mut VdirsyncerComponent) {
let _: Box<VdirsyncerComponent> = mem::transmute(c);
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_clear_err(e: *mut VdirsyncerError) {
CString::from_raw((*e).msg);
(*e).msg = ptr::null_mut();
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_change_uid(c: *mut VdirsyncerComponent, uid: *const c_char) {
let uid_cstring = CStr::from_ptr(uid);
change_uid(&mut (*c).0, uid_cstring.to_str().unwrap());
}
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());
}
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_clone_component(c: *mut VdirsyncerComponent) -> *mut VdirsyncerComponent {
mem::transmute(Box::new((*c).0.clone()))
}
#[no_mangle]
pub unsafe extern "C" fn vdirsyncer_write_component(c: *mut VdirsyncerComponent) -> *const c_char {
CString::new(vobject::write_component(&(*c).0)).unwrap().into_raw()
}

View file

@ -8,9 +8,12 @@ ARG distrover
RUN apt-get update RUN apt-get update
RUN apt-get install -y build-essential fakeroot debhelper git RUN apt-get install -y build-essential fakeroot debhelper git
RUN apt-get install -y python3-all python3-pip RUN apt-get install -y python3-all python3-dev python3-pip
RUN apt-get install -y ruby ruby-dev RUN apt-get install -y ruby ruby-dev
RUN apt-get install -y python-all python-pip 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 gem install fpm
@ -24,7 +27,7 @@ RUN mkdir /vdirsyncer/pkgs/
RUN basename *.tar.gz .tar.gz | cut -d'-' -f2 | sed -e 's/\.dev/~/g' | tee version 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 (echo -n *.tar.gz; echo '[google]') | tee requirements.txt
RUN . /vdirsyncer/env/bin/activate; fpm -s virtualenv -t deb \ RUN . /vdirsyncer/env/bin/activate; fpm --verbose -s virtualenv -t deb \
-n "vdirsyncer-latest" \ -n "vdirsyncer-latest" \
-v "$(cat version)" \ -v "$(cat version)" \
--prefix /opt/venvs/vdirsyncer-latest \ --prefix /opt/venvs/vdirsyncer-latest \

View file

@ -8,3 +8,6 @@ if [ "$TRAVIS_OS_NAME" = "osx" ]; then
virtualenv -p python3 $HOME/osx-py3 virtualenv -p python3 $HOME/osx-py3
. $HOME/osx-py3/bin/activate . $HOME/osx-py3/bin/activate
fi fi
curl https://sh.rustup.rs -sSf | sh -s -- -y
export PATH="$HOME/.cargo/bin/:$PATH"

View file

@ -9,5 +9,5 @@ addopts = --tb=short
# E731: Use a def instead of lambda expr # E731: Use a def instead of lambda expr
ignore = E731 ignore = E731
select = C,E,F,W,B,B9 select = C,E,F,W,B,B9
exclude = tests/storage/servers/owncloud/, tests/storage/servers/nextcloud/, tests/storage/servers/baikal/, build/ exclude = .eggs/, tests/storage/servers/owncloud/, tests/storage/servers/nextcloud/, tests/storage/servers/baikal/, build/, vdirsyncer/_native*
application-package-names = tests,vdirsyncer application-package-names = tests,vdirsyncer

View file

@ -9,6 +9,7 @@ how to package vdirsyncer.
from setuptools import Command, find_packages, setup from setuptools import Command, find_packages, setup
milksnake = 'milksnake'
requirements = [ requirements = [
# https://github.com/mitsuhiko/click/issues/200 # https://github.com/mitsuhiko/click/issues/200
@ -32,10 +33,29 @@ requirements = [
'requests_toolbelt >=0.4.0', 'requests_toolbelt >=0.4.0',
# https://github.com/untitaker/python-atomicwrites/commit/4d12f23227b6a944ab1d99c507a69fdbc7c9ed6d # noqa # https://github.com/untitaker/python-atomicwrites/commit/4d12f23227b6a944ab1d99c507a69fdbc7c9ed6d # noqa
'atomicwrites>=0.1.7' 'atomicwrites>=0.1.7',
milksnake
] ]
def build_native(spec):
build = spec.add_external_build(
cmd=['cargo', 'build', '--release'],
path='./rust'
)
spec.add_cffi_module(
module_path='vdirsyncer._native',
dylib=lambda: build.find_dylib(
'vdirsyncer_rustext', in_path='target/release'),
header_filename=lambda: build.find_header(
'vdirsyncer_rustext.h', in_path='target'),
# Rust bug: If thread-local storage is used, this flag is necessary
# (mitsuhiko)
rtld_flags=['NODELETE']
)
class PrintRequirements(Command): class PrintRequirements(Command):
description = 'Prints minimal requirements' description = 'Prints minimal requirements'
user_options = [] user_options = []
@ -75,7 +95,10 @@ setup(
}, },
# Build dependencies # Build dependencies
setup_requires=['setuptools_scm != 1.12.0'], setup_requires=[
'setuptools_scm != 1.12.0',
milksnake,
],
# Other # Other
packages=find_packages(exclude=['tests.*', 'tests']), packages=find_packages(exclude=['tests.*', 'tests']),
@ -101,4 +124,7 @@ setup(
'Topic :: Internet', 'Topic :: Internet',
'Topic :: Utilities', 'Topic :: Utilities',
], ],
milksnake_tasks=[build_native],
zip_safe=False,
platforms='any'
) )

View file

@ -3,9 +3,11 @@
Test suite for vdirsyncer. Test suite for vdirsyncer.
''' '''
import random
import hypothesis.strategies as st import hypothesis.strategies as st
from vdirsyncer.vobject import normalize_item from vdirsyncer.vobject import Item, normalize_item
import urllib3 import urllib3
import urllib3.exceptions import urllib3.exceptions
@ -109,3 +111,10 @@ uid_strategy = st.text(
)), )),
min_size=1 min_size=1
).filter(lambda x: x.strip() == x) ).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,6 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import random
import uuid import uuid
import textwrap import textwrap
@ -16,7 +15,8 @@ from vdirsyncer.storage.base import normalize_meta_value
from vdirsyncer.vobject import Item from vdirsyncer.vobject import Item
from .. import EVENT_TEMPLATE, TASK_TEMPLATE, VCARD_TEMPLATE, \ from .. import EVENT_TEMPLATE, TASK_TEMPLATE, VCARD_TEMPLATE, \
assert_item_equals, normalize_item, printable_characters_strategy assert_item_equals, format_item, normalize_item, \
printable_characters_strategy
def get_server_mixin(server_name): def get_server_mixin(server_name):
@ -25,12 +25,6 @@ def get_server_mixin(server_name):
return x.ServerMixin 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(object): class StorageTests(object):
storage_class = None storage_class = None
supports_collections = True supports_collections = True
@ -62,7 +56,7 @@ class StorageTests(object):
'VCARD': VCARD_TEMPLATE, 'VCARD': VCARD_TEMPLATE,
}[item_type] }[item_type]
return lambda **kw: format_item(template, **kw) return lambda **kw: format_item(item_template=template, **kw)
@pytest.fixture @pytest.fixture
def requires_collections(self): def requires_collections(self):

View file

@ -28,7 +28,7 @@ class TestCalDAVStorage(DAVStorageTests):
s = self.storage_class(item_types=(item_type,), **get_storage_args()) s = self.storage_class(item_types=(item_type,), **get_storage_args())
try: try:
s.upload(format_item(VCARD_TEMPLATE)) s.upload(format_item(item_template=VCARD_TEMPLATE))
except (exceptions.Error, requests.exceptions.HTTPError): except (exceptions.Error, requests.exceptions.HTTPError):
pass pass
assert not list(s.list()) assert not list(s.list())
@ -64,7 +64,7 @@ class TestCalDAVStorage(DAVStorageTests):
s = self.storage_class(start_date=start_date, end_date=end_date, s = self.storage_class(start_date=start_date, end_date=end_date,
**get_storage_args()) **get_storage_args())
too_old_item = format_item(dedent(u''' too_old_item = format_item(item_template=dedent(u'''
BEGIN:VCALENDAR BEGIN:VCALENDAR
VERSION:2.0 VERSION:2.0
PRODID:-//hacksw/handcal//NONSGML v1.0//EN PRODID:-//hacksw/handcal//NONSGML v1.0//EN
@ -78,7 +78,7 @@ class TestCalDAVStorage(DAVStorageTests):
END:VCALENDAR END:VCALENDAR
''').strip()) ''').strip())
too_new_item = format_item(dedent(u''' too_new_item = format_item(item_template=dedent(u'''
BEGIN:VCALENDAR BEGIN:VCALENDAR
VERSION:2.0 VERSION:2.0
PRODID:-//hacksw/handcal//NONSGML v1.0//EN PRODID:-//hacksw/handcal//NONSGML v1.0//EN
@ -92,7 +92,7 @@ class TestCalDAVStorage(DAVStorageTests):
END:VCALENDAR END:VCALENDAR
''').strip()) ''').strip())
good_item = format_item(dedent(u''' good_item = format_item(item_template=dedent(u'''
BEGIN:VCALENDAR BEGIN:VCALENDAR
VERSION:2.0 VERSION:2.0
PRODID:-//hacksw/handcal//NONSGML v1.0//EN PRODID:-//hacksw/handcal//NONSGML v1.0//EN
@ -136,8 +136,8 @@ class TestCalDAVStorage(DAVStorageTests):
@pytest.mark.skipif(dav_server == 'icloud', @pytest.mark.skipif(dav_server == 'icloud',
reason='iCloud only accepts VEVENT') reason='iCloud only accepts VEVENT')
def test_item_types_general(self, s): def test_item_types_general(self, s):
event = s.upload(format_item(EVENT_TEMPLATE))[0] event = s.upload(format_item(item_template=EVENT_TEMPLATE))[0]
task = s.upload(format_item(TASK_TEMPLATE))[0] task = s.upload(format_item(item_template=TASK_TEMPLATE))[0]
s.item_types = ('VTODO', 'VEVENT') s.item_types = ('VTODO', 'VEVENT')
def l(): def l():

View file

@ -5,9 +5,9 @@ import subprocess
import pytest import pytest
from vdirsyncer.storage.filesystem import FilesystemStorage from vdirsyncer.storage.filesystem import FilesystemStorage
from vdirsyncer.vobject import Item
from . import StorageTests from . import StorageTests
from tests import format_item
class TestFilesystemStorage(StorageTests): class TestFilesystemStorage(StorageTests):
@ -42,13 +42,13 @@ class TestFilesystemStorage(StorageTests):
def test_ident_with_slash(self, tmpdir): def test_ident_with_slash(self, tmpdir):
s = self.storage_class(str(tmpdir), '.txt') s = self.storage_class(str(tmpdir), '.txt')
s.upload(Item(u'UID:a/b/c')) s.upload(format_item('a/b/c'))
item_file, = tmpdir.listdir() item_file, = tmpdir.listdir()
assert '/' not in item_file.basename and item_file.isfile() assert '/' not in item_file.basename and item_file.isfile()
def test_too_long_uid(self, tmpdir): def test_too_long_uid(self, tmpdir):
s = self.storage_class(str(tmpdir), '.txt') s = self.storage_class(str(tmpdir), '.txt')
item = Item(u'UID:' + u'hue' * 600) item = format_item('hue' * 600)
href, etag = s.upload(item) href, etag = s.upload(item)
assert item.uid not in href assert item.uid not in href
@ -60,7 +60,7 @@ class TestFilesystemStorage(StorageTests):
monkeypatch.setattr(subprocess, 'call', check_call_mock) monkeypatch.setattr(subprocess, 'call', check_call_mock)
s = self.storage_class(str(tmpdir), '.txt', post_hook=None) s = self.storage_class(str(tmpdir), '.txt', post_hook=None)
s.upload(Item(u'UID:a/b/c')) s.upload(format_item('a/b/c'))
def test_post_hook_active(self, tmpdir, monkeypatch): def test_post_hook_active(self, tmpdir, monkeypatch):
@ -75,7 +75,7 @@ class TestFilesystemStorage(StorageTests):
monkeypatch.setattr(subprocess, 'call', check_call_mock) monkeypatch.setattr(subprocess, 'call', check_call_mock)
s = self.storage_class(str(tmpdir), '.txt', post_hook=exe) s = self.storage_class(str(tmpdir), '.txt', post_hook=exe)
s.upload(Item(u'UID:a/b/c')) s.upload(format_item('a/b/c'))
assert calls assert calls
def test_ignore_git_dirs(self, tmpdir): def test_ignore_git_dirs(self, tmpdir):

View file

@ -62,7 +62,7 @@ def test_repair_uids(storage, runner, repair_uids):
assert 'UID or href is unsafe, assigning random UID' in result.output assert 'UID or href is unsafe, assigning random UID' in result.output
assert not f.exists() assert not f.exists()
new_f, = storage.listdir() new_f, = storage.listdir()
s = new_f.read() s = new_f.read().strip()
assert s.startswith('BEGIN:VCARD') assert s.startswith('BEGIN:VCARD')
assert s.endswith('END:VCARD') assert s.endswith('END:VCARD')

View file

@ -9,6 +9,8 @@ from hypothesis import example, given
import pytest import pytest
from tests import format_item
def test_simple_run(tmpdir, runner): def test_simple_run(tmpdir, runner):
runner.write_with_general(dedent(''' runner.write_with_general(dedent('''
@ -37,10 +39,11 @@ def test_simple_run(tmpdir, runner):
result = runner.invoke(['sync']) result = runner.invoke(['sync'])
assert not result.exception assert not result.exception
tmpdir.join('path_a/haha.txt').write('UID:haha') item = format_item('haha')
tmpdir.join('path_a/haha.txt').write(item.raw)
result = runner.invoke(['sync']) result = runner.invoke(['sync'])
assert 'Copying (uploading) item haha to my_b' in result.output assert 'Copying (uploading) item haha to my_b' in result.output
assert tmpdir.join('path_b/haha.txt').read() == 'UID:haha' assert tmpdir.join('path_b/haha.txt').read() == item.raw
def test_sync_inexistant_pair(tmpdir, runner): def test_sync_inexistant_pair(tmpdir, runner):
@ -109,7 +112,8 @@ def test_empty_storage(tmpdir, runner):
result = runner.invoke(['sync']) result = runner.invoke(['sync'])
assert not result.exception assert not result.exception
tmpdir.join('path_a/haha.txt').write('UID:haha') item = format_item('haha')
tmpdir.join('path_a/haha.txt').write(item.raw)
result = runner.invoke(['sync']) result = runner.invoke(['sync'])
assert not result.exception assert not result.exception
tmpdir.join('path_b/haha.txt').remove() tmpdir.join('path_b/haha.txt').remove()
@ -152,7 +156,7 @@ def test_collections_cache_invalidation(tmpdir, runner):
collections = ["a", "b", "c"] collections = ["a", "b", "c"]
''').format(str(tmpdir))) ''').format(str(tmpdir)))
foo.join('a/itemone.txt').write('UID:itemone') foo.join('a/itemone.txt').write(format_item('itemone').raw)
result = runner.invoke(['discover']) result = runner.invoke(['discover'])
assert not result.exception assert not result.exception
@ -347,9 +351,10 @@ def test_ident_conflict(tmpdir, runner):
foo = tmpdir.mkdir('foo') foo = tmpdir.mkdir('foo')
tmpdir.mkdir('bar') tmpdir.mkdir('bar')
foo.join('one.txt').write('UID:1') item = format_item('1')
foo.join('two.txt').write('UID:1') foo.join('one.txt').write(item.raw)
foo.join('three.txt').write('UID:1') foo.join('two.txt').write(item.raw)
foo.join('three.txt').write(item.raw)
result = runner.invoke(['discover']) result = runner.invoke(['discover'])
assert not result.exception assert not result.exception
@ -403,8 +408,12 @@ def test_no_configured_pairs(tmpdir, runner, cmd):
assert result.exception.code == 5 assert result.exception.code == 5
item_a = format_item('lol')
item_b = format_item('lol')
@pytest.mark.parametrize('resolution,expect_foo,expect_bar', [ @pytest.mark.parametrize('resolution,expect_foo,expect_bar', [
(['command', 'cp'], 'UID:lol\nfööcontent', 'UID:lol\nfööcontent') (['command', 'cp'], item_a.raw, item_a.raw)
]) ])
def test_conflict_resolution(tmpdir, runner, resolution, expect_foo, def test_conflict_resolution(tmpdir, runner, resolution, expect_foo,
expect_bar): expect_bar):
@ -429,9 +438,9 @@ def test_conflict_resolution(tmpdir, runner, resolution, expect_foo,
foo = tmpdir.join('foo') foo = tmpdir.join('foo')
bar = tmpdir.join('bar') bar = tmpdir.join('bar')
fooitem = foo.join('lol.txt').ensure() fooitem = foo.join('lol.txt').ensure()
fooitem.write('UID:lol\nfööcontent') fooitem.write(item_a.raw)
baritem = bar.join('lol.txt').ensure() baritem = bar.join('lol.txt').ensure()
baritem.write('UID:lol\nbööcontent') baritem.write(item_b.raw)
r = runner.invoke(['discover']) r = runner.invoke(['discover'])
assert not r.exception assert not r.exception
@ -471,11 +480,12 @@ def test_partial_sync(tmpdir, runner, partial_sync):
foo = tmpdir.mkdir('foo') foo = tmpdir.mkdir('foo')
bar = tmpdir.mkdir('bar') bar = tmpdir.mkdir('bar')
foo.join('other.txt').write('UID:other') item = format_item('other')
bar.join('other.txt').write('UID:other') foo.join('other.txt').write(item.raw)
bar.join('other.txt').write(item.raw)
baritem = bar.join('lol.txt') baritem = bar.join('lol.txt')
baritem.write('UID:lol') baritem.write(format_item('lol').raw)
r = runner.invoke(['discover']) r = runner.invoke(['discover'])
assert not r.exception assert not r.exception

View file

@ -8,7 +8,7 @@ from hypothesis.stateful import Bundle, RuleBasedStateMachine, rule
import pytest import pytest
from tests import blow_up, uid_strategy from tests import blow_up, format_item, uid_strategy
from vdirsyncer.storage.memory import MemoryStorage, _random_string from vdirsyncer.storage.memory import MemoryStorage, _random_string
from vdirsyncer.sync import sync as _sync from vdirsyncer.sync import sync as _sync
@ -49,7 +49,7 @@ def test_missing_status():
a = MemoryStorage() a = MemoryStorage()
b = MemoryStorage() b = MemoryStorage()
status = {} status = {}
item = Item(u'asdf') item = format_item('asdf')
a.upload(item) a.upload(item)
b.upload(item) b.upload(item)
sync(a, b, status) sync(a, b, status)
@ -62,8 +62,8 @@ def test_missing_status_and_different_items():
b = MemoryStorage() b = MemoryStorage()
status = {} status = {}
item1 = Item(u'UID:1\nhaha') item1 = format_item('1')
item2 = Item(u'UID:1\nhoho') item2 = format_item('1')
a.upload(item1) a.upload(item1)
b.upload(item2) b.upload(item2)
with pytest.raises(SyncConflict): with pytest.raises(SyncConflict):
@ -79,8 +79,8 @@ def test_read_only_and_prefetch():
b.read_only = True b.read_only = True
status = {} status = {}
item1 = Item(u'UID:1\nhaha') item1 = format_item('1')
item2 = Item(u'UID:2\nhoho') item2 = format_item('2')
a.upload(item1) a.upload(item1)
a.upload(item2) a.upload(item2)
@ -95,7 +95,8 @@ def test_partial_sync_error():
b = MemoryStorage() b = MemoryStorage()
status = {} status = {}
a.upload(Item('UID:0')) item = format_item('0')
a.upload(item)
b.read_only = True b.read_only = True
with pytest.raises(PartialSync): with pytest.raises(PartialSync):
@ -107,13 +108,13 @@ def test_partial_sync_ignore():
b = MemoryStorage() b = MemoryStorage()
status = {} status = {}
item0 = Item('UID:0\nhehe') item0 = format_item('0')
a.upload(item0) a.upload(item0)
b.upload(item0) b.upload(item0)
b.read_only = True b.read_only = True
item1 = Item('UID:1\nhaha') item1 = format_item('1')
a.upload(item1) a.upload(item1)
sync(a, b, status, partial_sync='ignore') sync(a, b, status, partial_sync='ignore')
@ -128,23 +129,25 @@ def test_partial_sync_ignore2():
b = MemoryStorage() b = MemoryStorage()
status = {} status = {}
href, etag = a.upload(Item('UID:0')) item = format_item('0')
href, etag = a.upload(item)
a.read_only = True a.read_only = True
sync(a, b, status, partial_sync='ignore', force_delete=True) sync(a, b, status, partial_sync='ignore', force_delete=True)
assert items(b) == items(a) == {'UID:0'} assert items(b) == items(a) == {item.raw}
b.items.clear() b.items.clear()
sync(a, b, status, partial_sync='ignore', force_delete=True) sync(a, b, status, partial_sync='ignore', force_delete=True)
sync(a, b, status, partial_sync='ignore', force_delete=True) sync(a, b, status, partial_sync='ignore', force_delete=True)
assert items(a) == {'UID:0'} assert items(a) == {item.raw}
assert not b.items assert not b.items
a.read_only = False a.read_only = False
a.update(href, Item('UID:0\nupdated'), etag) new_item = format_item('0')
a.update(href, new_item, etag)
a.read_only = True a.read_only = True
sync(a, b, status, partial_sync='ignore', force_delete=True) sync(a, b, status, partial_sync='ignore', force_delete=True)
assert items(b) == items(a) == {'UID:0\nupdated'} assert items(b) == items(a) == {new_item.raw}
def test_upload_and_update(): def test_upload_and_update():
@ -152,22 +155,22 @@ def test_upload_and_update():
b = MemoryStorage(fileext='.b') b = MemoryStorage(fileext='.b')
status = {} status = {}
item = Item(u'UID:1') # new item 1 in a item = format_item('1') # new item 1 in a
a.upload(item) a.upload(item)
sync(a, b, status) sync(a, b, status)
assert items(b) == items(a) == {item.raw} assert items(b) == items(a) == {item.raw}
item = Item(u'UID:1\nASDF:YES') # update of item 1 in b item = format_item('1') # update of item 1 in b
b.update('1.b', item, b.get('1.b')[1]) b.update('1.b', item, b.get('1.b')[1])
sync(a, b, status) sync(a, b, status)
assert items(b) == items(a) == {item.raw} assert items(b) == items(a) == {item.raw}
item2 = Item(u'UID:2') # new item 2 in b item2 = format_item('2') # new item 2 in b
b.upload(item2) b.upload(item2)
sync(a, b, status) sync(a, b, status)
assert items(b) == items(a) == {item.raw, item2.raw} assert items(b) == items(a) == {item.raw, item2.raw}
item2 = Item(u'UID:2\nASDF:YES') # update of item 2 in a item2 = format_item('2') # update of item 2 in a
a.update('2.a', item2, a.get('2.a')[1]) a.update('2.a', item2, a.get('2.a')[1])
sync(a, b, status) sync(a, b, status)
assert items(b) == items(a) == {item.raw, item2.raw} assert items(b) == items(a) == {item.raw, item2.raw}
@ -178,9 +181,9 @@ def test_deletion():
b = MemoryStorage(fileext='.b') b = MemoryStorage(fileext='.b')
status = {} status = {}
item = Item(u'UID:1') item = format_item('1')
a.upload(item) a.upload(item)
item2 = Item(u'UID:2') item2 = format_item('2')
a.upload(item2) a.upload(item2)
sync(a, b, status) sync(a, b, status)
b.delete('1.b', b.get('1.b')[1]) b.delete('1.b', b.get('1.b')[1])
@ -200,14 +203,14 @@ def test_insert_hash():
b = MemoryStorage() b = MemoryStorage()
status = {} status = {}
item = Item('UID:1') item = format_item('1')
href, etag = a.upload(item) href, etag = a.upload(item)
sync(a, b, status) sync(a, b, status)
for d in status['1']: for d in status['1']:
del d['hash'] del d['hash']
a.update(href, Item('UID:1\nHAHA:YES'), etag) a.update(href, format_item('1'), etag) # new item content
sync(a, b, status) sync(a, b, status)
assert 'hash' in status['1'][0] and 'hash' in status['1'][1] assert 'hash' in status['1'][0] and 'hash' in status['1'][1]
@ -215,7 +218,7 @@ def test_insert_hash():
def test_already_synced(): def test_already_synced():
a = MemoryStorage(fileext='.a') a = MemoryStorage(fileext='.a')
b = MemoryStorage(fileext='.b') b = MemoryStorage(fileext='.b')
item = Item(u'UID:1') item = format_item('1')
a.upload(item) a.upload(item)
b.upload(item) b.upload(item)
status = { status = {
@ -243,14 +246,14 @@ def test_already_synced():
def test_conflict_resolution_both_etags_new(winning_storage): def test_conflict_resolution_both_etags_new(winning_storage):
a = MemoryStorage() a = MemoryStorage()
b = MemoryStorage() b = MemoryStorage()
item = Item(u'UID:1') item = format_item('1')
href_a, etag_a = a.upload(item) href_a, etag_a = a.upload(item)
href_b, etag_b = b.upload(item) href_b, etag_b = b.upload(item)
status = {} status = {}
sync(a, b, status) sync(a, b, status)
assert status assert status
item_a = Item(u'UID:1\nitem a') item_a = format_item('1')
item_b = Item(u'UID:1\nitem b') item_b = format_item('1')
a.update(href_a, item_a, etag_a) a.update(href_a, item_a, etag_a)
b.update(href_b, item_b, etag_b) b.update(href_b, item_b, etag_b)
with pytest.raises(SyncConflict): with pytest.raises(SyncConflict):
@ -264,13 +267,14 @@ def test_conflict_resolution_both_etags_new(winning_storage):
def test_updated_and_deleted(): def test_updated_and_deleted():
a = MemoryStorage() a = MemoryStorage()
b = MemoryStorage() b = MemoryStorage()
href_a, etag_a = a.upload(Item(u'UID:1')) item = format_item('1')
href_a, etag_a = a.upload(item)
status = {} status = {}
sync(a, b, status, force_delete=True) sync(a, b, status, force_delete=True)
(href_b, etag_b), = b.list() (href_b, etag_b), = b.list()
b.delete(href_b, etag_b) b.delete(href_b, etag_b)
updated = Item(u'UID:1\nupdated') updated = format_item('1')
a.update(href_a, updated, etag_a) a.update(href_a, updated, etag_a)
sync(a, b, status, force_delete=True) sync(a, b, status, force_delete=True)
@ -280,8 +284,8 @@ def test_updated_and_deleted():
def test_conflict_resolution_invalid_mode(): def test_conflict_resolution_invalid_mode():
a = MemoryStorage() a = MemoryStorage()
b = MemoryStorage() b = MemoryStorage()
item_a = Item(u'UID:1\nitem a') item_a = format_item('1')
item_b = Item(u'UID:1\nitem b') item_b = format_item('1')
a.upload(item_a) a.upload(item_a)
b.upload(item_b) b.upload(item_b)
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -291,7 +295,7 @@ def test_conflict_resolution_invalid_mode():
def test_conflict_resolution_new_etags_without_changes(): def test_conflict_resolution_new_etags_without_changes():
a = MemoryStorage() a = MemoryStorage()
b = MemoryStorage() b = MemoryStorage()
item = Item(u'UID:1') item = format_item('1')
href_a, etag_a = a.upload(item) href_a, etag_a = a.upload(item)
href_b, etag_b = b.upload(item) href_b, etag_b = b.upload(item)
status = {'1': (href_a, 'BOGUS_a', href_b, 'BOGUS_b')} status = {'1': (href_a, 'BOGUS_a', href_b, 'BOGUS_b')}
@ -326,7 +330,7 @@ def test_uses_get_multi(monkeypatch):
a = MemoryStorage() a = MemoryStorage()
b = MemoryStorage() b = MemoryStorage()
item = Item(u'UID:1') item = format_item('1')
expected_href, etag = a.upload(item) expected_href, etag = a.upload(item)
sync(a, b, {}) sync(a, b, {})
@ -336,8 +340,8 @@ def test_uses_get_multi(monkeypatch):
def test_empty_storage_dataloss(): def test_empty_storage_dataloss():
a = MemoryStorage() a = MemoryStorage()
b = MemoryStorage() b = MemoryStorage()
a.upload(Item(u'UID:1')) for i in '12':
a.upload(Item(u'UID:2')) a.upload(format_item(i))
status = {} status = {}
sync(a, b, status) sync(a, b, status)
with pytest.raises(StorageEmpty): with pytest.raises(StorageEmpty):
@ -350,22 +354,24 @@ def test_empty_storage_dataloss():
def test_no_uids(): def test_no_uids():
a = MemoryStorage() a = MemoryStorage()
b = MemoryStorage() b = MemoryStorage()
a.upload(Item(u'ASDF')) item_a = format_item('')
b.upload(Item(u'FOOBAR')) item_b = format_item('')
a.upload(item_a)
b.upload(item_b)
status = {} status = {}
sync(a, b, status) sync(a, b, status)
assert items(a) == items(b) == {u'ASDF', u'FOOBAR'} assert items(a) == items(b) == {item_a.raw, item_b.raw}
def test_changed_uids(): def test_changed_uids():
a = MemoryStorage() a = MemoryStorage()
b = MemoryStorage() b = MemoryStorage()
href_a, etag_a = a.upload(Item(u'UID:A-ONE')) href_a, etag_a = a.upload(format_item('a1'))
href_b, etag_b = b.upload(Item(u'UID:B-ONE')) href_b, etag_b = b.upload(format_item('b1'))
status = {} status = {}
sync(a, b, status) sync(a, b, status)
a.update(href_a, Item(u'UID:A-TWO'), etag_a) a.update(href_a, format_item('a2'), etag_a)
sync(a, b, status) sync(a, b, status)
@ -383,34 +389,37 @@ def test_partial_sync_revert():
a = MemoryStorage(instance_name='a') a = MemoryStorage(instance_name='a')
b = MemoryStorage(instance_name='b') b = MemoryStorage(instance_name='b')
status = {} status = {}
a.upload(Item(u'UID:1')) item1 = format_item('1')
b.upload(Item(u'UID:2')) item2 = format_item('2')
a.upload(item1)
b.upload(item2)
b.read_only = True b.read_only = True
sync(a, b, status, partial_sync='revert') sync(a, b, status, partial_sync='revert')
assert len(status) == 2 assert len(status) == 2
assert items(a) == {'UID:1', 'UID:2'} assert items(a) == {item1.raw, item2.raw}
assert items(b) == {'UID:2'} assert items(b) == {item2.raw}
sync(a, b, status, partial_sync='revert') sync(a, b, status, partial_sync='revert')
assert len(status) == 1 assert len(status) == 1
assert items(a) == {'UID:2'} assert items(a) == {item2.raw}
assert items(b) == {'UID:2'} assert items(b) == {item2.raw}
# Check that updates get reverted # Check that updates get reverted
a.items[next(iter(a.items))] = ('foo', Item('UID:2\nupdated')) item2_up = format_item('2')
assert items(a) == {'UID:2\nupdated'} a.items[next(iter(a.items))] = ('foo', item2_up)
assert items(a) == {item2_up.raw}
sync(a, b, status, partial_sync='revert') sync(a, b, status, partial_sync='revert')
assert len(status) == 1 assert len(status) == 1
assert items(a) == {'UID:2\nupdated'} assert items(a) == {item2_up.raw}
sync(a, b, status, partial_sync='revert') sync(a, b, status, partial_sync='revert')
assert items(a) == {'UID:2'} assert items(a) == {item2.raw}
# Check that deletions get reverted # Check that deletions get reverted
a.items.clear() a.items.clear()
sync(a, b, status, partial_sync='revert', force_delete=True) sync(a, b, status, partial_sync='revert', force_delete=True)
sync(a, b, status, partial_sync='revert', force_delete=True) sync(a, b, status, partial_sync='revert', force_delete=True)
assert items(a) == {'UID:2'} assert items(a) == {item2.raw}
@pytest.mark.parametrize('sync_inbetween', (True, False)) @pytest.mark.parametrize('sync_inbetween', (True, False))
@ -418,13 +427,16 @@ def test_ident_conflict(sync_inbetween):
a = MemoryStorage() a = MemoryStorage()
b = MemoryStorage() b = MemoryStorage()
status = {} status = {}
href_a, etag_a = a.upload(Item(u'UID:aaa')) item_a = format_item('aaa')
href_b, etag_b = a.upload(Item(u'UID:bbb')) item_b = format_item('bbb')
href_a, etag_a = a.upload(item_a)
href_b, etag_b = a.upload(item_b)
if sync_inbetween: if sync_inbetween:
sync(a, b, status) sync(a, b, status)
a.update(href_a, Item(u'UID:xxx'), etag_a) item_x = format_item('xxx')
a.update(href_b, Item(u'UID:xxx'), etag_b) a.update(href_a, item_x, etag_a)
a.update(href_b, item_x, etag_b)
with pytest.raises(IdentConflict): with pytest.raises(IdentConflict):
sync(a, b, status) sync(a, b, status)
@ -441,7 +453,8 @@ def test_moved_href():
a = MemoryStorage() a = MemoryStorage()
b = MemoryStorage() b = MemoryStorage()
status = {} status = {}
href, etag = a.upload(Item(u'UID:haha')) item = format_item('haha')
href, etag = a.upload(item)
sync(a, b, status) sync(a, b, status)
b.items['lol'] = b.items.pop('haha') b.items['lol'] = b.items.pop('haha')
@ -454,7 +467,7 @@ def test_moved_href():
sync(a, b, status) sync(a, b, status)
assert len(status) == 1 assert len(status) == 1
assert items(a) == items(b) == {'UID:haha'} assert items(a) == items(b) == {item.raw}
assert status['haha'][1]['href'] == 'lol' assert status['haha'][1]['href'] == 'lol'
old_status = deepcopy(status) old_status = deepcopy(status)
@ -463,7 +476,7 @@ def test_moved_href():
sync(a, b, status) sync(a, b, status)
assert old_status == status assert old_status == status
assert items(a) == items(b) == {'UID:haha'} assert items(a) == items(b) == {item.raw}
def test_bogus_etag_change(): def test_bogus_etag_change():
@ -476,26 +489,31 @@ def test_bogus_etag_change():
a = MemoryStorage() a = MemoryStorage()
b = MemoryStorage() b = MemoryStorage()
status = {} status = {}
href_a, etag_a = a.upload(Item(u'UID:ASDASD')) item = format_item('ASDASD')
sync(a, b, status)
assert len(status) == len(list(a.list())) == len(list(b.list())) == 1
href_a, etag_a = a.upload(item)
sync(a, b, status)
assert len(status) == 1
assert items(a) == items(b) == {item.raw}
new_item = format_item('ASDASD')
(href_b, etag_b), = b.list() (href_b, etag_b), = b.list()
a.update(href_a, Item(u'UID:ASDASD'), etag_a) a.update(href_a, item, etag_a)
b.update(href_b, Item(u'UID:ASDASD\nACTUALCHANGE:YES'), etag_b) b.update(href_b, new_item, etag_b)
b.delete = b.update = b.upload = blow_up b.delete = b.update = b.upload = blow_up
sync(a, b, status) sync(a, b, status)
assert len(status) == 1 assert len(status) == 1
assert items(a) == items(b) == {u'UID:ASDASD\nACTUALCHANGE:YES'} assert items(a) == items(b) == {new_item.raw}
def test_unicode_hrefs(): def test_unicode_hrefs():
a = MemoryStorage() a = MemoryStorage()
b = MemoryStorage() b = MemoryStorage()
status = {} status = {}
href, etag = a.upload(Item(u'UID:äää')) item = format_item('äää')
href, etag = a.upload(item)
sync(a, b, status) sync(a, b, status)
@ -565,7 +583,7 @@ class SyncMachine(RuleBasedStateMachine):
uid=uid_strategy, uid=uid_strategy,
etag=st.text()) etag=st.text())
def upload(self, storage, uid, etag): def upload(self, storage, uid, etag):
item = Item(u'UID:{}'.format(uid)) item = Item('BEGIN:VCARD\r\nUID:{}\r\nEND:VCARD'.format(uid))
storage.items[uid] = (etag, item) storage.items[uid] = (etag, item)
@rule(storage=Storage, href=st.text()) @rule(storage=Storage, href=st.text())
@ -643,8 +661,8 @@ def test_rollback(error_callback):
b = MemoryStorage() b = MemoryStorage()
status = {} status = {}
a.items['0'] = ('', Item('UID:0')) a.items['0'] = ('', format_item('0'))
b.items['1'] = ('', Item('UID:1')) b.items['1'] = ('', format_item('1'))
b.upload = b.update = b.delete = action_failure b.upload = b.update = b.delete = action_failure
@ -668,7 +686,7 @@ def test_duplicate_hrefs():
a = MemoryStorage() a = MemoryStorage()
b = MemoryStorage() b = MemoryStorage()
a.list = lambda: [('a', 'a')] * 3 a.list = lambda: [('a', 'a')] * 3
a.items['a'] = ('a', Item('UID:a')) a.items['a'] = ('a', format_item('a'))
status = {} status = {}
sync(a, b, status) sync(a, b, status)

View file

@ -38,7 +38,7 @@ def test_repair_uids(uid):
@settings(perform_health_check=False) # Using the random module for UIDs @settings(perform_health_check=False) # Using the random module for UIDs
def test_repair_unsafe_uids(uid): def test_repair_unsafe_uids(uid):
s = MemoryStorage() s = MemoryStorage()
item = Item(u'BEGIN:VCARD\nUID:{}\nEND:VCARD'.format(uid)) item = Item(u'BEGIN:VCARD\nUID:123\nEND:VCARD').with_uid(uid)
href, etag = s.upload(item) href, etag = s.upload(item)
assert s.get(href)[0].uid == uid assert s.get(href)[0].uid == uid
assert not href_safe(uid) assert not href_safe(uid)

View file

@ -223,7 +223,7 @@ def test_replace_uid(template, uid):
item = vobject.Item(template.format(r=123, uid=123)).with_uid(uid) item = vobject.Item(template.format(r=123, uid=123)).with_uid(uid)
assert item.uid == uid assert item.uid == uid
if uid: if uid:
assert item.raw.count('\nUID:{}'.format(uid)) == 1 assert item.raw.count('\nUID:') == 1
else: else:
assert '\nUID:' not in item.raw assert '\nUID:' not in item.raw

View file

@ -79,3 +79,7 @@ class UnsupportedMetadataError(Error, NotImplementedError):
class CollectionRequired(Error): class CollectionRequired(Error):
'''`collection = null` is not allowed.''' '''`collection = null` is not allowed.'''
class VobjectParseError(Error, ValueError):
'''The parsed vobject is invalid.'''

41
vdirsyncer/native.py Normal file
View file

@ -0,0 +1,41 @@
from ._native import ffi, lib
from .exceptions import VobjectParseError
def parse_component(raw):
e = ffi.new('VdirsyncerError *')
try:
c = lib.vdirsyncer_parse_component(raw, e)
if e.failed:
raise VobjectParseError(ffi.string(e.msg).decode('utf-8'))
return _component_rv(c)
finally:
if e.failed:
lib.vdirsyncer_clear_err(e)
def write_component(component):
return _string_rv(lib.vdirsyncer_write_component(component))
def get_uid(component):
return _string_rv(lib.vdirsyncer_get_uid(component))
def _string_rv(c_str):
try:
return ffi.string(c_str).decode('utf-8')
finally:
lib.vdirsyncer_free_str(c_str)
def change_uid(component, uid):
lib.vdirsyncer_change_uid(component, uid.encode('utf-8'))
def _component_rv(c):
return ffi.gc(c, lib.vdirsyncer_free_component)
def clone_component(c):
return _component_rv(lib.vdirsyncer_clone_component(c))

View file

@ -4,6 +4,7 @@ import hashlib
from itertools import chain, tee from itertools import chain, tee
from .utils import cached_property, uniq from .utils import cached_property, uniq
from . import exceptions, native
IGNORE_PROPS = ( IGNORE_PROPS = (
@ -39,25 +40,25 @@ class Item(object):
'''Immutable wrapper class for VCALENDAR (VEVENT, VTODO) and '''Immutable wrapper class for VCALENDAR (VEVENT, VTODO) and
VCARD''' VCARD'''
def __init__(self, raw): def __init__(self, raw, component=None):
assert isinstance(raw, str), type(raw) assert isinstance(raw, str), type(raw)
self._raw = raw self._raw = raw
try:
self._component = component or \
native.parse_component(self.raw.encode('utf-8'))
except exceptions.VobjectParseError:
self._component = None
def with_uid(self, new_uid): def with_uid(self, new_uid):
parsed = _Component.parse(self.raw) if not self._component:
stack = [parsed] raise ValueError('Item malformed.')
while stack:
component = stack.pop()
stack.extend(component.subcomponents)
if component.name in ('VEVENT', 'VTODO', 'VJOURNAL', 'VCARD'): new_c = native.clone_component(self._component)
del component['UID'] native.change_uid(new_c, new_uid or '')
if new_uid: return Item(native.write_component(new_c), component=new_c)
component['UID'] = new_uid
return Item('\r\n'.join(parsed.dump_lines())) @property
@cached_property
def raw(self): def raw(self):
'''Raw content of the item, as unicode string. '''Raw content of the item, as unicode string.
@ -69,13 +70,9 @@ class Item(object):
def uid(self): def uid(self):
'''Global identifier of the item, across storages, doesn't change after '''Global identifier of the item, across storages, doesn't change after
a modification of the item.''' a modification of the item.'''
# Don't actually parse component, but treat all lines as single if not self._component:
# component, avoiding traversal through all subcomponents.
x = _Component('TEMP', self.raw.splitlines(), [])
try:
return x['UID'].strip() or None
except KeyError:
return None return None
return native.get_uid(self._component) or None
@cached_property @cached_property
def hash(self): def hash(self):
@ -99,11 +96,16 @@ class Item(object):
@property @property
def parsed(self): def parsed(self):
'''Don't cache because the rv is mutable.''' '''Don't cache because the rv is mutable.'''
# FIXME: remove
try: try:
return _Component.parse(self.raw) return _Component.parse(self.raw)
except Exception: except Exception:
return None return None
@property
def is_valid(self):
return bool(self._component)
def normalize_item(item, ignore_props=IGNORE_PROPS): def normalize_item(item, ignore_props=IGNORE_PROPS):
'''Create syntactically invalid mess that is equal for similar items.''' '''Create syntactically invalid mess that is equal for similar items.'''