# We start with an empty QuestionFactory() that will be populated with # question generators that we can load from YAML files. # To generate an instance of a question we use the method generate(ref) where # the argument is que reference of the question we wish to produce. # # Example: # # # read everything from question files # factory = QuestionFactory() # factory.load_files(['file1.yaml', 'file1.yaml'], '/path/to') # # question = factory.generate('some_ref') # # # experiment answering one question and correct it # question['answer'] = 42 # insert answer # grade = question.correct() # correct answer # An instance of an actual question is an object that inherits from Question() # # Question - base class inherited by other classes # QuestionRadio - single choice from a list of options # QuestionCheckbox - multiple choice, equivalent to multiple true/false # QuestionText - line of text compared to a list of acceptable answers # QuestionTextRegex - line of text matched against a regular expression # QuestionTextArea - corrected by an external program # QuestionInformation - not a question, just a box with content import random import re import subprocess from os import path import logging import sys # setup logger for this module logger = logging.getLogger(__name__) try: import yaml except ImportError: logger.critical('Python package missing. See README.md for instructions.') sys.exit(1) else: # all regular expressions in yaml files, for example # correct: !regex '[aA]zul' yaml.add_constructor('!regex', lambda l, n: re.compile(l.construct_scalar(n))) # --------------------------------------------------------------------------- # Runs a script and returns its stdout parsed as yaml, or None on error. # Note: requires python 3.5+ # --------------------------------------------------------------------------- def run_script(script, stdin='', timeout=5): try: p = subprocess.run([script], input=stdin, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, timeout=timeout, ) except FileNotFoundError: logger.error('Script not found: "{0}".'.format(script)) # return qerror except PermissionError: logger.error('Script "{0}" not executable (wrong permissions?).'.format(script)) except subprocess.TimeoutExpired: logger.error('Timeout {0}s exceeded while running script "{1}"'.format(timeout, script)) else: if p.returncode != 0: logger.warning('Script "{0}" returned error code {1}.'.format(script, p.returncode)) else: try: output = yaml.load(p.stdout) except: logger.error('Error parsing yaml output of script "{0}"'.format(script)) else: return output # =========================================================================== # This class contains a pool of questions generators from which particular # Question() instances are generated using QuestionsFactory.generate(ref). # =========================================================================== class QuestionFactory(dict): # ----------------------------------------------------------------------- def __init__(self): super().__init__() # ----------------------------------------------------------------------- # Add single question provided in a dictionary. # After this, each question will have at least 'ref' and 'type' keys. # ----------------------------------------------------------------------- def add(self, question): # if ref missing try ref='/path/file.yaml:3' try: question.setdefault('ref', question['filename'] + ':' + str(question['index'])) except KeyError: logger.error('Missing "ref". Cannot add question to the pool.') return # check duplicate references if question['ref'] in self: logger.error('Duplicate reference "{0}". Replacing the original one!'.format(question['ref'])) question.setdefault('type', 'information') self[question['ref']] = question logger.debug('Added question "{0}" to the pool.'.format(question['ref'])) # ----------------------------------------------------------------------- # load single YAML questions file # ----------------------------------------------------------------------- def load_file(self, filename, questions_dir=''): try: with open(path.normpath(path.join(questions_dir, filename)), 'r', encoding='utf-8') as f: questions = yaml.load(f) except EnvironmentError: logger.error('Couldn''t open "{0}". Skipped!'.format(file)) questions = [] except yaml.parser.ParserError: logger.error('While loading questions from "{0}". Skipped!'.format(file)) questions = [] n = 0 for i, q in enumerate(questions): if isinstance(q, dict): q.update({ 'filename': filename, 'path': questions_dir, 'index': i # position in the file, 0 based }) self.add(q) # add question n += 1 # counter else: logger.error('Question index {0} from file {1} is not a dictionary. Skipped!'.format(i, filename)) logger.info('Loaded {0} questions from "{1}".'.format(n, filename)) # ----------------------------------------------------------------------- # load multiple YAML question files # ----------------------------------------------------------------------- def load_files(self, files, questions_dir=''): for filename in files: self.load_file(filename, questions_dir) # ----------------------------------------------------------------------- # Given a ref returns an instance of a descendent of Question(), # i.e. a question object (radio, checkbox, ...). # ----------------------------------------------------------------------- def generate(self, ref): # 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, 'textarea' : QuestionTextArea, # informative panels 'information': QuestionInformation, 'warning' : QuestionInformation, 'alert' : QuestionInformation, } # Shallow copy so that script generated questions will not replace # the original generators q = self[ref].copy() # 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 converted to a dictionary and `q` becomes that dict. if q['type'] == 'generator': logger.debug('Running script to generate question "{0}".'.format(q['ref'])) q.setdefault('arg', '') # optional arguments will be sent to stdin script = path.normpath(path.join(q['path'], q['script'])) q.update(run_script(script=script, stdin=q['arg'])) # The generator was replaced by a question but not yet instantiated # Finally we create an instance of Question() try: qinstance = types[q['type']](q) # instance with correct class except KeyError: logger.error('Unknown question type "{0}" in "{1}:{2}".'.format(q['type'], q['filename'], q['ref'])) except: logger.error('Failed to create question "{0}" from file "{1}".'.format(q['ref'], q['filename'])) else: logger.debug('Generated question "{}".'.format(ref)) return qinstance # =========================================================================== # 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, }) def correct(self): self['grade'] = 0.0 self['comments'] = '' return 0.0 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) shuffle (bool, default=True) correct (list of floats) discount (bool, default=True) answer (None or an actual answer) ''' #------------------------------------------------------------------------ def __init__(self, q): super().__init__(q) # set defaults if missing self.set_defaults({ 'text': '', 'correct': 0, 'shuffle': True, 'discount': True, }) n = len(self['options']) # 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 len(self['correct']) != n: logger.error('Options and correct mismatch in "{1}", file "{0}".'.format(self['filename'], self['ref'])) # generate random permutation, e.g. [2,1,4,0,3] # and apply to `options` and `correct` if self['shuffle']: perm = list(range(n)) random.shuffle(perm) self['options'] = [ str(self['options'][i]) for i in perm ] self['correct'] = [ float(self['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) 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': [0.0] * n, # useful for questionaries 'shuffle': True, 'discount': True, }) if len(self['correct']) != n: logger.error('Options and correct mismatch in "{1}", file "{0}".'.format(self['filename'], self['ref'])) # generate random permutation, e.g. [2,1,4,0,3] # and apply to `options` and `correct` if self['shuffle']: perm = list(range(n)) random.shuffle(perm) self['options'] = [ str(self['options'][i]) for i in perm ] self['correct'] = [ float(self['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'] = 0.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: self['grade'] = 1.0 if re.match(self['correct'], self['answer']) 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.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( 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('Correction script of "{0}" returned nonfloat.'.format(self['ref'])) except KeyError: logger.error('Correction script of "{0}" returned no "grade" key.'.format(self['ref'])) return self['grade'] # =========================================================================== class QuestionInformation(Question): '''An instance of QuestionInformation will always have the keys: type (str) text (str) points (0.0) ''' #------------------------------------------------------------------------ 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']