From c0192c8846f11d3e08fc9eb93d5ffaf2fbb34c28 Mon Sep 17 00:00:00 2001 From: Neal Davis Date: Thu, 13 Dec 2018 20:09:32 -0600 Subject: [PATCH 01/14] Post basic Octave support. --- course/page/__init__.py | 2 + course/page/code.py | 943 ++++++++++++++++++- course/page/code_runoc_backend.py | 314 ++++++ docker-image-run-octave/Dockerfile | 57 ++ docker-image-run-octave/docker-build.sh | 4 + docker-image-run-octave/flatten-container.sh | 12 + docker-image-run-octave/runoc | 156 +++ docker-image-run-py/docker-build.sh | 2 +- 8 files changed, 1488 insertions(+), 2 deletions(-) create mode 100644 course/page/code_runoc_backend.py create mode 100644 docker-image-run-octave/Dockerfile create mode 100755 docker-image-run-octave/docker-build.sh create mode 100755 docker-image-run-octave/flatten-container.sh create mode 100755 docker-image-run-octave/runoc diff --git a/course/page/__init__.py b/course/page/__init__.py index e268f9d69..39c9ce5ce 100644 --- a/course/page/__init__.py +++ b/course/page/__init__.py @@ -37,6 +37,8 @@ ChoiceQuestion, MultipleChoiceQuestion, SurveyChoiceQuestion) from course.page.code import ( PythonCodeQuestion, PythonCodeQuestionWithHumanTextFeedback) +from course.page.code import ( + OctaveCodeQuestion) from course.page.upload import FileUploadQuestion __all__ = ( diff --git a/course/page/code.py b/course/page/code.py index 96a878a4c..d95ff2faa 100644 --- a/course/page/code.py +++ b/course/page/code.py @@ -46,6 +46,7 @@ # True for 'spawn containers' (normal operation) # False for 'just connect to localhost:RUNPY_PORT' for runpy' SPAWN_CONTAINERS_FOR_RUNPY = True +SPAWN_CONTAINERS_FOR_RUNOC = True # {{{ python code question @@ -260,7 +261,6 @@ def check_timeout(): # Oh well. No need to bother the students with this nonsense. pass - def is_nuisance_failure(result): if result["result"] != "uncaught_error": return False @@ -1190,4 +1190,945 @@ def grade(self, page_context, page_data, answer_data, grade_data): # }}} +# {{{ octave code question + +class OctaveCodeForm(StyledForm): + # prevents form submission with codemirror's empty textarea + use_required_attribute = False + + def __init__(self, read_only, interaction_mode, initial_code, + data=None, *args, **kwargs): + super(OctaveCodeForm, self).__init__(data, *args, **kwargs) #TODO OctaveCodeForm + + from course.utils import get_codemirror_widget + cm_widget, cm_help_text = get_codemirror_widget( + language_mode="octave", + interaction_mode=interaction_mode, + read_only=read_only, + + # Automatically focus the text field once there has + # been some input. + autofocus=( + not read_only + and (data is not None and "answer" in data))) + + self.fields["answer"] = forms.CharField(required=True, + initial=initial_code, + help_text=cm_help_text, + widget=cm_widget, + label=_("Answer")) + + self.style_codemirror_widget() + + def clean(self): + # FIXME Should try compilation + pass + + +RUNOC_PORT = 9942 +DOCKER_TIMEOUT = 15 + + +class InvalidPingResponse(RuntimeError): + pass + + +def request_octave_run(run_req, run_timeout, image=None): + import json + from six.moves import http_client + import docker + import socket + import errno + from docker.errors import APIError as DockerAPIError + + debug = True + if debug: + def debug_print(s): + print(s) + else: + def debug_print(s): + pass + + if SPAWN_CONTAINERS_FOR_RUNOC: + docker_url = getattr(settings, "RELATE_DOCKER_URL", + "unix://var/run/docker.sock") + docker_tls = getattr(settings, "RELATE_DOCKER_TLS_CONFIG", + None) + docker_cnx = docker.Client( + base_url=docker_url, + tls=docker_tls, + timeout=DOCKER_TIMEOUT, + version="1.19") + + if image is None: + image = settings.RELATE_DOCKER_RUNOC_IMAGE + + dresult = docker_cnx.create_container( + image=image, + command=[ + "/opt/runoc/runoc", + "-1"], + host_config={ + "Memory": 384*10**6, + "MemorySwap": -1, + "PublishAllPorts": True, + # Do not enable: matplotlib stops working if enabled. + # "ReadonlyRootfs": True, + }, + user="runoc") + + container_id = dresult["Id"] + else: + container_id = None + + connect_host_ip = 'localhost' + + try: + # FIXME: Prohibit networking + + if container_id is not None: + docker_cnx.start(container_id) + + container_props = docker_cnx.inspect_container(container_id) + (port_info,) = (container_props + ["NetworkSettings"]["Ports"]["%d/tcp" % RUNOC_PORT]) + port_host_ip = port_info.get("HostIp") + + if port_host_ip != "0.0.0.0": + connect_host_ip = port_host_ip + + port = int(port_info["HostPort"]) + else: + port = RUNOC_PORT + + from time import time, sleep + start_time = time() + + # {{{ ping until response received + + from traceback import format_exc + + def check_timeout(): + if time() - start_time < DOCKER_TIMEOUT: + sleep(0.1) + # and retry + else: + return { + "result": "uncaught_error", + "message": "Timeout waiting for container.", + "traceback": "".join(format_exc()), + "exec_host": connect_host_ip, + } + + while True: + try: + connection = http_client.HTTPConnection(connect_host_ip, port) + + connection.request('GET', '/ping') + + response = connection.getresponse() + response_data = response.read().decode() + + if response_data != "OK": + raise InvalidPingResponse() + + break + + except (http_client.BadStatusLine, InvalidPingResponse): + ct_res = check_timeout() + if ct_res is not None: + return ct_res + + except socket.error as e: + if e.errno in [errno.ECONNRESET, errno.ECONNREFUSED]: + ct_res = check_timeout() + if ct_res is not None: + return ct_res + + else: + raise + + # }}} + + debug_print("PING SUCCESSFUL") + + try: + # Add a second to accommodate 'wire' delays + connection = http_client.HTTPConnection(connect_host_ip, port, + timeout=1 + run_timeout) + + headers = {'Content-type': 'application/json'} + + json_run_req = json.dumps(run_req).encode("utf-8") + + from time import time + start_time = time() + + debug_print("BEFPOST") + connection.request('POST', '/run-octave', json_run_req, headers) + debug_print("AFTPOST") + + http_response = connection.getresponse() + debug_print("GETR") + response_data = http_response.read().decode("utf-8") + debug_print("READR") + + end_time = time() + + result = json.loads(response_data) + + result["feedback"] = (result.get("feedback", []) + + ["Execution time: %.1f s -- Time limit: %.1f s" + % (end_time - start_time, run_timeout)]) + + result["exec_host"] = connect_host_ip + + return result + + except socket.timeout: + return { + "result": "timeout", + "exec_host": connect_host_ip, + } + finally: + if container_id is not None: + debug_print("-----------BEGIN DOCKER LOGS for %s" % container_id) + debug_print(docker_cnx.logs(container_id)) + debug_print("-----------END DOCKER LOGS for %s" % container_id) + + try: + docker_cnx.remove_container(container_id, force=True) + except DockerAPIError: + # Oh well. No need to bother the students with this nonsense. + pass + + +def is_nuisance_failure(result): + if result["result"] != "uncaught_error": + return False + + if "traceback" in result: + if "BadStatusLine" in result["traceback"]: + + # Occasionally, we fail to send a POST to the container, even after + # the inital ping GET succeeded, for (for now) mysterious reasons. + # Just try again. + + return True + + if "bind: address already in use" in result["traceback"]: + # https://github.com/docker/docker/issues/8714 + + return True + + if ("requests.packages.urllib3.exceptions.NewConnectionError" + in result["traceback"]): + return True + + if "http.client.RemoteDisconnected" in result["traceback"]: + return True + + if "[Errno 113] No route to host" in result["traceback"]: + return True + + return False + + +def request_octave_run_with_retries(run_req, run_timeout, image=None, retry_count=3): + while True: + result = request_octave_run(run_req, run_timeout, image=image) + + if retry_count and is_nuisance_failure(result): + retry_count -= 1 + continue + + return result + + +class OctaveCodeQuestion(PageBaseWithTitle, PageBaseWithValue): + """ + An auto-graded question allowing an answer consisting of Octave code. + All user code as well as all code specified as part of the problem + is in Octave 4.2+. + + If you are not including the + :attr:`course.constants.flow_permission.change_answer` + permission for your entire flow, you likely want to + include this snippet in your question definition: + + .. code-block:: yaml + + access_rules: + add_permissions: + - change_answer + + This will allow participants multiple attempts at getting + the right answer. + + .. attribute:: id + + |id-page-attr| + + .. attribute:: type + + ``OctaveCodeQuestion`` + + .. attribute:: is_optional_page + + |is-optional-page-attr| + + .. attribute:: access_rules + + |access-rules-page-attr| + + .. attribute:: title + + |title-page-attr| + + .. attribute:: value + + |value-page-attr| + + .. attribute:: prompt + + The page's prompt, written in :ref:`markup`. + + .. attribute:: timeout + + A number, giving the number of seconds for which setup code, + the given answer code, and the test code (combined) will be + allowed to run. + + .. attribute:: setup_code + + Optional. + Octave code to prepare the environment for the participants + answer. + + .. attribute:: show_setup_code + + Optional. ``True`` or ``False``. If true, the :attr:`setup_code` + will be shown to the participant. + + .. attribute:: names_for_user + + Optional. + Symbols defined at the end of the :attr:`setup_code` that will be + made available to the participant's code. + + A deep copy (using the standard library function :func:`copy.deepcopy`) + of these values is made, to prevent the user from modifying trusted + state of the grading code. + + .. attribute:: names_from_user + + Optional. + Symbols that the participant's code is expected to define. + These will be made available to the :attr:`test_code`. + Some remapping of types will be made between Octave and Python classes. + + .. attribute:: test_code + + Optional. + Code that will be run to determine the correctness of a + student-provided solution. Will have access to variables in + :attr:`names_from_user` (which will be *None*) if not provided. Should + never raise an exception. + + This may contain the marker "###CORRECT_CODE###", which will + be replaced with the contents of :attr:`correct_code`, with + each line indented to the same depth as where the marker + is found. The line with this marker is only allowed to have + white space and the marker on it. + + .. attribute:: show_test_code + + Optional. ``True`` or ``False``. If true, the :attr:`test_code` + will be shown to the participant. + + .. attribute:: correct_code_explanation + + Optional. + Code that is revealed when answers are visible + (see :ref:`flow-permissions`). This is shown before + :attr:`correct_code` as an explanation. + + .. attribute:: correct_code + + Optional. + Code that is revealed when answers are visible + (see :ref:`flow-permissions`). + + .. attribute:: initial_code + + Optional. + Code present in the code input field when the participant first starts + working on their solution. + + .. attribute:: data_files + + Optional. + A list of file names in the :ref:`git-repo` whose contents will be made + available to :attr:`setup_code` and :attr:`test_code` through the + ``data_files`` dictionary. (see below) + + .. attribute:: single_submission + + Optional, a Boolean. If the question does not allow multiple submissions + based on its :attr:`access_rules` (not the ones of the flow), a warning + is shown. Setting this attribute to True will silence the warning. + + The following symbols are available in :attr:`setup_code` and :attr:`test_code`: + + * ``GradingComplete``: An exception class that can be raised to indicated + that the grading code has concluded. + + * ``feedback``: A class instance with the following interface:: + + feedback.set_points(0.5) # 0<=points<=1 (usually) + feedback.add_feedback("This was wrong") + + # combines the above two and raises GradingComplete + feedback.finish(0, "This was wrong") + + feedback.check_numpy_array_sanity(name, num_axes, data) + + feedback.check_numpy_array_features(name, ref, data, report_failure=True) + + feedback.check_numpy_array_allclose(name, ref, data, + accuracy_critical=True, rtol=1e-5, atol=1e-8, + report_success=True, report_failure=True) + # If report_failure is True, this function will only return + # if *data* passes the tests. It will return *True* in this + # case. + # + # If report_failure is False, this function will always return, + # and the return value will indicate whether *data* passed the + # accuracy/shape/kind checks. + + feedback.check_list(name, ref, data, entry_type=None) + + feedback.check_scalar(name, ref, data, accuracy_critical=True, + rtol=1e-5, atol=1e-8, report_success=True, report_failure=True) + # returns True if accurate + + feedback.call_user(f, *args, **kwargs) + # Calls a user-supplied function and prints an appropriate + # feedback message in case of failure. + + * ``data_files``: A dictionary mapping file names from :attr:`data_files` + to :class:`bytes` instances with that file's contents. + + * ``user_code``: The user code being tested, as a string. + """ + + def __init__(self, vctx, location, page_desc): + super(OctaveCodeQuestion, self).__init__(vctx, location, page_desc) + + if vctx is not None and hasattr(page_desc, "data_files"): + for data_file in page_desc.data_files: + try: + if not isinstance(data_file, str): + raise ObjectDoesNotExist() + + from course.content import get_repo_blob + get_repo_blob(vctx.repo, data_file, vctx.commit_sha) + except ObjectDoesNotExist: + raise ValidationError( + string_concat( + "%(location)s: ", + _("data file '%(file)s' not found")) + % {"location": location, "file": data_file}) + + if not getattr(page_desc, "single_submission", False) and vctx is not None: + is_multi_submit = False + + if hasattr(page_desc, "access_rules"): + if hasattr(page_desc.access_rules, "add_permissions"): + if (flow_permission.change_answer + in page_desc.access_rules.add_permissions): + is_multi_submit = True + + if not is_multi_submit: + vctx.add_warning(location, _("code question does not explicitly " + "allow multiple submission. Either add " + "access_rules/add_permssions/change_answer " + "or add 'single_submission: True' to confirm that you intend " + "for only a single submission to be allowed. " + "While you're at it, consider adding " + "access_rules/add_permssions/see_correctness.")) + + def required_attrs(self): + return super(OctaveCodeQuestion, self).required_attrs() + ( + ("prompt", "markup"), + ("timeout", (int, float)), + ) + + def allowed_attrs(self): + return super(OctaveCodeQuestion, self).allowed_attrs() + ( + ("setup_code", str), + ("show_setup_code", bool), + ("names_for_user", list), + ("names_from_user", list), + ("test_code", str), + ("show_test_code", bool), + ("correct_code_explanation", "markup"), + ("correct_code", str), + ("initial_code", str), + ("data_files", list), + ("single_submission", bool), + ) + + def _initial_code(self): + result = getattr(self.page_desc, "initial_code", None) + if result is not None: + return result.strip() + else: + return result + + def markup_body_for_title(self): + return self.page_desc.prompt + + def body(self, page_context, page_data): + from django.template.loader import render_to_string + return render_to_string( + "course/prompt-code-question.html", + { + "prompt_html": + markup_to_html(page_context, self.page_desc.prompt), + "initial_code": self._initial_code(), + "show_setup_code": getattr( + self.page_desc, "show_setup_code", False), + "setup_code": getattr(self.page_desc, "setup_code", ""), + "show_test_code": getattr( + self.page_desc, "show_test_code", False), + "test_code": getattr(self.page_desc, "test_code", ""), + }) + + def make_form(self, page_context, page_data, + answer_data, page_behavior): + + if answer_data is not None: + answer = {"answer": answer_data["answer"]} + form = OctaveCodeForm( + not page_behavior.may_change_answer, + get_editor_interaction_mode(page_context), + self._initial_code(), + answer) + else: + answer = None + form = OctaveCodeForm( + not page_behavior.may_change_answer, + get_editor_interaction_mode(page_context), + self._initial_code(), + ) + + return form + + def process_form_post( + self, page_context, page_data, post_data, files_data, page_behavior): + return OctaveCodeForm( + not page_behavior.may_change_answer, + get_editor_interaction_mode(page_context), + self._initial_code(), + post_data, files_data) + + def answer_data(self, page_context, page_data, form, files_data): + return {"answer": form.cleaned_data["answer"].strip()} + + def get_test_code(self): + test_code = getattr(self.page_desc, "test_code", None) + if test_code is None: + return test_code + + correct_code = getattr(self.page_desc, "correct_code", None) + if correct_code is None: + correct_code = "" + + from .code_runoc_backend import substitute_correct_code_into_test_code + return substitute_correct_code_into_test_code(test_code, correct_code) + + def grade(self, page_context, page_data, answer_data, grade_data): + if answer_data is None: + return AnswerFeedback(correctness=0, + feedback=_("No answer provided.")) + + user_code = answer_data["answer"] + + # {{{ request run + + run_req = {"compile_only": False, "user_code": user_code} + + def transfer_attr(name): + if hasattr(self.page_desc, name): + run_req[name] = getattr(self.page_desc, name) + + transfer_attr("setup_code") + transfer_attr("names_for_user") + transfer_attr("names_from_user") + + run_req["test_code"] = self.get_test_code() + + if hasattr(self.page_desc, "data_files"): + run_req["data_files"] = {} + + from course.content import get_repo_blob + + # TODO: set this up to work with M-files for Octave functions + for data_file in self.page_desc.data_files: + from base64 import b64encode + run_req["data_files"][data_file] = \ + b64encode( + get_repo_blob( + page_context.repo, data_file, + page_context.commit_sha).data).decode() + + try: + response_dict = request_octave_run_with_retries(run_req, + run_timeout=self.page_desc.timeout) + except Exception: + from traceback import format_exc + response_dict = { + "result": "uncaught_error", + "message": "Error connecting to container", + "traceback": "".join(format_exc()), + } + + # }}} + + feedback_bits = [] + + correctness = None + + if "points" in response_dict: + correctness = response_dict["points"] + try: + feedback_bits.append( + "

%s

" + % _(get_auto_feedback(correctness))) + except Exception as e: + correctness = None + response_dict["result"] = "setup_error" + response_dict["message"] = ( + "%s: %s" % (type(e).__name__, str(e)) + ) + + # {{{ send email if the grading code broke + + if response_dict["result"] in [ + "uncaught_error", + "setup_compile_error", + "setup_error", + "test_compile_error", + "test_error"]: + error_msg_parts = ["RESULT: %s" % response_dict["result"]] + for key, val in sorted(response_dict.items()): + if (key not in ["result", "figures"] + and val + and isinstance(val, six.string_types)): + error_msg_parts.append("-------------------------------------") + error_msg_parts.append(key) + error_msg_parts.append("-------------------------------------") + error_msg_parts.append(val) + error_msg_parts.append("-------------------------------------") + error_msg_parts.append("user code") + error_msg_parts.append("-------------------------------------") + error_msg_parts.append(user_code) + error_msg_parts.append("-------------------------------------") + + error_msg = "\n".join(error_msg_parts) + + from relate.utils import local_now, format_datetime_local + from course.utils import LanguageOverride + with LanguageOverride(page_context.course): + from relate.utils import render_email_template + message = render_email_template( + "course/broken-code-question-email.txt", { + "site": getattr(settings, "RELATE_BASE_URL"), + "page_id": self.page_desc.id, + "course": page_context.course, + "error_message": error_msg, + "review_uri": page_context.page_uri, + "time": format_datetime_local(local_now()) + }) + + if ( + not page_context.in_sandbox + and not is_nuisance_failure(response_dict)): + try: + from django.core.mail import EmailMessage + msg = EmailMessage("".join(["[%s:%s] ", + _("code question execution failed")]) + % ( + page_context.course.identifier, + page_context.flow_session.flow_id + if page_context.flow_session is not None + else _("")), + message, + settings.ROBOT_EMAIL_FROM, + [page_context.course.notify_email]) + + from relate.utils import get_outbound_mail_connection + msg.connection = get_outbound_mail_connection("robot") + msg.send() + + except Exception: + from traceback import format_exc + feedback_bits.append( + six.text_type(string_concat( + "

", + _( + "Both the grading code and the attempt to " + "notify course staff about the issue failed. " + "Please contact the course or site staff and " + "inform them of this issue, mentioning this " + "entire error message:"), + "

", + "

", + _( + "Sending an email to the course staff about the " + "following failure failed with " + "the following error message:"), + "

",
+                                "".join(format_exc()),
+                                "
", + _("The original failure message follows:"), + "

"))) + + # }}} + + if hasattr(self.page_desc, "correct_code"): + def normalize_code(s): + return (s + .replace(" ", "") + .replace("\r", "") + .replace("\n", "") + .replace("\t", "")) + + if (normalize_code(user_code) + == normalize_code(self.page_desc.correct_code)): + feedback_bits.append( + "

%s

" + % _("It looks like you submitted code that is identical to " + "the reference solution. This is not allowed.")) + + from relate.utils import dict_to_struct + response = dict_to_struct(response_dict) + + bulk_feedback_bits = [] + + if response.result == "success": + pass + elif response.result in [ + "uncaught_error", + "setup_compile_error", + "setup_error", + "test_compile_error", + "test_error"]: + feedback_bits.append("".join([ + "

", + _( + "The grading code failed. Sorry about that. " + "The staff has been informed, and if this problem is " + "due to an issue with the grading code, " + "it will be fixed as soon as possible. " + "In the meantime, you'll see a traceback " + "below that may help you figure out what went wrong." + ), + "

"])) + elif response.result == "timeout": + feedback_bits.append("".join([ + "

", + _( + "Your code took too long to execute. The problem " + "specifies that your code may take at most %s seconds " + "to run. " + "It took longer than that and was aborted." + ), + "

"]) + % self.page_desc.timeout) + + correctness = 0 + elif response.result == "user_compile_error": + feedback_bits.append("".join([ + "

", + _("Your code failed to compile. An error message is " + "below."), + "

"])) + + correctness = 0 + elif response.result == "user_error": + feedback_bits.append("".join([ + "

", + _("Your code failed with an exception. " + "A traceback is below."), + "

"])) + + correctness = 0 + else: + raise RuntimeError("invalid runoc result: %s" % response.result) + + if hasattr(response, "feedback") and response.feedback: + def sanitize(s): + import bleach + return bleach.clean(s, tags=["p", "pre"]) + feedback_bits.append("".join([ + "

", + _("Here is some feedback on your code"), + ":" + "

"]) % + "".join( + "
  • %s
  • " % sanitize(fb_item) + for fb_item in response.feedback)) + if hasattr(response, "traceback") and response.traceback: + feedback_bits.append("".join([ + "

    ", + _("This is the exception traceback"), + ":" + "

    %s

    "]) % escape(response.traceback)) + if hasattr(response, "exec_host") and response.exec_host != "localhost": + import socket + try: + exec_host_name, dummy, dummy = socket.gethostbyaddr( + response.exec_host) + except socket.error: + exec_host_name = response.exec_host + + feedback_bits.append("".join([ + "

    ", + _("Your code ran on %s.") % exec_host_name, + "

    "])) + + if hasattr(response, "stdout") and response.stdout: + bulk_feedback_bits.append("".join([ + "

    ", + _("Your code printed the following output"), + ":" + "

    %s

    "]) + % escape(response.stdout)) + if hasattr(response, "stderr") and response.stderr: + bulk_feedback_bits.append("".join([ + "

    ", + _("Your code printed the following error messages"), + ":" + "

    %s

    "]) % escape(response.stderr)) + if hasattr(response, "figures") and response.figures: + fig_lines = ["".join([ + "

    ", + _("Your code produced the following plots"), + ":

    "]), + '
    ', + ] + + for nr, mime_type, b64data in response.figures: + if mime_type in ["image/jpeg", "image/png"]: + fig_lines.extend([ + "".join([ + "
    ", + _("Figure"), "%d
    "]) % nr, + '
    Figure %d
    ' + % (nr, mime_type, b64data)]) + + fig_lines.append("
    ") + bulk_feedback_bits.extend(fig_lines) + + # {{{ html output / santization + + if hasattr(response, "html") and response.html: + def is_allowed_data_uri(allowed_mimetypes, uri): + import re + m = re.match(r"^data:([-a-z0-9]+/[-a-z0-9]+);base64,", uri) + if not m: + return False + + mimetype = m.group(1) + return mimetype in allowed_mimetypes + + def sanitize(s): + import bleach + + def filter_audio_attributes(tag, name, value): + if name in ["controls"]: + return True + else: + return False + + def filter_source_attributes(tag, name, value): + if name in ["type"]: + return True + elif name == "src": + if is_allowed_data_uri([ + "audio/wav", + ], value): + return bleach.sanitizer.VALUE_SAFE + else: + return False + else: + return False + + def filter_img_attributes(tag, name, value): + if name in ["alt", "title"]: + return True + elif name == "src": + return is_allowed_data_uri([ + "image/png", + "image/jpeg", + ], value) + else: + return False + + if not isinstance(s, six.text_type): + return _("(Non-string in 'HTML' output filtered out)") + + return bleach.clean(s, + tags=bleach.ALLOWED_TAGS + ["audio", "video", "source"], + attributes={ + "audio": filter_audio_attributes, + "source": filter_source_attributes, + "img": filter_img_attributes, + }) + + bulk_feedback_bits.extend( + sanitize(snippet) for snippet in response.html) + + # }}} + + return AnswerFeedback( + correctness=correctness, + feedback="\n".join(feedback_bits), + bulk_feedback="\n".join(bulk_feedback_bits)) + + def correct_answer(self, page_context, page_data, answer_data, grade_data): + result = "" + + if hasattr(self.page_desc, "correct_code_explanation"): + result += markup_to_html( + page_context, + self.page_desc.correct_code_explanation) + + if hasattr(self.page_desc, "correct_code"): + result += ("".join([ + _("The following code is a valid answer"), + ":
    %s
    "]) + % escape(self.page_desc.correct_code)) + + return result + + def normalized_answer(self, page_context, page_data, answer_data): + if answer_data is None: + return None + + normalized_answer = answer_data["answer"] + + from django.utils.html import escape + return "
    %s
    " % escape(normalized_answer) + + def normalized_bytes_answer(self, page_context, page_data, answer_data): + if answer_data is None: + return None + + return (".m", answer_data["answer"].encode("utf-8")) + +# }}} + # vim: foldmethod=marker diff --git a/course/page/code_runoc_backend.py b/course/page/code_runoc_backend.py new file mode 100644 index 000000000..0c1499423 --- /dev/null +++ b/course/page/code_runoc_backend.py @@ -0,0 +1,314 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import + +__copyright__ = "Copyright (C) 2014 Andreas Kloeckner" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +import sys +import traceback + +try: + from .code_feedback import Feedback, GradingComplete +except SystemError: + from code_feedback import Feedback, GradingComplete # type: ignore +except ImportError: + from code_feedback import Feedback, GradingComplete # type: ignore + + +__doc__ = """ +PROTOCOL +======== + +.. class:: Request + + .. attribute:: setup_code + + .. attribute:: names_for_user + + .. attribute:: user_code + + .. attribute:: names_from_user + + .. attribute:: test_code + + .. attribute:: data_files + + A dictionary from data file names to their + base64-cencoded contents. + Optional. + + .. attribute:: compile_only + + :class:`bool` + +.. class Response:: + .. attribute:: result + + One of + + * ``success`` + * ``timeout`` + * ``uncaught_error`` + * ``setup_compile_error`` + * ``setup_error``, + * ``user_compile_error`` + * ``user_error`` + * ``test_compile_error`` + * ``test_error`` + + Always present. + + .. attribute:: message + + Optional. + + .. attribute:: traceback + + Optional. + + .. attribute:: stdout + + Whatever came out of stdout. + + Optional. + + .. attribute:: stderr + + Whatever came out of stderr. + + Optional. + + .. attribute:: figures + + A list of ``(index, mime_type, string)``, where *string* is a + base64-encoded representation of the figure. *index* will usually + correspond to the matplotlib figure number. + + Optional. + + .. attribute:: html + + A list of HTML strings generated. These are aggressively sanitized + before being rendered. + + .. attribute:: points + + A number between 0 and 1 (inclusive). + + Present on ``success`` if :attr:`Request.compile_only` is *False*. + + .. attribute:: feedback + + A list of strings. + + Present on ``success`` if :attr:`Request.compile_only` is *False*. +""" + + +# {{{ tools + +class Struct(object): + def __init__(self, entries): + for name, val in entries.items(): + self.__dict__[name] = val + + def __repr__(self): + return repr(self.__dict__) + +# }}} + + +def substitute_correct_code_into_test_code(test_code, correct_code): + import re + CORRECT_CODE_TAG = re.compile(r"^(\s*)###CORRECT_CODE###\s*$") # noqa + + new_test_code_lines = [] + for l in test_code.split("\n"): + match = CORRECT_CODE_TAG.match(l) + if match is not None: + prefix = match.group(1) + for cc_l in correct_code.split("\n"): + new_test_code_lines.append(prefix+cc_l) + else: + new_test_code_lines.append(l) + + return "\n".join(new_test_code_lines) + + +def package_exception(result, what): + tp, val, tb = sys.exc_info() + result["result"] = what + result["message"] = "%s: %s" % (tp.__name__, str(val)) + result["traceback"] = "".join( + traceback.format_exception(tp, val, tb)) + + +def run_code(result, run_req): + # {{{ silence matplotlib font cache warnings + + import warnings + warnings.filterwarnings( + "ignore", message="Matplotlib is building the font cache.*") + + # }}} + + # {{{ compile code + + if getattr(run_req, "setup_code", None): + try: + setup_code = compile( + run_req.setup_code, "[setup code]", 'exec') + except Exception: + package_exception(result, "setup_compile_error") + return + else: + setup_code = None + + try: + user_code = compile( + run_req.user_code, "[user code]", 'exec') + except Exception: + package_exception(result, "user_compile_error") + return + + if getattr(run_req, "test_code", None): + try: + test_code = compile( + run_req.test_code, "[test code]", 'exec') + except Exception: + package_exception(result, "test_compile_error") + return + else: + test_code = None + + # }}} + + if hasattr(run_req, "compile_only") and run_req.compile_only: + result["result"] = "success" + return + + # {{{ run code + + data_files = {} + if hasattr(run_req, "data_files"): + from base64 import b64decode + for name, contents in run_req.data_files.items(): + data_files[name] = b64decode(contents.encode()) + + generated_html = [] + result["html"] = generated_html + + def output_html(s): + generated_html.append(s) + + feedback = Feedback() + maint_ctx = { + "feedback": feedback, + "user_code": run_req.user_code, + "data_files": data_files, + "output_html": output_html, + "GradingComplete": GradingComplete, + } + + if setup_code is not None: + try: + maint_ctx["_MODULE_SOURCE_CODE"] = run_req.setup_code + exec(setup_code, maint_ctx) + except Exception: + package_exception(result, "setup_error") + return + + user_ctx = {} + if hasattr(run_req, "names_for_user"): + for name in run_req.names_for_user: + if name not in maint_ctx: + result["result"] = "setup_error" + result["message"] = "Setup code did not define '%s'." % name + + user_ctx[name] = maint_ctx[name] + + from copy import deepcopy + user_ctx = deepcopy(user_ctx) + + try: + user_ctx["_MODULE_SOURCE_CODE"] = run_req.user_code + exec(user_code, user_ctx) + except Exception: + package_exception(result, "user_error") + return + + # {{{ export plots + + ''' + if "matplotlib" in sys.modules: + import matplotlib.pyplot as pt + from io import BytesIO + from base64 import b64encode + + format = "png" + mime = "image/png" + figures = [] + + for fignum in pt.get_fignums(): + pt.figure(fignum) + bio = BytesIO() + try: + pt.savefig(bio, format=format) + except Exception: + pass + else: + figures.append( + (fignum, mime, b64encode(bio.getvalue()).decode())) + + result["figures"] = figures + ''' + # }}} + + if hasattr(run_req, "names_from_user"): + for name in run_req.names_from_user: + if name not in user_ctx: + feedback.add_feedback( + "Required answer variable '%s' is not defined." + % name) + maint_ctx[name] = None + else: + maint_ctx[name] = user_ctx[name] + + if test_code is not None: + try: + maint_ctx["_MODULE_SOURCE_CODE"] = run_req.test_code + exec(test_code, maint_ctx) + except GradingComplete: + pass + except Exception: + package_exception(result, "test_error") + return + + result["points"] = feedback.points + result["feedback"] = feedback.feedback_items + + # }}} + + result["result"] = "success" + +# vim: foldmethod=marker diff --git a/docker-image-run-octave/Dockerfile b/docker-image-run-octave/Dockerfile new file mode 100644 index 000000000..673e11d12 --- /dev/null +++ b/docker-image-run-octave/Dockerfile @@ -0,0 +1,57 @@ +FROM inducer/debian-amd64-minbase +MAINTAINER Neal Davis +EXPOSE 9942 +RUN useradd runoc + +# Based on `compdatasci/octave-desktop` Docker image + +# Temporarily needed for pandas + +ARG OCTAVE_VERSION=4.2.1 + +# Install system packages and Octave +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + wget \ + curl \ + build-essential \ + gfortran \ + cmake \ + bsdtar \ + rsync \ + imagemagick \ + \ + gnuplot-x11 \ + libopenblas-base \ + \ + octave \ + liboctave-dev \ + octave-info \ + octave-symbolic \ + octave-parallel \ + octave-struct \ + \ + python3-dev \ + pandoc \ + ttf-dejavu && \ + apt-get clean && \ + apt-get autoremove && \ + curl -L https://github.com/hbin/top-programming-fonts/raw/master/install.sh | bash && \ + rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +RUN apt-get clean +RUN fc-cache + +RUN mkdir -p /opt/runoc +ADD runoc /opt/runoc/ +COPY code_feedback.py /opt/runoc/ +COPY code_runoc_backend.py /opt/runoc/ + +# currently no graphics support +#RUN sed -i s/TkAgg/Agg/ /etc/matplotlibrc +#RUN echo "savefig.dpi : 80" >> /etc/matplotlibrc +#RUN echo "savefig.bbox : tight" >> /etc/matplotlibrc + +RUN rm -Rf /root/.cache + +# may use ./flatten-container.sh to reduce disk space diff --git a/docker-image-run-octave/docker-build.sh b/docker-image-run-octave/docker-build.sh new file mode 100755 index 000000000..eb04820fa --- /dev/null +++ b/docker-image-run-octave/docker-build.sh @@ -0,0 +1,4 @@ +#! /bin/sh +cp ../course/page/code_feedback.py ../course/page/code_runoc_backend.py . +docker build --no-cache . -t inducer/runoc +rm code_feedback.py code_runoc_backend.py diff --git a/docker-image-run-octave/flatten-container.sh b/docker-image-run-octave/flatten-container.sh new file mode 100755 index 000000000..d18bb6434 --- /dev/null +++ b/docker-image-run-octave/flatten-container.sh @@ -0,0 +1,12 @@ +#! /bin/bash + +if test "$1" = ""; then + echo "$0 imagename" + exit 1 +fi +CONTAINER=$(docker create "$1") +docker export "$CONTAINER" | \ + docker import \ + -c "EXPOSE 9941" \ + - +docker rm -f $CONTAINER diff --git a/docker-image-run-octave/runoc b/docker-image-run-octave/runoc new file mode 100755 index 000000000..3a5f6670f --- /dev/null +++ b/docker-image-run-octave/runoc @@ -0,0 +1,156 @@ +#! /usr/bin/env python3 + +# placate flake8 +from __future__ import print_function + +__copyright__ = "Copyright (C) 2014 Andreas Kloeckner" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +import socketserver +import json +import sys +import io +try: + from code_runoc_backend import Struct, run_code, package_exception +except ImportError: + try: + # When faking a container for unittest + from course.page.code_runoc_backend import ( + Struct, run_code, package_exception) + except ImportError: + # When debugging, i.e., run "python runpy" command line + import os + sys.path.insert(0, os.path.abspath( + os.path.join(os.path.dirname(__file__), os.pardir))) + from course.page.code_runoc_backend import ( + Struct, run_code, package_exception) + +from http.server import BaseHTTPRequestHandler + +PORT = 9942 +OUTPUT_LENGTH_LIMIT = 16*1024 + +TEST_COUNT = 0 + + +def truncate_if_long(s): + if len(s) > OUTPUT_LENGTH_LIMIT: + s = s[:OUTPUT_LENGTH_LIMIT] + "[TRUNCATED... TOO MUCH OUTPUT]" + return s + + +class RunRequestHandler(BaseHTTPRequestHandler): + def do_GET(self): + print("GET RECEIVED", file=sys.stderr) + if self.path != "/ping": + raise RuntimeError("unrecognized path in GET") + + self.send_response(200) + self.send_header("Content-type", "text/plain") + self.end_headers() + + self.wfile.write(b"OK") + print("PING RESPONSE DONE", file=sys.stderr) + + def do_POST(self): + global TEST_COUNT + TEST_COUNT += 1 + + response = {} + + prev_stdout = sys.stdout # noqa + prev_stderr = sys.stderr # noqa + + try: + print("POST RECEIVED", file=prev_stderr) + if self.path != "/run-octave": + raise RuntimeError("unrecognized path in POST") + + clength = int(self.headers['content-length']) + recv_data = self.rfile.read(clength) + + print("RUNOC RECEIVED %d bytes" % len(recv_data), + file=prev_stderr) + run_req = Struct(json.loads(recv_data.decode("utf-8"))) + print("REQUEST: %r" % run_req, file=prev_stderr) + + stdout = io.StringIO() + stderr = io.StringIO() + + sys.stdin = None + sys.stdout = stdout + sys.stderr = stderr + + run_code(response, run_req) + + response["stdout"] = truncate_if_long(stdout.getvalue()) + response["stderr"] = truncate_if_long(stderr.getvalue()) + + print("REQUEST SERVICED: %r" % response, file=prev_stderr) + + json_result = json.dumps(response).encode("utf-8") + + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + + print("WRITING RESPONSE", file=prev_stderr) + self.wfile.write(json_result) + print("WROTE RESPONSE", file=prev_stderr) + except: + print("ERROR RESPONSE", file=prev_stderr) + response = {} + package_exception(response, "uncaught_error") + json_result = json.dumps(response).encode("utf-8") + + self.send_response(500) + self.send_header("Content-type", "application/json") + self.end_headers() + + self.wfile.write(json_result) + finally: + sys.stdout = prev_stdout + sys.stderr = prev_stderr + + +def main(): + print("STARTING, LISTENING ON %d" % PORT, file=sys.stderr) + server = socketserver.TCPServer(("", PORT), RunRequestHandler) + + serve_single_test = len(sys.argv) > 1 and sys.argv[1] == "-1" + + while True: + server.handle_request() + print("SERVED REQUEST", file=sys.stderr) + if TEST_COUNT > 0 and serve_single_test: + break + + server.server_close() + print("FINISHED server_close()", file=sys.stderr) + + print("EXITING", file=sys.stderr) + + +if __name__ == "__main__": + main() + +# vim: foldmethod=marker diff --git a/docker-image-run-py/docker-build.sh b/docker-image-run-py/docker-build.sh index 3943a4e71..9f7b73bfc 100755 --- a/docker-image-run-py/docker-build.sh +++ b/docker-image-run-py/docker-build.sh @@ -1,5 +1,5 @@ #! /bin/sh cp ../course/page/code_feedback.py ../course/page/code_runpy_backend.py . -docker build --no-cache . +docker build --no-cache . -t inducer/relate-runpy-amd64 rm code_feedback.py code_runpy_backend.py From 50ca0d1d269ff4ad9000c388264277aa4952c314 Mon Sep 17 00:00:00 2001 From: Neal Davis Date: Sat, 15 Dec 2018 03:40:54 -0600 Subject: [PATCH 02/14] Use two steps to handle copying code over to Docker image. --- bin/relate | 0 course/page/code_runoc_backend.py | 67 ++-- docker-image-run-octave/Dockerfile | 9 +- .../runoc-update/Dockerfile | 14 + .../runoc-update/docker-build.sh | 4 + tests/test_pages/test_code.py | 286 ++++++++++++++++++ 6 files changed, 330 insertions(+), 50 deletions(-) mode change 100644 => 100755 bin/relate create mode 100644 docker-image-run-octave/runoc-update/Dockerfile create mode 100644 docker-image-run-octave/runoc-update/docker-build.sh diff --git a/bin/relate b/bin/relate old mode 100644 new mode 100755 diff --git a/course/page/code_runoc_backend.py b/course/page/code_runoc_backend.py index 0c1499423..525f7dff7 100644 --- a/course/page/code_runoc_backend.py +++ b/course/page/code_runoc_backend.py @@ -164,56 +164,24 @@ def package_exception(result, what): def run_code(result, run_req): - # {{{ silence matplotlib font cache warnings + # {{{ set up octave process - import warnings - warnings.filterwarnings( - "ignore", message="Matplotlib is building the font cache.*") + import oct2py - # }}} - - # {{{ compile code - - if getattr(run_req, "setup_code", None): - try: - setup_code = compile( - run_req.setup_code, "[setup code]", 'exec') - except Exception: - package_exception(result, "setup_compile_error") - return - else: - setup_code = None - - try: - user_code = compile( - run_req.user_code, "[user code]", 'exec') - except Exception: - package_exception(result, "user_compile_error") - return - - if getattr(run_req, "test_code", None): - try: - test_code = compile( - run_req.test_code, "[test code]", 'exec') - except Exception: - package_exception(result, "test_compile_error") - return - else: - test_code = None + oc = oct2py.Oct2Py() # }}} - if hasattr(run_req, "compile_only") and run_req.compile_only: - result["result"] = "success" - return - # {{{ run code data_files = {} if hasattr(run_req, "data_files"): from base64 import b64decode for name, contents in run_req.data_files.items(): + # This part "cheats" a litle, since Octave lets us evaluate functions + # in the same context as the main code. (MATLAB segregates these.) data_files[name] = b64decode(contents.encode()) + oc.eval(b64decode(contents.encode()).decode("utf-8")) generated_html = [] result["html"] = generated_html @@ -230,16 +198,16 @@ def output_html(s): "GradingComplete": GradingComplete, } - if setup_code is not None: + if run_req.setup_code is not None: try: - maint_ctx["_MODULE_SOURCE_CODE"] = run_req.setup_code - exec(setup_code, maint_ctx) + oc.eval(run_req.setup_code) except Exception: package_exception(result, "setup_error") return + ''' user_ctx = {} - if hasattr(run_req, "names_for_user"): + if hasattr(run_req, "names_for_user"): #XXX unused for Octave context currently for name in run_req.names_for_user: if name not in maint_ctx: result["result"] = "setup_error" @@ -249,10 +217,10 @@ def output_html(s): from copy import deepcopy user_ctx = deepcopy(user_ctx) + ''' try: - user_ctx["_MODULE_SOURCE_CODE"] = run_req.user_code - exec(user_code, user_ctx) + oc.eval(run_req.user_code) except Exception: package_exception(result, "user_error") return @@ -285,19 +253,20 @@ def output_html(s): # }}} if hasattr(run_req, "names_from_user"): + values = [] for name in run_req.names_from_user: - if name not in user_ctx: + try: + maint_ctx[name] = oc.pull(name) + except oct2py.Oct2PyError: feedback.add_feedback( "Required answer variable '%s' is not defined." % name) maint_ctx[name] = None - else: - maint_ctx[name] = user_ctx[name] - if test_code is not None: + if run_req.test_code is not None: # XXX test code is written in Python try: maint_ctx["_MODULE_SOURCE_CODE"] = run_req.test_code - exec(test_code, maint_ctx) + exec(run_req.test_code, maint_ctx) except GradingComplete: pass except Exception: diff --git a/docker-image-run-octave/Dockerfile b/docker-image-run-octave/Dockerfile index 673e11d12..6ce5f00c6 100644 --- a/docker-image-run-octave/Dockerfile +++ b/docker-image-run-octave/Dockerfile @@ -32,13 +32,20 @@ RUN apt-get update && \ octave-struct \ \ python3-dev \ + python3-setuptools \ + python3-pip \ + python3-numpy \ + python3-scipy \ + python3-matplotlib \ pandoc \ ttf-dejavu && \ apt-get clean && \ apt-get autoremove && \ - curl -L https://github.com/hbin/top-programming-fonts/raw/master/install.sh | bash && \ rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* +RUN apt-get update && \ + pip3 install oct2py + RUN apt-get clean RUN fc-cache diff --git a/docker-image-run-octave/runoc-update/Dockerfile b/docker-image-run-octave/runoc-update/Dockerfile new file mode 100644 index 000000000..634a3a567 --- /dev/null +++ b/docker-image-run-octave/runoc-update/Dockerfile @@ -0,0 +1,14 @@ +FROM inducer/runoc +MAINTAINER Neal Davis +EXPOSE 9942 +#RUN useradd runoc + +#RUN apt-get update && \ +# pip install oct2py + +RUN mkdir -p /opt/runoc +#ADD runoc /opt/runoc/ +COPY code_feedback.py /opt/runoc/ +COPY code_runoc_backend.py /opt/runoc/ + +RUN rm -Rf /root/.cache diff --git a/docker-image-run-octave/runoc-update/docker-build.sh b/docker-image-run-octave/runoc-update/docker-build.sh new file mode 100644 index 000000000..fb4e6ed9b --- /dev/null +++ b/docker-image-run-octave/runoc-update/docker-build.sh @@ -0,0 +1,4 @@ +#! /bin/sh +cp ../../course/page/code_feedback.py ../../course/page/code_runoc_backend.py . +docker build --no-cache . -t inducer/runoc-update +rm code_feedback.py code_runoc_backend.py diff --git a/tests/test_pages/test_code.py b/tests/test_pages/test_code.py index 5b22c1f53..374deefe6 100644 --- a/tests/test_pages/test_code.py +++ b/tests/test_pages/test_code.py @@ -35,6 +35,8 @@ from course.page.code import ( RUNPY_PORT, request_python_run_with_retries, InvalidPingResponse, is_nuisance_failure, PythonCodeQuestionWithHumanTextFeedback) +from course.page.code import ( + RUNOC_PORT, request_octave_run_with_retries) from course.utils import FlowPageContext, CoursePageContext from course.constants import MAX_EXTRA_CREDIT_FACTOR @@ -1284,6 +1286,290 @@ def test_docker_container_runpy_retries_count(self): self.assertEqual(mock_is_nuisance_failure.call_count, 1) +class RequestOctaveRunWithRetriesTest(unittest.TestCase): + # Testing course.page.code.request_octave_run_with_retries, + # adding tests for use cases that didn't cover in other tests + + @override_settings(RELATE_DOCKER_RUNOC_IMAGE="some_other_image") + def test_image_none(self): + # Testing if image is None, settings.RELATE_DOCKER_RUNPY_IMAGE is used + with mock.patch("docker.client.Client.create_container") as mock_create_ctn: + + # this will raise KeyError + mock_create_ctn.return_value = {} + + with self.assertRaises(KeyError): + request_python_run_with_retries( + run_req={}, run_timeout=0.1) + self.assertEqual(mock_create_ctn.call_count, 1) + self.assertIn("some_other_image", mock_create_ctn.call_args[0]) + + @override_settings(RELATE_DOCKER_RUNOC_IMAGE="some_other_image") + def test_image_not_none(self): + # Testing if image is None, settings.RELATE_DOCKER_RUNPY_IMAGE is used + with mock.patch("docker.client.Client.create_container") as mock_create_ctn: + + # this will raise KeyError + mock_create_ctn.return_value = {} + + my_image = "my_runoc_image" + + with self.assertRaises(KeyError): + request_python_run_with_retries( + run_req={}, image=my_image, run_timeout=0.1) + self.assertEqual(mock_create_ctn.call_count, 1) + self.assertIn(my_image, mock_create_ctn.call_args[0]) + + @skipIf(six.PY2, "PY2 doesn't support subTest") + def test_docker_container_ping_failure(self): + with ( + mock.patch("docker.client.Client.create_container")) as mock_create_ctn, ( # noqa + mock.patch("docker.client.Client.start")) as mock_ctn_start, ( + mock.patch("docker.client.Client.logs")) as mock_ctn_logs, ( + mock.patch("docker.client.Client.remove_container")) as mock_remove_ctn, ( # noqa + mock.patch("docker.client.Client.inspect_container")) as mock_inpect_ctn, ( # noqa + mock.patch("six.moves.http_client.HTTPConnection.request")) as mock_ctn_request: # noqa + + mock_create_ctn.return_value = {"Id": "someid"} + mock_ctn_start.side_effect = lambda x: None + mock_ctn_logs.side_effect = lambda x: None + mock_remove_ctn.return_value = None + fake_host_ip = "192.168.1.100" + fake_host_port = "69999" + + mock_inpect_ctn.return_value = { + "NetworkSettings": { + "Ports": {"%d/tcp" % RUNPY_PORT: ( + {"HostIp": fake_host_ip, "HostPort": fake_host_port}, + )} + }} + + with self.subTest(case="Docker ping timeout with BadStatusLine Error"): + from six.moves.http_client import BadStatusLine + fake_bad_statusline_msg = "my custom bad status" + mock_ctn_request.side_effect = BadStatusLine(fake_bad_statusline_msg) + + # force timeout + with mock.patch("course.page.code.DOCKER_TIMEOUT", 0.0001): + res = request_python_run_with_retries( + run_req={}, run_timeout=0.1, retry_count=0) + self.assertEqual(res["result"], "uncaught_error") + self.assertEqual(res['message'], + "Timeout waiting for container.") + self.assertEqual(res["exec_host"], fake_host_ip) + self.assertIn(fake_bad_statusline_msg, res["traceback"]) + + with self.subTest( + case="Docker ping timeout with InvalidPingResponse Error"): + invalid_ping_resp_msg = "my custom invalid ping response exception" + mock_ctn_request.side_effect = ( + InvalidPingResponse(invalid_ping_resp_msg)) + + # force timeout + with mock.patch("course.page.code.DOCKER_TIMEOUT", 0.0001): + res = request_python_run_with_retries( + run_req={}, run_timeout=0.1, retry_count=0) + self.assertEqual(res["result"], "uncaught_error") + self.assertEqual(res['message'], + "Timeout waiting for container.") + self.assertEqual(res["exec_host"], fake_host_ip) + self.assertIn(InvalidPingResponse.__name__, res["traceback"]) + self.assertIn(invalid_ping_resp_msg, res["traceback"]) + + with self.subTest( + case="Docker ping socket error with erron ECONNRESET"): + my_socket_error = socket_error() + my_socket_error.errno = errno.ECONNRESET + mock_ctn_request.side_effect = my_socket_error + + # force timeout + with mock.patch("course.page.code.DOCKER_TIMEOUT", 0.0001): + res = request_python_run_with_retries( + run_req={}, run_timeout=0.1, retry_count=0) + self.assertEqual(res["result"], "uncaught_error") + self.assertEqual(res['message'], + "Timeout waiting for container.") + self.assertEqual(res["exec_host"], fake_host_ip) + self.assertIn(type(my_socket_error).__name__, res["traceback"]) + + with self.subTest( + case="Docker ping socket error with erron ECONNREFUSED"): + my_socket_error = socket_error() + my_socket_error.errno = errno.ECONNREFUSED + mock_ctn_request.side_effect = my_socket_error + + # force timeout + with mock.patch("course.page.code.DOCKER_TIMEOUT", 0.0001): + res = request_python_run_with_retries( + run_req={}, run_timeout=0.1, retry_count=0) + self.assertEqual(res["result"], "uncaught_error") + self.assertEqual(res['message'], + "Timeout waiting for container.") + self.assertEqual(res["exec_host"], fake_host_ip) + self.assertIn(type(my_socket_error).__name__, res["traceback"]) + + with self.subTest( + case="Docker ping socket error with erron EAFNOSUPPORT"): + my_socket_error = socket_error() + + # This errno should raise error + my_socket_error.errno = errno.EAFNOSUPPORT + mock_ctn_request.side_effect = my_socket_error + + # force timeout + with mock.patch("course.page.code.DOCKER_TIMEOUT", 0.0001): + with self.assertRaises(socket_error) as e: + request_python_run_with_retries( + run_req={}, run_timeout=0.1, retry_count=0) + self.assertEqual(e.exception.errno, my_socket_error.errno) + + with self.assertRaises(socket_error) as e: + request_python_run_with_retries( + run_req={}, run_timeout=0.1, retry_count=0) + self.assertEqual(e.exception.errno, my_socket_error.errno) + + # This should be the last subTest, because this will the behavior of + # change mock_remove_ctn + with self.subTest( + case="Docker ping timeout with InvalidPingResponse and " + "remove container failed with APIError"): + invalid_ping_resp_msg = "my custom invalid ping response exception" + fake_host_ip = "0.0.0.0" + + mock_inpect_ctn.return_value = { + "NetworkSettings": { + "Ports": {"%d/tcp" % RUNPY_PORT: ( + {"HostIp": fake_host_ip, "HostPort": fake_host_port}, + )} + }} + + mock_ctn_request.side_effect = ( + InvalidPingResponse(invalid_ping_resp_msg)) + mock_remove_ctn.reset_mock() + from django.http import HttpResponse + fake_response_content = "this should not appear" + mock_remove_ctn.side_effect = DockerAPIError( + message="my custom docker api error", + response=HttpResponse(content=fake_response_content)) + + # force timeout + with mock.patch("course.page.code.DOCKER_TIMEOUT", 0.0001): + res = request_python_run_with_retries( + run_req={}, run_timeout=0.1, retry_count=0) + self.assertEqual(res["result"], "uncaught_error") + self.assertEqual(res['message'], + "Timeout waiting for container.") + self.assertEqual(res["exec_host"], "localhost") + self.assertIn(InvalidPingResponse.__name__, res["traceback"]) + self.assertIn(invalid_ping_resp_msg, res["traceback"]) + + # No need to bother the students with this nonsense. + self.assertNotIn(DockerAPIError.__name__, res["traceback"]) + self.assertNotIn(fake_response_content, res["traceback"]) + + @skipIf(six.PY2, "PY2 doesn't support subTest") + def test_docker_container_ping_return_not_ok(self): + with ( + mock.patch("docker.client.Client.create_container")) as mock_create_ctn, ( # noqa + mock.patch("docker.client.Client.start")) as mock_ctn_start, ( + mock.patch("docker.client.Client.logs")) as mock_ctn_logs, ( + mock.patch("docker.client.Client.remove_container")) as mock_remove_ctn, ( # noqa + mock.patch("docker.client.Client.inspect_container")) as mock_inpect_ctn, ( # noqa + mock.patch("six.moves.http_client.HTTPConnection.request")) as mock_ctn_request, ( # noqa + mock.patch("six.moves.http_client.HTTPConnection.getresponse")) as mock_ctn_get_response: # noqa + + mock_create_ctn.return_value = {"Id": "someid"} + mock_ctn_start.side_effect = lambda x: None + mock_ctn_logs.side_effect = lambda x: None + mock_remove_ctn.return_value = None + fake_host_ip = "192.168.1.100" + fake_host_port = "69999" + + mock_inpect_ctn.return_value = { + "NetworkSettings": { + "Ports": {"%d/tcp" % RUNPY_PORT: ( + {"HostIp": fake_host_ip, "HostPort": fake_host_port}, + )} + }} + + # force timeout + with mock.patch("course.page.code.DOCKER_TIMEOUT", 0.0001): + with self.subTest( + case="Docker ping response not OK"): + mock_ctn_request.side_effect = lambda x, y: None + mock_ctn_get_response.return_value = six.BytesIO(b"NOT OK") + + res = request_python_run_with_retries( + run_req={}, run_timeout=0.1, retry_count=0) + self.assertEqual(res["result"], "uncaught_error") + self.assertEqual(res['message'], + "Timeout waiting for container.") + self.assertEqual(res["exec_host"], fake_host_ip) + self.assertIn(InvalidPingResponse.__name__, res["traceback"]) + + @skipIf(six.PY2, "PY2 doesn't support subTest") + def test_docker_container_runpy_timeout(self): + with ( + mock.patch("docker.client.Client.create_container")) as mock_create_ctn, ( # noqa + mock.patch("docker.client.Client.start")) as mock_ctn_start, ( + mock.patch("docker.client.Client.logs")) as mock_ctn_logs, ( + mock.patch("docker.client.Client.remove_container")) as mock_remove_ctn, ( # noqa + mock.patch("docker.client.Client.inspect_container")) as mock_inpect_ctn, ( # noqa + mock.patch("six.moves.http_client.HTTPConnection.request")) as mock_ctn_request, ( # noqa + mock.patch("six.moves.http_client.HTTPConnection.getresponse")) as mock_ctn_get_response: # noqa + + mock_create_ctn.return_value = {"Id": "someid"} + mock_ctn_start.side_effect = lambda x: None + mock_ctn_logs.side_effect = lambda x: None + mock_remove_ctn.return_value = None + fake_host_ip = "192.168.1.100" + fake_host_port = "69999" + + mock_inpect_ctn.return_value = { + "NetworkSettings": { + "Ports": {"%d/tcp" % RUNOC_PORT: ( + {"HostIp": fake_host_ip, "HostPort": fake_host_port}, + )} + }} + + with self.subTest( + case="Docker ping passed by runpy timed out"): + + # first request is ping, second request raise socket.timeout + mock_ctn_request.side_effect = [None, sock_timeout] + mock_ctn_get_response.return_value = six.BytesIO(b"OK") + + res = request_python_run_with_retries( + run_req={}, run_timeout=0.1, retry_count=0) + self.assertEqual(res["result"], "timeout") + self.assertEqual(res["exec_host"], fake_host_ip) + + @skipIf(six.PY2, "PY2 doesn't support subTest") + def test_docker_container_runoc_retries_count(self): + with ( + mock.patch("course.page.code.request_octave_run")) as mock_req_run, ( # noqa + mock.patch("course.page.code.is_nuisance_failure")) as mock_is_nuisance_failure: # noqa + expected_result = "this is my custom result" + mock_req_run.return_value = {"result": expected_result} + with self.subTest(actual_retry_count=4): + mock_is_nuisance_failure.side_effect = [True, True, True, False] + res = request_python_run_with_retries( + run_req={}, run_timeout=0.1, retry_count=5) + self.assertEqual(res["result"], expected_result) + self.assertEqual(mock_req_run.call_count, 4) + self.assertEqual(mock_is_nuisance_failure.call_count, 4) + + mock_req_run.reset_mock() + mock_is_nuisance_failure.reset_mock() + with self.subTest(actual_retry_count=2): + mock_is_nuisance_failure.side_effect = [True, True, True, False] + res = request_python_run_with_retries( + run_req={}, run_timeout=0.1, retry_count=1) + self.assertEqual(res["result"], expected_result) + self.assertEqual(mock_req_run.call_count, 2) + self.assertEqual(mock_is_nuisance_failure.call_count, 1) + + class IsNuisanceFailureTest(unittest.TestCase): # Testing is_nuisance_failure From 0e8768dca313939e592bc694afbd705d1e383dd3 Mon Sep 17 00:00:00 2001 From: Neal Davis Date: Sun, 30 Dec 2018 16:06:10 -0600 Subject: [PATCH 03/14] Flatten Docker image back into one. --- docker-image-run-octave/runoc-update/Dockerfile | 14 -------------- .../runoc-update/docker-build.sh | 4 ---- 2 files changed, 18 deletions(-) delete mode 100644 docker-image-run-octave/runoc-update/Dockerfile delete mode 100644 docker-image-run-octave/runoc-update/docker-build.sh diff --git a/docker-image-run-octave/runoc-update/Dockerfile b/docker-image-run-octave/runoc-update/Dockerfile deleted file mode 100644 index 634a3a567..000000000 --- a/docker-image-run-octave/runoc-update/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM inducer/runoc -MAINTAINER Neal Davis -EXPOSE 9942 -#RUN useradd runoc - -#RUN apt-get update && \ -# pip install oct2py - -RUN mkdir -p /opt/runoc -#ADD runoc /opt/runoc/ -COPY code_feedback.py /opt/runoc/ -COPY code_runoc_backend.py /opt/runoc/ - -RUN rm -Rf /root/.cache diff --git a/docker-image-run-octave/runoc-update/docker-build.sh b/docker-image-run-octave/runoc-update/docker-build.sh deleted file mode 100644 index fb4e6ed9b..000000000 --- a/docker-image-run-octave/runoc-update/docker-build.sh +++ /dev/null @@ -1,4 +0,0 @@ -#! /bin/sh -cp ../../course/page/code_feedback.py ../../course/page/code_runoc_backend.py . -docker build --no-cache . -t inducer/runoc-update -rm code_feedback.py code_runoc_backend.py From 3491cd3818a171c4da66f3d5225ecd56134430b9 Mon Sep 17 00:00:00 2001 From: Neal Davis Date: Sun, 30 Dec 2018 16:11:15 -0600 Subject: [PATCH 04/14] Include Docker image by name. It needs to be built first. --- local_settings_example.py | 1 + 1 file changed, 1 insertion(+) diff --git a/local_settings_example.py b/local_settings_example.py index 17d982f14..75ddeeb39 100644 --- a/local_settings_example.py +++ b/local_settings_example.py @@ -343,6 +343,7 @@ # A string containing the image ID of the docker image to be used to run # student Python code. Docker should download the image on first run. RELATE_DOCKER_RUNPY_IMAGE = "inducer/relate-runpy-amd64" +RELATE_DOCKER_RUNOC_IMAGE = "inducer/runoc" # RELATE_DOCKER_RUNPY_IMAGE = "inducer/relate-runpy-amd64-tensorflow" # (bigger, but includes TensorFlow) From 6f8980c76f46173b21833c119425874fddf0e1a7 Mon Sep 17 00:00:00 2001 From: Neal Davis Date: Tue, 2 Apr 2019 16:55:58 -0500 Subject: [PATCH 05/14] First stab at a CodeQuestion interface. UNTESTED but pushed for backup --- bin/relate | 7 +- course/page/code.py | 1100 +++++------------ course/page/code_run_backend.py | 313 +++++ docker-image-run-octave/Dockerfile | 11 +- docker-image-run-octave/docker-build.sh | 5 +- docker-image-run-octave/{runoc => runcode} | 6 +- .../runoc-update/Dockerfile | 14 + .../runoc-update/docker-build.sh | 5 + docker-image-run-py/Dockerfile | 10 +- docker-image-run-py/docker-build.sh | 6 +- docker-image-run-py/{runpy => runcode} | 6 +- setup.cfg | 2 +- tests/base_test_mixins.py | 8 +- 13 files changed, 645 insertions(+), 848 deletions(-) create mode 100644 course/page/code_run_backend.py rename docker-image-run-octave/{runoc => runcode} (96%) create mode 100644 docker-image-run-octave/runoc-update/Dockerfile create mode 100644 docker-image-run-octave/runoc-update/docker-build.sh rename docker-image-run-py/{runpy => runcode} (96%) diff --git a/bin/relate b/bin/relate index 90879fd71..a5243fd86 100755 --- a/bin/relate +++ b/bin/relate @@ -52,7 +52,8 @@ def expand_yaml(yml_file, repo_root): def test_code_question(page_desc, repo_root): if page_desc.type not in [ "PythonCodeQuestion", - "PythonCodeQuestionWithHumanTextFeedback"]: + "PythonCodeQuestionWithHumanTextFeedback", + "OctaveCodeQuestion"]: return print(75*"-") @@ -64,11 +65,11 @@ def test_code_question(page_desc, repo_root): correct_code = getattr(page_desc, "correct_code", "") - from course.page.code_runpy_backend import \ + from course.page.code_run_backend import \ substitute_correct_code_into_test_code test_code = substitute_correct_code_into_test_code(test_code, correct_code) - from course.page.code_runpy_backend import run_code, package_exception + from course.page.code_run_backend import run_code, package_exception data_files = {} diff --git a/course/page/code.py b/course/page/code.py index d95ff2faa..017451e70 100644 --- a/course/page/code.py +++ b/course/page/code.py @@ -44,24 +44,23 @@ # DEBUGGING SWITCH: # True for 'spawn containers' (normal operation) -# False for 'just connect to localhost:RUNPY_PORT' for runpy' -SPAWN_CONTAINERS_FOR_RUNPY = True -SPAWN_CONTAINERS_FOR_RUNOC = True +# False for 'just connect to localhost:CODE_QUESTION_CONTAINER_PORT' for runcode' +SPAWN_CONTAINERS = True +# {{{ base code question -# {{{ python code question - -class PythonCodeForm(StyledForm): +class CodeForm(StyledForm): # prevents form submission with codemirror's empty textarea use_required_attribute = False def __init__(self, read_only, interaction_mode, initial_code, - data=None, *args, **kwargs): - super(PythonCodeForm, self).__init__(data, *args, **kwargs) + language_mode, data=None, *args, **kwargs): + super(CodeForm, self).__init__(data, *args, **kwargs) + self.language_mode = language_mode from course.utils import get_codemirror_widget cm_widget, cm_help_text = get_codemirror_widget( - language_mode="python", + language_mode=language_mode, interaction_mode=interaction_mode, read_only=read_only, @@ -84,7 +83,7 @@ def clean(self): pass -RUNPY_PORT = 9941 +CODE_QUESTION_CONTAINER_PORT = 9941 DOCKER_TIMEOUT = 15 @@ -92,7 +91,7 @@ class InvalidPingResponse(RuntimeError): pass -def request_python_run(run_req, run_timeout, image=None): +def request_run(run_req, run_timeout, image=None): import json from six.moves import http_client import docker @@ -100,7 +99,7 @@ def request_python_run(run_req, run_timeout, image=None): import errno from docker.errors import APIError as DockerAPIError - debug = False + debug = True if debug: def debug_print(s): print(s) @@ -108,7 +107,11 @@ def debug_print(s): def debug_print(s): pass - if SPAWN_CONTAINERS_FOR_RUNPY: + image = self.container_image + command_path = '/opt/runcode/runcode' + user = 'runcode' + + if SPAWN_CONTAINERS: docker_url = getattr(settings, "RELATE_DOCKER_URL", "unix://var/run/docker.sock") docker_tls = getattr(settings, "RELATE_DOCKER_TLS_CONFIG", @@ -119,13 +122,10 @@ def debug_print(s): timeout=DOCKER_TIMEOUT, version="1.19") - if image is None: - image = settings.RELATE_DOCKER_RUNPY_IMAGE - dresult = docker_cnx.create_container( image=image, command=[ - "/opt/runpy/runpy", + command_path, "-1"], host_config={ "Memory": 384*10**6, @@ -134,7 +134,7 @@ def debug_print(s): # Do not enable: matplotlib stops working if enabled. # "ReadonlyRootfs": True, }, - user="runpy") + user=user) container_id = dresult["Id"] else: @@ -150,7 +150,7 @@ def debug_print(s): container_props = docker_cnx.inspect_container(container_id) (port_info,) = (container_props - ["NetworkSettings"]["Ports"]["%d/tcp" % RUNPY_PORT]) + ["NetworkSettings"]["Ports"]["%d/tcp" % RUNOC_PORT]) port_host_ip = port_info.get("HostIp") if port_host_ip != "0.0.0.0": @@ -158,7 +158,7 @@ def debug_print(s): port = int(port_info["HostPort"]) else: - port = RUNPY_PORT + port = CODE_QUESTION_CONTAINER_PORT from time import time, sleep start_time = time() @@ -224,7 +224,7 @@ def check_timeout(): start_time = time() debug_print("BEFPOST") - connection.request('POST', '/run-python', json_run_req, headers) + connection.request('POST', '/run-%s'%language_mode, json_run_req, headers) debug_print("AFTPOST") http_response = connection.getresponse() @@ -261,6 +261,7 @@ def check_timeout(): # Oh well. No need to bother the students with this nonsense. pass + def is_nuisance_failure(result): if result["result"] != "uncaught_error": return False @@ -292,9 +293,9 @@ def is_nuisance_failure(result): return False -def request_python_run_with_retries(run_req, run_timeout, image=None, retry_count=3): +def request_run_with_retries(run_req, run_timeout, image=None, retry_count=3): while True: - result = request_python_run(run_req, run_timeout, image=image) + result = request_run(run_req, run_timeout, image=image) if retry_count and is_nuisance_failure(result): retry_count -= 1 @@ -303,11 +304,12 @@ def request_python_run_with_retries(run_req, run_timeout, image=None, retry_coun return result -class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue): +class CodeQuestion(PageBaseWithTitle, PageBaseWithValue): """ - An auto-graded question allowing an answer consisting of Python code. + An auto-graded question allowing an answer consisting of code. All user code as well as all code specified as part of the problem - is in Python 3. + is in the specified language. This class should be treated as an + interface and used only as a superclass. If you are not including the :attr:`course.constants.flow_permission.change_answer` @@ -329,7 +331,7 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue): .. attribute:: type - ``PythonCodeQuestion`` + ``CodeQuestion`` .. attribute:: is_optional_page @@ -360,7 +362,7 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue): .. attribute:: setup_code Optional. - Python code to prepare the environment for the participants + Language-specific code to prepare the environment for the participants answer. .. attribute:: show_setup_code @@ -479,8 +481,9 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue): * ``user_code``: The user code being tested, as a string. """ - def __init__(self, vctx, location, page_desc): - super(PythonCodeQuestion, self).__init__(vctx, location, page_desc) + def __init__(self, vctx, location, page_desc, language_mode): + super(CodeQuestion, self).__init__(vctx, location, page_desc) + self.language_mode = language_mode if vctx is not None and hasattr(page_desc, "data_files"): for data_file in page_desc.data_files: @@ -516,13 +519,13 @@ def __init__(self, vctx, location, page_desc): "access_rules/add_permssions/see_correctness.")) def required_attrs(self): - return super(PythonCodeQuestion, self).required_attrs() + ( + return super(CodeQuestion, self).required_attrs() + ( ("prompt", "markup"), ("timeout", (int, float)), ) def allowed_attrs(self): - return super(PythonCodeQuestion, self).allowed_attrs() + ( + return super(CodeQuestion, self).allowed_attrs() + ( ("setup_code", str), ("show_setup_code", bool), ("names_for_user", list), @@ -567,14 +570,14 @@ def make_form(self, page_context, page_data, if answer_data is not None: answer = {"answer": answer_data["answer"]} - form = PythonCodeForm( + form = CodeForm( not page_behavior.may_change_answer, get_editor_interaction_mode(page_context), self._initial_code(), answer) else: answer = None - form = PythonCodeForm( + form = CodeForm( not page_behavior.may_change_answer, get_editor_interaction_mode(page_context), self._initial_code(), @@ -584,7 +587,7 @@ def make_form(self, page_context, page_data, def process_form_post( self, page_context, page_data, post_data, files_data, page_behavior): - return PythonCodeForm( + return CodeForm( not page_behavior.may_change_answer, get_editor_interaction_mode(page_context), self._initial_code(), @@ -602,7 +605,7 @@ def get_test_code(self): if correct_code is None: correct_code = "" - from .code_runpy_backend import substitute_correct_code_into_test_code + from .code_run_backend import substitute_correct_code_into_test_code return substitute_correct_code_into_test_code(test_code, correct_code) def grade(self, page_context, page_data, answer_data, grade_data): @@ -640,8 +643,9 @@ def transfer_attr(name): page_context.commit_sha).data).decode() try: - response_dict = request_python_run_with_retries(run_req, - run_timeout=self.page_desc.timeout) + response_dict = request_run_with_retries(run_req, + run_timeout=self.page_desc.timeout, + language_mode=self.language_mode) except Exception: from traceback import format_exc response_dict = { @@ -822,7 +826,7 @@ def normalize_code(s): correctness = 0 else: - raise RuntimeError("invalid runpy result: %s" % response.result) + raise RuntimeError("invalid runoc result: %s" % response.result) if hasattr(response, "feedback") and response.feedback: def sanitize(s): @@ -980,13 +984,212 @@ def normalized_answer(self, page_context, page_data, answer_data): return "
    %s
    " % escape(normalized_answer) def normalized_bytes_answer(self, page_context, page_data, answer_data): - if answer_data is None: - return None + if answer_data is None: + return None - return (".py", answer_data["answer"].encode("utf-8")) + suffix = self.suffix + return (suffix, answer_data["answer"].encode("utf-8")) # }}} +# {{{ python code question + +class PythonCodeForm(CodeForm): + def __init__(self, vctx, location, page_desc): + super(PythonCodeQuestion, self).__init__(self, vctx, location, page_desc, language_mode='python') + +class PythonCodeQuestion(CodeQuestion): + """ + An auto-graded question allowing an answer consisting of Python code. + All user code as well as all code specified as part of the problem + is in Python 3. + + If you are not including the + :attr:`course.constants.flow_permission.change_answer` + permission for your entire flow, you likely want to + include this snippet in your question definition: + + .. code-block:: yaml + + access_rules: + add_permissions: + - change_answer + + This will allow participants multiple attempts at getting + the right answer. + + .. attribute:: id + + |id-page-attr| + + .. attribute:: type + + ``PythonCodeQuestion`` + + .. attribute:: is_optional_page + + |is-optional-page-attr| + + .. attribute:: access_rules + + |access-rules-page-attr| + + .. attribute:: title + + |title-page-attr| + + .. attribute:: value + + |value-page-attr| + + .. attribute:: prompt + + The page's prompt, written in :ref:`markup`. + + .. attribute:: timeout + + A number, giving the number of seconds for which setup code, + the given answer code, and the test code (combined) will be + allowed to run. + + .. attribute:: setup_code + + Optional. + Python code to prepare the environment for the participants + answer. + + .. attribute:: show_setup_code + + Optional. ``True`` or ``False``. If true, the :attr:`setup_code` + will be shown to the participant. + + .. attribute:: names_for_user + + Optional. + Symbols defined at the end of the :attr:`setup_code` that will be + made available to the participant's code. + + A deep copy (using the standard library function :func:`copy.deepcopy`) + of these values is made, to prevent the user from modifying trusted + state of the grading code. + + .. attribute:: names_from_user + + Optional. + Symbols that the participant's code is expected to define. + These will be made available to the :attr:`test_code`. + + .. attribute:: test_code + + Optional. + Code that will be run to determine the correctness of a + student-provided solution. Will have access to variables in + :attr:`names_from_user` (which will be *None*) if not provided. Should + never raise an exception. + + This may contain the marker "###CORRECT_CODE###", which will + be replaced with the contents of :attr:`correct_code`, with + each line indented to the same depth as where the marker + is found. The line with this marker is only allowed to have + white space and the marker on it. + + .. attribute:: show_test_code + + Optional. ``True`` or ``False``. If true, the :attr:`test_code` + will be shown to the participant. + + .. attribute:: correct_code_explanation + + Optional. + Code that is revealed when answers are visible + (see :ref:`flow-permissions`). This is shown before + :attr:`correct_code` as an explanation. + + .. attribute:: correct_code + + Optional. + Code that is revealed when answers are visible + (see :ref:`flow-permissions`). + + .. attribute:: initial_code + + Optional. + Code present in the code input field when the participant first starts + working on their solution. + + .. attribute:: data_files + + Optional. + A list of file names in the :ref:`git-repo` whose contents will be made + available to :attr:`setup_code` and :attr:`test_code` through the + ``data_files`` dictionary. (see below) + + .. attribute:: single_submission + + Optional, a Boolean. If the question does not allow multiple submissions + based on its :attr:`access_rules` (not the ones of the flow), a warning + is shown. Setting this attribute to True will silence the warning. + + The following symbols are available in :attr:`setup_code` and :attr:`test_code`: + + * ``GradingComplete``: An exception class that can be raised to indicated + that the grading code has concluded. + + * ``feedback``: A class instance with the following interface:: + + feedback.set_points(0.5) # 0<=points<=1 (usually) + feedback.add_feedback("This was wrong") + + # combines the above two and raises GradingComplete + feedback.finish(0, "This was wrong") + + feedback.check_numpy_array_sanity(name, num_axes, data) + + feedback.check_numpy_array_features(name, ref, data, report_failure=True) + + feedback.check_numpy_array_allclose(name, ref, data, + accuracy_critical=True, rtol=1e-5, atol=1e-8, + report_success=True, report_failure=True) + # If report_failure is True, this function will only return + # if *data* passes the tests. It will return *True* in this + # case. + # + # If report_failure is False, this function will always return, + # and the return value will indicate whether *data* passed the + # accuracy/shape/kind checks. + + feedback.check_list(name, ref, data, entry_type=None) + + feedback.check_scalar(name, ref, data, accuracy_critical=True, + rtol=1e-5, atol=1e-8, report_success=True, report_failure=True) + # returns True if accurate + + feedback.call_user(f, *args, **kwargs) + # Calls a user-supplied function and prints an appropriate + # feedback message in case of failure. + + * ``data_files``: A dictionary mapping file names from :attr:`data_files` + to :class:`bytes` instances with that file's contents. + + * ``user_code``: The user code being tested, as a string. + """ + + @property + def language_mode(self): + return 'python' + + @property + def container_image(self): + return settings.RELATE_DOCKER_RUNPY_IMAGE + + @property + def suffix(self): + return '.py' + + def __init__(self, vctx, location, page_desc, language_mode='python'): + super(PythonCodeQuestion, self).__init__(self, vctx, location, page_desc) + +# }}} # {{{ python code question with human feedback @@ -1192,292 +1395,43 @@ def grade(self, page_context, page_data, answer_data, grade_data): # {{{ octave code question -class OctaveCodeForm(StyledForm): - # prevents form submission with codemirror's empty textarea - use_required_attribute = False +class OctaveCodeForm(CodeForm): + def __init__(self, vctx, location, page_desc): + super(PythonCodeQuestion, self).__init__(self, vctx, location, page_desc, language_mode='octave') - def __init__(self, read_only, interaction_mode, initial_code, - data=None, *args, **kwargs): - super(OctaveCodeForm, self).__init__(data, *args, **kwargs) #TODO OctaveCodeForm +class OctaveCodeQuestion(CodeQuestion): + """ + An auto-graded question allowing an answer consisting of Octave code. + All user code as well as all code specified as part of the problem + is in Octave 4.2+. - from course.utils import get_codemirror_widget - cm_widget, cm_help_text = get_codemirror_widget( - language_mode="octave", - interaction_mode=interaction_mode, - read_only=read_only, + If you are not including the + :attr:`course.constants.flow_permission.change_answer` + permission for your entire flow, you likely want to + include this snippet in your question definition: - # Automatically focus the text field once there has - # been some input. - autofocus=( - not read_only - and (data is not None and "answer" in data))) + .. code-block:: yaml - self.fields["answer"] = forms.CharField(required=True, - initial=initial_code, - help_text=cm_help_text, - widget=cm_widget, - label=_("Answer")) + access_rules: + add_permissions: + - change_answer - self.style_codemirror_widget() + This will allow participants multiple attempts at getting + the right answer. - def clean(self): - # FIXME Should try compilation - pass + .. attribute:: id + |id-page-attr| -RUNOC_PORT = 9942 -DOCKER_TIMEOUT = 15 + .. attribute:: type + ``OctaveCodeQuestion`` -class InvalidPingResponse(RuntimeError): - pass + .. attribute:: is_optional_page + |is-optional-page-attr| -def request_octave_run(run_req, run_timeout, image=None): - import json - from six.moves import http_client - import docker - import socket - import errno - from docker.errors import APIError as DockerAPIError - - debug = True - if debug: - def debug_print(s): - print(s) - else: - def debug_print(s): - pass - - if SPAWN_CONTAINERS_FOR_RUNOC: - docker_url = getattr(settings, "RELATE_DOCKER_URL", - "unix://var/run/docker.sock") - docker_tls = getattr(settings, "RELATE_DOCKER_TLS_CONFIG", - None) - docker_cnx = docker.Client( - base_url=docker_url, - tls=docker_tls, - timeout=DOCKER_TIMEOUT, - version="1.19") - - if image is None: - image = settings.RELATE_DOCKER_RUNOC_IMAGE - - dresult = docker_cnx.create_container( - image=image, - command=[ - "/opt/runoc/runoc", - "-1"], - host_config={ - "Memory": 384*10**6, - "MemorySwap": -1, - "PublishAllPorts": True, - # Do not enable: matplotlib stops working if enabled. - # "ReadonlyRootfs": True, - }, - user="runoc") - - container_id = dresult["Id"] - else: - container_id = None - - connect_host_ip = 'localhost' - - try: - # FIXME: Prohibit networking - - if container_id is not None: - docker_cnx.start(container_id) - - container_props = docker_cnx.inspect_container(container_id) - (port_info,) = (container_props - ["NetworkSettings"]["Ports"]["%d/tcp" % RUNOC_PORT]) - port_host_ip = port_info.get("HostIp") - - if port_host_ip != "0.0.0.0": - connect_host_ip = port_host_ip - - port = int(port_info["HostPort"]) - else: - port = RUNOC_PORT - - from time import time, sleep - start_time = time() - - # {{{ ping until response received - - from traceback import format_exc - - def check_timeout(): - if time() - start_time < DOCKER_TIMEOUT: - sleep(0.1) - # and retry - else: - return { - "result": "uncaught_error", - "message": "Timeout waiting for container.", - "traceback": "".join(format_exc()), - "exec_host": connect_host_ip, - } - - while True: - try: - connection = http_client.HTTPConnection(connect_host_ip, port) - - connection.request('GET', '/ping') - - response = connection.getresponse() - response_data = response.read().decode() - - if response_data != "OK": - raise InvalidPingResponse() - - break - - except (http_client.BadStatusLine, InvalidPingResponse): - ct_res = check_timeout() - if ct_res is not None: - return ct_res - - except socket.error as e: - if e.errno in [errno.ECONNRESET, errno.ECONNREFUSED]: - ct_res = check_timeout() - if ct_res is not None: - return ct_res - - else: - raise - - # }}} - - debug_print("PING SUCCESSFUL") - - try: - # Add a second to accommodate 'wire' delays - connection = http_client.HTTPConnection(connect_host_ip, port, - timeout=1 + run_timeout) - - headers = {'Content-type': 'application/json'} - - json_run_req = json.dumps(run_req).encode("utf-8") - - from time import time - start_time = time() - - debug_print("BEFPOST") - connection.request('POST', '/run-octave', json_run_req, headers) - debug_print("AFTPOST") - - http_response = connection.getresponse() - debug_print("GETR") - response_data = http_response.read().decode("utf-8") - debug_print("READR") - - end_time = time() - - result = json.loads(response_data) - - result["feedback"] = (result.get("feedback", []) - + ["Execution time: %.1f s -- Time limit: %.1f s" - % (end_time - start_time, run_timeout)]) - - result["exec_host"] = connect_host_ip - - return result - - except socket.timeout: - return { - "result": "timeout", - "exec_host": connect_host_ip, - } - finally: - if container_id is not None: - debug_print("-----------BEGIN DOCKER LOGS for %s" % container_id) - debug_print(docker_cnx.logs(container_id)) - debug_print("-----------END DOCKER LOGS for %s" % container_id) - - try: - docker_cnx.remove_container(container_id, force=True) - except DockerAPIError: - # Oh well. No need to bother the students with this nonsense. - pass - - -def is_nuisance_failure(result): - if result["result"] != "uncaught_error": - return False - - if "traceback" in result: - if "BadStatusLine" in result["traceback"]: - - # Occasionally, we fail to send a POST to the container, even after - # the inital ping GET succeeded, for (for now) mysterious reasons. - # Just try again. - - return True - - if "bind: address already in use" in result["traceback"]: - # https://github.com/docker/docker/issues/8714 - - return True - - if ("requests.packages.urllib3.exceptions.NewConnectionError" - in result["traceback"]): - return True - - if "http.client.RemoteDisconnected" in result["traceback"]: - return True - - if "[Errno 113] No route to host" in result["traceback"]: - return True - - return False - - -def request_octave_run_with_retries(run_req, run_timeout, image=None, retry_count=3): - while True: - result = request_octave_run(run_req, run_timeout, image=image) - - if retry_count and is_nuisance_failure(result): - retry_count -= 1 - continue - - return result - - -class OctaveCodeQuestion(PageBaseWithTitle, PageBaseWithValue): - """ - An auto-graded question allowing an answer consisting of Octave code. - All user code as well as all code specified as part of the problem - is in Octave 4.2+. - - If you are not including the - :attr:`course.constants.flow_permission.change_answer` - permission for your entire flow, you likely want to - include this snippet in your question definition: - - .. code-block:: yaml - - access_rules: - add_permissions: - - change_answer - - This will allow participants multiple attempts at getting - the right answer. - - .. attribute:: id - - |id-page-attr| - - .. attribute:: type - - ``OctaveCodeQuestion`` - - .. attribute:: is_optional_page - - |is-optional-page-attr| - - .. attribute:: access_rules + .. attribute:: access_rules |access-rules-page-attr| @@ -1622,512 +1576,20 @@ class OctaveCodeQuestion(PageBaseWithTitle, PageBaseWithValue): * ``user_code``: The user code being tested, as a string. """ - def __init__(self, vctx, location, page_desc): - super(OctaveCodeQuestion, self).__init__(vctx, location, page_desc) - - if vctx is not None and hasattr(page_desc, "data_files"): - for data_file in page_desc.data_files: - try: - if not isinstance(data_file, str): - raise ObjectDoesNotExist() - - from course.content import get_repo_blob - get_repo_blob(vctx.repo, data_file, vctx.commit_sha) - except ObjectDoesNotExist: - raise ValidationError( - string_concat( - "%(location)s: ", - _("data file '%(file)s' not found")) - % {"location": location, "file": data_file}) - - if not getattr(page_desc, "single_submission", False) and vctx is not None: - is_multi_submit = False - - if hasattr(page_desc, "access_rules"): - if hasattr(page_desc.access_rules, "add_permissions"): - if (flow_permission.change_answer - in page_desc.access_rules.add_permissions): - is_multi_submit = True + @property + def language_mode(self): + return 'octave' - if not is_multi_submit: - vctx.add_warning(location, _("code question does not explicitly " - "allow multiple submission. Either add " - "access_rules/add_permssions/change_answer " - "or add 'single_submission: True' to confirm that you intend " - "for only a single submission to be allowed. " - "While you're at it, consider adding " - "access_rules/add_permssions/see_correctness.")) + @property + def container_image(self): + return settings.RELATE_DOCKER_RUNOC_IMAGE - def required_attrs(self): - return super(OctaveCodeQuestion, self).required_attrs() + ( - ("prompt", "markup"), - ("timeout", (int, float)), - ) - - def allowed_attrs(self): - return super(OctaveCodeQuestion, self).allowed_attrs() + ( - ("setup_code", str), - ("show_setup_code", bool), - ("names_for_user", list), - ("names_from_user", list), - ("test_code", str), - ("show_test_code", bool), - ("correct_code_explanation", "markup"), - ("correct_code", str), - ("initial_code", str), - ("data_files", list), - ("single_submission", bool), - ) - - def _initial_code(self): - result = getattr(self.page_desc, "initial_code", None) - if result is not None: - return result.strip() - else: - return result - - def markup_body_for_title(self): - return self.page_desc.prompt - - def body(self, page_context, page_data): - from django.template.loader import render_to_string - return render_to_string( - "course/prompt-code-question.html", - { - "prompt_html": - markup_to_html(page_context, self.page_desc.prompt), - "initial_code": self._initial_code(), - "show_setup_code": getattr( - self.page_desc, "show_setup_code", False), - "setup_code": getattr(self.page_desc, "setup_code", ""), - "show_test_code": getattr( - self.page_desc, "show_test_code", False), - "test_code": getattr(self.page_desc, "test_code", ""), - }) - - def make_form(self, page_context, page_data, - answer_data, page_behavior): - - if answer_data is not None: - answer = {"answer": answer_data["answer"]} - form = OctaveCodeForm( - not page_behavior.may_change_answer, - get_editor_interaction_mode(page_context), - self._initial_code(), - answer) - else: - answer = None - form = OctaveCodeForm( - not page_behavior.may_change_answer, - get_editor_interaction_mode(page_context), - self._initial_code(), - ) - - return form - - def process_form_post( - self, page_context, page_data, post_data, files_data, page_behavior): - return OctaveCodeForm( - not page_behavior.may_change_answer, - get_editor_interaction_mode(page_context), - self._initial_code(), - post_data, files_data) - - def answer_data(self, page_context, page_data, form, files_data): - return {"answer": form.cleaned_data["answer"].strip()} - - def get_test_code(self): - test_code = getattr(self.page_desc, "test_code", None) - if test_code is None: - return test_code - - correct_code = getattr(self.page_desc, "correct_code", None) - if correct_code is None: - correct_code = "" - - from .code_runoc_backend import substitute_correct_code_into_test_code - return substitute_correct_code_into_test_code(test_code, correct_code) - - def grade(self, page_context, page_data, answer_data, grade_data): - if answer_data is None: - return AnswerFeedback(correctness=0, - feedback=_("No answer provided.")) - - user_code = answer_data["answer"] - - # {{{ request run - - run_req = {"compile_only": False, "user_code": user_code} - - def transfer_attr(name): - if hasattr(self.page_desc, name): - run_req[name] = getattr(self.page_desc, name) - - transfer_attr("setup_code") - transfer_attr("names_for_user") - transfer_attr("names_from_user") - - run_req["test_code"] = self.get_test_code() - - if hasattr(self.page_desc, "data_files"): - run_req["data_files"] = {} - - from course.content import get_repo_blob - - # TODO: set this up to work with M-files for Octave functions - for data_file in self.page_desc.data_files: - from base64 import b64encode - run_req["data_files"][data_file] = \ - b64encode( - get_repo_blob( - page_context.repo, data_file, - page_context.commit_sha).data).decode() - - try: - response_dict = request_octave_run_with_retries(run_req, - run_timeout=self.page_desc.timeout) - except Exception: - from traceback import format_exc - response_dict = { - "result": "uncaught_error", - "message": "Error connecting to container", - "traceback": "".join(format_exc()), - } - - # }}} - - feedback_bits = [] - - correctness = None - - if "points" in response_dict: - correctness = response_dict["points"] - try: - feedback_bits.append( - "

    %s

    " - % _(get_auto_feedback(correctness))) - except Exception as e: - correctness = None - response_dict["result"] = "setup_error" - response_dict["message"] = ( - "%s: %s" % (type(e).__name__, str(e)) - ) - - # {{{ send email if the grading code broke - - if response_dict["result"] in [ - "uncaught_error", - "setup_compile_error", - "setup_error", - "test_compile_error", - "test_error"]: - error_msg_parts = ["RESULT: %s" % response_dict["result"]] - for key, val in sorted(response_dict.items()): - if (key not in ["result", "figures"] - and val - and isinstance(val, six.string_types)): - error_msg_parts.append("-------------------------------------") - error_msg_parts.append(key) - error_msg_parts.append("-------------------------------------") - error_msg_parts.append(val) - error_msg_parts.append("-------------------------------------") - error_msg_parts.append("user code") - error_msg_parts.append("-------------------------------------") - error_msg_parts.append(user_code) - error_msg_parts.append("-------------------------------------") - - error_msg = "\n".join(error_msg_parts) - - from relate.utils import local_now, format_datetime_local - from course.utils import LanguageOverride - with LanguageOverride(page_context.course): - from relate.utils import render_email_template - message = render_email_template( - "course/broken-code-question-email.txt", { - "site": getattr(settings, "RELATE_BASE_URL"), - "page_id": self.page_desc.id, - "course": page_context.course, - "error_message": error_msg, - "review_uri": page_context.page_uri, - "time": format_datetime_local(local_now()) - }) - - if ( - not page_context.in_sandbox - and not is_nuisance_failure(response_dict)): - try: - from django.core.mail import EmailMessage - msg = EmailMessage("".join(["[%s:%s] ", - _("code question execution failed")]) - % ( - page_context.course.identifier, - page_context.flow_session.flow_id - if page_context.flow_session is not None - else _("")), - message, - settings.ROBOT_EMAIL_FROM, - [page_context.course.notify_email]) - - from relate.utils import get_outbound_mail_connection - msg.connection = get_outbound_mail_connection("robot") - msg.send() - - except Exception: - from traceback import format_exc - feedback_bits.append( - six.text_type(string_concat( - "

    ", - _( - "Both the grading code and the attempt to " - "notify course staff about the issue failed. " - "Please contact the course or site staff and " - "inform them of this issue, mentioning this " - "entire error message:"), - "

    ", - "

    ", - _( - "Sending an email to the course staff about the " - "following failure failed with " - "the following error message:"), - "

    ",
    -                                "".join(format_exc()),
    -                                "
    ", - _("The original failure message follows:"), - "

    "))) - - # }}} - - if hasattr(self.page_desc, "correct_code"): - def normalize_code(s): - return (s - .replace(" ", "") - .replace("\r", "") - .replace("\n", "") - .replace("\t", "")) - - if (normalize_code(user_code) - == normalize_code(self.page_desc.correct_code)): - feedback_bits.append( - "

    %s

    " - % _("It looks like you submitted code that is identical to " - "the reference solution. This is not allowed.")) - - from relate.utils import dict_to_struct - response = dict_to_struct(response_dict) - - bulk_feedback_bits = [] - - if response.result == "success": - pass - elif response.result in [ - "uncaught_error", - "setup_compile_error", - "setup_error", - "test_compile_error", - "test_error"]: - feedback_bits.append("".join([ - "

    ", - _( - "The grading code failed. Sorry about that. " - "The staff has been informed, and if this problem is " - "due to an issue with the grading code, " - "it will be fixed as soon as possible. " - "In the meantime, you'll see a traceback " - "below that may help you figure out what went wrong." - ), - "

    "])) - elif response.result == "timeout": - feedback_bits.append("".join([ - "

    ", - _( - "Your code took too long to execute. The problem " - "specifies that your code may take at most %s seconds " - "to run. " - "It took longer than that and was aborted." - ), - "

    "]) - % self.page_desc.timeout) - - correctness = 0 - elif response.result == "user_compile_error": - feedback_bits.append("".join([ - "

    ", - _("Your code failed to compile. An error message is " - "below."), - "

    "])) - - correctness = 0 - elif response.result == "user_error": - feedback_bits.append("".join([ - "

    ", - _("Your code failed with an exception. " - "A traceback is below."), - "

    "])) - - correctness = 0 - else: - raise RuntimeError("invalid runoc result: %s" % response.result) - - if hasattr(response, "feedback") and response.feedback: - def sanitize(s): - import bleach - return bleach.clean(s, tags=["p", "pre"]) - feedback_bits.append("".join([ - "

    ", - _("Here is some feedback on your code"), - ":" - "

      %s

    "]) % - "".join( - "
  • %s
  • " % sanitize(fb_item) - for fb_item in response.feedback)) - if hasattr(response, "traceback") and response.traceback: - feedback_bits.append("".join([ - "

    ", - _("This is the exception traceback"), - ":" - "

    %s

    "]) % escape(response.traceback)) - if hasattr(response, "exec_host") and response.exec_host != "localhost": - import socket - try: - exec_host_name, dummy, dummy = socket.gethostbyaddr( - response.exec_host) - except socket.error: - exec_host_name = response.exec_host - - feedback_bits.append("".join([ - "

    ", - _("Your code ran on %s.") % exec_host_name, - "

    "])) - - if hasattr(response, "stdout") and response.stdout: - bulk_feedback_bits.append("".join([ - "

    ", - _("Your code printed the following output"), - ":" - "

    %s

    "]) - % escape(response.stdout)) - if hasattr(response, "stderr") and response.stderr: - bulk_feedback_bits.append("".join([ - "

    ", - _("Your code printed the following error messages"), - ":" - "

    %s

    "]) % escape(response.stderr)) - if hasattr(response, "figures") and response.figures: - fig_lines = ["".join([ - "

    ", - _("Your code produced the following plots"), - ":

    "]), - '
    ', - ] - - for nr, mime_type, b64data in response.figures: - if mime_type in ["image/jpeg", "image/png"]: - fig_lines.extend([ - "".join([ - "
    ", - _("Figure"), "%d
    "]) % nr, - '
    Figure %d
    ' - % (nr, mime_type, b64data)]) - - fig_lines.append("
    ") - bulk_feedback_bits.extend(fig_lines) - - # {{{ html output / santization - - if hasattr(response, "html") and response.html: - def is_allowed_data_uri(allowed_mimetypes, uri): - import re - m = re.match(r"^data:([-a-z0-9]+/[-a-z0-9]+);base64,", uri) - if not m: - return False - - mimetype = m.group(1) - return mimetype in allowed_mimetypes - - def sanitize(s): - import bleach - - def filter_audio_attributes(tag, name, value): - if name in ["controls"]: - return True - else: - return False - - def filter_source_attributes(tag, name, value): - if name in ["type"]: - return True - elif name == "src": - if is_allowed_data_uri([ - "audio/wav", - ], value): - return bleach.sanitizer.VALUE_SAFE - else: - return False - else: - return False - - def filter_img_attributes(tag, name, value): - if name in ["alt", "title"]: - return True - elif name == "src": - return is_allowed_data_uri([ - "image/png", - "image/jpeg", - ], value) - else: - return False - - if not isinstance(s, six.text_type): - return _("(Non-string in 'HTML' output filtered out)") - - return bleach.clean(s, - tags=bleach.ALLOWED_TAGS + ["audio", "video", "source"], - attributes={ - "audio": filter_audio_attributes, - "source": filter_source_attributes, - "img": filter_img_attributes, - }) - - bulk_feedback_bits.extend( - sanitize(snippet) for snippet in response.html) - - # }}} - - return AnswerFeedback( - correctness=correctness, - feedback="\n".join(feedback_bits), - bulk_feedback="\n".join(bulk_feedback_bits)) - - def correct_answer(self, page_context, page_data, answer_data, grade_data): - result = "" - - if hasattr(self.page_desc, "correct_code_explanation"): - result += markup_to_html( - page_context, - self.page_desc.correct_code_explanation) - - if hasattr(self.page_desc, "correct_code"): - result += ("".join([ - _("The following code is a valid answer"), - ":
    %s
    "]) - % escape(self.page_desc.correct_code)) - - return result - - def normalized_answer(self, page_context, page_data, answer_data): - if answer_data is None: - return None - - normalized_answer = answer_data["answer"] - - from django.utils.html import escape - return "
    %s
    " % escape(normalized_answer) - - def normalized_bytes_answer(self, page_context, page_data, answer_data): - if answer_data is None: - return None + @property + def suffix(self): + return '.m' - return (".m", answer_data["answer"].encode("utf-8")) + def __init__(self, vctx, location, page_desc, language_mode='octave'): + super(OctaveCodeQuestion, self).__init__(self, vctx, location, page_desc) # }}} diff --git a/course/page/code_run_backend.py b/course/page/code_run_backend.py new file mode 100644 index 000000000..b018db5cf --- /dev/null +++ b/course/page/code_run_backend.py @@ -0,0 +1,313 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import + +__copyright__ = "Copyright (C) 2014 Andreas Kloeckner" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +import sys +import traceback + +try: + from .code_feedback import Feedback, GradingComplete +except SystemError: + from code_feedback import Feedback, GradingComplete # type: ignore +except ImportError: + from code_feedback import Feedback, GradingComplete # type: ignore + + +__doc__ = """ +PROTOCOL +======== + +.. class:: Request + + .. attribute:: setup_code + + .. attribute:: names_for_user + + .. attribute:: user_code + + .. attribute:: names_from_user + + .. attribute:: test_code + + .. attribute:: data_files + + A dictionary from data file names to their + base64-cencoded contents. + Optional. + + .. attribute:: compile_only + + :class:`bool` + +.. class Response:: + .. attribute:: result + + One of + + * ``success`` + * ``timeout`` + * ``uncaught_error`` + * ``setup_compile_error`` + * ``setup_error``, + * ``user_compile_error`` + * ``user_error`` + * ``test_compile_error`` + * ``test_error`` + + Always present. + + .. attribute:: message + + Optional. + + .. attribute:: traceback + + Optional. + + .. attribute:: stdout + + Whatever came out of stdout. + + Optional. + + .. attribute:: stderr + + Whatever came out of stderr. + + Optional. + + .. attribute:: figures + + A list of ``(index, mime_type, string)``, where *string* is a + base64-encoded representation of the figure. *index* will usually + correspond to the matplotlib figure number. + + Optional. + + .. attribute:: html + + A list of HTML strings generated. These are aggressively sanitized + before being rendered. + + .. attribute:: points + + A number between 0 and 1 (inclusive). + + Present on ``success`` if :attr:`Request.compile_only` is *False*. + + .. attribute:: feedback + + A list of strings. + + Present on ``success`` if :attr:`Request.compile_only` is *False*. +""" + + +# {{{ tools + +class Struct(object): + def __init__(self, entries): + for name, val in entries.items(): + self.__dict__[name] = val + + def __repr__(self): + return repr(self.__dict__) + +# }}} + + +def substitute_correct_code_into_test_code(test_code, correct_code): + import re + CORRECT_CODE_TAG = re.compile(r"^(\s*)###CORRECT_CODE###\s*$") # noqa + + new_test_code_lines = [] + for l in test_code.split("\n"): + match = CORRECT_CODE_TAG.match(l) + if match is not None: + prefix = match.group(1) + for cc_l in correct_code.split("\n"): + new_test_code_lines.append(prefix+cc_l) + else: + new_test_code_lines.append(l) + + return "\n".join(new_test_code_lines) + + +def package_exception(result, what): + tp, val, tb = sys.exc_info() + result["result"] = what + result["message"] = "%s: %s" % (tp.__name__, str(val)) + result["traceback"] = "".join( + traceback.format_exception(tp, val, tb)) + + +def run_code(result, run_req): + # {{{ silence matplotlib font cache warnings + + import warnings + warnings.filterwarnings( + "ignore", message="Matplotlib is building the font cache.*") + + # }}} + + # {{{ compile code + + if getattr(run_req, "setup_code", None): + try: + setup_code = compile( + run_req.setup_code, "[setup code]", 'exec') + except Exception: + package_exception(result, "setup_compile_error") + return + else: + setup_code = None + + try: + user_code = compile( + run_req.user_code, "[user code]", 'exec') + except Exception: + package_exception(result, "user_compile_error") + return + + if getattr(run_req, "test_code", None): + try: + test_code = compile( + run_req.test_code, "[test code]", 'exec') + except Exception: + package_exception(result, "test_compile_error") + return + else: + test_code = None + + # }}} + + if hasattr(run_req, "compile_only") and run_req.compile_only: + result["result"] = "success" + return + + # {{{ run code + + data_files = {} + if hasattr(run_req, "data_files"): + from base64 import b64decode + for name, contents in run_req.data_files.items(): + data_files[name] = b64decode(contents.encode()) + + generated_html = [] + result["html"] = generated_html + + def output_html(s): + generated_html.append(s) + + feedback = Feedback() + maint_ctx = { + "feedback": feedback, + "user_code": run_req.user_code, + "data_files": data_files, + "output_html": output_html, + "GradingComplete": GradingComplete, + } + + if setup_code is not None: + try: + maint_ctx["_MODULE_SOURCE_CODE"] = run_req.setup_code + exec(setup_code, maint_ctx) + except Exception: + package_exception(result, "setup_error") + return + + user_ctx = {} + if hasattr(run_req, "names_for_user"): + for name in run_req.names_for_user: + if name not in maint_ctx: + result["result"] = "setup_error" + result["message"] = "Setup code did not define '%s'." % name + + user_ctx[name] = maint_ctx[name] + + from copy import deepcopy + user_ctx = deepcopy(user_ctx) + + try: + user_ctx["_MODULE_SOURCE_CODE"] = run_req.user_code + exec(user_code, user_ctx) + except Exception: + package_exception(result, "user_error") + return + + # {{{ export plots + + if "matplotlib" in sys.modules: + import matplotlib.pyplot as pt + from io import BytesIO + from base64 import b64encode + + format = "png" + mime = "image/png" + figures = [] + + for fignum in pt.get_fignums(): + pt.figure(fignum) + bio = BytesIO() + try: + pt.savefig(bio, format=format) + except Exception: + pass + else: + figures.append( + (fignum, mime, b64encode(bio.getvalue()).decode())) + + result["figures"] = figures + + # }}} + + if hasattr(run_req, "names_from_user"): + for name in run_req.names_from_user: + if name not in user_ctx: + feedback.add_feedback( + "Required answer variable '%s' is not defined." + % name) + maint_ctx[name] = None + else: + maint_ctx[name] = user_ctx[name] + + if test_code is not None: + try: + maint_ctx["_MODULE_SOURCE_CODE"] = run_req.test_code + exec(test_code, maint_ctx) + except GradingComplete: + pass + except Exception: + package_exception(result, "test_error") + return + + result["points"] = feedback.points + result["feedback"] = feedback.feedback_items + + # }}} + + result["result"] = "success" + +# vim: foldmethod=marker diff --git a/docker-image-run-octave/Dockerfile b/docker-image-run-octave/Dockerfile index 6ce5f00c6..0c9ba5e12 100644 --- a/docker-image-run-octave/Dockerfile +++ b/docker-image-run-octave/Dockerfile @@ -1,7 +1,7 @@ FROM inducer/debian-amd64-minbase MAINTAINER Neal Davis EXPOSE 9942 -RUN useradd runoc +RUN useradd runcode # Based on `compdatasci/octave-desktop` Docker image @@ -49,10 +49,11 @@ RUN apt-get update && \ RUN apt-get clean RUN fc-cache -RUN mkdir -p /opt/runoc -ADD runoc /opt/runoc/ -COPY code_feedback.py /opt/runoc/ -COPY code_runoc_backend.py /opt/runoc/ +# TODO XXX perhaps I should genericize this as well +RUN mkdir -p /opt/runcode +ADD runcode /opt/runcode/ +COPY code_feedback.py /opt/runcode/ +COPY code_run_backend.py /opt/runcode/ # currently no graphics support #RUN sed -i s/TkAgg/Agg/ /etc/matplotlibrc diff --git a/docker-image-run-octave/docker-build.sh b/docker-image-run-octave/docker-build.sh index eb04820fa..dbcf6b1de 100755 --- a/docker-image-run-octave/docker-build.sh +++ b/docker-image-run-octave/docker-build.sh @@ -1,4 +1,5 @@ #! /bin/sh -cp ../course/page/code_feedback.py ../course/page/code_runoc_backend.py . +cp ../course/page/code_feedback.py . +cp ../course/page/code_runoc_backend.py code_run_backend.py docker build --no-cache . -t inducer/runoc -rm code_feedback.py code_runoc_backend.py +rm code_feedback.py code_run_backend.py diff --git a/docker-image-run-octave/runoc b/docker-image-run-octave/runcode similarity index 96% rename from docker-image-run-octave/runoc rename to docker-image-run-octave/runcode index 3a5f6670f..bc756f5d2 100755 --- a/docker-image-run-octave/runoc +++ b/docker-image-run-octave/runcode @@ -30,18 +30,18 @@ import json import sys import io try: - from code_runoc_backend import Struct, run_code, package_exception + from code_run_backend import Struct, run_code, package_exception except ImportError: try: # When faking a container for unittest - from course.page.code_runoc_backend import ( + from course.page.code_run_backend import ( Struct, run_code, package_exception) except ImportError: # When debugging, i.e., run "python runpy" command line import os sys.path.insert(0, os.path.abspath( os.path.join(os.path.dirname(__file__), os.pardir))) - from course.page.code_runoc_backend import ( + from course.page.code_run_backend import ( Struct, run_code, package_exception) from http.server import BaseHTTPRequestHandler diff --git a/docker-image-run-octave/runoc-update/Dockerfile b/docker-image-run-octave/runoc-update/Dockerfile new file mode 100644 index 000000000..cf83a4e6a --- /dev/null +++ b/docker-image-run-octave/runoc-update/Dockerfile @@ -0,0 +1,14 @@ +FROM inducer/runoc +MAINTAINER Neal Davis +EXPOSE 9942 +#RUN useradd runcode + +#RUN apt-get update && \ +# pip install oct2py + +RUN mkdir -p /opt/runcode +#ADD runoc /opt/runcode/ +COPY code_feedback.py /opt/runcode/ +COPY code_run_backend.py /opt/runcode/ + +RUN rm -Rf /root/.cache diff --git a/docker-image-run-octave/runoc-update/docker-build.sh b/docker-image-run-octave/runoc-update/docker-build.sh new file mode 100644 index 000000000..364bad966 --- /dev/null +++ b/docker-image-run-octave/runoc-update/docker-build.sh @@ -0,0 +1,5 @@ +#! /bin/sh +cp ../../course/page/code_feedback.py . +cp ../../course/page/code_runoc_backend.py code_run_backend.py +docker build --no-cache . -t inducer/runoc-update +rm code_feedback.py code_run_backend.py diff --git a/docker-image-run-py/Dockerfile b/docker-image-run-py/Dockerfile index bf98e050e..6f620d2ec 100644 --- a/docker-image-run-py/Dockerfile +++ b/docker-image-run-py/Dockerfile @@ -1,7 +1,7 @@ FROM inducer/debian-amd64-minbase MAINTAINER Andreas Kloeckner EXPOSE 9941 -RUN useradd runpy +RUN useradd runcode # Temporarily needed for pandas RUN echo "deb http://httpredir.debian.org/debian unstable main contrib" >> /etc/apt/sources.list @@ -39,10 +39,10 @@ RUN apt-get -y -o APT::Install-Recommends=0 -o APT::Install-Suggests=0 install \ RUN apt-get clean RUN fc-cache -RUN mkdir -p /opt/runpy -ADD runpy /opt/runpy/ -COPY code_feedback.py /opt/runpy/ -COPY code_runpy_backend.py /opt/runpy/ +RUN mkdir -p /opt/runcode +ADD runcode /opt/runcode/ +COPY code_feedback.py /opt/runcode/ +COPY code_run_backend.py /opt/runcode/ RUN sed -i s/TkAgg/Agg/ /etc/matplotlibrc RUN echo "savefig.dpi : 80" >> /etc/matplotlibrc diff --git a/docker-image-run-py/docker-build.sh b/docker-image-run-py/docker-build.sh index 9f7b73bfc..fdd5e5570 100755 --- a/docker-image-run-py/docker-build.sh +++ b/docker-image-run-py/docker-build.sh @@ -1,5 +1,5 @@ #! /bin/sh -cp ../course/page/code_feedback.py ../course/page/code_runpy_backend.py . +cp ../course/page/code_feedback.py . +cp ../course/page/code_runpy_backend.py code_run_backend.py docker build --no-cache . -t inducer/relate-runpy-amd64 -rm code_feedback.py code_runpy_backend.py - +rm code_feedback.py code_run_backend.py diff --git a/docker-image-run-py/runpy b/docker-image-run-py/runcode similarity index 96% rename from docker-image-run-py/runpy rename to docker-image-run-py/runcode index 02f0b9175..87623061b 100755 --- a/docker-image-run-py/runpy +++ b/docker-image-run-py/runcode @@ -30,18 +30,18 @@ import json import sys import io try: - from code_runpy_backend import Struct, run_code, package_exception + from code_run_backend import Struct, run_code, package_exception except ImportError: try: # When faking a container for unittest - from course.page.code_runpy_backend import ( + from course.page.code_run_backend import ( Struct, run_code, package_exception) except ImportError: # When debugging, i.e., run "python runpy" command line import os sys.path.insert(0, os.path.abspath( os.path.join(os.path.dirname(__file__), os.pardir))) - from course.page.code_runpy_backend import ( + from course.page.code_run_backend import ( Struct, run_code, package_exception) from http.server import BaseHTTPRequestHandler diff --git a/setup.cfg b/setup.cfg index d595f7774..d2f7595e6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,7 +19,7 @@ omit = setup.py local_settings_example.py course/page/code_feedback.py - course/page/code_runpy_backend.py + course/page/code_run*_backend.py */wsgi.py */tests/* */tests.py diff --git a/tests/base_test_mixins.py b/tests/base_test_mixins.py index 169f6a1e2..fb13f0005 100644 --- a/tests/base_test_mixins.py +++ b/tests/base_test_mixins.py @@ -2487,7 +2487,7 @@ def setUpClass(cls): # noqa def setUp(self): super(SubprocessRunpyContainerMixin, self).setUp() self.faked_container_patch = mock.patch( - "course.page.code.SPAWN_CONTAINERS_FOR_RUNPY", False) + "course.page.code.SPAWN_CONTAINERS", False) self.faked_container_patch.start() self.addCleanup(self.faked_container_patch.stop) @@ -2495,9 +2495,9 @@ def setUp(self): def tearDownClass(cls): # noqa super(SubprocessRunpyContainerMixin, cls).tearDownClass() - from course.page.code import SPAWN_CONTAINERS_FOR_RUNPY - # Make sure SPAWN_CONTAINERS_FOR_RUNPY is reset to True - assert SPAWN_CONTAINERS_FOR_RUNPY + from course.page.code import SPAWN_CONTAINERS + # Make sure SPAWN_CONTAINERS is reset to True + assert SPAWN_CONTAINERS if sys.platform.startswith("win"): # Without these lines, tests on Appveyor hanged when all tests # finished. From 146c0aa219f8d5f15de0f02fb7922e709c6e9314 Mon Sep 17 00:00:00 2001 From: Neal Davis Date: Tue, 2 Apr 2019 19:03:03 -0500 Subject: [PATCH 06/14] Fixed the CodeQuestion blocks. --- course/page/base.py | 6 ++++++ course/page/code.py | 23 ++++++++--------------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/course/page/base.py b/course/page/base.py index e7bbef5a9..5f23b0cf0 100644 --- a/course/page/base.py +++ b/course/page/base.py @@ -768,6 +768,12 @@ def __init__(self, vctx, location, page_desc): if title is None: try: md_body = self.markup_body_for_title() + except AttributeError: + #TODO XXX + from warnings import warn + warn(_("PageBaseWithTitle subclass '%s' does not implement " + "markup_body_for_title()") + % type(self).__name__) except NotImplementedError: from warnings import warn warn(_("PageBaseWithTitle subclass '%s' does not implement " diff --git a/course/page/code.py b/course/page/code.py index 017451e70..0daf120c9 100644 --- a/course/page/code.py +++ b/course/page/code.py @@ -56,7 +56,6 @@ class CodeForm(StyledForm): def __init__(self, read_only, interaction_mode, initial_code, language_mode, data=None, *args, **kwargs): super(CodeForm, self).__init__(data, *args, **kwargs) - self.language_mode = language_mode from course.utils import get_codemirror_widget cm_widget, cm_help_text = get_codemirror_widget( @@ -107,11 +106,11 @@ def debug_print(s): def debug_print(s): pass - image = self.container_image + #image = self.container_image #XXX command_path = '/opt/runcode/runcode' user = 'runcode' - if SPAWN_CONTAINERS: + if SPAWN_CONTAINERS and image is not None: docker_url = getattr(settings, "RELATE_DOCKER_URL", "unix://var/run/docker.sock") docker_tls = getattr(settings, "RELATE_DOCKER_TLS_CONFIG", @@ -483,7 +482,6 @@ class CodeQuestion(PageBaseWithTitle, PageBaseWithValue): def __init__(self, vctx, location, page_desc, language_mode): super(CodeQuestion, self).__init__(vctx, location, page_desc) - self.language_mode = language_mode if vctx is not None and hasattr(page_desc, "data_files"): for data_file in page_desc.data_files: @@ -574,6 +572,7 @@ def make_form(self, page_context, page_data, not page_behavior.may_change_answer, get_editor_interaction_mode(page_context), self._initial_code(), + self.language_mode, answer) else: answer = None @@ -581,6 +580,7 @@ def make_form(self, page_context, page_data, not page_behavior.may_change_answer, get_editor_interaction_mode(page_context), self._initial_code(), + self.language_mode ) return form @@ -591,6 +591,7 @@ def process_form_post( not page_behavior.may_change_answer, get_editor_interaction_mode(page_context), self._initial_code(), + self.language_mode, post_data, files_data) def answer_data(self, page_context, page_data, form, files_data): @@ -645,7 +646,7 @@ def transfer_attr(name): try: response_dict = request_run_with_retries(run_req, run_timeout=self.page_desc.timeout, - language_mode=self.language_mode) + image=self.container_image) except Exception: from traceback import format_exc response_dict = { @@ -994,10 +995,6 @@ def normalized_bytes_answer(self, page_context, page_data, answer_data): # {{{ python code question -class PythonCodeForm(CodeForm): - def __init__(self, vctx, location, page_desc): - super(PythonCodeQuestion, self).__init__(self, vctx, location, page_desc, language_mode='python') - class PythonCodeQuestion(CodeQuestion): """ An auto-graded question allowing an answer consisting of Python code. @@ -1187,7 +1184,7 @@ def suffix(self): return '.py' def __init__(self, vctx, location, page_desc, language_mode='python'): - super(PythonCodeQuestion, self).__init__(self, vctx, location, page_desc) + super(PythonCodeQuestion, self).__init__(vctx, location, page_desc, language_mode) # }}} @@ -1395,10 +1392,6 @@ def grade(self, page_context, page_data, answer_data, grade_data): # {{{ octave code question -class OctaveCodeForm(CodeForm): - def __init__(self, vctx, location, page_desc): - super(PythonCodeQuestion, self).__init__(self, vctx, location, page_desc, language_mode='octave') - class OctaveCodeQuestion(CodeQuestion): """ An auto-graded question allowing an answer consisting of Octave code. @@ -1589,7 +1582,7 @@ def suffix(self): return '.m' def __init__(self, vctx, location, page_desc, language_mode='octave'): - super(OctaveCodeQuestion, self).__init__(self, vctx, location, page_desc) + super(OctaveCodeQuestion, self).__init__(vctx, location, page_desc, language_mode) # }}} From 37003018c50350e250f980f7dc4df20a0b744f3f Mon Sep 17 00:00:00 2001 From: Neal Davis Date: Tue, 2 Apr 2019 20:53:28 -0500 Subject: [PATCH 07/14] Everything seems to be working properly now. --- course/page/code.py | 4 ++-- docker-image-run-octave/Dockerfile | 3 +-- docker-image-run-octave/runcode | 4 ++-- .../runoc-update/Dockerfile | 2 +- docker-image-run-py/runcode | 2 +- tests/test_pages/test_code.py | 18 +++++++++--------- 6 files changed, 16 insertions(+), 17 deletions(-) diff --git a/course/page/code.py b/course/page/code.py index 0daf120c9..076736f28 100644 --- a/course/page/code.py +++ b/course/page/code.py @@ -149,7 +149,7 @@ def debug_print(s): container_props = docker_cnx.inspect_container(container_id) (port_info,) = (container_props - ["NetworkSettings"]["Ports"]["%d/tcp" % RUNOC_PORT]) + ["NetworkSettings"]["Ports"]["%d/tcp" % CODE_QUESTION_CONTAINER_PORT]) port_host_ip = port_info.get("HostIp") if port_host_ip != "0.0.0.0": @@ -223,7 +223,7 @@ def check_timeout(): start_time = time() debug_print("BEFPOST") - connection.request('POST', '/run-%s'%language_mode, json_run_req, headers) + connection.request('POST', '/run-code', json_run_req, headers) debug_print("AFTPOST") http_response = connection.getresponse() diff --git a/docker-image-run-octave/Dockerfile b/docker-image-run-octave/Dockerfile index 0c9ba5e12..54abbfe61 100644 --- a/docker-image-run-octave/Dockerfile +++ b/docker-image-run-octave/Dockerfile @@ -1,6 +1,6 @@ FROM inducer/debian-amd64-minbase MAINTAINER Neal Davis -EXPOSE 9942 +EXPOSE 9941 RUN useradd runcode # Based on `compdatasci/octave-desktop` Docker image @@ -49,7 +49,6 @@ RUN apt-get update && \ RUN apt-get clean RUN fc-cache -# TODO XXX perhaps I should genericize this as well RUN mkdir -p /opt/runcode ADD runcode /opt/runcode/ COPY code_feedback.py /opt/runcode/ diff --git a/docker-image-run-octave/runcode b/docker-image-run-octave/runcode index bc756f5d2..f3151a138 100755 --- a/docker-image-run-octave/runcode +++ b/docker-image-run-octave/runcode @@ -46,7 +46,7 @@ except ImportError: from http.server import BaseHTTPRequestHandler -PORT = 9942 +PORT = 9941 OUTPUT_LENGTH_LIMIT = 16*1024 TEST_COUNT = 0 @@ -82,7 +82,7 @@ class RunRequestHandler(BaseHTTPRequestHandler): try: print("POST RECEIVED", file=prev_stderr) - if self.path != "/run-octave": + if self.path != "/run-code": raise RuntimeError("unrecognized path in POST") clength = int(self.headers['content-length']) diff --git a/docker-image-run-octave/runoc-update/Dockerfile b/docker-image-run-octave/runoc-update/Dockerfile index cf83a4e6a..a640a3e77 100644 --- a/docker-image-run-octave/runoc-update/Dockerfile +++ b/docker-image-run-octave/runoc-update/Dockerfile @@ -1,6 +1,6 @@ FROM inducer/runoc MAINTAINER Neal Davis -EXPOSE 9942 +EXPOSE 9941 #RUN useradd runcode #RUN apt-get update && \ diff --git a/docker-image-run-py/runcode b/docker-image-run-py/runcode index 87623061b..ea06a70b3 100755 --- a/docker-image-run-py/runcode +++ b/docker-image-run-py/runcode @@ -82,7 +82,7 @@ class RunRequestHandler(BaseHTTPRequestHandler): try: print("POST RECEIVED", file=prev_stderr) - if self.path != "/run-python": + if self.path != "/run-code": raise RuntimeError("unrecognized path in POST") clength = int(self.headers['content-length']) diff --git a/tests/test_pages/test_code.py b/tests/test_pages/test_code.py index 374deefe6..c35ec347e 100644 --- a/tests/test_pages/test_code.py +++ b/tests/test_pages/test_code.py @@ -33,7 +33,7 @@ from course.models import FlowSession from course.page.code import ( - RUNPY_PORT, request_python_run_with_retries, InvalidPingResponse, + CODE_QUESTION_CONTAINER_PORT, request_python_run_with_retries, InvalidPingResponse, is_nuisance_failure, PythonCodeQuestionWithHumanTextFeedback) from course.page.code import ( RUNOC_PORT, request_octave_run_with_retries) @@ -1055,7 +1055,7 @@ def test_docker_container_ping_failure(self): mock_inpect_ctn.return_value = { "NetworkSettings": { - "Ports": {"%d/tcp" % RUNPY_PORT: ( + "Ports": {"%d/tcp" % CODE_QUESTION_CONTAINER_PORT: ( {"HostIp": fake_host_ip, "HostPort": fake_host_port}, )} }} @@ -1154,7 +1154,7 @@ def test_docker_container_ping_failure(self): mock_inpect_ctn.return_value = { "NetworkSettings": { - "Ports": {"%d/tcp" % RUNPY_PORT: ( + "Ports": {"%d/tcp" % CODE_QUESTION_CONTAINER_PORT: ( {"HostIp": fake_host_ip, "HostPort": fake_host_port}, )} }} @@ -1203,7 +1203,7 @@ def test_docker_container_ping_return_not_ok(self): mock_inpect_ctn.return_value = { "NetworkSettings": { - "Ports": {"%d/tcp" % RUNPY_PORT: ( + "Ports": {"%d/tcp" % CODE_QUESTION_CONTAINER_PORT: ( {"HostIp": fake_host_ip, "HostPort": fake_host_port}, )} }} @@ -1243,7 +1243,7 @@ def test_docker_container_runpy_timeout(self): mock_inpect_ctn.return_value = { "NetworkSettings": { - "Ports": {"%d/tcp" % RUNPY_PORT: ( + "Ports": {"%d/tcp" % CODE_QUESTION_CONTAINER_PORT: ( {"HostIp": fake_host_ip, "HostPort": fake_host_port}, )} }} @@ -1339,7 +1339,7 @@ def test_docker_container_ping_failure(self): mock_inpect_ctn.return_value = { "NetworkSettings": { - "Ports": {"%d/tcp" % RUNPY_PORT: ( + "Ports": {"%d/tcp" % CODE_QUESTION_CONTAINER_PORT: ( {"HostIp": fake_host_ip, "HostPort": fake_host_port}, )} }} @@ -1438,7 +1438,7 @@ def test_docker_container_ping_failure(self): mock_inpect_ctn.return_value = { "NetworkSettings": { - "Ports": {"%d/tcp" % RUNPY_PORT: ( + "Ports": {"%d/tcp" % CODE_QUESTION_CONTAINER_PORT: ( {"HostIp": fake_host_ip, "HostPort": fake_host_port}, )} }} @@ -1487,7 +1487,7 @@ def test_docker_container_ping_return_not_ok(self): mock_inpect_ctn.return_value = { "NetworkSettings": { - "Ports": {"%d/tcp" % RUNPY_PORT: ( + "Ports": {"%d/tcp" % CODE_QUESTION_CONTAINER_PORT: ( {"HostIp": fake_host_ip, "HostPort": fake_host_port}, )} }} @@ -1527,7 +1527,7 @@ def test_docker_container_runpy_timeout(self): mock_inpect_ctn.return_value = { "NetworkSettings": { - "Ports": {"%d/tcp" % RUNOC_PORT: ( + "Ports": {"%d/tcp" % CODE_QUESTION_CONTAINER_PORT: ( {"HostIp": fake_host_ip, "HostPort": fake_host_port}, )} }} From ef05a7013d81b3ed1da1a582a7aaaa4394d919f0 Mon Sep 17 00:00:00 2001 From: Neal Davis Date: Tue, 2 Apr 2019 21:04:22 -0500 Subject: [PATCH 08/14] Post davis68 version of docker image. --- docker-image-run-octave/code_feedback.py | 202 ++++++++++++++ docker-image-run-octave/code_run_backend.py | 283 ++++++++++++++++++++ docker-image-run-octave/docker-build.sh | 2 +- local_settings_example.py | 2 +- 4 files changed, 487 insertions(+), 2 deletions(-) create mode 100644 docker-image-run-octave/code_feedback.py create mode 100644 docker-image-run-octave/code_run_backend.py diff --git a/docker-image-run-octave/code_feedback.py b/docker-image-run-octave/code_feedback.py new file mode 100644 index 000000000..416d7b5f8 --- /dev/null +++ b/docker-image-run-octave/code_feedback.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- + +from __future__ import division, print_function + +__copyright__ = "Copyright (C) 2014 Andreas Kloeckner" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + + +class GradingComplete(Exception): + pass + + +class Feedback: + def __init__(self): + self.points = None + self.feedback_items = [] + + def set_points(self, points): + self.points = points + + def add_feedback(self, text): + self.feedback_items.append(text) + + def finish(self, points, fb_text): + self.add_feedback(fb_text) + self.set_points(points) + raise GradingComplete() + + def check_numpy_array_sanity(self, name, num_axes, data): + import numpy as np + if not isinstance(data, np.ndarray): + self.finish(0, "'%s' is not a numpy array" % name) + + if isinstance(data, np.matrix): + self.finish(0, "'%s' is a numpy matrix. Do not use those. " + "bit.ly/array-vs-matrix" % name) + + if len(data.shape) != num_axes: + self.finish( + 0, "'%s' does not have the correct number of axes--" + "got: %d, expected: %d" % ( + name, len(data.shape), num_axes)) + + if data.dtype.kind not in "fc": + self.finish( + 0, "'%s' does not consist of floating point numbers--" + "got: '%s'" % (name, data.dtype)) + + def check_numpy_array_features(self, name, ref, data, report_failure=True): + import numpy as np + assert isinstance(ref, np.ndarray) + + def bad(msg): + if report_failure: + self.finish(0, msg) + else: + return False + + if not isinstance(data, np.ndarray): + return bad("'%s' is not a numpy array" % name) + + if isinstance(data, np.matrix): + return bad("'%s' is a numpy matrix. Do not use those. " + "bit.ly/array-vs-matrix" % name) + + if ref.shape != data.shape: + return bad( + "'%s' does not have correct shape--" + "got: '%s', expected: '%s'" % ( + name, data.shape, ref.shape)) + + if ref.dtype.kind != data.dtype.kind: + return bad( + "'%s' does not have correct data type--" + "got: '%s', expected: '%s'" % ( + name, data.dtype, ref.dtype)) + + return True + + def check_numpy_array_allclose(self, name, ref, data, accuracy_critical=True, + rtol=1e-05, atol=1e-08, report_success=True, report_failure=True): + import numpy as np + + if not self.check_numpy_array_features(name, ref, data, report_failure): + return False + + good = np.allclose(ref, data, rtol=rtol, atol=atol) + + if not good: + if report_failure: + self.add_feedback("'%s' is inaccurate" % name) + else: + if report_success: + self.add_feedback("'%s' looks good" % name) + + if accuracy_critical and not good: + self.set_points(0) + raise GradingComplete() + + return good + + def check_list(self, name, ref, data, entry_type=None): + assert isinstance(ref, list) + if not isinstance(data, list): + self.finish(0, "'%s' is not a list" % name) + + if len(ref) != len(data): + self.finish(0, "'%s' has the wrong length--expected %d, got %d" + % (name, len(ref), len(data))) + + if entry_type is not None: + for i, entry in enumerate(data): + if not isinstance(entry, entry_type): + self.finish(0, "'%s[%d]' has the wrong type" % (name, i)) + + def check_scalar(self, name, ref, data, accuracy_critical=True, + rtol=1e-5, atol=1e-8, report_success=True, report_failure=True): + import numpy as np + + if not isinstance(data, (complex, float, int, np.number)): + try: + # Check whether data is a sympy number because sympy + # numbers do not follow the typical interface + # See https://github.com/inducer/relate/pull/284 + if not data.is_number: + self.finish(0, "'%s' is not a number" % name) + except AttributeError: + self.finish(0, "'%s' is not a number" % name) + + good = False + + if rtol is not None and abs(ref-data) < abs(ref)*rtol: + good = True + if atol is not None and abs(ref-data) < atol: + good = True + + if not good: + if report_failure: + self.add_feedback("'%s' is inaccurate" % name) + else: + if report_success: + self.add_feedback("'%s' looks good" % name) + + if accuracy_critical and not good: + self.set_points(0) + raise GradingComplete() + + return good + + def call_user(self, f, *args, **kwargs): + try: + return f(*args, **kwargs) + except Exception: + if callable(f): + try: + callable_name = f.__name__ + except Exception as e_name: + callable_name = ( + "" + % ( + type(e_name).__name__, + str(e_name))) + from traceback import format_exc + self.add_feedback( + "

    " + "The callable '%s' supplied in your code failed with " + "an exception while it was being called by the grading " + "code:" + "

    " + "
    %s
    " + % ( + callable_name, + "".join(format_exc()))) + else: + self.add_feedback( + "

    " + "Your code was supposed to supply a function or " + "callable, but the variable you supplied was not " + "callable." + "

    ") + + self.set_points(0) + raise GradingComplete() diff --git a/docker-image-run-octave/code_run_backend.py b/docker-image-run-octave/code_run_backend.py new file mode 100644 index 000000000..525f7dff7 --- /dev/null +++ b/docker-image-run-octave/code_run_backend.py @@ -0,0 +1,283 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import + +__copyright__ = "Copyright (C) 2014 Andreas Kloeckner" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +import sys +import traceback + +try: + from .code_feedback import Feedback, GradingComplete +except SystemError: + from code_feedback import Feedback, GradingComplete # type: ignore +except ImportError: + from code_feedback import Feedback, GradingComplete # type: ignore + + +__doc__ = """ +PROTOCOL +======== + +.. class:: Request + + .. attribute:: setup_code + + .. attribute:: names_for_user + + .. attribute:: user_code + + .. attribute:: names_from_user + + .. attribute:: test_code + + .. attribute:: data_files + + A dictionary from data file names to their + base64-cencoded contents. + Optional. + + .. attribute:: compile_only + + :class:`bool` + +.. class Response:: + .. attribute:: result + + One of + + * ``success`` + * ``timeout`` + * ``uncaught_error`` + * ``setup_compile_error`` + * ``setup_error``, + * ``user_compile_error`` + * ``user_error`` + * ``test_compile_error`` + * ``test_error`` + + Always present. + + .. attribute:: message + + Optional. + + .. attribute:: traceback + + Optional. + + .. attribute:: stdout + + Whatever came out of stdout. + + Optional. + + .. attribute:: stderr + + Whatever came out of stderr. + + Optional. + + .. attribute:: figures + + A list of ``(index, mime_type, string)``, where *string* is a + base64-encoded representation of the figure. *index* will usually + correspond to the matplotlib figure number. + + Optional. + + .. attribute:: html + + A list of HTML strings generated. These are aggressively sanitized + before being rendered. + + .. attribute:: points + + A number between 0 and 1 (inclusive). + + Present on ``success`` if :attr:`Request.compile_only` is *False*. + + .. attribute:: feedback + + A list of strings. + + Present on ``success`` if :attr:`Request.compile_only` is *False*. +""" + + +# {{{ tools + +class Struct(object): + def __init__(self, entries): + for name, val in entries.items(): + self.__dict__[name] = val + + def __repr__(self): + return repr(self.__dict__) + +# }}} + + +def substitute_correct_code_into_test_code(test_code, correct_code): + import re + CORRECT_CODE_TAG = re.compile(r"^(\s*)###CORRECT_CODE###\s*$") # noqa + + new_test_code_lines = [] + for l in test_code.split("\n"): + match = CORRECT_CODE_TAG.match(l) + if match is not None: + prefix = match.group(1) + for cc_l in correct_code.split("\n"): + new_test_code_lines.append(prefix+cc_l) + else: + new_test_code_lines.append(l) + + return "\n".join(new_test_code_lines) + + +def package_exception(result, what): + tp, val, tb = sys.exc_info() + result["result"] = what + result["message"] = "%s: %s" % (tp.__name__, str(val)) + result["traceback"] = "".join( + traceback.format_exception(tp, val, tb)) + + +def run_code(result, run_req): + # {{{ set up octave process + + import oct2py + + oc = oct2py.Oct2Py() + + # }}} + + # {{{ run code + + data_files = {} + if hasattr(run_req, "data_files"): + from base64 import b64decode + for name, contents in run_req.data_files.items(): + # This part "cheats" a litle, since Octave lets us evaluate functions + # in the same context as the main code. (MATLAB segregates these.) + data_files[name] = b64decode(contents.encode()) + oc.eval(b64decode(contents.encode()).decode("utf-8")) + + generated_html = [] + result["html"] = generated_html + + def output_html(s): + generated_html.append(s) + + feedback = Feedback() + maint_ctx = { + "feedback": feedback, + "user_code": run_req.user_code, + "data_files": data_files, + "output_html": output_html, + "GradingComplete": GradingComplete, + } + + if run_req.setup_code is not None: + try: + oc.eval(run_req.setup_code) + except Exception: + package_exception(result, "setup_error") + return + + ''' + user_ctx = {} + if hasattr(run_req, "names_for_user"): #XXX unused for Octave context currently + for name in run_req.names_for_user: + if name not in maint_ctx: + result["result"] = "setup_error" + result["message"] = "Setup code did not define '%s'." % name + + user_ctx[name] = maint_ctx[name] + + from copy import deepcopy + user_ctx = deepcopy(user_ctx) + ''' + + try: + oc.eval(run_req.user_code) + except Exception: + package_exception(result, "user_error") + return + + # {{{ export plots + + ''' + if "matplotlib" in sys.modules: + import matplotlib.pyplot as pt + from io import BytesIO + from base64 import b64encode + + format = "png" + mime = "image/png" + figures = [] + + for fignum in pt.get_fignums(): + pt.figure(fignum) + bio = BytesIO() + try: + pt.savefig(bio, format=format) + except Exception: + pass + else: + figures.append( + (fignum, mime, b64encode(bio.getvalue()).decode())) + + result["figures"] = figures + ''' + # }}} + + if hasattr(run_req, "names_from_user"): + values = [] + for name in run_req.names_from_user: + try: + maint_ctx[name] = oc.pull(name) + except oct2py.Oct2PyError: + feedback.add_feedback( + "Required answer variable '%s' is not defined." + % name) + maint_ctx[name] = None + + if run_req.test_code is not None: # XXX test code is written in Python + try: + maint_ctx["_MODULE_SOURCE_CODE"] = run_req.test_code + exec(run_req.test_code, maint_ctx) + except GradingComplete: + pass + except Exception: + package_exception(result, "test_error") + return + + result["points"] = feedback.points + result["feedback"] = feedback.feedback_items + + # }}} + + result["result"] = "success" + +# vim: foldmethod=marker diff --git a/docker-image-run-octave/docker-build.sh b/docker-image-run-octave/docker-build.sh index dbcf6b1de..eaabf1669 100755 --- a/docker-image-run-octave/docker-build.sh +++ b/docker-image-run-octave/docker-build.sh @@ -1,5 +1,5 @@ #! /bin/sh cp ../course/page/code_feedback.py . cp ../course/page/code_runoc_backend.py code_run_backend.py -docker build --no-cache . -t inducer/runoc +docker build --no-cache . -t davis68/runoc rm code_feedback.py code_run_backend.py diff --git a/local_settings_example.py b/local_settings_example.py index 75ddeeb39..8b13f89fb 100644 --- a/local_settings_example.py +++ b/local_settings_example.py @@ -343,7 +343,7 @@ # A string containing the image ID of the docker image to be used to run # student Python code. Docker should download the image on first run. RELATE_DOCKER_RUNPY_IMAGE = "inducer/relate-runpy-amd64" -RELATE_DOCKER_RUNOC_IMAGE = "inducer/runoc" +RELATE_DOCKER_RUNOC_IMAGE = "davis68/runoc" # RELATE_DOCKER_RUNPY_IMAGE = "inducer/relate-runpy-amd64-tensorflow" # (bigger, but includes TensorFlow) From 8bfe887aff18aad7a2cf2fa7fb3a2f85ed6392da Mon Sep 17 00:00:00 2001 From: Neal Davis Date: Tue, 2 Apr 2019 22:24:00 -0500 Subject: [PATCH 09/14] Post changes to tests. --- .gitignore | 3 + docker-image-run-octave/docker-build.sh | 2 +- docker-image-run-py/Dockerfile | 3 + exercise-docker.py | 4 +- local_settings_example.py | 2 +- tests/test_pages/test_code.py | 78 ++++++++++++------------- 6 files changed, 49 insertions(+), 43 deletions(-) diff --git a/.gitignore b/.gitignore index 3fc425ee2..3231659ce 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ git-roots /.idea /.env + +my_todo +password diff --git a/docker-image-run-octave/docker-build.sh b/docker-image-run-octave/docker-build.sh index eaabf1669..b7e7d3d6e 100755 --- a/docker-image-run-octave/docker-build.sh +++ b/docker-image-run-octave/docker-build.sh @@ -1,5 +1,5 @@ #! /bin/sh cp ../course/page/code_feedback.py . cp ../course/page/code_runoc_backend.py code_run_backend.py -docker build --no-cache . -t davis68/runoc +docker build --no-cache . -t davis68/relate-octave rm code_feedback.py code_run_backend.py diff --git a/docker-image-run-py/Dockerfile b/docker-image-run-py/Dockerfile index 6f620d2ec..fa0e460ae 100644 --- a/docker-image-run-py/Dockerfile +++ b/docker-image-run-py/Dockerfile @@ -11,6 +11,7 @@ RUN echo 'APT::Default-Release "testing";' >> /etc/apt/apt.conf RUN apt-get update RUN apt-get -y -o APT::Install-Recommends=0 -o APT::Install-Suggests=0 install \ python3-scipy \ + python3-numpy \ python3-pip \ python3-matplotlib \ python3-pillow \ @@ -39,6 +40,8 @@ RUN apt-get -y -o APT::Install-Recommends=0 -o APT::Install-Suggests=0 install \ RUN apt-get clean RUN fc-cache +RUN pip3 install numpy==1.16.1 + RUN mkdir -p /opt/runcode ADD runcode /opt/runcode/ COPY code_feedback.py /opt/runcode/ diff --git a/exercise-docker.py b/exercise-docker.py index 09821f7b6..bc91ada30 100644 --- a/exercise-docker.py +++ b/exercise-docker.py @@ -1,5 +1,5 @@ def main(): - from course.page import request_python_run + from course.page import request_run req = { "setup_code": "a,b=5,2", @@ -13,7 +13,7 @@ def main(): while True: print count count += 1 - res = request_python_run(req, 5, + res = request_run(req, 5, image="inducer/runpy-i386") if res["result"] != "success": print res diff --git a/local_settings_example.py b/local_settings_example.py index 8b13f89fb..dc4e9d016 100644 --- a/local_settings_example.py +++ b/local_settings_example.py @@ -343,7 +343,7 @@ # A string containing the image ID of the docker image to be used to run # student Python code. Docker should download the image on first run. RELATE_DOCKER_RUNPY_IMAGE = "inducer/relate-runpy-amd64" -RELATE_DOCKER_RUNOC_IMAGE = "davis68/runoc" +RELATE_DOCKER_RUNOC_IMAGE = "davis68/relate-octave" # RELATE_DOCKER_RUNPY_IMAGE = "inducer/relate-runpy-amd64-tensorflow" # (bigger, but includes TensorFlow) diff --git a/tests/test_pages/test_code.py b/tests/test_pages/test_code.py index c35ec347e..cbfd41d0d 100644 --- a/tests/test_pages/test_code.py +++ b/tests/test_pages/test_code.py @@ -33,7 +33,7 @@ from course.models import FlowSession from course.page.code import ( - CODE_QUESTION_CONTAINER_PORT, request_python_run_with_retries, InvalidPingResponse, + CODE_QUESTION_CONTAINER_PORT, request_run_with_retries, InvalidPingResponse, is_nuisance_failure, PythonCodeQuestionWithHumanTextFeedback) from course.page.code import ( RUNOC_PORT, request_octave_run_with_retries) @@ -75,7 +75,7 @@ "The grading code failed. Sorry about that." ) -RUNPY_WITH_RETRIES_PATH = "course.page.code.request_python_run_with_retries" +RUNPY_WITH_RETRIES_PATH = "course.page.code.request_run_with_retries" AUTO_FEEDBACK_POINTS_OUT_OF_RANGE_ERROR_MSG_PATTERN = ( "'correctness' is invalid: expecting " @@ -411,13 +411,13 @@ def test_question_with_human_feedback_value_0_feedback_0_percentage(self): self.assertSandboxHasValidPage(resp) self.assertSandboxWarningTextContain(resp, None) - def test_request_python_run_with_retries_raise_uncaught_error_in_sandbox(self): + def test_request_run_with_retries_raise_uncaught_error_in_sandbox(self): with mock.patch( RUNPY_WITH_RETRIES_PATH, autospec=True ) as mock_runpy: expected_error_str = ("This is an error raised with " - "request_python_run_with_retries") + "request_run_with_retries") # correct_code_explanation and correct_code expected_feedback = ( @@ -438,13 +438,13 @@ def test_request_python_run_with_retries_raise_uncaught_error_in_sandbox(self): # No email when in sandbox self.assertEqual(len(mail.outbox), 0) - def test_request_python_run_with_retries_raise_uncaught_error_debugging(self): + def test_request_run_with_retries_raise_uncaught_error_debugging(self): with mock.patch( RUNPY_WITH_RETRIES_PATH, autospec=True ) as mock_runpy: expected_error_str = ("This is an error raised with " - "request_python_run_with_retries") + "request_run_with_retries") mock_runpy.side_effect = RuntimeError(expected_error_str) with override_settings(DEBUG=True): @@ -457,13 +457,13 @@ def test_request_python_run_with_retries_raise_uncaught_error_debugging(self): # No email when debugging self.assertEqual(len(mail.outbox), 0) - def test_request_python_run_with_retries_raise_uncaught_error(self): + def test_request_run_with_retries_raise_uncaught_error(self): with mock.patch( RUNPY_WITH_RETRIES_PATH, autospec=True ) as mock_runpy: expected_error_str = ("This is an error raised with " - "request_python_run_with_retries") + "request_run_with_retries") mock_runpy.side_effect = RuntimeError(expected_error_str) with mock.patch("course.page.PageContext") as mock_page_context: @@ -484,13 +484,13 @@ def test_request_python_run_with_retries_raise_uncaught_error(self): self.assertEqual(len(mail.outbox), 1) self.assertIn(expected_error_str, mail.outbox[0].body) - def test_send_email_failure_when_request_python_run_with_retries_raise_uncaught_error(self): # noqa + def test_send_email_failure_when_request_run_with_retries_raise_uncaught_error(self): # noqa with mock.patch( RUNPY_WITH_RETRIES_PATH, autospec=True ) as mock_runpy: expected_error_str = ("This is an error raised with " - "request_python_run_with_retries") + "request_run_with_retries") mock_runpy.side_effect = RuntimeError(expected_error_str) with mock.patch("course.page.PageContext") as mock_page_context: @@ -548,7 +548,7 @@ def assert_runpy_result_and_response(self, result_type, expected_msgs=None, correctness) self.assertEqual(len(mail.outbox), mail_count) - def test_request_python_run_with_retries_timed_out(self): + def test_request_run_with_retries_timed_out(self): self.assert_runpy_result_and_response( "timeout", "Your code took too long to execute.") @@ -1003,7 +1003,7 @@ def test_feedback_code_error_exceed_max_extra_credit_factor_email(self): class RequestPythonRunWithRetriesTest(unittest.TestCase): - # Testing course.page.code.request_python_run_with_retries, + # Testing course.page.code.request_run_with_retries, # adding tests for use cases that didn't cover in other tests @override_settings(RELATE_DOCKER_RUNPY_IMAGE="some_other_image") @@ -1015,7 +1015,7 @@ def test_image_none(self): mock_create_ctn.return_value = {} with self.assertRaises(KeyError): - request_python_run_with_retries( + request_run_with_retries( run_req={}, run_timeout=0.1) self.assertEqual(mock_create_ctn.call_count, 1) self.assertIn("some_other_image", mock_create_ctn.call_args[0]) @@ -1031,7 +1031,7 @@ def test_image_not_none(self): my_image = "my_runpy_image" with self.assertRaises(KeyError): - request_python_run_with_retries( + request_run_with_retries( run_req={}, image=my_image, run_timeout=0.1) self.assertEqual(mock_create_ctn.call_count, 1) self.assertIn(my_image, mock_create_ctn.call_args[0]) @@ -1067,7 +1067,7 @@ def test_docker_container_ping_failure(self): # force timeout with mock.patch("course.page.code.DOCKER_TIMEOUT", 0.0001): - res = request_python_run_with_retries( + res = request_run_with_retries( run_req={}, run_timeout=0.1, retry_count=0) self.assertEqual(res["result"], "uncaught_error") self.assertEqual(res['message'], @@ -1083,7 +1083,7 @@ def test_docker_container_ping_failure(self): # force timeout with mock.patch("course.page.code.DOCKER_TIMEOUT", 0.0001): - res = request_python_run_with_retries( + res = request_run_with_retries( run_req={}, run_timeout=0.1, retry_count=0) self.assertEqual(res["result"], "uncaught_error") self.assertEqual(res['message'], @@ -1100,7 +1100,7 @@ def test_docker_container_ping_failure(self): # force timeout with mock.patch("course.page.code.DOCKER_TIMEOUT", 0.0001): - res = request_python_run_with_retries( + res = request_run_with_retries( run_req={}, run_timeout=0.1, retry_count=0) self.assertEqual(res["result"], "uncaught_error") self.assertEqual(res['message'], @@ -1116,7 +1116,7 @@ def test_docker_container_ping_failure(self): # force timeout with mock.patch("course.page.code.DOCKER_TIMEOUT", 0.0001): - res = request_python_run_with_retries( + res = request_run_with_retries( run_req={}, run_timeout=0.1, retry_count=0) self.assertEqual(res["result"], "uncaught_error") self.assertEqual(res['message'], @@ -1135,12 +1135,12 @@ def test_docker_container_ping_failure(self): # force timeout with mock.patch("course.page.code.DOCKER_TIMEOUT", 0.0001): with self.assertRaises(socket_error) as e: - request_python_run_with_retries( + request_run_with_retries( run_req={}, run_timeout=0.1, retry_count=0) self.assertEqual(e.exception.errno, my_socket_error.errno) with self.assertRaises(socket_error) as e: - request_python_run_with_retries( + request_run_with_retries( run_req={}, run_timeout=0.1, retry_count=0) self.assertEqual(e.exception.errno, my_socket_error.errno) @@ -1170,7 +1170,7 @@ def test_docker_container_ping_failure(self): # force timeout with mock.patch("course.page.code.DOCKER_TIMEOUT", 0.0001): - res = request_python_run_with_retries( + res = request_run_with_retries( run_req={}, run_timeout=0.1, retry_count=0) self.assertEqual(res["result"], "uncaught_error") self.assertEqual(res['message'], @@ -1215,7 +1215,7 @@ def test_docker_container_ping_return_not_ok(self): mock_ctn_request.side_effect = lambda x, y: None mock_ctn_get_response.return_value = six.BytesIO(b"NOT OK") - res = request_python_run_with_retries( + res = request_run_with_retries( run_req={}, run_timeout=0.1, retry_count=0) self.assertEqual(res["result"], "uncaught_error") self.assertEqual(res['message'], @@ -1255,7 +1255,7 @@ def test_docker_container_runpy_timeout(self): mock_ctn_request.side_effect = [None, sock_timeout] mock_ctn_get_response.return_value = six.BytesIO(b"OK") - res = request_python_run_with_retries( + res = request_run_with_retries( run_req={}, run_timeout=0.1, retry_count=0) self.assertEqual(res["result"], "timeout") self.assertEqual(res["exec_host"], fake_host_ip) @@ -1263,13 +1263,13 @@ def test_docker_container_runpy_timeout(self): @skipIf(six.PY2, "PY2 doesn't support subTest") def test_docker_container_runpy_retries_count(self): with ( - mock.patch("course.page.code.request_python_run")) as mock_req_run, ( # noqa + mock.patch("course.page.code.request_run")) as mock_req_run, ( # noqa mock.patch("course.page.code.is_nuisance_failure")) as mock_is_nuisance_failure: # noqa expected_result = "this is my custom result" mock_req_run.return_value = {"result": expected_result} with self.subTest(actual_retry_count=4): mock_is_nuisance_failure.side_effect = [True, True, True, False] - res = request_python_run_with_retries( + res = request_run_with_retries( run_req={}, run_timeout=0.1, retry_count=5) self.assertEqual(res["result"], expected_result) self.assertEqual(mock_req_run.call_count, 4) @@ -1279,7 +1279,7 @@ def test_docker_container_runpy_retries_count(self): mock_is_nuisance_failure.reset_mock() with self.subTest(actual_retry_count=2): mock_is_nuisance_failure.side_effect = [True, True, True, False] - res = request_python_run_with_retries( + res = request_run_with_retries( run_req={}, run_timeout=0.1, retry_count=1) self.assertEqual(res["result"], expected_result) self.assertEqual(mock_req_run.call_count, 2) @@ -1299,7 +1299,7 @@ def test_image_none(self): mock_create_ctn.return_value = {} with self.assertRaises(KeyError): - request_python_run_with_retries( + request_run_with_retries( run_req={}, run_timeout=0.1) self.assertEqual(mock_create_ctn.call_count, 1) self.assertIn("some_other_image", mock_create_ctn.call_args[0]) @@ -1315,7 +1315,7 @@ def test_image_not_none(self): my_image = "my_runoc_image" with self.assertRaises(KeyError): - request_python_run_with_retries( + request_run_with_retries( run_req={}, image=my_image, run_timeout=0.1) self.assertEqual(mock_create_ctn.call_count, 1) self.assertIn(my_image, mock_create_ctn.call_args[0]) @@ -1351,7 +1351,7 @@ def test_docker_container_ping_failure(self): # force timeout with mock.patch("course.page.code.DOCKER_TIMEOUT", 0.0001): - res = request_python_run_with_retries( + res = request_run_with_retries( run_req={}, run_timeout=0.1, retry_count=0) self.assertEqual(res["result"], "uncaught_error") self.assertEqual(res['message'], @@ -1367,7 +1367,7 @@ def test_docker_container_ping_failure(self): # force timeout with mock.patch("course.page.code.DOCKER_TIMEOUT", 0.0001): - res = request_python_run_with_retries( + res = request_run_with_retries( run_req={}, run_timeout=0.1, retry_count=0) self.assertEqual(res["result"], "uncaught_error") self.assertEqual(res['message'], @@ -1384,7 +1384,7 @@ def test_docker_container_ping_failure(self): # force timeout with mock.patch("course.page.code.DOCKER_TIMEOUT", 0.0001): - res = request_python_run_with_retries( + res = request_run_with_retries( run_req={}, run_timeout=0.1, retry_count=0) self.assertEqual(res["result"], "uncaught_error") self.assertEqual(res['message'], @@ -1400,7 +1400,7 @@ def test_docker_container_ping_failure(self): # force timeout with mock.patch("course.page.code.DOCKER_TIMEOUT", 0.0001): - res = request_python_run_with_retries( + res = request_run_with_retries( run_req={}, run_timeout=0.1, retry_count=0) self.assertEqual(res["result"], "uncaught_error") self.assertEqual(res['message'], @@ -1419,12 +1419,12 @@ def test_docker_container_ping_failure(self): # force timeout with mock.patch("course.page.code.DOCKER_TIMEOUT", 0.0001): with self.assertRaises(socket_error) as e: - request_python_run_with_retries( + request_run_with_retries( run_req={}, run_timeout=0.1, retry_count=0) self.assertEqual(e.exception.errno, my_socket_error.errno) with self.assertRaises(socket_error) as e: - request_python_run_with_retries( + request_run_with_retries( run_req={}, run_timeout=0.1, retry_count=0) self.assertEqual(e.exception.errno, my_socket_error.errno) @@ -1454,7 +1454,7 @@ def test_docker_container_ping_failure(self): # force timeout with mock.patch("course.page.code.DOCKER_TIMEOUT", 0.0001): - res = request_python_run_with_retries( + res = request_run_with_retries( run_req={}, run_timeout=0.1, retry_count=0) self.assertEqual(res["result"], "uncaught_error") self.assertEqual(res['message'], @@ -1499,7 +1499,7 @@ def test_docker_container_ping_return_not_ok(self): mock_ctn_request.side_effect = lambda x, y: None mock_ctn_get_response.return_value = six.BytesIO(b"NOT OK") - res = request_python_run_with_retries( + res = request_run_with_retries( run_req={}, run_timeout=0.1, retry_count=0) self.assertEqual(res["result"], "uncaught_error") self.assertEqual(res['message'], @@ -1539,7 +1539,7 @@ def test_docker_container_runpy_timeout(self): mock_ctn_request.side_effect = [None, sock_timeout] mock_ctn_get_response.return_value = six.BytesIO(b"OK") - res = request_python_run_with_retries( + res = request_run_with_retries( run_req={}, run_timeout=0.1, retry_count=0) self.assertEqual(res["result"], "timeout") self.assertEqual(res["exec_host"], fake_host_ip) @@ -1553,7 +1553,7 @@ def test_docker_container_runoc_retries_count(self): mock_req_run.return_value = {"result": expected_result} with self.subTest(actual_retry_count=4): mock_is_nuisance_failure.side_effect = [True, True, True, False] - res = request_python_run_with_retries( + res = request_run_with_retries( run_req={}, run_timeout=0.1, retry_count=5) self.assertEqual(res["result"], expected_result) self.assertEqual(mock_req_run.call_count, 4) @@ -1563,7 +1563,7 @@ def test_docker_container_runoc_retries_count(self): mock_is_nuisance_failure.reset_mock() with self.subTest(actual_retry_count=2): mock_is_nuisance_failure.side_effect = [True, True, True, False] - res = request_python_run_with_retries( + res = request_run_with_retries( run_req={}, run_timeout=0.1, retry_count=1) self.assertEqual(res["result"], expected_result) self.assertEqual(mock_req_run.call_count, 2) From a4cedefbf9a0ccbce885186af8a80754481640f2 Mon Sep 17 00:00:00 2001 From: Neal Davis Date: Tue, 2 Apr 2019 22:24:14 -0500 Subject: [PATCH 10/14] Post changes to tests. --- tests/test_pages/markdowns.py | 365 +++++++++++++++++++++++++++++++++- 1 file changed, 364 insertions(+), 1 deletion(-) diff --git a/tests/test_pages/markdowns.py b/tests/test_pages/markdowns.py index dc0039d84..336384b57 100644 --- a/tests/test_pages/markdowns.py +++ b/tests/test_pages/markdowns.py @@ -7,7 +7,7 @@ # }}} -# {{{ code questions +# {{{ python code questions CODE_MARKDWON = """ type: PythonCodeQuestion @@ -418,4 +418,367 @@ # }}} +# {{{ octave code questions + +OCTAVE_CODE_MARKDWON = """ +type: OctaveCodeQuestion +access_rules: + add_permissions: + - change_answer +id: addition +value: 1 +timeout: 10 +prompt: | + + # Adding 1 and 2, and assign it to c + +names_from_user: [c] + +initial_code: | + c = + +test_code: | + if not isinstance(c, float): + feedback.finish(0, "Your computed c is not a float.") + + correct_c = 3 + rel_err = abs(correct_c-c)/abs(correct_c) + + if rel_err < 1e-7: + feedback.finish(1, "Your computed c was correct.") + else: + feedback.finish(0, "Your computed c was incorrect.") + +correct_code: | + + c = 2 + 1 + +correct_code_explanation: This is the [explanation](http://example.com/1). +""" + +OCTAVE_CODE_MARKDWON_PATTERN_WITH_DATAFILES = """ +type: OctaveCodeQuestion +id: addition +value: 1 +timeout: 10 +data_files: + - question-data/random-data.npy + %(extra_data_file)s +prompt: | + + # Adding two numbers in Python + +setup_code: | + import random + + a = random.uniform(-10, 10) + b = random.uniform(-10, 10) + +names_for_user: [a, b] + +names_from_user: [c] + +test_code: | + if not isinstance(c, float): + feedback.finish(0, "Your computed c is not a float.") + + correct_c = a + b + rel_err = abs(correct_c-c)/abs(correct_c) + + if rel_err < 1e-7: + feedback.finish(1, "Your computed c was correct.") + else: + feedback.finish(0, "Your computed c was incorrect.") + +correct_code: | + + c = a + b +""" + +OCTAVE_CODE_MARKDWON_WITH_DATAFILES_BAD_FORMAT = """ +type: OctaveCodeQuestion +id: addition +value: 1 +timeout: 10 +data_files: + - question-data/random-data.npy + - - foo + - bar +prompt: | + + # Adding two numbers in Python + +setup_code: | + import random + + a = random.uniform(-10, 10) + b = random.uniform(-10, 10) + +names_for_user: [a, b] + +names_from_user: [c] + +test_code: | + if not isinstance(c, float): + feedback.finish(0, "Your computed c is not a float.") + + correct_c = a + b + rel_err = abs(correct_c-c)/abs(correct_c) + + if rel_err < 1e-7: + feedback.finish(1, "Your computed c was correct.") + else: + feedback.finish(0, "Your computed c was incorrect.") + +correct_code: | + + c = a + b +""" + +OCTAVE_CODE_MARKDWON_NOT_EXPLICITLY_NOT_ALLOW_MULTI_SUBMIT1 = """ +type: OctaveCodeQuestion +access_rules: + add_permissions: + - see_answer_after_submission +id: addition +value: 1 +timeout: 10 +prompt: | + + # Adding two numbers in Python + +setup_code: | + import random + + a = random.uniform(-10, 10) + b = random.uniform(-10, 10) + +names_for_user: [a, b] + +names_from_user: [c] + +test_code: | + if not isinstance(c, float): + feedback.finish(0, "Your computed c is not a float.") + + correct_c = a + b + rel_err = abs(correct_c-c)/abs(correct_c) + + if rel_err < 1e-7: + feedback.finish(1, "Your computed c was correct.") + else: + feedback.finish(0, "Your computed c was incorrect.") + +correct_code: | + + c = a + b +""" + +OCTAVE_CODE_MARKDWON_NOT_EXPLICITLY_NOT_ALLOW_MULTI_SUBMIT2 = """ +type: OctaveCodeQuestion +access_rules: + remove_permissions: + - see_answer_after_submission +id: addition +value: 1 +timeout: 10 +prompt: | + + # Adding two numbers in Python + +setup_code: | + import random + + a = random.uniform(-10, 10) + b = random.uniform(-10, 10) + +names_for_user: [a, b] + +names_from_user: [c] + +test_code: | + if not isinstance(c, float): + feedback.finish(0, "Your computed c is not a float.") + + correct_c = a + b + rel_err = abs(correct_c-c)/abs(correct_c) + + if rel_err < 1e-7: + feedback.finish(1, "Your computed c was correct.") + else: + feedback.finish(0, "Your computed c was incorrect.") + +correct_code: | + + c = a + b +""" + +OCTAVE_CODE_MARKDWON_PATTERN_EXPLICITLY_NOT_ALLOW_MULTI_SUBMIT = """ +type: OctaveCodeQuestion +id: addition +value: 1 +timeout: 10 +single_submission: True +prompt: | + + # Adding two numbers in Python + +setup_code: | + import random + + a = random.uniform(-10, 10) + b = random.uniform(-10, 10) + +names_for_user: [a, b] + +names_from_user: [c] + +test_code: | + if not isinstance(c, float): + feedback.finish(0, "Your computed c is not a float.") + + correct_c = a + b + rel_err = abs(correct_c-c)/abs(correct_c) + + if rel_err < 1e-7: + feedback.finish(1, "Your computed c was correct.") + else: + feedback.finish(0, "Your computed c was incorrect.") + +correct_code: | + c = a + b +""" + +OCTAVE_CODE_MARKDWON_PATTERN_WITHOUT_TEST_CODE = """ +type: OctaveCodeQuestion +id: addition +value: 1 +timeout: 10 +single_submission: True +prompt: | + + # Adding two numbers in Python + +setup_code: | + import random + + a = random.uniform(-10, 10) + b = random.uniform(-10, 10) + +names_for_user: [a, b] + +names_from_user: [c] + +correct_code: | + c = a + b +""" + +OCTAVE_CODE_MARKDWON_PATTERN_WITHOUT_CORRECT_CODE = """ +type: OctaveCodeQuestion +id: addition +value: 1 +timeout: 10 +single_submission: True +prompt: | + + # Adding two numbers in Python + +setup_code: | + import random + + a = random.uniform(-10, 10) + b = random.uniform(-10, 10) + +names_for_user: [a, b] + +names_from_user: [c] + +test_code: | + if not isinstance(c, float): + feedback.finish(0, "Your computed c is not a float.") + + correct_c = a + b + rel_err = abs(correct_c-c)/abs(correct_c) + + if rel_err < 1e-7: + feedback.finish(1, "Your computed c was correct.") + else: + feedback.finish(0, "Your computed c was incorrect.") + +""" + +OCTAVE_FEEDBACK_POINTS_CODE_MARKDWON_PATTERN = """ +type: OctaveCodeQuestion +id: addition +value: 1 +timeout: 10 +prompt: | + + # Adding two numbers in Python + +setup_code: | + import random + + a = random.uniform(-10, 10) + b = random.uniform(-10, 10) + +names_for_user: [a, b] + +names_from_user: [c] + +test_code: | + if not isinstance(c, float): + feedback.finish(0, "Your computed c is not a float.") + + correct_c = a + b + rel_err = abs(correct_c-c)/abs(correct_c) + + if rel_err < 1e-7: + feedback.finish(%(full_points)s, "Your computed c was correct.") + else: + feedback.finish(%(min_points)s, "Your computed c was incorrect.") + +correct_code: | + + c = a + b +""" # noqa + +OCTAVE_FEEDBACK_POINTS_CODE_MARKDWON_PATTERN = """ +type: OctaveCodeQuestion +id: addition +value: 1 +timeout: 10 +prompt: | + + # Adding two numbers in Python + +setup_code: | + import random + + a = random.uniform(-10, 10) + b = random.uniform(-10, 10) + +names_for_user: [a, b] + +names_from_user: [c] + +test_code: | + if not isinstance(c, float): + feedback.finish(0, "Your computed c is not a float.") + + correct_c = a + b + rel_err = abs(correct_c-c)/abs(correct_c) + + if rel_err < 1e-7: + feedback.finish(%(full_points)s, "Your computed c was correct.") + else: + feedback.finish(%(min_points)s, "Your computed c was incorrect.") + +correct_code: | + + c = a + b +""" # noqa + +# }}} + # vim: fdm=marker From 1e6051b20d1b6ede2722382f4bc43e451f4d6152 Mon Sep 17 00:00:00 2001 From: Neal Davis Date: Tue, 9 Apr 2019 08:36:45 -0500 Subject: [PATCH 11/14] Post tests. --- tests/test_pages/markdowns.py | 76 ++++----- tests/test_pages/test_code.py | 300 ++++++++++++++++++++++++++++++++++ 2 files changed, 329 insertions(+), 47 deletions(-) diff --git a/tests/test_pages/markdowns.py b/tests/test_pages/markdowns.py index 336384b57..ed0693cb9 100644 --- a/tests/test_pages/markdowns.py +++ b/tests/test_pages/markdowns.py @@ -462,17 +462,15 @@ value: 1 timeout: 10 data_files: - - question-data/random-data.npy + - question-data/random-data.m %(extra_data_file)s prompt: | - # Adding two numbers in Python + # Adding two numbers in Octave setup_code: | - import random - - a = random.uniform(-10, 10) - b = random.uniform(-10, 10) + a = unifrnd(-10,10) + b = unifrnd(-10,10) names_for_user: [a, b] @@ -501,18 +499,16 @@ value: 1 timeout: 10 data_files: - - question-data/random-data.npy + - question-data/random-data.m - - foo - bar prompt: | - # Adding two numbers in Python + # Adding two numbers in Octave setup_code: | - import random - - a = random.uniform(-10, 10) - b = random.uniform(-10, 10) + a = unifrnd(-10,10) + b = unifrnd(-10,10) names_for_user: [a, b] @@ -545,13 +541,11 @@ timeout: 10 prompt: | - # Adding two numbers in Python + # Adding two numbers in Octave setup_code: | - import random - - a = random.uniform(-10, 10) - b = random.uniform(-10, 10) + a = unifrnd(-10,10) + b = unifrnd(-10,10) names_for_user: [a, b] @@ -584,13 +578,11 @@ timeout: 10 prompt: | - # Adding two numbers in Python + # Adding two numbers in Octave setup_code: | - import random - - a = random.uniform(-10, 10) - b = random.uniform(-10, 10) + a = unifrnd(-10,10) + b = unifrnd(-10,10) names_for_user: [a, b] @@ -621,13 +613,11 @@ single_submission: True prompt: | - # Adding two numbers in Python + # Adding two numbers in Octave setup_code: | - import random - - a = random.uniform(-10, 10) - b = random.uniform(-10, 10) + a = unifrnd(-10,10) + b = unifrnd(-10,10) names_for_user: [a, b] @@ -657,13 +647,11 @@ single_submission: True prompt: | - # Adding two numbers in Python + # Adding two numbers in Octave setup_code: | - import random - - a = random.uniform(-10, 10) - b = random.uniform(-10, 10) + a = unifrnd(-10,10) + b = unifrnd(-10,10) names_for_user: [a, b] @@ -681,13 +669,11 @@ single_submission: True prompt: | - # Adding two numbers in Python + # Adding two numbers in Octave setup_code: | - import random - - a = random.uniform(-10, 10) - b = random.uniform(-10, 10) + a = unifrnd(-10,10) + b = unifrnd(-10,10) names_for_user: [a, b] @@ -714,13 +700,11 @@ timeout: 10 prompt: | - # Adding two numbers in Python + # Adding two numbers in Octave setup_code: | - import random - - a = random.uniform(-10, 10) - b = random.uniform(-10, 10) + a = unifrnd(-10,10) + b = unifrnd(-10,10) names_for_user: [a, b] @@ -750,13 +734,11 @@ timeout: 10 prompt: | - # Adding two numbers in Python + # Adding two numbers in Octave setup_code: | - import random - - a = random.uniform(-10, 10) - b = random.uniform(-10, 10) + a = unifrnd(-10,10) + b = unifrnd(-10,10) names_for_user: [a, b] diff --git a/tests/test_pages/test_code.py b/tests/test_pages/test_code.py index cbfd41d0d..a83179e6b 100644 --- a/tests/test_pages/test_code.py +++ b/tests/test_pages/test_code.py @@ -1001,6 +1001,306 @@ def test_feedback_code_error_exceed_max_extra_credit_factor_email(self): # }}} + # {{{ Octave code tests patterned after Python tests + + def test_data_files_missing_random_question_data_file(self): + file_name = "foo" + markdown = ( + markdowns.OCTAVE_CODE_MARKDWON_PATTERN_WITH_DATAFILES + % {"extra_data_file": "- %s" % file_name} + ) + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxNotHasValidPage(resp) + self.assertResponseContextContains( + resp, PAGE_ERRORS, "data file '%s' not found" % file_name) + + def test_data_files_missing_random_question_data_file_bad_format(self): + markdown = markdowns.OCTAVE_CODE_MARKDWON_WITH_DATAFILES_BAD_FORMAT + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxNotHasValidPage(resp) + self.assertResponseContextContains( + resp, PAGE_ERRORS, "data file '%s' not found" % "['foo', 'bar']") + + def test_not_multiple_submit_warning(self): + markdown = ( + markdowns.OCTAVE_CODE_MARKDWON_PATTERN_WITH_DATAFILES + % {"extra_data_file": ""} + ) + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxHasValidPage(resp) + self.assertSandboxWarningTextContain( + resp, + NOT_ALLOW_MULTIPLE_SUBMISSION_WARNING + ) + + def test_not_multiple_submit_warning2(self): + markdown = markdowns.OCTAVE_CODE_MARKDWON_NOT_EXPLICITLY_NOT_ALLOW_MULTI_SUBMIT1 + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxHasValidPage(resp) + self.assertSandboxWarningTextContain( + resp, + NOT_ALLOW_MULTIPLE_SUBMISSION_WARNING + ) + + def test_not_multiple_submit_warning3(self): + markdown = markdowns.OCTAVE_CODE_MARKDWON_NOT_EXPLICITLY_NOT_ALLOW_MULTI_SUBMIT2 + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxHasValidPage(resp) + self.assertSandboxWarningTextContain( + resp, + NOT_ALLOW_MULTIPLE_SUBMISSION_WARNING + ) + + def test_allow_multiple_submit(self): + markdown = markdowns.OCTAVE_CODE_MARKDWON + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxHasValidPage(resp) + self.assertSandboxWarningTextContain(resp, None) + + def test_explicity_not_allow_multiple_submit(self): + markdown = ( + markdowns.OCTAVE_CODE_MARKDWON_PATTERN_EXPLICITLY_NOT_ALLOW_MULTI_SUBMIT + % {"extra_data_file": ""} + ) + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxHasValidPage(resp) + self.assertSandboxWarningTextContain(resp, None) + + def test_question_without_test_code(self): + markdown = markdowns.OCTAVE_CODE_MARKDWON_PATTERN_WITHOUT_TEST_CODE + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxHasValidPage(resp) + self.assertSandboxWarningTextContain(resp, None) + + resp = self.get_page_sandbox_submit_answer_response( + markdown, + answer_data={"answer": ['c = b + a\r']}) + self.assertEqual(resp.status_code, 200) + self.assertResponseContextAnswerFeedbackCorrectnessEquals(resp, None) + self.assertResponseContextAnswerFeedbackContainsFeedback( + resp, NO_CORRECTNESS_INFO_MSG) + + def test_question_without_correct_code(self): + markdown = markdowns.OCTAVE_CODE_MARKDWON_PATTERN_WITHOUT_CORRECT_CODE + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxHasValidPage(resp) + self.assertSandboxWarningTextContain(resp, None) + + resp = self.get_page_sandbox_submit_answer_response( + markdown, + answer_data={"answer": ['c = b + a\r']}) + self.assertEqual(resp.status_code, 200) + self.assertResponseContextAnswerFeedbackCorrectnessEquals(resp, 1) + + def test_feedback_points_close_to_1(self): + markdown = (markdowns.OCTAVE_FEEDBACK_POINTS_CODE_MARKDWON_PATTERN + % { + "full_points": 1.000000000002, + "min_points": 0 + }) + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxHasValidPage(resp) + + resp = self.get_page_sandbox_submit_answer_response( + markdown, + answer_data={"answer": ['c = b + a\r']}) + self.assertEqual(resp.status_code, 200) + self.assertResponseContextAnswerFeedbackCorrectnessEquals(resp, 1) + + def test_feedback_code_exceed_1(self): + feedback_points = 1.1 + markdown = (markdowns.OCTAVE_FEEDBACK_POINTS_CODE_MARKDWON_PATTERN + % { + "full_points": feedback_points, + "min_points": 0 + }) + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxHasValidPage(resp) + + resp = self.get_page_sandbox_submit_answer_response( + markdown, + answer_data={"answer": ['c = b + a\r']}) + self.assertEqual(resp.status_code, 200) + self.assertResponseContextAnswerFeedbackCorrectnessEquals(resp, 1.1) + + expected_feedback = "Your answer is correct and earned bonus points." + + self.assertResponseContextAnswerFeedbackContainsFeedback( + resp, expected_feedback) + + def test_feedback_code_positive_close_to_0(self): + # https://github.com/inducer/relate/pull/448#issuecomment-363655132 + markdown = (markdowns.OCTAVE_FEEDBACK_POINTS_CODE_MARKDWON_PATTERN + % { + "full_points": 1, + "min_points": 0.00000000001 + }) + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxHasValidPage(resp) + + # Post a wrong answer + resp = self.get_page_sandbox_submit_answer_response( + markdown, + answer_data={"answer": ['c = b - a\r']}) + self.assertEqual(resp.status_code, 200) + self.assertResponseContextAnswerFeedbackCorrectnessEquals(resp, 0) + + def test_feedback_code_negative_close_to_0(self): + # https://github.com/inducer/relate/pull/448#issuecomment-363655132 + markdown = (markdowns.OCTAVE_FEEDBACK_POINTS_CODE_MARKDWON_PATTERN + % { + "full_points": 1, + "min_points": -0.00000000001 + }) + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxHasValidPage(resp) + + # Post a wrong answer + resp = self.get_page_sandbox_submit_answer_response( + markdown, + answer_data={"answer": ['c = b - a\r']}) + self.assertEqual(resp.status_code, 200) + self.assertResponseContextAnswerFeedbackCorrectnessEquals(resp, 0) + + def test_feedback_code_error_close_below_max_auto_feedback_points(self): + feedback_points = MAX_EXTRA_CREDIT_FACTOR - 1e-6 + markdown = (markdowns.OCTAVE_FEEDBACK_POINTS_CODE_MARKDWON_PATTERN + % { + "full_points": feedback_points, + "min_points": 0 + }) + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxHasValidPage(resp) + + resp = self.get_page_sandbox_submit_answer_response( + markdown, + answer_data={"answer": ['c = b + a\r']}) + self.assertEqual(resp.status_code, 200) + self.assertResponseContextAnswerFeedbackCorrectnessEquals( + resp, MAX_EXTRA_CREDIT_FACTOR) + + def test_feedback_code_error_close_above_max_auto_feedback_points(self): + feedback_points = MAX_EXTRA_CREDIT_FACTOR + 1e-6 + markdown = (markdowns.OCTAVE_FEEDBACK_POINTS_CODE_MARKDWON_PATTERN + % { + "full_points": feedback_points, + "min_points": 0 + }) + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxHasValidPage(resp) + + resp = self.get_page_sandbox_submit_answer_response( + markdown, + answer_data={"answer": ['c = b + a\r']}) + self.assertEqual(resp.status_code, 200) + self.assertResponseContextAnswerFeedbackCorrectnessEquals( + resp, MAX_EXTRA_CREDIT_FACTOR) + + def test_feedback_code_error_negative_feedback_points(self): + invalid_feedback_points = -0.1 + markdown = (markdowns.OCTAVE_FEEDBACK_POINTS_CODE_MARKDWON_PATTERN + % { + "full_points": 1, + "min_points": invalid_feedback_points + }) + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxHasValidPage(resp) + + # Post a wrong answer + resp = self.get_page_sandbox_submit_answer_response( + markdown, + answer_data={"answer": ['c = b - a\r']}) + self.assertEqual(resp.status_code, 200) + self.assertResponseContextAnswerFeedbackCorrectnessEquals(resp, None) + + error_msg = (AUTO_FEEDBACK_POINTS_OUT_OF_RANGE_ERROR_MSG_PATTERN + % (MAX_EXTRA_CREDIT_FACTOR, invalid_feedback_points)) + + self.assertResponseContextAnswerFeedbackNotContainsFeedback( + resp, error_msg) + + self.assertResponseContextAnswerFeedbackContainsFeedback( + resp, GRADE_CODE_FAILING_MSG) + + def test_feedback_code_error_exceed_max_extra_credit_factor(self): + invalid_feedback_points = 10.1 + markdown = (markdowns.OCTAVE_FEEDBACK_POINTS_CODE_MARKDWON_PATTERN + % { + "full_points": invalid_feedback_points, + "min_points": 0 + }) + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxHasValidPage(resp) + + resp = self.get_page_sandbox_submit_answer_response( + markdown, + answer_data={"answer": ['c = b + a\r']}) + self.assertEqual(resp.status_code, 200) + self.assertResponseContextAnswerFeedbackCorrectnessEquals(resp, None) + error_msg = (AUTO_FEEDBACK_POINTS_OUT_OF_RANGE_ERROR_MSG_PATTERN + % (MAX_EXTRA_CREDIT_FACTOR, invalid_feedback_points)) + + self.assertResponseContextAnswerFeedbackNotContainsFeedback( + resp, error_msg) + + self.assertResponseContextAnswerFeedbackContainsFeedback( + resp, GRADE_CODE_FAILING_MSG) + + def test_feedback_code_error_exceed_max_extra_credit_factor_email(self): + invalid_feedback_points = 10.1 + markdown = (markdowns.OCTAVE_FEEDBACK_POINTS_CODE_MARKDWON_PATTERN + % { + "full_points": invalid_feedback_points, + "min_points": 0 + }) + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxHasValidPage(resp) + + with mock.patch("course.page.PageContext") as mock_page_context: + mock_page_context.return_value.in_sandbox = False + mock_page_context.return_value.course = self.course + + # This remove the warning caused by mocked commit_sha value + # "CacheKeyWarning: Cache key contains characters that + # will cause errors ..." + mock_page_context.return_value.commit_sha = b"1234" + + resp = self.get_page_sandbox_submit_answer_response( + markdown, + answer_data={"answer": ['c = b + a\r']}) + self.assertEqual(resp.status_code, 200) + self.assertResponseContextAnswerFeedbackCorrectnessEquals(resp, None) + error_msg = (AUTO_FEEDBACK_POINTS_OUT_OF_RANGE_ERROR_MSG_PATTERN + % (MAX_EXTRA_CREDIT_FACTOR, invalid_feedback_points)) + + self.assertResponseContextAnswerFeedbackNotContainsFeedback( + resp, error_msg) + + self.assertResponseContextAnswerFeedbackContainsFeedback( + resp, GRADE_CODE_FAILING_MSG) + self.assertEqual(len(mail.outbox), 1) + + self.assertIn(error_msg, mail.outbox[0].body) + + # }}} class RequestPythonRunWithRetriesTest(unittest.TestCase): # Testing course.page.code.request_run_with_retries, From cd7cfca4b14619f521786be387c11e254dab8827 Mon Sep 17 00:00:00 2001 From: Neal Davis Date: Tue, 9 Apr 2019 09:06:33 -0500 Subject: [PATCH 12/14] Fix some imports from older version of code. --- tests/test_pages/test_code.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_pages/test_code.py b/tests/test_pages/test_code.py index a83179e6b..434c48b76 100644 --- a/tests/test_pages/test_code.py +++ b/tests/test_pages/test_code.py @@ -35,8 +35,6 @@ from course.page.code import ( CODE_QUESTION_CONTAINER_PORT, request_run_with_retries, InvalidPingResponse, is_nuisance_failure, PythonCodeQuestionWithHumanTextFeedback) -from course.page.code import ( - RUNOC_PORT, request_octave_run_with_retries) from course.utils import FlowPageContext, CoursePageContext from course.constants import MAX_EXTRA_CREDIT_FACTOR From c477cd0b33e22b0a9a1ac0c45346c93abae4f1c6 Mon Sep 17 00:00:00 2001 From: Neal Davis Date: Tue, 9 Apr 2019 09:19:19 -0500 Subject: [PATCH 13/14] Fix some imports from older version of code. --- tests/test_pages/test_code.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_pages/test_code.py b/tests/test_pages/test_code.py index 434c48b76..9383aac89 100644 --- a/tests/test_pages/test_code.py +++ b/tests/test_pages/test_code.py @@ -1585,7 +1585,7 @@ def test_docker_container_runpy_retries_count(self): class RequestOctaveRunWithRetriesTest(unittest.TestCase): - # Testing course.page.code.request_octave_run_with_retries, + # Testing course.page.code.request_run_with_retries, # adding tests for use cases that didn't cover in other tests @override_settings(RELATE_DOCKER_RUNOC_IMAGE="some_other_image") @@ -1845,7 +1845,7 @@ def test_docker_container_runpy_timeout(self): @skipIf(six.PY2, "PY2 doesn't support subTest") def test_docker_container_runoc_retries_count(self): with ( - mock.patch("course.page.code.request_octave_run")) as mock_req_run, ( # noqa + mock.patch("course.page.code.request_run")) as mock_req_run, ( # noqa mock.patch("course.page.code.is_nuisance_failure")) as mock_is_nuisance_failure: # noqa expected_result = "this is my custom result" mock_req_run.return_value = {"result": expected_result} From 8c2d4f00bf22ef5c53b9703c975e88e4f3b6dc69 Mon Sep 17 00:00:00 2001 From: Neal Davis Date: Thu, 11 Apr 2019 12:01:51 -0500 Subject: [PATCH 14/14] Turn off debugging. --- course/page/code.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/course/page/code.py b/course/page/code.py index 076736f28..765fd302e 100644 --- a/course/page/code.py +++ b/course/page/code.py @@ -98,7 +98,7 @@ def request_run(run_req, run_timeout, image=None): import errno from docker.errors import APIError as DockerAPIError - debug = True + debug = False if debug: def debug_print(s): print(s)