diff --git a/demo/demo.yaml b/demo/demo.yaml index f9587c4..4a29017 100644 --- a/demo/demo.yaml +++ b/demo/demo.yaml @@ -21,7 +21,7 @@ title: Teste de demonstraĆ§Ć£o (tutorial) # Duration in minutes. # (0 or undefined means infinite time) -duration: 10 +duration: 2 autosubmit: true # Show points for each question, scale 0-20. diff --git a/perguntations/questions.py b/perguntations/questions.py index 93d639f..6ca6e22 100644 --- a/perguntations/questions.py +++ b/perguntations/questions.py @@ -1,10 +1,14 @@ +''' +Classes the implement several types of questions. +''' + # python standard library import asyncio +import logging import random import re from os import path -import logging from typing import Any, Dict, NewType import uuid @@ -19,7 +23,7 @@ QDict = NewType('QDict', Dict[str, Any]) class QuestionException(Exception): - pass + '''Exceptions raised in this module''' # ============================================================================ @@ -45,16 +49,18 @@ class Question(dict): })) def correct(self) -> None: + '''default correction (synchronous version)''' self['comments'] = '' self['grade'] = 0.0 async def correct_async(self) -> None: + '''default correction (async version)''' self.correct() - def set_defaults(self, d: QDict) -> None: - 'Add k:v pairs from default dict d for nonexistent keys' - for k, v in d.items(): - self.setdefault(k, v) + def set_defaults(self, qdict: QDict) -> None: + '''Add k:v pairs from default dict d for nonexistent keys''' + for k, val in qdict.items(): + self.setdefault(k, val) # ============================================================================ @@ -74,31 +80,31 @@ class QuestionRadio(Question): def __init__(self, q: QDict) -> None: super().__init__(q) - n = len(self['options']) + nopts = len(self['options']) self.set_defaults(QDict({ 'text': '', 'correct': 0, 'shuffle': True, 'discount': True, - 'max_tries': (n + 3) // 4 # 1 try for each 4 options + 'max_tries': (nopts + 3) // 4 # 1 try for each 4 options })) # check correct bounds and convert int to list, # e.g. correct: 2 --> correct: [0,0,1,0,0] if isinstance(self['correct'], int): - if not (0 <= self['correct'] < n): - msg = (f'Correct option not in range 0..{n-1} in ' + if not 0 <= self['correct'] < nopts: + msg = (f'Correct option not in range 0..{nopts-1} in ' f'"{self["ref"]}"') raise QuestionException(msg) self['correct'] = [1.0 if x == self['correct'] else 0.0 - for x in range(n)] + for x in range(nopts)] elif isinstance(self['correct'], list): # must match number of options - if len(self['correct']) != n: - msg = (f'Incompatible sizes: {n} options vs ' + if len(self['correct']) != nopts: + msg = (f'Incompatible sizes: {nopts} options vs ' f'{len(self["correct"])} correct in "{self["ref"]}"') raise QuestionException(msg) # make sure is a list of floats @@ -126,16 +132,16 @@ class QuestionRadio(Question): # otherwise, select 1 correct and choose a few wrong ones if self['shuffle']: # lists with indices of right and wrong options - right = [i for i in range(n) if self['correct'][i] >= 1] - wrong = [i for i in range(n) if self['correct'][i] < 1] + right = [i for i in range(nopts) if self['correct'][i] >= 1] + wrong = [i for i in range(nopts) if self['correct'][i] < 1] self.set_defaults(QDict({'choose': 1+len(wrong)})) # try to choose 1 correct option if right: - r = random.choice(right) - options = [self['options'][r]] - correct = [self['correct'][r]] + sel = random.choice(right) + options = [self['options'][sel]] + correct = [self['correct'][sel]] else: options = [] correct = [] @@ -157,15 +163,15 @@ class QuestionRadio(Question): super().correct() if self['answer'] is not None: - x = self['correct'][int(self['answer'])] # get grade of the answer - n = len(self['options']) - x_aver = sum(self['correct']) / n # expected value of grade + grade = self['correct'][int(self['answer'])] # grade of the answer + nopts = len(self['options']) + grade_aver = sum(self['correct']) / nopts # expected value # note: there are no numerical errors when summing 1.0s so the # x_aver can be exactly 1.0 if all options are right - if self['discount'] and x_aver != 1.0: - x = (x - x_aver) / (1.0 - x_aver) - self['grade'] = x + if self['discount'] and grade_aver != 1.0: + grade = (grade - grade_aver) / (1.0 - grade_aver) + self['grade'] = grade # ============================================================================ @@ -185,16 +191,16 @@ class QuestionCheckbox(Question): def __init__(self, q: QDict) -> None: super().__init__(q) - n = len(self['options']) + nopts = len(self['options']) # set defaults if missing self.set_defaults(QDict({ 'text': '', - 'correct': [1.0] * n, # Using 0.0 breaks (right, wrong) options + 'correct': [1.0] * nopts, # Using 0.0 breaks (right, wrong) 'shuffle': True, 'discount': True, - 'choose': n, # number of options - 'max_tries': max(1, min(n - 1, 3)) + 'choose': nopts, # number of options + 'max_tries': max(1, min(nopts - 1, 3)) })) # must be a list of numbers @@ -203,8 +209,8 @@ class QuestionCheckbox(Question): raise QuestionException(msg) # must match number of options - if len(self['correct']) != n: - msg = (f'Incompatible sizes: {n} options vs ' + if len(self['correct']) != nopts: + msg = (f'Incompatible sizes: {nopts} options vs ' f'{len(self["correct"])} correct in "{self["ref"]}"') raise QuestionException(msg) @@ -230,7 +236,7 @@ class QuestionCheckbox(Question): logger.warning(msg2) logger.warning(msg3) logger.warning(msg4) - logger.warning(f'please fix "{self["ref"]}"') + logger.warning('please fix "%s"', self["ref"]) # normalize to [0,1] self['correct'] = [(x+1)/2 for x in self['correct']] @@ -238,20 +244,19 @@ class QuestionCheckbox(Question): # if an option is a list of (right, wrong), pick one options = [] correct = [] - for o, c in zip(self['options'], self['correct']): - if isinstance(o, list): - r = random.randint(0, 1) - o = o[r] - if r == 1: - # c = -c - c = 1.0 - c - options.append(str(o)) - correct.append(c) + for option, corr in zip(self['options'], self['correct']): + if isinstance(option, list): + sel = random.randint(0, 1) + option = option[sel] + if sel == 1: + corr = 1.0 - corr + options.append(str(option)) + correct.append(corr) # generate random permutation, e.g. [2,1,4,0,3] # and apply to `options` and `correct` if self['shuffle']: - perm = random.sample(range(n), k=self['choose']) + perm = random.sample(range(nopts), k=self['choose']) self['options'] = [options[i] for i in perm] self['correct'] = [correct[i] for i in perm] else: @@ -264,18 +269,18 @@ class QuestionCheckbox(Question): super().correct() if self['answer'] is not None: - x = 0.0 + grade = 0.0 if self['discount']: sum_abs = sum(abs(2*p-1) for p in self['correct']) - for i, p in enumerate(self['correct']): - x += 2*p-1 if str(i) in self['answer'] else 1-2*p + for i, pts in enumerate(self['correct']): + grade += 2*pts-1 if str(i) in self['answer'] else 1-2*pts else: sum_abs = sum(abs(p) for p in self['correct']) - for i, p in enumerate(self['correct']): - x += p if str(i) in self['answer'] else 0.0 + for i, pts in enumerate(self['correct']): + grade += pts if str(i) in self['answer'] else 0.0 try: - self['grade'] = x / sum_abs + self['grade'] = grade / sum_abs except ZeroDivisionError: self['grade'] = 1.0 # limit p->0 @@ -306,33 +311,35 @@ class QuestionText(Question): # make sure all elements of the list are strings self['correct'] = [str(a) for a in self['correct']] - for f in self['transform']: - if f not in ('remove_space', 'trim', 'normalize_space', 'lower', - 'upper'): - msg = (f'Unknown transform "{f}" in "{self["ref"]}"') + for transform in self['transform']: + if transform not in ('remove_space', 'trim', 'normalize_space', + 'lower', 'upper'): + msg = (f'Unknown transform "{transform}" in "{self["ref"]}"') raise QuestionException(msg) # check if answers are invariant with respect to the transforms if any(c != self.transform(c) for c in self['correct']): - logger.warning(f'in "{self["ref"]}", correct answers are not ' - 'invariant wrt transformations => never correct') + logger.warning('in "%s", correct answers are not invariant wrt ' + 'transformations => never correct', self["ref"]) # ------------------------------------------------------------------------ - # apply optional filters to the answer def transform(self, ans): - for f in self['transform']: - if f == 'remove_space': # removes all spaces + '''apply optional filters to the answer''' + + for transform in self['transform']: + if transform == 'remove_space': # removes all spaces ans = ans.replace(' ', '') - elif f == 'trim': # removes spaces around + elif transform == 'trim': # removes spaces around ans = ans.strip() - elif f == 'normalize_space': # replaces multiple spaces by one + elif transform == 'normalize_space': # replaces multiple spaces by one ans = re.sub(r'\s+', ' ', ans.strip()) - elif f == 'lower': # convert to lowercase + elif transform == 'lower': # convert to lowercase ans = ans.lower() - elif f == 'upper': # convert to uppercase + elif transform == 'upper': # convert to uppercase ans = ans.upper() else: - logger.warning(f'in "{self["ref"]}", unknown transform "{f}"') + logger.warning('in "%s", unknown transform "%s"', + self["ref"], transform) return ans # ------------------------------------------------------------------------ @@ -381,14 +388,14 @@ class QuestionTextRegex(Question): super().correct() if self['answer'] is not None: self['grade'] = 0.0 - for r in self['correct']: + for regex in self['correct']: try: - if r.match(self['answer']): + if regex.match(self['answer']): self['grade'] = 1.0 return except TypeError: - logger.error(f'While matching regex {r.pattern} with ' - f'answer "{self["answer"]}".') + logger.error('While matching regex %s with answer "%s".', + regex.pattern, self["answer"]) # ============================================================================ @@ -485,21 +492,21 @@ class QuestionTextArea(Question): ) if out is None: - logger.warning(f'No grade after running "{self["correct"]}".') + logger.warning('No grade after running "%s".', self["correct"]) self['grade'] = 0.0 elif isinstance(out, dict): self['comments'] = out.get('comments', '') try: self['grade'] = float(out['grade']) except ValueError: - logger.error(f'Output error in "{self["correct"]}".') + logger.error('Output error in "%s".', self["correct"]) except KeyError: - logger.error(f'No grade in "{self["correct"]}".') + logger.error('No grade in "%s".', self["correct"]) else: try: self['grade'] = float(out) except (TypeError, ValueError): - logger.error(f'Invalid grade in "{self["correct"]}".') + logger.error('Invalid grade in "%s".', self["correct"]) # ------------------------------------------------------------------------ async def correct_async(self) -> None: @@ -514,25 +521,30 @@ class QuestionTextArea(Question): ) if out is None: - logger.warning(f'No grade after running "{self["correct"]}".') + logger.warning('No grade after running "%s".', self["correct"]) self['grade'] = 0.0 elif isinstance(out, dict): self['comments'] = out.get('comments', '') try: self['grade'] = float(out['grade']) except ValueError: - logger.error(f'Output error in "{self["correct"]}".') + logger.error('Output error in "%s".', self["correct"]) except KeyError: - logger.error(f'No grade in "{self["correct"]}".') + logger.error('No grade in "%s".', self["correct"]) else: try: self['grade'] = float(out) except (TypeError, ValueError): - logger.error(f'Invalid grade in "{self["correct"]}".') + logger.error('Invalid grade in "%s".', self["correct"]) # ============================================================================ class QuestionInformation(Question): + ''' + Not really a question, just an information panel. + The correction is always right. + ''' + # ------------------------------------------------------------------------ def __init__(self, q: QDict) -> None: super().__init__(q) @@ -547,38 +559,38 @@ class QuestionInformation(Question): # ============================================================================ -# -# QFactory is a class that can generate question instances, e.g. by shuffling -# options, running a script to generate the question, etc. -# -# To generate an instance of a question we use the method generate(). -# It returns a question instance of the correct class. -# There is also an asynchronous version called gen_async(). This version is -# synchronous for all question types (radio, checkbox, etc) except for -# generator types which run asynchronously. -# -# Example: -# -# # make a factory for a question -# qfactory = QFactory({ -# 'type': 'radio', -# 'text': 'Choose one', -# 'options': ['a', 'b'] -# }) -# -# # generate synchronously -# question = qfactory.generate() -# -# # generate asynchronously -# question = await qfactory.gen_async() -# -# # answer one question and correct it -# question['answer'] = 42 # set answer -# question.correct() # correct answer -# grade = question['grade'] # get grade -# -# ============================================================================ -class QFactory(object): +class QFactory(): + ''' + QFactory is a class that can generate question instances, e.g. by shuffling + options, running a script to generate the question, etc. + + To generate an instance of a question we use the method generate(). + It returns a question instance of the correct class. + There is also an asynchronous version called gen_async(). This version is + synchronous for all question types (radio, checkbox, etc) except for + generator types which run asynchronously. + + Example: + + # make a factory for a question + qfactory = QFactory({ + 'type': 'radio', + 'text': 'Choose one', + 'options': ['a', 'b'] + }) + + # generate synchronously + question = qfactory.generate() + + # generate asynchronously + question = await qfactory.gen_async() + + # answer one question and correct it + question['answer'] = 42 # set answer + question.correct() # correct answer + grade = question['grade'] # get grade + ''' + # Depending on the type of question, a different question class will be # instantiated. All these classes derive from the base class `Question`. _types = { @@ -599,44 +611,48 @@ class QFactory(object): self.question = qdict # ------------------------------------------------------------------------ - # generates a question instance of QuestionRadio, QuestionCheckbox, ..., - # which is a descendent of base class Question. - # ------------------------------------------------------------------------ async def gen_async(self) -> Question: - logger.debug(f'generating {self.question["ref"]}...') + ''' + generates a question instance of QuestionRadio, QuestionCheckbox, ..., + which is a descendent of base class Question. + ''' + + logger.debug('generating %s...', self.question["ref"]) # Shallow copy so that script generated questions will not replace # the original generators - q = self.question.copy() - q['qid'] = str(uuid.uuid4()) # unique for each generated question + question = self.question.copy() + question['qid'] = str(uuid.uuid4()) # unique for each question # If question is of generator type, an external program will be run # which will print a valid question in yaml format to stdout. This # output is then yaml parsed into a dictionary `q`. - if q['type'] == 'generator': - logger.debug(f' \\_ Running "{q["script"]}".') - q.setdefault('args', []) - q.setdefault('stdin', '') - script = path.join(q['path'], q['script']) - out = await run_script_async(script=script, args=q['args'], - stdin=q['stdin']) - q.update(out) + if question['type'] == 'generator': + logger.debug(' \\_ Running "%s".', question["script"]) + question.setdefault('args', []) + question.setdefault('stdin', '') + script = path.join(question['path'], question['script']) + out = await run_script_async(script=script, args=question['args'], + stdin=question['stdin']) + question.update(out) # Get class for this question type try: - qclass = self._types[q['type']] + qclass = self._types[question['type']] except KeyError: - logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"') + logger.error('Invalid type "%s" in "%s"', + question["type"], question["ref"]) raise # Finally create an instance of Question() try: - qinstance = qclass(QDict(q)) - except QuestionException as e: - # logger.error(e) - raise e + qinstance = qclass(QDict(question)) + except QuestionException: + logger.error('Error generating question %s', question['ref']) + raise return qinstance # ------------------------------------------------------------------------ def generate(self) -> Question: + '''generate question (synchronous version)''' return asyncio.get_event_loop().run_until_complete(self.gen_async()) diff --git a/perguntations/templates/test.html b/perguntations/templates/test.html index c29eabf..d8f4eb6 100644 --- a/perguntations/templates/test.html +++ b/perguntations/templates/test.html @@ -41,7 +41,7 @@ - +