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