Merge pull request #912 from pimutils/typing

Add some typing hints
This commit is contained in:
Hugo Osvaldo Barrera 2021-08-04 15:12:35 +02:00 committed by GitHub
commit 8886854367
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 114 additions and 55 deletions

View file

@ -22,3 +22,13 @@ repos:
hooks: hooks:
- id: isort - id: isort
name: isort (python) name: isort (python)
- repo: https://github.com/pre-commit/mirrors-mypy
rev: "v0.910"
hooks:
- id: mypy
files: vdirsyncer/.*
additional_dependencies:
- types-setuptools
- types-docutils
- types-requests
- types-atomicwrites

View file

@ -24,3 +24,8 @@ import-order-style = smarkets
[isort] [isort]
force_single_line=true force_single_line=true
[mypy]
ignore_missing_imports = True
# See https://github.com/python/mypy/issues/7511:
warn_no_return = False

View file

@ -25,7 +25,7 @@ requirements = [
class PrintRequirements(Command): class PrintRequirements(Command):
description = "Prints minimal requirements" description = "Prints minimal requirements"
user_options = [] user_options: list = []
def initialize_options(self): def initialize_options(self):
pass pass

View file

@ -1,5 +1,6 @@
import json import json
from textwrap import dedent from textwrap import dedent
from typing import List
import pytest import pytest
@ -208,6 +209,12 @@ def test_collection_required(a_requires, b_requires, tmpdir, runner, monkeypatch
assert not kw.get("collection") assert not kw.get("collection")
raise exceptions.CollectionRequired() raise exceptions.CollectionRequired()
async def get(self, href: str):
raise NotImplementedError()
async def list(self) -> List[tuple]:
raise NotImplementedError()
from vdirsyncer.cli.utils import storage_names from vdirsyncer.cli.utils import storage_names
monkeypatch.setitem(storage_names._storages, "test", TestStorage) monkeypatch.setitem(storage_names._storages, "test", TestStorage)

View file

@ -1,12 +1,20 @@
import contextlib import contextlib
import functools import functools
from abc import ABCMeta
from abc import abstractmethod
from typing import Iterable
from typing import List
from typing import Optional from typing import Optional
from vdirsyncer.vobject import Item
from .. import exceptions from .. import exceptions
from ..utils import uniq from ..utils import uniq
def mutating_storage_method(f): def mutating_storage_method(f):
"""Wrap a method and fail if the instance is readonly."""
@functools.wraps(f) @functools.wraps(f)
async def inner(self, *args, **kwargs): async def inner(self, *args, **kwargs):
if self.read_only: if self.read_only:
@ -16,8 +24,10 @@ def mutating_storage_method(f):
return inner return inner
class StorageMeta(type): class StorageMeta(ABCMeta):
def __init__(cls, name, bases, d): def __init__(cls, name, bases, d):
"""Wrap mutating methods to fail if the storage is readonly."""
for method in ("update", "upload", "delete"): for method in ("update", "upload", "delete"):
setattr(cls, method, mutating_storage_method(getattr(cls, method))) setattr(cls, method, mutating_storage_method(getattr(cls, method)))
return super().__init__(name, bases, d) return super().__init__(name, bases, d)
@ -48,7 +58,7 @@ class Storage(metaclass=StorageMeta):
# The string used in the config to denote the type of storage. Should be # The string used in the config to denote the type of storage. Should be
# overridden by subclasses. # overridden by subclasses.
storage_name = None storage_name: str
# The string used in the config to denote a particular instance. Will be # The string used in the config to denote a particular instance. Will be
# overridden during instantiation. # overridden during instantiation.
@ -63,7 +73,7 @@ class Storage(metaclass=StorageMeta):
read_only = False read_only = False
# The attribute values to show in the representation of the storage. # The attribute values to show in the representation of the storage.
_repr_attributes = () _repr_attributes: List[str] = []
def __init__(self, instance_name=None, read_only=None, collection=None): def __init__(self, instance_name=None, read_only=None, collection=None):
if read_only is None: if read_only is None:
@ -121,13 +131,14 @@ class Storage(metaclass=StorageMeta):
{x: getattr(self, x) for x in self._repr_attributes}, {x: getattr(self, x) for x in self._repr_attributes},
) )
async def list(self): @abstractmethod
async def list(self) -> List[tuple]:
""" """
:returns: list of (href, etag) :returns: list of (href, etag)
""" """
raise NotImplementedError()
async def get(self, href): @abstractmethod
async def get(self, href: str):
"""Fetch a single item. """Fetch a single item.
:param href: href to fetch :param href: href to fetch
@ -135,9 +146,8 @@ class Storage(metaclass=StorageMeta):
:raises: :exc:`vdirsyncer.exceptions.PreconditionFailed` if item can't :raises: :exc:`vdirsyncer.exceptions.PreconditionFailed` if item can't
be found. be found.
""" """
raise NotImplementedError()
async def get_multi(self, hrefs): async def get_multi(self, hrefs: Iterable[str]):
"""Fetch multiple items. Duplicate hrefs must be ignored. """Fetch multiple items. Duplicate hrefs must be ignored.
Functionally similar to :py:meth:`get`, but might bring performance Functionally similar to :py:meth:`get`, but might bring performance
@ -152,11 +162,8 @@ class Storage(metaclass=StorageMeta):
item, etag = await self.get(href) item, etag = await self.get(href)
yield href, item, etag yield href, item, etag
async def has(self, href): async def has(self, href) -> bool:
"""Check if an item exists by its href. """Check if an item exists by its href."""
:returns: True or False
"""
try: try:
await self.get(href) await self.get(href)
except exceptions.PreconditionFailed: except exceptions.PreconditionFailed:
@ -164,7 +171,7 @@ class Storage(metaclass=StorageMeta):
else: else:
return True return True
async def upload(self, item): async def upload(self, item: Item):
"""Upload a new item. """Upload a new item.
In cases where the new etag cannot be atomically determined (i.e. in In cases where the new etag cannot be atomically determined (i.e. in
@ -179,7 +186,7 @@ class Storage(metaclass=StorageMeta):
""" """
raise NotImplementedError() raise NotImplementedError()
async def update(self, href, item, etag): async def update(self, href: str, item: Item, etag):
"""Update an item. """Update an item.
The etag may be none in some cases, see `upload`. The etag may be none in some cases, see `upload`.
@ -192,7 +199,7 @@ class Storage(metaclass=StorageMeta):
""" """
raise NotImplementedError() raise NotImplementedError()
async def delete(self, href, etag): async def delete(self, href: str, etag: str):
"""Delete an item by href. """Delete an item by href.
:raises: :exc:`vdirsyncer.exceptions.PreconditionFailed` when item has :raises: :exc:`vdirsyncer.exceptions.PreconditionFailed` when item has
@ -228,21 +235,19 @@ class Storage(metaclass=StorageMeta):
:param key: The metadata key. :param key: The metadata key.
:return: The metadata or None, if metadata is missing. :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: str, value: Optional[str]): async def set_meta(self, key: str, value: Optional[str]):
"""Get metadata value for collection/storage. """Set metadata value for collection/storage.
:param key: The metadata key. :param key: The metadata key.
:param value: The value. Use None to delete the data. :param value: The value. Use None to delete the data.
""" """
raise NotImplementedError("This storage does not support metadata.") raise NotImplementedError("This storage does not support metadata.")
def normalize_meta_value(value) -> Optional[str]: def normalize_meta_value(value) -> Optional[str]:
# `None` is returned by iCloud for empty properties. # `None` is returned by iCloud for empty properties.
if value is None or value == "None": if value is None or value == "None":
return return None
return value.strip() if value else "" return value.strip() if value else ""

View file

@ -2,14 +2,17 @@ import datetime
import logging import logging
import urllib.parse as urlparse import urllib.parse as urlparse
import xml.etree.ElementTree as etree import xml.etree.ElementTree as etree
from abc import abstractmethod
from inspect import getfullargspec from inspect import getfullargspec
from inspect import signature from inspect import signature
from typing import Optional from typing import Optional
from typing import Type
import aiohttp import aiohttp
import aiostream import aiostream
from vdirsyncer.exceptions import Error from vdirsyncer.exceptions import Error
from vdirsyncer.vobject import Item
from .. import exceptions from .. import exceptions
from .. import http from .. import http
@ -18,7 +21,6 @@ from ..http import USERAGENT
from ..http import prepare_auth from ..http import prepare_auth
from ..http import prepare_client_cert from ..http import prepare_client_cert
from ..http import prepare_verify from ..http import prepare_verify
from ..vobject import Item
from .base import Storage from .base import Storage
from .base import normalize_meta_value from .base import normalize_meta_value
@ -146,11 +148,31 @@ def _fuzzy_matches_mimetype(strict, weak):
class Discover: class Discover:
_namespace = None @property
_resourcetype = None @abstractmethod
_homeset_xml = None def _namespace(self) -> str:
_homeset_tag = None pass
_well_known_uri = None
@property
@abstractmethod
def _resourcetype(self) -> Optional[str]:
pass
@property
@abstractmethod
def _homeset_xml(self) -> bytes:
pass
@property
@abstractmethod
def _homeset_tag(self) -> str:
pass
@property
@abstractmethod
def _well_known_uri(self) -> str:
pass
_collection_xml = b""" _collection_xml = b"""
<propfind xmlns="DAV:"> <propfind xmlns="DAV:">
<prop> <prop>
@ -347,7 +369,7 @@ class CalDiscover(Discover):
class CardDiscover(Discover): class CardDiscover(Discover):
_namespace = "urn:ietf:params:xml:ns:carddav" _namespace = "urn:ietf:params:xml:ns:carddav"
_resourcetype = "{%s}addressbook" % _namespace _resourcetype: Optional[str] = "{%s}addressbook" % _namespace
_homeset_xml = b""" _homeset_xml = b"""
<propfind xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"> <propfind xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav">
<prop> <prop>
@ -434,21 +456,31 @@ class DAVSession:
class DAVStorage(Storage): class DAVStorage(Storage):
# the file extension of items. Useful for testing against radicale. # the file extension of items. Useful for testing against radicale.
fileext = None fileext: str
# mimetype of items # mimetype of items
item_mimetype = None item_mimetype: str
# XML to use when fetching multiple hrefs.
get_multi_template = None @property
# The LXML query for extracting results in get_multi @abstractmethod
get_multi_data_query = None def get_multi_template(self) -> str:
# The Discover subclass to use """XML to use when fetching multiple hrefs."""
discovery_class = None
@property
@abstractmethod
def get_multi_data_query(self) -> str:
"""LXML query for extracting results in get_multi."""
@property
@abstractmethod
def discovery_class(self) -> Type[Discover]:
"""Discover subclass to use."""
# The DAVSession class to use # The DAVSession class to use
session_class = DAVSession session_class = DAVSession
connector: aiohttp.TCPConnector connector: aiohttp.TCPConnector
_repr_attributes = ("username", "url") _repr_attributes = ["username", "url"]
_property_table = { _property_table = {
"displayname": ("displayname", "DAV:"), "displayname": ("displayname", "DAV:"),
@ -466,7 +498,8 @@ class DAVStorage(Storage):
) )
super().__init__(**kwargs) super().__init__(**kwargs)
__init__.__signature__ = signature(session_class.__init__) __init__.__signature__ = signature(session_class.__init__) # type: ignore
# See https://github.com/python/mypy/issues/5958
@classmethod @classmethod
async def discover(cls, **kwargs): async def discover(cls, **kwargs):
@ -492,7 +525,7 @@ class DAVStorage(Storage):
def _is_item_mimetype(self, mimetype): def _is_item_mimetype(self, mimetype):
return _fuzzy_matches_mimetype(self.item_mimetype, mimetype) return _fuzzy_matches_mimetype(self.item_mimetype, mimetype)
async def get(self, href): async def get(self, href: str):
((actual_href, item, etag),) = await aiostream.stream.list( ((actual_href, item, etag),) = await aiostream.stream.list(
self.get_multi([href]) self.get_multi([href])
) )
@ -588,7 +621,7 @@ class DAVStorage(Storage):
href, etag = await self._put(self._normalize_href(href), item, etag) href, etag = await self._put(self._normalize_href(href), item, etag)
return etag return etag
async def upload(self, item): async def upload(self, item: Item):
href = self._get_href(item) href = self._get_href(item)
rv = await self._put(href, item, None) rv = await self._put(href, item, None)
return rv return rv
@ -687,17 +720,14 @@ class DAVStorage(Storage):
raise exceptions.UnsupportedMetadataError() raise exceptions.UnsupportedMetadataError()
xpath = f"{{{namespace}}}{tagname}" xpath = f"{{{namespace}}}{tagname}"
data = """<?xml version="1.0" encoding="utf-8" ?> body = f"""<?xml version="1.0" encoding="utf-8" ?>
<propfind xmlns="DAV:"> <propfind xmlns="DAV:">
<prop> <prop>
{} {etree.tostring(etree.Element(xpath), encoding="unicode")}
</prop> </prop>
</propfind> </propfind>
""".format( """
etree.tostring(etree.Element(xpath), encoding="unicode") data = body.encode("utf-8")
).encode(
"utf-8"
)
headers = self.session.get_default_headers() headers = self.session.get_default_headers()
headers["Depth"] = "0" headers["Depth"] = "0"

View file

@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
class FilesystemStorage(Storage): class FilesystemStorage(Storage):
storage_name = "filesystem" storage_name = "filesystem"
_repr_attributes = ("path",) _repr_attributes = ["path"]
def __init__( def __init__(
self, self,

View file

@ -36,7 +36,8 @@ class GoogleSession(dav.DAVSession):
client_id, client_id,
client_secret, client_secret,
url=None, url=None,
connector: aiohttp.BaseConnector = None, *,
connector: aiohttp.BaseConnector,
): ):
if not have_oauth2: if not have_oauth2:
raise exceptions.UserError("aiohttp-oauthlib not installed") raise exceptions.UserError("aiohttp-oauthlib not installed")
@ -172,7 +173,7 @@ class GoogleCalendarStorage(dav.CalDAVStorage):
# This is ugly: We define/override the entire signature computed for the # This is ugly: We define/override the entire signature computed for the
# docs here because the current way we autogenerate those docs are too # docs here because the current way we autogenerate those docs are too
# simple for our advanced argspec juggling in `vdirsyncer.storage.dav`. # simple for our advanced argspec juggling in `vdirsyncer.storage.dav`.
__init__._traverse_superclass = base.Storage __init__._traverse_superclass = base.Storage # type: ignore
class GoogleContactsStorage(dav.CardDAVStorage): class GoogleContactsStorage(dav.CardDAVStorage):
@ -187,7 +188,7 @@ class GoogleContactsStorage(dav.CardDAVStorage):
url = "https://www.googleapis.com/.well-known/carddav" url = "https://www.googleapis.com/.well-known/carddav"
scope = ["https://www.googleapis.com/auth/carddav"] scope = ["https://www.googleapis.com/auth/carddav"]
class discovery_class(dav.CardDAVStorage.discovery_class): class discovery_class(dav.CardDiscover):
# Google CardDAV doesn't return any resourcetype prop. # Google CardDAV doesn't return any resourcetype prop.
_resourcetype = None _resourcetype = None
@ -207,4 +208,4 @@ class GoogleContactsStorage(dav.CardDAVStorage):
# This is ugly: We define/override the entire signature computed for the # This is ugly: We define/override the entire signature computed for the
# docs here because the current way we autogenerate those docs are too # docs here because the current way we autogenerate those docs are too
# simple for our advanced argspec juggling in `vdirsyncer.storage.dav`. # simple for our advanced argspec juggling in `vdirsyncer.storage.dav`.
__init__._traverse_superclass = base.Storage __init__._traverse_superclass = base.Storage # type: ignore

View file

@ -16,7 +16,7 @@ from .base import Storage
class HttpStorage(Storage): class HttpStorage(Storage):
storage_name = "http" storage_name = "http"
read_only = True read_only = True
_repr_attributes = ("username", "url") _repr_attributes = ["username", "url"]
_items = None _items = None
# Required for tests. # Required for tests.

View file

@ -36,7 +36,7 @@ def _writing_op(f):
class SingleFileStorage(Storage): class SingleFileStorage(Storage):
storage_name = "singlefile" storage_name = "singlefile"
_repr_attributes = ("path",) _repr_attributes = ["path"]
_write_mode = "wb" _write_mode = "wb"
_append_mode = "ab" _append_mode = "ab"

View file

@ -3,6 +3,7 @@ import os
import sys import sys
import uuid import uuid
from inspect import getfullargspec from inspect import getfullargspec
from typing import Callable
from . import exceptions from . import exceptions
@ -25,7 +26,7 @@ def expand_path(p: str) -> str:
return p return p
def split_dict(d: dict, f: callable): def split_dict(d: dict, f: Callable):
"""Puts key into first dict if f(key), otherwise in second dict""" """Puts key into first dict if f(key), otherwise in second dict"""
a = {} a = {}
b = {} b = {}