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"): 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")

View file

@ -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]

View file

@ -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":

View file

@ -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 ""

View file

@ -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"
) )

View file

@ -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))

View file

@ -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)