feat: actually handle dates, datetimes and timezones as defined in specs

pull/97/head
Martin Eigenmann 2021-12-26 15:25:56 +01:00
rodzic 2117c0da04
commit 1df3dc1db2
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 069D1EE3806CA368
4 zmienionych plików z 453 dodań i 330 usunięć

Wyświetl plik

@ -20,6 +20,9 @@ def events(
end=None,
fix_apple=False,
http=None,
tzinfo=None,
sort=None,
strict=False
):
"""
Get all events form the given iCal URL occurring in the given time range.
@ -30,6 +33,9 @@ def events(
:param start: start date (see dateutils.date)
:param end: end date (see dateutils.date)
:param fix_apple: fix known Apple iCal issues
: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
:return: events as list of dictionaries
"""
found_events = []
@ -46,7 +52,7 @@ 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)
return found_events

Wyświetl plik

@ -2,20 +2,20 @@
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,41 +89,33 @@ 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()
if not self.start.tzinfo:
self.start = normalize(self.start)
if not self.end.tzinfo:
self.end = normalize(self.end)
# 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"
return "%s: %s (%s)" % (self.start, self.summary, delta)
return "%s: %s (%s)" % (self.start, self.summary, self.end - self.start)
def astimezone(self, tzinfo):
if type(self.start) is datetime:
self.start = self.start.astimezone(tzinfo)
if type(self.end) is datetime:
self.end = self.end.astimezone(tzinfo)
return self
def copy_to(self, new_start=None, uid=None):
"""
Create a new event equal to this with new start date.
@ -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,7 @@ 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.
@ -332,7 +278,13 @@ def parse_events(content, start=None, end=None, default_span=timedelta(days=7)):
raise ValueError("Content is invalid!")
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 +312,179 @@ 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 = []
for component in calendar.walk():
# Skip dates that are stored as exceptions.
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():
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"], 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
if component.name == "VEVENT":
e = create_event(component, utc_default, cal_tz)
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
# 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
e = create_event(component, utc_default)
# 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)
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)
# 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)
ecopy = e.copy_to(dt.date() if type(s) is date else dt, e.uid)
add_if_not_exception(ecopy)
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)
# 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"]
]
# Parse the rrules, might return a rruleset instance, instead of rrule
rule = rrulestr(
if not component.get("rrule"):
return None
dtstart = component.get("dtstart").dt
# component['rrule'] can be both a scalar and a list
rrules = component.get("rrule")
if not isinstance(rrules, list):
rrules = [rrules]
def conform_until(until, dtstart):
if type(dtstart) is datetime:
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)
elif type(dtstart) is date:
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=rdtstart,
dtstart=dtstart,
forceset=True,
unfold=True,
)
if component.get("exdate"):
# Add exdates to the rruleset
for exd in extract_exdates(component):
if component.get("exdate"):
# Add exdates to the rruleset
for exd in extract_exdates(component):
if type(dtstart) is date:
if exd.tzinfo:
rule.exdate(exd.replace(tzinfo=None))
#rule.exdate(datetime(year=exd.year, month=exd.month, day=exd.day) + timedelta(days=1))
else:
rule.exdate(exd)
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
print('exrule', component.get("exrule"))
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
print('rdate', component.get("rdate"))
return rule
@ -549,13 +497,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
if exd_prop := component.get("exdate"):
if isinstance(exd_prop, list):
for exd_list in exd_prop:
dates.extend(normalize(exd.dt) for exd in exd_list.dts)
dates.extend(exd.dt for exd in exd_list.dts)
elif isinstance(exd_prop, vDDDLists):
dates.extend(normalize(exd.dt) for exd in exd_prop.dts)
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

@ -288,46 +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"
@ -405,9 +365,19 @@ 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"
@ -427,8 +397,16 @@ class ICalEventsTests(unittest.TestCase):
self.assertEqual(
len(evs), 239, "239 events in total"
) # 102 events / 91 + 11 recurring
)
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"
start = date(2021, 3, 1)
@ -441,18 +419,133 @@ class ICalEventsTests(unittest.TestCase):
ical = "test/test_data/cest.ics"
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)
evs = icalevents.events(file=ical, start=start, end=end)
self.assertEqual(len(evs), 20, "20 in mai")
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), 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"
start = date(2020, 1, 1)
@ -607,3 +700,104 @@ 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))

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