""" 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}[^>]*>(.*)", "\\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