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,10 +32,32 @@ import yaml | ||
32 | import random | 32 | import random |
33 | import re | 33 | import re |
34 | import subprocess | 34 | import subprocess |
35 | -import sys | ||
36 | import os.path | 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 | class QuestionsPool(dict): | 62 | class QuestionsPool(dict): |
41 | '''This class contains base questions read from files, but which are | 63 | '''This class contains base questions read from files, but which are |
@@ -46,43 +68,42 @@ class QuestionsPool(dict): | @@ -46,43 +68,42 @@ class QuestionsPool(dict): | ||
46 | # add some defaults if missing from sources | 68 | # add some defaults if missing from sources |
47 | for i, q in enumerate(questions): | 69 | for i, q in enumerate(questions): |
48 | if not isinstance(q, dict): | 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 | continue | 72 | continue |
51 | 73 | ||
52 | if q['ref'] in self: | 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 | # add question to the pool | 87 | # add question to the pool |
71 | self[q['ref']] = q | 88 | self[q['ref']] = q |
89 | + qlogger.debug('Added question "{0}" to the pool.'.format(q['ref'])) | ||
72 | 90 | ||
73 | #------------------------------------------------------------------------ | 91 | #------------------------------------------------------------------------ |
74 | def add_from_files(self, files, path='.'): | 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 | for filename in files: | 95 | for filename in files: |
76 | try: | 96 | try: |
77 | with open(os.path.normpath(os.path.join(path, filename)), 'r', encoding='utf-8') as f: | 97 | with open(os.path.normpath(os.path.join(path, filename)), 'r', encoding='utf-8') as f: |
78 | questions = yaml.load(f) | 98 | questions = yaml.load(f) |
79 | except(FileNotFoundError): | 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 | continue | 101 | continue |
82 | except(yaml.parser.ParserError): | 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 | continue | 104 | continue |
85 | self.add(questions, filename, path) | 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,13 +118,6 @@ def create_question(q): | ||
97 | The remaing keys depend on the type of question. | 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 | # Depending on the type of question, a different question class is | 121 | # Depending on the type of question, a different question class is |
108 | # instantiated. All these classes derive from the base class `Question`. | 122 | # instantiated. All these classes derive from the base class `Question`. |
109 | types = { | 123 | types = { |
@@ -111,28 +125,33 @@ def create_question(q): | @@ -111,28 +125,33 @@ def create_question(q): | ||
111 | 'checkbox' : QuestionCheckbox, | 125 | 'checkbox' : QuestionCheckbox, |
112 | 'text' : QuestionText, | 126 | 'text' : QuestionText, |
113 | 'text_regex': QuestionTextRegex, | 127 | 'text_regex': QuestionTextRegex, |
114 | - # 'text-regex': QuestionTextRegex, | ||
115 | - # 'regex' : QuestionTextRegex, | ||
116 | 'textarea' : QuestionTextArea, | 128 | 'textarea' : QuestionTextArea, |
117 | 'information': QuestionInformation, | 129 | 'information': QuestionInformation, |
118 | 'warning' : QuestionInformation, | 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 | # Get the correct question class for the declared question type | 142 | # Get the correct question class for the declared question type |
124 | try: | 143 | try: |
125 | questiontype = types[q['type']] | 144 | questiontype = types[q['type']] |
126 | except KeyError: | 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 | # Create question instance and return | 149 | # Create question instance and return |
131 | try: | 150 | try: |
132 | qinstance = questiontype(q) | 151 | qinstance = questiontype(q) |
133 | except: | 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 | return qinstance | 156 | return qinstance |
138 | 157 | ||
@@ -142,35 +161,47 @@ def question_generator(q): | @@ -142,35 +161,47 @@ def question_generator(q): | ||
142 | '''Run an external program that will generate a question in yaml format. | 161 | '''Run an external program that will generate a question in yaml format. |
143 | This function will return the yaml converted back to a dict.''' | 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 | script = os.path.abspath(os.path.normpath(os.path.join(q['path'], q['script']))) | 166 | script = os.path.abspath(os.path.normpath(os.path.join(q['path'], q['script']))) |
148 | try: | 167 | try: |
149 | p = subprocess.Popen([script], stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT) | 168 | p = subprocess.Popen([script], stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT) |
150 | except FileNotFoundError: | 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 | except PermissionError: | 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 | try: | 176 | try: |
158 | qyaml = p.communicate(input=q['arg'].encode('utf-8'), timeout=5)[0].decode('utf-8') | 177 | qyaml = p.communicate(input=q['arg'].encode('utf-8'), timeout=5)[0].decode('utf-8') |
159 | except subprocess.TimeoutExpired: | 178 | except subprocess.TimeoutExpired: |
160 | p.kill() | 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 | return yaml.load(qyaml) | 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 | class Question(dict): | 190 | class Question(dict): |
169 | ''' | 191 | ''' |
170 | Classes derived from this base class are meant to instantiate a question | 192 | Classes derived from this base class are meant to instantiate a question |
171 | to a student. | 193 | to a student. |
172 | Instances can shuffle options, or automatically generate questions. | 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 | def correct(self): | 205 | def correct(self): |
175 | self['grade'] = 0.0 | 206 | self['grade'] = 0.0 |
176 | return 0.0 | 207 | return 0.0 |
@@ -204,7 +235,6 @@ class QuestionRadio(Question): | @@ -204,7 +235,6 @@ class QuestionRadio(Question): | ||
204 | 'correct': 0, | 235 | 'correct': 0, |
205 | 'shuffle': True, | 236 | 'shuffle': True, |
206 | 'discount': True, | 237 | 'discount': True, |
207 | - 'answer': None, | ||
208 | }) | 238 | }) |
209 | 239 | ||
210 | n = len(self['options']) | 240 | n = len(self['options']) |
@@ -215,7 +245,7 @@ class QuestionRadio(Question): | @@ -215,7 +245,7 @@ class QuestionRadio(Question): | ||
215 | self['correct'] = [1.0 if x==self['correct'] else 0.0 for x in range(n)] | 245 | self['correct'] = [1.0 if x==self['correct'] else 0.0 for x in range(n)] |
216 | 246 | ||
217 | if len(self['correct']) != n: | 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 | # generate random permutation, e.g. [2,1,4,0,3] | 250 | # generate random permutation, e.g. [2,1,4,0,3] |
221 | # and apply to `options` and `correct` | 251 | # and apply to `options` and `correct` |
@@ -266,11 +296,10 @@ class QuestionCheckbox(Question): | @@ -266,11 +296,10 @@ class QuestionCheckbox(Question): | ||
266 | 'correct': [0.0] * n, # useful for questionaries | 296 | 'correct': [0.0] * n, # useful for questionaries |
267 | 'shuffle': True, | 297 | 'shuffle': True, |
268 | 'discount': True, | 298 | 'discount': True, |
269 | - 'answer': None, | ||
270 | }) | 299 | }) |
271 | 300 | ||
272 | if len(self['correct']) != n: | 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 | # generate random permutation, e.g. [2,1,4,0,3] | 304 | # generate random permutation, e.g. [2,1,4,0,3] |
276 | # and apply to `options` and `correct` | 305 | # and apply to `options` and `correct` |
@@ -280,7 +309,6 @@ class QuestionCheckbox(Question): | @@ -280,7 +309,6 @@ class QuestionCheckbox(Question): | ||
280 | self['options'] = [ str(self['options'][i]) for i in perm ] | 309 | self['options'] = [ str(self['options'][i]) for i in perm ] |
281 | self['correct'] = [ float(self['correct'][i]) for i in perm ] | 310 | self['correct'] = [ float(self['correct'][i]) for i in perm ] |
282 | 311 | ||
283 | - | ||
284 | #------------------------------------------------------------------------ | 312 | #------------------------------------------------------------------------ |
285 | # can return negative values for wrong answers | 313 | # can return negative values for wrong answers |
286 | def correct(self): | 314 | def correct(self): |
@@ -325,7 +353,6 @@ class QuestionText(Question): | @@ -325,7 +353,6 @@ class QuestionText(Question): | ||
325 | self.set_defaults({ | 353 | self.set_defaults({ |
326 | 'text': '', | 354 | 'text': '', |
327 | 'correct': [], | 355 | 'correct': [], |
328 | - 'answer': None, | ||
329 | }) | 356 | }) |
330 | 357 | ||
331 | # make sure its always a list of possible correct answers | 358 | # make sure its always a list of possible correct answers |
@@ -365,7 +392,6 @@ class QuestionTextRegex(Question): | @@ -365,7 +392,6 @@ class QuestionTextRegex(Question): | ||
365 | self.set_defaults({ | 392 | self.set_defaults({ |
366 | 'text': '', | 393 | 'text': '', |
367 | 'correct': '$.^', # will always return false | 394 | 'correct': '$.^', # will always return false |
368 | - 'answer': None, | ||
369 | }) | 395 | }) |
370 | 396 | ||
371 | #------------------------------------------------------------------------ | 397 | #------------------------------------------------------------------------ |
@@ -398,8 +424,8 @@ class QuestionTextArea(Question): | @@ -398,8 +424,8 @@ class QuestionTextArea(Question): | ||
398 | 424 | ||
399 | self.set_defaults({ | 425 | self.set_defaults({ |
400 | 'text': '', | 426 | 'text': '', |
401 | - 'answer': None, | ||
402 | 'lines': 8, | 427 | 'lines': 8, |
428 | + 'timeout': 5, # seconds | ||
403 | }) | 429 | }) |
404 | 430 | ||
405 | self['correct'] = os.path.abspath(os.path.normpath(os.path.join(self['path'], self['correct']))) | 431 | self['correct'] = os.path.abspath(os.path.normpath(os.path.join(self['path'], self['correct']))) |
@@ -412,37 +438,32 @@ class QuestionTextArea(Question): | @@ -412,37 +438,32 @@ class QuestionTextArea(Question): | ||
412 | self['grade'] = 0.0 | 438 | self['grade'] = 0.0 |
413 | else: | 439 | else: |
414 | # answered | 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 | try: | 441 | try: |
419 | - p = subprocess.Popen([self['correct']], | 442 | + p = subprocess.run([self['correct']], |
443 | + input=self['answer'], | ||
420 | stdout=subprocess.PIPE, | 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 | except subprocess.TimeoutExpired: | 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 | return self['grade'] | 468 | return self['grade'] |
448 | 469 | ||
@@ -468,5 +489,5 @@ class QuestionInformation(Question): | @@ -468,5 +489,5 @@ class QuestionInformation(Question): | ||
468 | #------------------------------------------------------------------------ | 489 | #------------------------------------------------------------------------ |
469 | # can return negative values for wrong answers | 490 | # can return negative values for wrong answers |
470 | def correct(self): | 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 | return self['grade'] | 493 | return self['grade'] |