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