# python standard library import random import re from os import path import logging import asyncio # user installed libraries import yaml # this project from tools import run_script # regular expressions in yaml files, e.g. correct: !regex '[aA]zul' yaml.add_constructor('!regex', lambda l, n: re.compile(l.construct_scalar(n))) # setup logger for this module logger = logging.getLogger(__name__) # =========================================================================== # 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 a question to a student. Instances can shuffle options, or automatically generate questions. ''' def __init__(self, q): super().__init__(q) # add these if missing self.set_defaults({ 'title': '', 'answer': None, 'comments': '', 'solution': '', 'files': {}, }) def correct(self): self['comments'] = '' self['grade'] = 0.0 return 0.0 async def correct_async(self): # loop = asyncio.get_running_loop() # FIXME python 3.7 only loop = asyncio.get_event_loop() grade = await loop.run_in_executor(None, self.correct) return grade def set_defaults(self, d): '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 ''' #------------------------------------------------------------------------ def __init__(self, q): super().__init__(q) n = len(self['options']) # set defaults if missing self.set_defaults({ 'text': '', 'correct': 0, 'shuffle': True, 'discount': True, }) # always convert 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 self['shuffle']: # separate right from 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({'choose': 1+len(wrong)}) # choose 1 correct option r = random.choice(right) options = [ self['options'][r] ] correct = [ 1.0 ] # choose remaining wrong options random.shuffle(wrong) nwrong = self['choose']-1 options.extend(self['options'][i] for i in wrong[:nwrong]) correct.extend(self['correct'][i] for i in wrong[:nwrong]) # final shuffle of the options perm = random.sample(range(self['choose']), self['choose']) self['options'] = [ str(options[i]) for i in perm ] self['correct'] = [ float(correct[i]) for i in perm ] #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self): 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 return self['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): super().__init__(q) n = len(self['options']) # set defaults if missing self.set_defaults({ 'text': '', 'correct': [1.0] * n, # Using 0.0 breaks the (right, wrong) options 'shuffle': True, 'discount': True, 'choose': n, # number of options }) if len(self['correct']) != n: logger.error(f'Options and correct size mismatch in "{self["ref"]}", file "{self["filename"]}".') # if an option is a list of (right, wrong), pick one # FIXME it's possible that all options are chosen wrong options = [] correct = [] for o,c in zip(self['options'], self['correct']): if isinstance(o, list): r = random.randint(0,1) o = o[r] c = c if r==0 else -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), self['choose']) self['options'] = [options[i] for i in perm] self['correct'] = [correct[i] for i in perm] #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self): 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 return self['grade'] # =========================================================================== 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): super().__init__(q) self.set_defaults({ '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']] #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self): super().correct() if self['answer'] is not None: self['grade'] = 1.0 if self['answer'] in self['correct'] else 0.0 return self['grade'] # =========================================================================== 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): super().__init__(q) self.set_defaults({ 'text': '', 'correct': '$.^', # will always return false }) #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self): super().correct() if self['answer'] is not None: try: self['grade'] = 1.0 if re.match(self['correct'], self['answer']) else 0.0 except TypeError: logger.error('While matching regex {self["correct"]} with answer {self["answer"]}.') return self['grade'] # =========================================================================== 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): super().__init__(q) self.set_defaults({ 'text': '', 'correct': [1.0, -1.0], # will always return false }) #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self): 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 não é numérica.' self['grade'] = 0.0 else: self['grade'] = 1.0 if lower <= answer <= upper else 0.0 return self['grade'] # =========================================================================== 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) lines (int) ''' #------------------------------------------------------------------------ def __init__(self, q): super().__init__(q) self.set_defaults({ 'text': '', 'lines': 8, 'timeout': 5, # seconds 'correct': '' # trying to execute this will fail => grade 0.0 }) # self['correct'] = path.join(self['path'], self['correct']) self['correct'] = path.abspath(path.normpath(path.join(self['path'], self['correct']))) #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self): super().correct() if self['answer'] is not None: # correct answer out = run_script( # and parse yaml ouput script=self['correct'], stdin=self['answer'], timeout=self['timeout'] ) if type(out) in (int, float): self['grade'] = float(out) elif isinstance(out, dict): self['comments'] = out.get('comments', '') try: self['grade'] = float(out['grade']) except ValueError: logger.error(f'Correction script of "{self["ref"]}" returned nonfloat.') except KeyError: logger.error('Correction script of "{self["ref"]}" returned no "grade".') return self['grade'] # =========================================================================== class QuestionInformation(Question): #------------------------------------------------------------------------ def __init__(self, q): super().__init__(q) self.set_defaults({ 'text': '', }) #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self): super().correct() self['grade'] = 1.0 # always "correct" but points should be zero! return self['grade']