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:
Hyungi Ahn
2026-03-19 13:53:55 +09:00
parent dc08d29509
commit c2257d3a86
2709 changed files with 619549 additions and 10 deletions

View 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"]

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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 {}

File diff suppressed because it is too large Load Diff

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

View 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,
)

View File

@@ -0,0 +1 @@
#!/usr/bin/env python

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

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

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

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

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

View 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"]

View 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

View File

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

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

View 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

View 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 *

View 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

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