Reorganize modules in yaml2ical

Reorganize modules in yaml2ical so that:
- Reading meetings from YAML (and checking conflicts) is in meeting.py
- Functions to produce iCal output are in ical.py
- Date recurrence functions are in recurrence.py

There is no need for a generic utils.py anymore.
Further refactoring of functions and object classes is coming.

Change-Id: I35d2833f5eac088c13e684bf094a455591135dff
This commit is contained in:
Thierry Carrez 2015-01-16 18:21:13 +01:00
parent 923218b121
commit 4ab55d5c77
9 changed files with 306 additions and 308 deletions

View File

@ -14,7 +14,7 @@ import argparse
import logging
import os
from yaml2ical import utils
from yaml2ical import ical
# logging settings
@ -43,7 +43,7 @@ project infrastructure.
dest="ical_dir",
help="output directory (one file per meeting)")
outputtype.add_argument("-o", "--output",
dest="ical",
dest="icalfile",
help="output file (one file for all meetings)")
parser.add_argument("-f", "--force",
dest="force",
@ -78,16 +78,16 @@ def main():
else:
raise Exception("Directory for storing iCals is not empty, "
"suggest running with -f to remove old files.")
utils.convert_yaml_to_ical(yaml_dir, outputdir=ical_dir)
ical.convert_yaml_to_ical(yaml_dir, outputdir=ical_dir)
else:
ical = os.path.abspath(args.ical)
if os.path.exists(ical):
icalfile = os.path.abspath(args.icalfile)
if os.path.exists(icalfile):
if force:
os.remove(ical)
os.remove(icalfile)
else:
raise Exception("Output file already exists, suggest running "
"with -f to overwrite previous file.")
utils.convert_yaml_to_ical(yaml_dir, outputfile=ical)
ical.convert_yaml_to_ical(yaml_dir, outputfile=ical)
if __name__ == '__main__':

139
yaml2ical/ical.py Normal file
View File

@ -0,0 +1,139 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import datetime
import icalendar
import logging
import os
import pytz
from yaml2ical import meeting
from yaml2ical import recurrence
class Yaml2IcalCalendar(icalendar.Calendar):
"""A calendar in ics format."""
def __init__(self):
super(Yaml2IcalCalendar, self).__init__()
self.add('prodid', '-//yaml2ical agendas//EN')
self.add('version', '2.0')
def add_meeting(self, meeting):
"""Add this meeting to the calendar."""
for sch in meeting.schedules:
# one Event per iCal file
event = icalendar.Event()
# NOTE(jotan): I think the summary field needs to be unique per
# event in an ical file (at least, for it to work with
# Google Calendar)
event.add('summary', meeting.project)
event.add('location', '#' + sch.irc)
# add ical description
project_descript = "Project: %s" % (meeting.project)
chair_descript = "Chair: %s" % (meeting.chair)
descript_descript = "Description: %s" % (meeting.description)
ical_descript = "\n".join((project_descript,
chair_descript,
descript_descript))
event.add('description', ical_descript)
# get starting date
start_date = datetime.datetime.utcnow()
if sch.freq.startswith('biweekly'):
meet_on_even = sch.freq.endswith('even')
next_meeting = recurrence.next_biweekly_meeting(
start_date,
sch.day,
meet_on_even=meet_on_even)
else:
next_meeting = recurrence.next_weekday(start_date, sch.day)
next_meeting_date = datetime.datetime(next_meeting.year,
next_meeting.month,
next_meeting.day,
sch.time.hour,
sch.time.minute,
tzinfo=pytz.utc)
event.add('dtstart', next_meeting_date)
# add recurrence rule
if sch.freq.startswith('biweekly'):
cadence = ()
# NOTE(lbragstad): Setting the `cadence` for the schedule
# will allow for alternating meetings. Typically there are
# only 4 weeks in a month but adding `5` and `6` allow for
# cases where there are 5 meetings in a month, which would
# otherwise be unsupported if only setting `cadence` to
# either (1, 3) or (2, 4).
if sch.freq == 'biweekly-odd':
cadence = (1, 3, 5)
elif sch.freq == 'biweekly-even':
cadence = (2, 4, 6)
rule_dict = {'freq': 'monthly',
'byday': sch.day[0:2],
'bysetpos': cadence}
event.add('rrule', rule_dict)
else:
event.add('rrule', {'freq': sch.freq})
# add meeting length
# TODO(jotan): determine duration to use for OpenStack meetings
event.add('duration', datetime.timedelta(hours=1))
# add event to calendar
self.add_component(event)
def write_to_disk(self, filename):
# write ical files to disk
with open(filename, 'wb') as ics:
ics.write(self.to_ical())
def convert_yaml_to_ical(yaml_dir, outputdir=None, outputfile=None):
"""Convert meeting YAML files to iCal.
If meeting_list is specified, only those meetings in yaml_dir with
filenames contained in meeting_list are converted; otherwise,
all meeting in yaml_dir are converted.
:param yaml_dir: directory where meeting.yaml files are stored
:param outputdir: location to store iCal files (one file per meeting)
:param outputfile: output iCal file (one single file for all meetings)
"""
meetings = meeting.load_meetings(yaml_dir)
# Check uniqueness and conflicts here before writing out to .ics
meeting.check_for_meeting_conflicts(meetings)
# convert meetings to a list of ical
if outputdir:
for m in meetings:
cal = Yaml2IcalCalendar()
cal.add_meeting(m)
filename = os.path.basename(m._filename).split('.')[0] + '.ics'
cal.write_to_disk(os.path.join(outputdir, filename))
# convert meetings into a single ical
if outputfile:
cal = Yaml2IcalCalendar()
for m in meetings:
cal.add_meeting(m)
cal.write_to_disk(outputfile)
# TODO(jotan): verify converted ical is valid
logging.info('Wrote %d meetings to iCal' % (len(meetings)))

View File

@ -13,30 +13,19 @@
import datetime
import hashlib
import os
import icalendar
import pytz
import yaml
from yaml2ical import schedule
class Schedule:
"""A meeting schedule."""
WEEKDAYS = {'Monday': 0, 'Tuesday': 1, 'Wednesday': 2, 'Thursday': 3,
'Friday': 4, 'Saturday': 5, 'Sunday': 6}
def __init__(self, sched_yaml):
"""Initialize schedule from yaml."""
class Yaml2IcalCalendar(icalendar.Calendar):
"""A calendar in ics format."""
def __init__(self):
super(Yaml2IcalCalendar, self).__init__()
self.add('prodid', '-//yaml2ical agendas//EN')
self.add('version', '2.0')
def write_to_disk(self, filename):
# write ical files to disk
with open(filename, 'wb') as ics:
ics.write(self.to_ical())
self.time = datetime.datetime.strptime(sched_yaml['time'], '%H%M')
self.day = sched_yaml['day']
self.irc = sched_yaml['irc']
self.freq = sched_yaml['frequency']
class Meeting:
@ -46,90 +35,6 @@ class Meeting:
"""Initialize meeting from yaml file name 'filename'."""
pass
def add_to_calendar(self, cal):
"""Add this meeting to an existing calendar."""
for sch in self.schedules:
# one Event per iCal file
event = icalendar.Event()
# NOTE(jotan): I think the summary field needs to be unique per
# event in an ical file (at least, for it to work with
# Google Calendar)
event.add('summary', self.project)
event.add('location', '#' + sch.irc)
# add ical description
project_descript = "Project: %s" % (self.project)
chair_descript = "Chair: %s" % (self.chair)
descript_descript = "Description: %s" % (self.description)
ical_descript = "\n".join((project_descript,
chair_descript,
descript_descript))
event.add('description', ical_descript)
# get starting date
start_date = datetime.datetime.utcnow()
if sch.freq.startswith('biweekly'):
meet_on_even = sch.freq.endswith('even')
next_meeting = next_biweekly_meeting(start_date,
WEEKDAYS[sch.day],
meet_on_even=meet_on_even)
else:
next_meeting = next_weekday(start_date,
WEEKDAYS[sch.day])
next_meeting_date = datetime.datetime(next_meeting.year,
next_meeting.month,
next_meeting.day,
sch.time.hour,
sch.time.minute,
tzinfo=pytz.utc)
event.add('dtstart', next_meeting_date)
# add recurrence rule
if sch.freq.startswith('biweekly'):
cadence = ()
# NOTE(lbragstad): Setting the `cadence` for the schedule
# will allow for alternating meetings. Typically there are
# only 4 weeks in a month but adding `5` and `6` allow for
# cases where there are 5 meetings in a month, which would
# otherwise be unsupported if only setting `cadence` to
# either (1, 3) or (2, 4).
if sch.freq == 'biweekly-odd':
cadence = (1, 3, 5)
elif sch.freq == 'biweekly-even':
cadence = (2, 4, 6)
rule_dict = {'freq': 'monthly',
'byday': sch.day[0:2],
'bysetpos': cadence}
event.add('rrule', rule_dict)
else:
event.add('rrule', {'freq': sch.freq})
# add meeting length
# TODO(jotan): determine duration to use for OpenStack meetings
event.add('duration', datetime.timedelta(hours=1))
# add event to calendar
cal.add_component(event)
def next_weekday(ref_date, weekday):
"""Return the date of the next meeting.
:param ref_date: datetime object of meeting
:param weekday: weekday the meeting is held on
:returns: datetime object of the next meeting time
"""
days_ahead = weekday - ref_date.weekday()
if days_ahead <= 0: # target day already happened this week
days_ahead += 7
return ref_date + datetime.timedelta(days_ahead)
def load_meetings(yaml_source):
"""Build YAML object and load meeting data
@ -178,37 +83,55 @@ def _load_meeting(meeting_yaml):
# of having every Meeting object build a list of Schedule objects.
m.schedules = []
for sch in yaml_obj['schedule']:
s = schedule.Schedule(sch)
s = Schedule(sch)
m.schedules.append(s)
return m
def next_biweekly_meeting(current_date_time, weekday, meet_on_even=False):
"""Calculate the next biweekly meeting.
class MeetingConflictError(Exception):
pass
:param current_date_time: the current datetime object
:param weekday: scheduled day of the meeting
:param meet_on_even: True if meeting on even weeks and False if meeting
on odd weeks
:returns: datetime object of next meeting
def _extract_meeting_info(meeting_obj):
"""Pull out meeting info of Meeting object.
:param meeting_obj: Meeting object
:returns: a dictionary of meeting info
"""
first_day_of_mo = current_date_time.replace(day=1)
day_of_week = first_day_of_mo.strftime("%w")
adjustment = (8 - int(day_of_week)) % (7 - weekday)
if meet_on_even:
adjustment += 7
next_meeting = first_day_of_mo + datetime.timedelta(adjustment)
meeting_info = []
for schedule in meeting_obj.schedules:
info = {'name': meeting_obj.project,
'filename': meeting_obj._filename,
'day': schedule.day,
'time': schedule.time,
'irc_room': schedule.irc}
meeting_info.append(info)
if current_date_time > next_meeting:
next_meeting = next_meeting + datetime.timedelta(14)
if current_date_time > next_meeting:
current_date_time = current_date_time.replace(
month=current_date_time.month + 1, day=1)
first_wday_next_mo = next_weekday(current_date_time, weekday)
if meet_on_even:
next_meeting = first_wday_next_mo + datetime.timedelta(7)
else:
next_meeting = first_wday_next_mo
return next_meeting
return meeting_info
def check_for_meeting_conflicts(meetings):
"""Check if a list of meetings have conflicts.
:param meetings: list of Meeting objects
"""
for i in range(len(meetings)):
meeting_info = _extract_meeting_info(meetings[i])
for j in range(i + 1, len(meetings)):
next_meeting_info = _extract_meeting_info(meetings[j])
for current_meeting in meeting_info:
for next_meeting in next_meeting_info:
if current_meeting['day'] != next_meeting['day']:
continue
if current_meeting['time'] != next_meeting['time']:
continue
if current_meeting['irc_room'] != next_meeting['irc_room']:
continue
msg_dict = {'first': current_meeting['filename'],
'second': next_meeting['filename']}
raise MeetingConflictError("Conflict between %(first)s "
"and %(second)s." % msg_dict)

64
yaml2ical/recurrence.py Normal file
View File

@ -0,0 +1,64 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import datetime
WEEKDAYS = {'Monday': 0, 'Tuesday': 1, 'Wednesday': 2, 'Thursday': 3,
'Friday': 4, 'Saturday': 5, 'Sunday': 6}
def next_weekday(ref_date, day):
"""Return the date of the next meeting.
:param ref_date: datetime object of meeting
:param day: weekday the meeting is held on
:returns: datetime object of the next meeting time
"""
weekday = WEEKDAYS[day]
days_ahead = weekday - ref_date.weekday()
if days_ahead <= 0: # target day already happened this week
days_ahead += 7
return ref_date + datetime.timedelta(days_ahead)
def next_biweekly_meeting(current_date_time, day, meet_on_even=False):
"""Calculate the next biweekly meeting.
:param current_date_time: the current datetime object
:param day: scheduled day of the meeting
:param meet_on_even: True if meeting on even weeks and False if meeting
on odd weeks
:returns: datetime object of next meeting
"""
weekday = WEEKDAYS[day]
first_day_of_mo = current_date_time.replace(day=1)
day_of_week = first_day_of_mo.strftime("%w")
adjustment = (8 - int(day_of_week)) % (7 - weekday)
if meet_on_even:
adjustment += 7
next_meeting = first_day_of_mo + datetime.timedelta(adjustment)
if current_date_time > next_meeting:
next_meeting = next_meeting + datetime.timedelta(14)
if current_date_time > next_meeting:
current_date_time = current_date_time.replace(
month=current_date_time.month + 1, day=1)
first_wday_next_mo = next_weekday(current_date_time, weekday)
if meet_on_even:
next_meeting = first_wday_next_mo + datetime.timedelta(7)
else:
next_meeting = first_wday_next_mo
return next_meeting

View File

@ -1,25 +0,0 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import datetime
class Schedule:
"""A meeting schedule."""
def __init__(self, sched_yaml):
"""Initialize schedule from yaml."""
self.time = datetime.datetime.strptime(sched_yaml['time'], '%H%M')
self.day = sched_yaml['day']
self.irc = sched_yaml['irc']
self.freq = sched_yaml['frequency']

View File

@ -10,7 +10,6 @@
# License for the specific language governing permissions and limitations
# under the License.
import datetime
import unittest
from yaml2ical import meeting
@ -26,12 +25,18 @@ class MeetingTestCase(unittest.TestCase):
self.assertEqual('Weekly meeting for Subteam project.\n',
m.description)
def test_calculate_next_biweekly_meeting_meet_on_even(self):
test_time = datetime.datetime(2014, 10, 5, 2, 47, 28, 832666)
test_weekday = 2
meet_on_even = True
next_meeting = meeting.next_biweekly_meeting(test_time,
test_weekday,
meet_on_even=meet_on_even)
expected_meeting = datetime.datetime(2014, 10, 8, 2, 47, 28, 832666)
self.assertEqual(expected_meeting, next_meeting)
def test_exception_raised_when_conflict_detected(self):
"""Exception is raised when a meeting conflict is detected."""
meeting_one = meeting.load_meetings(sample_data.FIRST_MEETING_YAML)
meeting_two = meeting.load_meetings(sample_data.SECOND_MEETING_YAML)
meeting_list = [meeting_one.pop(), meeting_two.pop()]
self.assertRaises(meeting.MeetingConflictError,
meeting.check_for_meeting_conflicts,
meeting_list)
def test_no_exception_raised_with_diff_irc_rooms(self):
"""No exception raised when using different IRC rooms."""
meeting_one = meeting.load_meetings(sample_data.FIRST_MEETING_YAML)
meeting_two = meeting.load_meetings(sample_data.THIRD_MEETING_YAML)
meeting_list = [meeting_one.pop(), meeting_two.pop()]
meeting.check_for_meeting_conflicts(meeting_list)

View File

@ -0,0 +1,30 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import datetime
import unittest
from yaml2ical import recurrence
class RecurrenceTestCase(unittest.TestCase):
def test_calculate_next_biweekly_meeting_meet_on_even(self):
test_time = datetime.datetime(2014, 10, 5, 2, 47, 28, 832666)
test_weekday = 'Wednesday'
meet_on_even = True
next_meeting = recurrence.next_biweekly_meeting(
test_time,
test_weekday,
meet_on_even=meet_on_even)
expected_meeting = datetime.datetime(2014, 10, 8, 2, 47, 28, 832666)
self.assertEqual(expected_meeting, next_meeting)

View File

@ -1,36 +0,0 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import unittest
from yaml2ical import meeting
from yaml2ical.tests import sample_data
from yaml2ical import utils
class UtilsTestCase(unittest.TestCase):
def test_exception_raised_when_conflict_detected(self):
"""Exception is raised when a meeting conflict is detected."""
meeting_one = meeting.load_meetings(sample_data.FIRST_MEETING_YAML)
meeting_two = meeting.load_meetings(sample_data.SECOND_MEETING_YAML)
meeting_list = [meeting_one.pop(), meeting_two.pop()]
self.assertRaises(utils.MeetingConflictError,
utils._check_for_meeting_conflicts,
meeting_list)
def test_no_exception_raised_with_diff_irc_rooms(self):
"""No exception raised when using different IRC rooms."""
meeting_one = meeting.load_meetings(sample_data.FIRST_MEETING_YAML)
meeting_two = meeting.load_meetings(sample_data.THIRD_MEETING_YAML)
meeting_list = [meeting_one.pop(), meeting_two.pop()]
utils._check_for_meeting_conflicts(meeting_list)

View File

@ -1,102 +0,0 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import logging
import os
from yaml2ical import meeting
"""Utility functions."""
class MeetingConflictError(Exception):
pass
def _extract_meeting_info(meeting_obj):
"""Pull out meeting info of Meeting object.
:param meeting_obj: Meeting object
:returns: a dictionary of meeting info
"""
meeting_info = []
for schedule in meeting_obj.schedules:
info = {'name': meeting_obj.project,
'filename': meeting_obj._filename,
'day': schedule.day,
'time': schedule.time,
'irc_room': schedule.irc}
meeting_info.append(info)
return meeting_info
def _check_for_meeting_conflicts(meetings):
"""Check if a list of meetings have conflicts.
:param meetings: list of Meeting objects
"""
for i in range(len(meetings)):
meeting_info = _extract_meeting_info(meetings[i])
for j in range(i + 1, len(meetings)):
next_meeting_info = _extract_meeting_info(meetings[j])
for current_meeting in meeting_info:
for next_meeting in next_meeting_info:
if current_meeting['day'] != next_meeting['day']:
continue
if current_meeting['time'] != next_meeting['time']:
continue
if current_meeting['irc_room'] != next_meeting['irc_room']:
continue
msg_dict = {'first': current_meeting['filename'],
'second': next_meeting['filename']}
raise MeetingConflictError("Conflict between %(first)s "
"and %(second)s." % msg_dict)
def convert_yaml_to_ical(yaml_dir, outputdir=None, outputfile=None):
"""Convert meeting YAML files to iCal.
If meeting_list is specified, only those meetings in yaml_dir with
filenames contained in meeting_list are converted; otherwise,
all meeting in yaml_dir are converted.
:param yaml_dir: directory where meeting.yaml files are stored
:param outputdir: location to store iCal files (one file per meeting)
:param outputfile: output iCal file (one single file for all meetings)
"""
meetings = meeting.load_meetings(yaml_dir)
# Check uniqueness and conflicts here before writing out to .ics
_check_for_meeting_conflicts(meetings)
# convert meetings to a list of ical
if outputdir:
for m in meetings:
cal = meeting.Yaml2IcalCalendar()
m.add_to_calendar(cal)
filename = os.path.basename(m._filename).split('.')[0] + '.ics'
cal.write_to_disk(os.path.join(outputdir, filename))
# convert meetings into a single ical
if outputfile:
cal = meeting.Yaml2IcalCalendar()
for m in meetings:
m.add_to_calendar(cal)
cal.write_to_disk(outputfile)
# TODO(jotan): verify converted ical is valid
logging.info('Wrote %d meetings to iCal' % (len(meetings)))