From 2c2b37cfd3a018b1a6ff15b275c3768dee153d08 Mon Sep 17 00:00:00 2001 From: rsiddharth Date: Fri, 18 Mar 2016 22:34:00 -0400 Subject: Initial version of LPiCal class done. Addresses issue #8. --- lps_gen.py | 139 +++++++++++++++++++++++++++++++++++++++-- setup.py | 2 +- tests/test_lps_gen.py | 170 ++++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 294 insertions(+), 17 deletions(-) diff --git a/lps_gen.py b/lps_gen.py index f6696b6..352d0b6 100644 --- a/lps_gen.py +++ b/lps_gen.py @@ -22,14 +22,19 @@ import json import re import sys +import pytz + from argparse import ArgumentParser from collections import OrderedDict +from datetime import datetime from os import path from bs4 import BeautifulSoup +from icalendar import Calendar, Event, vCalAddress, vText, vDatetime from jinja2 import Environment, FileSystemLoader from jinja2.exceptions import TemplateNotFound from mistune import Renderer, Markdown +from pytz import timezone from unidecode import unidecode from lpschedule_generator._version import __version__ @@ -108,14 +113,36 @@ class LPiCal(object): Used for producing iCal for LP schedule. """ - def __init__(self, lps_dict): + def __init__(self, lps_dict, lp_year): self.lps_dict = lps_dict + self.lp_year = lp_year # Matches strings like '09:45 - 10:30: Lorem ipsum dolor sit.' - self.timeslot_re = re.compile(r'(\d+:\d+).+?(\d+:\d+)') + self.timeslot_re = re.compile(r'(\d+:\d+).+?(\d+:\d+):\s*(.+\b)') # Matches strings like 'Saturday, March 19' self.month_day_re = re.compile(r'\w+,\s*([a-zA-Z]+)\s*(\d+)') + self.cal = Calendar() + self.cal.add('prodid', '-//lpschedule generator//mxm.dk//') + self.cal.add('version', '2.0') + + # RFC 2445 requires DTSTAMP to be in UTC. DTSTAMP is used in + # VEVENT (Event object, see `add_event` method). + self.dtstamp = vDatetime(datetime.now(pytz.utc)) + + # used to generate uid for ical. + self.ucounter = 0 + + + def gen_uid(self): + """Returns an unique id. + + Used for Event object. + """ + self.ucounter = self.ucounter + 1 + return '%s@LP%s@libreplanet.org' % (str(self.ucounter), + self.lp_year) + def get_timeslot(self, s): """Get start and end time for a timeslot. @@ -123,10 +150,11 @@ class LPiCal(object): timeslot = self.timeslot_re.search(s) - start = timeslot.group(1) - end = timeslot.group(2) + t_start = timeslot.group(1) + t_end = timeslot.group(2) + name = timeslot.group(3) - return start, end + return t_start, t_end, name def get_month_day(self, s): @@ -141,6 +169,107 @@ class LPiCal(object): return month, day + def mk_datetime(self, month, day, time): + """Returns datetime object (EST). + """ + # Day %d + # Month %B + # Year %Y + # Hour %H (24-hr) + # Minute %M (zero padded) + # Second %S (zero padded) + datetime_fmt = '%d %B %Y %H:%M:%S' + eastern = timezone('US/Eastern') + + hour = time.split(':')[0] + minute = time.split(':')[1] + datetime_str = '%s %s %s %s:%s:%s' % (day, month, self.lp_year, + hour.zfill(2), minute.zfill(2), + '00') + + dt_object = datetime.strptime(datetime_str, datetime_fmt) + + return vDatetime(eastern.localize(dt_object)) + + + def mk_attendee(self, speaker): + """ + Make Attendee to be added to an Event object. + + See `add_event` method. + """ + # Get rid of HTML ( element, etc) in `speaker` + speaker = BeautifulSoup(speaker, 'html.parser').get_text() + + attendee = vCalAddress('invalid:nomail') + attendee.params['cn'] = vText(speaker) + attendee.params['ROLE'] = vText('REQ-PARTICIPANT') + attendee.params['CUTYPE'] = vText('INDIVIDUAL') + + return attendee + + + def add_event(self, month, day, t_start, t_end, session, session_info): + """Adds event to calendar. + """ + event = Event() + event['uid'] = self.gen_uid() + event['dtstamp'] = self.dtstamp + event['class'] = vText('PUBLIC') + event['status'] = vText('CONFIRMED') + event['method'] = vText('PUBLISH') + + event['summary'] = session + event['location'] = vText(session_info['room']) + + # Get rid of HTML in 'desc' + desc = BeautifulSoup(' '.join( + session_info['desc']).replace( + '\n', ' '), 'html.parser').get_text() + event['description'] = desc + + # Add speakers + for speaker in session_info['speakers']: + event.add('attendee', self.mk_attendee(speaker), encode=0) + + dt_start = self.mk_datetime(month, day, t_start) + dt_end = self.mk_datetime(month, day, t_end) + + event['dtstart'] = dt_start + event['dtend'] = dt_end + + # Add to calendar + self.cal.add_component(event) + + return event + + + def gen_ical(self): + """Parse LP schedule dict and generate iCal Calendar object. + + """ + + for day_str, timeslots in self.lps_dict.iteritems(): + month, day = self.get_month_day(day_str) + for timeslot_str, sessions in timeslots.iteritems(): + t_start, t_end, t_name = self.get_timeslot(timeslot_str) + for session, session_info in sessions.iteritems(): + self.add_event(month, day, t_start, t_end, + session, session_info) + + return self.cal.to_ical() + + + def to_ical(self): + """Writes iCal to disk. + + """ + filename = 'lp%s-schedule.ics' % self.lp_year + write_file(filename, self.gen_ical()) + + return filename + + class LPSRenderer(Renderer): """Helps in converting Markdown version of LP schedule to a dictionary. """ diff --git a/setup.py b/setup.py index 2074b1d..3cb27ae 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,7 @@ config = { 'url': 'https://notabug.org/rsd/lpschedule-generator/', 'author': 'rsiddharth', 'author_email': 'rsd@gnu.org', - 'install_requires': ['mistune', 'Jinja2', 'beautifulsoup4', 'unidecode'], + 'install_requires': ['mistune', 'Jinja2', 'beautifulsoup4', 'unidecode', 'icalendar', 'pytz'], 'tests_require': ['nose', 'mock'], 'test_suite': 'nose.collector', 'py_modules': ['lps_gen', 'lpschedule_generator._version'], diff --git a/tests/test_lps_gen.py b/tests/test_lps_gen.py index 9cc9406..3d28101 100644 --- a/tests/test_lps_gen.py +++ b/tests/test_lps_gen.py @@ -29,7 +29,10 @@ from collections import OrderedDict from os import path from StringIO import StringIO +from bs4 import BeautifulSoup +from icalendar import vCalAddress, vText, vDatetime from nose.tools import * +from pytz import timezone from lps_gen import * @@ -119,7 +122,38 @@ class TestLPiCal(object): def setup_class(self): """Setting up things for Testing LPiCal class. """ - self.lp_ical = LPiCal({}) + + # Change current working directory to the tests directory. + self.old_cwd = os.getcwd() + os.chdir('tests') + + self.MD_FILE = path.join('files', 'lp-sch.md') + self.MD_FILE_CONTENT = read_file(self.MD_FILE) + + self.SCH_TEMPLATE = path.join('..', 'libreplanet-templates/2016', + 'lp-schedule.jinja2') + + self.markdown = LPSMarkdown() + self.lps_dict = self.markdown(self.MD_FILE_CONTENT) + self.purge_list = ['speakers.noids'] + + + def setup(self): + """Setting up things for a new test. + """ + self.lp_ical = LPiCal(self.lps_dict, '2016') + + + def test_gen_uid(self): + """Testing LPiCal.gen_uid. + """ + + uid_fmt = ''.join(['{id}@LP', self.lp_ical.lp_year, + '@libreplanet.org']) + + for i in range(40): + assert_equals(self.lp_ical.gen_uid(), + uid_fmt.format(id=i+1)) def test_get_timeslot(self): @@ -128,20 +162,29 @@ class TestLPiCal(object): """ timeslots = { - '09:00-09:45: Registration and Breakfast': ['09:00', '09:45'], - ' 09:45 - 10:45: Opening Keynote': ['09:45', '10:45'], - '10:5 - 10:55: Break': ['10:5', '10:55'], - ' 10:55 - 11:40: Session Block 1A': ['10:55', '11:40'], - ' 11:40 - 11:50: Break': ['11:40', '11:50'], - '9:45 - 10:30: Keynote ': ['9:45', '10:30'], - '16:55 - 17:40:Session Block 6B': ['16:55', '17:40'], - '17:50 - 18:35: Closing keynote': ['17:50', '18:35'], + '09:00-09:45: Registration and Breakfast': + ['09:00', '09:45', 'Registration and Breakfast'], + ' 09:45 - 10:45: Opening Keynote': + ['09:45', '10:45', 'Opening Keynote'], + '10:5 - 10:55: Break': + ['10:5', '10:55', 'Break'], + ' 10:55 - 11:40: Session Block 1A': + ['10:55', '11:40', 'Session Block 1A'], + ' 11:40 - 11:50: Break': + ['11:40', '11:50', 'Break'], + '9:45 - 10:30: Keynote ': + ['9:45', '10:30', 'Keynote'], + '16:55 - 17:40:Session Block 6B': + ['16:55', '17:40', 'Session Block 6B'], + '17:50 - 18:35: Closing keynote': + ['17:50', '18:35', 'Closing keynote'], } for string, timeslot in timeslots.iteritems(): - start, end = self.lp_ical.get_timeslot(string) + start, end, name = self.lp_ical.get_timeslot(string) assert_equal(start, timeslot[0]) assert_equal(end, timeslot[1]) + assert_equal(name, timeslot[2]) def test_get_month_day(self): @@ -163,12 +206,116 @@ class TestLPiCal(object): assert_equal(day, month_day[1]) + def test_mk_datetime(self): + """Testing LPiCal.mk_datetime + """ + + datetimes = [ + { + 'params': ['February', '28','08:00'], + 'datetime': '2016-02-28 08:00:00', + }, + { + 'params': ['March', '21', '9:0'], + 'datetime': '2016-03-21 09:00:00', + }, + { + 'params': ['March', '23', '15:30'], + 'datetime': '2016-03-23 15:30:00', + }, + ] + + for test in datetimes: + month = test['params'][0] + day = test['params'][1] + time = test['params'][2] + + dt_obj = self.lp_ical.mk_datetime(month, day, time) + + assert str(dt_obj.dt.tzinfo) == 'US/Eastern' + assert str(dt_obj.dt)[:-6] == test['datetime'] + + + def test_mk_attendee(self): + """Testing LPiCal.mk_attendee + """ + speakers = [ + 'Richard Stallman', + 'ginger coons', + 'Marianne Corvellec', + 'Jonathan Le Lous', + 'Jonas \xc3\x96berg', + ] + + for speaker in speakers: + attendee = self.lp_ical.mk_attendee(speaker) + assert str(attendee) == 'invalid:nomail' + assert attendee.params.get('cn') == BeautifulSoup( + speaker, 'html.parser').get_text() + assert attendee.params.get('ROLE') == 'REQ-PARTICIPANT' + assert attendee.params.get('CUTYPE') == 'INDIVIDUAL' + + + def test_add_event(self): + """Testing LPiCal.add_event + """ + uids = [] + + for day_str, timeslots in self.lps_dict.iteritems(): + month, day = self.lp_ical.get_month_day(day_str) + for timeslot_str, sessions in timeslots.iteritems(): + t_start, t_end, t_name = self.lp_ical.get_timeslot(timeslot_str) + for session, session_info in sessions.iteritems(): + event = self.lp_ical.add_event(month, day, t_start, t_end, + session, session_info) + assert event['uid'] not in uids + uids.append(event['uid']) + + assert event['dtstamp'] == self.lp_ical.dtstamp + assert event['class'] == 'PUBLIC' + assert event['status'] == 'CONFIRMED' + assert event['method'] == 'PUBLISH' + assert event['summary'] == session + assert event['location'] == session_info['room'] + assert event['description'] == BeautifulSoup(' '.join( + session_info['desc']).replace( + '\n',' '), 'html.parser').get_text() + + if type(event['attendee']) is list: + for attendee in event['attendee']: + assert isinstance(attendee, vCalAddress) + else: + assert isinstance(event['attendee'], vCalAddress) + + assert isinstance(event['dtstart'], vDatetime) + assert isinstance(event['dtend'], vDatetime) + + + def test_gen_ical(self): + """Testing LPiCal.gen_ical. + """ + ical = self.lp_ical.gen_ical() + + + def test_to_ical(self): + """Testing LPiCal.to_ical. + """ + self.purge_list.append(self.lp_ical.to_ical()) + + @classmethod def teardown_class(self): """ Tearing down the mess created by Testing LPiCal class. """ - pass + + # remove files in the purge_list. + for f in self.purge_list: + if path.isfile(f): + os.remove(f) + + # Change back to the old cwd + os.chdir(self.old_cwd) class TestLPS(object): @@ -349,6 +496,7 @@ class TestLPS(object): # Change back to the old cwd os.chdir(self.old_cwd) + class TestLPSpeakers(object): """ Class that tests everything related LP Speakers -- cgit v1.2.3