Merge pull request #97 from nalpch/recurring-and-floating-events

Recurring and floating events
pull/119/head
Martin Eigenmann 2022-12-18 14:56:58 +01:00 zatwierdzone przez GitHub
commit c0c9c46475
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
6 zmienionych plików z 540 dodań i 2587 usunięć

Wyświetl plik

@ -20,7 +20,9 @@ def events(
end=None,
fix_apple=False,
http=None,
sort=True,
tzinfo=None,
sort=None,
strict=False,
) -> list[Event]:
"""
Get all events form the given iCal URL occurring in the given time range.
@ -31,9 +33,12 @@ def events(
:param start: start date (see dateutils.date)
:param end: end date (see dateutils.date)
:param fix_apple: fix known Apple iCal issues
:sort: sorts events by start time
:param tzinfo: return values in specified tz
:param sort: sort return values
:param strict: return dates, datetimes and datetime with timezones as specified in ical
:sort sorts events by start time
:return: events
:return events
"""
found_events = []
@ -49,7 +54,9 @@ def events(
if not content and string_content:
content = ical_download.data_from_string(string_content, apple_fix=fix_apple)
found_events += parse_events(content, start=start, end=end)
found_events += parse_events(
content, start=start, end=end, tzinfo=tzinfo, sort=sort, strict=strict
)
if found_events is not None and sort is True:
found_events.sort()

Wyświetl plik

@ -2,20 +2,21 @@
Parse iCal data to Events.
"""
# for UID generation
from faulthandler import is_enabled
from random import randint
from datetime import datetime, timedelta, date
from datetime import datetime, timedelta, date, tzinfo
from typing import Optional
from dateutil.relativedelta import relativedelta
from dateutil.rrule import rrule, rruleset, rrulestr
from dateutil.rrule import rrulestr
from dateutil.tz import UTC, gettz
from icalendar import Calendar
from icalendar.windows_to_olson import WINDOWS_TO_OLSON
from icalendar.prop import vDDDLists, vText
from pytz import timezone
from uuid import uuid4
from icalendar.windows_to_olson import WINDOWS_TO_OLSON
from pytz import timezone
def now():
"""
@ -89,40 +90,31 @@ class Event:
"Only events can be compared with each other! Other is %s" % type(other)
)
else:
return self.start < other.start
# start and end can be dates, datetimes and datetimes with timezoneinfo
if type(self.start) is date and type(other.start) is date:
return self.start < other.start
elif type(self.start) is datetime and type(other.start) is datetime:
if self.start.tzinfo == other.start.tzinfo:
return self.start < other.start
else:
return self.start.astimezone(UTC) < other.start.astimezone(UTC)
elif type(self.start) is date and type(other.start) is datetime:
return self.start < other.start.date()
elif type(self.start) is datetime and type(other.start) is date:
return self.start.date() < other.start
def __str__(self):
n = now()
return "%s: %s (%s)" % (self.start, self.summary, self.end - self.start)
if not self.start.tzinfo:
self.start = normalize(self.start)
if not self.end.tzinfo:
self.end = normalize(self.end)
def astimezone(self, tzinfo):
# compute time delta description
if not self.all_day:
if self.end > n > self.start:
# event is now
delta = "now"
elif self.start > n:
# event is a future event
if self.time_left().days > 0:
delta = "%s days left" % self.time_left().days
else:
hours = self.time_left().seconds / (60 * 60)
delta = "%.1f hours left" % hours
else:
# event is over
delta = "ended"
else:
if self.end > n > self.start:
delta = "today"
elif self.start > n:
delta = "%s days left" % self.time_left().days
else:
delta = "ended"
if type(self.start) is datetime:
self.start = self.start.astimezone(tzinfo)
return "%s: %s (%s)" % (self.start, self.summary, delta)
if type(self.end) is datetime:
self.end = self.end.astimezone(tzinfo)
return self
def copy_to(self, new_start=None, uid=None):
"""
@ -174,24 +166,23 @@ def encode(value: Optional[vText]) -> Optional[str]:
return str(value.encode("utf-8"))
def create_event(component, utc_default, tz=UTC):
def create_event(component, utc_default):
"""
Create an event from its iCal representation.
:param component: iCal component
:param tz: timezone for start and end times
:return: event
"""
event = Event()
event.start = normalize(component.get("dtstart").dt, tz=tz)
event.start = component.get("dtstart").dt
# The RFC specifies that the TZID parameter must be specified for datetime or time
# Otherwise we set a default timezone (if only one is set with VTIMEZONE) or utc
event.floating = type(component.get("dtstart").dt) == date and utc_default
if component.get("dtend"):
event.end = normalize(component.get("dtend").dt, tz=tz)
event.end = component.get("dtend").dt
elif component.get("duration"): # compute implicit end as start + duration
event.end = event.start + component.get("duration").dt
else: # compute implicit end as start + 0
@ -231,13 +222,19 @@ def create_event(component, utc_default, tz=UTC):
event.transparent = component.get("transp") == "TRANSPARENT"
if component.get("created"):
event.created = normalize(component.get("created").dt, tz)
event.created = component.get("created").dt
if component.get("RECURRENCE-ID"):
event.recurrence_id = normalize(component.get("RECURRENCE-ID").dt, tz)
rid = component.get("RECURRENCE-ID").dt
# Spec defines that if DTSTART is a date RECURRENCE-ID also is to be interpreted as a date
if type(component.get("dtstart").dt) is date:
event.recurrence_id = date(year=rid.year, month=rid.month, day=rid.day)
else:
event.recurrence_id = rid
if component.get("last-modified"):
event.last_modified = normalize(component.get("last-modified").dt, tz)
event.last_modified = component.get("last-modified").dt
elif event.created:
event.last_modified = event.created
@ -261,58 +258,15 @@ def create_event(component, utc_default, tz=UTC):
return event
def normalize(dt, tz=UTC):
"""
Convert date or datetime to datetime with timezone.
:param dt: date to normalize
:param tz: the normalized date's timezone
:return: date as datetime with timezone
"""
if type(dt) is date:
dt = dt + relativedelta(hour=0)
elif type(dt) is datetime:
pass
else:
raise ValueError("unknown type %s" % type(dt))
if dt.tzinfo:
dt = dt.astimezone(tz)
else:
dt = dt.replace(tzinfo=tz)
return dt
def get_timezone(tz_name):
if tz_name in WINDOWS_TO_OLSON:
return gettz(WINDOWS_TO_OLSON[tz_name])
else:
return gettz(tz_name)
def adjust_timezone(component, dates, tz=None):
# Remove timezone if none is present in component
if (
isinstance(component["dtstart"].dt, date)
or component["dtstart"].dt.tzinfo is None
):
dates = [
date.replace(tzinfo=None) if type(date) is datetime else date
for date in dates
]
# Add timezone if one is present in component
if (
isinstance(component["dtstart"].dt, datetime)
and not component["dtstart"].dt.tzinfo is None
):
dates = [normalize(date) for date in dates]
return dates
def parse_events(content, start=None, end=None, default_span=timedelta(days=7)):
def parse_events(
content,
start=None,
end=None,
default_span=timedelta(days=7),
tzinfo=None,
sort=False,
strict=False,
):
"""
Query the events occurring in a given time range.
@ -333,6 +287,11 @@ def parse_events(content, start=None, end=None, default_span=timedelta(days=7)):
calendar = Calendar.from_ical(content)
# > Will be deprecated ========================
# Calendar.from_ical already parses timezones as specified in the ical.
# We can specify a 'default' tz but this is not according to spec.
# Kept this here to verify tests and backward compatibility
# Keep track of the timezones defined in the calendar
timezones = {}
@ -360,183 +319,222 @@ def parse_events(content, start=None, end=None, default_span=timedelta(days=7)):
else:
utc_default = True
cal_tz = UTC
start = normalize(start, cal_tz)
end = normalize(end, cal_tz)
# < ==========================================
found = []
def add_if_not_exception(event):
exdate = "%04d%02d%02d" % (
event.start.year,
event.start.month,
event.start.day,
)
if exdate not in exceptions:
found.append(event)
for component in calendar.walk():
# Skip dates that are stored as exceptions.
exceptions = {}
if "EXDATE" in component:
# Deal with the fact that sometimes it's a list and
# sometimes it's a singleton
exlist = []
if isinstance(component["EXDATE"], vDDDLists):
exlist = component["EXDATE"].dts
else:
exlist = component["EXDATE"]
for ex in exlist:
exdate = ex.to_ical().decode("UTF-8")
exceptions[exdate[0:8]] = exdate
if component.name == "VEVENT":
e = create_event(component, utc_default, cal_tz)
e = create_event(component, utc_default)
if "EXDATE" in component:
# Deal with the fact that sometimes it's a list and
# sometimes it's a singleton
exlist = []
if isinstance(component["EXDATE"], list):
exlist = component["EXDATE"]
else:
exlist.append(component["EXDATE"])
for ex in exlist:
exdate = ex.to_ical().decode("UTF-8")
exceptions[exdate[0:8]] = exdate
# make rule.between happy and provide from, to points in time that have the same format as dtstart
s = component["dtstart"].dt
if type(s) is date and not e.recurring:
f, t = date(start.year, start.month, start.day), date(
end.year, end.month, end.day
)
elif type(s) is datetime and s.tzinfo:
f, t = datetime(
start.year, start.month, start.day, tzinfo=s.tzinfo
), datetime(end.year, end.month, end.day, tzinfo=s.tzinfo)
else:
f, t = datetime(start.year, start.month, start.day), datetime(
end.year, end.month, end.day
)
# Attempt to work out what timezone is used for the start
# and end times. If the timezone is defined in the calendar,
# use it; otherwise, attempt to load the rules from pytz.
start_tz = None
if e.start.tzinfo != UTC:
if str(e.start.tzinfo) in timezones:
start_tz = timezones[str(e.start.tzinfo)]
else:
start_tz = e.start.tzinfo
# If we've been passed or constructed start/end values
# that are timezone naive, but the actual appointment
# start and end times are in a timezone, convert start
# and end to have a timezone. Otherwise, python will
# raise an exception for comparing timezone naive
# and offset-aware values.
if e.start.tzinfo and not start.tzinfo:
start = normalize(start, e.start.tzinfo)
if e.start.tzinfo and not end.tzinfo:
end = normalize(end, e.start.tzinfo)
duration = e.end - e.start
event_start = component.get("dtstart").dt
rule_start_time_zone = cal_tz
if type(event_start) is datetime and event_start.tzinfo:
rule_start_time_zone = component.get("dtstart").dt.tzinfo
if e.recurring:
# Unfold recurring events according to their rrule
rule = parse_rrule(component, rule_start_time_zone)
[after] = adjust_timezone(component, [start], start_tz)
[end] = adjust_timezone(component, [end], start_tz)
for dt in rule.between(after, end, inc=True):
if start_tz is None:
# Shrug. If we couldn't work out the timezone, it is what it is.
ecopy = e.copy_to(dt, e.uid)
rule = parse_rrule(component)
for dt in rule.between(f, t, inc=True):
# Recompute the start time in the current timezone *on* the
# date of *this* occurrence. This handles the case where the
# recurrence has crossed over the daylight savings time boundary.
if type(dt) is datetime and dt.tzinfo:
dtstart = dt.replace(tzinfo=get_timezone(str(dt.tzinfo)))
ecopy = e.copy_to(
dtstart.date() if type(s) is date else dtstart, e.uid
)
else:
# Recompute the start time in the current timezone *on* the
# date of *this* occurrence. This handles the case where the
# recurrence has crossed over the daylight savings time boundary.
naive = datetime(
dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second
)
dtstart = normalize(naive, tz=start_tz)
ecopy = e.copy_to(dtstart, e.uid)
ecopy = e.copy_to(dt.date() if type(s) is date else dt, e.uid)
add_if_not_exception(ecopy)
# We're effectively looping over the start time, we might need
# to adjust the end time too, but don't have it's recurred value.
# Make sure it's adjusted by constructing it from the meeting
# duration. Pro: it'll be right. Con: if it was in a different
# timezone from the start time, we'll have lost that.
ecopy.end = dtstart + duration
exdate = "%04d%02d%02d" % (
ecopy.start.year,
ecopy.start.month,
ecopy.start.day,
)
if exdate not in exceptions:
found.append(ecopy)
elif e.end >= start and e.start <= end:
exdate = "%04d%02d%02d" % (e.start.year, e.start.month, e.start.day)
if exdate not in exceptions:
if (
type(e.recurrence_id) == datetime
and type(component.get("dtstart").dt) == datetime
):
naive = datetime(
e.recurrence_id.year,
e.recurrence_id.month,
e.recurrence_id.day,
e.recurrence_id.hour,
e.recurrence_id.minute,
e.recurrence_id.second,
)
e.recurrence_id = normalize(
naive, tz=component.get("dtstart").dt.tzinfo
)
found.append(e)
elif e.end >= f and e.start <= t:
add_if_not_exception(e)
result = found.copy()
# Remove events that are replaced in ical
for event in found:
if not event.recurrence_id and (event.uid, event.start) in [
(f.uid, f.recurrence_id) for f in found
]:
result.remove(event)
# > Will be deprecated ========================
# We will apply default cal_tz as required by some tests.
# This is just here for backward-compatibility
if not strict:
for event in result:
if type(event.start) is date:
event.start = datetime(
year=event.start.year,
month=event.start.month,
day=event.start.day,
hour=0,
minute=0,
tzinfo=cal_tz,
)
event.end = datetime(
year=event.end.year,
month=event.end.month,
day=event.end.day,
hour=0,
minute=0,
tzinfo=cal_tz,
)
elif type(event.start) is datetime:
if event.start.tzinfo:
event.start = event.start.astimezone(cal_tz)
event.end = event.end.astimezone(cal_tz)
else:
event.start = event.start.replace(tzinfo=cal_tz)
event.end = event.end.replace(tzinfo=cal_tz)
if event.created:
if type(event.created) is date:
event.created = datetime(
year=event.created.year,
month=event.created.month,
day=event.created.day,
hour=0,
minute=0,
tzinfo=cal_tz,
)
if type(event.created) is datetime:
if event.created.tzinfo:
event.created = event.created.astimezone(cal_tz)
else:
event.created = event.created.replace(tzinfo=cal_tz)
if event.last_modified:
if type(event.last_modified) is date:
event.last_modified = datetime(
year=event.last_modified.year,
month=event.last_modified.month,
day=event.last_modified.day,
hour=0,
minute=0,
tzinfo=cal_tz,
)
if type(event.last_modified) is datetime:
if event.last_modified.tzinfo:
event.last_modified = event.last_modified.astimezone(cal_tz)
else:
event.last_modified = event.last_modified.replace(tzinfo=cal_tz)
# < ==========================================
if sort:
result.sort()
if tzinfo:
result = [event.astimezone(tzinfo) for event in result]
return result
def parse_rrule(component, tz=UTC):
def parse_rrule(component):
"""
Extract a dateutil.rrule object from an icalendar component. Also includes
the component's dtstart and exdate properties. The rdate and exrule
properties are not yet supported.
:param component: icalendar component
:param tz: timezone for DST handling
:return: extracted rrule or rruleset
:return: extracted rrule or rruleset or None
"""
if component.get("rrule"):
# component['rrule'] can be both a scalar and a list
rrules = component["rrule"]
if not isinstance(rrules, list):
rrules = [rrules]
# If dtstart is a datetime, make sure it's in a timezone.
rdtstart = component["dtstart"].dt
if type(rdtstart) is datetime:
rdtstart = normalize(rdtstart, tz=tz)
dtstart = component.get("dtstart").dt
# Remove/add timezone to rrule until dates depending on component
for index, rru in enumerate(rrules):
if "UNTIL" in rru:
if type(rdtstart) is date:
rrules[index]["UNTIL"] = [
normalize(until, tz).date() for until in rrules[index]["UNTIL"]
]
else:
# Handle summer/winter time
rrules[index]["UNTIL"] = [
normalize(until, UTC)
+ tz.utcoffset(component["dtstart"].dt, is_dst=True)
for until in rrules[index]["UNTIL"]
]
# component['rrule'] can be both a scalar and a list
rrules = component.get("rrule")
if not isinstance(rrules, list):
rrules = [rrules]
# Parse the rrules, might return a rruleset instance, instead of rrule
rule = rrulestr(
"\n".join(x.to_ical().decode() for x in rrules),
dtstart=rdtstart,
forceset=True,
unfold=True,
)
def conform_until(until, dtstart):
if type(dtstart) is datetime:
# If we have timezone defined adjust for daylight saving time
if type(until) is datetime:
return until + abs(
(
until.astimezone(dtstart.tzinfo).utcoffset()
if until.tzinfo is not None and dtstart.tzinfo is not None
else None
)
or timedelta()
)
if component.get("exdate"):
# Add exdates to the rruleset
for exd in extract_exdates(component):
return (
until.astimezone(UTC)
if type(until) is datetime
else datetime(
year=until.year, month=until.month, day=until.day, tzinfo=UTC
)
) + (
(dtstart.tzinfo.utcoffset(dtstart) if dtstart.tzinfo else None)
or timedelta()
)
return until.date() + timedelta(days=1) if type(until) is datetime else until
for index, rru in enumerate(rrules):
if "UNTIL" in rru:
rrules[index]["UNTIL"] = [
conform_until(until, dtstart) for until in rrules[index]["UNTIL"]
]
rule = rrulestr(
"\n".join(x.to_ical().decode() for x in rrules),
dtstart=dtstart,
forceset=True,
unfold=True,
)
if component.get("exdate"):
# Add exdates to the rruleset
for exd in extract_exdates(component):
if type(dtstart) is date:
rule.exdate(exd.replace(tzinfo=None))
else:
rule.exdate(exd)
# TODO: What about rdates and exrules?
if component.get("exrule"):
pass
# TODO: What about rdates and exrules?
if component.get("exrule"):
pass
if component.get("rdate"):
pass
# You really want an rrule for a component without rrule? Here you are.
else:
rule = rruleset()
rule.rdate(normalize(component["dtstart"].dt, tz=tz))
if component.get("rdate"):
pass
return rule
@ -549,13 +547,18 @@ def extract_exdates(component):
:return: list of exception dates
"""
dates = []
exd_prop = component.get("exdate")
if exd_prop:
if isinstance(exd_prop, list): # In case there is more than one exdate property
for exd_list in exd_prop:
dates.extend(normalize(exd.dt) for exd in exd_list.dts)
elif isinstance(exd_prop, vDDDLists):
dates.extend(normalize(exd.dt) for exd in exd_prop.dts)
if isinstance(exd_prop, list):
for exd_list in exd_prop:
dates.extend(exd.dt for exd in exd_list.dts)
else: # it must be a vDDDLists
dates.extend(exd.dt for exd in exd_prop.dts)
return adjust_timezone(component, dates)
return dates
def get_timezone(tz_name):
if tz_name in WINDOWS_TO_OLSON:
return gettz(WINDOWS_TO_OLSON[tz_name])
else:
return gettz(tz_name)

Wyświetl plik

@ -0,0 +1,47 @@
BEGIN:VCALENDAR
METHOD:PUBLISH
PRODID:Microsoft Exchange Server 2010
VERSION:2.0
X-WR-CALNAME:Calendar
BEGIN:VTIMEZONE
TZID:Eastern Standard Time
BEGIN:STANDARD
DTSTART:16010101T020000
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=1SU;BYMONTH=11
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:16010101T020000
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=2SU;BYMONTH=3
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
DESCRIPTION:Test event that has an RRULE with multiple EXDATE
RRULE:FREQ=WEEKLY;UNTIL=20220429T150000Z;INTERVAL=1;BYDAY=FR;WKST=MO
EXDATE;TZID=Eastern Standard Time:20220318T110000,20220401T110000,20220408T
110000
UID:040000008200R00074P5O7101N82R00800000000R0Q793689428Q801000000000000000
010000000QOPN4SS024R0264S9P0Q7OOQQ16PN399
SUMMARY:Recurring With Exclusions
DTSTART;TZID=Eastern Standard Time:20220311T110000
DTEND;TZID=Eastern Standard Time:20220311T113000
CLASS:PUBLIC
PRIORITY:5
DTSTAMP:20220330T125447Z
TRANSP:OPAQUE
STATUS:CONFIRMED
SEQUENCE:0
LOCATION:Microsoft Teams Meeting
X-MICROSOFT-CDO-APPT-SEQUENCE:0
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
X-MICROSOFT-CDO-IMPORTANCE:1
X-MICROSOFT-CDO-INSTTYPE:1
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
X-MICROSOFT-DISALLOW-COUNTER:FALSE
END:VEVENT
END:VCALENDAR

Wyświetl plik

@ -146,7 +146,7 @@ class ICalEventsTests(unittest.TestCase):
start = date(2021, 1, 1)
end = date(2022, 1, 1)
evs = icalevents.events(file=ical, start=start, end=end)
evs = icalevents.events(file=ical, start=start, end=end, sort=True)
ev = evs[0]
self.assertEqual(len(evs), 3)
@ -288,47 +288,6 @@ class ICalEventsTests(unittest.TestCase):
self.assertTrue(icalevents.all_done(key), "request is finished")
self.assertEqual(len(icalevents.latest_events(key)), 2, "two events are found")
def test_event_str(self):
ical = "test/test_data/duration.ics"
start = date(2018, 1, 1)
end = date(2018, 2, 1)
n = datetime.now(UTC)
m = relativedelta(hour=0, minute=0, second=0, microsecond=0)
evs = icalevents.events(file=ical, start=start, end=end)
e1 = evs[0]
self.assertIsNotNone(
search(r"ended", str(e1.copy_to(n - relativedelta(days=5) + m))),
"stringify past event",
)
self.assertIsNotNone(
search(r"today", str(e1.copy_to(n - relativedelta(days=1) + m))),
"stringify ongoing event",
)
self.assertIsNotNone(
search(r"days left", str(e1.copy_to(n + relativedelta(days=3) + m))),
"stringify future event",
)
e2 = evs[1]
self.assertIsNotNone(
search(r"ended", str(e2.copy_to(n - relativedelta(hours=5)))),
"stringify past event",
)
self.assertIsNotNone(
search(r"now", str(e2.copy_to(n - relativedelta(hours=1)))),
"stringify ongoing event",
)
self.assertIsNotNone(
search(r"hours left", str(e2.copy_to(n + relativedelta(hours=3)))),
"stringify future event",
)
self.assertIsNotNone(
search(r"days left", str(e2.copy_to(n + relativedelta(days=3)))),
"stringify future event",
)
def test_events_no_description(self):
ical = "test/test_data/no_description.ics"
start = date(2018, 10, 15)
@ -405,10 +364,20 @@ class ICalEventsTests(unittest.TestCase):
start = date(2021, 1, 1)
end = date(2021, 12, 31)
evs = icalevents.events(file=ical, start=start, end=end)
evs = icalevents.events(file=ical, start=start, end=end, sort=True)
self.assertEqual(len(evs), 42, "42 events in total - one was moved")
def test_recurence_id_ms_moved(self):
ical = "test/test_data/recurrenceid_ms.ics"
start = date(2021, 4, 8)
end = date(2021, 4, 10)
evs = icalevents.events(file=ical, start=start, end=end, sort=True)
self.assertEqual(evs[0].start.day, 10)
self.assertEqual(len(evs), 1, "only one event - it was moved")
def test_recurence_id_google(self):
ical = "test/test_data/recurrenceid_google.ics"
start = date(2021, 1, 1)
@ -425,9 +394,15 @@ class ICalEventsTests(unittest.TestCase):
evs = icalevents.events(file=ical, start=start, end=end)
self.assertEqual(
len(evs), 239, "239 events in total"
) # 102 events / 91 + 11 recurring
self.assertEqual(len(evs), 239, "239 events in total")
def test_cest_2021_02(self):
ical = "test/test_data/cest.ics"
start = date(2021, 2, 1)
end = date(2021, 2, 28)
evs = icalevents.events(file=ical, start=start, end=end)
self.assertEqual(len(evs), 17, "17 in february")
def test_cest_2021_03(self):
ical = "test/test_data/cest.ics"
@ -442,16 +417,114 @@ class ICalEventsTests(unittest.TestCase):
start = date(2021, 4, 1)
end = date(2021, 5, 1)
evs = icalevents.events(file=ical, start=start, end=end)
self.assertEqual(len(evs), 27, "27 in april")
tz = gettz("Europe/Zurich")
events = icalevents.events(
file=ical, start=start, end=end, tzinfo=tz, sort=True, strict=True
)
times = [
((2021, 4, 1, 14, 0), (2021, 4, 1, 14, 30)),
((2021, 4, 1, 15, 30), (2021, 4, 1, 17, 0)),
((2021, 4, 2), (2021, 4, 3)),
((2021, 4, 5, 16, 00), (2021, 4, 5, 17, 0)),
((2021, 4, 7), (2021, 4, 8)),
((2021, 4, 8, 11, 0), (2021, 4, 8, 12, 0)),
((2021, 4, 8, 14, 30), (2021, 4, 8, 15, 0)),
((2021, 4, 8, 15, 0), (2021, 4, 8, 15, 30)),
((2021, 4, 9), (2021, 4, 10)),
# ((2021, 4, 9), (2021, 4, 10)), # Google said this should exist, microsoft disagrees
((2021, 4, 12, 11, 0), (2021, 4, 12, 11, 30)),
((2021, 4, 12, 16, 0), (2021, 4, 12, 17, 0)),
((2021, 4, 14), (2021, 4, 15)),
((2021, 4, 15, 12, 0), (2021, 4, 15, 13, 0)),
((2021, 4, 15, 15, 0), (2021, 4, 15, 15, 30)),
((2021, 4, 16), (2021, 4, 17)),
((2021, 4, 19, 16, 0), (2021, 4, 19, 17, 0)),
((2021, 4, 21), (2021, 4, 22)),
((2021, 4, 22, 11, 0), (2021, 4, 22, 12, 0)),
((2021, 4, 22, 14, 45), (2021, 4, 22, 15, 15)),
((2021, 4, 23), (2021, 4, 24)),
((2021, 4, 26, 16, 0), (2021, 4, 26, 17, 0)),
((2021, 4, 28), (2021, 4, 29)),
((2021, 4, 29, 9, 0), (2021, 4, 29, 11, 0)),
((2021, 4, 29, 11, 0), (2021, 4, 29, 11, 30)),
((2021, 4, 29, 14, 15), (2021, 4, 29, 15, 00)),
((2021, 4, 29, 15, 0), (2021, 4, 29, 15, 30)),
((2021, 4, 30), (2021, 5, 1)),
]
for index, time in enumerate(times):
self.assertEqual(
events[index].start,
date(*time[0]) if len(time[0]) == 3 else datetime(*time[0], tzinfo=tz),
)
self.assertEqual(
events[index].end,
date(*time[1]) if len(time[1]) == 3 else datetime(*time[1], tzinfo=tz),
)
self.assertEqual(len(events), len(times))
def test_cest_2021_05(self):
ical = "test/test_data/cest.ics"
start = date(2021, 5, 1)
end = date(2021, 6, 1)
tz = gettz("Europe/Zurich")
events = icalevents.events(
file=ical, start=start, end=end, tzinfo=tz, sort=True, strict=True
)
times = [
((2021, 5, 3, 16, 0), (2021, 5, 3, 17, 0)),
((2021, 5, 5), (2021, 5, 6)),
((2021, 5, 6, 11, 0), (2021, 5, 6, 12, 0)),
((2021, 5, 6, 15, 0), (2021, 5, 6, 15, 30)),
((2021, 5, 7), (2021, 5, 8)),
((2021, 5, 10, 16, 0), (2021, 5, 10, 17, 0)),
((2021, 5, 12), (2021, 5, 13)),
((2021, 5, 13, 15, 0), (2021, 5, 13, 15, 30)),
((2021, 5, 14), (2021, 5, 15)),
((2021, 5, 17, 16, 0), (2021, 5, 17, 17, 0)),
((2021, 5, 19), (2021, 5, 20)),
((2021, 5, 20, 11, 0), (2021, 5, 20, 12, 0)),
((2021, 5, 20, 12, 0), (2021, 5, 20, 13, 0)),
((2021, 5, 20, 15, 0), (2021, 5, 20, 15, 30)),
((2021, 5, 21), (2021, 5, 22)),
((2021, 5, 24, 16, 0), (2021, 5, 24, 17, 0)),
((2021, 5, 26), (2021, 5, 27)),
((2021, 5, 27, 15, 0), (2021, 5, 27, 15, 30)),
((2021, 5, 28), (2021, 5, 29)),
((2021, 5, 31, 16, 0), (2021, 5, 31, 17, 0)),
]
for index, time in enumerate(times):
self.assertEqual(
events[index].start,
date(*time[0]) if len(time[0]) == 3 else datetime(*time[0], tzinfo=tz),
)
self.assertEqual(
events[index].end,
date(*time[1]) if len(time[1]) == 3 else datetime(*time[1], tzinfo=tz),
)
self.assertEqual(len(events), len(times))
def test_cest_2021_06(self):
ical = "test/test_data/cest.ics"
start = date(2021, 6, 1)
end = date(2021, 6, 30)
evs = icalevents.events(file=ical, start=start, end=end)
self.assertEqual(len(evs), 20, "20 in mai")
self.assertEqual(len(evs), 11, "11 in june")
def test_cest_2021_07(self):
ical = "test/test_data/cest.ics"
start = date(2021, 7, 1)
end = date(2021, 7, 31)
evs = icalevents.events(file=ical, start=start, end=end)
self.assertEqual(len(evs), 1, "1 in july")
def test_cest_1(self):
ical = "test/test_data/cest_every_day_for_one_year.ics"
@ -607,3 +680,118 @@ class ICalEventsTests(unittest.TestCase):
datetime(2021, 11, 23, 13, 0, tzinfo=UTC),
"moved 1 hour from 12:00 to 13:00",
)
def test_recurring_tz_passover_fall(self):
ical = "test/test_data/recurring_override.ics"
start = date(2021, 8, 30)
end = date(2021, 9, 18)
tz = gettz("Europe/Zurich")
events = icalevents.events(
file=ical, start=start, end=end, tzinfo=tz, sort=True, strict=True
)
times = [
((2021, 8, 30, 8, 0), (2021, 8, 30, 17, 0)),
((2021, 8, 30, 9, 30), (2021, 8, 30, 10, 0)),
((2021, 8, 31, 10, 0), (2021, 8, 31, 10, 30)),
((2021, 8, 31, 10, 15), (2021, 8, 31, 10, 45)),
((2021, 8, 31, 13, 15), (2021, 8, 31, 14, 0)),
((2021, 9, 1, 9, 0), (2021, 9, 1, 10, 0)),
((2021, 9, 1, 9, 30), (2021, 9, 1, 10, 0)),
((2021, 9, 1, 12, 0), (2021, 9, 1, 13, 0)),
((2021, 9, 2, 10, 0), (2021, 9, 2, 10, 30)),
((2021, 9, 3, 8, 0), (2021, 9, 3, 8, 30)),
((2021, 9, 3, 9, 0), (2021, 9, 3, 9, 30)),
((2021, 9, 3, 9, 30), (2021, 9, 3, 10, 0)),
((2021, 9, 3, 15, 30), (2021, 9, 3, 16, 0)),
((2021, 9, 3, 17, 30), (2021, 9, 3, 19, 0)),
((2021, 9, 6, 8, 0), (2021, 9, 6, 17, 0)),
((2021, 9, 6, 9, 30), (2021, 9, 6, 10, 0)),
((2021, 9, 7, 9, 0), (2021, 9, 7, 12, 0)),
((2021, 9, 7, 9, 0), (2021, 9, 7, 12, 0)),
((2021, 9, 7, 10, 0), (2021, 9, 7, 10, 30)),
((2021, 9, 8, 9, 30), (2021, 9, 8, 10, 0)),
((2021, 9, 8, 12, 0), (2021, 9, 8, 13, 0)),
((2021, 9, 9), (2021, 9, 10)),
((2021, 9, 9, 10, 0), (2021, 9, 9, 10, 30)),
((2021, 9, 9, 11, 0), (2021, 9, 9, 12, 0)),
((2021, 9, 10, 8, 0), (2021, 9, 10, 8, 30)),
((2021, 9, 10, 9, 30), (2021, 9, 10, 10, 0)),
((2021, 9, 10, 17, 30), (2021, 9, 10, 19, 0)),
((2021, 9, 13, 9, 30), (2021, 9, 13, 10, 0)),
((2021, 9, 14, 9, 0), (2021, 9, 14, 10, 0)),
((2021, 9, 14, 10, 0), (2021, 9, 14, 10, 30)),
((2021, 9, 14, 15, 0), (2021, 9, 14, 15, 30)),
((2021, 9, 15, 9, 30), (2021, 9, 15, 10, 0)),
((2021, 9, 16, 10, 0), (2021, 9, 16, 10, 30)),
((2021, 9, 16), (2021, 9, 17)),
((2021, 9, 17, 9, 30), (2021, 9, 17, 10, 0)),
((2021, 9, 17, 17, 30), (2021, 9, 17, 19, 0)),
]
for index, time in enumerate(times):
self.assertEqual(
events[index].start,
date(*time[0]) if len(time[0]) == 3 else datetime(*time[0], tzinfo=tz),
)
self.assertEqual(
events[index].end,
date(*time[1]) if len(time[1]) == 3 else datetime(*time[1], tzinfo=tz),
)
self.assertEqual(len(events), len(times))
def test_recurring_tz_passover_spring(self):
ical = "test/test_data/recurring_override.ics"
start = date(2022, 3, 6)
end = date(2022, 4, 10)
tz = gettz("Europe/Zurich")
events = icalevents.events(
file=ical, start=start, end=end, tzinfo=tz, sort=True, strict=True
)
times = [
((2022, 3, 8, 11, 45), (2022, 3, 8, 12, 0)),
((2022, 3, 10), (2022, 3, 11)),
((2022, 3, 10, 11, 0), (2022, 3, 10, 12, 0)),
((2022, 3, 15, 11, 45), (2022, 3, 15, 12, 0)),
((2022, 3, 22, 11, 45), (2022, 3, 22, 12, 0)),
((2022, 3, 22, 14, 00), (2022, 3, 22, 15, 0)),
((2022, 3, 24), (2022, 3, 25)),
((2022, 3, 29, 11, 45), (2022, 3, 29, 12, 0)),
((2022, 4, 3, 8, 0), (2022, 4, 3, 8, 30)),
((2022, 4, 7), (2022, 4, 8)),
]
for index, time in enumerate(times):
self.assertEqual(
events[index].start,
date(*time[0]) if len(time[0]) == 3 else datetime(*time[0], tzinfo=tz),
)
self.assertEqual(
events[index].end,
date(*time[1]) if len(time[1]) == 3 else datetime(*time[1], tzinfo=tz),
)
self.assertEqual(len(events), len(times))
def test_multi_exdate_same_line(self):
ical = "test/test_data/multi_exdate_same_line_ms.ics"
tz = gettz("America/New_York")
start = date(2022, 3, 1)
end = date(2022, 5, 1)
evs = icalevents.events(file=ical, start=start, end=end)
# parsing starts at 2022-03-01
self.assertEqual(evs[0].start, datetime(2022, 3, 11, 11, 0, 0, tzinfo=tz))
# 2022-03-18 is excluded by EXDATE rule
self.assertEqual(evs[1].start, datetime(2022, 3, 25, 11, 0, 0, tzinfo=tz))
# 2022-04-01 is excluded by EXDATE rule
# 2022-04-08 is excluded by EXDATE rule
self.assertEqual(evs[2].start, datetime(2022, 4, 15, 11, 0, 0, tzinfo=tz))
self.assertEqual(evs[3].start, datetime(2022, 4, 22, 11, 0, 0, tzinfo=tz))
self.assertEqual(evs[4].start, datetime(2022, 4, 29, 11, 0, 0, tzinfo=tz))
# parsing stops at 2022-05-01

Wyświetl plik

@ -78,36 +78,6 @@ class ICalParserTests(unittest.TestCase):
def test_event_order(self):
self.assertTrue(self.eventA > self.eventB, "order of events")
def test_normalize(self):
dt = date(year=2016, month=11, day=13)
norm = icalevents.icalparser.normalize(dt)
self.assertTrue(type(norm) is datetime, "type is datetime")
self.assertEqual(2016, norm.year, "year")
self.assertEqual(11, norm.month, "month")
self.assertEqual(13, norm.day, "day")
self.assertEqual(0, norm.hour, "hour")
self.assertEqual(0, norm.minute, "minute")
self.assertEqual(0, norm.second, "second")
self.assertEqual(0, norm.microsecond, "microsecond")
self.assertEqual(UTC, norm.tzinfo, "timezone")
dt = datetime(year=2016, month=11, day=13, hour=1, minute=2, second=3)
norm = icalevents.icalparser.normalize(dt)
self.assertTrue(type(norm) is datetime, "type is datetime")
self.assertEqual(2016, norm.year, "year")
self.assertEqual(11, norm.month, "month")
self.assertEqual(13, norm.day, "day")
self.assertEqual(1, norm.hour, "hour")
self.assertEqual(2, norm.minute, "minute")
self.assertEqual(3, norm.second, "second")
self.assertEqual(0, norm.microsecond, "microsecond")
self.assertEqual(UTC, norm.tzinfo, "timezone")
with self.assertRaises(ValueError, msg="type check effective"):
icalevents.icalparser.normalize(None)
def test_attendee(self):
self.assertIsInstance(self.eventA.attendee, str)
self.assertIsInstance(self.eventB.attendee, list)
@ -119,4 +89,4 @@ class ICalParserTests(unittest.TestCase):
def test_str(self):
self.eventA.start = datetime(year=2017, month=2, day=3, hour=12, minute=5)
self.eventA.end = datetime(year=2017, month=2, day=3, hour=15, minute=5)
self.assertEqual("2017-02-03 12:05:00+00:00: Event A (ended)", str(self.eventA))
self.assertEqual("2017-02-03 12:05:00: Event A (3:00:00)", str(self.eventA))