Source code for scwidgets.exercise._widget_code_exercise

# postpones evaluation of annotations
# see https://stackoverflow.com/a/33533514
from __future__ import annotations

import inspect
import types
from platform import python_version
from typing import Any, Callable, Dict, List, Optional, Union

from ipywidgets import HTML, Box, HBox, HTMLMath, Layout, VBox, Widget
from matplotlib.figure import Figure
from widget_code_input import WidgetCodeInput
from widget_code_input.utils import CodeValidationError

from .._utils import Formatter
from ..check import Check, CheckableWidget, CheckRegistry, CheckResult
from ..code._widget_code_input import CodeInput
from ..code._widget_parameters_panel import ParametersPanel
from ..css_style import CssStyle
from ..cue import (
    CheckCueBox,
    CheckResetCueButton,
    CueFigure,
    CueObject,
    CueOutput,
    SaveCueBox,
    SaveResetCueButton,
    UpdateCueBox,
    UpdateResetCueButton,
)
from ._widget_exercise_registry import ExerciseRegistry, ExerciseWidget


[docs] class CodeExercise(VBox, CheckableWidget, ExerciseWidget): """ A widget to demonstrate code interactively in a variety of ways. It is a combination of the several widgets that allow to check check, run and visualize code. :param code: A function or CodeInput that is the input of code :param check_registry: A check registry that is used to register checks :param exercise_registry: A exercise registry that is used to register the answers to save them later. If specified the save and load panel will appear. :param key: The key that is used to store the exercise in the json file. :param parameters: Input parameters for the :py:class:`ParametersPanel` class or an initialized :py:class:`ParametersPanel` object. Specifies the arguments in the parameter panel. :param update_mode: Determines how the parameters are refreshed on changes of the code input or parameters :param outputs: List of CueOuputs that are drawn and refreshed :param update: A function that is run during the update process. The function takes as argument the CodeExercise, so it can update all cue_ouputs :param description: A string describing the exercises that will be put into an HTML widget above the exercise. :param title: A title for the exercise. If not given the key is used. """ def __init__( self, code: Union[None, WidgetCodeInput, types.FunctionType] = None, check_registry: Optional[CheckRegistry] = None, exercise_registry: Optional[ExerciseRegistry] = None, key: Optional[str] = None, parameters: Optional[ Union[Dict[str, Union[Check.FunInParamT, Widget]], ParametersPanel] ] = None, update_mode: str = "manual", outputs: Union[None, Figure, CueOutput, List[CueOutput]] = None, update: Optional[ Union[ Callable[[CodeExercise], Union[Any, Check.FunOutParamsT]], Callable[[], Union[Any, Check.FunOutParamsT]], ] ] = None, description: Optional[str] = None, title: Optional[str] = None, *args, **kwargs, ): allowed_update_modes = ["manual", "continuous", "release"] if update_mode not in allowed_update_modes: raise TypeError( f"Got update mode {update_mode!r} but only " f"{allowed_update_modes} are allowed." ) self._update_mode = update_mode self._update_func: Optional[ Union[ Callable[[CodeExercise], Union[Any, Check.FunOutParamsT]], Callable[[], Union[Any, Check.FunOutParamsT]], ] ] = update # We test update instead of self._update_func because self._update_func # has one additional argument because of self self._update_func_nb_nondefault_args: Optional[int] if update is not None: self._update_func_nb_nondefault_args = len( [ value for value in inspect.signature(update).parameters.values() if not isinstance(value.default, inspect._empty) ] ) if self._update_func_nb_nondefault_args > 1: raise ValueError( f"The given update function has " f"{self._update_func_nb_nondefault_args} parameters without " "defaults, but only zero or one are supported." ) else: self._update_func_nb_nondefault_args = None self._description = description if description is None: self._description_html = None else: self._description_html = HTMLMath(self._description) if title is None: if key is None: self._title = None self._title_html = None else: self._title = key self._title_html = HTML(f"<b>{key}</b>") else: self._title = title self._title_html = HTML(f"<b>{title}</b>") if self._description_html is not None: self._description_html.add_class("exercise-description") if self._title_html is not None: self._title_html.add_class("exercise-title") # verify if input argument `parameter` is valid if parameters is not None: allowed_parameter_types = [dict, ParametersPanel] parameter_type_allowed = False for allowed_parameter_type in allowed_parameter_types: if isinstance(parameters, allowed_parameter_type): parameter_type_allowed = True if not (parameter_type_allowed): raise TypeError( f"Got parameter {type(parameters)!r} but only " f"{allowed_parameter_types} are allowed." ) # verify if input argument `parameter` is valid if isinstance(code, types.FunctionType): code = CodeInput(function=code) elif code is not None and not (isinstance(code, WidgetCodeInput)): raise TypeError( "For input code expected type None, FunctionType or " f"WidgetCodeInput but got {type(code)!r}" ) # check compability between code and parameters, can only be checked if # update_func is not used because we cannot know how the code input is used if update is None and code is not None and parameters is not None: if isinstance(parameters, dict): compatibility_result = code.compatible_with_signature( list(parameters.keys()) ) elif isinstance(parameters, ParametersPanel): compatibility_result = code.compatible_with_signature( list(parameters.parameters.keys()) ) if compatibility_result != "": raise ValueError( "code and parameters do no match: " + compatibility_result ) name = kwargs.get("name", key) CheckableWidget.__init__(self, check_registry, name) if exercise_registry is not None: ExerciseWidget.__init__(self, exercise_registry, key) else: # otherwise ExerciseWidget constructor will raise an error ExerciseWidget.__init__(self, None, None) self._code = code self._output = CueOutput() if outputs is None: outputs = [] elif not (isinstance(outputs, list)): outputs = [outputs] self._cue_outputs: List[CueOutput] = [] for output in outputs: if isinstance(output, Figure): # This needs to happen before the creation of the # ParametersPanel otherwise the figure is not properly closed. I # am not sure why, I guess it is something related to interact self._cue_outputs.append(CueFigure(output)) elif isinstance(output, CueOutput): self._cue_outputs.append(output) else: self._cue_outputs.append(CueObject(output)) self._parameters_panel: Union[ParametersPanel, None] if isinstance(parameters, dict): self._parameters_panel = ParametersPanel(**parameters) elif isinstance(parameters, ParametersPanel): self._parameters_panel = parameters else: self._parameters_panel = None self._cue_code = self._code if self._check_registry is None or self._code is None: self._check_button = None else: self._cue_code = CheckCueBox( self._code, "function_body", self._cue_code, cued=True ) self._check_button = CheckResetCueButton( [self._cue_code], self._on_click_check_action, disable_on_successful_action=kwargs.pop( "disable_check_button_on_successful_action", False ), disable_during_action=kwargs.pop( "disable_check_button_during_action", True ), description="Check Code", button_tooltip="Check the correctness of your code", ) self._cue_parameters_panel = self._parameters_panel if self._parameters_panel is None and self._update_func is None: self._update_button = None self._cue_parameters_panel = None else: # set up update button and cueing # ------------------------------- if self._code is not None: self._cue_code = UpdateCueBox( self._code, "function_body", self._cue_code, cued=True, layout=Layout(width="98%", height="auto"), ) # set up parameter panel # ---------------------- if self._parameters_panel is not None: if self._update_mode == "continuous": self._parameters_panel.set_parameters_widget_attr( "continuous_update", True ) elif self._update_mode == "release": self._parameters_panel.set_parameters_widget_attr( "continuous_update", False ) if self._update_mode in ["continuous", "release"]: self._parameters_panel.observe_parameters( self._on_trait_parameters_changed, "value" ) if self._code is not None: # the button only cues on cue_code change widgets_to_observe = [self._code] traits_to_observe = ["function_body"] else: widgets_to_observe = None traits_to_observe = None # assume when continuous that the function is fast # and that disabling causes flicker update_button_disable_during_action = False if self._code is not None: for cue_output in self._cue_outputs: # TODO this has to be made public cue_output._widgets_to_observe = [self._code] cue_output._traits_to_observe = ["function_body"] cue_output.observe_widgets() self._cue_parameters_panel = UpdateCueBox( [], [], self._parameters_panel, cued=self._code is not None, ) else: widgets_to_observe = None traits_to_observe = None update_button_disable_during_action = True self._cue_parameters_panel = UpdateCueBox( self._parameters_panel.panel_parameters_widget, self._parameters_panel.panel_parameters_trait, # type: ignore self._parameters_panel, ) for cue_output in self._cue_outputs: if self._code is not None: # TODO this has to be made public cue_output._widgets_to_observe = [ self._code ] + self._parameters_panel.panel_parameters_widget # fmt: off cue_output._traits_to_observe = ( [ # type: ignore[assignment] "function_body" ] + self._parameters_panel.panel_parameters_trait ) # fmt: on cue_output.observe_widgets() else: # TODO this has to be made public cue_output._widgets_to_observe = ( self._parameters_panel.panel_parameters_widget ) cue_output._traits_to_observe = ( self._parameters_panel.panel_parameters_trait # type: ignore[assignment] # noqa: E501 ) cue_output.observe_widgets() elif self._code is not None: widgets_to_observe = [self._code] traits_to_observe = ["function_body"] # only code demo with an update function exists, # we therefore assume update is slow update_button_disable_during_action = True else: widgets_to_observe = [] traits_to_observe = [] # only update function exists, we assume update is slow update_button_disable_during_action = True reset_update_cue_widgets = [] if self._cue_code is not None: reset_update_cue_widgets.append(self._cue_code) if self._cue_parameters_panel is not None: reset_update_cue_widgets.append(self._cue_parameters_panel) if self._cue_outputs is not None: reset_update_cue_widgets.extend(self._cue_outputs) 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, # type: ignore[arg-type] description=description, button_tooltip=button_tooltip, cued=True, ) else: self._update_button = None if self._exercise_registry is None or ( self._code is None and self._parameters_panel is None ): self._save_button = None self._load_button = None self._save_cue_box = None else: save_widgets_to_observe = [] save_traits_to_observe = [] if self._cue_code is not None: save_widgets_to_observe.append(self._code) save_traits_to_observe.append("function_body") if self._parameters_panel is not None: save_widgets_to_observe.extend( self._parameters_panel.panel_parameters_widget ) save_traits_to_observe.extend( self._parameters_panel.panel_parameters_trait ) if self._cue_code is not None: self._cue_code = SaveCueBox( save_widgets_to_observe, save_traits_to_observe, # type: ignore[arg-type] self._cue_code, cued=True, ) self._save_cue_box = self._cue_code self._save_button = SaveResetCueButton( SaveCueBox(Box()), # dummy cue box, because we set cues later on self._on_click_save_action, cued=True, disable_on_successful_action=kwargs.pop( "disable_save_button_on_successful_action", False ), disable_during_action=kwargs.pop( "disable_save_button_during_action", True ), description="Save code", button_tooltip="Loads your code and parameters from the loaded file", ) self._load_button = SaveResetCueButton( SaveCueBox(Box()), # dummy cue box, because we set cues later on self._on_click_load_action, cued=True, disable_on_successful_action=kwargs.pop( "disable_load_button_on_successful_action", False ), disable_during_action=kwargs.pop( "disable_load_button_during_action", True ), description="Load code", button_tooltip="Saves your code and parameters to the loaded file", ) # click on load button resets cue of save buton and vise-versa self._save_button.set_cue_widgets( [ widget for widget in [ self._cue_code, self._cue_parameters_panel, self._load_button, ] if widget is not None ] ) self._load_button.set_cue_widgets( [ widget for widget in [ self._cue_code, self._cue_parameters_panel, self._save_button, ] if widget is not None ] ) demo_children = [CssStyle()] if self._title_html is not None: demo_children.append(self._title_html) if self._description_html is not None: demo_children.append(self._description_html) if self._cue_code is not None: demo_children.append(self._cue_code) if self._cue_parameters_panel is not None: demo_children.append(self._cue_parameters_panel) buttons = [] if self._check_button is None and self._update_button is None: self._code_buttons = HBox([]) elif self._check_button is None: self._code_buttons = HBox([self._update_button]) elif self._update_button is None: self._code_buttons = HBox([self._check_button]) else: self._code_buttons = HBox([self._check_button, self._update_button]) buttons.append(self._code_buttons) if self._save_button is not None and self._load_button is not None: self._answer_buttons = HBox( [self._save_button, self._load_button], layout=Layout(justify_content="flex-end"), ) else: self._answer_buttons = Box([]) buttons.append(self._answer_buttons) self._buttons_panel = HBox( buttons, layout=Layout(justify_content="space-between") ) demo_children.extend( [ self._buttons_panel, self._output, ] ) demo_children.extend(self._cue_outputs) VBox.__init__( self, demo_children, *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: return { "code": None if self._code is None else self._code.function_body, "parameters_panel": ( None if self._parameters_panel is None else self._parameters_panel.parameters ), } @answer.setter def answer(self, answer: dict): if self._save_cue_box is not None: self._save_cue_box.unobserve_widgets() if self._save_button is not None: self._save_button.unobserve_widgets() if self._load_button is not None: self._load_button.unobserve_widgets() if answer["code"] is not None and self._code is not None: self._code.function_body = answer["code"] if ( answer["parameters_panel"] is not None and self._parameters_panel is not None ): self._parameters_panel.update_parameters(answer["parameters_panel"]) if self._save_cue_box is not None: self._save_cue_box.observe_widgets() if self._save_button is not None: self._save_button.observe_widgets() if self._load_button is not None: self._load_button.observe_widgets() @property def parameters_panel(self) -> Union[ParametersPanel, None]: """ :return: The parametergs panel widget. """ return self._parameters_panel @property def panel_parameters(self) -> Dict[str, Check.FunInParamT]: """ :return: Only parameters that are tunable in the parameter panel are returned. Fixed parameters are ignored. """ return ( {} if self._parameters_panel is None else self._parameters_panel.panel_parameters ) @property def parameters(self) -> Dict[str, Check.FunInParamT]: """ :return: All parameters that were given on initialization are returned, also including also fixed parameters. """ return ( {} if self._parameters_panel is None else self._parameters_panel.parameters ) @property def title(self) -> Union[str, None]: return self._title @property def description(self) -> Union[str, None]: return self._description def _on_trait_parameters_changed(self, change: dict): self.run_update() def _on_click_check_action(self) -> bool: self._output.clear_output(wait=True) raised_error = False with self._output: try: self._check() except Exception as e: raised_error = True if python_version() >= "3.11": e.add_note("This error is most likely not related to your code.") raise e return not (raised_error) def _on_click_save_action(self) -> bool: self._output.clear_output(wait=True) raised_error = False with self._output: try: result = self.save() if isinstance(result, str): print(Formatter.color_success_message(result)) elif isinstance(result, Exception): raise result else: print(result) except Exception as e: raised_error = True print(Formatter.color_error_message("Error raised while saving file:")) raise e return not (raised_error) def _on_click_load_action(self) -> bool: self._output.clear_output(wait=True) raised_error = False with self._output: try: result = self.load() if isinstance(result, str): print(Formatter.color_success_message(result)) elif isinstance(result, Exception): raise result else: print(result) except Exception as e: raised_error = True print(Formatter.color_error_message("Error raised while loading file:")) raise e return not (raised_error) def _check(self) -> List[Union[CheckResult, Exception]]: return CheckableWidget.check(self) def run_check(self) -> None: if self._check_button is not None: self._check_button.click() else: self._on_click_check_action()
[docs] def compute_output_to_check(self, *args, **kwargs) -> Check.FunOutParamsT: return self.run_code(*args, **kwargs)
[docs] def handle_checks_result(self, results: List[Union[CheckResult, Exception]]): self._output.clear_output(wait=True) with self._output: for i, result in enumerate(results): if isinstance(result, Exception): raise result elif isinstance(result, CheckResult): if result.successful: print(Formatter.color_success_message("Check was successful")) print(Formatter.color_success_message("--------------------")) print(result.message()) else: print(Formatter.color_error_message("Check failed")) print(Formatter.color_error_message("------------")) print(result.message()) else: print(result) # add linebreak if not end if i != len(results): print()
[docs] def handle_save_result(self, result: Union[str, Exception]): self._output.clear_output(wait=True) with self._output: if isinstance(result, Exception): raise result else: if self._load_button is not None: self._load_button.cued = False if self._save_button is not None: self._save_button.cued = False if self._save_cue_box is not None: self._save_cue_box.cued = False print(Formatter.color_success_message(result))
[docs] def handle_load_result(self, result: Union[str, Exception]): self._output.clear_output(wait=True) with self._output: if isinstance(result, Exception): raise result else: if self._load_button is not None: self._load_button.cued = False if self._save_button is not None: self._save_button.cued = False if self._save_cue_box is not None: self._save_cue_box.cued = False print(Formatter.color_success_message(result))
@property def code(self): return self._code @property def output(self) -> Union[CueOutput, None]: return self._cue_outputs[0] if len(self._cue_outputs) > 0 else None @property def outputs(self) -> List[CueOutput]: return self._cue_outputs @property def figure(self) -> Union[Figure, None]: return ( self._cue_outputs[0].figure if len(self._cue_outputs) > 0 and isinstance(self._cue_outputs[0], CueFigure) else None ) def _on_click_update_action(self) -> bool: self._output.clear_output(wait=True) raised_error = False # runs code and displays output with self._output: try: for cue_output in self.outputs: if hasattr(cue_output, "clear_display"): cue_output.clear_display(wait=True) if self._update_func is not None: if self._update_func_nb_nondefault_args == 0: self._update_func() # type: ignore[call-arg] else: self._update_func(self) # type: ignore[call-arg] elif self._code is not None: self.run_code(**self.parameters) for cue_output in self.outputs: if hasattr(cue_output, "draw_display"): cue_output.draw_display() except CodeValidationError as e: raised_error = True raise e except Exception as e: 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 is not cleared. We # enforce it to be invoked by printing an empty char print("\0", end="") return not (raised_error)
[docs] def run_update(self): """ Invokes an update run, the same that is invoked by a click on the update button or for :param update_mode: "release" and "continuous" when a parameter panel parameter is changed """ if self._update_button is not None: # to also invoke the reset cue action, we click the cued button self._update_button.click() else: # we might be in update_mode "release" or "continuous" where no button is # displayed self._on_click_update_action()
[docs] def run_code(self, *args, **kwargs) -> Check.FunOutParamsT: """ Runs the `code` with the given (keyword) arguments and returns the output of the `code`. If no `code` was given on intialization, then a `ValueError` is raised. """ try: if self._code is None: raise ValueError( "run_code was invoked, but no code was given on initializaion" ) return self._code(*args, **kwargs) except CodeValidationError as e: raise e except Exception as e: # we give the student the additional information that this is most likely # not because of his code if python_version() >= "3.11": e.add_note("This might be not related to your code input.") raise e