From 9d3abbe4097992e073c79912f957a9121a826b4c Mon Sep 17 00:00:00 2001 From: Alexander Goscinski Date: Sat, 13 Jul 2024 13:27:23 +0200 Subject: [PATCH 1/4] Fix nonclearing output when nothing is outputed When the the update does not print anything the output is not cleared, since it waits for an output to be cleared. We fix this by printing an empty string to invoke the clearning of the output --- src/scwidgets/exercise/_widget_code_exercise.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/scwidgets/exercise/_widget_code_exercise.py b/src/scwidgets/exercise/_widget_code_exercise.py index 0d15f36..04aff84 100644 --- a/src/scwidgets/exercise/_widget_code_exercise.py +++ b/src/scwidgets/exercise/_widget_code_exercise.py @@ -657,6 +657,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): From cbb2d6b04eb1a74f2c996aa915dc57f42683c36b Mon Sep 17 00:00:00 2001 From: Alexander Goscinski Date: Sat, 13 Jul 2024 14:23:01 +0200 Subject: [PATCH 2/4] Only create update button when update_mode manual or code input is not None This is a reimplementation of https://github.com/osscar-org/scicode-widgets/commit/186a4959290493de1393509fd92c696fed00eade that still shows the update button when update_mode is manaul and it does not create the update button in the first place. --- .../exercise/_widget_code_exercise.py | 63 ++++++++++--------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/src/scwidgets/exercise/_widget_code_exercise.py b/src/scwidgets/exercise/_widget_code_exercise.py index 04aff84..9b27ef8 100644 --- a/src/scwidgets/exercise/_widget_code_exercise.py +++ b/src/scwidgets/exercise/_widget_code_exercise.py @@ -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) From 1d182b92b69e6b5d71da203b4795bfedaabf81d6 Mon Sep 17 00:00:00 2001 From: Alexander Goscinski Date: Sat, 13 Jul 2024 14:39:49 +0200 Subject: [PATCH 3/4] Change default update_mode to manual --- src/scwidgets/exercise/_widget_code_exercise.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scwidgets/exercise/_widget_code_exercise.py b/src/scwidgets/exercise/_widget_code_exercise.py index 9b27ef8..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]] From eda43ef8aafecdc5c9e9cd3715bf42a18473ec98 Mon Sep 17 00:00:00 2001 From: Alexander Goscinski Date: Mon, 15 Jul 2024 09:14:08 +0200 Subject: [PATCH 4/4] Fix for nested errors are not correctly resolved When an exception is thrown within a WCI, the last frame is used as context to add the input lines. This does not work if the exception is thrown within a function within WCI. Therefore we now iterate through all traceback frames to find the one corresponding to WCI. Will be fixed in wci https://github.com/osscar-org/widget-code-input/pull/26 --- src/scwidgets/code/_widget_code_input.py | 101 +++++++++++++++++++++++ 1 file changed, 101 insertions(+) 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