# python standard library import random import re from os import path import logging 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): pass # =========================================================================== # 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': {}, 'max_tries': 3, })) def correct(self) -> None: self['comments'] = '' self['grade'] = 0.0 async def correct_async(self) -> None: 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) # ========================================================================== 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 ''' # ------------------------------------------------------------------------ # FIXME marking all options right breaks def __init__(self, q: QDict) -> None: super().__init__(q) n = 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 })) # convert int to list, e.g. correct: 2 --> correct: [0,0,1,0,0] # correctness levels from 0.0 to 1.0 (no discount here!) if isinstance(self['correct'], int): self['correct'] = [1.0 if x == self['correct'] else 0.0 for x in range(n)] if len(self['correct']) != n: msg = (f'Options and correct mismatch in ' f'"{self["ref"]}", file "{self["filename"]}".') logger.error(msg) raise QuestionException(msg) 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] 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]] 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'] = [float(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: x = self['correct'][int(self['answer'])] if self['discount']: n = len(self['options']) # number of options x_aver = sum(self['correct']) / n x = (x - x_aver) / (1.0 - x_aver) self['grade'] = x # =========================================================================== 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) n = len(self['options']) # set defaults if missing self.set_defaults(QDict({ 'text': '', 'correct': [1.0] * n, # Using 0.0 breaks (right, wrong) options 'shuffle': True, 'discount': True, 'choose': n, # number of options 'max_tries': max(1, min(n - 1, 3)) })) if len(self['correct']) != n: msg = (f'Options and correct size mismatch in ' f'"{self["ref"]}", file "{self["filename"]}".') logger.error(msg) raise QuestionException(msg) # 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 options.append(str(o)) correct.append(float(c)) # 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']) 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: sum_abs = sum(abs(p) for p in self['correct']) if sum_abs < 1e-6: # case correct [0,...,0] avoid div-by-zero self['grade'] = 1.0 else: x = 0.0 if self['discount']: for i, p in enumerate(self['correct']): x += p if str(i) in self['answer'] else -p else: for i, p in enumerate(self['correct']): x += p if str(i) in self['answer'] else 0.0 self['grade'] = x / sum_abs # =========================================================================== 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': [], })) # make sure its always a list of possible correct answers if not isinstance(self['correct'], list): self['correct'] = [self['correct']] # make sure all elements of the list are strings self['correct'] = [str(a) for a in self['correct']] # ------------------------------------------------------------------------ def correct(self) -> None: super().correct() if self['answer'] is not None: self['grade'] = 1.0 if self['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 with regex) answer (None or an actual answer) ''' # ------------------------------------------------------------------------ def __init__(self, q: QDict) -> None: super().__init__(q) self.set_defaults(QDict({ 'text': '', 'correct': '$.^', # will always return false })) # ------------------------------------------------------------------------ def correct(self) -> None: super().correct() if self['answer'] is not None: try: ok = re.match(self['correct'], self['answer']) except TypeError: logger.error(f'While matching regex {self["correct"]} with ' f'answer {self["answer"]}.') self['grade'] = 1.0 if ok else 0.0 # =========================================================================== 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 })) # ------------------------------------------------------------------------ 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(f'No grade after running "{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"]}".') except KeyError: logger.error(f'No grade in "{self["correct"]}".') else: try: self['grade'] = float(out) except (TypeError, ValueError): logger.error(f'Invalid grade in "{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(f'No grade after running "{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"]}".') except KeyError: logger.error(f'No grade in "{self["correct"]}".') else: try: self['grade'] = float(out) except (TypeError, ValueError): logger.error(f'Invalid grade in "{self["correct"]}".') # =========================================================================== class QuestionInformation(Question): # ------------------------------------------------------------------------ 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! # =========================================================================== # 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() where # the argument is the reference of the question we wish to produce. # The generate() method returns a question instance of the correct class. # # Example: # # # generate a question instance from a dictionary # qdict = { # 'type': 'radio', # 'text': 'Choose one', # 'options': ['a', 'b'] # } # qfactory = QFactory(qdict) # question = qfactory.generate() # # # answer one question and correct it # question['answer'] = 42 # set answer # question.correct() # correct answer # grade = question['grade'] # get grade # =========================================================================== class QFactory(object): # 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 # ----------------------------------------------------------------------- # Given a ref returns an instance of a descendent of Question(), # i.e. a question object (radio, checkbox, ...). # ----------------------------------------------------------------------- def generate(self) -> Question: logger.debug(f'generating {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 # 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', '') # FIXME is it really necessary? script = path.join(q['path'], q['script']) out = run_script(script=script, args=q['args'], stdin=q['stdin']) q.update(out) # Finally we create an instance of Question() try: qinstance = self._types[q['type']](QDict(q)) # of matching class except QuestionException as e: logger.error(e) raise e except KeyError: logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"') raise else: return qinstance # ----------------------------------------------------------------------- async def generate_async(self) -> Question: logger.debug(f'generating {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 # 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', '') # FIXME is it really necessary? script = path.join(q['path'], q['script']) out = await run_script_async(script=script, args=q['args'], stdin=q['stdin']) q.update(out) # Finally we create an instance of Question() try: qinstance = self._types[q['type']](QDict(q)) # of matching class except QuestionException as e: logger.error(e) raise e except KeyError: logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"') raise else: logger.debug('ok') return qinstance