Skip to content

Commit 7df3874

Browse files
authored
Connect without telling the (full) URL
Summary: * Use `features: posteo` instead of `url: https://posteo.de:8443/` in the connection configuration. * Use `features: nextcloud` and `url: my.nextcloud.provider.eu` instead of `url: https://my.nextcloud.provider.eu/remote.php/dav` Details: * The server compatibility hints given during connection may now include basepath and domain * If no URL is given in the connection parameters, or if the URL is just the domain name (without any slashes), then the URL will be deducted from the server compatibility hints. * It's now possible to pass the features as a string label referencing the server. This is needed for configuration file support. This solves #463 Documentation remains, it will be fixed later. The biggest providers like iCloud, Google and Fastmail is still mssing, it will be fixed later. Auto-detection of the server in use is also missing. This is also procrastinated until later. (But obviously, if no URL is given, auto-detection will fail. For well-known cloud solutions, passing the label rather than the URL should always be easier).
1 parent 90dee4f commit 7df3874

File tree

4 files changed

+156
-54
lines changed

4 files changed

+156
-54
lines changed

caldav/compatibility_hints.py

Lines changed: 105 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
This file serves as a database of different compatibility issues we've
44
encountered while working on the caldav library, and descriptions on
55
how the well-known servers behave.
6+
7+
TODO: it should probably be split with the "feature definitions",
8+
"server implementation details" and "feature database logic" in three separate files.
69
"""
710
import copy
811

@@ -19,8 +22,8 @@ class FeatureSet:
1922
2023
An object of this class describes the feature set of a server.
2124
22-
TODO: use enums?
23-
type -> "client-feature", "server-peculiarity", "tests-behaviour", "server-observation", "server-feature" (last is default)
25+
TODO: use enums? TODO: describe the different types TODO: think more through the different types, consolidate?
26+
type -> "client-feature", "client-hints", "server-peculiarity", "tests-behaviour", "server-observation", "server-feature" (last is default)
2427
support -> "supported" (default), "unsupported", "fragile", "quirk", "broken", "ungraceful"
2528
2629
types:
@@ -32,6 +35,22 @@ class FeatureSet:
3235
* "support" -> "quirk" if we have a server-peculiarity where it's needed with special care to get the request through.
3336
"""
3437
FEATURES = {
38+
"auto-connect": {
39+
## Nothing here - everything is under auto-connect.url as for now.
40+
## Other connection details - like what auth method to use - could also
41+
## be under the auto-connect umbrella
42+
"type": "client-hints",
43+
},
44+
"auto-connect.url": {
45+
"description": "Instruction for how to access DAV. I.e. `/remote.php/dav` - see also https://github.com/python-caldav/caldav/issues/463. To be used in the get_davclient method if the URL only contains a domain",
46+
"type": "client-hints",
47+
"extra_keys": {
48+
"basepath": "The path to append to the domain",
49+
"domain": "Domain name may be given through the features - useful for well-known cloud solutions",
50+
"scheme": "The scheme to prepend to the domain. Defaults to https",
51+
## TODO: in the future, templates for the principal URL, calendar URLs etc may also be added.
52+
}
53+
},
3554
"get-all-principals": {
3655
"description": "Search for all principals, using a DAV REPORT query, yields at least one principal"
3756
},
@@ -182,6 +201,8 @@ def __init__(self, feature_set_dict=None):
182201
## changed ... but we need test code in place)
183202
self.backward_compatibility_mode = feature_set_dict is None
184203
self._server_features = {}
204+
## TODO: remove this when it can be removed
205+
self._old_flags = []
185206
if feature_set_dict:
186207
self.copyFeatureSet(feature_set_dict)
187208

@@ -190,6 +211,7 @@ def copyFeatureSet(self, feature_set, collapse=True):
190211
for feature in feature_set:
191212
## TODO: temp - should be removed
192213
if feature == 'old_flags':
214+
self._old_flags = feature_set[feature]
193215
continue
194216
feature_info = self.find_feature(feature)
195217
value = feature_set[feature]
@@ -260,7 +282,7 @@ def _default(self, feature_info):
260282
return { "behaviour": "normal" }
261283
elif feature_type == 'server-observation':
262284
return { "observed": True }
263-
elif feature_type == 'tests-behaviour':
285+
elif feature_type in ('tests-behaviour', 'client-hints'):
264286
return { }
265287
else:
266288
breakpoint()
@@ -593,7 +615,9 @@ def dotted_feature_set_list(self, compact=False):
593615

594616
## This is for Xandikos 0.2.12.
595617
## Lots of development going on as of summer 2025, so expect the list to become shorter soon!
596-
xandikos = {
618+
xandikos_v0_2_12 = {
619+
## this only applies for very simple installations
620+
"auto-connect.url": {"domain": "localhost", "scheme": "http", "basepath": "/"},
597621
'search.recurrences.includes-implicit': {'support': 'unsupported'},
598622
'search.recurrences.expanded': {'support': 'unsupported'},
599623
'search.time-range.todo': {'support': 'unsupported'},
@@ -625,6 +649,41 @@ def dotted_feature_set_list(self, compact=False):
625649
]
626650
}
627651

652+
xandikos_master = {
653+
## this only applies for very simple installations
654+
"auto-connect.url": {"domain": "localhost", "scheme": "http", "basepath": "/"},
655+
'search.comp-type-optional': {'support': 'unsupported'},
656+
"search.category.fullstring": {"support": "unsupported"},
657+
"search.recurrences.includes-implicit.todo.pending": {"support": "unsupported"},
658+
'search.recurrences.expanded.todo': {'support': 'unsupported'},
659+
'search.recurrences.expanded.exception': {'support': 'unsupported'},
660+
"old_flags": [
661+
## https://github.com/jelmer/xandikos/issues/8
662+
'date_todo_search_ignores_duration',
663+
'vtodo_datesearch_nostart_future_tasks_delivered',
664+
665+
## scheduling is not supported
666+
"no_scheduling",
667+
'no-principal-search',
668+
669+
## The test in the tests itself passes, but the test in the
670+
## check_server_compatibility triggers a 500-error
671+
"no_freebusy_rfc4791",
672+
673+
## The test with an rrule and an overridden event passes as
674+
## long as it's with timestamps. With dates, xandikos gets
675+
## into troubles. I've chosen to edit the test to use timestamp
676+
## rather than date, just to have the test exercised ... but we
677+
## should report this upstream
678+
#'broken_expand_on_exceptions',
679+
680+
## No alarm search (500 internal server error)
681+
"no_alarmsearch",
682+
]
683+
}
684+
685+
xandikos=xandikos_v0_2_12
686+
628687
## This seems to work as of version 3.5.4 of Radicale.
629688
## There is much development going on at Radicale as of summar 2025,
630689
## so I'm expecting this list to shrink a lot soon.
@@ -633,6 +692,8 @@ def dotted_feature_set_list(self, compact=False):
633692
"search.recurrences.includes-implicit.todo.pending": {"support": "unsupported"},
634693
"search.recurrences.expanded.todo": {"support": "unsupported"},
635694
"search.recurrences.expanded.exception": {"support": "unsupported"},
695+
## this only applies for very simple installations
696+
"auto-connect.url": {"domain": "localhost", "scheme": "http", "basepath": "/"},
636697
'old_flags': [
637698
## calendar listings and calendar creation works a bit
638699
## "weird" on radicale
@@ -655,13 +716,15 @@ def dotted_feature_set_list(self, compact=False):
655716
]
656717
}
657718

658-
## TODO: Latest - mismatch between config and test script in delete-calendar.free-namespace ... and create-calendar.set-displayname?
659-
ecloud = {
719+
## NOT TESTED ... but this works for ecloud, and ecloud is based on nextcloud
720+
nextcloud = {
721+
'auto-connect.url': {
722+
'basepath': '/remote.php/dav',
723+
},
660724
'search.category.fullstring.smart': {'support': 'unsupported'}, ## TODO: verify
661725
'search.comp-type-optional': {'support': 'ungraceful'},
662726
'search.recurrences.expanded.todo': {'support': 'unsupported'},
663727
'search.recurrences.expanded.exception': {'support': 'unsupported'}, ## TODO: verify
664-
665728
'delete-calendar': {
666729
'support': 'fragile',
667730
'behaviour': 'Deleting a recently created calendar fails'},
@@ -674,18 +737,28 @@ def dotted_feature_set_list(self, compact=False):
674737
},
675738
"search.combined-is-logical-and": {"support": "unsupported"},
676739
'search.recurrences.includes-implicit.todo': {'support': 'unsupported'},
740+
'old_flags': ['no-principal-search-all', 'no-principal-search-self', 'unique_calendar_ids'],
741+
}
742+
743+
## TODO: Latest - mismatch between config and test script in delete-calendar.free-namespace ... and create-calendar.set-displayname?
744+
ecloud = nextcloud | {
677745
## TODO: this applies only to test runs, not to ordinary usage
678746
'rate-limit': {
679747
'enable': True,
680748
'interval': 10,
681749
'count': 1,
682-
'description': "It's needed to manually empty trashbin frequently when running tests. Since this oepration takes some time and/or there are some caches, it's needed to run tests slowly, even when hammering the 'empty thrashbin' frequently"},
683-
'old_flags': ['no-principal-search-all', 'no-principal-search-self', 'unique_calendar_ids'],
750+
'description': "It's needed to manually empty trashbin frequently when running tests. Since this oepration takes some time and/or there are some caches, it's needed to run tests slowly, even when hammering the 'empty thrashbin' frequently",
751+
},
752+
'auto-connect.url': {
753+
'basepath': '/remote.php/dav',
754+
'domain': 'ecloud.global',
755+
'scheme': 'https',
756+
},
684757
}
685758

686-
## ZIMBRA IS THE MOST SILLY, AND THERE ARE REGRESSIONS FOR EVERY RELEASE!
687-
## AAARGH!
759+
## Zimbra is not very good at it's caldav support
688760
zimbra = {
761+
'auto-connect.url': {'basepath': '/dav/'},
689762
'search.recurrences.expanded.exception': {'support': 'unsupported'}, ## TODO: verify
690763
'create-calendar.set-displayname': {'support': 'unsupported'},
691764
'save-load.todo.mixed-calendar': {'support': 'unsupported'},
@@ -810,22 +883,6 @@ def dotted_feature_set_list(self, compact=False):
810883
# 'no_freebusy_rfc4791'
811884
#]
812885

813-
nextcloud = [
814-
'date_search_ignores_duration',
815-
'unique_calendar_ids',
816-
'broken_expand',
817-
'no_delete_calendar',
818-
'sync_breaks_on_delete',
819-
'no_recurring_todo',
820-
'combined_search_not_working',
821-
'text_search_is_exact_match_sometimes',
822-
'search_needs_comptype',
823-
'calendar_color',
824-
'calendar_order',
825-
'date_todo_search_ignores_duration',
826-
'broken_expand_on_exceptions'
827-
]
828-
829886
#fastmail = [
830887
# 'duplicates_not_allowed',
831888
# 'duplicate_in_other_calendar_with_same_uid_breaks',
@@ -845,6 +902,10 @@ def dotted_feature_set_list(self, compact=False):
845902
#]
846903

847904
robur = {
905+
"auto-connect.url": {
906+
'domain': 'calendar.robur.coop',
907+
'basepath': '/principals/', # TODO: this seems fishy
908+
},
848909
"delete-calendar": { "support": "fragile" },
849910
"search.time-range.todo": { "support": "unsupported" },
850911
"search.category": { "support": "unsupported" },
@@ -870,6 +931,11 @@ def dotted_feature_set_list(self, compact=False):
870931
}
871932

872933
posteo = {
934+
'auto-connect.url': {
935+
'scheme': 'https',
936+
'domain': 'posteo.de:8443',
937+
'basepath': '/',
938+
},
873939
'create-calendar': {'support': 'unsupported'},
874940
'search.category.fullstring.smart': {'support': 'unsupported'},
875941
'search.comp-type-optional': {'support': 'ungraceful'},
@@ -904,8 +970,12 @@ def dotted_feature_set_list(self, compact=False):
904970
purelymail = {
905971
## Purelymail claims that the search indexes are "lazily" populated,
906972
## so search works some minutes after the event was created/edited.
907-
'search-cache': {'behaviour': 'delay', 'delay': 120},
973+
'search-cache': {'behaviour': 'delay', 'delay': 160},
908974
"create-calendar.auto": {"support": "full"},
975+
'auto-connect.url': {
976+
'basepath': '/webdav/',
977+
'domain': 'purelymail.com',
978+
},
909979
'old_flags': [
910980
## Known, work in progress
911981
'no_scheduling',
@@ -921,6 +991,13 @@ def dotted_feature_set_list(self, compact=False):
921991
}
922992

923993
gmx = {
994+
'auto-connect.url': {
995+
'scheme': 'https',
996+
'domain': 'caldav.gmx.net',
997+
## This won't work yet. I'm not able to connect with gmx at all now,
998+
## so unable to create a verified fix for it now
999+
'basepath': '/begenda/dav/{username}/calendar', ## TODO: foobar
1000+
},
9241001
'create-calendar': {'support': 'unsupported'},
9251002
'search.category.fullstring.smart': {'support': 'unsupported'},
9261003
'search.comp-type-optional': {'support': 'fragile', 'description': 'unexpected results from date-search without comp-type - but only sometimes - TODO: research more'},

caldav/davclient.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from caldav.collection import Calendar
3535
from caldav.collection import CalendarSet
3636
from caldav.collection import Principal
37+
import caldav.compatibility_hints
3738
from caldav.compatibility_hints import FeatureSet
3839
from caldav.elements import cdav
3940
from caldav.elements import dav
@@ -89,10 +90,24 @@
8990
"ssl_cert",
9091
"auth",
9192
"auth_type",
93+
"features",
9294
)
9395
)
9496

9597

98+
def _auto_url(url, features):
99+
if isinstance(features, dict):
100+
features = FeatureSet(features)
101+
if not "/" in str(url):
102+
url_hints = features.is_supported("auto-connect.url", dict)
103+
if not url and "domain" in url_hints:
104+
url = url_hints["domain"]
105+
url = (
106+
f"{url_hints.get('scheme', 'https')}://{url}{url_hints.get('basepath', '')}"
107+
)
108+
return url
109+
110+
96111
class DAVResponse:
97112
"""
98113
This class is a response from a DAV request. It is instantiated from
@@ -456,7 +471,7 @@ class DAVClient:
456471

457472
def __init__(
458473
self,
459-
url: str,
474+
url: Optional[str] = "",
460475
proxy: Optional[str] = None,
461476
username: Optional[str] = None,
462477
password: Optional[str] = None,
@@ -467,7 +482,7 @@ def __init__(
467482
ssl_cert: Union[str, Tuple[str, str], None] = None,
468483
headers: Mapping[str, str] = None,
469484
huge_tree: bool = False,
470-
features: Union[FeatureSet, dict] = None,
485+
features: Union[FeatureSet, dict, str] = None,
471486
) -> None:
472487
"""
473488
Sets up a HTTPConnection object towards the server in the url.
@@ -502,10 +517,15 @@ def __init__(
502517
except TypeError:
503518
self.session = requests.Session()
504519

520+
if isinstance(features, str):
521+
features = getattr(caldav.compatibility_hints, features)
522+
self.features = FeatureSet(features)
523+
self.huge_tree = huge_tree
524+
525+
url = _auto_url(url, self.features)
526+
505527
log.debug("url: " + str(url))
506528
self.url = URL.objectify(url)
507-
self.huge_tree = huge_tree
508-
self.features = FeatureSet(features)
509529
# Prepare proxy info
510530
if proxy is not None:
511531
_proxy = proxy

tests/conf.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -140,20 +140,23 @@ def teardown_radicale(self):
140140
i = 0
141141
self.serverdir.__exit__(None, None, None)
142142

143-
url = "http://%s:%i/" % (radicale_host, radicale_port)
143+
domain = f"{radicale_host}:{radicale_port}"
144+
features = compatibility_hints.radicale.copy()
145+
features["auto-connect.url"]["domain"] = domain
146+
compatibility_hints.radicale_tmp_test = features
144147
caldav_servers.append(
145148
{
146-
"url": url,
147149
"name": "LocalRadicale",
148150
"username": "user1",
149151
"password": "",
150-
"backwards_compatibility_url": url + "user1",
151-
"features": compatibility_hints.radicale,
152+
"features": "radicale_tmp_test",
153+
"backwards_compatibility_url": f"http://{domain}/user1",
152154
"setup": setup_radicale,
153155
"teardown": teardown_radicale,
154156
}
155157
)
156158

159+
## TODO: quite much duplicated code
157160
if test_xandikos:
158161
import asyncio
159162

@@ -227,13 +230,14 @@ def silly_request():
227230

228231
self.serverdir.__exit__(None, None, None)
229232

230-
url = "http://%s:%i/" % (xandikos_host, xandikos_port)
233+
features = compatibility_hints.xandikos.copy()
234+
domain = f"{xandikos_host}:{xandikos_port}"
235+
features["auto-connect.url"]["domain"] = domain
231236
caldav_servers.append(
232237
{
233238
"name": "LocalXandikos",
234-
"url": url,
235-
"backwards_compatibility_url": url + "sometestuser",
236-
"features": compatibility_hints.xandikos,
239+
"backwards_compatibility_url": f"http://{domain}/sometestuser",
240+
"features": features,
237241
"setup": setup_xandikos,
238242
"teardown": teardown_xandikos,
239243
}
@@ -261,7 +265,6 @@ def client(
261265
elif no_args:
262266
return None
263267
for bad_param in (
264-
"features",
265268
"incompatibilities",
266269
"backwards_compatibility_url",
267270
"principal_url",
@@ -279,7 +282,6 @@ def client(
279282
conn = DAVClient(**kwargs_)
280283
conn.setup = setup
281284
conn.teardown = teardown
282-
conn.features = FeatureSet(kwargs.get("features"))
283285
conn.server_name = name
284286
return conn
285287

0 commit comments

Comments
 (0)