Commit cfeff39733053a95dc07e65ec20705d324db67ff
1 parent
acf93255
Exists in
master
and in
1 other branch
- use logging instead of print
- updated correction code to use recommended subprocess.run() from python 3.5 - remove exit() calls so that the server will not terminate
Showing
1 changed file
with
98 additions
and
77 deletions
Show diff stats
questions.py
... | ... | @@ -32,10 +32,32 @@ import yaml |
32 | 32 | import random |
33 | 33 | import re |
34 | 34 | import subprocess |
35 | -import sys | |
36 | 35 | import os.path |
36 | +import logging | |
37 | 37 | |
38 | 38 | |
39 | +qlogger = logging.getLogger('Questions') | |
40 | +qlogger.setLevel(logging.INFO) | |
41 | + | |
42 | +fh = logging.FileHandler('question.log') | |
43 | +ch = logging.StreamHandler() | |
44 | +ch.setLevel(logging.INFO) | |
45 | + | |
46 | +formatter = logging.Formatter('%(asctime)s | %(name)s | %(levelname)s | %(message)s') | |
47 | +fh.setFormatter(formatter) | |
48 | +ch.setFormatter(formatter) | |
49 | + | |
50 | +qlogger.addHandler(fh) | |
51 | +qlogger.addHandler(ch) | |
52 | + | |
53 | +# if an error occurs in a question, the question is replaced by this message | |
54 | +qerror = { | |
55 | + 'filename': 'questions.py', | |
56 | + 'ref': '__error__', | |
57 | + 'type': 'warning', | |
58 | + 'text': 'An error occurred while generating this question.' | |
59 | + } | |
60 | + | |
39 | 61 | # =========================================================================== |
40 | 62 | class QuestionsPool(dict): |
41 | 63 | '''This class contains base questions read from files, but which are |
... | ... | @@ -46,43 +68,42 @@ class QuestionsPool(dict): |
46 | 68 | # add some defaults if missing from sources |
47 | 69 | for i, q in enumerate(questions): |
48 | 70 | if not isinstance(q, dict): |
49 | - print('[ WARNG ] Question with index {0} in file {1} is not a dict. Ignoring...'.format(i, filename)) | |
71 | + qlogger.error('Question index {0} from file {1} is not a dictionary. Skipped...'.format(i, filename)) | |
50 | 72 | continue |
51 | 73 | |
52 | 74 | if q['ref'] in self: |
53 | - print('[ ERROR ] Duplicate question "{0}" in files "{1}" and "{2}".'.format(q['ref'], filename, self[q['ref']]['filename'])) | |
54 | - sys.exit(1) | |
55 | - | |
56 | - # filename and index (number in the file, 0 based) | |
57 | - q['filename'] = filename | |
58 | - q['path'] = path | |
59 | - q['index'] = i | |
60 | - | |
61 | - # ref (if missing, add 'filename.yaml:3') | |
62 | - q['ref'] = str(q.get('ref', filename + ':' + str(i))) | |
63 | - | |
64 | - # type (default type is 'information') | |
65 | - q['type'] = str(q.get('type', 'information')) | |
75 | + qlogger.error('Duplicate question "{0}" in files "{1}" and "{2}". Skipped...'.format(q['ref'], filename, self[q['ref']]['filename'])) | |
76 | + continue | |
66 | 77 | |
67 | - # optional title (default empty string) | |
68 | - q['title'] = str(q.get('title', '')) | |
78 | + # index is the position in the questions file, 0 based | |
79 | + q.update({ | |
80 | + 'filename': filename, | |
81 | + 'path': path, | |
82 | + 'index': i | |
83 | + }) | |
84 | + q.setdefault('ref', filename + ':' + str(i)) # 'filename.yaml:3' | |
85 | + q.setdefault('type', 'information') | |
69 | 86 | |
70 | 87 | # add question to the pool |
71 | 88 | self[q['ref']] = q |
89 | + qlogger.debug('Added question "{0}" to the pool.'.format(q['ref'])) | |
72 | 90 | |
73 | 91 | #------------------------------------------------------------------------ |
74 | 92 | def add_from_files(self, files, path='.'): |
93 | + '''Given a list of YAML files, reads them all and tries to add | |
94 | + questions to the pool.''' | |
75 | 95 | for filename in files: |
76 | 96 | try: |
77 | 97 | with open(os.path.normpath(os.path.join(path, filename)), 'r', encoding='utf-8') as f: |
78 | 98 | questions = yaml.load(f) |
79 | 99 | except(FileNotFoundError): |
80 | - print('[ ERROR ] Questions file "{0}" not found.'.format(filename)) | |
100 | + qlogger.error('Questions file "{0}" not found. Skipping this one.'.format(filename)) | |
81 | 101 | continue |
82 | 102 | except(yaml.parser.ParserError): |
83 | - print('[ ERROR ] Error loading questions in YAML file "{0}".'.format(filename)) | |
103 | + qlogger.error('Error loading questions from YAML file "{0}". Skipping this one.'.format(filename)) | |
84 | 104 | continue |
85 | 105 | self.add(questions, filename, path) |
106 | + qlogger.info('Added {0} questions from "{1}" to the pool.'.format(len(questions), filename)) | |
86 | 107 | |
87 | 108 | |
88 | 109 | #============================================================================ |
... | ... | @@ -97,13 +118,6 @@ def create_question(q): |
97 | 118 | The remaing keys depend on the type of question. |
98 | 119 | ''' |
99 | 120 | |
100 | - # If `q` is of a question generator type, an external program will be run | |
101 | - # and expected to print a valid question in yaml format to stdout. This | |
102 | - # output is then converted to a dictionary and `q` becomes that dict. | |
103 | - if q['type'] == 'generator': | |
104 | - q.update(question_generator(q)) | |
105 | - # At this point the generator question was replaced by an actual question. | |
106 | - | |
107 | 121 | # Depending on the type of question, a different question class is |
108 | 122 | # instantiated. All these classes derive from the base class `Question`. |
109 | 123 | types = { |
... | ... | @@ -111,28 +125,33 @@ def create_question(q): |
111 | 125 | 'checkbox' : QuestionCheckbox, |
112 | 126 | 'text' : QuestionText, |
113 | 127 | 'text_regex': QuestionTextRegex, |
114 | - # 'text-regex': QuestionTextRegex, | |
115 | - # 'regex' : QuestionTextRegex, | |
116 | 128 | 'textarea' : QuestionTextArea, |
117 | 129 | 'information': QuestionInformation, |
118 | 130 | 'warning' : QuestionInformation, |
119 | - # 'info' : QuestionInformation, | |
120 | - # '' : QuestionInformation, # default | |
121 | 131 | } |
122 | 132 | |
133 | + | |
134 | + # If `q` is of a question generator type, an external program will be run | |
135 | + # and expected to print a valid question in yaml format to stdout. This | |
136 | + # output is then converted to a dictionary and `q` becomes that dict. | |
137 | + if q['type'] == 'generator': | |
138 | + qlogger.debug('Generating question "{0}"...'.format(q['ref'])) | |
139 | + q.update(question_generator(q)) | |
140 | + # At this point the generator question was replaced by an actual question. | |
141 | + | |
123 | 142 | # Get the correct question class for the declared question type |
124 | 143 | try: |
125 | 144 | questiontype = types[q['type']] |
126 | 145 | except KeyError: |
127 | - print('[ ERROR ] Unsupported question type "{0}" in "{1}:{2}".'.format(q['type'], q['filename'], q['ref'])) | |
128 | - questiontype = Question | |
146 | + qlogger.error('Unsupported question type "{0}" in "{1}:{2}".'.format(q['type'], q['filename'], q['ref'])) | |
147 | + questiontype, q = QuestionWarning, qerror | |
129 | 148 | |
130 | 149 | # Create question instance and return |
131 | 150 | try: |
132 | 151 | qinstance = questiontype(q) |
133 | 152 | except: |
134 | - print('[ ERROR ] Creating question "{0}" from file "{1}".'.format(q['ref'], q['filename'])) | |
135 | - # FIXME if here, then no qinstance is defined to return... | |
153 | + qlogger.error('Could not create question "{0}" from file "{1}".'.format(q['ref'], q['filename'])) | |
154 | + qinstance = QuestionInformation(qerror) | |
136 | 155 | |
137 | 156 | return qinstance |
138 | 157 | |
... | ... | @@ -142,35 +161,47 @@ def question_generator(q): |
142 | 161 | '''Run an external program that will generate a question in yaml format. |
143 | 162 | This function will return the yaml converted back to a dict.''' |
144 | 163 | |
145 | - q['arg'] = q.get('arg', '') # send this string to stdin | |
164 | + q.setdefault('arg', '') # will be sent to stdin | |
146 | 165 | |
147 | 166 | script = os.path.abspath(os.path.normpath(os.path.join(q['path'], q['script']))) |
148 | 167 | try: |
149 | 168 | p = subprocess.Popen([script], stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT) |
150 | 169 | except FileNotFoundError: |
151 | - print('[ ERROR ] Script "{0}" of question "{2}:{1}" not found'.format(script, q['ref'], q['filename'])) | |
152 | - sys.exit(1) | |
170 | + qlogger.error('Script "{0}" of question "{2}:{1}" not found'.format(script, q['ref'], q['filename'])) | |
171 | + return qerror | |
153 | 172 | except PermissionError: |
154 | - print('[ ERROR ] Script "{0}" of question "{2}:{1}" has wrong permissions. Is it executable?'.format(script, q['ref'], q['filename'])) | |
155 | - sys.exit(1) | |
173 | + qlogger.error('Script "{0}" has wrong permissions. Is it executable?'.format(script, q['ref'], q['filename'])) | |
174 | + return qerror | |
156 | 175 | |
157 | 176 | try: |
158 | 177 | qyaml = p.communicate(input=q['arg'].encode('utf-8'), timeout=5)[0].decode('utf-8') |
159 | 178 | except subprocess.TimeoutExpired: |
160 | 179 | p.kill() |
161 | - print('[ ERROR ] Timeout on script "{0}" of question "{2}:{1}"'.format(script, q['ref'], q['filename'])) | |
162 | - sys.exit(1) | |
180 | + qlogger.error('Timeout on script "{0}" of question "{2}:{1}"'.format(script, q['ref'], q['filename'])) | |
181 | + return qerror | |
163 | 182 | |
164 | 183 | return yaml.load(qyaml) |
165 | 184 | |
166 | 185 | |
167 | 186 | # =========================================================================== |
187 | +# Questions derived from Question are already instantiated and ready to be | |
188 | +# presented to students. | |
189 | +# =========================================================================== | |
168 | 190 | class Question(dict): |
169 | 191 | ''' |
170 | 192 | Classes derived from this base class are meant to instantiate a question |
171 | 193 | to a student. |
172 | 194 | Instances can shuffle options, or automatically generate questions. |
173 | 195 | ''' |
196 | + def __init__(self, q): | |
197 | + super().__init__(q) | |
198 | + | |
199 | + # these are mandatory for any question: | |
200 | + self.set_defaults({ | |
201 | + 'title': '', | |
202 | + 'answer': None, | |
203 | + }) | |
204 | + | |
174 | 205 | def correct(self): |
175 | 206 | self['grade'] = 0.0 |
176 | 207 | return 0.0 |
... | ... | @@ -204,7 +235,6 @@ class QuestionRadio(Question): |
204 | 235 | 'correct': 0, |
205 | 236 | 'shuffle': True, |
206 | 237 | 'discount': True, |
207 | - 'answer': None, | |
208 | 238 | }) |
209 | 239 | |
210 | 240 | n = len(self['options']) |
... | ... | @@ -215,7 +245,7 @@ class QuestionRadio(Question): |
215 | 245 | self['correct'] = [1.0 if x==self['correct'] else 0.0 for x in range(n)] |
216 | 246 | |
217 | 247 | if len(self['correct']) != n: |
218 | - print('[ ERROR ] Options and correct mismatch in "{1}", file "{0}".'.format(self['filename'], self['ref'])) | |
248 | + qlogger.error('Options and correct mismatch in "{1}", file "{0}".'.format(self['filename'], self['ref'])) | |
219 | 249 | |
220 | 250 | # generate random permutation, e.g. [2,1,4,0,3] |
221 | 251 | # and apply to `options` and `correct` |
... | ... | @@ -266,11 +296,10 @@ class QuestionCheckbox(Question): |
266 | 296 | 'correct': [0.0] * n, # useful for questionaries |
267 | 297 | 'shuffle': True, |
268 | 298 | 'discount': True, |
269 | - 'answer': None, | |
270 | 299 | }) |
271 | 300 | |
272 | 301 | if len(self['correct']) != n: |
273 | - print('[ ERROR ] Options and correct mismatch in "{1}", file "{0}".'.format(self['filename'], self['ref'])) | |
302 | + qlogger.error('Options and correct mismatch in "{1}", file "{0}".'.format(self['filename'], self['ref'])) | |
274 | 303 | |
275 | 304 | # generate random permutation, e.g. [2,1,4,0,3] |
276 | 305 | # and apply to `options` and `correct` |
... | ... | @@ -280,7 +309,6 @@ class QuestionCheckbox(Question): |
280 | 309 | self['options'] = [ str(self['options'][i]) for i in perm ] |
281 | 310 | self['correct'] = [ float(self['correct'][i]) for i in perm ] |
282 | 311 | |
283 | - | |
284 | 312 | #------------------------------------------------------------------------ |
285 | 313 | # can return negative values for wrong answers |
286 | 314 | def correct(self): |
... | ... | @@ -325,7 +353,6 @@ class QuestionText(Question): |
325 | 353 | self.set_defaults({ |
326 | 354 | 'text': '', |
327 | 355 | 'correct': [], |
328 | - 'answer': None, | |
329 | 356 | }) |
330 | 357 | |
331 | 358 | # make sure its always a list of possible correct answers |
... | ... | @@ -365,7 +392,6 @@ class QuestionTextRegex(Question): |
365 | 392 | self.set_defaults({ |
366 | 393 | 'text': '', |
367 | 394 | 'correct': '$.^', # will always return false |
368 | - 'answer': None, | |
369 | 395 | }) |
370 | 396 | |
371 | 397 | #------------------------------------------------------------------------ |
... | ... | @@ -398,8 +424,8 @@ class QuestionTextArea(Question): |
398 | 424 | |
399 | 425 | self.set_defaults({ |
400 | 426 | 'text': '', |
401 | - 'answer': None, | |
402 | 427 | 'lines': 8, |
428 | + 'timeout': 5, # seconds | |
403 | 429 | }) |
404 | 430 | |
405 | 431 | self['correct'] = os.path.abspath(os.path.normpath(os.path.join(self['path'], self['correct']))) |
... | ... | @@ -412,37 +438,32 @@ class QuestionTextArea(Question): |
412 | 438 | self['grade'] = 0.0 |
413 | 439 | else: |
414 | 440 | # answered |
415 | - | |
416 | - # The correction program expects data from stdin and prints the result to stdout. | |
417 | - # The result should be a string that can be parsed to a float. | |
418 | 441 | try: |
419 | - p = subprocess.Popen([self['correct']], | |
442 | + p = subprocess.run([self['correct']], | |
443 | + input=self['answer'], | |
420 | 444 | stdout=subprocess.PIPE, |
421 | - stdin=subprocess.PIPE, | |
422 | - stderr=subprocess.STDOUT) | |
423 | - except FileNotFoundError as e: | |
424 | - print('[ ERROR ] Script "{0}" defined in question "{1}" of file "{2}" could not be found'.format(self['correct'], self['ref'], self['filename'])) | |
425 | - raise e | |
426 | - except PermissionError as e: | |
427 | - print('[ ERROR ] Script "{0}" has wrong permissions. Is it executable?'.format(self['correct'])) | |
428 | - raise e | |
429 | - | |
430 | - try: | |
431 | - value = p.communicate(input=self['answer'].encode('utf-8'), timeout=5)[0].decode('utf-8') | |
445 | + stderr=subprocess.STDOUT, | |
446 | + universal_newlines=True, | |
447 | + timeout=self['timeout'], | |
448 | + ) | |
449 | + except FileNotFoundError: | |
450 | + qlogger.error('Script "{0}" defined in question "{1}" of file "{2}" could not be found.'.format(self['correct'], self['ref'], self['filename'])) | |
451 | + self['grade'] = 0.0 | |
452 | + except PermissionError: | |
453 | + qlogger.error('Script "{0}" has wrong permissions. Is it executable?'.format(self['correct'])) | |
454 | + self['grade'] = 0.0 | |
432 | 455 | except subprocess.TimeoutExpired: |
433 | - p.kill() | |
434 | - value = 0.0 # student gets a zero if timout occurs | |
435 | - print('[ WARNG ] Timeout in correction script "{0}"'.format(self['correct'])) | |
456 | + qlogger.warning('Timeout {1}s exceeded while running "{0}"'.format(self['correct'], self['timeout'])) | |
457 | + self['grade'] = 0.0 # student gets a zero if timout occurs | |
458 | + else: | |
459 | + if p.returncode != 0: | |
460 | + qlogger.warning('Script "{0}" returned error code {1}.'.format(self['correct'], p.returncode)) | |
436 | 461 | |
437 | - # In case the correction program returns a non float value, we assume an error | |
438 | - # has occurred (for instance, invalid input). We just assume its the student's | |
439 | - # fault and give him Zero. | |
440 | - try: | |
441 | - self['grade'] = float(value) | |
442 | - except ValueError as e: | |
443 | - self['grade'] = 0.0 | |
444 | - print('[ ERROR ] Correction of question "{0}" returned nonfloat:\n{1}\n'.format(self['ref'], value)) | |
445 | - raise e | |
462 | + try: | |
463 | + self['grade'] = float(p.stdout) | |
464 | + except ValueError: | |
465 | + qlogger.error('Correction script of "{0}" returned nonfloat:\n{1}\n'.format(self['ref'], p.stdout)) | |
466 | + self['grade'] = 0.0 | |
446 | 467 | |
447 | 468 | return self['grade'] |
448 | 469 | |
... | ... | @@ -468,5 +489,5 @@ class QuestionInformation(Question): |
468 | 489 | #------------------------------------------------------------------------ |
469 | 490 | # can return negative values for wrong answers |
470 | 491 | def correct(self): |
471 | - self['grade'] = 0.0 # always "correct" but points should be zero! | |
492 | + self['grade'] = 1.0 # always "correct" but points should be zero! | |
472 | 493 | return self['grade'] | ... | ... |