123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408 |
- #!/usr/bin/env python3
- from datetime import date, timedelta
- import sys
- import argparse
- from argparse import RawTextHelpFormatter
- import yaml
- # text appended to the end of generated emails
- email_body = """
- Full speaker schedule:
- https://cs.uwaterloo.ca/twiki/view/CrySP/SpeakerSchedule
- Other upcoming events:
- https://cs.uwaterloo.ca/twiki/view/CrySP/UpcomingEvents
- """
- def str_to_date(string_date):
- if type(string_date) is date:
- return string_date
- try:
- return date(*[int(s) for s in string_date.split('-')])
- except:
- print("invalid date: " + string_date)
- sys.exit()
- class CryspCalendar:
- """Representation of a CrySP weekly meeting schedule for a term"""
- def __init__(self, start, end=None, weeks=None, names=[],
- constraints=[], practice_talks=[], half_practices=[],
- notes=[], irc_handles=dict(), no_repeat=False):
- self.dates = self.meeting_dates(start, end, weeks)
- self.names = names
- self.constraints = self.parse_constraints(constraints)
- self.practice_talks = self.parse_practice_talks(practice_talks)
- self.practice_talks.update(self.parse_practice_talks(half_practices,
- half=True))
- self.notes = self.parse_notes(notes)
- self.notes.update(self.parse_notes(map(lambda l: l[1:],
- practice_talks)))
- self.notes.update(self.parse_notes(map(lambda l: l[1:],
- half_practices)))
- self.verify_constraints()
- self.irc_handles = irc_handles
- self.no_repeat = no_repeat
- def meeting_dates(self, start, end=None, weeks=None):
- first_meeting = str_to_date(start)
- last_meeting = None
- if end:
- last_meeting = str_to_date(end)
- if weeks:
- last_meeting = min(first_meeting + timedelta(weeks=weeks),
- last_meeting)
- ret = []
- next_meeting = first_meeting
- one_week = timedelta(weeks=1)
- while next_meeting <= last_meeting:
- ret.append(next_meeting)
- next_meeting += one_week
- return ret
- def parse_constraints(self, constraints):
- """takes an iterable of (name,datestr,...) tuples"""
- constraint_dict = {}
- for constraint in constraints:
- name = constraint[0]
- date_strs = constraint[1:]
- dates = []
- for date_str in date_strs:
- if type(date_str) is not date and '--' in date_str:
- # date_range = list(map(str_to_date, date_str.split('--')))
- date_range = date_str.split('--')
- date_range = [str_to_date(date_range[0]),
- str_to_date(date_range[1])]
- [dates.append(d) for d in self.dates
- if date_range[0] <= d <= date_range[1]]
- else:
- dates.append(str_to_date(date_str))
- constraint_dict[name] = dates
- return constraint_dict
- def parse_practice_talks(self, practice, half=False):
- """'practice' is an iterable of (name, day, note) tuples"""
- practice_dict = {}
- for talk in practice:
- name = talk[0]
- day = str_to_date(talk[1])
- if day in practice_dict:
- assert half,\
- 'conflicting talks: %s and %s on %s' %\
- (practice_dict[day][0], name, day)
- practice_dict[day].append(name)
- elif half:
- practice_dict[day] = [name, ]
- else:
- practice_dict[day] = [name, '']
- return practice_dict
- def parse_notes(self, notes):
- """takes an iterable of (day, note) tuples"""
- notes_dict = {}
- for note in notes:
- day = str_to_date(note[0])
- message = note[1]
- assert day not in notes_dict or notes_dict[day] == message,\
- 'conflicting notes: "%s" and "%s" on %s' %\
- (notes_dict[day], message, note[0])
- notes_dict[day] = message
- return notes_dict
- def verify_constraints(self):
- for name in self.constraints:
- for day in self.constraints[name]:
- assert day in self.dates,\
- "constraint %s on %s isnt a meeting date" %\
- (name, day)
- for day in self.practice_talks:
- assert day in self.dates,\
- 'talk by %s designated on %s, which is not a meeting date' %\
- (self.practice_talks[day][0], day)
- for day in self.notes:
- assert day in self.dates,\
- 'note "%s" designated on %s, which is not a meeting date' %\
- (self.notes[day], day)
- def pop_name(self, day, names, i):
- """Pops the name of the next available on day from the names stack,
- where 'i' is which speaker they would be for that day (0 or 1).
- Returns None if no speakers are left in the given names stack."""
- name = None
- if day in self.practice_talks:
- if i < len(self.practice_talks[day]):
- name = self.practice_talks[day][i]
- if name is None:
- constrained_names = (name for name in names
- if name not in self.constraints
- or day not in self.constraints[name])
- name = next(constrained_names, None)
- if name and name in names:
- names.remove(name)
- return name
- def next_speaker(self, day, names, i):
- """Wrapper for pop_name() that repopulates names stack if needed."""
- name = self.pop_name(day, names, i)
- if name or self.no_repeat:
- return name
- names.extend(self.names)
- return self.pop_name(day, names, i)
- def date_to_speakers(self, day, names):
- speaker1 = self.next_speaker(day, names, 0)
- speaker2 = self.next_speaker(day, names, 1)
- note = self.notes.get(day, '')
- return speaker1, speaker2, note
- def table(self, today=date.today()):
- """Returns a string containing a twiki table."""
- table = '| *Date* | *Speakers* | *Notes* |\n'
- names = self.names.copy()
- for day in self.dates:
- color = "%SILVER% " if day <= today else ""
- endcolor = " %ENDCOLOR%" if day <= today else ""
- speaker1, speaker2, note = self.date_to_speakers(day, names)
- speaker2 = ', ' + speaker2 if speaker2 else ""
- row = "| {}{}{} | {}{}{}{} | {}{}{} |\n".format(
- color, day.strftime('%b %e'), endcolor,
- color, speaker1, speaker2, endcolor,
- color, note, endcolor
- )
- table += row
- return table
- def email(self, weeks=2, today=date.today()):
- """Returns a string for emailing to the lab who is speaking next"""
- emailstr = ''
- names = self.names.copy()
- i = 0
- for day in self.dates:
- if day <= today:
- self.date_to_speakers(day, names)
- continue
- i += 1
- if i > weeks:
- break
- speaker1, speaker2, note = self.date_to_speakers(day, names)
- emailstr += day.strftime('%b %e: ')
- if speaker1:
- emailstr += speaker1
- if speaker2:
- emailstr += ', ' + speaker2
- if note != '':
- emailstr += ' (' + note + ')'
- else:
- emailstr += note
- emailstr += '\n'
- emailstr += email_body
- return emailstr
- def irc(self, today=date.today()):
- """Returns a string for putting in the IRC channel topic"""
- names = self.names.copy()
- i = 0
- for day in self.dates:
- if day <= today:
- self.date_to_speakers(day, names)
- continue
- i += 1
- speaker1, speaker2, note = self.date_to_speakers(day, names)
- if speaker1:
- speaker1 = self.irc_handles.get(speaker1, speaker1)
- if speaker2:
- speaker2 = ', ' + self.irc_handles.get(speaker2, speaker2)
- if note != '':
- note = ' (' + note + ')'
- return "{}{}{}".format(speaker1, speaker2, note)
- def parse_constraintsfile(filename):
- with open(filename, 'r') as f:
- tweaks = {'c': [], 'p': [], 'h': [], 'n': []}
- for line in f:
- line = [x.strip() for x in line.rstrip().split(',')]
- tweaks[line[0]].append(line[1:])
- return tweaks['c'], tweaks['p'], tweaks['h'], tweaks['n']
- def parse_yaml(filename, today=date.today()):
- with open(filename, 'r') as f:
- struct = yaml.safe_load(f)
- currentTerm = None
- for term in struct['terms'].values():
- if 'start' in term and 'end' in term:
- term['end'] = str_to_date(term['end'])
- term['start'] = str_to_date(term['start'])
- if currentTerm is None or (term['end'] >= today and
- term['start'] > currentTerm['start']):
- currentTerm = term
- names = currentTerm['speakers']
- for name in names:
- assert name not in currentTerm['out']
- def triple_check(field_name, location):
- return field_name in location and \
- location[field_name] is not None and \
- len(location[field_name]) > 0
- constraints = [[name] + struct['speakers'][name]['constraints']
- for name in names
- if triple_check('constraints', struct['speakers'][name])]
- practice_talks = [(talk['name'], talk['date'], talk['note'])
- for talk in currentTerm['practice_talks']]
- half_practices = [(talk['name'], talk['date'], talk['note'])
- for talk in currentTerm['half_practices']]
- for speaker in struct['speakers']:
- location = struct['speakers'][speaker]
- if triple_check('practice_talks', location):
- practice_talks += {(speaker, talk['date'], talk['note'])
- for talk in location['practice_talks']}
- if triple_check('half_practices', location):
- half_practices += {(speaker, talk['date'], talk['note'])
- for talk in location['half_practices']}
- notes = [(note['date'], note['note']) for note in currentTerm['notes']]
- irc_handles = {name: struct['speakers'][name]['irc']
- for name in struct['speakers']
- if 'irc' in struct['speakers'][name]}
- return CryspCalendar(currentTerm['start'], currentTerm['end'],
- names=names, constraints=constraints,
- practice_talks=practice_talks,
- half_practices=half_practices,
- notes=notes, irc_handles=irc_handles)
- def make_parser():
- parser = argparse.ArgumentParser(description='''\
- Print a speaker schedule for the CrySP weekly meetings.
- All dates are ISO formatted (yyyy-mm-dd). Constraints in files can also be
- ranges of ISO formatted dates (yyyy-mm-dd--yyyy-mm-dd).''',
- epilog='''\
- Now that the lab is so large, you likely want to use one of the file options,
- probably YAML. The command line options still function if you want to play
- with how this script works to learn, though.
- The YAML file has the following format:
- There are two main objects, a "terms" object that stores data about particular
- terms, and a "speakers" ojbect that stores data about the speakers.
- The "terms" object contains any number of term objects with any name. Each
- term has the following objects:
- start: the date of the first meeting
- end: a date on and beyond which there are no more meetings
- speakers: an ordered list of names for each speaker this term
- out: a list to store lab members who won't speak this term (ignored)
- practice_talks: a list of practice talks, of the form
- {date: _, name: _, note: _}
- half_practices: a list of half practice talks (format is the same)
- notes: notes, of the form
- {date: _, note: _}
- The "speakers" object contains any number of speaker objects. The name of each
- speaker object should be the same as the name in the speakers list, if they
- are speaking this term. The names can have spaces. Each speaker object has
- the following objects:
- irc: the IRC handle of the speaker, if they're in CrySP IRC
- email: the email address of the speaker (check the UW whitepages if need be)
- constraints: a list of dates/date ranges the speaker cannot speak on
- practice_talks: a list of practice talks, each of the form:
- {date: _, note: _}
- half_practices: a list of half practice talks (format is the same)
- ''',
- formatter_class=RawTextHelpFormatter)
- parser.add_argument('-y', '--yaml',
- metavar='FILE',
- help='full description of the term via YAML file.')
- parser.add_argument('-s', '--start',
- metavar='DATE',
- help='date of the first meeting')
- parser.add_argument('-e', '--end', metavar='DATE',
- help='last date to schedule a meeting on or before\n'
- '(if -w also specified, uses whatever is shortest)')
- parser.add_argument('-w', '--weeks', type=int,
- help='number of weeks to schedule for\n'
- '(if -e also specified, uses whatever is shortest)')
- group = parser.add_mutually_exclusive_group()
- group.add_argument('-n', '--names',
- nargs='+', default=[], metavar='NAME',
- help='names to schedule in their speaking order')
- group.add_argument('-f', '--namesfile',
- metavar='FILE',
- help='path of a file that contains a comma-seperated\n'
- 'list of names to schedule in their speaking order')
- parser.add_argument('-c', '--constraintsfile',
- metavar='FILE',
- help='Provide constraints, practice talks, and notes\n'
- 'via supplied CSV file. The CSV can contain the\n'
- 'following lines:\n'
- 'constraints - dates where someone cannot speak:\n'
- '"c,[name],[date/range1],[date/range2],[...]"\n'
- 'notes - goes in the notes column for that date:\n'
- '"n,[date],[note]"\n'
- 'practice talks - dates where something other than\n'
- 'the usual CrySP meeting talks is happening:\n'
- '"p,[name],[date],[note (e.g., "practice talk")]"\n'
- 'half-slot - a rare case where someone/thing needs\n'
- 'only one of the speaker slots on this date:\n'
- '"h,[name],[date],[note]"\n')
- parser.add_argument('-r', '--no-repeat',
- action='store_true',
- help='disables repeating the list of names')
- parser.add_argument('-E', '--email',
- nargs='?', const=2, default=False,
- metavar='WEEKS',
- help='print an email for notifying who is speaking\n'
- 'the next two (or specified) weeks')
- parser.add_argument('-i', '--irc',
- action='store_true',
- help='print names formatted for an irc channel topic')
- parser.add_argument('-d', '--today',
- default=date.today(), metavar='DATE',
- help='pretend the script is being run on the day DATE')
- return parser
- def old_format(args):
- if args.namesfile:
- with open(args.namesfile, 'r') as f:
- names = [x.strip() for x in f.read().split(',')]
- else:
- names = args.names
- if args.constraintsfile:
- constraints, practice, halves, notes = \
- parse_constraintsfile(args.constraintsfile)
- return CryspCalendar(args.start, args.end, args.weeks, names,
- constraints, practice, halves, notes,
- args.no_repeat)
- return CryspCalendar(args.start, args.end, args.weeks, names,
- no_repeat=args.no_repeat)
- def main(argv=None):
- if argv is None:
- argv = sys.argv
- parser = make_parser()
- args = parser.parse_args()
- if not args.end and not args.weeks and not args.yaml:
- parser.error('requires -w/--weeks or -e/--end')
- if not args.yaml:
- cal = old_format(args)
- else:
- cal = parse_yaml(args.yaml)
- today = str_to_date(args.today)
- if args.email:
- print(cal.email(int(args.email), today))
- elif args.irc:
- print(cal.irc(today))
- else:
- print(cal.table(today))
- return 0
- if __name__ == '__main__':
- sys.exit(main())
|