fix: align pylint fixes in edx-platform Problem XBlock with extracted xblocks-contrib/problem (#37758)

* fix: pylint issues for problem xblock
This commit is contained in:
Irtaza Akram
2026-01-07 16:39:11 +05:00
committed by GitHub
parent 2f065bbae4
commit d29b0473f4
49 changed files with 2376 additions and 1623 deletions

View File

@@ -28,7 +28,7 @@ from xmodule.capa.tests.response_xml_factory import (
OptionResponseXMLFactory,
SchematicResponseXMLFactory
)
from xmodule.capa.tests.test_util import use_unsafe_codejail
from xmodule.capa.tests.test_util import UseUnsafeCodejail
from xmodule.capa.xqueue_interface import XQueueInterface
from common.djangoapps.course_modes.models import CourseMode
from lms.djangoapps.courseware.models import BaseStudentModuleHistory, StudentModule
@@ -811,7 +811,7 @@ class ProblemWithUploadedFilesTest(TestSubmittingProblems):
self.assertEqual(list(kwargs['files'].keys()), filenames.split())
@use_unsafe_codejail()
@UseUnsafeCodejail()
class TestPythonGradedResponse(TestSubmittingProblems):
"""
Check that we can submit a schematic and custom response, and it answers properly.

View File

@@ -22,7 +22,7 @@ from django.urls import reverse
from xmodule.capa.responsetypes import StudentInputError
from xmodule.capa.tests.response_xml_factory import CodeResponseXMLFactory, CustomResponseXMLFactory
from xmodule.capa.tests.test_util import use_unsafe_codejail
from xmodule.capa.tests.test_util import UseUnsafeCodejail
from lms.djangoapps.courseware.model_data import StudentModule
from lms.djangoapps.grades.api import CourseGradeFactory
from lms.djangoapps.instructor_task.api import (
@@ -73,7 +73,7 @@ class TestIntegrationTask(InstructorTaskModuleTestCase):
@ddt.ddt
@override_settings(RATELIMIT_ENABLE=False)
@use_unsafe_codejail()
@UseUnsafeCodejail()
class TestRescoringTask(TestIntegrationTask):
"""
Integration-style tests for rescoring problems in a background task.

View File

@@ -26,11 +26,13 @@ class FormatHtmlTest(unittest.TestCase):
("<a>нтмℓ-єѕ¢αρє∂</a>", "&lt;a&gt;нтмℓ-єѕ¢αρє∂&lt;/a&gt;"),
)
def test_simple(self, before_after):
"""Verify that plain text is safely HTML-escaped."""
(before, after) = before_after
assert str(Text(_(before))) == after # pylint: disable=translation-of-non-string
assert str(Text(before)) == after
def test_formatting(self):
"""Ensure Text.format correctly mixes escaped text with raw HTML."""
# The whole point of this function is to make sure this works:
out = Text(_("Point & click {start}here{end}!")).format(
start=HTML("<a href='http://edx.org'>"),
@@ -39,6 +41,7 @@ class FormatHtmlTest(unittest.TestCase):
assert str(out) == "Point &amp; click <a href='http://edx.org'>here</a>!"
def test_nested_formatting(self):
"""Validate nested formatting where HTML contains formatted text."""
# Sometimes, you have plain text, with html inserted, and the html has
# plain text inserted. It gets twisty...
out = Text(_("Send {start}email{end}")).format(
@@ -48,6 +51,7 @@ class FormatHtmlTest(unittest.TestCase):
assert str(out) == "Send <a href='mailto:A&amp;B'>email</a>"
def test_mako(self):
"""Confirm Mako templates format Text/HTML objects with expected filters."""
# The default_filters used here have to match the ones in edxmako.
template = Template(
"""
@@ -64,6 +68,7 @@ class FormatHtmlTest(unittest.TestCase):
assert out.strip() == "A &amp; B & C"
def test_ungettext(self):
"""Check that ngettext output is properly formatted and HTML-escaped."""
for i in [1, 2]:
out = Text(ngettext("1 & {}", "2 & {}", i)).format(HTML("<>"))
assert out == f"{i} &amp; <>"

View File

@@ -22,7 +22,8 @@ class RestrictedElement(_etree.ElementBase):
__slots__ = ()
blacklist = (_etree._Entity, _etree._ProcessingInstruction, _etree._Comment) # pylint: disable=protected-access
def _filter(self, iterator): # pylint: disable=missing-function-docstring
def _filter(self, iterator):
"""Yield only elements not in the blacklist from the given iterator."""
blacklist = self.blacklist
for child in iterator:
if isinstance(child, blacklist):
@@ -30,37 +31,37 @@ class RestrictedElement(_etree.ElementBase):
yield child
def __iter__(self):
iterator = super(RestrictedElement, self).__iter__() # pylint: disable=super-with-arguments
iterator = super().__iter__()
return self._filter(iterator)
def iterchildren(self, tag=None, reversed=False): # pylint: disable=redefined-builtin
iterator = super(RestrictedElement, self).iterchildren( # pylint: disable=super-with-arguments
tag=tag, reversed=reversed
)
"""Iterate over child elements while excluding blacklisted nodes."""
iterator = super().iterchildren(tag=tag, reversed=reversed)
return self._filter(iterator)
def iter(self, tag=None, *tags): # pylint: disable=keyword-arg-before-vararg
iterator = super(RestrictedElement, self).iter(tag=tag, *tags) # pylint: disable=super-with-arguments
"""Iterate over the element tree excluding blacklisted nodes."""
iterator = super().iter(tag=tag, *tags)
return self._filter(iterator)
def iterdescendants(self, tag=None, *tags): # pylint: disable=keyword-arg-before-vararg
iterator = super(RestrictedElement, self).iterdescendants( # pylint: disable=super-with-arguments
tag=tag, *tags
)
"""Iterate over descendants while filtering out blacklisted nodes."""
iterator = super().iterdescendants(tag=tag, *tags)
return self._filter(iterator)
def itersiblings(self, tag=None, preceding=False):
iterator = super(RestrictedElement, self).itersiblings( # pylint: disable=super-with-arguments
tag=tag, preceding=preceding
)
"""Iterate over siblings excluding blacklisted node types."""
iterator = super().itersiblings(tag=tag, preceding=preceding)
return self._filter(iterator)
def getchildren(self):
iterator = super(RestrictedElement, self).__iter__() # pylint: disable=super-with-arguments
"""Return a list of non-blacklisted child elements."""
iterator = super().__iter__()
return list(self._filter(iterator))
def getiterator(self, tag=None):
iterator = super(RestrictedElement, self).getiterator(tag) # pylint: disable=super-with-arguments
"""Iterate over the tree with blacklisted nodes filtered out."""
iterator = super().getiterator(tag)
return self._filter(iterator)
@@ -73,7 +74,8 @@ class GlobalParserTLS(threading.local):
element_class = RestrictedElement
def createDefaultParser(self): # pylint: disable=missing-function-docstring
def create_default_parser(self):
"""Create a secure XMLParser using the restricted element class."""
parser = _etree.XMLParser(**self.parser_config)
element_class = self.element_class
if self.element_class is not None:
@@ -81,19 +83,21 @@ class GlobalParserTLS(threading.local):
parser.set_element_class_lookup(lookup)
return parser
def setDefaultParser(self, parser):
def set_default_parser(self, parser):
"""Store a thread-local default XML parser instance."""
self._default_parser = parser # pylint: disable=attribute-defined-outside-init
def getDefaultParser(self): # pylint: disable=missing-function-docstring
def get_default_parser(self):
"""Return the thread-local default parser, creating it if missing."""
parser = getattr(self, "_default_parser", None)
if parser is None:
parser = self.createDefaultParser()
self.setDefaultParser(parser)
parser = self.create_default_parser()
self.set_default_parser(parser)
return parser
_parser_tls = GlobalParserTLS()
getDefaultParser = _parser_tls.getDefaultParser
get_default_parser = _parser_tls.get_default_parser
def check_docinfo(elementtree, forbid_dtd=False, forbid_entities=True):
@@ -107,9 +111,7 @@ def check_docinfo(elementtree, forbid_dtd=False, forbid_entities=True):
raise DTDForbidden(docinfo.doctype, docinfo.system_url, docinfo.public_id)
if forbid_entities and not LXML3:
# lxml < 3 has no iterentities()
raise NotSupportedError(
"Unable to check for entity declarations in lxml 2.x"
) # pylint: disable=implicit-str-concat
raise NotSupportedError("Unable to check for entity declarations in lxml 2.x")
if forbid_entities:
for dtd in docinfo.internalDTD, docinfo.externalDTD:
@@ -119,29 +121,28 @@ def check_docinfo(elementtree, forbid_dtd=False, forbid_entities=True):
raise EntitiesForbidden(entity.name, entity.content, None, None, None, None)
def parse(
source, parser=None, base_url=None, forbid_dtd=False, forbid_entities=True
): # pylint: disable=missing-function-docstring
def parse(source, parser=None, base_url=None, forbid_dtd=False, forbid_entities=True):
"""Securely parse XML from a source and enforce DTD/entity restrictions."""
if parser is None:
parser = getDefaultParser()
parser = get_default_parser()
elementtree = _etree.parse(source, parser, base_url=base_url)
check_docinfo(elementtree, forbid_dtd, forbid_entities)
return elementtree
def fromstring(
text, parser=None, base_url=None, forbid_dtd=False, forbid_entities=True
): # pylint: disable=missing-function-docstring
def fromstring(text, parser=None, base_url=None, forbid_dtd=False, forbid_entities=True):
"""Securely parse XML from a string and validate docinfo."""
if parser is None:
parser = getDefaultParser()
parser = get_default_parser()
rootelement = _etree.fromstring(text, parser, base_url=base_url)
elementtree = rootelement.getroottree()
check_docinfo(elementtree, forbid_dtd, forbid_entities)
return rootelement
XML = fromstring
XML = fromstring # pylint: disable=invalid-name
def iterparse(*args, **kwargs):
"""Disabled XML iterparse function that always raises NotSupportedError."""
raise NotSupportedError("iterparse not available")

View File

@@ -1,3 +1,4 @@
# pylint: disable=too-many-lines
#
# File: capa/capa_problem.py
#
@@ -27,12 +28,9 @@ from django.conf import settings
from lxml import etree
from pytz import UTC
import xmodule.capa.customrender as customrender
import xmodule.capa.inputtypes as inputtypes
import xmodule.capa.responsetypes as responsetypes
import xmodule.capa.xqueue_interface as xqueue_interface
from openedx.core.djangolib.markup import HTML, Text
from openedx.core.lib.safe_lxml.xmlparser import XML
from xmodule.capa import customrender, inputtypes, responsetypes, xqueue_interface
from xmodule.capa.correctmap import CorrectMap
from xmodule.capa.safe_exec import safe_exec
from xmodule.capa.util import contextualize_text, convert_files_to_filenames, get_course_id_from_capa_block
@@ -80,7 +78,7 @@ log = logging.getLogger(__name__)
# main class for this module
class LoncapaSystem(object):
class LoncapaSystem: # pylint: disable=too-few-public-methods,too-many-instance-attributes
"""
An encapsulation of resources needed from the outside.
@@ -96,14 +94,14 @@ class LoncapaSystem(object):
"""
def __init__(
def __init__( # pylint: disable=too-many-positional-arguments,too-many-arguments
self,
ajax_url,
anonymous_student_id,
cache,
can_execute_unsafe_code,
get_python_lib_zip,
DEBUG,
DEBUG, # pylint: disable=invalid-name
i18n,
render_template,
resources_fs,
@@ -126,12 +124,12 @@ class LoncapaSystem(object):
self.matlab_api_key = matlab_api_key
class LoncapaProblem(object):
class LoncapaProblem: # pylint: disable=too-many-public-methods,too-many-instance-attributes
"""
Main class for capa Problems.
"""
def __init__(
def __init__( # pylint: disable=too-many-positional-arguments,too-many-arguments
self,
problem_text,
id, # pylint: disable=redefined-builtin
@@ -165,7 +163,7 @@ class LoncapaProblem(object):
"""
## Initialize class variables from state
# Initialize class variables from state
self.do_reset()
self.problem_id = id
self.capa_system = capa_system
@@ -339,7 +337,7 @@ class LoncapaProblem(object):
self.student_answers = initial_answers
def __str__(self):
return "LoncapaProblem ({0})".format(self.problem_id)
return f"LoncapaProblem ({self.problem_id})"
def get_state(self):
"""
@@ -439,7 +437,7 @@ class LoncapaProblem(object):
if self.correct_map.is_queued(answer_id)
]
queuetimes = [
datetime.strptime(qt_str, xqueue_interface.dateformat).replace(tzinfo=UTC) for qt_str in queuetime_strs
datetime.strptime(qt_str, xqueue_interface.DATEFORMAT).replace(tzinfo=UTC) for qt_str in queuetime_strs
]
return max(queuetimes)
@@ -459,7 +457,7 @@ class LoncapaProblem(object):
# if answers include File objects, convert them to filenames.
self.student_answers = convert_files_to_filenames(answers)
new_cmap = self.get_grade_from_current_answers(answers)
self.correct_map = new_cmap # lint-amnesty, pylint: disable=attribute-defined-outside-init
self.correct_map = new_cmap # pylint: disable=attribute-defined-outside-init
self.correct_map_history.append(deepcopy(new_cmap))
return self.correct_map
@@ -515,7 +513,9 @@ class LoncapaProblem(object):
# TODO: figure out where to get file submissions when rescoring.
if "filesubmission" in responder.allowed_inputfields and student_answers is None:
_ = self.capa_system.i18n.gettext
raise Exception(_("Cannot rescore problems with possible file submissions"))
raise Exception( # pylint: disable=broad-exception-raised
_("Cannot rescore problems with possible file submissions")
)
# use 'student_answers' only if it is provided, and if it might contain a file
# submission that would not exist in the persisted "student_answers".
@@ -540,7 +540,7 @@ class LoncapaProblem(object):
"""
# dict of (id, correct_answer)
answer_map = {}
for response in self.responders.keys(): # lint-amnesty, pylint: disable=consider-iterating-dictionary
for response in self.responders:
results = self.responder_answers[response]
answer_map.update(results)
@@ -560,7 +560,7 @@ class LoncapaProblem(object):
get_question_answers may only return a subset of these.
"""
answer_ids = []
for response in self.responders.keys(): # lint-amnesty, pylint: disable=consider-iterating-dictionary
for response in self.responders:
results = self.responder_answers[response]
answer_ids.append(list(results.keys()))
return answer_ids
@@ -577,7 +577,7 @@ class LoncapaProblem(object):
"""
xml_elements = self.tree.xpath('//*[@id="' + answer_id + '"]')
if not xml_elements:
return
return None
xml_element = xml_elements[0]
answer_text = xml_element.xpath("@answer")
if answer_text:
@@ -653,12 +653,12 @@ class LoncapaProblem(object):
# then from the first optionresponse we'll end with the <p>.
# If we start in the second optionresponse, we'll find another response in the way,
# stop early, and instead of a question we'll report "Question 2".
SKIP_ELEMS = ["description"]
LABEL_ELEMS = ["p", "label"]
while questiontext_elem is not None and questiontext_elem.tag in SKIP_ELEMS:
skip_elems = ["description"]
label_elems = ["p", "label"]
while questiontext_elem is not None and questiontext_elem.tag in skip_elems:
questiontext_elem = questiontext_elem.getprevious()
if questiontext_elem is not None and questiontext_elem.tag in LABEL_ELEMS:
if questiontext_elem is not None and questiontext_elem.tag in label_elems:
question_text = questiontext_elem.text
else:
question_text = generate_default_question_label()
@@ -695,11 +695,7 @@ class LoncapaProblem(object):
elif isinstance(current_answer, str) and current_answer.startswith("choice_"):
# Many problem (e.g. checkbox) report "choice_0" "choice_1" etc.
# Here we transform it
elems = self.tree.xpath(
'//*[@id="{answer_id}"]//*[@name="{choice_number}"]'.format(
answer_id=answer_id, choice_number=current_answer
)
)
elems = self.tree.xpath(f'//*[@id="{answer_id}"]//*[@name="{current_answer}"]')
if len(elems) == 0:
log.warning("Answer Text Missing for answer id: %s and choice number: %s", answer_id, current_answer)
answer_text = "Answer Text Missing"
@@ -721,7 +717,7 @@ class LoncapaProblem(object):
return answer_text or "Answer Text Missing"
def do_targeted_feedback(self, tree):
def do_targeted_feedback(self, tree): # pylint: disable=too-many-locals,too-many-branches
"""
Implements targeted-feedback in-place on <multiplechoiceresponse> --
choice-level explanations shown to a student after submission.
@@ -827,9 +823,9 @@ class LoncapaProblem(object):
if self.inputs[input_id]:
dispatch = data["dispatch"]
return self.inputs[input_id].handle_ajax(dispatch, data)
else:
log.warning("Could not find matching input for id: %s", input_id)
return {}
log.warning("Could not find matching input for id: %s", input_id)
return {}
# ======= Private Methods Below ========
@@ -845,27 +841,25 @@ class LoncapaProblem(object):
try:
# open using LoncapaSystem OSFS filesystem
ifp = self.capa_system.resources_fs.open(filename)
except Exception as err: # lint-amnesty, pylint: disable=broad-except
except Exception as err: # pylint: disable=broad-exception-caught
log.warning("Error %s in problem xml include: %s", err, etree.tostring(inc, pretty_print=True))
log.warning("Cannot find file %s in %s", filename, self.capa_system.resources_fs)
# if debugging, don't fail - just log error
# TODO (vshnayder): need real error handling, display to users
if not self.capa_system.DEBUG: # lint-amnesty, pylint: disable=no-else-raise
if not self.capa_system.DEBUG:
raise
else:
continue
continue
try:
# read in and convert to XML
incxml = etree.XML(ifp.read())
except Exception as err: # lint-amnesty, pylint: disable=broad-except
except Exception as err: # pylint: disable=broad-exception-caught
log.warning("Error %s in problem xml include: %s", err, etree.tostring(inc, pretty_print=True))
log.warning("Cannot parse XML in %s", (filename))
# if debugging, don't fail - just log error
# TODO (vshnayder): same as above
if not self.capa_system.DEBUG: # lint-amnesty, pylint: disable=no-else-raise
if not self.capa_system.DEBUG:
raise
else:
continue
continue
# insert new XML into tree in place of include
parent = inc.getparent()
@@ -882,15 +876,15 @@ class LoncapaProblem(object):
script : ?? (TODO)
"""
DEFAULT_PATH = ["code"]
default_path = ["code"]
# Separate paths by :, like the system path.
raw_path = script.get("system_path", "").split(":") + DEFAULT_PATH
raw_path = script.get("system_path", "").split(":") + default_path
# find additional comma-separated modules search path
path = []
for dir in raw_path: # lint-amnesty, pylint: disable=redefined-builtin
for dir in raw_path: # pylint: disable=redefined-builtin
if not dir:
continue
@@ -936,8 +930,8 @@ class LoncapaProblem(object):
if d not in python_path and os.path.exists(d):
python_path.append(d)
XMLESC = {"&apos;": "'", "&quot;": '"'}
code = unescape(script.text, XMLESC)
xmlesc = {"&apos;": "'", "&quot;": '"'}
code = unescape(script.text, xmlesc)
all_code += code
extra_files = []
@@ -961,10 +955,8 @@ class LoncapaProblem(object):
unsafely=self.capa_system.can_execute_unsafe_code(),
)
except Exception as err:
log.exception( # lint-amnesty, pylint: disable=logging-not-lazy
"Error while execing script code: " + all_code
)
msg = Text("Error while executing script code: %s" % str(err))
log.exception("Error while execing script code: %s", all_code)
msg = Text(f"Error while executing script code: {err}")
raise responsetypes.LoncapaProblemError(msg)
# Store code source in context, along with the Python path needed to run it correctly.
@@ -973,7 +965,9 @@ class LoncapaProblem(object):
context["extra_files"] = extra_files or None
return context
def _extract_html(self, problemtree): # private
def _extract_html( # private
self, problemtree
): # pylint: disable=too-many-statements,too-many-locals,too-many-branches,too-many-return-statements
"""
Main (private) function which converts Problem XML tree to HTML.
Calls itself recursively.
@@ -988,14 +982,14 @@ class LoncapaProblem(object):
# and we're ok leaving those behind.
# BTW: etree gives us no good way to distinguish these things
# other than to examine .tag to see if it's a string. :(
return
return None
if problemtree.tag == "script" and problemtree.get("type") and "javascript" in problemtree.get("type"):
# leave javascript intact.
return deepcopy(problemtree)
if problemtree.tag in html_problem_semantics:
return
return None
problemid = problemtree.get("id") # my ID
@@ -1116,7 +1110,7 @@ class LoncapaProblem(object):
for entry in inputfields:
entry.attrib["response_id"] = str(response_id)
entry.attrib["answer_id"] = str(answer_id)
entry.attrib["id"] = "%s_%i_%i" % (self.problem_id, response_id, answer_id)
entry.attrib["id"] = f"{self.problem_id}_{response_id}_{answer_id}"
answer_id = answer_id + 1
self.response_a11y_data(response, inputfields, responsetype_id, problem_data)
@@ -1133,13 +1127,11 @@ class LoncapaProblem(object):
# get responder answers (do this only once, since there may be a performance cost,
# eg with externalresponse)
self.responder_answers = {}
for response in self.responders.keys(): # lint-amnesty, pylint: disable=consider-iterating-dictionary
for response, responder in self.responders.items():
try:
self.responder_answers[response] = self.responders[response].get_answers()
except:
log.debug(
"responder %s failed to properly return get_answers()", self.responders[response]
) # FIXME
self.responder_answers[response] = responder.get_answers()
except Exception:
log.debug("responder %s failed to properly return get_answers()", responder) # FIXME
raise
# <solution>...</solution> may not be associated with any specific response; give
@@ -1147,12 +1139,14 @@ class LoncapaProblem(object):
# TODO: We should make the namespaces consistent and unique (e.g. %s_problem_%i).
solution_id = 1
for solution in tree.findall(".//solution"):
solution.attrib["id"] = "%s_solution_%i" % (self.problem_id, solution_id)
solution.attrib["id"] = f"{self.problem_id}_solution_{solution_id}"
solution_id += 1
return problem_data
def response_a11y_data(self, response, inputfields, responsetype_id, problem_data):
def response_a11y_data( # pylint: disable=too-many-locals,too-many-branches
self, response, inputfields, responsetype_id, problem_data
):
"""
Construct data to be used for a11y.
@@ -1173,7 +1167,7 @@ class LoncapaProblem(object):
response.set("multiple_inputtypes", "true")
group_label_tag = response.find("label")
group_description_tags = response.findall("description")
group_label_tag_id = "multiinput-group-label-{}".format(responsetype_id)
group_label_tag_id = f"multiinput-group-label-{responsetype_id}"
group_label_tag_text = ""
if group_label_tag is not None:
group_label_tag.tag = "p"
@@ -1184,7 +1178,7 @@ class LoncapaProblem(object):
group_description_ids = []
for index, group_description_tag in enumerate(group_description_tags):
group_description_tag_id = "multiinput-group-description-{}-{}".format(responsetype_id, index)
group_description_tag_id = f"multiinput-group-description-{responsetype_id}-{index}"
group_description_tag.tag = "p"
group_description_tag.set("id", group_description_tag_id)
group_description_tag.set("class", "multi-inputs-group-description question-description")
@@ -1235,9 +1229,7 @@ class LoncapaProblem(object):
description_id = 1
descriptions = OrderedDict()
for description in description_tags:
descriptions["description_%s_%i" % (responsetype_id, description_id)] = HTML(
stringify_children(description)
)
descriptions[f"description_{responsetype_id}_{description_id}"] = HTML(stringify_children(description))
response.remove(description)
description_id += 1

View File

@@ -3,7 +3,6 @@
Commandline tool for doing operations on Problems
"""
import argparse
import logging
import sys
@@ -18,9 +17,11 @@ logging.basicConfig(format="%(levelname)s %(message)s")
log = logging.getLogger("capa.checker")
class DemoSystem(object): # lint-amnesty, pylint: disable=missing-class-docstring
class DemoSystem: # pylint: disable=too-few-public-methods
"""Render templates using Django's template engine."""
def __init__(self):
self.DEBUG = True
self.DEBUG = True # pylint: disable=invalid-name
def render_template(self, template_filename, dictionary):
"""
@@ -29,7 +30,9 @@ class DemoSystem(object): # lint-amnesty, pylint: disable=missing-class-docstri
return get_template(template_filename).render(dictionary)
def main(): # lint-amnesty, pylint: disable=missing-function-docstring
def main():
"""Parse command-line arguments to test or display Loncapa problem files."""
parser = argparse.ArgumentParser(description="Check Problem Files")
parser.add_argument("command", choices=["test", "show"]) # Watch? Render? Open?
parser.add_argument("files", nargs="+", type=argparse.FileType("r"))
@@ -47,14 +50,17 @@ def main(): # lint-amnesty, pylint: disable=missing-function-docstring
system = DemoSystem()
for problem_file in args.files:
log.info("Opening {0}".format(problem_file.name))
log.info("Opening %s", problem_file.name)
try:
problem = LoncapaProblem( # lint-amnesty, pylint: disable=no-value-for-parameter, unexpected-keyword-arg
problem_file, "fakeid", seed=args.seed, system=system
problem = LoncapaProblem( # pylint: disable=unexpected-keyword-arg, no-value-for-parameter
problem_file,
"fakeid",
seed=args.seed,
system=system,
)
except Exception as ex: # lint-amnesty, pylint: disable=broad-except
log.error("Could not parse file {0}".format(problem_file.name))
except Exception as ex: # pylint: disable=broad-exception-caught
log.error("Could not parse file %s", problem_file.name)
log.exception(ex)
continue
@@ -73,7 +79,9 @@ def command_show(problem):
print(problem.get_html())
def command_test(problem): # lint-amnesty, pylint: disable=missing-function-docstring
def command_test(problem):
"""Run tests on a problem while capturing and logging stdout and stderr output."""
# We're going to trap stdout/stderr from the problems (yes, some print)
old_stdout, old_stderr = sys.stdout, sys.stderr
try:
@@ -83,9 +91,9 @@ def command_test(problem): # lint-amnesty, pylint: disable=missing-function-doc
check_that_suggested_answers_work(problem)
check_that_blanks_fail(problem)
log_captured_output(sys.stdout, "captured stdout from {0}".format(problem))
log_captured_output(sys.stderr, "captured stderr from {0}".format(problem))
except Exception as e: # lint-amnesty, pylint: disable=broad-except
log_captured_output(sys.stdout, f"captured stdout from {problem}")
log_captured_output(sys.stderr, f"captured stderr from {problem}")
except Exception as e: # pylint: disable=broad-exception-caught
log.exception(e)
finally:
sys.stdout, sys.stderr = old_stdout, old_stderr
@@ -99,9 +107,9 @@ def check_that_blanks_fail(problem):
assert all(result == "incorrect" for result in grading_results.values())
except AssertionError:
log.error(
"Blank accepted as correct answer in {0} for {1}".format(
problem, [answer_id for answer_id, result in sorted(grading_results.items()) if result != "incorrect"]
)
"Blank accepted as correct answer in %s for %s",
problem,
[answer_id for answer_id, result in sorted(grading_results.items()) if result != "incorrect"],
)
@@ -124,7 +132,7 @@ def check_that_suggested_answers_work(problem):
all_answer_ids = problem.get_answer_ids()
all_answers = dict((answer_id, real_answers.get(answer_id, "")) for answer_id in all_answer_ids)
log.debug("Real answers: {0}".format(real_answers))
log.debug("Real answers: %s", real_answers)
if real_answers:
try:
real_results = dict(
@@ -135,28 +143,28 @@ def check_that_suggested_answers_work(problem):
log.debug(real_results)
assert all(result == "correct" for answer_id, result in real_results.items())
except UndefinedVariable as uv_exc:
log.error( # lint-amnesty, pylint: disable=logging-not-lazy
'The variable "{0}" specified in the '.format(uv_exc)
+ "solution isn't recognized (is it a units measure?)."
log.error(
'The variable "%s" specified in the solution isn\'t recognized (is it a units measure?).',
uv_exc,
)
except AssertionError:
log.error("The following generated answers were not accepted for {0}:".format(problem))
log.error("The following generated answers were not accepted for %s:", problem)
for question_id, result in sorted(real_results.items()):
if result != "correct":
log.error(" {0} = {1}".format(question_id, real_answers[question_id]))
except Exception as ex: # lint-amnesty, pylint: disable=broad-except
log.error("Uncaught error in {0}".format(problem))
log.error(" %s = %s", question_id, real_answers[question_id])
except Exception as ex: # pylint: disable=broad-exception-caught
log.error("Uncaught error in %s", problem)
log.exception(ex)
def log_captured_output(output_stream, stream_name): # lint-amnesty, pylint: disable=missing-function-docstring
def log_captured_output(output_stream, stream_name):
"""Log the contents of a captured output stream with header and footer markers."""
output_stream.seek(0)
output_text = output_stream.read()
if output_text:
log.info(
"##### Begin {0} #####\n".format(stream_name) + output_text
) # lint-amnesty, pylint: disable=logging-not-lazy
log.info("##### End {0} #####".format(stream_name))
log.info("##### Begin %s #####\n%s", stream_name, output_text)
log.info("##### End %s #####", stream_name)
if __name__ == "__main__":

View File

@@ -1,11 +1,15 @@
# lint-amnesty, pylint: disable=missing-module-docstring
"""
CorrectMap: A utility class to store and manage graded responses to CAPA questions.
Provides methods to track correctness, points, messages, hints, and queue state.
"""
# -----------------------------------------------------------------------------
# class used to store graded responses to CAPA questions
#
# Used by responsetypes and capa_problem
class CorrectMap(object):
class CorrectMap:
"""
Stores map between answer_id and response evaluation result for each question
in a capa problem. The response evaluation result for each answer_id includes
@@ -39,8 +43,8 @@ class CorrectMap(object):
return self.cmap.__iter__()
# See the documentation for 'set_dict' for the use of kwargs
def set( # lint-amnesty, pylint: disable=missing-function-docstring
self, # lint-amnesty, pylint: disable=unused-argument
def set( # pylint: disable=too-many-positional-arguments,too-many-arguments
self,
answer_id=None,
correctness=None,
npoints=None,
@@ -49,8 +53,12 @@ class CorrectMap(object):
hintmode=None,
queuestate=None,
answervariable=None,
**kwargs
**kwargs, # pylint: disable=unused-argument
):
"""
Set or update the stored evaluation result for a given answer_id.
Unused kwargs are ignored for compatibility with older formats.
"""
if answer_id is not None:
self.cmap[answer_id] = {
@@ -124,48 +132,59 @@ class CorrectMap(object):
return None
def is_queued(self, answer_id):
"""Return True if the answer has a non-empty queue state."""
return answer_id in self.cmap and self.cmap[answer_id]["queuestate"] is not None
def is_right_queuekey(self, answer_id, test_key):
"""Return True if the queued answer matches the provided queue key."""
return self.is_queued(answer_id) and self.cmap[answer_id]["queuestate"]["key"] == test_key
def get_queuetime_str(self, answer_id):
"""Return the stored queue timestamp string for the given answer."""
if self.cmap[answer_id]["queuestate"]:
return self.cmap[answer_id]["queuestate"]["time"]
else:
return None
return None
def get_npoints(self, answer_id):
"""Return the number of points for an answer, used for partial credit."""
npoints = self.get_property(answer_id, "npoints")
if npoints is not None:
return npoints
elif self.is_correct(answer_id):
if self.is_correct(answer_id):
return 1
# if not correct and no points have been assigned, return 0
return 0
def set_property(self, answer_id, property, value): # lint-amnesty, pylint: disable=redefined-builtin
def set_property(self, answer_id, prop, value):
"""Set a specific property value for the given answer_id."""
if answer_id in self.cmap:
self.cmap[answer_id][property] = value
self.cmap[answer_id][prop] = value
else:
self.cmap[answer_id] = {property: value}
self.cmap[answer_id] = {prop: value}
def get_property(self, answer_id, property, default=None): # lint-amnesty, pylint: disable=redefined-builtin
def get_property(self, answer_id, prop, default=None):
"""Return the specified property for an answer, or a default value."""
if answer_id in self.cmap:
return self.cmap[answer_id].get(property, default)
return self.cmap[answer_id].get(prop, default)
return default
def get_correctness(self, answer_id):
"""Return the correctness value for the given answer."""
return self.get_property(answer_id, "correctness")
def get_msg(self, answer_id):
"""Return the feedback message for the given answer."""
return self.get_property(answer_id, "msg", "")
def get_hint(self, answer_id):
"""Return the hint text associated with the given answer."""
return self.get_property(answer_id, "hint", "")
def get_hintmode(self, answer_id):
"""Return the hint display mode for the given answer."""
return self.get_property(answer_id, "hintmode", None)
def set_hint_and_mode(self, answer_id, hint, hintmode):
@@ -181,7 +200,9 @@ class CorrectMap(object):
Update this CorrectMap with the contents of another CorrectMap
"""
if not isinstance(other_cmap, CorrectMap):
raise Exception("CorrectMap.update called with invalid argument %s" % other_cmap)
raise Exception( # pylint: disable=broad-exception-raised
f"CorrectMap.update called with invalid argument {other_cmap}"
)
self.cmap.update(other_cmap.get_dict())
self.set_overall_message(other_cmap.get_overall_message())

View File

@@ -8,9 +8,9 @@ and the xml element.
import logging
import re
import xml.sax.saxutils as saxutils
from xml.sax import saxutils
from django.utils import html
from django.utils import html as html_escape
from lxml import etree
from .registry import TagRegistry
@@ -23,7 +23,11 @@ registry = TagRegistry()
# -----------------------------------------------------------------------------
class MathRenderer(object): # lint-amnesty, pylint: disable=missing-class-docstring
class MathRenderer: # pylint: disable=too-few-public-methods
"""
Renders <math> tags into MathJax-compatible HTML for displaying math expressions.
"""
tags = ["math"]
def __init__(self, system, xml):
@@ -48,37 +52,30 @@ class MathRenderer(object): # lint-amnesty, pylint: disable=missing-class-docst
mtag += "inline"
else:
mathstr = mathstr.replace(r"\displaystyle", "")
self.mathstr = mathstr.replace("mathjaxinline]", "%s]" % mtag)
self.mathstr = mathstr.replace("mathjaxinline]", f"{mtag}]")
def get_html(self):
"""
Return the contents of this tag, rendered to html, as an etree element.
"""
# TODO: why are there nested html tags here?? Why are there html tags at all, in fact?
# xss-lint: disable=python-interpolate-html
html = "<html><html>%s</html><html>%s</html></html>" % ( # lint-amnesty, pylint: disable=redefined-outer-name
self.mathstr,
saxutils.escape(self.xml.tail),
)
html = f"<html><html>{self.mathstr}</html><html>{saxutils.escape(self.xml.tail or '')}</html></html>"
try:
xhtml = etree.XML(html)
except Exception as err: # lint-amnesty, pylint: disable=broad-except
except Exception as err: # pylint: disable=broad-exception-caught
if self.system.DEBUG:
# xss-lint: disable=python-interpolate-html
msg = '<html><div class="inline-error"><p>Error %s</p>' % (
str(err).replace("<", "&lt;") # xss-lint: disable=python-custom-escape
msg = (
f"<html><div class='inline-error'>"
f"<p>Error {html_escape.escape(err)}</p>"
f"<p>Failed to construct math expression from <pre>{html_escape.escape(html)}</pre></p>"
f"</div></html>"
)
# xss-lint: disable=python-interpolate-html
msg += "<p>Failed to construct math expression from <pre>%s</pre></p>" % html.replace(
"<", "&lt;" # xss-lint: disable=python-custom-escape
)
msg += "</div></html>"
log.error(msg)
return etree.XML(msg)
else:
raise
raise
return xhtml
@@ -88,7 +85,7 @@ registry.register(MathRenderer)
# -----------------------------------------------------------------------------
class SolutionRenderer(object):
class SolutionRenderer: # pylint: disable=too-few-public-methods
"""
A solution is just a <span>...</span> which is given an ID, that is used for displaying an
extended answer (a problem "solution") after "show answers" is pressed.
@@ -103,11 +100,10 @@ class SolutionRenderer(object):
self.system = system
self.id = xml.get("id")
def get_html(self): # pylint: disable=missing-function-docstring
def get_html(self):
"""Return the solution HTML rendered as an etree element."""
context = {"id": self.id}
html = self.system.render_template( # lint-amnesty, pylint: disable=redefined-outer-name
"solutionspan.html", context
)
html = self.system.render_template("solutionspan.html", context)
return etree.XML(html)
@@ -117,7 +113,7 @@ registry.register(SolutionRenderer)
# -----------------------------------------------------------------------------
class TargetedFeedbackRenderer(object):
class TargetedFeedbackRenderer: # pylint: disable=too-few-public-methods
"""
A targeted feedback is just a <span>...</span> that is used for displaying an
extended piece of feedback to students if they incorrectly answered a question.
@@ -134,29 +130,30 @@ class TargetedFeedbackRenderer(object):
Return the contents of this tag, rendered to html, as an etree element.
"""
# xss-lint: disable=python-wrap-html
html_str = '<section class="targeted-feedback-span"><span>{}</span></section>'.format(
etree.tostring(self.xml, encoding="unicode")
html_str = (
f'<section class="targeted-feedback-span">'
f'<span>{etree.tostring(self.xml, encoding="unicode")}</span>'
f"</section>"
)
try:
xhtml = etree.XML(html_str)
except Exception as err: # pylint: disable=broad-except
except Exception as err: # pylint: disable=broad-exception-caught
if self.system.DEBUG:
# xss-lint: disable=python-wrap-html
msg = """
msg = f"""
<html>
<div class="inline-error">
<p>Error {err}</p>
<p>Failed to construct targeted feedback from <pre>{html}</pre></p>
</div>
<div class="inline-error">
<p>Error {html_escape.escape(err)}</p>
<p>Failed to construct targeted feedback from <pre>{html_escape.escape(html_str)}</pre></p>
</div>
</html>
""".format(
err=html.escape(err), html=html.escape(html_str)
)
"""
log.error(msg)
return etree.XML(msg)
else:
raise
raise
return xhtml
@@ -166,7 +163,7 @@ registry.register(TargetedFeedbackRenderer)
# -----------------------------------------------------------------------------
class ClarificationRenderer(object):
class ClarificationRenderer: # pylint: disable=too-few-public-methods
"""
A clarification appears as an inline icon which reveals more information when the user
hovers over it.
@@ -188,9 +185,7 @@ class ClarificationRenderer(object):
Return the contents of this tag, rendered to html, as an etree element.
"""
context = {"clarification": self.inner_html}
html = self.system.render_template( # lint-amnesty, pylint: disable=redefined-outer-name
"clarification.html", context
)
html = self.system.render_template("clarification.html", context)
xml = etree.XML(html)
# We must include any text that was following our original <clarification>...</clarification> XML node.:
xml.tail = self.tail

View File

@@ -1,3 +1,4 @@
# pylint: disable=too-many-lines
#
# File: courseware/capa/inputtypes.py
#
@@ -49,9 +50,9 @@ from datetime import datetime
import html5lib
import nh3
import pyparsing
import six
from calc.preview import latex_preview
import pyparsing
from chem import chemcalc
from lxml import etree
@@ -65,10 +66,10 @@ from .util import sanitize_html
log = logging.getLogger(__name__)
registry = TagRegistry() # pylint: disable=invalid-name
registry = TagRegistry()
class Status(object):
class Status:
"""
Problem status
attributes: classname, display_name, display_tooltip
@@ -111,7 +112,7 @@ class Status(object):
return self._status
def __repr__(self):
return "Status(%r)" % self._status
return f"Status({self._status!r})"
def __eq__(self, other):
return self._status == str(other)
@@ -120,7 +121,7 @@ class Status(object):
return hash(str(self))
class Attribute(object):
class Attribute: # pylint: disable=too-few-public-methods
"""
Allows specifying required and optional attributes for input types.
"""
@@ -128,7 +129,9 @@ class Attribute(object):
# want to allow default to be None, but also allow required objects
_sentinel = object()
def __init__(self, name, default=_sentinel, transform=None, validate=None, render=True):
def __init__( # pylint: disable=too-many-positional-arguments,too-many-arguments
self, name, default=_sentinel, transform=None, validate=None, render=True
):
"""
Define an attribute
@@ -160,7 +163,7 @@ class Attribute(object):
"""
val = element.get(self.name)
if self.default == self._sentinel and val is None:
raise ValueError("Missing required attribute {0}.".format(self.name))
raise ValueError(f"Missing required attribute {self.name}.")
if val is None:
# not required, so return default
@@ -175,7 +178,7 @@ class Attribute(object):
return val
class InputTypeBase(object):
class InputTypeBase: # pylint: disable=too-many-instance-attributes
"""
Abstract base class for input types.
"""
@@ -214,7 +217,7 @@ class InputTypeBase(object):
self.input_id = state.get("id", xml.get("id"))
if self.input_id is None:
raise ValueError("input id state is None. xml is {0}".format(etree.tostring(xml)))
raise ValueError(f"input id state is None. xml is {etree.tostring(xml)}")
self.value = state.get("value", "")
@@ -242,9 +245,9 @@ class InputTypeBase(object):
# super().__init__, and are isolated from changes to the input
# constructor interface.
self.setup()
except Exception as err: # lint-amnesty, pylint: disable=broad-except
except Exception as err: # pylint: disable=broad-exception-caught
# Something went wrong: add xml to message, but keep the traceback
msg = "Error in xml '{x}': {err} ".format(x=etree.tostring(xml), err=str(err))
msg = f"Error in xml '{etree.tostring(xml)}': {err} "
six.reraise(Exception, Exception(msg), sys.exc_info()[2])
@classmethod
@@ -285,7 +288,6 @@ class InputTypeBase(object):
If this method raises an exception, it will be wrapped with a message that includes the
problem xml.
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
def handle_ajax(self, dispatch, data):
"""
@@ -298,7 +300,6 @@ class InputTypeBase(object):
Output:
a dictionary object that can be serialized into JSON. This will be sent back to the Javascript.
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
def _get_render_context(self):
"""
@@ -356,7 +357,7 @@ class InputTypeBase(object):
Return the html for this input, as an etree element.
"""
if self.template is None:
raise NotImplementedError("no rendering template specified for class {0}".format(self.__class__))
raise NotImplementedError(f"no rendering template specified for class {self.__class__}")
context = self._get_render_context()
@@ -366,11 +367,12 @@ class InputTypeBase(object):
output = etree.XML(html)
except etree.XMLSyntaxError as ex:
# If `html` contains attrs with no values, like `controls` in <audio controls src='smth'/>,
# XML parser will raise exception, so wee fallback to html5parser, which will set empty "" values for such attrs. # lint-amnesty, pylint: disable=line-too-long
# XML parser will raise exception, so wee fallback to html5parser,
# which will set empty "" values for such attrs.
try:
output = html5lib.parseFragment(html, treebuilder="lxml", namespaceHTMLElements=False)[0]
except IndexError:
raise ex # lint-amnesty, pylint: disable=raise-missing-from
except IndexError as exc:
raise ex from exc
return output
@@ -489,7 +491,7 @@ class ChoiceGroup(InputTypeBase):
_ = i18n.gettext
# Translators: 'ChoiceGroup' is an input type and should not be translated.
msg = _("ChoiceGroup: unexpected tag {tag_name}").format(tag_name=self.tag)
raise Exception(msg)
raise Exception(msg) # pylint: disable=broad-exception-raised
self.choices = self.extract_choices(self.xml, i18n)
self._choices_map = dict(
@@ -500,7 +502,7 @@ class ChoiceGroup(InputTypeBase):
def get_attributes(cls):
# Make '_' a no-op so we can scrape strings. Using lambda instead of
# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
_ = lambda text: text
_ = lambda text: text # pylint: disable=unnecessary-lambda-assignment
return [Attribute("show_correctness", "always"), Attribute("submitted_message", _("Answer received."))]
def _extra_context(self):
@@ -538,7 +540,7 @@ class ChoiceGroup(InputTypeBase):
_("Expected a <choice> or <compoundhint> tag; got {given_tag} instead")
).format(given_tag=choice.tag)
)
raise Exception(msg)
raise Exception(msg) # pylint: disable=broad-exception-raised
return choices
def get_user_visible_answer(self, internal_answer):
@@ -608,8 +610,8 @@ class JSInput(InputTypeBase):
def _extra_context(self):
context = {
"jschannel_loader": "{static_url}js/capa/src/jschannel.js".format(static_url=self.capa_system.STATIC_URL),
"jsinput_loader": "{static_url}js/capa/src/jsinput.js".format(static_url=self.capa_system.STATIC_URL),
"jschannel_loader": f"{self.capa_system.STATIC_URL}js/capa/src/jschannel.js",
"jsinput_loader": f"{self.capa_system.STATIC_URL}js/capa/src/jsinput.js",
"saved_state": self.value,
}
@@ -785,12 +787,12 @@ class CodeInput(InputTypeBase):
self.value = self.xml.text.strip()
# Check if problem has been queued
self.queue_len = 0 # lint-amnesty, pylint: disable=attribute-defined-outside-init
self.queue_len = 0 # pylint: disable=attribute-defined-outside-init
# Flag indicating that the problem has been queued, 'msg' is length of
# queue
if self.status == "incomplete":
self.status = "queued"
self.queue_len = self.msg # lint-amnesty, pylint: disable=attribute-defined-outside-init
self.queue_len = self.msg # pylint: disable=attribute-defined-outside-init
self.msg = nh3.clean(self.submitted_msg)
def setup(self):
@@ -919,8 +921,8 @@ class MatlabInput(CodeInput):
"""
if self.status in ["correct", "incorrect", "partially-correct"]:
return False
else:
return True
return True
def _extra_context(self):
"""Set up additional context variables"""
@@ -942,9 +944,7 @@ class MatlabInput(CodeInput):
"queue_len": str(self.queue_len),
"queue_msg": queue_msg,
"button_enabled": self.button_enabled(),
"matlab_editor_js": "{static_url}js/vendor/CodeMirror/octave.js".format(
static_url=self.capa_system.STATIC_URL
),
"matlab_editor_js": f"{self.capa_system.STATIC_URL}js/vendor/CodeMirror/octave.js",
"msg": sanitize_html(self.msg), # sanitize msg before rendering into template
}
return extra_context
@@ -984,7 +984,7 @@ class MatlabInput(CodeInput):
# construct xqueue headers
qinterface = self.capa_system.xqueue.interface
qtime = datetime.utcnow().strftime(xqueue_interface.dateformat)
qtime = datetime.utcnow().strftime(xqueue_interface.DATEFORMAT)
callback_url = self.capa_system.xqueue.construct_callback("ungraded_response")
anonymous_student_id = self.capa_system.anonymous_student_id
# TODO: Why is this using self.capa_system.seed when we have self.seed???
@@ -1044,7 +1044,7 @@ class Schematic(InputTypeBase):
def _extra_context(self):
context = {
"setup_script": "{static_url}js/capa/schematicinput.js".format(static_url=self.capa_system.STATIC_URL),
"setup_script": f"{self.capa_system.STATIC_URL}js/capa/schematicinput.js",
}
return context
@@ -1179,9 +1179,7 @@ class ChemicalEquationInput(InputTypeBase):
TODO (vshnayder): Get rid of this once we have a standard way of requiring js to be loaded.
"""
return {
"previewer": "{static_url}js/capa/chemical_equation_preview.js".format(
static_url=self.capa_system.STATIC_URL
),
"previewer": f"{self.capa_system.STATIC_URL}js/capa/chemical_equation_preview.js",
}
def handle_ajax(self, dispatch, data):
@@ -1217,7 +1215,7 @@ class ChemicalEquationInput(InputTypeBase):
result["preview"] = chemcalc.render_to_html(formula)
except pyparsing.ParseException as err:
result["error"] = _("Couldn't parse formula: {error_msg}").format(error_msg=err.msg)
except Exception: # lint-amnesty, pylint: disable=broad-except
except Exception: # pylint: disable=broad-exception-caught
# this is unexpected, so log
log.warning("Error while previewing chemical formula", exc_info=True)
result["error"] = _("Error while rendering preview")
@@ -1263,18 +1261,16 @@ class FormulaEquationInput(InputTypeBase):
# `reported_status` is basically `status`, except we say 'unanswered'
return {
"previewer": "{static_url}js/capa/src/formula_equation_preview.js".format(
static_url=self.capa_system.STATIC_URL
),
"previewer": f"{self.capa_system.STATIC_URL}js/capa/src/formula_equation_preview.js",
}
def handle_ajax(self, dispatch, get): # lint-amnesty, pylint: disable=arguments-differ
def handle_ajax(self, dispatch, data):
"""
Since we only have formcalc preview this input, check to see if it
matches the corresponding dispatch and send it through if it does
"""
if dispatch == "preview_formcalc":
return self.preview_formcalc(get)
return self.preview_formcalc(data)
return {}
def preview_formcalc(self, get):
@@ -1313,9 +1309,9 @@ class FormulaEquationInput(InputTypeBase):
if not numeric_result["is_valid"]:
result["formula"] = formula
except pyparsing.ParseException:
result['error'] = _("Sorry, couldn't parse formula")
result['formula'] = formula
except Exception: # lint-amnesty, pylint: disable=broad-except
result["error"] = _("Sorry, couldn't parse formula")
result["formula"] = formula
except Exception: # pylint: disable=broad-exception-caught
log.warning("Error while previewing formula", exc_info=True)
result["error"] = _("Error while rendering preview")
return result
@@ -1327,17 +1323,17 @@ def preview_numeric_input(formula):
"""
Handles numeric validations, validates that the formula provided is a valid formula.
"""
result = {'preview': '', 'is_valid': True, 'error': ''}
result = {"preview": "", "is_valid": True, "error": ""}
try:
result['preview'] = latex_preview(formula)
result["preview"] = latex_preview(formula)
except pyparsing.ParseException:
result["error"] = "Sorry, couldn't parse formula"
result['is_valid'] = False
result["is_valid"] = False
return result
except Exception: # pylint: disable=broad-exception-caught
log.warning("Error while previewing formula", exc_info=True)
result['error'] = "Error while rendering preview"
result['is_valid'] = False
result["error"] = "Error while rendering preview"
result["is_valid"] = False
return result
@@ -1380,18 +1376,18 @@ class DragAndDropInput(InputTypeBase):
"""
tag_attrs = {}
tag_attrs["draggable"] = {
"id": Attribute._sentinel, # lint-amnesty, pylint: disable=protected-access
"id": Attribute._sentinel, # pylint: disable=protected-access
"label": "",
"icon": "",
"can_reuse": "",
}
tag_attrs["target"] = {
"id": Attribute._sentinel, # lint-amnesty, pylint: disable=protected-access
"x": Attribute._sentinel, # lint-amnesty, pylint: disable=protected-access
"y": Attribute._sentinel, # lint-amnesty, pylint: disable=protected-access
"w": Attribute._sentinel, # lint-amnesty, pylint: disable=protected-access
"h": Attribute._sentinel, # lint-amnesty, pylint: disable=protected-access
"id": Attribute._sentinel, # pylint: disable=protected-access
"x": Attribute._sentinel, # pylint: disable=protected-access
"y": Attribute._sentinel, # pylint: disable=protected-access
"w": Attribute._sentinel, # pylint: disable=protected-access
"h": Attribute._sentinel, # pylint: disable=protected-access
}
dic = {}
@@ -1458,7 +1454,7 @@ class DesignProtein2dInput(InputTypeBase):
def _extra_context(self):
context = {
"applet_loader": "{static_url}js/capa/design-protein-2d.js".format(static_url=self.capa_system.STATIC_URL),
"applet_loader": f"{self.capa_system.STATIC_URL}js/capa/design-protein-2d.js",
}
return context
@@ -1490,7 +1486,7 @@ class EditAGeneInput(InputTypeBase):
def _extra_context(self):
context = {
"applet_loader": "{static_url}js/capa/edit-a-gene.js".format(static_url=self.capa_system.STATIC_URL),
"applet_loader": f"{self.capa_system.STATIC_URL}js/capa/edit-a-gene.js",
}
return context
@@ -1500,7 +1496,7 @@ class EditAGeneInput(InputTypeBase):
@registry.register
class AnnotationInput(InputTypeBase):
class AnnotationInput(InputTypeBase): # pylint: disable=too-many-instance-attributes
"""
Input type for annotations: students can enter some notes or other text
(currently ungraded), and then choose from a set of tags/optoins, which are graded.
@@ -1562,12 +1558,10 @@ class AnnotationInput(InputTypeBase):
valid_choices = ("correct", "partially-correct", "incorrect")
for option in self.options:
choice = option["choice"]
if choice is None: # lint-amnesty, pylint: disable=no-else-raise
if choice is None:
raise ValueError("Missing required choice attribute.")
elif choice not in valid_choices:
raise ValueError(
"Invalid choice attribute: {0}. Must be one of: {1}".format(choice, ", ".join(valid_choices))
)
if choice not in valid_choices:
raise ValueError(f"Invalid choice attribute: {choice}. Must be one of: {', '.join(valid_choices)}")
def _unpack(self, json_value):
"""Unpacks the json input state into a dict."""
@@ -1688,7 +1682,7 @@ class ChoiceTextGroup(InputTypeBase):
else:
_ = self.capa_system.i18n.gettext
msg = _("{input_type}: unexpected tag {tag_name}").format(input_type="ChoiceTextGroup", tag_name=self.tag)
raise Exception(msg)
raise Exception(msg) # pylint: disable=broad-exception-raised
if self.value == "":
# Make `value` an empty dictionary, if it currently has an empty
@@ -1704,7 +1698,7 @@ class ChoiceTextGroup(InputTypeBase):
"""
# Make '_' a no-op so we can scrape strings. Using lambda instead of
# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
_ = lambda text: text
_ = lambda text: text # pylint: disable=unnecessary-lambda-assignment
return [
Attribute("show_correctness", "always"),
Attribute("submitted_message", _("Answer received.")),
@@ -1773,7 +1767,7 @@ class ChoiceTextGroup(InputTypeBase):
given_tag=choice.tag,
)
)
raise Exception(msg)
raise Exception(msg) # pylint: disable=broad-exception-raised
components = []
choice_text = ""

View File

@@ -1,7 +1,7 @@
"""A registry for finding classes based on tags in the class."""
class TagRegistry(object):
class TagRegistry:
"""
A registry mapping tags to handlers.
@@ -23,7 +23,7 @@ class TagRegistry(object):
# Do all checks and complain before changing any state.
if len(cls.tags) == 0:
raise ValueError("No tags specified for class {0}".format(cls.__name__))
raise ValueError(f"No tags specified for class {cls.__name__}")
for tag in cls.tags:
if tag in self._mapping:
@@ -32,12 +32,8 @@ class TagRegistry(object):
# registering the same class multiple times seems silly, but ok
continue
raise ValueError(
"Tag {0} already registered by class {1}."
" Can't register for class {2}".format(
tag,
other_cls.__name__,
cls.__name__,
)
f"Tag {tag} already registered by class {other_cls.__name__}. "
f"Can't register for class {cls.__name__}"
)
# Ok, should be good to change state now.

View File

@@ -1,3 +1,4 @@
# pylint: disable=too-many-lines
"""
Problem response evaluation. Handles checking of student responses,
of a variety of types.
@@ -35,19 +36,20 @@ from pyparsing import ParseException
from pytz import UTC
from shapely.geometry import MultiPoint, Point
from six.moves import map, range, zip
from symmath import symmath_check
import xmodule.capa.safe_exec as safe_exec
import xmodule.capa.xqueue_interface as xqueue_interface
from openedx.core.djangolib.markup import HTML, Text
from openedx.core.lib.grade_utils import round_away_from_zero
from xmodule.capa.safe_exec import safe_exec
from xmodule.capa.xqueue_interface import DATEFORMAT, make_hashkey, make_xheader
from . import correctmap
from .registry import TagRegistry
from .util import (
DEFAULT_TOLERANCE,
compare_with_tolerance,
contextualize_text,
convert_files_to_filenames,
default_tolerance,
find_with_default,
get_course_id_from_capa_block,
get_inner_html_from_xpath,
@@ -63,7 +65,7 @@ CORRECTMAP_PY = None
# Make '_' a no-op so we can scrape strings. Using lambda instead of
# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
_ = lambda text: text
_ = lambda text: text # pylint: disable=unnecessary-lambda-assignment
QUESTION_HINT_CORRECT_STYLE = "feedback-hint-correct"
QUESTION_HINT_INCORRECT_STYLE = "feedback-hint-incorrect"
@@ -80,8 +82,6 @@ class LoncapaProblemError(Exception):
Error in specification of a problem
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
class ResponseError(Exception):
"""
@@ -89,8 +89,6 @@ class ResponseError(Exception):
exceptions that occur when executing a custom script.
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
class StudentInputError(Exception):
"""
@@ -98,15 +96,13 @@ class StudentInputError(Exception):
For example, submitting a string when the problem expects a number
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
# -----------------------------------------------------------------------------
#
# Main base class for CAPA responsetypes
class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
class LoncapaResponse(six.with_metaclass(abc.ABCMeta)): # pylint: disable=too-many-instance-attributes
"""
Base class for CAPA responsetypes. Each response type (ie a capa question,
which is part of a capa problem) is represented as a subclass,
@@ -158,7 +154,9 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
# By default, we set this to False, allowing subclasses to override as appropriate.
multi_device_support = False
def __init__(self, xml, inputfields, context, system, capa_block, minimal_init):
def __init__( # pylint: disable=too-many-arguments,too-many-branches,too-many-positional-arguments
self, xml, inputfields, context, system, capa_block, minimal_init
):
"""
Init is passed the following arguments:
@@ -180,19 +178,19 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
# only displayed to the user when settings.DEBUG is True
for abox in inputfields:
if abox.tag not in self.allowed_inputfields:
msg = "%s: cannot have input field %s" % (str(self), abox.tag)
msg += "\nSee XML source line %s" % getattr(xml, "sourceline", "[unavailable]")
msg = f"{self}: cannot have input field {abox.tag}"
msg += f"\nSee XML source line {getattr(xml, 'sourceline', '[unavailable]')}"
raise LoncapaProblemError(msg)
if self.max_inputfields and len(inputfields) > self.max_inputfields:
msg = "%s: cannot have more than %s input fields" % (str(self), self.max_inputfields)
msg += "\nSee XML source line %s" % getattr(xml, "sourceline", "[unavailable]")
msg = f"{self}: cannot have more than {self.max_inputfields} input fields"
msg += f"\nSee XML source line {getattr(xml, 'sourceline', '[unavailable]')}"
raise LoncapaProblemError(msg)
for prop in self.required_attributes:
if not xml.get(prop):
msg = "Error in problem specification: %s missing required attribute %s" % (str(self), prop)
msg += "\nSee XML source line %s" % getattr(xml, "sourceline", "[unavailable]")
msg = f"Error in problem specification: {self} missing required attribute {prop}"
msg += f"\nSee XML source line {getattr(xml, 'sourceline', '[unavailable]')}"
raise LoncapaProblemError(msg)
# ordered list of answer_id values for this response
@@ -304,7 +302,7 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
self.get_hints(convert_files_to_filenames(student_answers), new_cmap, old_cmap)
return new_cmap
def make_hint_div(
def make_hint_div( # pylint: disable=too-many-positional-arguments,too-many-arguments
self,
hint_node,
correct,
@@ -418,9 +416,8 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
installing it in the new_map for display.
Implemented by subclasses that have extended hints.
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
def get_hints(self, student_answers, new_cmap, old_cmap):
def get_hints(self, student_answers, new_cmap, old_cmap): # pylint: disable=too-many-locals
"""
Generate adaptive hints for this problem based on student answers, the old CorrectMap,
and the new CorrectMap produced by get_score.
@@ -449,7 +446,7 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
# We may extend this in the future to add another argument which provides a
# callback procedure to a social hint generation system.
global CORRECTMAP_PY
global CORRECTMAP_PY # pylint: disable=global-statement
if CORRECTMAP_PY is None:
# We need the CorrectMap code for hint functions. No, this is not great.
CORRECTMAP_PY = inspect.getsource(correctmap)
@@ -479,7 +476,7 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
}
try:
safe_exec.safe_exec(
safe_exec(
code,
globals_dict,
python_path=self.context["python_path"],
@@ -494,7 +491,7 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
msg = _("Error {err} in evaluating hint function {hintfn}.").format(err=err, hintfn=hintfn)
sourcenum = getattr(self.xml, "sourceline", _("(Source code line unavailable)"))
msg += "\n" + _("See XML source line {sourcenum}.").format(sourcenum=sourcenum)
raise ResponseError(msg) # lint-amnesty, pylint: disable=raise-missing-from
raise ResponseError(msg) from err
new_cmap.set_dict(globals_dict["new_cmap_dict"])
return
@@ -524,7 +521,7 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
):
rephints = hintgroup.findall(self.hint_tag)
hints_to_show = self.check_hint_condition( # lint-amnesty, pylint: disable=assignment-from-no-return
hints_to_show = self.check_hint_condition( # pylint: disable=assignment-from-no-return
rephints, student_answers
)
# can be 'on_request' or 'always' (default)
@@ -551,14 +548,12 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
Arguments:
- student_answers : dict of (answer_id, answer) where answer = student input (string)
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
@abc.abstractmethod
def get_answers(self):
"""
Return a dict of (answer_id, answer_text) for each answer for this question.
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
def check_hint_condition(self, hxml_set, student_answers):
"""
@@ -572,13 +567,12 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
Returns a list of names of hint conditions which were satisfied. Those are used
to determine which hints are displayed.
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
def setup_response(self):
pass
"""Check if the given string matches any expected string, optionally case-insensitive."""
def __str__(self):
return "LoncapaProblem Response %s" % self.xml.tag
return f"LoncapaProblem Response {self.xml.tag}"
def _render_response_msg_html(self, response_msg):
"""Render a <div> for a message that applies to the entire response.
@@ -593,7 +587,7 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
# If we can't do that, create the <div> and set the message
# as the text of the <div>
except Exception: # pylint: disable=broad-except
except Exception: # pylint: disable=broad-exception-caught
response_msg_div = etree.Element("div")
response_msg_div.text = str(response_msg)
@@ -733,10 +727,10 @@ class ChoiceResponse(LoncapaResponse):
if edc_current_grade == edc_max_grade:
return CorrectMap(self.answer_id, correctness="correct")
elif edc_current_grade > 0:
if edc_current_grade > 0:
return CorrectMap(self.answer_id, correctness="partially-correct", npoints=return_grade)
else:
return CorrectMap(self.answer_id, correctness="incorrect", npoints=0)
return CorrectMap(self.answer_id, correctness="incorrect", npoints=0)
def grade_via_halves(self, **kwargs):
"""
@@ -765,14 +759,16 @@ class ChoiceResponse(LoncapaResponse):
if halves_error_count == 0:
return_grade = self.get_max_score()
return CorrectMap(self.answer_id, correctness="correct", npoints=return_grade)
elif halves_error_count == 1 and len(all_choices) > 2:
if halves_error_count == 1 and len(all_choices) > 2:
return_grade = round_away_from_zero(self.get_max_score() / 2.0, 2)
return CorrectMap(self.answer_id, correctness="partially-correct", npoints=return_grade)
elif halves_error_count == 2 and len(all_choices) > 4:
if halves_error_count == 2 and len(all_choices) > 4:
return_grade = round_away_from_zero(self.get_max_score() / 4.0, 2)
return CorrectMap(self.answer_id, correctness="partially-correct", npoints=return_grade)
else:
return CorrectMap(self.answer_id, "incorrect")
return CorrectMap(self.answer_id, "incorrect")
def grade_without_partial_credit(self, **kwargs):
"""
@@ -790,8 +786,8 @@ class ChoiceResponse(LoncapaResponse):
if correct:
return CorrectMap(self.answer_id, "correct")
else:
return CorrectMap(self.answer_id, "incorrect")
return CorrectMap(self.answer_id, "incorrect")
def get_score(self, student_answers):
@@ -855,7 +851,7 @@ class ChoiceResponse(LoncapaResponse):
def get_answers(self):
return {self.answer_id: list(self.correct_choices)}
def get_extended_hints(self, student_answers, new_cmap):
def get_extended_hints(self, student_answers, new_cmap): # pylint: disable=too-many-locals
"""
Extract compound and extended hint information from the xml based on the student_answers.
The hint information goes into the msg= in new_cmap for display.
@@ -922,7 +918,7 @@ class ChoiceResponse(LoncapaResponse):
log_extra={"choice_all": choice_all}, # checkbox specific logging
)
def get_compound_hints(self, new_cmap, student_answers):
def get_compound_hints(self, new_cmap, student_answers): # pylint: disable=too-many-locals
"""
Compound hints are a type of extended hint specific to checkboxgroup with the
<compoundhint value="A C"> meaning choices A and C were selected.
@@ -1036,7 +1032,7 @@ class MultipleChoiceResponse(LoncapaResponse):
if contextualize_text(choice.get("correct"), self.context).lower() == "partial"
]
def get_extended_hints(self, student_answer_dict, new_cmap): # lint-amnesty, pylint: disable=arguments-differ
def get_extended_hints(self, student_answers, new_cmap):
"""
Extract any hints in a <choicegroup> matching the student's answers
<choicegroup label="What is your favorite color?" type="MultipleChoice">
@@ -1046,8 +1042,8 @@ class MultipleChoiceResponse(LoncapaResponse):
...
Any hint text is installed in the new_cmap.
"""
if self.answer_id in student_answer_dict:
student_answer = student_answer_dict[self.answer_id]
if self.answer_id in student_answers:
student_answer = student_answers[self.answer_id]
# Warning: mostly student_answer is a string, but sometimes it is a list of strings.
if isinstance(student_answer, list):
@@ -1055,9 +1051,7 @@ class MultipleChoiceResponse(LoncapaResponse):
# Find the named choice used by the student. Silently ignore a non-matching
# choice name.
choice = self.xml.find(
'./choicegroup[@id="{0}"]/choice[@name="{1}"]'.format(self.answer_id, student_answer)
)
choice = self.xml.find(f'./choicegroup[@id="{self.answer_id}"]/choice[@name="{student_answer}"]')
if choice is not None:
hint_node = choice.find("./choicehint")
new_cmap[self.answer_id]["msg"] += self.make_hint_div(
@@ -1119,12 +1113,12 @@ class MultipleChoiceResponse(LoncapaResponse):
if self.answer_id in student_answers and student_answers[self.answer_id] in self.correct_choices:
return CorrectMap(self.answer_id, correctness="correct")
elif self.answer_id in student_answers and student_answers[self.answer_id] in self.partial_choices:
if self.answer_id in student_answers and student_answers[self.answer_id] in self.partial_choices:
choice_index = self.partial_choices.index(student_answers[self.answer_id])
credit_amount = self.partial_values[choice_index]
return CorrectMap(self.answer_id, correctness="partially-correct", npoints=credit_amount)
else:
return CorrectMap(self.answer_id, "incorrect")
return CorrectMap(self.answer_id, "incorrect")
def grade_without_partial_credit(self, **kwargs):
"""
@@ -1138,8 +1132,8 @@ class MultipleChoiceResponse(LoncapaResponse):
if self.answer_id in student_answers and student_answers[self.answer_id] in self.correct_choices:
return CorrectMap(self.answer_id, correctness="correct")
else:
return CorrectMap(self.answer_id, "incorrect")
return CorrectMap(self.answer_id, "incorrect")
def get_score(self, student_answers):
"""
@@ -1265,12 +1259,12 @@ class MultipleChoiceResponse(LoncapaResponse):
# defined at the problem level, so the multiple rng's would be seeded the same.
# The name _shared_rng begins with an _ to suggest that it is not a facility
# for general use.
# pylint: disable=protected-access
if not hasattr(problem, "_shared_rng"):
problem._shared_rng = random.Random(self.context["seed"])
return problem._shared_rng
def do_answer_pool(self, tree, problem):
if not hasattr(problem, "_shared_rng"):
problem._shared_rng = random.Random(self.context["seed"]) # pylint: disable=protected-access
return problem._shared_rng # pylint: disable=protected-access
def do_answer_pool(self, tree, problem): # pylint: disable=too-many-locals
"""
Implements the answer-pool subsetting operation in-place on the tree.
Allows for problem questions with a pool of answers, from which answer options shown to the student
@@ -1291,11 +1285,11 @@ class MultipleChoiceResponse(LoncapaResponse):
return
try:
num_choices = int(num_str)
except ValueError:
except ValueError as exc:
_ = self.capa_system.i18n.gettext
# Translators: 'answer-pool' is an attribute name and should not be translated.
msg = _("answer-pool value should be an integer")
raise LoncapaProblemError(msg) # lint-amnesty, pylint: disable=raise-missing-from
raise LoncapaProblemError(msg) from exc
# Note in the response that answerpool is done.
# Both to avoid double-processing, and to feed the logs.
@@ -1383,14 +1377,16 @@ class MultipleChoiceResponse(LoncapaResponse):
@registry.register
class TrueFalseResponse(
MultipleChoiceResponse
): # lint-amnesty, pylint: disable=abstract-method, missing-class-docstring
class TrueFalseResponse(MultipleChoiceResponse):
"""Response type for True/False multiple choice questions."""
human_name = _("True/False Choice")
tags = ["truefalseresponse"]
def mc_setup_response(self):
"""
Sets up the XML structure for the True/False choices.
"""
i = 0
for response in self.xml.xpath("choicegroup"):
response.set("type", "TrueFalse")
@@ -1402,6 +1398,9 @@ class TrueFalseResponse(
choice.set("name", "choice_" + choice.get("name"))
def get_score(self, student_answers):
"""
Grade the student's answer for True/False.
"""
correct = set(self.correct_choices)
answers = student_answers.get(self.answer_id, [])
if not isinstance(answers, list):
@@ -1412,6 +1411,10 @@ class TrueFalseResponse(
return CorrectMap(self.answer_id, "incorrect")
def unmask_name(self, name):
"""Return the original choice name (no masking needed for True/False)."""
return name
# -----------------------------------------------------------------------------
@@ -1446,18 +1449,14 @@ class OptionResponse(LoncapaResponse):
return cmap
def get_answers(self):
amap = dict( # lint-amnesty, pylint: disable=consider-using-dict-comprehension
[
(
af.get("id"),
contextualize_text(
af.get("correct"),
self.context,
),
)
for af in self.answer_fields
]
)
amap = {
af.get("id"): contextualize_text(
af.get("correct"),
self.context,
)
for af in self.answer_fields
}
return amap
def get_student_answer_variable_name(self, student_answers, aid):
@@ -1516,10 +1515,10 @@ class NumericalResponse(LoncapaResponse):
self.correct_answer = ""
self.additional_answers = []
self.additional_answer_index = -1
self.tolerance = default_tolerance
self.tolerance = DEFAULT_TOLERANCE
self.range_tolerance = False
self.answer_range = self.inclusion = None
super(NumericalResponse, self).__init__(*args, **kwargs) # lint-amnesty, pylint: disable=super-with-arguments
super().__init__(*args, **kwargs)
def setup_response(self):
xml = self.xml
@@ -1531,18 +1530,16 @@ class NumericalResponse(LoncapaResponse):
if answer.startswith(("[", "(")) and answer.endswith(("]", ")")): # range tolerance case
self.range_tolerance = True
self.inclusion = (
True if answer.startswith("[") else False, # lint-amnesty, pylint: disable=simplifiable-if-expression
True if answer.endswith("]") else False, # lint-amnesty, pylint: disable=simplifiable-if-expression
answer.startswith("["),
answer.endswith("]"),
)
try:
self.answer_range = [contextualize_text(x, context) for x in answer[1:-1].split(",")]
self.correct_answer = answer[0] + self.answer_range[0] + ", " + self.answer_range[1] + answer[-1]
except Exception:
except Exception as exc:
log.debug("Content error--answer '%s' is not a valid range tolerance answer", answer)
_ = self.capa_system.i18n.gettext
raise StudentInputError( # lint-amnesty, pylint: disable=raise-missing-from
_("There was a problem with the staff answer to this problem.")
)
raise StudentInputError(_("There was a problem with the staff answer to this problem.")) from exc
else:
self.correct_answer = contextualize_text(answer, context)
@@ -1566,16 +1563,14 @@ class NumericalResponse(LoncapaResponse):
# `complex` seems to only generate `ValueErrors`, only catch these.
try:
correct_ans = evaluator({}, {}, answer)
except Exception:
except Exception as exc:
log.debug("Content error--answer '%s' is not a valid number", answer)
_ = self.capa_system.i18n.gettext
raise StudentInputError( # lint-amnesty, pylint: disable=raise-missing-from
_("There was a problem with the staff answer to this problem.")
)
raise StudentInputError(_("There was a problem with the staff answer to this problem.")) from exc
return correct_ans
def get_score(self, student_answers): # lint-amnesty, pylint: disable=too-many-statements
def get_score(self, student_answers): # pylint: disable=too-many-statements,too-many-locals,too-many-branches
"""
Grade a numeric response.
"""
@@ -1602,28 +1597,27 @@ class NumericalResponse(LoncapaResponse):
try:
student_float = evaluator({}, {}, student_answer)
except UndefinedVariable as err:
raise StudentInputError(err.args[0]) # lint-amnesty, pylint: disable=raise-missing-from
raise StudentInputError(err.args[0]) from err
except UnmatchedParenthesis as err:
raise StudentInputError(err.args[0]) # lint-amnesty, pylint: disable=raise-missing-from
raise StudentInputError(err.args[0]) from err
except ValueError as val_err:
if "factorial" in str(val_err): # lint-amnesty, pylint: disable=no-else-raise
if "factorial" in str(val_err):
# This is thrown when fact() or factorial() is used in an answer
# that evaluates on negative and/or non-integer inputs
# str(ve) will be: `factorial() only accepts integral values` or
# `factorial() not defined for negative values`
raise StudentInputError( # lint-amnesty, pylint: disable=raise-missing-from
raise StudentInputError(
_("Factorial function evaluated outside its domain:'{student_answer}'").format(
student_answer=html.escape(student_answer)
)
)
else:
raise general_exception # lint-amnesty, pylint: disable=raise-missing-from
except ParseException:
raise StudentInputError( # lint-amnesty, pylint: disable=raise-missing-from
) from val_err
raise general_exception from val_err
except ParseException as exc:
raise StudentInputError(
_("Invalid math syntax: '{student_answer}'").format(student_answer=html.escape(student_answer))
)
except Exception:
raise general_exception # lint-amnesty, pylint: disable=raise-missing-from
) from exc
except Exception as exc:
raise general_exception from exc
# End `evaluator` block -- we figured out the student's answer!
tree = self.xml
@@ -1720,11 +1714,11 @@ class NumericalResponse(LoncapaResponse):
if compare_with_tolerance(student_float, value, self.tolerance):
is_correct = "partially-correct"
break
elif "close" in self.credit_type:
if "close" in self.credit_type:
if compare_with_tolerance(student_float, correct_float, expanded_tolerance):
is_correct = "partially-correct"
break
elif compare_with_tolerance(student_float, value, expanded_tolerance):
if compare_with_tolerance(student_float, value, expanded_tolerance):
is_correct = "partially-correct"
partial_score = partial_score * partial_score
break
@@ -1748,8 +1742,8 @@ class NumericalResponse(LoncapaResponse):
if is_correct == "partially-correct":
return CorrectMap(self.answer_id, is_correct, npoints=partial_score)
else:
return CorrectMap(self.answer_id, is_correct)
return CorrectMap(self.answer_id, is_correct)
def compare_answer(self, ans1, ans2):
"""
@@ -1860,6 +1854,7 @@ class StringResponse(LoncapaResponse):
multi_device_support = True
def setup_response_backward(self):
"""Prepare the correct answers for backward-compatible string responses."""
self.correct_answer = [
contextualize_text(answer, self.context).strip() for answer in self.xml.get("answer").split("_or_")
]
@@ -1895,6 +1890,7 @@ class StringResponse(LoncapaResponse):
return CorrectMap(self.answer_id, "correct" if correct else "incorrect")
def check_string_backward(self, expected, given):
"""Check if the given string matches any expected string, optionally case-insensitive."""
if self.case_insensitive:
return given.lower() in [i.lower() for i in expected]
return given in expected
@@ -1974,13 +1970,13 @@ class StringResponse(LoncapaResponse):
# We follow the check_string convention/exception, adding ^ and $
regex = re.compile("^" + answer + "$", flags=flags | re.UNICODE)
return re.search(regex, given)
except Exception: # pylint: disable=broad-except
except Exception: # pylint: disable=broad-exception-caught
return False
if ci_mode:
return answer.lower() == given.lower()
else:
return answer == given
return answer == given
def check_string(self, expected, given):
"""
@@ -2017,17 +2013,16 @@ class StringResponse(LoncapaResponse):
regexp = re.compile("^" + "|".join(expected) + "$", flags=flags | re.UNICODE)
result = re.search(regexp, given)
except Exception as err:
msg = "[courseware.capa.responsetypes.stringresponse] {error}: {message}".format(
error=_("error"), message=str(err)
)
msg = f"[courseware.capa.responsetypes.stringresponse] {_('error')}: {err}"
log.error(msg, exc_info=True)
raise ResponseError(msg) # lint-amnesty, pylint: disable=raise-missing-from
raise ResponseError(msg) from err
return bool(result)
else: # string match
if self.case_insensitive:
return given.lower() in [i.lower() for i in expected]
else:
return given in expected
# string match
if self.case_insensitive:
return given.lower() in [i.lower() for i in expected]
return given in expected
def check_hint_condition(self, hxml_set, student_answers):
given = student_answers[self.answer_id].strip()
@@ -2114,14 +2109,14 @@ class CustomResponse(LoncapaResponse):
# and invoke the function with the data needed.
def make_check_function(script_code, cfn):
def check_function(expect, ans, **kwargs):
extra_args = "".join(", {0}={0}".format(k) for k in kwargs)
code = script_code + "\n" + "cfn_return = %s(expect, ans%s)\n" % (cfn, extra_args)
extra_args = "".join(f", {k}={k}" for k in kwargs)
code = f"{script_code}\ncfn_return = {cfn}(expect, ans{extra_args})\n"
globals_dict = {
"expect": expect,
"ans": ans,
}
globals_dict.update(kwargs)
safe_exec.safe_exec(
safe_exec(
code,
globals_dict,
python_path=self.context["python_path"],
@@ -2149,7 +2144,7 @@ class CustomResponse(LoncapaResponse):
else:
self.code = answer.text
def get_score(self, student_answers):
def get_score(self, student_answers): # pylint: disable=too-many-locals
"""
student_answers is a dict with everything from request.POST, but with the first part
of each key removed (the string before the first "_").
@@ -2167,12 +2162,10 @@ class CustomResponse(LoncapaResponse):
# ordered list of answers
submission = [student_answers[k] for k in idset]
except Exception as err:
msg = "[courseware.capa.responsetypes.customresponse] {message}\n idset = {idset}, error = {err}".format(
message=_("error getting student answer from {student_answers}").format(
student_answers=student_answers,
),
idset=idset,
err=err,
msg = (
f"[courseware.capa.responsetypes.customresponse] "
f"{_('error getting student answer from {student_answers}').format(student_answers=student_answers)}\n"
f"idset = {idset}, error = {err}"
)
log.error(
@@ -2183,7 +2176,7 @@ class CustomResponse(LoncapaResponse):
idset,
err,
)
raise Exception(msg) # lint-amnesty, pylint: disable=raise-missing-from
raise Exception(msg) from err # pylint: disable=broad-exception-raised
# global variable in context which holds the Presentation MathML from dynamic math input
# ordered list of dynamath responses
@@ -2251,8 +2244,8 @@ class CustomResponse(LoncapaResponse):
correct_map = CorrectMap()
correct_map.set_overall_message(overall_message)
for k in range(len(idset)): # lint-amnesty, pylint: disable=consider-using-enumerate
max_points = self.maxpoints[idset[k]]
for k, item_id in enumerate(idset):
max_points = self.maxpoints[item_id]
if grade_decimals:
npoints = max_points * grade_decimals[k]
else:
@@ -2267,11 +2260,14 @@ class CustomResponse(LoncapaResponse):
def execute_check_function(
self, idset, submission
): # lint-amnesty, pylint: disable=missing-function-docstring, too-many-statements
): # pylint: disable=too-many-statements,too-many-locals,too-many-branches
"""Execute the custom check function for a submission, updating correctness,
messages, and grades in the context."""
# exec the check function
if isinstance(self.code, str): # lint-amnesty, pylint: disable=too-many-nested-blocks
if isinstance(self.code, str): # pylint: disable=too-many-nested-blocks
try:
safe_exec.safe_exec(
safe_exec(
self.code,
self.context,
cache=self.capa_system.cache,
@@ -2282,7 +2278,7 @@ class CustomResponse(LoncapaResponse):
random_seed=self.context["seed"],
unsafely=self.capa_system.can_execute_unsafe_code(),
)
except Exception as err: # pylint: disable=broad-except
except Exception as err: # pylint: disable=broad-exception-caught
self._handle_exec_exception(err)
else:
@@ -2296,7 +2292,7 @@ class CustomResponse(LoncapaResponse):
log.debug(" submission = %s", submission)
try:
ret = tutor_cfn(self.expect, answer_given, **kwargs)
except Exception as err: # pylint: disable=broad-except
except Exception as err: # pylint: disable=broad-exception-caught
self._handle_exec_exception(err)
log.debug("[courseware.capa.responsetypes.customresponse.get_score] ret = %s", ret)
if isinstance(ret, dict):
@@ -2432,7 +2428,9 @@ class CustomResponse(LoncapaResponse):
self.context["correct"] = correct
def clean_message_html(self, msg): # lint-amnesty, pylint: disable=missing-function-docstring
def clean_message_html(self, msg):
"""Clean and prettify HTML in the given message string,
handling special characters and removing wrapper tags."""
# If *msg* is an empty string, then the code below
# will return "</html>". To avoid this, we first check
@@ -2459,8 +2457,7 @@ class CustomResponse(LoncapaResponse):
return msg.strip()
# If we start with an empty string, then return an empty string
else:
return ""
return ""
def get_answers(self):
"""
@@ -2515,11 +2512,9 @@ class SymbolicResponse(CustomResponse):
self.xml.set("cfn", "symmath_check")
# Let CustomResponse do its setup
super(SymbolicResponse, self).setup_response() # lint-amnesty, pylint: disable=super-with-arguments
super().setup_response()
def execute_check_function(self, idset, submission):
from symmath import symmath_check
try:
# Since we have limited max_inputfields to 1,
# we can assume that there is only one submission
@@ -2540,18 +2535,18 @@ class SymbolicResponse(CustomResponse):
msg = _("An error occurred with SymbolicResponse. The error was: {error_msg}").format(
error_msg=err,
)
raise Exception(msg) # lint-amnesty, pylint: disable=raise-missing-from
raise Exception(msg) from err # pylint: disable=broad-exception-raised
self.context["messages"][0] = self.clean_message_html(ret["msg"])
self.context["correct"] = ["correct" if ret["ok"] else "incorrect"] * len(idset)
# -----------------------------------------------------------------------------
## ScoreMessage named tuple ##
## valid: Flag indicating valid score_msg format (Boolean)
## correct: Correctness of submission (Boolean)
## score: Points to be assigned (numeric, can be float)
## msg: Message from grader to display to student (string)
# ScoreMessage named tuple #
# valid: Flag indicating valid score_msg format (Boolean)
# correct: Correctness of submission (Boolean)
# score: Points to be assigned (numeric, can be float)
# msg: Message from grader to display to student (string)
ScoreMessage = namedtuple("ScoreMessage", ["valid", "correct", "points", "msg"])
@@ -2634,7 +2629,7 @@ class CodeResponse(LoncapaResponse):
_ = self.capa_system.i18n.gettext
self.answer = find_with_default(codeparam, "answer_display", _("No answer provided."))
def get_score(self, student_answers):
def get_score(self, student_answers): # pylint: disable=too-many-locals
_ = self.capa_system.i18n.gettext
try:
# Note that submission can be a file
@@ -2646,7 +2641,7 @@ class CodeResponse(LoncapaResponse):
self.answer_id,
convert_files_to_filenames(student_answers),
)
raise Exception(err) # lint-amnesty, pylint: disable=raise-missing-from
raise Exception(err) from err # pylint: disable=broad-exception-raised
# We do not support xqueue within Studio.
if self.capa_system.xqueue is None:
@@ -2658,18 +2653,14 @@ class CodeResponse(LoncapaResponse):
# ------------------------------------------------------------
qinterface = self.capa_system.xqueue.interface
qtime = datetime.strftime(datetime.now(UTC), xqueue_interface.dateformat)
qtime = datetime.strftime(datetime.now(UTC), DATEFORMAT)
anonymous_student_id = self.capa_system.anonymous_student_id
# Generate header
queuekey = xqueue_interface.make_hashkey(
str(self.capa_system.seed) + qtime + anonymous_student_id + self.answer_id
)
queuekey = make_hashkey(str(self.capa_system.seed) + qtime + anonymous_student_id + self.answer_id)
callback_url = self.capa_system.xqueue.construct_callback()
xheader = xqueue_interface.make_xheader(
lms_callback_url=callback_url, lms_key=queuekey, queue_name=self.queue_name
)
xheader = make_xheader(lms_callback_url=callback_url, lms_key=queuekey, queue_name=self.queue_name)
# Generate body
if is_list_of_files(submission):
@@ -2748,8 +2739,7 @@ class CodeResponse(LoncapaResponse):
# matches
if oldcmap.is_right_queuekey(self.answer_id, queuekey):
# Sanity check on returned points
if points < 0: # lint-amnesty, pylint: disable=consider-using-max-builtin
points = 0
points = max(points, 0)
# Queuestate is consumed
oldcmap.set(
self.answer_id,
@@ -2857,7 +2847,7 @@ class ExternalResponse(LoncapaResponse):
self.url = ""
self.tests = []
self.code = ""
super(ExternalResponse, self).__init__(*args, **kwargs) # lint-amnesty, pylint: disable=super-with-arguments
super().__init__(*args, **kwargs)
def setup_response(self):
xml = self.xml
@@ -2876,8 +2866,8 @@ class ExternalResponse(LoncapaResponse):
# no <answer> stanza; get code from <script>
self.code = self.context["script_code"]
if not self.code:
msg = "%s: Missing answer script code for externalresponse" % str(self)
msg += "\nSee XML source line %s" % getattr(self.xml, "sourceline", "[unavailable]")
msg = f"{self}: Missing answer script code for externalresponse"
msg += f"\nSee XML source line {getattr(self.xml, 'sourceline', '[unavailable]')}"
raise LoncapaProblemError(msg)
self.tests = xml.get("tests")
@@ -2903,25 +2893,27 @@ class ExternalResponse(LoncapaResponse):
try:
# call external server. TODO: synchronous call, can block for a
# long time
req = requests.post(self.url, data=payload)
req = requests.post(self.url, data=payload, timeout=10)
except Exception as err:
msg = "Error {0} - cannot connect to external server url={1}".format(err, self.url)
msg = f"Error {err} - cannot connect to external server url={self.url}"
log.error(msg)
raise Exception(msg) # lint-amnesty, pylint: disable=raise-missing-from
raise Exception(msg) from err # pylint: disable=broad-exception-raised
if self.capa_system.DEBUG:
log.info("response = %s", req.text)
if (not req.text) or (not req.text.strip()):
raise Exception("Error: no response from external server url=%s" % self.url)
raise Exception( # pylint: disable=broad-exception-raised
f"Error: no response from external server url={self.url}"
)
try:
# response is XML; parse it
rxml = etree.fromstring(req.text)
except Exception as err:
msg = "Error {0} - cannot parse response from external server req.text={1}".format(err, req.text)
msg = f"Error {err} - cannot parse response from external server req.text={req.text}"
log.error(msg)
raise Exception(msg) # lint-amnesty, pylint: disable=raise-missing-from
raise Exception(msg) from err # pylint: disable=broad-exception-raised
return rxml
@@ -2934,7 +2926,7 @@ class ExternalResponse(LoncapaResponse):
log.error(
"Error %s: cannot get student answer for %s; student_answers=%s", err, self.answer_ids, student_answers
)
raise Exception(err) # lint-amnesty, pylint: disable=raise-missing-from
raise Exception(err) from err # pylint: disable=broad-exception-raised
self.context.update({"submission": submission})
@@ -2942,7 +2934,7 @@ class ExternalResponse(LoncapaResponse):
try:
rxml = self.do_external_request("get_score", extra_payload)
except Exception as err: # pylint: disable=broad-except
except Exception as err: # pylint: disable=broad-exception-caught
log.error("Error %s", err)
if self.capa_system.DEBUG:
cmap.set_dict(dict(list(zip(sorted(self.answer_ids), ["incorrect"] * len(idset)))))
@@ -2972,7 +2964,7 @@ class ExternalResponse(LoncapaResponse):
try:
rxml = self.do_external_request("get_answers", {})
exans = json.loads(rxml.find("expected").text)
except Exception as err: # pylint: disable=broad-except
except Exception as err: # pylint: disable=broad-exception-caught
log.error("Error %s", err)
if self.capa_system.DEBUG:
msg = HTML('<span class="inline-error">{}</span>').format(err)
@@ -2981,7 +2973,7 @@ class ExternalResponse(LoncapaResponse):
if not len(exans) == len(self.answer_ids):
log.error("Expected %s answers from external server, only got %s!", len(self.answer_ids), len(exans))
raise Exception("Short response from external server")
raise Exception("Short response from external server") # pylint: disable=broad-exception-raised
return dict(list(zip(self.answer_ids, exans)))
@@ -3005,9 +2997,9 @@ class FormulaResponse(LoncapaResponse):
def __init__(self, *args, **kwargs):
self.correct_answer = ""
self.samples = ""
self.tolerance = default_tolerance
self.tolerance = DEFAULT_TOLERANCE
self.case_sensitive = False
super(FormulaResponse, self).__init__(*args, **kwargs) # lint-amnesty, pylint: disable=super-with-arguments
super().__init__(*args, **kwargs)
def setup_response(self):
xml = self.xml
@@ -3061,10 +3053,10 @@ class FormulaResponse(LoncapaResponse):
)
except UndefinedVariable as err:
log.debug("formularesponse: undefined variable in formula=%s", html.escape(answer))
raise StudentInputError(err.args[0]) # lint-amnesty, pylint: disable=raise-missing-from
raise StudentInputError(err.args[0]) from err
except UnmatchedParenthesis as err:
log.debug("formularesponse: unmatched parenthesis in formula=%s", html.escape(answer))
raise StudentInputError(err.args[0]) # lint-amnesty, pylint: disable=raise-missing-from
raise StudentInputError(err.args[0]) from err
except ValueError as err:
if "factorial" in str(err):
# This is thrown when fact() or factorial() is used in a formularesponse answer
@@ -3079,26 +3071,26 @@ class FormulaResponse(LoncapaResponse):
),
html.escape(answer),
)
raise StudentInputError( # lint-amnesty, pylint: disable=raise-missing-from
raise StudentInputError(
_(
"Factorial function not permitted in answer "
"for this problem. Provided answer was: "
"{bad_input}"
).format(bad_input=html.escape(answer))
)
) from err
# If non-factorial related ValueError thrown, handle it the same as any other Exception
log.debug("formularesponse: error %s in formula", err)
raise StudentInputError( # lint-amnesty, pylint: disable=raise-missing-from
raise StudentInputError(
_("Invalid input: Could not parse '{bad_input}' as a formula.").format(
bad_input=html.escape(answer)
)
)
) from err
except Exception as err:
# traceback.print_exc()
log.debug("formularesponse: error %s in formula", err)
raise StudentInputError( # lint-amnesty, pylint: disable=raise-missing-from
raise StudentInputError(
_("Invalid input: Could not parse '{bad_input}' as a formula").format(bad_input=html.escape(answer))
)
) from err
return out
def randomize_variables(self, samples):
@@ -3138,8 +3130,8 @@ class FormulaResponse(LoncapaResponse):
)
if correct:
return "correct"
else:
return "incorrect"
return "incorrect"
def compare_answer(self, ans1, ans2):
"""
@@ -3165,13 +3157,12 @@ class FormulaResponse(LoncapaResponse):
keys and all non-numeric values stripped out. All values also
converted to float. Used so we can safely use Python contexts.
"""
inp_d = dict( # lint-amnesty, pylint: disable=consider-using-dict-comprehension
[
(k, numpy.complex(inp_d[k]))
for k in inp_d
if isinstance(k, str) and k.isalnum() and isinstance(inp_d[k], numbers.Number)
]
)
inp_d = {
k: numpy.complex(inp_d[k])
for k in inp_d
if isinstance(k, str) and k.isalnum() and isinstance(inp_d[k], numbers.Number)
}
return inp_d
def check_hint_condition(self, hxml_set, student_answers):
@@ -3181,10 +3172,10 @@ class FormulaResponse(LoncapaResponse):
samples = hxml.get("samples")
name = hxml.get("name")
correct_answer = contextualize_text(hxml.get("answer"), self.context)
# pylint: disable=broad-except
try:
correctness = self.check_formula(correct_answer, given, samples)
except Exception:
except Exception: # pylint: disable=broad-exception-caught
correctness = "incorrect"
if correctness == "correct":
hints_to_show.append(name)
@@ -3210,7 +3201,7 @@ class SchematicResponse(LoncapaResponse):
def __init__(self, *args, **kwargs):
self.code = ""
super(SchematicResponse, self).__init__(*args, **kwargs) # lint-amnesty, pylint: disable=super-with-arguments
super().__init__(*args, **kwargs)
def setup_response(self):
xml = self.xml
@@ -3226,7 +3217,7 @@ class SchematicResponse(LoncapaResponse):
submission = [json.loads(student_answers[k]) for k in sorted(self.answer_ids)]
self.context.update({"submission": submission})
try:
safe_exec.safe_exec(
safe_exec(
self.code,
self.context,
cache=self.capa_system.cache,
@@ -3241,7 +3232,7 @@ class SchematicResponse(LoncapaResponse):
_ = self.capa_system.i18n.gettext
# Translators: 'SchematicResponse' is a problem type and should not be translated.
msg = _("Error in evaluating SchematicResponse. The error was: {error_msg}").format(error_msg=err)
raise ResponseError(msg) # lint-amnesty, pylint: disable=raise-missing-from
raise ResponseError(msg) from err
cmap = CorrectMap()
cmap.set_dict(dict(list(zip(sorted(self.answer_ids), self.context["correct"]))))
return cmap
@@ -3288,13 +3279,13 @@ class ImageResponse(LoncapaResponse):
def __init__(self, *args, **kwargs):
self.ielements = []
super(ImageResponse, self).__init__(*args, **kwargs) # lint-amnesty, pylint: disable=super-with-arguments
super().__init__(*args, **kwargs)
def setup_response(self):
self.ielements = self.inputfields
self.answer_ids = [ie.get("id") for ie in self.ielements]
def get_score(self, student_answers):
def get_score(self, student_answers): # pylint: disable=too-many-locals
_ = self.capa_system.i18n.gettext
correct_map = CorrectMap()
expectedset = self.get_mapped_answers()
@@ -3310,7 +3301,9 @@ class ImageResponse(LoncapaResponse):
msg = _("error grading {image_input_id} (input={user_input})").format(
image_input_id=aid, user_input=given
)
raise Exception("[capamodule.capa.responsetypes.imageinput] " + msg)
raise Exception( # pylint: disable=broad-exception-raised
"[capamodule.capa.responsetypes.imageinput] " + msg
)
(ans_x, ans_y) = [int(x) for x in acoords.groups()]
@@ -3331,7 +3324,9 @@ class ImageResponse(LoncapaResponse):
msg = _("Error in problem specification! Cannot parse rectangle in {sr_coords}").format(
sr_coords=etree.tostring(self.ielements[aid], pretty_print=True)
)
raise Exception("[capamodule.capa.responsetypes.imageinput] " + msg)
raise Exception( # pylint: disable=broad-exception-raised
"[capamodule.capa.responsetypes.imageinput] " + msg
)
(llx, lly, urx, ury) = [int(x) for x in sr_coords.groups()]
@@ -3367,19 +3362,10 @@ class ImageResponse(LoncapaResponse):
regions (dict) - a map of inputs to the defined region for that input
"""
answers = (
dict( # lint-amnesty, pylint: disable=consider-using-dict-comprehension
[
(
ie.get("id"),
ie.get("rectangle"),
)
for ie in self.ielements
]
),
dict( # lint-amnesty, pylint: disable=consider-using-dict-comprehension
[(ie.get("id"), ie.get("regions")) for ie in self.ielements]
),
{ie.get("id"): ie.get("rectangle") for ie in self.ielements},
{ie.get("id"): ie.get("regions") for ie in self.ielements},
)
return answers
def get_answers(self):
@@ -3421,7 +3407,7 @@ class AnnotationResponse(LoncapaResponse):
def __init__(self, *args, **kwargs):
self.scoring_map = {}
self.answer_map = {}
super(AnnotationResponse, self).__init__(*args, **kwargs) # lint-amnesty, pylint: disable=super-with-arguments
super().__init__(*args, **kwargs)
def setup_response(self):
self.scoring_map = self._get_scoring_map()
@@ -3452,21 +3438,17 @@ class AnnotationResponse(LoncapaResponse):
def _get_scoring_map(self):
"""Returns a dict of option->scoring for each input."""
scoring = self.default_scoring
choices = dict( # lint-amnesty, pylint: disable=consider-using-dict-comprehension
[(choice, choice) for choice in scoring]
)
choices = {choice: choice for choice in scoring}
scoring_map = {}
for inputfield in self.inputfields:
option_scoring = dict( # lint-amnesty, pylint: disable=consider-using-dict-comprehension
[
(
option["id"],
{"correctness": choices.get(option["choice"]), "points": scoring.get(option["choice"])},
)
for option in self._find_options(inputfield)
]
)
option_scoring = {
option["id"]: {
"correctness": choices.get(option["choice"]),
"points": scoring.get(option["choice"]),
}
for option in self._find_options(inputfield)
}
scoring_map[inputfield.get("id")] = option_scoring
@@ -3486,9 +3468,7 @@ class AnnotationResponse(LoncapaResponse):
"""Returns a dict of the max points for each input: input id -> maxpoints."""
scoring = self.default_scoring
correct_points = scoring.get("correct")
return dict( # lint-amnesty, pylint: disable=consider-using-dict-comprehension
[(inputfield.get("id"), correct_points) for inputfield in self.inputfields]
)
return {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."""
@@ -3503,6 +3483,7 @@ class AnnotationResponse(LoncapaResponse):
for option in self._find_options(inputfield):
if option["choice"] == choice:
return option
return None
def _unpack(self, json_value):
"""Unpacks a student response value submitted as JSON."""
@@ -3550,7 +3531,7 @@ class ChoiceTextResponse(LoncapaResponse):
self.correct_inputs = {}
self.answer_values = {}
self.correct_choices = {}
super(ChoiceTextResponse, self).__init__(*args, **kwargs) # lint-amnesty, pylint: disable=super-with-arguments
super().__init__(*args, **kwargs)
def setup_response(self):
"""
@@ -3595,7 +3576,7 @@ class ChoiceTextResponse(LoncapaResponse):
answer = contextualize_text(answer, context)
input_name = child.get("name")
# Contextualize the tolerance to value.
tolerance = contextualize_text(child.get("tolerance", default_tolerance), context)
tolerance = contextualize_text(child.get("tolerance", DEFAULT_TOLERANCE), context)
# Add the answer and tolerance information for the current
# numtolerance_input to `correct_inputs`
self.correct_inputs[input_name] = {"answer": answer, "tolerance": tolerance}
@@ -3808,28 +3789,26 @@ class ChoiceTextResponse(LoncapaResponse):
correct_ans = params["answer"]
# Set the tolerance to '0' if it was not specified in the xml
tolerance = params.get("tolerance", default_tolerance)
tolerance = params.get("tolerance", DEFAULT_TOLERANCE)
# Make sure that the staff answer is a valid number
try:
correct_ans = complex(correct_ans)
except ValueError:
except ValueError as exc:
log.debug("Content error--answer '%s' is not a valid complex number", correct_ans)
raise StudentInputError( # lint-amnesty, pylint: disable=raise-missing-from
_("The Staff answer could not be interpreted as a number.")
)
raise StudentInputError(_("The Staff answer could not be interpreted as a number.")) from exc
# Compare the student answer to the staff answer/ or to 0
# if all that is important is verifying numericality
try:
partial_correct = compare_with_tolerance(evaluator({}, {}, answer_value), correct_ans, tolerance)
except:
except Exception as exc:
# Use the traceback-preserving version of re-raising with a
# different type
__, __, trace = sys.exc_info()
msg = _("Could not interpret '{given_answer}' as a number.").format(
given_answer=html.escape(answer_value)
msg = _( # pylint: disable=translation-of-non-string
f"Could not interpret '{html.escape(answer_value)}' as a number."
)
msg += " ({0})".format(trace)
raise StudentInputError(msg) # lint-amnesty, pylint: disable=raise-missing-from
msg += f" ({trace})"
raise StudentInputError(msg) from exc
# Ignore the results of the comparisons which were just for
# Numerical Validation.
@@ -3844,22 +3823,20 @@ class ChoiceTextResponse(LoncapaResponse):
# TEMPORARY: List of all response subclasses
# FIXME: To be replaced by auto-registration
# pylint: disable=invalid-all-object
__all__ = [
CodeResponse,
NumericalResponse,
FormulaResponse,
CustomResponse,
SchematicResponse,
ExternalResponse,
ImageResponse,
OptionResponse,
SymbolicResponse,
StringResponse,
ChoiceResponse,
MultipleChoiceResponse,
TrueFalseResponse,
AnnotationResponse,
ChoiceTextResponse,
"CodeResponse",
"NumericalResponse",
"FormulaResponse",
"CustomResponse",
"SchematicResponse",
"ExternalResponse",
"ImageResponse",
"OptionResponse",
"SymbolicResponse",
"StringResponse",
"ChoiceResponse",
"MultipleChoiceResponse",
"TrueFalseResponse",
"AnnotationResponse",
"ChoiceTextResponse",
]
# pylint: enable=invalid-all-object

View File

@@ -8,7 +8,7 @@ in the public domain.
import sys
class LazyModule(object):
class LazyModule: # pylint: disable=too-few-public-methods
"""A lazy module proxy."""
def __init__(self, modname):
@@ -32,14 +32,12 @@ class LazyModule(object):
if hasattr(mod, name):
return getattr(mod, name)
else:
try:
subname = "%s.%s" % (self.__name__, name)
__import__(subname)
submod = getattr(mod, name) # lint-amnesty, pylint: disable=unused-variable
except ImportError:
raise AttributeError( # lint-amnesty, pylint: disable=raise-missing-from
"'module' object has no attribute %r" % name
)
self.__dict__[name] = LazyModule(subname)
return self.__dict__[name]
try:
subname = f"{self.__name__}.{name}"
__import__(subname)
submod = getattr(mod, name) # pylint: disable=unused-variable
except ImportError as exc:
raise AttributeError(f"'module' object has no attribute {name!r}") from exc
self.__dict__[name] = LazyModule(subname)
return self.__dict__[name]

View File

@@ -42,6 +42,7 @@ ENABLE_CODEJAIL_DARKLAUNCH = SettingToggle("ENABLE_CODEJAIL_DARKLAUNCH", default
def is_codejail_rest_service_enabled():
"""Return whether the codejail REST service is enabled."""
return ENABLE_CODEJAIL_REST_SERVICE.is_enabled()
@@ -69,6 +70,7 @@ def get_remote_exec(*args, **kwargs):
def get_codejail_rest_service_endpoint():
"""Return the endpoint URL for the codejail REST service."""
return f"{settings.CODE_JAIL_REST_SERVICE_HOST}/api/v0/code-exec"

View File

@@ -69,16 +69,16 @@ ASSUMED_IMPORTS = [
]
# We'll need the code from lazymod.py for use in safe_exec, so read it now.
lazymod_py_file = lazymod.__file__
if lazymod_py_file.endswith("c"):
lazymod_py_file = lazymod_py_file[:-1]
LAZYMOD_PY_FILE = lazymod.__file__
if LAZYMOD_PY_FILE.endswith("c"):
LAZYMOD_PY_FILE = LAZYMOD_PY_FILE[:-1]
with open(lazymod_py_file) as f:
with open(LAZYMOD_PY_FILE, encoding="utf-8") as f:
lazymod_py = f.read()
LAZY_IMPORTS = [lazymod_py]
for name, modname in ASSUMED_IMPORTS:
LAZY_IMPORTS.append("{} = LazyModule('{}')\n".format(name, modname))
LAZY_IMPORTS.append(f"{name} = LazyModule('{modname}')\n")
LAZY_IMPORTS = "".join(LAZY_IMPORTS)
@@ -107,7 +107,7 @@ def update_hash(hasher, obj):
@function_trace("safe_exec")
def safe_exec(
def safe_exec( # pylint: disable=too-many-arguments,too-many-branches,too-many-locals,too-many-positional-arguments,too-many-statements
code,
globals_dict,
random_seed=None,
@@ -117,7 +117,7 @@ def safe_exec(
limit_overrides_context=None,
slug=None,
unsafely=False,
): # pylint: disable=too-many-statements
):
"""
Execute python code safely.
@@ -155,7 +155,7 @@ def safe_exec(
md5er = hashlib.md5()
md5er.update(repr(code).encode("utf-8"))
update_hash(md5er, safe_globals)
key = "safe_exec.%r.%s" % (random_seed, md5er.hexdigest())
key = f"safe_exec.{random_seed!r}.{md5er.hexdigest()}"
cached = cache.get(key)
if cached is not None:
# We have a cached result. The result is a pair: the exception
@@ -210,7 +210,7 @@ def safe_exec(
limit_overrides_context=limit_overrides_context,
slug=slug,
)
except BaseException as e:
except BaseException as e: # pylint: disable=broad-exception-caught
# Saving SafeExecException e in exception to be used later.
exception = e
emsg = str(e)
@@ -263,7 +263,7 @@ def safe_exec(
# SafeExecException wrapped around emsg (if present).
remote_emsg, _ = get_remote_exec(data)
remote_exception = None
except BaseException as e: # pragma: no cover # pylint: disable=broad-except
except BaseException as e: # pragma: no cover # pylint: disable=broad-exception-caught
# Swallow all exceptions and log it in monitoring so that dark launch doesn't cause issues during
# deploy.
remote_emsg = None
@@ -282,7 +282,7 @@ def safe_exec(
emsg_remote=remote_emsg,
unexpected_exc_remote=remote_exception,
)
except BaseException as e: # pragma: no cover # pylint: disable=broad-except
except BaseException: # pragma: no cover # pylint: disable=broad-exception-caught
log.exception("Error occurred while trying to report codejail darklaunch data.")
record_exception()
@@ -376,7 +376,7 @@ def emsg_normalizers():
custom_setting = getattr(settings, "CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS", [])
try:
custom_normalizers = _compile_normalizers(custom_setting)
except BaseException as e:
except BaseException: # pylint: disable=broad-exception-caught
log.error("Could not load custom codejail darklaunch emsg normalizers")
record_exception()
return default_normalizers
@@ -390,8 +390,9 @@ def emsg_normalizers():
combine = getattr(settings, "CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS_COMBINE", "append")
if combine == "replace":
return custom_normalizers
else: # 'append', or unknown
return default_normalizers + custom_normalizers
# 'append', or unknown
return default_normalizers + custom_normalizers
def normalize_error_message(emsg):
@@ -407,7 +408,7 @@ def normalize_error_message(emsg):
return emsg
def report_darklaunch_results(
def report_darklaunch_results( # pylint: disable=too-many-arguments
*,
limit_overrides_context,
slug,
@@ -454,22 +455,32 @@ def report_darklaunch_results(
set_custom_attribute("codejail.darklaunch.globals_match", "N/A")
set_custom_attribute("codejail.darklaunch.emsg_match", "N/A")
log.info(
"Codejail darklaunch had unexpected exception for "
f"course={limit_overrides_context!r}, slug={slug!r}:\n"
f"Local exception: {unexpected_exc_local!r}\n"
f"Remote exception: {unexpected_exc_remote!r}"
"Codejail darklaunch had unexpected exception for course=%r, slug=%r:\n"
"Local exception: %r\nRemote exception: %r",
limit_overrides_context,
slug,
unexpected_exc_local,
unexpected_exc_remote,
)
return
return None
globals_match = globals_local == globals_remote
emsg_match = normalize_error_message(emsg_local) == normalize_error_message(emsg_remote)
if not globals_match or not emsg_match:
log.info(
f"Codejail darklaunch had mismatch for course={limit_overrides_context!r}, slug={slug!r}:\n"
f"{emsg_match=}, {globals_match=}\n"
f"Local: globals={globals_local!r}, emsg={emsg_local!r}\n"
f"Remote: globals={globals_remote!r}, emsg={emsg_remote!r}"
"Codejail darklaunch had mismatch for course=%r, slug=%r:\n"
"emsg_match=%r, globals_match=%r\n"
"Local: globals=%r, emsg=%r\n"
"Remote: globals=%r, emsg=%r",
limit_overrides_context,
slug,
emsg_match,
globals_match,
globals_local,
emsg_local,
globals_remote,
emsg_remote,
)
# .. custom_attribute_name: codejail.darklaunch.globals_match
@@ -484,10 +495,11 @@ def report_darklaunch_results(
# the randomized directory names used for sandboxes. 'N/A' when either
# arm raised an uncaught error.
set_custom_attribute("codejail.darklaunch.emsg_match", emsg_match)
return None
@receiver(setting_changed)
def reset_caches(sender, **kwargs):
def reset_caches(sender, **kwargs): # pylint: disable=unused-argument
"""
Reset cached settings during unit tests.
"""

View File

@@ -6,7 +6,7 @@ import unittest
from xmodule.capa.safe_exec.lazymod import LazyModule
class ModuleIsolation(object):
class ModuleIsolation: # pylint: disable=too-few-public-methods
"""
Manage changes to sys.modules so that we can roll back imported modules.
@@ -18,7 +18,9 @@ class ModuleIsolation(object):
# Save all the names of all the imported modules.
self.mods = set(sys.modules)
def clean_up(self): # lint-amnesty, pylint: disable=missing-function-docstring
def clean_up(self):
"""Remove any modules imported after initialization to restore state."""
# Get a list of modules that didn't exist when we were created
new_mods = [m for m in sys.modules if m not in self.mods]
# and delete them all so another import will run code for real again.
@@ -26,14 +28,16 @@ class ModuleIsolation(object):
del sys.modules[m]
class TestLazyMod(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring
class TestLazyMod(unittest.TestCase):
"""Unit tests for verifying lazy module importing with LazyModule."""
def setUp(self):
super(TestLazyMod, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
# Each test will remove modules that it imported.
self.addCleanup(ModuleIsolation().clean_up)
def test_simple(self):
"""Test lazy import of a standard module and verify functionality."""
# Import some stdlib module that has not been imported before
module_name = "colorsys"
if module_name in sys.modules:
@@ -45,6 +49,7 @@ class TestLazyMod(unittest.TestCase): # lint-amnesty, pylint: disable=missing-c
assert hsv[0] == 0.25
def test_dotted(self):
"""Test lazy import of a dotted submodule and verify functionality."""
# wsgiref is a module with submodules that is not already imported.
# Any similar module would do. This test demonstrates that the module
# is not already imported

View File

@@ -20,6 +20,7 @@ class TestRemoteExec(TestCase):
)
@patch("requests.post")
def test_json_encode(self, mock_post):
"""Verify that get_remote_exec correctly JSON-encodes payload with globals."""
get_remote_exec(
{
"code": "out = 1 + 1",

View File

@@ -21,31 +21,40 @@ from six.moves import range
from openedx.core.djangolib.testing.utils import skip_unless_lms
from xmodule.capa.safe_exec import safe_exec, update_hash
from xmodule.capa.safe_exec.remote_exec import is_codejail_in_darklaunch, is_codejail_rest_service_enabled
from xmodule.capa.safe_exec.remote_exec import (
is_codejail_in_darklaunch,
is_codejail_rest_service_enabled,
)
from xmodule.capa.safe_exec.safe_exec import emsg_normalizers, normalize_error_message
from xmodule.capa.tests.test_util import use_unsafe_codejail
from xmodule.capa.tests.test_util import UseUnsafeCodejail
@use_unsafe_codejail()
class TestSafeExec(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring
@UseUnsafeCodejail()
class TestSafeExec(unittest.TestCase):
"""Unit tests for verifying functionality and restrictions of safe_exec."""
def test_set_values(self):
"""Verify assignment of values in safe_exec."""
g = {}
safe_exec("a = 17", g)
assert g["a"] == 17
def test_division(self):
"""Verify division operation in safe_exec."""
g = {}
# Future division: 1/2 is 0.5.
safe_exec("a = 1/2", g)
assert g["a"] == 0.5
def test_assumed_imports(self):
"""Check assumed standard imports in safe_exec."""
g = {}
# Math is always available.
safe_exec("a = int(math.pi)", g)
assert g["a"] == 3
def test_random_seeding(self):
"""Test predictable random results with seeding in safe_exec."""
g = {}
r = random.Random(17)
rnums = [r.randint(0, 999) for _ in range(100)]
@@ -59,6 +68,7 @@ class TestSafeExec(unittest.TestCase): # lint-amnesty, pylint: disable=missing-
assert g["rnums"] == rnums
def test_random_is_still_importable(self):
"""Ensure random module works with seeding in safe_exec."""
g = {}
r = random.Random(17)
rnums = [r.randint(0, 999) for _ in range(100)]
@@ -68,18 +78,21 @@ class TestSafeExec(unittest.TestCase): # lint-amnesty, pylint: disable=missing-
assert g["rnums"] == rnums
def test_python_lib(self):
"""Test importing Python library from custom path in safe_exec."""
pylib = os.path.dirname(__file__) + "/test_files/pylib"
g = {}
safe_exec("import constant; a = constant.THE_CONST", g, python_path=[pylib])
def test_raising_exceptions(self):
"""Ensure exceptions are raised correctly in safe_exec."""
g = {}
with pytest.raises(SafeExecException) as cm:
safe_exec("1/0", g)
assert "ZeroDivisionError" in str(cm.value)
class TestSafeOrNot(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring
class TestSafeOrNot(unittest.TestCase):
"""Tests to verify safe vs unsafe execution behavior of code jail."""
@skip_unless_lms
def test_cant_do_something_forbidden(self):
@@ -131,7 +144,7 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
@patch("xmodule.capa.safe_exec.safe_exec.get_remote_exec")
@patch("xmodule.capa.safe_exec.safe_exec.codejail_safe_exec")
def test_default_code_execution(self, mock_local_exec, mock_remote_exec):
"""Check that default code execution uses local exec only."""
# Test default only runs local exec.
g = {}
safe_exec("a=1", g)
@@ -142,6 +155,7 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
@patch("xmodule.capa.safe_exec.safe_exec.get_remote_exec")
@patch("xmodule.capa.safe_exec.safe_exec.codejail_safe_exec")
def test_code_execution_only_codejail_service(self, mock_local_exec, mock_remote_exec):
"""Check execution via codejail service only."""
# Set return values to empty values to indicate no error.
mock_remote_exec.return_value = (None, None)
# Test with only the service enabled.
@@ -164,7 +178,7 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
assert mock_remote_exec.called
@override_settings(ENABLE_CODEJAIL_DARKLAUNCH=True)
def run_dark_launch(
def run_dark_launch( # pylint: disable=too-many-positional-arguments,too-many-arguments
self,
globals_dict,
local,
@@ -202,7 +216,7 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
limit_overrides_context="course-v1:org+course+run",
slug="hw1",
)
except BaseException as e:
except BaseException as e: # pylint: disable=broad-exception-caught
safe_exec_e = e
else:
safe_exec_e = None
@@ -234,7 +248,7 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
local_globals = None
remote_globals = None
def local_exec(code, globals_dict, **kwargs):
def local_exec(code, globals_dict, **kwargs): # pylint: disable=unused-argument
# Preserve what local exec saw
nonlocal local_globals
local_globals = copy.deepcopy(globals_dict)
@@ -264,11 +278,18 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
],
expect_log_info_calls=[
call(
"Codejail darklaunch had mismatch for "
"course='course-v1:org+course+run', slug='hw1':\n"
"emsg_match=True, globals_match=False\n"
"Local: globals={'overwrite': 'mock local'}, emsg=None\n"
"Remote: globals={'overwrite': 'mock remote'}, emsg=None"
"Codejail darklaunch had mismatch for course=%r, slug=%r:\n"
"emsg_match=%r, globals_match=%r\n"
"Local: globals=%r, emsg=%r\n"
"Remote: globals=%r, emsg=%r",
"course-v1:org+course+run",
"hw1",
True,
False,
{"overwrite": "mock local"},
None,
{"overwrite": "mock remote"},
None,
),
],
# Should only see behavior of local exec
@@ -283,12 +304,13 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
def test_remote_runs_even_if_local_raises(self):
"""Test that remote exec runs even if local raises."""
expected_error = BaseException("unexpected")
def local_exec(code, globals_dict, **kwargs):
# Raise something other than a SafeExecException.
raise BaseException("unexpected")
raise expected_error # pylint: disable=broad-exception-raised
def remote_exec(data):
def remote_exec(data): # pylint: disable=unused-argument
return (None, None)
results = self.run_dark_launch(
@@ -306,10 +328,12 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
],
expect_log_info_calls=[
call(
"Codejail darklaunch had unexpected exception "
"for course='course-v1:org+course+run', slug='hw1':\n"
"Local exception: BaseException('unexpected')\n"
"Remote exception: None"
"Codejail darklaunch had unexpected exception for course=%r, slug=%r:\n"
"Local exception: %r\nRemote exception: %r",
"course-v1:org+course+run",
"hw1",
expected_error,
None,
),
],
expect_globals_contains={},
@@ -325,7 +349,7 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
def local_exec(code, globals_dict, **kwargs):
raise SafeExecException("oops")
def remote_exec(data):
def remote_exec(data): # pylint: disable=unused-argument
return ("OH NO", SafeExecException("OH NO"))
results = self.run_dark_launch(
@@ -343,11 +367,18 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
],
expect_log_info_calls=[
call(
"Codejail darklaunch had mismatch for "
"course='course-v1:org+course+run', slug='hw1':\n"
"emsg_match=False, globals_match=True\n"
"Local: globals={}, emsg='oops'\n"
"Remote: globals={}, emsg='OH NO'"
"Codejail darklaunch had mismatch for course=%r, slug=%r:\n"
"emsg_match=%r, globals_match=%r\n"
"Local: globals=%r, emsg=%r\n"
"Remote: globals=%r, emsg=%r",
"course-v1:org+course+run",
"hw1",
False,
True,
{},
"oops",
{},
"OH NO",
),
],
expect_globals_contains={},
@@ -361,7 +392,7 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
def local_exec(code, globals_dict, **kwargs):
raise SafeExecException("stack trace involving /tmp/codejail-1234567/whatever.py")
def remote_exec(data):
def remote_exec(data): # pylint: disable=unused-argument
emsg = "stack trace involving /tmp/codejail-abcd_EFG/whatever.py"
return (emsg, SafeExecException(emsg))
@@ -449,7 +480,7 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
@patch("xmodule.capa.safe_exec.safe_exec.log.error")
def test_normalizers_validate(self, mock_log_error, mock_record_exception):
"""Normalizers are validated, and fall back to default list on error."""
assert len(emsg_normalizers()) > 0 # pylint: disable=use-implicit-booleaness-not-comparison
assert len(emsg_normalizers()) > 0
mock_log_error.assert_called_once_with("Could not load custom codejail darklaunch emsg normalizers")
mock_record_exception.assert_called_once()
@@ -529,28 +560,31 @@ class TestLimitConfiguration(unittest.TestCase):
assert jail_code.get_effective_limits("course-v1:my+special+course")["NPROC"] == 30
class DictCache(object):
class DictCache:
"""A cache implementation over a simple dict, for testing."""
def __init__(self, d):
self.cache = d
def get(self, key):
"""Get value from cache by key with length check."""
# Actual cache implementations have limits on key length
assert len(key) <= 250
return self.cache.get(key)
def set(self, key, value):
"""Set value in cache by key with length check."""
# Actual cache implementations have limits on key length
assert len(key) <= 250
self.cache[key] = value
@use_unsafe_codejail()
@UseUnsafeCodejail()
class TestSafeExecCaching(unittest.TestCase):
"""Test that caching works on safe_exec."""
def test_cache_miss_then_hit(self):
"""Test caching works on miss and hit in safe_exec."""
g = {}
cache = {}
@@ -568,6 +602,7 @@ class TestSafeExecCaching(unittest.TestCase):
assert g["a"] == 17
def test_cache_large_code_chunk(self):
"""Test caching handles large code chunks."""
# Caching used to die on memcache with more than 250 bytes of code.
# Check that it doesn't any more.
code = "a = 0\n" + ("a += 1\n" * 12345)
@@ -578,6 +613,7 @@ class TestSafeExecCaching(unittest.TestCase):
assert g["a"] == 12345
def test_cache_exceptions(self):
"""Test caching of exceptions in safe_exec."""
# Used to be that running code that raised an exception didn't cache
# the result. Check that now it does.
code = "1/0"
@@ -588,7 +624,7 @@ class TestSafeExecCaching(unittest.TestCase):
# The exception should be in the cache now.
assert len(cache) == 1
cache_exc_msg, cache_globals = list(cache.values())[0] # lint-amnesty, pylint: disable=unused-variable
cache_exc_msg, cache_globals = list(cache.values())[0] # pylint: disable=unused-variable
assert "ZeroDivisionError" in cache_exc_msg
# Change the value stored in the cache, the result should change.
@@ -607,6 +643,7 @@ class TestSafeExecCaching(unittest.TestCase):
assert g["a"] == 17
def test_unicode_submission(self):
"""Test safe_exec handles non-ASCII unicode."""
# Check that using non-ASCII unicode does not raise an encoding error.
# Try several non-ASCII unicode characters.
for code in [129, 500, 2**8 - 1, 2**16 - 1]:
@@ -614,7 +651,7 @@ class TestSafeExecCaching(unittest.TestCase):
try:
safe_exec(code_with_unichr, {}, cache=DictCache({}))
except UnicodeEncodeError:
self.fail("Tried executing code with non-ASCII unicode: {0}".format(code))
self.fail(f"Tried executing code with non-ASCII unicode: {code}")
class TestUpdateHash(unittest.TestCase):
@@ -644,6 +681,7 @@ class TestUpdateHash(unittest.TestCase):
return d1, d2
def test_simple_cases(self):
"""Test hashing of simple objects."""
h1 = self.hash_obj(1)
h10 = self.hash_obj(10)
hs1 = self.hash_obj("1")
@@ -652,17 +690,20 @@ class TestUpdateHash(unittest.TestCase):
assert h1 != hs1
def test_list_ordering(self):
"""Test that list ordering affects hash."""
h1 = self.hash_obj({"a": [1, 2, 3]})
h2 = self.hash_obj({"a": [3, 2, 1]})
assert h1 != h2
def test_dict_ordering(self):
"""Test that dict ordering does not affect hash."""
d1, d2 = self.equal_but_different_dicts()
h1 = self.hash_obj(d1)
h2 = self.hash_obj(d2)
assert h1 == h2
def test_deep_ordering(self):
"""Test that nested structures are hashed consistently."""
d1, d2 = self.equal_but_different_dicts()
o1 = {"a": [1, 2, [d1], 3, 4]}
o2 = {"a": [1, 2, [d2], 3, 4]}
@@ -671,9 +712,12 @@ class TestUpdateHash(unittest.TestCase):
assert h1 == h2
@use_unsafe_codejail()
class TestRealProblems(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring
@UseUnsafeCodejail()
class TestRealProblems(unittest.TestCase):
"""Unit tests for executing real problem code snippets safely."""
def test_802x(self):
"Test execution of real problem code snippet safely."
code = textwrap.dedent(
"""\
import math

View File

@@ -1,6 +1,7 @@
"""
Score rendering when submission is evaluated for external grader and has been saved successfully
"""
import logging
from functools import partial
@@ -20,10 +21,10 @@ log = logging.getLogger(__name__)
def load_xblock_for_external_grader(
user_id: str,
course_key: CourseKey,
usage_key: UsageKey,
course=None,
user_id: str,
course_key: CourseKey,
usage_key: UsageKey,
course=None,
):
"""
Load a single XBlock for external grading without user access checks.
@@ -35,22 +36,16 @@ def load_xblock_for_external_grader(
try:
block = modulestore().get_item(usage_key)
except Exception as e:
log.exception(f"Could not find block {usage_key} in modulestore: {e}")
log.exception("Could not find block %s in modulestore: %s", usage_key, e)
raise Http404(f"Module {usage_key} was not found") from e
field_data_cache = FieldDataCache.cache_for_block_descendents(
course_key, user, block, depth=0
)
field_data_cache = FieldDataCache.cache_for_block_descendents(course_key, user, block, depth=0)
student_kvs = DjangoKeyValueStore(field_data_cache)
student_data = KvsFieldData(student_kvs)
instance = get_block_for_descriptor_without_access_check(
user=user,
block=block,
student_data=student_data,
course_key=course_key,
course=course
user=user, block=block, student_data=student_data, course_key=course_key, course=course
)
if instance is None:

View File

@@ -4,8 +4,8 @@ import gettext
import io
import os
import os.path
import xml.sax.saxutils as saxutils
from unittest.mock import MagicMock, Mock
from xml.sax import saxutils
import fs.osfs
from django.template.loader import get_template as django_get_template
@@ -35,15 +35,16 @@ def tst_render_template(template, context): # pylint: disable=unused-argument
A test version of render to template. Renders to the repr of the context, completely ignoring
the template name. To make the output valid xml, quotes the content, and wraps it in a <div>
"""
return "<div>{0}</div>".format(saxutils.escape(repr(context)))
return f"<div>{saxutils.escape(repr(context))}</div>"
class StubXQueueService:
class StubXQueueService: # pylint: disable=too-few-public-methods
"""
Stubs out the XQueueService for Capa problem tests.
"""
def __init__(self):
"""Initialize the stubbed XQueueService instance."""
self.interface = MagicMock()
self.interface.send_to_queue.return_value = (0, "Success!")
self.default_queuename = "testqueue"
@@ -82,7 +83,7 @@ def mock_capa_block():
capa response types needs just two things from the capa_block: location and publish.
"""
def mock_location_text(self): # lint-amnesty, pylint: disable=unused-argument
def mock_location_text(self): # pylint: disable=unused-argument
"""
Mock implementation of __unicode__ or __str__ for the block's location.
"""

View File

@@ -1,4 +1,4 @@
# lint-amnesty, pylint: disable=missing-module-docstring
"""Factories to build CAPA response XML."""
from abc import ABCMeta, abstractmethod
@@ -28,7 +28,7 @@ class ResponseXMLFactory(six.with_metaclass(ABCMeta, object)):
representing the capa input XML (such as <textline />)"""
return None
def build_xml(self, **kwargs):
def build_xml(self, **kwargs): # pylint: disable=too-many-locals
"""Construct an XML string for a capa response
based on **kwargs.
@@ -364,13 +364,12 @@ class CodeResponseXMLFactory(ResponseXMLFactory):
"""Factory for creating <coderesponse> XML trees"""
def build_xml(self, **kwargs):
"""Build a <coderesponse> XML tree."""
# Since we are providing an <answer> tag,
# we should override the default behavior
# of including a <solution> tag as well
kwargs["explanation_text"] = None
return super(CodeResponseXMLFactory, self).build_xml( # lint-amnesty, pylint: disable=super-with-arguments
**kwargs
)
return super().build_xml(**kwargs)
def create_response_element(self, **kwargs):
"""
@@ -452,7 +451,7 @@ class ChoiceResponseXMLFactory(ResponseXMLFactory):
class FormulaResponseXMLFactory(ResponseXMLFactory):
"""Factory for creating <formularesponse> XML trees"""
def create_response_element(self, **kwargs):
def create_response_element(self, **kwargs): # pylint: disable=too-many-locals
"""Create a <formularesponse> element.
*sample_dict*: A dictionary of the form:
@@ -534,9 +533,9 @@ class FormulaResponseXMLFactory(ResponseXMLFactory):
def create_input_element(self, **kwargs):
return ResponseXMLFactory.textline_input_xml(**kwargs)
def _sample_str(
self, sample_dict, num_samples, tolerance
): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument
def _sample_str(self, sample_dict, num_samples, tolerance): # pylint: disable=unused-argument
"""Generate a sample string for Loncapa using variable ranges and repetition count."""
# Loncapa uses a special format for sample strings:
# "x,y,z@4,5,3:10,12,8#4" means plug in values for (x,y,z)
# from within the box defined by points (4,5,3) and (10,12,8)
@@ -681,8 +680,8 @@ class OptionResponseXMLFactory(ResponseXMLFactory):
# Set the "options" attribute
# Format: "('first', 'second', 'third')"
options_attr_string = ",".join(["'{}'".format(o) for o in options_list])
options_attr_string = "({})".format(options_attr_string)
options_attr_string = ",".join([f"'{o}'" for o in options_list])
options_attr_string = f"({options_attr_string})"
optioninput_element.set("options", options_attr_string)
# Set the "correct" attribute
@@ -694,7 +693,7 @@ class OptionResponseXMLFactory(ResponseXMLFactory):
class StringResponseXMLFactory(ResponseXMLFactory):
"""Factory for producing <stringresponse> XML"""
def create_response_element(self, **kwargs):
def create_response_element(self, **kwargs): # pylint: disable=too-many-locals
"""Create a <stringresponse> XML element.
Uses **kwargs:
@@ -897,7 +896,7 @@ class ChoiceTextResponseXMLFactory(ResponseXMLFactory):
for ind, choice in enumerate(choice_inputs):
# Give each choice text equal to it's position(0,1,2...)
choice.text = "choice_{0}".format(ind)
choice.text = f"choice_{ind}"
input_element.append(choice)
return input_element

View File

@@ -14,7 +14,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
"""Capa Answer Pool Test"""
def setUp(self):
super(CapaAnswerPoolTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
self.system = mock_capa_system()
# XML problem setup used by a few tests.
@@ -55,6 +55,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
)
def test_answer_pool_4_choices_1_multiplechoiceresponse_seed1(self):
"""Test answer-pool=4 with one multiplechoiceresponse and a fixed seed."""
problem = new_loncapa_problem(self.common_question_xml, seed=723)
the_html = problem.get_html()
# [('choice_3', 'wrong-3'), ('choice_5', 'correct-2'), ('choice_1', 'wrong-2'), ('choice_4', 'wrong-4')]
@@ -68,6 +69,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
assert response.unmask_order() == ["choice_3", "choice_5", "choice_1", "choice_4"]
def test_answer_pool_4_choices_1_multiplechoiceresponse_seed2(self):
"""Test answer-pool=4 with one multiplechoiceresponse and a different seed."""
problem = new_loncapa_problem(self.common_question_xml, seed=9)
the_html = problem.get_html()
# [('choice_0', 'wrong-1'), ('choice_4', 'wrong-4'), ('choice_3', 'wrong-3'), ('choice_2', 'correct-1')]
@@ -80,6 +82,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
assert response.unmask_order() == ["choice_0", "choice_4", "choice_3", "choice_2"]
def test_no_answer_pool_4_choices_1_multiplechoiceresponse(self):
"""Test behavior when no answer-pool attribute is provided."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -121,7 +124,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
the_html = problem.get_html()
self.assertRegex(
the_html, r"<div>.*\[.*'wrong-1'.*'wrong-2'.*'correct-1'.*'wrong-3'.*'wrong-4'.*'correct-2'.*\].*</div>"
) # lint-amnesty, pylint: disable=line-too-long
)
self.assertRegex(the_html, r"<div>\{.*'1_solution_1'.*'1_solution_2'.*\}</div>")
assert the_html == problem.get_html(), "should be able to call get_html() twice"
# Check about masking
@@ -130,6 +133,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
assert not response.has_answerpool()
def test_0_answer_pool_4_choices_1_multiplechoiceresponse(self):
"""Test behavior when answer-pool is explicitly set to 0."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -171,13 +175,14 @@ class CapaAnswerPoolTest(unittest.TestCase):
the_html = problem.get_html()
self.assertRegex(
the_html, r"<div>.*\[.*'wrong-1'.*'wrong-2'.*'correct-1'.*'wrong-3'.*'wrong-4'.*'correct-2'.*\].*</div>"
) # lint-amnesty, pylint: disable=line-too-long
)
self.assertRegex(the_html, r"<div>\{.*'1_solution_1'.*'1_solution_2'.*\}</div>")
response = list(problem.responders.values())[0]
assert not response.has_mask()
assert not response.has_answerpool()
def test_invalid_answer_pool_value(self):
"""Ensure error is raised for non-integer answer-pool values."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -219,6 +224,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
new_loncapa_problem(xml_str)
def test_invalid_answer_pool_none_correct(self):
"""Ensure error is raised if no correct choice exists in answer-pool."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -239,6 +245,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
new_loncapa_problem(xml_str)
def test_invalid_answer_pool_all_correct(self):
"""Ensure error is raised if all choices are marked correct in answer-pool."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -258,6 +265,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
new_loncapa_problem(xml_str)
def test_answer_pool_5_choices_1_multiplechoiceresponse_seed1(self):
"""Test answer-pool=5 with one multiplechoiceresponse and fixed seed."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -304,6 +312,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
assert response.unmask_order() == ["choice_5", "choice_0", "choice_1", "choice_3", "choice_4"]
def test_answer_pool_2_multiplechoiceresponses_seed1(self):
"""Test two multiplechoiceresponses with different answer-pools and fixed seed."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -390,6 +399,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
self.assertRegex(without_new_lines, str3 + r".*" + str4)
def test_answer_pool_2_multiplechoiceresponses_seed2(self):
"""Test two multiplechoiceresponses with different answer-pools and second seed."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -544,6 +554,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
self.assertRegex(without_new_lines, str1)
def test_no_answer_pool(self):
"""Test multiplechoiceresponse behavior when answer-pool attribute is missing."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -575,6 +586,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
assert not response.has_answerpool()
def test_answer_pool_and_no_answer_pool(self):
"""Test combination of responses with and without answer-pools."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -650,6 +662,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
self.assertRegex(without_new_lines, str3 + r".*" + str4)
def test_answer_pool_without_solutionset(self):
"""Test answer-pool behavior when no solutionset is provided."""
xml_str = textwrap.dedent(
"""
<problem>

View File

@@ -17,14 +17,14 @@ from openedx.core.djangolib.markup import HTML
from xmodule.capa.correctmap import CorrectMap
from xmodule.capa.responsetypes import LoncapaProblemError
from xmodule.capa.tests.helpers import new_loncapa_problem
from xmodule.capa.tests.test_util import use_unsafe_codejail
from xmodule.capa.tests.test_util import UseUnsafeCodejail
FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS = settings.FEATURES.copy()
FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS["ENABLE_GRADING_METHOD_IN_PROBLEMS"] = True
@ddt.ddt
@use_unsafe_codejail()
@UseUnsafeCodejail()
class CAPAProblemTest(unittest.TestCase):
"""CAPA problem related tests"""
@@ -42,20 +42,18 @@ class CAPAProblemTest(unittest.TestCase):
This is the case when we have a problem with single question or
problem with multiple-questions separated as per the new format.
"""
xml = """
xml = f"""
<problem>
<choiceresponse>
<label>{question}</label>
<description>Only the paranoid survive.</description>
<checkboxgroup>
<choice correct="true">over-suspicious</choice>
<choice correct="false">funny</choice>
<choice correct="false">sad</choice>
</checkboxgroup>
</choiceresponse>
</problem>
""".format(
question=question
)
"""
problem = new_loncapa_problem(xml)
assert problem.problem_data == {
"1_2_1": {"label": question, "descriptions": {"description_1_1_1": "Only the paranoid survive."}}
@@ -77,20 +75,18 @@ class CAPAProblemTest(unittest.TestCase):
"""
Verify that legacy problem is handled correctly.
"""
xml = """
xml = f"""
<problem>
<p>Be sure to check your spelling.</p>
<p>{}</p>
<p>{question}</p>
<stringresponse answer="vulnerable" type="ci">
<textline label="{}" size="40"/>
<textline label="{label_attr}" size="40"/>
</stringresponse>
</problem>
""".format(
question, label_attr
)
"""
problem = new_loncapa_problem(xml)
assert problem.problem_data == {"1_2_1": {"label": question, "descriptions": {}}}
assert len(problem.tree.xpath("//*[normalize-space(text())='{}']".format(question))) == 0
assert len(problem.tree.xpath(f"//*[normalize-space(text())='{question}']")) == 0
@ddt.unpack
@ddt.data(
@@ -112,31 +108,29 @@ class CAPAProblemTest(unittest.TestCase):
tag and label attribute inside responsetype. But we have a label tag
before the responsetype.
"""
xml = """
xml = f"""
<problem>
<p>Be sure to check your spelling.</p>
<label>{}</label>
<label>{question1}</label>
<stringresponse answer="hide" type="ci">
<textline size="40"/>
</stringresponse>
<choiceresponse>
<label>{}</label>
<label>{question2}</label>
<checkboxgroup>
<choice correct="true">over-suspicious</choice>
<choice correct="false">funny</choice>
<choice correct="false">shy</choice>
</checkboxgroup>
</choiceresponse>
</problem>
""".format(
question1, question2
)
"""
problem = new_loncapa_problem(xml)
assert problem.problem_data == {
"1_2_1": {"label": question1, "descriptions": {}},
"1_3_1": {"label": question2, "descriptions": {}},
}
for question in (question1, question2):
assert len(problem.tree.xpath('//label[text()="{}"]'.format(question))) == 0
assert len(problem.tree.xpath(f'//label[text()="{question}"]')) == 0
def test_multiple_descriptions(self):
"""
@@ -144,19 +138,17 @@ class CAPAProblemTest(unittest.TestCase):
"""
desc1 = "The problem with trying to be the <em>bad guy</em>, there's always someone <strong>worse</strong>."
desc2 = "Anyone who looks the world as if it was a game of chess deserves to lose."
xml = """
xml = f"""
<problem>
<p>Be sure to check your spelling.</p>
<stringresponse answer="War" type="ci">
<label>___ requires sacrifices.</label>
<description>{}</description>
<description>{}</description>
<description>{desc1}</description>
<description>{desc2}</description>
<textline size="40"/>
</stringresponse>
</problem>
""".format(
desc1, desc2
)
"""
problem = new_loncapa_problem(xml)
assert problem.problem_data == {
"1_2_1": {
@@ -187,22 +179,22 @@ class CAPAProblemTest(unittest.TestCase):
Verify that tag with question text is not removed when inputtype is not fully accessible.
"""
question = "Click the country which is home to the Pyramids."
# lint-amnesty, pylint: disable=duplicate-string-formatting-argument
xml = """
<problem>
<p>{}</p>
<p>{question}</p>
<imageresponse>
<imageinput label="{}"
<imageinput label="{question}"
src="/static/Africa.png" width="600" height="638" rectangle="(338,98)-(412,168)"/>
</imageresponse>
</problem>
""".format(
question, question
question=question
)
problem = new_loncapa_problem(xml)
assert problem.problem_data == {"1_2_1": {"label": question, "descriptions": {}}}
# <p> tag with question text should not be deleted
assert problem.tree.xpath("string(p[text()='{}'])".format(question)) == question
assert problem.tree.xpath(f"string(p[text()='{question}'])") == question
def test_label_is_empty_if_no_label_attribute(self):
"""
@@ -210,17 +202,15 @@ class CAPAProblemTest(unittest.TestCase):
attribute is missing and responsetype is not fully accessible.
"""
question = "Click the country which is home to the Pyramids."
xml = """
xml = f"""
<problem>
<p>{}</p>
<p>{question}</p>
<imageresponse>
<imageinput
src="/static/Africa.png" width="600" height="638" rectangle="(338,98)-(412,168)"/>
</imageresponse>
</problem>
""".format(
question
)
"""
problem = new_loncapa_problem(xml)
assert problem.problem_data == {"1_2_1": {"label": "", "descriptions": {}}}
@@ -238,7 +228,7 @@ class CAPAProblemTest(unittest.TestCase):
<description>Only the paranoid survive.</description>
<checkboxgroup>
<choice correct="true">over-suspicious</choice>
<choice correct="false">funny</choice>
<choice correct="false">happy</choice>
</checkboxgroup>
</choiceresponse>
<multiplechoiceresponse>
@@ -276,21 +266,19 @@ class CAPAProblemTest(unittest.TestCase):
responsetype to contain other elements. We do not want to delete information in that case.
"""
question = "Is egg plant a fruit?"
xml = """
xml = f"""
<problem>
<p>Choose wisely.</p>
<p>Select the correct synonym of paranoid?</p>
<p><img src="" /></p>
<choiceresponse>
<checkboxgroup label="{}">
<checkboxgroup label="{question}">
<choice correct="true">over-suspicious</choice>
<choice correct="false">funny</choice>
</checkboxgroup>
</choiceresponse>
</problem>
""".format(
question
)
"""
problem = new_loncapa_problem(xml)
assert problem.problem_data == {"1_2_1": {"label": "", "descriptions": {}}}
assert len(problem.tree.xpath("//p/img")) == 1
@@ -306,17 +294,15 @@ class CAPAProblemTest(unittest.TestCase):
"""
input1_label = "What color is the sky?"
input2_label = "What color are pine needles?"
xml = """
xml = f"""
<problem>
<optionresponse>
<label>{}</label>
<optioninput options="('yellow','blue','green')" correct="blue" label="{}"/>
<optioninput options="('yellow','blue','green')" correct="green" label="{}"/>
<label>{group_label}</label>
<optioninput options="('yellow','blue','green')" correct="blue" label="{input1_label}"/>
<optioninput options="('orange','blue','green')" correct="green" label="{input2_label}"/>
</optionresponse>
</problem>
""".format(
group_label, input1_label, input2_label
)
"""
problem = new_loncapa_problem(xml)
assert problem.problem_data == {
@@ -330,20 +316,18 @@ class CAPAProblemTest(unittest.TestCase):
"""
question = "Enter sum of 1+2"
xml = textwrap.dedent(
"""
f"""
<problem>
<customresponse cfn="test_sum" expect="3">
<script type="loncapa/python">
def test_sum(expect, ans):
return int(expect) == int(ans)
</script>
<label>{}</label>
<label>{question}</label>
<textline size="20" correct_answer="3" />
</customresponse>
</problem>
""".format(
question
)
"""
)
problem = new_loncapa_problem(xml, use_capa_render_template=True)
problem_html = etree.XML(problem.get_html())
@@ -353,18 +337,18 @@ class CAPAProblemTest(unittest.TestCase):
assert len(multi_inputs_group) == 0
# verify that question is rendered only once
question = problem_html.xpath("//*[normalize-space(text())='{}']".format(question))
question = problem_html.xpath(f"//*[normalize-space(text())='{question}']")
assert len(question) == 1
def assert_question_tag(self, question1, question2, tag, label_attr=False):
"""
Verify question tag correctness.
"""
question1_tag = "<{tag}>{}</{tag}>".format(question1, tag=tag) if question1 else ""
question2_tag = "<{tag}>{}</{tag}>".format(question2, tag=tag) if question2 else ""
question1_label_attr = 'label="{}"'.format(question1) if label_attr else ""
question2_label_attr = 'label="{}"'.format(question2) if label_attr else ""
xml = """
question1_tag = f"<{tag}>{question1}</{tag}>" if question1 else ""
question2_tag = f"<{tag}>{question2}</{tag}>" if question2 else ""
question1_label_attr = f'label="{question1}"' if label_attr else ""
question2_label_attr = f'label="{question2}"' if label_attr else ""
xml = f"""
<problem>
{question1_tag}
<choiceresponse>
@@ -381,18 +365,13 @@ class CAPAProblemTest(unittest.TestCase):
</choicegroup>
</multiplechoiceresponse>
</problem>
""".format(
question1_tag=question1_tag,
question2_tag=question2_tag,
question1_label_attr=question1_label_attr,
question2_label_attr=question2_label_attr,
)
"""
problem = new_loncapa_problem(xml)
assert problem.problem_data == {
"1_2_1": {"label": question1, "descriptions": {}},
"1_3_1": {"label": question2, "descriptions": {}},
}
assert len(problem.tree.xpath("//{}".format(tag))) == 0
assert len(problem.tree.xpath(f"//{tag}")) == 0
@ddt.unpack
@ddt.data(
@@ -422,7 +401,9 @@ class CAPAProblemTest(unittest.TestCase):
xml = """
<problem>
<optionresponse>
<p>You can use this template as a guide to the simple editor markdown and OLX markup to use for dropdown problems. Edit this component to replace this template with your own assessment.</p>
<p>You can use this template as a guide to the simple editor markdown and
OLX markup to use for dropdown problems. Edit this component to replace
this template with your own assessment.</p>
<label>Add the question text, or prompt, here. This text is required.</label>
<description>You can add an optional tip or note related to the prompt like this. </description>
<optioninput>
@@ -460,7 +441,7 @@ class CAPAProblemTest(unittest.TestCase):
@ddt.ddt
@use_unsafe_codejail()
@UseUnsafeCodejail()
class CAPAMultiInputProblemTest(unittest.TestCase):
"""TestCase for CAPA problems with multiple inputtypes"""
@@ -497,14 +478,14 @@ class CAPAMultiInputProblemTest(unittest.TestCase):
# verify that multi input group label <p> tag exists and its
# id matches with correct multi input group aria-labelledby
multi_inputs_group_label_id = multi_inputs_group[0].attrib.get("aria-labelledby")
multi_inputs_group_label = html.xpath('//p[@id="{}"]'.format(multi_inputs_group_label_id))
multi_inputs_group_label = html.xpath(f'//p[@id="{multi_inputs_group_label_id}"]')
assert len(multi_inputs_group_label) == 1
assert multi_inputs_group_label[0].text == group_label
# verify that label for each input comes only once
for input_label in input_labels:
# normalize-space is used to remove whitespace around the text
input_label_element = multi_inputs_group[0].xpath('//*[normalize-space(text())="{}"]'.format(input_label))
input_label_element = multi_inputs_group[0].xpath(f'//*[normalize-space(text())="{input_label}"]')
assert len(input_label_element) == 1
@ddt.unpack
@@ -518,7 +499,7 @@ class CAPAMultiInputProblemTest(unittest.TestCase):
"""
input1_label = "What color is the sky?"
input2_label = "What color are pine needles?"
xml = """
xml = f"""
<problem>
<optionresponse>
{label_html}
@@ -526,9 +507,7 @@ class CAPAMultiInputProblemTest(unittest.TestCase):
<optioninput options="('yellow','blue','green')" correct="green" label="{input2_label}"/>
</optionresponse>
</problem>
""".format(
label_html=label_html, input1_label=input1_label, input2_label=input2_label
)
"""
problem = self.capa_problem(xml)
self.assert_problem_html(problem.get_html(), group_label, input1_label, input2_label)
self.assert_problem_data(problem.problem_data)
@@ -544,21 +523,19 @@ class CAPAMultiInputProblemTest(unittest.TestCase):
input1_label = "Integer 1"
input2_label = "Integer 2"
xml = textwrap.dedent(
"""
f"""
<problem>
<customresponse cfn="test_add_to_ten">
<script type="loncapa/python">
def test_add_to_ten(expect, ans):
return test_add(10, ans)
</script>
<label>{}</label>
<{inputtype} size="40" correct_answer="3" label="{}" /><br/>
<{inputtype} size="40" correct_answer="7" label="{}" />
<label>{group_label}</label>
<{inputtype} size="40" correct_answer="3" label="{input1_label}" /><br/>
<{inputtype} size="40" correct_answer="7" label="{input2_label}" />
</customresponse>
</problem>
""".format(
group_label, input1_label, input2_label, inputtype=inputtype
)
"""
)
problem = self.capa_problem(xml)
self.assert_problem_html(problem.get_html(), group_label, input1_label, input2_label)
@@ -576,7 +553,7 @@ class CAPAMultiInputProblemTest(unittest.TestCase):
"""
Verify that groups descriptions are rendered correctly.
"""
xml = """
xml = f"""
<problem>
<optionresponse>
<label>group label</label>
@@ -585,9 +562,7 @@ class CAPAMultiInputProblemTest(unittest.TestCase):
<optioninput options="('yellow','blue','green')" correct="green" label="second label"/>
</optionresponse>
</problem>
""".format(
descriptions_html=descriptions_html
)
"""
problem = self.capa_problem(xml)
problem_html = etree.XML(problem.get_html())
@@ -599,7 +574,7 @@ class CAPAMultiInputProblemTest(unittest.TestCase):
# For each description, check its order and text is correct
for index, description_id in enumerate(description_ids):
description_element = multi_inputs_group.xpath('//p[@id="{}"]'.format(description_id))
description_element = multi_inputs_group.xpath(f'//p[@id="{description_id}"]')
assert len(description_element) == 1
assert description_element[0].text == descriptions[index]
@@ -618,13 +593,15 @@ class CAPAProblemReportHelpersTest(unittest.TestCase):
)
@ddt.unpack
def test_find_question_label(self, answer_id, label, stripped_label):
problem = new_loncapa_problem('<problem><some-problem id="{}"/></problem>'.format(answer_id))
"""Verify that find_question_label returns the correctly stripped question label."""
problem = new_loncapa_problem(f'<problem><some-problem id="{answer_id}"/></problem>')
mock_problem_data = {answer_id: {"label": HTML(label) if label else ""}}
with patch.object(problem, "problem_data", mock_problem_data):
assert problem.find_question_label(answer_id) == stripped_label
@ddt.data(None, {}, [None])
def test_find_answer_test_not_implemented(self, current_answer):
"""Ensure find_answer_text raises NotImplementedError for unsupported responses."""
problem = new_loncapa_problem("<problem/>")
self.assertRaises(NotImplementedError, problem.find_answer_text, "", current_answer)
@@ -639,6 +616,7 @@ class CAPAProblemReportHelpersTest(unittest.TestCase):
)
@ddt.unpack
def test_find_answer_text_choices(self, answer_id, choice_id, answer_text):
"""Verify find_answer_text returns correct answer text for choice, multiple-choice, and option responses."""
problem = new_loncapa_problem(
"""
<problem>
@@ -676,6 +654,7 @@ class CAPAProblemReportHelpersTest(unittest.TestCase):
)
@ddt.unpack
def test_find_answer_text_choices_with_missing_text(self, answer_id, choice_id, answer_text):
"""Ensure find_answer_text handles missing answer text correctly and returns 'Answer Text Missing'."""
problem = new_loncapa_problem(
"""
<problem>
@@ -739,6 +718,7 @@ class CAPAProblemReportHelpersTest(unittest.TestCase):
assert problem.find_correct_answer_text(answer_id) == answer_text
def test_find_answer_text_textinput(self):
"""Check that find_answer_text correctly returns the answer for stringresponse/textinput problems."""
problem = new_loncapa_problem(
"""
<problem>
@@ -751,6 +731,7 @@ class CAPAProblemReportHelpersTest(unittest.TestCase):
assert problem.find_answer_text("1_2_1", "hide") == "hide"
def test_get_question_answer(self):
"""Ensure get_question_answers returns answer text as strings suitable for JSON serialization."""
problem = new_loncapa_problem(
"""
<problem>

View File

@@ -16,10 +16,11 @@ class CorrectMapTest(unittest.TestCase):
"""
def setUp(self):
super(CorrectMapTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
self.cmap = CorrectMap()
def test_set_input_properties(self):
"""Verify setting input properties and correctness-related methods behave correctly."""
# Set the correctmap properties for three inputs
self.cmap.set(
answer_id="1_2_1",
@@ -96,6 +97,7 @@ class CorrectMapTest(unittest.TestCase):
assert not self.cmap.is_right_queuekey("2_2_1", None)
def test_get_npoints(self):
"""Ensure get_npoints returns correct values based on correctness and assigned points."""
# Set the correctmap properties for 4 inputs
# 1) correct, 5 points
# 2) correct, None points
@@ -132,6 +134,7 @@ class CorrectMapTest(unittest.TestCase):
assert self.cmap.get_npoints("7_2_1") == 1
def test_set_overall_message(self):
"""Verify setting and retrieving the overall message works correctly."""
# Default is an empty string string
assert self.cmap.get_overall_message() == ""
@@ -147,6 +150,7 @@ class CorrectMapTest(unittest.TestCase):
assert self.cmap.get_overall_message() == ""
def test_update_from_correctmap(self):
"""Test updating one CorrectMap from another preserves all properties."""
# Initialize a CorrectMap with some properties
self.cmap.set(
answer_id="1_2_1",
@@ -171,6 +175,7 @@ class CorrectMapTest(unittest.TestCase):
assert other_cmap.get_dict() == self.cmap.get_dict()
def test_update_from_invalid(self):
"""Ensure updating CorrectMap with invalid inputs raises exceptions."""
# Should get an exception if we try to update() a CorrectMap
# with a non-CorrectMap value
invalid_list = [None, "string", 5, datetime.datetime.today()]

View File

@@ -1,7 +1,7 @@
# lint-amnesty, pylint: disable=missing-module-docstring
"""Unit tests for custom rendering of capa problem elements, including solutions and math expressions."""
import unittest
import xml.sax.saxutils as saxutils
from xml.sax import saxutils
from lxml import etree
@@ -17,10 +17,11 @@ def extract_context(xml):
Given an xml element corresponding to the output of test_capa_system.render_template, get back the
original context
"""
return eval(xml.text) # lint-amnesty, pylint: disable=eval-used
return eval(xml.text) # pylint: disable=eval-used
def quote_attr(s):
"""Return a string safe for XML attributes without outer quotes."""
return saxutils.quoteattr(s)[1:-1] # don't want the outer quotes
@@ -30,10 +31,12 @@ class HelperTest(unittest.TestCase):
"""
def check(self, d):
"""Check that rendering and extracting context returns the original data."""
xml = etree.XML(mock_capa_system().render_template("blah", d))
assert d == extract_context(xml)
def test_extract_context(self):
"""Test that the context can be extracted correctly from rendered XML."""
self.check({})
self.check({1, 2})
self.check({"id", "an id"})
@@ -46,8 +49,9 @@ class SolutionRenderTest(unittest.TestCase):
"""
def test_rendering(self):
"""Ensure that <solution> elements are rendered correctly with proper IDs."""
solution = "To compute unicorns, count them."
xml_str = """<solution id="solution_12">{s}</solution>""".format(s=solution)
xml_str = f"""<solution id="solution_12">{solution}</solution>"""
element = etree.fromstring(xml_str)
renderer = lookup_tag("solution")(mock_capa_system(), element)
@@ -65,8 +69,9 @@ class MathRenderTest(unittest.TestCase):
Make sure math renders properly.
"""
def check_parse(self, latex_in, mathjax_out): # lint-amnesty, pylint: disable=missing-function-docstring
xml_str = """<math>{tex}</math>""".format(tex=latex_in)
def check_parse(self, latex_in, mathjax_out):
"""Check that LaTeX input is correctly converted to MathJax output."""
xml_str = f"""<math>{latex_in}</math>"""
element = etree.fromstring(xml_str)
renderer = lookup_tag("math")(mock_capa_system(), element)
@@ -74,6 +79,7 @@ class MathRenderTest(unittest.TestCase):
assert renderer.mathstr == mathjax_out
def test_parsing(self):
"""Verify that LaTeX input is correctly converted to MathJax output."""
self.check_parse("$abc$", "[mathjaxinline]abc[/mathjaxinline]")
self.check_parse("$abc", "$abc")
self.check_parse(r"$\displaystyle 2+2$", "[mathjax] 2+2[/mathjax]")

View File

@@ -15,30 +15,35 @@ from xmodule.capa.errors import (
def test_json_parsing_error():
"""Verify that JSONParsingError is raised with the correct message."""
with pytest.raises(JSONParsingError) as excinfo:
raise JSONParsingError("test_name", "test_error")
assert str(excinfo.value) == "Error parsing test_name: test_error"
def test_missing_key_error():
"""Ensure MissingKeyError is raised with the correct key in the message."""
with pytest.raises(MissingKeyError) as excinfo:
raise MissingKeyError("test_key")
assert str(excinfo.value) == "Missing key: test_key"
def test_validation_error():
"""Check that ValidationError is raised with the expected message."""
with pytest.raises(ValidationError) as excinfo:
raise ValidationError("test_error")
assert str(excinfo.value) == "Validation error: test_error"
def test_type_error_submission():
"""Confirm TypeErrorSubmission is raised with the appropriate message."""
with pytest.raises(TypeErrorSubmission) as excinfo:
raise TypeErrorSubmission("test_error")
assert str(excinfo.value) == "Type error: test_error"
def test_runtime_error_submission():
"""Validate that RuntimeErrorSubmission is raised with the correct message."""
with pytest.raises(RuntimeErrorSubmission) as excinfo:
raise RuntimeErrorSubmission("test_error")
assert str(excinfo.value) == "Runtime error: test_error"

View File

@@ -1,3 +1,4 @@
# pylint: disable=too-many-lines
# -*- coding: utf-8 -*-
"""
Tests of extended hints
@@ -14,7 +15,6 @@ from xmodule.capa.tests.helpers import load_fixture, new_loncapa_problem
# With the use of ddt, some of the data expected_string cases below are naturally long stretches
# of text text without whitespace. I think it's best to leave such lines intact
# in the test code. Therefore:
# pylint: disable=line-too-long
# For out many ddt data cases, prefer a compact form of { .. }
@@ -34,8 +34,8 @@ class HintTest(unittest.TestCase):
adict = cmap.cmap.get(problem_id)
if adict:
return adict["msg"]
else:
return ""
return ""
# It is a little surprising how much more complicated TextInput is than all the other cases.
@@ -71,45 +71,74 @@ class TextInputHintsTest(HintTest):
{
"problem_id": "1_2_1",
"choice": "GermanyΩ",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">I do not think so.&#937;</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span>'
'<div class="hint-text">I do not think so.&#937;</div></div>'
),
},
{
"problem_id": "1_2_1",
"choice": "franceΩ",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">Viva la France!&#937;</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">Viva la France!&#937;</div></div>'
),
},
{
"problem_id": "1_2_1",
"choice": "FranceΩ",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">Viva la France!&#937;</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">Viva la France!&#937;</div></div>'
),
},
{"problem_id": "1_2_1", "choice": "Mexico", "expected_string": ""},
{
"problem_id": "1_2_1",
"choice": "USAΩ",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">Less well known, but yes, there is a Paris, Texas.&#937;</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span><div class="hint-text">'
"Less well known, but yes, there is a Paris, Texas.&#937;</div></div>"
),
},
{
"problem_id": "1_2_1",
"choice": "usaΩ",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">Less well known, but yes, there is a Paris, Texas.&#937;</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span><div class="hint-text">'
"Less well known, but yes, there is a Paris, Texas.&#937;</div></div>"
),
},
{"problem_id": "1_2_1", "choice": "uSAxΩ", "expected_string": ""},
{
"problem_id": "1_2_1",
"choice": "NICKLANDΩ",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">The country name does not end in LAND&#937;</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span>'
'<div class="hint-text">The country name does not end in LAND&#937;</div></div>'
),
},
{
"problem_id": "1_3_1",
"choice": "Blue",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">The red light is scattered by water molecules leaving only blue light.</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span><div class="hint-text">'
"The red light is scattered by water molecules leaving only blue light.</div></div>"
),
},
{"problem_id": "1_3_1", "choice": "blue", "expected_string": ""},
{"problem_id": "1_3_1", "choice": "b", "expected_string": ""},
)
@unpack
def test_text_input_hints(self, problem_id, choice, expected_string):
"""Check that the correct hint HTML is returned for each text input answer."""
hint = self.get_hint(problem_id, choice)
assert hint == expected_string
@@ -126,47 +155,72 @@ class TextInputExtendedHintsCaseInsensitive(HintTest):
{
"problem_id": "1_5_1",
"choice": "A",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Woo Hoo </span><div class="hint-text">hint1</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Woo Hoo </span><div class="hint-text">hint1</div></div>'
),
},
{
"problem_id": "1_5_1",
"choice": "a",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Woo Hoo </span><div class="hint-text">hint1</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Woo Hoo </span><div class="hint-text">hint1</div></div>'
),
},
{
"problem_id": "1_5_1",
"choice": "B",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><div class="hint-text">hint2</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<div class="hint-text">hint2</div></div>'
),
},
{
"problem_id": "1_5_1",
"choice": "b",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><div class="hint-text">hint2</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<div class="hint-text">hint2</div></div>'
),
},
{
"problem_id": "1_5_1",
"choice": "C",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><div class="hint-text">hint4</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<div class="hint-text">hint4</div></div>'
),
},
{
"problem_id": "1_5_1",
"choice": "c",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><div class="hint-text">hint4</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<div class="hint-text">hint4</div></div>'
),
},
# regexp cases
{
"problem_id": "1_5_1",
"choice": "FGGG",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><div class="hint-text">hint6</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<div class="hint-text">hint6</div></div>'
),
},
{
"problem_id": "1_5_1",
"choice": "fgG",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><div class="hint-text">hint6</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<div class="hint-text">hint6</div></div>'
),
},
)
@unpack
def test_text_input_hints(self, problem_id, choice, expected_string):
"""Ensure that text input hints match case-insensitively when expected."""
hint = self.get_hint(problem_id, choice)
assert hint == expected_string
@@ -183,31 +237,46 @@ class TextInputExtendedHintsCaseSensitive(HintTest):
{
"problem_id": "1_6_1",
"choice": "A",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">hint1</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span><div class="hint-text">hint1</div></div>'
),
},
{"problem_id": "1_6_1", "choice": "a", "expected_string": ""},
{
"problem_id": "1_6_1",
"choice": "B",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">hint2</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span><div class="hint-text">hint2</div></div>'
),
},
{"problem_id": "1_6_1", "choice": "b", "expected_string": ""},
{
"problem_id": "1_6_1",
"choice": "C",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">hint4</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span>'
'<div class="hint-text">hint4</div></div>'
),
},
{"problem_id": "1_6_1", "choice": "c", "expected_string": ""},
# regexp cases
{
"problem_id": "1_6_1",
"choice": "FGG",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">hint6</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span>'
'<div class="hint-text">hint6</div></div>'
),
},
{"problem_id": "1_6_1", "choice": "fgG", "expected_string": ""},
)
@unpack
def test_text_input_hints(self, problem_id, choice, expected_string):
"""Ensure that text input hints match case-sensitively when required."""
message_text = self.get_hint(problem_id, choice)
assert message_text == expected_string
@@ -226,14 +295,22 @@ class TextInputExtendedHintsCompatible(HintTest):
"problem_id": "1_7_1",
"choice": "A",
"correct": "correct",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">hint1</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">hint1</div></div>'
),
},
{"problem_id": "1_7_1", "choice": "B", "correct": "correct", "expected_string": ""},
{
"problem_id": "1_7_1",
"choice": "C",
"correct": "correct",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">hint2</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">hint2</div></div>'
),
},
{"problem_id": "1_7_1", "choice": "D", "correct": "incorrect", "expected_string": ""},
# check going through conversion with difficult chars
@@ -241,6 +318,7 @@ class TextInputExtendedHintsCompatible(HintTest):
)
@unpack
def test_text_input_hints(self, problem_id, choice, correct, expected_string):
"""Test compatibility between old and new style additional_answer hints."""
message_text = self.get_hint(problem_id, choice)
assert message_text == expected_string
assert self.correctness(problem_id, choice) == correct
@@ -261,59 +339,96 @@ class TextInputExtendedHintsRegex(HintTest):
"problem_id": "1_8_1",
"choice": "ABC",
"correct": "correct",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">hint1</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">hint1</div></div>'
),
},
{
"problem_id": "1_8_1",
"choice": "ABBBBC",
"correct": "correct",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">hint1</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">hint1</div></div>'
),
},
{
"problem_id": "1_8_1",
"choice": "aBc",
"correct": "correct",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">hint1</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">hint1</div></div>'
),
},
{
"problem_id": "1_8_1",
"choice": "BBBB",
"correct": "correct",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">hint2</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">hint2</div></div>'
),
},
{
"problem_id": "1_8_1",
"choice": "bbb",
"correct": "correct",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">hint2</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">hint2</div></div>'
),
},
{
"problem_id": "1_8_1",
"choice": "C",
"correct": "incorrect",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">hint4</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span>'
'<div class="hint-text">hint4</div></div>'
),
},
{
"problem_id": "1_8_1",
"choice": "c",
"correct": "incorrect",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">hint4</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span>'
'<div class="hint-text">hint4</div></div>'
),
},
{
"problem_id": "1_8_1",
"choice": "D",
"correct": "incorrect",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">hint6</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span>'
'<div class="hint-text">hint6</div></div>'
),
},
{
"problem_id": "1_8_1",
"choice": "d",
"correct": "incorrect",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">hint6</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span>'
'<div class="hint-text">hint6</div></div>'
),
},
)
@unpack
def test_text_input_hints(self, problem_id, choice, correct, expected_string):
"""Validate text input hints where answers are defined with regex matching."""
message_text = self.get_hint(problem_id, choice)
assert message_text == expected_string
assert self.correctness(problem_id, choice) == correct
@@ -329,6 +444,7 @@ class NumericInputHintsTest(HintTest):
problem = new_loncapa_problem(xml) # this problem is properly constructed
def test_tracking_log(self):
"""Verify that the tracking log is published correctly for numeric input hints."""
self.get_hint("1_2_1", "1.141")
self.problem.capa_block.runtime.publish.assert_called_with(
self.problem.capa_block,
@@ -349,30 +465,47 @@ class NumericInputHintsTest(HintTest):
{
"problem_id": "1_2_1",
"choice": "1.141",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Nice </span><div class="hint-text">The square root of two turns up in the strangest places.</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Nice </span><div class="hint-text">'
"The square root of two turns up in the strangest places.</div></div>"
),
},
# additional answer
{
"problem_id": "1_2_1",
"choice": "10",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">This is an additional hint.</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">This is an additional hint.</div></div>'
),
},
{
"problem_id": "1_3_1",
"choice": "4",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">Pretty easy, uh?.</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">Pretty easy, uh?.</div></div>'
),
},
# should get hint, when correct via numeric-tolerance
{
"problem_id": "1_2_1",
"choice": "1.15",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Nice </span><div class="hint-text">The square root of two turns up in the strangest places.</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Nice </span><div class="hint-text">'
"The square root of two turns up in the strangest places.</div></div>"
),
},
# when they answer wrong, nothing
{"problem_id": "1_2_1", "choice": "2", "expected_string": ""},
)
@unpack
def test_numeric_input_hints(self, problem_id, choice, expected_string):
"""Check that the correct hint HTML is returned for numeric input answers."""
hint = self.get_hint(problem_id, choice)
assert hint == expected_string
@@ -390,122 +523,248 @@ class CheckboxHintsTest(HintTest):
{
"problem_id": "1_2_1",
"choice": ["choice_0"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">You are right that apple is a fruit.</div><div class="hint-text">You are right that mushrooms are not fruit</div><div class="hint-text">Remember that grape is also a fruit.</div><div class="hint-text">What is a camero anyway?</div></div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">You are right that apple is a fruit.</div>'
'<div class="hint-text">You are right that mushrooms are not fruit</div>'
'<div class="hint-text">Remember that grape is also a fruit.</div>'
'<div class="hint-text">What is a camero anyway?</div></div></div>'
),
},
{
"problem_id": "1_2_1",
"choice": ["choice_1"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">Remember that apple is also a fruit.</div><div class="hint-text">Mushroom is a fungus, not a fruit.</div><div class="hint-text">Remember that grape is also a fruit.</div><div class="hint-text">What is a camero anyway?</div></div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">Remember that apple is also a fruit.</div>'
'<div class="hint-text">Mushroom is a fungus, not a fruit.</div>'
'<div class="hint-text">Remember that grape is also a fruit.</div>'
'<div class="hint-text">What is a camero anyway?</div></div></div>'
),
},
{
"problem_id": "1_2_1",
"choice": ["choice_2"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">Remember that apple is also a fruit.</div><div class="hint-text">You are right that mushrooms are not fruit</div><div class="hint-text">You are right that grape is a fruit</div><div class="hint-text">What is a camero anyway?</div></div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">Remember that apple is also a fruit.</div>'
'<div class="hint-text">You are right that mushrooms are not fruit</div>'
'<div class="hint-text">You are right that grape is a fruit</div>'
'<div class="hint-text">What is a camero anyway?</div></div></div>'
),
},
{
"problem_id": "1_2_1",
"choice": ["choice_3"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">Remember that apple is also a fruit.</div><div class="hint-text">You are right that mushrooms are not fruit</div><div class="hint-text">Remember that grape is also a fruit.</div><div class="hint-text">What is a camero anyway?</div></div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">Remember that apple is also a fruit.</div>'
'<div class="hint-text">You are right that mushrooms are not fruit</div>'
'<div class="hint-text">Remember that grape is also a fruit.</div>'
'<div class="hint-text">What is a camero anyway?</div></div></div>'
),
},
{
"problem_id": "1_2_1",
"choice": ["choice_4"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">Remember that apple is also a fruit.</div><div class="hint-text">You are right that mushrooms are not fruit</div><div class="hint-text">Remember that grape is also a fruit.</div><div class="hint-text">I do not know what a Camero is but it is not a fruit.</div></div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">Remember that apple is also a fruit.</div>'
'<div class="hint-text">You are right that mushrooms are not fruit</div>'
'<div class="hint-text">Remember that grape is also a fruit.</div>'
'<div class="hint-text">I do not know what a Camero is but it is not a fruit.'
"</div></div></div>"
),
},
{
"problem_id": "1_2_1",
"choice": ["choice_0", "choice_1"], # compound
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Almost right </span><div class="hint-text">You are right that apple is a fruit, but there is one you are missing. Also, mushroom is not a fruit.</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Almost right </span><div class="hint-text">'
"You are right that apple is a fruit, but there is one you are missing. "
"Also, mushroom is not a fruit.</div></div>"
),
},
{
"problem_id": "1_2_1",
"choice": ["choice_1", "choice_2"], # compound
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">You are right that grape is a fruit, but there is one you are missing. Also, mushroom is not a fruit.</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="hint-text">'
"You are right that grape is a fruit, but there is one you are missing. "
"Also, mushroom is not a fruit.</div></div>"
),
},
{
"problem_id": "1_2_1",
"choice": ["choice_0", "choice_2"],
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="feedback-hint-multi"><div class="hint-text">You are right that apple is a fruit.</div><div class="hint-text">You are right that mushrooms are not fruit</div><div class="hint-text">You are right that grape is a fruit</div><div class="hint-text">What is a camero anyway?</div></div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span><div class="feedback-hint-multi">'
'<div class="hint-text">You are right that apple is a fruit.</div>'
'<div class="hint-text">You are right that mushrooms are not fruit</div>'
'<div class="hint-text">You are right that grape is a fruit</div>'
'<div class="hint-text">What is a camero anyway?</div></div></div>'
),
},
{
"problem_id": "1_3_1",
"choice": ["choice_0"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">No, sorry, a banana is a fruit.</div><div class="hint-text">You are right that mushrooms are not vegatbles</div><div class="hint-text">Brussel sprout is the only vegetable in this list.</div></div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">No, sorry, a banana is a fruit.</div>'
'<div class="hint-text">You are right that mushrooms are not vegatbles</div>'
'<div class="hint-text">Brussel sprout is the only vegetable in this list.'
"</div></div></div>"
),
},
{
"problem_id": "1_3_1",
"choice": ["choice_1"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">poor banana.</div><div class="hint-text">You are right that mushrooms are not vegatbles</div><div class="hint-text">Brussel sprout is the only vegetable in this list.</div></div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">poor banana.</div>'
'<div class="hint-text">You are right that mushrooms are not vegatbles</div>'
'<div class="hint-text">Brussel sprout is the only vegetable in this list.'
"</div></div></div>"
),
},
{
"problem_id": "1_3_1",
"choice": ["choice_2"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">poor banana.</div><div class="hint-text">Mushroom is a fungus, not a vegetable.</div><div class="hint-text">Brussel sprout is the only vegetable in this list.</div></div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">poor banana.</div>'
'<div class="hint-text">Mushroom is a fungus, not a vegetable.</div>'
'<div class="hint-text">Brussel sprout is the only vegetable in this list.'
"</div></div></div>"
),
},
{
"problem_id": "1_3_1",
"choice": ["choice_3"],
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="feedback-hint-multi"><div class="hint-text">poor banana.</div><div class="hint-text">You are right that mushrooms are not vegatbles</div><div class="hint-text">Brussel sprouts are vegetables.</div></div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span><div class="feedback-hint-multi">'
'<div class="hint-text">poor banana.</div>'
'<div class="hint-text">You are right that mushrooms are not vegatbles</div>'
'<div class="hint-text">Brussel sprouts are vegetables.</div></div></div>'
),
},
{
"problem_id": "1_3_1",
"choice": ["choice_0", "choice_1"], # compound
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Very funny </span><div class="hint-text">Making a banana split?</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Very funny </span>'
'<div class="hint-text">Making a banana split?</div></div>'
),
},
{
"problem_id": "1_3_1",
"choice": ["choice_1", "choice_2"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">poor banana.</div><div class="hint-text">Mushroom is a fungus, not a vegetable.</div><div class="hint-text">Brussel sprout is the only vegetable in this list.</div></div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">poor banana.</div>'
'<div class="hint-text">Mushroom is a fungus, not a vegetable.</div>'
'<div class="hint-text">Brussel sprout is the only vegetable in this list.'
"</div></div></div>"
),
},
{
"problem_id": "1_3_1",
"choice": ["choice_0", "choice_2"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">No, sorry, a banana is a fruit.</div><div class="hint-text">Mushroom is a fungus, not a vegetable.</div><div class="hint-text">Brussel sprout is the only vegetable in this list.</div></div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">No, sorry, a banana is a fruit.</div>'
'<div class="hint-text">Mushroom is a fungus, not a vegetable.</div>'
'<div class="hint-text">Brussel sprout is the only vegetable in this list.'
"</div></div></div>"
),
},
# check for interaction between compoundhint and correct/incorrect
{
"problem_id": "1_4_1",
"choice": ["choice_0", "choice_1"], # compound
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">AB</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="hint-text">AB</div></div>'
),
},
{
"problem_id": "1_4_1",
"choice": ["choice_0", "choice_2"], # compound
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">AC</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span><div class="hint-text">AC</div></div>'
),
},
# check for labeling where multiple child hints have labels
# These are some tricky cases
{
"problem_id": "1_5_1",
"choice": ["choice_0", "choice_1"],
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">AA </span><div class="feedback-hint-multi"><div class="hint-text">aa</div></div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">AA </span><div class="feedback-hint-multi">'
'<div class="hint-text">aa</div></div></div>'
),
},
{
"problem_id": "1_5_1",
"choice": ["choice_0"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">aa</div><div class="hint-text">bb</div></div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">aa</div><div class="hint-text">bb</div></div></div>'
),
},
{"problem_id": "1_5_1", "choice": ["choice_1"], "expected_string": ""},
{
"problem_id": "1_5_1",
"choice": [],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">BB </span><div class="feedback-hint-multi"><div class="hint-text">bb</div></div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">BB </span><div class="feedback-hint-multi">'
'<div class="hint-text">bb</div></div></div>'
),
},
{
"problem_id": "1_6_1",
"choice": ["choice_0"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><div class="feedback-hint-multi"><div class="hint-text">aa</div></div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<div class="feedback-hint-multi"><div class="hint-text">aa</div></div></div>'
),
},
{
"problem_id": "1_6_1",
"choice": ["choice_0", "choice_1"],
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><div class="hint-text">compoundo</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<div class="hint-text">compoundo</div></div>'
),
},
# The user selects *nothing*, but can still get "unselected" feedback
{
"problem_id": "1_7_1",
"choice": [],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">bb</div></div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">bb</div></div></div>'
),
},
# 100% not match of sel/unsel feedback
{"problem_id": "1_7_1", "choice": ["choice_1"], "expected_string": ""},
@@ -513,11 +772,18 @@ class CheckboxHintsTest(HintTest):
{
"problem_id": "1_7_1",
"choice": ["choice_0"],
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="feedback-hint-multi"><div class="hint-text">aa</div><div class="hint-text">bb</div></div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span><div class="feedback-hint-multi">'
'<div class="hint-text">aa</div><div class="hint-text">bb</div></div></div>'
),
},
)
@unpack
def test_checkbox_hints(self, problem_id, choice, expected_string):
"""
Check that the correct hint HTML is returned for selected checkboxes, including compound and multi-child hints.
"""
self.maxDiff = None # pylint: disable=invalid-name
hint = self.get_hint(problem_id, choice)
assert hint == expected_string
@@ -648,28 +914,44 @@ class MultpleChoiceHintsTest(HintTest):
{
"problem_id": "1_2_1",
"choice": "choice_0",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><div class="hint-text">Mushroom is a fungus, not a fruit.</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<div class="hint-text">Mushroom is a fungus, not a fruit.</div></div>'
),
},
{"problem_id": "1_2_1", "choice": "choice_1", "expected_string": ""},
{
"problem_id": "1_3_1",
"choice": "choice_1",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">Potato is a root vegetable.</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">Potato is a root vegetable.</div></div>'
),
},
{
"problem_id": "1_2_1",
"choice": "choice_2",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">OUTSTANDING </span><div class="hint-text">Apple is indeed a fruit.</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">OUTSTANDING </span>'
'<div class="hint-text">Apple is indeed a fruit.</div></div>'
),
},
{
"problem_id": "1_3_1",
"choice": "choice_2",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">OOPS </span><div class="hint-text">Apple is a fruit.</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">OOPS </span>'
'<div class="hint-text">Apple is a fruit.</div></div>'
),
},
{"problem_id": "1_3_1", "choice": "choice_9", "expected_string": ""},
)
@unpack
def test_multiplechoice_hints(self, problem_id, choice, expected_string):
"""Check that the correct hint HTML is returned for each choice in multiple choice problems."""
hint = self.get_hint(problem_id, choice)
assert hint == expected_string
@@ -707,21 +989,34 @@ class MultpleChoiceHintsWithHtmlTest(HintTest):
{
"problem_id": "1_2_1",
"choice": "choice_0",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">Mushroom <img src="#" ale="#"/>is a fungus, not a fruit.</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="hint-text">'
'Mushroom <img src="#" ale="#"/>is a fungus, not a fruit.</div></div>'
),
},
{
"problem_id": "1_2_1",
"choice": "choice_1",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">Potato is <img src="#" ale="#"/> not a fruit.</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="hint-text">'
'Potato is <img src="#" ale="#"/> not a fruit.</div></div>'
),
},
{
"problem_id": "1_2_1",
"choice": "choice_2",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text"><a href="#">Apple</a> is a fruit.</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span><div class="hint-text">'
'<a href="#">Apple</a> is a fruit.</div></div>'
),
},
)
@unpack
def test_multiplechoice_hints(self, problem_id, choice, expected_string):
"""Check that the correct hint HTML, including HTML content, is returned for each choice."""
hint = self.get_hint(problem_id, choice)
assert hint == expected_string
@@ -758,44 +1053,73 @@ class DropdownHintsTest(HintTest):
{
"problem_id": "1_2_1",
"choice": "Multiple Choice",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Good Job </span><div class="hint-text">Yes, multiple choice is the right answer.</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Good Job </span><div class="hint-text">'
"Yes, multiple choice is the right answer.</div></div>"
),
},
{
"problem_id": "1_2_1",
"choice": "Text Input",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">No, text input problems do not present options.</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="hint-text">'
"No, text input problems do not present options.</div></div>"
),
},
{
"problem_id": "1_2_1",
"choice": "Numerical Input",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">No, numerical input problems do not present options.</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="hint-text">'
"No, numerical input problems do not present options.</div></div>"
),
},
{
"problem_id": "1_3_1",
"choice": "FACES",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">With lots of makeup, doncha know?</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span><div class="hint-text">'
"With lots of makeup, doncha know?</div></div>"
),
},
{
"problem_id": "1_3_1",
"choice": "dogs",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">NOPE </span><div class="hint-text">Not dogs, not cats, not toads</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">NOPE </span><div class="hint-text">'
"Not dogs, not cats, not toads</div></div>"
),
},
{"problem_id": "1_3_1", "choice": "wrongo", "expected_string": ""},
# Regression case where feedback includes answer substring
{
"problem_id": "1_4_1",
"choice": "AAA",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">AAABBB1</div></div>',
"expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span>'
'<div class="hint-text">AAABBB1</div></div>'
),
},
{
"problem_id": "1_4_1",
"choice": "BBB",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">AAABBB2</div></div>',
"expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">AAABBB2</div></div>'
),
},
{"problem_id": "1_4_1", "choice": "not going to match", "expected_string": ""},
)
@unpack
def test_dropdown_hints(self, problem_id, choice, expected_string):
"""Check that the correct hint HTML is returned for each choice in dropdown problems."""
hint = self.get_hint(problem_id, choice)
assert hint == expected_string
@@ -806,6 +1130,7 @@ class ErrorConditionsTest(HintTest):
"""
def test_error_conditions_illegal_element(self):
"""Ensure that malformed XML raises an exception when creating a problem."""
xml_with_errors = load_fixture("extended_hints_with_errors.xml")
with pytest.raises(Exception):
new_loncapa_problem(xml_with_errors) # this problem is improperly constructed

View File

@@ -12,20 +12,20 @@ from lxml import etree
from openedx.core.djangolib.markup import HTML
from xmodule.capa.tests.helpers import mock_capa_system, new_loncapa_problem
from xmodule.capa.tests.test_util import use_unsafe_codejail
from xmodule.capa.tests.test_util import UseUnsafeCodejail
from .response_xml_factory import CustomResponseXMLFactory, StringResponseXMLFactory
@ddt.ddt
@use_unsafe_codejail()
@UseUnsafeCodejail()
class CapaHtmlRenderTest(unittest.TestCase):
"""
CAPA HTML rendering tests class.
"""
def setUp(self):
super(CapaHtmlRenderTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
self.capa_system = mock_capa_system()
def test_blank_problem(self):
@@ -43,6 +43,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
# TODO: This test should inspect the rendered html and assert one or more things about it
def test_include_html(self):
"""Verify that <include> files are embedded correctly in rendered HTML."""
# Create a test file to include
self._create_test_file("test_include.xml", "<test>Test include</test>")
@@ -67,6 +68,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
assert test_element.text == "Test include"
def test_process_outtext(self):
"""Ensure <startouttext/> and <endouttext/> are converted to <span> tags."""
# Generate some XML with <startouttext /> and <endouttext />
xml_str = textwrap.dedent(
"""
@@ -88,6 +90,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
assert span_element.text == "Test text"
def test_anonymous_student_id(self):
"""Check that $anonymous_student_id is rendered as 'student'."""
# make sure anonymous_student_id is rendered properly as a context variable
xml_str = textwrap.dedent(
"""
@@ -108,6 +111,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
assert span_element.text == "Welcome student"
def test_render_script(self):
"""Ensure <script> tags are removed from rendered HTML."""
# Generate some XML with a <script> tag
xml_str = textwrap.dedent(
"""
@@ -128,6 +132,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
assert script_element is None
def test_render_javascript(self):
"""Verify JavaScript in <script> tags remains in rendered HTML."""
# Generate some XML with a <script> tag
xml_str = textwrap.dedent(
"""
@@ -147,6 +152,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
assert '<script type="text/javascript">function(){}</script>' in etree.tostring(rendered_html).decode("utf-8")
def test_render_response_xml(self):
"""Test that response XML is rendered correctly into HTML with template calls."""
# Generate some XML for a string response
kwargs = {
"question_text": "Test question",
@@ -214,6 +220,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
assert the_system.render_template.call_args_list == expected_calls
def test_correct_aria_label(self):
"""Check that rendered responses have correct aria-label attributes."""
xml = """
<problem>
<choiceresponse>
@@ -237,6 +244,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
assert response_elements[1].attrib["aria-label"] == "Question 2"
def test_render_response_with_overall_msg(self):
"""Verify that CustomResponse overall messages are rendered correctly."""
# CustomResponse script that sets an overall_message
script = textwrap.dedent(
"""
@@ -275,6 +283,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
assert msg_p_elements[1].text == "Test message 2"
def test_substitute_python_vars(self):
"""Ensure Python variables in XML scripts are substituted correctly in attributes."""
# Generate some XML with Python variables defined in a script
# and used later as attributes
xml_str = textwrap.dedent(
@@ -295,6 +304,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
assert span_element.get("attr") == "TEST"
def test_xml_comments_and_other_odd_things(self):
"""Ensure XML comments and processing instructions are skipped in rendering."""
# Comments and processing instructions should be skipped.
xml_str = textwrap.dedent(
"""\
@@ -314,7 +324,9 @@ class CapaHtmlRenderTest(unittest.TestCase):
the_html = problem.get_html()
self.assertRegex(the_html, r"<div>\s*</div>")
def _create_test_file(self, path, content_str): # lint-amnesty, pylint: disable=missing-function-docstring
def _create_test_file(self, path, content_str):
"""Create a temporary test file with the given content and schedule cleanup."""
test_fp = self.capa_system.resources_fs.open(path, "w")
test_fp.write(content_str)
test_fp.close()

View File

@@ -1,5 +1,6 @@
# pylint: disable=too-many-lines
"""
~Tests for the logic in input type Django templates.
Tests for the logic in input type Django templates.
"""
import json
@@ -21,8 +22,6 @@ class TemplateError(Exception):
Error occurred while rendering a Django template.
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
class TemplateTestCase(unittest.TestCase):
"""
@@ -45,7 +44,7 @@ class TemplateTestCase(unittest.TestCase):
"""
Initialize the context.
"""
super(TemplateTestCase, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
self.context = {}
def render_to_xml(self, context_dict):
@@ -68,9 +67,7 @@ class TemplateTestCase(unittest.TestCase):
try:
xml = etree.fromstring("<test>" + xml_str + "</test>")
except Exception as exc:
raise TemplateError( # lint-amnesty, pylint: disable=raise-missing-from
"Could not parse XML from '{0}': {1}".format(xml_str, str(exc))
)
raise TemplateError(f"Could not parse XML from '{xml_str}': {exc}") from exc
return xml
def assert_has_xpath(self, xml_root, xpath, context_dict, exact_num=1):
@@ -82,11 +79,9 @@ class TemplateTestCase(unittest.TestCase):
`context` is used to print a debugging message
`exact_num` is the exact number of matches to expect.
"""
message = "XML does not have %d match(es) for xpath '%s'\nXML: %s\nContext: %s" % (
exact_num,
str(xpath),
etree.tostring(xml_root),
str(context_dict),
message = (
f"XML does not have {exact_num} match(es) for xpath '{xpath}'\n"
f"XML: {etree.tostring(xml_root)}\nContext: {context_dict}"
)
assert len(xml_root.xpath(xpath)) == exact_num, message
@@ -115,7 +110,7 @@ class TemplateTestCase(unittest.TestCase):
If no elements are found, the assertion fails.
"""
element_list = xml_root.xpath(xpath)
assert len(element_list) > 0, "Could not find element at '%s'\n%s" % (str(xpath), etree.tostring(xml_root))
assert len(element_list) > 0, f"Could not find element at '{xpath}'\n{etree.tostring(xml_root)}"
if exact:
assert text == element_list[0].text.strip()
else:
@@ -183,14 +178,14 @@ class TemplateTestCase(unittest.TestCase):
# Expect that we get a <div> with correct class
if status_div:
xpath = "//div[normalize-space(@class)='%s']" % div_class
xpath = f"//div[normalize-space(@class)='{div_class}']"
self.assert_has_xpath(xml, xpath, self.context)
# Expect that we get a <span> with class="status"
# (used to by CSS to draw the green check / red x)
self.assert_has_text(
xml,
"//span[@class='status {}']/span[@class='sr']".format(div_class if status_class else ""),
f"//span[@class='status {div_class if status_class else ''}']/span[@class='sr']",
self.context["status"].display_name,
)
@@ -221,7 +216,7 @@ class TemplateTestCase(unittest.TestCase):
xml = self.render_to_xml(self.context)
if aria_label:
self.assert_has_xpath(xml, "//*[@aria-label='%s']" % label["expected"], self.context)
self.assert_has_xpath(xml, f"//*[@aria-label='{label['expected']}']", self.context)
else:
element_list = xml.xpath(xpath)
assert len(element_list) == 1
@@ -236,7 +231,7 @@ class ChoiceGroupTemplateTest(TemplateTestCase):
TEMPLATE_NAME = "choicegroup.html"
def setUp(self):
super(ChoiceGroupTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
choices = [("1", "choice 1"), ("2", "choice 2"), ("3", "choice 3")]
self.context = {
"id": "1",
@@ -466,7 +461,7 @@ class TextlineTemplateTest(TemplateTestCase):
TEMPLATE_NAME = "textline.html"
def setUp(self):
super(TextlineTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
self.context = {
"id": "1",
"status": Status("correct"),
@@ -478,6 +473,7 @@ class TextlineTemplateTest(TemplateTestCase):
}
def test_section_class(self):
"""Verify CSS classes for <textline> input under different context combinations."""
cases = [
({}, " capa_inputtype textline"),
({"do_math": True}, "text-input-dynamath capa_inputtype textline"),
@@ -489,7 +485,7 @@ class TextlineTemplateTest(TemplateTestCase):
base_context = self.context.copy()
base_context.update(context)
xml = self.render_to_xml(base_context)
xpath = "//div[@class='%s']" % css_class
xpath = f"//div[@class='{css_class}']"
self.assert_has_xpath(xml, xpath, self.context)
def test_status(self):
@@ -505,6 +501,7 @@ class TextlineTemplateTest(TemplateTestCase):
self.assert_label(xpath="//label[@class='problem-group-label']")
def test_hidden(self):
"""Ensure that hidden inputs and containers are rendered with display:none style."""
self.context["hidden"] = True
xml = self.render_to_xml(self.context)
@@ -515,6 +512,7 @@ class TextlineTemplateTest(TemplateTestCase):
self.assert_has_xpath(xml, xpath, self.context)
def test_do_math(self):
"""Verify that math-related elements and classes are rendered for do_math context."""
self.context["do_math"] = True
xml = self.render_to_xml(self.context)
@@ -528,6 +526,7 @@ class TextlineTemplateTest(TemplateTestCase):
self.assert_has_xpath(xml, xpath, self.context)
def test_size(self):
"""Ensure that the size attribute is correctly applied to input elements."""
self.context["size"] = "20"
xml = self.render_to_xml(self.context)
@@ -535,6 +534,7 @@ class TextlineTemplateTest(TemplateTestCase):
self.assert_has_xpath(xml, xpath, self.context)
def test_preprocessor(self):
"""Verify that preprocessor-related data attributes are rendered correctly."""
self.context["preprocessor"] = {"class_name": "test_class", "script_src": "test_script"}
xml = self.render_to_xml(self.context)
@@ -545,6 +545,7 @@ class TextlineTemplateTest(TemplateTestCase):
self.assert_has_xpath(xml, xpath, self.context)
def test_do_inline_and_preprocessor(self):
"""Ensure inline and preprocessor settings are applied together correctly."""
self.context["preprocessor"] = {"class_name": "test_class", "script_src": "test_script"}
self.context["inline"] = True
xml = self.render_to_xml(self.context)
@@ -553,6 +554,7 @@ class TextlineTemplateTest(TemplateTestCase):
self.assert_has_xpath(xml, xpath, self.context)
def test_do_inline(self):
"""Verify inline class is applied correctly based on status context."""
cases = [
("correct", "correct"),
("unsubmitted", "unanswered"),
@@ -567,10 +569,11 @@ class TextlineTemplateTest(TemplateTestCase):
xml = self.render_to_xml(self.context)
# Expect that we get a <div> with correct class
xpath = "//div[@class='%s inline']" % div_class
xpath = f"//div[@class='{div_class} inline']"
self.assert_has_xpath(xml, xpath, self.context)
def test_message(self):
"""Check that message text is rendered inside the proper span element."""
self.context["msg"] = "Test message"
xml = self.render_to_xml(self.context)
@@ -587,14 +590,12 @@ class TextlineTemplateTest(TemplateTestCase):
class FormulaEquationInputTemplateTest(TemplateTestCase):
"""
Test make template for `<formulaequationinput>`s.
"""
"""Test django template for `<formulaequationinput>` input."""
TEMPLATE_NAME = "formulaequationinput.html"
def setUp(self):
super(FormulaEquationInputTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
self.context = {
"id": 2,
"value": "PREFILLED_VALUE",
@@ -607,10 +608,12 @@ class FormulaEquationInputTemplateTest(TemplateTestCase):
}
def test_no_size(self):
"""Ensure no size attribute is present when not specified in context."""
xml = self.render_to_xml(self.context)
self.assert_no_xpath(xml, "//input[@size]", self.context)
def test_size(self):
"""Verify that size attribute is correctly applied to formula equation input."""
self.context["size"] = "40"
xml = self.render_to_xml(self.context)
@@ -645,7 +648,7 @@ class AnnotationInputTemplateTest(TemplateTestCase):
TEMPLATE_NAME = "annotationinput.html"
def setUp(self):
super(AnnotationInputTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
self.context = {
"id": 2,
"value": "<p>Test value</p>",
@@ -689,7 +692,7 @@ class AnnotationInputTemplateTest(TemplateTestCase):
# Create options 0-4 and select option 2
self.context["options_value"] = [2]
self.context["options"] = [
{"id": id_num, "choice": "correct", "description": "<p>Unescaped <b>HTML {0}</b></p>".format(id_num)}
{"id": id_num, "choice": "correct", "description": f"<p>Unescaped <b>HTML {id_num}</b></p>"}
for id_num in range(5)
]
@@ -699,8 +702,8 @@ class AnnotationInputTemplateTest(TemplateTestCase):
# with unescaped HTML.
# Since the HTML is unescaped, we can traverse the XML tree
for id_num in range(5):
xpath = "//span[@data-id='{0}']/p/b".format(id_num)
self.assert_has_text(xml, xpath, "HTML {0}".format(id_num), exact=False)
xpath = f"//span[@data-id='{id_num}']/p/b"
self.assert_has_text(xml, xpath, f"HTML {id_num}", exact=False)
# Expect that the correct option is selected
xpath = "//span[contains(@class,'selected')]/p/b"
@@ -718,7 +721,7 @@ class AnnotationInputTemplateTest(TemplateTestCase):
self.context["status"] = Status(input_status)
xml = self.render_to_xml(self.context)
xpath = "//span[@class='status {0}']".format(expected_css_class)
xpath = f"//span[@class='status {expected_css_class}']"
self.assert_has_xpath(xml, xpath, self.context)
# If individual options are being marked, then expect
@@ -770,10 +773,11 @@ class MathStringTemplateTest(TemplateTestCase):
TEMPLATE_NAME = "mathstring.html"
def setUp(self):
super(MathStringTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
self.context = {"isinline": False, "mathstr": "", "tail": ""}
def test_math_string_inline(self):
"""Verify that math string is rendered inline correctly with MathJax tags."""
self.context["isinline"] = True
self.context["mathstr"] = "y = ax^2 + bx + c"
@@ -782,6 +786,7 @@ class MathStringTemplateTest(TemplateTestCase):
self.assert_has_text(xml, xpath, "[mathjaxinline]y = ax^2 + bx + c[/mathjaxinline]")
def test_math_string_not_inline(self):
"""Verify that math string is rendered as block (not inline) correctly with MathJax tags."""
self.context["isinline"] = False
self.context["mathstr"] = "y = ax^2 + bx + c"
@@ -790,6 +795,7 @@ class MathStringTemplateTest(TemplateTestCase):
self.assert_has_text(xml, xpath, "[mathjax]y = ax^2 + bx + c[/mathjax]")
def test_tail_html(self):
"""Ensure that tail HTML is rendered correctly and unescaped."""
self.context["tail"] = "<p>This is some <b>tail</b> <em>HTML</em></p>"
xml = self.render_to_xml(self.context)
@@ -810,7 +816,7 @@ class OptionInputTemplateTest(TemplateTestCase):
TEMPLATE_NAME = "optioninput.html"
def setUp(self):
super(OptionInputTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
self.context = {
"id": 2,
"options": [],
@@ -822,9 +828,9 @@ class OptionInputTemplateTest(TemplateTestCase):
}
def test_select_options(self):
"""Verify that option elements are rendered correctly and the selected option is marked."""
# Create options 0-4, and select option 2
self.context["options"] = [(id_num, "Option {0}".format(id_num)) for id_num in range(5)]
self.context["options"] = [(id_num, f"Option {id_num}") for id_num in range(5)]
self.context["value"] = 2
xml = self.render_to_xml(self.context)
@@ -834,8 +840,8 @@ class OptionInputTemplateTest(TemplateTestCase):
self.assert_has_xpath(xml, xpath, self.context)
for id_num in range(5):
xpath = "//option[@value='{0}']".format(id_num)
self.assert_has_text(xml, xpath, "Option {0}".format(id_num))
xpath = f"//option[@value='{id_num}']"
self.assert_has_text(xml, xpath, f"Option {id_num}")
# Should have the correct option selected
xpath = "//option[@selected='true']"
@@ -870,11 +876,11 @@ class DragAndDropTemplateTest(TemplateTestCase):
TEMPLATE_NAME = "drag_and_drop_input.html"
def setUp(self):
super(DragAndDropTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
self.context = {"id": 2, "drag_and_drop_json": "", "value": 0, "status": Status("unsubmitted"), "msg": ""}
def test_status(self):
"""Verify that drag-and-drop input renders correct status CSS classes and text."""
# Test cases, where each tuple represents
# `(input_status, expected_css_class, expected_text)`
test_cases = [
@@ -889,14 +895,15 @@ class DragAndDropTemplateTest(TemplateTestCase):
xml = self.render_to_xml(self.context)
# Expect a <div> with the status
xpath = "//div[@class='{0}']".format(expected_css_class)
xpath = f"//div[@class='{expected_css_class}']"
self.assert_has_xpath(xml, xpath, self.context)
# Expect a <span> with the status
xpath = "//span[@class='status {0}']/span[@class='sr']".format(expected_css_class)
xpath = f"//span[@class='status {expected_css_class}']/span[@class='sr']"
self.assert_has_text(xml, xpath, expected_text, exact=False)
def test_drag_and_drop_json_html(self):
"""Ensure that drag-and-drop JSON with HTML is rendered without escaping HTML."""
json_with_html = json.dumps({"test": "<p>Unescaped <b>HTML</b></p>"})
self.context["drag_and_drop_json"] = json_with_html
@@ -931,7 +938,7 @@ class ChoiceTextGroupTemplateTest(TemplateTestCase):
}
def setUp(self):
super(ChoiceTextGroupTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
choices = [
(
"1_choiceinput_0bc",
@@ -1013,9 +1020,9 @@ class ChoiceTextGroupTemplateTest(TemplateTestCase):
# Should NOT mark individual options
grouping_tag = grouping_tags[test_conditions["input_type"]]
self.assert_no_xpath(xml, "//{0}[@class='choicetextgroup_incorrect']".format(grouping_tag), self.context)
self.assert_no_xpath(xml, f"//{grouping_tag}[@class='choicetextgroup_incorrect']", self.context)
self.assert_no_xpath(xml, "//{0}[@class='choicetextgroup_correct']".format(grouping_tag), self.context)
self.assert_no_xpath(xml, f"//{grouping_tag}[@class='choicetextgroup_correct']", self.context)
def test_problem_marked_unsubmitted(self):
"""Test all conditions under which the entire problem
@@ -1041,9 +1048,9 @@ class ChoiceTextGroupTemplateTest(TemplateTestCase):
# Should NOT mark individual options
grouping_tag = grouping_tags[test_conditions["input_type"]]
self.assert_no_xpath(xml, "//{0}[@class='choicetextgroup_incorrect']".format(grouping_tag), self.context)
self.assert_no_xpath(xml, f"//{grouping_tag}[@class='choicetextgroup_incorrect']", self.context)
self.assert_no_xpath(xml, "//{0}[@class='choicetextgroup_correct']".format(grouping_tag), self.context)
self.assert_no_xpath(xml, f"//{grouping_tag}[@class='choicetextgroup_correct']", self.context)
def test_option_marked_correct(self):
"""Test conditions under which a particular option
@@ -1096,7 +1103,7 @@ class ChemicalEquationTemplateTest(TemplateTestCase):
TEMPLATE_NAME = "chemicalequationinput.html"
def setUp(self):
super(ChemicalEquationTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
self.context = {
"id": "1",
"status": Status("correct"),
@@ -1117,7 +1124,7 @@ class SchematicInputTemplateTest(TemplateTestCase):
TEMPLATE_NAME = "schematicinput.html"
def setUp(self):
super(SchematicInputTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
self.context = {
"id": "1",
"status": Status("correct"),
@@ -1149,7 +1156,7 @@ class CodeinputTemplateTest(TemplateTestCase):
TEMPLATE_NAME = "codeinput.html"
def setUp(self):
super(CodeinputTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
self.context = {
"id": "1",
"status": Status("correct"),

View File

@@ -1,3 +1,4 @@
# pylint: disable=too-many-lines
# -*- coding: utf-8 -*-
"""
Tests of input types.
@@ -21,9 +22,9 @@ TODO:
import json
import textwrap
import unittest
import xml.sax.saxutils as saxutils
from collections import OrderedDict
from unittest.mock import ANY, patch
from xml.sax import saxutils
import pytest
import six
@@ -50,6 +51,7 @@ RESPONSE_DATA = {"label": "question text 101", "descriptions": DESCRIPTIONS}
def quote_attr(s):
"""Return a string with XML attribute-escaped content, without outer quotes."""
return saxutils.quoteattr(s)[1:-1] # don't want the outer quotes
@@ -59,6 +61,7 @@ class OptionInputTest(unittest.TestCase):
"""
def test_rendering(self):
"""Test that OptionInput renders correctly with given state and XML element."""
xml_str = """<optioninput options="('Up','Down','Don't know')" id="sky_input" correct="Up"/>"""
element = etree.fromstring(xml_str)
@@ -89,9 +92,10 @@ class OptionInputTest(unittest.TestCase):
assert context == expected
def test_option_parsing(self):
"""Test that OptionInput.parse_options correctly parses various option strings."""
f = inputtypes.OptionInput.parse_options
def check(input, options): # lint-amnesty, pylint: disable=redefined-builtin
def check(input, options): # pylint: disable=redefined-builtin
"""
Take list of options, confirm that output is in the silly doubled format
"""
@@ -117,9 +121,8 @@ class ChoiceGroupTest(unittest.TestCase):
Test choice groups, radio groups, and checkbox groups
"""
def check_group(
self, tag, expected_input_type, expected_suffix
): # lint-amnesty, pylint: disable=missing-function-docstring
def check_group(self, tag, expected_input_type, expected_suffix):
"""Test that choice group inputs render expected context."""
xml_str = """
<{tag}>
<choice correct="false" name="foil1"><text>This is foil One.</text></choice>
@@ -161,12 +164,15 @@ class ChoiceGroupTest(unittest.TestCase):
assert context == expected
def test_choicegroup(self):
"""Test that a <choicegroup> renders correctly as radio inputs."""
self.check_group("choicegroup", "radio", "")
def test_radiogroup(self):
"""Test that a <radiogroup> renders correctly as radio inputs with name suffix."""
self.check_group("radiogroup", "radio", "[]")
def test_checkboxgroup(self):
"""Test that a <checkboxgroup> renders correctly as checkbox inputs with name suffix."""
self.check_group("checkboxgroup", "checkbox", "[]")
@@ -256,8 +262,9 @@ class TextLineTest(unittest.TestCase):
"""
def test_rendering(self):
"""Test that a standard textline input renders correctly."""
size = "42"
xml_str = """<textline id="prob_1_2" size="{size}"/>""".format(size=size)
xml_str = f"""<textline id="prob_1_2" size="{size}"/>"""
element = etree.fromstring(xml_str)
@@ -284,15 +291,14 @@ class TextLineTest(unittest.TestCase):
assert context == expected
def test_math_rendering(self):
"""Test that a math-enabled textline input renders correctly with preprocessor."""
size = "42"
preprocessorClass = "preParty"
preprocessor_class = "preParty"
script = "foo/party.js"
xml_str = """<textline math="True" id="prob_1_2" size="{size}"
preprocessorClassName="{pp}"
preprocessorSrc="{sc}"/>""".format(
size=size, pp=preprocessorClass, sc=script
)
xml_str = f"""<textline math="True" id="prob_1_2" size="{size}"
preprocessorClassName="{preprocessor_class}"
preprocessorSrc="{script}"/>"""
element = etree.fromstring(xml_str)
@@ -313,7 +319,7 @@ class TextLineTest(unittest.TestCase):
"trailing_text": "",
"do_math": True,
"preprocessor": {
"class_name": preprocessorClass,
"class_name": preprocessor_class,
"script_src": script,
},
"response_data": RESPONSE_DATA,
@@ -322,6 +328,7 @@ class TextLineTest(unittest.TestCase):
assert context == expected
def test_trailing_text_rendering(self):
"""Test that trailing text in textline inputs is correctly rendered and escaped."""
size = "42"
# store (xml_text, expected)
trailing_text = []
@@ -334,12 +341,10 @@ class TextLineTest(unittest.TestCase):
trailing_text.append(("a &lt; b", "a < b"))
for xml_text, expected_text in trailing_text:
xml_str = """<textline id="prob_1_2"
xml_str = f"""<textline id="prob_1_2"
size="{size}"
trailing_text="{tt}"
/>""".format(
size=size, tt=xml_text
)
trailing_text="{xml_text}"
/>"""
element = etree.fromstring(xml_str)
@@ -372,16 +377,14 @@ class FileSubmissionTest(unittest.TestCase):
"""
def test_rendering(self):
"""Test that a filesubmission input renders correctly with allowed and required files."""
allowed_files = "runme.py nooooo.rb ohai.java"
required_files = "cookies.py"
xml_str = """<filesubmission id="prob_1_2"
allowed_files="{af}"
required_files="{rf}"
/>""".format(
af=allowed_files,
rf=required_files,
)
xml_str = f"""<filesubmission id="prob_1_2"
allowed_files="{allowed_files}"
required_files="{required_files}"
/>"""
element = etree.fromstring(xml_str)
@@ -418,21 +421,20 @@ class CodeInputTest(unittest.TestCase):
"""
def test_rendering(self):
"""Test that a codeinput input renders correctly with specified editor settings."""
mode = "parrot"
linenumbers = "false"
rows = "37"
cols = "11"
tabsize = "7"
xml_str = """<codeinput id="prob_1_2"
mode="{m}"
cols="{c}"
rows="{r}"
linenumbers="{ln}"
tabsize="{ts}"
/>""".format(
m=mode, c=cols, r=rows, ln=linenumbers, ts=tabsize
)
xml_str = f"""<codeinput id="prob_1_2"
mode="{mode}"
cols="{cols}"
rows="{rows}"
linenumbers="{linenumbers}"
tabsize="{tabsize}"
/>"""
element = etree.fromstring(xml_str)
@@ -461,7 +463,7 @@ class CodeInputTest(unittest.TestCase):
"hidden": "",
"tabsize": int(tabsize),
"queue_len": "3",
"aria_label": "{mode} editor".format(mode=mode),
"aria_label": f"{mode} editor",
"code_mirror_exit_message": "Press ESC then TAB or click outside of the code editor to exit",
"response_data": RESPONSE_DATA,
"describedby_html": DESCRIBEDBY.format(status_id=prob_id),
@@ -470,29 +472,27 @@ class CodeInputTest(unittest.TestCase):
assert context == expected
class MatlabTest(unittest.TestCase):
class MatlabTest(unittest.TestCase): # pylint: disable=too-many-instance-attributes
"""
Test Matlab input types
"""
def setUp(self):
super(MatlabTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
self.rows = "10"
self.cols = "80"
self.tabsize = "4"
self.mode = ""
self.payload = "payload"
self.linenumbers = "true"
self.xml = """<matlabinput id="prob_1_2"
rows="{r}" cols="{c}"
tabsize="{tabsize}" mode="{m}"
linenumbers="{ln}">
self.xml = f"""<matlabinput id="prob_1_2"
rows="{self.rows}" cols="{self.cols}"
tabsize="{self.tabsize}" mode="{self.mode}"
linenumbers="{self.linenumbers}">
<plot_payload>
{payload}
{self.payload}
</plot_payload>
</matlabinput>""".format(
r=self.rows, c=self.cols, tabsize=self.tabsize, m=self.mode, payload=self.payload, ln=self.linenumbers
)
</matlabinput>"""
elt = etree.fromstring(self.xml)
state = {
"value": 'print "good evening"',
@@ -505,6 +505,7 @@ class MatlabTest(unittest.TestCase):
self.the_input = self.input_class(mock_capa_system(), elt, state)
def test_rendering(self):
"""Check that Matlab input renders with default context."""
context = self.the_input._get_render_context() # pylint: disable=protected-access
expected = {
@@ -530,6 +531,7 @@ class MatlabTest(unittest.TestCase):
assert context == expected
def test_rendering_with_state(self):
"""Verify rendering when Matlab input has a pre-existing state."""
state = {
"value": 'print "good evening"',
"status": "incomplete",
@@ -565,6 +567,7 @@ class MatlabTest(unittest.TestCase):
assert context == expected
def test_rendering_when_completed(self):
"""Ensure rendering is correct when Matlab input status is completed."""
for status in ["correct", "incorrect"]:
state = {
"value": 'print "good evening"',
@@ -599,7 +602,8 @@ class MatlabTest(unittest.TestCase):
assert context == expected
@patch("xmodule.capa.inputtypes.time.time", return_value=10)
def test_rendering_while_queued(self, time): # lint-amnesty, pylint: disable=unused-argument
def test_rendering_while_queued(self, time): # pylint: disable=unused-argument
"""Test rendering of Matlab input while a queue is in progress."""
state = {
"value": 'print "good evening"',
"status": "incomplete",
@@ -633,6 +637,7 @@ class MatlabTest(unittest.TestCase):
assert context == expected
def test_plot_data(self):
"""Verify that plot submission sends data to Xqueue successfully."""
data = {"submission": "x = 1234;"}
response = self.the_input.handle_ajax("plot", data)
self.the_input.capa_system.xqueue.interface.send_to_queue.assert_called_with(header=ANY, body=ANY)
@@ -641,6 +646,7 @@ class MatlabTest(unittest.TestCase):
assert self.the_input.input_state["queuestate"] == "queued"
def test_plot_data_failure(self):
"""Check behavior when plot submission to Xqueue fails."""
data = {"submission": "x = 1234;"}
error_message = "Error message!"
self.the_input.capa_system.xqueue.interface.send_to_queue.return_value = (1, error_message)
@@ -651,7 +657,8 @@ class MatlabTest(unittest.TestCase):
assert "queuestate" not in self.the_input.input_state
@patch("xmodule.capa.inputtypes.time.time", return_value=10)
def test_ungraded_response_success(self, time): # lint-amnesty, pylint: disable=unused-argument
def test_ungraded_response_success(self, time): # pylint: disable=unused-argument
"""Test successful handling of ungraded responses from Xqueue."""
queuekey = "abcd"
input_state = {"queuekey": queuekey, "queuestate": "queued", "queuetime": 5}
state = {
@@ -672,7 +679,8 @@ class MatlabTest(unittest.TestCase):
assert input_state["queue_msg"] == inner_msg
@patch("xmodule.capa.inputtypes.time.time", return_value=10)
def test_ungraded_response_key_mismatch(self, time): # lint-amnesty, pylint: disable=unused-argument
def test_ungraded_response_key_mismatch(self, time): # pylint: disable=unused-argument
"""Ensure ungraded responses with mismatched keys are ignored."""
queuekey = "abcd"
input_state = {"queuekey": queuekey, "queuestate": "queued", "queuetime": 5}
state = {
@@ -693,7 +701,8 @@ class MatlabTest(unittest.TestCase):
assert "queue_msg" not in input_state
@patch("xmodule.capa.inputtypes.time.time", return_value=20)
def test_matlab_response_timeout_not_exceeded(self, time): # lint-amnesty, pylint: disable=unused-argument
def test_matlab_response_timeout_not_exceeded(self, time): # pylint: disable=unused-argument
"""Check status when Matlab response timeout has not been exceeded."""
state = {"input_state": {"queuestate": "queued", "queuetime": 5}}
elt = etree.fromstring(self.xml)
@@ -702,17 +711,18 @@ class MatlabTest(unittest.TestCase):
assert the_input.status == "queued"
@patch("xmodule.capa.inputtypes.time.time", return_value=45)
def test_matlab_response_timeout_exceeded(self, time): # lint-amnesty, pylint: disable=unused-argument
def test_matlab_response_timeout_exceeded(self, time): # pylint: disable=unused-argument
"""Verify behavior when Matlab response timeout is exceeded."""
state = {"input_state": {"queuestate": "queued", "queuetime": 5}}
elt = etree.fromstring(self.xml)
the_input = self.input_class(mock_capa_system(), elt, state)
assert the_input.status == "unsubmitted"
assert the_input.msg == "No response from Xqueue within {} seconds. Aborted.".format(XQUEUE_TIMEOUT)
assert the_input.msg == f"No response from Xqueue within {XQUEUE_TIMEOUT} seconds. Aborted."
@patch("xmodule.capa.inputtypes.time.time", return_value=20)
def test_matlab_response_migration_of_queuetime(self, time): # lint-amnesty, pylint: disable=unused-argument
def test_matlab_response_migration_of_queuetime(self, time): # pylint: disable=unused-argument
"""
Test if problem was saved before queuetime was introduced.
"""
@@ -732,14 +742,15 @@ class MatlabTest(unittest.TestCase):
the_input = lookup_tag("matlabinput")(system, elt, {})
data = {"submission": "x = 1234;"}
response = the_input.handle_ajax("plot", data) # lint-amnesty, pylint: disable=unused-variable
response = the_input.handle_ajax("plot", data) # pylint: disable=unused-variable
body = system.xqueue.interface.send_to_queue.call_args[1]["body"]
payload = json.loads(body)
assert "test_api_key" == payload["token"]
assert "2" == payload["endpoint_version"]
def test_get_html(self):
def test_get_html(self): # pylint: disable=too-many-locals
"""Test that Matlab input generates correct HTML representation."""
# usual output
output = self.the_input.get_html()
output_string = etree.tostring(output).decode("utf-8")
@@ -807,6 +818,7 @@ class MatlabTest(unittest.TestCase):
self.the_input.capa_system.render_template = old_render_template
def test_malformed_queue_msg(self):
"""Verify handling of malformed queue messages in Matlab input."""
# an actual malformed response
queue_msg = textwrap.dedent(
"""
@@ -867,16 +879,34 @@ class MatlabTest(unittest.TestCase):
the_input = self.input_class(mock_capa_system(), elt, state)
context = the_input._get_render_context() # pylint: disable=protected-access
self.maxDiff = None
self.maxDiff = None # pylint: disable=invalid-name
expected = fromstring(
'\n<div class="matlabResponse"><div class="commandWindowOutput" style="white-space: pre;"> <strong>if</strong> Conditionally execute statements.\nThe general form of the <strong>if</strong> statement is\n\n <strong>if</strong> expression\n statements\n ELSEIF expression\n statements\n ELSE\n statements\n END\n\nThe statements are executed if the real part of the expression \nhas all non-zero elements. The ELSE and ELSEIF parts are optional.\nZero or more ELSEIF parts can be used as well as nested <strong>if</strong>\'s.\nThe expression is usually of the form expr rop expr where \nrop is ==, &lt;, &gt;, &lt;=, &gt;=, or ~=.\n<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAjAAAAGkCAIAAACgj==">\n\nExample\n if I == J\n A(I,J) = 2;\n elseif abs(I-J) == 1\n A(I,J) = -1;\n else\n A(I,J) = 0;\n end\n\nSee also <a>relop</a>, <a>else</a>, <a>elseif</a>, <a>end</a>, <a>for</a>, <a>while</a>, <a>switch</a>.\n\nReference page in Help browser\n <a>doc if</a>\n\n</div><ul></ul></div>\n' # lint-amnesty, pylint: disable=line-too-long
(
'\n<div class="matlabResponse"><div class="commandWindowOutput" '
'style="white-space: pre;"> <strong>if</strong> Conditionally execute '
"statements.\nThe general form of the <strong>if</strong> statement is\n\n"
" <strong>if</strong> expression\n statements\n ELSEIF expression\n"
" statements\n ELSE\n statements\n END\n\nThe statements are "
"executed if the real part of the expression \nhas all non-zero elements. "
"The ELSE and ELSEIF parts are optional.\nZero or more ELSEIF parts can be "
"used as well as nested <strong>if</strong>'s.\nThe expression is usually "
"of the form expr rop expr where \nrop is ==, &lt;, &gt;, &lt;=, &gt;=, or "
'~=.\n<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAjAAAAGkCAIA'
'AACgj==">\n\nExample\n if I == J\n A(I,J) = 2;\n elseif abs(I-J) '
"== 1\n A(I,J) = -1;\n else\n A(I,J) = 0;\n end\n\nSee also "
"<a>relop</a>, <a>else</a>, <a>elseif</a>, <a>end</a>, <a>for</a>, "
"<a>while</a>, <a>switch</a>.\n\nReference page in Help browser\n "
"<a>doc if</a>\n\n</div><ul></ul></div>\n"
)
)
received = fromstring(context["queue_msg"])
html_tree_equal(received, expected)
def test_rendering_with_invalid_queue_msg(self):
"""Ensure invalid queue messages are sanitized and handled."""
self.the_input.queue_msg = (
"<div class='matlabResponse'><div style='white-space:pre' class='commandWindowOutput'>" # lint-amnesty, pylint: disable=line-too-long
"<div class='matlabResponse'><div style='white-space:pre' class='commandWindowOutput'>"
"\nans =\n\n\u0002\n\n</div><ul></ul></div>"
)
context = self.the_input._get_render_context() # pylint: disable=protected-access
@@ -900,7 +930,7 @@ class MatlabTest(unittest.TestCase):
"queue_len": "3",
"matlab_editor_js": "/dummy-static/js/vendor/CodeMirror/octave.js",
"response_data": {},
"describedby_html": 'aria-describedby="status_{id}"'.format(id=prob_id),
"describedby_html": f'aria-describedby="status_{prob_id}"',
}
assert context == expected
self.the_input.capa_system.render_template = DemoSystem().render_template
@@ -912,7 +942,7 @@ class MatlabTest(unittest.TestCase):
"""
allowed_tags = ["div", "p", "audio", "pre", "span"]
for tag in allowed_tags:
queue_msg = "<{0}>Test message</{0}>".format(tag)
queue_msg = f"<{tag}>Test message</{tag}>"
state = {
"input_state": {"queue_msg": queue_msg},
"status": "queued",
@@ -926,7 +956,7 @@ class MatlabTest(unittest.TestCase):
Test not allowed tag.
"""
not_allowed_tag = "script"
queue_msg = "<{0}>Test message</{0}>".format(not_allowed_tag)
queue_msg = f"<{not_allowed_tag}>Test message</{not_allowed_tag}>"
state = {
"input_state": {"queue_msg": queue_msg},
"status": "queued",
@@ -941,7 +971,7 @@ class MatlabTest(unittest.TestCase):
Check that the_input.msg is sanitized.
"""
not_allowed_tag = "script"
self.the_input.msg = "<{0}>Test message</{0}>".format(not_allowed_tag)
self.the_input.msg = f"<{not_allowed_tag}>Test message</{not_allowed_tag}>"
expected = ""
assert self.the_input._get_render_context()["msg"] == expected # pylint: disable=protected-access
@@ -966,6 +996,7 @@ class SchematicTest(unittest.TestCase):
"""
def test_rendering(self):
"""Check that schematic input renders with expected context."""
height = "12"
width = "33"
parts = "resistors, capacitors, and flowers"
@@ -973,16 +1004,14 @@ class SchematicTest(unittest.TestCase):
initial_value = "two large batteries"
submit_analyses = "maybe"
xml_str = """<schematic id="prob_1_2"
height="{h}"
width="{w}"
parts="{p}"
analyses="{a}"
initial_value="{iv}"
submit_analyses="{sa}"
/>""".format(
h=height, w=width, p=parts, a=analyses, iv=initial_value, sa=submit_analyses
)
xml_str = f"""<schematic id="prob_1_2"
height="{height}"
width="{width}"
parts="{parts}"
analyses="{analyses}"
initial_value="{initial_value}"
submit_analyses="{submit_analyses}"
/>"""
element = etree.fromstring(xml_str)
@@ -1018,18 +1047,17 @@ class ImageInputTest(unittest.TestCase):
Check that image inputs work
"""
def check(self, value, egx, egy): # lint-amnesty, pylint: disable=missing-function-docstring
def check(self, value, egx, egy):
"""Test that imageinput renders with expected context."""
height = "78"
width = "427"
src = "http://www.edx.org/cowclicker.jpg"
xml_str = """<imageinput id="prob_1_2"
src="{s}"
height="{h}"
width="{w}"
/>""".format(
s=src, h=height, w=width
)
xml_str = f"""<imageinput id="prob_1_2"
src="{src}"
height="{height}"
width="{width}"
/>"""
element = etree.fromstring(xml_str)
@@ -1057,13 +1085,16 @@ class ImageInputTest(unittest.TestCase):
assert context == expected
def test_with_value(self):
"""Test image input rendering when a value is provided."""
# Check that compensating for the dot size works properly.
self.check("[50,40]", 35, 25)
def test_without_value(self):
"""Test image input rendering when no value is provided."""
self.check("", 0, 0)
def test_corrupt_values(self):
"""Ensure image input handles malformed or corrupt values safely."""
self.check("[12", 0, 0)
self.check("[12, a]", 0, 0)
self.check("[12 10]", 0, 0)
@@ -1077,15 +1108,14 @@ class CrystallographyTest(unittest.TestCase):
"""
def test_rendering(self):
"""Check that crystallography input renders with expected context."""
height = "12"
width = "33"
xml_str = """<crystallography id="prob_1_2"
height="{h}"
width="{w}"
/>""".format(
h=height, w=width
)
xml_str = f"""<crystallography id="prob_1_2"
height="{height}"
width="{width}"
/>"""
element = etree.fromstring(xml_str)
@@ -1117,19 +1147,18 @@ class VseprTest(unittest.TestCase):
"""
def test_rendering(self):
"""Test that a vsepr input renders correctly with molecules and geometries."""
height = "12"
width = "33"
molecules = "H2O, C2O"
geometries = "AX12,TK421"
xml_str = """<vsepr id="prob_1_2"
height="{h}"
width="{w}"
molecules="{m}"
geometries="{g}"
/>""".format(
h=height, w=width, m=molecules, g=geometries
)
xml_str = f"""<vsepr id="prob_1_2"
height="{height}"
width="{width}"
molecules="{molecules}"
geometries="{geometries}"
/>"""
element = etree.fromstring(xml_str)
@@ -1163,9 +1192,9 @@ class ChemicalEquationTest(unittest.TestCase):
"""
def setUp(self):
super(ChemicalEquationTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
self.size = "42"
xml_str = """<chemicalequationinput id="prob_1_2" size="{size}"/>""".format(size=self.size)
xml_str = f"""<chemicalequationinput id="prob_1_2" size="{self.size}"/>"""
element = etree.fromstring(xml_str)
@@ -1248,9 +1277,9 @@ class FormulaEquationTest(unittest.TestCase):
"""
def setUp(self):
super(FormulaEquationTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
self.size = "42"
xml_str = """<formulaequationinput id="prob_1_2" size="{size}"/>""".format(size=self.size)
xml_str = f"""<formulaequationinput id="prob_1_2" size="{self.size}"/>"""
element = etree.fromstring(xml_str)
@@ -1294,12 +1323,10 @@ class FormulaEquationTest(unittest.TestCase):
trailing_text.append(("a &lt; b", "a < b"))
for xml_text, expected_text in trailing_text:
xml_str = """<formulaequationinput id="prob_1_2"
xml_str = f"""<formulaequationinput id="prob_1_2"
size="{size}"
trailing_text="{tt}"
/>""".format(
size=size, tt=xml_text
)
trailing_text="{xml_text}"
/>"""
element = etree.fromstring(xml_str)
@@ -1361,7 +1388,7 @@ class FormulaEquationTest(unittest.TestCase):
With parse errors, FormulaEquationInput should give an error message
"""
# Simulate answering a problem that raises the exception
with patch('xmodule.capa.inputtypes.preview_numeric_input') as mock_preview:
with patch("xmodule.capa.inputtypes.preview_numeric_input") as mock_preview:
mock_preview.side_effect = ParseException("Oopsie")
response = self.the_input.handle_ajax(
"preview_formcalc",
@@ -1379,7 +1406,7 @@ class FormulaEquationTest(unittest.TestCase):
"""
With other errors, test that FormulaEquationInput also logs it
"""
with patch('xmodule.capa.inputtypes.preview_numeric_input') as mock_preview:
with patch("xmodule.capa.inputtypes.preview_numeric_input") as mock_preview:
mock_preview.side_effect = Exception()
response = self.the_input.handle_ajax(
"preview_formcalc",
@@ -1399,6 +1426,7 @@ class DragAndDropTest(unittest.TestCase):
"""
def test_rendering(self):
"""Test that a drag-and-drop input renders correctly with draggables and targets."""
path_to_images = "/dummy-static/images/"
xml_str = """
@@ -1436,14 +1464,14 @@ class DragAndDropTest(unittest.TestCase):
"id": "name_with_icon",
"icon": "/dummy-static/images/cc.jpg",
"target_fields": [],
}, # lint-amnesty, pylint: disable=line-too-long
},
{
"can_reuse": "",
"label": "arrow-left",
"id": "with_icon",
"icon": "/dummy-static/images/arrow-left.png",
"target_fields": [],
}, # lint-amnesty, pylint: disable=line-too-long
},
{"can_reuse": "", "label": "Label2", "id": "5", "icon": "", "target_fields": []},
{
"can_reuse": "",
@@ -1451,21 +1479,21 @@ class DragAndDropTest(unittest.TestCase):
"id": "2",
"icon": "/dummy-static/images/mute.png",
"target_fields": [],
}, # lint-amnesty, pylint: disable=line-too-long
},
{
"can_reuse": "",
"label": "spinner",
"id": "name_label_icon3",
"icon": "/dummy-static/images/spinner.gif",
"target_fields": [],
}, # lint-amnesty, pylint: disable=line-too-long
},
{
"can_reuse": "",
"label": "Star",
"id": "name4",
"icon": "/dummy-static/images/volume.png",
"target_fields": [],
}, # lint-amnesty, pylint: disable=line-too-long
},
{"can_reuse": "", "label": "Label3", "id": "7", "icon": "", "target_fields": []},
],
"one_per_target": "True",
@@ -1503,6 +1531,7 @@ class AnnotationInputTest(unittest.TestCase):
"""
def test_rendering(self):
"""Test that an annotationinput renders correctly with comments, options, and state."""
xml_str = """
<annotationinput>
<title>foo</title>
@@ -1554,7 +1583,7 @@ class AnnotationInputTest(unittest.TestCase):
"describedby_html": DESCRIBEDBY.format(status_id=prob_id),
}
self.maxDiff = None
self.maxDiff = None # pylint: disable=invalid-name
self.assertDictEqual(context, expected)
@@ -1575,7 +1604,7 @@ class TestChoiceText(unittest.TestCase):
choice = {"type": node_type, "contents": contents, "tail_text": tail_text, "value": value}
return choice
def check_group(self, tag, choice_tag, expected_input_type):
def check_group(self, tag, choice_tag, expected_input_type): # pylint: disable=too-many-locals
"""
Build a radio or checkbox group, parse it and check the resuls against the
expected output.
@@ -1699,7 +1728,10 @@ class TestStatus(unittest.TestCase):
"""
Test that display names are "translated"
"""
func = lambda t: t.upper()
def func(t):
return t.upper()
# status is in the mapping
statobj = inputtypes.Status("queued", func)
assert statobj.display_name == "PROCESSING"

View File

@@ -1,3 +1,4 @@
# pylint: disable=too-many-lines
# -*- coding: utf-8 -*-
"""
Tests of responsetypes
@@ -19,8 +20,16 @@ import requests
from pytz import UTC
from xmodule.capa.correctmap import CorrectMap
from xmodule.capa.responsetypes import LoncapaProblemError, ResponseError, StudentInputError
from xmodule.capa.tests.helpers import load_fixture, mock_capa_system, new_loncapa_problem
from xmodule.capa.responsetypes import (
LoncapaProblemError,
ResponseError,
StudentInputError,
)
from xmodule.capa.tests.helpers import (
load_fixture,
mock_capa_system,
new_loncapa_problem,
)
from xmodule.capa.tests.response_xml_factory import (
AnnotationResponseXMLFactory,
ChoiceResponseXMLFactory,
@@ -37,9 +46,9 @@ from xmodule.capa.tests.response_xml_factory import (
SymbolicResponseXMLFactory,
TrueFalseResponseXMLFactory,
)
from xmodule.capa.tests.test_util import use_unsafe_codejail
from xmodule.capa.tests.test_util import UseUnsafeCodejail
from xmodule.capa.util import convert_files_to_filenames
from xmodule.capa.xqueue_interface import dateformat
from xmodule.capa.xqueue_interface import DATEFORMAT
class ResponseTest(unittest.TestCase):
@@ -51,16 +60,18 @@ class ResponseTest(unittest.TestCase):
maxDiff = None
def setUp(self):
super(ResponseTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
if self.xml_factory_class:
self.xml_factory = self.xml_factory_class() # lint-amnesty, pylint: disable=not-callable
self.xml_factory = self.xml_factory_class() # pylint: disable=not-callable
def build_problem(self, capa_system=None, **kwargs):
"""Build a Loncapa problem using the XML factory and provided arguments."""
xml = self.xml_factory.build_xml(**kwargs)
return new_loncapa_problem(xml, capa_system=capa_system)
# pylint: disable=missing-function-docstring
def assert_grade(self, problem, submission, expected_correctness, msg=None):
"""Assert that a single submission is graded as expected."""
input_dict = {"1_2_1": submission}
correct_map = problem.grade_answers(input_dict)
if msg is None:
@@ -69,11 +80,12 @@ class ResponseTest(unittest.TestCase):
assert correct_map.get_correctness("1_2_1") == expected_correctness, msg
def assert_answer_format(self, problem):
"""Assert that the problem's answers are in a valid format."""
answers = problem.get_question_answers()
assert answers["1_2_1"] is not None
# pylint: disable=missing-function-docstring
def assert_multiple_grade(self, problem, correct_answers, incorrect_answers):
"""Assert that a set of inputs are graded correctly as either correct or incorrect."""
for input_str in correct_answers:
result = problem.grade_answers({"1_2_1": input_str}).get_correctness("1_2_1")
assert result == "correct"
@@ -109,11 +121,14 @@ class ResponseTest(unittest.TestCase):
return str(rand.randint(0, 1e9))
@use_unsafe_codejail()
class MultiChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
@UseUnsafeCodejail()
class MultiChoiceResponseTest(ResponseTest):
"""Unit tests for the MultipleChoiceResponse class."""
xml_factory_class = MultipleChoiceResponseXMLFactory
def test_multiple_choice_grade(self):
"""Test grading of a standard multiple-choice problem."""
problem = self.build_problem(choices=[False, True, False])
# Ensure that we get the expected grades
@@ -122,6 +137,7 @@ class MultiChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-do
self.assert_grade(problem, "choice_2", "incorrect")
def test_partial_multiple_choice_grade(self):
"""Test grading of multiple-choice problem with partial credit."""
problem = self.build_problem(choices=[False, True, "partial"], credit_type="points")
# Ensure that we get the expected grades
@@ -130,6 +146,7 @@ class MultiChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-do
self.assert_grade(problem, "choice_2", "partially-correct")
def test_named_multiple_choice_grade(self):
"""Test grading of multiple-choice problem with named choices."""
problem = self.build_problem(choices=[False, True, False], choice_names=["foil_1", "foil_2", "foil_3"])
# Ensure that we get the expected grades
@@ -138,6 +155,7 @@ class MultiChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-do
self.assert_grade(problem, "choice_foil_3", "incorrect")
def test_multiple_choice_valid_grading_schemes(self):
"""Test that invalid multiple-choice grading schemes raise an error."""
# Multiple Choice problems only allow one partial credit scheme.
# Change this test if that changes.
problem = self.build_problem(choices=[False, True, "partial"], credit_type="points,points")
@@ -152,6 +170,7 @@ class MultiChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-do
problem.grade_answers(input_dict)
def test_partial_points_multiple_choice_grade(self):
"""Test that multiple-choice choices return the correct partial points."""
problem = self.build_problem(
choices=["partial", "partial", "partial"], credit_type="points", points=["1", "0.6", "0"]
)
@@ -168,6 +187,7 @@ class MultiChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-do
assert round(correct_map.get_npoints("1_2_1") - 0, 7) >= 0
def test_contextualized_choices(self):
"""Test grading for multiple-choice responses with contextualized expressions."""
script = textwrap.dedent(
"""
a = 2
@@ -194,10 +214,13 @@ class MultiChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-do
self.assert_grade(problem, "choice_infinity may be both ... (should be partial)", "partially-correct")
class TrueFalseResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
class TrueFalseResponseTest(ResponseTest):
"""Unit tests for the TrueFalseResponse class."""
xml_factory_class = TrueFalseResponseXMLFactory
def test_true_false_grade(self):
"""Test grading for standard True/False response problems."""
problem = self.build_problem(choices=[False, True, True])
# Check the results
@@ -215,6 +238,7 @@ class TrueFalseResponseTest(ResponseTest): # pylint: disable=missing-class-docs
self.assert_grade(problem, "not_a_choice", "incorrect")
def test_named_true_false_grade(self):
"""Test grading for True/False problems with named choices."""
problem = self.build_problem(choices=[False, True, True], choice_names=["foil_1", "foil_2", "foil_3"])
# Check the results
@@ -232,15 +256,19 @@ class TrueFalseResponseTest(ResponseTest): # pylint: disable=missing-class-docs
self.assert_grade(problem, "not_a_choice", "incorrect")
def test_single_correct_response(self):
"""Test grading when there is a single correct True/False choice."""
problem = self.build_problem(choices=[True, False])
self.assert_grade(problem, "choice_0", "correct")
self.assert_grade(problem, ["choice_0"], "correct")
class ImageResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
class ImageResponseTest(ResponseTest):
"""Unit tests for the ImageResponse class."""
xml_factory_class = ImageResponseXMLFactory
def test_rectangle_grade(self):
"""Test grading for a single rectangular region in ImageResponse."""
# Define a rectangle with corners (10,10) and (20,20)
problem = self.build_problem(rectangle="(10,10)-(20,20)")
@@ -251,6 +279,7 @@ class ImageResponseTest(ResponseTest): # pylint: disable=missing-class-docstrin
self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs)
def test_multiple_rectangles_grade(self):
"""Test grading for multiple rectangles in ImageResponse."""
# Define two rectangles
rectangle_str = "(10,10)-(20,20);(100,100)-(200,200)"
@@ -261,6 +290,7 @@ class ImageResponseTest(ResponseTest): # pylint: disable=missing-class-docstrin
self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs)
def test_region_grade(self):
"""Test grading for a single polygonal region in ImageResponse."""
# Define a triangular region with corners (0,0), (5,10), and (0, 10)
region_str = "[ [1,1], [5,10], [0,10] ]"
@@ -271,6 +301,7 @@ class ImageResponseTest(ResponseTest): # pylint: disable=missing-class-docstrin
self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs)
def test_multiple_regions_grade(self):
"""Test grading for multiple regions in ImageResponse."""
# Define multiple regions that the user can select
region_str = "[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"
@@ -281,6 +312,7 @@ class ImageResponseTest(ResponseTest): # pylint: disable=missing-class-docstrin
self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs)
def test_region_and_rectangle_grade(self):
"""Test grading for combined rectangle and region in ImageResponse."""
rectangle_str = "(100,100)-(200,200)"
region_str = "[[10,10], [20,10], [20, 30]]"
@@ -291,6 +323,7 @@ class ImageResponseTest(ResponseTest): # pylint: disable=missing-class-docstrin
self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs)
def test_show_answer(self):
"""Test that ImageResponse answers are returned in the correct format."""
rectangle_str = "(100,100)-(200,200)"
region_str = "[[10,10], [20,10], [20, 30]]"
@@ -298,10 +331,13 @@ class ImageResponseTest(ResponseTest): # pylint: disable=missing-class-docstrin
self.assert_answer_format(problem)
class SymbolicResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
class SymbolicResponseTest(ResponseTest):
"""Unit tests for the SymbolicResponse class."""
xml_factory_class = SymbolicResponseXMLFactory
def test_grade_single_input_incorrect(self):
"""Test grading of incorrect single symbolic input."""
problem = self.build_problem(math_display=True, expect="2*x+3*y")
# Incorrect answers
@@ -323,6 +359,7 @@ class SymbolicResponseTest(ResponseTest): # pylint: disable=missing-class-docst
self._assert_symbolic_grade(problem, input_str, input_mathml, "incorrect")
def test_complex_number_grade_incorrect(self):
"""Test grading of incorrect complex number symbolic input."""
problem = self.build_problem(
math_display=True,
@@ -348,13 +385,16 @@ class SymbolicResponseTest(ResponseTest): # pylint: disable=missing-class-docst
)
def test_multiple_inputs_exception(self):
"""Test that specifying multiple inputs when only one is expected raises an exception."""
# Should not allow multiple inputs, since we specify
# only one "expect" value
with pytest.raises(Exception):
self.build_problem(math_display=True, expect="2*x+3*y", num_inputs=3)
def _assert_symbolic_grade(self, problem, student_input, dynamath_input, expected_correctness, snuggletex_resp=""):
def _assert_symbolic_grade( # pylint: disable=too-many-arguments,too-many-positional-arguments
self, problem, student_input, dynamath_input, expected_correctness, snuggletex_resp=""
):
"""
Assert that the symbolic response has a certain grade.
@@ -375,11 +415,14 @@ class SymbolicResponseTest(ResponseTest): # pylint: disable=missing-class-docst
assert correct_map.get_correctness("1_2_1") == expected_correctness
@use_unsafe_codejail()
class OptionResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
@UseUnsafeCodejail()
class OptionResponseTest(ResponseTest):
"""Unit tests for the OptionResponse class."""
xml_factory_class = OptionResponseXMLFactory
def test_grade(self):
"""Test grading of OptionResponse problem with multiple options."""
problem = self.build_problem(options=["first", "second", "third"], correct_option="second")
# Assert that we get the expected grades
@@ -391,6 +434,7 @@ class OptionResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, "invalid_option", "incorrect")
def test_quote_option(self):
"""Test that OptionResponse handles options containing quotes correctly."""
# Test that option response properly escapes quotes inside options strings
problem = self.build_problem(options=["hasnot", "hasn't", "has'nt"], correct_option="hasn't")
@@ -419,7 +463,7 @@ class OptionResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correct_map.get_property("1_2_1", "answervariable") == "$a"
@use_unsafe_codejail()
@UseUnsafeCodejail()
class FormulaResponseTest(ResponseTest):
"""
Test the FormulaResponse class
@@ -553,8 +597,10 @@ class FormulaResponseTest(ResponseTest):
assert not list(problem.responders.values())[0].validate_answer("3*y+2*x")
@use_unsafe_codejail()
class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
@UseUnsafeCodejail()
class StringResponseTest(ResponseTest):
"""Unit and integration tests for the StringResponse class."""
xml_factory_class = StringResponseXMLFactory
def test_backward_compatibility_for_multiple_answers(self):
@@ -579,6 +625,7 @@ class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, "Other String", "incorrect")
def test_regexp(self):
"""Test grading with various regular expression patterns and options."""
problem = self.build_problem(answer="Second", case_sensitive=False, regexp=True)
self.assert_grade(problem, "Second", "correct")
@@ -662,7 +709,7 @@ class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
Test some special cases of [unicode] regexps.
One needs to use either r'' strings or write real `repr` of unicode strings, because of the following
(from python docs, http://docs.python.org/2/library/re.html):
(from python docs, https://docs.python.org/3/library/re.html):
'for example, to match a literal backslash, one might have to write '\\\\' as the pattern string,
because the regular expression must be \\,
@@ -680,14 +727,17 @@ class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, "5\\æ", "correct")
def test_backslash(self):
"""Test grading of answers containing literal backslashes."""
problem = self.build_problem(answer="a\\\\c1", case_sensitive=False, regexp=True)
self.assert_grade(problem, "a\\c1", "correct")
def test_special_chars(self):
"""Test grading of answers containing special characters like whitespace."""
problem = self.build_problem(answer="a \\s1", case_sensitive=False, regexp=True)
self.assert_grade(problem, "a 1", "correct")
def test_case_sensitive(self):
"""Test that case-sensitive answers are graded correctly."""
# Test single answer
problem_specified = self.build_problem(answer="Second", case_sensitive=True)
@@ -732,6 +782,7 @@ class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, "\\", "correct")
def test_case_insensitive(self):
"""Test that case-insensitive answers are graded correctly."""
# Test single answer
problem = self.build_problem(answer="Second", case_sensitive=False)
@@ -756,17 +807,20 @@ class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, "Other String", "incorrect")
def test_compatible_non_attribute_additional_answer_xml(self):
"""Test grading with non-attribute additional answers in XML."""
problem = self.build_problem(answer="Donut", non_attribute_answers=["Sprinkles"])
self.assert_grade(problem, "Donut", "correct")
self.assert_grade(problem, "Sprinkles", "correct")
self.assert_grade(problem, "Meh", "incorrect")
def test_partial_matching(self):
"""Test grading of answers using partial regex matching."""
problem = self.build_problem(answer="a2", case_sensitive=False, regexp=True, additional_answers=[".?\\d.?"])
self.assert_grade(problem, "a3", "correct")
self.assert_grade(problem, "3a", "correct")
def test_exception(self):
"""Test that invalid regex patterns raise the correct exception."""
problem = self.build_problem(answer="a2", case_sensitive=False, regexp=True, additional_answers=["?\\d?"])
with pytest.raises(Exception) as cm:
self.assert_grade(problem, "a3", "correct")
@@ -774,6 +828,7 @@ class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert "nothing to repeat" in exception_message
def test_hints(self):
"""Test that hints are provided correctly based on student answers."""
hints = [
("wisconsin", "wisc", "The state capital of Wisconsin is Madison"),
@@ -805,6 +860,7 @@ class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correct_map.get_hint("1_2_1") == ""
def test_hints_regexp_and_answer_regexp(self):
"""Test that hints work correctly with regex patterns in answers."""
different_student_answers = [
"May be it is Boston",
"Boston, really?",
@@ -862,6 +918,7 @@ class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correct_map.get_hint("1_2_1") == ""
def test_computed_hints(self):
"""Test that computed hints from a hint function are returned correctly."""
problem = self.build_problem(
answer="Michigan",
hintfn="gimme_a_hint",
@@ -880,19 +937,17 @@ class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correct_map.get_hint("1_2_1") == "Hello??"
def test_hint_function_randomization(self):
"""Test that hint functions respect the problem's random seed."""
# The hint function should get the seed from the problem.
problem = self.build_problem(
answer="1",
hintfn="gimme_a_random_hint",
script=textwrap.dedent(
"""
f"""
def gimme_a_random_hint(answer_ids, student_answers, new_cmap, old_cmap):
answer = {code}
answer = {self._get_random_number_code()}
new_cmap.set_hint_and_mode(answer_ids[0], answer, "always")
""".format(
code=self._get_random_number_code()
)
"""
),
)
correct_map = problem.grade_answers({"1_2_1": "2"})
@@ -907,11 +962,13 @@ class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, " ", "incorrect")
class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
class CodeResponseTest(ResponseTest):
"""Unit and integration tests for the CodeResponse class."""
xml_factory_class = CodeResponseXMLFactory
def setUp(self):
super(CodeResponseTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
grader_payload = json.dumps({"grader": "ps04/grade_square.py"})
self.problem = self.build_problem(
@@ -921,7 +978,7 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
@staticmethod
def make_queuestate(key, time):
"""Create queuestate dict"""
timestr = datetime.strftime(time, dateformat)
timestr = datetime.strftime(time, DATEFORMAT)
return {"key": key, "time": timestr}
def test_is_queued(self):
@@ -948,7 +1005,7 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
assert self.problem.is_queued() is True
def test_update_score(self):
def test_update_score(self): # pylint: disable=too-many-locals
"""
Test whether LoncapaProblem.update_score can deliver queued result to the right subproblem
"""
@@ -982,7 +1039,7 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
for answer_id in answer_ids:
assert self.problem.correct_map.is_queued(answer_id)
# Should be still queued, since message undelivered # lint-amnesty, pylint: disable=line-too-long
# Should be still queued, since message undelivered
# Correct queuekey, state should be updated
for correctness in ["correct", "incorrect"]:
@@ -995,7 +1052,7 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
npoints = 1 if correctness == "correct" else 0
new_cmap.set(
answer_id=answer_id, npoints=npoints, correctness=correctness, msg=grader_msg, queuestate=None
) # lint-amnesty, pylint: disable=line-too-long
)
self.problem.update_score(xserver_msgs[correctness], queuekey=1000 + i)
assert self.problem.correct_map.get_dict() == new_cmap.get_dict()
@@ -1003,10 +1060,10 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
for j, test_id in enumerate(answer_ids):
if j == i:
assert not self.problem.correct_map.is_queued(test_id)
# Should be dequeued, message delivered # lint-amnesty, pylint: disable=line-too-long
# Should be dequeued, message delivered
else:
assert self.problem.correct_map.is_queued(test_id)
# Should be queued, message undelivered # lint-amnesty, pylint: disable=line-too-long
# Should be queued, message undelivered
def test_recentmost_queuetime(self):
"""
@@ -1032,7 +1089,7 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
self.problem.correct_map.update(cmap)
# Queue state only tracks up to second
latest_timestamp = datetime.strptime(datetime.strftime(latest_timestamp, dateformat), dateformat).replace(
latest_timestamp = datetime.strptime(datetime.strftime(latest_timestamp, DATEFORMAT), DATEFORMAT).replace(
tzinfo=UTC
)
@@ -1043,7 +1100,7 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
Test whether file objects are converted to filenames without altering other structures
"""
problem_file = os.path.join(os.path.dirname(__file__), "test_files/filename_convert_test.txt")
with open(problem_file) as fp:
with open(problem_file, encoding="utf-8") as fp:
answers_with_file = {
"1_2_1": "String-based answer",
"1_3_1": ["answer1", "answer2", "answer3"],
@@ -1062,10 +1119,22 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
"<span>MESSAGE</span>", # Valid XML
textwrap.dedent(
"""
<div class='matlabResponse'><div id='mwAudioPlaceHolder'>
<audio controls autobuffer autoplay src='data:audio/wav;base64='>Audio is not supported on this browser.</audio>
<div>Right click <a href=https://endpoint.mss-mathworks.com/media/filename.wav>here</a> and click \"Save As\" to download the file</div></div>
<div style='white-space:pre' class='commandWindowOutput'></div><ul></ul></div>
<div class='matlabResponse'>
<div id='mwAudioPlaceHolder'>
<audio controls autobuffer autoplay src='data:audio/wav;base64='>
Audio is not supported on this browser.
</audio>
<div>
Right click
<a href=https://endpoint.mss-mathworks.com/media/filename.wav>
here
</a>
and click \"Save As\" to download the file
</div>
</div>
<div style='white-space:pre' class='commandWindowOutput'></div>
<ul></ul>
</div>
"""
).replace(
"\n", ""
@@ -1117,11 +1186,14 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
assert output[answer_id]["msg"] == "Invalid grader reply. Please contact the course staff."
@use_unsafe_codejail()
class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
@UseUnsafeCodejail()
class ChoiceResponseTest(ResponseTest):
"""Unit and integration tests for the ChoiceResponse class."""
xml_factory_class = ChoiceResponseXMLFactory
def test_radio_group_grade(self):
"""Test grading behavior for radio choice groups."""
problem = self.build_problem(choice_type="radio", choices=[False, True, False])
# Check that we get the expected results
@@ -1133,6 +1205,7 @@ class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, "choice_3", "incorrect")
def test_checkbox_group_grade(self):
"""Test grading behavior for checkbox choice groups."""
problem = self.build_problem(choice_type="checkbox", choices=[False, True, True])
# Check that we get the expected results
@@ -1147,6 +1220,7 @@ class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, "choice_3", "incorrect")
def test_checkbox_group_valid_grading_schemes(self):
"""Test validation of allowed grading schemes for checkbox groups."""
# Checkbox-type problems only allow one partial credit scheme.
# Change this test if that changes.
problem = self.build_problem(
@@ -1163,6 +1237,7 @@ class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
problem.grade_answers(input_dict)
def test_checkbox_group_partial_credit_grade(self):
"""Test partial credit grading behavior for checkbox groups."""
# First: Every Decision Counts grading style
problem = self.build_problem(choice_type="checkbox", choices=[False, False, True, True], credit_type="edc")
@@ -1204,6 +1279,7 @@ class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, ["choice_0", "choice_1", "choice_2", "choice_3", "choice_4"], "incorrect")
def test_checkbox_group_partial_points_grade(self):
"""Test that partial points are assigned correctly for checkbox groups."""
# Ensure that we get the expected number of points
# Using assertAlmostEqual to avoid floating point issues
# First: Every Decision Counts grading style
@@ -1236,6 +1312,7 @@ class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correct_map.get_correctness("1_2_1") == "incorrect"
def test_contextualized_choices(self):
"""Test grading of checkbox choices that depend on contextual script variables."""
script = textwrap.dedent(
"""
a = 6
@@ -1256,14 +1333,17 @@ class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, ["choice_1", "choice_3"], "incorrect")
@use_unsafe_codejail()
class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
@UseUnsafeCodejail()
class NumericalResponseTest(ResponseTest): # pylint: disable=too-many-public-methods
"""Unit and integration tests for the NumericalResponse class."""
xml_factory_class = NumericalResponseXMLFactory
# We blend the line between integration (using evaluator) and exclusively
# unit testing the NumericalResponse (mocking out the evaluator)
# For simple things its not worth the effort.
def test_grade_range_tolerance(self):
"""Test that numerical responses are graded correctly within a range tolerance."""
problem_setup = [
# [given_answer, [list of correct responses], [list of incorrect responses]]
["[5, 7)", ["5", "6", "6.999"], ["4.999", "7"]],
@@ -1322,6 +1402,7 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs
assert new_cmap.get_correctness("1_2_1") == "incorrect"
def test_grade_range_tolerance_partial_credit(self):
"""Test that partially correct answers are graded properly within range tolerance."""
problem_setup = [
# [given_answer,
# [list of correct responses],
@@ -1337,6 +1418,7 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs
self.assert_multiple_partial(problem, correct_responses, incorrect_responses, partial_responses)
def test_grade_range_tolerance_exceptions(self):
"""Test that invalid inputs and complex numbers raise the appropriate exceptions."""
# no complex number in range tolerance staff answer
problem = self.build_problem(answer="[1j, 5]")
input_dict = {"1_2_1": "3"}
@@ -1368,12 +1450,14 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs
problem.grade_answers(input_dict)
def test_grade_exact(self):
"""Test that exact numerical answers are graded correctly."""
problem = self.build_problem(answer=4)
correct_responses = ["4", "4.0", "4.00"]
incorrect_responses = ["", "3.9", "4.1", "0"]
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
def test_grade_partial(self):
"""Test grading of partially correct answers for different grading schemes."""
# First: "list"-style grading scheme.
problem = self.build_problem(answer=4, credit_type="list", partial_answers="2,8,-4")
correct_responses = ["4", "4.0"]
@@ -1405,6 +1489,7 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs
self.assert_multiple_partial(problem, correct_responses, incorrect_responses, partial_responses)
def test_numerical_valid_grading_schemes(self):
"""Test that invalid grading schemes raise an error."""
# 'bongo' is not a valid grading scheme.
problem = self.build_problem(answer=4, tolerance=0.1, credit_type="bongo")
input_dict = {"1_2_1": "4"}
@@ -1412,12 +1497,14 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs
problem.grade_answers(input_dict)
def test_grade_decimal_tolerance(self):
"""Test grading of numerical answers with decimal tolerance."""
problem = self.build_problem(answer=4, tolerance=0.1)
correct_responses = ["4.0", "4.00", "4.09", "3.91"]
incorrect_responses = ["", "4.11", "3.89", "0"]
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
def test_grade_percent_tolerance(self):
"""Test grading of numerical answers with percentage-based tolerance."""
# Positive only range
problem = self.build_problem(answer=4, tolerance="10%")
correct_responses = ["4.0", "4.00", "4.39", "3.61"]
@@ -1479,6 +1566,7 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
def test_grade_with_script(self):
"""Test that script-based answers are graded correctly."""
script_text = "computed_response = math.sqrt(4)"
problem = self.build_problem(answer="$computed_response", script=script_text)
correct_responses = ["2", "2.0"]
@@ -1517,12 +1605,12 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs
mock_log.debug.assert_called_once_with("Content error--answer '%s' is not a valid number", staff_ans)
@mock.patch("xmodule.capa.responsetypes.log")
def test_responsetype_i18n(self, mock_log): # lint-amnesty, pylint: disable=unused-argument
def test_responsetype_i18n(self, mock_log): # pylint: disable=unused-argument
"""Test that LoncapaSystem has an i18n that works."""
staff_ans = "clearly bad syntax )[+1e"
problem = self.build_problem(answer=staff_ans, tolerance=1e-3)
class FakeTranslations(object):
class FakeTranslations: # pylint: disable=too-few-public-methods
"""A fake gettext.Translations object."""
def ugettext(self, text):
@@ -1584,10 +1672,10 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs
with mock.patch("xmodule.capa.responsetypes.evaluator") as mock_eval:
for err, msg_regex in errors:
def evaluator_side_effect(_, __, math_string):
def evaluator_side_effect(_, __, math_string, err=err):
"""Raise an error only for the student input."""
if math_string != "4":
raise err # lint-amnesty, pylint: disable=cell-var-from-loop
raise err
mock_eval.side_effect = evaluator_side_effect
@@ -1609,11 +1697,14 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs
assert not responder.validate_answer("fish")
@use_unsafe_codejail()
class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
@UseUnsafeCodejail()
class CustomResponseTest(ResponseTest): # pylint: disable=too-many-public-methods
"""Unit tests for validating CustomResponse behavior"""
xml_factory_class = CustomResponseXMLFactory
def test_inline_code(self):
"""Test that inline code correctly evaluates and grades a single answer."""
# For inline code, we directly modify global context variables
# 'answers' is a list of answers provided to us
# 'correct' is a list we fill in with True/False
@@ -1626,6 +1717,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, "0", "incorrect")
def test_inline_message(self):
"""Verify that inline code can set per-input and overall messages."""
# Inline code can update the global messages list
# to pass messages to the CorrectMap for a particular input
# The code can also set the global overall_message (str)
@@ -1650,8 +1742,9 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert overall_msg == "Overall message"
def test_inline_randomization(self):
"""Ensure inline code respects the problem's random seed."""
# Make sure the seed from the problem gets fed into the script execution.
inline_script = "messages[0] = {code}".format(code=self._get_random_number_code())
inline_script = f"messages[0] = {self._get_random_number_code()}"
problem = self.build_problem(answer=inline_script)
input_dict = {"1_2_1": "0"}
@@ -1661,6 +1754,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert input_msg == self._get_random_number_result(problem.seed)
def test_function_code_single_input(self):
"""Check that function code grades a single input with optional partial credit."""
# For function code, we pass in these arguments:
#
# 'expect' is the expect attribute of the <customresponse>
@@ -1724,6 +1818,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert npoints == 0
def test_function_code_single_input_decimal_score(self):
"""Verify that function code returns a decimal score for a single input."""
# For function code, we pass in these arguments:
#
# 'expect' is the expect attribute of the <customresponse>
@@ -1776,6 +1871,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correct_map.get_correctness("1_2_1") == "partially-correct"
def test_script_context(self):
"""Ensure script variables can be used in 'expect' and 'answer' fields."""
# Ensure that python script variables can be used in the "expect" and "answer" fields,
script = script = textwrap.dedent(
@@ -1805,6 +1901,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correctness == "correct"
def test_function_code_multiple_input_no_msg(self):
"""Test multiple-input function grading without returning messages."""
# Check functions also have the option of returning
# a single boolean or string value
@@ -1859,6 +1956,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correctness == "incorrect"
def test_function_code_multiple_inputs(self):
"""Verify multiple-input function grading with individual messages."""
# If the <customresponse> has multiple inputs associated with it,
# the check function can return a dict of the form:
@@ -1915,6 +2013,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correct_map.get_msg("1_2_4") == "Feedback 4"
def test_function_code_multiple_inputs_decimal_score(self):
"""Check multiple-input function grading with decimal scores."""
# If the <customresponse> has multiple inputs associated with it,
# the check function can return a dict of the form:
@@ -1966,6 +2065,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correct_map.get_npoints("1_2_4") == 0.7
def test_function_code_with_extra_args(self):
"""Test function code receiving extra arguments from the problem context."""
script = textwrap.dedent(
"""\
def check_func(expect, answer_given, options, dynamath):
@@ -2016,6 +2116,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert msg == "Message text"
def test_function_code_with_attempt_number(self):
"""Verify that the function code can access and use the attempt number."""
script = textwrap.dedent(
"""\
def gradeit(expect, ans, **kwargs):
@@ -2053,6 +2154,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert msg == "This is attempt number 2"
def test_multiple_inputs_return_one_status(self):
"""Ensure multiple inputs receive the same status when function returns single dict."""
# When given multiple inputs, the 'answer_given' argument
# to the check_func() is a list of inputs
#
@@ -2111,6 +2213,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correct_map.get_overall_message() == "Message text"
def test_script_exception_function(self):
"""Check that exceptions in function scripts raise a ResponseError."""
# Construct a script that will raise an exception
script = textwrap.dedent(
@@ -2127,6 +2230,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
problem.grade_answers({"1_2_1": "42"})
def test_script_exception_inline(self):
"""Check that exceptions in inline scripts raise a ResponseError."""
# Construct a script that will raise an exception
script = 'raise Exception("Test")'
@@ -2137,6 +2241,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
problem.grade_answers({"1_2_1": "42"})
def test_invalid_dict_exception(self):
"""Verify that returning an invalid dictionary format raises ResponseError."""
# Construct a script that passes back an invalid dict format
script = textwrap.dedent(
@@ -2153,26 +2258,20 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
problem.grade_answers({"1_2_1": "42"})
def test_setup_randomization(self):
"""Ensure problem setup script receives the problem's random seed."""
# Ensure that the problem setup script gets the random seed from the problem.
script = textwrap.dedent(
"""
num = {code}
""".format(
code=self._get_random_number_code()
)
)
script = textwrap.dedent(f"num = {self._get_random_number_code()}")
problem = self.build_problem(script=script)
assert problem.context["num"] == self._get_random_number_result(problem.seed)
def test_check_function_randomization(self):
"""Verify that the check function is seeded with the problem's random seed."""
# The check function should get random-seeded from the problem.
script = textwrap.dedent(
"""
f"""
def check_func(expect, answer_given):
return {{'ok': True, 'msg': {code} }}
""".format(
code=self._get_random_number_code()
)
return {{'ok': True, 'msg': {self._get_random_number_code()} }}
"""
)
problem = self.build_problem(script=script, cfn="check_func", expect="42")
@@ -2182,6 +2281,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert msg == self._get_random_number_result(problem.seed)
def test_random_isnt_none(self):
"""Test that random seeding works correctly and does not return None."""
# Bug LMS-500 says random.seed(10) fails with:
# File "<string>", line 61, in <module>
# File "/usr/lib/python2.7/random.py", line 116, in seed
@@ -2224,10 +2324,9 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
# If the name is not defined, then the script
# will raise an exception
script = textwrap.dedent(
"""
f"""
correct[0] = 'correct'
assert('%s' in globals())"""
% module_name
assert('{module_name}' in globals())"""
)
# Create the problem
@@ -2239,7 +2338,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
problem.grade_answers({"1_2_1": "42"})
except ResponseError:
self.fail("Could not use name '{0}s' in custom response".format(module_name))
self.fail(f"Could not use name '{module_name}s' in custom response")
def test_module_imports_function(self):
"""
@@ -2264,11 +2363,10 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
# If the name is not defined, then the script
# will raise an exception
script = textwrap.dedent(
"""
f"""
def check_func(expect, answer_given):
assert('%s' in globals())
assert('{module_name}' in globals())
return True"""
% module_name
)
# Create the problem
@@ -2280,24 +2378,24 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
problem.grade_answers({"1_2_1": "42"})
except ResponseError:
self.fail("Could not use name '{0}s' in custom response".format(module_name))
self.fail(f"Could not use name '{module_name}s' in custom response")
def test_python_lib_zip_is_available(self):
"""Test importing Python code from a zipfile in custom response scripts."""
# Prove that we can import code from a zipfile passed down to us.
# Make a zipfile with one module in it with one function.
zipstring = io.BytesIO()
zipf = zipfile.ZipFile(zipstring, "w") # lint-amnesty, pylint: disable=consider-using-with
zipf.writestr(
"my_helper.py",
textwrap.dedent(
"""\
def seventeen():
return 17
"""
),
)
zipf.close()
with zipfile.ZipFile(zipstring, "w") as zipf:
zipf.writestr(
"my_helper.py",
textwrap.dedent(
"""\
def seventeen():
return 17
"""
),
)
# Use that module in our Python script.
script = textwrap.dedent(
@@ -2307,13 +2405,12 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
"""
)
capa_system = mock_capa_system()
capa_system.get_python_lib_zip = (
lambda: zipstring.getvalue() # lint-amnesty, pylint: disable=unnecessary-lambda
)
capa_system.get_python_lib_zip = zipstring.getvalue
problem = self.build_problem(script=script, capa_system=capa_system)
assert problem.context["num"] == 17
def test_function_code_multiple_inputs_order(self):
"""Verify that multiple-input grading respects the input order for subproblems."""
# Ensure that order must be correct according to sub-problem position
script = textwrap.dedent(
"""
@@ -2393,7 +2490,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correct_map.get_msg("1_2_11") == "11"
@use_unsafe_codejail()
@UseUnsafeCodejail()
class SchematicResponseTest(ResponseTest):
"""
Class containing setup and tests for Schematic responsetype.
@@ -2402,6 +2499,7 @@ class SchematicResponseTest(ResponseTest):
xml_factory_class = SchematicResponseXMLFactory
def test_grade(self):
"""Test that SchematicResponse grades answers correctly using a script."""
# Most of the schematic-specific work is handled elsewhere
# (in client-side JavaScript)
# The <schematicresponse> is responsible only for executing the
@@ -2426,10 +2524,9 @@ class SchematicResponseTest(ResponseTest):
assert correct_map.get_correctness("1_2_1") == "correct"
def test_check_function_randomization(self):
"""Test that the check function correctly uses randomization from problem seed."""
# The check function should get a random seed from the problem.
script = "correct = ['correct' if (submission[0]['num'] == {code}) else 'incorrect']".format(
code=self._get_random_number_code()
) # lint-amnesty, pylint: disable=line-too-long
script = f"correct = ['correct' if (submission[0]['num'] == {self._get_random_number_code()}) else 'incorrect']"
problem = self.build_problem(answer=script)
submission_dict = {"num": self._get_random_number_result(problem.seed)}
@@ -2439,6 +2536,7 @@ class SchematicResponseTest(ResponseTest):
assert correct_map.get_correctness("1_2_1") == "correct"
def test_script_exception(self):
"""Test that exceptions in schematic scripts are properly raised."""
# Construct a script that will raise an exception
script = "raise Exception('test')"
problem = self.build_problem(answer=script)
@@ -2450,15 +2548,20 @@ class SchematicResponseTest(ResponseTest):
problem.grade_answers(input_dict)
class AnnotationResponseTest(ResponseTest): # lint-amnesty, pylint: disable=missing-class-docstring
class AnnotationResponseTest(ResponseTest):
"""Unit tests for grading logic of AnnotationResponse"""
xml_factory_class = AnnotationResponseXMLFactory
def test_grade(self):
def test_grade(self): # pylint: disable=too-many-locals
"""Test grading of AnnotationResponse with correct, partial, and incorrect options."""
(correct, partially, incorrect) = ("correct", "partially-correct", "incorrect")
answer_id = "1_2_1"
options = (("x", correct), ("y", partially), ("z", incorrect))
make_answer = lambda option_ids: {answer_id: json.dumps({"options": option_ids})}
def make_answer(option_ids):
return {answer_id: json.dumps({"options": option_ids})}
tests = [
{"correctness": correct, "points": 2, "answers": make_answer([0])},
@@ -2481,14 +2584,11 @@ class AnnotationResponseTest(ResponseTest): # lint-amnesty, pylint: disable=mis
actual_correctness = correct_map.get_correctness(answer_id)
actual_points = correct_map.get_npoints(answer_id)
assert expected_correctness == actual_correctness, "%s should be marked %s" % (
answer_id,
expected_correctness,
)
assert expected_points == actual_points, "%s should have %d points" % (answer_id, expected_points)
assert expected_correctness == actual_correctness, f"{answer_id} should be marked {expected_correctness}"
assert expected_points == actual_points, f"{answer_id} should have {expected_points} points"
@use_unsafe_codejail()
@UseUnsafeCodejail()
class ChoiceTextResponseTest(ResponseTest):
"""
Class containing setup and tests for ChoiceText responsetype.
@@ -2615,8 +2715,8 @@ class ChoiceTextResponseTest(ResponseTest):
if choice:
# Radio/Checkbox inputs in choicetext problems follow
# a naming convention that gives them names ending with "bc"
choice_id = "1_2_1_choiceinput_{index}bc".format(index=index)
choice_value = "choiceinput_{index}".format(index=index)
choice_id = f"1_2_1_choiceinput_{index}bc"
choice_value = f"choiceinput_{index}"
answer_dict[choice_id] = choice_value
# Build the names for the numtolerance_inputs and add their answers
# to `answer_dict`.
@@ -2624,7 +2724,7 @@ class ChoiceTextResponseTest(ResponseTest):
# In `answer_id` `index` represents the ordinality of the
# choice and `ind` represents the ordinality of the
# numtolerance_input inside the parent choice.
answer_id = "1_2_1_choiceinput_{index}_numtolerance_input_{ind}".format(index=index, ind=ind)
answer_id = f"1_2_1_choiceinput_{index}_numtolerance_input_{ind}"
answer_dict[answer_id] = answer
return answer_dict
@@ -2671,6 +2771,7 @@ class ChoiceTextResponseTest(ResponseTest):
)
def test_staff_answer_error(self):
"""Test that invalid staff answers raise a StudentInputError."""
broken_problem = self._make_problem(
[("true", {"answer": "Platypus", "tolerance": "0"}), ("true", {"answer": "edX", "tolerance": "0"})],
"checkboxtextgroup",
@@ -2697,7 +2798,7 @@ class ChoiceTextResponseTest(ResponseTest):
# Build the actual problem for the test.
test_problem = self._make_problem(test_choices, "radiotextgroup", test_script)
# Make sure the actual grade matches the expected grade.
self.assert_grade(test_problem, submission, correctness, msg="{0} should be {1}".format(name, correctness))
self.assert_grade(test_problem, submission, correctness, msg=f"{name} should be {correctness}")
def test_checkbox_grades(self):
"""
@@ -2744,4 +2845,4 @@ class ChoiceTextResponseTest(ResponseTest):
problem = problems[problem_name]
# Make sure the actual grade matches the expected grade
self.assert_grade(problem, submission, correctness, msg="{0} should be {1}".format(name, correctness))
self.assert_grade(problem, submission, correctness, msg=f"{name} should be {correctness}")

View File

@@ -1,29 +1,27 @@
"""
Test for xmodule.capa.score_render module
"""
import json
from unittest.mock import MagicMock, patch
from django.http import Http404
from opaque_keys.edx.keys import CourseKey, UsageKey
from common.djangoapps.student.models import AnonymousUserId
from common.djangoapps.student.tests.factories import UserFactory
from lms.djangoapps.grades.signals.handlers import handle_external_grader_score
from xmodule.capa.score_render import get_block_for_descriptor_without_access_check, load_xblock_for_external_grader
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
from xmodule.capa.score_render import (
load_xblock_for_external_grader,
get_block_for_descriptor_without_access_check
)
from opaque_keys.edx.keys import CourseKey, UsageKey
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory
class ScoreEvent:
class ScoreEvent: # pylint: disable=too-few-public-methods
"""
Mock class to represent an external grader score event.
"""
def __init__(
def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments
self,
score_msg=None,
course_id=None,
@@ -31,7 +29,7 @@ class ScoreEvent:
module_id=None,
submission_id=None,
queue_key=None,
queue_name=None
queue_name=None,
):
self.score_msg = score_msg
self.course_id = course_id
@@ -54,21 +52,15 @@ class TestScoreRender(ModuleStoreTestCase):
super().setUp()
self.course = CourseFactory.create()
self.user = UserFactory.create()
self.problem = BlockFactory.create(
category='problem',
parent=self.course,
display_name='Test Problem'
)
self.anonymous_user_id = '12345'
self.problem = BlockFactory.create(category="problem", parent=self.course, display_name="Test Problem")
self.anonymous_user_id = "12345"
# Create AnonymousUserId instance
AnonymousUserId.objects.create(
user=self.user,
anonymous_user_id=self.anonymous_user_id,
course_id=self.course.id
user=self.user, anonymous_user_id=self.anonymous_user_id, course_id=self.course.id
)
@patch('xmodule.capa.score_render.modulestore')
@patch('xmodule.capa.score_render.FieldDataCache')
@patch("xmodule.capa.score_render.modulestore")
@patch("xmodule.capa.score_render.FieldDataCache")
def test_load_xblock_for_external_grader(self, mock_field_data_cache, mock_modulestore):
"""
Test loading an XBlock for external grading.
@@ -78,15 +70,12 @@ class TestScoreRender(ModuleStoreTestCase):
mock_modulestore.return_value.get_item.return_value = MagicMock()
mock_field_data_cache.cache_for_block_descendents.return_value = MagicMock()
with patch('xmodule.capa.score_render.get_block_for_descriptor_without_access_check') as mock_get_block:
with patch("xmodule.capa.score_render.get_block_for_descriptor_without_access_check") as mock_get_block:
mock_get_block.return_value = MagicMock()
# Call the function
result = load_xblock_for_external_grader(
self.anonymous_user_id,
str(self.course.id),
str(self.problem.location),
self.course
self.anonymous_user_id, str(self.course.id), str(self.problem.location), self.course
)
# Assertions
@@ -95,8 +84,8 @@ class TestScoreRender(ModuleStoreTestCase):
mock_field_data_cache.cache_for_block_descendents.assert_called_once()
mock_get_block.assert_called_once()
@patch('xmodule.capa.score_render.modulestore')
@patch('xmodule.capa.score_render.AnonymousUserId.objects.get')
@patch("xmodule.capa.score_render.modulestore")
@patch("xmodule.capa.score_render.AnonymousUserId.objects.get")
def test_load_xblock_for_external_grader_missing_block(self, mock_anon_user, mock_modulestore):
"""
Test that Http404 is raised when the block is not found.
@@ -109,13 +98,10 @@ class TestScoreRender(ModuleStoreTestCase):
# Test that Http404 is raised
with self.assertRaises(Http404):
load_xblock_for_external_grader(
self.anonymous_user_id,
str(self.course.id),
str(self.problem.location),
self.course
self.anonymous_user_id, str(self.course.id), str(self.problem.location), self.course
)
@patch('xmodule.capa.score_render.prepare_runtime_for_user')
@patch("xmodule.capa.score_render.prepare_runtime_for_user")
def test_get_block_for_descriptor_without_access_check(self, mock_prepare_runtime):
"""
Test initializing an XBlock instance without access checks.
@@ -127,11 +113,7 @@ class TestScoreRender(ModuleStoreTestCase):
# Call the function
result = get_block_for_descriptor_without_access_check(
self.user,
block,
student_data,
self.course.id,
self.course
self.user, block, student_data, self.course.id, self.course
)
# Assertions
@@ -139,8 +121,8 @@ class TestScoreRender(ModuleStoreTestCase):
mock_prepare_runtime.assert_called_once()
block.bind_for_student.assert_called_once()
@patch('xmodule.capa.score_render.modulestore')
@patch('xmodule.capa.score_render.load_xblock_for_external_grader')
@patch("xmodule.capa.score_render.modulestore")
@patch("xmodule.capa.score_render.load_xblock_for_external_grader")
def test_handle_external_grader_score_json_string(self, mock_load_xblock, mock_modulestore):
"""
Test handling an external grader score with a JSON string message.
@@ -156,9 +138,9 @@ class TestScoreRender(ModuleStoreTestCase):
course_id=str(self.course.id),
user_id=self.anonymous_user_id,
module_id=str(self.problem.location),
submission_id='sub_123',
queue_key='key_456',
queue_name='test_queue'
submission_id="sub_123",
queue_key="key_456",
queue_name="test_queue",
)
# Call the handler
@@ -174,18 +156,18 @@ class TestScoreRender(ModuleStoreTestCase):
self.assertIsInstance(call_args[2], UsageKey)
self.assertEqual(str(call_args[2]), score.module_id)
self.assertIn('course', call_kwargs)
self.assertIn("course", call_kwargs)
mock_instance.handle_ajax.assert_called_once()
ajax_args, _ = mock_instance.handle_ajax.call_args
self.assertEqual(ajax_args[0], 'score_update')
self.assertIn('xqueue_header', ajax_args[1])
self.assertIn('xqueue_body', ajax_args[1])
self.assertIn('queuekey', ajax_args[1])
self.assertEqual(ajax_args[0], "score_update")
self.assertIn("xqueue_header", ajax_args[1])
self.assertIn("xqueue_body", ajax_args[1])
self.assertIn("queuekey", ajax_args[1])
mock_instance.save.assert_called_once()
@patch('xmodule.capa.score_render.modulestore')
@patch('xmodule.capa.score_render.load_xblock_for_external_grader')
@patch("xmodule.capa.score_render.modulestore")
@patch("xmodule.capa.score_render.load_xblock_for_external_grader")
def test_handle_external_grader_score_plain_text(self, mock_load_xblock, mock_modulestore):
"""
Test handling an external grader score with a plain text message.
@@ -202,9 +184,9 @@ class TestScoreRender(ModuleStoreTestCase):
course_id=str(self.course.id),
user_id=self.anonymous_user_id,
module_id=str(self.problem.location),
submission_id='sub_123',
queue_key='key_456',
queue_name='test_queue'
submission_id="sub_123",
queue_key="key_456",
queue_name="test_queue",
)
# json.loads must fail BEFORE anything else runs
@@ -218,8 +200,8 @@ class TestScoreRender(ModuleStoreTestCase):
mock_instance.save.assert_not_called()
@patch('xmodule.capa.score_render.modulestore')
@patch('xmodule.capa.score_render.load_xblock_for_external_grader')
@patch("xmodule.capa.score_render.modulestore")
@patch("xmodule.capa.score_render.load_xblock_for_external_grader")
def test_handle_external_grader_score_exception(self, mock_load_xblock, mock_modulestore):
"""
Test handling an exception during score processing.
@@ -234,21 +216,22 @@ class TestScoreRender(ModuleStoreTestCase):
course_id=str(self.course.id),
user_id=self.anonymous_user_id,
module_id=str(self.problem.location),
submission_id='sub_123',
queue_key='key_456',
queue_name='test_queue'
submission_id="sub_123",
queue_key="key_456",
queue_name="test_queue",
)
# Call the handler and expect exception to be raised
with self.assertRaises(Exception):
handle_external_grader_score(None, None, score)
@patch('xmodule.capa.score_render.AnonymousUserId.objects.get')
@patch('xmodule.capa.score_render.modulestore')
@patch('xmodule.capa.score_render.FieldDataCache')
@patch('xmodule.capa.score_render.get_block_for_descriptor_without_access_check')
def test_load_xblock_for_external_grader_none_instance(self, mock_get_block, mock_field_data_cache,
mock_modulestore, mock_anon_user):
@patch("xmodule.capa.score_render.AnonymousUserId.objects.get")
@patch("xmodule.capa.score_render.modulestore")
@patch("xmodule.capa.score_render.FieldDataCache")
@patch("xmodule.capa.score_render.get_block_for_descriptor_without_access_check")
def test_load_xblock_for_external_grader_none_instance(
self, mock_get_block, mock_field_data_cache, mock_modulestore, mock_anon_user
):
"""
Test that Http404 is raised when get_block_for_descriptor_without_access_check returns None.
"""
@@ -262,11 +245,7 @@ class TestScoreRender(ModuleStoreTestCase):
# Test that Http404 is raised
with self.assertRaises(Http404) as context:
load_xblock_for_external_grader(
self.anonymous_user_id,
str(self.course.id),
str(self.problem.location)
)
load_xblock_for_external_grader(self.anonymous_user_id, str(self.course.id), str(self.problem.location))
expected_msg = f"Could not bind XBlock instance for usage key: {str(self.problem.location)}"
self.assertEqual(str(context.exception), expected_msg)
@@ -290,26 +269,20 @@ class TestScoreRenderIntegration(ModuleStoreTestCase):
super().setUp()
self.course = CourseFactory.create()
self.user = UserFactory.create()
self.problem = BlockFactory.create(
category='problem',
parent=self.course,
display_name='Test Problem'
)
self.anonymous_user_id = '12345'
self.problem = BlockFactory.create(category="problem", parent=self.course, display_name="Test Problem")
self.anonymous_user_id = "12345"
# Create AnonymousUserId instance
AnonymousUserId.objects.create(
user=self.user,
anonymous_user_id=self.anonymous_user_id,
course_id=self.course.id
user=self.user, anonymous_user_id=self.anonymous_user_id, course_id=self.course.id
)
@patch('xmodule.capa.score_render.modulestore')
def test_end_to_end_grading_flow(self, mock_modulestore):
@patch("xmodule.capa.score_render.modulestore")
def test_end_to_end_grading_flow(self, mock_modulestore): # pylint: disable=unused-argument
"""
Test the end-to-end flow from receiving a score event to updating the grade.
"""
# Mock the internal call to load_xblock_for_external_grader
with patch('xmodule.capa.score_render.load_xblock_for_external_grader') as mock_load_xblock:
with patch("xmodule.capa.score_render.load_xblock_for_external_grader") as mock_load_xblock:
# Setup the mock XBlock instance
mock_instance = MagicMock()
mock_load_xblock.return_value = mock_instance
@@ -320,9 +293,9 @@ class TestScoreRenderIntegration(ModuleStoreTestCase):
course_id=str(self.course.id),
user_id=self.anonymous_user_id,
module_id=str(self.problem.location),
submission_id='sub_123',
queue_key='key_456',
queue_name='test_queue'
submission_id="sub_123",
queue_key="key_456",
queue_name="test_queue",
)
# Call the handler
@@ -335,19 +308,19 @@ class TestScoreRenderIntegration(ModuleStoreTestCase):
# Verify the data structure passed to handle_ajax
handle_ajax_args = mock_instance.handle_ajax.call_args[0]
self.assertEqual(handle_ajax_args[0], 'score_update')
self.assertEqual(handle_ajax_args[0], "score_update")
data = handle_ajax_args[1]
self.assertIn('xqueue_header', data)
self.assertIn('xqueue_body', data)
self.assertIn('queuekey', data)
self.assertIn("xqueue_header", data)
self.assertIn("xqueue_body", data)
self.assertIn("queuekey", data)
header = json.loads(data['xqueue_header'])
self.assertEqual(header['lms_key'], 'sub_123')
self.assertEqual(header['queue_name'], 'test_queue')
header = json.loads(data["xqueue_header"])
self.assertEqual(header["lms_key"], "sub_123")
self.assertEqual(header["queue_name"], "test_queue")
# Verify the body is the correct JSON
body = json.loads(data['xqueue_body'])
self.assertEqual(body['score'], 1)
self.assertEqual(body['max_score'], 1)
self.assertTrue(body['correct'])
body = json.loads(data["xqueue_body"])
self.assertEqual(body["score"], 1)
self.assertEqual(body["max_score"], 1)
self.assertTrue(body["correct"])

View File

@@ -11,10 +11,11 @@ class CapaShuffleTest(unittest.TestCase):
"""Capa problem tests for shuffling and choice-name masking."""
def setUp(self):
super(CapaShuffleTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
self.system = mock_capa_system()
def test_shuffle_4_choices(self):
"""Verify shuffling and name-masking for four choices."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -41,6 +42,7 @@ class CapaShuffleTest(unittest.TestCase):
assert the_html == problem.get_html(), "should be able to call get_html() twice"
def test_shuffle_custom_names(self):
"""Verify shuffling preserves custom choice names."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -64,6 +66,7 @@ class CapaShuffleTest(unittest.TestCase):
assert response.unmask_order() == ["choice_0", "choice_aaa", "choice_1", "choice_ddd"]
def test_shuffle_different_seed(self):
"""Check that shuffling produces different order with a different seed."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -83,6 +86,7 @@ class CapaShuffleTest(unittest.TestCase):
self.assertRegex(the_html, r"<div>.*\[.*'Donut'.*'Apple'.*'Banana'.*'Chocolate'.*\].*</div>")
def test_shuffle_1_choice(self):
"""Verify shuffling behavior with only one choice."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -103,6 +107,7 @@ class CapaShuffleTest(unittest.TestCase):
assert response.unmask_order() == ["choice_0"]
def test_shuffle_6_choices(self):
"""Test shuffling with six choices to ensure proper randomization."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -124,9 +129,10 @@ class CapaShuffleTest(unittest.TestCase):
the_html = problem.get_html()
self.assertRegex(
the_html, r"<div>.*\[.*'Chocolate'.*'Eggplant'.*'Apple'.*'Banana'.*'Zonut'.*'Filet Mignon'.*\].*</div>"
) # lint-amnesty, pylint: disable=line-too-long
)
def test_shuffle_false(self):
"""Verify that shuffle='false' keeps original order."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -149,6 +155,7 @@ class CapaShuffleTest(unittest.TestCase):
assert not response.has_shuffle()
def test_shuffle_fixed_head_end(self):
"""Ensure choices fixed at the head remain in place during shuffle."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -171,6 +178,7 @@ class CapaShuffleTest(unittest.TestCase):
self.assertRegex(the_html, r"<div>.*\[.*'Alpha'.*'Beta'.*'B'.*'A'.*'C'.*'D'.*\].*</div>")
def test_shuffle_fixed_tail_end(self):
"""Ensure choices fixed at the tail remain in place during shuffle."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -193,6 +201,7 @@ class CapaShuffleTest(unittest.TestCase):
self.assertRegex(the_html, r"<div>.*\[.*'B'.*'A'.*'C'.*'D'.*'Alpha'.*'Beta'.*\].*</div>")
def test_shuffle_fixed_both_ends(self):
"""Ensure choices fixed at both ends remain in place during shuffle."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -217,6 +226,7 @@ class CapaShuffleTest(unittest.TestCase):
self.assertRegex(the_html, r"<div>.*\[.*'Alpha'.*'Beta'.*'B'.*'A'.*'C'.*'D'.*'Psi'.*'Omega'.*\].*</div>")
def test_shuffle_fixed_both_ends_thin(self):
"""Test shuffling with only three choices, two of which are fixed."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -235,6 +245,7 @@ class CapaShuffleTest(unittest.TestCase):
self.assertRegex(the_html, r"<div>.*\[.*'Alpha'.*'A'.*'Omega'.*\].*</div>")
def test_shuffle_fixed_all(self):
"""Verify that all fixed choices remain in order with no shuffle."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -274,6 +285,7 @@ class CapaShuffleTest(unittest.TestCase):
self.assertRegex(the_html, r"<div>.*\[.*'A'.*'Mid'.*'Mid'.*'C'.*'D'.*\].*</div>")
def test_multiple_shuffle_responses(self):
"""Check shuffling for multiple choice groups in the same problem."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -301,7 +313,6 @@ class CapaShuffleTest(unittest.TestCase):
orig_html = problem.get_html()
assert orig_html == problem.get_html(), "should be able to call get_html() twice"
html = orig_html.replace("\n", " ") # avoid headaches with .* matching
print(html)
self.assertRegex(
html,
r"<div>.*\[.*'Banana'.*'Apple'.*'Chocolate'.*'Donut'.*\].*</div>.*"

View File

@@ -15,10 +15,11 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
"""
def setUp(self):
super(CapaTargetedFeedbackTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
self.system = mock_capa_system()
def test_no_targeted_feedback(self):
"""Verify that no targeted feedback is shown when not finished."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -84,6 +85,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
self.assertRegex(without_new_lines, r"feedback1|feedback2|feedback3|feedbackC")
def test_targeted_feedback_not_finished(self):
"""Check HTML output when targeted feedback is incomplete."""
problem = new_loncapa_problem(load_fixture("targeted_feedback.xml"))
the_html = problem.get_html()
without_new_lines = the_html.replace("\n", "")
@@ -93,16 +95,20 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
assert the_html == problem.get_html(), "Should be able to call get_html() twice"
def test_targeted_feedback_student_answer1(self):
"""Test targeted feedback rendering for a specific wrong student answer."""
problem = new_loncapa_problem(load_fixture("targeted_feedback.xml"))
problem.done = True
problem.student_answers = {"1_2_1": "choice_3"}
the_html = problem.get_html()
without_new_lines = the_html.replace("\\n", "").replace("\n", "")
# pylint: disable=line-too-long
self.assertRegex(
without_new_lines,
r"<targetedfeedback explanation-id=\"feedback3\" role=\"group\" aria-describedby=\"1_2_1-legend\">\s*<span class=\"sr\">Incorrect</span>.*3rd WRONG solution",
(
r"<targetedfeedback explanation-id=\"feedback3\" role=\"group\" "
r"aria-describedby=\"1_2_1-legend\">\s*"
r"<span class=\"sr\">Incorrect</span>.*3rd WRONG solution"
),
)
self.assertNotRegex(without_new_lines, r"feedback1|feedback2|feedbackC")
# Check that calling it multiple times yields the same thing
@@ -110,16 +116,20 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
assert the_html == the_html2
def test_targeted_feedback_student_answer2(self):
"""Test targeted feedback rendering for another wrong student answer."""
problem = new_loncapa_problem(load_fixture("targeted_feedback.xml"))
problem.done = True
problem.student_answers = {"1_2_1": "choice_0"}
the_html = problem.get_html()
without_new_lines = the_html.replace("\\n", "").replace("\n", "")
# pylint: disable=line-too-long
self.assertRegex(
without_new_lines,
r"<targetedfeedback explanation-id=\"feedback1\" role=\"group\" aria-describedby=\"1_2_1-legend\">\s*<span class=\"sr\">Incorrect</span>.*1st WRONG solution",
(
r"<targetedfeedback explanation-id=\"feedback1\" role=\"group\" "
r"aria-describedby=\"1_2_1-legend\">\s*"
r"<span class=\"sr\">Incorrect</span>.*1st WRONG solution"
),
)
self.assertRegex(without_new_lines, r"<div>\{.*'1_solution_1'.*\}</div>")
self.assertNotRegex(without_new_lines, r"feedback2|feedback3|feedbackC")
@@ -132,10 +142,13 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
the_html = problem.get_html()
without_new_lines = the_html.replace("\\n", "").replace("\n", "")
# pylint: disable=line-too-long
self.assertRegex(
without_new_lines,
r"<targetedfeedback explanation-id=\"feedbackC\" role=\"group\" aria-describedby=\"1_2_1-legend\">\s*<span class=\"sr\">Correct</span>.*Feedback on your correct solution...",
(
r"<targetedfeedback explanation-id=\"feedbackC\" role=\"group\" "
r"aria-describedby=\"1_2_1-legend\">\s*"
r"<span class=\"sr\">Correct</span>.*Feedback on your correct solution..."
),
)
self.assertNotRegex(without_new_lines, r"feedback1|feedback2|feedback3")
@@ -211,6 +224,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
self.assertRegex(the_html, r"<targetedfeedbackset>\s*</targetedfeedbackset>")
def test_targeted_feedback_no_solution_element(self):
"""Check behavior when the solution element is missing."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -245,6 +259,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
self.assertRegex(without_new_lines, r"<div>.*<targetedfeedbackset>.*</targetedfeedbackset>\s*</div>")
def test_targeted_feedback_show_solution_explanation(self):
"""Verify that solution explanation is shown when configured to always show."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -307,10 +322,12 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
the_html = problem.get_html()
without_new_lines = the_html.replace("\n", "")
# pylint: disable=line-too-long
self.assertRegex(
without_new_lines,
r"<targetedfeedback explanation-id=\"feedback1\" role=\"group\" aria-describedby=\"1_2_1-legend\">.*1st WRONG solution",
(
r"<targetedfeedback explanation-id=\"feedback1\" role=\"group\" "
r"aria-describedby=\"1_2_1-legend\">.*1st WRONG solution"
),
)
self.assertRegex(without_new_lines, r"<targetedfeedback explanation-id=\"feedbackC\".*solution explanation")
self.assertNotRegex(without_new_lines, r"<div>\{.*'1_solution_1'.*\}</div>")
@@ -320,6 +337,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
assert the_html == the_html2
def test_targeted_feedback_no_show_solution_explanation(self):
"""Verify that solution explanation is hidden when not configured to show."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -382,16 +400,19 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
the_html = problem.get_html()
without_new_lines = the_html.replace("\n", "")
# pylint: disable=line-too-long
self.assertRegex(
without_new_lines,
r"<targetedfeedback explanation-id=\"feedback1\" role=\"group\" aria-describedby=\"1_2_1-legend\">.*1st WRONG solution",
(
r"<targetedfeedback explanation-id=\"feedback1\" role=\"group\" "
r"aria-describedby=\"1_2_1-legend\">.*1st WRONG solution"
),
)
self.assertNotRegex(without_new_lines, r"<targetedfeedback explanation-id=\"feedbackC\".*solution explanation")
self.assertRegex(without_new_lines, r"<div>\{.*'1_solution_1'.*\}</div>")
self.assertNotRegex(without_new_lines, r"feedback2|feedback3|feedbackC")
def test_targeted_feedback_with_solutionset_explanation(self):
"""Test targeted feedback when multiple correct solutions exist."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -464,10 +485,12 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
the_html = problem.get_html()
without_new_lines = the_html.replace("\n", "")
# pylint: disable=line-too-long
self.assertRegex(
without_new_lines,
r"<targetedfeedback explanation-id=\"feedback1\" role=\"group\" aria-describedby=\"1_2_1-legend\">.*1st WRONG solution",
(
r"<targetedfeedback explanation-id=\"feedback1\" role=\"group\" "
r"aria-describedby=\"1_2_1-legend\">.*1st WRONG solution"
),
)
self.assertRegex(
without_new_lines, r"<targetedfeedback explanation-id=\"feedbackC2\".*other solution explanation"
@@ -476,6 +499,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
self.assertNotRegex(without_new_lines, r"feedback2|feedback3")
def test_targeted_feedback_no_feedback_for_selected_choice1(self):
"""Check behavior when selected choice has no feedback but correct solution should show."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -541,6 +565,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
self.assertNotRegex(without_new_lines, r"feedback1|feedback3")
def test_targeted_feedback_no_feedback_for_selected_choice2(self):
"""Check behavior when selected choice has no feedback and no solution explanation is shown."""
xml_str = textwrap.dedent(
"""
<problem>
@@ -605,6 +630,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
self.assertNotRegex(without_new_lines, r"feedback1|feedback3|feedbackC")
def test_targeted_feedback_multiple_not_answered(self):
"""Ensure empty targeted feedback is rendered for unanswered multiple questions."""
# Not answered -> empty targeted feedback
problem = new_loncapa_problem(load_fixture("targeted_feedback_multiple.xml"))
the_html = problem.get_html()
@@ -616,6 +642,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
)
def test_targeted_feedback_multiple_answer_1(self):
"""Test feedback rendering for the first question answered in a multi-question problem."""
problem = new_loncapa_problem(load_fixture("targeted_feedback_multiple.xml"))
problem.done = True
problem.student_answers = {"1_2_1": "choice_0"} # feedback1
@@ -629,6 +656,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
)
def test_targeted_feedback_multiple_answer_2(self):
"""Test feedback rendering for multiple answered questions in a multi-question problem."""
problem = new_loncapa_problem(load_fixture("targeted_feedback_multiple.xml"))
problem.done = True
problem.student_answers = {"1_2_1": "choice_0", "1_3_1": "choice_2"} # Q1 wrong, Q2 correct

View File

@@ -26,10 +26,11 @@ class UtilTest(unittest.TestCase):
"""Tests for util"""
def setUp(self):
super(UtilTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
super().setUp()
self.system = mock_capa_system()
def test_compare_with_tolerance(self): # lint-amnesty, pylint: disable=too-many-statements
def test_compare_with_tolerance(self): # pylint: disable=too-many-statements
"""Test numeric comparison with relative and absolute tolerances."""
# Test default tolerance '0.001%' (it is relative)
result = compare_with_tolerance(100.0, 100.0)
assert result
@@ -67,7 +68,7 @@ class UtilTest(unittest.TestCase):
assert result
result = compare_with_tolerance(112.0, 100.0, 0.1, True)
assert not result
##### Infinite values #####
# Infinite values #
infinity = float("Inf")
# Test relative tolerance (float)
result = compare_with_tolerance(infinity, 100.0, 1.0, True)
@@ -127,11 +128,11 @@ class UtilTest(unittest.TestCase):
"""
allowed_tags = ["div", "p", "audio", "pre", "span"]
for tag in allowed_tags:
queue_msg = "<{0}>Test message</{0}>".format(tag)
queue_msg = f"<{tag}>Test message</{tag}>"
assert sanitize_html(queue_msg) == queue_msg
not_allowed_tag = "script"
queue_msg = "<{0}>Test message</{0}>".format(not_allowed_tag)
queue_msg = f"<{not_allowed_tag}>Test message</{not_allowed_tag}>"
expected = ""
assert sanitize_html(queue_msg) == expected
@@ -170,7 +171,7 @@ class UtilTest(unittest.TestCase):
assert expected_text == contextual_text
class use_unsafe_codejail(TestContextDecorator):
class UseUnsafeCodejail(TestContextDecorator):
"""
Tell codejail to run in unsafe mode for the scope of the decorator.
Use this as a decorator on Django TestCase classes or methods.
@@ -188,8 +189,10 @@ class use_unsafe_codejail(TestContextDecorator):
super().__init__()
def enable(self):
"""Enable unsafe mode for codejail within the test scope."""
self.old_be_unsafe = codejail.safe_exec.ALWAYS_BE_UNSAFE
codejail.safe_exec.ALWAYS_BE_UNSAFE = True
def disable(self):
"""Restore the previous codejail unsafe mode state."""
codejail.safe_exec.ALWAYS_BE_UNSAFE = self.old_be_unsafe

View File

@@ -40,7 +40,7 @@ class XQueueServiceTest(TestCase):
assert self.service.interface.session.auth.password == "agarwal"
@patch("xmodule.capa.xqueue_interface.use_edx_submissions_for_xqueue", return_value=True)
def test_construct_callback_with_flag_enabled(self, mock_flag):
def test_construct_callback_with_flag_enabled(self, mock_flag): # pylint: disable=unused-argument
"""Test construct_callback when the waffle flag is enabled."""
usage_id = self.block.scope_ids.usage_id
course_id = str(usage_id.course_key)
@@ -56,7 +56,7 @@ class XQueueServiceTest(TestCase):
assert self.service.construct_callback() == f"{custom_callback_url}/{callback_url}/score_update"
@patch("xmodule.capa.xqueue_interface.use_edx_submissions_for_xqueue", return_value=False)
def test_construct_callback_with_flag_disabled(self, mock_flag):
def test_construct_callback_with_flag_disabled(self, mock_flag): # pylint: disable=unused-argument
"""Test construct_callback when the waffle flag is disabled."""
usage_id = self.block.scope_ids.usage_id
callback_url = f"courses/{usage_id.context_key}/xqueue/user1/{usage_id}"
@@ -83,28 +83,32 @@ class XQueueServiceTest(TestCase):
@pytest.mark.django_db
@patch("xmodule.capa.xqueue_interface.use_edx_submissions_for_xqueue", return_value=True)
@patch("xmodule.capa.xqueue_submission.XQueueInterfaceSubmission.send_to_submission")
def test_send_to_queue_with_flag_enabled(mock_send_to_submission, mock_flag):
def test_send_to_queue_with_flag_enabled(mock_send_to_submission, mock_flag): # pylint: disable=unused-argument
"""Test send_to_queue when the waffle flag is enabled."""
url = "http://example.com/xqueue"
django_auth = {"username": "user", "password": "pass"}
block = Mock() # Mock block for the constructor
xqueue_interface = XQueueInterface(url, django_auth, block=block)
header = json.dumps({
"lms_callback_url": (
"http://example.com/courses/course-v1:test_org+test_course+test_run/"
"xqueue/block@item_id/type@problem"
),
"lms_key": "default"
})
body = json.dumps({
"student_info": json.dumps({"anonymous_student_id": "student_id"}),
"student_response": "student_answer",
})
header = json.dumps(
{
"lms_callback_url": (
"http://example.com/courses/course-v1:test_org+test_course+test_run/"
"xqueue/block@item_id/type@problem"
),
"lms_key": "default",
}
)
body = json.dumps(
{
"student_info": json.dumps({"anonymous_student_id": "student_id"}),
"student_response": "student_answer",
}
)
files_to_upload = None
mock_send_to_submission.return_value = {"submission": "mock_submission"}
error, msg = xqueue_interface.send_to_queue(header, body, files_to_upload)
error, msg = xqueue_interface.send_to_queue(header, body, files_to_upload) # pylint: disable=unused-variable
mock_send_to_submission.assert_called_once_with(header, body, "default", {})
@@ -112,28 +116,32 @@ def test_send_to_queue_with_flag_enabled(mock_send_to_submission, mock_flag):
@pytest.mark.django_db
@patch("xmodule.capa.xqueue_interface.use_edx_submissions_for_xqueue", return_value=False)
@patch("xmodule.capa.xqueue_interface.XQueueInterface._http_post")
def test_send_to_queue_with_flag_disabled(mock_http_post, mock_flag):
def test_send_to_queue_with_flag_disabled(mock_http_post, mock_flag): # pylint: disable=unused-argument
"""Test send_to_queue when the waffle flag is disabled."""
url = "http://example.com/xqueue"
django_auth = {"username": "user", "password": "pass"}
block = Mock() # Mock block for the constructor
xqueue_interface = XQueueInterface(url, django_auth, block=block)
header = json.dumps({
"lms_callback_url": (
"http://example.com/courses/course-v1:test_org+test_course+test_run/"
"xqueue/block@item_id/type@problem"
),
"lms_key": "default"
})
body = json.dumps({
"student_info": json.dumps({"anonymous_student_id": "student_id"}),
"student_response": "student_answer",
})
header = json.dumps(
{
"lms_callback_url": (
"http://example.com/courses/course-v1:test_org+test_course+test_run/"
"xqueue/block@item_id/type@problem"
),
"lms_key": "default",
}
)
body = json.dumps(
{
"student_info": json.dumps({"anonymous_student_id": "student_id"}),
"student_response": "student_answer",
}
)
files_to_upload = None
mock_http_post.return_value = (0, "Submission sent successfully")
error, msg = xqueue_interface.send_to_queue(header, body, files_to_upload)
error, msg = xqueue_interface.send_to_queue(header, body, files_to_upload) # pylint: disable=unused-variable
mock_http_post.assert_called_once_with(
"http://example.com/xqueue/xqueue/submit/",

View File

@@ -23,7 +23,7 @@ def xqueue_service():
return XQueueInterfaceSubmission(block)
def test_get_submission_params(xqueue_service):
def test_get_submission_params(xqueue_service): # pylint: disable=redefined-outer-name
"""
Test extracting item data from an xqueue submission.
"""
@@ -54,7 +54,7 @@ def test_get_submission_params(xqueue_service):
@pytest.mark.django_db
@patch("submissions.api.create_external_grader_detail")
def test_send_to_submission(mock_create_external_grader_detail, xqueue_service):
def test_send_to_submission(mock_create_external_grader_detail, xqueue_service): # pylint: disable=redefined-outer-name
"""
Test sending a submission to the grading system.
"""
@@ -87,10 +87,10 @@ def test_send_to_submission(mock_create_external_grader_detail, xqueue_service):
"course_id": "course-v1:test_org+test_course+test_run",
"student_id": "student_id",
},
'student_answer',
queue_name='default',
queue_key='default',
grader_file_name='test.py',
"student_answer",
queue_name="default",
queue_key="default",
grader_file_name="test.py",
points_possible=10,
files=None,
)
@@ -98,7 +98,9 @@ def test_send_to_submission(mock_create_external_grader_detail, xqueue_service):
@pytest.mark.django_db
@patch("submissions.api.create_external_grader_detail")
def test_send_to_submission_with_missing_fields(mock_create_external_grader_detail, xqueue_service):
def test_send_to_submission_with_missing_fields(
mock_create_external_grader_detail, xqueue_service
): # pylint: disable=redefined-outer-name
"""
Test send_to_submission with missing required fields.
"""

View File

@@ -16,11 +16,11 @@ from openedx.core.djangolib.markup import HTML
# -----------------------------------------------------------------------------
#
# Utility functions used in CAPA responsetypes
default_tolerance = "0.001%"
DEFAULT_TOLERANCE = "0.001%"
log = logging.getLogger(__name__)
def compare_with_tolerance(student_complex, instructor_complex, tolerance=default_tolerance, relative_tolerance=False):
def compare_with_tolerance(student_complex, instructor_complex, tolerance=DEFAULT_TOLERANCE, relative_tolerance=False):
"""
Compare student_complex to instructor_complex with maximum tolerance tolerance.
@@ -42,7 +42,7 @@ def compare_with_tolerance(student_complex, instructor_complex, tolerance=defaul
instructor_complex = 10, student_complex = 20, tolerance = '10%' will give
[8.0, 12.0].
This is typically used internally to compare float, with a
default_tolerance = '0.001%'.
DEFAULT_TOLERANCE = '0.001%'.
Default tolerance of 1e-3% is added to compare two floats for
near-equality (to handle machine representation errors).
@@ -56,7 +56,7 @@ def compare_with_tolerance(student_complex, instructor_complex, tolerance=defaul
Out[212]: 268435456.0
"""
if isinstance(tolerance, str):
if tolerance == default_tolerance:
if tolerance == DEFAULT_TOLERANCE:
relative_tolerance = True
if tolerance.endswith("%"):
tolerance = evaluator({}, {}, tolerance[:-1]) * 0.01
@@ -90,10 +90,9 @@ def compare_with_tolerance(student_complex, instructor_complex, tolerance=defaul
tolerance_decimal = Decimal(str(tolerance))
return abs(student_decimal - instructor_decimal) <= tolerance_decimal
else:
# v1 and v2 are, in general, complex numbers:
# there are some notes about backward compatibility issue: see responsetypes.get_staff_ans()).
return abs(student_complex - instructor_complex) <= tolerance
# v1 and v2 are, in general, complex numbers:
# there are some notes about backward compatibility issue: see responsetypes.get_staff_ans()).
return abs(student_complex - instructor_complex) <= tolerance
def contextualize_text(text, context): # private
@@ -144,6 +143,7 @@ def convert_files_to_filenames(answers):
def is_list_of_files(files):
"""Return True if the input is a list of valid files."""
return isinstance(files, list) and all(is_file(f) for f in files)
@@ -171,8 +171,8 @@ def find_with_default(node, path, default):
v = node.find(path)
if v is not None:
return v.text
else:
return default
return default
def sanitize_html(html_code):
@@ -204,7 +204,7 @@ def get_inner_html_from_xpath(xpath_node):
html = etree.tostring(xpath_node).strip().decode("utf-8")
# strips outer tag from html string
# xss-lint: disable=python-interpolate-html
inner_html = re.sub("(?ms)<%s[^>]*>(.*)</%s>" % (xpath_node.tag, xpath_node.tag), "\\1", html)
inner_html = re.sub(f"(?ms)<{xpath_node.tag}[^>]*>(.*)</{xpath_node.tag}>", "\\1", html)
return inner_html.strip()

View File

@@ -20,7 +20,7 @@ if TYPE_CHECKING:
from xmodule.capa_block import ProblemBlock
log = logging.getLogger(__name__)
dateformat = "%Y%m%d%H%M%S"
DATEFORMAT = "%Y%m%d%H%M%S"
XQUEUE_METRIC_NAME = "edxapp.xqueue"
@@ -99,7 +99,7 @@ def parse_xreply(xreply):
return (return_code, content)
class XQueueInterface:
class XQueueInterface: # pylint: disable=too-few-public-methods
"""Initializes the XQueue interface."""
def __init__(
@@ -143,7 +143,7 @@ class XQueueInterface:
# log the send to xqueue
header_info = json.loads(header)
queue_name = header_info.get("queue_name", "") # lint-amnesty, pylint: disable=unused-variable
queue_name = header_info.get("queue_name", "") # pylint: disable=unused-variable
# Attempt to send to queue
(error, msg) = self._send_to_queue(header, body, files_to_upload)
@@ -163,11 +163,13 @@ class XQueueInterface:
return error, msg
def _login(self): # lint-amnesty, pylint: disable=missing-function-docstring
def _login(self):
payload = {"username": self.auth["username"], "password": self.auth["password"]}
return self._http_post(self.url + "/xqueue/login/", payload)
def _send_to_queue(self, header, body, files_to_upload): # lint-amnesty, pylint: disable=missing-function-docstring
def _send_to_queue(self, header, body, files_to_upload):
"""Send the problem submission to XQueue, handling legacy fallback and edX submission logic."""
payload = {"xqueue_header": header, "xqueue_body": body}
files = {}
if files_to_upload is not None:
@@ -184,15 +186,19 @@ class XQueueInterface:
course_key = self.block.scope_ids.usage_id.context_key
header_info = json.loads(header)
queue_key = header_info['lms_key']
queue_key = header_info["lms_key"] # pylint: disable=unused-variable
if use_edx_submissions_for_xqueue(course_key):
submission = self.submission.send_to_submission(header, body, queue_key, files)
return None, ''
submission = self.submission.send_to_submission( # pylint: disable=unused-variable
header, body, queue_key, files
)
return None, ""
return self._http_post(self.url + "/xqueue/submit/", payload, files=files)
def _http_post(self, url, data, files=None): # lint-amnesty, pylint: disable=missing-function-docstring
def _http_post(self, url, data, files=None):
"""Send an HTTP POST request and handle connection errors, timeouts, and unexpected status codes."""
try:
response = self.session.post(url, data=data, files=files, timeout=(CONNECT_TIMEOUT, READ_TIMEOUT))
except requests.exceptions.ConnectionError as err:
@@ -204,7 +210,7 @@ class XQueueInterface:
return 1, "failed to read from the server"
if response.status_code not in [200]:
return 1, "unexpected HTTP status code [%d]" % response.status_code
return 1, f"unexpected HTTP status code [{response.status_code}]"
return parse_xreply(response.text)

View File

@@ -86,7 +86,7 @@ class XQueueInterfaceSubmission:
Submits the extracted student data to the edx-submissions system.
"""
try:
from submissions.api import create_external_grader_detail
from submissions.api import create_external_grader_detail # pylint: disable=import-outside-toplevel
student_item, answer, queue_name, grader_file_name, points_possible = self.get_submission_params(
header, body

View File

@@ -1,3 +1,4 @@
# pylint: disable=too-many-lines
"""
Implements the Problem XBlock, which is built on top of the CAPA subsystem.
"""
@@ -69,7 +70,7 @@ log = logging.getLogger("edx.courseware")
# Make '_' a no-op so we can scrape strings. Using lambda instead of
# `django.utils.translation.gettext_noop` because Django cannot be imported in this file
_ = lambda text: text
_ = lambda text: text # pylint: disable=unnecessary-lambda-assignment
# Generate this many different variants of problems with rerandomize=per_student
NUM_RANDOMIZATION_BINS = 20
@@ -83,7 +84,7 @@ except ImproperlyConfigured:
FEATURES = {}
class SHOWANSWER:
class SHOWANSWER: # pylint: disable=too-few-public-methods
"""
Constants for when to show answer
"""
@@ -102,7 +103,7 @@ class SHOWANSWER:
ATTEMPTED_NO_PAST_DUE = "attempted_no_past_due"
class GRADING_METHOD:
class GRADING_METHOD: # pylint: disable=too-few-public-methods,invalid-name
"""
Constants for grading method options.
"""
@@ -113,7 +114,7 @@ class GRADING_METHOD:
AVERAGE_SCORE = "average_score"
class RANDOMIZATION:
class RANDOMIZATION: # pylint: disable=too-few-public-methods
"""
Constants for problem randomization
"""
@@ -124,16 +125,19 @@ class RANDOMIZATION:
PER_STUDENT = "per_student"
class Randomization(String):
class Randomization(String): # pylint: disable=too-few-public-methods
"""
Define a field to store how to randomize a problem.
"""
def from_json(self, value):
"""Convert stored randomization flags into their internal enum values."""
if value in ("", "true"):
return RANDOMIZATION.ALWAYS
elif value == "false":
if value == "false":
return RANDOMIZATION.PER_STUDENT
return value
to_json = from_json
@@ -146,7 +150,7 @@ class Randomization(String):
@XBlock.needs("sandbox")
@XBlock.needs("replace_urls")
@XBlock.wants("call_to_action")
class _BuiltInProblemBlock(
class _BuiltInProblemBlock( # pylint: disable=too-many-public-methods,too-many-instance-attributes,too-many-ancestors
ScorableXBlockMixin,
RawMixin,
XmlMixin,
@@ -361,7 +365,7 @@ class _BuiltInProblemBlock(
default=False,
)
def bind_for_student(self, *args, **kwargs): # lint-amnesty, pylint: disable=signature-differs
def bind_for_student(self, *args, **kwargs):
super().bind_for_student(*args, **kwargs)
# Capa was an XModule. When bind_for_student() was called on it with a new runtime, a new CapaModule object
@@ -377,7 +381,7 @@ class _BuiltInProblemBlock(
# self.score is initialized in self.lcp but in this method is accessed before self.lcp so just call it first.
try:
self.lcp
except Exception as err: # lint-amnesty, pylint: disable=broad-except
except Exception as err: # pylint: disable=broad-exception-caught
html = self.handle_fatal_lcp_error(err if show_detailed_errors else None)
else:
html = self.get_html()
@@ -397,9 +401,9 @@ class _BuiltInProblemBlock(
# normal student_view. To prevent anonymous users from viewing specific problems, adjust course policies
# and/or content groups.
return self.student_view(context)
else:
# Show a message that this content requires users to login/enroll.
return super().public_view(context)
# Show a message that this content requires users to login/enroll.
return super().public_view(context)
def author_view(self, context):
"""
@@ -420,7 +424,7 @@ class _BuiltInProblemBlock(
shim_xmodule_js(fragment, "MarkdownEditingDescriptor")
return fragment
def handle_ajax(self, dispatch, data):
def handle_ajax(self, dispatch, data): # pylint: disable=too-many-locals
"""
This is called by courseware.block_render, to handle an AJAX call.
@@ -432,7 +436,7 @@ class _BuiltInProblemBlock(
<other request-specific values here > }
"""
# self.score is initialized in self.lcp but in this method is accessed before self.lcp so just call it first.
self.lcp # lint-amnesty, pylint: disable=pointless-statement
self.lcp # pylint: disable=pointless-statement
handlers = {
"hint_button": self.hint_button,
"problem_get": self.get_problem,
@@ -475,7 +479,7 @@ class _BuiltInProblemBlock(
_, _, traceback_obj = sys.exc_info()
raise ProcessingError(not_found_error_message).with_traceback(traceback_obj) from ex
except Exception as ex: # lint-amnesty, pylint: disable=broad-except
except Exception as ex:
log.exception(
"Unknown error when dispatching %s to %s for user %s",
dispatch,
@@ -574,6 +578,7 @@ class _BuiltInProblemBlock(
# edited in the cms
@classmethod
def backcompat_paths(cls, path):
"""Return legacy filesystem paths for backward compatibility."""
return [
"problems/" + path[8:],
path[8:],
@@ -581,6 +586,7 @@ class _BuiltInProblemBlock(
@property
def non_editable_metadata_fields(self):
"""Return metadata fields that cannot be edited in Studio."""
non_editable_fields = super().non_editable_metadata_fields
non_editable_fields.extend(
[
@@ -606,7 +612,7 @@ class _BuiltInProblemBlock(
try:
tree = etree.XML(self.data)
except etree.XMLSyntaxError:
log.error(f"Error parsing problem types from xml for capa block {self.display_name}")
log.error("Error parsing problem types from xml for capa block %s", self.display_name)
return None # short-term fix to prevent errors (TNL-5057). Will be more properly addressed in TNL-4525.
registered_tags = responsetypes.registry.registered_tags()
return {node.tag for node in tree.iter() if node.tag in registered_tags}
@@ -660,7 +666,7 @@ class _BuiltInProblemBlock(
xblock_body["problem_types"] = list(self.problem_types)
return xblock_body
def has_support(self, view, functionality):
def has_support(self, view, functionality): # pylint: disable=unused-argument
"""
Override the XBlock.has_support method to return appropriate
value for the multi-device functionality.
@@ -702,7 +708,7 @@ class _BuiltInProblemBlock(
minimal_init=True,
)
except responsetypes.LoncapaProblemError:
log.exception(f"LcpFatalError for block {str(self.location)} while getting max score")
log.exception("LcpFatalError for block %s while getting max score", str(self.location))
maximum_score = 0
else:
maximum_score = lcp.get_max_score()
@@ -843,8 +849,8 @@ class _BuiltInProblemBlock(
if self.graceperiod is not None and due_date:
return due_date + self.graceperiod
else:
return due_date
return due_date
def get_seed(self):
"""
@@ -855,11 +861,12 @@ class _BuiltInProblemBlock(
return self.seed
@cached_property
def lcp(self): # lint-amnesty, pylint: disable=method-hidden, missing-function-docstring
def lcp(self): # pylint: disable=method-hidden
"""Lazily create and return a LoncapaProblem instance for this block."""
try:
lcp = self.new_lcp(self.get_state_for_lcp())
except Exception as err: # pylint: disable=broad-except
msg = "cannot create LoncapaProblem {loc}: {err}".format(loc=str(self.location), err=err)
except Exception as err:
msg = f"cannot create LoncapaProblem {str(self.location)}: {err}"
raise LoncapaProblemError(msg).with_traceback(sys.exc_info()[2])
if self.score is None:
@@ -1019,17 +1026,19 @@ class _BuiltInProblemBlock(
},
)
def handle_fatal_lcp_error(self, error): # lint-amnesty, pylint: disable=missing-function-docstring
log.exception(f"LcpFatalError Encountered for {str(self.location)}")
def handle_fatal_lcp_error(self, error):
"""
Log a fatal LoncapaProblem error and return an HTML message for display to the user.
"""
log.exception("LcpFatalError Encountered for %s", str(self.location))
if error:
return HTML('<p>Error formatting HTML for problem:</p><p><pre style="color:red">{msg}</pre></p>').format(
msg=str(error)
)
else:
return HTML(
"<p>Could not format HTML for problem. "
"Contact course staff in the discussion forum for assistance.</p>"
)
return HTML(
"<p>Could not format HTML for problem. Contact course staff in the discussion forum for assistance.</p>"
)
def submit_button_name(self):
"""
@@ -1065,8 +1074,8 @@ class _BuiltInProblemBlock(
# for the user to reset a randomized problem
if self.closed() or submitted_without_reset:
return False
else:
return True
return True
def should_show_reset_button(self):
"""
@@ -1082,12 +1091,12 @@ class _BuiltInProblemBlock(
# Button only shows up for randomized problems if the question has been submitted
if self.rerandomize in [RANDOMIZATION.ALWAYS, RANDOMIZATION.ONRESET] and self.is_submitted():
return True
else:
# Do NOT show the button if the problem is correct
if self.is_correct():
return False
else:
return self.show_reset_button
# Do NOT show the button if the problem is correct
if self.is_correct():
return False
return self.show_reset_button
def should_show_save_button(self):
"""
@@ -1099,33 +1108,33 @@ class _BuiltInProblemBlock(
# (past due / too many attempts)
if self.force_save_button:
return not self.closed()
else:
is_survey_question = self.max_attempts == 0
needs_reset = self.is_submitted() and self.rerandomize == RANDOMIZATION.ALWAYS
# If the student has unlimited attempts, and their answers
# are not randomized, then we do not need a save button
# because they can use the "Check" button without consequences.
#
# The consequences we want to avoid are:
# * Using up an attempt (if max_attempts is set)
# * Changing the current problem, and no longer being
# able to view it (if rerandomize is "always")
#
# In those cases. the if statement below is false,
# and the save button can still be displayed.
#
if self.max_attempts is None and self.rerandomize != RANDOMIZATION.ALWAYS:
return False
is_survey_question = self.max_attempts == 0
needs_reset = self.is_submitted() and self.rerandomize == RANDOMIZATION.ALWAYS
# If the problem is closed (and not a survey question with max_attempts==0),
# then do NOT show the save button
# If we're waiting for the user to reset a randomized problem
# then do NOT show the save button
elif (self.closed() and not is_survey_question) or needs_reset:
return False
else:
return True
# If the student has unlimited attempts, and their answers
# are not randomized, then we do not need a save button
# because they can use the "Check" button without consequences.
#
# The consequences we want to avoid are:
# * Using up an attempt (if max_attempts is set)
# * Changing the current problem, and no longer being
# able to view it (if rerandomize is "always")
#
# In those cases. the if statement below is false,
# and the save button can still be displayed.
#
if self.max_attempts is None and self.rerandomize != RANDOMIZATION.ALWAYS:
return False
# If the problem is closed (and not a survey question with max_attempts==0),
# then do NOT show the save button
# If we're waiting for the user to reset a randomized problem
# then do NOT show the save button
if (self.closed() and not is_survey_question) or needs_reset:
return False
return True
def handle_problem_html_error(self, err):
"""
@@ -1279,7 +1288,7 @@ class _BuiltInProblemBlock(
"msg": total_text,
}
def get_problem_html(self, encapsulate=True, submit_notification=False):
def get_problem_html(self, encapsulate=True, submit_notification=False): # pylint: disable=too-many-locals
"""
Return html for the problem.
@@ -1294,7 +1303,7 @@ class _BuiltInProblemBlock(
# If we cannot construct the problem HTML,
# then generate an error message instead.
except Exception as err: # pylint: disable=broad-except
except Exception as err: # pylint: disable=broad-exception-caught
html = self.handle_problem_html_error(err)
html = self.remove_tags_from_html(html)
@@ -1365,7 +1374,7 @@ class _BuiltInProblemBlock(
return html
def _get_answer_notification(self, render_notifications):
def _get_answer_notification(self, render_notifications): # pylint: disable=too-many-branches
"""
Generate the answer notification type and message from the current problem status.
@@ -1449,7 +1458,7 @@ class _BuiltInProblemBlock(
for tag in tags:
html = re.sub(
rf"<{tag}.*?>.*?</{tag}>", "", html, flags=re.DOTALL
) # xss-lint: disable=python-interpolate-html # lint-amnesty, pylint: disable=line-too-long
) # xss-lint: disable=python-interpolate-html
# Some of these tags span multiple lines
# Note: could probably speed this up by calling sub() once with a big regex
# vs. simply calling sub() many times as we have here.
@@ -1510,7 +1519,7 @@ class _BuiltInProblemBlock(
self.lcp # pylint: disable=pointless-statement
return self.score.raw_earned == self.score.raw_possible
def answer_available(self):
def answer_available(self): # pylint: disable=too-many-branches,too-many-return-statements
"""
Is the user allowed to see an answer?
"""
@@ -1518,41 +1527,41 @@ class _BuiltInProblemBlock(
if not self.correctness_available():
# If correctness is being withheld, then don't show answers either.
return False
elif self.showanswer == "":
if self.showanswer == "":
return False
elif self.showanswer == SHOWANSWER.NEVER:
if self.showanswer == SHOWANSWER.NEVER:
return False
elif user_is_staff:
if user_is_staff:
# This is after the 'never' check because admins can see the answer
# unless the problem explicitly prevents it
return True
elif self.showanswer == SHOWANSWER.ATTEMPTED:
if self.showanswer == SHOWANSWER.ATTEMPTED:
return self.is_attempted() or self.is_past_due()
elif self.showanswer == SHOWANSWER.ANSWERED:
if self.showanswer == SHOWANSWER.ANSWERED:
# NOTE: this is slightly different from 'attempted' -- resetting the problems
# makes lcp.done False, but leaves attempts unchanged.
return self.is_correct()
elif self.showanswer == SHOWANSWER.CLOSED:
if self.showanswer == SHOWANSWER.CLOSED:
return self.closed()
elif self.showanswer == SHOWANSWER.FINISHED:
if self.showanswer == SHOWANSWER.FINISHED:
return self.closed() or self.is_correct()
elif self.showanswer == SHOWANSWER.CORRECT_OR_PAST_DUE:
if self.showanswer == SHOWANSWER.CORRECT_OR_PAST_DUE:
return self.is_correct() or self.is_past_due()
elif self.showanswer == SHOWANSWER.PAST_DUE:
if self.showanswer == SHOWANSWER.PAST_DUE:
return self.is_past_due()
elif self.showanswer == SHOWANSWER.AFTER_SOME_NUMBER_OF_ATTEMPTS:
if self.showanswer == SHOWANSWER.AFTER_SOME_NUMBER_OF_ATTEMPTS:
required_attempts = self.attempts_before_showanswer_button
if self.max_attempts and required_attempts >= self.max_attempts:
required_attempts = self.max_attempts
return self.attempts >= required_attempts
elif self.showanswer == SHOWANSWER.ALWAYS:
if self.showanswer == SHOWANSWER.ALWAYS:
return True
elif self.showanswer == SHOWANSWER.AFTER_ALL_ATTEMPTS:
if self.showanswer == SHOWANSWER.AFTER_ALL_ATTEMPTS:
return self.used_all_attempts()
elif self.showanswer == SHOWANSWER.AFTER_ALL_ATTEMPTS_OR_CORRECT:
if self.showanswer == SHOWANSWER.AFTER_ALL_ATTEMPTS_OR_CORRECT:
return self.used_all_attempts() or self.is_correct()
elif self.showanswer == SHOWANSWER.ATTEMPTED_NO_PAST_DUE:
if self.showanswer == SHOWANSWER.ATTEMPTED_NO_PAST_DUE:
return self.is_attempted()
return False
@@ -1640,28 +1649,29 @@ class _BuiltInProblemBlock(
event_info = {}
event_info["problem_id"] = str(self.location)
self.publish_unmasked("showanswer", event_info)
if not self.answer_available(): # lint-amnesty, pylint: disable=no-else-raise
if not self.answer_available():
raise NotFoundError("Answer is not available")
else:
answers = self.lcp.get_question_answers()
self.set_state_from_lcp()
answers = self.lcp.get_question_answers()
self.set_state_from_lcp()
# answers (eg <solution>) may have embedded images
# but be careful, some problems are using non-string answer dicts
new_answers = {}
for answer_id in answers:
for answer_id, answer_value in answers.items():
try:
answer_content = self.runtime.service(self, "replace_urls").replace_urls(answers[answer_id])
answer_content = self.runtime.service(self, "replace_urls").replace_urls(answer_value)
new_answer = {answer_id: answer_content}
except TypeError:
log.debug("Unable to perform URL substitution on answers[%s]: %s", answer_id, answers[answer_id])
new_answer = {answer_id: answers[answer_id]}
log.debug("Unable to perform URL substitution on answers[%s]: %s", answer_id, answer_value)
new_answer = {answer_id: answer_value}
new_answers.update(new_answer)
return {
"answers": new_answers,
"correct_status_html": render_to_string(
"status_span.html", {"status": Status("correct", self.runtime.service(self, "i18n").gettext)}
"status_span.html",
{"status": Status("correct", self.runtime.service(self, "i18n").gettext)},
),
}
@@ -1723,39 +1733,36 @@ class _BuiltInProblemBlock(
# If key has no underscores, then partition
# will return (key, '', '')
# We detect this and raise an error
if not name: # lint-amnesty, pylint: disable=no-else-raise
if not name:
raise ValueError(f"{key} must contain at least one underscore")
# This allows for answers which require more than one value for
# the same form input (e.g. checkbox inputs). The convention is that
# if the name ends with '[]' (which looks like an array), then the
# answer will be an array.
# if the name ends with '{}' (Which looks like a dict),
# then the answer will be a dict
is_list_key = name.endswith("[]")
is_dict_key = name.endswith("{}")
name = name[:-2] if is_list_key or is_dict_key else name
if is_list_key:
val = data.getall(key)
elif is_dict_key:
try:
val = json.loads(data[key])
# If the submission wasn't deserializable, raise an error.
except (KeyError, ValueError) as exc:
raise ValueError(f"Invalid submission: {data[key]} for {key}") from exc
else:
# This allows for answers which require more than one value for
# the same form input (e.g. checkbox inputs). The convention is that
# if the name ends with '[]' (which looks like an array), then the
# answer will be an array.
# if the name ends with '{}' (Which looks like a dict),
# then the answer will be a dict
is_list_key = name.endswith("[]")
is_dict_key = name.endswith("{}")
name = name[:-2] if is_list_key or is_dict_key else name
val = data[key]
if is_list_key:
val = data.getall(key)
elif is_dict_key:
try:
val = json.loads(data[key])
# If the submission wasn't deserializable, raise an error.
except (KeyError, ValueError):
raise ValueError( # lint-amnesty, pylint: disable=raise-missing-from
f"Invalid submission: {data[key]} for {key}"
)
else:
val = data[key]
# If the name already exists, then we don't want
# to override it. Raise an error instead
if name in answers:
raise ValueError(f"Key {name} already exists in answers dict")
# If the name already exists, then we don't want
# to override it. Raise an error instead
if name in answers: # lint-amnesty, pylint: disable=no-else-raise
raise ValueError(f"Key {name} already exists in answers dict")
else:
answers[name] = val
answers[name] = val
return answers
@@ -1777,8 +1784,9 @@ class _BuiltInProblemBlock(
return {"grade": self.score.raw_earned, "max_grade": self.score.raw_possible}
# pylint: disable=too-many-statements
def submit_problem(self, data, override_time=False):
def submit_problem( # pylint: disable=too-many-statements,too-many-branches,too-many-locals
self, data, override_time=False
):
"""
Checks whether answers to a problem are correct
@@ -1796,7 +1804,6 @@ class _BuiltInProblemBlock(
self.student_answers_history.append(answers_without_files)
event_info["answers"] = answers_without_files
metric_name = "xmodule.capa.check_problem.{}".format # lint-amnesty, pylint: disable=unused-variable
# Can override current time
current_time = datetime.datetime.now(utc)
if override_time is not False:
@@ -1931,8 +1938,6 @@ class _BuiltInProblemBlock(
return {"success": success, "contents": html}
# pylint: enable=too-many-statements
def get_score_with_grading_method(self, current_score: Score) -> Score:
"""
Calculate and return the current score based on the grading method.
@@ -2040,7 +2045,7 @@ class _BuiltInProblemBlock(
"""
try:
return self.get_submission_metadata(answers, correct_map)
except Exception: # pylint: disable=broad-except
except Exception: # pylint: disable=broad-exception-caught
# NOTE: The above process requires deep inspection of capa structures that may break for some
# uncommon problem types. Ensure that it does not prevent answer submission in those
# cases. Any occurrences of errors in this block should be investigated and resolved.
@@ -2137,10 +2142,9 @@ class _BuiltInProblemBlock(
self.publish_unmasked("save_problem_fail", event_info)
return {
"success": False,
# pylint: disable=line-too-long
# Translators: 'closed' means the problem's due date has passed. You may no longer attempt to solve the problem.
# Translators: 'closed' means the problem's due date has passed.
# You may no longer attempt to solve the problem.
"msg": _("Problem is closed."),
# pylint: enable=line-too-long
}
# Problem submitted. Student should reset before saving
@@ -2186,10 +2190,9 @@ class _BuiltInProblemBlock(
self.publish_unmasked("reset_problem_fail", event_info)
return {
"success": False,
# pylint: disable=line-too-long
# Translators: 'closed' means the problem's due date has passed. You may no longer attempt to solve the problem.
# Translators: 'closed' means the problem's due date has passed.
# You may no longer attempt to solve the problem.
"msg": _("You cannot select Reset for a problem that is closed."),
# pylint: enable=line-too-long
}
if not self.is_submitted():
@@ -2250,10 +2253,10 @@ class _BuiltInProblemBlock(
if not self.lcp.supports_rescoring():
event_info["failure"] = "unsupported"
self.publish_unmasked("problem_rescore_fail", event_info)
# pylint: disable=line-too-long
# Translators: 'rescoring' refers to the act of re-submitting a student's solution so it can get a new score.
# Translators: 'rescoring' refers to the act of re-submitting a student's
# solution so it can get a new score.
raise NotImplementedError(_("Problem's definition does not support rescoring."))
# pylint: enable=line-too-long
if not self.done:
event_info["failure"] = "unanswered"
@@ -2270,7 +2273,7 @@ class _BuiltInProblemBlock(
StudentInputError,
ResponseError,
LoncapaProblemError,
) as inst: # lint-amnesty, pylint: disable=unused-variable
):
log.warning("Input error in capa_block:problem_rescore", exc_info=True)
event_info["failure"] = "input_error"
self.publish_unmasked("problem_rescore_fail", event_info)
@@ -2325,6 +2328,7 @@ class _BuiltInProblemBlock(
return grading_method_handler.get_score()
def has_submitted_answer(self):
"""Return True if the learner has already submitted an answer."""
return self.done
def set_score(self, score):
@@ -2503,13 +2507,13 @@ class ComplexEncoder(json.JSONEncoder):
Extend the JSON encoder to correctly handle complex numbers
"""
def default(self, obj): # lint-amnesty, pylint: disable=arguments-differ, method-hidden
def default(self, o):
"""
Print a nicely formatted complex number, or default to the JSON encoder
"""
if isinstance(obj, complex):
return f"{obj.real:.7g}{obj.imag:+.7g}*j"
return json.JSONEncoder.default(self, obj)
if isinstance(o, complex):
return f"{o.real:.7g}{o.imag:+.7g}*j"
return json.JSONEncoder.default(self, o)
def randomization_bin(seed, problem_id):

View File

@@ -1,4 +1,4 @@
# lint-amnesty, pylint: disable=missing-module-docstring
"""Django integration utilities for loading and accessing the content store."""
from importlib import import_module
@@ -18,7 +18,8 @@ def load_function(path):
return getattr(import_module(module_path), name)
def contentstore(name="default"): # lint-amnesty, pylint: disable=missing-function-docstring
def contentstore(name="default"):
"""Return a contentstore instance by name, creating and caching it if not already initialized."""
if name not in _CONTENTSTORE:
class_ = load_function(settings.CONTENTSTORE["ENGINE"])
options = {}

View File

@@ -1,4 +1,5 @@
# lint-amnesty, pylint: disable=missing-module-docstring
"""Mixin classes for handling raw XML data in XBlocks."""
import logging
import re
@@ -22,12 +23,11 @@ class RawMixin:
data = String(help="XML data for the block", default="", scope=Scope.content)
@classmethod
def definition_from_xml(
cls, xml_object, system
): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument
def definition_from_xml(cls, xml_object, system): # pylint: disable=unused-argument
"""Convert XML node into a dictionary with 'data' key for XBlock."""
return {"data": etree.tostring(xml_object, pretty_print=True, encoding="unicode")}, []
def definition_to_xml(self, resource_fs): # lint-amnesty, pylint: disable=unused-argument
def definition_to_xml(self, resource_fs): # pylint: disable=unused-argument
"""
Return an Element if we've kept the import OLX, or None otherwise.
"""
@@ -54,11 +54,11 @@ class RawMixin:
# re-raise
lines = self.data.split("\n")
line, offset = err.position
msg = ("Unable to create xml for block {loc}. " "Context: '{context}'").format(
context=lines[line - 1][offset - 40 : offset + 40],
loc=self.location,
msg = (
f"Unable to create xml for block {self.location}. "
f"Context: '{lines[line - 1][offset - 40 : offset + 40]}'"
)
raise SerializationError(self.location, msg) # lint-amnesty, pylint: disable=raise-missing-from
raise SerializationError(self.location, msg) from err
@classmethod
def parse_xml_new_runtime(cls, node, runtime, keys):
@@ -93,12 +93,14 @@ class EmptyDataRawMixin:
data = String(default="", scope=Scope.content)
@classmethod
def definition_from_xml(cls, xml_object, system): # lint-amnesty, pylint: disable=unused-argument
def definition_from_xml(cls, xml_object, system): # pylint: disable=unused-argument
"""Convert XML node to dictionary with 'data', handling empty nodes specially."""
if len(xml_object) == 0 and len(list(xml_object.items())) == 0:
return {"data": ""}, []
return {"data": etree.tostring(xml_object, pretty_print=True, encoding="unicode")}, []
def definition_to_xml(self, resource_fs): # lint-amnesty, pylint: disable=unused-argument
def definition_to_xml(self, resource_fs): # pylint: disable=unused-argument
"""Return an XML Element from stored data, or an empty element if data is empty."""
if self.data:
return etree.fromstring(self.data)
return etree.Element(self.category)

View File

@@ -1,4 +1,4 @@
# lint-amnesty, pylint: disable=missing-module-docstring
"""Utility for converting XML nodes to their inner string representation."""
from lxml import etree

View File

@@ -1,10 +1,8 @@
# pylint: disable=too-many-lines
"""
Tests of the Capa XModule
"""
# pylint: disable=invalid-name
import datetime
import json
import os
@@ -34,8 +32,12 @@ from lms.djangoapps.courseware.user_state_client import XBlockUserState
from openedx.core.djangolib.testing.utils import skip_unless_lms
from xmodule.capa import responsetypes
from xmodule.capa.correctmap import CorrectMap
from xmodule.capa.responsetypes import LoncapaProblemError, ResponseError, StudentInputError
from xmodule.capa.tests.test_util import use_unsafe_codejail
from xmodule.capa.responsetypes import (
LoncapaProblemError,
ResponseError,
StudentInputError,
)
from xmodule.capa.tests.test_util import UseUnsafeCodejail
from xmodule.capa.xqueue_interface import XQueueInterface
from xmodule.capa_block import ComplexEncoder, ProblemBlock
from xmodule.tests import DATA_DIR
@@ -70,6 +72,7 @@ class CapaFactory:
@classmethod
def next_num(cls):
"""Increment and return a unique number for naming problems."""
cls.num += 1
return cls.num
@@ -85,14 +88,12 @@ class CapaFactory:
"""
Return the key stored in the capa problem answer dict
"""
return "%s_%d_%d" % (
"-".join(["i4x", "edX", "capa_test", "problem", "SampleProblem%d" % cls.num]),
response_num,
input_num,
return (
f"{'-'.join(['i4x', 'edX', 'capa_test', 'problem', f'SampleProblem{cls.num}'])}_{response_num}_{input_num}"
)
@classmethod
def create(
def create( # pylint: disable=too-many-arguments,too-many-positional-arguments
cls,
attempts=None,
problem_state=None,
@@ -206,7 +207,8 @@ if submission[0] == '':
@ddt.ddt
@skip_unless_lms
class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring
class ProblemBlockTest(unittest.TestCase): # pylint: disable=too-many-public-methods
"""Tests for various problem types in XBlocks."""
def setUp(self):
super().setUp()
@@ -221,6 +223,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
self.two_day_delta_str = "2 days"
def test_import(self):
"""Verify CapaFactory creates blocks with zero initial score and unique URLs."""
block = CapaFactory.create()
assert block.get_score().raw_earned == 0
@@ -282,11 +285,11 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
"""
xqueue_interface = XQueueInterface("http://example.com/xqueue", Mock())
with patch.object(xqueue_interface.session, "post", side_effect=exception):
# pylint: disable = protected-access
response = xqueue_interface._http_post("http://some/fake/url", {})
response = xqueue_interface._http_post("http://some/fake/url", {}) # pylint: disable=protected-access
assert response == result
def test_showanswer_attempted(self):
"""Check answer availability changes after attempting the problem."""
problem = CapaFactory.create(showanswer="attempted")
assert not problem.answer_available()
problem.attempts = 1
@@ -351,6 +354,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert problem.answer_available() == answer_available_after_attempt
def test_showanswer_closed(self):
"""Check show answer visibility with showanswer='closed' and various conditions."""
# can see after attempts used up, even with due date in the future
used_all_attempts = CapaFactory.create(
@@ -695,6 +699,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert problem.correctness_available() == expected_result
def test_closed(self):
"""Verify problem closed status based on attempts and due date."""
# Attempts < Max attempts --> NOT closed
block = CapaFactory.create(max_attempts="1", attempts="0")
@@ -722,6 +727,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
@patch.object(ProblemBlock, "course_end_date", new_callable=PropertyMock)
def test_closed_for_archive(self, mock_course_end_date):
"""Check closed status for archived and active courses with/without grace periods."""
# Utility to create a datetime object in the past
def past_datetime(days):
@@ -752,6 +758,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert not block.closed()
def test_parse_get_params(self):
"""Test parsing of GET parameters into response dictionaries with validation."""
# Valid GET param dict
# 'input_5' intentionally left unset,
@@ -770,9 +777,9 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
# Expect that we get a dict with "input" stripped from key names
# and that we get the same values back
for key in result.keys(): # lint-amnesty, pylint: disable=consider-iterating-dictionary
for key in result:
original_key = "input_" + key
assert original_key in valid_get_dict, "Output dict should have key %s" % original_key
assert original_key in valid_get_dict, f"Output dict should have key {original_key}"
assert valid_get_dict[original_key] == result[key]
# Valid GET param dict with list keys
@@ -801,6 +808,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
result = ProblemBlock.make_dict_of_responses(invalid_get_dict)
def test_submit_problem_correct(self):
"""Verify submitting a correct problem updates attempts, grading, and HTML content."""
block = CapaFactory.create(attempts=1)
@@ -1172,6 +1180,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.score == Score(raw_earned=0.25, raw_possible=1)
def test_submit_problem_incorrect(self):
"""Verify submitting an incorrect answer marks failure and increments attempts."""
block = CapaFactory.create(attempts=0)
@@ -1192,6 +1201,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.lcp.context["attempt"] == 1
def test_submit_problem_closed(self):
"""Ensure submitting a closed problem raises NotFoundError and does not increment attempts."""
block = CapaFactory.create(attempts=3)
# Problem closed -- cannot submit
@@ -1207,6 +1217,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
@ddt.data(RANDOMIZATION.ALWAYS, "true")
def test_submit_problem_resubmitted_with_randomize(self, rerandomize):
"""Verify resubmission is blocked when rerandomization is enabled and problem is done."""
# Randomize turned on
block = CapaFactory.create(rerandomize=rerandomize, attempts=0)
@@ -1223,6 +1234,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
@ddt.data(RANDOMIZATION.NEVER, "false", RANDOMIZATION.PER_STUDENT)
def test_submit_problem_resubmitted_no_randomize(self, rerandomize):
"""Verify resubmission succeeds when rerandomization is disabled."""
# Randomize turned off
block = CapaFactory.create(rerandomize=rerandomize, attempts=0, done=True)
@@ -1237,6 +1249,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.lcp.context["attempt"] == 1
def test_submit_problem_queued(self):
"""Ensure queued problems return a wait message and do not increment attempts."""
block = CapaFactory.create(attempts=1)
# Simulate that the problem is queued
@@ -1259,13 +1272,13 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
@pytest.mark.django_db
@patch.object(XQueueInterface, "_http_post")
def test_submit_problem_with_files(self, mock_xqueue_post):
"""Verify file-upload submissions are sent correctly to XQueue via submit_problem."""
# Check a problem with uploaded files, using the submit_problem API.
# pylint: disable=protected-access
# The files we'll be uploading.
fnames = ["prog1.py", "prog2.py", "prog3.py"]
fpaths = [os.path.join(DATA_DIR, "capa", fname) for fname in fnames]
fileobjs = [open(fpath) for fpath in fpaths]
fileobjs = [open(fpath, encoding="utf-8") for fpath in fpaths] # pylint: disable=consider-using-with
for fileobj in fileobjs:
self.addCleanup(fileobj.close)
@@ -1282,24 +1295,42 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
block.submit_problem(get_request_dict)
# pylint: disable=line-too-long
# _http_post is called like this:
# _http_post(
# 'http://example.com/xqueue/xqueue/submit/',
# {
# 'xqueue_header': '{"lms_key": "df34fb702620d7ae892866ba57572491", "lms_callback_url": "/", "queue_name": "BerkeleyX-cs188x"}',
# 'xqueue_body': '{"student_info": "{\\"anonymous_student_id\\": \\"student\\", \\"submission_time\\": \\"20131117183318\\"}", "grader_payload": "{\\"project\\": \\"p3\\"}", "student_response": ""}',
# 'xqueue_header':
# '{"lms_key": "df34fb702620d7ae892866ba57572491", '
# '"lms_callback_url": "/", '
# '"queue_name": "BerkeleyX-cs188x"}',
# 'xqueue_body':
# '{"student_info": "{\\"anonymous_student_id\\": '
# '\\"student\\", \\"submission_time\\": '
# '\\"20131117183318\\"}", '
# '"grader_payload": "{\\"project\\": \\"p3\\"}", '
# '"student_response": ""}',
# },
# files={
# path(u'/home/ned/edx/edx-platform/common/test/data/uploads/asset.html'):
# <open file u'/home/ned/edx/edx-platform/common/test/data/uploads/asset.html', mode 'r' at 0x49c5f60>,
# path(u'/home/ned/edx/edx-platform/common/test/data/uploads/image.jpg'):
# <open file u'/home/ned/edx/edx-platform/common/test/data/uploads/image.jpg', mode 'r' at 0x49c56f0>,
# path(u'/home/ned/edx/edx-platform/common/test/data/uploads/textbook.pdf'):
# <open file u'/home/ned/edx/edx-platform/common/test/data/uploads/textbook.pdf', mode 'r' at 0x49c5a50>,
# path(
# u'/home/ned/edx/edx-platform/common/test/data/uploads/'
# 'asset.html'
# ):
# <open file u'/home/ned/edx/edx-platform/common/test/data/uploads/'
# 'asset.html', mode 'r' at 0x49c5f60>,
# path(
# u'/home/ned/edx/edx-platform/common/test/data/uploads/'
# 'image.jpg'
# ):
# <open file u'/home/ned/edx/edx-platform/common/test/data/uploads/'
# 'image.jpg', mode 'r' at 0x49c56f0>,
# path(
# u'/home/ned/edx/edx-platform/common/test/data/uploads/'
# 'textbook.pdf'
# ):
# <open file u'/home/ned/edx/edx-platform/common/test/data/uploads/'
# 'textbook.pdf', mode 'r' at 0x49c5a50>,
# },
# )
# pylint: enable=line-too-long
assert mock_xqueue_post.call_count == 1
_, kwargs = mock_xqueue_post.call_args
@@ -1310,15 +1341,16 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
@pytest.mark.django_db
@patch.object(XQueueInterface, "_http_post")
def test_submit_problem_with_files_as_xblock(self, mock_xqueue_post):
"""Verify file-upload submissions work correctly via the XBlock handler API."""
# Check a problem with uploaded files, using the XBlock API.
# pylint: disable=protected-access
# The files we'll be uploading.
fnames = ["prog1.py", "prog2.py", "prog3.py"]
fpaths = [os.path.join(DATA_DIR, "capa", fname) for fname in fnames]
fileobjs = [open(fpath) for fpath in fpaths]
for fileobj in fileobjs:
self.addCleanup(fileobj.close)
fileobjs = []
for fpath in fpaths:
with open(fpath, encoding="utf-8") as f:
fileobjs.append(f.read())
block = CapaFactoryWithFiles.create()
@@ -1341,6 +1373,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert fpath == fileobj.name
def test_submit_problem_error(self):
"""Ensure expected grading errors return messages without incrementing attempts."""
# Try each exception that capa_block should handle
exception_classes = [StudentInputError, LoncapaProblemError, ResponseError]
@@ -1366,6 +1399,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.lcp.context["attempt"] == 2
def test_submit_problem_error_with_codejail_exception(self):
"""Verify codejail execution errors are sanitized and handled correctly."""
# Try each exception that capa_block should handle
exception_classes = [StudentInputError, LoncapaProblemError, ResponseError]
@@ -1436,6 +1470,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
block.submit_problem(get_request_dict)
def test_submit_problem_error_nonascii(self):
"""Ensure non-ASCII error messages are preserved and handled correctly."""
# Try each exception that capa_block should handle
exception_classes = [StudentInputError, LoncapaProblemError, ResponseError]
@@ -1461,6 +1496,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.lcp.context["attempt"] == 2
def test_submit_problem_error_with_staff_user(self):
"""Verify staff users receive full traceback information on errors."""
# Try each exception that capa block should handle
for exception_class in [StudentInputError, LoncapaProblemError, ResponseError]:
@@ -1495,6 +1531,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
)
@ddt.unpack
def test_handle_ajax_show_correctness(self, show_correctness, is_correct, expected_score, expected_success):
"""Verify AJAX submission respects show_correctness settings."""
block = CapaFactory.create(show_correctness=show_correctness, due=self.tomorrow_str, correct=is_correct)
# Simulate marking the input correct/incorrect
@@ -1515,6 +1552,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.lcp.context["attempt"] == 1
def test_reset_problem(self):
"""Ensure resetting a completed problem regenerates state and HTML."""
block = CapaFactory.create(done=True)
block.new_lcp = Mock(wraps=block.new_lcp)
block.choose_new_seed = Mock(wraps=block.choose_new_seed)
@@ -1538,6 +1576,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
block.new_lcp.assert_called_once_with(None)
def test_reset_problem_closed(self):
"""Verify reset is blocked when the problem is closed."""
# pre studio default
block = CapaFactory.create(rerandomize=RANDOMIZATION.ALWAYS)
@@ -1553,6 +1592,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert ("success" in result) and (not result["success"])
def test_reset_problem_not_done(self):
"""Verify reset is blocked when the problem is not yet completed."""
# Simulate that the problem is NOT done
block = CapaFactory.create(done=False)
@@ -1564,6 +1604,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert ("success" in result) and (not result["success"])
def test_rescore_problem_correct(self):
"""Ensure rescoring marks the problem correct without incrementing attempts."""
block = CapaFactory.create(attempts=0, done=True)
@@ -1592,6 +1633,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.lcp.context["attempt"] == 1
def test_rescore_problem_additional_correct(self):
"""Verify rescoring updates scores correctly when new correct answers are added."""
# make sure it also works when new correct answer has been added
block = CapaFactory.create(attempts=0)
answer_id = CapaFactory.answer_key()
@@ -1631,6 +1673,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.lcp.context["attempt"] == 1
def test_rescore_problem_incorrect(self):
"""Ensure rescoring marks the problem incorrect without changing attempts."""
# make sure it also works when attempts have been reset,
# so add this to the test:
block = CapaFactory.create(attempts=0, done=True)
@@ -1844,6 +1887,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
mock_publish_grade.assert_called_with(score=Score(raw_earned=0.33, raw_possible=1), only_if_higher=False)
def test_rescore_problem_not_done(self):
"""Ensure rescoring an unfinished problem raises NotFoundError."""
# Simulate that the problem is NOT done
block = CapaFactory.create(done=False)
@@ -1852,6 +1896,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
block.rescore(only_if_higher=False)
def test_rescore_problem_not_supported(self):
"""Ensure rescoring raises NotImplementedError when unsupported by the problem."""
block = CapaFactory.create(done=True)
# Try to rescore the problem, and get exception
@@ -1998,7 +2043,9 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
current_score.raw_possible,
)
def capa_factory_for_problem_xml(self, xml): # lint-amnesty, pylint: disable=missing-function-docstring
def capa_factory_for_problem_xml(self, xml):
"""Return a custom CapaFactory configured with the given problem XML."""
class CustomCapaFactory(CapaFactory):
"""
A factory for creating a Capa problem with arbitrary xml.
@@ -2009,6 +2056,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
return CustomCapaFactory
def test_codejail_error_upon_problem_creation(self):
"""Verify codejail execution errors during problem creation raise LoncapaProblemError."""
# Simulate a codejail safe_exec failure upon problem creation.
# Create a problem with some script attached.
xml_str = textwrap.dedent(
@@ -2048,15 +2096,19 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.lcp.context["attempt"] == 1
def test_rescore_problem_student_input_error(self):
"""Ensure StudentInputError during rescore is handled correctly."""
self._rescore_problem_error_helper(StudentInputError)
def test_rescore_problem_problem_error(self):
"""Ensure LoncapaProblemError during rescore is handled correctly."""
self._rescore_problem_error_helper(LoncapaProblemError)
def test_rescore_problem_response_error(self):
"""Ensure ResponseError during rescore is handled correctly."""
self._rescore_problem_error_helper(ResponseError)
def test_save_problem(self):
"""Verify saving a problem persists answers and returns success."""
block = CapaFactory.create(done=False)
# Save the problem
@@ -2071,6 +2123,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert ("success" in result) and result["success"]
def test_save_problem_closed(self):
"""Ensure saving a closed problem fails."""
block = CapaFactory.create(done=False)
# Simulate that the problem is closed
@@ -2086,6 +2139,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
@ddt.data(RANDOMIZATION.ALWAYS, "true")
def test_save_problem_submitted_with_randomize(self, rerandomize):
"""Verify saving fails when problem is submitted and rerandomization is enabled."""
# Capa XModule treats 'always' and 'true' equivalently
block = CapaFactory.create(rerandomize=rerandomize, done=True)
@@ -2098,6 +2152,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
@ddt.data(RANDOMIZATION.NEVER, "false", RANDOMIZATION.PER_STUDENT)
def test_save_problem_submitted_no_randomize(self, rerandomize):
"""Verify saving succeeds when problem is submitted without rerandomization."""
# Capa XBlock treats 'false' and 'per_student' equivalently
block = CapaFactory.create(rerandomize=rerandomize, done=True)
@@ -2109,14 +2164,17 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert ("success" in result) and result["success"]
def test_submit_button_name(self):
"""Verify the submit button label is correct."""
block = CapaFactory.create(attempts=0)
assert block.submit_button_name() == "Submit"
def test_submit_button_submitting_name(self):
"""Verify the submitting button label is correct."""
block = CapaFactory.create(attempts=1, max_attempts=10)
assert block.submit_button_submitting_name() == "Submitting"
def test_should_enable_submit_button(self):
"""Verify submit button enablement logic across deadlines, attempts, and states."""
attempts = random.randint(1, 10)
@@ -2159,6 +2217,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.should_enable_submit_button()
def test_should_show_reset_button(self):
"""Verify reset button visibility logic across problem states and settings."""
attempts = random.randint(1, 10)
@@ -2203,6 +2262,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.should_show_reset_button()
def test_should_show_save_button(self):
"""Verify save button visibility logic across attempts, deadlines, and randomization."""
attempts = random.randint(1, 10)
@@ -2253,6 +2313,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.should_show_save_button()
def test_should_show_save_button_force_save_button(self):
"""Verify force_save_button overrides normal save button visibility rules."""
# If we're after the deadline, do NOT show the save button
# even though we're forcing a save
block = CapaFactory.create(due=self.yesterday_str, force_save_button="true", done=True)
@@ -2273,6 +2334,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.should_show_save_button()
def test_no_max_attempts(self):
"""Ensure problems with empty max_attempts render without errors."""
block = CapaFactory.create(max_attempts="")
html = block.get_problem_html()
assert html is not None
@@ -2280,6 +2342,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
@patch("xmodule.capa_block.render_to_string")
def test_get_problem_html(self, render_template):
"""Verify problem HTML rendering uses correct template context and encapsulation."""
render_template.return_value = "<div>Test Template HTML</div>"
block = CapaFactory.create()
@@ -2341,6 +2404,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
@patch("xmodule.capa_block.render_to_string")
def test_demand_hint(self, render_template):
"""Verify image-based demand hints render correctly without static URL issues."""
# HTML generation is mocked out to be meaningless here, so instead we check
# the context dict passed into HTML generation.
render_template.return_value = "<div>Test Template HTML</div>"
@@ -2442,6 +2506,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
)
def test_input_state_consistency(self):
"""Verify input_state keys remain consistent and isolated across block instances."""
block1 = CapaFactory.create()
block2 = CapaFactory.create()
@@ -2538,6 +2603,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
"false", "true", RANDOMIZATION.NEVER, RANDOMIZATION.PER_STUDENT, RANDOMIZATION.ALWAYS, RANDOMIZATION.ONRESET
)
def test_random_seed_no_change(self, rerandomize):
"""Verify problem seed remains stable when rerandomization does not apply."""
# Run the test for each possible rerandomize value
@@ -2551,7 +2617,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
# If we're not rerandomizing, the seed is always set
# to the same value (1)
if rerandomize == RANDOMIZATION.NEVER:
assert seed == 1, "Seed should always be 1 when rerandomize='%s'" % rerandomize
assert seed == 1, f"Seed should always be 1 when rerandomize='{rerandomize}'"
# Check the problem
get_request_dict = {CapaFactory.input_key(): "3.14"}
@@ -2665,6 +2731,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
@ddt.data(RANDOMIZATION.ALWAYS, RANDOMIZATION.PER_STUDENT, "true", RANDOMIZATION.ONRESET)
def test_random_seed_bins(self, rerandomize):
"""Ensure generated random seeds fall within the expected numeric range."""
# Assert that we are limiting the number of possible seeds.
# Get a bunch of seeds, they should all be in 0-999.
i = 200
@@ -2846,7 +2913,9 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
@ddt.ddt
class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring
class ProblemBlockXMLTest(unittest.TestCase):
"""Tests XML strings for various problem types in XBlocks."""
sample_checkbox_problem_xml = textwrap.dedent(
"""
<problem>
@@ -3242,6 +3311,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
}
def test_response_types_ignores_non_response_tags(self):
"""Ensure non-response XML tags are ignored when determining problem response types."""
xml = textwrap.dedent(
"""
<problem>
@@ -3268,6 +3338,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
}
def test_response_types_multiple_tags(self):
"""Verify indexing behavior when multiple response types are present in a single problem."""
xml = textwrap.dedent(
"""
<problem>
@@ -3309,6 +3380,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
)
def test_solutions_not_indexed(self):
"""Confirm that solutions, scripts, styles, answers, and hints are excluded from indexing."""
xml = textwrap.dedent(
"""
<problem>
@@ -3350,6 +3422,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
}
def test_indexing_checkboxes(self):
"""Verify correct indexing of checkbox-based problems and extracted content."""
name = "Checkboxes"
block = self._create_block(self.sample_checkbox_problem_xml, name=name)
capa_content = textwrap.dedent(
@@ -3374,6 +3447,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
}
def test_indexing_dropdown(self):
"""Verify correct indexing of dropdown-based problems and extracted content."""
name = "Dropdown"
block = self._create_block(self.sample_dropdown_problem_xml, name=name)
capa_content = textwrap.dedent(
@@ -3392,6 +3466,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
}
def test_indexing_multiple_choice(self):
"""Verify correct indexing of multiple-choice problems and extracted content."""
name = "Multiple Choice"
block = self._create_block(self.sample_multichoice_problem_xml, name=name)
capa_content = textwrap.dedent(
@@ -3414,6 +3489,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
}
def test_indexing_numerical_input(self):
"""Verify correct indexing of numerical input problems and extracted content."""
name = "Numerical Input"
block = self._create_block(self.sample_numerical_input_problem_xml, name=name)
capa_content = textwrap.dedent(
@@ -3439,6 +3515,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
}
def test_indexing_text_input(self):
"""Verify correct indexing of text input problems and extracted content."""
name = "Text Input"
block = self._create_block(self.sample_text_input_problem_xml, name=name)
capa_content = textwrap.dedent(
@@ -3461,6 +3538,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
}
def test_indexing_non_latin_problem(self):
"""Ensure non-Latin characters are preserved correctly in indexed problem content."""
sample_text_input_problem_xml = textwrap.dedent(
"""
<problem>
@@ -3477,6 +3555,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
assert block_dict["content"]["capa_content"] == smart_str(capa_content)
def test_indexing_checkboxes_with_hints_and_feedback(self):
"""Verify indexing of checkbox problems containing hints and feedback."""
name = "Checkboxes with Hints and Feedback"
block = self._create_block(self.sample_checkboxes_with_hints_and_feedback_problem_xml, name=name)
capa_content = textwrap.dedent(
@@ -3504,6 +3583,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
}
def test_indexing_dropdown_with_hints_and_feedback(self):
"""Verify indexing of dropdown problems containing hints and feedback."""
name = "Dropdown with Hints and Feedback"
block = self._create_block(self.sample_dropdown_with_hints_and_feedback_problem_xml, name=name)
capa_content = textwrap.dedent(
@@ -3527,6 +3607,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
}
def test_indexing_multiple_choice_with_hints_and_feedback(self):
"""Verify indexing of multiple-choice problems containing hints and feedback."""
name = "Multiple Choice with Hints and Feedback"
block = self._create_block(self.sample_multichoice_with_hints_and_feedback_problem_xml, name=name)
capa_content = textwrap.dedent(
@@ -3550,6 +3631,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
}
def test_indexing_numerical_input_with_hints_and_feedback(self):
"""Verify indexing of numerical input problems containing hints and feedback."""
name = "Numerical Input with Hints and Feedback"
block = self._create_block(self.sample_numerical_input_with_hints_and_feedback_problem_xml, name=name)
capa_content = textwrap.dedent(
@@ -3571,6 +3653,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
}
def test_indexing_text_input_with_hints_and_feedback(self):
"""Verify indexing of text input problems containing hints and feedback."""
name = "Text Input with Hints and Feedback"
block = self._create_block(self.sample_text_input_with_hints_and_feedback_problem_xml, name=name)
capa_content = textwrap.dedent(
@@ -3592,6 +3675,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
}
def test_indexing_problem_with_html_tags(self):
"""Ensure HTML tags, comments, scripts, and styles are safely ignored during indexing."""
sample_problem_xml = textwrap.dedent(
"""
<problem>
@@ -3678,7 +3762,8 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
CapaFactory.create(xml=problem_xml)
class ComplexEncoderTest(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring
class ComplexEncoderTest(unittest.TestCase):
"""Tests JSON encoding of complex numbers."""
def test_default(self):
"""
@@ -3692,7 +3777,7 @@ class ComplexEncoderTest(unittest.TestCase): # lint-amnesty, pylint: disable=mi
@skip_unless_lms
@use_unsafe_codejail()
@UseUnsafeCodejail()
class ProblemCheckTrackingTest(unittest.TestCase):
"""
Ensure correct tracking information is included in events emitted during problem checks.
@@ -3700,9 +3785,10 @@ class ProblemCheckTrackingTest(unittest.TestCase):
def setUp(self):
super().setUp()
self.maxDiff = None
self.maxDiff = None # pylint: disable=invalid-name
def test_choice_answer_text(self):
"""Verify tracked submission data for multiple choice, option, and checkbox responses."""
xml = """\
<problem display_name="Multiple Choice Questions">
<optionresponse>
@@ -3774,7 +3860,9 @@ class ProblemCheckTrackingTest(unittest.TestCase):
},
}
def capa_factory_for_problem_xml(self, xml): # lint-amnesty, pylint: disable=missing-function-docstring
def capa_factory_for_problem_xml(self, xml):
"""Create a custom CapaFactory for a given problem XML string."""
class CustomCapaFactory(CapaFactory):
"""
A factory for creating a Capa problem with arbitrary xml.
@@ -3784,9 +3872,8 @@ class ProblemCheckTrackingTest(unittest.TestCase):
return CustomCapaFactory
def get_event_for_answers(
self, block, answer_input_dict
): # lint-amnesty, pylint: disable=missing-function-docstring
def get_event_for_answers(self, block, answer_input_dict):
"""Submit answers and return the emitted tracking event payload."""
with patch.object(block.runtime, "publish") as mock_publish:
block.submit_problem(answer_input_dict)
@@ -3798,6 +3885,7 @@ class ProblemCheckTrackingTest(unittest.TestCase):
return event
def test_numerical_textline(self):
"""Verify tracking data for numerical textline responses."""
factory = CapaFactory
block = factory.create()
@@ -3817,21 +3905,20 @@ class ProblemCheckTrackingTest(unittest.TestCase):
}
def test_multiple_inputs(self):
"""Verify tracking data for multiple inputs within a single response group."""
group_label = "Choose the correct color"
input1_label = "What color is the sky?"
input2_label = "What color are pine needles?"
factory = self.capa_factory_for_problem_xml(
"""\
f"""\
<problem display_name="Multiple Inputs">
<optionresponse>
<label>{}</label>
<optioninput options="('yellow','blue','green')" correct="blue" label="{}"/>
<optioninput options="('yellow','blue','green')" correct="green" label="{}"/>
</optionresponse>
<optionresponse>
<label>{group_label}</label>
<optioninput options="('yellow','blue','green')" correct="blue" label="{input1_label}"/>
<optioninput options="('yellow','blue','green')" correct="green" label="{input2_label}"/>
</optionresponse>
</problem>
""".format(
group_label, input1_label, input2_label
)
"""
)
block = factory.create()
answer_input_dict = {
@@ -3867,11 +3954,11 @@ class ProblemCheckTrackingTest(unittest.TestCase):
input1_label = "input 1 label"
input2_label = "input 2 label"
factory = self.capa_factory_for_problem_xml(
"""\
f"""\
<problem display_name="Woo Hoo">
<optionresponse>
<label>{}</label>
<optioninput label="{}">
<label>{group_label}</label>
<optioninput label="{input1_label}">
<option correct="True" label="Good Job">
apple
<optionhint>
@@ -3886,7 +3973,7 @@ class ProblemCheckTrackingTest(unittest.TestCase):
</option>
</optioninput>
<optioninput label="{}">
<optioninput label="{input2_label}">
<option correct="True">
apple
<optionhint>
@@ -3902,9 +3989,7 @@ class ProblemCheckTrackingTest(unittest.TestCase):
</optioninput>
</optionresponse>
</problem>
""".format(
group_label, input1_label, input2_label
)
"""
)
block = factory.create()
@@ -3936,6 +4021,7 @@ class ProblemCheckTrackingTest(unittest.TestCase):
}
def test_rerandomized_inputs(self):
"""Ensure variant seed is included in tracking data for rerandomized problems."""
factory = CapaFactory
block = factory.create(rerandomize=RANDOMIZATION.ALWAYS)
@@ -3957,9 +4043,10 @@ class ProblemCheckTrackingTest(unittest.TestCase):
@pytest.mark.django_db
@patch.object(XQueueInterface, "_http_post")
def test_file_inputs(self, mock_xqueue_post):
"""Verify tracking data for file submission and custom response inputs."""
fnames = ["prog1.py", "prog2.py", "prog3.py"]
fpaths = [os.path.join(DATA_DIR, "capa", fname) for fname in fnames]
fileobjs = [open(fpath) for fpath in fpaths]
fileobjs = [open(fpath, encoding="utf-8") for fpath in fpaths] # pylint: disable=consider-using-with
for fileobj in fileobjs:
self.addCleanup(fileobj.close)
@@ -4031,7 +4118,7 @@ class ProblemBlockReportGenerationTest(unittest.TestCase):
Ensure that Capa report generation works correctly
"""
def setUp(self): # lint-amnesty, pylint: disable=super-method-not-called
def setUp(self):
self.find_question_label_patcher = patch(
"xmodule.capa.capa_problem.LoncapaProblem.find_question_label", lambda self, answer_id: answer_id
)
@@ -4063,7 +4150,8 @@ class ProblemBlockReportGenerationTest(unittest.TestCase):
scope=None,
)
def _get_block(self): # lint-amnesty, pylint: disable=missing-function-docstring
def _get_block(self):
"""Create and return a mock ProblemBlock with default test data."""
scope_ids = Mock(block_type="problem")
block = ProblemBlock(get_test_system(), scope_ids=scope_ids)
block.runtime = Mock()
@@ -4071,17 +4159,20 @@ class ProblemBlockReportGenerationTest(unittest.TestCase):
return block
def test_generate_report_data_not_implemented(self):
"""Verify report generation is not supported for non-problem blocks."""
scope_ids = Mock(block_type="noproblem")
block = ProblemBlock(get_test_system(), scope_ids=scope_ids)
with pytest.raises(NotImplementedError):
next(block.generate_report_data(iter([])))
def test_generate_report_data_limit_responses(self):
"""Ensure report generation respects the response limit."""
block = self._get_block()
report_data = list(block.generate_report_data(self._mock_user_state_generator(), 2))
assert 2 == len(report_data)
def test_generate_report_data_dont_limit_responses(self):
"""Verify all responses are included when no limit is provided."""
block = self._get_block()
user_count = 5
response_count = 10
@@ -4096,16 +4187,18 @@ class ProblemBlockReportGenerationTest(unittest.TestCase):
assert (user_count * response_count) == len(report_data)
def test_generate_report_data_skip_dynamath(self):
"""Ensure Dynamath responses are excluded from reports."""
block = self._get_block()
iterator = iter([self._user_state(suffix="_dynamath")])
report_data = list(block.generate_report_data(iterator))
assert 0 == len(report_data)
def test_generate_report_data_report_loncapa_error(self):
"""Verify LonCapa errors are captured and reported instead of aborting."""
# Test to make sure reports continue despite loncappa errors, and write them into the report.
block = self._get_block()
with patch("xmodule.capa_block.LoncapaProblem") as mock_LoncapaProblem:
mock_LoncapaProblem.side_effect = LoncapaProblemError
with patch("xmodule.capa_block.LoncapaProblem") as mock_loncapa_problem:
mock_loncapa_problem.side_effect = LoncapaProblemError
report_data = list(
block.generate_report_data(
self._mock_user_state_generator(

View File

@@ -8,6 +8,7 @@ from xmodule.stringify import stringify_children
def test_stringify():
"""Test that `stringify_children` correctly concatenates text and child elements."""
text = 'Hi <div x="foo">there <span>Bruce</span><b>!</b></div>'
html = f"""<html a="b" foo="bar">{text}</html>"""
xml = etree.fromstring(html)
@@ -16,6 +17,7 @@ def test_stringify():
def test_stringify_again():
"""Test that `stringify_children` handles complex HTML without duplicating content."""
html = r"""<html name="Voltage Source Answer" >A voltage source is non-linear!
<div align="center">
<img src="/static/images/circuits/voltage-source.png"/>

View File

@@ -1,11 +1,10 @@
# disable missing docstring
# pylint: disable=missing-docstring
"""Unit tests for XBlock field serialization, deserialization, and XML attributes."""
import unittest
from unittest.mock import Mock
import dateutil.parser
from lxml import etree
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
from xblock.field_data import DictFieldData
from xblock.fields import Any, Boolean, Date, Dict, Float, Integer, List, RelativeTime, Scope, String, Timedelta
@@ -22,12 +21,17 @@ from xmodule.x_module import XModuleMixin
from xmodule.xml_block import XmlMixin, deserialize_field, serialize_field
class CrazyJsonString(String):
class CrazyJsonString(String): # pylint: disable=too-few-public-methods
"""String field that appends ' JSON' when serialized."""
def to_json(self, value):
"""Return the string value appended with ' JSON'."""
return value + " JSON"
class TestFields:
class TestFields: # pylint: disable=too-few-public-methods
"""XBlock fields for testing editable and inherited behavior."""
# Will be returned by editable_metadata_fields.
max_attempts = Integer(scope=Scope.settings, default=1000, values={"min": 1, "max": 10})
# Will not be returned by editable_metadata_fields because filtered out by non_editable_metadata_fields.
@@ -60,7 +64,7 @@ class InheritingFieldDataTest(unittest.TestCase):
Tests of InheritingFieldData.
"""
class TestableInheritingXBlock(XmlMixin): # lint-amnesty, pylint: disable=abstract-method
class TestableInheritingXBlock(XmlMixin):
"""
An XBlock we can use in these tests.
"""
@@ -68,6 +72,13 @@ class InheritingFieldDataTest(unittest.TestCase):
inherited = String(scope=Scope.settings, default="the default")
not_inherited = String(scope=Scope.settings, default="nothing")
@classmethod
def definition_from_xml(cls, xml_object, system):
return {}, []
def definition_to_xml(self, resource_fs):
return etree.Element("test_block")
def setUp(self):
super().setUp()
self.dummy_course_key = CourseLocator("test_org", "test_123", "test_run")
@@ -178,8 +189,8 @@ class InheritingFieldDataTest(unittest.TestCase):
parent_block = self.get_block_using_split_kvs(
block_type="library_content",
block_id="parent",
fields=dict(inherited="changed!"),
defaults=dict(inherited="parent's default"),
fields={"inherited": "changed!"},
defaults={"inherited": "parent's default"},
)
assert parent_block.inherited == "changed!"
@@ -200,8 +211,8 @@ class InheritingFieldDataTest(unittest.TestCase):
parent_block = self.get_block_using_split_kvs(
block_type="library_content",
block_id="parent",
fields=dict(inherited="changed!"),
defaults=dict(inherited="parent's default"),
fields={"inherited": "changed!"},
defaults={"inherited": "parent's default"},
)
assert parent_block.inherited == "changed!"
@@ -209,19 +220,29 @@ class InheritingFieldDataTest(unittest.TestCase):
block_type="library_content",
block_id="parent",
fields={},
defaults=dict(inherited="child's default"),
defaults={"inherited": "child's default"},
)
child.parent = parent_block.location
assert child.inherited == "child's default"
class EditableMetadataFieldsTest(unittest.TestCase):
class TestableXmlXBlock(XmlMixin, XModuleMixin): # lint-amnesty, pylint: disable=abstract-method
"""Tests editable metadata fields and their serialization."""
class TestableXmlXBlock(XmlMixin, XModuleMixin):
"""
This is subclassing `XModuleMixin` to use metadata fields in the unmixed class.
"""
@classmethod
def definition_from_xml(cls, xml_object, system):
return {}, []
def definition_to_xml(self, resource_fs):
return etree.Element("test_block")
def test_display_name_field(self):
"""Test filtering and values of display_name editable metadata field."""
editable_fields = self.get_xml_editable_fields(DictFieldData({}))
# Tests that the xblock fields (currently tags and name) get filtered out.
# Also tests that xml_attributes is filtered out of XmlMixin.
@@ -236,6 +257,7 @@ class EditableMetadataFieldsTest(unittest.TestCase):
)
def test_override_default(self):
"""Test explicitly set values override defaults for editable fields."""
# Tests that explicitly_set is correct when a value overrides the default (not inheritable).
editable_fields = self.get_xml_editable_fields(DictFieldData({"display_name": "foo"}))
self.assert_field_values(
@@ -248,6 +270,7 @@ class EditableMetadataFieldsTest(unittest.TestCase):
)
def test_integer_field(self):
"""Test serialization and options of Integer metadata fields."""
block = self.get_block(DictFieldData({"max_attempts": "7"}))
editable_fields = block.editable_metadata_fields
assert 8 == len(editable_fields)
@@ -283,6 +306,7 @@ class EditableMetadataFieldsTest(unittest.TestCase):
)
def test_inherited_field(self):
"""Test inheritance behavior of editable metadata fields."""
kvs = InheritanceKeyValueStore(initial_values={}, inherited_settings={"showanswer": "inherited"})
model_data = KvsFieldData(kvs)
block = self.get_block(model_data)
@@ -313,6 +337,7 @@ class EditableMetadataFieldsTest(unittest.TestCase):
)
def test_type_and_options(self):
"""Test type and options representation of various editable metadata fields."""
# test_display_name_field verifies that a String field is of type "Generic".
# test_integer_field verifies that a Integer field is of type "Integer".
@@ -380,6 +405,7 @@ class EditableMetadataFieldsTest(unittest.TestCase):
# Start of helper methods
def get_xml_editable_fields(self, field_data):
"""Return editable fields from a test XML XBlock with given field data."""
runtime = get_test_descriptor_system()
return runtime.construct_xblock_from_class(
self.TestableXmlXBlock,
@@ -388,7 +414,11 @@ class EditableMetadataFieldsTest(unittest.TestCase):
).editable_metadata_fields
def get_block(self, field_data):
class TestModuleBlock(TestFields, self.TestableXmlXBlock): # lint-amnesty, pylint: disable=abstract-method
"""Construct a test XBlock combining test fields and XML behavior."""
class TestModuleBlock(TestFields, self.TestableXmlXBlock): # pylint: disable=too-many-ancestors
"""Test XBlock class combining test fields and XML behavior, overriding non-editable fields."""
@property
def non_editable_metadata_fields(self):
non_editable_fields = super().non_editable_metadata_fields
@@ -398,7 +428,7 @@ class EditableMetadataFieldsTest(unittest.TestCase):
system = get_test_descriptor_system(render_template=Mock())
return system.construct_xblock_from_class(TestModuleBlock, field_data=field_data, scope_ids=Mock())
def assert_field_values( # lint-amnesty, pylint: disable=dangerous-default-value
def assert_field_values( # pylint: disable=dangerous-default-value,too-many-arguments,too-many-positional-arguments
self,
editable_fields,
name,
@@ -406,9 +436,10 @@ class EditableMetadataFieldsTest(unittest.TestCase):
explicitly_set,
value,
default_value,
type="Generic",
type="Generic", # pylint: disable=redefined-builtin
options=[],
): # lint-amnesty, pylint: disable=redefined-builtin
):
"""Assert correctness of field values, type, options, and explicitness."""
test_field = editable_fields[name]
assert field.name == test_field["field_name"]
@@ -428,6 +459,7 @@ class TestSerialize(unittest.TestCase):
"""Tests the serialize, method, which is not dependent on type."""
def test_serialize(self):
"""Test serialization of various field types to JSON-compatible strings."""
assert serialize_field(None) == "null"
assert serialize_field(-2) == "-2"
assert serialize_field("2") == "2"
@@ -438,11 +470,7 @@ class TestSerialize(unittest.TestCase):
assert serialize_field("fAlse") == "fAlse"
assert serialize_field("hat box") == "hat box"
serialized_dict = serialize_field({"bar": "hat", "frog": "green"})
assert (
serialized_dict # lint-amnesty, pylint: disable=consider-using-in, line-too-long
== '{"bar": "hat", "frog": "green"}'
or serialized_dict == '{"frog": "green", "bar": "hat"}'
)
assert serialized_dict in ('{"bar": "hat", "frog": "green"}', '{"frog": "green", "bar": "hat"}')
assert serialize_field([3.5, 5.6]) == "[3.5, 5.6]"
assert serialize_field(["foo", "bar"]) == '["foo", "bar"]'
assert serialize_field("2012-12-31T23:59:59Z") == "2012-12-31T23:59:59Z"
@@ -451,25 +479,26 @@ class TestSerialize(unittest.TestCase):
class TestDeserialize(unittest.TestCase):
"""Base class for testing deserialization of field values."""
def assertDeserializeEqual(self, expected, arg):
def assert_deserialize_equal(self, expected, arg):
"""
Asserts the result of deserialize_field.
"""
assert deserialize_field(self.field_type(), arg) == expected # lint-amnesty, pylint: disable=no-member
assert deserialize_field(self.field_type(), arg) == expected # pylint: disable=no-member
def assertDeserializeNonString(self):
def assert_deserialize_non_string(self):
"""
Asserts input value is returned for None or something that is not a string.
For all types, 'null' is also always returned as None.
"""
self.assertDeserializeEqual(None, None)
self.assertDeserializeEqual(3.14, 3.14)
self.assertDeserializeEqual(True, True)
self.assertDeserializeEqual([10], [10])
self.assertDeserializeEqual({}, {})
self.assertDeserializeEqual([], [])
self.assertDeserializeEqual(None, "null")
self.assert_deserialize_equal(None, None)
self.assert_deserialize_equal(3.14, 3.14)
self.assert_deserialize_equal(True, True)
self.assert_deserialize_equal([10], [10])
self.assert_deserialize_equal({}, {})
self.assert_deserialize_equal([], [])
self.assert_deserialize_equal(None, "null")
class TestDeserializeInteger(TestDeserialize):
@@ -478,23 +507,25 @@ class TestDeserializeInteger(TestDeserialize):
field_type = Integer
def test_deserialize(self):
self.assertDeserializeEqual(-2, "-2")
self.assertDeserializeEqual("450", '"450"')
"""Test deserialization of Integer field values."""
self.assert_deserialize_equal(-2, "-2")
self.assert_deserialize_equal("450", '"450"')
# False can be parsed as a int (converts to 0)
self.assertDeserializeEqual(False, "false")
self.assert_deserialize_equal(False, "false")
# True can be parsed as a int (converts to 1)
self.assertDeserializeEqual(True, "true")
self.assert_deserialize_equal(True, "true")
# 2.78 can be converted to int, so the string will be deserialized
self.assertDeserializeEqual(-2.78, "-2.78")
self.assert_deserialize_equal(-2.78, "-2.78")
def test_deserialize_unsupported_types(self):
self.assertDeserializeEqual("[3]", "[3]")
"""Test deserialization handles unsupported Integer inputs gracefully."""
self.assert_deserialize_equal("[3]", "[3]")
# '2.78' cannot be converted to int, so input value is returned
self.assertDeserializeEqual('"-2.78"', '"-2.78"')
self.assert_deserialize_equal('"-2.78"', '"-2.78"')
# 'false' cannot be converted to int, so input value is returned
self.assertDeserializeEqual('"false"', '"false"')
self.assertDeserializeNonString()
self.assert_deserialize_equal('"false"', '"false"')
self.assert_deserialize_non_string()
class TestDeserializeFloat(TestDeserialize):
@@ -503,21 +534,23 @@ class TestDeserializeFloat(TestDeserialize):
field_type = Float
def test_deserialize(self):
self.assertDeserializeEqual(-2, "-2")
self.assertDeserializeEqual("450", '"450"')
self.assertDeserializeEqual(-2.78, "-2.78")
self.assertDeserializeEqual("0.45", '"0.45"')
"""Test deserialization of Float field values."""
self.assert_deserialize_equal(-2, "-2")
self.assert_deserialize_equal("450", '"450"')
self.assert_deserialize_equal(-2.78, "-2.78")
self.assert_deserialize_equal("0.45", '"0.45"')
# False can be parsed as a float (converts to 0)
self.assertDeserializeEqual(False, "false")
self.assert_deserialize_equal(False, "false")
# True can be parsed as a float (converts to 1)
self.assertDeserializeEqual(True, "true")
self.assert_deserialize_equal(True, "true")
def test_deserialize_unsupported_types(self):
self.assertDeserializeEqual("[3]", "[3]")
"""Test deserialization handles unsupported Float inputs gracefully."""
self.assert_deserialize_equal("[3]", "[3]")
# 'false' cannot be converted to float, so input value is returned
self.assertDeserializeEqual('"false"', '"false"')
self.assertDeserializeNonString()
self.assert_deserialize_equal('"false"', '"false"')
self.assert_deserialize_non_string()
class TestDeserializeBoolean(TestDeserialize):
@@ -526,23 +559,24 @@ class TestDeserializeBoolean(TestDeserialize):
field_type = Boolean
def test_deserialize(self):
"""Test deserialization of Boolean field values."""
# json.loads converts the value to Python bool
self.assertDeserializeEqual(False, "false")
self.assertDeserializeEqual(True, "true")
self.assert_deserialize_equal(False, "false")
self.assert_deserialize_equal(True, "true")
# json.loads fails, string value is returned.
self.assertDeserializeEqual("False", "False")
self.assertDeserializeEqual("True", "True")
self.assert_deserialize_equal("False", "False")
self.assert_deserialize_equal("True", "True")
# json.loads deserializes as a string
self.assertDeserializeEqual("false", '"false"')
self.assertDeserializeEqual("fAlse", '"fAlse"')
self.assertDeserializeEqual("TruE", '"TruE"')
self.assert_deserialize_equal("false", '"false"')
self.assert_deserialize_equal("fAlse", '"fAlse"')
self.assert_deserialize_equal("TruE", '"TruE"')
# 2.78 can be converted to a bool, so the string will be deserialized
self.assertDeserializeEqual(-2.78, "-2.78")
self.assert_deserialize_equal(-2.78, "-2.78")
self.assertDeserializeNonString()
self.assert_deserialize_non_string()
class TestDeserializeString(TestDeserialize):
@@ -551,16 +585,18 @@ class TestDeserializeString(TestDeserialize):
field_type = String
def test_deserialize(self):
self.assertDeserializeEqual("hAlf", '"hAlf"')
self.assertDeserializeEqual("false", '"false"')
self.assertDeserializeEqual("single quote", "single quote")
"""Test deserialization of String field values."""
self.assert_deserialize_equal("hAlf", '"hAlf"')
self.assert_deserialize_equal("false", '"false"')
self.assert_deserialize_equal("single quote", "single quote")
def test_deserialize_unsupported_types(self):
self.assertDeserializeEqual("3.4", "3.4")
self.assertDeserializeEqual("false", "false")
self.assertDeserializeEqual("2", "2")
self.assertDeserializeEqual("[3]", "[3]")
self.assertDeserializeNonString()
"""Test deserialization handles unsupported String inputs gracefully."""
self.assert_deserialize_equal("3.4", "3.4")
self.assert_deserialize_equal("false", "false")
self.assert_deserialize_equal("2", "2")
self.assert_deserialize_equal("[3]", "[3]")
self.assert_deserialize_non_string()
class TestDeserializeAny(TestDeserialize):
@@ -569,14 +605,15 @@ class TestDeserializeAny(TestDeserialize):
field_type = Any
def test_deserialize(self):
self.assertDeserializeEqual("hAlf", '"hAlf"')
self.assertDeserializeEqual("false", '"false"')
self.assertDeserializeEqual({"bar": "hat", "frog": "green"}, '{"bar": "hat", "frog": "green"}')
self.assertDeserializeEqual([3.5, 5.6], "[3.5, 5.6]")
self.assertDeserializeEqual("[", "[")
self.assertDeserializeEqual(False, "false")
self.assertDeserializeEqual(3.4, "3.4")
self.assertDeserializeNonString()
"""Test deserialization of Any-type field values."""
self.assert_deserialize_equal("hAlf", '"hAlf"')
self.assert_deserialize_equal("false", '"false"')
self.assert_deserialize_equal({"bar": "hat", "frog": "green"}, '{"bar": "hat", "frog": "green"}')
self.assert_deserialize_equal([3.5, 5.6], "[3.5, 5.6]")
self.assert_deserialize_equal("[", "[")
self.assert_deserialize_equal(False, "false")
self.assert_deserialize_equal(3.4, "3.4")
self.assert_deserialize_non_string()
class TestDeserializeList(TestDeserialize):
@@ -585,26 +622,29 @@ class TestDeserializeList(TestDeserialize):
field_type = List
def test_deserialize(self):
self.assertDeserializeEqual(["foo", "bar"], '["foo", "bar"]')
self.assertDeserializeEqual([3.5, 5.6], "[3.5, 5.6]")
self.assertDeserializeEqual([], "[]")
"""Test deserialization of List field values."""
self.assert_deserialize_equal(["foo", "bar"], '["foo", "bar"]')
self.assert_deserialize_equal([3.5, 5.6], "[3.5, 5.6]")
self.assert_deserialize_equal([], "[]")
def test_deserialize_unsupported_types(self):
self.assertDeserializeEqual("3.4", "3.4")
self.assertDeserializeEqual("false", "false")
self.assertDeserializeEqual("2", "2")
self.assertDeserializeNonString()
"""Test deserialization handles unsupported List inputs gracefully."""
self.assert_deserialize_equal("3.4", "3.4")
self.assert_deserialize_equal("false", "false")
self.assert_deserialize_equal("2", "2")
self.assert_deserialize_non_string()
class TestDeserializeDate(TestDeserialize):
"""Tests deserialize as related to Date type."""
"""Test deserialization of Date field values."""
field_type = Date
def test_deserialize(self):
self.assertDeserializeEqual("2012-12-31T23:59:59Z", "2012-12-31T23:59:59Z")
self.assertDeserializeEqual("2012-12-31T23:59:59Z", '"2012-12-31T23:59:59Z"')
self.assertDeserializeNonString()
"""Test deserialization of Timedelta field values."""
self.assert_deserialize_equal("2012-12-31T23:59:59Z", "2012-12-31T23:59:59Z")
self.assert_deserialize_equal("2012-12-31T23:59:59Z", '"2012-12-31T23:59:59Z"')
self.assert_deserialize_non_string()
class TestDeserializeTimedelta(TestDeserialize):
@@ -613,9 +653,10 @@ class TestDeserializeTimedelta(TestDeserialize):
field_type = Timedelta
def test_deserialize(self):
self.assertDeserializeEqual("1 day 12 hours 59 minutes 59 seconds", "1 day 12 hours 59 minutes 59 seconds")
self.assertDeserializeEqual("1 day 12 hours 59 minutes 59 seconds", '"1 day 12 hours 59 minutes 59 seconds"')
self.assertDeserializeNonString()
"""Test deserialization of RelativeTime field values."""
self.assert_deserialize_equal("1 day 12 hours 59 minutes 59 seconds", "1 day 12 hours 59 minutes 59 seconds")
self.assert_deserialize_equal("1 day 12 hours 59 minutes 59 seconds", '"1 day 12 hours 59 minutes 59 seconds"')
self.assert_deserialize_non_string()
class TestDeserializeRelativeTime(TestDeserialize):
@@ -627,8 +668,8 @@ class TestDeserializeRelativeTime(TestDeserialize):
"""
There is no check for
self.assertDeserializeEqual('10:20:30', '10:20:30')
self.assertDeserializeNonString()
self.assert_deserialize_equal('10:20:30', '10:20:30')
self.assert_deserialize_non_string()
because these two tests work only because json.loads fires exception,
and xml_module.deserialized_field catches it and returns same value,
@@ -637,24 +678,28 @@ class TestDeserializeRelativeTime(TestDeserialize):
"""
# test that from_json produces no exceptions
self.assertDeserializeEqual("10:20:30", '"10:20:30"')
self.assert_deserialize_equal("10:20:30", '"10:20:30"')
class TestXmlAttributes(XModuleXmlImportTest):
"""Tests XML import/export of XBlock attributes, including known, unknown, and inheritable attributes."""
def test_unknown_attribute(self):
"""Test processing and retention of unknown XML attributes."""
assert not hasattr(CourseBlock, "unknown_attr")
course = self.process_xml(CourseFactory.build(unknown_attr="value"))
assert not hasattr(course, "unknown_attr")
assert course.xml_attributes["unknown_attr"] == "value"
def test_known_attribute(self):
"""Test that known XML attributes are correctly assigned to XBlock fields."""
assert hasattr(CourseBlock, "show_calculator")
course = self.process_xml(CourseFactory.build(show_calculator="true"))
assert course.show_calculator
assert "show_calculator" not in course.xml_attributes
def test_rerandomize_in_policy(self):
"""Test that rerandomize attribute from policy is correctly processed."""
# Rerandomize isn't a basic attribute of Sequence
assert not hasattr(SequenceBlock, "rerandomize")
@@ -671,6 +716,7 @@ class TestXmlAttributes(XModuleXmlImportTest):
assert "rerandomize" not in seq.xml_attributes
def test_attempts_in_policy(self):
"""Test that attempts attribute from policy is correctly handled in XML import."""
# attempts isn't a basic attribute of Sequence
assert not hasattr(SequenceBlock, "attempts")
@@ -689,6 +735,7 @@ class TestXmlAttributes(XModuleXmlImportTest):
assert "attempts" in seq.xml_attributes
def check_inheritable_attribute(self, attribute, value):
"""Check that an inheritable attribute is correctly processed and excluded from XML attributes."""
# `attribute` isn't a basic attribute of Sequence
assert not hasattr(SequenceBlock, attribute)
@@ -715,6 +762,7 @@ class TestXmlAttributes(XModuleXmlImportTest):
assert attribute not in seq.xml_attributes
def test_inheritable_attributes(self):
"""Check multiple inheritable attributes are processed correctly from XML."""
self.check_inheritable_attribute("days_early_for_beta", 2)
self.check_inheritable_attribute("max_attempts", 5)
self.check_inheritable_attribute("visible_to_staff_only", True)

View File

@@ -1,4 +1,4 @@
# lint-amnesty, pylint: disable=missing-module-docstring
"""Utilities for managing course code libraries and sandbox execution."""
import re
@@ -51,8 +51,8 @@ def get_python_lib_zip(contentstore, context_key: LearningContextKey):
zip_lib = contentstore().find(asset_key, throw_on_not_found=False)
if zip_lib is not None:
return zip_lib.data
else:
return None
return None
class SandboxService:

View File

@@ -1,12 +1,13 @@
# lint-amnesty, pylint: disable=missing-module-docstring
# pylint: disable=too-many-lines
"""Core classes, mixins, and utilities for XModules and XBlock integration."""
import importlib.resources as resources
import logging
import os
import time
import warnings
from collections import namedtuple
from functools import partial
from importlib.resources import as_file, files
import yaml
from django.conf import settings
@@ -195,7 +196,7 @@ def shim_xmodule_js(fragment, js_module_name):
"""
# Delay this import so that it is only used (and django settings are parsed) when
# they are required (rather than at startup)
import webpack_loader.utils # lint-amnesty, pylint: disable=unused-import
import webpack_loader.utils # pylint: disable=unused-import,import-outside-toplevel
if not fragment.js_init_fn:
fragment.initialize_js("XBlockToXModuleShim")
@@ -204,7 +205,7 @@ def shim_xmodule_js(fragment, js_module_name):
add_webpack_js_to_fragment(fragment, "XModuleShim")
class XModuleFields:
class XModuleFields: # pylint: disable=too-few-public-methods
"""
Common fields for XModules.
"""
@@ -220,7 +221,7 @@ class XModuleFields:
@XBlock.needs("i18n")
class XModuleMixin(XModuleFields, XBlock):
class XModuleMixin(XModuleFields, XBlock): # pylint: disable=too-many-public-methods
"""
Fields and methods used by XModules internally.
@@ -273,6 +274,7 @@ class XModuleMixin(XModuleFields, XBlock):
@property
def runtime(self):
"""Return the runtime for this XBlock instance."""
return self._runtime
@runtime.setter
@@ -309,14 +311,17 @@ class XModuleMixin(XModuleFields, XBlock):
@property
def course_id(self):
"""Return the course key for this block."""
return self.location.course_key
@property
def category(self):
"""Return the block type/category."""
return self.scope_ids.block_type
@property
def location(self):
"""Return the usage key identifying this block instance."""
return self.scope_ids.usage_id
@location.setter
@@ -329,6 +334,7 @@ class XModuleMixin(XModuleFields, XBlock):
@property
def url_name(self):
"""Return the URL-friendly name for this block."""
return block_metadata_utils.url_name_for_block(self)
@property
@@ -391,15 +397,13 @@ class XModuleMixin(XModuleFields, XBlock):
any set to None.)
"""
result = {}
for field in self.fields.values(): # lint-amnesty, pylint: disable=no-member
for field in self.fields.values():
if field.scope == scope and field.is_set_on(self):
try:
result[field.name] = field.read_json(self)
except TypeError as exception:
exception_message = "{message}, Block-location:{location}, Field-name:{field_name}".format(
message=str(exception), location=str(self.location), field_name=field.name
)
raise TypeError(exception_message) # lint-amnesty, pylint: disable=raise-missing-from
exception_message = f"{exception}, Block-location:{self.location}, Field-name:{field.name}"
raise TypeError(exception_message) from exception
return result
def has_children_at_depth(self, depth):
@@ -418,12 +422,13 @@ class XModuleMixin(XModuleFields, XBlock):
So the example above would return True for `has_children_at_depth(2)`, and False
for depth > 2
"""
if depth < 0: # lint-amnesty, pylint: disable=no-else-raise
if depth < 0:
raise ValueError("negative depth argument is invalid")
elif depth == 0:
if depth == 0:
return bool(self.get_children())
else:
return any(child.has_children_at_depth(depth - 1) for child in self.get_children())
return any(child.has_children_at_depth(depth - 1) for child in self.get_children())
def get_content_titles(self):
r"""
@@ -447,11 +452,11 @@ class XModuleMixin(XModuleFields, XBlock):
"""
if self.has_children:
return sum((child.get_content_titles() for child in self.get_children()), [])
else:
# xss-lint: disable=python-deprecated-display-name
return [self.display_name_with_default_escaped]
def get_children(self, usage_id_filter=None, usage_key_filter=None): # pylint: disable=arguments-differ
# xss-lint: disable=python-deprecated-display-name
return [self.display_name_with_default_escaped]
def get_children(self, usage_id_filter=None, usage_key_filter=None):
"""Returns a list of XBlock instances for the children of
this module"""
@@ -580,7 +585,7 @@ class XModuleMixin(XModuleFields, XBlock):
self.clear_child_cache()
# Clear out any cached field data scoped to the old user.
for field in self.fields.values(): # lint-amnesty, pylint: disable=no-member
for field in self.fields.values():
if field.scope in (Scope.parent, Scope.children):
continue
@@ -659,8 +664,8 @@ class XModuleMixin(XModuleFields, XBlock):
"""Localize a text value that might be None."""
if value is None:
return None
else:
return self.runtime.service(self, "i18n").ugettext(value)
return self.runtime.service(self, "i18n").ugettext(value)
# gets the 'default_value' and 'explicitly_set' attrs
metadata_field_editor_info = self.runtime.get_field_provenance(self, field)
@@ -713,7 +718,7 @@ class XModuleMixin(XModuleFields, XBlock):
"Sign in or register, and enroll in this course to view it."
).format(display_name=self.display_name)
else:
display_text = _(DEFAULT_PUBLIC_VIEW_MESSAGE) # lint-amnesty, pylint: disable=translation-of-non-string
display_text = _(DEFAULT_PUBLIC_VIEW_MESSAGE) # pylint: disable=translation-of-non-string
return Fragment(alert_html.format(display_text))
@@ -736,7 +741,7 @@ class XModuleToXBlockMixin:
XBlock handler that wraps `handle_ajax`
"""
class FileObjForWebobFiles:
class FileObjForWebobFiles: # pylint: disable=too-few-public-methods
"""
Turn Webob cgi.FieldStorage uploaded files into pure file objects.
@@ -801,7 +806,7 @@ class ResourceTemplates:
if not os.path.exists(template_path):
return None
with open(template_path) as file_object:
with open(template_path, encoding="utf-8") as file_object:
template = yaml.safe_load(file_object)
template["template_id"] = template_id
return template
@@ -839,21 +844,21 @@ class ResourceTemplates:
return list(templates.values())
@classmethod
def get_template_dir(cls): # lint-amnesty, pylint: disable=missing-function-docstring
def get_template_dir(cls):
"""Return the directory name for the classs built-in resource templates."""
if getattr(cls, "template_dir_name", None):
dirname = os.path.join("templates", cls.template_dir_name) # lint-amnesty, pylint: disable=no-member
template_path = resources.files(__name__.rsplit(".", 1)[0]) / dirname
dirname = os.path.join("templates", cls.template_dir_name)
template_path = files(__name__.rsplit(".", 1)[0]) / dirname
if not template_path.is_dir():
log.warning(
"No resource directory {dir} found when loading {cls_name} templates".format(
dir=dirname,
cls_name=cls.__name__,
)
"No resource directory %s found when loading %s templates",
dirname,
cls.__name__,
)
return
return None
return dirname
return
return None
@classmethod
def get_template_dirpaths(cls):
@@ -863,9 +868,9 @@ class ResourceTemplates:
template_dirpaths = []
template_dirname = cls.get_template_dir()
if template_dirname:
template_path = resources.files(__name__.rsplit(".", 1)[0]) / template_dirname
template_path = files(__name__.rsplit(".", 1)[0]) / template_dirname
if template_path.is_dir():
with resources.as_file(template_path) as template_real_path:
with as_file(template_path) as template_real_path:
template_dirpaths.append(str(template_real_path))
custom_template_dir = cls.get_custom_template_dir()
@@ -882,7 +887,7 @@ class ResourceTemplates:
template_dir_name = getattr(cls, "template_dir_name", None)
if template_dir_name is None:
return
return None
resource_dir = settings.CUSTOM_RESOURCE_TEMPLATES_DIRECTORY
@@ -893,6 +898,7 @@ class ResourceTemplates:
if os.path.exists(template_dir_path):
return template_dir_path
return None
@classmethod
@@ -906,6 +912,8 @@ class ResourceTemplates:
if os.path.exists(abs_path):
return cls._load_template(abs_path, template_id)
return None
class _ConfigurableFragmentWrapper:
"""
@@ -939,7 +947,9 @@ class _ConfigurableFragmentWrapper:
return frag
def wrap_aside(self, block, aside, view, frag, context): # pylint: disable=unused-argument
def wrap_aside(
self, block, aside, view, frag, context
): # pylint: disable=unused-argument,too-many-arguments,too-many-positional-arguments
"""
See :func:`Runtime.wrap_child`
"""
@@ -973,7 +983,7 @@ def block_global_local_resource_url(block, uri):
"""
raise NotImplementedError(
"Applications must monkey-patch this function before using local_resource_url for studio_view"
) # lint-amnesty, pylint: disable=line-too-long
)
class _MetricsMixin:
@@ -981,7 +991,8 @@ class _MetricsMixin:
Mixin for adding metric logging for render and handle methods in the ModuleStoreRuntime.
"""
def render(self, block, view_name, context=None): # lint-amnesty, pylint: disable=missing-function-docstring
def render(self, block, view_name, context=None):
"""Render a block view while recording execution time."""
context = context or {}
start_time = time.time()
try:
@@ -997,9 +1008,8 @@ class _MetricsMixin:
getattr(block, "location", ""),
)
def handle(
self, block, handler_name, request, suffix=""
): # lint-amnesty, pylint: disable=missing-function-docstring
def handle(self, block, handler_name, request, suffix=""):
"""Handle a block request while recording execution time."""
start_time = time.time()
try:
return super().handle(block, handler_name, request, suffix=suffix)
@@ -1037,7 +1047,7 @@ class _ModuleSystemShim:
DeprecationWarning,
stacklevel=3,
)
user_service = self._services.get("user")
user_service = self._services.get("user") # pylint: disable=no-member
if user_service:
return user_service.get_current_user().opt_attrs.get(ATTR_KEY_ANONYMOUS_USER_ID)
return None
@@ -1069,7 +1079,7 @@ class _ModuleSystemShim:
DeprecationWarning,
stacklevel=2,
)
user_service = self._services.get("user")
user_service = self._services.get("user") # pylint: disable=no-member
if user_service:
return user_service.get_current_user().opt_attrs.get(ATTR_KEY_USER_ID)
return None
@@ -1086,7 +1096,7 @@ class _ModuleSystemShim:
DeprecationWarning,
stacklevel=2,
)
user_service = self._services.get("user")
user_service = self._services.get("user") # pylint: disable=no-member
if user_service:
return user_service.get_current_user().opt_attrs.get(ATTR_KEY_USER_IS_STAFF)
return None
@@ -1103,7 +1113,7 @@ class _ModuleSystemShim:
DeprecationWarning,
stacklevel=2,
)
user_service = self._services.get("user")
user_service = self._services.get("user") # pylint: disable=no-member
if user_service:
return user_service.get_current_user().opt_attrs.get(ATTR_KEY_REQUEST_COUNTRY_CODE)
return None
@@ -1124,7 +1134,7 @@ class _ModuleSystemShim:
DeprecationWarning,
stacklevel=2,
)
user_service = self._services.get("user")
user_service = self._services.get("user") # pylint: disable=no-member
if user_service:
return user_service.get_user_by_anonymous_id
return None
@@ -1143,10 +1153,12 @@ class _ModuleSystemShim:
DeprecationWarning,
stacklevel=2,
)
user_service = self._services.get("user")
user_service = self._services.get("user") # pylint: disable=no-member
if user_service:
return partial(user_service.get_current_user().opt_attrs.get, ATTR_KEY_USER_ROLE)
return None
@property
def user_is_beta_tester(self):
"""
@@ -1159,10 +1171,12 @@ class _ModuleSystemShim:
DeprecationWarning,
stacklevel=2,
)
user_service = self._services.get("user")
user_service = self._services.get("user") # pylint: disable=no-member
if user_service:
return user_service.get_current_user().opt_attrs.get(ATTR_KEY_USER_IS_BETA_TESTER)
return None
@property
def user_is_admin(self):
"""
@@ -1175,10 +1189,12 @@ class _ModuleSystemShim:
DeprecationWarning,
stacklevel=2,
)
user_service = self._services.get("user")
user_service = self._services.get("user") # pylint: disable=no-member
if user_service:
return user_service.get_current_user().opt_attrs.get(ATTR_KEY_USER_IS_GLOBAL_STAFF)
return None
@property
def render_template(self):
"""
@@ -1194,7 +1210,7 @@ class _ModuleSystemShim:
)
if hasattr(self, "_deprecated_render_template"):
return self._deprecated_render_template
render_service = self._services.get("mako")
render_service = self._services.get("mako") # pylint: disable=no-member
if render_service:
return render_service.render_template
return None
@@ -1221,7 +1237,7 @@ class _ModuleSystemShim:
DeprecationWarning,
stacklevel=2,
)
sandbox_service = self._services.get("sandbox")
sandbox_service = self._services.get("sandbox") # pylint: disable=no-member
if sandbox_service:
return sandbox_service.can_execute_unsafe_code
# Default to saying "no unsafe code".
@@ -1242,7 +1258,7 @@ class _ModuleSystemShim:
DeprecationWarning,
stacklevel=2,
)
sandbox_service = self._services.get("sandbox")
sandbox_service = self._services.get("sandbox") # pylint: disable=no-member
if sandbox_service:
return sandbox_service.get_python_lib_zip
# Default to saying "no lib data"
@@ -1262,7 +1278,7 @@ class _ModuleSystemShim:
DeprecationWarning,
stacklevel=2,
)
return self._services.get("cache") or DoNothingCache()
return self._services.get("cache") or DoNothingCache() # pylint: disable=no-member
@property
def filestore(self):
@@ -1276,7 +1292,7 @@ class _ModuleSystemShim:
DeprecationWarning,
stacklevel=2,
)
return self.resources_fs
return self.resources_fs # pylint: disable=no-member
@property
def node_path(self):
@@ -1317,10 +1333,12 @@ class _ModuleSystemShim:
DeprecationWarning,
stacklevel=2,
)
rebind_user_service = self._services.get("rebind_user")
rebind_user_service = self._services.get("rebind_user") # pylint: disable=no-member
if rebind_user_service:
return partial(rebind_user_service.rebind_noauth_module_to_user)
return None
# noinspection PyPep8Naming
@property
def STATIC_URL(self): # pylint: disable=invalid-name
@@ -1350,6 +1368,8 @@ class _ModuleSystemShim:
if hasattr(self, "_deprecated_course_id"):
return self._deprecated_course_id.for_branch(None)
return None
@course_id.setter
def course_id(self, course_id):
"""
@@ -1363,7 +1383,7 @@ class ModuleStoreRuntime(_MetricsMixin, _ConfigurableFragmentWrapper, _ModuleSys
Base class for :class:`Runtime`s to be used with :class:`XBlock`s loaded from ModuleStore.
"""
def __init__(
def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments
self,
load_item,
resources_fs,
@@ -1416,7 +1436,7 @@ class ModuleStoreRuntime(_MetricsMixin, _ConfigurableFragmentWrapper, _ModuleSys
# Dashboard bulk emails tab, when rendering the HtmlBlock for its WYSIWYG editor. * during testing, when
# fetching factory-created blocks.
if "mako" not in self._services:
from common.djangoapps.edxmako.services import MakoService
from common.djangoapps.edxmako.services import MakoService # pylint: disable=import-outside-toplevel
self._services["mako"] = MakoService()
@@ -1459,20 +1479,23 @@ class ModuleStoreRuntime(_MetricsMixin, _ConfigurableFragmentWrapper, _ModuleSys
:param xblock:
:param field:
"""
# pylint: disable=protected-access
# in runtime b/c runtime contains app-specific xblock behavior. Studio's the only app
# which needs this level of introspection right now. runtime also is 'allowed' to know
# about the kvs, dbmodel, etc.
result = {}
result["explicitly_set"] = xblock._field_data.has(xblock, field.name)
result["explicitly_set"] = xblock._field_data.has(xblock, field.name) # pylint: disable=protected-access
try:
result["default_value"] = xblock._field_data.default(xblock, field.name)
result["default_value"] = xblock._field_data.default(xblock, field.name) # pylint: disable=protected-access
except KeyError:
result["default_value"] = field.to_json(field.default)
return result
def handler_url(self, block, handler_name, suffix="", query="", thirdparty=False):
def handler_url( # pylint: disable=too-many-positional-arguments
self, block, handler_name, suffix="", query="", thirdparty=False
): # pylint: disable=too-many-arguments
"""Return the handler URL for a block, using override if provided."""
# When the Modulestore instantiates ModuleStoreRuntime, we will reference a
# global function that the application can override, unless a specific function is
# defined for LMS/CMS through the handler_url_override property.
@@ -1509,17 +1532,18 @@ class ModuleStoreRuntime(_MetricsMixin, _ConfigurableFragmentWrapper, _ModuleSys
raise NotImplementedError("edX Platform doesn't currently implement XBlock resource urls")
def add_block_as_child_node(self, block, node):
"""Append the blocks XML to the given parent XML node."""
child = etree.SubElement(node, block.category)
child.set("url_name", block.url_name)
block.add_xml_to_node(child)
def publish(self, block, event_type, event): # lint-amnesty, pylint: disable=arguments-differ
def publish(self, block, event_type, event_data):
"""
Publish events through the `EventPublishingService`.
This ensures that the correct track method is used for Instructor tasks.
"""
if publish_service := self._services.get("publish"):
publish_service.publish(block, event_type, event)
publish_service.publish(block, event_type, event_data)
def service(self, block, service_name):
"""
@@ -1543,13 +1567,18 @@ class ModuleStoreRuntime(_MetricsMixin, _ConfigurableFragmentWrapper, _ModuleSys
return service(block)
return service
def wrap_aside(self, block, aside, view, frag, context):
def wrap_aside(
self, block, aside, view, frag, context
): # pylint: disable=too-many-arguments,too-many-positional-arguments
# LMS/CMS can define custom wrap aside using wrap_asides_override as required.
if getattr(self, "wrap_asides_override", None):
return self.wrap_asides_override(block, aside, view, frag, context, request_token=self.request_token)
return super().wrap_aside(block, aside, view, frag, context)
def layout_asides(self, block, context, frag, view_name, aside_frag_fns):
def layout_asides(
self, block, context, frag, view_name, aside_frag_fns
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Layout aside fragments for a block, with LMS/CMS override support."""
# LMS/CMS can define custom layout aside using layout_asides_override as required.
if getattr(self, "layout_asides_override", None):
return self.layout_asides_override(block, context, frag, view_name, aside_frag_fns)
@@ -1560,7 +1589,8 @@ class DoNothingCache:
"""A duck-compatible object to use in ModuleSystemShim when there's no cache."""
def get(self, _key):
"""Return None for any requested cache key."""
return None
def set(self, key, value, timeout=None):
pass
"""Ignore cache set calls and store nothing."""

View File

@@ -1,4 +1,5 @@
# lint-amnesty, pylint: disable=missing-module-docstring
"""Utilities for XML parsing and XBlock/XModuleDescriptor serialization."""
import copy
import datetime
import json
@@ -61,9 +62,11 @@ def serialize_field(value):
"""
if isinstance(value, str):
return value
elif isinstance(value, datetime.datetime):
if isinstance(value, datetime.datetime):
if value.tzinfo is not None and value.utcoffset() is None:
return value.isoformat() + "Z"
return value.isoformat()
return json.dumps(value, cls=EdxJSONEncoder)
@@ -170,7 +173,7 @@ class XmlMixin:
xml_object: An etree Element
"""
raise NotImplementedError("%s does not implement definition_from_xml" % cls.__name__)
raise NotImplementedError(f"{cls.__name__} does not implement definition_from_xml")
@classmethod
def clean_metadata_from_xml(cls, xml_object, excluded_fields=()):
@@ -197,7 +200,7 @@ class XmlMixin:
return etree.parse(file_object, parser=EDX_XML_PARSER).getroot()
@classmethod
def load_file(cls, filepath, fs, def_id): # pylint: disable=invalid-name
def load_file(cls, filepath, fs, def_id):
"""
Open the specified file in fs, and call cls.file_to_xml on it,
returning the lxml object.
@@ -207,9 +210,11 @@ class XmlMixin:
try:
with fs.open(filepath) as xml_file:
return cls.file_to_xml(xml_file)
except Exception as err: # lint-amnesty, pylint: disable=broad-except
except Exception as err:
# Add info about where we are, but keep the traceback
raise Exception(f"Unable to load file contents at path {filepath} for item {def_id}: {err}") from err
raise Exception( # pylint: disable=broad-exception-raised
f"Unable to load file contents at path {filepath} for item {def_id}: {err}"
) from err
@classmethod
def load_definition(cls, xml_object, system, def_id, id_generator):
@@ -301,7 +306,7 @@ class XmlMixin:
metadata[attr] = value
@classmethod
def parse_xml(cls, node, runtime, keys): # pylint: disable=too-many-statements
def parse_xml(cls, node, runtime, keys): # pylint: disable=too-many-locals,too-many-branches
"""
Use `node` to construct a new block.
@@ -316,7 +321,10 @@ class XmlMixin:
Returns (XBlock): The newly parsed XBlock
"""
from xmodule.modulestore.xml import XMLImportingModuleStoreRuntime # done here to avoid circular import
from xmodule.modulestore.xml import ( # pylint: disable=import-outside-toplevel
XMLImportingModuleStoreRuntime,
)
if keys is None:
# Passing keys=None is against the XBlock API but some platform tests do it.
@@ -357,7 +365,7 @@ class XmlMixin:
metadata["definition_metadata_raw"] = dmdata
try:
metadata.update(json.loads(dmdata))
except Exception as err: # lint-amnesty, pylint: disable=broad-except
except Exception as err: # pylint: disable=broad-exception-caught
log.debug("Error in loading metadata %r", dmdata, exc_info=True)
metadata["definition_metadata_err"] = str(err)
@@ -481,9 +489,10 @@ class XmlMixin:
val = serialize_field(self.fields[attr].to_json(getattr(self, attr)))
try:
xml_object.set(attr, val)
except Exception: # lint-amnesty, pylint: disable=broad-except
except Exception: # pylint: disable=broad-exception-caught
logging.exception(
"Failed to serialize metadata attribute %s with value %s in module %s. This could mean data loss!!!", # lint-amnesty, pylint: disable=line-too-long
"Failed to serialize metadata attribute %s with value %s in module %s."
" This could mean data loss!!!",
attr,
val,
self.url_name,
@@ -527,7 +536,7 @@ class XmlMixin:
"""
Return a new etree Element object created from this modules definition.
"""
raise NotImplementedError("%s does not implement definition_to_xml" % self.__class__.__name__)
raise NotImplementedError(f"{self.__class__.__name__} does not implement definition_to_xml")
@property
def non_editable_metadata_fields(self):