Created
October 21, 2012 02:18
-
-
Save rmasters/3925486 to your computer and use it in GitHub Desktop.
Finding occurrences of repeated events within a given datetime range
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
Find all events from an iCalendar file, with some support for recurrent events | |
Implemented: | |
- Normal events (with DTSTART and DTEND) | |
- Daily, weekly, monthly, yearly, hourly and minutely frequencies (FREQ) | |
- Intervals in frequencies (INTERVAL) | |
- Until date (UNTIL) | |
- Maximum occurrences (COUNT) | |
- Excluded dates (EXDATE) | |
Not implemented: | |
- BYDAY (generally weekly works if the DTSTART uses the desired day) | |
- BYMONTH | |
- BYMONTHDAY | |
- BYSETPOS | |
- BYHOUR | |
- BYMINUTE | |
- WKST | |
Timezone support is also week (icalendar does not report the timezone used on | |
DTSTART/DTEND so this is difficult to tell). | |
""" | |
import datetime | |
import copy | |
import pytz | |
from icalendar import Calendar, Event | |
TIMEZONE = pytz.timezone('Europe/London') | |
""" | |
Make a datetime timezone aware, and convert datetime.dates to datetime.datetimes | |
icalendar returns naive datetimes, so this attempts to cooerce them to a | |
timezone in a dubious manner | |
""" | |
def fixtz(dt): | |
if isinstance(dt, datetime.datetime): | |
if dt.tzinfo is None: | |
dt = TIMEZONE.localize(dt) | |
elif dt.tzinfo is not TIMEZONE: | |
dt = dt.astimezone(TIMEZONE) | |
else: | |
dt = datetime.datetime.combine(dt, datetime.time(0,0,0)) | |
dt = TIMEZONE.localize(dt) | |
return dt | |
""" | |
Get events within a given datetime range | |
calendar: a icalendar.Calendar() instance | |
start, end: datetime.datetime instances | |
""" | |
def get_events(calendar, start, end): | |
start = fixtz(start) | |
end = fixtz(end) | |
# Collected events | |
events = [] | |
# Walk each event in the calendar | |
for evt in calendar.walk('VEVENT'): | |
# Normalise start and end times | |
evt['DTSTART'].dt = fixtz(evt['DTSTART'].dt) | |
evt['DTEND'].dt = fixtz(evt['DTEND'].dt) | |
# If the event is not recurrent, simply check if it's within our desired | |
# range | |
if 'RRULE' not in evt: | |
if evt['DTSTART'].dt >= start and evt['DTEND'].dt <= end: | |
events.append(evt) | |
continue | |
# The event is recurrent - find occurrences | |
occurences = [] | |
# For use with RRULE:COUNT=n | |
counter = 1 | |
# A tuple of the current/last start and end date for incrementing | |
cur_date = (evt['DTSTART'].dt, evt['DTEND'].dt) | |
# Increment a start/end datetime pair | |
def increment(start, end): | |
interval = evt['RRULE']['INTERVAL'][0] if 'INTERVAL' in evt['RRULE'] else 1 | |
freq = evt['RRULE']['FREQ'][0] | |
if freq == 'DAILY': | |
# Add n days | |
delta = datetime.timedelta(days=interval) | |
return (start+delta, end+delta) | |
elif freq == 'WEEKLY': | |
# Add n weeks | |
delta = datetime.timedelta(weeks=interval) | |
return (start+delta, end+delta) | |
elif freq == 'MONTHLY': | |
# Add n months | |
month, year = divmod(interval, 12) | |
return (start.replace(month=start.month+month, year=start.year+year), \ | |
end.replace(month=end.month+month, year=end.year+year)) | |
elif freq == 'YEARLY': | |
# Add n years | |
return (start.replace(year=start.year+interval), \ | |
end.replace(year=end.year+interval)) | |
elif freq == 'HOURLY': | |
# Add n hours | |
delta = datetime.timedelta(hours=interval) | |
return (start+delta, end+delta) | |
elif freq == 'MINUTELY': | |
# Add n minutes | |
delta = datetime.timedelta(minutes=interval) | |
return (start+delta, end+delta) | |
# Not incremented - should really be reported somehow | |
return (start, end) | |
# Find all occurences | |
while True: | |
# If we have reached the max occurences required, bail out | |
if 'COUNT' in evt['RRULE'] and counter > evt['RRULE']['COUNT'][0]: break | |
# Make a shallow copy of the event | |
occ = copy.deepcopy(evt) | |
occ['RRULE'] = None | |
if counter > 1: cur_date = increment(cur_date[0], cur_date[1]) | |
occ['DTSTART'].dt = cur_date[0] | |
occ['DTEND'].dt = cur_date[1] | |
# If we have passed the maximum date, exit | |
if 'UNTIL' in evt['RRULE'] and cur_date > evt['RRULE']['UNTIL'].dt: break | |
# If the event ends after the end date, exit | |
if occ['DTEND'].dt > end: break | |
# Ensure the date is within the range from the start | |
if occ['DTSTART'].dt >= start: | |
# Ensure date is not in the exclusion list | |
if 'EXDATE' not in evt or \ | |
occ['DTSTART'].dt not in [fixtz(ex.dt) for ex in evt['EXDATE'].dts]: | |
occurences.append(occ) | |
dump_evt(occ) | |
counter += 1 | |
events += occurences | |
return events | |
# Sample usage | |
if __name__ == "__main__": | |
with open('calendar.ics') as f: | |
calendar = Calendar().from_ical(f.read()) | |
start = datetime.datetime.today() | |
end = datetime.datetime.today() | |
events = get_events(calendar, start, end) | |
for evt in events: | |
print evt['DTSTART'].dt, evt['DTEND'].dt, evt['SUMMARY'] |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
5765ab Fixes an issue where an occurrence is modified by the next run (most assignments in Python create references).