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:
Hugo Osvaldo Barrera 2021-07-26 13:07:45 +02:00 committed by GitHub
commit 7b493416f7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 63 additions and 29 deletions

View file

@ -312,20 +312,29 @@ class StorageTests:
if self.storage_class.storage_name.endswith("dav"):
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
async def test_metadata(self, requires_metadata, s):
if not getattr(self, "dav_server", ""):
assert not await s.get_meta("color")
assert not await s.get_meta("displayname")
if getattr(self, "dav_server", "") == "xandikos":
pytest.skip("xandikos does not support removing metadata.")
try:
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")
assert await s.get_meta("color") == "#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")

View file

@ -28,6 +28,10 @@ async def test_basic(monkeypatch):
b = MemoryStorage()
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 metasync(a, b, status, keys=["foo"])
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)
for key in keys:
s = status.get(key, "")
s = status.get(key)
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]

View file

@ -14,20 +14,27 @@ class MetaSyncConflict(MetaSyncError):
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 _a_to_b():
logger.info(f"Copying {key} to {storage_b}")
await storage_b.set_meta(key, a)
status[key] = a
status_set_key(status, key, a)
async def _b_to_a():
logger.info(f"Copying {key} to {storage_a}")
await storage_a.set_meta(key, b)
status[key] = b
status_set_key(status, key, b)
async def _resolve_conflict():
if a == b:
status[key] = a
status_set_key(status, key, a)
elif conflict_resolution == "a wins":
await _a_to_b()
elif conflict_resolution == "b wins":

View file

@ -1,5 +1,6 @@
import contextlib
import functools
from typing import Optional
from .. import exceptions
from ..utils import uniq
@ -219,31 +220,29 @@ class Storage(metaclass=StorageMeta):
"""
yield
async def get_meta(self, key):
async def get_meta(self, key: str) -> Optional[str]:
"""Get metadata value for collection/storage.
See the vdir specification for the keys that *have* to be accepted.
: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.")
async def set_meta(self, key, value):
async def set_meta(self, key: str, value: Optional[str]):
"""Get metadata value for collection/storage.
:param key: The metadata key.
:type key: unicode
:param value: The value.
:type value: unicode
:param value: The value. Use None to delete the data.
"""
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.
if not value or value == "None":
value = ""
return value.strip()
if value is None or value == "None":
return
return value.strip() if value else ""

View file

@ -4,6 +4,7 @@ import urllib.parse as urlparse
import xml.etree.ElementTree as etree
from inspect import getfullargspec
from inspect import signature
from typing import Optional
import aiohttp
import aiostream
@ -679,7 +680,7 @@ class DAVStorage(Storage):
for href, etag, _prop in rv:
yield href, etag
async def get_meta(self, key):
async def get_meta(self, key) -> Optional[str]:
try:
tagname, namespace = self._property_table[key]
except KeyError:
@ -714,7 +715,7 @@ class DAVStorage(Storage):
text = normalize_meta_value(getattr(prop, "text", None))
if text:
return text
return ""
return None
async def set_meta(self, key, value):
try:
@ -724,18 +725,23 @@ class DAVStorage(Storage):
lxml_selector = f"{{{namespace}}}{tagname}"
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" ?>
<propertyupdate xmlns="DAV:">
<set>
<{action}>
<prop>
{}
</prop>
</set>
</{action}>
</propertyupdate>
""".format(
etree.tostring(element, encoding="unicode")
etree.tostring(element, encoding="unicode"),
action=action,
).encode(
"utf-8"
)

View file

@ -183,7 +183,7 @@ class FilesystemStorage(Storage):
return normalize_meta_value(f.read().decode(self.encoding))
except OSError as e:
if e.errno == errno.ENOENT:
return ""
return None
else:
raise
@ -191,5 +191,11 @@ class FilesystemStorage(Storage):
value = normalize_meta_value(value)
fpath = os.path.join(self.path, key)
with atomic_write(fpath, mode="wb", overwrite=True) as f:
f.write(value.encode(self.encoding))
if value is None:
try:
os.remove(fpath)
except OSError:
pass
else:
with atomic_write(fpath, mode="wb", overwrite=True) as f:
f.write(value.encode(self.encoding))

View file

@ -69,4 +69,7 @@ class MemoryStorage(Storage):
return normalize_meta_value(self.metadata.get(key))
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)