"""
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("Rock & Roll", tags=set())
'Rock & Roll'
>>> nh3.clean("Rock & Roll", 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