--- name: expand_rrule kind: function lang: py domain: infra version: "1.0.0" purity: pure signature: "def expand_rrule(dtstart_ical: str, rrule: str, range_start: str, range_end: str, all_day: bool = False) -> list[str]" description: "Expande una RRULE iCalendar a la lista ordenada de fechas DTSTART de cada ocurrencia que cae dentro de un rango [range_start, range_end]. Pura, determinista, solo stdlib (sin python-dateutil). Soporta FREQ DAILY/WEEKLY/MONTHLY/YEARLY, INTERVAL, COUNT, UNTIL y BYDAY (para WEEKLY)." tags: [dav, calendar, ical, rrule, recurrence, caldav] uses_functions: [] uses_types: [] returns: [] returns_optional: false error_type: "" imports: [] tested: true tests: ["test_golden_weekly_count_4", "test_edge_monthly_interval_2", "test_edge_weekly_byday_two_days", "test_edge_all_day_vs_with_time", "test_until_recorta", "test_filtro_por_rango_excluye_fuera", "test_dtstart_anterior_al_rango_pero_serie_entra", "test_componente_no_soportado_se_ignora", "test_sin_count_ni_until_acota_a_range_end"] test_file_path: "python/functions/infra/expand_rrule_test.py" file_path: "python/functions/infra/expand_rrule.py" params: - name: dtstart_ical desc: "Fecha de inicio del evento maestro en formato iCal crudo: YYYYMMDD (all-day), YYYYMMDDTHHMMSS o YYYYMMDDTHHMMSSZ. Es la primera ocurrencia (la serie la incluye si cae en rango)." - name: rrule desc: "Cuerpo de la RRULE SIN el prefijo 'RRULE:', p.ej. 'FREQ=WEEKLY;INTERVAL=1;COUNT=10' o 'FREQ=MONTHLY;UNTIL=20261231;BYDAY=MO,WE'." - name: range_start desc: "Limite inferior del rango como YYYYMMDD (inclusive). Solo se devuelven ocurrencias cuya fecha YYYYMMDD del DTSTART cae en [range_start, range_end]." - name: range_end desc: "Limite superior del rango como YYYYMMDD (inclusive). Tambien acota la generacion cuando faltan COUNT y UNTIL en la RRULE." - name: all_day desc: "Si True las ocurrencias se devuelven como YYYYMMDD; si False conservan la parte de hora del dtstart original (misma hora local en cada ocurrencia) y devuelven YYYYMMDDTHHMMSS sin sufijo Z. Default False." output: "Lista ordenada de strings DTSTART iCal, una por ocurrencia en rango. Lista vacia si la RRULE no produce ninguna en [range_start, range_end]." --- ## Ejemplo ```python import sys, os sys.path.insert(0, os.path.join("python", "functions")) from infra.expand_rrule import expand_rrule # Reunion semanal los lunes a las 09:00, 4 ocurrencias, ventana de enero 2026. fechas = expand_rrule( "20260105T090000", "FREQ=WEEKLY;COUNT=4", "20260101", "20261231", ) print(fechas) # ['20260105T090000', '20260112T090000', '20260119T090000', '20260126T090000'] # Evento all-day mensual cada 2 meses, solo las que caen en el primer semestre. fechas = expand_rrule( "20260115", "FREQ=MONTHLY;INTERVAL=2;COUNT=4", "20260101", "20260630", all_day=True, ) print(fechas) # ['20260115', '20260315', '20260515'] ``` ## Cuando usarla Cuando un cliente CalDAV necesita mostrar las ocurrencias de un evento recurrente dentro de la ventana visible del calendario: tienes el DTSTART y la RRULE del VEVENT maestro y quieres la lista concreta de fechas de inicio que caen entre dos limites para pintarlas en la agenda. Tambien para contar o iterar instancias de una serie sin instanciar todo el iCal. ## Gotchas - **No implementa el RFC 5545 completo.** Componentes soportados: - `FREQ` (obligatorio): `DAILY`, `WEEKLY`, `MONTHLY`, `YEARLY`. - `INTERVAL` (default 1). - `COUNT` (incluye la primera ocurrencia = dtstart). - `UNTIL` (`YYYYMMDD` o `YYYYMMDDTHHMMSSZ`, inclusive). - `BYDAY` solo para `FREQ=WEEKLY` (`MO,TU,WE,TH,FR,SA,SU`). - Cualquier otro componente (`BYMONTHDAY`, `BYSETPOS`, `BYMONTH`, `WKST` avanzado, EXDATE, RDATE, etc.) se **ignora silenciosamente** — no falla, pero el resultado puede diferir del esperado por el RFC en esos casos. - Si faltan **COUNT y UNTIL** a la vez, la generacion se acota por `range_end` con un tope de seguridad duro de 1000 ocurrencias para no colgar. - En `FREQ=MONTHLY`/`YEARLY` con dia 29/30/31, los meses sin ese dia recortan al ultimo dia valido del mes destino. - No gestiona zonas horarias: con `all_day=False` conserva la hora local del dtstart sin sufijo `Z`; el llamador es responsable de la tz (TZID/VTIMEZONE). - El filtro de rango compara solo la parte `YYYYMMDD` del DTSTART, no la hora.