feat(dav): expand_rrule + dav_make_calendar para recurrencia y multi-calendario
Dos funciones nuevas del grupo de capacidad `dav`: - expand_rrule_py_infra (pure): expande una RRULE iCalendar a las fechas de cada ocurrencia dentro de un rango [from, to]. Solo stdlib (datetime, re). Soporta FREQ DAILY/WEEKLY/MONTHLY/YEARLY, INTERVAL, COUNT, UNTIL, BYDAY. 9 tests. - dav_make_calendar_py_infra (impure): crea una coleccion de calendario nueva via MKCALENDAR + PROPPATCH de nombre/color. Idempotente si ya existe. 11 tests. Consumidas por la app osint_web (eventos recurrentes + creacion de agendas). Pagina del grupo dav actualizada con ambas. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
---
|
||||
name: dav_make_calendar
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def dav_make_calendar(base_url: str, username: str, password: str, calendar_home: str, slug: str, display_name: str = \"\", color: str = \"\", description: str = \"\", *, timeout_s: float = 20.0, verify_tls: bool = True) -> dict"
|
||||
description: "Crea una nueva coleccion de calendario CalDAV (una agenda nueva) bajo el calendar-home de un principal via MKCALENDAR, fijando el displayname en el cuerpo, y opcionalmente fija color (Apple calendar-color) y descripcion (CalDAV calendar-description) con un PROPPATCH posterior. La coleccion se crea en <calendar_home><slug>/. El slug se sanea a [a-z0-9_-] (minusculas, espacios->guion); si queda vacio devuelve error de validacion. Idempotente: 201 Created es exito; 405/301 (ya existe) devuelve {status:'ok', existed:True}. Escapa display_name/description para XML. Construye Authorization: Basic base64(user:pass) a mano. Maneja errores sin lanzar (salvo validacion de args). Solo stdlib (urllib, base64, re, ssl, xml.sax.saxutils). Probado contra Xandikos."
|
||||
tags: [dav, caldav, calendar, mkcalendar, proppatch, create, collection, color, http, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [base64, re, ssl, urllib.error, urllib.request, xml.sax.saxutils]
|
||||
params:
|
||||
- name: base_url
|
||||
desc: "URL base del servidor DAV sin barra final (p.ej. 'https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com')."
|
||||
- name: username
|
||||
desc: "usuario para HTTP Basic auth (p.ej. 'enmanuel')."
|
||||
- name: password
|
||||
desc: "contrasena para HTTP Basic auth. Resolver desde pass con pass_get_secret, nunca hardcodear."
|
||||
- name: calendar_home
|
||||
desc: "ruta del calendar-home del principal con barra final (p.ej. '/enmanuel/calendars/'). La nueva coleccion cuelga de el."
|
||||
- name: slug
|
||||
desc: "segmento de path de la coleccion en la URL (p.ej. 'trabajo'); se sanea a [a-z0-9_-]. La coleccion se crea en <calendar_home><slug>/. Si queda vacio tras sanear, devuelve error de validacion."
|
||||
- name: display_name
|
||||
desc: "nombre visible de la coleccion (DAV:displayname). Si vacio, usa el slug saneado."
|
||||
- name: color
|
||||
desc: "color de la coleccion como hex '#rrggbb' (propiedad calendar-color de Apple, http://apple.com/ns/ical/). Opcional; '' lo omite."
|
||||
- name: description
|
||||
desc: "descripcion de la coleccion (calendar-description de CalDAV). Opcional; '' lo omite."
|
||||
- name: timeout_s
|
||||
desc: "timeout de cada peticion HTTP en segundos. Default 20.0."
|
||||
- name: verify_tls
|
||||
desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba."
|
||||
output: "dict. En exito: {status:'ok', http_status:int, href:str} y, si la coleccion ya existia, ademas existed:True. En error (sin lanzar): {status:'error', http_status:int|None, href:str, error:str}. href es la ruta de la coleccion (calendar_home + slug saneado + '/')."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_sanitize_slug_minusculas"
|
||||
- "test_sanitize_slug_espacios_a_guion"
|
||||
- "test_sanitize_slug_elimina_caracteres_raros"
|
||||
- "test_sanitize_slug_colapsa_guiones_y_recorta"
|
||||
- "test_sanitize_slug_vacio"
|
||||
- "test_join_url_compone_la_coleccion"
|
||||
- "test_mkcalendar_xml_incluye_displayname"
|
||||
- "test_mkcalendar_xml_escapa_displayname"
|
||||
- "test_proppatch_xml_color_y_descripcion"
|
||||
- "test_proppatch_xml_solo_color"
|
||||
- "test_proppatch_xml_escapa_descripcion"
|
||||
test_file_path: "python/functions/infra/dav_make_calendar_test.py"
|
||||
file_path: "python/functions/infra/dav_make_calendar.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.pass_get_secret import pass_get_secret
|
||||
from infra.dav_make_calendar import dav_make_calendar
|
||||
|
||||
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
|
||||
|
||||
res = dav_make_calendar(
|
||||
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
|
||||
username="enmanuel",
|
||||
password=pw,
|
||||
calendar_home="/enmanuel/calendars/",
|
||||
slug="trabajo",
|
||||
display_name="Trabajo",
|
||||
color="#e8590c",
|
||||
)
|
||||
print(res)
|
||||
# {'status': 'ok', 'http_status': 201, 'href': '/enmanuel/calendars/trabajo/'}
|
||||
# Volver a llamar con el mismo slug:
|
||||
# {'status': 'ok', 'http_status': 405, 'href': '/enmanuel/calendars/trabajo/', 'existed': True}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando el usuario quiere anadir una agenda/calendario nuevo ademas del
|
||||
principal: una coleccion CalDAV separada ("Trabajo", "Personal", "Cumpleanos")
|
||||
con su propio nombre visible y color, bajo el calendar-home del principal. El
|
||||
`href` devuelto es lo que luego pasas como `collection_path` a
|
||||
`caldav_put_event` para crear eventos en esa agenda, o a `dav_list_calendars`
|
||||
para verla en el selector.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Impura: requiere red + Basic auth contra el servidor DAV. El password viene de
|
||||
`pass`, no se logea ni se hardcodea.
|
||||
- Idempotente: si la coleccion ya existe en ese path el servidor responde 405
|
||||
(Method Not Allowed) o 301; ambos se traducen a `{status:'ok', existed:True}`
|
||||
en vez de error, asi que es seguro reintentar.
|
||||
- El PROPPATCH de color usa el `calendar-color` de Apple
|
||||
(`http://apple.com/ns/ical/`). Servidores que no lo soporten pueden ignorarlo:
|
||||
el fallo del PROPPATCH NO es fatal (el calendario ya quedo creado) y se ignora
|
||||
silenciosamente; el color simplemente no se aplica. Si necesitas confirmar el
|
||||
color, leelo despues con `dav_list_calendars`.
|
||||
- El `slug` se sanea a `[a-z0-9_-]` (minusculas, espacios->guion, resto fuera).
|
||||
Un slug que queda vacio tras sanear (p.ej. solo simbolos) devuelve error de
|
||||
validacion sin tocar la red. El `display_name` y la `description` se escapan
|
||||
para XML, pero el `slug` que va en la URL ya esta restringido al charset
|
||||
seguro.
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Crea una nueva coleccion de calendario CalDAV bajo un calendar-home.
|
||||
|
||||
Funcion impura: hace una peticion HTTP MKCALENDAR (metodo HTTP literal) para
|
||||
crear una "agenda" nueva bajo el calendar-home de un principal, y opcionalmente
|
||||
un PROPPATCH posterior para fijarle el color (Apple `calendar-color`) y la
|
||||
descripcion (`{urn:ietf:params:xml:ns:caldav}calendar-description`). El nombre
|
||||
visible (DAV:displayname) se setea ya en el cuerpo del MKCALENDAR.
|
||||
|
||||
El slug (segmento de path de la coleccion) se sanea a `[a-z0-9_-]` (minusculas,
|
||||
espacios -> '-'); si queda vacio se devuelve un error de validacion. La
|
||||
coleccion se crea en `<calendar_home><slug>/`.
|
||||
|
||||
Idempotente: un 201 (Created) es exito; un 405 (Method Not Allowed) o un 301
|
||||
(la coleccion ya existe en ese path) se devuelven como
|
||||
{status:'ok', existed:True}. El display_name y la description se escapan para
|
||||
XML. Construye `Authorization: Basic base64(user:pass)` a mano con stdlib.
|
||||
Maneja errores sin lanzar (salvo validacion de args). Solo usa stdlib
|
||||
(urllib, base64, re, ssl, xml.sax.saxutils). Probado contra Xandikos.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import re
|
||||
import ssl
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from xml.sax.saxutils import escape as _xml_escape
|
||||
|
||||
_UNSAFE_SLUG_RE = re.compile(r"[^a-z0-9_-]")
|
||||
|
||||
|
||||
def _basic_auth_header(username: str, password: str) -> str:
|
||||
raw = ("%s:%s" % (username, password)).encode("utf-8")
|
||||
return "Basic " + base64.b64encode(raw).decode("ascii")
|
||||
|
||||
|
||||
def _sanitize_slug(slug: str) -> str:
|
||||
"""Sanea un slug a `[a-z0-9_-]`.
|
||||
|
||||
Pasa a minusculas, convierte espacios (y runs de espacios) en un guion, y
|
||||
elimina cualquier otro caracter no permitido. Colapsa guiones repetidos y
|
||||
recorta guiones de los extremos. Puede devolver "" si no queda nada usable;
|
||||
el caller trata "" como error de validacion.
|
||||
"""
|
||||
s = slug.strip().lower()
|
||||
s = re.sub(r"\s+", "-", s)
|
||||
s = _UNSAFE_SLUG_RE.sub("", s)
|
||||
s = re.sub(r"-{2,}", "-", s).strip("-")
|
||||
return s
|
||||
|
||||
|
||||
def _build_mkcalendar_xml(display_name: str) -> str:
|
||||
"""Cuerpo XML minimo del MKCALENDAR que setea el displayname (escapado)."""
|
||||
name = _xml_escape(display_name)
|
||||
return (
|
||||
'<?xml version="1.0" encoding="utf-8" ?>'
|
||||
'<C:mkcalendar xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:">'
|
||||
"<D:set><D:prop>"
|
||||
"<D:displayname>%s</D:displayname>"
|
||||
"</D:prop></D:set>"
|
||||
"</C:mkcalendar>"
|
||||
) % name
|
||||
|
||||
|
||||
def _build_proppatch_xml(color: str = "", description: str = "") -> str:
|
||||
"""Cuerpo XML del PROPPATCH que fija color (Apple) y/o descripcion (CalDAV).
|
||||
|
||||
Solo incluye las props no vacias. El color va como `calendar-color` del
|
||||
namespace `http://apple.com/ns/ical/` con el hex tal cual lo pasa el caller
|
||||
(p.ej. '#RRGGBB'). La descripcion es `calendar-description` de CalDAV. Ambos
|
||||
valores se escapan para XML.
|
||||
"""
|
||||
props = []
|
||||
if color:
|
||||
props.append("<A:calendar-color>%s</A:calendar-color>" % _xml_escape(color))
|
||||
if description:
|
||||
props.append(
|
||||
"<C:calendar-description>%s</C:calendar-description>"
|
||||
% _xml_escape(description)
|
||||
)
|
||||
return (
|
||||
'<?xml version="1.0" encoding="utf-8" ?>'
|
||||
'<D:propertyupdate xmlns:D="DAV:" '
|
||||
'xmlns:A="http://apple.com/ns/ical/" '
|
||||
'xmlns:C="urn:ietf:params:xml:ns:caldav">'
|
||||
"<D:set><D:prop>%s</D:prop></D:set>"
|
||||
"</D:propertyupdate>"
|
||||
) % "".join(props)
|
||||
|
||||
|
||||
def _join_url(base_url: str, calendar_home: str, slug: str) -> str:
|
||||
return base_url.rstrip("/") + "/" + calendar_home.strip("/") + "/" + slug + "/"
|
||||
|
||||
|
||||
def dav_make_calendar(
|
||||
base_url: str,
|
||||
username: str,
|
||||
password: str,
|
||||
calendar_home: str,
|
||||
slug: str,
|
||||
display_name: str = "",
|
||||
color: str = "",
|
||||
description: str = "",
|
||||
*,
|
||||
timeout_s: float = 20.0,
|
||||
verify_tls: bool = True,
|
||||
) -> dict:
|
||||
"""Crea una nueva coleccion de calendario CalDAV (MKCALENDAR + PROPPATCH).
|
||||
|
||||
Crea la coleccion en `<calendar_home><slug>/` via MKCALENDAR, fijando el
|
||||
displayname en el propio cuerpo. Si se pasa `color` y/o `description`, hace
|
||||
un PROPPATCH posterior para setearlos. Idempotente: si la coleccion ya
|
||||
existe (405/301) devuelve {status:'ok', existed:True}.
|
||||
|
||||
Args:
|
||||
base_url: URL base del servidor DAV (sin barra final), p.ej.
|
||||
'https://dav-x.organic-machine.com'.
|
||||
username: usuario para HTTP Basic auth.
|
||||
password: contrasena para HTTP Basic auth. Resolver desde pass.
|
||||
calendar_home: ruta del calendar-home del principal (con barra final),
|
||||
p.ej. '/enmanuel/calendars/'. La coleccion cuelga de el.
|
||||
slug: segmento de path de la coleccion (p.ej. 'trabajo'); se sanea a
|
||||
[a-z0-9_-]. Si queda vacio tras sanear, error de validacion.
|
||||
display_name: nombre visible (DAV:displayname). Si vacio, usa el slug.
|
||||
color: color de la coleccion como hex '#rrggbb' (Apple calendar-color).
|
||||
Opcional.
|
||||
description: descripcion (CalDAV calendar-description). Opcional.
|
||||
timeout_s: timeout de cada peticion en segundos. Default 20.0.
|
||||
verify_tls: si True (default) verifica el certificado TLS.
|
||||
|
||||
Returns:
|
||||
dict. En exito: {status:'ok', http_status:int, href:str} (y existed:True
|
||||
si ya existia). En error (sin lanzar): {status:'error', http_status:
|
||||
int|None, href:str, error:str}.
|
||||
"""
|
||||
clean = _sanitize_slug(slug)
|
||||
href = (calendar_home.rstrip("/") + "/" + clean + "/") if clean else ""
|
||||
if not clean:
|
||||
return {
|
||||
"status": "error",
|
||||
"http_status": None,
|
||||
"href": href,
|
||||
"error": "slug invalido: queda vacio tras sanear a [a-z0-9_-]",
|
||||
}
|
||||
|
||||
name = display_name if display_name else clean
|
||||
url = _join_url(base_url, calendar_home, clean)
|
||||
context = None if verify_tls else ssl._create_unverified_context()
|
||||
headers = {
|
||||
"Authorization": _basic_auth_header(username, password),
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
}
|
||||
|
||||
# 1) MKCALENDAR — crea la coleccion + displayname.
|
||||
mk_body = _build_mkcalendar_xml(name).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url, data=mk_body, method="MKCALENDAR", headers=headers
|
||||
)
|
||||
existed = False
|
||||
http_status = None
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout_s, context=context) as resp:
|
||||
http_status = resp.status
|
||||
except urllib.error.HTTPError as e:
|
||||
# 405/301: la coleccion ya existe en ese path -> idempotente.
|
||||
if e.code in (301, 405):
|
||||
existed = True
|
||||
http_status = e.code
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"http_status": e.code,
|
||||
"href": href,
|
||||
"error": "http %s" % e.code,
|
||||
}
|
||||
except urllib.error.URLError as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"http_status": None,
|
||||
"href": href,
|
||||
"error": str(e.reason),
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "http_status": None, "href": href, "error": str(e)}
|
||||
|
||||
# 2) PROPPATCH — color y/o descripcion. Fallo aqui no es fatal: la coleccion
|
||||
# ya existe. Servidores que no soporten calendar-color pueden ignorarlo.
|
||||
if color or description:
|
||||
pp_body = _build_proppatch_xml(color, description).encode("utf-8")
|
||||
pp_req = urllib.request.Request(
|
||||
url, data=pp_body, method="PROPPATCH", headers=headers
|
||||
)
|
||||
try:
|
||||
urllib.request.urlopen(pp_req, timeout=timeout_s, context=context).close()
|
||||
except Exception: # noqa: BLE001
|
||||
# No fatal: el calendario quedo creado; el color/desc puede no
|
||||
# soportarse en este servidor. Se ignora silenciosamente.
|
||||
pass
|
||||
|
||||
result = {"status": "ok", "http_status": http_status, "href": href}
|
||||
if existed:
|
||||
result["existed"] = True
|
||||
return result
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Tests para dav_make_calendar.
|
||||
|
||||
La funcion publica es impura (hace HTTP), asi que no se prueba contra un servidor
|
||||
real. Se ejercitan los helpers puros extraidos a nivel de modulo: la
|
||||
sanitizacion del slug, la construccion de la URL de la coleccion y la generacion
|
||||
de los cuerpos XML del MKCALENDAR y del PROPPATCH (displayname/color/descripcion
|
||||
escapados). Sin red.
|
||||
"""
|
||||
|
||||
from infra.dav_make_calendar import (
|
||||
_build_mkcalendar_xml,
|
||||
_build_proppatch_xml,
|
||||
_join_url,
|
||||
_sanitize_slug,
|
||||
)
|
||||
|
||||
|
||||
def test_sanitize_slug_minusculas():
|
||||
assert _sanitize_slug("Trabajo") == "trabajo"
|
||||
|
||||
|
||||
def test_sanitize_slug_espacios_a_guion():
|
||||
assert _sanitize_slug("agenda de trabajo") == "agenda-de-trabajo"
|
||||
|
||||
|
||||
def test_sanitize_slug_elimina_caracteres_raros():
|
||||
assert _sanitize_slug("Casa/Ocio!! 2026") == "casaocio-2026"
|
||||
|
||||
|
||||
def test_sanitize_slug_colapsa_guiones_y_recorta():
|
||||
assert _sanitize_slug(" --Foo Bar-- ") == "foo-bar"
|
||||
|
||||
|
||||
def test_sanitize_slug_vacio():
|
||||
assert _sanitize_slug(" !!! ") == ""
|
||||
|
||||
|
||||
def test_join_url_compone_la_coleccion():
|
||||
url = _join_url(
|
||||
"https://dav-x.organic-machine.com",
|
||||
"/enmanuel/calendars/",
|
||||
"trabajo",
|
||||
)
|
||||
assert url == "https://dav-x.organic-machine.com/enmanuel/calendars/trabajo/"
|
||||
|
||||
|
||||
def test_mkcalendar_xml_incluye_displayname():
|
||||
xml = _build_mkcalendar_xml("Trabajo")
|
||||
assert "<C:mkcalendar" in xml
|
||||
assert "<D:displayname>Trabajo</D:displayname>" in xml
|
||||
|
||||
|
||||
def test_mkcalendar_xml_escapa_displayname():
|
||||
xml = _build_mkcalendar_xml("Casa & <Ocio>")
|
||||
assert "Casa & <Ocio>" in xml
|
||||
assert "<Ocio>" not in xml
|
||||
|
||||
|
||||
def test_proppatch_xml_color_y_descripcion():
|
||||
xml = _build_proppatch_xml(color="#e8590c", description="Mi agenda")
|
||||
assert "<A:calendar-color>#e8590c</A:calendar-color>" in xml
|
||||
assert "<C:calendar-description>Mi agenda</C:calendar-description>" in xml
|
||||
|
||||
|
||||
def test_proppatch_xml_solo_color():
|
||||
xml = _build_proppatch_xml(color="#e8590c")
|
||||
assert "<A:calendar-color>#e8590c</A:calendar-color>" in xml
|
||||
assert "calendar-description" not in xml
|
||||
|
||||
|
||||
def test_proppatch_xml_escapa_descripcion():
|
||||
xml = _build_proppatch_xml(description="A & B <c>")
|
||||
assert "A & B <c>" in xml
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Expandir una RRULE iCalendar a las fechas de inicio de cada ocurrencia.
|
||||
|
||||
Implementacion pura y determinista, solo stdlib (datetime, re). NO usa
|
||||
python-dateutil (no disponible en los venvs del ecosistema). Soporta un
|
||||
subconjunto pragmatico de RFC 5545 suficiente para una agenda personal:
|
||||
FREQ (DAILY/WEEKLY/MONTHLY/YEARLY), INTERVAL, COUNT, UNTIL y BYDAY (solo
|
||||
para FREQ=WEEKLY). Cualquier otro componente se ignora silenciosamente.
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
# Tope de seguridad para no colgar cuando faltan COUNT y UNTIL.
|
||||
_MAX_OCCURRENCES = 1000
|
||||
|
||||
# Mapeo de codigos BYDAY iCal -> weekday() de Python (lunes=0 .. domingo=6).
|
||||
_BYDAY_TO_WEEKDAY = {
|
||||
"MO": 0,
|
||||
"TU": 1,
|
||||
"WE": 2,
|
||||
"TH": 3,
|
||||
"FR": 4,
|
||||
"SA": 5,
|
||||
"SU": 6,
|
||||
}
|
||||
|
||||
|
||||
def _parse_ical_date(value: str) -> date:
|
||||
"""Extrae la parte YYYYMMDD de un valor iCal y la devuelve como date.
|
||||
|
||||
Acepta YYYYMMDD, YYYYMMDDTHHMMSS y YYYYMMDDTHHMMSSZ.
|
||||
"""
|
||||
digits = value.strip()[:8]
|
||||
return date(int(digits[:4]), int(digits[4:6]), int(digits[6:8]))
|
||||
|
||||
|
||||
def _parse_time_suffix(dtstart_ical: str) -> str:
|
||||
"""Devuelve la parte de hora 'THHMMSS' del dtstart, o '' si es all-day."""
|
||||
m = re.search(r"T(\d{6})", dtstart_ical.strip())
|
||||
return f"T{m.group(1)}" if m else ""
|
||||
|
||||
|
||||
def _parse_rrule(rrule: str) -> dict:
|
||||
"""Parsea el cuerpo de una RRULE a un dict de componentes en mayusculas."""
|
||||
parts: dict[str, str] = {}
|
||||
for token in rrule.strip().split(";"):
|
||||
if not token or "=" not in token:
|
||||
continue
|
||||
key, _, val = token.partition("=")
|
||||
parts[key.strip().upper()] = val.strip()
|
||||
return parts
|
||||
|
||||
|
||||
def _add_months(d: date, months: int) -> date:
|
||||
"""Suma `months` meses a una fecha, recortando el dia al ultimo valido."""
|
||||
total = (d.year * 12 + (d.month - 1)) + months
|
||||
year, month = divmod(total, 12)
|
||||
month += 1
|
||||
# Recorte de dia: si el dia no existe en el mes destino, usar el ultimo.
|
||||
if month == 12:
|
||||
next_first = date(year + 1, 1, 1)
|
||||
else:
|
||||
next_first = date(year, month + 1, 1)
|
||||
last_day = (next_first - timedelta(days=1)).day
|
||||
return date(year, month, min(d.day, last_day))
|
||||
|
||||
|
||||
def _format_occurrence(d: date, time_suffix: str, all_day: bool) -> str:
|
||||
"""Formatea una fecha de ocurrencia como string DTSTART iCal."""
|
||||
ymd = f"{d.year:04d}{d.month:02d}{d.day:02d}"
|
||||
if all_day or not time_suffix:
|
||||
return ymd
|
||||
return f"{ymd}{time_suffix}"
|
||||
|
||||
|
||||
def _generate_dates(dtstart: date, rule: dict) -> list[date]:
|
||||
"""Genera las fechas de ocurrencia segun la RRULE (antes de filtrar rango).
|
||||
|
||||
El recorte por rango lo hace el llamador; aqui se respeta COUNT, UNTIL y
|
||||
el tope de seguridad _MAX_OCCURRENCES.
|
||||
"""
|
||||
freq = rule.get("FREQ", "").upper()
|
||||
interval = max(1, int(rule["INTERVAL"])) if rule.get("INTERVAL", "").isdigit() else 1
|
||||
count = int(rule["COUNT"]) if rule.get("COUNT", "").isdigit() else None
|
||||
until = _parse_ical_date(rule["UNTIL"]) if rule.get("UNTIL") else None
|
||||
|
||||
results: list[date] = []
|
||||
|
||||
def reached_limit() -> bool:
|
||||
if count is not None and len(results) >= count:
|
||||
return True
|
||||
return len(results) >= _MAX_OCCURRENCES
|
||||
|
||||
def past_until(d: date) -> bool:
|
||||
return until is not None and d > until
|
||||
|
||||
if freq == "WEEKLY":
|
||||
byday = rule.get("BYDAY", "")
|
||||
weekdays = [
|
||||
_BYDAY_TO_WEEKDAY[code.strip().upper()]
|
||||
for code in byday.split(",")
|
||||
if code.strip().upper() in _BYDAY_TO_WEEKDAY
|
||||
]
|
||||
if not weekdays:
|
||||
weekdays = [dtstart.weekday()]
|
||||
weekdays = sorted(set(weekdays))
|
||||
# Lunes de la semana del dtstart.
|
||||
week_anchor = dtstart - timedelta(days=dtstart.weekday())
|
||||
week_index = 0
|
||||
while True:
|
||||
week_start = week_anchor + timedelta(weeks=week_index * interval)
|
||||
for wd in weekdays:
|
||||
occ = week_start + timedelta(days=wd)
|
||||
if occ < dtstart:
|
||||
continue
|
||||
if past_until(occ):
|
||||
return results
|
||||
if reached_limit():
|
||||
return results
|
||||
results.append(occ)
|
||||
if reached_limit():
|
||||
return results
|
||||
# Corte: si no hay COUNT ni UNTIL, el llamador acota por range_end,
|
||||
# pero el tope duro evita el bucle infinito.
|
||||
if until is None and count is None and len(results) >= _MAX_OCCURRENCES:
|
||||
return results
|
||||
week_index += 1
|
||||
# Salvaguarda extra si UNTIL/COUNT no recortan a tiempo.
|
||||
if week_index > _MAX_OCCURRENCES:
|
||||
return results
|
||||
return results
|
||||
|
||||
# FREQ por periodos simples (DAILY / MONTHLY / YEARLY).
|
||||
step = 0
|
||||
while True:
|
||||
if freq == "DAILY":
|
||||
occ = dtstart + timedelta(days=step * interval)
|
||||
elif freq == "MONTHLY":
|
||||
occ = _add_months(dtstart, step * interval)
|
||||
elif freq == "YEARLY":
|
||||
occ = _add_months(dtstart, step * interval * 12)
|
||||
else:
|
||||
# FREQ desconocido o ausente: solo la primera ocurrencia.
|
||||
occ = dtstart
|
||||
if not past_until(occ):
|
||||
results.append(occ)
|
||||
return results
|
||||
|
||||
if past_until(occ):
|
||||
return results
|
||||
if reached_limit():
|
||||
return results
|
||||
results.append(occ)
|
||||
if reached_limit():
|
||||
return results
|
||||
if until is None and count is None and len(results) >= _MAX_OCCURRENCES:
|
||||
return results
|
||||
step += 1
|
||||
if step > _MAX_OCCURRENCES:
|
||||
return results
|
||||
|
||||
|
||||
def expand_rrule(
|
||||
dtstart_ical: str,
|
||||
rrule: str,
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
all_day: bool = False,
|
||||
) -> list[str]:
|
||||
"""Expande una RRULE iCal a las fechas DTSTART de cada ocurrencia en rango.
|
||||
|
||||
Args:
|
||||
dtstart_ical: fecha de inicio del evento maestro en formato iCal crudo.
|
||||
Puede ser YYYYMMDD (all-day), YYYYMMDDTHHMMSS o YYYYMMDDTHHMMSSZ.
|
||||
Es la primera ocurrencia (la serie incluye dtstart si cae en rango).
|
||||
rrule: cuerpo de la RRULE SIN el prefijo 'RRULE:', p.ej.
|
||||
'FREQ=WEEKLY;INTERVAL=1;COUNT=10' o 'FREQ=MONTHLY;UNTIL=20261231;BYDAY=MO,WE'.
|
||||
range_start: limite inferior del rango como YYYYMMDD (inclusive).
|
||||
range_end: limite superior del rango como YYYYMMDD (inclusive).
|
||||
all_day: 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).
|
||||
|
||||
Returns:
|
||||
Lista ordenada de strings DTSTART iCal, una por ocurrencia cuya fecha
|
||||
(parte YYYYMMDD del DTSTART) cae en [range_start, range_end]. Lista
|
||||
vacia si la RRULE no produce ninguna en rango.
|
||||
"""
|
||||
rule = _parse_rrule(rrule)
|
||||
dtstart = _parse_ical_date(dtstart_ical)
|
||||
time_suffix = _parse_time_suffix(dtstart_ical)
|
||||
lo = _parse_ical_date(range_start)
|
||||
hi = _parse_ical_date(range_end)
|
||||
|
||||
occurrences = _generate_dates(dtstart, rule)
|
||||
|
||||
out: list[str] = []
|
||||
for occ in occurrences:
|
||||
if lo <= occ <= hi:
|
||||
out.append(_format_occurrence(occ, time_suffix, all_day))
|
||||
out.sort()
|
||||
return out
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Tests para expand_rrule."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
from functions.infra.expand_rrule import expand_rrule
|
||||
|
||||
|
||||
def test_golden_weekly_count_4():
|
||||
# FREQ=WEEKLY;COUNT=4 a partir del 2026-01-05 (lunes) -> 4 fechas a 7 dias.
|
||||
got = expand_rrule(
|
||||
"20260105T090000",
|
||||
"FREQ=WEEKLY;COUNT=4",
|
||||
"20260101",
|
||||
"20261231",
|
||||
)
|
||||
assert got == [
|
||||
"20260105T090000",
|
||||
"20260112T090000",
|
||||
"20260119T090000",
|
||||
"20260126T090000",
|
||||
]
|
||||
|
||||
|
||||
def test_edge_monthly_interval_2():
|
||||
# INTERVAL=2 mensual: cada dos meses, dia 15.
|
||||
got = expand_rrule(
|
||||
"20260115",
|
||||
"FREQ=MONTHLY;INTERVAL=2;COUNT=4",
|
||||
"20260101",
|
||||
"20261231",
|
||||
all_day=True,
|
||||
)
|
||||
assert got == ["20260115", "20260315", "20260515", "20260715"]
|
||||
|
||||
|
||||
def test_edge_weekly_byday_two_days():
|
||||
# BYDAY con 2 dias en WEEKLY: MO y WE, dentro de cada semana del intervalo.
|
||||
# dtstart 2026-01-05 (lunes). COUNT=4 -> MO,WE de la sem 1 y MO,WE de la sem 2.
|
||||
got = expand_rrule(
|
||||
"20260105",
|
||||
"FREQ=WEEKLY;BYDAY=MO,WE;COUNT=4",
|
||||
"20260101",
|
||||
"20261231",
|
||||
all_day=True,
|
||||
)
|
||||
assert got == ["20260105", "20260107", "20260112", "20260114"]
|
||||
|
||||
|
||||
def test_edge_all_day_vs_with_time():
|
||||
# all_day=True -> YYYYMMDD; all_day=False conserva la hora del dtstart.
|
||||
all_day = expand_rrule(
|
||||
"20260105T143000",
|
||||
"FREQ=DAILY;COUNT=2",
|
||||
"20260101",
|
||||
"20261231",
|
||||
all_day=True,
|
||||
)
|
||||
assert all_day == ["20260105", "20260106"]
|
||||
|
||||
with_time = expand_rrule(
|
||||
"20260105T143000",
|
||||
"FREQ=DAILY;COUNT=2",
|
||||
"20260101",
|
||||
"20261231",
|
||||
all_day=False,
|
||||
)
|
||||
assert with_time == ["20260105T143000", "20260106T143000"]
|
||||
|
||||
|
||||
def test_until_recorta():
|
||||
# UNTIL=20260120 recorta la serie semanal inclusive en esa fecha.
|
||||
got = expand_rrule(
|
||||
"20260105T090000",
|
||||
"FREQ=WEEKLY;UNTIL=20260120T235959Z",
|
||||
"20260101",
|
||||
"20261231",
|
||||
)
|
||||
assert got == [
|
||||
"20260105T090000",
|
||||
"20260112T090000",
|
||||
"20260119T090000",
|
||||
]
|
||||
|
||||
|
||||
def test_filtro_por_rango_excluye_fuera():
|
||||
# COUNT=10 semanal, pero el rango solo cubre 3 ocurrencias intermedias.
|
||||
got = expand_rrule(
|
||||
"20260105T090000",
|
||||
"FREQ=WEEKLY;COUNT=10",
|
||||
"20260112",
|
||||
"20260131",
|
||||
)
|
||||
assert got == [
|
||||
"20260112T090000",
|
||||
"20260119T090000",
|
||||
"20260126T090000",
|
||||
]
|
||||
|
||||
|
||||
def test_dtstart_anterior_al_rango_pero_serie_entra():
|
||||
# dtstart en 2025-12-29 (antes del rango), serie semanal entra en enero 2026.
|
||||
got = expand_rrule(
|
||||
"20251229T090000",
|
||||
"FREQ=WEEKLY;COUNT=8",
|
||||
"20260101",
|
||||
"20260115",
|
||||
)
|
||||
assert got == [
|
||||
"20260105T090000",
|
||||
"20260112T090000",
|
||||
]
|
||||
|
||||
|
||||
def test_componente_no_soportado_se_ignora():
|
||||
# BYMONTHDAY no soportado -> se ignora, no falla. Serie mensual normal.
|
||||
got = expand_rrule(
|
||||
"20260110",
|
||||
"FREQ=MONTHLY;BYMONTHDAY=10;COUNT=3",
|
||||
"20260101",
|
||||
"20261231",
|
||||
all_day=True,
|
||||
)
|
||||
assert got == ["20260110", "20260210", "20260310"]
|
||||
|
||||
|
||||
def test_sin_count_ni_until_acota_a_range_end():
|
||||
# Sin COUNT ni UNTIL: la generacion debe acotarse por range_end.
|
||||
got = expand_rrule(
|
||||
"20260101",
|
||||
"FREQ=DAILY",
|
||||
"20260101",
|
||||
"20260105",
|
||||
all_day=True,
|
||||
)
|
||||
assert got == [
|
||||
"20260101",
|
||||
"20260102",
|
||||
"20260103",
|
||||
"20260104",
|
||||
"20260105",
|
||||
]
|
||||
Reference in New Issue
Block a user