diff --git a/handler.py b/handler.py index e92e287..5b70463 100755 --- a/handler.py +++ b/handler.py @@ -158,6 +158,11 @@ def handle_muEd_command(event: JsonType, command: str) -> HandlerResponse: } return wrap_muEd_response(error, event, 400) + except commands.InvalidSubmissionError as e: + detail = str(e) if str(e) else repr(e) + error = {"title": "Unprocessable submission", "message": detail, "code": ErrorCode.VALIDATION_ERROR} + return wrap_muEd_response(error, event, 422) + except EvaluationException as e: detail = str(e) if str(e) else repr(e) error = {"title": "Internal server error", "message": detail, "code": ErrorCode.INTERNAL_ERROR} diff --git a/schemas/muEd/openapi-v0_1_0.yml b/schemas/muEd/openapi-v0_1_0.yml index 9004264..421ab05 100644 --- a/schemas/muEd/openapi-v0_1_0.yml +++ b/schemas/muEd/openapi-v0_1_0.yml @@ -368,6 +368,8 @@ paths: $ref: '#/components/responses/403-Forbidden' '406': $ref: '#/components/responses/406-VersionNotSupported' + '422': + $ref: '#/components/responses/422-UnprocessableSubmission' '500': $ref: '#/components/responses/500-InternalError' '501': @@ -1778,6 +1780,30 @@ components: requestedVersion: '0.0' supportedVersions: - 0.1.0 + 422-UnprocessableSubmission: + description: The request is valid, but the evaluation function cannot process the submission content (e.g. an unparseable expression). + headers: + X-Request-Id: + description: Request id for tracing this request across services. + schema: + type: string + X-Api-Version: + description: The API version that was used to serve this response. + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + unprocessableSubmission: + summary: Example unprocessable submission error + value: + title: Unprocessable submission + message: 'Failed to parse SymPy expression: A/(w*' + code: VALIDATION_ERROR + trace: null + details: null 500-InternalError: description: Internal server error. headers: diff --git a/tests/mued_handling_test.py b/tests/mued_handling_test.py index d077414..c12e731 100644 --- a/tests/mued_handling_test.py +++ b/tests/mued_handling_test.py @@ -436,6 +436,78 @@ def test_presubmission_disabled_runs_normal_evaluation(self): self.assertIn("awardedPoints", body[0]) self.assertNotIn("preSubmissionFeedback", body[0]) + def test_preview_value_error_returns_unprocessable(self): + def preview_function(response, params): + raise ValueError(f"Failed to parse SymPy expression: {response}") + + commands.preview_function = preview_function + event = { + "path": "/evaluate", + "body": { + "submission": {"type": "MATH", "content": {"expression": "A/(w*"}}, + "preSubmissionFeedback": {"enabled": True}, + }, + } + + response = handler(event) + + self.assertEqual(response["statusCode"], 422) + body = json.loads(response["body"]) + self.assertEqual(body["code"], "VALIDATION_ERROR") + self.assertEqual(body["message"], "Failed to parse SymPy expression: A/(w*") + + def test_preview_unexpected_error_returns_internal_error(self): + def preview_function(response, params): + raise RuntimeError("boom") + + commands.preview_function = preview_function + event = { + "path": "/evaluate", + "body": { + "submission": {"type": "MATH", "content": {"expression": "x+1"}}, + "preSubmissionFeedback": {"enabled": True}, + }, + } + + response = handler(event) + + self.assertEqual(response["statusCode"], 500) + body = json.loads(response["body"]) + self.assertEqual(body["code"], "INTERNAL_ERROR") + + def test_evaluation_value_error_returns_unprocessable(self): + def evaluation_function(response, answer, params): + raise ValueError(f"Failed to parse SymPy expression: {response}") + + commands.evaluation_function = evaluation_function + event = { + "path": "/evaluate", + "body": {"submission": {"type": "MATH", "content": {"expression": "A/(w*"}}}, + } + + response = handler(event) + + self.assertEqual(response["statusCode"], 422) + body = json.loads(response["body"]) + self.assertEqual(body["code"], "VALIDATION_ERROR") + self.assertEqual(body["message"], "Failed to parse SymPy expression: A/(w*") + + def test_evaluation_unexpected_error_returns_internal_error(self): + def evaluation_function(response, answer, params): + raise RuntimeError("boom") + + commands.evaluation_function = evaluation_function + event = { + "path": "/evaluate", + "body": {"submission": {"type": "MATH", "content": {"expression": "x+1"}}}, + } + + response = handler(event) + + self.assertEqual(response["statusCode"], 500) + body = json.loads(response["body"]) + self.assertEqual(body["code"], "INTERNAL_ERROR") + class TestMuEdPreviewExtraction(unittest.TestCase): def setUp(self) -> None: diff --git a/tools/commands.py b/tools/commands.py index 8d8b21b..61aabc5 100644 --- a/tools/commands.py +++ b/tools/commands.py @@ -42,6 +42,10 @@ def preview_function(response: Any, params: Any) -> Dict: ) +class InvalidSubmissionError(Exception): + """Raised when the evaluation function cannot process the submitted response.""" + + class CaseWarning(TypedDict, total=False): """Dictionary for reporting issues when testing cases""" @@ -184,7 +188,10 @@ def _extract_muEd_submission(body: JsonType): def _run_muEd_preview(body: JsonType) -> List[Dict]: response, params, _ = _extract_muEd_submission(body) - preview_result = preview_function(response, params) + try: + preview_result = preview_function(response, params) + except ValueError as e: + raise InvalidSubmissionError(str(e)) from e return [{"preSubmissionFeedback": preview_result.get("preview", {})}] @@ -200,7 +207,10 @@ def _run_muEd_evaluation(body: JsonType) -> List[Dict]: else: answer = None - result = _run_evaluation(response, answer, params) + try: + result = _run_evaluation(response, answer, params) + except ValueError as e: + raise InvalidSubmissionError(str(e)) from e is_correct = result.get("is_correct") or 0 feedback_text = result.get("feedback", "")