mirror of
https://github.com/samsonjs/vdirsyncer.git
synced 2026-04-27 14:57:41 +00:00
commit
8886854367
11 changed files with 114 additions and 55 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
2
setup.py
2
setup.py
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 = {}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue