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