8d3d7683d61a6c596a332140caa9f56bd5bd32ae
[pygrader.git] / pygrader / storage.py
1 # Copyright (C) 2012 W. Trevor King <wking@tremily.us>
2 #
3 # This file is part of pygrader.
4 #
5 # pygrader is free software: you can redistribute it and/or modify it under the
6 # terms of the GNU General Public License as published by the Free Software
7 # Foundation, either version 3 of the License, or (at your option) any later
8 # version.
9 #
10 # pygrader is distributed in the hope that it will be useful, but WITHOUT ANY
11 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
12 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License along with
15 # pygrader.  If not, see <http://www.gnu.org/licenses/>.
16
17 from __future__ import absolute_import
18
19 import calendar as _calendar
20 import configparser as _configparser
21 import email.utils as _email_utils
22 import io as _io
23 import os as _os
24 import os.path as _os_path
25 import re as _re
26 import sys as _sys
27 import time as _time
28
29 import pygrader as _pygrader
30 from . import LOG as _LOG
31 from .model.assignment import Assignment as _Assignment
32 from .model.course import Course as _Course
33 from .model.grade import Grade as _Grade
34 from .model.person import Person as _Person
35 from .todo import newer
36
37
38 _DATE_REGEXP = _re.compile('^([^T]*)(T?)([^TZ+-.]*)([.]?[0-9]*)([+-][0-9:]*|Z?)$')
39
40
41 def load_course(basedir):
42     """Load a course directory.
43
44     >>> from pygrader.test.course import StubCourse
45     >>> stub_course = StubCourse(load=False)
46     >>> course = load_course(basedir=stub_course.basedir)
47     >>> course.name
48     'Physics 101'
49     >>> course.assignments  # doctest: +ELLIPSIS
50     [<pygrader.model.assignment.Assignment object at 0x...>, ...]
51     >>> course.people  # doctest: +ELLIPSIS
52     [<pygrader.model.person.Person object at 0x...>, ...]
53     >>> course.grades
54     []
55     >>> print(course.robot)
56     <Person Robot101>
57     >>> stub_course.cleanup()
58     """
59     _LOG.debug('loading course from {}'.format(basedir))
60     config = _configparser.ConfigParser()
61     config.read([_os_path.join(basedir, 'course.conf')],
62                 encoding=_pygrader.ENCODING)
63     name = config.get('course', 'name')
64     names = {'robot': [config.get('course', 'robot').strip()]}
65     for option in ['assignments', 'professors', 'assistants', 'students']:
66         names[option] = [
67             a.strip() for a in
68             config.get('course', option, fallback='').split(',')]
69         while '' in names[option]:
70             names[option].remove('')
71     assignments = []
72     for assignment in names['assignments']:
73         _LOG.debug('loading assignment {}'.format(assignment))
74         assignments.append(load_assignment(
75                 name=assignment, data=dict(config.items(assignment))))
76     people = {}
77     for group in ['robot', 'professors', 'assistants', 'students']:
78         for person in names[group]:
79             if person in people:
80                 _LOG.debug('adding person {} to group {}'.format(
81                         person, group))
82                 people[person].groups.append(group)
83             else:
84                 _LOG.debug('loading person {} in group {}'.format(
85                         person, group))
86                 people[person] = load_person(
87                     name=person, data=dict(config.items(person)))
88                 people[person].groups = [group]
89     people = people.values()
90     robot = [p for p in people if 'robot' in p.groups][0]
91     grades = list(load_grades(basedir, assignments, people))
92     return _Course(
93         name=name, assignments=assignments, people=people, grades=grades,
94         robot=robot)
95
96 def parse_date(string):
97     """Parse dates given using the W3C DTF profile of ISO 8601.
98
99     The following are legal formats::
100
101       YYYY (e.g. 2000)
102       YYYY-MM (e.g. 2000-02)
103       YYYY-MM-DD (e.g. 2000-02-12)
104       YYYY-MM-DDThh:mmTZD (e.g. 2000-02-12T06:05+05:30)
105       YYYY-MM-DDThh:mm:ssTZD (e.g. 2000-02-12T06:05:30+05:30)
106       YYYY-MM-DDThh:mm:ss.sTZD (e.g. 2000-02-12T06:05:30.45+05:30)
107
108     Note that the TZD can be either the capital letter `Z` to indicate
109     UTC time, a string in the format +hh:mm to indicate a local time
110     expressed with a time zone hh hours and mm minutes ahead of UTC or
111     -hh:mm to indicate a local time expressed with a time zone hh
112     hours and mm minutes behind UTC.
113
114     >>> import calendar
115     >>> import email.utils
116     >>> import time
117     >>> ref = calendar.timegm(time.strptime('2000', '%Y'))
118     >>> y = parse_date('2000')
119     >>> y - ref  # seconds between y and ref
120     0
121     >>> ym = parse_date('2000-02')
122     >>> (ym - y)/(3600.*24)  # days between ym and y
123     31.0
124     >>> ymd = parse_date('2000-02-12')
125     >>> (ymd - ym)/(3600.*24)  # days between ymd and ym
126     11.0
127     >>> ymdhm = parse_date('2000-02-12T06:05+05:30')
128     >>> (ymdhm - ymd)/60.  # minutes between ymdhm and ymd
129     35.0
130     >>> (ymdhm - parse_date('2000-02-12T06:05Z'))/3600.
131     -5.5
132     >>> ymdhms = parse_date('2000-02-12T06:05:30+05:30')
133     >>> ymdhms - ymdhm
134     30
135     >>> (ymdhms - parse_date('2000-02-12T06:05:30Z'))/3600.
136     -5.5
137     >>> ymdhms_ms = parse_date('2000-02-12T06:05:30.45+05:30')
138     >>> ymdhms_ms - ymdhms  # doctest: +ELLIPSIS
139     0.45000...
140     >>> (ymdhms_ms - parse_date('2000-02-12T06:05:30.45Z'))/3600.
141     -5.5
142     >>> p = parse_date('1994-11-05T08:15:30-05:00')
143     >>> email.utils.formatdate(p, localtime=True)
144     'Sat, 05 Nov 1994 08:15:30 -0500'
145     >>> p - parse_date('1994-11-05T13:15:30Z')
146     0
147     """
148     m = _DATE_REGEXP.match(string)
149     if not m:
150         raise ValueError(string)
151     date,t,time,ms,zone = m.groups()
152     ret = None
153     if t:
154         date += 'T' + time
155     error = None
156     for fmt in ['%Y-%m-%dT%H:%M:%S',
157                 '%Y-%m-%dT%H:%M',
158                 '%Y-%m-%d',
159                 '%Y-%m',
160                 '%Y',
161                 ]:
162         try:
163             ret = _time.strptime(date, fmt)
164         except ValueError as e:
165             error = e
166         else:
167             break
168     if ret is None:
169         raise error
170     ret = list(ret)
171     ret[-1] = 0  # don't use daylight savings time
172     ret = _calendar.timegm(ret)
173     if ms:
174         ret += float(ms)
175     if zone and zone != 'Z':
176         sign = int(zone[1] + '1')
177         hour,minute = map(int, zone.split(':', 1))
178         offset = sign*(3600*hour + 60*minute)
179         ret -= offset
180     return ret
181
182 def parse_boolean(value):
183     """Convert a boolean string into ``True`` or ``False``.
184
185     Supports the same values as ``RawConfigParser``
186
187     >>> parse_boolean('YES')
188     True
189     >>> parse_boolean('Yes')
190     True
191     >>> parse_boolean('tRuE')
192     True
193     >>> parse_boolean('False')
194     False
195     >>> parse_boolean('FALSE')
196     False
197     >>> parse_boolean('no')
198     False
199     >>> parse_boolean('none')
200     Traceback (most recent call last):
201       ...
202     ValueError: Not a boolean: none
203     >>> parse_boolean('')  # doctest: +NORMALIZE_WHITESPACE
204     Traceback (most recent call last):
205       ...
206     ValueError: Not a boolean:
207
208     It passes through boolean inputs without modification (so you
209     don't have to use strings for default values):
210
211     >>> parse_boolean({}.get('my-option', True))
212     True
213     >>> parse_boolean({}.get('my-option', False))
214     False
215     """
216     if value in [True, False]:
217         return value
218     # Using an underscored method is hackish, but it should be fairly stable.
219     p = _configparser.RawConfigParser()
220     return p._convert_to_boolean(value)
221
222 def load_assignment(name, data):
223     r"""Load an assignment from a ``dict``
224
225     >>> from email.utils import formatdate
226     >>> a = load_assignment(
227     ...     name='Attendance 1',
228     ...     data={'points': '1',
229     ...           'weight': '0.1/2',
230     ...           'due': '2011-10-04T00:00-04:00',
231     ...           'submittable': 'yes',
232     ...           })
233     >>> print(('{0.name} (points: {0.points}, weight: {0.weight}, '
234     ...        'due: {0.due}, submittable: {0.submittable})').format(a))
235     Attendance 1 (points: 1, weight: 0.05, due: 1317700800, submittable: True)
236     >>> print(formatdate(a.due, localtime=True))
237     Tue, 04 Oct 2011 00:00:00 -0400
238     """
239     points = int(data['points'])
240     wterms = data['weight'].split('/')
241     if len(wterms) == 1:
242         weight = float(wterms[0])
243     else:
244         assert len(wterms) == 2, wterms
245         weight = float(wterms[0])/float(wterms[1])
246     due = parse_date(data['due'])
247     submittable = parse_boolean(data.get('submittable', False))
248     return _Assignment(
249         name=name, points=points, weight=weight, due=due,
250         submittable=submittable)
251
252 def load_person(name, data={}):
253     r"""Load a person from a ``dict``
254
255     >>> from io import StringIO
256     >>> stream = StringIO('''#comment line
257     ... Tom Bombadil <tbomb@oldforest.net>  # post address comment
258     ... Tom Bombadil <yellow.boots@oldforest.net>
259     ... Goldberry <gb@oldforest.net>
260     ... ''')
261
262     >>> p = load_person(
263     ...     name='Gandalf',
264     ...     data={'nickname': 'G-Man',
265     ...           'emails': 'g@grey.edu, g@greyhavens.net',
266     ...           'pgp-key': '0x0123456789ABCDEF',
267     ...           })
268     >>> print('{0.name}: {0.emails} | {0.pgp_key}'.format(p))
269     Gandalf: ['g@grey.edu', 'g@greyhavens.net'] | 0x0123456789ABCDEF
270     >>> p = load_person(name='Gandalf')
271     >>> print('{0.name}: {0.emails} | {0.pgp_key}'.format(p))
272     Gandalf: None | None
273     """
274     kwargs = {}
275     emails = [x.strip() for x in data.get('emails', '').split(',')]
276     emails = list(filter(bool, emails))  # remove blank emails
277     if emails:
278         kwargs['emails'] = emails
279     nickname = data.get('nickname', None)
280     if nickname:
281         kwargs['aliases'] = [nickname]
282     pgp_key = data.get('pgp-key', None)
283     if pgp_key:
284         kwargs['pgp_key'] = pgp_key
285     return _Person(name=name, **kwargs)
286
287 def load_grades(basedir, assignments, people):
288     "Load all grades in a course directory."
289     for assignment in assignments:
290         for person in people:
291             if 'students' in person.groups:
292                 try:
293                     yield load_grade(basedir, assignment, person)
294                 except IOError:
295                     continue
296
297 def load_grade(basedir, assignment, person):
298     "Load a single grade from a course directory."
299     _LOG.debug('loading {} grade for {}'.format(assignment, person))
300     path = assignment_path(basedir, assignment, person)
301     gpath = _os_path.join(path, 'grade')
302     g = parse_grade(_io.open(gpath, 'r', encoding=_pygrader.ENCODING),
303                     assignment, person)
304     #g.late = _os.stat(gpath).st_mtime > assignment.due
305     g.late = _os_path.exists(_os_path.join(path, 'late'))
306     npath = _os_path.join(path, 'notified')
307     if _os_path.exists(npath):
308         g.notified = newer(npath, gpath)
309     else:
310         g.notified = False
311     return g
312
313 def parse_grade(stream, assignment, person):
314     "Parse the points and comment from a grade stream."
315     try:
316         points = float(stream.readline())
317     except ValueError:
318         _sys.stderr.write('failure reading {}, {}\n'.format(
319                 assignment.name, person.name))
320         raise
321     comment = stream.read().strip() or None
322     return _Grade(
323         student=person, assignment=assignment, points=points, comment=comment)
324
325 def assignment_path(basedir, assignment, person):
326     return _os_path.join(basedir,
327                   _filesystem_name(person.name),
328                   _filesystem_name(assignment.name))
329
330 def _filesystem_name(name):
331     for a,b in [(' ', '_'), ('.', ''), ("'", ''), ('"', '')]:
332         name = name.replace(a, b)
333     return name
334
335 def set_notified(basedir, grade):
336     """Mark `grade.student` as notified about `grade`
337     """
338     path = assignment_path(
339         basedir=basedir, assignment=grade.assignment, person=grade.student)
340     npath = _os_path.join(path, 'notified')
341     _touch(npath)
342
343 def set_late(basedir, assignment, person):
344     path = assignment_path(
345         basedir=basedir, assignment=assignment, person=person)
346     Lpath = _os_path.join(path, 'late')
347     _touch(Lpath)
348
349 def save_grade(basedir, grade):
350     "Save a grade into a course directory"
351     path = assignment_path(
352         basedir=basedir, assignment=grade.assignment, person=grade.student)
353     if not _os_path.isdir(path):
354         _os.makedirs(path)
355     gpath = _os_path.join(path, 'grade')
356     with _io.open(gpath, 'w', encoding=_pygrader.ENCODING) as f:
357         f.write('{}\n'.format(grade.points))
358         if grade.comment:
359             f.write('\n{}\n'.format(grade.comment.strip()))
360     set_notified(basedir=basedir, grade=grade)
361     set_late(
362         basedir=basedir, assignment=grade.assignment, person=grade.student)
363
364 def _touch(path):
365     """Touch a file (`path` is created if it doesn't already exist)
366
367     Also updates the access and modification times to the current
368     time.
369
370     >>> from os import listdir, rmdir, unlink
371     >>> from os.path import join
372     >>> from tempfile import mkdtemp
373     >>> d = mkdtemp(prefix='pygrader')
374     >>> listdir(d)
375     []
376     >>> p = join(d, 'touched')
377     >>> _touch(p)
378     >>> listdir(d)
379     ['touched']
380     >>> _touch(p)
381     >>> unlink(p)
382     >>> rmdir(d)
383     """
384     with open(path, 'a') as f:
385         pass
386     _os.utime(path, None)
387
388 def initialize(basedir, course, dry_run=False, **kwargs):
389     """Stub out the directory tree based on the course configuration.
390     """
391     for person in course.people:
392         for assignment in course.assignments:
393             path = assignment_path(basedir, assignment, person)
394             if dry_run:  # we'll need to guess if mkdirs would work
395                 if not _os_path.exists(path):
396                     _LOG.debug('creating {}'.format(path))
397             else:
398                 try:
399                     _os.makedirs(path)
400                 except OSError:
401                     continue
402                 else:
403                     _LOG.debug('creating {}'.format(path))