diff --git a/quick_start.md b/quick_start.md index 594cbee..288be18 100644 --- a/quick_start.md +++ b/quick_start.md @@ -171,6 +171,7 @@ Calls a function defined in the student code with the given arguments and **retu - **On success**: Returns the function's return value - **On error**: Raises a `RuntimeError` with details about the exception +- **If the function doesn't exist**: Raises a `NameError` saying so **Example:** @@ -228,7 +229,7 @@ def test_raises_value_error(sandbox: StudentFixture, feedback: FeedbackFixture) The `query_function_raw` response contains: -- `status`: One of `"success"`, `"exception"`, `"timeout"`, `"not_found"`, or `"not_serializable"`. `"not_serializable"` means the function finished, but its return value can't be sent back to the grader. `exception_message` explains this, and later queries on the sandbox still work. +- `status`: One of `"success"`, `"exception"`, `"timeout"`, `"not_found"`, or `"not_serializable"`. `"not_found"` means the function doesn't exist, and only the lookup can cause it: an error raised while the function runs, even a `KeyError` or `ImportError`, is `"exception"`. `"not_serializable"` means the function finished, but its return value can't be sent back to the grader. `exception_message` explains this, and later queries on the sandbox still work. - `value`: The return value (when status is `"success"`) - `stdout` / `stderr`: Captured output from the function call - `exception_name`: The exception class name (e.g., `"ValueError"`) diff --git a/src/pytest_prairielearn_grader/_student_code_runner.py b/src/pytest_prairielearn_grader/_student_code_runner.py index 77b34ea..952d478 100644 --- a/src/pytest_prairielearn_grader/_student_code_runner.py +++ b/src/pytest_prairielearn_grader/_student_code_runner.py @@ -90,6 +90,36 @@ def _resolve_dotted_name(name: str, student_code_vars: dict[str, Any]) -> Any: return student_code_vars[name] +class _FunctionNotFoundError(Exception): + """The requested function doesn't exist. Only the lookup raises this, never the student's call.""" + + +def _look_up_function(name: str, student_code_vars: dict[str, Any], workspace_mode: bool) -> Any: + """ + Find a student function by name, raising _FunctionNotFoundError if it doesn't exist. + + In workspace mode, importing a dotted name's module runs student code. If that code fails, the + error isn't caught here unless the failure is that the requested module itself doesn't exist, + so a student module that exists but has a bad import is reported as an exception. + """ + not_found = _FunctionNotFoundError(f"Function '{name}' wasn't found in the student code") + if workspace_mode and "." in name: + module_path, attr_name = name.rsplit(".", 1) + try: + module = importlib.import_module(module_path) + except ModuleNotFoundError as e: + if e.name is not None and (module_path == e.name or module_path.startswith(f"{e.name}.")): + raise not_found from e + raise + try: + return getattr(module, attr_name) + except AttributeError as e: + raise not_found from e + if name not in student_code_vars: + raise not_found + return student_code_vars[name] + + async def student_function_runner( student_code_vars: dict[str, Any], func_name: str, @@ -105,14 +135,12 @@ async def student_function_runner( exception_traceback: str | None = None result = None timed_out = False + function_not_found = False try: def student_function_temp() -> Any: - if workspace_mode: - student_function = _resolve_dotted_name(func_name, student_code_vars) - else: - student_function = student_code_vars[func_name] + student_function = _look_up_function(func_name, student_code_vars, workspace_mode) return student_function(*args_tup, **kwargs_dict) with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): @@ -123,6 +151,9 @@ def student_function_temp() -> Any: else: execution_error = asyncio.TimeoutError(f"Function timed out after {timeout} seconds") timed_out = True + except _FunctionNotFoundError as e: + execution_error = NameError(str(e)) + function_not_found = True except Exception as e: execution_error = e exception_traceback = traceback.format_exc(limit=-1) @@ -133,6 +164,8 @@ def student_function_temp() -> Any: if execution_error is None else FunctionStatusCode.TIMEOUT if timed_out + else FunctionStatusCode.NOT_FOUND + if function_not_found else FunctionStatusCode.EXCEPTION ), "value": None, diff --git a/src/pytest_prairielearn_grader/fixture.py b/src/pytest_prairielearn_grader/fixture.py index 3ea59a3..2bbe868 100644 --- a/src/pytest_prairielearn_grader/fixture.py +++ b/src/pytest_prairielearn_grader/fixture.py @@ -320,7 +320,7 @@ def query_function(self, function_name: str, *args: Any, query_timeout: float = case FunctionStatusCode.TIMEOUT: raise TimeoutError(f"Query for function '{function_name}' timed out after {query_timeout} seconds.") case FunctionStatusCode.NOT_FOUND: - raise NameError(f"Query for function '{function_name}' failed: {response['exception_message']}") + raise NameError(response["exception_message"]) case FunctionStatusCode.NOT_SERIALIZABLE: raise RuntimeError(response["exception_message"]) return from_json(response["value"]) diff --git a/tests/scenario_root/test_function_not_found/__init__.py b/tests/scenario_root/test_function_not_found/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/scenario_root/test_function_not_found/expected_outcome.json b/tests/scenario_root/test_function_not_found/expected_outcome.json new file mode 100644 index 0000000..7f8ce79 --- /dev/null +++ b/tests/scenario_root/test_function_not_found/expected_outcome.json @@ -0,0 +1,105 @@ +{ + "expected_data_object": { + "score": 0.8333333333333334, + "tests": [ + { + "max_points": 1, + "name": "raw_response", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_function_not_found.py::test_raw_response_has_all_fields[student_code]" + }, + { + "max_points": 1, + "name": "query_function", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_function_not_found.py::test_query_function_raises_clear_error[student_code]" + }, + { + "max_points": 1, + "name": "student_errors", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_function_not_found.py::test_errors_inside_function_stay_exceptions[student_code-raises_key_error-KeyError-'missing_key']" + }, + { + "max_points": 1, + "name": "student_errors", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_function_not_found.py::test_errors_inside_function_stay_exceptions[student_code-raises_key_error_with_function_name-KeyError-'missing_function']" + }, + { + "max_points": 1, + "name": "student_errors", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_function_not_found.py::test_errors_inside_function_stay_exceptions[student_code-raises_attribute_error-AttributeError-missing_attribute]" + }, + { + "max_points": 1, + "name": "student_errors", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_function_not_found.py::test_errors_inside_function_stay_exceptions[student_code-raises_import_error-ImportError-student import problem]" + }, + { + "max_points": 1, + "name": "student_errors", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_function_not_found.py::test_errors_inside_function_stay_exceptions[student_code-raises_name_error-NameError-undefined_variable]" + }, + { + "max_points": 1, + "name": "output_kept", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_function_not_found.py::test_output_kept_for_student_errors[student_code]" + }, + { + "max_points": 1, + "name": "not_callable", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_function_not_found.py::test_name_that_is_not_callable_stays_an_exception[student_code]" + }, + { + "max_points": 1, + "name": "later_queries", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_function_not_found.py::test_later_queries_still_work[student_code]" + }, + { + "max_points": 1, + "name": "assert_fn_equal", + "points": 0.0, + "points_frac": 0.0, + "outcome": "failed", + "test_id": "test_function_not_found.py::test_assert_fn_equal_shows_clear_message[student_code]", + "message": "Function 'missing_function' wasn't found in the student code" + }, + { + "max_points": 1, + "name": "assert_fn_approx_equal", + "points": 0.0, + "points_frac": 0.0, + "outcome": "failed", + "test_id": "test_function_not_found.py::test_assert_fn_approx_equal_shows_clear_message[student_code]", + "message": "Function 'missing_function' wasn't found in the student code" + } + ] + } +} diff --git a/tests/scenario_root/test_function_not_found/scenario.py b/tests/scenario_root/test_function_not_found/scenario.py new file mode 100644 index 0000000..bc8243b --- /dev/null +++ b/tests/scenario_root/test_function_not_found/scenario.py @@ -0,0 +1,82 @@ +import pytest + +from pytest_prairielearn_grader.assertions import assert_fn_approx_equal +from pytest_prairielearn_grader.assertions import assert_fn_equal +from pytest_prairielearn_grader.fixture import StudentFixture +from pytest_prairielearn_grader.utils import FunctionStatusCode +from pytest_prairielearn_grader.utils import GradingOutputLevel + +EXPECTED_MESSAGE = "Function 'missing_function' wasn't found in the student code" + + +@pytest.mark.grading_data(name="raw_response", points=1) +def test_raw_response_has_all_fields(sandbox: StudentFixture) -> None: + assert sandbox.query_function_raw("missing_function") == { + "status": FunctionStatusCode.NOT_FOUND, + "value": None, + "stdout": "", + "stderr": "", + "exception_name": "NameError", + "exception_message": EXPECTED_MESSAGE, + "traceback": None, + } + + +@pytest.mark.grading_data(name="query_function", points=1) +def test_query_function_raises_clear_error(sandbox: StudentFixture) -> None: + with pytest.raises(NameError) as error: + sandbox.query_function("missing_function") + assert str(error.value) == EXPECTED_MESSAGE + + +@pytest.mark.grading_data(name="student_errors", points=1) +@pytest.mark.parametrize( + ("function_name", "exception_name", "message"), + [ + ("raises_key_error", "KeyError", "'missing_key'"), + ("raises_key_error_with_function_name", "KeyError", "'missing_function'"), + ("raises_attribute_error", "AttributeError", "missing_attribute"), + ("raises_import_error", "ImportError", "student import problem"), + ("raises_name_error", "NameError", "undefined_variable"), + ], +) +def test_errors_inside_function_stay_exceptions(sandbox: StudentFixture, function_name: str, exception_name: str, message: str) -> None: + response = sandbox.query_function_raw(function_name) + + assert response["status"] == FunctionStatusCode.EXCEPTION + assert response["exception_name"] == exception_name + assert response["exception_message"] is not None + assert message in response["exception_message"] + assert response["traceback"] is not None + assert "student_code" in response["traceback"] + + +@pytest.mark.grading_data(name="output_kept", points=1) +def test_output_kept_for_student_errors(sandbox: StudentFixture) -> None: + assert sandbox.query_function_raw("raises_key_error")["stdout"] == "before the error\n" + + +@pytest.mark.grading_data(name="not_callable", points=1) +def test_name_that_is_not_callable_stays_an_exception(sandbox: StudentFixture) -> None: + response = sandbox.query_function_raw("not_callable") + + assert response["status"] == FunctionStatusCode.EXCEPTION + assert response["exception_name"] == "TypeError" + + +@pytest.mark.grading_data(name="later_queries", points=1) +def test_later_queries_still_work(sandbox: StudentFixture) -> None: + assert sandbox.query_function_raw("missing_function")["status"] == FunctionStatusCode.NOT_FOUND + assert sandbox.query_function("add_one", 1) == 2 + + +@pytest.mark.output(level=GradingOutputLevel.Friendly) +@pytest.mark.grading_data(name="assert_fn_equal", points=1) +def test_assert_fn_equal_shows_clear_message(sandbox: StudentFixture) -> None: + assert_fn_equal(sandbox, "missing_function", args=(1,), expected=2) + + +@pytest.mark.output(level=GradingOutputLevel.Friendly) +@pytest.mark.grading_data(name="assert_fn_approx_equal", points=1) +def test_assert_fn_approx_equal_shows_clear_message(sandbox: StudentFixture) -> None: + assert_fn_approx_equal(sandbox, "missing_function", args=(1,), expected=2.0) diff --git a/tests/scenario_root/test_function_not_found/student_code.py b/tests/scenario_root/test_function_not_found/student_code.py new file mode 100644 index 0000000..2ffff84 --- /dev/null +++ b/tests/scenario_root/test_function_not_found/student_code.py @@ -0,0 +1,26 @@ +not_callable = 5 + + +def add_one(x): + return x + 1 + + +def raises_key_error(): + print("before the error") + return {}["missing_key"] + + +def raises_key_error_with_function_name(): + raise KeyError("missing_function") + + +def raises_attribute_error(): + return add_one.missing_attribute + + +def raises_import_error(): + raise ImportError("student import problem") + + +def raises_name_error(): + return undefined_variable # noqa: F821 diff --git a/tests/scenario_root/test_quiz_2_1/expected_outcome.json b/tests/scenario_root/test_quiz_2_1/expected_outcome.json index 6508fea..55b61de 100644 --- a/tests/scenario_root/test_quiz_2_1/expected_outcome.json +++ b/tests/scenario_root/test_quiz_2_1/expected_outcome.json @@ -4,7 +4,7 @@ "tests": [ { "max_points": 6, - "message": "Function 'analyze_vibration' raised an exception", + "message": "Function 'analyze_vibration' wasn't found in the student code", "name": "Test analyze_vibration function", "outcome": "failed", "points_frac": 0.0, @@ -13,7 +13,7 @@ }, { "max_points": 6, - "message": "Function 'analyze_vibration' raised an exception", + "message": "Function 'analyze_vibration' wasn't found in the student code", "name": "Test analyze_vibration function", "outcome": "failed", "points_frac": 0.0, diff --git a/tests/scenario_root/test_stdout_feature/expected_outcome.json b/tests/scenario_root/test_stdout_feature/expected_outcome.json index 7ca1d8c..0020f76 100644 --- a/tests/scenario_root/test_stdout_feature/expected_outcome.json +++ b/tests/scenario_root/test_stdout_feature/expected_outcome.json @@ -60,7 +60,7 @@ }, { "test_id": "test_stdout_feature.py::test_global_scope_stdout[student_code]", - "message": "Student code grading failed with an exception: RuntimeError\nException Message: Function 'get_global_message' raised an exception KeyError: 'get_global_message'", + "message": "Student code grading failed with an exception: NameError\nException Message: Function 'get_global_message' wasn't found in the student code", "max_points": 2, "points_frac": 0.0, "points": 0.0, diff --git a/tests/scenario_root/test_workspace_function_not_found/__init__.py b/tests/scenario_root/test_workspace_function_not_found/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/scenario_root/test_workspace_function_not_found/expected_outcome.json b/tests/scenario_root/test_workspace_function_not_found/expected_outcome.json new file mode 100644 index 0000000..b1b5586 --- /dev/null +++ b/tests/scenario_root/test_workspace_function_not_found/expected_outcome.json @@ -0,0 +1,127 @@ +{ + "expected_data_object": { + "score": 1.0, + "tests": [ + { + "max_points": 1, + "name": "not_found", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_workspace_function_not_found.py::test_missing_names_are_not_found[missing_flat_name]" + }, + { + "max_points": 1, + "name": "not_found", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_workspace_function_not_found.py::test_missing_names_are_not_found[calculator.missing_function]" + }, + { + "max_points": 1, + "name": "not_found", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_workspace_function_not_found.py::test_missing_names_are_not_found[no_such_module.function]" + }, + { + "max_points": 1, + "name": "not_found", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_workspace_function_not_found.py::test_missing_names_are_not_found[no_such_package.no_such_module.function]" + }, + { + "max_points": 1, + "name": "not_found", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_workspace_function_not_found.py::test_missing_names_are_not_found[pkg.no_such_module.function]" + }, + { + "max_points": 1, + "name": "not_found", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_workspace_function_not_found.py::test_missing_names_are_not_found[calculator.add_one.function]" + }, + { + "max_points": 1, + "name": "query_function", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_workspace_function_not_found.py::test_query_function_raises_clear_error" + }, + { + "max_points": 1, + "name": "exceptions", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_workspace_function_not_found.py::test_student_errors_stay_exceptions[calculator.raises_key_error-KeyError-missing_key]" + }, + { + "max_points": 1, + "name": "exceptions", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_workspace_function_not_found.py::test_student_errors_stay_exceptions[calculator.raises_import_error-ImportError-student import problem]" + }, + { + "max_points": 1, + "name": "exceptions", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_workspace_function_not_found.py::test_student_errors_stay_exceptions[calculator.imports_missing_module-ModuleNotFoundError-no_such_module_inside_function]" + }, + { + "max_points": 1, + "name": "exceptions", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_workspace_function_not_found.py::test_student_errors_stay_exceptions[has_bad_import.function-ModuleNotFoundError-no_such_module_in_student_file]" + }, + { + "max_points": 1, + "name": "exceptions", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_workspace_function_not_found.py::test_student_errors_stay_exceptions[raises_import_error_on_load.function-ImportError-student module problem]" + }, + { + "max_points": 1, + "name": "exceptions", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_workspace_function_not_found.py::test_student_errors_stay_exceptions[raises_attribute_error_on_load.function-AttributeError-attribute]" + }, + { + "max_points": 1, + "name": "exceptions", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_workspace_function_not_found.py::test_student_errors_stay_exceptions[calculator.CONSTANT-TypeError-not callable]" + }, + { + "max_points": 1, + "name": "existing_functions", + "points": 1.0, + "points_frac": 1.0, + "outcome": "passed", + "test_id": "test_workspace_function_not_found.py::test_existing_functions_still_work" + } + ] + } +} diff --git a/tests/scenario_root/test_workspace_function_not_found/scenario.py b/tests/scenario_root/test_workspace_function_not_found/scenario.py new file mode 100644 index 0000000..4b2296d --- /dev/null +++ b/tests/scenario_root/test_workspace_function_not_found/scenario.py @@ -0,0 +1,67 @@ +import pytest + +from pytest_prairielearn_grader import ConfigObject +from pytest_prairielearn_grader.fixture import WorkspaceFixture +from pytest_prairielearn_grader.utils import FunctionStatusCode + +autograder_config = ConfigObject(workspace_mode=True, sandbox_timeout=10.0) + + +@pytest.mark.grading_data(name="not_found", points=1) +@pytest.mark.parametrize( + "name", + [ + "missing_flat_name", + "calculator.missing_function", + "no_such_module.function", + "no_such_package.no_such_module.function", + "pkg.no_such_module.function", + "calculator.add_one.function", + ], +) +def test_missing_names_are_not_found(workspace_sandbox: WorkspaceFixture, name: str) -> None: + assert workspace_sandbox.query_function_raw(name) == { + "status": FunctionStatusCode.NOT_FOUND, + "value": None, + "stdout": "", + "stderr": "", + "exception_name": "NameError", + "exception_message": f"Function '{name}' wasn't found in the student code", + "traceback": None, + } + + +@pytest.mark.grading_data(name="query_function", points=1) +def test_query_function_raises_clear_error(workspace_sandbox: WorkspaceFixture) -> None: + with pytest.raises(NameError) as error: + workspace_sandbox.query_function("calculator.missing_function") + assert str(error.value) == "Function 'calculator.missing_function' wasn't found in the student code" + + +@pytest.mark.grading_data(name="exceptions", points=1) +@pytest.mark.parametrize( + ("name", "exception_name", "message"), + [ + ("calculator.raises_key_error", "KeyError", "missing_key"), + ("calculator.raises_import_error", "ImportError", "student import problem"), + ("calculator.imports_missing_module", "ModuleNotFoundError", "no_such_module_inside_function"), + ("has_bad_import.function", "ModuleNotFoundError", "no_such_module_in_student_file"), + ("raises_import_error_on_load.function", "ImportError", "student module problem"), + ("raises_attribute_error_on_load.function", "AttributeError", "attribute"), + ("calculator.CONSTANT", "TypeError", "not callable"), + ], +) +def test_student_errors_stay_exceptions(workspace_sandbox: WorkspaceFixture, name: str, exception_name: str, message: str) -> None: + response = workspace_sandbox.query_function_raw(name) + + assert response["status"] == FunctionStatusCode.EXCEPTION + assert response["exception_name"] == exception_name + assert response["exception_message"] is not None + assert message in response["exception_message"] + + +@pytest.mark.grading_data(name="existing_functions", points=1) +def test_existing_functions_still_work(workspace_sandbox: WorkspaceFixture) -> None: + assert workspace_sandbox.query_function_raw("no_such_module.function")["status"] == FunctionStatusCode.NOT_FOUND + assert workspace_sandbox.query_function("calculator.add_one", 1) == 2 + assert workspace_sandbox.query_function("pkg.util.double", 2) == 4 diff --git a/tests/scenario_root/test_workspace_function_not_found/student/calculator.py b/tests/scenario_root/test_workspace_function_not_found/student/calculator.py new file mode 100644 index 0000000..7230100 --- /dev/null +++ b/tests/scenario_root/test_workspace_function_not_found/student/calculator.py @@ -0,0 +1,17 @@ +CONSTANT = 5 + + +def add_one(x): + return x + 1 + + +def raises_key_error(): + return {}["missing_key"] + + +def raises_import_error(): + raise ImportError("student import problem") + + +def imports_missing_module(): + import no_such_module_inside_function # noqa: F401, PLC0415 diff --git a/tests/scenario_root/test_workspace_function_not_found/student/has_bad_import.py b/tests/scenario_root/test_workspace_function_not_found/student/has_bad_import.py new file mode 100644 index 0000000..3a5fe0a --- /dev/null +++ b/tests/scenario_root/test_workspace_function_not_found/student/has_bad_import.py @@ -0,0 +1,5 @@ +import no_such_module_in_student_file # noqa: F401 + + +def function(): + return 1 diff --git a/tests/scenario_root/test_workspace_function_not_found/student/pkg/__init__.py b/tests/scenario_root/test_workspace_function_not_found/student/pkg/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/scenario_root/test_workspace_function_not_found/student/pkg/util.py b/tests/scenario_root/test_workspace_function_not_found/student/pkg/util.py new file mode 100644 index 0000000..c12b30a --- /dev/null +++ b/tests/scenario_root/test_workspace_function_not_found/student/pkg/util.py @@ -0,0 +1,2 @@ +def double(x): + return x * 2 diff --git a/tests/scenario_root/test_workspace_function_not_found/student/raises_attribute_error_on_load.py b/tests/scenario_root/test_workspace_function_not_found/student/raises_attribute_error_on_load.py new file mode 100644 index 0000000..4b97f9c --- /dev/null +++ b/tests/scenario_root/test_workspace_function_not_found/student/raises_attribute_error_on_load.py @@ -0,0 +1,2 @@ +missing = None +missing.attribute # noqa: B018 diff --git a/tests/scenario_root/test_workspace_function_not_found/student/raises_import_error_on_load.py b/tests/scenario_root/test_workspace_function_not_found/student/raises_import_error_on_load.py new file mode 100644 index 0000000..a4f4578 --- /dev/null +++ b/tests/scenario_root/test_workspace_function_not_found/student/raises_import_error_on_load.py @@ -0,0 +1 @@ +raise ImportError("student module problem")