''' Classes the implement several types of questions. ''' # python standard library import asyncio from datetime import datetime import logging from os import path import random import re from typing import Any, Dict, NewType import uuid from urllib.error import HTTPError import json import http.client # this project from perguntations.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 set_answer(self, ans) -> None: '''set answer field and register time''' self['answer'] = ans self['finish_time'] = datetime.now() 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) try: nopts = len(self['options']) except KeyError as exc: msg = f'Missing `options`. In question "{self["ref"]}"' logger.error(msg) raise QuestionException(msg) from exc except TypeError as exc: msg = f'`options` must be a list. In question "{self["ref"]}"' logger.error(msg) raise QuestionException(msg) from exc 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` out of range 0..{nopts-1}. ' f'In question "{self["ref"]}"') logger.error(msg) 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'{nopts} options vs {len(self["correct"])} correct. ' f'In question "{self["ref"]}"') logger.error(msg) raise QuestionException(msg) # make sure is a list of floats try: self['correct'] = [float(x) for x in self['correct']] except (ValueError, TypeError) as exc: msg = ('`correct` must be list of numbers or booleans.' f'In "{self["ref"]}"') logger.error(msg) raise QuestionException(msg) from exc # check grade boundaries if self['discount'] and not all(0.0 <= x <= 1.0 for x in self['correct']): msg = ('`correct` values must be in the interval [0.0, 1.0]. ' f'In "{self["ref"]}"') logger.error(msg) raise QuestionException(msg) # at least one correct option if all(x < 1.0 for x in self['correct']): msg = ('At least one correct option is required. ' f'In "{self["ref"]}"') logger.error(msg) 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] # ------------------------------------------------------------------------ def correct(self) -> None: ''' Correct `answer` and set `grade`. Can assign negative grades for wrong answers ''' 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) try: nopts = len(self['options']) except KeyError as exc: msg = f'Missing `options`. In question "{self["ref"]}"' logger.error(msg) raise QuestionException(msg) from exc except TypeError as exc: msg = f'`options` must be a list. In question "{self["ref"]}"' logger.error(msg) raise QuestionException(msg) from exc # 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' logger.error(msg) raise QuestionException(msg) # must match number of options if len(self['correct']) != nopts: msg = (f'{nopts} options vs {len(self["correct"])} correct. ' f'In question "{self["ref"]}"') logger.error(msg) raise QuestionException(msg) # make sure is a list of floats try: self['correct'] = [float(x) for x in self['correct']] except (ValueError, TypeError) as exc: msg = ('`correct` must be list of numbers or booleans.' f'In "{self["ref"]}"') logger.error(msg) raise QuestionException(msg) from exc # check grade boundaries if self['discount'] and not all(0.0 <= x <= 1.0 for x in self['correct']): msg = ('values in the `correct` field of checkboxes must be in ' 'the [0.0, 1.0] interval. ' f'Please fix "{self["ref"]}" in "{self["path"]}"') logger.error(msg) raise QuestionException(msg) # 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 as exc: msg = f'Failed to compile regex in "{self["ref"]}"' logger.error(msg) raise QuestionException(msg) from exc # ------------------------------------------------------------------------ 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"]}') logger.error(msg) raise QuestionException(msg) try: self['correct'] = [float(n) for n in self['correct']] except Exception as exc: msg = (f'Numeric interval must be a list with two numbers, in ' f'{self["ref"]}') logger.error(msg) raise QuestionException(msg) from exc # invalid else: msg = (f'Numeric interval must be a list with two numbers, in ' f'{self["ref"]}') logger.error(msg) 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['comments'] = 'O programa de correcção abortou...' 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['comments'] = 'O programa de correcção abortou...' 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 QuestionCode(Question): '''An instance of QuestionCode will always have the keys: type (str) text (str) correct (str with script to run) answer (None or an actual answer) ''' _outcomes = { 0: 'JOBE outcome: Successful run', 11: 'JOBE outcome: Compile error', 12: 'JOBE outcome: Runtime error', 13: 'JOBE outcome: Time limit exceeded', 15: 'JOBE outcome: Successful run', 17: 'JOBE outcome: Memory limit exceeded', 19: 'JOBE outcome: Illegal system call', 20: 'JOBE outcome: Internal error, please report', 21: 'JOBE outcome: Server overload', } # ------------------------------------------------------------------------ def __init__(self, q: QDict) -> None: super().__init__(q) self.set_defaults(QDict({ 'text': '', 'timeout': 5, # seconds 'server': '127.0.0.1', # JOBE server 'language': 'c', 'correct': [{'stdin': '', 'stdout': '', 'stderr': '', 'args': ''}], })) # ------------------------------------------------------------------------ def correct(self) -> None: super().correct() if self['answer'] is None: return # submit answer to JOBE server resource = '/jobe/index.php/restapi/runs/' headers = {"Content-type": "application/json; charset=utf-8", "Accept": "application/json"} for expected in self['correct']: data_json = json.dumps({ 'run_spec' : { 'language_id': self['language'], 'sourcecode': self['answer'], 'input': expected.get('stdin', ''), }, }) try: connect = http.client.HTTPConnection(self['server']) connect.request( method='POST', url=resource, body=data_json, headers=headers ) response = connect.getresponse() logger.debug('JOBE response status %d', response.status) if response.status != 204: content = response.read().decode('utf8') if content: result = json.loads(content) connect.close() except (HTTPError, ValueError): logger.error('HTTPError while connecting to JOBE server') try: outcome = result['outcome'] except (NameError, TypeError, KeyError): logger.error('Bad result returned from JOBE server: %s', result) return logger.debug(self._outcomes[outcome]) if result['cmpinfo']: # compiler errors and warnings self['comments'] = f'Erros de compilação:\n{result["cmpinfo"]}' self['grade'] = 0.0 return if result['stdout'] != expected['stdout']: self['comments'] = 'O output gerado é diferente do esperado.' self['grade'] = 0.0 return self['comments'] = 'Ok!' self['grade'] = 1.0 # ------------------------------------------------------------------------ async def correct_async(self) -> None: self.correct() # 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['comments'] = 'O programa de correcção abortou...' # 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, 'code': QuestionCode, # -- 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". See "%s/%s"', question['ref'], question['path'], question['filename']) raise return qinstance # ------------------------------------------------------------------------ def generate(self) -> Question: '''generate question (synchronous version)''' return asyncio.get_event_loop().run_until_complete(self.gen_async())