Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions AI_POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ started receiving pull requests with code changes generated by AI (and
I've seen people posting screenshots of simple questions and answers
from ChatGPT in forum discussions, without contributing anything else).

As of 2025-11, I've spent some time testing Claude. I'm actually
As of 2025-12, I've spent some time testing Claude. I'm actually
positively surprised, it's doing a much better job than what I had
expected. The AI may do things faster, smarter and better than a good
coder. Sometimes. Other times it may spend a lot of "tokens" and a
Expand Down Expand Up @@ -59,12 +59,12 @@ experiences is that the AI performs best when being "supervised" and
should be informed about both in the pull request itself and in the
git commit message. The most common way to do this is to add
"Assisted-by: (name of AI-tool)" at the end of the message. Claude
seems to sign off with "Co-Authored-By: Claude
<noreply@anthropic.com>" when it's doing commits, that's also OK.
seems to sign off with `Co-Authored-By: Claude
<noreply@anthropic.com>` when it's doing commits, that's also OK.

* **YOU** should be ready to follow up and respond to feedback and
questions on the contribution. If all you do is to relay it to the
AI and relaying the AI thoughts back to the pull request, then
AI and relaying the AI output back to the pull request, then
you're not adding value to the project and you're not transparent
and honest. You should at least do a quick QA on the AI-answer and
acknowledge that it was generated by the AI.
Expand Down
35 changes: 26 additions & 9 deletions caldav/compatibility_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ class FeatureSet:
"delete-calendar.free-namespace": {
"description": "The delete operations clears the namespace, so that another calendar with the same ID/name can be created"
},
"http": { },
"http.multiplexing": {
"description": "chulka/baikal:nginx is having Problems with using HTTP/2 with multiplexing, ref https://github.com/python-caldav/caldav/issues/564. I haven't (yet) been able to reproduce this locally, so no check for this yet. We'll define it as fragile in the radicale config as for now"
},
"save-load": {
"description": "it's possible to save and load objects to the calendar"
},
Expand Down Expand Up @@ -264,6 +268,26 @@ def __init__(self, feature_set_dict=None):
if feature_set_dict:
self.copyFeatureSet(feature_set_dict, collapse=False)


def set_feature(self, feature, value=True):
if isinstance(value, dict):
fc = {feature: value}
elif isinstance(value, str):
fc = {feature: {"support": value}}
elif value is True:
fc = {feature: {"support": "full"}}
elif value is False:
fc = {feature: {"support": "unsupported"}}
elif value is None:
fc = {feature: {"support": "unknown"}}
else:
assert False
self.copyFeatureSet(fc, collapse=False)
feat_def = self.find_feature(feature)
feat_type = feat_def.get('type', 'server-feature')
sup = fc[feature].get('support', feat_def.get('default', 'full'))


## TODO: Why is this camelCase while every other method is with under_score? rename ...
def copyFeatureSet(self, feature_set, collapse=True):
for feature in feature_set:
Expand Down Expand Up @@ -874,14 +898,13 @@ def dotted_feature_set_list(self, compact=False):
'search.time-range.alarm': False,
'sync-token': 'fragile',
'delete-calendar': False,
'delete-calendar.free-namespace': False,
'search.comp-type-optional': 'fragile',
"search.recurrences.expanded.exception": False,
'test-calendar': {'cleanup-regime': 'wipe-calendar'},
'old_flags': ['vtodo_datesearch_nodtstart_task_is_skipped'],
'old_flags': ['vtodo_datesearch_nodtstart_task_is_skipped'],
}

baikal = { ## version 0.10.1
"http.multiplexing": "fragile", ## ref https://github.com/python-caldav/caldav/issues/564
"save-load.journal": {'support': 'ungraceful'},
#'search.comp-type-optional': {'support': 'ungraceful'}, ## Possibly this has been fixed?
'search.recurrences.expanded.todo': {'support': 'unsupported'},
Expand Down Expand Up @@ -1036,12 +1059,6 @@ def dotted_feature_set_list(self, compact=False):
# 'isnotdefined_not_working',
#]

#synology = [
# "fragile_sync_tokens",
# "vtodo_datesearch_notime_task_is_skipped",
# "no_recurring_todo",
#]

robur = {
"auto-connect.url": {
'domain': 'calendar.robur.coop',
Expand Down
32 changes: 26 additions & 6 deletions caldav/davclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,18 +599,19 @@ def __init__(
"""
headers = headers or {}

## Deprecation TODO: give a warning, user should use get_davclient or auto_calendar instead

try:
self.session = requests.Session(multiplexed=True)
except TypeError:
self.session = requests.Session()
## Deprecation TODO: give a warning, user should use get_davclient or auto_calendar instead. Probably.

if isinstance(features, str):
features = getattr(caldav.compatibility_hints, features)
self.features = FeatureSet(features)
self.huge_tree = huge_tree

try:
multiplexed = self.features.is_supported("http.multiplexing")
self.session = requests.Session(multiplexed=multiplexed)
except TypeError:
self.session = requests.Session()

url, discovered_username = _auto_url(
url,
self.features,
Expand Down Expand Up @@ -1100,6 +1101,24 @@ def request(
and self.password
and isinstance(self.password, bytes)
):
## TODO: this has become a mess and should be refactored.
## (Arguably, this logic doesn't belong here at all.
## with niquests it's possible to just pass the username
## and password, maybe we should try that?)

## Most likely we're here due to wrong username/password
## combo, but it could also be a multiplexing problem.
if (
self.features.is_supported("http.multiplexing", return_defaults=False)
is None
):
self.session = requests.Session()
self.features.set_feature("http.multiplexing", "unknown")
## If this one also fails, we give up
ret = self.request(str(url_obj), method, body, headers)
self.features.set_feature("http.multiplexing", False)
return ret

## Most likely we're here due to wrong username/password
## combo, but it could also be charset problems. Some
## (ancient) servers don't like UTF-8 binary auth with
Expand All @@ -1115,6 +1134,7 @@ def request(

self.username = None
self.password = None

return self.request(str(url_obj), method, body, headers)

if error.debug_dump_communication:
Expand Down
23 changes: 23 additions & 0 deletions caldav/lib/vcal.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ def fix(event):
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"):
Expand All @@ -77,6 +89,17 @@ def fix(event):
## 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.
Expand Down
89 changes: 78 additions & 11 deletions tests/test_vcal.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def normalize(s, ignore_uid):
self.assertEqual(normalize(ical1, ignore_uid), normalize(ical2, ignore_uid))
return ical2

def verifyICal(self, ical):
def verifyICal(self, ical, allow_reordering=False):
"""
Does a best effort on verifying that the ical is correct, by
pushing it through the vobject and icalendar library
Expand Down Expand Up @@ -155,16 +155,6 @@ def test_vcal_fixups(self):
CATEGORIES:oslo
END:VEVENT
END:VCALENDAR
""",
## Next one contains a DTSTAMP before BEGIN:VEVENT
## Doesn’t make sense, but valid, and more importantly,
## not failing during the `fix` call.
"""DTSTAMP:20210205T101751Z
BEGIN:VEVENT
UID:20200516T060000Z-123401@example.com
SUMMARY:Do the needful
DTSTART:20210517T060000Z
END:VEVENT
""",
]
broken_ical = [
Expand Down Expand Up @@ -295,3 +285,80 @@ def test_vcal_fixups(self):

for ical in non_broken_ical:
assert vcal.fix(ical) == ical

def test_missing_dtstamp_fix(self) -> None:
"""
Test that missing DTSTAMP is added by the fix function.
DTSTAMP is mandatory according to RFC5545 but doesn't cause
vobject to fail, so it needs its own test (issue #504).
"""
# Event without DTSTAMP
double_event_without_dtstamp = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//FOSDEM//Example//EN
BEGIN:VEVENT
UID:missing-dtstamp-test@example.com
DTSTART:20250101T100000Z
DTEND:20250101T110000Z
SUMMARY:Event without DTSTAMP
RRULE:FREQ=YEARLY
END:VEVENT
BEGIN:VEVENT
UID:missing-dtstamp-test@example.com
DTSTART:20260101T100000Z
DTEND:20260101T110000Z
SUMMARY:Event without DateTimeSTAMP 2026
RECURRENCE-ID:20260101T100000Z
END:VEVENT
END:VCALENDAR"""

# Todo without DTSTAMP
todo_without_dtstamp = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//FOSDEM//Example//EN
BEGIN:VTODO
UID:missing-dtstamp-todo@example.com
SUMMARY:Todo without DTSTAMP
DUE:20250101T120000Z
END:VTODO
END:VCALENDAR"""

# Journal without DTSTAMP
journal_without_dtstamp = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//FOSDEM//Example//EN
BEGIN:VJOURNAL
UID:missing-dtstamp-journal@example.com
SUMMARY:Journal without DTSTAMP
DTSTART:20250101T100000Z
END:VJOURNAL
END:VCALENDAR"""

# Test each component type
for ical in [
double_event_without_dtstamp,
todo_without_dtstamp,
journal_without_dtstamp,
]:
# Verify the original doesn't have DTSTAMP
assert "DTSTAMP:" not in ical

# Apply the fix
fixed = vcal.fix(ical)

# Verify DTSTAMP was added
assert "DTSTAMP:" in fixed, f"DTSTAMP should be added to:\n{ical}"

# Verify it matches the expected format (YYYYMMDDTHHMMSSZ)
dtstamp_match = re.search(r"DTSTAMP:(\d{8}T\d{6}Z)", fixed)
assert (
dtstamp_match is not None
), f"DTSTAMP should be in correct format in:\n{fixed}"

if ical.count("BEGIN:VEVENT") == 2:
assert fixed.count("DTSTAMP:") == 2
else:
assert fixed.count("DTSTAMP:") == 1

# Verify the fixed ical is valid
self.verifyICal(fixed)