252 lines
8.8 KiB
Python
252 lines
8.8 KiB
Python
"""
|
|
Utility functions for capa.
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
from cmath import isinf, isnan
|
|
from decimal import Decimal
|
|
|
|
import nh3
|
|
from calc import evaluator
|
|
from lxml import etree
|
|
|
|
from openedx.core.djangolib.markup import HTML
|
|
|
|
# -----------------------------------------------------------------------------
|
|
#
|
|
# Utility functions used in CAPA responsetypes
|
|
DEFAULT_TOLERANCE = "0.001%"
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def compare_with_tolerance(student_complex, instructor_complex, tolerance=DEFAULT_TOLERANCE, relative_tolerance=False):
|
|
"""
|
|
Compare student_complex to instructor_complex with maximum tolerance tolerance.
|
|
|
|
- student_complex : student result (float complex number)
|
|
- instructor_complex : instructor result (float complex number)
|
|
- tolerance : float, or string (representing a float or a percentage)
|
|
- relative_tolerance: bool, to explicitly use passed tolerance as relative
|
|
|
|
Note: when a tolerance is a percentage (i.e. '10%'), it will compute that
|
|
percentage of the instructor result and yield a number.
|
|
|
|
If relative_tolerance is set to False, it will use that value and the
|
|
instructor result to define the bounds of valid student result:
|
|
instructor_complex = 10, tolerance = '10%' will give [9.0, 11.0].
|
|
|
|
If relative_tolerance is set to True, it will use that value and both
|
|
instructor result and student result to define the bounds of valid student
|
|
result:
|
|
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 of 1e-3% is added to compare two floats for
|
|
near-equality (to handle machine representation errors).
|
|
Default tolerance is relative, as the acceptable difference between two
|
|
floats depends on the magnitude of the floats.
|
|
(http://randomascii.wordpress.com/2012/02/25/comparing-floating-point-numbers-2012-edition/)
|
|
Examples:
|
|
In [183]: 0.000016 - 1.6*10**-5
|
|
Out[183]: -3.3881317890172014e-21
|
|
In [212]: 1.9e24 - 1.9*10**24
|
|
Out[212]: 268435456.0
|
|
"""
|
|
if isinstance(tolerance, str):
|
|
if tolerance == DEFAULT_TOLERANCE:
|
|
relative_tolerance = True
|
|
if tolerance.endswith("%"):
|
|
tolerance = evaluator({}, {}, tolerance[:-1]) * 0.01
|
|
if not relative_tolerance:
|
|
tolerance = tolerance * abs(instructor_complex)
|
|
else:
|
|
tolerance = evaluator({}, {}, tolerance)
|
|
|
|
if relative_tolerance:
|
|
tolerance = tolerance * max(abs(student_complex), abs(instructor_complex))
|
|
|
|
if isinf(student_complex) or isinf(instructor_complex):
|
|
# If an input is infinite, we can end up with `abs(student_complex-instructor_complex)` and
|
|
# `tolerance` both equal to infinity. Then, below we would have
|
|
# `inf <= inf` which is a fail. Instead, compare directly.
|
|
return student_complex == instructor_complex
|
|
|
|
# because student_complex and instructor_complex are not necessarily
|
|
# complex here, we enforce it here:
|
|
student_complex = complex(student_complex)
|
|
instructor_complex = complex(instructor_complex)
|
|
|
|
# if both the instructor and student input are real,
|
|
# compare them as Decimals to avoid rounding errors
|
|
if not (instructor_complex.imag or student_complex.imag):
|
|
# if either of these are not a number, short circuit and return False
|
|
if isnan(instructor_complex.real) or isnan(student_complex.real):
|
|
return False
|
|
student_decimal = Decimal(str(student_complex.real))
|
|
instructor_decimal = Decimal(str(instructor_complex.real))
|
|
tolerance_decimal = Decimal(str(tolerance))
|
|
return abs(student_decimal - instructor_decimal) <= tolerance_decimal
|
|
|
|
# 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
|
|
"""
|
|
Takes a string with variables. E.g. $a+$b.
|
|
Does a substitution of those variables from the context
|
|
"""
|
|
|
|
def convert_to_str(value):
|
|
"""The method tries to convert unicode/non-ascii values into string"""
|
|
try:
|
|
return str(value)
|
|
except UnicodeEncodeError:
|
|
return value.encode("utf8", errors="ignore")
|
|
|
|
if not text:
|
|
return text
|
|
|
|
for key in sorted(context, key=len, reverse=True):
|
|
# TODO (vshnayder): This whole replacement thing is a big hack
|
|
# right now--context contains not just the vars defined in the
|
|
# program, but also e.g. a reference to the numpy module.
|
|
# Should be a separate dict of variables that should be
|
|
# replaced.
|
|
context_key = "$" + key
|
|
if context_key in (text.decode("utf-8") if isinstance(text, bytes) else text):
|
|
text = convert_to_str(text)
|
|
context_value = convert_to_str(context[key])
|
|
text = text.replace(context_key, context_value)
|
|
|
|
return text
|
|
|
|
|
|
def convert_files_to_filenames(answers):
|
|
"""
|
|
Check for File objects in the dict of submitted answers,
|
|
convert File objects to their filename (string)
|
|
"""
|
|
new_answers = {}
|
|
for answer_id in answers.keys():
|
|
answer = answers[answer_id]
|
|
# Files are stored as a list, even if one file
|
|
if is_list_of_files(answer):
|
|
new_answers[answer_id] = [f.name for f in answer]
|
|
else:
|
|
new_answers[answer_id] = answers[answer_id]
|
|
return new_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)
|
|
|
|
|
|
def is_file(file_to_test):
|
|
"""
|
|
Duck typing to check if 'file_to_test' is a File object
|
|
"""
|
|
return all(hasattr(file_to_test, method) for method in ["read", "name"])
|
|
|
|
|
|
def find_with_default(node, path, default):
|
|
"""
|
|
Look for a child of node using , and return its text if found.
|
|
Otherwise returns default.
|
|
|
|
Arguments:
|
|
node: lxml node
|
|
path: xpath search expression
|
|
default: value to return if nothing found
|
|
|
|
Returns:
|
|
node.find(path).text if the find succeeds, default otherwise.
|
|
|
|
"""
|
|
v = node.find(path)
|
|
if v is not None:
|
|
return v.text
|
|
|
|
return default
|
|
|
|
|
|
def sanitize_html(html_code):
|
|
"""
|
|
Sanitize html_code for safe embed on LMS pages.
|
|
|
|
Used to sanitize XQueue responses from Matlab.
|
|
"""
|
|
attributes = nh3.ALLOWED_ATTRIBUTES.copy()
|
|
attributes.update(
|
|
{
|
|
"*": {"class", "style", "id"},
|
|
"audio": {"controls", "autobuffer", "autoplay", "src"},
|
|
"img": {"src", "width", "height", "class"},
|
|
}
|
|
)
|
|
output = nh3.clean(
|
|
html_code, tags=nh3.ALLOWED_TAGS | {"div", "p", "audio", "pre", "img", "span"}, attributes=attributes
|
|
)
|
|
return output
|
|
|
|
|
|
def get_inner_html_from_xpath(xpath_node):
|
|
"""
|
|
Returns inner html as string from xpath node.
|
|
|
|
"""
|
|
# returns string from 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(f"(?ms)<{xpath_node.tag}[^>]*>(.*)</{xpath_node.tag}>", "\\1", html)
|
|
return inner_html.strip()
|
|
|
|
|
|
def remove_markup(html):
|
|
"""
|
|
Return html with markup stripped and text HTML-escaped.
|
|
|
|
>>> nh3.clean("<b>Rock & Roll</b>", tags=set())
|
|
'Rock & Roll'
|
|
>>> nh3.clean("<b>Rock & Roll</b>", tags=set())
|
|
'Rock & Roll'
|
|
"""
|
|
return HTML(nh3.clean(html, tags=set()))
|
|
|
|
|
|
def get_course_id_from_capa_block(capa_block):
|
|
"""
|
|
Extract a stringified course run key from a CAPA block (aka ProblemBlock).
|
|
|
|
This is a bit of a hack. Its intended use is to allow us to pass the course id
|
|
(if available) to `safe_exec`, enabling course-run-specific resource limits
|
|
in the safe execution environment (codejail).
|
|
|
|
Arguments:
|
|
capa_block (ProblemBlock|None)
|
|
|
|
Returns: str|None
|
|
The stringified course run key of the block.
|
|
If not available, fall back to None.
|
|
"""
|
|
if not capa_block:
|
|
return None
|
|
try:
|
|
return str(capa_block.scope_ids.usage_id.course_key)
|
|
except (AttributeError, TypeError):
|
|
# AttributeError:
|
|
# If the capa block lacks scope ids or has unexpected scope ids, we
|
|
# would rather fall back to `None` than let an AttributeError be raised
|
|
# here.
|
|
# TypeError:
|
|
# Old Mongo usage keys lack a 'run' specifier, and may
|
|
# raise a type error when we try to serialize them into a course
|
|
# run key. This is tolerable because such course runs are deprecated.
|
|
return None
|