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