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 21  
22 22 # Duration in minutes.
23 23 # (0 or undefined means infinite time)
24   -duration: 10
  24 +duration: 2
25 25 autosubmit: true
26 26  
27 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 6 # python standard library
3 7 import asyncio
  8 +import logging
4 9 import random
5 10 import re
6 11 from os import path
7   -import logging
8 12 from typing import Any, Dict, NewType
9 13 import uuid
10 14  
... ... @@ -19,7 +23,7 @@ QDict = NewType('QDict', Dict[str, Any])
19 23  
20 24  
21 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 49 }))
46 50  
47 51 def correct(self) -> None:
  52 + '''default correction (synchronous version)'''
48 53 self['comments'] = ''
49 54 self['grade'] = 0.0
50 55  
51 56 async def correct_async(self) -> None:
  57 + '''default correction (async version)'''
52 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 80 def __init__(self, q: QDict) -> None:
75 81 super().__init__(q)
76 82  
77   - n = len(self['options'])
  83 + nopts = len(self['options'])
78 84  
79 85 self.set_defaults(QDict({
80 86 'text': '',
81 87 'correct': 0,
82 88 'shuffle': True,
83 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 93 # check correct bounds and convert int to list,
88 94 # e.g. correct: 2 --> correct: [0,0,1,0,0]
89 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 98 f'"{self["ref"]}"')
93 99 raise QuestionException(msg)
94 100  
95 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 104 elif isinstance(self['correct'], list):
99 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 108 f'{len(self["correct"])} correct in "{self["ref"]}"')
103 109 raise QuestionException(msg)
104 110 # make sure is a list of floats
... ... @@ -126,16 +132,16 @@ class QuestionRadio(Question):
126 132 # otherwise, select 1 correct and choose a few wrong ones
127 133 if self['shuffle']:
128 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 138 self.set_defaults(QDict({'choose': 1+len(wrong)}))
133 139  
134 140 # try to choose 1 correct option
135 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 145 else:
140 146 options = []
141 147 correct = []
... ... @@ -157,15 +163,15 @@ class QuestionRadio(Question):
157 163 super().correct()
158 164  
159 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 170 # note: there are no numerical errors when summing 1.0s so the
165 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 191 def __init__(self, q: QDict) -> None:
186 192 super().__init__(q)
187 193  
188   - n = len(self['options'])
  194 + nopts = len(self['options'])
189 195  
190 196 # set defaults if missing
191 197 self.set_defaults(QDict({
192 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 200 'shuffle': True,
195 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 206 # must be a list of numbers
... ... @@ -203,8 +209,8 @@ class QuestionCheckbox(Question):
203 209 raise QuestionException(msg)
204 210  
205 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 214 f'{len(self["correct"])} correct in "{self["ref"]}"')
209 215 raise QuestionException(msg)
210 216  
... ... @@ -230,7 +236,7 @@ class QuestionCheckbox(Question):
230 236 logger.warning(msg2)
231 237 logger.warning(msg3)
232 238 logger.warning(msg4)
233   - logger.warning(f'please fix "{self["ref"]}"')
  239 + logger.warning('please fix "%s"', self["ref"])
234 240  
235 241 # normalize to [0,1]
236 242 self['correct'] = [(x+1)/2 for x in self['correct']]
... ... @@ -238,20 +244,19 @@ class QuestionCheckbox(Question):
238 244 # if an option is a list of (right, wrong), pick one
239 245 options = []
240 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 256 # generate random permutation, e.g. [2,1,4,0,3]
252 257 # and apply to `options` and `correct`
253 258 if self['shuffle']:
254   - perm = random.sample(range(n), k=self['choose'])
  259 + perm = random.sample(range(nopts), k=self['choose'])
255 260 self['options'] = [options[i] for i in perm]
256 261 self['correct'] = [correct[i] for i in perm]
257 262 else:
... ... @@ -264,18 +269,18 @@ class QuestionCheckbox(Question):
264 269 super().correct()
265 270  
266 271 if self['answer'] is not None:
267   - x = 0.0
  272 + grade = 0.0
268 273 if self['discount']:
269 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 277 else:
273 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 282 try:
278   - self['grade'] = x / sum_abs
  283 + self['grade'] = grade / sum_abs
279 284 except ZeroDivisionError:
280 285 self['grade'] = 1.0 # limit p->0
281 286  
... ... @@ -306,33 +311,35 @@ class QuestionText(Question):
306 311 # make sure all elements of the list are strings
307 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 318 raise QuestionException(msg)
314 319  
315 320 # check if answers are invariant with respect to the transforms
316 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 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 331 ans = ans.replace(' ', '')
326   - elif f == 'trim': # removes spaces around
  332 + elif transform == 'trim': # removes spaces around
327 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 335 ans = re.sub(r'\s+', ' ', ans.strip())
330   - elif f == 'lower': # convert to lowercase
  336 + elif transform == 'lower': # convert to lowercase
331 337 ans = ans.lower()
332   - elif f == 'upper': # convert to uppercase
  338 + elif transform == 'upper': # convert to uppercase
333 339 ans = ans.upper()
334 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 343 return ans
337 344  
338 345 # ------------------------------------------------------------------------
... ... @@ -381,14 +388,14 @@ class QuestionTextRegex(Question):
381 388 super().correct()
382 389 if self['answer'] is not None:
383 390 self['grade'] = 0.0
384   - for r in self['correct']:
  391 + for regex in self['correct']:
385 392 try:
386   - if r.match(self['answer']):
  393 + if regex.match(self['answer']):
387 394 self['grade'] = 1.0
388 395 return
389 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 492 )
486 493  
487 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 496 self['grade'] = 0.0
490 497 elif isinstance(out, dict):
491 498 self['comments'] = out.get('comments', '')
492 499 try:
493 500 self['grade'] = float(out['grade'])
494 501 except ValueError:
495   - logger.error(f'Output error in "{self["correct"]}".')
  502 + logger.error('Output error in "%s".', self["correct"])
496 503 except KeyError:
497   - logger.error(f'No grade in "{self["correct"]}".')
  504 + logger.error('No grade in "%s".', self["correct"])
498 505 else:
499 506 try:
500 507 self['grade'] = float(out)
501 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 512 async def correct_async(self) -> None:
... ... @@ -514,25 +521,30 @@ class QuestionTextArea(Question):
514 521 )
515 522  
516 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 525 self['grade'] = 0.0
519 526 elif isinstance(out, dict):
520 527 self['comments'] = out.get('comments', '')
521 528 try:
522 529 self['grade'] = float(out['grade'])
523 530 except ValueError:
524   - logger.error(f'Output error in "{self["correct"]}".')
  531 + logger.error('Output error in "%s".', self["correct"])
525 532 except KeyError:
526   - logger.error(f'No grade in "{self["correct"]}".')
  533 + logger.error('No grade in "%s".', self["correct"])
527 534 else:
528 535 try:
529 536 self['grade'] = float(out)
530 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 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 549 def __init__(self, q: QDict) -> None:
538 550 super().__init__(q)
... ... @@ -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 594 # Depending on the type of question, a different question class will be
583 595 # instantiated. All these classes derive from the base class `Question`.
584 596 _types = {
... ... @@ -599,44 +611,48 @@ class QFactory(object):
599 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 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 621 # Shallow copy so that script generated questions will not replace
608 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 626 # If question is of generator type, an external program will be run
613 627 # which will print a valid question in yaml format to stdout. This
614 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 638 # Get class for this question type
625 639 try:
626   - qclass = self._types[q['type']]
  640 + qclass = self._types[question['type']]
627 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 644 raise
630 645  
631 646 # Finally create an instance of Question()
632 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 653 return qinstance
639 654  
640 655 # ------------------------------------------------------------------------
641 656 def generate(self) -> Question:
  657 + '''generate question (synchronous version)'''
642 658 return asyncio.get_event_loop().run_until_complete(self.gen_async())
... ...
perguntations/templates/test.html
... ... @@ -41,7 +41,7 @@
41 41  
42 42 </head>
43 43 <!-- ===================================================================== -->
44   -<body id="test">
  44 +<body>
45 45 <!-- ===================================================================== -->
46 46  
47 47 <nav id="navbar" class="navbar navbar-expand-sm fixed-top navbar-dark bg-dark">
... ... @@ -157,37 +157,32 @@
157 157 $("#clock").html("+\u221e");
158 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 184 {% end %}
187 185  
188 186 </script>
189   -
190   -
191   -
192 187 </body>
193 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 6 # python standard library
4 7 from os import path
... ... @@ -7,7 +10,7 @@ from datetime import datetime
7 10 import logging
8 11  
9 12 # this project
10   -from perguntations.questions import QFactory
  13 +from perguntations.questions import QFactory, QuestionException
11 14 from perguntations.tools import load_yaml
12 15  
13 16 # Logger configuration
... ... @@ -16,7 +19,7 @@ logger = logging.getLogger(__name__)
16 19  
17 20 # ============================================================================
18 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 30 instances of TestFactory(), one for each test.
28 31 '''
29 32  
30   -
31 33 # ------------------------------------------------------------------------
32 34 def __init__(self, conf):
33 35 '''
... ... @@ -120,30 +122,29 @@ class TestFactory(dict):
120 122 if qmissing:
121 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 129 if 'ref' not in self:
132 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 134 if 'database' not in self:
136 135 raise TestFactoryException('Missing "database" in configuration')
137 136 if not path.isfile(path.expanduser(self['database'])):
138 137 msg = f'Database "{self["database"]}" not found!'
139 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 142 if 'answers_dir' not in self:
143 143 msg = 'Missing "answers_dir" in configuration'
144 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 148 testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME')
148 149 try:
149 150 with open(testfile, 'w') as file:
... ... @@ -152,82 +153,105 @@ class TestFactory(dict):
152 153 msg = f'Cannot write answers to directory "{self["answers_dir"]}"'
153 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 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 161 self['questions_dir'] = path.curdir
171 162 elif not path.isdir(path.expanduser(self['questions_dir'])):
172 163 raise TestFactoryException(f'Can\'t find questions directory '
173 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 168 if 'files' not in self:
177 169 msg = ('Missing "files" in configuration with the list of '
178 170 'question files to import!')
179 171 raise TestFactoryException(msg)
180   - # FIXME allow no files and define the questions directly in the test
181 172  
182 173 if isinstance(self['files'], str):
183 174 self['files'] = [self['files']]
184 175  
185   - # --- questions
  176 + def check_question_list(self):
  177 + '''normalize question list'''
186 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 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 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 228 # make list of questions
206 229 test = []
207   - n = 1 # track question number
  230 + qnum = 1 # track question number
208 231 nerr = 0 # count errors generating questions
209 232  
210   - for qq in self['questions']:
  233 + for qlist in self['questions']:
211 234 # choose one question variant
212   - qref = random.choice(qq['ref'])
  235 + qref = random.choice(qlist['ref'])
213 236  
214 237 # generate instance of question
215 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 242 nerr += 1
220 243 continue
221 244  
222 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 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 256 # normalize question points to scale
233 257 if self['scale_points']:
... ... @@ -236,11 +260,11 @@ class TestFactory(dict):
236 260 logger.warning('Can\'t scale, total points in the test is 0!')
237 261 else:
238 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 266 if nerr > 0:
243   - logger.error(f'{nerr} errors found!')
  267 + logger.error('%s errors found!', nerr)
244 268  
245 269 return Test({
246 270 'ref': self['ref'],
... ... @@ -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 294 class Test(dict):
  295 + '''
  296 + Each instance Test() is a concrete test of a single student.
  297 + '''
  298 +
273 299 # ------------------------------------------------------------------------
274 300 def __init__(self, d):
275 301 super().__init__(d)
... ... @@ -279,28 +305,33 @@ class Test(dict):
279 305 self['comment'] = ''
280 306  
281 307 # ------------------------------------------------------------------------
282   - # Removes all answers from the test (clean)
283 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 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 319 for ref, answer in ans.items():
292 320 self['questions'][ref]['answer'] = answer
293 321  
294 322 # ------------------------------------------------------------------------
295   - # Corrects all the answers of the test and computes the final grade
296 323 async def correct(self):
  324 + '''Corrects all the answers of the test and computes the final grade'''
  325 +
297 326 self['finish_time'] = datetime.now()
298 327 self['state'] = 'FINISHED'
299 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 336 # truncate to avoid negative grade and adjust scale
306 337 self['grade'] = max(0.0, grade) + self['scale_min']
... ... @@ -308,8 +339,9 @@ class Test(dict):
308 339  
309 340 # ------------------------------------------------------------------------
310 341 def giveup(self):
  342 + '''Test is marqued as QUIT and is not corrected'''
311 343 self['finish_time'] = datetime.now()
312 344 self['state'] = 'QUIT'
313 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 347 return self['grade']
... ...