diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index b53f38fd90..02767514a3 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -35,17 +35,17 @@ from calc import evaluator, UndefinedVariable from . import correctmap from datetime import datetime from pytz import UTC -from .util import * +from .util import compare_with_tolerance, contextualize_text, convert_files_to_filenames, is_list_of_files, find_with_default from lxml import etree from lxml.html.soupparser import fromstring as fromstring_bs # uses Beautiful Soup!!! FIXME? import capa.xqueue_interface as xqueue_interface -import safe_exec +import capa.safe_exec as safe_exec log = logging.getLogger(__name__) -CorrectMap = correctmap.CorrectMap +CorrectMap = correctmap.CorrectMap # pylint: disable=C0103 CORRECTMAP_PY = None @@ -1181,7 +1181,7 @@ class CustomResponse(LoncapaResponse): fn = self.code answer_given = submission[0] if (len(idset) == 1) else submission kwnames = self.xml.get("cfn_extra_args", "").split() - kwargs = {n:self.context.get(n) for n in kwnames} + kwargs = {n: self.context.get(n) for n in kwnames} log.debug(" submission = %s" % submission) try: ret = fn(self.expect, answer_given, **kwargs) @@ -1304,7 +1304,7 @@ class CustomResponse(LoncapaResponse): # Notify student with a student input error _, _, traceback_obj = sys.exc_info() - raise ResponseError, err.message, traceback_obj + raise ResponseError(err.message, traceback_obj) #----------------------------------------------------------------------------- @@ -1597,15 +1597,25 @@ class CodeResponse(LoncapaResponse): class ExternalResponse(LoncapaResponse): - ''' + """ Grade the students input using an external server. Typically used by coding problems. - ''' + """ response_tag = 'externalresponse' allowed_inputfields = ['textline', 'textbox'] + awdmap = { + 'EXACT_ANS': 'correct', # TODO: handle other loncapa responses + 'WRONG_FORMAT': 'incorrect', + } + + def __init__(self, *args, **kwargs): + self.url = '' + self.tests = [] + self.code = '' + super(ExternalResponse, self).__init__(*args, **kwargs) def setup_response(self): xml = self.xml @@ -1633,45 +1643,44 @@ class ExternalResponse(LoncapaResponse): self.tests = xml.get('tests') def do_external_request(self, cmd, extra_payload): - ''' + """ Perform HTTP request / post to external server. cmd = remote command to perform (str) extra_payload = dict of extra stuff to post. Return XML tree of response (from response body) - ''' + """ xmlstr = etree.tostring(self.xml, pretty_print=True) - payload = {'xml': xmlstr, - 'edX_cmd': cmd, - 'edX_tests': self.tests, - 'processor': self.code, - } + payload = { + 'xml': xmlstr, + 'edX_cmd': cmd, + 'edX_tests': self.tests, + 'processor': self.code, + } payload.update(extra_payload) try: # call external server. TODO: synchronous call, can block for a # long time - r = requests.post(self.url, data=payload) + req = requests.post(self.url, data=payload) except Exception as err: - msg = 'Error %s - cannot connect to external server url=%s' % ( - err, self.url) + msg = 'Error {0} - cannot connect to external server url={1}'.format(err, self.url) log.error(msg) raise Exception(msg) if self.system.DEBUG: - log.info('response = %s' % r.text) + log.info('response = %s', req.text) - if (not r.text) or (not r.text.strip()): + if (not req.text) or (not req.text.strip()): raise Exception( 'Error: no response from external server url=%s' % self.url) try: # response is XML; parse it - rxml = etree.fromstring(r.text) + rxml = etree.fromstring(req.text) except Exception as err: - msg = 'Error %s - cannot parse response from external server r.text=%s' % ( - err, r.text) + msg = 'Error {0} - cannot parse response from external server req.text={1}'.format(err, req.text) log.error(msg) raise Exception(msg) @@ -1682,9 +1691,13 @@ class ExternalResponse(LoncapaResponse): cmap = CorrectMap() try: submission = [student_answers[k] for k in idset] - except Exception as err: - log.error('Error %s: cannot get student answer for %s; student_answers=%s' % - (err, self.answer_ids, student_answers)) + except Exception as err: # pylint: disable=W0703 + log.error( + 'Error %s: cannot get student answer for %s; student_answers=%s', + err, + self.answer_ids, + student_answers + ) raise Exception(err) self.context.update({'submission': submission}) @@ -1693,8 +1706,8 @@ class ExternalResponse(LoncapaResponse): try: rxml = self.do_external_request('get_score', extra_payload) - except Exception as err: - log.error('Error %s' % err) + except Exception as err: # pylint: disable=W0703 + log.error('Error %s', err) if self.system.DEBUG: cmap.set_dict(dict(zip(sorted( self.answer_ids), ['incorrect'] * len(idset)))) @@ -1703,13 +1716,11 @@ class ExternalResponse(LoncapaResponse): '%s' % str(err).replace('<', '<')) return cmap - ad = rxml.find('awarddetail').text - admap = {'EXACT_ANS': 'correct', # TODO: handle other loncapa responses - 'WRONG_FORMAT': 'incorrect', - } + awd = rxml.find('awarddetail').text + self.context['correct'] = ['correct'] - if ad in admap: - self.context['correct'][0] = admap[ad] + if awd in self.awdmap: + self.context['correct'][0] = self.awdmap[awd] # create CorrectMap for key in idset: @@ -1721,14 +1732,14 @@ class ExternalResponse(LoncapaResponse): return cmap def get_answers(self): - ''' + """ Use external server to get expected answers - ''' + """ try: rxml = self.do_external_request('get_answers', {}) exans = json.loads(rxml.find('expected').text) - except Exception as err: - log.error('Error %s' % err) + except Exception as err: # pylint: disable=W0703 + log.error('Error %s', err) if self.system.DEBUG: msg = '%s' % str( err).replace('<', '<') @@ -1736,8 +1747,8 @@ class ExternalResponse(LoncapaResponse): exans[0] = msg if not (len(exans) == len(self.answer_ids)): - log.error('Expected %d answers from external server, only got %d!' % - (len(self.answer_ids), len(exans))) + log.error('Expected %s answers from external server, only got %s!', + len(self.answer_ids), len(exans)) raise Exception('Short response from external server') return dict(zip(self.answer_ids, exans)) @@ -1745,9 +1756,9 @@ class ExternalResponse(LoncapaResponse): #----------------------------------------------------------------------------- class FormulaResponse(LoncapaResponse): - ''' + """ Checking of symbolic math response using numerical sampling. - ''' + """ response_tag = 'formularesponse' hint_tag = 'formulahint' @@ -1776,11 +1787,11 @@ class FormulaResponse(LoncapaResponse): if tolerance_xml: # If it isn't an empty list... self.tolerance = contextualize_text(tolerance_xml[0], context) - ts = xml.get('type') - if ts is None: + types = xml.get('type') + if types is None: typeslist = [] else: - typeslist = ts.split(',') + typeslist = types.split(',') if 'ci' in typeslist: # Case insensitive self.case_sensitive = False @@ -1812,30 +1823,33 @@ class FormulaResponse(LoncapaResponse): answer, case_sensitive=self.case_sensitive, )) - except UndefinedVariable as uv: + except UndefinedVariable as err: log.debug( - 'formularesponse: undefined variable in formula=%s' % answer) - raise StudentInputError( - "Invalid input: " + uv.message + " not permitted in answer" + 'formularesponse: undefined variable in formula=%s', + cgi.escape(answer) ) - except ValueError as ve: - if 'factorial' in ve.message: + raise StudentInputError( + "Invalid input: " + err.message + " not permitted in answer" + ) + except ValueError as err: + if 'factorial' in err.message: # This is thrown when fact() or factorial() is used in a formularesponse answer # that tests on negative and/or non-integer inputs - # ve.message will be: `factorial() only accepts integral values` or + # err.message will be: `factorial() only accepts integral values` or # `factorial() not defined for negative values` log.debug( ('formularesponse: factorial function used in response ' 'that tests negative and/or non-integer inputs. ' - 'given={0}').format(given) + 'Provided answer was: %s'), + cgi.escape(answer) ) raise StudentInputError( ("factorial function not permitted in answer " "for this problem. Provided answer was: " - "{0}").format(cgi.escape(given)) + "{0}").format(cgi.escape(answer)) ) # If non-factorial related ValueError thrown, handle it the same as any other Exception - log.debug('formularesponse: error {0} in formula'.format(ve)) + log.debug('formularesponse: error %s in formula', err) raise StudentInputError("Invalid input: Could not parse '%s' as a formula" % cgi.escape(answer)) except Exception as err: @@ -1857,7 +1871,7 @@ class FormulaResponse(LoncapaResponse): ranges = dict(zip(variables, sranges)) out = [] - for i in range(numsamples): + for _ in range(numsamples): var_dict = {} # ranges give numerical ranges for testing for var in ranges: @@ -1902,15 +1916,17 @@ class FormulaResponse(LoncapaResponse): except StudentInputError: return False - def strip_dict(self, d): - ''' Takes a dict. Returns an identical dict, with all non-word + def strip_dict(self, inp_d): + """ + Takes a dict. Returns an identical dict, with all non-word keys and all non-numeric values stripped out. All values also converted to float. Used so we can safely use Python contexts. - ''' - d = dict([(k, numpy.complex(d[k])) for k in d if type(k) == str and - k.isalnum() and - isinstance(d[k], numbers.Number)]) - return d + """ + inp_d = dict([(k, numpy.complex(inp_d[k])) + for k in inp_d if type(k) == str and + k.isalnum() and + isinstance(inp_d[k], numbers.Number)]) + return inp_d def check_hint_condition(self, hxml_set, student_answers): given = student_answers[self.answer_id] @@ -1920,14 +1936,18 @@ class FormulaResponse(LoncapaResponse): name = hxml.get('name') correct_answer = contextualize_text( hxml.get('answer'), self.context) + # pylint: disable=W0703 try: correctness = self.check_formula( - correct_answer, given, samples) + correct_answer, + given, + samples + ) except Exception: correctness = 'incorrect' if correctness == 'correct': hints_to_show.append(name) - log.debug('hints_to_show = %s' % hints_to_show) + log.debug('hints_to_show = %s', hints_to_show) return hints_to_show def get_answers(self): @@ -1937,10 +1957,16 @@ class FormulaResponse(LoncapaResponse): class SchematicResponse(LoncapaResponse): - + """ + Circuit schematic response type. + """ response_tag = 'schematicresponse' allowed_inputfields = ['schematic'] + def __init__(self, *args, **kwargs): + self.code = '' + super(SchematicResponse, self).__init__(*args, **kwargs) + def setup_response(self): xml = self.xml answer = xml.xpath('//*[@id=$id]//answer', id=xml.get('id'))[0] @@ -2010,6 +2036,10 @@ class ImageResponse(LoncapaResponse): response_tag = 'imageresponse' allowed_inputfields = ['imageinput'] + def __init__(self, *args, **kwargs): + self.ielements = [] + super(ImageResponse, self).__init__(*args, **kwargs) + def setup_response(self): self.ielements = self.inputfields self.answer_ids = [ie.get('id') for ie in self.ielements] @@ -2018,40 +2048,39 @@ class ImageResponse(LoncapaResponse): correct_map = CorrectMap() expectedset = self.get_mapped_answers() for aid in self.answer_ids: # loop through IDs of - # fields in our stanza - given = student_answers[ - aid] # this should be a string of the form '[x,y]' + # Fields in our stanza + given = student_answers[aid] # This should be a string of the form '[x,y]' correct_map.set(aid, 'incorrect') if not given: # No answer to parse. Mark as incorrect and move on continue - # parse given answer - m = re.match(r'\[([0-9]+),([0-9]+)]', given.strip().replace(' ', '')) - if not m: + # Parse given answer + acoords = re.match(r'\[([0-9]+),([0-9]+)]', given.strip().replace(' ', '')) + if not acoords: raise Exception('[capamodule.capa.responsetypes.imageinput] ' - 'error grading %s (input=%s)' % (aid, given)) - (gx, gy) = [int(x) for x in m.groups()] + 'error grading {0} (input={1})'.format(aid, given)) + (ans_x, ans_y) = [int(x) for x in acoords.groups()] rectangles, regions = expectedset - if rectangles[aid]: # rectangles part - for backward compatibility + if rectangles[aid]: # Rectangles part - for backward compatibility # Check whether given point lies in any of the solution # rectangles solution_rectangles = rectangles[aid].split(';') for solution_rectangle in solution_rectangles: # parse expected answer # TODO: Compile regexp on file load - m = re.match( + sr_coords = re.match( r'[\(\[]([0-9]+),([0-9]+)[\)\]]-[\(\[]([0-9]+),([0-9]+)[\)\]]', solution_rectangle.strip().replace(' ', '')) - if not m: + if not sr_coords: msg = 'Error in problem specification! cannot parse rectangle in %s' % ( etree.tostring(self.ielements[aid], pretty_print=True)) raise Exception( '[capamodule.capa.responsetypes.imageinput] ' + msg) - (llx, lly, urx, ury) = [int(x) for x in m.groups()] + (llx, lly, urx, ury) = [int(x) for x in sr_coords.groups()] # answer is correct if (x,y) is within the specified # rectangle - if (llx <= gx <= urx) and (lly <= gy <= ury): + if (llx <= ans_x <= urx) and (lly <= ans_y <= ury): correct_map.set(aid, 'correct') break if correct_map[aid]['correctness'] != 'correct' and regions[aid]: @@ -2065,13 +2094,13 @@ class ImageResponse(LoncapaResponse): for region in parsed_region: polygon = MultiPoint(region).convex_hull if (polygon.type == 'Polygon' and - polygon.contains(Point(gx, gy))): + polygon.contains(Point(ans_x, ans_y))): correct_map.set(aid, 'correct') break return correct_map def get_mapped_answers(self): - ''' + """ Returns the internal representation of the answers Input: @@ -2080,7 +2109,7 @@ class ImageResponse(LoncapaResponse): tuple (dict, dict) - rectangles (dict) - a map of inputs to the defined rectangle for that input regions (dict) - a map of inputs to the defined region for that input - ''' + """ answers = ( dict([(ie.get('id'), ie.get( 'rectangle')) for ie in self.ielements]), @@ -2088,7 +2117,7 @@ class ImageResponse(LoncapaResponse): return answers def get_answers(self): - ''' + """ Returns the external representation of the answers Input: @@ -2096,11 +2125,11 @@ class ImageResponse(LoncapaResponse): Returns: dict (str, (str, str)) - a map of inputs to a tuple of their rectange and their regions - ''' + """ answers = {} - for ie in self.ielements: - ie_id = ie.get('id') - answers[ie_id] = (ie.get('rectangle'), ie.get('regions')) + for ielt in self.ielements: + ie_id = ielt.get('id') + answers[ie_id] = (ielt.get('rectangle'), ielt.get('regions')) return answers @@ -2108,26 +2137,32 @@ class ImageResponse(LoncapaResponse): class AnnotationResponse(LoncapaResponse): - ''' + """ Checking of annotation responses. The response contains both a comment (student commentary) and an option (student tag). Only the tag is currently graded. Answers may be incorrect, partially correct, or correct. - ''' + """ response_tag = 'annotationresponse' allowed_inputfields = ['annotationinput'] max_inputfields = 1 default_scoring = {'incorrect': 0, 'partially-correct': 1, 'correct': 2} + def __init__(self, *args, **kwargs): + self.scoring_map = {} + self.answer_map = {} + super(AnnotationResponse, self).__init__(*args, **kwargs) + def setup_response(self): - xml = self.xml self.scoring_map = self._get_scoring_map() self.answer_map = self._get_answer_map() self.maxpoints = self._get_max_points() def get_score(self, student_answers): - ''' Returns a CorrectMap for the student answer, which may include - partially correct answers.''' + """ + Returns a CorrectMap for the student answer, which may include + partially correct answers. + """ student_answer = student_answers[self.answer_id] student_option = self._get_submitted_option_id(student_answer) @@ -2146,23 +2181,26 @@ class AnnotationResponse(LoncapaResponse): return self.answer_map def _get_scoring_map(self): - ''' Returns a dict of option->scoring for each input. ''' + """Returns a dict of option->scoring for each input.""" scoring = self.default_scoring choices = dict([(choice, choice) for choice in scoring]) scoring_map = {} for inputfield in self.inputfields: - option_scoring = dict([(option['id'], { + option_scoring = dict([( + option['id'], + { 'correctness': choices.get(option['choice']), 'points': scoring.get(option['choice']) - }) for option in self._find_options(inputfield)]) + } + ) for option in self._find_options(inputfield)]) scoring_map[inputfield.get('id')] = option_scoring return scoring_map def _get_answer_map(self): - ''' Returns a dict of answers for each input.''' + """Returns a dict of answers for each input.""" answer_map = {} for inputfield in self.inputfields: correct_option = self._find_option_with_choice( @@ -2173,13 +2211,13 @@ class AnnotationResponse(LoncapaResponse): return answer_map def _get_max_points(self): - ''' Returns a dict of the max points for each input: input id -> maxpoints. ''' + """Returns a dict of the max points for each input: input id -> maxpoints.""" scoring = self.default_scoring correct_points = scoring.get('correct') return dict([(inputfield.get('id'), correct_points) for inputfield in self.inputfields]) def _find_options(self, inputfield): - ''' Returns an array of dicts where each dict represents an option. ''' + """Returns an array of dicts where each dict represents an option. """ elements = inputfield.findall('./options/option') return [{ 'id': index, @@ -2188,22 +2226,22 @@ class AnnotationResponse(LoncapaResponse): } for (index, option) in enumerate(elements)] def _find_option_with_choice(self, inputfield, choice): - ''' Returns the option with the given choice value, otherwise None. ''' + """Returns the option with the given choice value, otherwise None. """ for option in self._find_options(inputfield): if option['choice'] == choice: return option def _unpack(self, json_value): - ''' Unpacks a student response value submitted as JSON.''' - d = json.loads(json_value) - if type(d) != dict: - d = {} + """Unpacks a student response value submitted as JSON.""" + json_d = json.loads(json_value) + if type(json_d) != dict: + json_d = {} - comment_value = d.get('comment', '') - if not isinstance(d, basestring): + comment_value = json_d.get('comment', '') + if not isinstance(json_d, basestring): comment_value = '' - options_value = d.get('options', []) + options_value = json_d.get('options', []) if not isinstance(options_value, list): options_value = [] @@ -2213,7 +2251,7 @@ class AnnotationResponse(LoncapaResponse): } def _get_submitted_option_id(self, student_answer): - ''' Return the single option that was selected, otherwise None.''' + """Return the single option that was selected, otherwise None.""" submitted = self._unpack(student_answer) option_ids = submitted['options_value'] if len(option_ids) == 1: @@ -2235,6 +2273,12 @@ class ChoiceTextResponse(LoncapaResponse): 'radiotextgroup' ] + def __init__(self, *args, **kwargs): + self.correct_inputs = {} + self.answer_values = {} + self.correct_choices = {} + super(ChoiceTextResponse, self).__init__(*args, **kwargs) + def setup_response(self): """ Sets up three dictionaries for use later: @@ -2250,10 +2294,8 @@ class ChoiceTextResponse(LoncapaResponse): """ context = self.context - self.correct_choices = {} - self.assign_choice_names() - self.correct_inputs = {} self.answer_values = {self.answer_id: []} + self.assign_choice_names() correct_xml = self.xml.xpath('//*[@id=$id]//choice[@correct="true"]', id=self.xml.get('id')) for node in correct_xml: @@ -2552,6 +2594,7 @@ class ChoiceTextResponse(LoncapaResponse): # TEMPORARY: List of all response subclasses # FIXME: To be replaced by auto-registration +# pylint: disable=E0604 __all__ = [CodeResponse, NumericalResponse, FormulaResponse,