1 # Copyright (C) 2012 W. Trevor King <wking@tremily.us>
3 # This file is part of pygrader.
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
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.
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/>.
17 from __future__ import absolute_import
19 import calendar as _calendar
20 import configparser as _configparser
21 import email.utils as _email_utils
24 import os.path as _os_path
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
38 _DATE_REGEXP = _re.compile('^([^T]*)(T?)([^TZ+-.]*)([.]?[0-9]*)([+-][0-9:]*|Z?)$')
41 def load_course(basedir):
42 """Load a course directory.
44 >>> from pygrader.test.course import StubCourse
45 >>> stub_course = StubCourse(load=False)
46 >>> course = load_course(basedir=stub_course.basedir)
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...>, ...]
55 >>> print(course.robot)
57 >>> stub_course.cleanup()
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']:
68 config.get('course', option, fallback='').split(',')]
69 while '' in names[option]:
70 names[option].remove('')
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))))
77 for group in ['robot', 'professors', 'assistants', 'students']:
78 for person in names[group]:
80 _LOG.debug('adding person {} to group {}'.format(
82 people[person].groups.append(group)
84 _LOG.debug('loading person {} in group {}'.format(
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))
93 name=name, assignments=assignments, people=people, grades=grades,
96 def parse_date(string):
97 """Parse dates given using the W3C DTF profile of ISO 8601.
99 The following are legal formats::
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)
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.
115 >>> import email.utils
117 >>> ref = calendar.timegm(time.strptime('2000', '%Y'))
118 >>> y = parse_date('2000')
119 >>> y - ref # seconds between y and ref
121 >>> ym = parse_date('2000-02')
122 >>> (ym - y)/(3600.*24) # days between ym and y
124 >>> ymd = parse_date('2000-02-12')
125 >>> (ymd - ym)/(3600.*24) # days between ymd and ym
127 >>> ymdhm = parse_date('2000-02-12T06:05+05:30')
128 >>> (ymdhm - ymd)/60. # minutes between ymdhm and ymd
130 >>> (ymdhm - parse_date('2000-02-12T06:05Z'))/3600.
132 >>> ymdhms = parse_date('2000-02-12T06:05:30+05:30')
135 >>> (ymdhms - parse_date('2000-02-12T06:05:30Z'))/3600.
137 >>> ymdhms_ms = parse_date('2000-02-12T06:05:30.45+05:30')
138 >>> ymdhms_ms - ymdhms # doctest: +ELLIPSIS
140 >>> (ymdhms_ms - parse_date('2000-02-12T06:05:30.45Z'))/3600.
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')
148 m = _DATE_REGEXP.match(string)
150 raise ValueError(string)
151 date,t,time,ms,zone = m.groups()
156 for fmt in ['%Y-%m-%dT%H:%M:%S',
163 ret = _time.strptime(date, fmt)
164 except ValueError as e:
171 ret[-1] = 0 # don't use daylight savings time
172 ret = _calendar.timegm(ret)
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)
182 def parse_boolean(value):
183 """Convert a boolean string into ``True`` or ``False``.
185 Supports the same values as ``RawConfigParser``
187 >>> parse_boolean('YES')
189 >>> parse_boolean('Yes')
191 >>> parse_boolean('tRuE')
193 >>> parse_boolean('False')
195 >>> parse_boolean('FALSE')
197 >>> parse_boolean('no')
199 >>> parse_boolean('none')
200 Traceback (most recent call last):
202 ValueError: Not a boolean: none
203 >>> parse_boolean('') # doctest: +NORMALIZE_WHITESPACE
204 Traceback (most recent call last):
206 ValueError: Not a boolean:
208 It passes through boolean inputs without modification (so you
209 don't have to use strings for default values):
211 >>> parse_boolean({}.get('my-option', True))
213 >>> parse_boolean({}.get('my-option', False))
216 if value in [True, False]:
218 # Using an underscored method is hackish, but it should be fairly stable.
219 p = _configparser.RawConfigParser()
220 return p._convert_to_boolean(value)
222 def load_assignment(name, data):
223 r"""Load an assignment from a ``dict``
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',
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
239 points = int(data['points'])
240 wterms = data['weight'].split('/')
242 weight = float(wterms[0])
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))
249 name=name, points=points, weight=weight, due=due,
250 submittable=submittable)
252 def load_person(name, data={}):
253 r"""Load a person from a ``dict``
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>
264 ... data={'nickname': 'G-Man',
265 ... 'emails': 'g@grey.edu, g@greyhavens.net',
266 ... 'pgp-key': '0x0123456789ABCDEF',
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))
275 emails = [x.strip() for x in data.get('emails', '').split(',')]
276 emails = list(filter(bool, emails)) # remove blank emails
278 kwargs['emails'] = emails
279 nickname = data.get('nickname', None)
281 kwargs['aliases'] = [nickname]
282 pgp_key = data.get('pgp-key', None)
284 kwargs['pgp_key'] = pgp_key
285 return _Person(name=name, **kwargs)
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:
293 yield load_grade(basedir, assignment, person)
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),
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)
313 def parse_grade(stream, assignment, person):
314 "Parse the points and comment from a grade stream."
316 points = float(stream.readline())
318 _sys.stderr.write('failure reading {}, {}\n'.format(
319 assignment.name, person.name))
321 comment = stream.read().strip() or None
323 student=person, assignment=assignment, points=points, comment=comment)
325 def assignment_path(basedir, assignment, person):
326 return _os_path.join(basedir,
327 _filesystem_name(person.name),
328 _filesystem_name(assignment.name))
330 def _filesystem_name(name):
331 for a,b in [(' ', '_'), ('.', ''), ("'", ''), ('"', '')]:
332 name = name.replace(a, b)
335 def set_notified(basedir, grade):
336 """Mark `grade.student` as notified about `grade`
338 path = assignment_path(
339 basedir=basedir, assignment=grade.assignment, person=grade.student)
340 npath = _os_path.join(path, 'notified')
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')
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):
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))
359 f.write('\n{}\n'.format(grade.comment.strip()))
360 set_notified(basedir=basedir, grade=grade)
362 basedir=basedir, assignment=grade.assignment, person=grade.student)
365 """Touch a file (`path` is created if it doesn't already exist)
367 Also updates the access and modification times to the current
370 >>> from os import listdir, rmdir, unlink
371 >>> from os.path import join
372 >>> from tempfile import mkdtemp
373 >>> d = mkdtemp(prefix='pygrader')
376 >>> p = join(d, 'touched')
384 with open(path, 'a') as f:
386 _os.utime(path, None)
388 def initialize(basedir, course, dry_run=False, **kwargs):
389 """Stub out the directory tree based on the course configuration.
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))
403 _LOG.debug('creating {}'.format(path))