First commit
This commit is contained in:
commit
1692a5b23b
8 changed files with 402 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
*.zip
|
||||
lukkari.py
|
||||
__pycache__
|
||||
*.pyc
|
16
Makefile
Normal file
16
Makefile
Normal file
|
@ -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
|
69
lukkari/__init__.py
Normal file
69
lukkari/__init__.py
Normal file
|
@ -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))
|
3
lukkari/__main__.py
Normal file
3
lukkari/__main__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
import lukkari
|
||||
if __name__ == '__main__':
|
||||
lukkari.main()
|
94
lukkari/check_date.py
Normal file
94
lukkari/check_date.py
Normal file
|
@ -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
|
67
lukkari/daterange.py
Normal file
67
lukkari/daterange.py
Normal file
|
@ -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))
|
18
lukkari/generate_timetable.py
Normal file
18
lukkari/generate_timetable.py
Normal file
|
@ -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
|
131
lukkari/parse_coursefile.py
Normal file
131
lukkari/parse_coursefile.py
Normal file
|
@ -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
|
Loading…
Reference in a new issue