Das Problem

Du baust ein Script, das deine heutigen Kalender-Termine abruft. CalDAV-Query mit time-range für heute, 00:00 bis 23:59 Uhr. Sollte einfach sein, oder?

# CalDAV Query: heute 00:00 bis 23:59
start_str = "20250123T000000Z"
end_str = "20250123T235959Z"

response = caldav_report(start_str, end_str)
events = parse_events(response)

print(f"Events heute: {len(events)}")
# Output: Events heute: 63

63 Events. Aber du weißt genau, dass heute nur ein einziger Termin stattfindet.

Was zum Teufel?

TL;DR

CalDAV gibt bei wiederkehrenden Terminen (RRULE) alle Events zurück, die den Zeitraum potenziell betreffen könnten — nicht nur die, die heute tatsächlich stattfinden. DTSTART ist das Ursprungsdatum des wiederkehrenden Termins, nicht das aktuelle.

Lösung: dateutil.rrule nutzen, um zu berechnen, ob ein Event-Instanz heute stattfindet:

from dateutil.rrule import rrulestr

recur = rrulestr(rrule_str, dtstart=dtstart_dt, ignoretz=True)
today_start = datetime.combine(today_date, datetime.min.time())
today_end = datetime.combine(today_date, datetime.max.time())
instances_today = list(recur.between(today_start, today_end, inc=True))

Ursache: RRULE — die Zeitreise-Maschine

Wiederkehrende Termine haben eine RRULE-Zeile:

BEGIN:VEVENT
SUMMARY:Daily Standup
DTSTART;TZID="(GMT +01:00)":20240101T090000
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR
END:VEVENT

Hier steht:

  • DTSTART: 1. Januar 2024, 09:00 Uhr ← Das ist das Ursprungsdatum
  • RRULE: Jede Woche, Montag bis Freitag

Wenn du jetzt am 23. Januar 2025 deine CalDAV-Query abschickst mit time-range="20250123T000000Z/20250123T235959Z", dann denkt CalDAV:

“Okay, dieser Event könnte theoretisch heute stattfinden (weil RRULE sagt wöchentlich Mo-Fr). Ich gebe ihn zurück.”

CalDAV gibt dir das komplette Event — mit dem DTSTART vom 1. Januar 2024. Nicht heute. Das Ursprungsdatum.

Und wenn du 50 wiederkehrende Termine im Kalender hast (wöchentliches Team-Meeting, tägliches Standup, jeden Freitag Sport, etc.), dann bekommst du alle 50 zurück, obwohl heute nur 3 davon stattfinden.

Warum macht CalDAV das?

Weil CalDAV-Server dumm sind. Sie expandieren RRULE nicht selbst, sondern überlassen dir die Arbeit.

Der time-range Filter checkt nur:

  1. Liegt DTSTART im Zeitraum? ✅
  2. Betrifft die RRULE den Zeitraum potenziell? ✅

Beide Bedingungen erfüllt → Event kommt in die Antwort.

Das perfide

CalDAV sieht aus als ob es filtert, tut es aber nicht.

Du schickst eine Query: “Gib mir alle Termine für heute, 23. Januar 2025.” CalDAV antwortet mit 63 Events. Du denkst: “Perfekt, 63 Termine heute!”

Falsch. CalDAV hat dir 63 Events gegeben, die potenziell heute stattfinden könnten. Nicht die, die tatsächlich heute stattfinden.

Das ist wie wenn du dein Grommunio auf srv-mail.rebellion.local fragst: “Welche Meetings habe ich heute?” und es antwortet: “Hier sind alle Meetings die jemals montags stattfinden, egal welches Jahr.”

Die Konsequenz: Du musst jedes Event selbst prüfen. CalDAV verschwendet Bandbreite mit 60+ Events die du nicht brauchst, und du musst lokal filtern.

Lösung: RRULE selbst auswerten

Du musst für jedes Event prüfen, ob es heute tatsächlich stattfindet.

Python-Bibliotheken

  • icalendar: Parst iCal-Daten, aber handhabt RRULE nicht selbst
  • dateutil.rrule: Berechnet Instanzen von wiederkehrenden Terminen

Schritt 1: Event parsen

from icalendar import Calendar as iCalendar
from datetime import datetime, date, timedelta

cal = iCalendar.from_ical(raw_ical_text)

for component in cal.walk():
    if component.name != 'VEVENT':
        continue
    
    summary = str(component.get('SUMMARY', 'Ohne Titel'))
    dtstart = component.get('DTSTART').dt
    rrule_prop = component.get('RRULE')

Schritt 2: RRULE-Check

from dateutil.rrule import rrulestr

def event_happens_today(component, today_date):
    """Prüft ob Event heute stattfindet (berücksichtigt RRULE)"""
    dtstart = component.get('DTSTART')
    if not dtstart:
        return False
    
    dt_val = dtstart.dt
    all_day = isinstance(dt_val, date) and not isinstance(dt_val, datetime)
    
    rrule_prop = component.get('RRULE')
    
    if rrule_prop is None:
        # Kein RRULE → einfacher Termin
        event_date = dt_val if all_day else dt_val.date()
        return event_date == today_date
    
    # RRULE vorhanden → Instanzen berechnen
    rrule_str = rrule_prop.to_ical().decode('utf-8')
    
    # DTSTART als naive datetime (dateutil erwartet keine Timezone)
    if all_day:
        dtstart_dt = datetime.combine(dt_val, datetime.min.time())
    else:
        dtstart_dt = dt_val.replace(tzinfo=None) if dt_val.tzinfo else dt_val
    
    # Parse RRULE
    recur = rrulestr(rrule_str, dtstart=dtstart_dt, ignoretz=True)
    
    # Prüfe ob heute eine Instanz stattfindet
    today_start = datetime.combine(today_date, datetime.min.time())
    today_end = datetime.combine(today_date, datetime.max.time())
    
    instances_today = list(recur.between(today_start, today_end, inc=True))
    
    return len(instances_today) > 0

Schritt 3: Filtern

today = datetime.now().date()
events_today = []

for component in cal.walk():
    if component.name == 'VEVENT':
        if event_happens_today(component, today):
            events_today.append(component)

print(f"Tatsächliche Events heute: {len(events_today)}")
# Output: Tatsächliche Events heute: 1

Boom. Von 63 auf 1.

Pitfall: DTSTART mit TZID parsen

Manche CalDAV-Server schreiben DTSTART so:

DTSTART;TZID="(GMT +01:00)":20250123T090000

Die icalendar-Library kommt damit manchmal nicht klar (vor allem mit den Quotes um die TZID).

Fallback: Regex-Parsing

import re

def _parse_ical_datetime(ical_text, property_name):
    """Robuster Parser für DTSTART/DTEND aus iCal-Text"""
    pattern = rf'{property_name}[^:]*?:(\d{{8}}(?:T\d{{6}})?(?:Z)?)'
    match = re.search(pattern, ical_text)
    
    if match:
        datetime_str = match.group(1)
        
        if 'T' not in datetime_str:
            # Nur Datum (ganztägiges Event)
            dt = datetime.strptime(datetime_str, '%Y%m%d')
            return dt.replace(tzinfo=timezone(timedelta(hours=1)))
        elif datetime_str.endswith('Z'):
            # UTC time
            dt = datetime.strptime(datetime_str, '%Y%m%dT%H%M%SZ')
            return dt.replace(tzinfo=timezone.utc)
        else:
            # Local time
            dt = datetime.strptime(datetime_str, '%Y%m%dT%H%M%S')
            return dt.replace(tzinfo=timezone(timedelta(hours=1)))
    
    return None

Nutze das als Fallback, wenn component.get('DTSTART') fehlschlägt.

Vollständige Funktion

Hier die produktionsreife Version (aus einem echten TTS-Termin-Ansage-Script):

from dateutil.rrule import rrulestr
from datetime import datetime, date, timedelta, timezone

def _get_event_instance_for_today(component, today_date, ical_raw_text=None):
    """
    Berechnet ob ein Event (mit oder ohne RRULE) heute stattfindet.
    Gibt None zurück wenn nicht, oder ein dict mit start/end/all_day wenn ja.
    """
    dtstart = component.get('DTSTART')
    dtend_component = component.get('DTEND')
    
    # Fallback auf Regex-Parsing wenn icalendar fehlschlägt
    if not dtstart and ical_raw_text:
        dt_val = _parse_ical_datetime(ical_raw_text, 'DTSTART')
        dtend_val = _parse_ical_datetime(ical_raw_text, 'DTEND')
        if not dt_val:
            return None
        all_day = False
    else:
        if not dtstart:
            return None
        dt_val = dtstart.dt
        dtend_val = dtend_component.dt if dtend_component else None
        all_day = isinstance(dt_val, date) and not isinstance(dt_val, datetime)
    
    # Check for RRULE
    rrule_prop = component.get('RRULE')
    
    if rrule_prop is None:
        # Simple event ohne Wiederholung
        if all_day:
            event_date = dt_val
        else:
            event_date = dt_val.date() if hasattr(dt_val, 'date') else dt_val
        
        if event_date != today_date:
            return None
    else:
        # Wiederkehrendes Event - berechne Instanzen
        rrule_str = rrule_prop.to_ical().decode('utf-8')
        
        # DTSTART als naive datetime für rrule
        if all_day:
            dtstart_dt = datetime.combine(dt_val, datetime.min.time())
        else:
            if isinstance(dt_val, datetime):
                dtstart_dt = dt_val.replace(tzinfo=None) if dt_val.tzinfo else dt_val
            else:
                dtstart_dt = datetime.combine(dt_val, datetime.min.time())
        
        # Parse RRULE mit ignoretz
        recur = rrulestr(rrule_str, dtstart=dtstart_dt, ignoretz=True)
        
        # Berechne ob heute eine Instanz stattfindet
        today_start = datetime.combine(today_date, datetime.min.time())
        today_end = datetime.combine(today_date, datetime.max.time())
        
        instances_today = list(recur.between(today_start, today_end, inc=True))
        
        if not instances_today:
            return None
        
        # Nutze die erste Instanz heute
        first_instance = instances_today[0]
        
        if all_day:
            dt_val = first_instance.date()
        else:
            dt_val = first_instance
    
    # Event findet heute statt - berechne Start/End
    start_dt = dt_val if isinstance(dt_val, datetime) else datetime.combine(dt_val, datetime.min.time())
    
    if dtend_val:
        end_dt = dtend_val if isinstance(dtend_val, datetime) else datetime.combine(dtend_val, datetime.min.time())
    else:
        duration = component.get('DURATION')
        if duration:
            end_dt = start_dt + duration.dt
        else:
            end_dt = start_dt + (timedelta(days=1) if all_day else timedelta(hours=1))
    
    return {
        'start': start_dt,
        'end': end_dt,
        'all_day': all_day
    }

Verifikation

Test mit einem wiederkehrenden Termin:

from icalendar import Calendar as iCalendar

ical_data = """BEGIN:VCALENDAR
BEGIN:VEVENT
SUMMARY:Daily Standup
DTSTART:20240101T090000
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR
END:VEVENT
END:VCALENDAR"""

cal = iCalendar.from_ical(ical_data)
component = [c for c in cal.walk() if c.name == 'VEVENT'][0]

# Heute ist Donnerstag, 23. Januar 2025
today = date(2025, 1, 23)
result = _get_event_instance_for_today(component, today)

print(result)
# Output: {'start': datetime(2025, 1, 23, 9, 0), 'end': datetime(2025, 1, 23, 10, 0), 'all_day': False}

Fazit

CalDAV ist nicht dein Freund wenn es um wiederkehrende Termine geht. Der time-range Filter gibt dir Events, die theoretisch im Zeitraum stattfinden könnten — aber nicht, ob sie es tatsächlich tun.

Was du gelernt hast:

  1. CalDAV gibt ALLE RRULE-Events zurück, die den Zeitraum betreffen
  2. DTSTART ist bei wiederkehrenden Terminen das Ursprungsdatum, nicht das aktuelle
  3. dateutil.rrule ist dein Freund: rrulestr() + between() für Instanz-Berechnung
  4. Regex-Parsing als Fallback wenn icalendar bei TZID mit Quotes scheitert

Und wenn du das nächste Mal denkst “CalDAV gibt mir 63 Events zurück, aber es sollten nur 3 sein” — jetzt weißt du warum.

Happy hacking! 🚀

Referenzen