Commit cfeff39733053a95dc07e65ec20705d324db67ff

Authored by Miguel Barão
1 parent acf93255
Exists in master and in 1 other branch dev

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