import yaml import random import re import subprocess import sys import os.path # Example usage: # # pool = QuestionPool() # pool.add_from_files(['file1.yaml', 'file1.yaml']) # # test = [] # for q in pool.values(): # test.append(create_question(q)) # # test[0]['answer'] = 42 # insert answer # grade = test[0].correct() # correct answer # =========================================================================== class QuestionsPool(dict): '''This class contains base questions read from files, but which are not ready yet. They have to be instantiated for each student.''' #------------------------------------------------------------------------ def add(self, questions, filename, path): # add some defaults if missing from sources for i, q in enumerate(questions): if not isinstance(q, dict): print('[ WARNG ] Question with index {0} in file {1} is not a dict. Ignoring...'.format(i, filename)) continue if q['ref'] in self: print('[ ERROR ] Duplicate question "{0}" in files "{1}" and "{2}".'.format(q['ref'], filename, self[q['ref']]['filename'])) sys.exit(1) # filename and index (number in the file, 0 based) q['filename'] = filename q['path'] = path q['index'] = i # ref (if missing, add 'filename.yaml:3') q['ref'] = str(q.get('ref', filename + ':' + str(i))) # type (default type is 'information') q['type'] = str(q.get('type', 'information')) # optional title (default empty string) q['title'] = str(q.get('title', '')) # add question to the pool self[q['ref']] = q #------------------------------------------------------------------------ def add_from_files(self, files, path='.'): for filename in files: try: with open(os.path.normpath(os.path.join(path, filename)), 'r') as f: questions = yaml.load(f) except(FileNotFoundError): print('[ ERROR ] Questions file "{0}" not found.'.format(filename)) continue except(yaml.parser.ParserError): print('[ ERROR ] Error loading questions in YAML file "{0}".'.format(filename)) continue self.add(questions, filename, path) #============================================================================ # Question Factory # Given a dictionary returns a question instance. def create_question(q): '''To create a question, q must be a dictionary with at least the following keys defined: filename ref type The remaing keys depend on the type of question. ''' if q['type'] == 'generator': q.update(question_generator(q)) # at this point the generator question was replaced by an actual question types = { 'radio' : QuestionRadio, 'checkbox' : QuestionCheckbox, 'text' : QuestionText, 'text_regex': QuestionTextRegex, # 'text-regex': QuestionTextRegex, # 'regex' : QuestionTextRegex, 'textarea' : QuestionTextArea, 'information': QuestionInformation, # 'info' : QuestionInformation, # '' : QuestionInformation, # default } # create instance of given type try: questiontype = types[q['type']] except KeyError: print('[ ERROR ] Unsupported question type "{0}" in "{1}:{2}".'.format(q['type'], q['filename'], q['ref'])) questiontype = Question # create question instance and return try: qinstance = questiontype(q) except: print('[ ERROR ] Creating question "{0}" from file "{1}".'.format(q['ref'], q['filename'])) return qinstance # --------------------------------------------------------------------------- def question_generator(q): '''Run an external script that will generate a question in yaml format. This function will return the yaml converted back to a dict.''' q['arg'] = q.get('arg', '') # send this string to stdin script = os.path.abspath(os.path.normpath(os.path.join(q['path'], q['script']))) try: p = subprocess.Popen([script], stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT) except FileNotFoundError: print('[ ERROR ] Script "{0}" of question "{2}:{1}" not found'.format(script, q['ref'], q['filename'])) sys.exit(1) try: qyaml = p.communicate(input=q['arg'].encode('utf-8'), timeout=5)[0].decode('utf-8') except subprocess.TimeoutExpired: p.kill() return yaml.load(qyaml) # =========================================================================== 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 correct(self): self['grade'] = 0.0 return 0.0 # =========================================================================== 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): # create key/values as given in q super().__init__(q) self['text'] = self.get('text', '') # generate an order for the options, e.g. [0,1,2,3,4] n = len(self['options']) perm = list(range(n)) # shuffle the order, e.g. [2,1,4,0,3] if self.get('shuffle', True): self['shuffle'] = True random.shuffle(perm) else: self['shuffle'] = False # sort options in the given order options = [None] * n # will contain list with shuffled options for i, v in enumerate(self['options']): options[perm[i]] = str(v) # always convert to string self['options'] = options # default correct option is the first one if 'correct' not in self: self['correct'] = 0 # correct can be either an integer with the correct option # or a list of degrees of correction 0..1 # always convert to list, e.g. [0,0,1,0,0] if isinstance(self['correct'], int): correct = [0.0] * n correct[self['correct']] = 1.0 self['correct'] = correct # sort correct in the given order correct = [None] * n for i, v in enumerate(self['correct']): correct[perm[i]] = float(v) self['correct'] = correct self['discount'] = bool(self.get('discount', True)) self['answer'] = None #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self): if self['answer'] is None: x = 0.0 # zero points if no answer given else: 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 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) answer (None or an actual answer) ''' #------------------------------------------------------------------------ def __init__(self, q): # create key/values as given in q super().__init__(q) self['text'] = self.get('text', '') # generate an order for the options, e.g. [0,1,2,3,4] n = len(self['options']) perm = list(range(n)) # shuffle the order, e.g. [2,1,4,0,3] if self.get('shuffle', True): self['shuffle'] = True random.shuffle(perm) else: self['shuffle'] = False # sort options in the given order options = [None] * n # will contain list with shuffled options for i, v in enumerate(self['options']): options[perm[i]] = str(v) # always convert to string self['options'] = options # default is to give zero to all options [0,0,...,0] if 'correct' not in self: self['correct'] = [0.0] * n # sort correct in the given order correct = [None] * n for i, v in enumerate(self['correct']): correct[perm[i]] = float(v) self['correct'] = correct self['discount'] = bool(self.get('discount', True)) self['answer'] = None #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self): if self['answer'] is None: # not answered self['grade'] = 0.0 else: # answered sum_abs = sum(abs(p) for p in self['correct']) if sum_abs < 1e-6: # in case correct: [0,0,0,0,0] 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): # create key/values as given in q super().__init__(q) self['text'] = self.get('text', '') # make sure its always a list of possible correct answers if not isinstance(self['correct'], list): self['correct'] = [self['correct']] # make sure the elements of the list are strings for i, a in enumerate(self['correct']): self['correct'][i] = str(a) self['answer'] = None #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self): if self['answer'] is None: # not answered self['grade'] = 0.0 else: # answered 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): # create key/values as given in q super().__init__(q) self['text'] = self.get('text', '') self['answer'] = None #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self): if self['answer'] is None: # not answered self['grade'] = 0.0 else: # answered 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): # create key/values as given in q super().__init__(q) self['text'] = self.get('text', '') self['answer'] = None self['lines'] = self.get('lines', 8) #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self): if self['answer'] is None: # not answered self['grade'] = 0.0 else: # answered # The correction program expects data from stdin and prints the result to stdout. # The result should be a string that can be parsed to a float. script = os.path.abspath(os.path.normpath(os.path.join(self['path'], self['correct']))) try: p = subprocess.Popen([script], stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT) except FileNotFoundError as e: print(' * Script "{0}" defined in question "{1}" of file "{2}" could not be found'.format(self['correct'], self['ref'], self['filename'])) raise e try: value = p.communicate(input=self['answer'].encode('utf-8'), timeout=5)[0].decode('utf-8') # esta a dar erro! except subprocess.TimeoutExpired: p.kill() # p.communicate() # FIXME parece que este communicate obriga a ficar ate ao final do processo, mas nao consigo repetir o comportamento num script simples... value = 0.0 # student gets a zero if timout occurs printf(' * Timeout in correction script') # In case the correction program returns a non float value, we assume an error # has occurred (for instance, invalid input). We just assume its the student's # fault and give him Zero. try: self['grade'] = float(value) except (ValueError): self['grade'] = 0.0 raise Exception('Correction of question "{0}" returned nonfloat "{1}".'.format(self['ref'], value)) 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): # create key/values as given in q super().__init__(q) self['text'] = self.get('text', '') self['points'] = 0.0 # always override the points #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self): self['grade'] = 1.0 # always "correct" but points should be zero! return self['grade']