Commit aec96ca5e7448e864d754065982a8faa40e5790c

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

fix many pylint warnings

fix possible submit/autosubmit regression
demo/demo.yaml
@@ -21,7 +21,7 @@ title: Teste de demonstração (tutorial) @@ -21,7 +21,7 @@ title: Teste de demonstração (tutorial)
21 21
22 # Duration in minutes. 22 # Duration in minutes.
23 # (0 or undefined means infinite time) 23 # (0 or undefined means infinite time)
24 -duration: 10 24 +duration: 2
25 autosubmit: true 25 autosubmit: true
26 26
27 # Show points for each question, scale 0-20. 27 # Show points for each question, scale 0-20.
perguntations/questions.py
  1 +'''
  2 +Classes the implement several types of questions.
  3 +'''
  4 +
1 5
2 # python standard library 6 # python standard library
3 import asyncio 7 import asyncio
  8 +import logging
4 import random 9 import random
5 import re 10 import re
6 from os import path 11 from os import path
7 -import logging  
8 from typing import Any, Dict, NewType 12 from typing import Any, Dict, NewType
9 import uuid 13 import uuid
10 14
@@ -19,7 +23,7 @@ QDict = NewType('QDict', Dict[str, Any]) @@ -19,7 +23,7 @@ QDict = NewType('QDict', Dict[str, Any])
19 23
20 24
21 class QuestionException(Exception): 25 class QuestionException(Exception):
22 - pass 26 + '''Exceptions raised in this module'''
23 27
24 28
25 # ============================================================================ 29 # ============================================================================
@@ -45,16 +49,18 @@ class Question(dict): @@ -45,16 +49,18 @@ class Question(dict):
45 })) 49 }))
46 50
47 def correct(self) -> None: 51 def correct(self) -> None:
  52 + '''default correction (synchronous version)'''
48 self['comments'] = '' 53 self['comments'] = ''
49 self['grade'] = 0.0 54 self['grade'] = 0.0
50 55
51 async def correct_async(self) -> None: 56 async def correct_async(self) -> None:
  57 + '''default correction (async version)'''
52 self.correct() 58 self.correct()
53 59
54 - def set_defaults(self, d: QDict) -> None:  
55 - 'Add k:v pairs from default dict d for nonexistent keys'  
56 - for k, v in d.items():  
57 - self.setdefault(k, v) 60 + def set_defaults(self, qdict: QDict) -> None:
  61 + '''Add k:v pairs from default dict d for nonexistent keys'''
  62 + for k, val in qdict.items():
  63 + self.setdefault(k, val)
58 64
59 65
60 # ============================================================================ 66 # ============================================================================
@@ -74,31 +80,31 @@ class QuestionRadio(Question): @@ -74,31 +80,31 @@ class QuestionRadio(Question):
74 def __init__(self, q: QDict) -> None: 80 def __init__(self, q: QDict) -> None:
75 super().__init__(q) 81 super().__init__(q)
76 82
77 - n = len(self['options']) 83 + nopts = len(self['options'])
78 84
79 self.set_defaults(QDict({ 85 self.set_defaults(QDict({
80 'text': '', 86 'text': '',
81 'correct': 0, 87 'correct': 0,
82 'shuffle': True, 88 'shuffle': True,
83 'discount': True, 89 'discount': True,
84 - 'max_tries': (n + 3) // 4 # 1 try for each 4 options 90 + 'max_tries': (nopts + 3) // 4 # 1 try for each 4 options
85 })) 91 }))
86 92
87 # check correct bounds and convert int to list, 93 # check correct bounds and convert int to list,
88 # e.g. correct: 2 --> correct: [0,0,1,0,0] 94 # e.g. correct: 2 --> correct: [0,0,1,0,0]
89 if isinstance(self['correct'], int): 95 if isinstance(self['correct'], int):
90 - if not (0 <= self['correct'] < n):  
91 - msg = (f'Correct option not in range 0..{n-1} in ' 96 + if not 0 <= self['correct'] < nopts:
  97 + msg = (f'Correct option not in range 0..{nopts-1} in '
92 f'"{self["ref"]}"') 98 f'"{self["ref"]}"')
93 raise QuestionException(msg) 99 raise QuestionException(msg)
94 100
95 self['correct'] = [1.0 if x == self['correct'] else 0.0 101 self['correct'] = [1.0 if x == self['correct'] else 0.0
96 - for x in range(n)] 102 + for x in range(nopts)]
97 103
98 elif isinstance(self['correct'], list): 104 elif isinstance(self['correct'], list):
99 # must match number of options 105 # must match number of options
100 - if len(self['correct']) != n:  
101 - msg = (f'Incompatible sizes: {n} options vs ' 106 + if len(self['correct']) != nopts:
  107 + msg = (f'Incompatible sizes: {nopts} options vs '
102 f'{len(self["correct"])} correct in "{self["ref"]}"') 108 f'{len(self["correct"])} correct in "{self["ref"]}"')
103 raise QuestionException(msg) 109 raise QuestionException(msg)
104 # make sure is a list of floats 110 # make sure is a list of floats
@@ -126,16 +132,16 @@ class QuestionRadio(Question): @@ -126,16 +132,16 @@ class QuestionRadio(Question):
126 # otherwise, select 1 correct and choose a few wrong ones 132 # otherwise, select 1 correct and choose a few wrong ones
127 if self['shuffle']: 133 if self['shuffle']:
128 # lists with indices of right and wrong options 134 # lists with indices of right and wrong options
129 - right = [i for i in range(n) if self['correct'][i] >= 1]  
130 - wrong = [i for i in range(n) if self['correct'][i] < 1] 135 + right = [i for i in range(nopts) if self['correct'][i] >= 1]
  136 + wrong = [i for i in range(nopts) if self['correct'][i] < 1]
131 137
132 self.set_defaults(QDict({'choose': 1+len(wrong)})) 138 self.set_defaults(QDict({'choose': 1+len(wrong)}))
133 139
134 # try to choose 1 correct option 140 # try to choose 1 correct option
135 if right: 141 if right:
136 - r = random.choice(right)  
137 - options = [self['options'][r]]  
138 - correct = [self['correct'][r]] 142 + sel = random.choice(right)
  143 + options = [self['options'][sel]]
  144 + correct = [self['correct'][sel]]
139 else: 145 else:
140 options = [] 146 options = []
141 correct = [] 147 correct = []
@@ -157,15 +163,15 @@ class QuestionRadio(Question): @@ -157,15 +163,15 @@ class QuestionRadio(Question):
157 super().correct() 163 super().correct()
158 164
159 if self['answer'] is not None: 165 if self['answer'] is not None:
160 - x = self['correct'][int(self['answer'])] # get grade of the answer  
161 - n = len(self['options'])  
162 - x_aver = sum(self['correct']) / n # expected value of grade 166 + grade = self['correct'][int(self['answer'])] # grade of the answer
  167 + nopts = len(self['options'])
  168 + grade_aver = sum(self['correct']) / nopts # expected value
163 169
164 # note: there are no numerical errors when summing 1.0s so the 170 # note: there are no numerical errors when summing 1.0s so the
165 # x_aver can be exactly 1.0 if all options are right 171 # x_aver can be exactly 1.0 if all options are right
166 - if self['discount'] and x_aver != 1.0:  
167 - x = (x - x_aver) / (1.0 - x_aver)  
168 - self['grade'] = x 172 + if self['discount'] and grade_aver != 1.0:
  173 + grade = (grade - grade_aver) / (1.0 - grade_aver)
  174 + self['grade'] = grade
169 175
170 176
171 # ============================================================================ 177 # ============================================================================
@@ -185,16 +191,16 @@ class QuestionCheckbox(Question): @@ -185,16 +191,16 @@ class QuestionCheckbox(Question):
185 def __init__(self, q: QDict) -> None: 191 def __init__(self, q: QDict) -> None:
186 super().__init__(q) 192 super().__init__(q)
187 193
188 - n = len(self['options']) 194 + nopts = len(self['options'])
189 195
190 # set defaults if missing 196 # set defaults if missing
191 self.set_defaults(QDict({ 197 self.set_defaults(QDict({
192 'text': '', 198 'text': '',
193 - 'correct': [1.0] * n, # Using 0.0 breaks (right, wrong) options 199 + 'correct': [1.0] * nopts, # Using 0.0 breaks (right, wrong)
194 'shuffle': True, 200 'shuffle': True,
195 'discount': True, 201 'discount': True,
196 - 'choose': n, # number of options  
197 - 'max_tries': max(1, min(n - 1, 3)) 202 + 'choose': nopts, # number of options
  203 + 'max_tries': max(1, min(nopts - 1, 3))
198 })) 204 }))
199 205
200 # must be a list of numbers 206 # must be a list of numbers
@@ -203,8 +209,8 @@ class QuestionCheckbox(Question): @@ -203,8 +209,8 @@ class QuestionCheckbox(Question):
203 raise QuestionException(msg) 209 raise QuestionException(msg)
204 210
205 # must match number of options 211 # must match number of options
206 - if len(self['correct']) != n:  
207 - msg = (f'Incompatible sizes: {n} options vs ' 212 + if len(self['correct']) != nopts:
  213 + msg = (f'Incompatible sizes: {nopts} options vs '
208 f'{len(self["correct"])} correct in "{self["ref"]}"') 214 f'{len(self["correct"])} correct in "{self["ref"]}"')
209 raise QuestionException(msg) 215 raise QuestionException(msg)
210 216
@@ -230,7 +236,7 @@ class QuestionCheckbox(Question): @@ -230,7 +236,7 @@ class QuestionCheckbox(Question):
230 logger.warning(msg2) 236 logger.warning(msg2)
231 logger.warning(msg3) 237 logger.warning(msg3)
232 logger.warning(msg4) 238 logger.warning(msg4)
233 - logger.warning(f'please fix "{self["ref"]}"') 239 + logger.warning('please fix "%s"', self["ref"])
234 240
235 # normalize to [0,1] 241 # normalize to [0,1]
236 self['correct'] = [(x+1)/2 for x in self['correct']] 242 self['correct'] = [(x+1)/2 for x in self['correct']]
@@ -238,20 +244,19 @@ class QuestionCheckbox(Question): @@ -238,20 +244,19 @@ class QuestionCheckbox(Question):
238 # if an option is a list of (right, wrong), pick one 244 # if an option is a list of (right, wrong), pick one
239 options = [] 245 options = []
240 correct = [] 246 correct = []
241 - for o, c in zip(self['options'], self['correct']):  
242 - if isinstance(o, list):  
243 - r = random.randint(0, 1)  
244 - o = o[r]  
245 - if r == 1:  
246 - # c = -c  
247 - c = 1.0 - c  
248 - options.append(str(o))  
249 - correct.append(c) 247 + for option, corr in zip(self['options'], self['correct']):
  248 + if isinstance(option, list):
  249 + sel = random.randint(0, 1)
  250 + option = option[sel]
  251 + if sel == 1:
  252 + corr = 1.0 - corr
  253 + options.append(str(option))
  254 + correct.append(corr)
250 255
251 # generate random permutation, e.g. [2,1,4,0,3] 256 # generate random permutation, e.g. [2,1,4,0,3]
252 # and apply to `options` and `correct` 257 # and apply to `options` and `correct`
253 if self['shuffle']: 258 if self['shuffle']:
254 - perm = random.sample(range(n), k=self['choose']) 259 + perm = random.sample(range(nopts), k=self['choose'])
255 self['options'] = [options[i] for i in perm] 260 self['options'] = [options[i] for i in perm]
256 self['correct'] = [correct[i] for i in perm] 261 self['correct'] = [correct[i] for i in perm]
257 else: 262 else:
@@ -264,18 +269,18 @@ class QuestionCheckbox(Question): @@ -264,18 +269,18 @@ class QuestionCheckbox(Question):
264 super().correct() 269 super().correct()
265 270
266 if self['answer'] is not None: 271 if self['answer'] is not None:
267 - x = 0.0 272 + grade = 0.0
268 if self['discount']: 273 if self['discount']:
269 sum_abs = sum(abs(2*p-1) for p in self['correct']) 274 sum_abs = sum(abs(2*p-1) for p in self['correct'])
270 - for i, p in enumerate(self['correct']):  
271 - x += 2*p-1 if str(i) in self['answer'] else 1-2*p 275 + for i, pts in enumerate(self['correct']):
  276 + grade += 2*pts-1 if str(i) in self['answer'] else 1-2*pts
272 else: 277 else:
273 sum_abs = sum(abs(p) for p in self['correct']) 278 sum_abs = sum(abs(p) for p in self['correct'])
274 - for i, p in enumerate(self['correct']):  
275 - x += p if str(i) in self['answer'] else 0.0 279 + for i, pts in enumerate(self['correct']):
  280 + grade += pts if str(i) in self['answer'] else 0.0
276 281
277 try: 282 try:
278 - self['grade'] = x / sum_abs 283 + self['grade'] = grade / sum_abs
279 except ZeroDivisionError: 284 except ZeroDivisionError:
280 self['grade'] = 1.0 # limit p->0 285 self['grade'] = 1.0 # limit p->0
281 286
@@ -306,33 +311,35 @@ class QuestionText(Question): @@ -306,33 +311,35 @@ class QuestionText(Question):
306 # make sure all elements of the list are strings 311 # make sure all elements of the list are strings
307 self['correct'] = [str(a) for a in self['correct']] 312 self['correct'] = [str(a) for a in self['correct']]
308 313
309 - for f in self['transform']:  
310 - if f not in ('remove_space', 'trim', 'normalize_space', 'lower',  
311 - 'upper'):  
312 - msg = (f'Unknown transform "{f}" in "{self["ref"]}"') 314 + for transform in self['transform']:
  315 + if transform not in ('remove_space', 'trim', 'normalize_space',
  316 + 'lower', 'upper'):
  317 + msg = (f'Unknown transform "{transform}" in "{self["ref"]}"')
313 raise QuestionException(msg) 318 raise QuestionException(msg)
314 319
315 # check if answers are invariant with respect to the transforms 320 # check if answers are invariant with respect to the transforms
316 if any(c != self.transform(c) for c in self['correct']): 321 if any(c != self.transform(c) for c in self['correct']):
317 - logger.warning(f'in "{self["ref"]}", correct answers are not '  
318 - 'invariant wrt transformations => never correct') 322 + logger.warning('in "%s", correct answers are not invariant wrt '
  323 + 'transformations => never correct', self["ref"])
319 324
320 # ------------------------------------------------------------------------ 325 # ------------------------------------------------------------------------
321 - # apply optional filters to the answer  
322 def transform(self, ans): 326 def transform(self, ans):
323 - for f in self['transform']:  
324 - if f == 'remove_space': # removes all spaces 327 + '''apply optional filters to the answer'''
  328 +
  329 + for transform in self['transform']:
  330 + if transform == 'remove_space': # removes all spaces
325 ans = ans.replace(' ', '') 331 ans = ans.replace(' ', '')
326 - elif f == 'trim': # removes spaces around 332 + elif transform == 'trim': # removes spaces around
327 ans = ans.strip() 333 ans = ans.strip()
328 - elif f == 'normalize_space': # replaces multiple spaces by one 334 + elif transform == 'normalize_space': # replaces multiple spaces by one
329 ans = re.sub(r'\s+', ' ', ans.strip()) 335 ans = re.sub(r'\s+', ' ', ans.strip())
330 - elif f == 'lower': # convert to lowercase 336 + elif transform == 'lower': # convert to lowercase
331 ans = ans.lower() 337 ans = ans.lower()
332 - elif f == 'upper': # convert to uppercase 338 + elif transform == 'upper': # convert to uppercase
333 ans = ans.upper() 339 ans = ans.upper()
334 else: 340 else:
335 - logger.warning(f'in "{self["ref"]}", unknown transform "{f}"') 341 + logger.warning('in "%s", unknown transform "%s"',
  342 + self["ref"], transform)
336 return ans 343 return ans
337 344
338 # ------------------------------------------------------------------------ 345 # ------------------------------------------------------------------------
@@ -381,14 +388,14 @@ class QuestionTextRegex(Question): @@ -381,14 +388,14 @@ class QuestionTextRegex(Question):
381 super().correct() 388 super().correct()
382 if self['answer'] is not None: 389 if self['answer'] is not None:
383 self['grade'] = 0.0 390 self['grade'] = 0.0
384 - for r in self['correct']: 391 + for regex in self['correct']:
385 try: 392 try:
386 - if r.match(self['answer']): 393 + if regex.match(self['answer']):
387 self['grade'] = 1.0 394 self['grade'] = 1.0
388 return 395 return
389 except TypeError: 396 except TypeError:
390 - logger.error(f'While matching regex {r.pattern} with '  
391 - f'answer "{self["answer"]}".') 397 + logger.error('While matching regex %s with answer "%s".',
  398 + regex.pattern, self["answer"])
392 399
393 400
394 # ============================================================================ 401 # ============================================================================
@@ -485,21 +492,21 @@ class QuestionTextArea(Question): @@ -485,21 +492,21 @@ class QuestionTextArea(Question):
485 ) 492 )
486 493
487 if out is None: 494 if out is None:
488 - logger.warning(f'No grade after running "{self["correct"]}".') 495 + logger.warning('No grade after running "%s".', self["correct"])
489 self['grade'] = 0.0 496 self['grade'] = 0.0
490 elif isinstance(out, dict): 497 elif isinstance(out, dict):
491 self['comments'] = out.get('comments', '') 498 self['comments'] = out.get('comments', '')
492 try: 499 try:
493 self['grade'] = float(out['grade']) 500 self['grade'] = float(out['grade'])
494 except ValueError: 501 except ValueError:
495 - logger.error(f'Output error in "{self["correct"]}".') 502 + logger.error('Output error in "%s".', self["correct"])
496 except KeyError: 503 except KeyError:
497 - logger.error(f'No grade in "{self["correct"]}".') 504 + logger.error('No grade in "%s".', self["correct"])
498 else: 505 else:
499 try: 506 try:
500 self['grade'] = float(out) 507 self['grade'] = float(out)
501 except (TypeError, ValueError): 508 except (TypeError, ValueError):
502 - logger.error(f'Invalid grade in "{self["correct"]}".') 509 + logger.error('Invalid grade in "%s".', self["correct"])
503 510
504 # ------------------------------------------------------------------------ 511 # ------------------------------------------------------------------------
505 async def correct_async(self) -> None: 512 async def correct_async(self) -> None:
@@ -514,25 +521,30 @@ class QuestionTextArea(Question): @@ -514,25 +521,30 @@ class QuestionTextArea(Question):
514 ) 521 )
515 522
516 if out is None: 523 if out is None:
517 - logger.warning(f'No grade after running "{self["correct"]}".') 524 + logger.warning('No grade after running "%s".', self["correct"])
518 self['grade'] = 0.0 525 self['grade'] = 0.0
519 elif isinstance(out, dict): 526 elif isinstance(out, dict):
520 self['comments'] = out.get('comments', '') 527 self['comments'] = out.get('comments', '')
521 try: 528 try:
522 self['grade'] = float(out['grade']) 529 self['grade'] = float(out['grade'])
523 except ValueError: 530 except ValueError:
524 - logger.error(f'Output error in "{self["correct"]}".') 531 + logger.error('Output error in "%s".', self["correct"])
525 except KeyError: 532 except KeyError:
526 - logger.error(f'No grade in "{self["correct"]}".') 533 + logger.error('No grade in "%s".', self["correct"])
527 else: 534 else:
528 try: 535 try:
529 self['grade'] = float(out) 536 self['grade'] = float(out)
530 except (TypeError, ValueError): 537 except (TypeError, ValueError):
531 - logger.error(f'Invalid grade in "{self["correct"]}".') 538 + logger.error('Invalid grade in "%s".', self["correct"])
532 539
533 540
534 # ============================================================================ 541 # ============================================================================
535 class QuestionInformation(Question): 542 class QuestionInformation(Question):
  543 + '''
  544 + Not really a question, just an information panel.
  545 + The correction is always right.
  546 + '''
  547 +
536 # ------------------------------------------------------------------------ 548 # ------------------------------------------------------------------------
537 def __init__(self, q: QDict) -> None: 549 def __init__(self, q: QDict) -> None:
538 super().__init__(q) 550 super().__init__(q)
@@ -547,38 +559,38 @@ class QuestionInformation(Question): @@ -547,38 +559,38 @@ class QuestionInformation(Question):
547 559
548 560
549 # ============================================================================ 561 # ============================================================================
550 -#  
551 -# QFactory is a class that can generate question instances, e.g. by shuffling  
552 -# options, running a script to generate the question, etc.  
553 -#  
554 -# To generate an instance of a question we use the method generate().  
555 -# It returns a question instance of the correct class.  
556 -# There is also an asynchronous version called gen_async(). This version is  
557 -# synchronous for all question types (radio, checkbox, etc) except for  
558 -# generator types which run asynchronously.  
559 -#  
560 -# Example:  
561 -#  
562 -# # make a factory for a question  
563 -# qfactory = QFactory({  
564 -# 'type': 'radio',  
565 -# 'text': 'Choose one',  
566 -# 'options': ['a', 'b']  
567 -# })  
568 -#  
569 -# # generate synchronously  
570 -# question = qfactory.generate()  
571 -#  
572 -# # generate asynchronously  
573 -# question = await qfactory.gen_async()  
574 -#  
575 -# # answer one question and correct it  
576 -# question['answer'] = 42 # set answer  
577 -# question.correct() # correct answer  
578 -# grade = question['grade'] # get grade  
579 -#  
580 -# ============================================================================  
581 -class QFactory(object): 562 +class QFactory():
  563 + '''
  564 + QFactory is a class that can generate question instances, e.g. by shuffling
  565 + options, running a script to generate the question, etc.
  566 +
  567 + To generate an instance of a question we use the method generate().
  568 + It returns a question instance of the correct class.
  569 + There is also an asynchronous version called gen_async(). This version is
  570 + synchronous for all question types (radio, checkbox, etc) except for
  571 + generator types which run asynchronously.
  572 +
  573 + Example:
  574 +
  575 + # make a factory for a question
  576 + qfactory = QFactory({
  577 + 'type': 'radio',
  578 + 'text': 'Choose one',
  579 + 'options': ['a', 'b']
  580 + })
  581 +
  582 + # generate synchronously
  583 + question = qfactory.generate()
  584 +
  585 + # generate asynchronously
  586 + question = await qfactory.gen_async()
  587 +
  588 + # answer one question and correct it
  589 + question['answer'] = 42 # set answer
  590 + question.correct() # correct answer
  591 + grade = question['grade'] # get grade
  592 + '''
  593 +
582 # Depending on the type of question, a different question class will be 594 # Depending on the type of question, a different question class will be
583 # instantiated. All these classes derive from the base class `Question`. 595 # instantiated. All these classes derive from the base class `Question`.
584 _types = { 596 _types = {
@@ -599,44 +611,48 @@ class QFactory(object): @@ -599,44 +611,48 @@ class QFactory(object):
599 self.question = qdict 611 self.question = qdict
600 612
601 # ------------------------------------------------------------------------ 613 # ------------------------------------------------------------------------
602 - # generates a question instance of QuestionRadio, QuestionCheckbox, ...,  
603 - # which is a descendent of base class Question.  
604 - # ------------------------------------------------------------------------  
605 async def gen_async(self) -> Question: 614 async def gen_async(self) -> Question:
606 - logger.debug(f'generating {self.question["ref"]}...') 615 + '''
  616 + generates a question instance of QuestionRadio, QuestionCheckbox, ...,
  617 + which is a descendent of base class Question.
  618 + '''
  619 +
  620 + logger.debug('generating %s...', self.question["ref"])
607 # Shallow copy so that script generated questions will not replace 621 # Shallow copy so that script generated questions will not replace
608 # the original generators 622 # the original generators
609 - q = self.question.copy()  
610 - q['qid'] = str(uuid.uuid4()) # unique for each generated question 623 + question = self.question.copy()
  624 + question['qid'] = str(uuid.uuid4()) # unique for each question
611 625
612 # If question is of generator type, an external program will be run 626 # If question is of generator type, an external program will be run
613 # which will print a valid question in yaml format to stdout. This 627 # which will print a valid question in yaml format to stdout. This
614 # output is then yaml parsed into a dictionary `q`. 628 # output is then yaml parsed into a dictionary `q`.
615 - if q['type'] == 'generator':  
616 - logger.debug(f' \\_ Running "{q["script"]}".')  
617 - q.setdefault('args', [])  
618 - q.setdefault('stdin', '')  
619 - script = path.join(q['path'], q['script'])  
620 - out = await run_script_async(script=script, args=q['args'],  
621 - stdin=q['stdin'])  
622 - q.update(out) 629 + if question['type'] == 'generator':
  630 + logger.debug(' \\_ Running "%s".', question["script"])
  631 + question.setdefault('args', [])
  632 + question.setdefault('stdin', '')
  633 + script = path.join(question['path'], question['script'])
  634 + out = await run_script_async(script=script, args=question['args'],
  635 + stdin=question['stdin'])
  636 + question.update(out)
623 637
624 # Get class for this question type 638 # Get class for this question type
625 try: 639 try:
626 - qclass = self._types[q['type']] 640 + qclass = self._types[question['type']]
627 except KeyError: 641 except KeyError:
628 - logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"') 642 + logger.error('Invalid type "%s" in "%s"',
  643 + question["type"], question["ref"])
629 raise 644 raise
630 645
631 # Finally create an instance of Question() 646 # Finally create an instance of Question()
632 try: 647 try:
633 - qinstance = qclass(QDict(q))  
634 - except QuestionException as e:  
635 - # logger.error(e)  
636 - raise e 648 + qinstance = qclass(QDict(question))
  649 + except QuestionException:
  650 + logger.error('Error generating question %s', question['ref'])
  651 + raise
637 652
638 return qinstance 653 return qinstance
639 654
640 # ------------------------------------------------------------------------ 655 # ------------------------------------------------------------------------
641 def generate(self) -> Question: 656 def generate(self) -> Question:
  657 + '''generate question (synchronous version)'''
642 return asyncio.get_event_loop().run_until_complete(self.gen_async()) 658 return asyncio.get_event_loop().run_until_complete(self.gen_async())
perguntations/templates/test.html
@@ -41,7 +41,7 @@ @@ -41,7 +41,7 @@
41 41
42 </head> 42 </head>
43 <!-- ===================================================================== --> 43 <!-- ===================================================================== -->
44 -<body id="test"> 44 +<body>
45 <!-- ===================================================================== --> 45 <!-- ===================================================================== -->
46 46
47 <nav id="navbar" class="navbar navbar-expand-sm fixed-top navbar-dark bg-dark"> 47 <nav id="navbar" class="navbar navbar-expand-sm fixed-top navbar-dark bg-dark">
@@ -157,37 +157,32 @@ @@ -157,37 +157,32 @@
157 $("#clock").html("+\u221e"); 157 $("#clock").html("+\u221e");
158 {% else %} 158 {% else %}
159 159
160 -  
161 -  
162 - // Update the count down every 1 second  
163 - var x = setInterval(function() {  
164 - var now = new Date().getTime();  
165 - var distance = finishtime - now;  
166 -  
167 - // Time calculations for days, hours, minutes and seconds  
168 - var minutes = Math.floor((distance / (1000 * 60)));  
169 - var seconds = Math.floor((distance % (1000 * 60)) / 1000);  
170 -  
171 - if (distance >= 1000*60) {  
172 - $("#clock").html(minutes + ":" + (seconds<10?'0':'') +seconds);  
173 - }  
174 - else if (distance >= 0) {  
175 - $("#navbar").removeClass('bg-dark').addClass("bg-danger");  
176 - $("#clock").html(seconds);  
177 - }  
178 - else {  
179 - $("#clock").html(0);  
180 - {% if t['autosubmit'] %}  
181 - $("#test").submit();  
182 - {% end %}  
183 - }  
184 - }, 1000); 160 + // Update the count down every 1 second
  161 + var x = setInterval(function() {
  162 + var now = new Date().getTime();
  163 + var distance = finishtime - now;
  164 +
  165 + // Time calculations for days, hours, minutes and seconds
  166 + var minutes = Math.floor((distance / (1000 * 60)));
  167 + var seconds = Math.floor((distance % (1000 * 60)) / 1000);
  168 +
  169 + if (distance >= 1000*60) {
  170 + $("#clock").html(minutes + ":" + (seconds<10?'0':'') +seconds);
  171 + }
  172 + else if (distance >= 0) {
  173 + $("#navbar").removeClass('bg-dark').addClass("bg-danger");
  174 + $("#clock").html(seconds);
  175 + }
  176 + else {
  177 + $("#clock").html(0);
  178 + {% if t['autosubmit'] %}
  179 + $("#test").submit();
  180 + {% end %}
  181 + }
  182 + }, 1000);
185 183
186 {% end %} 184 {% end %}
187 185
188 </script> 186 </script>
189 -  
190 -  
191 -  
192 </body> 187 </body>
193 </html> 188 </html>
perguntations/test.py
1 - 1 +'''
  2 +TestFactory - generates tests for students
  3 +Test - instances of this class are individual tests
  4 +'''
2 5
3 # python standard library 6 # python standard library
4 from os import path 7 from os import path
@@ -7,7 +10,7 @@ from datetime import datetime @@ -7,7 +10,7 @@ from datetime import datetime
7 import logging 10 import logging
8 11
9 # this project 12 # this project
10 -from perguntations.questions import QFactory 13 +from perguntations.questions import QFactory, QuestionException
11 from perguntations.tools import load_yaml 14 from perguntations.tools import load_yaml
12 15
13 # Logger configuration 16 # Logger configuration
@@ -16,7 +19,7 @@ logger = logging.getLogger(__name__) @@ -16,7 +19,7 @@ logger = logging.getLogger(__name__)
16 19
17 # ============================================================================ 20 # ============================================================================
18 class TestFactoryException(Exception): 21 class TestFactoryException(Exception):
19 - pass 22 + '''exception raised in this module'''
20 23
21 24
22 # ============================================================================ 25 # ============================================================================
@@ -27,7 +30,6 @@ class TestFactory(dict): @@ -27,7 +30,6 @@ class TestFactory(dict):
27 instances of TestFactory(), one for each test. 30 instances of TestFactory(), one for each test.
28 ''' 31 '''
29 32
30 -  
31 # ------------------------------------------------------------------------ 33 # ------------------------------------------------------------------------
32 def __init__(self, conf): 34 def __init__(self, conf):
33 ''' 35 '''
@@ -120,30 +122,29 @@ class TestFactory(dict): @@ -120,30 +122,29 @@ class TestFactory(dict):
120 if qmissing: 122 if qmissing:
121 raise TestFactoryException(f'Could not find questions {qmissing}.') 123 raise TestFactoryException(f'Could not find questions {qmissing}.')
122 124
123 - # ------------------------------------------------------------------------  
124 - def sanity_checks(self):  
125 - '''  
126 - Checks for valid keys and sets default values.  
127 - Also checks if some files and directories exist  
128 - '''  
129 125
130 - # --- ref 126 + # ------------------------------------------------------------------------
  127 + def check_missing_ref(self):
  128 + '''Test must have a `ref`'''
131 if 'ref' not in self: 129 if 'ref' not in self:
132 raise TestFactoryException('Missing "ref" in configuration!') 130 raise TestFactoryException('Missing "ref" in configuration!')
133 131
134 - # --- check database 132 + def check_missing_database(self):
  133 + '''Test must have a database'''
135 if 'database' not in self: 134 if 'database' not in self:
136 raise TestFactoryException('Missing "database" in configuration') 135 raise TestFactoryException('Missing "database" in configuration')
137 if not path.isfile(path.expanduser(self['database'])): 136 if not path.isfile(path.expanduser(self['database'])):
138 msg = f'Database "{self["database"]}" not found!' 137 msg = f'Database "{self["database"]}" not found!'
139 raise TestFactoryException(msg) 138 raise TestFactoryException(msg)
140 139
141 - # --- check answers_dir 140 + def check_missing_answers_directory(self):
  141 + '''Test must have a answers directory'''
142 if 'answers_dir' not in self: 142 if 'answers_dir' not in self:
143 msg = 'Missing "answers_dir" in configuration' 143 msg = 'Missing "answers_dir" in configuration'
144 raise TestFactoryException(msg) 144 raise TestFactoryException(msg)
145 145
146 - # --- check if answers_dir is a writable directory 146 + def check_answers_directory_writable(self):
  147 + '''Answers directory must be writable'''
147 testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME') 148 testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME')
148 try: 149 try:
149 with open(testfile, 'w') as file: 150 with open(testfile, 'w') as file:
@@ -152,82 +153,105 @@ class TestFactory(dict): @@ -152,82 +153,105 @@ class TestFactory(dict):
152 msg = f'Cannot write answers to directory "{self["answers_dir"]}"' 153 msg = f'Cannot write answers to directory "{self["answers_dir"]}"'
153 raise TestFactoryException(msg) 154 raise TestFactoryException(msg)
154 155
155 - # --- check title  
156 - if not self['title']:  
157 - logger.warning('Undefined title!')  
158 -  
159 - if self['scale_points']:  
160 - smin, smax = self["scale_min"], self["scale_max"]  
161 - logger.info('Grades will be scaled to [%g, %g]', smin, smax)  
162 - else:  
163 - logger.info('Grades are just the sum of points defined for the '  
164 - 'questions, not being scaled.')  
165 -  
166 - # --- questions_dir 156 + def check_questions_directory(self):
  157 + '''Check if questions directory is missing or not accessible.'''
167 if 'questions_dir' not in self: 158 if 'questions_dir' not in self:
168 - logger.warning(f'Missing "questions_dir". '  
169 - f'Using "{path.abspath(path.curdir)}"') 159 + logger.warning('Missing "questions_dir". Using "%s"',
  160 + path.abspath(path.curdir))
170 self['questions_dir'] = path.curdir 161 self['questions_dir'] = path.curdir
171 elif not path.isdir(path.expanduser(self['questions_dir'])): 162 elif not path.isdir(path.expanduser(self['questions_dir'])):
172 raise TestFactoryException(f'Can\'t find questions directory ' 163 raise TestFactoryException(f'Can\'t find questions directory '
173 f'"{self["questions_dir"]}"') 164 f'"{self["questions_dir"]}"')
174 165
175 - # --- files 166 + def check_import_files(self):
  167 + '''Check if there are files to import (with questions)'''
176 if 'files' not in self: 168 if 'files' not in self:
177 msg = ('Missing "files" in configuration with the list of ' 169 msg = ('Missing "files" in configuration with the list of '
178 'question files to import!') 170 'question files to import!')
179 raise TestFactoryException(msg) 171 raise TestFactoryException(msg)
180 - # FIXME allow no files and define the questions directly in the test  
181 172
182 if isinstance(self['files'], str): 173 if isinstance(self['files'], str):
183 self['files'] = [self['files']] 174 self['files'] = [self['files']]
184 175
185 - # --- questions 176 + def check_question_list(self):
  177 + '''normalize question list'''
186 if 'questions' not in self: 178 if 'questions' not in self:
187 - raise TestFactoryException(f'Missing "questions" in Configuration') 179 + raise TestFactoryException('Missing "questions" in configuration')
188 180
189 - for i, q in enumerate(self['questions']): 181 + for i, question in enumerate(self['questions']):
190 # normalize question to a dict and ref to a list of references 182 # normalize question to a dict and ref to a list of references
191 - if isinstance(q, str): # e.g., - some_ref  
192 - q = {'ref': [q]} # becomes - ref: [some_ref]  
193 - elif isinstance(q, dict) and isinstance(q['ref'], str):  
194 - q['ref'] = [q['ref']]  
195 - elif isinstance(q, list):  
196 - q = {'ref': [str(a) for a in q]} 183 + if isinstance(question, str): # e.g., - some_ref
  184 + question = {'ref': [question]} # becomes - ref: [some_ref]
  185 + elif isinstance(question, dict) and isinstance(question['ref'], str):
  186 + question['ref'] = [question['ref']]
  187 + elif isinstance(question, list):
  188 + question = {'ref': [str(a) for a in question]}
  189 +
  190 + self['questions'][i] = question
197 191
198 - self['questions'][i] = q 192 + def check_missing_title(self):
  193 + '''Warns if title is missing'''
  194 + if not self['title']:
  195 + logger.warning('Title is undefined!')
  196 +
  197 + def check_grade_scaling(self):
  198 + '''Just informs the scale limits'''
  199 + if self['scale_points']:
  200 + smin, smax = self["scale_min"], self["scale_max"]
  201 + logger.info('Grades will be scaled to [%g, %g]', smin, smax)
  202 + else:
  203 + logger.info('Grades are not being scaled.')
199 204
200 # ------------------------------------------------------------------------ 205 # ------------------------------------------------------------------------
201 - # Given a dictionary with a student dict {'name':'john', 'number': 123}  
202 - # returns instance of Test() for that particular student 206 + def sanity_checks(self):
  207 + '''
  208 + Checks for valid keys and sets default values.
  209 + Also checks if some files and directories exist
  210 + '''
  211 + self.check_missing_ref()
  212 + self.check_missing_database()
  213 + self.check_missing_answers_directory()
  214 + self.check_answers_directory_writable()
  215 + self.check_questions_directory()
  216 + self.check_import_files()
  217 + self.check_question_list()
  218 + self.check_missing_title()
  219 + self.check_grade_scaling()
  220 +
203 # ------------------------------------------------------------------------ 221 # ------------------------------------------------------------------------
204 async def generate(self, student): 222 async def generate(self, student):
  223 + '''
  224 + Given a dictionary with a student dict {'name':'john', 'number': 123}
  225 + returns instance of Test() for that particular student
  226 + '''
  227 +
205 # make list of questions 228 # make list of questions
206 test = [] 229 test = []
207 - n = 1 # track question number 230 + qnum = 1 # track question number
208 nerr = 0 # count errors generating questions 231 nerr = 0 # count errors generating questions
209 232
210 - for qq in self['questions']: 233 + for qlist in self['questions']:
211 # choose one question variant 234 # choose one question variant
212 - qref = random.choice(qq['ref']) 235 + qref = random.choice(qlist['ref'])
213 236
214 # generate instance of question 237 # generate instance of question
215 try: 238 try:
216 - q = await self.question_factory[qref].gen_async()  
217 - except Exception:  
218 - logger.error(f'Can\'t generate question "{qref}". Skipping.') 239 + question = await self.question_factory[qref].gen_async()
  240 + except QuestionException:
  241 + logger.error('Can\'t generate question "%s". Skipping.', qref)
219 nerr += 1 242 nerr += 1
220 continue 243 continue
221 244
222 # some defaults 245 # some defaults
223 - if q['type'] in ('information', 'success', 'warning', 'alert'):  
224 - q['points'] = qq.get('points', 0.0) 246 + if question['type'] in ('information', 'success', 'warning',
  247 + 'alert'):
  248 + question['points'] = qlist.get('points', 0.0)
225 else: 249 else:
226 - q['points'] = qq.get('points', 1.0)  
227 - q['number'] = n # counter for non informative panels  
228 - n += 1 250 + question['points'] = qlist.get('points', 1.0)
  251 + question['number'] = qnum # counter for non informative panels
  252 + qnum += 1
229 253
230 - test.append(q) 254 + test.append(question)
231 255
232 # normalize question points to scale 256 # normalize question points to scale
233 if self['scale_points']: 257 if self['scale_points']:
@@ -236,11 +260,11 @@ class TestFactory(dict): @@ -236,11 +260,11 @@ class TestFactory(dict):
236 logger.warning('Can\'t scale, total points in the test is 0!') 260 logger.warning('Can\'t scale, total points in the test is 0!')
237 else: 261 else:
238 scale = (self['scale_max'] - self['scale_min']) / total_points 262 scale = (self['scale_max'] - self['scale_min']) / total_points
239 - for q in test:  
240 - q['points'] *= scale 263 + for question in test:
  264 + question['points'] *= scale
241 265
242 if nerr > 0: 266 if nerr > 0:
243 - logger.error(f'{nerr} errors found!') 267 + logger.error('%s errors found!', nerr)
244 268
245 return Test({ 269 return Test({
246 'ref': self['ref'], 270 'ref': self['ref'],
@@ -267,9 +291,11 @@ class TestFactory(dict): @@ -267,9 +291,11 @@ class TestFactory(dict):
267 291
268 292
269 # ============================================================================ 293 # ============================================================================
270 -# Each instance Test() is a concrete test of a single student.  
271 -# ============================================================================  
272 class Test(dict): 294 class Test(dict):
  295 + '''
  296 + Each instance Test() is a concrete test of a single student.
  297 + '''
  298 +
273 # ------------------------------------------------------------------------ 299 # ------------------------------------------------------------------------
274 def __init__(self, d): 300 def __init__(self, d):
275 super().__init__(d) 301 super().__init__(d)
@@ -279,28 +305,33 @@ class Test(dict): @@ -279,28 +305,33 @@ class Test(dict):
279 self['comment'] = '' 305 self['comment'] = ''
280 306
281 # ------------------------------------------------------------------------ 307 # ------------------------------------------------------------------------
282 - # Removes all answers from the test (clean)  
283 def reset_answers(self): 308 def reset_answers(self):
284 - for q in self['questions']:  
285 - q['answer'] = None 309 + '''Removes all answers from the test (clean)'''
  310 + for question in self['questions']:
  311 + question['answer'] = None
286 312
287 # ------------------------------------------------------------------------ 313 # ------------------------------------------------------------------------
288 - # Given a dictionary ans={'ref': 'some answer'} updates the answers of the  
289 - # test. Only affects the questions referred in the dictionary.  
290 def update_answers(self, ans): 314 def update_answers(self, ans):
  315 + '''
  316 + Given a dictionary ans={'ref': 'some answer'} updates the answers of
  317 + the test. Only affects the questions referred in the dictionary.
  318 + '''
291 for ref, answer in ans.items(): 319 for ref, answer in ans.items():
292 self['questions'][ref]['answer'] = answer 320 self['questions'][ref]['answer'] = answer
293 321
294 # ------------------------------------------------------------------------ 322 # ------------------------------------------------------------------------
295 - # Corrects all the answers of the test and computes the final grade  
296 async def correct(self): 323 async def correct(self):
  324 + '''Corrects all the answers of the test and computes the final grade'''
  325 +
297 self['finish_time'] = datetime.now() 326 self['finish_time'] = datetime.now()
298 self['state'] = 'FINISHED' 327 self['state'] = 'FINISHED'
299 grade = 0.0 328 grade = 0.0
300 - for q in self['questions']:  
301 - await q.correct_async()  
302 - grade += q['grade'] * q['points']  
303 - logger.debug(f'Correcting {q["ref"]:>30}: {q["grade"]*100:4.0f}%') 329 + for question in self['questions']:
  330 + await question.correct_async()
  331 + grade += question['grade'] * question['points']
  332 + # logger.debug(f'Correcting {q["ref"]:>30}: {q["grade"]*100:4.0f}%')
  333 + logger.debug('Correcting %30s: %3g%%',
  334 + question["ref"], question["grade"]*100)
304 335
305 # truncate to avoid negative grade and adjust scale 336 # truncate to avoid negative grade and adjust scale
306 self['grade'] = max(0.0, grade) + self['scale_min'] 337 self['grade'] = max(0.0, grade) + self['scale_min']
@@ -308,8 +339,9 @@ class Test(dict): @@ -308,8 +339,9 @@ class Test(dict):
308 339
309 # ------------------------------------------------------------------------ 340 # ------------------------------------------------------------------------
310 def giveup(self): 341 def giveup(self):
  342 + '''Test is marqued as QUIT and is not corrected'''
311 self['finish_time'] = datetime.now() 343 self['finish_time'] = datetime.now()
312 self['state'] = 'QUIT' 344 self['state'] = 'QUIT'
313 self['grade'] = 0.0 345 self['grade'] = 0.0
314 - logger.info(f'Student {self["student"]["number"]}: gave up.') 346 + logger.info('Student %s: gave up.', self["student"]["number"])
315 return self['grade'] 347 return self['grade']