mirror of
https://github.com/samsonjs/vdirsyncer.git
synced 2026-04-27 14:57:41 +00:00
Merge pull request #920 from pimutils/meta_delete
metasync: use None as no-value and delete missing values on syncing
This commit is contained in:
commit
7b493416f7
7 changed files with 63 additions and 29 deletions
|
|
@ -312,20 +312,29 @@ class StorageTests:
|
||||||
if self.storage_class.storage_name.endswith("dav"):
|
if self.storage_class.storage_name.endswith("dav"):
|
||||||
assert urlquote(uid, "/@:") in href
|
assert urlquote(uid, "/@:") in href
|
||||||
|
|
||||||
|
@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
|
@pytest.mark.asyncio
|
||||||
async def test_metadata(self, requires_metadata, s):
|
async def test_metadata(self, requires_metadata, s):
|
||||||
if not getattr(self, "dav_server", ""):
|
if getattr(self, "dav_server", "") == "xandikos":
|
||||||
assert not await s.get_meta("color")
|
pytest.skip("xandikos does not support removing metadata.")
|
||||||
assert not await s.get_meta("displayname")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await s.set_meta("color", None)
|
await s.set_meta("color", None)
|
||||||
assert not await s.get_meta("color")
|
assert await s.get_meta("color") is None
|
||||||
await s.set_meta("color", "#ff0000")
|
await s.set_meta("color", "#ff0000")
|
||||||
assert await s.get_meta("color") == "#ff0000"
|
assert await s.get_meta("color") == "#ff0000"
|
||||||
except exceptions.UnsupportedMetadataError:
|
except exceptions.UnsupportedMetadataError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_encoding_metadata(self, requires_metadata, s):
|
||||||
for x in ("hello world", "hello wörld"):
|
for x in ("hello world", "hello wörld"):
|
||||||
await s.set_meta("displayname", x)
|
await s.set_meta("displayname", x)
|
||||||
rv = await s.get_meta("displayname")
|
rv = await s.get_meta("displayname")
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ async def test_basic(monkeypatch):
|
||||||
b = MemoryStorage()
|
b = MemoryStorage()
|
||||||
status = {}
|
status = {}
|
||||||
|
|
||||||
|
await a.set_meta("foo", None)
|
||||||
|
await metasync(a, b, status, keys=["foo"])
|
||||||
|
assert await a.get_meta("foo") is None and await b.get_meta("foo") is None
|
||||||
|
|
||||||
await a.set_meta("foo", "bar")
|
await a.set_meta("foo", "bar")
|
||||||
await metasync(a, b, status, keys=["foo"])
|
await metasync(a, b, status, keys=["foo"])
|
||||||
assert await a.get_meta("foo") == await b.get_meta("foo") == "bar"
|
assert await a.get_meta("foo") == await b.get_meta("foo") == "bar"
|
||||||
|
|
@ -183,7 +187,7 @@ async def test_fuzzing(a, b, status, keys, conflict_resolution):
|
||||||
await metasync(a, b, status, keys=keys, conflict_resolution=conflict_resolution)
|
await metasync(a, b, status, keys=keys, conflict_resolution=conflict_resolution)
|
||||||
|
|
||||||
for key in keys:
|
for key in keys:
|
||||||
s = status.get(key, "")
|
s = status.get(key)
|
||||||
assert await a.get_meta(key) == await b.get_meta(key) == s
|
assert await a.get_meta(key) == await b.get_meta(key) == s
|
||||||
if expected_values.get(key, "") and s:
|
if expected_values.get(key) and s:
|
||||||
assert s == expected_values[key]
|
assert s == expected_values[key]
|
||||||
|
|
|
||||||
|
|
@ -14,20 +14,27 @@ class MetaSyncConflict(MetaSyncError):
|
||||||
key = None
|
key = None
|
||||||
|
|
||||||
|
|
||||||
|
def status_set_key(status, key, value):
|
||||||
|
if value is None:
|
||||||
|
status.pop(key, None)
|
||||||
|
else:
|
||||||
|
status[key] = value
|
||||||
|
|
||||||
|
|
||||||
async def metasync(storage_a, storage_b, status, keys, conflict_resolution=None):
|
async def metasync(storage_a, storage_b, status, keys, conflict_resolution=None):
|
||||||
async def _a_to_b():
|
async def _a_to_b():
|
||||||
logger.info(f"Copying {key} to {storage_b}")
|
logger.info(f"Copying {key} to {storage_b}")
|
||||||
await storage_b.set_meta(key, a)
|
await storage_b.set_meta(key, a)
|
||||||
status[key] = a
|
status_set_key(status, key, a)
|
||||||
|
|
||||||
async def _b_to_a():
|
async def _b_to_a():
|
||||||
logger.info(f"Copying {key} to {storage_a}")
|
logger.info(f"Copying {key} to {storage_a}")
|
||||||
await storage_a.set_meta(key, b)
|
await storage_a.set_meta(key, b)
|
||||||
status[key] = b
|
status_set_key(status, key, b)
|
||||||
|
|
||||||
async def _resolve_conflict():
|
async def _resolve_conflict():
|
||||||
if a == b:
|
if a == b:
|
||||||
status[key] = a
|
status_set_key(status, key, a)
|
||||||
elif conflict_resolution == "a wins":
|
elif conflict_resolution == "a wins":
|
||||||
await _a_to_b()
|
await _a_to_b()
|
||||||
elif conflict_resolution == "b wins":
|
elif conflict_resolution == "b wins":
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import contextlib
|
import contextlib
|
||||||
import functools
|
import functools
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from .. import exceptions
|
from .. import exceptions
|
||||||
from ..utils import uniq
|
from ..utils import uniq
|
||||||
|
|
@ -219,31 +220,29 @@ class Storage(metaclass=StorageMeta):
|
||||||
"""
|
"""
|
||||||
yield
|
yield
|
||||||
|
|
||||||
async def get_meta(self, key):
|
async def get_meta(self, key: str) -> Optional[str]:
|
||||||
"""Get metadata value for collection/storage.
|
"""Get metadata value for collection/storage.
|
||||||
|
|
||||||
See the vdir specification for the keys that *have* to be accepted.
|
See the vdir specification for the keys that *have* to be accepted.
|
||||||
|
|
||||||
:param key: The metadata key.
|
:param key: The metadata key.
|
||||||
:type key: unicode
|
:return: The metadata or None, if metadata is missing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
raise NotImplementedError("This storage does not support metadata.")
|
raise NotImplementedError("This storage does not support metadata.")
|
||||||
|
|
||||||
async def set_meta(self, key, value):
|
async def set_meta(self, key: str, value: Optional[str]):
|
||||||
"""Get metadata value for collection/storage.
|
"""Get metadata value for collection/storage.
|
||||||
|
|
||||||
:param key: The metadata key.
|
:param key: The metadata key.
|
||||||
:type key: unicode
|
:param value: The value. Use None to delete the data.
|
||||||
:param value: The value.
|
|
||||||
:type value: unicode
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
raise NotImplementedError("This storage does not support metadata.")
|
raise NotImplementedError("This storage does not support metadata.")
|
||||||
|
|
||||||
|
|
||||||
def normalize_meta_value(value):
|
def normalize_meta_value(value) -> Optional[str]:
|
||||||
# `None` is returned by iCloud for empty properties.
|
# `None` is returned by iCloud for empty properties.
|
||||||
if not value or value == "None":
|
if value is None or value == "None":
|
||||||
value = ""
|
return
|
||||||
return value.strip()
|
return value.strip() if value else ""
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import urllib.parse as urlparse
|
||||||
import xml.etree.ElementTree as etree
|
import xml.etree.ElementTree as etree
|
||||||
from inspect import getfullargspec
|
from inspect import getfullargspec
|
||||||
from inspect import signature
|
from inspect import signature
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import aiostream
|
import aiostream
|
||||||
|
|
@ -679,7 +680,7 @@ class DAVStorage(Storage):
|
||||||
for href, etag, _prop in rv:
|
for href, etag, _prop in rv:
|
||||||
yield href, etag
|
yield href, etag
|
||||||
|
|
||||||
async def get_meta(self, key):
|
async def get_meta(self, key) -> Optional[str]:
|
||||||
try:
|
try:
|
||||||
tagname, namespace = self._property_table[key]
|
tagname, namespace = self._property_table[key]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
|
@ -714,7 +715,7 @@ class DAVStorage(Storage):
|
||||||
text = normalize_meta_value(getattr(prop, "text", None))
|
text = normalize_meta_value(getattr(prop, "text", None))
|
||||||
if text:
|
if text:
|
||||||
return text
|
return text
|
||||||
return ""
|
return None
|
||||||
|
|
||||||
async def set_meta(self, key, value):
|
async def set_meta(self, key, value):
|
||||||
try:
|
try:
|
||||||
|
|
@ -724,18 +725,23 @@ class DAVStorage(Storage):
|
||||||
|
|
||||||
lxml_selector = f"{{{namespace}}}{tagname}"
|
lxml_selector = f"{{{namespace}}}{tagname}"
|
||||||
element = etree.Element(lxml_selector)
|
element = etree.Element(lxml_selector)
|
||||||
element.text = normalize_meta_value(value)
|
if value is None:
|
||||||
|
action = "remove"
|
||||||
|
else:
|
||||||
|
element.text = normalize_meta_value(value)
|
||||||
|
action = "set"
|
||||||
|
|
||||||
data = """<?xml version="1.0" encoding="utf-8" ?>
|
data = """<?xml version="1.0" encoding="utf-8" ?>
|
||||||
<propertyupdate xmlns="DAV:">
|
<propertyupdate xmlns="DAV:">
|
||||||
<set>
|
<{action}>
|
||||||
<prop>
|
<prop>
|
||||||
{}
|
{}
|
||||||
</prop>
|
</prop>
|
||||||
</set>
|
</{action}>
|
||||||
</propertyupdate>
|
</propertyupdate>
|
||||||
""".format(
|
""".format(
|
||||||
etree.tostring(element, encoding="unicode")
|
etree.tostring(element, encoding="unicode"),
|
||||||
|
action=action,
|
||||||
).encode(
|
).encode(
|
||||||
"utf-8"
|
"utf-8"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,7 @@ class FilesystemStorage(Storage):
|
||||||
return normalize_meta_value(f.read().decode(self.encoding))
|
return normalize_meta_value(f.read().decode(self.encoding))
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
if e.errno == errno.ENOENT:
|
if e.errno == errno.ENOENT:
|
||||||
return ""
|
return None
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
@ -191,5 +191,11 @@ class FilesystemStorage(Storage):
|
||||||
value = normalize_meta_value(value)
|
value = normalize_meta_value(value)
|
||||||
|
|
||||||
fpath = os.path.join(self.path, key)
|
fpath = os.path.join(self.path, key)
|
||||||
with atomic_write(fpath, mode="wb", overwrite=True) as f:
|
if value is None:
|
||||||
f.write(value.encode(self.encoding))
|
try:
|
||||||
|
os.remove(fpath)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
with atomic_write(fpath, mode="wb", overwrite=True) as f:
|
||||||
|
f.write(value.encode(self.encoding))
|
||||||
|
|
|
||||||
|
|
@ -69,4 +69,7 @@ class MemoryStorage(Storage):
|
||||||
return normalize_meta_value(self.metadata.get(key))
|
return normalize_meta_value(self.metadata.get(key))
|
||||||
|
|
||||||
async def set_meta(self, key, value):
|
async def set_meta(self, key, value):
|
||||||
self.metadata[key] = normalize_meta_value(value)
|
if value is None:
|
||||||
|
self.metadata.pop(key, None)
|
||||||
|
else:
|
||||||
|
self.metadata[key] = normalize_meta_value(value)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue