diff --git a/src/scwidgets/code/_widget_code_input.py b/src/scwidgets/code/_widget_code_input.py index cb5fb53..9e9761a 100644 --- a/src/scwidgets/code/_widget_code_input.py +++ b/src/scwidgets/code/_widget_code_input.py @@ -2,8 +2,13 @@ import re import types from typing import List, Optional +from functools import wraps +import sys +import traceback +import warnings from widget_code_input import WidgetCodeInput +from widget_code_input.utils import CodeValidationError, is_valid_variable_name from ..check import Check @@ -127,3 +132,99 @@ def get_code(func: types.FunctionType) -> str: ) return source + + + def get_function_object(self): + """ + Return the compiled function object. + + This can be assigned to a variable and then called, for instance:: + + func = widget.get_function_object() # This can raise a SyntaxError + retval = func(parameters) + + :raise SyntaxError: if the function code has syntax errors (or if + the function name is not a valid identifier) + """ + globals_dict = { + "__builtins__": globals()["__builtins__"], + "__name__": "__main__", + "__doc__": None, + "__package__": None, + } + + if not is_valid_variable_name(self.function_name): + raise SyntaxError("Invalid function name '{}'".format(self.function_name)) + + # Optionally one could do a ast.parse here already, to check syntax before execution + try: + exec( + compile(self.full_function_code, __name__, "exec", dont_inherit=True), + globals_dict, + ) + except SyntaxError as exc: + raise CodeValidationError( + format_syntax_error_msg(exc), orig_exc=exc + ) from exc + + function_object = globals_dict[self.function_name] + + def catch_exceptions(func): + @wraps(func) + def wrapper(*args, **kwargs): + """Wrap and check exceptions to return a longer and clearer exception.""" + + try: + return func(*args, **kwargs) + except Exception as exc: + err_msg = format_generic_error_msg(exc, code_widget=self) + raise CodeValidationError(err_msg, orig_exc=exc) from exc + + return wrapper + + return catch_exceptions(function_object) + +# Temporary fix until https://github.com/osscar-org/widget-code-input/pull/26 +# is merged +def format_generic_error_msg(exc, code_widget): + """ + Return a string reproducing the traceback of a typical error. + This includes line numbers, as well as neighboring lines. + + It will require also the code_widget instance, to get the actual source code. + + :note: this must be called from withou the exception, as it will get the current traceback state. + + :param exc: The exception that is being processed. + :param code_widget: the instance of the code widget with the code that raised the exception. + """ + error_class, _, tb = sys.exc_info() + frame_summaries = traceback.extract_tb(tb) + # The correct frame summary corresponding to wci not allways at the end + # therefore we loop through all of them + wci_frame_summary = None + for frame_summary in frame_summaries: + if frame_summary.filename == "widget_code_input": + wci_frame_summary = frame_summary + if wci_frame_summary is None: + warnings.warn( + "Could not find traceback frame corresponding to " + "widget_code_input, we output whole error message." + ) + + return exc + line_number = wci_frame_summary[1] + code_lines = code_widget.full_function_code.splitlines() + + err_msg = f"{error_class.__name__} in code input: {str(exc)}\n" + if line_number > 2: + err_msg += f" {line_number - 2:4d} {code_lines[line_number - 3]}\n" + if line_number > 1: + err_msg += f" {line_number - 1:4d} {code_lines[line_number - 2]}\n" + err_msg += f"---> {line_number:4d} {code_lines[line_number - 1]}\n" + if line_number < len(code_lines): + err_msg += f" {line_number + 1:4d} {code_lines[line_number]}\n" + if line_number < len(code_lines) - 1: + err_msg += f" {line_number + 2:4d} {code_lines[line_number + 1]}\n" + + return err_msg diff --git a/src/scwidgets/exercise/_widget_code_exercise.py b/src/scwidgets/exercise/_widget_code_exercise.py index 0d15f36..0e648a9 100644 --- a/src/scwidgets/exercise/_widget_code_exercise.py +++ b/src/scwidgets/exercise/_widget_code_exercise.py @@ -64,7 +64,7 @@ def __init__( parameters: Optional[ Union[Dict[str, Union[Check.FunInParamT, Widget]], ParameterPanel] ] = None, - update_mode: str = "release", + update_mode: str = "manual", cue_outputs: Union[None, CueOutput, List[CueOutput]] = None, update_func: Optional[ Callable[[CodeExercise], Union[Any, Check.FunOutParamsT]] @@ -244,6 +244,7 @@ def __init__( [], [], self._parameter_panel, + cued = False, ) else: widgets_to_observe = None @@ -295,30 +296,34 @@ def __init__( if self._cue_outputs is not None: reset_update_cue_widgets.extend(self._cue_outputs) - if self._code is not None: - description = "Run Code" - button_tooltip = ( - "Runs the code and updates outputs with the specified parameters" + if self._code is not None or self._update_mode == "manual": + if self._code is not None: + description = "Run Code" + button_tooltip = ( + "Runs the code and updates outputs with the specified parameters" + ) + else: + description = "Update" + button_tooltip = "Updates outputs with the specified parameters" + + self._update_button = UpdateResetCueButton( + reset_update_cue_widgets, # type: ignore[arg-type] + self._on_click_update_action, + disable_on_successful_action=kwargs.pop( + "disable_update_button_on_successful_action", False + ), + disable_during_action=kwargs.pop( + "disable_update_button_during_action", + update_button_disable_during_action, + ), + widgets_to_observe=widgets_to_observe, + traits_to_observe=traits_to_observe, + description=description, + button_tooltip=button_tooltip, ) else: - description = "Update" - button_tooltip = "Updates outputs with the specified parameters" + self._update_button = None - self._update_button = UpdateResetCueButton( - reset_update_cue_widgets, # type: ignore[arg-type] - self._on_click_update_action, - disable_on_successful_action=kwargs.pop( - "disable_update_button_on_successful_action", False - ), - disable_during_action=kwargs.pop( - "disable_update_button_during_action", - update_button_disable_during_action, - ), - widgets_to_observe=widgets_to_observe, - traits_to_observe=traits_to_observe, - description=description, - button_tooltip=button_tooltip, - ) if self._exercise_registry is None or ( self._code is None and self._parameter_panel is None @@ -447,6 +452,11 @@ def __init__( *args, **kwargs, ) + # In this case there is no code to be written by the student, so the code + # exercise should work out of the box. Since the cues for the parameters + # are also disabled, we update at the beginning once. + if self._update_mode in ["release", "continuous"] and self._code is None: + self.run_update() @property def answer(self) -> dict: @@ -511,16 +521,7 @@ def exercise_description(self) -> Union[str, None]: return self._exercise_description def _on_trait_parameters_changed(self, change: dict): - if self._update_button is None: - self._output.clear_output(wait=True) - error = ValueError( - "Invalid state: _on_trait_parameters_changed was " - "invoked but no update button was defined" - ) - with self._output: - raise error - raise error - self._update_button.click() + self.run_update() def _on_click_check_action(self) -> bool: self._output.clear_output(wait=True) @@ -657,6 +658,11 @@ def _on_click_update_action(self) -> bool: raised_error = True raise e + # The clear_output command at the beginning of the function waits till + # something is printed. If nothing is printed it, it is not cleared. We + # enforce it to be invoked by printing an empty string + print('', end='') + return not (raised_error) def run_update(self):