Begin versioning! (better late than never)
[pygrader.git] / pygrader / storage.py
1 # Copyright
2
3 from __future__ import absolute_import
4
5 import calendar as _calendar
6 import configparser as _configparser
7 import email.utils as _email_utils
8 import io as _io
9 import os as _os
10 import os.path as _os_path
11 import re as _re
12 import sys as _sys
13 import time as _time
14
15 from . import LOG as _LOG
16 from . import ENCODING as _ENCODING
17 from .model.assignment import Assignment as _Assignment
18 from .model.course import Course as _Course
19 from .model.grade import Grade as _Grade
20 from .model.person import Person as _Person
21 from .todo import newer
22
23
24 _DATE_REGEXP = _re.compile('^([^T]*)(T?)([^TZ+-.]*)([.]?[0-9]*)([+-][0-9:]*|Z?)$')
25
26
27 def load_course(basedir):
28     _LOG.debug('loading course from {}'.format(basedir))
29     config = _configparser.ConfigParser()
30     config.read([_os_path.join(basedir, 'course.conf')])
31     names = {}
32     for option in ['assignments', 'professors', 'assistants', 'students']:
33         names[option] = [
34         a.strip() for a in config.get('course', option).split(',')]
35     assignments = []
36     for assignment in names['assignments']:
37         _LOG.debug('loading assignment {}'.format(assignment))
38         assignments.append(load_assignment(
39                 name=assignment, data=dict(config.items(assignment))))
40     people = {}
41     for group in ['professors', 'assistants', 'students']:
42         for person in names[group]:
43             if person in people:
44                 _LOG.debug('adding person {} to group {}'.format(
45                         person, group))
46                 people[person].groups.append(group)
47             else:
48                 _LOG.debug('loading person {} in group {}'.format(
49                         person, group))
50                 people[person] = load_person(
51                     name=person, data=dict(config.items(person)))
52                 people[person].groups = [group]
53     people = people.values()
54     grades = list(load_grades(basedir, assignments, people))
55     return _Course(assignments=assignments, people=people, grades=grades)
56
57 def parse_date(string):
58     """Parse dates given using the W3C DTF profile of ISO 8601.
59
60     The following are legal formats::
61
62       YYYY (e.g. 2000)
63       YYYY-MM (e.g. 2000-02)
64       YYYY-MM-DD (e.g. 2000-02-12)
65       YYYY-MM-DDThh:mmTZD (e.g. 2000-02-12T06:05+05:30)
66       YYYY-MM-DDThh:mm:ssTZD (e.g. 2000-02-12T06:05:30+05:30)
67       YYYY-MM-DDThh:mm:ss.sTZD (e.g. 2000-02-12T06:05:30.45+05:30)
68
69     Note that the TZD can be either the capital letter `Z` to indicate
70     UTC time, a string in the format +hh:mm to indicate a local time
71     expressed with a time zone hh hours and mm minutes ahead of UTC or
72     -hh:mm to indicate a local time expressed with a time zone hh
73     hours and mm minutes behind UTC.
74
75     >>> import calendar
76     >>> import email.utils
77     >>> import time
78     >>> ref = calendar.timegm(time.strptime('2000', '%Y'))
79     >>> y = parse_date('2000')
80     >>> y - ref  # seconds between y and ref
81     0
82     >>> ym = parse_date('2000-02')
83     >>> (ym - y)/(3600.*24)  # days between ym and y
84     31.0
85     >>> ymd = parse_date('2000-02-12')
86     >>> (ymd - ym)/(3600.*24)  # days between ymd and ym
87     11.0
88     >>> ymdhm = parse_date('2000-02-12T06:05+05:30')
89     >>> (ymdhm - ymd)/60.  # minutes between ymdhm and ymd
90     35.0
91     >>> (ymdhm - parse_date('2000-02-12T06:05Z'))/3600.
92     -5.5
93     >>> ymdhms = parse_date('2000-02-12T06:05:30+05:30')
94     >>> ymdhms - ymdhm
95     30
96     >>> (ymdhms - parse_date('2000-02-12T06:05:30Z'))/3600.
97     -5.5
98     >>> ymdhms_ms = parse_date('2000-02-12T06:05:30.45+05:30')
99     >>> ymdhms_ms - ymdhms  # doctest: +ELLIPSIS
100     0.45000...
101     >>> (ymdhms_ms - parse_date('2000-02-12T06:05:30.45Z'))/3600.
102     -5.5
103     >>> p = parse_date('1994-11-05T08:15:30-05:00')
104     >>> email.utils.formatdate(p, localtime=True)
105     'Sat, 05 Nov 1994 08:15:30 -0500'
106     >>> p - parse_date('1994-11-05T13:15:30Z')
107     0
108     """
109     m = _DATE_REGEXP.match(string)
110     if not m:
111         raise ValueError(string)
112     date,t,time,ms,zone = m.groups()
113     ret = None
114     if t:
115         date += 'T' + time
116     error = None
117     for fmt in ['%Y-%m-%dT%H:%M:%S',
118                 '%Y-%m-%dT%H:%M',
119                 '%Y-%m-%d',
120                 '%Y-%m',
121                 '%Y',
122                 ]:
123         try:
124             ret = _time.strptime(date, fmt)
125         except ValueError as e:
126             error = e
127         else:
128             break
129     if ret is None:
130         raise error
131     ret = list(ret)
132     ret[-1] = 0  # don't use daylight savings time
133     ret = _calendar.timegm(ret)
134     if ms:
135         ret += float(ms)
136     if zone and zone != 'Z':
137         sign = int(zone[1] + '1')
138         hour,minute = map(int, zone.split(':', 1))
139         offset = sign*(3600*hour + 60*minute)
140         ret -= offset
141     return ret
142
143 def load_assignment(name, data):
144     r"""Load an assignment from a ``dict``
145
146     >>> from email.utils import formatdate
147     >>> a = load_assignment(
148     ...     name='Attendance 1',
149     ...     data={'points': '1',
150     ...           'weight': '0.1/2',
151     ...           'due': '2011-10-04T00:00-04:00',
152     ...           })
153     >>> print('{0.name} (points: {0.points}, weight: {0.weight}, due: {0.due})'.format(a))
154     Attendance 1 (points: 1, weight: 0.05, due: 1317700800)
155     >>> print(formatdate(a.due, localtime=True))
156     Tue, 04 Oct 2011 00:00:00 -0400
157     """
158     points = int(data['points'])
159     wterms = data['weight'].split('/')
160     if len(wterms) == 1:
161         weight = float(wterms[0])
162     else:
163         assert len(wterms) == 2, wterms
164         weight = float(wterms[0])/float(wterms[1])
165     due = parse_date(data['due'])
166     return _Assignment(name=name, points=points, weight=weight, due=due)
167
168 def load_person(name, data={}):
169     r"""Load a person from a ``dict``
170
171     >>> from io import StringIO
172     >>> stream = StringIO('''#comment line
173     ... Tom Bombadil <tbomb@oldforest.net>  # post address comment
174     ... Tom Bombadil <yellow.boots@oldforest.net>
175     ... Goldberry <gb@oldforest.net>
176     ... ''')
177
178     >>> p = load_person(
179     ...     name='Gandalf',
180     ...     data={'nickname': 'G-Man',
181     ...           'emails': 'g@grey.edu, g@greyhavens.net',
182     ...           'pgp-key': '0x0123456789ABCDEF',
183     ...           })
184     >>> print('{0.name}: {0.emails}'.format(p))
185     Gandalf: ['g@grey.edu', 'g@greyhavens.net'] | 0x0123456789ABCDEF
186     >>> p = load_person(name='Gandalf')
187     >>> print('{0.name}: {0.emails} | {0.pgp_key}'.format(p))
188     Gandalf: None | None
189     """
190     kwargs = {}
191     emails = [x.strip() for x in data.get('emails', '').split(',')]
192     emails = list(filter(bool, emails))  # remove blank emails
193     if emails:
194         kwargs['emails'] = emails
195     nickname = data.get('nickname', None)
196     if nickname:
197         kwargs['aliases'] = [nickname]
198     pgp_key = data.get('pgp-key', None)
199     if pgp_key:
200         kwargs['pgp_key'] = pgp_key
201     return _Person(name=name, **kwargs)
202
203 def load_grades(basedir, assignments, people):
204     for assignment in assignments:
205         for person in people:
206             _LOG.debug('loading {} grade for {}'.format(assignment, person))
207             path = assignment_path(basedir, assignment, person)
208             gpath = _os_path.join(path, 'grade')
209             try:
210                 g = _load_grade(_io.open(gpath, 'r', encoding=_ENCODING),
211                                 assignment, person)
212             except IOError:
213                 continue
214             #g.late = _os.stat(gpath).st_mtime > assignment.due
215             g.late = _os_path.exists(_os_path.join(path, 'late'))
216             npath = _os_path.join(path, 'notified')
217             if _os_path.exists(npath):
218                 g.notified = newer(npath, gpath)
219             else:
220                 g.notified = False
221             yield g
222
223 def _load_grade(stream, assignment, person):
224     try:
225         points = float(stream.readline())
226     except ValueError:
227         _sys.stderr.write('failure reading {}, {}\n'.format(
228                 assignment.name, person.name))
229         raise
230     comment = stream.read().strip() or None
231     return _Grade(
232         student=person, assignment=assignment, points=points, comment=comment)
233
234 def assignment_path(basedir, assignment, person):
235     return _os_path.join(basedir,
236                   _filesystem_name(person.name),
237                   _filesystem_name(assignment.name))
238
239 def _filesystem_name(name):
240     for a,b in [(' ', '_'), ('.', ''), ("'", ''), ('"', '')]:
241         name = name.replace(a, b)
242     return name
243
244 def set_notified(basedir, grade):
245     """Mark `grade.student` as notified about `grade`
246     """
247     path = assignment_path(
248         basedir=basedir, assignment=grade.assignment, person=grade.student)
249     npath = _os_path.join(path, 'notified')
250     _touch(npath)
251
252 def set_late(basedir, assignment, person):
253     path = assignment_path(
254         basedir=basedir, assignment=assignment, person=person)
255     Lpath = _os_path.join(path, 'late')
256     _touch(Lpath)
257
258 def _touch(path):
259     """Touch a file (`path` is created if it doesn't already exist)
260
261     Also updates the access and modification times to the current
262     time.
263
264     >>> from os import listdir, rmdir, unlink
265     >>> from os.path import join
266     >>> from tempfile import mkdtemp
267     >>> d = mkdtemp(prefix='pygrader')
268     >>> listdir(d)
269     []
270     >>> p = join(d, 'touched')
271     >>> _touch(p)
272     >>> listdir(d)
273     ['touched']
274     >>> _touch(p)
275     >>> unlink(p)
276     >>> rmdir(d)
277     """
278     with open(path, 'a') as f:
279         pass
280     _os.utime(path, None)
281
282 def initialize(basedir, course, dry_run=False, **kwargs):
283     """Stub out the directory tree based on the course configuration.
284     """
285     for person in course.people:
286         for assignment in course.assignments:
287             path = assignment_path(basedir, assignment, person)
288             if dry_run:  # we'll need to guess if mkdirs would work
289                 if not _os_path.exists(path):
290                     _LOG.debug('creating {}'.format(path))
291             else:
292                 try:
293                     _os.makedirs(path)
294                 except OSError:
295                     continue
296                 else:
297                     _LOG.debug('creating {}'.format(path))