''' Classes the implement several types of questions. ''' # python standard library import asyncio import logging import random import re from os import path from typing import Any, Dict, NewType import uuid # this project from .tools import run_script, run_script_async # setup logger for this module logger = logging.getLogger(__name__) QDict = NewType('QDict', Dict[str, Any]) class QuestionException(Exception): '''Exceptions raised in this module''' # ============================================================================ # Questions derived from Question are already instantiated and ready to be # presented to students. # ============================================================================ class Question(dict): ''' Classes derived from this base class are meant to instantiate questions for each student. Instances can shuffle options or automatically generate questions. ''' def __init__(self, q: QDict) -> None: super().__init__(q) # add required keys if missing self.set_defaults(QDict({ 'title': '', 'answer': None, 'comments': '', 'solution': '', 'files': {}, })) 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, qdict: QDict) -> None: '''Add k:v pairs from default dict d for nonexistent keys''' for k, val in qdict.items(): self.setdefault(k, val) # ============================================================================ class QuestionRadio(Question): '''An instance of QuestionRadio will always have the keys: type (str) text (str) options (list of strings) correct (list of floats) discount (bool, default=True) answer (None or an actual answer) shuffle (bool, default=True) choose (int) # only used if shuffle=True ''' # ------------------------------------------------------------------------ def __init__(self, q: QDict) -> None: super().__init__(q) nopts = len(self['options']) self.set_defaults(QDict({ 'text': '', 'correct': 0, 'shuffle': True, 'discount': True, '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'] < 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(nopts)] elif isinstance(self['correct'], list): # must match number of options 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 try: self['correct'] = [float(x) for x in self['correct']] except (ValueError, TypeError): msg = (f'Correct list must contain numbers [0.0, 1.0] or ' f'booleans in "{self["ref"]}"') raise QuestionException(msg) # check grade boundaries if self['discount'] and not all(0.0 <= x <= 1.0 for x in self['correct']): msg = (f'Correct values must be in the interval [0.0, 1.0] in ' f'"{self["ref"]}"') raise QuestionException(msg) # at least one correct option if all(x < 1.0 for x in self['correct']): msg = (f'At least one correct option is required in ' f'"{self["ref"]}"') raise QuestionException(msg) # If shuffle==false, all options are shown as defined # 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(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: sel = random.choice(right) options = [self['options'][sel]] correct = [self['correct'][sel]] else: options = [] correct = [] # choose remaining wrong options nwrong = self['choose'] - len(correct) wrongsample = random.sample(wrong, k=nwrong) options += [self['options'][i] for i in wrongsample] correct += [self['correct'][i] for i in wrongsample] # final shuffle of the options perm = random.sample(range(self['choose']), k=self['choose']) self['options'] = [str(options[i]) for i in perm] self['correct'] = [correct[i] for i in perm] # ------------------------------------------------------------------------ # can assign negative grades for wrong answers def correct(self) -> None: super().correct() if self['answer'] is not None: 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 grade_aver != 1.0: grade = (grade - grade_aver) / (1.0 - grade_aver) self['grade'] = grade # ============================================================================ class QuestionCheckbox(Question): '''An instance of QuestionCheckbox will always have the keys: type (str) text (str) options (list of strings) shuffle (bool, default True) correct (list of floats) discount (bool, default True) choose (int) answer (None or an actual answer) ''' # ------------------------------------------------------------------------ def __init__(self, q: QDict) -> None: super().__init__(q) nopts = len(self['options']) # set defaults if missing self.set_defaults(QDict({ 'text': '', 'correct': [1.0] * nopts, # Using 0.0 breaks (right, wrong) 'shuffle': True, 'discount': True, 'choose': nopts, # number of options 'max_tries': max(1, min(nopts - 1, 3)) })) # must be a list of numbers if not isinstance(self['correct'], list): msg = 'Correct must be a list of numbers or booleans' raise QuestionException(msg) # must match number of options 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 try: self['correct'] = [float(x) for x in self['correct']] except (ValueError, TypeError): msg = (f'Correct list must contain numbers or ' f'booleans in "{self["ref"]}"') raise QuestionException(msg) # check grade boundaries if self['discount'] and not all(0.0 <= x <= 1.0 for x in self['correct']): msg0 = ('+--------------- BEHAVIOR CHANGE NOTICE ---------------+') msg1 = ('| Correct values in checkbox questions must be in the |') msg2 = ('| interval [0.0, 1.0]. I will convert to the new |') msg3 = ('| behavior, for now, but you should fix it. |') msg4 = ('+------------------------------------------------------+') logger.warning(msg0) logger.warning(msg1) logger.warning(msg2) logger.warning(msg3) logger.warning(msg4) logger.warning('please fix "%s"', self["ref"]) # normalize to [0,1] self['correct'] = [(x+1)/2 for x in self['correct']] # if an option is a list of (right, wrong), pick one options = [] correct = [] 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(nopts), k=self['choose']) self['options'] = [options[i] for i in perm] self['correct'] = [correct[i] for i in perm] else: self['options'] = options[:self['choose']] self['correct'] = correct[:self['choose']] # ------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self) -> None: super().correct() if self['answer'] is not None: grade = 0.0 if self['discount']: sum_abs = sum(abs(2*p-1) for p in self['correct']) 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, pts in enumerate(self['correct']): grade += pts if str(i) in self['answer'] else 0.0 try: self['grade'] = grade / sum_abs except ZeroDivisionError: self['grade'] = 1.0 # limit p->0 # ============================================================================ class QuestionText(Question): '''An instance of QuestionText will always have the keys: type (str) text (str) correct (list of str) answer (None or an actual answer) ''' # ------------------------------------------------------------------------ def __init__(self, q: QDict) -> None: super().__init__(q) self.set_defaults(QDict({ 'text': '', 'correct': [], # no correct answers, always wrong 'transform': [], # transformations applied to the answer, in order })) # make sure its always a list of possible correct answers if not isinstance(self['correct'], list): self['correct'] = [str(self['correct'])] else: # make sure all elements of the list are strings self['correct'] = [str(a) for a in self['correct']] 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('in "%s", correct answers are not invariant wrt ' 'transformations => never correct', self["ref"]) # ------------------------------------------------------------------------ def transform(self, ans): '''apply optional filters to the answer''' for transform in self['transform']: if transform == 'remove_space': # removes all spaces ans = ans.replace(' ', '') elif transform == 'trim': # removes spaces around ans = ans.strip() elif transform == 'normalize_space': # replaces multiple spaces by one ans = re.sub(r'\s+', ' ', ans.strip()) elif transform == 'lower': # convert to lowercase ans = ans.lower() elif transform == 'upper': # convert to uppercase ans = ans.upper() else: logger.warning('in "%s", unknown transform "%s"', self["ref"], transform) return ans # ------------------------------------------------------------------------ def correct(self) -> None: super().correct() if self['answer'] is not None: answer = self.transform(self['answer']) # apply transformations self['grade'] = 1.0 if answer in self['correct'] else 0.0 # ============================================================================ class QuestionTextRegex(Question): '''An instance of QuestionTextRegex will always have the keys: type (str) text (str) correct (str or list[str]) answer (None or an actual answer) The correct strings are python standard regular expressions. Grade is 1.0 when the answer matches any of the regex in the list. ''' # ------------------------------------------------------------------------ def __init__(self, q: QDict) -> None: super().__init__(q) self.set_defaults(QDict({ 'text': '', 'correct': ['$.^'], # will always return false })) # make sure its always a list of regular expressions if not isinstance(self['correct'], list): self['correct'] = [self['correct']] # converts patterns to compiled versions try: self['correct'] = [re.compile(a) for a in self['correct']] except Exception: msg = f'Failed to compile regex in "{self["ref"]}"' raise QuestionException(msg) # ------------------------------------------------------------------------ def correct(self) -> None: super().correct() if self['answer'] is not None: self['grade'] = 0.0 for regex in self['correct']: try: if regex.match(self['answer']): self['grade'] = 1.0 return except TypeError: logger.error('While matching regex %s with answer "%s".', regex.pattern, self["answer"]) # ============================================================================ class QuestionNumericInterval(Question): '''An instance of QuestionTextNumeric will always have the keys: type (str) text (str) correct (list [lower bound, upper bound]) answer (None or an actual answer) An answer is correct if it's in the closed interval. ''' # ------------------------------------------------------------------------ def __init__(self, q: QDict) -> None: super().__init__(q) self.set_defaults(QDict({ 'text': '', 'correct': [1.0, -1.0], # will always return false })) # if only one number n is given, make an interval [n,n] if isinstance(self['correct'], (int, float)): self['correct'] = [float(self['correct']), float(self['correct'])] # make sure its a list of two numbers elif isinstance(self['correct'], list): if len(self['correct']) != 2: msg = (f'Numeric interval must be a list with two numbers, in ' f'{self["ref"]}') raise QuestionException(msg) try: self['correct'] = [float(n) for n in self['correct']] except Exception: msg = (f'Numeric interval must be a list with two numbers, in ' f'{self["ref"]}') raise QuestionException(msg) # invalid else: msg = (f'Numeric interval must be a list with two numbers, in ' f'{self["ref"]}') raise QuestionException(msg) # ------------------------------------------------------------------------ def correct(self) -> None: super().correct() if self['answer'] is not None: lower, upper = self['correct'] try: # replace , by . and convert to float answer = float(self['answer'].replace(',', '.', 1)) except ValueError: self['comments'] = ('A resposta tem de ser numérica, ' 'por exemplo `12.345`.') self['grade'] = 0.0 else: self['grade'] = 1.0 if lower <= answer <= upper else 0.0 # ============================================================================ class QuestionTextArea(Question): '''An instance of QuestionTextArea will always have the keys: type (str) text (str) correct (str with script to run) answer (None or an actual answer) ''' # ------------------------------------------------------------------------ def __init__(self, q: QDict) -> None: super().__init__(q) self.set_defaults(QDict({ 'text': '', 'timeout': 5, # seconds 'correct': '', # trying to execute this will fail => grade 0.0 'args': [] })) self['correct'] = path.join(self['path'], self['correct']) # ------------------------------------------------------------------------ def correct(self) -> None: super().correct() if self['answer'] is not None: # correct answer and parse yaml ouput out = run_script( script=self['correct'], args=self['args'], stdin=self['answer'], timeout=self['timeout'] ) if out is None: 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('Output error in "%s".', self["correct"]) except KeyError: logger.error('No grade in "%s".', self["correct"]) else: try: self['grade'] = float(out) except (TypeError, ValueError): logger.error('Invalid grade in "%s".', self["correct"]) # ------------------------------------------------------------------------ async def correct_async(self) -> None: super().correct() if self['answer'] is not None: # correct answer and parse yaml ouput out = await run_script_async( script=self['correct'], args=self['args'], stdin=self['answer'], timeout=self['timeout'] ) if out is None: 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('Output error in "%s".', self["correct"]) except KeyError: logger.error('No grade in "%s".', self["correct"]) else: try: self['grade'] = float(out) except (TypeError, ValueError): 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) self.set_defaults(QDict({ 'text': '', })) # ------------------------------------------------------------------------ def correct(self) -> None: super().correct() self['grade'] = 1.0 # always "correct" but points should be zero! # ============================================================================ 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 = { 'radio': QuestionRadio, 'checkbox': QuestionCheckbox, 'text': QuestionText, 'text-regex': QuestionTextRegex, 'numeric-interval': QuestionNumericInterval, 'textarea': QuestionTextArea, # -- informative panels -- 'information': QuestionInformation, 'success': QuestionInformation, 'warning': QuestionInformation, 'alert': QuestionInformation, } def __init__(self, qdict: QDict = QDict({})) -> None: self.question = qdict # ------------------------------------------------------------------------ async def gen_async(self) -> Question: ''' 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 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 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[question['type']] except KeyError: logger.error('Invalid type "%s" in "%s"', question["type"], question["ref"]) raise # Finally create an instance of Question() try: 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())