fix: 포트 충돌 회피 — note_bridge 8098, intent_service 8099
Jellyfin(8096), OrbStack(8097) 포트 충돌으로 변경. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
33
.venv/lib/python3.9/site-packages/caldav/__init__.py
Normal file
33
.venv/lib/python3.9/site-packages/caldav/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python
|
||||
import logging
|
||||
|
||||
try:
|
||||
from ._version import __version__
|
||||
except ModuleNotFoundError:
|
||||
__version__ = "(unknown)"
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"You need to install the `build` package and do a `python -m build` to get caldav.__version__ set correctly"
|
||||
)
|
||||
from .davclient import DAVClient
|
||||
from .davclient import get_davclient
|
||||
from .search import CalDAVSearcher
|
||||
|
||||
## TODO: this should go away in some future version of the library.
|
||||
from .objects import *
|
||||
|
||||
## We should consider if the NullHandler-logic below is needed or not, and
|
||||
## if there are better alternatives?
|
||||
# Silence notification of no default logging handler
|
||||
log = logging.getLogger("caldav")
|
||||
|
||||
|
||||
class NullHandler(logging.Handler):
|
||||
def emit(self, record) -> None:
|
||||
pass
|
||||
|
||||
|
||||
log.addHandler(NullHandler())
|
||||
|
||||
__all__ = ["__version__", "DAVClient", "get_davclient"]
|
||||
34
.venv/lib/python3.9/site-packages/caldav/_version.py
Normal file
34
.venv/lib/python3.9/site-packages/caldav/_version.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# file generated by setuptools-scm
|
||||
# don't change, don't track in version control
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"__version_tuple__",
|
||||
"version",
|
||||
"version_tuple",
|
||||
"__commit_id__",
|
||||
"commit_id",
|
||||
]
|
||||
|
||||
TYPE_CHECKING = False
|
||||
if TYPE_CHECKING:
|
||||
from typing import Tuple
|
||||
from typing import Union
|
||||
|
||||
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
||||
COMMIT_ID = Union[str, None]
|
||||
else:
|
||||
VERSION_TUPLE = object
|
||||
COMMIT_ID = object
|
||||
|
||||
version: str
|
||||
__version__: str
|
||||
__version_tuple__: VERSION_TUPLE
|
||||
version_tuple: VERSION_TUPLE
|
||||
commit_id: COMMIT_ID
|
||||
__commit_id__: COMMIT_ID
|
||||
|
||||
__version__ = version = '2.2.6'
|
||||
__version_tuple__ = version_tuple = (2, 2, 6)
|
||||
|
||||
__commit_id__ = commit_id = None
|
||||
1664
.venv/lib/python3.9/site-packages/caldav/calendarobjectresource.py
Normal file
1664
.venv/lib/python3.9/site-packages/caldav/calendarobjectresource.py
Normal file
File diff suppressed because it is too large
Load Diff
1549
.venv/lib/python3.9/site-packages/caldav/collection.py
Normal file
1549
.venv/lib/python3.9/site-packages/caldav/collection.py
Normal file
File diff suppressed because it is too large
Load Diff
1175
.venv/lib/python3.9/site-packages/caldav/compatibility_hints.py
Normal file
1175
.venv/lib/python3.9/site-packages/caldav/compatibility_hints.py
Normal file
File diff suppressed because it is too large
Load Diff
125
.venv/lib/python3.9/site-packages/caldav/config.py
Normal file
125
.venv/lib/python3.9/site-packages/caldav/config.py
Normal file
@@ -0,0 +1,125 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
"""
|
||||
This configuration parsing code was just copied from my plann library (and will be removed from there at some point in the future). Test coverage is poor as for now.
|
||||
"""
|
||||
|
||||
## This is being moved from my plann library. The code itself will be introduced into caldav 2.0, but proper test code and documentation will come in a later release (2.1?)
|
||||
|
||||
|
||||
## TODO TODO TODO - write test code for all the corner cases
|
||||
## TODO TODO TODO - write documentation of config format
|
||||
def expand_config_section(config, section="default", blacklist=None):
|
||||
"""
|
||||
In the "normal" case, will return [ section ]
|
||||
|
||||
We allow:
|
||||
|
||||
* * includes all sections in config file
|
||||
* "Meta"-sections in the config file with the keyword "contains" followed by a list of section names
|
||||
* Recursive "meta"-sections
|
||||
* Glob patterns (work_* for all sections starting with work_)
|
||||
* Glob patterns in "meta"-sections
|
||||
"""
|
||||
## Optimizating for a special case. The results should be the same without this optimization.
|
||||
if section == "*":
|
||||
return [x for x in config if not config[x].get("disable", False)]
|
||||
|
||||
## If it's not a glob-pattern ...
|
||||
if set(section).isdisjoint(set("[*?")):
|
||||
## If it's referring to a "meta section" with the "contains" keyword
|
||||
if "contains" in config[section]:
|
||||
results = []
|
||||
if not blacklist:
|
||||
blacklist = set()
|
||||
blacklist.add(section)
|
||||
for subsection in config[section]["contains"]:
|
||||
if not subsection in results and not subsection in blacklist:
|
||||
for recursivesubsection in expand_config_section(
|
||||
config, subsection, blacklist
|
||||
):
|
||||
if not recursivesubsection in results:
|
||||
results.append(recursivesubsection)
|
||||
return results
|
||||
else:
|
||||
## Disabled sections should be ignored
|
||||
if config.get("section", {}).get("disable", False):
|
||||
return []
|
||||
|
||||
## NORMAL CASE - return [ section ]
|
||||
return [section]
|
||||
## section name is a glob pattern
|
||||
matching_sections = [x for x in config if fnmatch(x, section)]
|
||||
results = set()
|
||||
for s in matching_sections:
|
||||
if set(s).isdisjoint(set("[*?")):
|
||||
results.update(expand_config_section(config, s))
|
||||
else:
|
||||
## Section names shouldn't contain []?* ... but in case they do ... don't recurse
|
||||
results.add(s)
|
||||
return results
|
||||
|
||||
|
||||
def config_section(config, section="default"):
|
||||
if section in config and "inherits" in config[section]:
|
||||
ret = config_section(config, config[section]["inherits"])
|
||||
else:
|
||||
ret = {}
|
||||
if section in config:
|
||||
ret.update(config[section])
|
||||
return ret
|
||||
|
||||
|
||||
def read_config(fn, interactive_error=False):
|
||||
if not fn:
|
||||
cfgdir = f"{os.environ.get('HOME', '/')}/.config/"
|
||||
for config_file in (
|
||||
f"{cfgdir}/caldav/calendar.conf",
|
||||
f"{cfgdir}/caldav/calendar.yaml",
|
||||
f"{cfgdir}/caldav/calendar.json",
|
||||
f"{cfgdir}/calendar.conf",
|
||||
"/etc/calendar.conf",
|
||||
"/etc/caldav/calendar.conf",
|
||||
):
|
||||
cfg = read_config(config_file)
|
||||
if cfg:
|
||||
return cfg
|
||||
return None
|
||||
|
||||
## This can probably be refactored into fewer lines ...
|
||||
try:
|
||||
try:
|
||||
with open(fn, "rb") as config_file:
|
||||
return json.load(config_file)
|
||||
except json.decoder.JSONDecodeError:
|
||||
## Late import, wrapped in try/except. yaml is external module,
|
||||
## and not included in the requirements as for now.
|
||||
try:
|
||||
import yaml
|
||||
|
||||
try:
|
||||
with open(fn, "rb") as config_file:
|
||||
return yaml.load(config_file, yaml.Loader)
|
||||
except yaml.scanner.ScannerError:
|
||||
logging.error(
|
||||
f"config file {fn} exists but is neither valid json nor yaml. Check the syntax."
|
||||
)
|
||||
except ImportError:
|
||||
logging.error(
|
||||
f"config file {fn} exists but is not valid json, and pyyaml is not installed."
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
## File not found
|
||||
logging.info("no config file found")
|
||||
except ValueError:
|
||||
if interactive_error:
|
||||
logging.error(
|
||||
"error in config file. Be aware that the interactive configuration will ignore and overwrite the current broken config file",
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
logging.error("error in config file. It will be ignored", exc_info=True)
|
||||
return {}
|
||||
1311
.venv/lib/python3.9/site-packages/caldav/davclient.py
Normal file
1311
.venv/lib/python3.9/site-packages/caldav/davclient.py
Normal file
File diff suppressed because it is too large
Load Diff
430
.venv/lib/python3.9/site-packages/caldav/davobject.py
Normal file
430
.venv/lib/python3.9/site-packages/caldav/davobject.py
Normal file
@@ -0,0 +1,430 @@
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
from typing import TYPE_CHECKING
|
||||
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
|
||||
|
||||
from lxml import etree
|
||||
|
||||
try:
|
||||
from typing import ClassVar, Optional, Union, Type
|
||||
|
||||
TimeStamp = Optional[Union[date, datetime]]
|
||||
except:
|
||||
pass
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from icalendar import vCalAddress
|
||||
|
||||
from .davclient import DAVClient
|
||||
|
||||
if sys.version_info < (3, 9):
|
||||
from typing import Callable, Container, Iterable, Iterator, Sequence
|
||||
|
||||
from typing_extensions import DefaultDict, Literal
|
||||
else:
|
||||
from collections import defaultdict as DefaultDict
|
||||
from collections.abc import Callable, Container, Iterable, Iterator, Sequence
|
||||
from typing import Literal
|
||||
|
||||
if sys.version_info < (3, 11):
|
||||
from typing_extensions import Self
|
||||
else:
|
||||
from typing import Self
|
||||
|
||||
from .elements import cdav, dav
|
||||
from .elements.base import BaseElement
|
||||
from .lib import error
|
||||
from .lib.error import errmsg
|
||||
from .lib.python_utilities import to_wire
|
||||
from .lib.url import URL
|
||||
|
||||
_CC = TypeVar("_CC", bound="CalendarObjectResource")
|
||||
log = logging.getLogger("caldav")
|
||||
|
||||
|
||||
"""
|
||||
This file contains one class, the DAVObject which is the base
|
||||
class for Calendar, Principal, CalendarObjectResource (Event) and many
|
||||
others. There is some code here for handling some of the DAV-related
|
||||
communication, and the class lists some common methods that are shared
|
||||
on all kind of objects. Library users should not need to know a lot
|
||||
about the DAVObject class, should never need to initialize one, but
|
||||
may encounter inheritated methods coming from this class.
|
||||
"""
|
||||
|
||||
|
||||
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: Optional[str] = None
|
||||
url: Optional[URL] = None
|
||||
client: Optional["DAVClient"] = None
|
||||
parent: Optional["DAVObject"] = None
|
||||
name: Optional[str] = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
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.
|
||||
|
||||
Args:
|
||||
client: A DAVClient instance
|
||||
url: The url for this object. May be a full URL or a relative URL.
|
||||
parent: The parent object - used when creating objects
|
||||
name: A displayname - to be removed at some point, see https://github.com/python-caldav/caldav/issues/128 for details
|
||||
props: a dict with known properties for this object
|
||||
id: The resource id (UID for an Event)
|
||||
"""
|
||||
|
||||
if client is None and parent is not None:
|
||||
client = parent.client
|
||||
self.client = client
|
||||
self.parent = parent
|
||||
self.name = name
|
||||
self.id = id
|
||||
self.props = props or {}
|
||||
self.extra_init_options = extra
|
||||
# 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) -> str:
|
||||
if self.url is None:
|
||||
raise ValueError("Unexpected value None for self.url")
|
||||
return str(self.url.canonical())
|
||||
|
||||
def children(self, type: Optional[str] = None) -> List[Tuple[URL, Any, Any]]:
|
||||
"""List children, using a propfind (resourcetype) on the parent object,
|
||||
at depth = 1.
|
||||
|
||||
TODO: This is old code, it's querying for DisplayName and
|
||||
ResourceTypes prop and returning a tuple of those. Those two
|
||||
are relatively arbitrary. I think it's mostly only calendars
|
||||
having DisplayName, but it may make sense to ask for the
|
||||
children of a calendar also as an alternative way to get all
|
||||
events? It should be redone into a more generic method, and
|
||||
it should probably return a dict rather than a tuple. We
|
||||
should also look over to see if there is any code duplication.
|
||||
"""
|
||||
## Late import to avoid circular imports
|
||||
from .collection import CalendarSet
|
||||
|
||||
c = []
|
||||
|
||||
depth = 1
|
||||
|
||||
if self.url is None:
|
||||
raise ValueError("Unexpected value None for self.url")
|
||||
|
||||
props = [dav.DisplayName()]
|
||||
multiprops = [dav.ResourceType()]
|
||||
props_multiprops = props + multiprops
|
||||
response = self._query_properties(props_multiprops, depth)
|
||||
properties = response.expand_simple_props(
|
||||
props=props, multi_value_props=multiprops
|
||||
)
|
||||
|
||||
for path in properties:
|
||||
resource_types = properties[path][dav.ResourceType.tag]
|
||||
resource_name = properties[path][dav.DisplayName.tag]
|
||||
|
||||
if type is None or type in resource_types:
|
||||
url = URL(path)
|
||||
if url.hostname is None:
|
||||
# Quote when path is not a full URL
|
||||
path = quote(path)
|
||||
# TODO: investigate the RFCs thoroughly - why does a "get
|
||||
# members of this collection"-request also return the
|
||||
# collection URL itself?
|
||||
# And why is the strip_trailing_slash-method needed?
|
||||
# The collection URL should always end with a slash according
|
||||
# to RFC 2518, section 5.2.
|
||||
if (isinstance(self, CalendarSet) and type == cdav.Calendar.tag) or (
|
||||
self.url.canonical().strip_trailing_slash()
|
||||
!= self.url.join(path).canonical().strip_trailing_slash()
|
||||
):
|
||||
c.append((self.url.join(path), resource_types, resource_name))
|
||||
|
||||
## TODO: return objects rather than just URLs, and include
|
||||
## the properties we've already fetched
|
||||
return c
|
||||
|
||||
def _query_properties(
|
||||
self, props: Optional[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
|
||||
similar-looking code into a common method.
|
||||
"""
|
||||
root = None
|
||||
# build the propfind request
|
||||
if props is not None and len(props) > 0:
|
||||
prop = dav.Prop() + props
|
||||
root = dav.Propfind() + prop
|
||||
|
||||
return self._query(root, depth)
|
||||
|
||||
def _query(
|
||||
self,
|
||||
root=None,
|
||||
depth=0,
|
||||
query_method="propfind",
|
||||
url=None,
|
||||
expected_return_value=None,
|
||||
):
|
||||
"""
|
||||
This is an internal method for doing a query. It's a
|
||||
result of code-refactoring work, attempting to consolidate
|
||||
similar-looking code into a common method.
|
||||
"""
|
||||
body = ""
|
||||
if root:
|
||||
if hasattr(root, "xmlelement"):
|
||||
body = etree.tostring(
|
||||
root.xmlelement(),
|
||||
encoding="utf-8",
|
||||
xml_declaration=True,
|
||||
pretty_print=error.debug_dump_communication,
|
||||
)
|
||||
else:
|
||||
body = root
|
||||
if url is None:
|
||||
url = self.url
|
||||
ret = getattr(self.client, query_method)(url, body, depth)
|
||||
if ret.status == 404:
|
||||
raise error.NotFoundError(errmsg(ret))
|
||||
if (
|
||||
expected_return_value is not None and ret.status != expected_return_value
|
||||
) or ret.status >= 400:
|
||||
## COMPATIBILITY HACK - see https://github.com/python-caldav/caldav/issues/309
|
||||
## TODO: server quirks!
|
||||
body = to_wire(body)
|
||||
if (
|
||||
ret.status == 500
|
||||
and b"D:getetag" not in body
|
||||
and b"<C:calendar-data" in body
|
||||
):
|
||||
body = body.replace(
|
||||
b"<C:calendar-data", b"<D:getetag/><C:calendar-data"
|
||||
)
|
||||
return self._query(
|
||||
body, depth, query_method, url, expected_return_value
|
||||
)
|
||||
raise error.exception_by_method[query_method](errmsg(ret))
|
||||
return ret
|
||||
|
||||
def get_property(
|
||||
self, prop: BaseElement, use_cached: bool = False, **passthrough
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Wrapper for the :class:`get_properties`, when only one property is wanted
|
||||
|
||||
Args:
|
||||
|
||||
prop: the property to search for
|
||||
use_cached: don't send anything to the server if we've asked before
|
||||
|
||||
Other parameters are sent directly to the :class:`get_properties` method
|
||||
"""
|
||||
## TODO: use_cached should probably be true
|
||||
if use_cached:
|
||||
if prop.tag in self.props:
|
||||
return self.props[prop.tag]
|
||||
foo = self.get_properties([prop], **passthrough)
|
||||
return foo.get(prop.tag, None)
|
||||
|
||||
def get_properties(
|
||||
self,
|
||||
props: Optional[Sequence[BaseElement]] = None,
|
||||
depth: int = 0,
|
||||
parse_response_xml: bool = True,
|
||||
parse_props: bool = True,
|
||||
):
|
||||
"""Get properties (PROPFIND) for this object.
|
||||
|
||||
With parse_response_xml and parse_props set to True a
|
||||
best-attempt will be done on decoding the XML we get from the
|
||||
server - but this works only for properties that don't have
|
||||
complex types. With parse_response_xml set to False, a
|
||||
DAVResponse object will be returned, and it's up to the caller
|
||||
to decode. With parse_props set to false but
|
||||
parse_response_xml set to true, xml elements will be returned
|
||||
rather than values.
|
||||
|
||||
Args:
|
||||
props: ``[dav.ResourceType(), dav.DisplayName(), ...]``
|
||||
|
||||
Returns:
|
||||
``{proptag: value, ...}``
|
||||
|
||||
"""
|
||||
from .collection import Principal ## late import to avoid cyclic dependencies
|
||||
|
||||
rc = None
|
||||
response = self._query_properties(props, depth)
|
||||
if not parse_response_xml:
|
||||
return response
|
||||
|
||||
if not parse_props:
|
||||
properties = response.find_objects_and_props()
|
||||
else:
|
||||
properties = response.expand_simple_props(props)
|
||||
|
||||
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]
|
||||
else:
|
||||
exchange_path = path + "/"
|
||||
|
||||
if path in properties:
|
||||
rc = properties[path]
|
||||
elif exchange_path in properties:
|
||||
if not isinstance(self, Principal):
|
||||
## Some caldav servers reports the URL for the current
|
||||
## principal to end with / when doing a propfind for
|
||||
## current-user-principal - I believe that's a bug,
|
||||
## the principal is not a collection and should not
|
||||
## end with /. (example in rfc5397 does not end with /).
|
||||
## ... but it gets worse ... when doing a propfind on the
|
||||
## principal, the href returned may be without the slash.
|
||||
## Such inconsistency is clearly a bug.
|
||||
log.warning(
|
||||
"potential path handling problem with ending slashes. Path given: %s, path found: %s. %s"
|
||||
% (path, exchange_path, error.ERR_FRAGMENT)
|
||||
)
|
||||
error.assert_(False)
|
||||
rc = properties[exchange_path]
|
||||
elif self.url in properties:
|
||||
rc = properties[self.url]
|
||||
elif "/principal/" in properties and path.endswith("/principal/"):
|
||||
## Workaround for a known iCloud bug.
|
||||
## The properties key is expected to be the same as the path.
|
||||
## path is on the format /123456/principal/ but properties key is /principal/
|
||||
## tests apparently passed post bc589093a34f0ed0ef489ad5e9cba048750c9837 and 3ee4e42e2fa8f78b71e5ffd1ef322e4007df7a60, even without this workaround
|
||||
## TODO: should probably be investigated more.
|
||||
## (observed also by others, ref https://github.com/python-caldav/caldav/issues/168)
|
||||
rc = properties["/principal/"]
|
||||
elif "//" in path and path.replace("//", "/") in properties:
|
||||
## ref https://github.com/python-caldav/caldav/issues/302
|
||||
## though, it would be nice to find the root cause,
|
||||
## self.url should not contain double slashes in the first place
|
||||
rc = properties[path.replace("//", "/")]
|
||||
elif len(properties) == 1:
|
||||
## Ref https://github.com/python-caldav/caldav/issues/191 ...
|
||||
## let's be pragmatic and just accept whatever the server is
|
||||
## throwing at us. But we'll log an error anyway.
|
||||
log.warning(
|
||||
"Possibly the server has a path handling problem, possibly the URL configured is wrong.\n"
|
||||
"Path expected: %s, path found: %s %s.\n"
|
||||
"Continuing, probably everything will be fine"
|
||||
% (path, str(list(properties)), error.ERR_FRAGMENT)
|
||||
)
|
||||
rc = list(properties.values())[0]
|
||||
else:
|
||||
log.warning(
|
||||
"Possibly the server has a path handling problem. Path expected: %s, paths found: %s %s"
|
||||
% (path, str(list(properties)), error.ERR_FRAGMENT)
|
||||
)
|
||||
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: Optional[Any] = None) -> Self:
|
||||
"""
|
||||
Set properties (PROPPATCH) for this object.
|
||||
|
||||
* props = [dav.DisplayName('name'), ...]
|
||||
|
||||
Returns:
|
||||
* self
|
||||
"""
|
||||
props = [] if props is None else props
|
||||
prop = dav.Prop() + props
|
||||
set = dav.Set() + prop
|
||||
root = dav.PropertyUpdate() + set
|
||||
|
||||
r = self._query(root, query_method="proppatch")
|
||||
|
||||
statuses = r.tree.findall(".//" + dav.Status.tag)
|
||||
for s in statuses:
|
||||
if " 200 " not in s.text:
|
||||
raise error.PropsetError(s.text)
|
||||
|
||||
return self
|
||||
|
||||
def save(self) -> Self:
|
||||
"""
|
||||
Save the object. This is an abstract method, that all classes
|
||||
derived from DAVObject implement.
|
||||
|
||||
Returns:
|
||||
* self
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def delete(self) -> None:
|
||||
"""
|
||||
Delete the object.
|
||||
"""
|
||||
if self.url is not None:
|
||||
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):
|
||||
raise error.DeleteError(errmsg(r))
|
||||
|
||||
def get_display_name(self):
|
||||
"""
|
||||
Get display name (calendar, principal, ...more?)
|
||||
"""
|
||||
return self.get_property(dav.DisplayName(), use_cached=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
try:
|
||||
return (
|
||||
str(self.get_property(dav.DisplayName(), use_cached=True)) or self.url
|
||||
)
|
||||
except:
|
||||
return str(self.url)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "%s(%s)" % (self.__class__.__name__, self.url)
|
||||
561
.venv/lib/python3.9/site-packages/caldav/discovery.py
Normal file
561
.venv/lib/python3.9/site-packages/caldav/discovery.py
Normal file
@@ -0,0 +1,561 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
RFC 6764 - Locating Services for Calendaring and Contacts (CalDAV/CardDAV)
|
||||
|
||||
This module implements DNS-based service discovery for CalDAV and CardDAV
|
||||
servers as specified in RFC 6764. It allows clients to discover service
|
||||
endpoints from just a domain name or email address.
|
||||
|
||||
Discovery methods (in order of preference):
|
||||
1. DNS SRV records (_caldavs._tcp / _carddavs._tcp for TLS)
|
||||
2. DNS TXT records (for path information)
|
||||
3. Well-Known URIs (/.well-known/caldav or /.well-known/carddav)
|
||||
|
||||
SECURITY CONSIDERATIONS:
|
||||
DNS-based discovery is vulnerable to attacks if DNS is not secured with DNSSEC:
|
||||
|
||||
- DNS Spoofing: Attackers can provide malicious SRV/TXT records pointing to
|
||||
attacker-controlled servers
|
||||
- Downgrade Attacks: Malicious DNS can specify non-TLS services, causing
|
||||
credentials to be sent in plaintext
|
||||
- Man-in-the-Middle: Even with HTTPS, attackers can redirect to their servers
|
||||
|
||||
MITIGATIONS:
|
||||
- require_tls=True (DEFAULT): Only accept HTTPS connections, preventing
|
||||
downgrade attacks
|
||||
- ssl_verify_cert=True (DEFAULT): Verify TLS certificates per RFC 6125
|
||||
- Domain validation (RFC 6764 Section 8): Discovered hostnames must be
|
||||
in the same domain as the queried domain to prevent redirection attacks
|
||||
- Use DNSSEC when possible for DNS integrity
|
||||
- Manually verify discovered endpoints for sensitive applications
|
||||
- Consider certificate pinning for known domains
|
||||
|
||||
For high-security environments, manual configuration may be preferable to
|
||||
automatic discovery.
|
||||
|
||||
See: https://datatracker.ietf.org/doc/html/rfc6764
|
||||
"""
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
from urllib.parse import urljoin
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import dns.exception
|
||||
import dns.resolver
|
||||
|
||||
try:
|
||||
import niquests as requests
|
||||
except ImportError:
|
||||
import requests
|
||||
|
||||
from caldav.lib.error import DAVError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DiscoveryError(DAVError):
|
||||
"""Raised when service discovery fails"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceInfo:
|
||||
"""Information about a discovered CalDAV/CardDAV service"""
|
||||
|
||||
url: str
|
||||
hostname: str
|
||||
port: int
|
||||
path: str
|
||||
tls: bool
|
||||
priority: int = 0
|
||||
weight: int = 0
|
||||
source: str = "unknown" # 'srv', 'txt', 'well-known', 'manual'
|
||||
username: Optional[str] = None # Extracted from email address if provided
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"ServiceInfo(url={self.url}, source={self.source}, priority={self.priority}, username={self.username})"
|
||||
|
||||
|
||||
def _is_subdomain_or_same(discovered_domain: str, original_domain: str) -> bool:
|
||||
"""
|
||||
Check if discovered domain is the same as or a subdomain of the original domain.
|
||||
|
||||
This prevents DNS hijacking attacks where malicious DNS records redirect
|
||||
to completely different domains (e.g., acme.com -> evil.hackers.are.us).
|
||||
|
||||
Args:
|
||||
discovered_domain: The hostname discovered via DNS SRV/TXT or well-known URI
|
||||
original_domain: The domain from the user's identifier
|
||||
|
||||
Returns:
|
||||
True if discovered_domain is safe (same domain or subdomain), False otherwise
|
||||
|
||||
Examples:
|
||||
>>> _is_subdomain_or_same('calendar.example.com', 'example.com')
|
||||
True
|
||||
>>> _is_subdomain_or_same('example.com', 'example.com')
|
||||
True
|
||||
>>> _is_subdomain_or_same('evil.com', 'example.com')
|
||||
False
|
||||
>>> _is_subdomain_or_same('subdomain.calendar.example.com', 'example.com')
|
||||
True
|
||||
>>> _is_subdomain_or_same('exampleXcom.evil.com', 'example.com')
|
||||
False
|
||||
"""
|
||||
# Normalize to lowercase for comparison
|
||||
discovered = discovered_domain.lower().strip(".")
|
||||
original = original_domain.lower().strip(".")
|
||||
|
||||
# Same domain is always allowed
|
||||
if discovered == original:
|
||||
return True
|
||||
|
||||
# Check if discovered is a subdomain of original
|
||||
# Must end with .original_domain to be a valid subdomain
|
||||
if discovered.endswith("." + original):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _extract_domain(identifier: str) -> Tuple[str, Optional[str]]:
|
||||
"""
|
||||
Extract domain and optional username from an email address or URL.
|
||||
|
||||
Args:
|
||||
identifier: Email address (user@example.com) or domain (example.com)
|
||||
|
||||
Returns:
|
||||
A tuple of (domain, username) where username is None if not present
|
||||
|
||||
Examples:
|
||||
>>> _extract_domain('user@example.com')
|
||||
('example.com', 'user')
|
||||
>>> _extract_domain('example.com')
|
||||
('example.com', None)
|
||||
>>> _extract_domain('https://caldav.example.com/path')
|
||||
('caldav.example.com', None)
|
||||
"""
|
||||
# If it looks like a URL, parse it
|
||||
if "://" in identifier:
|
||||
parsed = urlparse(identifier)
|
||||
return (parsed.hostname or identifier, None)
|
||||
|
||||
# If it contains @, it's an email address
|
||||
if "@" in identifier:
|
||||
parts = identifier.split("@")
|
||||
username = parts[0].strip() if parts[0] else None
|
||||
domain = parts[-1].strip()
|
||||
return (domain, username)
|
||||
|
||||
# Otherwise assume it's already a domain
|
||||
return (identifier.strip(), None)
|
||||
|
||||
|
||||
def _parse_txt_record(txt_data: str) -> Optional[str]:
|
||||
"""
|
||||
Parse TXT record data to extract the path attribute.
|
||||
|
||||
According to RFC 6764, TXT records contain attribute=value pairs.
|
||||
We're looking for the 'path' attribute.
|
||||
|
||||
Args:
|
||||
txt_data: TXT record data (e.g., "path=/caldav/")
|
||||
|
||||
Returns:
|
||||
The path value or None if not found
|
||||
|
||||
Examples:
|
||||
>>> _parse_txt_record('path=/caldav/')
|
||||
'/caldav/'
|
||||
>>> _parse_txt_record('path=/caldav/ other=value')
|
||||
'/caldav/'
|
||||
"""
|
||||
# TXT records are key=value pairs separated by spaces
|
||||
for pair in txt_data.split():
|
||||
if "=" in pair:
|
||||
key, value = pair.split("=", 1)
|
||||
if key.strip().lower() == "path":
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _srv_lookup(
|
||||
domain: str, service_type: str, use_tls: bool = True
|
||||
) -> List[Tuple[str, int, int, int]]:
|
||||
"""
|
||||
Perform DNS SRV record lookup.
|
||||
|
||||
Args:
|
||||
domain: The domain to query
|
||||
service_type: Either 'caldav' or 'carddav'
|
||||
use_tls: If True, query for TLS service (_caldavs), else non-TLS (_caldav)
|
||||
|
||||
Returns:
|
||||
List of tuples: (hostname, port, priority, weight)
|
||||
Sorted by priority (lower is better), then randomized by weight
|
||||
"""
|
||||
# Construct the SRV record name
|
||||
# RFC 6764 defines: _caldavs._tcp, _caldav._tcp, _carddavs._tcp, _carddav._tcp
|
||||
service_suffix = "s" if use_tls else ""
|
||||
srv_name = f"_{service_type}{service_suffix}._tcp.{domain}"
|
||||
|
||||
log.debug(f"Performing SRV lookup for {srv_name}")
|
||||
|
||||
try:
|
||||
answers = dns.resolver.resolve(srv_name, "SRV")
|
||||
results = []
|
||||
|
||||
for rdata in answers:
|
||||
hostname = str(rdata.target).rstrip(".")
|
||||
port = int(rdata.port)
|
||||
priority = int(rdata.priority)
|
||||
weight = int(rdata.weight)
|
||||
|
||||
log.debug(
|
||||
f"Found SRV record: {hostname}:{port} (priority={priority}, weight={weight})"
|
||||
)
|
||||
results.append((hostname, port, priority, weight))
|
||||
|
||||
# Sort by priority (lower is better), then by weight (for weighted random selection)
|
||||
results.sort(key=lambda x: (x[2], -x[3]))
|
||||
return results
|
||||
|
||||
except (
|
||||
dns.resolver.NXDOMAIN,
|
||||
dns.resolver.NoAnswer,
|
||||
dns.exception.DNSException,
|
||||
) as e:
|
||||
log.debug(f"SRV lookup failed for {srv_name}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _txt_lookup(domain: str, service_type: str, use_tls: bool = True) -> Optional[str]:
|
||||
"""
|
||||
Perform DNS TXT record lookup to find the service path.
|
||||
|
||||
Args:
|
||||
domain: The domain to query
|
||||
service_type: Either 'caldav' or 'carddav'
|
||||
use_tls: If True, query for TLS service (_caldavs), else non-TLS (_caldav)
|
||||
|
||||
Returns:
|
||||
The path from the TXT record, or None if not found
|
||||
"""
|
||||
service_suffix = "s" if use_tls else ""
|
||||
txt_name = f"_{service_type}{service_suffix}._tcp.{domain}"
|
||||
|
||||
log.debug(f"Performing TXT lookup for {txt_name}")
|
||||
|
||||
try:
|
||||
answers = dns.resolver.resolve(txt_name, "TXT")
|
||||
|
||||
for rdata in answers:
|
||||
# TXT records can have multiple strings; join them
|
||||
txt_data = "".join(
|
||||
[
|
||||
s.decode("utf-8") if isinstance(s, bytes) else s
|
||||
for s in rdata.strings
|
||||
]
|
||||
)
|
||||
log.debug(f"Found TXT record: {txt_data}")
|
||||
|
||||
path = _parse_txt_record(txt_data)
|
||||
if path:
|
||||
return path
|
||||
|
||||
except (
|
||||
dns.resolver.NXDOMAIN,
|
||||
dns.resolver.NoAnswer,
|
||||
dns.exception.DNSException,
|
||||
) as e:
|
||||
log.debug(f"TXT lookup failed for {txt_name}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _well_known_lookup(
|
||||
domain: str, service_type: str, timeout: int = 10, ssl_verify_cert: bool = True
|
||||
) -> Optional[ServiceInfo]:
|
||||
"""
|
||||
Try to discover service via Well-Known URI (RFC 5785).
|
||||
|
||||
According to RFC 6764, if SRV/TXT lookup fails, clients should try:
|
||||
- https://domain/.well-known/caldav
|
||||
- https://domain/.well-known/carddav
|
||||
|
||||
Security: Redirects to different domains are validated per RFC 6764 Section 8.
|
||||
|
||||
Args:
|
||||
domain: The domain to query
|
||||
service_type: Either 'caldav' or 'carddav'
|
||||
timeout: Request timeout in seconds
|
||||
ssl_verify_cert: Whether to verify SSL certificates
|
||||
|
||||
Returns:
|
||||
ServiceInfo if successful, None otherwise
|
||||
"""
|
||||
well_known_path = f"/.well-known/{service_type}"
|
||||
url = f"https://{domain}{well_known_path}"
|
||||
|
||||
log.debug(f"Trying well-known URI: {url}")
|
||||
|
||||
try:
|
||||
# We expect a redirect to the actual service URL
|
||||
# Use HEAD or GET with allow_redirects
|
||||
response = requests.get(
|
||||
url,
|
||||
timeout=timeout,
|
||||
verify=ssl_verify_cert,
|
||||
allow_redirects=False, # We want to see the redirect
|
||||
)
|
||||
|
||||
# RFC 6764 says we should follow redirects
|
||||
if response.status_code in (301, 302, 303, 307, 308):
|
||||
location = response.headers.get("Location")
|
||||
if location:
|
||||
log.debug(f"Well-known URI redirected to: {location}")
|
||||
|
||||
# Make it an absolute URL if it's relative
|
||||
final_url = urljoin(url, location)
|
||||
parsed = urlparse(final_url)
|
||||
redirect_hostname = parsed.hostname or domain
|
||||
|
||||
# RFC 6764 Section 8 Security: Validate redirect target is in same domain
|
||||
if not _is_subdomain_or_same(redirect_hostname, domain):
|
||||
log.warning(
|
||||
f"RFC 6764 Security: Rejecting well-known redirect to different domain. "
|
||||
f"Queried domain: {domain}, Redirect target: {redirect_hostname}. "
|
||||
f"Ignoring this redirect for security."
|
||||
)
|
||||
return None
|
||||
|
||||
return ServiceInfo(
|
||||
url=final_url,
|
||||
hostname=redirect_hostname,
|
||||
port=parsed.port or (443 if parsed.scheme == "https" else 80),
|
||||
path=parsed.path or "/",
|
||||
tls=parsed.scheme == "https",
|
||||
source="well-known",
|
||||
)
|
||||
|
||||
# If we get 200 OK, the well-known URI itself is the service endpoint
|
||||
if response.status_code == 200:
|
||||
log.debug(f"Well-known URI is the service endpoint: {url}")
|
||||
return ServiceInfo(
|
||||
url=url,
|
||||
hostname=domain,
|
||||
port=443,
|
||||
path=well_known_path,
|
||||
tls=True,
|
||||
source="well-known",
|
||||
)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
log.debug(f"Well-known URI lookup failed: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def discover_service(
|
||||
identifier: str,
|
||||
service_type: str = "caldav",
|
||||
timeout: int = 10,
|
||||
ssl_verify_cert: bool = True,
|
||||
prefer_tls: bool = True,
|
||||
require_tls: bool = True,
|
||||
) -> Optional[ServiceInfo]:
|
||||
"""
|
||||
Discover CalDAV or CardDAV service for a domain or email address.
|
||||
|
||||
This is the main entry point for RFC 6764 service discovery.
|
||||
It tries multiple methods in order:
|
||||
1. DNS SRV records (with TLS preferred)
|
||||
2. DNS TXT records for path information
|
||||
3. Well-Known URIs as fallback
|
||||
|
||||
SECURITY WARNING:
|
||||
RFC 6764 discovery relies on DNS, which can be spoofed if not using DNSSEC.
|
||||
An attacker controlling DNS could:
|
||||
- Redirect connections to a malicious server
|
||||
- Downgrade from HTTPS to HTTP to capture credentials
|
||||
- Perform man-in-the-middle attacks
|
||||
|
||||
By default, require_tls=True prevents HTTP downgrade attacks.
|
||||
For production use, consider:
|
||||
- Using DNSSEC-validated domains
|
||||
- Manual verification of discovered endpoints
|
||||
- Pinning certificates for known domains
|
||||
|
||||
Args:
|
||||
identifier: Domain name (example.com) or email address (user@example.com)
|
||||
service_type: Either 'caldav' or 'carddav'
|
||||
timeout: Timeout for HTTP requests in seconds
|
||||
ssl_verify_cert: Whether to verify SSL certificates
|
||||
prefer_tls: If True, try TLS services first (only used if require_tls=False)
|
||||
require_tls: If True (default), ONLY accept TLS connections. This prevents
|
||||
DNS-based downgrade attacks to plaintext HTTP. Set to False
|
||||
only if you explicitly need to support non-TLS servers and
|
||||
trust your DNS infrastructure.
|
||||
|
||||
Returns:
|
||||
ServiceInfo object with discovered service details, or None if discovery fails
|
||||
|
||||
Raises:
|
||||
DiscoveryError: If service_type is invalid
|
||||
|
||||
Examples:
|
||||
>>> info = discover_service('user@example.com', 'caldav')
|
||||
>>> if info:
|
||||
... print(f"Service URL: {info.url}")
|
||||
|
||||
>>> # Allow non-TLS (INSECURE - only for testing)
|
||||
>>> info = discover_service('user@example.com', 'caldav', require_tls=False)
|
||||
"""
|
||||
if service_type not in ("caldav", "carddav"):
|
||||
raise DiscoveryError(
|
||||
reason=f"Invalid service_type: {service_type}. Must be 'caldav' or 'carddav'"
|
||||
)
|
||||
|
||||
domain, username = _extract_domain(identifier)
|
||||
log.info(f"Discovering {service_type} service for domain: {domain}")
|
||||
if username:
|
||||
log.debug(f"Username extracted from identifier: {username}")
|
||||
|
||||
# Try SRV/TXT records first (RFC 6764 section 5)
|
||||
# Security: require_tls=True prevents downgrade attacks
|
||||
if require_tls:
|
||||
tls_options = [True] # Only accept TLS connections
|
||||
log.debug("require_tls=True: Only attempting TLS discovery")
|
||||
else:
|
||||
# Prefer TLS services over non-TLS when both are allowed
|
||||
tls_options = [True, False] if prefer_tls else [False, True]
|
||||
log.warning("require_tls=False: Allowing non-TLS connections (INSECURE)")
|
||||
|
||||
for use_tls in tls_options:
|
||||
srv_records = _srv_lookup(domain, service_type, use_tls)
|
||||
|
||||
if srv_records:
|
||||
# Use the highest priority record (first in sorted list)
|
||||
hostname, port, priority, weight = srv_records[0]
|
||||
|
||||
# RFC 6764 Section 8 Security: Validate discovered hostname is in same domain
|
||||
# "clients SHOULD check that the target FQDN returned in the SRV record
|
||||
# matches the original service domain that was queried"
|
||||
if not _is_subdomain_or_same(hostname, domain):
|
||||
log.warning(
|
||||
f"RFC 6764 Security: Rejecting SRV record pointing to different domain. "
|
||||
f"Queried domain: {domain}, Target FQDN: {hostname}. "
|
||||
f"This may indicate DNS hijacking or misconfiguration."
|
||||
)
|
||||
continue # Try next TLS option or fall back to well-known
|
||||
|
||||
# Try to get path from TXT record
|
||||
path = _txt_lookup(domain, service_type, use_tls)
|
||||
if not path:
|
||||
# RFC 6764 section 5: If no TXT record, try well-known URI for path
|
||||
log.debug("No TXT record found, using root path")
|
||||
path = "/"
|
||||
|
||||
# Construct the service URL
|
||||
scheme = "https" if use_tls else "http"
|
||||
# Only include port in URL if it's non-standard
|
||||
default_port = 443 if use_tls else 80
|
||||
if port != default_port:
|
||||
url = f"{scheme}://{hostname}:{port}{path}"
|
||||
else:
|
||||
url = f"{scheme}://{hostname}{path}"
|
||||
|
||||
log.info(f"Discovered {service_type} service via SRV: {url}")
|
||||
|
||||
return ServiceInfo(
|
||||
url=url,
|
||||
hostname=hostname,
|
||||
port=port,
|
||||
path=path,
|
||||
tls=use_tls,
|
||||
priority=priority,
|
||||
weight=weight,
|
||||
source="srv",
|
||||
username=username,
|
||||
)
|
||||
|
||||
# Fallback to well-known URI (RFC 6764 section 5)
|
||||
log.debug("SRV lookup failed, trying well-known URI")
|
||||
well_known_info = _well_known_lookup(domain, service_type, timeout, ssl_verify_cert)
|
||||
|
||||
if well_known_info:
|
||||
# Preserve username from email address
|
||||
well_known_info.username = username
|
||||
log.info(
|
||||
f"Discovered {service_type} service via well-known URI: {well_known_info.url}"
|
||||
)
|
||||
return well_known_info
|
||||
|
||||
# All discovery methods failed
|
||||
log.warning(f"Failed to discover {service_type} service for {domain}")
|
||||
return None
|
||||
|
||||
|
||||
def discover_caldav(
|
||||
identifier: str,
|
||||
timeout: int = 10,
|
||||
ssl_verify_cert: bool = True,
|
||||
prefer_tls: bool = True,
|
||||
require_tls: bool = True,
|
||||
) -> Optional[ServiceInfo]:
|
||||
"""
|
||||
Convenience function to discover CalDAV service.
|
||||
|
||||
Args:
|
||||
identifier: Domain name or email address
|
||||
timeout: Timeout for HTTP requests in seconds
|
||||
ssl_verify_cert: Whether to verify SSL certificates
|
||||
prefer_tls: If True, try TLS services first
|
||||
require_tls: If True (default), only accept TLS connections
|
||||
|
||||
Returns:
|
||||
ServiceInfo object or None
|
||||
"""
|
||||
return discover_service(
|
||||
identifier=identifier,
|
||||
service_type="caldav",
|
||||
timeout=timeout,
|
||||
ssl_verify_cert=ssl_verify_cert,
|
||||
prefer_tls=prefer_tls,
|
||||
require_tls=require_tls,
|
||||
)
|
||||
|
||||
|
||||
def discover_carddav(
|
||||
identifier: str,
|
||||
timeout: int = 10,
|
||||
ssl_verify_cert: bool = True,
|
||||
prefer_tls: bool = True,
|
||||
require_tls: bool = True,
|
||||
) -> Optional[ServiceInfo]:
|
||||
"""
|
||||
Convenience function to discover CardDAV service.
|
||||
|
||||
Args:
|
||||
identifier: Domain name or email address
|
||||
timeout: Timeout for HTTP requests in seconds
|
||||
ssl_verify_cert: Whether to verify SSL certificates
|
||||
prefer_tls: If True, try TLS services first
|
||||
require_tls: If True (default), only accept TLS connections
|
||||
|
||||
Returns:
|
||||
ServiceInfo object or None
|
||||
"""
|
||||
return discover_service(
|
||||
identifier=identifier,
|
||||
service_type="carddav",
|
||||
timeout=timeout,
|
||||
ssl_verify_cert=ssl_verify_cert,
|
||||
prefer_tls=prefer_tls,
|
||||
require_tls=require_tls,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
#!/usr/bin/env python
|
||||
106
.venv/lib/python3.9/site-packages/caldav/elements/base.py
Normal file
106
.venv/lib/python3.9/site-packages/caldav/elements/base.py
Normal file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python
|
||||
import sys
|
||||
from typing import ClassVar
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Union
|
||||
|
||||
from lxml import etree
|
||||
from lxml.etree import _Element
|
||||
|
||||
from caldav.lib.namespace import nsmap
|
||||
from caldav.lib.python_utilities import to_unicode
|
||||
|
||||
if sys.version_info < (3, 9):
|
||||
from typing import Iterable
|
||||
else:
|
||||
from collections.abc import Iterable
|
||||
|
||||
if sys.version_info < (3, 11):
|
||||
from typing_extensions import Self
|
||||
else:
|
||||
from typing import Self
|
||||
|
||||
|
||||
class BaseElement:
|
||||
children: Optional[List[Self]] = None
|
||||
tag: ClassVar[Optional[str]] = None
|
||||
value: Optional[str] = None
|
||||
attributes: Optional[dict] = None
|
||||
caldav_class = None
|
||||
|
||||
def __init__(
|
||||
self, name: Optional[str] = None, value: Union[str, bytes, None] = None
|
||||
) -> None:
|
||||
self.children = []
|
||||
self.attributes = {}
|
||||
value = to_unicode(value)
|
||||
self.value = None
|
||||
if name is not None:
|
||||
self.attributes["name"] = name
|
||||
if value is not None:
|
||||
self.value = value
|
||||
|
||||
def __add__(
|
||||
self, other: Union["BaseElement", Iterable["BaseElement"]]
|
||||
) -> "BaseElement":
|
||||
return self.append(other)
|
||||
|
||||
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) -> _Element:
|
||||
if self.tag is None:
|
||||
## We can do better than this. tag may be a property that expands
|
||||
## from the class name. Another layer of base class to indicate
|
||||
## if it's the D-namespace, C-namespace or another namespace.
|
||||
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
|
||||
|
||||
for k in self.attributes:
|
||||
root.set(k, self.attributes[k])
|
||||
|
||||
self.xmlchildren(root)
|
||||
return 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: 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)
|
||||
else:
|
||||
self.children.append(element)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
class NamedBaseElement(BaseElement):
|
||||
def __init__(self, name: Optional[str] = None) -> None:
|
||||
super(NamedBaseElement, self).__init__(name=name)
|
||||
|
||||
def xmlelement(self):
|
||||
if self.attributes.get("name") is None:
|
||||
raise Exception("name attribute must be defined")
|
||||
return super(NamedBaseElement, self).xmlelement()
|
||||
|
||||
|
||||
class ValuedBaseElement(BaseElement):
|
||||
def __init__(self, value: Union[str, bytes, None] = None) -> None:
|
||||
super(ValuedBaseElement, self).__init__(value=value)
|
||||
223
.venv/lib/python3.9/site-packages/caldav/elements/cdav.py
Normal file
223
.venv/lib/python3.9/site-packages/caldav/elements/cdav.py
Normal file
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env python
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from typing import ClassVar
|
||||
from typing import Optional
|
||||
|
||||
from .base import BaseElement
|
||||
from .base import NamedBaseElement
|
||||
from .base import ValuedBaseElement
|
||||
from caldav.lib.namespace import ns
|
||||
|
||||
utc_tz = timezone.utc
|
||||
|
||||
|
||||
def _to_utc_date_string(ts):
|
||||
# type (Union[date,datetime]]) -> str
|
||||
"""coerce datetimes to UTC (assume localtime if nothing is given)"""
|
||||
if isinstance(ts, datetime):
|
||||
try:
|
||||
## for any python version, this should work for a non-native
|
||||
## timestamp.
|
||||
## in python 3.6 and higher, ts.astimezone() will assume a
|
||||
## naive timestamp is localtime (and so do we)
|
||||
ts = ts.astimezone(utc_tz)
|
||||
except:
|
||||
## native time stamp and the current python version is
|
||||
## not able to treat it as localtime.
|
||||
import tzlocal
|
||||
|
||||
ts = ts.replace(tzinfo=tzlocal.get_localzone())
|
||||
|
||||
mindate = datetime.min.replace(tzinfo=utc_tz)
|
||||
maxdate = datetime.max.replace(tzinfo=utc_tz)
|
||||
if mindate + ts.tzinfo.utcoffset(ts) > ts:
|
||||
logging.error(
|
||||
"Cannot coerce datetime %s to UTC. Changed to min-date.", ts
|
||||
)
|
||||
ts = mindate
|
||||
elif ts > maxdate - ts.tzinfo.utcoffset(ts):
|
||||
logging.error(
|
||||
"Cannot coerce datetime %s to UTC. Changed to max-date.", ts
|
||||
)
|
||||
ts = maxdate
|
||||
else:
|
||||
ts = ts.astimezone(utc_tz)
|
||||
|
||||
return ts.strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
|
||||
# Operations
|
||||
class CalendarQuery(BaseElement):
|
||||
tag: ClassVar[str] = ns("C", "calendar-query")
|
||||
|
||||
|
||||
class FreeBusyQuery(BaseElement):
|
||||
tag: ClassVar[str] = ns("C", "free-busy-query")
|
||||
|
||||
|
||||
class Mkcalendar(BaseElement):
|
||||
tag: ClassVar[str] = ns("C", "mkcalendar")
|
||||
|
||||
|
||||
class CalendarMultiGet(BaseElement):
|
||||
tag: ClassVar[str] = ns("C", "calendar-multiget")
|
||||
|
||||
|
||||
class ScheduleInboxURL(BaseElement):
|
||||
tag: ClassVar[str] = ns("C", "schedule-inbox-URL")
|
||||
|
||||
|
||||
class ScheduleOutboxURL(BaseElement):
|
||||
tag: ClassVar[str] = ns("C", "schedule-outbox-URL")
|
||||
|
||||
|
||||
# Filters
|
||||
class Filter(BaseElement):
|
||||
tag: ClassVar[str] = ns("C", "filter")
|
||||
|
||||
|
||||
class CompFilter(NamedBaseElement):
|
||||
tag: ClassVar[str] = ns("C", "comp-filter")
|
||||
|
||||
|
||||
class PropFilter(NamedBaseElement):
|
||||
tag: ClassVar[str] = ns("C", "prop-filter")
|
||||
|
||||
|
||||
class ParamFilter(NamedBaseElement):
|
||||
tag: ClassVar[str] = ns("C", "param-filter")
|
||||
|
||||
|
||||
# Conditions
|
||||
class TextMatch(ValuedBaseElement):
|
||||
tag: ClassVar[str] = ns("C", "text-match")
|
||||
|
||||
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: ClassVar[str] = ns("C", "time-range")
|
||||
|
||||
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:
|
||||
self.attributes["end"] = _to_utc_date_string(end)
|
||||
|
||||
|
||||
class NotDefined(BaseElement):
|
||||
tag: ClassVar[str] = ns("C", "is-not-defined")
|
||||
|
||||
|
||||
# Components / Data
|
||||
class CalendarData(BaseElement):
|
||||
tag: ClassVar[str] = ns("C", "calendar-data")
|
||||
|
||||
|
||||
class Expand(BaseElement):
|
||||
tag: ClassVar[str] = ns("C", "expand")
|
||||
|
||||
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:
|
||||
self.attributes["end"] = _to_utc_date_string(end)
|
||||
|
||||
|
||||
class Comp(NamedBaseElement):
|
||||
tag: ClassVar[str] = ns("C", "comp")
|
||||
|
||||
|
||||
# Uhhm ... can't find any references to calendar-collection in rfc4791.txt
|
||||
# and newer versions of baikal gives 403 forbidden when this one is
|
||||
# encountered
|
||||
# class CalendarCollection(BaseElement):
|
||||
# tag = ns("C", "calendar-collection")
|
||||
|
||||
|
||||
# Properties
|
||||
class CalendarUserAddressSet(BaseElement):
|
||||
tag: ClassVar[str] = ns("C", "calendar-user-address-set")
|
||||
|
||||
|
||||
class CalendarUserType(BaseElement):
|
||||
tag: ClassVar[str] = ns("C", "calendar-user-type")
|
||||
|
||||
|
||||
class CalendarHomeSet(BaseElement):
|
||||
tag: ClassVar[str] = ns("C", "calendar-home-set")
|
||||
|
||||
|
||||
# calendar resource type, see rfc4791, sec. 4.2
|
||||
class Calendar(BaseElement):
|
||||
tag: ClassVar[str] = ns("C", "calendar")
|
||||
|
||||
|
||||
class CalendarDescription(ValuedBaseElement):
|
||||
tag: ClassVar[str] = ns("C", "calendar-description")
|
||||
|
||||
|
||||
class CalendarTimeZone(ValuedBaseElement):
|
||||
tag: ClassVar[str] = ns("C", "calendar-timezone")
|
||||
|
||||
|
||||
class SupportedCalendarComponentSet(ValuedBaseElement):
|
||||
tag: ClassVar[str] = ns("C", "supported-calendar-component-set")
|
||||
|
||||
|
||||
class SupportedCalendarData(ValuedBaseElement):
|
||||
tag: ClassVar[str] = ns("C", "supported-calendar-data")
|
||||
|
||||
|
||||
class MaxResourceSize(ValuedBaseElement):
|
||||
tag: ClassVar[str] = ns("C", "max-resource-size")
|
||||
|
||||
|
||||
class MinDateTime(ValuedBaseElement):
|
||||
tag: ClassVar[str] = ns("C", "min-date-time")
|
||||
|
||||
|
||||
class MaxDateTime(ValuedBaseElement):
|
||||
tag: ClassVar[str] = ns("C", "max-date-time")
|
||||
|
||||
|
||||
class MaxInstances(ValuedBaseElement):
|
||||
tag: ClassVar[str] = ns("C", "max-instances")
|
||||
|
||||
|
||||
class MaxAttendeesPerInstance(ValuedBaseElement):
|
||||
tag: ClassVar[str] = ns("C", "max-attendees-per-instance")
|
||||
|
||||
|
||||
class Allprop(BaseElement):
|
||||
tag: ClassVar[str] = ns("C", "allprop")
|
||||
|
||||
|
||||
class ScheduleTag(BaseElement):
|
||||
tag: ClassVar[str] = ns("C", "schedule-tag")
|
||||
115
.venv/lib/python3.9/site-packages/caldav/elements/dav.py
Normal file
115
.venv/lib/python3.9/site-packages/caldav/elements/dav.py
Normal file
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python
|
||||
from typing import ClassVar
|
||||
|
||||
from .base import BaseElement
|
||||
from .base import ValuedBaseElement
|
||||
from caldav.lib.namespace import ns
|
||||
|
||||
|
||||
# Operations
|
||||
class Propfind(BaseElement):
|
||||
tag: ClassVar[str] = ns("D", "propfind")
|
||||
|
||||
|
||||
class PropertyUpdate(BaseElement):
|
||||
tag: ClassVar[str] = ns("D", "propertyupdate")
|
||||
|
||||
|
||||
class Mkcol(BaseElement):
|
||||
tag: ClassVar[str] = ns("D", "mkcol")
|
||||
|
||||
|
||||
class SyncCollection(BaseElement):
|
||||
tag: ClassVar[str] = ns("D", "sync-collection")
|
||||
|
||||
|
||||
class PrincipalPropertySearch(BaseElement):
|
||||
tag: ClassVar[str] = ns("D", "principal-property-search")
|
||||
|
||||
|
||||
class PropertySearch(BaseElement):
|
||||
tag: ClassVar[str] = ns("D", "property-search")
|
||||
|
||||
|
||||
# Filters
|
||||
|
||||
|
||||
class Match(BaseElement):
|
||||
tag: ClassVar[str] = ns("D", "match")
|
||||
|
||||
|
||||
# Conditions
|
||||
class SyncToken(BaseElement):
|
||||
tag: ClassVar[str] = ns("D", "sync-token")
|
||||
|
||||
|
||||
class SyncLevel(BaseElement):
|
||||
tag: ClassVar[str] = ns("D", "sync-level")
|
||||
|
||||
|
||||
# Components / Data
|
||||
|
||||
|
||||
class Prop(BaseElement):
|
||||
tag: ClassVar[str] = ns("D", "prop")
|
||||
|
||||
|
||||
class Collection(BaseElement):
|
||||
tag: ClassVar[str] = ns("D", "collection")
|
||||
|
||||
|
||||
class Set(BaseElement):
|
||||
tag: ClassVar[str] = ns("D", "set")
|
||||
|
||||
|
||||
# Properties
|
||||
class ResourceType(BaseElement):
|
||||
tag: ClassVar[str] = ns("D", "resourcetype")
|
||||
|
||||
|
||||
class DisplayName(ValuedBaseElement):
|
||||
tag: ClassVar[str] = ns("D", "displayname")
|
||||
|
||||
|
||||
class GetEtag(ValuedBaseElement):
|
||||
tag: ClassVar[str] = ns("D", "getetag")
|
||||
|
||||
|
||||
class Href(BaseElement):
|
||||
tag: ClassVar[str] = ns("D", "href")
|
||||
|
||||
|
||||
class SupportedReportSet(BaseElement):
|
||||
tag = ns("D", "supported-report-set")
|
||||
|
||||
|
||||
class Response(BaseElement):
|
||||
tag: ClassVar[str] = ns("D", "response")
|
||||
|
||||
|
||||
class Status(BaseElement):
|
||||
tag: ClassVar[str] = ns("D", "status")
|
||||
|
||||
|
||||
class PropStat(BaseElement):
|
||||
tag: ClassVar[str] = ns("D", "propstat")
|
||||
|
||||
|
||||
class MultiStatus(BaseElement):
|
||||
tag: ClassVar[str] = ns("D", "multistatus")
|
||||
|
||||
|
||||
class CurrentUserPrincipal(BaseElement):
|
||||
tag: ClassVar[str] = ns("D", "current-user-principal")
|
||||
|
||||
|
||||
class PrincipalCollectionSet(BaseElement):
|
||||
tag: ClassVar[str] = ns("D", "principal-collection-set")
|
||||
|
||||
|
||||
class Allprop(BaseElement):
|
||||
tag: ClassVar[str] = ns("D", "allprop")
|
||||
|
||||
|
||||
class Owner(BaseElement):
|
||||
tag: ClassVar[str] = ns("D", "owner")
|
||||
14
.venv/lib/python3.9/site-packages/caldav/elements/ical.py
Normal file
14
.venv/lib/python3.9/site-packages/caldav/elements/ical.py
Normal file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env python
|
||||
from typing import ClassVar
|
||||
|
||||
from .base import ValuedBaseElement
|
||||
from caldav.lib.namespace import ns
|
||||
|
||||
|
||||
# Properties
|
||||
class CalendarColor(ValuedBaseElement):
|
||||
tag: ClassVar[str] = ns("I", "calendar-color")
|
||||
|
||||
|
||||
class CalendarOrder(ValuedBaseElement):
|
||||
tag: ClassVar[str] = ns("I", "calendar-order")
|
||||
16
.venv/lib/python3.9/site-packages/caldav/lib/debug.py
Normal file
16
.venv/lib/python3.9/site-packages/caldav/lib/debug.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from lxml import etree
|
||||
|
||||
|
||||
def xmlstring(root):
|
||||
if isinstance(root, str):
|
||||
return root
|
||||
if hasattr(root, "xmlelement"):
|
||||
root = root.xmlelement()
|
||||
try:
|
||||
return etree.tostring(root, pretty_print=True).decode("utf-8")
|
||||
except:
|
||||
return root
|
||||
|
||||
|
||||
def printxml(root) -> None:
|
||||
print(xmlstring(root))
|
||||
151
.venv/lib/python3.9/site-packages/caldav/lib/error.py
Normal file
151
.venv/lib/python3.9/site-packages/caldav/lib/error.py
Normal file
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from typing import Dict
|
||||
from typing import Optional
|
||||
|
||||
from caldav import __version__
|
||||
|
||||
debug_dump_communication = False
|
||||
try:
|
||||
import os
|
||||
|
||||
## Environmental variables prepended with "PYTHON_CALDAV" are used for debug purposes,
|
||||
## environmental variables prepended with "CALDAV_" are for connection parameters
|
||||
debug_dump_communication = os.environ.get("PYTHON_CALDAV_COMMDUMP", False)
|
||||
## one of DEBUG_PDB, DEBUG, DEVELOPMENT, PRODUCTION
|
||||
debugmode = os.environ["PYTHON_CALDAV_DEBUGMODE"]
|
||||
except:
|
||||
if "dev" in __version__ or __version__ == "(unknown)":
|
||||
debugmode = "DEVELOPMENT"
|
||||
else:
|
||||
debugmode = "PRODUCTION"
|
||||
|
||||
log = logging.getLogger("caldav")
|
||||
if debugmode.startswith("DEBUG"):
|
||||
log.setLevel(logging.DEBUG)
|
||||
else:
|
||||
log.setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def errmsg(r) -> str:
|
||||
"""Utility for formatting a an error response to an error string"""
|
||||
return "%s %s\n\n%s" % (r.status, r.reason, r.raw)
|
||||
|
||||
|
||||
def weirdness(*reasons):
|
||||
from caldav.lib.debug import xmlstring
|
||||
|
||||
reason = " : ".join([xmlstring(x) for x in reasons])
|
||||
log.warning(f"Deviation from expectations found: {reason}")
|
||||
if debugmode == "DEBUG_PDB":
|
||||
log.error(f"Dropping into debugger due to {reason}")
|
||||
import pdb
|
||||
|
||||
pdb.set_trace()
|
||||
|
||||
|
||||
def assert_(condition: object) -> None:
|
||||
try:
|
||||
assert condition
|
||||
except AssertionError:
|
||||
if debugmode == "PRODUCTION":
|
||||
log.error(
|
||||
"Deviation from expectations found. %s" % ERR_FRAGMENT, exc_info=True
|
||||
)
|
||||
elif debugmode == "DEBUG_PDB":
|
||||
log.error("Deviation from expectations found. Dropping into debugger")
|
||||
import pdb
|
||||
|
||||
pdb.set_trace()
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
ERR_FRAGMENT: str = "Please consider raising an issue at https://github.com/python-caldav/caldav/issues or reach out to t-caldav@tobixen.no, include this error and the traceback (if any) and tell what server you are using"
|
||||
|
||||
|
||||
class DAVError(Exception):
|
||||
url: Optional[str] = None
|
||||
reason: str = "no reason"
|
||||
|
||||
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) -> str:
|
||||
return "%s at '%s', reason %s" % (
|
||||
self.__class__.__name__,
|
||||
self.url,
|
||||
self.reason,
|
||||
)
|
||||
|
||||
|
||||
class AuthorizationError(DAVError):
|
||||
"""
|
||||
The client encountered an HTTP 403 error and is passing it on
|
||||
to the user. The url property will contain the url in question,
|
||||
the reason property will contain the excuse the server sent.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PropsetError(DAVError):
|
||||
pass
|
||||
|
||||
|
||||
class ProppatchError(DAVError):
|
||||
pass
|
||||
|
||||
|
||||
class PropfindError(DAVError):
|
||||
pass
|
||||
|
||||
|
||||
class ReportError(DAVError):
|
||||
pass
|
||||
|
||||
|
||||
class MkcolError(DAVError):
|
||||
pass
|
||||
|
||||
|
||||
class MkcalendarError(DAVError):
|
||||
pass
|
||||
|
||||
|
||||
class PutError(DAVError):
|
||||
pass
|
||||
|
||||
|
||||
class DeleteError(DAVError):
|
||||
pass
|
||||
|
||||
|
||||
class NotFoundError(DAVError):
|
||||
pass
|
||||
|
||||
|
||||
class ConsistencyError(DAVError):
|
||||
pass
|
||||
|
||||
|
||||
class ResponseError(DAVError):
|
||||
pass
|
||||
|
||||
|
||||
exception_by_method: Dict[str, DAVError] = defaultdict(lambda: DAVError)
|
||||
for method in (
|
||||
"delete",
|
||||
"put",
|
||||
"mkcalendar",
|
||||
"mkcol",
|
||||
"report",
|
||||
"propset",
|
||||
"propfind",
|
||||
"proppatch",
|
||||
):
|
||||
exception_by_method[method] = locals()[method[0].upper() + method[1:] + "Error"]
|
||||
24
.venv/lib/python3.9/site-packages/caldav/lib/namespace.py
Normal file
24
.venv/lib/python3.9/site-packages/caldav/lib/namespace.py
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import Optional
|
||||
|
||||
nsmap: Dict[str, str] = {
|
||||
"D": "DAV:",
|
||||
"C": "urn:ietf:params:xml:ns:caldav",
|
||||
}
|
||||
|
||||
## silly thing with this one ... but quite many caldav libraries,
|
||||
## caldav clients and caldav servers supports this namespace and the
|
||||
## 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: Dict[str, Any] = nsmap.copy()
|
||||
nsmap2["I"] = ("http://apple.com/ns/ical/",)
|
||||
|
||||
|
||||
def ns(prefix: str, tag: Optional[str] = None) -> str:
|
||||
name = "{%s}" % nsmap2[prefix]
|
||||
if tag is not None:
|
||||
name = "%s%s" % (name, tag)
|
||||
return name
|
||||
@@ -0,0 +1,41 @@
|
||||
## This file was originally made to fascilitate support for both python2 and python3.
|
||||
|
||||
|
||||
def to_wire(text):
|
||||
if text is None:
|
||||
return None
|
||||
if isinstance(text, str):
|
||||
text = bytes(text, "utf-8")
|
||||
text = text.replace(b"\n", b"\r\n")
|
||||
text = text.replace(b"\r\r\n", b"\r\n")
|
||||
return text
|
||||
|
||||
|
||||
def to_local(text):
|
||||
if text is None:
|
||||
return None
|
||||
if not isinstance(text, str):
|
||||
text = text.decode("utf-8")
|
||||
text = text.replace("\r\n", "\n")
|
||||
return text
|
||||
|
||||
|
||||
to_str = to_local
|
||||
|
||||
|
||||
def to_normal_str(text):
|
||||
"""
|
||||
Make sure we return a normal string
|
||||
"""
|
||||
if text is None:
|
||||
return text
|
||||
if not isinstance(text, str):
|
||||
text = text.decode("utf-8")
|
||||
text = text.replace("\r\n", "\n")
|
||||
return text
|
||||
|
||||
|
||||
def to_unicode(text):
|
||||
if text and isinstance(text, bytes):
|
||||
return text.decode("utf-8")
|
||||
return text
|
||||
213
.venv/lib/python3.9/site-packages/caldav/lib/url.py
Normal file
213
.venv/lib/python3.9/site-packages/caldav/lib/url.py
Normal file
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env python
|
||||
import sys
|
||||
import urllib.parse
|
||||
from typing import Any
|
||||
from typing import cast
|
||||
from typing import Optional
|
||||
from typing import Union
|
||||
from urllib.parse import ParseResult
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import SplitResult
|
||||
from urllib.parse import unquote
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlunparse
|
||||
|
||||
from caldav.lib.python_utilities import to_normal_str
|
||||
from caldav.lib.python_utilities import to_unicode
|
||||
|
||||
if sys.version_info < (3, 11):
|
||||
from typing_extensions import Self
|
||||
else:
|
||||
from typing import Self
|
||||
|
||||
|
||||
class URL:
|
||||
"""
|
||||
This class is for wrapping URLs into objects. It's used
|
||||
internally in the library, end users should not need to know
|
||||
anything about this class. All methods that accept URLs can be
|
||||
fed either with a URL object, a string or a urlparse.ParsedURL
|
||||
object.
|
||||
|
||||
Addresses may be one out of three:
|
||||
|
||||
1) a path relative to the DAV-root, i.e. "someuser/calendar" may
|
||||
refer to
|
||||
"http://my.davical-server.example.com/caldav.php/someuser/calendar".
|
||||
|
||||
2) an absolute path, i.e. "/caldav.php/someuser/calendar"
|
||||
|
||||
3) a fully qualified URL, i.e.
|
||||
"http://someuser:somepass@my.davical-server.example.com/caldav.php/someuser/calendar".
|
||||
Remark that hostname, port, user, pass is typically given when
|
||||
instantiating the DAVClient object and cannot be overridden later.
|
||||
|
||||
As of 2013-11, some methods in the caldav library expected strings
|
||||
and some expected urlParseResult objects, some expected
|
||||
fully qualified URLs and most expected absolute paths. The purpose
|
||||
of this class is to ensure consistency and at the same time
|
||||
maintaining backward compatibility. Basically, all methods should
|
||||
accept any kind of URL.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, url: Union[str, ParseResult, SplitResult]) -> None:
|
||||
if isinstance(url, ParseResult) or isinstance(url, SplitResult):
|
||||
self.url_parsed: Optional[Union[ParseResult, SplitResult]] = url
|
||||
self.url_raw = None
|
||||
else:
|
||||
self.url_raw = url
|
||||
self.url_parsed = None
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
if self.url_raw or self.url_parsed:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def __ne__(self, other: object) -> bool:
|
||||
return not self == other
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if str(self) == str(other):
|
||||
return True
|
||||
# The URLs could have insignificant differences
|
||||
me = self.canonical()
|
||||
if hasattr(other, "canonical"):
|
||||
other = other.canonical()
|
||||
return str(me) == str(other)
|
||||
|
||||
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: Union[Self, str, ParseResult, SplitResult]) -> "URL":
|
||||
if url is None or isinstance(url, URL):
|
||||
return url
|
||||
else:
|
||||
return URL(url)
|
||||
|
||||
# To deal with all kind of methods/properties in the ParseResult
|
||||
# class
|
||||
def __getattr__(self, attr: str):
|
||||
if "url_parsed" not in vars(self):
|
||||
raise AttributeError
|
||||
if self.url_parsed is None:
|
||||
self.url_parsed = 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) -> str:
|
||||
return to_normal_str(self.__unicode__())
|
||||
|
||||
# returns the url in text format
|
||||
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) -> str:
|
||||
return "URL(%s)" % str(self)
|
||||
|
||||
def strip_trailing_slash(self) -> "URL":
|
||||
if str(self)[-1] == "/":
|
||||
return URL.objectify(str(self)[:-1])
|
||||
else:
|
||||
return self
|
||||
|
||||
def is_auth(self) -> bool:
|
||||
return self.username is not None
|
||||
|
||||
def unauth(self) -> "URL":
|
||||
if not self.is_auth():
|
||||
return self
|
||||
return URL.objectify(
|
||||
ParseResult(
|
||||
self.scheme,
|
||||
"%s:%s"
|
||||
% (self.hostname, self.port or {"https": 443, "http": 80}[self.scheme]),
|
||||
self.path.replace("//", "/"),
|
||||
self.params,
|
||||
self.query,
|
||||
self.fragment,
|
||||
)
|
||||
)
|
||||
|
||||
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,
|
||||
run it through the urlparser, and make sure path is properly quoted
|
||||
"""
|
||||
url = self.unauth()
|
||||
|
||||
arr = list(cast(urllib.parse.ParseResult, self.url_parsed))
|
||||
## quoting path and removing double slashes
|
||||
arr[2] = quote(unquote(url.path.replace("//", "/")))
|
||||
## sensible defaults
|
||||
if not arr[0]:
|
||||
arr[0] = "https"
|
||||
if arr[1] and ":" not in arr[1]:
|
||||
if arr[0] == "https":
|
||||
portpart = ":443"
|
||||
elif arr[0] == "http":
|
||||
portpart = ":80"
|
||||
else:
|
||||
portpart = ""
|
||||
arr[1] += portpart
|
||||
|
||||
# make sure to delete the string version
|
||||
url.url_raw = urlunparse(arr)
|
||||
url.url_parsed = None
|
||||
|
||||
return url
|
||||
|
||||
def join(self, path: 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
|
||||
is absolute, it should be added to the connection details of
|
||||
self. If the path already contains connection details and the
|
||||
connection details differ from self, raise an error.
|
||||
"""
|
||||
pathAsString = str(path)
|
||||
if not path or not pathAsString:
|
||||
return self
|
||||
path = URL.objectify(path)
|
||||
if (
|
||||
(path.scheme and self.scheme and path.scheme != self.scheme)
|
||||
or (path.hostname and self.hostname and path.hostname != self.hostname)
|
||||
or (path.port and self.port and path.port != self.port)
|
||||
):
|
||||
raise ValueError("%s can't be joined with %s" % (self, path))
|
||||
|
||||
if path.path and path.path[0] == "/":
|
||||
ret_path = path.path
|
||||
else:
|
||||
sep = "/"
|
||||
if self.path.endswith("/"):
|
||||
sep = ""
|
||||
ret_path = "%s%s%s" % (self.path, sep, path.path)
|
||||
return URL(
|
||||
ParseResult(
|
||||
self.scheme or path.scheme,
|
||||
self.netloc or path.netloc,
|
||||
ret_path,
|
||||
path.params,
|
||||
path.query,
|
||||
path.fragment,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def make(url: Union[URL, str, ParseResult, SplitResult]) -> URL:
|
||||
"""Backward compatibility"""
|
||||
return URL.objectify(url)
|
||||
264
.venv/lib/python3.9/site-packages/caldav/lib/vcal.py
Normal file
264
.venv/lib/python3.9/site-packages/caldav/lib/vcal.py
Normal file
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env python
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import icalendar
|
||||
|
||||
from caldav.lib.python_utilities import to_normal_str
|
||||
|
||||
## Global counter. We don't want to be too verbose on the users, ref https://github.com/home-assistant/core/issues/86938
|
||||
fixup_error_loggings = 0
|
||||
|
||||
## Fixups to the icalendar data to work around compatibility issues.
|
||||
|
||||
## TODO:
|
||||
|
||||
## 1) this should only be done if needed. Use try-except around the
|
||||
## fragments where icalendar/vobject is parsing ical data, and do the
|
||||
## fixups there.
|
||||
|
||||
## 2) arguably, this is outside the scope of the caldav library.
|
||||
## check if this can be done in vobject or icalendar libraries instead
|
||||
## of here
|
||||
|
||||
|
||||
## TODO: would be nice with proper documentation on what systems are
|
||||
## generating broken data. Compatibility issues should also be collected
|
||||
## in the documentation. somewhere.
|
||||
def fix(event):
|
||||
"""This function receives some ical as it's given from the server, checks for
|
||||
breakages with the standard, and attempts to fix up known issues:
|
||||
|
||||
1) COMPLETED MUST be a datetime in UTC according to the RFC, but sometimes
|
||||
a date is given. (Google Calendar?) SOGo! Ref https://github.com/home-assistant/core/issues/106671
|
||||
|
||||
|
||||
2) The RFC does not specify any range restrictions on the dates,
|
||||
but clearly it doesn't make sense with a CREATED-timestamp that is
|
||||
centuries or decades before RFC2445 was published in 1998.
|
||||
Apparently some calendar servers generate nonsensical CREATED
|
||||
timestamps while other calendar servers can't handle CREATED
|
||||
timestamps prior to 1970. Probably it would make more sense to
|
||||
drop the CREATED line completely rather than moving it from the
|
||||
end of year 0AD to the beginning of year 1970. (Google Calendar)
|
||||
|
||||
3) iCloud apparently duplicates the DTSTAMP property sometimes -
|
||||
keep the first DTSTAMP encountered (arguably the DTSTAMP with earliest value
|
||||
should be kept).
|
||||
|
||||
4) ref https://github.com/python-caldav/caldav/issues/37,
|
||||
X-APPLE-STRUCTURED-EVENT attribute sometimes comes with trailing
|
||||
white space. I've decided to remove all trailing spaces, since
|
||||
they seem to cause a traceback with vobject and those lines are
|
||||
simply ignored by icalendar.
|
||||
|
||||
5) Zimbra can apparently create events with both dtstart, dtend
|
||||
and duration set - which is forbidden according to the RFC. We
|
||||
should probably verify that the data is consistent. As for now,
|
||||
we'll just drop DURATION or DTEND (whatever comes last).
|
||||
|
||||
6) On FOSSDEM I was presented with icalendar data missing the
|
||||
DTSTAMP field. This is mandatory according to the RFC.
|
||||
|
||||
All logic here is done with on the ical string, and not on
|
||||
icalendar objects. There are two reasons for it, originally
|
||||
optimization (not having to parse the icalendar data and create an
|
||||
object, if it's to be tossed away again shortly afterwards), but
|
||||
also because broken icalendar data may cause the instantiation of
|
||||
an icalendar object to break.
|
||||
|
||||
TODO: this should probably be moved out from the library.
|
||||
"""
|
||||
event = to_normal_str(event)
|
||||
if not event.endswith("\n"):
|
||||
event = event + "\n"
|
||||
|
||||
## TODO: add ^ before COMPLETED and CREATED?
|
||||
## 1) Add an arbitrary time if completed is given as date
|
||||
fixed = re.sub(
|
||||
r"COMPLETED(?:;VALUE=DATE)?:(\d+)\s", r"COMPLETED:\g<1>T120000Z", event
|
||||
)
|
||||
|
||||
## 2) CREATED timestamps prior to epoch does not make sense,
|
||||
## change from year 0001 to epoch.
|
||||
fixed = re.sub("CREATED:00001231T000000Z", "CREATED:19700101T000000Z", fixed)
|
||||
fixed = re.sub(r"\\+('\")", r"\1", fixed)
|
||||
|
||||
## 4) trailing whitespace probably never makes sense
|
||||
fixed = re.sub(" *$", "", fixed)
|
||||
|
||||
## 6) add DTSTAMP if not given
|
||||
## (corner case that DTSTAMP is given in one but not all the recurrences is ignored)
|
||||
if not "\nDTSTAMP:" in fixed:
|
||||
assert "\nEND" in fixed
|
||||
dtstamp = datetime.datetime.now(tz=datetime.timezone.utc).strftime(
|
||||
"%Y%m%dT%H%M%SZ"
|
||||
)
|
||||
fixed = re.sub(
|
||||
"(\nEND:(VTODO|VEVENT|VJOURNAL))", f"\nDTSTAMP:{dtstamp}\\1", fixed
|
||||
)
|
||||
|
||||
## 3 fix duplicated DTSTAMP ... and ...
|
||||
## 5 prepare to remove DURATION or DTEND/DUE if both DURATION and
|
||||
## DTEND/DUE is set.
|
||||
## remove duplication of DTSTAMP
|
||||
fixed2 = (
|
||||
"\n".join(filter(LineFilterDiscardingDuplicates(), fixed.strip().split("\n")))
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
if fixed2 != event:
|
||||
## This obscure code will ensure efficient rate-limiting of the error
|
||||
## logging. The "remove_bit" lambda will return 0 only for powers of
|
||||
## two (2, 4, 8, 16, 32, 64, etc).
|
||||
global fixup_error_loggings
|
||||
fixup_error_loggings += 1
|
||||
is_power_of_two = lambda n: not (n & (n - 1))
|
||||
logger = logging.getLogger("caldav")
|
||||
if is_power_of_two(fixup_error_loggings):
|
||||
log = logger.warning
|
||||
else:
|
||||
log = logger.debug
|
||||
|
||||
log_message = [
|
||||
"Ical data was modified to avoid compatibility issues",
|
||||
"(Your calendar server breaks the icalendar standard)",
|
||||
"This is probably harmless, particularly if not editing events or tasks",
|
||||
f"(error count: {fixup_error_loggings} - this error is ratelimited)",
|
||||
]
|
||||
|
||||
try:
|
||||
import difflib
|
||||
|
||||
diff = list(
|
||||
difflib.unified_diff(event.split("\n"), fixed2.split("\n"), lineterm="")
|
||||
)
|
||||
except:
|
||||
diff = ["Original: ", event, "Modified: ", fixed2]
|
||||
|
||||
log("\n".join(log_message + diff))
|
||||
|
||||
return fixed2
|
||||
|
||||
|
||||
class LineFilterDiscardingDuplicates:
|
||||
"""Needs to be a class because it keeps track of whether a certain
|
||||
group of date line was already encountered within a vobject.
|
||||
This must be called line by line in order on the complete text, at
|
||||
least comprising the complete vobject.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.stamped = 0
|
||||
self.ended = 0
|
||||
|
||||
def __call__(self, line):
|
||||
if line.startswith("BEGIN:V"):
|
||||
self.stamped = 0
|
||||
self.ended = 0
|
||||
|
||||
elif re.match("(DURATION|DTEND|DUE)[:;]", line):
|
||||
if self.ended:
|
||||
return False
|
||||
self.ended += 1
|
||||
|
||||
elif re.match("DTSTAMP[:;]", line):
|
||||
if self.stamped:
|
||||
return False
|
||||
self.stamped += 1
|
||||
|
||||
return True
|
||||
|
||||
|
||||
## sorry for being english-language-euro-centric ... fits rather perfectly as default language for me :-)
|
||||
def create_ical(ical_fragment=None, objtype=None, language="en_DK", **props):
|
||||
"""Creates some icalendar based on properties given as parameters.
|
||||
It basically creates an icalendar object with all the boilerplate,
|
||||
some sensible defaults, the properties given and returns it as a
|
||||
string.
|
||||
|
||||
TODO: timezones not supported so far
|
||||
"""
|
||||
ical_fragment = to_normal_str(ical_fragment)
|
||||
if "class_" in props:
|
||||
props["class"] = props.pop("class_")
|
||||
if not ical_fragment or not re.search("^BEGIN:V", ical_fragment, re.MULTILINE):
|
||||
my_instance = icalendar.Calendar()
|
||||
if objtype is None:
|
||||
objtype = "VEVENT"
|
||||
try:
|
||||
component = icalendar.cal.component_factory[objtype]()
|
||||
except:
|
||||
component = icalendar.cal.component_factory.ComponentFactory()[objtype]()
|
||||
my_instance.add_component(component)
|
||||
## STATUS should default to NEEDS-ACTION for tasks, if it's not set
|
||||
## (otherwise we cannot easily add a task to a davical calendar and
|
||||
## then find it again - ref https://gitlab.com/davical-project/davical/-/issues/281
|
||||
if (
|
||||
not props.get("status")
|
||||
and "\nSTATUS:" not in (ical_fragment or "")
|
||||
and objtype == "VTODO"
|
||||
):
|
||||
props["status"] = "NEEDS-ACTION"
|
||||
|
||||
else:
|
||||
if not ical_fragment.strip().startswith("BEGIN:VCALENDAR"):
|
||||
ical_fragment = (
|
||||
"BEGIN:VCALENDAR\n"
|
||||
+ to_normal_str(ical_fragment.strip())
|
||||
+ "\nEND:VCALENDAR\n"
|
||||
)
|
||||
my_instance = icalendar.Calendar.from_ical(ical_fragment)
|
||||
component = my_instance.subcomponents[0]
|
||||
ical_fragment = None
|
||||
|
||||
## Populate with mandatory fields, if missing
|
||||
if not my_instance.get("prodid"):
|
||||
my_instance.add("prodid", "-//python-caldav//caldav//" + language)
|
||||
if not my_instance.get("version"):
|
||||
my_instance.add("version", "2.0")
|
||||
if not component.get("dtstamp") and not props.get("dtstamp"):
|
||||
component.add("dtstamp", datetime.datetime.now(tz=datetime.timezone.utc))
|
||||
if not component.get("uid") and not props.get("uid"):
|
||||
component.add("uid", uuid.uuid1())
|
||||
|
||||
alarm = {}
|
||||
for prop in props:
|
||||
if props[prop] is not None:
|
||||
if isinstance(props[prop], datetime.datetime) and not props[prop].tzinfo:
|
||||
## We need to have a timezone! Assume UTC.
|
||||
props[prop] = props[prop].astimezone(datetime.timezone.utc)
|
||||
if prop in ("child", "parent"):
|
||||
for value in props[prop]:
|
||||
component.add(
|
||||
"related-to",
|
||||
str(value),
|
||||
parameters={"reltype": prop.upper()},
|
||||
encode=True,
|
||||
)
|
||||
elif prop.startswith("alarm_"):
|
||||
alarm[prop[6:]] = props[prop]
|
||||
else:
|
||||
component.add(prop, props[prop])
|
||||
if alarm:
|
||||
add_alarm(my_instance, alarm)
|
||||
ret = to_normal_str(my_instance.to_ical())
|
||||
if ical_fragment and ical_fragment.strip():
|
||||
ret = re.sub(
|
||||
"^END:V",
|
||||
ical_fragment.strip() + "\nEND:V",
|
||||
ret,
|
||||
flags=re.MULTILINE,
|
||||
count=1,
|
||||
)
|
||||
return ret
|
||||
|
||||
|
||||
def add_alarm(ical, alarm):
|
||||
ia = icalendar.Alarm()
|
||||
for prop in alarm:
|
||||
ia.add(prop, alarm[prop])
|
||||
ical.subcomponents[0].add_component(ia)
|
||||
return ical
|
||||
17
.venv/lib/python3.9/site-packages/caldav/objects.py
Executable file
17
.venv/lib/python3.9/site-packages/caldav/objects.py
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
I got fed up with several thousand lines of code in one and the same file.
|
||||
|
||||
This file is by now just a backward compatibility layer.
|
||||
|
||||
Logic has been split out:
|
||||
|
||||
* DAVObject base class -> davobject.py
|
||||
* CalendarObjectResource base class -> calendarobjectresource.py
|
||||
* Event/Todo/Journal/FreeBusy -> calendarobjectresource.py
|
||||
* Everything else (mostly collection objects) -> collection.py
|
||||
"""
|
||||
## For backward compatibility
|
||||
from .calendarobjectresource import *
|
||||
from .collection import *
|
||||
from .davobject import *
|
||||
0
.venv/lib/python3.9/site-packages/caldav/py.typed
Normal file
0
.venv/lib/python3.9/site-packages/caldav/py.typed
Normal file
19
.venv/lib/python3.9/site-packages/caldav/requests.py
Normal file
19
.venv/lib/python3.9/site-packages/caldav/requests.py
Normal file
@@ -0,0 +1,19 @@
|
||||
try:
|
||||
from niquests.auth import AuthBase
|
||||
except ImportError:
|
||||
from requests.auth import AuthBase
|
||||
|
||||
|
||||
class HTTPBearerAuth(AuthBase):
|
||||
def __init__(self, password: str) -> None:
|
||||
self.password = password
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
return self.password == getattr(other, "password", None)
|
||||
|
||||
def __ne__(self, other: object) -> bool:
|
||||
return not self == other
|
||||
|
||||
def __call__(self, r):
|
||||
r.headers["Authorization"] = f"Bearer {self.password}"
|
||||
return r
|
||||
783
.venv/lib/python3.9/site-packages/caldav/search.py
Normal file
783
.venv/lib/python3.9/site-packages/caldav/search.py
Normal file
@@ -0,0 +1,783 @@
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
from icalendar import Timezone
|
||||
from icalendar.prop import TypesFactory
|
||||
from icalendar_searcher import Searcher
|
||||
from icalendar_searcher.collation import Collation
|
||||
from lxml import etree
|
||||
|
||||
from .calendarobjectresource import CalendarObjectResource
|
||||
from .calendarobjectresource import Event
|
||||
from .calendarobjectresource import Journal
|
||||
from .calendarobjectresource import Todo
|
||||
from .collection import Calendar
|
||||
from .elements import cdav
|
||||
from .elements import dav
|
||||
from .elements.base import BaseElement
|
||||
from .lib import error
|
||||
|
||||
TypesFactory = TypesFactory()
|
||||
|
||||
|
||||
def _collation_to_caldav(collation: Collation, case_sensitive: bool = True) -> str:
|
||||
"""Map icalendar-searcher Collation enum to CalDAV collation identifier.
|
||||
|
||||
CalDAV supports collation identifiers from RFC 4790. The default is "i;ascii-casemap"
|
||||
and servers must support at least "i;ascii-casemap" and "i;octet".
|
||||
|
||||
:param collation: icalendar-searcher Collation enum value
|
||||
:param case_sensitive: Whether the collation should be case-sensitive
|
||||
:return: CalDAV collation identifier string
|
||||
"""
|
||||
if collation == Collation.SIMPLE:
|
||||
# SIMPLE collation maps to CalDAV's basic collations
|
||||
if case_sensitive:
|
||||
return "i;octet"
|
||||
else:
|
||||
return "i;ascii-casemap"
|
||||
elif collation == Collation.UNICODE:
|
||||
# Unicode Collation Algorithm - not all servers support this
|
||||
# Note: "i;unicode-casemap" is case-insensitive by definition
|
||||
# For case-sensitive Unicode, we fall back to i;octet (binary)
|
||||
if case_sensitive:
|
||||
return "i;octet"
|
||||
else:
|
||||
return "i;unicode-casemap"
|
||||
elif collation == Collation.LOCALE:
|
||||
# Locale-specific collation - not widely supported in CalDAV
|
||||
# Fallback to i;ascii-casemap as most servers don't support locale-specific collations
|
||||
return "i;ascii-casemap"
|
||||
else:
|
||||
# Default to binary/octet for unknown collations
|
||||
return "i;octet"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalDAVSearcher(Searcher):
|
||||
"""The baseclass (which is generic, and not CalDAV-specific)
|
||||
allows building up a search query search logic.
|
||||
|
||||
The base class also allows for simple client-side filtering (and
|
||||
at some point in the future, more complex client-side filtering).
|
||||
|
||||
The CalDAV protocol is difficult, ambigiuous and does not offer
|
||||
all kind of searches. Client-side filtering may be needed to
|
||||
smoothen over differences in how the different servers handle
|
||||
search queries, as well as allowing for more complex searches.
|
||||
|
||||
A search may be performed by first setting up a CalDAVSearcher,
|
||||
populate it with filter options, and then initiate the search from
|
||||
he CalDAVSearcher. Something like this (see the doc in the base
|
||||
class):
|
||||
|
||||
``ComponentSearchFilter(from=..., to=...).search(calendar)``
|
||||
|
||||
However, for simple searches, the old way to
|
||||
do it will always work:
|
||||
|
||||
``calendar.search(from=..., to=..., ...)``
|
||||
|
||||
The ``todo``, ``event`` and ``journal`` parameters are booleans
|
||||
for filtering the component type. It's currently recommended to
|
||||
set one and only one of them to True, as of 2025-11 there is no
|
||||
guarantees for correct behaviour if setting two of them. Also, if
|
||||
none is given (the default), all objects should be returned -
|
||||
however, all examples in the CalDAV RFC filters things by
|
||||
component, and the different servers do different things when
|
||||
confronted with a search missing component type. With the correct
|
||||
``compatibility_hints`` (``davclient.features``) configured for
|
||||
the caldav server, the algorithms will ensure correct behaviour.
|
||||
|
||||
Both the iCalendar standard and the (Cal)DAV standard defines
|
||||
"properties". Make sure not to confuse those. iCalendar
|
||||
properties used for filtering can be passed using
|
||||
``searcher.add_property_filter``.
|
||||
"""
|
||||
|
||||
comp_class: Optional["CalendarObjectResource"] = None
|
||||
_explicit_operators: set = field(default_factory=set)
|
||||
|
||||
def add_property_filter(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
operator: str = None,
|
||||
case_sensitive: bool = True,
|
||||
collation: Optional[Collation] = None,
|
||||
locale: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Adds a filter for some specific iCalendar property.
|
||||
|
||||
Examples of valid iCalendar properties: SUMMARY,
|
||||
LOCATION, DESCRIPTION, DTSTART, STATUS, CLASS, etc
|
||||
|
||||
:param key: iCalendar property name (e.g., SUMMARY).
|
||||
Special virtual property "category" (singular) is also supported
|
||||
for substring matching within category names
|
||||
:param value: Filter value, should adhere to the type defined in the RFC
|
||||
:param operator: Comparison operator ("contains", "==", "undef"). If not
|
||||
specified, the server decides the matching behavior (usually
|
||||
substring search per RFC). If explicitly set to "contains"
|
||||
and the server doesn't support substring search, client-side
|
||||
filtering is used (may transfer more data from server).
|
||||
:param case_sensitive: If False, text comparisons are case-insensitive.
|
||||
Note: CalDAV standard case-insensitivity only applies
|
||||
to ASCII characters.
|
||||
:param collation: Advanced collation strategy for text comparison.
|
||||
May not work on all servers.
|
||||
:param locale: Locale string (e.g., "de_DE") for locale-aware collation.
|
||||
Only used with collation=Collation.LOCALE. May not work on
|
||||
all servers.
|
||||
|
||||
**Supported operators:**
|
||||
|
||||
* **contains** - substring match (e.g., "rain" matches "Training session"
|
||||
and "Singing in the rain")
|
||||
* **==** - exact match required, enforced client-side
|
||||
* **undef** - matches if property is not defined (value parameter ignored)
|
||||
|
||||
**Special handling for categories:**
|
||||
|
||||
- **"categories"** (plural): Exact category name matching
|
||||
- "contains": subset check (all filter categories must be in component)
|
||||
- "==": exact set equality (same categories, order doesn't matter)
|
||||
- Commas in filter values split into multiple categories
|
||||
|
||||
- **"category"** (singular): Substring matching within category names
|
||||
- "contains": substring match (e.g., "out" matches "outdoor")
|
||||
- "==": exact match to at least one category name
|
||||
- Commas in filter values treated as literal characters
|
||||
|
||||
Examples:
|
||||
# Case-insensitive search
|
||||
searcher.add_property_filter("SUMMARY", "meeting", case_sensitive=False)
|
||||
|
||||
# Explicit substring search (guaranteed via client-side if needed)
|
||||
searcher.add_property_filter("LOCATION", "room", operator="contains")
|
||||
|
||||
# Exact match
|
||||
searcher.add_property_filter("STATUS", "CONFIRMED", operator="==")
|
||||
"""
|
||||
if operator is not None:
|
||||
# Base class lowercases the key, so we need to as well
|
||||
self._explicit_operators.add(key.lower())
|
||||
super().add_property_filter(
|
||||
key, value, operator, case_sensitive, collation, locale
|
||||
)
|
||||
else:
|
||||
# operator not specified - don't pass it, let base class use default
|
||||
# Don't track as explicit
|
||||
super().add_property_filter(
|
||||
key,
|
||||
value,
|
||||
case_sensitive=case_sensitive,
|
||||
collation=collation,
|
||||
locale=locale,
|
||||
)
|
||||
|
||||
def _search_with_comptypes(
|
||||
self,
|
||||
calendar: Calendar,
|
||||
server_expand: bool = False,
|
||||
split_expanded: bool = True,
|
||||
props: Optional[List[cdav.CalendarData]] = None,
|
||||
xml: str = None,
|
||||
_hacks: str = None,
|
||||
post_filter: bool = None,
|
||||
) -> List[CalendarObjectResource]:
|
||||
"""
|
||||
Internal method - does three searches, one for each comp class (event, journal, todo).
|
||||
"""
|
||||
if xml and (isinstance(xml, str) or "calendar-query" in xml.tag):
|
||||
raise NotImplementedError(
|
||||
"full xml given, and it has to be patched to include comp_type"
|
||||
)
|
||||
objects = []
|
||||
|
||||
assert self.event is None and self.todo is None and self.journal is None
|
||||
|
||||
for comp_class in (Event, Todo, Journal):
|
||||
clone = replace(self)
|
||||
clone.comp_class = comp_class
|
||||
objects += clone.search(
|
||||
calendar, server_expand, split_expanded, props, xml, post_filter, _hacks
|
||||
)
|
||||
return self.sort(objects)
|
||||
|
||||
## TODO: refactor, split more logic out in smaller methods
|
||||
def search(
|
||||
self,
|
||||
calendar: Calendar,
|
||||
server_expand: bool = False,
|
||||
split_expanded: bool = True,
|
||||
props: Optional[List[cdav.CalendarData]] = None,
|
||||
xml: str = None,
|
||||
post_filter=None,
|
||||
_hacks: str = None,
|
||||
) -> List[CalendarObjectResource]:
|
||||
"""Do the search on a CalDAV calendar.
|
||||
|
||||
Only CalDAV-specific parameters goes to this method. Those
|
||||
parameters are pretty obscure - mostly for power users and
|
||||
internal usage. Unless you have some very special needs, the
|
||||
recommendation is to not pass anything but the calendar.
|
||||
|
||||
:param calendar: Calendar to be searched
|
||||
:param server_expand: Ask the CalDAV server to expand recurrences
|
||||
:param split_expanded: Don't collect a recurrence set in one ical calendar
|
||||
:param props: CalDAV properties to send in the query
|
||||
:param xml: XML query to be sent to the server (string or elements)
|
||||
:param post_filter: Do client-side filtering after querying the server
|
||||
:param _hacks: Please don't ask!
|
||||
|
||||
Make sure not to confuse he CalDAV properties with iCalendar properties.
|
||||
|
||||
If ``xml`` is given, any other filtering will not be sent to the server.
|
||||
They may still be applied through client-side filtering. (TODO: work in progress)
|
||||
|
||||
``post_filter`` takes three values, ``True`` will always
|
||||
filter the results, ``False`` will never filter the results,
|
||||
and the default ``None`` will cause automagics to happen (not
|
||||
implemented yet). Or perhaps I'll just set it to True as
|
||||
default. TODO - make a decision here
|
||||
|
||||
In the CalDAV protocol, a VCALENDAR object returned from the
|
||||
server may contain only one event/task/journal - but if the
|
||||
object is recurrent, it may contain several recurrences.
|
||||
``split_expanded`` will split the recurrences into several
|
||||
objects. If you don't know what you're doing, then leave this
|
||||
flag on.
|
||||
|
||||
Use ``searcher.search(calendar)`` to apply the search on a caldav server.
|
||||
|
||||
"""
|
||||
## Handle servers with broken component-type filtering (e.g., Bedework)
|
||||
## Such servers may misclassify component types in responses
|
||||
comp_type_support = calendar.client.features.is_supported(
|
||||
"search.comp-type", str
|
||||
)
|
||||
if (
|
||||
(self.comp_class or self.todo or self.event or self.journal)
|
||||
and comp_type_support == "broken"
|
||||
and not _hacks
|
||||
and post_filter is not False
|
||||
):
|
||||
_hacks = "no_comp_filter"
|
||||
post_filter = True
|
||||
|
||||
## Setting default value for post_filter
|
||||
if post_filter is None and (
|
||||
(self.todo and not self.include_completed)
|
||||
or self.expand
|
||||
or "categories" in self._property_filters
|
||||
or "category" in self._property_filters
|
||||
or not calendar.client.features.is_supported("search.text.case-sensitive")
|
||||
or not calendar.client.features.is_supported("search.time-range.accurate")
|
||||
):
|
||||
post_filter = True
|
||||
|
||||
## split_expanded should only take effect on expanded data
|
||||
if not self.expand and not server_expand:
|
||||
split_expanded = False
|
||||
|
||||
if self.expand or server_expand:
|
||||
if not self.start or not self.end:
|
||||
raise error.ReportError("can't expand without a date range")
|
||||
|
||||
## special compatbility-case for servers that does not
|
||||
## support category search properly
|
||||
things = ("filters", "operator", "locale", "collation")
|
||||
things = [f"_property_{thing}" for thing in things]
|
||||
if (
|
||||
not calendar.client.features.is_supported("search.text.category")
|
||||
and (
|
||||
"categories" in self._property_filters
|
||||
or "category" in self._property_filters
|
||||
)
|
||||
and post_filter is not False
|
||||
):
|
||||
replacements = {}
|
||||
for thing in things:
|
||||
replacements[thing] = getattr(self, thing).copy()
|
||||
replacements[thing].pop("categories", None)
|
||||
replacements[thing].pop("category", None)
|
||||
clone = replace(self, **replacements)
|
||||
objects = clone.search(calendar, server_expand, split_expanded, props, xml)
|
||||
return self.filter(objects, post_filter, split_expanded, server_expand)
|
||||
|
||||
## special compatibility-case for servers that do not support substring search
|
||||
## Only applies when user explicitly requested substring search with operator="contains"
|
||||
if (
|
||||
not calendar.client.features.is_supported("search.text.substring")
|
||||
and post_filter is not False
|
||||
):
|
||||
# Check if any property has explicitly specified operator="contains"
|
||||
explicit_contains = [
|
||||
prop
|
||||
for prop in self._property_operator
|
||||
if prop in self._explicit_operators
|
||||
and self._property_operator[prop] == "contains"
|
||||
]
|
||||
if explicit_contains:
|
||||
# Remove explicit substring filters from server query,
|
||||
# will be applied client-side instead
|
||||
replacements = {}
|
||||
for thing in things:
|
||||
replacements[thing] = getattr(self, thing).copy()
|
||||
for prop in explicit_contains:
|
||||
replacements[thing].pop(prop, None)
|
||||
# Also need to preserve the _explicit_operators set but remove these properties
|
||||
clone = replace(self, **replacements)
|
||||
clone._explicit_operators = self._explicit_operators - set(
|
||||
explicit_contains
|
||||
)
|
||||
objects = clone.search(
|
||||
calendar, server_expand, split_expanded, props, xml
|
||||
)
|
||||
return self.filter(
|
||||
objects,
|
||||
post_filter=True,
|
||||
split_expanded=split_expanded,
|
||||
server_expand=server_expand,
|
||||
)
|
||||
|
||||
## special compatibility-case for servers that does not
|
||||
## support combined searches very well
|
||||
if not calendar.client.features.is_supported("search.combined-is-logical-and"):
|
||||
if self.start or self.end:
|
||||
if self._property_filters:
|
||||
replacements = {}
|
||||
for thing in things:
|
||||
replacements[thing] = {}
|
||||
clone = replace(self, **replacements)
|
||||
objects = clone.search(
|
||||
calendar, server_expand, split_expanded, props, xml
|
||||
)
|
||||
return self.filter(
|
||||
objects, post_filter, split_expanded, server_expand
|
||||
)
|
||||
|
||||
## special compatibility-case when searching for pending todos
|
||||
if self.todo and not self.include_completed:
|
||||
## There are two ways to get the pending tasks - we can
|
||||
## ask the server to filter them out, or we can do it
|
||||
## client side.
|
||||
|
||||
## If the server does not support combined searches, then it's
|
||||
## safest to do it client-side.
|
||||
|
||||
## There is a special case (observed with radicale as of
|
||||
## 2025-11) where future recurrences of a task does not
|
||||
## match when doing a server-side filtering, so for this
|
||||
## case we also do client-side filtering (but the
|
||||
## "feature"
|
||||
## search.recurrences.includes-implicit.todo.pending will
|
||||
## not be supported if the feature
|
||||
## "search.recurrences.includes-implicit.todo" is not
|
||||
## supported ... hence the weird or below)
|
||||
|
||||
## To be completely sure to get all pending tasks, for all
|
||||
## server implementations and for all valid icalendar
|
||||
## objects, we send three different searches to the
|
||||
## server. This is probably bloated, and may in many
|
||||
## cases be more expensive than to ask for all tasks. At
|
||||
## the other hand, for a well-used and well-handled old
|
||||
## todo-list, there may be a small set of pending tasks
|
||||
## and heaps of done tasks.
|
||||
|
||||
## TODO: consider if not ignore_completed3 is sufficient,
|
||||
## then the recursive part of the query here is moot, and
|
||||
## we wouldn't waste so much time on repeated queries
|
||||
clone = replace(self, include_completed=True)
|
||||
clone.include_completed = True
|
||||
## No point with expanding in the subqueries - the expand logic will be handled
|
||||
## further down. We leave server_expand as it is, though.
|
||||
clone.expand = False
|
||||
if (
|
||||
calendar.client.features.is_supported("search.text")
|
||||
and calendar.client.features.is_supported(
|
||||
"search.combined-is-logical-and"
|
||||
)
|
||||
and (
|
||||
not calendar.client.features.is_supported(
|
||||
"search.recurrences.includes-implicit.todo"
|
||||
)
|
||||
or calendar.client.features.is_supported(
|
||||
"search.recurrences.includes-implicit.todo.pending"
|
||||
)
|
||||
)
|
||||
):
|
||||
matches = []
|
||||
for hacks in (
|
||||
"ignore_completed1",
|
||||
"ignore_completed2",
|
||||
"ignore_completed3",
|
||||
):
|
||||
## The algorithm below does not handle recurrence split gently
|
||||
matches.extend(
|
||||
clone.search(
|
||||
calendar,
|
||||
server_expand,
|
||||
split_expanded=False,
|
||||
props=props,
|
||||
xml=xml,
|
||||
_hacks=hacks,
|
||||
)
|
||||
)
|
||||
else:
|
||||
## The algorithm below does not handle recurrence split gently
|
||||
matches = clone.search(
|
||||
calendar,
|
||||
server_expand,
|
||||
split_expanded=False,
|
||||
props=props,
|
||||
xml=xml,
|
||||
_hacks=_hacks,
|
||||
)
|
||||
objects = []
|
||||
match_set = set()
|
||||
for item in matches:
|
||||
if item.url not in match_set:
|
||||
match_set.add(item.url)
|
||||
objects.append(item)
|
||||
else:
|
||||
orig_xml = xml
|
||||
|
||||
## Now the xml variable may be either a full query or a filter
|
||||
## and it may be either a string or an object.
|
||||
if not xml or (
|
||||
not isinstance(xml, str) and not xml.tag.endswith("calendar-query")
|
||||
):
|
||||
(xml, self.comp_class) = self.build_search_xml_query(
|
||||
server_expand, props=props, filters=xml, _hacks=_hacks
|
||||
)
|
||||
|
||||
if not self.comp_class and not calendar.client.features.is_supported(
|
||||
"search.comp-type-optional"
|
||||
):
|
||||
if self.include_completed is None:
|
||||
self.include_completed = True
|
||||
|
||||
return self._search_with_comptypes(
|
||||
calendar,
|
||||
server_expand,
|
||||
split_expanded,
|
||||
props,
|
||||
orig_xml,
|
||||
post_filter,
|
||||
_hacks,
|
||||
)
|
||||
|
||||
try:
|
||||
(response, objects) = calendar._request_report_build_resultlist(
|
||||
xml, self.comp_class, props=props
|
||||
)
|
||||
|
||||
except error.ReportError as err:
|
||||
## This is only for backward compatibility.
|
||||
## Partial fix https://github.com/python-caldav/caldav/issues/401
|
||||
if (
|
||||
calendar.client.features.backward_compatibility_mode
|
||||
and not self.comp_class
|
||||
and not "400" in err.reason
|
||||
):
|
||||
return self._search_with_comptypes(
|
||||
calendar,
|
||||
server_expand,
|
||||
split_expanded,
|
||||
props,
|
||||
orig_xml,
|
||||
post_filter,
|
||||
_hacks,
|
||||
)
|
||||
raise
|
||||
|
||||
## Some things, like `calendar.object_by_uid`, should always work, no matter if `davclient.compatibility_hints` is correctly configured or not
|
||||
if not objects and not self.comp_class and _hacks == "insist":
|
||||
return self._search_with_comptypes(
|
||||
calendar,
|
||||
server_expand,
|
||||
split_expanded,
|
||||
props,
|
||||
orig_xml,
|
||||
post_filter,
|
||||
_hacks,
|
||||
)
|
||||
|
||||
obj2 = []
|
||||
|
||||
for o in objects:
|
||||
## This would not be needed if the servers would follow the standard ...
|
||||
## TODO: use calendar.calendar_multiget - see https://github.com/python-caldav/caldav/issues/487
|
||||
try:
|
||||
o.load(only_if_unloaded=True)
|
||||
obj2.append(o)
|
||||
except:
|
||||
logging.error(
|
||||
"Server does not want to reveal details about the calendar object",
|
||||
exc_info=True,
|
||||
)
|
||||
pass
|
||||
objects = obj2
|
||||
|
||||
## Google sometimes returns empty objects
|
||||
objects = [o for o in objects if o.has_component()]
|
||||
objects = self.filter(objects, post_filter, split_expanded, server_expand)
|
||||
|
||||
## partial workaround for https://github.com/python-caldav/caldav/issues/201
|
||||
for obj in objects:
|
||||
try:
|
||||
obj.load(only_if_unloaded=True)
|
||||
except:
|
||||
pass
|
||||
|
||||
return self.sort(objects)
|
||||
|
||||
def filter(
|
||||
self,
|
||||
objects: List[CalendarObjectResource],
|
||||
post_filter: Optional[bool] = None,
|
||||
split_expanded: bool = True,
|
||||
server_expand: bool = False,
|
||||
) -> List[CalendarObjectResource]:
|
||||
"""Apply client-side filtering and handle recurrence expansion/splitting.
|
||||
|
||||
This method performs client-side filtering of calendar objects, handles
|
||||
recurrence expansion, and splits expanded recurrences into separate objects
|
||||
when requested.
|
||||
|
||||
:param objects: List of Event/Todo/Journal objects to filter
|
||||
:param post_filter: Whether to apply the searcher's filter logic.
|
||||
- True: Always apply filters (check_component)
|
||||
- False: Never apply filters, only handle splitting
|
||||
- None: Use default behavior (depends on self.expand and other flags)
|
||||
:param split_expanded: Whether to split recurrence sets into multiple
|
||||
separate CalendarObjectResource objects. If False, a recurrence set
|
||||
will be contained in a single object with multiple subcomponents.
|
||||
:param server_expand: Indicates that the server was supposed to expand
|
||||
recurrences. If True and split_expanded is True, splitting will be
|
||||
performed even without self.expand being set.
|
||||
:return: Filtered and/or split list of CalendarObjectResource objects
|
||||
|
||||
The method handles:
|
||||
- Client-side filtering when server returns too many results
|
||||
- Exact match filtering (== operator)
|
||||
- Recurrence expansion via self.check_component
|
||||
- Splitting expanded recurrences into separate objects
|
||||
- Preserving VTIMEZONE components when splitting
|
||||
"""
|
||||
if post_filter or self.expand or (split_expanded and server_expand):
|
||||
objects_ = objects
|
||||
objects = []
|
||||
for o in objects_:
|
||||
if self.expand or post_filter:
|
||||
filtered = self.check_component(o, expand_only=not post_filter)
|
||||
if not filtered:
|
||||
continue
|
||||
else:
|
||||
filtered = [
|
||||
x
|
||||
for x in o.icalendar_instance.subcomponents
|
||||
if not isinstance(x, Timezone)
|
||||
]
|
||||
i = o.icalendar_instance
|
||||
tz_ = [x for x in i.subcomponents if isinstance(x, Timezone)]
|
||||
i.subcomponents = tz_
|
||||
for comp in filtered:
|
||||
if isinstance(comp, Timezone):
|
||||
continue
|
||||
if split_expanded:
|
||||
new_obj = o.copy(keep_uid=True)
|
||||
new_i = new_obj.icalendar_instance
|
||||
new_i.subcomponents = []
|
||||
for tz in tz_:
|
||||
new_i.add_component(tz)
|
||||
objects.append(new_obj)
|
||||
else:
|
||||
new_i = i
|
||||
new_i.add_component(comp)
|
||||
if not (split_expanded):
|
||||
objects.append(o)
|
||||
return objects
|
||||
|
||||
def build_search_xml_query(
|
||||
self, server_expand=False, props=None, filters=None, _hacks=None
|
||||
):
|
||||
"""This method will produce a caldav search query as an etree object.
|
||||
|
||||
It is primarily to be used from the search method. See the
|
||||
documentation for the search method for more information.
|
||||
"""
|
||||
# those xml elements are weird. (a+b)+c != a+(b+c). First makes b and c as list members of a, second makes c an element in b which is an element of a.
|
||||
# First objective is to let this take over all xml search query building and see that the current tests pass.
|
||||
# ref https://www.ietf.org/rfc/rfc4791.txt, section 7.8.9 for how to build a todo-query
|
||||
# We'll play with it and don't mind it's getting ugly and don't mind that the test coverage is lacking.
|
||||
# we'll refactor and create some unit tests later, as well as ftests for complicated queries.
|
||||
|
||||
# build the request
|
||||
data = cdav.CalendarData()
|
||||
if server_expand:
|
||||
if not self.start or not self.end:
|
||||
raise error.ReportError("can't expand without a date range")
|
||||
data += cdav.Expand(self.start, self.end)
|
||||
if props is None:
|
||||
props_ = [data]
|
||||
else:
|
||||
props_ = [data] + props
|
||||
prop = dav.Prop() + props_
|
||||
vcalendar = cdav.CompFilter("VCALENDAR")
|
||||
|
||||
comp_filter = None
|
||||
|
||||
if filters:
|
||||
## It's disgraceful - `somexml = xml + [ more_elements ]` will alter xml,
|
||||
## and there exists no `xml.copy`
|
||||
## Hence, we need to import the deepcopy tool ...
|
||||
filters = deepcopy(filters)
|
||||
if filters.tag == cdav.CompFilter.tag:
|
||||
comp_filter = filters
|
||||
filters = []
|
||||
|
||||
else:
|
||||
filters = []
|
||||
|
||||
vNotCompleted = cdav.TextMatch("COMPLETED", negate=True)
|
||||
vNotCancelled = cdav.TextMatch("CANCELLED", negate=True)
|
||||
vNeedsAction = cdav.TextMatch("NEEDS-ACTION")
|
||||
vStatusNotCompleted = cdav.PropFilter("STATUS") + vNotCompleted
|
||||
vStatusNotCancelled = cdav.PropFilter("STATUS") + vNotCancelled
|
||||
vStatusNeedsAction = cdav.PropFilter("STATUS") + vNeedsAction
|
||||
vStatusNotDefined = cdav.PropFilter("STATUS") + cdav.NotDefined()
|
||||
vNoCompleteDate = cdav.PropFilter("COMPLETED") + cdav.NotDefined()
|
||||
if _hacks == "ignore_completed1":
|
||||
## This query is quite much in line with https://tools.ietf.org/html/rfc4791#section-7.8.9
|
||||
filters.extend([vNoCompleteDate, vStatusNotCompleted, vStatusNotCancelled])
|
||||
elif _hacks == "ignore_completed2":
|
||||
## some server implementations (i.e. NextCloud
|
||||
## and Baikal) will yield "false" on a negated TextMatch
|
||||
## if the field is not defined. Hence, for those
|
||||
## implementations we need to turn back and ask again
|
||||
## ... do you have any VTODOs for us where the STATUS
|
||||
## field is not defined? (ref
|
||||
## https://github.com/python-caldav/caldav/issues/14)
|
||||
filters.extend([vNoCompleteDate, vStatusNotDefined])
|
||||
elif _hacks == "ignore_completed3":
|
||||
## ... and considering recurring tasks we really need to
|
||||
## look a third time as well, this time for any task with
|
||||
## the NEEDS-ACTION status set (do we need the first go?
|
||||
## NEEDS-ACTION or no status set should cover them all?)
|
||||
filters.extend([vStatusNeedsAction])
|
||||
|
||||
if self.start or self.end:
|
||||
filters.append(cdav.TimeRange(self.start, self.end))
|
||||
|
||||
if self.alarm_start or self.alarm_end:
|
||||
filters.append(
|
||||
cdav.CompFilter("VALARM")
|
||||
+ cdav.TimeRange(self.alarm_start, self.alarm_end)
|
||||
)
|
||||
|
||||
## I've designed this badly, at different places the caller
|
||||
## may pass the component type either as boolean flags:
|
||||
## `search(event=True, ...)`
|
||||
## as a component class:
|
||||
## `search(comp_class=caldav.calendarobjectresource.Event)`
|
||||
## or as a component filter:
|
||||
## `search(filters=cdav.CompFilter('VEVENT'), ...)`
|
||||
## The only thing I don't support is the component name ('VEVENT').
|
||||
## Anyway, this code section ensures both comp_filter and comp_class
|
||||
## is given. Or at least, it tries to ensure it.
|
||||
for flag, comp_name, comp_class_ in (
|
||||
("event", "VEVENT", Event),
|
||||
("todo", "VTODO", Todo),
|
||||
("journal", "VJOURNAL", Journal),
|
||||
):
|
||||
flagged = getattr(self, flag)
|
||||
if flagged:
|
||||
## event/journal/todo is set, we adjust comp_class accordingly
|
||||
if self.comp_class is not None and self.comp_class is not comp_class_:
|
||||
raise error.ConsistencyError(
|
||||
f"inconsistent search parameters - comp_class = {self.comp_class}, want {comp_class_}"
|
||||
)
|
||||
self.comp_class = comp_class_
|
||||
|
||||
if comp_filter and comp_filter.attributes["name"] == comp_name:
|
||||
self.comp_class = comp_class_
|
||||
if flag == "todo" and not self.todo and self.include_completed is None:
|
||||
self.include_completed = True
|
||||
setattr(self, flag, True)
|
||||
|
||||
if self.comp_class == comp_class_:
|
||||
if comp_filter:
|
||||
assert comp_filter.attributes["name"] == comp_name
|
||||
else:
|
||||
comp_filter = cdav.CompFilter(comp_name)
|
||||
setattr(self, flag, True)
|
||||
|
||||
if self.comp_class and not comp_filter:
|
||||
raise error.ConsistencyError(
|
||||
f"unsupported comp class {self.comp_class} for search"
|
||||
)
|
||||
|
||||
## Special hack for bedework.
|
||||
## If asked for todos, we should NOT give any comp_filter to the server,
|
||||
## we should rather ask for everything, and then do client-side filtering
|
||||
if _hacks == "no_comp_filter":
|
||||
comp_filter = None
|
||||
self.comp_class = None
|
||||
|
||||
for property in self._property_operator:
|
||||
if self._property_operator[property] == "undef":
|
||||
match = cdav.NotDefined()
|
||||
filters.append(cdav.PropFilter(property.upper()) + match)
|
||||
else:
|
||||
value = self._property_filters[property]
|
||||
property_ = property.upper()
|
||||
if property.lower() == "category":
|
||||
property_ = "CATEGORIES"
|
||||
if property.lower() == "categories":
|
||||
values = value.cats
|
||||
else:
|
||||
values = [value]
|
||||
|
||||
for value in values:
|
||||
if hasattr(value, "to_ical"):
|
||||
value = value.to_ical()
|
||||
|
||||
# Get collation setting for this property if available
|
||||
collation_str = "i;octet" # Default to binary
|
||||
if (
|
||||
hasattr(self, "_property_collation")
|
||||
and property in self._property_collation
|
||||
):
|
||||
case_sensitive = self._property_case_sensitive.get(
|
||||
property, True
|
||||
)
|
||||
collation_str = _collation_to_caldav(
|
||||
self._property_collation[property], case_sensitive
|
||||
)
|
||||
|
||||
match = cdav.TextMatch(value, collation=collation_str)
|
||||
filters.append(cdav.PropFilter(property_) + match)
|
||||
|
||||
if comp_filter and filters:
|
||||
comp_filter += filters
|
||||
vcalendar += comp_filter
|
||||
elif comp_filter:
|
||||
vcalendar += comp_filter
|
||||
elif filters:
|
||||
vcalendar += filters
|
||||
|
||||
filter = cdav.Filter() + vcalendar
|
||||
|
||||
root = cdav.CalendarQuery() + [prop, filter]
|
||||
|
||||
return (root, self.comp_class)
|
||||
Reference in New Issue
Block a user