commit 1692a5b23b99dae06f27aa50bebadeaf5268105a Author: Juhani Haverinen Date: Wed Aug 24 15:12:52 2016 +0300 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..516a4ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.zip +lukkari.py +__pycache__ +*.pyc diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4d886ea --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +all: lukkari.py + +lukkari.py: lukkari/*.py + zip --quiet lukkari lukkari/*.py + zip --quiet --junk-paths lukkari lukkari/__main__.py + echo '#!/usr/bin/env python3' > lukkari.py + cat lukkari.zip >> lukkari.py + rm lukkari.zip + chmod +x lukkari.py + +.PHONY: all clean + +clean: + find . -name "*.pyc" -delete + find . -name "__pycache__" -delete + rm lukkari.zip lukkari.py || true diff --git a/lukkari/__init__.py b/lukkari/__init__.py new file mode 100644 index 0000000..87d5e51 --- /dev/null +++ b/lukkari/__init__.py @@ -0,0 +1,69 @@ +import datetime +import sys +import os.path +from . import check_date +from . import daterange +from . import generate_timetable +from . import parse_coursefile + +def parse_date(text): + split = text.split('-') + assert(len(split) == 3) + return tuple(map(int, split)) + +def main(): + if len(sys.argv) == 2: + year, week, day = datetime.date.today().isocalendar() + if day >= 6: # on weekend, show next week + year, week, day = (datetime.date.today() + datetime.timedelta(2)).isocalendar() + dates = daterange.week(year, week) + filename = sys.argv[1] + elif len(sys.argv) == 3: + date = parse_date(sys.argv[1]) + dates = daterange.between(date, date) + filename = sys.argv[2] + elif len(sys.argv) == 4: + start = parse_date(sys.argv[1]) + end = parse_date(sys.argv[2]) + dates = daterange.between(start, end) + filename = sys.argv[3] + else: + print('%s [start [end]] file' % (os.path.basename(sys.argv[0]))) + print('start and end are in yyyy-mm-dd format') + sys.exit(1) + + with open(filename, 'r') as f: + courses_parsed = parse_coursefile.parse(f.read()) + + courses = [] + for name, info, parsed_filter in courses_parsed: + date_filter = check_date.compile(parsed_filter) + courses.append((name, info, date_filter)) + + timetable = generate_timetable.generate_timetable(dates, courses) + + timetable_by_date = [] + current_date = None + current_date_entries = [] + for date, name, info in timetable: + if current_date is None: + current_date = date + + if date == current_date: + current_date_entries.append((name, info)) + else: + timetable_by_date.append((current_date, current_date_entries)) + current_date = date + current_date_entries = [] + current_date_entries.append((name, info)) + + if current_date is not None and current_date_entries != []: + timetable_by_date.append((current_date, current_date_entries)) + + print(dates) + print() + + for date, entries in timetable_by_date: + print(date) + for name, info in entries: + print('\t%s: %s' % (name, info)) diff --git a/lukkari/__main__.py b/lukkari/__main__.py new file mode 100644 index 0000000..30305f6 --- /dev/null +++ b/lukkari/__main__.py @@ -0,0 +1,3 @@ +import lukkari +if __name__ == '__main__': + lukkari.main() diff --git a/lukkari/check_date.py b/lukkari/check_date.py new file mode 100644 index 0000000..e18cdf0 --- /dev/null +++ b/lukkari/check_date.py @@ -0,0 +1,94 @@ +import enum +from . import daterange + +class Filters(enum.Enum): + in_date_range, is_weekday = range(2) + +class Conjunctions(enum.Enum): + all, any, none, implies = range(4) + +def compile(parsed): + assert(len(parsed) >= 1) + function, *parameters = parsed + + assert(function in ['date', 'week', 'weekday', 'and', 'or', 'not', 'if']) + + if function == 'date': + if len(parameters) == 1: + start, = parameters + end = start + else: + assert(len(parameters) == 2) + start, end = parameters + + date_range = daterange.between(start, end) + return (Filters.in_date_range, date_range) + + elif function == 'week': + if len(parameters) > 1: + return (Conjunctions.any, *[compile(('week', week)) for week in parameters]) + else: + assert(len(parameters) == 1) + week_data, = parameters + year, week = week_data + + date_range = daterange.week(year, week) + return (Filters.in_date_range, date_range) + + elif function == 'weekday': + if len(parameters) > 1: + return (Conjunctions.any, *[compile(('weekday', weekday)) for weekday in parameters]) + else: + assert(len(parameters) == 1) + weekday, = parameters + + return (Filters.is_weekday, weekday) + + elif function == 'and': + return (Conjunctions.all, *[compile(parameter) for parameter in parameters]) + + elif function == 'or': + return (Conjunctions.any, *[compile(parameter) for parameter in parameters]) + + elif function == 'not': + return (Conjunctions.none, *[compile(parameter) for parameter in parameters]) + + elif function == 'if': + assert(len(parameters) == 2) + condition, then = parameters + return (Conjunctions.implies, compile(condition), compile(then)) + +def check_day_match(day, date_filter): + assert(len(date_filter) >= 1) + function, *parameters = date_filter + + assert(function in Filters or function in Conjunctions) + if function == Conjunctions.implies: + assert(len(parameters) == 2) + elif function in [Filters.in_date_range, Filters.is_weekday]: + assert(len(parameters) == 1) + else: + assert(function in [Conjunctions.all, Conjunctions.any, Conjunctions.none]) + + if function == Conjunctions.all: + return all(map(lambda parameter: check_day_match(day, parameter), parameters)) + + elif function == Conjunctions.any: + return any(map(lambda parameter: check_day_match(day, parameter), parameters)) + + elif function == Conjunctions.implies: + left, right = parameters + left_truth = check_day_match(day, left) + right_truth = check_day_match(day, right) + return left_truth and right_truth or not left_truth + + elif function == Conjunctions.none: + return not any(map(lambda parameter: check_day_match(day, parameter), parameters)) + + elif function == Filters.in_date_range: + date_range, = parameters + return day in date_range + + elif function == Filters.is_weekday: + weekday, = parameters + return day.isoweekday() == weekday diff --git a/lukkari/daterange.py b/lukkari/daterange.py new file mode 100644 index 0000000..8500bc2 --- /dev/null +++ b/lukkari/daterange.py @@ -0,0 +1,67 @@ +import datetime + +class Daterange: + def __init__(self, start, length): + self.start = start + self.length = length + + def range(self): + end = self.start + self.length - datetime.timedelta(1) + return (self.start, end) + + def __contains__(self, day): + delta = day - self.start + return datetime.timedelta(0) <= delta and delta < self.length + + def overlaps(self, other): + if other.start < self.start: + return other.overlaps(self) + + assert(self.start <= other.start) + return other.start < self.start + self.length + + def __repr__(self): + return 'Daterange(%s, %s)' % (repr(self.start), repr(self.length)) + + def __str__(self): + start, end = map(str, self.range()) + return '%s - %s' % (start, end) + + def __eq__(self, other): + return self.start == other.start and self.length == other.length + + def __ne__(self, other): + return not self == other + +def week(year, week): + # Ensure that week is correct for the given year + assert(1 <= week and week <= 53) + if week == 53: + assert(datetime.date(year, 12, 31).isocalendar()[1] == 53) + + # First attempt, this will most likely be wrong + # Use 28 instead of probably better 30 so we don't have to special-case february + days_into_year = 1 + week * 7 + guess = datetime.date(year + days_into_year // 365, days_into_year // 28 % 12 + 1, week * 7 % 28 + 1) + guess_year, guess_week, guess_weekday = guess.isocalendar() + + while guess_year != year or guess_week != week or guess_weekday != 1: + year_delta = year - guess_year + week_delta = week - guess_week + weekday_delta = 1 - guess_weekday + + # Year is not quite right, but by repeated application year_delta should become 0, after which it's correct + delta = datetime.timedelta(year_delta * 365 + week_delta * 7 + weekday_delta) + + guess += delta + guess_year, guess_week, guess_weekday = guess.isocalendar() + + return Daterange(guess, datetime.timedelta(7)) + +def between(start, end): + assert(len(start) == 3 and len(end) == 3) + start_year, start_month, start_day = start + end_year, end_month, end_day = end + start_obj = datetime.date(start_year, start_month, start_day) + end_obj = datetime.date(end_year, end_month, end_day) + return Daterange(start_obj, end_obj - start_obj + datetime.timedelta(1)) diff --git a/lukkari/generate_timetable.py b/lukkari/generate_timetable.py new file mode 100644 index 0000000..b9bcc04 --- /dev/null +++ b/lukkari/generate_timetable.py @@ -0,0 +1,18 @@ +import datetime +from . import check_date + +def generate_timetable(day_range, courses): + start_date, end_date = day_range.range() + date = start_date + appointments = [] + while True: + for name, info, date_filter in courses: + if check_date.check_day_match(date, date_filter): + appointments.append((date, name, info)) + + if date == end_date: + break + + date += datetime.timedelta(1) + + return appointments diff --git a/lukkari/parse_coursefile.py b/lukkari/parse_coursefile.py new file mode 100644 index 0000000..baf12ea --- /dev/null +++ b/lukkari/parse_coursefile.py @@ -0,0 +1,131 @@ +def strip_comments(text): + lines = [] + for line in text.split('\n'): + comment_start = line.find('#') + if comment_start != -1: + lines.append(line[:comment_start]) + else: + lines.append(line) + return '\n'.join(lines) + +def parse_filter(text): + weekdays = {'mon': 1, 'tue': 2, 'wed': 3, 'thu': 4, 'fri': 5, 'sat': 6, 'sun': 7} + functions = ['date', 'week', 'weekday', 'and', 'or', 'not', 'if'] + def eof(): + nonlocal index, length + return index >= length + + def skip_whitespace(): + nonlocal index, length + while not eof() and text[index].isspace(): + index += 1 + + def match(matches): + nonlocal index, length + assert(not eof()) + assert(text[index] in matches) + index += 1 + + def read_atom(): + nonlocal index, length + start = index + + while not eof() and not text[index].isspace() and text[index] != ')': + index += 1 + + return text[start:index] + + def subexpression(): + nonlocal index, length + if not eof() and text[index] == '(': + match('(') + skip_whitespace() + + elements = [] + + function = read_atom() + skip_whitespace() + assert(not eof()) + + assert(function in functions) + elements.append(function) + + while not eof() and text[index] != ')': + elements.append(subexpression()) + skip_whitespace() + + match(')') + + return elements + + else: + atom = read_atom() + assert(len(atom) >= 1) + + if atom in weekdays: + return weekdays[atom] + else: + split = atom.split('-') + assert(all(map(lambda x: x.isnumeric(), split))) + assert(len(split) == 2 or len(split) == 3) + return tuple(map(int, split)) + + index = 0 + length = len(text) + + skip_whitespace() + expression = subexpression() + skip_whitespace() + assert(eof()) + + return expression + +def parse(text): + def eof(): + nonlocal index, length + return index >= length + + def skip_whitespace(): + nonlocal index, length + while not eof() and text[index].isspace(): + index += 1 + + def match(matches): + nonlocal index, length + assert(not eof()) + assert(text[index] in matches) + index += 1 + + def read_field(): + nonlocal index, length + skip_whitespace() + + start = index + while not eof() and text[index] not in [';', '\n']: + if text[index] == '\\': + index += 1 + index += 1 + + return text[start:index].strip() + + text = strip_comments(text) + index = 0 + length = len(text) + + courses = [] + while not eof(): + name = read_field() + match(';') + + info = read_field() + match(';') + + date_filter = parse_filter(read_field()) + if not eof(): + match('\n') + + courses.append((name, info, date_filter)) + + assert(eof()) + + return courses