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'] |