diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dfa1039..fc9bed72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project should more or less adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [x.x.x] - unreleased + +* Initial work at integrating typing information. Details in https://github.com/python-caldav/caldav/pull/358 + ## [1.3.9] - 2023-12-12 Some bugfixes. diff --git a/caldav/__init__.py b/caldav/__init__.py index 4cedc338..6c117346 100644 --- a/caldav/__init__.py +++ b/caldav/__init__.py @@ -18,7 +18,7 @@ class NullHandler(logging.Handler): - def emit(self, record): + def emit(self, record) -> None: pass diff --git a/caldav/davclient.py b/caldav/davclient.py index 8d9291f5..eeea9bd6 100644 --- a/caldav/davclient.py +++ b/caldav/davclient.py @@ -1,25 +1,39 @@ #!/usr/bin/env python # -*- encoding: utf-8 -*- import logging +import sys +import typing +from types import TracebackType +from typing import Optional +from typing import Union from urllib.parse import unquote import requests -from caldav.elements import cdav +import typing_extensions from caldav.elements import dav -from caldav.elements import ical from caldav.lib import error from caldav.lib.python_utilities import to_normal_str -from caldav.lib.python_utilities import to_unicode from caldav.lib.python_utilities import to_wire from caldav.lib.url import URL from caldav.objects import Calendar -from caldav.objects import errmsg from caldav.objects import log from caldav.objects import Principal -from caldav.objects import ScheduleInbox -from caldav.objects import ScheduleOutbox from caldav.requests import HTTPBearerAuth from lxml import etree +from lxml.etree import _Element +from requests.auth import AuthBase +from requests.models import Response +from requests.structures import CaseInsensitiveDict + +from .elements.base import BaseElement + +if typing.TYPE_CHECKING: + pass + +if sys.version_info < (3, 9): + from typing import Iterable, Mapping +else: + from collections.abc import Iterable, Mapping class DAVResponse: @@ -31,14 +45,16 @@ class DAVResponse: """ raw = "" - reason = "" - tree = None - headers = {} - status = 0 + reason: str = "" + tree: Optional[_Element] = None + headers: CaseInsensitiveDict = {} + status: int = 0 davclient = None - huge_tree = False + huge_tree: bool = False - def __init__(self, response, davclient=None): + def __init__( + self, response: Response, davclient: typing.Optional["DAVClient"] = None + ) -> None: self.headers = response.headers log.debug("response headers: " + str(self.headers)) log.debug("response status: " + str(self.status)) @@ -111,9 +127,9 @@ def __init__(self, response, davclient=None): if hasattr(self, "_raw"): log.debug(self._raw) # ref https://github.com/python-caldav/caldav/issues/112 stray CRs may cause problems - if type(self._raw) == bytes: + if isinstance(self._raw, bytes): self._raw = self._raw.replace(b"\r\n", b"\n") - elif type(self._raw) == str: + elif isinstance(self._raw, str): self._raw = self._raw.replace("\r\n", "\n") self.status = response.status_code ## ref https://github.com/python-caldav/caldav/issues/81, @@ -125,11 +141,13 @@ def __init__(self, response, davclient=None): self.reason = "" @property - def raw(self): + def raw(self) -> str: ## TODO: this should not really be needed? if not hasattr(self, "_raw"): - self._raw = etree.tostring(self.tree, pretty_print=True) - return self._raw + self._raw = etree.tostring( + typing.cast(_Element, self.tree), pretty_print=True + ) + return self._raw.decode() def _strip_to_multistatus(self): """ @@ -160,7 +178,7 @@ def _strip_to_multistatus(self): return self.tree return [self.tree] - def validate_status(self, status): + def validate_status(self, status: str) -> None: """ status is a string like "HTTP/1.1 404 Not Found". 200, 207 and 404 are considered good statuses. The SOGo caldav server even @@ -177,15 +195,17 @@ def validate_status(self, status): ): raise error.ResponseError(status) - def _parse_response(self, response): + def _parse_response( + self, response + ) -> typing.Tuple[str, typing.List[_Element], typing.Optional[typing.Any]]: """ One response should contain one or zero status children, one href tag and zero or more propstats. Find them, assert there isn't more in the response and return those three fields """ status = None - href = None - propstats = [] + href: typing.Optional[str] = None + propstats: typing.List[_Element] = [] error.assert_(response.tag == dav.Response.tag) for elem in response: if elem.tag == dav.Status.tag: @@ -201,9 +221,9 @@ def _parse_response(self, response): else: error.assert_(False) error.assert_(href) - return (href, propstats, status) + return (typing.cast(str, href), propstats, status) - def find_objects_and_props(self): + def find_objects_and_props(self) -> typing.Dict[str, typing.Dict[str, _Element]]: """Check the response from the server, check that it is on an expected format, find hrefs and props from it and check statuses delivered. @@ -213,7 +233,7 @@ def find_objects_and_props(self): self.sync_token will be populated if found, self.objects will be populated. """ - self.objects = {} + self.objects: typing.Dict[str, typing.Dict[str, _Element]] = {} if "Schedule-Tag" in self.headers: self.schedule_tag = self.headers["Schedule-Tag"] @@ -239,7 +259,7 @@ def find_objects_and_props(self): cnt = 0 status = propstat.find(dav.Status.tag) error.assert_(status is not None) - if status is not None: + if status is not None and status.text is not None: error.assert_(len(status) == 0) cnt += 1 self.validate_status(status.text) @@ -285,7 +305,12 @@ def _expand_simple_prop( return values[0] ## TODO: "expand" does not feel quite right. - def expand_simple_props(self, props=[], multi_value_props=[], xpath=None): + def expand_simple_props( + self, + props: Iterable[BaseElement] = [], + multi_value_props: Iterable[typing.Any] = [], + xpath: Optional[str] = None, + ) -> typing.Dict[str, typing.Dict[str, str]]: """ The find_objects_and_props() will stop at the xml element below the prop tag. This method will expand those props into @@ -299,14 +324,21 @@ def expand_simple_props(self, props=[], multi_value_props=[], xpath=None): for href in self.objects: props_found = self.objects[href] for prop in props: + if prop.tag is None: + continue + props_found[prop.tag] = self._expand_simple_prop( prop.tag, props_found, xpath=xpath ) for prop in multi_value_props: + if prop.tag is None: + continue + props_found[prop.tag] = self._expand_simple_prop( prop.tag, props_found, xpath=xpath, multi_value_allowed=True ) - return self.objects + # _Element objects in self.objects are parsed to str, thus the need to cast the return + return typing.cast(typing.Dict[str, typing.Dict[str, str]], self.objects) class DAVClient: @@ -318,23 +350,23 @@ class DAVClient: the constructor (__init__), the principal method and the calendar method. """ - proxy = None - url = None - huge_tree = False + proxy: Optional[str] = None + url: URL = None + huge_tree: bool = False def __init__( self, - url, - proxy=None, - username=None, - password=None, - auth=None, - timeout=None, - ssl_verify_cert=True, - ssl_cert=None, - headers={}, - huge_tree=False, - ): + url: str, + proxy: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + auth: Optional[AuthBase] = None, + timeout: Optional[int] = None, + ssl_verify_cert: Union[bool, str] = True, + ssl_cert: Union[str, typing.Tuple[str, str], None] = None, + headers: typing.Dict[str, str] = {}, + huge_tree: bool = False, + ) -> None: """ Sets up a HTTPConnection object towards the server in the url. Parameters: @@ -358,18 +390,20 @@ def __init__( self.huge_tree = huge_tree # Prepare proxy info if proxy is not None: - self.proxy = proxy + _proxy = proxy # requests library expects the proxy url to have a scheme if "://" not in proxy: - self.proxy = self.url.scheme + "://" + proxy + _proxy = self.url.scheme + "://" + proxy # add a port is one is not specified # TODO: this will break if using basic auth and embedding # username:password in the proxy URL - p = self.proxy.split(":") + p = _proxy.split(":") if len(p) == 2: - self.proxy += ":8080" - log.debug("init - proxy: %s" % (self.proxy)) + _proxy += ":8080" + log.debug("init - proxy: %s" % (_proxy)) + + self.proxy = _proxy # Build global headers self.headers = headers @@ -387,7 +421,7 @@ def __init__( self.username = username self.password = password ## I had problems with passwords with non-ascii letters in it ... - if hasattr(self.password, "encode"): + if isinstance(self.password, str): self.password = self.password.encode("utf-8") self.auth = auth # TODO: it's possible to force through a specific auth method here, @@ -400,13 +434,18 @@ def __init__( self._principal = None - def __enter__(self): + def __enter__(self) -> typing_extensions.Self: return self - def __exit__(self, exc_type, exc_value, traceback): + def __exit__( + self, + exc_type: Optional[typing.Type[BaseException]], + exc_value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: self.close() - def close(self): + def close(self) -> None: """ Closes the DAVClient's session object """ @@ -438,7 +477,7 @@ def calendar(self, **kwargs): """ return Calendar(client=self, **kwargs) - def check_dav_support(self): + def check_dav_support(self) -> Optional[str]: try: ## SOGo does not return the full capability list on the caldav ## root URL, and that's OK according to the RFC ... so apparently @@ -447,18 +486,20 @@ def check_dav_support(self): ## Anyway, packing this into a try-except in case it fails. response = self.options(self.principal().url) except: - response = self.options(self.url) + response = self.options(str(self.url)) return response.headers.get("DAV", None) - def check_cdav_support(self): + def check_cdav_support(self) -> bool: support_list = self.check_dav_support() - return support_list and "calendar-access" in support_list + return support_list is not None and "calendar-access" in support_list - def check_scheduling_support(self): + def check_scheduling_support(self) -> bool: support_list = self.check_dav_support() - return support_list and "calendar-auto-schedule" in support_list + return support_list is not None and "calendar-auto-schedule" in support_list - def propfind(self, url=None, props="", depth=0): + def propfind( + self, url: Optional[str] = None, props: str = "", depth: int = 0 + ) -> DAVResponse: """ Send a propfind request. @@ -470,9 +511,11 @@ def propfind(self, url=None, props="", depth=0): Returns * DAVResponse """ - return self.request(url or self.url, "PROPFIND", props, {"Depth": str(depth)}) + return self.request( + url or str(self.url), "PROPFIND", props, {"Depth": str(depth)} + ) - def proppatch(self, url, body, dummy=None): + def proppatch(self, url: str, body: str, dummy: None = None) -> DAVResponse: """ Send a proppatch request. @@ -486,7 +529,7 @@ def proppatch(self, url, body, dummy=None): """ return self.request(url, "PROPPATCH", body) - def report(self, url, query="", depth=0): + def report(self, url: str, query: str = "", depth: int = 0) -> DAVResponse: """ Send a report request. @@ -505,7 +548,7 @@ def report(self, url, query="", depth=0): {"Depth": str(depth), "Content-Type": 'application/xml; charset="utf-8"'}, ) - def mkcol(self, url, body, dummy=None): + def mkcol(self, url: str, body: str, dummy: None = None) -> DAVResponse: """ Send a MKCOL request. @@ -528,7 +571,7 @@ def mkcol(self, url, body, dummy=None): """ return self.request(url, "MKCOL", body) - def mkcalendar(self, url, body="", dummy=None): + def mkcalendar(self, url: str, body: str = "", dummy: None = None) -> DAVResponse: """ Send a mkcalendar request. @@ -542,25 +585,25 @@ def mkcalendar(self, url, body="", dummy=None): """ return self.request(url, "MKCALENDAR", body) - def put(self, url, body, headers={}): + def put(self, url: str, body: str, headers: Mapping[str, str] = {}) -> DAVResponse: """ Send a put request. """ return self.request(url, "PUT", body, headers) - def post(self, url, body, headers={}): + def post(self, url: str, body: str, headers: Mapping[str, str] = {}) -> DAVResponse: """ Send a POST request. """ return self.request(url, "POST", body, headers) - def delete(self, url): + def delete(self, url: str) -> DAVResponse: """ Send a delete request. """ return self.request(url, "DELETE") - def options(self, url): + def options(self, url: str) -> DAVResponse: return self.request(url, "OPTIONS") def extract_auth_types(self, header): @@ -569,7 +612,13 @@ def extract_auth_types(self, header): auth_types = map(lambda auth_type: auth_type.split(" ")[0], auth_types) return list(filter(lambda auth_type: auth_type, auth_types)) - def request(self, url, method="GET", body="", headers={}): + def request( + self, + url: str, + method: str = "GET", + body: str = "", + headers: Mapping[str, str] = {}, + ) -> DAVResponse: """ Actually sends the request, and does the authentication """ @@ -578,24 +627,24 @@ def request(self, url, method="GET", body="", headers={}): if (body is None or body == "") and "Content-Type" in combined_headers: del combined_headers["Content-Type"] + # objectify the url + url_obj = URL.objectify(url) + proxies = None if self.proxy is not None: - proxies = {url.scheme: self.proxy} + proxies = {url_obj.scheme: self.proxy} log.debug("using proxy - %s" % (proxies)) - # objectify the url - url = URL.objectify(url) - log.debug( "sending request - method={0}, url={1}, headers={2}\nbody:\n{3}".format( - method, str(url), combined_headers, to_normal_str(body) + method, str(url_obj), combined_headers, to_normal_str(body) ) ) try: r = self.session.request( method, - str(url), + str(url_obj), data=to_wire(body), headers=combined_headers, proxies=proxies, @@ -615,7 +664,7 @@ def request(self, url, method="GET", body="", headers={}): raise r = self.session.request( method="GET", - url=str(url), + url=str(url_obj), headers=combined_headers, proxies=proxies, timeout=self.timeout, @@ -652,7 +701,7 @@ def request(self, url, method="GET", body="", headers={}): and "WWW-Authenticate" in r.headers and self.auth and self.password - and hasattr(self.password, "decode") + and isinstance(self.password, bytes) ): ## Most likely we're here due to wrong username/password ## combo, but it could also be charset problems. Some @@ -665,20 +714,20 @@ def request(self, url, method="GET", body="", headers={}): auth_types = self.extract_auth_types(r.headers["WWW-Authenticate"]) - if "digest" in auth_types: + if self.password and self.username and "digest" in auth_types: self.auth = requests.auth.HTTPDigestAuth( self.username, self.password.decode() ) - elif "basic" in auth_types: + elif self.password and self.username and "basic" in auth_types: self.auth = requests.auth.HTTPBasicAuth( self.username, self.password.decode() ) - elif "bearer" in auth_types: + elif self.password and "bearer" in auth_types: self.auth = HTTPBearerAuth(self.password.decode()) self.username = None self.password = None - return self.request(url, method, body, headers) + return self.request(str(url_obj), method, body, headers) # this is an error condition that should be raised to the application if ( @@ -689,6 +738,6 @@ def request(self, url, method="GET", body="", headers={}): reason = response.reason except AttributeError: reason = "None given" - raise error.AuthorizationError(url=str(url), reason=reason) + raise error.AuthorizationError(url=str(url_obj), reason=reason) return response diff --git a/caldav/elements/base.py b/caldav/elements/base.py index 8191fcb2..e91f0a3a 100644 --- a/caldav/elements/base.py +++ b/caldav/elements/base.py @@ -1,18 +1,35 @@ #!/usr/bin/env python # -*- encoding: utf-8 -*- +import sys +import typing +from typing import ClassVar +from typing import List +from typing import Optional +from typing import Union + from caldav.lib.namespace import nsmap from caldav.lib.python_utilities import to_unicode from lxml import etree +from lxml.etree import _Element +from typing_extensions import Self +from typing_extensions import TypeAlias + +if sys.version_info < (3, 9): + from typing import Iterable +else: + from collections.abc import Iterable -class BaseElement(object): - children = None - tag = None - value = None - attributes = None +class BaseElement: + children: Optional[List[Self]] = None + tag: ClassVar[Optional[str]] = None + value: Optional[str] = None + attributes: typing.Optional[dict] = None caldav_class = None - def __init__(self, name=None, value=None): + def __init__( + self, name: Optional[str] = None, value: Union[str, bytes, None] = None + ) -> None: self.children = [] self.attributes = {} value = to_unicode(value) @@ -22,16 +39,24 @@ def __init__(self, name=None, value=None): if value is not None: self.value = value - def __add__(self, other): + def __add__( + self, other: Union["BaseElement", Iterable["BaseElement"]] + ) -> "BaseElement": return self.append(other) - def __str__(self): + def __str__(self) -> str: utf8 = etree.tostring( self.xmlelement(), encoding="utf-8", xml_declaration=True, pretty_print=True ) return str(utf8, "utf-8") - def xmlelement(self): + def xmlelement(self) -> _Element: + if self.tag is None: + raise ValueError("Unexpected value None for self.tag") + + if self.attributes is None: + raise ValueError("Unexpected value None for self.attributes") + root = etree.Element(self.tag, nsmap=nsmap) if self.value is not None: root.text = self.value @@ -41,21 +66,27 @@ def xmlelement(self): self.xmlchildren(root) return root - def xmlchildren(self, root): + def xmlchildren(self, root: _Element) -> None: + if self.children is None: + raise ValueError("Unexpected value None for self.children") + for c in self.children: root.append(c.xmlelement()) - def append(self, element): - try: - iter(element) + def append(self, element: Union[Self, Iterable[Self]]) -> Self: + if self.children is None: + raise ValueError("Unexpected value None for self.children") + + if isinstance(element, Iterable): self.children.extend(element) - except TypeError: + else: self.children.append(element) + return self class NamedBaseElement(BaseElement): - def __init__(self, name=None): + def __init__(self, name: Optional[str] = None) -> None: super(NamedBaseElement, self).__init__(name=name) def xmlelement(self): @@ -65,5 +96,5 @@ def xmlelement(self): class ValuedBaseElement(BaseElement): - def __init__(self, value=None): + def __init__(self, value: Union[str, bytes, None] = None) -> None: super(ValuedBaseElement, self).__init__(value=value) diff --git a/caldav/elements/cdav.py b/caldav/elements/cdav.py index 1e7d3ce8..d59e3772 100644 --- a/caldav/elements/cdav.py +++ b/caldav/elements/cdav.py @@ -2,6 +2,12 @@ # -*- encoding: utf-8 -*- import logging from datetime import datetime +from typing import ClassVar +from typing import Optional + +from .base import BaseElement +from .base import NamedBaseElement +from .base import ValuedBaseElement try: from datetime import timezone @@ -55,64 +61,74 @@ def _to_utc_date_string(ts): # Operations class CalendarQuery(BaseElement): - tag = ns("C", "calendar-query") + tag: ClassVar[str] = ns("C", "calendar-query") class FreeBusyQuery(BaseElement): - tag = ns("C", "free-busy-query") + tag: ClassVar[str] = ns("C", "free-busy-query") class Mkcalendar(BaseElement): - tag = ns("C", "mkcalendar") + tag: ClassVar[str] = ns("C", "mkcalendar") class CalendarMultiGet(BaseElement): - tag = ns("C", "calendar-multiget") + tag: ClassVar[str] = ns("C", "calendar-multiget") class ScheduleInboxURL(BaseElement): - tag = ns("C", "schedule-inbox-URL") + tag: ClassVar[str] = ns("C", "schedule-inbox-URL") class ScheduleOutboxURL(BaseElement): - tag = ns("C", "schedule-outbox-URL") + tag: ClassVar[str] = ns("C", "schedule-outbox-URL") # Filters class Filter(BaseElement): - tag = ns("C", "filter") + tag: ClassVar[str] = ns("C", "filter") class CompFilter(NamedBaseElement): - tag = ns("C", "comp-filter") + tag: ClassVar[str] = ns("C", "comp-filter") class PropFilter(NamedBaseElement): - tag = ns("C", "prop-filter") + tag: ClassVar[str] = ns("C", "prop-filter") class ParamFilter(NamedBaseElement): - tag = ns("C", "param-filter") + tag: ClassVar[str] = ns("C", "param-filter") # Conditions class TextMatch(ValuedBaseElement): - tag = ns("C", "text-match") + tag: ClassVar[str] = ns("C", "text-match") - def __init__(self, value, collation="i;octet", negate=False): + def __init__(self, value, collation: str = "i;octet", negate: bool = False) -> None: super(TextMatch, self).__init__(value=value) + + if self.attributes is None: + raise ValueError("Unexpected value None for self.attributes") + self.attributes["collation"] = collation if negate: self.attributes["negate-condition"] = "yes" class TimeRange(BaseElement): - tag = ns("C", "time-range") + tag: ClassVar[str] = ns("C", "time-range") - def __init__(self, start=None, end=None): + def __init__( + self, start: Optional[datetime] = None, end: Optional[datetime] = None + ) -> None: ## start and end should be an icalendar "date with UTC time", ## ref https://tools.ietf.org/html/rfc4791#section-9.9 super(TimeRange, self).__init__() + + if self.attributes is None: + raise ValueError("Unexpected value None for self.attributes") + if start is not None: self.attributes["start"] = _to_utc_date_string(start) if end is not None: @@ -120,19 +136,25 @@ def __init__(self, start=None, end=None): class NotDefined(BaseElement): - tag = ns("C", "is-not-defined") + tag: ClassVar[str] = ns("C", "is-not-defined") # Components / Data class CalendarData(BaseElement): - tag = ns("C", "calendar-data") + tag: ClassVar[str] = ns("C", "calendar-data") class Expand(BaseElement): - tag = ns("C", "expand") + tag: ClassVar[str] = ns("C", "expand") - def __init__(self, start, end=None): + def __init__( + self, start: Optional[datetime], end: Optional[datetime] = None + ) -> None: super(Expand, self).__init__() + + if self.attributes is None: + raise ValueError("Unexpected value None for self.attributes") + if start is not None: self.attributes["start"] = _to_utc_date_string(start) if end is not None: @@ -140,7 +162,7 @@ def __init__(self, start, end=None): class Comp(NamedBaseElement): - tag = ns("C", "comp") + tag: ClassVar[str] = ns("C", "comp") # Uhhm ... can't find any references to calendar-collection in rfc4791.txt @@ -152,61 +174,61 @@ class Comp(NamedBaseElement): # Properties class CalendarUserAddressSet(BaseElement): - tag = ns("C", "calendar-user-address-set") + tag: ClassVar[str] = ns("C", "calendar-user-address-set") class CalendarUserType(BaseElement): - tag = ns("C", "calendar-user-type") + tag: ClassVar[str] = ns("C", "calendar-user-type") class CalendarHomeSet(BaseElement): - tag = ns("C", "calendar-home-set") + tag: ClassVar[str] = ns("C", "calendar-home-set") # calendar resource type, see rfc4791, sec. 4.2 class Calendar(BaseElement): - tag = ns("C", "calendar") + tag: ClassVar[str] = ns("C", "calendar") class CalendarDescription(ValuedBaseElement): - tag = ns("C", "calendar-description") + tag: ClassVar[str] = ns("C", "calendar-description") class CalendarTimeZone(ValuedBaseElement): - tag = ns("C", "calendar-timezone") + tag: ClassVar[str] = ns("C", "calendar-timezone") class SupportedCalendarComponentSet(ValuedBaseElement): - tag = ns("C", "supported-calendar-component-set") + tag: ClassVar[str] = ns("C", "supported-calendar-component-set") class SupportedCalendarData(ValuedBaseElement): - tag = ns("C", "supported-calendar-data") + tag: ClassVar[str] = ns("C", "supported-calendar-data") class MaxResourceSize(ValuedBaseElement): - tag = ns("C", "max-resource-size") + tag: ClassVar[str] = ns("C", "max-resource-size") class MinDateTime(ValuedBaseElement): - tag = ns("C", "min-date-time") + tag: ClassVar[str] = ns("C", "min-date-time") class MaxDateTime(ValuedBaseElement): - tag = ns("C", "max-date-time") + tag: ClassVar[str] = ns("C", "max-date-time") class MaxInstances(ValuedBaseElement): - tag = ns("C", "max-instances") + tag: ClassVar[str] = ns("C", "max-instances") class MaxAttendeesPerInstance(ValuedBaseElement): - tag = ns("C", "max-attendees-per-instance") + tag: ClassVar[str] = ns("C", "max-attendees-per-instance") class Allprop(BaseElement): - tag = ns("C", "allprop") + tag: ClassVar[str] = ns("C", "allprop") class ScheduleTag(BaseElement): - tag = ns("C", "schedule-tag") + tag: ClassVar[str] = ns("C", "schedule-tag") diff --git a/caldav/elements/dav.py b/caldav/elements/dav.py index d6bd68c2..d9e07d4c 100644 --- a/caldav/elements/dav.py +++ b/caldav/elements/dav.py @@ -1,5 +1,7 @@ #!/usr/bin/env python # -*- encoding: utf-8 -*- +from typing import ClassVar + from caldav.lib.namespace import ns from .base import BaseElement @@ -8,62 +10,62 @@ # Operations class Propfind(BaseElement): - tag = ns("D", "propfind") + tag: ClassVar[str] = ns("D", "propfind") class PropertyUpdate(BaseElement): - tag = ns("D", "propertyupdate") + tag: ClassVar[str] = ns("D", "propertyupdate") class Mkcol(BaseElement): - tag = ns("D", "mkcol") + tag: ClassVar[str] = ns("D", "mkcol") class SyncCollection(BaseElement): - tag = ns("D", "sync-collection") + tag: ClassVar[str] = ns("D", "sync-collection") # Filters # Conditions class SyncToken(BaseElement): - tag = ns("D", "sync-token") + tag: ClassVar[str] = ns("D", "sync-token") class SyncLevel(BaseElement): - tag = ns("D", "sync-level") + tag: ClassVar[str] = ns("D", "sync-level") # Components / Data class Prop(BaseElement): - tag = ns("D", "prop") + tag: ClassVar[str] = ns("D", "prop") class Collection(BaseElement): - tag = ns("D", "collection") + tag: ClassVar[str] = ns("D", "collection") class Set(BaseElement): - tag = ns("D", "set") + tag: ClassVar[str] = ns("D", "set") # Properties class ResourceType(BaseElement): - tag = ns("D", "resourcetype") + tag: ClassVar[str] = ns("D", "resourcetype") class DisplayName(ValuedBaseElement): - tag = ns("D", "displayname") + tag: ClassVar[str] = ns("D", "displayname") class GetEtag(ValuedBaseElement): - tag = ns("D", "getetag") + tag: ClassVar[str] = ns("D", "getetag") class Href(BaseElement): - tag = ns("D", "href") + tag: ClassVar[str] = ns("D", "href") class SupportedReportSet(BaseElement): @@ -71,28 +73,28 @@ class SupportedReportSet(BaseElement): class Response(BaseElement): - tag = ns("D", "response") + tag: ClassVar[str] = ns("D", "response") class Status(BaseElement): - tag = ns("D", "status") + tag: ClassVar[str] = ns("D", "status") class PropStat(BaseElement): - tag = ns("D", "propstat") + tag: ClassVar[str] = ns("D", "propstat") class MultiStatus(BaseElement): - tag = ns("D", "multistatus") + tag: ClassVar[str] = ns("D", "multistatus") class CurrentUserPrincipal(BaseElement): - tag = ns("D", "current-user-principal") + tag: ClassVar[str] = ns("D", "current-user-principal") class PrincipalCollectionSet(BaseElement): - tag = ns("D", "principal-collection-set") + tag: ClassVar[str] = ns("D", "principal-collection-set") class Allprop(BaseElement): - tag = ns("D", "allprop") + tag: ClassVar[str] = ns("D", "allprop") diff --git a/caldav/elements/ical.py b/caldav/elements/ical.py index 902df325..721e6e2e 100644 --- a/caldav/elements/ical.py +++ b/caldav/elements/ical.py @@ -1,5 +1,7 @@ #!/usr/bin/env python # -*- encoding: utf-8 -*- +from typing import ClassVar + from caldav.lib.namespace import ns from .base import BaseElement @@ -7,8 +9,8 @@ # Properties class CalendarColor(ValuedBaseElement): - tag = ns("I", "calendar-color") + tag: ClassVar[str] = ns("I", "calendar-color") class CalendarOrder(ValuedBaseElement): - tag = ns("I", "calendar-order") + tag: ClassVar[str] = ns("I", "calendar-order") diff --git a/caldav/lib/debug.py b/caldav/lib/debug.py index 783923b1..9c7bcd2d 100644 --- a/caldav/lib/debug.py +++ b/caldav/lib/debug.py @@ -7,5 +7,5 @@ def xmlstring(root): return etree.tostring(root, pretty_print=True).decode("utf-8") -def printxml(root): +def printxml(root) -> None: print(xmlstring(root)) diff --git a/caldav/lib/error.py b/caldav/lib/error.py index cdeed444..047ef0e9 100644 --- a/caldav/lib/error.py +++ b/caldav/lib/error.py @@ -1,7 +1,9 @@ #!/usr/bin/env python # -*- encoding: utf-8 -*- import logging +import typing from collections import defaultdict +from typing import Optional from caldav import __version__ @@ -23,7 +25,7 @@ log.setLevel(logging.WARNING) -def assert_(condition): +def assert_(condition: object) -> None: try: assert condition except AssertionError: @@ -40,20 +42,20 @@ def assert_(condition): raise -ERR_FRAGMENT = "Please raise an issue at https://github.com/python-caldav/caldav/issues or reach out to t-caldav@tobixen.no, include this error and the traceback and tell what server you are using" +ERR_FRAGMENT: str = "Please raise an issue at https://github.com/python-caldav/caldav/issues or reach out to t-caldav@tobixen.no, include this error and the traceback and tell what server you are using" class DAVError(Exception): - url = None - reason = "no reason" + url: Optional[str] = None + reason: str = "no reason" - def __init__(self, url=None, reason=None): + def __init__(self, url: Optional[str] = None, reason: Optional[str] = None) -> None: if url: self.url = url if reason: self.reason = reason - def __str__(self): + def __str__(self) -> str: return "%s at '%s', reason %s" % ( self.__class__.__name__, self.url, @@ -115,7 +117,9 @@ class ResponseError(DAVError): pass -exception_by_method = defaultdict(lambda: DAVError) +exception_by_method: typing.Dict[str, typing.Type[DAVError]] = defaultdict( + lambda: DAVError +) for method in ( "delete", "put", diff --git a/caldav/lib/namespace.py b/caldav/lib/namespace.py index 72f1a6b6..3cbd1c43 100644 --- a/caldav/lib/namespace.py +++ b/caldav/lib/namespace.py @@ -1,7 +1,10 @@ #!/usr/bin/env python # -*- encoding: utf-8 -*- +import typing +from typing import Dict +from typing import Optional -nsmap = { +nsmap: Dict[str, str] = { "D": "DAV:", "C": "urn:ietf:params:xml:ns:caldav", } @@ -11,11 +14,11 @@ ## calendar-color and calendar-order properties. However, those ## attributes aren't described anywhere, and the I-URL even gives a ## 404! I don't want to ship it in the namespace list of every request. -nsmap2 = nsmap.copy() +nsmap2: Dict[str, typing.Any] = nsmap.copy() nsmap2["I"] = ("http://apple.com/ns/ical/",) -def ns(prefix, tag=None): +def ns(prefix: str, tag: Optional[str] = None) -> str: name = "{%s}" % nsmap2[prefix] if tag is not None: name = "%s%s" % (name, tag) diff --git a/caldav/lib/url.py b/caldav/lib/url.py index b5fe887a..e5790b42 100644 --- a/caldav/lib/url.py +++ b/caldav/lib/url.py @@ -1,5 +1,8 @@ #!/usr/bin/env python # -*- encoding: utf-8 -*- +import typing +import urllib.parse +from typing import Union from urllib.parse import ParseResult from urllib.parse import quote from urllib.parse import SplitResult @@ -9,6 +12,7 @@ from caldav.lib.python_utilities import to_normal_str from caldav.lib.python_utilities import to_unicode +from typing_extensions import Self class URL: @@ -41,24 +45,24 @@ class URL: """ - def __init__(self, url): + def __init__(self, url: Union[str, ParseResult, SplitResult]) -> None: if isinstance(url, ParseResult) or isinstance(url, SplitResult): - self.url_parsed = url + self.url_parsed: typing.Optional[Union[ParseResult, SplitResult]] = url self.url_raw = None else: self.url_raw = url self.url_parsed = None - def __bool__(self): + def __bool__(self) -> bool: if self.url_raw or self.url_parsed: return True else: return False - def __ne__(self, other): + def __ne__(self, other: object) -> bool: return not self == other - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if str(self) == str(other): return True # The URLs could have insignificant differences @@ -67,13 +71,13 @@ def __eq__(self, other): other = other.canonical() return str(me) == str(other) - def __hash__(self): + def __hash__(self) -> int: return hash(str(self)) # TODO: better naming? Will return url if url is already a URL # object, else will instantiate a new URL object @classmethod - def objectify(self, url): + def objectify(self, url: Union[Self, str, ParseResult, SplitResult]) -> "URL": if url is None: return None if isinstance(url, URL): @@ -83,39 +87,44 @@ def objectify(self, url): # To deal with all kind of methods/properties in the ParseResult # class - def __getattr__(self, attr): + def __getattr__(self, attr: str): if "url_parsed" not in vars(self): raise AttributeError if self.url_parsed is None: - self.url_parsed = urlparse(self.url_raw) + self.url_parsed = typing.cast( + urllib.parse.ParseResult, urlparse(self.url_raw) + ) if hasattr(self.url_parsed, attr): return getattr(self.url_parsed, attr) else: return getattr(self.__unicode__(), attr) # returns the url in text format - def __str__(self): + def __str__(self) -> str: return to_normal_str(self.__unicode__()) # returns the url in text format - def __unicode__(self): + def __unicode__(self) -> str: if self.url_raw is None: + if self.url_parsed is None: + raise ValueError("Unexpected value None for self.url_parsed") + self.url_raw = self.url_parsed.geturl() return to_unicode(self.url_raw) - def __repr__(self): + def __repr__(self) -> str: return "URL(%s)" % str(self) - def strip_trailing_slash(self): + def strip_trailing_slash(self) -> "URL": if str(self)[-1] == "/": return URL.objectify(str(self)[:-1]) else: return self - def is_auth(self): + def is_auth(self) -> bool: return self.username is not None - def unauth(self): + def unauth(self) -> "URL": if not self.is_auth(): return self return URL.objectify( @@ -130,7 +139,7 @@ def unauth(self): ) ) - def canonical(self): + def canonical(self) -> "URL": """ a canonical URL ... remove authentication details, make sure there are no double slashes, and to make sure the URL is always the same, @@ -138,7 +147,7 @@ def canonical(self): """ url = self.unauth() - arr = list(self.url_parsed) + arr = list(typing.cast(urllib.parse.ParseResult, self.url_parsed)) ## quoting path and removing double slashes arr[2] = quote(unquote(url.path.replace("//", "/"))) ## sensible defaults @@ -159,7 +168,7 @@ def canonical(self): return url - def join(self, path): + def join(self, path: typing.Any) -> "URL": """ assumes this object is the base URL or base path. If the path is relative, it should be appended to the base. If the path @@ -197,6 +206,6 @@ def join(self, path): ) -def make(url): +def make(url: Union[URL, str, ParseResult, SplitResult]) -> URL: """Backward compatibility""" return URL.objectify(url) diff --git a/caldav/lib/vcal.py b/caldav/lib/vcal.py index 8ef08fa3..492a2e43 100644 --- a/caldav/lib/vcal.py +++ b/caldav/lib/vcal.py @@ -122,7 +122,7 @@ class LineFilterDiscardingDuplicates: least comprising the complete vobject. """ - def __init__(self): + def __init__(self) -> None: self.stamped = 0 self.ended = 0 diff --git a/caldav/objects.py b/caldav/objects.py index 1ca092ec..7aeddbc3 100644 --- a/caldav/objects.py +++ b/caldav/objects.py @@ -9,12 +9,23 @@ class hierarchy into a separate file) """ import re +import sys +import typing import uuid from collections import defaultdict from datetime import date from datetime import datetime from datetime import timedelta from datetime import timezone +from typing import Any +from typing import Optional +from typing import Type +from typing import TypeVar +from typing import Union +from urllib.parse import ParseResult +from urllib.parse import quote +from urllib.parse import SplitResult +from urllib.parse import unquote import icalendar import vobject @@ -23,12 +34,14 @@ class hierarchy into a separate file) from caldav.lib.python_utilities import to_wire from dateutil.rrule import rrulestr from lxml import etree +from lxml.etree import _Element +from typing_extensions import Literal +from typing_extensions import Self +from vobject.base import VBase -try: - # noinspection PyCompatibility - from urllib.parse import unquote, quote -except ImportError: - from urllib import unquote, quote +from .elements.base import BaseElement +from .elements.cdav import CalendarData +from .elements.cdav import CompFilter try: from typing import ClassVar, Union, Optional @@ -39,41 +52,59 @@ class hierarchy into a separate file) from caldav.lib import error, vcal from caldav.lib.url import URL -from caldav.elements import dav, cdav, ical +from caldav.elements import dav, cdav + import logging + +if typing.TYPE_CHECKING: + from .davclient import DAVClient + from icalendar import vCalAddress + +if sys.version_info < (3, 9): + from typing import Callable, Container, Iterator, Sequence + from typing import Iterable + from typing_extensions import DefaultDict +else: + from collections.abc import Callable + from collections.abc import Container + from collections.abc import Iterable + from collections.abc import Iterator + from collections.abc import Sequence + from collections import defaultdict as DefaultDict + +_CC = TypeVar("_CC", bound="CalendarObjectResource") log = logging.getLogger("caldav") -def errmsg(r): +def errmsg(r) -> str: """Utility for formatting a response xml tree to an error string""" return "%s %s\n\n%s" % (r.status, r.reason, r.raw) -class DAVObject(object): - +class DAVObject: """ Base class for all DAV objects. Can be instantiated by a client and an absolute or relative URL, or from the parent object. """ - id = None - url = None - client = None - parent = None - name = None + id: Optional[str] = None + url: Optional[URL] = None + client: Optional["DAVClient"] = None + parent: Optional["DAVObject"] = None + name: Optional[str] = None def __init__( self, - client=None, - url=None, - parent=None, - name=None, - id=None, + client: Optional["DAVClient"] = None, + url: Union[str, ParseResult, SplitResult, URL, None] = None, + parent: Optional["DAVObject"] = None, + name: Optional[str] = None, + id: Optional[str] = None, props=None, **extra, - ): + ) -> None: """ Default constructor. @@ -100,14 +131,20 @@ def __init__( # url may be a path relative to the caldav root if client and url: self.url = client.url.join(url) + elif url is None: + self.url = None else: self.url = URL.objectify(url) @property - def canonical_url(self): + def canonical_url(self) -> str: + if self.url is None: + raise ValueError("Unexpected value None for self.url") return str(self.url.canonical()) - def children(self, type=None): + def children( + self, type: Optional[str] = None + ) -> typing.List[typing.Tuple[URL, Any, Any]]: """List children, using a propfind (resourcetype) on the parent object, at depth = 1. @@ -123,11 +160,14 @@ def children(self, type=None): c = [] depth = 1 - properties = {} + + if self.url is None: + raise ValueError("Unexpected value None for self.url") props = [dav.DisplayName()] multiprops = [dav.ResourceType()] - response = self._query_properties(props + multiprops, depth) + props_multiprops = props + multiprops + response = self._query_properties(props_multiprops, depth) properties = response.expand_simple_props( props=props, multi_value_props=multiprops ) @@ -157,7 +197,9 @@ def children(self, type=None): ## the properties we've already fetched return c - def _query_properties(self, props=None, depth=0): + def _query_properties( + self, props: Optional[typing.Sequence[BaseElement]] = None, depth: int = 0 + ): """ This is an internal method for doing a propfind query. It's a result of code-refactoring work, attempting to consolidate @@ -216,7 +258,9 @@ def _query( raise error.exception_by_method[query_method](errmsg(ret)) return ret - def get_property(self, prop, use_cached=False, **passthrough): + def get_property( + self, prop: BaseElement, use_cached: bool = False, **passthrough + ) -> Optional[str]: ## TODO: use_cached should probably be true if use_cached: if prop.tag in self.props: @@ -225,7 +269,11 @@ def get_property(self, prop, use_cached=False, **passthrough): return foo.get(prop.tag, None) def get_properties( - self, props=None, depth=0, parse_response_xml=True, parse_props=True + self, + props: Optional[typing.Sequence[BaseElement]] = None, + depth: int = 0, + parse_response_xml: bool = True, + parse_props: bool = True, ): """Get properties (PROPFIND) for this object. @@ -257,6 +305,9 @@ def get_properties( error.assert_(properties) + if self.url is None: + raise ValueError("Unexpected value None for self.url") + path = unquote(self.url.path) if path.endswith("/"): exchange_path = path[:-1] @@ -279,7 +330,7 @@ def get_properties( "potential path handling problem with ending slashes. Path given: %s, path found: %s. %s" % (path, exchange_path, error.ERR_FRAGMENT) ) - error._assert(False) + error.assert_(False) rc = properties[exchange_path] elif self.url in properties: rc = properties[self.url] @@ -315,10 +366,13 @@ def get_properties( error.assert_(False) if parse_props: + if rc is None: + raise ValueError("Unexpected value None for rc") + self.props.update(rc) return rc - def set_properties(self, props=None): + def set_properties(self, props: Optional[Any] = None) -> Self: """ Set properties (PROPPATCH) for this object. @@ -341,7 +395,7 @@ def set_properties(self, props=None): return self - def save(self): + def save(self) -> Self: """ Save the object. This is an abstract method, that all classes derived from DAVObject implement. @@ -351,12 +405,15 @@ def save(self): """ raise NotImplementedError() - def delete(self): + def delete(self) -> None: """ Delete the object. """ if self.url is not None: - r = self.client.delete(self.url) + if self.client is None: + raise ValueError("Unexpected value None for self.client") + + r = self.client.delete(str(self.url)) # TODO: find out why we get 404 if r.status not in (200, 204, 404): @@ -368,7 +425,7 @@ def get_display_name(self): """ return self.get_property(dav.DisplayName()) - def __str__(self): + def __str__(self) -> str: try: return ( str(self.get_property(dav.DisplayName(), use_cached=True)) or self.url @@ -376,7 +433,7 @@ def __str__(self): except: return str(self.url) - def __repr__(self): + def __repr__(self) -> str: return "%s(%s)" % (self.__class__.__name__, self.url) @@ -385,7 +442,7 @@ class CalendarSet(DAVObject): A CalendarSet is a set of calendars. """ - def calendars(self): + def calendars(self) -> typing.List["Calendar"]: """ List all calendar collections in this set. @@ -408,8 +465,11 @@ def calendars(self): return cals def make_calendar( - self, name=None, cal_id=None, supported_calendar_component_set=None - ): + self, + name: Optional[str] = None, + cal_id: Optional[str] = None, + supported_calendar_component_set: Optional[Any] = None, + ) -> "Calendar": """ Utility method for creating a new calendar. @@ -432,7 +492,9 @@ def make_calendar( supported_calendar_component_set=supported_calendar_component_set, ).save() - def calendar(self, name=None, cal_id=None): + def calendar( + self, name: Optional[str] = None, cal_id: Optional[str] = None + ) -> "Calendar": """ The calendar method will return a calendar object. If it gets a cal_id but no name, it will not initiate any communication with the server @@ -456,17 +518,31 @@ def calendar(self, name=None, cal_id=None): if not cal_id and not name: return self.calendars()[0] + if self.client is None: + raise ValueError("Unexpected value None for self.client") + + if cal_id is None: + raise ValueError("Unexpected value None for cal_id") + if str(URL.objectify(cal_id).canonical()).startswith( str(self.client.url.canonical()) ): url = self.client.url.join(cal_id) - elif ( - isinstance(cal_id, URL) - or cal_id.startswith("https://") - or cal_id.startswith("http://") + elif isinstance(cal_id, URL) or ( + isinstance(cal_id, str) + and (cal_id.startswith("https://") or cal_id.startswith("http://")) ): + if self.url is None: + raise ValueError("Unexpected value None for self.url") + url = self.url.join(cal_id) else: + if self.url is None: + raise ValueError("Unexpected value None for self.url") + + if cal_id is None: + raise ValueError("Unexpected value None for cal_id") + url = self.url.join(quote(cal_id) + "/") return Calendar(self.client, name=name, parent=self, url=url, id=cal_id) @@ -487,7 +563,11 @@ class Principal(DAVObject): is not stored anywhere) """ - def __init__(self, client=None, url=None): + def __init__( + self, + client: Optional["DAVClient"] = None, + url: Union[str, ParseResult, SplitResult, URL, None] = None, + ) -> None: """ Returns a Principal. @@ -502,13 +582,23 @@ def __init__(self, client=None, url=None): self._calendar_home_set = None if url is None: + if self.client is None: + raise ValueError("Unexpected value None for self.client") + self.url = self.client.url cup = self.get_property(dav.CurrentUserPrincipal()) + + if cup is None: + raise ValueError("Unexpected value None for cup") + self.url = self.client.url.join(URL.objectify(cup)) def make_calendar( - self, name=None, cal_id=None, supported_calendar_component_set=None - ): + self, + name: Optional[str] = None, + cal_id: Optional[str] = None, + supported_calendar_component_set: Optional[Any] = None, + ) -> "Calendar": """ Convenience method, bypasses the self.calendar_home_set object. See CalendarSet.make_calendar for details. @@ -519,7 +609,12 @@ def make_calendar( supported_calendar_component_set=supported_calendar_component_set, ) - def calendar(self, name=None, cal_id=None, cal_url=None): + def calendar( + self, + name: Optional[str] = None, + cal_id: Optional[str] = None, + cal_url: Optional[str] = None, + ) -> "Calendar": """ The calendar method will return a calendar object. It will not initiate any communication with the server. @@ -527,9 +622,12 @@ def calendar(self, name=None, cal_id=None, cal_url=None): if not cal_url: return self.calendar_home_set.calendar(name, cal_id) else: + if self.client is None: + raise ValueError("Unexpected value None for self.client") + return Calendar(self.client, url=self.client.url.join(cal_url)) - def get_vcal_address(self): + def get_vcal_address(self) -> "vCalAddress": """ Returns the principal, as an icalendar.vCalAddress object """ @@ -560,7 +658,7 @@ def calendar_home_set(self): return self._calendar_home_set @calendar_home_set.setter - def calendar_home_set(self, url): + def calendar_home_set(self, url) -> None: if isinstance(url, CalendarSet): self._calendar_home_set = url return @@ -586,7 +684,7 @@ def calendar_home_set(self, url): self.client, self.client.url.join(sanitized_url) ) - def calendars(self): + def calendars(self) -> typing.List["Calendar"]: """ Return the principials calendars """ @@ -617,22 +715,28 @@ def freebusy_request(self, dtstart, dtend, attendees): ) return response.find_objects_and_props() - def calendar_user_address_set(self): + def calendar_user_address_set(self) -> typing.List[typing.Optional[str]]: """ defined in RFC6638 """ - addresses = self.get_property(cdav.CalendarUserAddressSet(), parse_props=False) - assert not [x for x in addresses if x.tag != dav.Href().tag] - addresses = list(addresses) + _addresses: typing.Optional[_Element] = self.get_property( + cdav.CalendarUserAddressSet(), parse_props=False + ) + + if _addresses is None: + raise ValueError("Unexpected value None for _addresses") + + assert not [x for x in _addresses if x.tag != dav.Href().tag] + addresses = list(_addresses) ## possibly the preferred attribute is iCloud-specific. ## TODO: do more research on that addresses.sort(key=lambda x: -int(x.get("preferred", 0))) return [x.text for x in addresses] - def schedule_inbox(self): + def schedule_inbox(self) -> "ScheduleInbox": return ScheduleInbox(principal=self) - def schedule_outbox(self): + def schedule_outbox(self) -> "ScheduleOutbox": return ScheduleOutbox(principal=self) @@ -643,7 +747,9 @@ class Calendar(DAVObject): https://tools.ietf.org/html/rfc4791#section-5.3.1 """ - def _create(self, name=None, id=None, supported_calendar_component_set=None): + def _create( + self, name=None, id=None, supported_calendar_component_set=None + ) -> None: """ Create a new calendar with display name `name` in `parent`. """ @@ -698,11 +804,14 @@ def _create(self, name=None, id=None, supported_calendar_component_set=None): ) error.assert_(False) - def get_supported_components(self): + def get_supported_components(self) -> typing.List[Any]: """ returns a list of component types supported by the calendar, in string format (typically ['VJOURNAL', 'VTODO', 'VEVENT']) """ + if self.url is None: + raise ValueError("Unexpected value None for self.url") + props = [cdav.SupportedCalendarComponentSet()] response = self.get_properties(props, parse_response_xml=False) response_list = response.find_objects_and_props() @@ -711,7 +820,7 @@ def get_supported_components(self): ] return [supported.get("name") for supported in prop] - def save_with_invites(self, ical, attendees, **attendeeoptions): + def save_with_invites(self, ical: str, attendees, **attendeeoptions) -> None: """ sends a schedule request to the server. Equivalent with save_event, save_todo, etc, but the attendees will be added to the ical object before sending it to the server. @@ -738,7 +847,13 @@ def _use_or_create_ics(self, ical, objtype, **ical_data): return ical ## TODO: consolidate save_* - too much code duplication here - def save_event(self, ical=None, no_overwrite=False, no_create=False, **ical_data): + def save_event( + self, + ical: Optional[str] = None, + no_overwrite: bool = False, + no_create: bool = False, + **ical_data, + ) -> "Event": """ Add a new event to the calendar, with the given ical. @@ -757,7 +872,13 @@ def save_event(self, ical=None, no_overwrite=False, no_create=False, **ical_data self._handle_relations(e.id, ical_data) return e - def save_todo(self, ical=None, no_overwrite=False, no_create=False, **ical_data): + def save_todo( + self, + ical: Optional[str] = None, + no_overwrite: bool = False, + no_create: bool = False, + **ical_data, + ) -> "Todo": """ Add a new task to the calendar, with the given ical. @@ -773,7 +894,13 @@ def save_todo(self, ical=None, no_overwrite=False, no_create=False, **ical_data) self._handle_relations(t.id, ical_data) return t - def save_journal(self, ical=None, no_overwrite=False, no_create=False, **ical_data): + def save_journal( + self, + ical: Optional[str] = None, + no_overwrite: bool = False, + no_create: bool = False, + **ical_data, + ) -> "Journal": """ Add a new journal entry to the calendar, with the given ical. @@ -789,7 +916,7 @@ def save_journal(self, ical=None, no_overwrite=False, no_create=False, **ical_da self._handle_relations(j.id, ical_data) return j - def _handle_relations(self, uid, ical_data): + def _handle_relations(self, uid, ical_data) -> None: for reverse_reltype, other_uid in [ ("parent", x) for x in ical_data.get("child", ()) ] + [("child", x) for x in ical_data.get("parent", ())]: @@ -819,12 +946,15 @@ def save(self): self._create(id=self.id, name=self.name, **self.extra_init_options) return self - def calendar_multiget(self, event_urls): + def calendar_multiget(self, event_urls: Iterable[URL]) -> typing.List["Event"]: """ get multiple events' data @author mtorange@gmail.com @type events list of Event """ + if self.url is None: + raise ValueError("Unexpected value None for self.url") + rv = [] prop = dav.Prop() + cdav.CalendarData() root = ( @@ -849,7 +979,11 @@ def calendar_multiget(self, event_urls): ## TODO: Upgrade the warning to an error (and perhaps critical) in future ## releases, and then finally remove this method completely. def build_date_search_query( - self, start, end=None, compfilter="VEVENT", expand="maybe" + self, + start, + end: Optional[datetime] = None, + compfilter: Optional[Literal["VEVENT"]] = "VEVENT", + expand: Union[bool, Literal["maybe"]] = "maybe", ): """ WARNING: DEPRECATED @@ -879,8 +1013,13 @@ def build_date_search_query( ) def date_search( - self, start, end=None, compfilter="VEVENT", expand="maybe", verify_expand=False - ): + self, + start: datetime, + end: Optional[datetime] = None, + compfilter: None = "VEVENT", + expand: Union[bool, Literal["maybe"]] = "maybe", + verify_expand: bool = False, + ) -> Sequence["CalendarObjectResource"]: # type (TimeStamp, TimeStamp, str, str) -> CalendarObjectResource """Deprecated. Use self.search() instead. @@ -990,14 +1129,14 @@ def _request_report_build_resultlist( def search( self, xml=None, - comp_class=None, - todo=None, - include_completed=False, - sort_keys=(), - split_expanded=True, - props=None, + comp_class: Optional[Type[_CC]] = None, + todo: Optional[bool] = None, + include_completed: bool = False, + sort_keys: Sequence[str] = (), + split_expanded: bool = True, + props: Optional[typing.List[CalendarData]] = None, **kwargs, - ): + ) -> typing.List[_CC]: """Creates an XML query, does a REPORT request towards the server and returns objects found, eventually sorting them before delivery. @@ -1333,7 +1472,7 @@ def build_search_xml_query( return (root, comp_class) - def freebusy_request(self, start, end): + def freebusy_request(self, start: datetime, end: datetime) -> "FreeBusy": """ Search the calendar, but return only the free/busy information. @@ -1351,8 +1490,11 @@ def freebusy_request(self, start, end): return FreeBusy(self, response.raw) def todos( - self, sort_keys=("due", "priority"), include_completed=False, sort_key=None - ): + self, + sort_keys: Sequence[str] = ("due", "priority"), + include_completed: bool = False, + sort_key: Optional[str] = None, + ) -> typing.List["Todo"]: """ fetches a list of todo events (refactored to a wrapper around search) @@ -1406,13 +1548,18 @@ def _calendar_comp_class_by_data(self, data): return ical2caldav[sc.__class__] return CalendarObjectResource - def event_by_url(self, href, data=None): + def event_by_url(self, href, data: Optional[Any] = None) -> "Event": """ Returns the event with the given URL """ return Event(url=href, data=data, parent=self).load() - def object_by_uid(self, uid, comp_filter=None, comp_class=None): + def object_by_uid( + self, + uid: str, + comp_filter: Optional[CompFilter] = None, + comp_class: typing.Optional["CalendarObjectResource"] = None, + ) -> "Event": """ Get one event from the calendar. @@ -1427,6 +1574,10 @@ def object_by_uid(self, uid, comp_filter=None, comp_class=None): if comp_filter: assert not comp_class if hasattr(comp_filter, "attributes"): + if comp_filter.attributes is None: + raise ValueError( + "Unexpected None value for variable comp_filter.attributes" + ) comp_filter = comp_filter.attributes["name"] if comp_filter == "VTODO": comp_class = Todo @@ -1445,7 +1596,7 @@ def object_by_uid(self, uid, comp_filter=None, comp_class=None): ) try: - items_found = self.search(root) + items_found: typing.List[Event] = self.search(root) if not items_found: raise error.NotFoundError("%s not found on server" % uid) except Exception as err: @@ -1489,19 +1640,19 @@ def object_by_uid(self, uid, comp_filter=None, comp_class=None): error.assert_(len(items_found2) == 1) return items_found2[0] - def todo_by_uid(self, uid): + def todo_by_uid(self, uid: str) -> "CalendarObjectResource": return self.object_by_uid(uid, comp_filter=cdav.CompFilter("VTODO")) - def event_by_uid(self, uid): + def event_by_uid(self, uid: str) -> "CalendarObjectResource": return self.object_by_uid(uid, comp_filter=cdav.CompFilter("VEVENT")) - def journal_by_uid(self, uid): + def journal_by_uid(self, uid: str) -> "CalendarObjectResource": return self.object_by_uid(uid, comp_filter=cdav.CompFilter("VJOURNAL")) # alias for backward compatibility event = event_by_uid - def events(self): + def events(self) -> typing.List["Event"]: """ List all events from the calendar. @@ -1510,7 +1661,9 @@ def events(self): """ return self.search(comp_class=Event) - def objects_by_sync_token(self, sync_token=None, load_objects=False): + def objects_by_sync_token( + self, sync_token: Optional[Any] = None, load_objects: bool = False + ) -> "SynchronizableCalendarObjectCollection": """objects_by_sync_token aka objects Do a sync-collection report, ref RFC 6578 and @@ -1557,7 +1710,7 @@ def objects_by_sync_token(self, sync_token=None, load_objects=False): objects = objects_by_sync_token - def journals(self): + def journals(self) -> typing.List["Journal"]: """ List all journals from the calendar. @@ -1578,7 +1731,12 @@ class ScheduleMailbox(Calendar): eventually. """ - def __init__(self, client=None, principal=None, url=None): + def __init__( + self, + client: Optional["DAVClient"] = None, + principal: Optional[Principal] = None, + url: Union[str, ParseResult, SplitResult, URL, None] = None, + ) -> None: """ Will locate the mbox if no url is given """ @@ -1587,20 +1745,36 @@ def __init__(self, client=None, principal=None, url=None): if not client and principal: self.client = principal.client if not principal and client: + if self.client is None: + raise ValueError("Unexpected value None for self.client") + principal = self.client.principal if url is not None: + if client is None: + raise ValueError("Unexpected value None for client") + self.url = client.url.join(URL.objectify(url)) else: + if principal is None: + raise ValueError("Unexpected value None for principal") + + if self.client is None: + raise ValueError("Unexpected value None for self.client") + self.url = principal.url try: - self.url = self.client.url.join(URL(self.get_property(self.findprop()))) + # we ignore the type here as this is defined in sub-classes only; require more changes to + # properly fix in a future revision + self.url = self.client.url.join(URL(self.get_property(self.findprop()))) # type: ignore except: logging.error("something bad happened", exc_info=True) error.assert_(self.client.check_scheduling_support()) self.url = None + # we ignore the type here as this is defined in sub-classes only; require more changes to + # properly fix in a future revision raise error.NotFoundError( "principal has no %s. %s" - % (str(self.findprop()), error.ERR_FRAGMENT) + % (str(self.findprop()), error.ERR_FRAGMENT) # type: ignore ) def get_items(self): @@ -1650,7 +1824,7 @@ class ScheduleOutbox(ScheduleMailbox): findprop = cdav.ScheduleOutboxURL -class SynchronizableCalendarObjectCollection(object): +class SynchronizableCalendarObjectCollection: """ This class may hold a cached snapshot of a calendar, and changes in the calendar can easily be copied over through the sync method. @@ -1659,16 +1833,16 @@ class SynchronizableCalendarObjectCollection(object): calendar.objects(load_objects=True) """ - def __init__(self, calendar, objects, sync_token): + def __init__(self, calendar, objects, sync_token) -> None: self.calendar = calendar self.sync_token = sync_token self.objects = objects self._objects_by_url = None - def __iter__(self): + def __iter__(self) -> Iterator[Any]: return self.objects.__iter__() - def __len__(self): + def __len__(self) -> int: return len(self.objects) def objects_by_url(self): @@ -1681,7 +1855,7 @@ def objects_by_url(self): self._objects_by_url[obj.url.canonical()] = obj return self._objects_by_url - def sync(self): + def sync(self) -> typing.Tuple[Any, Any]: """ This method will contact the caldav server, request all changes from it, and sync up the collection @@ -1733,8 +1907,14 @@ class CalendarObjectResource(DAVObject): _data = None def __init__( - self, client=None, url=None, data=None, parent=None, id=None, props=None - ): + self, + client: Optional["DAVClient"] = None, + url: Union[str, ParseResult, SplitResult, URL, None] = None, + data: Optional[Any] = None, + parent: Optional[Any] = None, + id: Optional[Any] = None, + props: Optional[Any] = None, + ) -> None: """ CalendarObjectResource has an additional parameter for its constructor: * data = "...", vCal data for the event @@ -1748,17 +1928,20 @@ def __init__( old_id = self.icalendar_component.pop("UID", None) self.icalendar_component.add("UID", id) - def add_organizer(self): + def add_organizer(self) -> None: """ goes via self.client, finds the principal, figures out the right attendee-format and adds an organizer line to the event """ + if self.client is None: + raise ValueError("Unexpected value None for self.client") + principal = self.client.principal() ## TODO: remove Organizer-field, if exists ## TODO: what if walk returns more than one vevent? self.icalendar_component.add("organizer", principal.get_vcal_address()) - def split_expanded(self): + def split_expanded(self) -> typing.List[Self]: i = self.icalendar_instance.subcomponents tz_ = [x for x in i if isinstance(x, icalendar.Timezone)] ntz = [x for x in i if not isinstance(x, icalendar.Timezone)] @@ -1776,7 +1959,7 @@ def split_expanded(self): ret.append(obj) return ret - def expand_rrule(self, start, end): + def expand_rrule(self, start: datetime, end: datetime) -> None: """This method will transform the calendar content of the event and expand the calendar data from a "master copy" with RRULE set and into a "recurrence set" with RECURRENCE-ID set @@ -1789,8 +1972,8 @@ def expand_rrule(self, start, end): of 2022-10 there is no test code verifying this. :param event: Event - :param start: datetime.datetime - :param end: datetime.datetime + :param start: datetime + :param end: datetime """ import recurring_ical_events @@ -1801,8 +1984,14 @@ def expand_rrule(self, start, end): recurrence_properties = ["exdate", "exrule", "rdate", "rrule"] # FIXME too much copying stripped_event = self.copy(keep_uid=True) + + if stripped_event.vobject_instance is None: + raise ValueError( + "Unexpected value None for stripped_event.vobject_instance" + ) + # remove all recurrence properties - for component in stripped_event.vobject_instance.components(): + for component in stripped_event.vobject_instance.components(): # type: ignore if component.name in ("VEVENT", "VTODO"): for key in recurrence_properties: try: @@ -1822,7 +2011,7 @@ def expand_rrule(self, start, end): def set_relation( self, other, reltype=None, set_reverse=True - ): ## TODO: logic to find and set siblings? + ) -> None: ## TODO: logic to find and set siblings? """ Sets a relation between this object and another object (given by uid or object). """ @@ -1867,8 +2056,12 @@ def set_relation( ## However, as this consolidated and eliminated quite some duplicated code in the ## plann project, it is extensively tested in plann. def get_relatives( - self, reltypes=None, relfilter=None, fetch_objects=True, ignore_missing=True - ): + self, + reltypes: Optional[Container[str]] = None, + relfilter: Optional[Callable[[Any], bool]] = None, + fetch_objects: bool = True, + ignore_missing: bool = True, + ) -> DefaultDict[str, typing.Set[str]]: """ By default, loads all objects pointed to by the RELATED-TO property and loads the related objects. @@ -1897,13 +2090,25 @@ def get_relatives( if fetch_objects: for reltype in ret: uids = ret[reltype] - ret[reltype] = [] + reltype_set = set() + + if self.parent is None: + raise ValueError("Unexpected value None for self.parent") + + if not isinstance(self.parent, Calendar): + raise ValueError( + "self.parent expected to be of type Calendar but it is not" + ) + for obj in uids: try: - ret[reltype].append(self.parent.object_by_uid(obj)) + reltype_set.add(self.parent.object_by_uid(obj)) except error.NotFoundError: if not ignore_missing: raise + + ret[reltype] = reltype_set + return ret def _get_icalendar_component(self, assert_one=False): @@ -1930,7 +2135,7 @@ def _get_icalendar_component(self, assert_one=False): return x error.assert_(False) - def _set_icalendar_component(self, value): + def _set_icalendar_component(self, value) -> None: s = self.icalendar_instance.subcomponents i = [i for i in range(0, len(s)) if not isinstance(s[i], icalendar.Timezone)] if len(i) == 1: @@ -1968,7 +2173,9 @@ def get_due(self): get_dtend = get_due - def add_attendee(self, attendee, no_default_parameters=False, **parameters): + def add_attendee( + self, attendee, no_default_parameters: bool = False, **parameters + ) -> None: """ For the current (event/todo/journal), add an attendee. @@ -2033,23 +2240,23 @@ def add_attendee(self, attendee, no_default_parameters=False, **parameters): ievent = self.icalendar_component ievent.add("attendee", attendee_obj) - def is_invite_request(self): + def is_invite_request(self) -> bool: self.load(only_if_unloaded=True) return self.icalendar_instance.get("method", None) == "REQUEST" - def accept_invite(self, calendar=None): + def accept_invite(self, calendar: Optional[Calendar] = None) -> None: self._reply_to_invite_request("ACCEPTED", calendar) - def decline_invite(self, calendar=None): + def decline_invite(self, calendar: Optional[Calendar] = None) -> None: self._reply_to_invite_request("DECLINED", calendar) - def tentatively_accept_invite(self, calendar=None): + def tentatively_accept_invite(self, calendar: Optional[Any] = None) -> None: self._reply_to_invite_request("TENTATIVE", calendar) ## TODO: DELEGATED is also a valid option, and for vtodos the ## partstat can also be set to COMPLETED and IN-PROGRESS. - def _reply_to_invite_request(self, partstat, calendar): + def _reply_to_invite_request(self, partstat, calendar) -> None: error.assert_(self.is_invite_request()) if not calendar: calendar = self.client.principal().calendars()[0] @@ -2069,12 +2276,12 @@ def _reply_to_invite_request(self, partstat, calendar): self.load() self.get_property(cdav.ScheduleTag(), use_cached=False) outbox = self.client.principal().schedule_outbox() - if calendar != outbox: + if calendar.url != outbox.url: self._reply_to_invite_request(partstat, calendar=outbox) else: self.save() - def copy(self, keep_uid=False, new_parent=None): + def copy(self, keep_uid: bool = False, new_parent: Optional[Any] = None) -> Self: """ Events, todos etc can be copied within the same calendar, to another calendar or even to another caldav server @@ -2090,13 +2297,20 @@ def copy(self, keep_uid=False, new_parent=None): obj.url = self.url return obj - def load(self, only_if_unloaded=False): + def load(self, only_if_unloaded: bool = False) -> Self: """ (Re)load the object from the caldav server. """ if only_if_unloaded and self.is_loaded(): - return - r = self.client.request(self.url) + return self + + if self.url is None: + raise ValueError("Unexpected value None for self.url") + + if self.client is None: + raise ValueError("Unexpected value None for self.client") + + r = self.client.request(str(self.url)) if r.status == 404: raise error.NotFoundError(errmsg(r)) self.data = vcal.fix(r.raw) @@ -2107,7 +2321,7 @@ def load(self, only_if_unloaded=False): return self ## TODO: self.id should either always be available or never - def _find_id_path(self, id=None, path=None): + def _find_id_path(self, id=None, path=None) -> None: """ With CalDAV, every object has a URL. With icalendar, every object should have a UID. This UID may or may not be copied into self.id. @@ -2168,7 +2382,7 @@ def _put(self, retry_on_failure=True): else: raise error.PutError(errmsg(r)) - def _create(self, id=None, path=None, retry_on_failure=True): + def _create(self, id=None, path=None, retry_on_failure=True) -> None: ## We're efficiently running the icalendar code through the icalendar ## library. This may cause data modifications and may "unfix" ## https://github.com/python-caldav/caldav/issues/43 @@ -2183,8 +2397,11 @@ def generate_url(self): self.id = self._get_icalendar_component(assert_one=False)["UID"] return self.parent.url.join(quote(self.id.replace("/", "%2F")) + ".ics") - def change_attendee_status(self, attendee=None, **kwargs): + def change_attendee_status(self, attendee: Optional[Any] = None, **kwargs) -> None: if not attendee: + if self.client is None: + raise ValueError("Unexpected value None for self.client") + attendee = self.client.principal() cnt = 0 @@ -2219,12 +2436,12 @@ def change_attendee_status(self, attendee=None, **kwargs): def save( self, - no_overwrite=False, - no_create=False, - obj_type=None, - increase_seqno=True, - if_schedule_tag_match=False, - ): + no_overwrite: bool = False, + no_create: bool = False, + obj_type: Optional[str] = None, + increase_seqno: bool = True, + if_schedule_tag_match: bool = False, + ) -> Self: """ Save the object, can be used for creation and update. @@ -2309,7 +2526,7 @@ def is_loaded(self): self._data or self._vobject_instance or self._icalendar_instance ) and self.data.count("BEGIN:") > 1 - def __str__(self): + def __str__(self) -> str: return "%s: %s" % (self.__class__.__name__, self.url) ## implementation of the properties self.data, @@ -2353,7 +2570,7 @@ def _get_wire_data(self): return to_wire(self._icalendar_instance.to_ical()) return None - data = property( + data: Any = property( _get_data, _set_data, doc="vCal representation of the object as normal string" ) wire_data = property( @@ -2362,19 +2579,19 @@ def _get_wire_data(self): doc="vCal representation of the object in wire format (UTF-8, CRLN)", ) - def _set_vobject_instance(self, inst): + def _set_vobject_instance(self, inst: vobject.base.Component): self._vobject_instance = inst self._data = None self._icalendar_instance = None return self - def _get_vobject_instance(self): + def _get_vobject_instance(self) -> typing.Optional[vobject.base.Component]: if not self._vobject_instance: if self._get_data() is None: return None try: self._set_vobject_instance( - vobject.readOne(to_unicode(self._get_data())) + vobject.readOne(to_unicode(self._get_data())) # type: ignore ) except: log.critical( @@ -2384,7 +2601,7 @@ def _get_vobject_instance(self): raise return self._vobject_instance - vobject_instance = property( + vobject_instance: VBase = property( _get_vobject_instance, _set_vobject_instance, doc="vobject instance of the object", @@ -2405,13 +2622,13 @@ def _get_icalendar_instance(self): ) return self._icalendar_instance - icalendar_instance = property( + icalendar_instance: Any = property( _get_icalendar_instance, _set_icalendar_instance, doc="icalendar instance of the object", ) - def get_duration(self): + def get_duration(self) -> timedelta: """According to the RFC, either DURATION or DUE should be set for a task, but never both - implicitly meaning that DURATION is the difference between DTSTART and DUE (personally I @@ -2448,7 +2665,7 @@ def _get_duration(self, i): ## for backward-compatibility - may be changed to ## icalendar_instance in version 1.0 - instance = vobject_instance + instance: VBase = vobject_instance class Event(CalendarObjectResource): @@ -2487,7 +2704,13 @@ class FreeBusy(CalendarObjectResource): Update: With RFC6638 a freebusy object can have a URL and an ID. """ - def __init__(self, parent, data, url=None, id=None): + def __init__( + self, + parent, + data, + url: Union[str, ParseResult, SplitResult, URL, None] = None, + id: Optional[Any] = None, + ) -> None: CalendarObjectResource.__init__( self, client=parent.client, url=url, data=data, parent=parent, id=id ) @@ -2607,7 +2830,7 @@ def _next(self, ts=None, i=None, dtstart=None, rrule=None, by=None, no_count=Tru rrule = rrulestr(rrule.to_ical().decode("utf-8"), dtstart=dtstart) return rrule.after(ts) - def _reduce_count(self, i=None): + def _reduce_count(self, i=None) -> bool: if not i: i = self.icalendar_component if "COUNT" in i["RRULE"]: @@ -2644,7 +2867,7 @@ def _complete_recurring_safe(self, completion_timestamp): self.save() - def _complete_recurring_thisandfuture(self, completion_timestamp): + def _complete_recurring_thisandfuture(self, completion_timestamp) -> None: """The RFC is not much helpful, a lot of guesswork is needed to consider what the "right thing" to do wrg of a completion of recurring tasks is ... but this is my shot at it. @@ -2735,8 +2958,11 @@ def _complete_recurring_thisandfuture(self, completion_timestamp): self.save(increase_seqno=False) def complete( - self, completion_timestamp=None, handle_rrule=False, rrule_mode="safe" - ): + self, + completion_timestamp: Optional[datetime] = None, + handle_rrule: bool = False, + rrule_mode: Literal["safe", "this_and_future"] = "safe", + ) -> None: """Marks the task as completed. Parameters: @@ -2761,7 +2987,7 @@ def complete( self._complete_ical(completion_timestamp=completion_timestamp) self.save() - def _complete_ical(self, i=None, completion_timestamp=None): + def _complete_ical(self, i=None, completion_timestamp=None) -> None: ## my idea was to let self.complete call this one ... but self.complete ## should use vobject and not icalendar library due to backward compatibility. if i is None: @@ -2771,7 +2997,7 @@ def _complete_ical(self, i=None, completion_timestamp=None): i.add("STATUS", "COMPLETED") i.add("COMPLETED", completion_timestamp) - def _is_pending(self, i=None): + def _is_pending(self, i=None) -> Optional[bool]: if i is None: i = self.icalendar_component if i.get("COMPLETED", None) is not None: @@ -2785,7 +3011,7 @@ def _is_pending(self, i=None): ## input data does not conform to the RFC assert False - def uncomplete(self): + def uncomplete(self) -> None: """Undo completion - marks a completed task as not completed""" ### TODO: needs test code for code coverage! ## (it has been tested through the calendar-cli test code) @@ -2810,7 +3036,7 @@ def set_duration(self, duration, movable_attr="DTSTART"): i = self.icalendar_component return self._set_duration(i, duration, movable_attr) - def _set_duration(self, i, duration, movable_attr="DTSTART"): + def _set_duration(self, i, duration, movable_attr="DTSTART") -> None: if ("DUE" in i or "DURATION" in i) and "DTSTART" in i: i.pop(movable_attr, None) if movable_attr == "DUE": diff --git a/caldav/py.typed b/caldav/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/caldav/requests.py b/caldav/requests.py index 53bce2a4..0d0c330c 100644 --- a/caldav/requests.py +++ b/caldav/requests.py @@ -2,13 +2,13 @@ class HTTPBearerAuth(AuthBase): - def __init__(self, password): + def __init__(self, password: str) -> None: self.password = password - def __eq__(self, other): + def __eq__(self, other: object) -> bool: return self.password == getattr(other, "password", None) - def __ne__(self, other): + def __ne__(self, other: object) -> bool: return not self == other def __call__(self, r): diff --git a/setup.py b/setup.py index 4fe85187..ad69e9e6 100755 --- a/setup.py +++ b/setup.py @@ -89,6 +89,7 @@ "requests", "icalendar", "recurring-ical-events>=2.0.0", + "typing_extensions", ] + extra_packages, extras_require={ diff --git a/tests/test_caldav.py b/tests/test_caldav.py index 14892379..05eb7f09 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -1542,11 +1542,11 @@ def testCreateChildParent(self): foo = parent_.get_relatives(reltypes={"PARENT"}) assert len(foo) == 1 assert len(foo["PARENT"]) == 1 - assert [foo["PARENT"][0].icalendar_component["UID"] == grandparent.id] + assert [list(foo["PARENT"])[0].icalendar_component["UID"] == grandparent.id] foo = parent_.get_relatives(reltypes={"CHILD"}) assert len(foo) == 1 assert len(foo["CHILD"]) == 1 - assert [foo["CHILD"][0].icalendar_component["UID"] == child.id] + assert [list(foo["CHILD"])[0].icalendar_component["UID"] == child.id] foo = parent_.get_relatives(reltypes={"CHILD", "PARENT"}) assert len(foo) == 2 assert len(foo["CHILD"]) == 1