From 85e93baca8f7878376e76b6e8171b9ce94fa42ef Mon Sep 17 00:00:00 2001 From: Jitse Niesen Date: Sun, 3 Sep 2023 17:36:40 +0100 Subject: [PATCH 01/16] Remove option for labels in array editor This option was added in Spyder 2, but it is not used at the moment and may never been used. It interferes with the refresh functionality which is to be added in a following commit. --- .../variableexplorer/widgets/arrayeditor.py | 44 +++++-------------- .../widgets/tests/test_arrayeditor.py | 21 ++++----- 2 files changed, 19 insertions(+), 46 deletions(-) diff --git a/spyder/plugins/variableexplorer/widgets/arrayeditor.py b/spyder/plugins/variableexplorer/widgets/arrayeditor.py index 311e2c00cc1..fbf16c17acc 100644 --- a/spyder/plugins/variableexplorer/widgets/arrayeditor.py +++ b/spyder/plugins/variableexplorer/widgets/arrayeditor.py @@ -130,14 +130,11 @@ class ArrayModel(QAbstractTableModel, SpyderFontsMixin): ROWS_TO_LOAD = 500 COLS_TO_LOAD = 40 - def __init__(self, data, format_spec=".6g", xlabels=None, ylabels=None, - readonly=False, parent=None): + def __init__(self, data, format_spec=".6g", readonly=False, parent=None): QAbstractTableModel.__init__(self) self.dialog = parent self.changes = {} - self.xlabels = xlabels - self.ylabels = ylabels self.readonly = readonly self.test_array = np.array([0], dtype=data.dtype) @@ -383,11 +380,7 @@ def headerData(self, section, orientation, role=Qt.DisplayRole): """Set header data""" if role != Qt.DisplayRole: return to_qvariant() - labels = self.xlabels if orientation == Qt.Horizontal else self.ylabels - if labels is None: - return to_qvariant(int(section)) - else: - return to_qvariant(labels[section]) + return to_qvariant(int(section)) def reset(self): self.beginResetModel() @@ -601,8 +594,7 @@ def edit_item(self): class ArrayEditorWidget(QWidget): - def __init__(self, parent, data, readonly=False, - xlabels=None, ylabels=None): + def __init__(self, parent, data, readonly=False): QWidget.__init__(self, parent) self.data = data self.old_data_shape = None @@ -614,8 +606,8 @@ def __init__(self, parent, data, readonly=False, self.data.shape = (1, 1) format_spec = SUPPORTED_FORMATS.get(data.dtype.name, 's') - self.model = ArrayModel(self.data, format_spec=format_spec, xlabels=xlabels, - ylabels=ylabels, readonly=readonly, parent=self) + self.model = ArrayModel(self.data, format_spec=format_spec, + readonly=readonly, parent=self) self.view = ArrayView(self, self.model, data.dtype, data.shape) layout = QVBoxLayout() @@ -675,8 +667,7 @@ def __init__(self, parent=None): self.dim_indexes = [{}, {}, {}] self.last_dim = 0 # Adjust this for changing the startup dimension - def setup_and_check(self, data, title='', readonly=False, - xlabels=None, ylabels=None): + def setup_and_check(self, data, title='', readonly=False): """ Setup ArrayEditor: return False if data is not supported, True otherwise @@ -697,14 +688,6 @@ def setup_and_check(self, data, title='', readonly=False, self.error(_("Arrays with more than 3 dimensions are not " "supported")) return False - if xlabels is not None and len(xlabels) != self.data.shape[1]: - self.error(_("The 'xlabels' argument length do no match array " - "column number")) - return False - if ylabels is not None and len(ylabels) != self.data.shape[0]: - self.error(_("The 'ylabels' argument length do no match array row " - "number")) - return False if not is_record_array: # This is necessary in case users subclass ndarray and set the # dtype to an object that is not an actual dtype. @@ -744,15 +727,11 @@ def setup_and_check(self, data, title='', readonly=False, if is_record_array: for name in data.dtype.names: self.stack.addWidget(ArrayEditorWidget(self, data[name], - readonly, xlabels, - ylabels)) + readonly)) elif is_masked_array: - self.stack.addWidget(ArrayEditorWidget(self, data, readonly, - xlabels, ylabels)) - self.stack.addWidget(ArrayEditorWidget(self, data.data, readonly, - xlabels, ylabels)) - self.stack.addWidget(ArrayEditorWidget(self, data.mask, readonly, - xlabels, ylabels)) + self.stack.addWidget(ArrayEditorWidget(self, data, readonly)) + self.stack.addWidget(ArrayEditorWidget(self, data.data, readonly)) + self.stack.addWidget(ArrayEditorWidget(self, data.mask, readonly)) elif data.ndim == 3: # We create here the necessary widgets for current_dim_changed to # work. The rest are created below. @@ -767,8 +746,7 @@ def setup_and_check(self, data, title='', readonly=False, # Set the widget to display when launched self.current_dim_changed(self.last_dim) else: - self.stack.addWidget(ArrayEditorWidget(self, data, readonly, - xlabels, ylabels)) + self.stack.addWidget(ArrayEditorWidget(self, data, readonly)) self.arraywidget = self.stack.currentWidget() self.arraywidget.model.dataChanged.connect(self.save_and_close_enable) diff --git a/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py b/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py index 7b1e38a059b..e935a71f023 100644 --- a/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py +++ b/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py @@ -37,10 +37,10 @@ # ============================================================================= # Utility functions # ============================================================================= -def launch_arrayeditor(data, title="", xlabels=None, ylabels=None): +def launch_arrayeditor(data, title=""): """Helper routine to launch an arrayeditor and return its result.""" dlg = ArrayEditor() - assert dlg.setup_and_check(data, title, xlabels=xlabels, ylabels=ylabels) + assert dlg.setup_and_check(data, title) dlg.show() dlg.accept() # trigger slot connected to OK button return dlg.get_value() @@ -185,16 +185,13 @@ def test_arrayeditor_with_record_array_with_titles(qtbot): def test_arrayeditor_with_float_array(qtbot): arr = np.random.rand(5, 5) - assert_array_equal(arr, launch_arrayeditor(arr, "float array", - xlabels=['a', 'b', 'c', 'd', 'e'])) + assert_array_equal(arr, launch_arrayeditor(arr, "float array")) def test_arrayeditor_with_complex_array(qtbot): arr = np.round(np.random.rand(5, 5)*10)+\ np.round(np.random.rand(5, 5)*10)*1j - assert_array_equal(arr, launch_arrayeditor(arr, "complex array", - xlabels=np.linspace(-12, 12, 5), - ylabels=np.linspace(-12, 12, 5))) + assert_array_equal(arr, launch_arrayeditor(arr, "complex array")) def test_arrayeditor_with_bool_array(qtbot): @@ -231,7 +228,7 @@ def test_arrayeditor_edit_1d_array(qtbot): exp_arr = np.array([1, 0, 2, 3, 4]) arr = np.arange(0, 5) dlg = ArrayEditor() - assert dlg.setup_and_check(arr, '1D array', xlabels=None, ylabels=None) + assert dlg.setup_and_check(arr, '1D array') with qtbot.waitExposed(dlg): dlg.show() view = dlg.arraywidget.view @@ -251,7 +248,7 @@ def test_arrayeditor_edit_2d_array(qtbot): arr = np.ones((3, 3)) diff_arr = arr.copy() dlg = ArrayEditor() - assert dlg.setup_and_check(arr, '2D array', xlabels=None, ylabels=None) + assert dlg.setup_and_check(arr, '2D array') with qtbot.waitExposed(dlg): dlg.show() view = dlg.arraywidget.view @@ -276,8 +273,7 @@ def test_arrayeditor_edit_complex_array(qtbot): cnum = -1+0.5j arr = (np.random.random((10, 10)) - 0.50) * cnum dlg = ArrayEditor() - assert dlg.setup_and_check(arr, '2D complex array', xlabels=None, - ylabels=None) + assert dlg.setup_and_check(arr, '2D complex array') with qtbot.waitExposed(dlg): dlg.show() view = dlg.arraywidget.view @@ -343,8 +339,7 @@ def test_arrayeditor_edit_overflow(qtbot, monkeypatch): for idx, int_type, bit_exponent in test_parameters: test_array = np.arange(0, 5).astype(int_type) dialog = ArrayEditor() - assert dialog.setup_and_check(test_array, '1D array', - xlabels=None, ylabels=None) + assert dialog.setup_and_check(test_array, '1D array') with qtbot.waitExposed(dialog): dialog.show() view = dialog.arraywidget.view From bb61d7c0c5f86e6c74d2c0a8433e62ec133431b1 Mon Sep 17 00:00:00 2001 From: Jitse Niesen Date: Sun, 3 Sep 2023 17:49:09 +0100 Subject: [PATCH 02/16] Split ArrayEditor.setup_and_check() in two Make two functions. The first is setup_ui(), which sets up the UI and should be called only once. The second is set_data_and_check(), which specifies the data for the editor and can be called repeatedly. This is to prepare for the refresh functionality in the next commit. --- .../variableexplorer/widgets/arrayeditor.py | 388 +++++++++++------- 1 file changed, 250 insertions(+), 138 deletions(-) diff --git a/spyder/plugins/variableexplorer/widgets/arrayeditor.py b/spyder/plugins/variableexplorer/widgets/arrayeditor.py index fbf16c17acc..25feb45becd 100644 --- a/spyder/plugins/variableexplorer/widgets/arrayeditor.py +++ b/spyder/plugins/variableexplorer/widgets/arrayeditor.py @@ -121,6 +121,14 @@ def get_idx_rect(index_list): return ( min(rows), max(rows), min(cols), max(cols) ) +def safe_disconnect(signal): + """Disconnect a QtSignal, ignoring TypeError""" + try: + signal.disconnect() + except TypeError: + # Raised when no slots are connected to the signal + pass + #============================================================================== # Main classes #============================================================================== @@ -668,6 +676,152 @@ def __init__(self, parent=None): self.last_dim = 0 # Adjust this for changing the startup dimension def setup_and_check(self, data, title='', readonly=False): + """ + Setup ArrayEditor: + return False if data is not supported, True otherwise + """ + self.setup_ui(title, readonly) + return self.set_data_and_check(data, readonly) + + def setup_ui(self, title='', readonly=False): + """ + Create user interface + + This creates the necessary widgets and layouts that make up the user + interface of the array editor. Some elements need to be hidden + depending on the data; this will be done when the data is set. + """ + self.layout = QGridLayout() + self.setLayout(self.layout) + + # ---- Toolbar and actions + + toolbar = SpyderToolbar(parent=self, title='Editor toolbar') + toolbar.setStyleSheet(str(PANES_TOOLBAR_STYLESHEET)) + + def do_nothing(): + # .create_action() needs a toggled= parameter, but we can only + # set it later in .set_data_and_check(), so we use this function + # as a placeholder here. + pass + + self.copy_action = self.create_action( + ArrayEditorActions.Copy, + text=_('Copy'), + icon=self.create_icon('editcopy'), + triggered=do_nothing) + toolbar.add_item(self.copy_action) + + self.edit_action = self.create_action( + ArrayEditorActions.Edit, + text=_('Edit'), + icon=self.create_icon('edit'), + triggered=do_nothing) + toolbar.add_item(self.edit_action) + + self.format_action = self.create_action( + ArrayEditorActions.Format, + text=_('Format'), + icon=self.create_icon('format_float'), + tip=_('Set format of floating-point numbers'), + triggered=do_nothing) + toolbar.add_item(self.format_action) + + self.resize_action = self.create_action( + ArrayEditorActions.Resize, + text=_('Resize'), + icon=self.create_icon('collapse_column'), + tip=_('Resize columns to contents'), + triggered=do_nothing) + toolbar.add_item(self.resize_action) + + self.toggle_bgcolor_action = self.create_action( + ArrayEditorActions.ToggleBackgroundColor, + text=_('Background color'), + icon=self.create_icon('background_color'), + toggled=do_nothing) + toolbar.add_item(self.toggle_bgcolor_action) + + toolbar._render() + self.layout.addWidget(toolbar, 0, 0) + + # ---- Stack widget (empty) + + self.stack = QStackedWidget(self) + self.stack.currentChanged.connect(self.current_widget_changed) + self.layout.addWidget(self.stack, 1, 0) + + # ---- Widgets in bottom left for special arrays + # + # These are normally hidden. When editing masked, record or 3d arrays, + # the relevant elements are made visible in `.set_data_and_check()`. + + self.btn_layout = QHBoxLayout() + + self.combo_label = QLabel() + self.btn_layout.addWidget(self.combo_label) + + self.combo_box = QComboBox(self) + self.combo_box.currentIndexChanged.connect(self.combo_box_changed) + self.btn_layout.addWidget(self.combo_box) + + self.shape_label = QLabel() + self.btn_layout.addWidget(self.shape_label) + + self.index_label = QLabel(_('Index:')) + self.btn_layout.addWidget(self.index_label) + + self.index_spin = QSpinBox(self, keyboardTracking=False) + self.index_spin.valueChanged.connect(self.change_active_widget) + self.btn_layout.addWidget(self.index_spin) + + self.slicing_label = QLabel() + self.btn_layout.addWidget(self.slicing_label) + + self.masked_label = QLabel( + _('Warning: Changes are applied separately')) + self.masked_label.setToolTip( + _("For performance reasons, changes applied to masked arrays won't" + "be reflected in array's data (and vice-versa).")) + self.btn_layout.addWidget(self.masked_label) + + self.btn_layout.addStretch() + + # ---- Push buttons on bottom right + + self.btn_save_and_close = QPushButton(_('Save and Close')) + self.btn_save_and_close.setDisabled(True) + self.btn_save_and_close.clicked.connect(self.accept) + self.btn_layout.addWidget(self.btn_save_and_close) + + self.btn_close = QPushButton(_('Close')) + self.btn_close.setAutoDefault(True) + self.btn_close.setDefault(True) + self.btn_close.clicked.connect(self.reject) + self.btn_layout.addWidget(self.btn_close) + + # ---- Final layout + + # Add bottom row of widgets + self.btn_layout.setContentsMargins(4, 4, 4, 4) + self.layout.addLayout(self.btn_layout, 2, 0) + + # Set title + if title: + title = to_text_string(title) + " - " + _("NumPy object array") + else: + title = _("Array editor") + if readonly: + title += ' (' + _('read only') + ')' + self.setWindowTitle(title) + + # Set minimum size + self.setMinimumSize(500, 300) + + # Make the dialog act as a window + self.setWindowFlags(Qt.Window) + + def set_data_and_check(self, data, readonly=False): """ Setup ArrayEditor: return False if data is not supported, True otherwise @@ -676,6 +830,10 @@ def setup_and_check(self, data, title='', readonly=False): readonly = readonly or not self.data.flags.writeable is_masked_array = isinstance(data, np.ma.MaskedArray) + # Reset data for 3d arrays + self.dim_indexes = [{}, {}, {}] + self.last_dim = 0 + # This is necessary in case users subclass ndarray and set the dtype # to an object that is not an actual dtype. # Fixes spyder-ide/spyder#20462 @@ -712,18 +870,16 @@ def setup_and_check(self, data, title='', readonly=False): self.error(_("%s are currently not supported") % arr) return False - self.layout = QGridLayout() - self.setLayout(self.layout) - if title: - title = to_text_string(title) + " - " + _("NumPy object array") - else: - title = _("Array editor") - if readonly: - title += ' (' + _('read only') + ')' - self.setWindowTitle(title) - # ---- Stack widget - self.stack = QStackedWidget(self) + + # Remove old widgets, if any + while self.stack.count() > 0: + # Note: widgets get renumbered after removeWidget() + widget = self.stack.widget(0) + self.stack.removeWidget(widget) + widget.deleteLater() + + # Add widgets to the stack if is_record_array: for name in data.dtype.names: self.stack.addWidget(ArrayEditorWidget(self, data[name], @@ -733,157 +889,106 @@ def setup_and_check(self, data, title='', readonly=False): self.stack.addWidget(ArrayEditorWidget(self, data.data, readonly)) self.stack.addWidget(ArrayEditorWidget(self, data.mask, readonly)) elif data.ndim == 3: - # We create here the necessary widgets for current_dim_changed to - # work. The rest are created below. - # QSpinBox - self.index_spin = QSpinBox(self, keyboardTracking=False) - self.index_spin.valueChanged.connect(self.change_active_widget) - - # Labels - self.shape_label = QLabel() - self.slicing_label = QLabel() - # Set the widget to display when launched - self.current_dim_changed(self.last_dim) + self.combo_box_changed(self.last_dim) else: self.stack.addWidget(ArrayEditorWidget(self, data, readonly)) self.arraywidget = self.stack.currentWidget() self.arraywidget.model.dataChanged.connect(self.save_and_close_enable) - self.stack.currentChanged.connect(self.current_widget_changed) - self.layout.addWidget(self.stack, 1, 0) - # ---- Toolbar and actions - toolbar = SpyderToolbar(parent=self, title='Editor toolbar') - toolbar.setStyleSheet(str(PANES_TOOLBAR_STYLESHEET)) + # ---- Actions - self.copy_action = self.create_action( - ArrayEditorActions.Copy, - text=_('Copy'), - icon=self.create_icon('editcopy'), - triggered=self.arraywidget.view.copy) - toolbar.add_item(self.copy_action) + safe_disconnect(self.copy_action.triggered) + self.copy_action.triggered.connect(self.arraywidget.view.copy) - self.edit_action = self.create_action( - ArrayEditorActions.Edit, - text=_('Edit'), - icon=self.create_icon('edit'), - triggered=self.arraywidget.view.edit_item) - toolbar.add_item(self.edit_action) + safe_disconnect(self.edit_action.triggered) + self.edit_action.triggered.connect(self.arraywidget.view.edit_item) - self.format_action = self.create_action( - ArrayEditorActions.Format, - text=_('Format'), - icon=self.create_icon('format_float'), - tip=_('Set format of floating-point numbers'), - triggered=self.arraywidget.change_format) + safe_disconnect(self.format_action.triggered) + self.format_action.triggered.connect(self.arraywidget.change_format) self.format_action.setEnabled(is_float(self.arraywidget.data.dtype)) - toolbar.add_item(self.format_action) - self.resize_action = self.create_action( - ArrayEditorActions.Resize, - text=_('Resize'), - icon=self.create_icon('collapse_column'), - tip=_('Resize columns to contents'), - triggered=self.arraywidget.view.resize_to_contents) - toolbar.add_item(self.resize_action) + safe_disconnect(self.resize_action.triggered) + self.resize_action.triggered.connect( + self.arraywidget.view.resize_to_contents) - self.toggle_bgcolor_action = self.create_action( - ArrayEditorActions.ToggleBackgroundColor, - text=_('Background color'), - icon=self.create_icon('background_color'), - toggled=lambda state: self.arraywidget.model.bgcolor(state), - initial=self.arraywidget.model.bgcolor_enabled) + safe_disconnect(self.toggle_bgcolor_action.toggled) + self.toggle_bgcolor_action.toggled.connect( + lambda state: self.arraywidget.model.bgcolor(state)) self.toggle_bgcolor_action.setEnabled( self.arraywidget.model.bgcolor_enabled) - toolbar.add_item(self.toggle_bgcolor_action) + self.toggle_bgcolor_action.setChecked( + self.arraywidget.model.bgcolor_enabled) - toolbar._render() - self.layout.addWidget(toolbar, 0, 0) + # ---- Widgets in bottom left - # ---- Buttons in bottom left, if any - btn_layout = QHBoxLayout() - if is_record_array or is_masked_array or data.ndim == 3: - - if is_record_array: - btn_layout.addWidget(QLabel(_("Record array fields:"))) - names = [] - for name in data.dtype.names: - field = data.dtype.fields[name] - text = name - if len(field) >= 3: - title = field[2] - if not is_text_string(title): - title = repr(title) - text += ' - '+title - names.append(text) - else: - names = [_('Masked data'), _('Data'), _('Mask')] - - if data.ndim == 3: - # QComboBox - names = [str(i) for i in range(3)] - ra_combo = QComboBox(self) - ra_combo.addItems(names) - ra_combo.currentIndexChanged.connect(self.current_dim_changed) - - # Adding the widgets to layout - label = QLabel(_("Axis:")) - btn_layout.addWidget(label) - btn_layout.addWidget(ra_combo) - btn_layout.addWidget(self.shape_label) - - label = QLabel(_("Index:")) - btn_layout.addWidget(label) - btn_layout.addWidget(self.index_spin) - - btn_layout.addWidget(self.slicing_label) - else: - ra_combo = QComboBox(self) - ra_combo.currentIndexChanged.connect(self.stack.setCurrentIndex) - ra_combo.addItems(names) - btn_layout.addWidget(ra_combo) - - if is_masked_array: - label = QLabel( - _("Warning: Changes are applied separately") - ) - label.setToolTip(_("For performance reasons, changes applied " - "to masked arrays won't be reflected in " - "array's data (and vice-versa).")) - btn_layout.addWidget(label) - - # ---- Buttons on bottom right - btn_layout.addStretch() - - if not readonly: - self.btn_save_and_close = QPushButton(_('Save and Close')) - self.btn_save_and_close.setDisabled(True) - self.btn_save_and_close.clicked.connect(self.accept) - btn_layout.addWidget(self.btn_save_and_close) + # By default, all these widgets are hidden + self.combo_label.hide() + self.combo_box.hide() + self.shape_label.hide() + self.index_label.hide() + self.index_spin.hide() + self.slicing_label.hide() + self.masked_label.hide() - self.btn_close = QPushButton(_('Close')) - self.btn_close.setAutoDefault(True) - self.btn_close.setDefault(True) - self.btn_close.clicked.connect(self.reject) - btn_layout.addWidget(self.btn_close) + # Empty combo box + while self.combo_box.count() > 0: + self.combo_box.removeItem(0) - # ---- Final layout - btn_layout.setContentsMargins(4, 4, 4, 4) - self.layout.addLayout(btn_layout, 2, 0) + # Handle cases + if is_record_array: - # Set minimum size - self.setMinimumSize(500, 300) + self.combo_label.setText(_('Record array fields:')) + self.combo_label.show() - # Make the dialog act as a window - self.setWindowFlags(Qt.Window) + names = [] + for name in data.dtype.names: + field = data.dtype.fields[name] + text = name + if len(field) >= 3: + title = field[2] + if not is_text_string(title): + title = repr(title) + text += ' - '+title + names.append(text) + self.combo_box.addItems(names) + self.combo_box.show() + + elif is_masked_array: + + names = [_('Masked data'), _('Data'), _('Mask')] + self.combo_box.addItems(names) + self.combo_box.show() + + self.masked_label.show() + + elif data.ndim == 3: + + self.combo_label.setText(_('Axis:')) + self.combo_label.show() + + names = [str(i) for i in range(3)] + self.combo_box.addItems(names) + self.combo_box.show() + + self.shape_label.show() + self.index_label.show() + self.index_spin.show() + self.slicing_label.show() + + # ---- Bottom row of buttons + + self.btn_save_and_close.setDisabled(True) + if readonly: + self.btn_save_and_close.hide() return True @Slot(QModelIndex, QModelIndex) def save_and_close_enable(self, left_top, bottom_right): """Handle the data change event to enable the save and close button.""" - if self.btn_save_and_close: + if self.btn_save_and_close.isVisible(): self.btn_save_and_close.setEnabled(True) self.btn_save_and_close.setAutoDefault(True) self.btn_save_and_close.setDefault(True) @@ -922,11 +1027,18 @@ def change_active_widget(self, index): self.stack.update() self.stack.setCurrentIndex(stack_index) - def current_dim_changed(self, index): + def combo_box_changed(self, index): """ - This change the active axis the array editor is plotting over - in 3D + Handle changes in the combo box + + For masked and record arrays, this changes the visible widget in the + stack. For 3d arrays, this changes the active axis the array editor is + plotting over. """ + if self.data.ndim != 3: + self.stack.setCurrentIndex(index) + return + self.last_dim = index string_size = ['%i']*3 string_size[index] = '%i' From 711ea1be123bb598ab5c654de313f83bbac98764 Mon Sep 17 00:00:00 2001 From: Jitse Niesen Date: Wed, 30 Aug 2023 17:35:01 +0100 Subject: [PATCH 03/16] Add a Refresh button for refreshing array values * Add a function `CollectionsDelegate.make_data_function()` for retrieving the current value when the user refreshes an editor. This function returns `None`, indicating that the editor can not be refreshed. * Override `.make_data_function()` in `RemoteCollectionsDelegate`, so that if the function is called in the namespace editor in the variable explorer, it retrieves the current value of the variable currently selected in the variable explorer. * When creating a new array editor in `CollectionsDelegate`, pass the `data_function` that is created by `make_data_function` to the constructor. * Add a Refresh button to the toolbar of the array editor. When pressed, this calls the new function `.refresh()`. * This function calls `data_function` that was passed to the constructor and displays the return value in the array editor. --- .../variableexplorer/widgets/arrayeditor.py | 50 +++++++++++++++++-- .../widgets/collectionsdelegate.py | 34 +++++++++++-- .../widgets/tests/test_arrayeditor.py | 26 ++++++++++ spyder/widgets/collectionseditor.py | 31 ++++++++++++ spyder/widgets/tests/test_collectioneditor.py | 19 +++++++ 5 files changed, 152 insertions(+), 8 deletions(-) diff --git a/spyder/plugins/variableexplorer/widgets/arrayeditor.py b/spyder/plugins/variableexplorer/widgets/arrayeditor.py index 25feb45becd..7a2111c49f2 100644 --- a/spyder/plugins/variableexplorer/widgets/arrayeditor.py +++ b/spyder/plugins/variableexplorer/widgets/arrayeditor.py @@ -8,6 +8,8 @@ NumPy Array Editor Dialog based on Qt """ +from __future__ import annotations + # pylint: disable=C0103 # pylint: disable=R0903 # pylint: disable=R0911 @@ -15,6 +17,7 @@ # Standard library imports import io +from typing import Callable, Optional, TYPE_CHECKING # Third party imports from qtpy.compat import from_qvariant, to_qvariant @@ -29,6 +32,9 @@ from spyder_kernels.utils.nsview import value_to_display from spyder_kernels.utils.lazymodules import numpy as np +if TYPE_CHECKING: + from numpy.typing import ArrayLike + # Local imports from spyder.api.config.fonts import SpyderFontsMixin, SpyderFontType from spyder.api.widgets.mixins import SpyderWidgetMixin @@ -47,6 +53,7 @@ class ArrayEditorActions: Copy = 'copy_action' Edit = 'edit_action' Format = 'format_action' + Refresh = 'refresh_action' Resize = 'resize_action' ToggleBackgroundColor = 'toggle_background_color_action' @@ -656,7 +663,21 @@ class ArrayEditor(BaseDialog, SpyderWidgetMixin): CONF_SECTION = 'variable_explorer' - def __init__(self, parent=None): + def __init__(self, parent: Optional[QWidget] = None, + data_function: Optional[Callable[[], ArrayLike]] = None): + """ + Constructor. + + Parameters + ---------- + parent : Optional[QWidget] + The parent widget. The default is None. + data_function : Optional[Callable[[], ArrayLike]] + A function which returns the current value of the array. This is + used for refreshing the editor. If set to None, the editor cannot + be refreshed. The default is None. + """ + super().__init__(parent) # Destroying the C++ object right after closing the dialog box, @@ -665,6 +686,7 @@ def __init__(self, parent=None): # a segmentation fault on UNIX or an application crash on Windows self.setAttribute(Qt.WA_DeleteOnClose) + self.data_function = data_function self.data = None self.arraywidget = None self.stack = None @@ -742,6 +764,15 @@ def do_nothing(): toggled=do_nothing) toolbar.add_item(self.toggle_bgcolor_action) + self.refresh_action = self.create_action( + ArrayEditorActions.Refresh, + text=_('Refresh'), + icon=self.create_icon('refresh'), + tip=_('Refresh editor with current value of variable in console'), + triggered=self.refresh) + self.refresh_action.setDisabled(self.data_function is None) + toolbar.add_item(self.refresh_action) + toolbar._render() self.layout.addWidget(toolbar, 0, 0) @@ -995,9 +1026,11 @@ def save_and_close_enable(self, left_top, bottom_right): def current_widget_changed(self, index): self.arraywidget = self.stack.widget(index) - self.arraywidget.model.dataChanged.connect(self.save_and_close_enable) - self.toggle_bgcolor_action.setChecked( - self.arraywidget.model.bgcolor_enabled) + if self.arraywidget: + self.arraywidget.model.dataChanged.connect( + self.save_and_close_enable) + self.toggle_bgcolor_action.setChecked( + self.arraywidget.model.bgcolor_enabled) def change_active_widget(self, index): """ @@ -1053,6 +1086,15 @@ def combo_box_changed(self, index): self.index_spin.setRange(-self.data.shape[index], self.data.shape[index]-1) + def refresh(self) -> None: + """ + Refresh data in editor. + """ + assert self.data_function is not None + + data = self.data_function() + self.set_data_and_check(data) + @Slot() def accept(self): """Reimplement Qt method.""" diff --git a/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py b/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py index 4be56040b46..920b4f248f5 100644 --- a/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py +++ b/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py @@ -12,12 +12,14 @@ import datetime import functools import operator +from typing import Any, Callable, Optional # Third party imports from qtpy.compat import to_qvariant -from qtpy.QtCore import QDateTime, Qt, Signal -from qtpy.QtWidgets import (QAbstractItemDelegate, QDateEdit, QDateTimeEdit, - QItemDelegate, QLineEdit, QMessageBox, QTableView) +from qtpy.QtCore import QDateTime, QModelIndex, Qt, Signal +from qtpy.QtWidgets import ( + QAbstractItemDelegate, QDateEdit, QDateTimeEdit, QItemDelegate, QLineEdit, + QMessageBox, QTableView) from spyder_kernels.utils.lazymodules import ( FakeObject, numpy as np, pandas as pd, PIL) from spyder_kernels.utils.nsview import (display_to_value, is_editable_type, @@ -56,6 +58,29 @@ def set_value(self, index, value): if index.isValid(): index.model().set_value(index, value) + def make_data_function(self, index: QModelIndex + ) -> Optional[Callable[[], Any]]: + """ + Construct function which returns current value of data. + + This is used to refresh editors created from this piece of data. + + Parameters + ---------- + index : QModelIndex + Index of item whose current value is to be returned by the + function constructed here. + + Returns + ------- + Optional[Callable[[], Any]] + Function which returns the current value of the data, or None if + such a function cannot be constructed. + """ + # TODO: Implement this to handle refreshing editors opened from other + # editors, e.g., arrays nested inside a list. + return None + def show_warning(self, index): """ Decide if showing a warning when the user is trying to view @@ -174,7 +199,8 @@ def createEditor(self, parent, option, index, object_explorer=False): np.ndarray is not FakeObject and not object_explorer): # We need to leave this import here for tests to pass. from .arrayeditor import ArrayEditor - editor = ArrayEditor(parent=parent) + editor = ArrayEditor( + parent=parent, data_function=self.make_data_function(index)) if not editor.setup_and_check(value, title=key, readonly=readonly): self.sig_editor_shown.emit() return diff --git a/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py b/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py index e935a71f023..76fe9aca43d 100644 --- a/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py +++ b/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py @@ -224,6 +224,32 @@ def test_arrayeditor_with_empty_3d_array(qtbot): assert_array_equal(arr, launch_arrayeditor(arr, "3D array")) +def test_arrayeditor_refreshaction_disabled(): + """ + Test that the Refresh action is disabled by default. + """ + arr_ones = np.ones((3, 3)) + dlg = ArrayEditor() + dlg.setup_and_check(arr_ones, '2D array') + assert not dlg.refresh_action.isEnabled() + + +def test_arrayeditor_refresh(): + """ + Test that after pressing the refresh button, the value of the Array Editor + is replaced by the return value of the data_function. + """ + arr_ones = np.ones((3, 3)) + arr_zeros = np.zeros((4, 4)) + datafunc = lambda: arr_zeros + dlg = ArrayEditor(data_function=datafunc) + assert dlg.setup_and_check(arr_ones, '2D array') + assert_array_equal(dlg.get_value(), arr_ones) + assert dlg.refresh_action.isEnabled() + dlg.refresh_action.trigger() + assert_array_equal(dlg.get_value(), arr_zeros) + + def test_arrayeditor_edit_1d_array(qtbot): exp_arr = np.array([1, 0, 2, 3, 4]) arr = np.arange(0, 5) diff --git a/spyder/widgets/collectionseditor.py b/spyder/widgets/collectionseditor.py index 9abc86d9bfe..725c626faa0 100644 --- a/spyder/widgets/collectionseditor.py +++ b/spyder/widgets/collectionseditor.py @@ -24,6 +24,7 @@ import re import sys import warnings +from typing import Any, Callable, Optional # Third party imports from qtpy.compat import getsavefilename, to_qvariant @@ -1595,6 +1596,36 @@ def set_value(self, index, value): name = source_index.model().keys[source_index.row()] self.parent().new_value(name, value) + def make_data_function(self, index: QModelIndex + ) -> Optional[Callable[[], Any]]: + """ + Construct function which returns current value of data. + + The returned function uses the associated console to retrieve the + current value of the variable. This is used to refresh editors created + from that variable. + + Parameters + ---------- + index : QModelIndex + Index of item whose current value is to be returned by the + function constructed here. + + Returns + ------- + Optional[Callable[[], Any]] + Function which returns the current value of the data, or None if + such a function cannot be constructed. + """ + source_index = index.model().mapToSource(index) + name = source_index.model().keys[source_index.row()] + parent = self.parent() + + def get_data(): + return parent.get_value(name) + + return get_data + class RemoteCollectionsEditorTableView(BaseTableView): """DictEditor table view""" diff --git a/spyder/widgets/tests/test_collectioneditor.py b/spyder/widgets/tests/test_collectioneditor.py index 93675b9bfe2..4985937c34b 100644 --- a/spyder/widgets/tests/test_collectioneditor.py +++ b/spyder/widgets/tests/test_collectioneditor.py @@ -265,6 +265,25 @@ def test_filter_rows(qtbot): assert editor.model.rowCount() == 0 +def test_remote_make_data_function(): + """ + Test that the function returned by make_data_function() ... + """ + variables = {'a': {'type': 'int', + 'size': 1, + 'view': '1', + 'python_type': 'int', + 'numpy_type': 'Unknown'}} + mock_shellwidget = Mock() + editor = RemoteCollectionsEditorTableView( + None, variables, mock_shellwidget) + index = editor.model.index(0, 0) + data_function = editor.delegate.make_data_function(index) + value = data_function() + mock_shellwidget.get_value.assert_called_once_with('a') + assert value == mock_shellwidget.get_value.return_value + + def test_create_dataframeeditor_with_correct_format(qtbot): df = pandas.DataFrame(['foo', 'bar']) editor = CollectionsEditorTableView(None, {'df': df}) From 4750e773db6621168f121346a690c77149799cc2 Mon Sep 17 00:00:00 2001 From: Jitse Niesen Date: Thu, 7 Sep 2023 10:37:59 +0100 Subject: [PATCH 04/16] Ask for confirmation to refresh if editor dirty --- .../variableexplorer/widgets/arrayeditor.py | 17 ++++++++++ .../widgets/tests/test_arrayeditor.py | 32 ++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/spyder/plugins/variableexplorer/widgets/arrayeditor.py b/spyder/plugins/variableexplorer/widgets/arrayeditor.py index 7a2111c49f2..0c1bf54a608 100644 --- a/spyder/plugins/variableexplorer/widgets/arrayeditor.py +++ b/spyder/plugins/variableexplorer/widgets/arrayeditor.py @@ -1092,9 +1092,26 @@ def refresh(self) -> None: """ assert self.data_function is not None + if self.btn_save_and_close.isEnabled(): + if not self.ask_for_refresh_confirmation(): + return data = self.data_function() self.set_data_and_check(data) + def ask_for_refresh_confirmation(self) -> bool: + """ + Ask user to confirm refreshing the editor. + + This function is to be called if refreshing the editor would overwrite + changes that the user made previously. The function returns True if + the user confirms that they want to refresh and False otherwise. + """ + message = _('Refreshing the editor will overwrite the changes that ' + 'you made. Do you want to proceed?') + result = QMessageBox.question( + self, _('Refresh array editor?'), message) + return result == QMessageBox.Yes + @Slot() def accept(self): """Reimplement Qt method.""" diff --git a/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py b/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py index 76fe9aca43d..d1789aa2ef4 100644 --- a/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py +++ b/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py @@ -13,7 +13,7 @@ # Standard library imports import os import sys -from unittest.mock import Mock, ANY +from unittest.mock import Mock, patch, ANY # Third party imports from flaky import flaky @@ -21,6 +21,7 @@ from numpy.testing import assert_array_equal import pytest from qtpy.QtCore import Qt +from qtpy.QtWidgets import QMessageBox from scipy.io import loadmat # Local imports @@ -250,6 +251,35 @@ def test_arrayeditor_refresh(): assert_array_equal(dlg.get_value(), arr_zeros) +@pytest.mark.parametrize('result', [QMessageBox.Yes, QMessageBox.No]) +def test_arrayeditor_refresh_after_edit(result): + """ + Test that after changing a value in the array editor, pressing the Refresh + button opens a dialog box (which asks for confirmation), and that the + editor is only refreshed if the user clicks Yes. + """ + arr_ones = np.ones((3, 3)) + arr_edited = arr_ones.copy() + arr_edited[0, 0] = 2 + arr_zeros = np.zeros((4, 4)) + datafunc = lambda: arr_zeros + dlg = ArrayEditor(data_function=datafunc) + dlg.setup_and_check(arr_ones, '2D array') + dlg.show() + model = dlg.arraywidget.model + model.setData(model.index(0, 0), '2') + with patch('spyder.plugins.variableexplorer.widgets.arrayeditor' + '.QMessageBox.question', + return_value=result) as mock_question: + dlg.refresh_action.trigger() + mock_question.assert_called_once() + dlg.accept() + if result == QMessageBox.Yes: + assert_array_equal(dlg.get_value(), arr_zeros) + else: + assert_array_equal(dlg.get_value(), arr_edited) + + def test_arrayeditor_edit_1d_array(qtbot): exp_arr = np.array([1, 0, 2, 3, 4]) arr = np.arange(0, 5) From bfaa52409295ebe1c73392d617a1f1f229f47cd2 Mon Sep 17 00:00:00 2001 From: Jitse Niesen Date: Thu, 7 Sep 2023 14:48:04 +0100 Subject: [PATCH 05/16] Handle if new value can't be displayed --- .../variableexplorer/widgets/arrayeditor.py | 7 ++++++- .../widgets/tests/test_arrayeditor.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/spyder/plugins/variableexplorer/widgets/arrayeditor.py b/spyder/plugins/variableexplorer/widgets/arrayeditor.py index 0c1bf54a608..b148379923b 100644 --- a/spyder/plugins/variableexplorer/widgets/arrayeditor.py +++ b/spyder/plugins/variableexplorer/widgets/arrayeditor.py @@ -857,6 +857,9 @@ def set_data_and_check(self, data, readonly=False): Setup ArrayEditor: return False if data is not supported, True otherwise """ + if not isinstance(data, (np.ndarray, np.ma.MaskedArray)): + return False + self.data = data readonly = readonly or not self.data.flags.writeable is_masked_array = isinstance(data, np.ma.MaskedArray) @@ -1096,7 +1099,9 @@ def refresh(self) -> None: if not self.ask_for_refresh_confirmation(): return data = self.data_function() - self.set_data_and_check(data) + if not self.set_data_and_check(data): + self.error( + _('The new value cannot be displayed in the array editor.')) def ask_for_refresh_confirmation(self) -> bool: """ diff --git a/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py b/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py index d1789aa2ef4..7d3641ffe94 100644 --- a/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py +++ b/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py @@ -280,6 +280,22 @@ def test_arrayeditor_refresh_after_edit(result): assert_array_equal(dlg.get_value(), arr_edited) +def test_arrayeditor_refresh_into_int(qtbot): + """ + Test that if the value after refreshing is not an array but an integer, + a critical dialog box is displayed and that the array editor is closed. + """ + arr_ones = np.ones((3, 3)) + datafunc = lambda: 1 + dlg = ArrayEditor(data_function=datafunc) + dlg.setup_and_check(arr_ones, '2D array') + with patch('spyder.plugins.variableexplorer.widgets.arrayeditor' + '.QMessageBox.critical') as mock_critical, \ + qtbot.waitSignal(dlg.rejected, timeout=0): + dlg.refresh_action.trigger() + mock_critical.assert_called_once() + + def test_arrayeditor_edit_1d_array(qtbot): exp_arr = np.array([1, 0, 2, 3, 4]) arr = np.arange(0, 5) From fbb8799e8f01df622f432e86040c68458cb6e9b1 Mon Sep 17 00:00:00 2001 From: Jitse Niesen Date: Thu, 7 Sep 2023 15:12:19 +0100 Subject: [PATCH 06/16] Handle if variable is deleted before refresh --- .../variableexplorer/widgets/arrayeditor.py | 8 +++++++- .../widgets/tests/test_arrayeditor.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/spyder/plugins/variableexplorer/widgets/arrayeditor.py b/spyder/plugins/variableexplorer/widgets/arrayeditor.py index b148379923b..67dc0b759e5 100644 --- a/spyder/plugins/variableexplorer/widgets/arrayeditor.py +++ b/spyder/plugins/variableexplorer/widgets/arrayeditor.py @@ -1098,7 +1098,13 @@ def refresh(self) -> None: if self.btn_save_and_close.isEnabled(): if not self.ask_for_refresh_confirmation(): return - data = self.data_function() + + try: + data = self.data_function() + except KeyError: + self.error(_('The variable no longer exists.')) + return + if not self.set_data_and_check(data): self.error( _('The new value cannot be displayed in the array editor.')) diff --git a/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py b/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py index 7d3641ffe94..e9b9acc849b 100644 --- a/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py +++ b/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py @@ -296,6 +296,24 @@ def test_arrayeditor_refresh_into_int(qtbot): mock_critical.assert_called_once() +def test_arrayeditor_refresh_when_variable_deleted(qtbot): + """ + Test that if the variable is deleted and then the editor is refreshed + (resulting in data_function raising a KeyError), a critical dialog box + is displayed and that the array editor is closed. + """ + def datafunc(): + raise KeyError + arr_ones = np.ones((3, 3)) + dlg = ArrayEditor(data_function=datafunc) + dlg.setup_and_check(arr_ones, '2D array') + with patch('spyder.plugins.variableexplorer.widgets.arrayeditor' + '.QMessageBox.critical') as mock_critical, \ + qtbot.waitSignal(dlg.rejected, timeout=0): + dlg.refresh_action.trigger() + mock_critical.assert_called_once() + + def test_arrayeditor_edit_1d_array(qtbot): exp_arr = np.array([1, 0, 2, 3, 4]) arr = np.arange(0, 5) From 7a66789298bd45bfcb3f4b67165f0a0055e3b9d9 Mon Sep 17 00:00:00 2001 From: Jitse Niesen Date: Tue, 19 Sep 2023 14:49:16 +0100 Subject: [PATCH 07/16] Implement refresh in collection editor * In CollectionDelegate, pass `data_function` when creating a `CollectionEditor`. Pass this along to the `CollectionEditorWidget`. * Add a refresh button to the toolbar of `CollectionEditorWidget`, which raises a signal when pressed. Enable this button if `data_function` is set. * Add a function `CollectionEditor.refresh_editor()` and connect it to the new signal. In this function, check whether the user made any edits and if so, ask for confirmation first. Call `data_function` and use its return value as the new value to be displayed. Handle exceptions thrown by `data_function`. * Add tests for the new refresh functionality. --- .../widgets/collectionsdelegate.py | 3 +- spyder/widgets/collectionseditor.py | 68 ++++++++++++++++-- spyder/widgets/tests/test_collectioneditor.py | 72 ++++++++++++++++++- 3 files changed, 133 insertions(+), 10 deletions(-) diff --git a/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py b/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py index 920b4f248f5..454683522c6 100644 --- a/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py +++ b/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py @@ -188,7 +188,8 @@ def createEditor(self, parent, option, index, object_explorer=False): elif isinstance(value, (list, set, tuple, dict)) and not object_explorer: from spyder.widgets.collectionseditor import CollectionsEditor editor = CollectionsEditor( - parent=parent, namespacebrowser=self.namespacebrowser) + parent=parent, namespacebrowser=self.namespacebrowser, + data_function=self.make_data_function(index)) editor.setup(value, key, icon=self.parent().windowIcon(), readonly=readonly) self.create_dialog(editor, dict(model=index.model(), editor=editor, diff --git a/spyder/widgets/collectionseditor.py b/spyder/widgets/collectionseditor.py index 725c626faa0..45b5f95eaf7 100644 --- a/spyder/widgets/collectionseditor.py +++ b/spyder/widgets/collectionseditor.py @@ -1316,8 +1316,8 @@ def paste(self): class CollectionsEditorTableView(BaseTableView): """CollectionsEditor table view""" - def __init__(self, parent, data, namespacebrowser=None, readonly=False, - title="", names=False): + def __init__(self, parent, data, namespacebrowser=None, + readonly=False, title="", names=False): BaseTableView.__init__(self, parent) self.dictfilter = None self.namespacebrowser = namespacebrowser @@ -1445,8 +1445,11 @@ def set_filter(self, dictfilter=None): class CollectionsEditorWidget(QWidget): """Dictionary Editor Widget""" - def __init__(self, parent, data, namespacebrowser=None, readonly=False, - title="", remote=False): + sig_refresh_requested = Signal() + + def __init__(self, parent, data, namespacebrowser=None, + data_function: Optional[Callable[[], Any]] = None, + readonly=False, title="", remote=False): QWidget.__init__(self, parent) if remote: self.editor = RemoteCollectionsEditorTableView( @@ -1462,8 +1465,18 @@ def __init__(self, parent, data, namespacebrowser=None, readonly=False, if item is not None: toolbar.addAction(item) + self.refresh_action = create_action( + self, + text=_('Refresh'), + icon=ima.icon('refresh'), + tip=_('Refresh editor with current value of variable in console'), + triggered=lambda: self.sig_refresh_requested.emit()) + toolbar.addAction(self.refresh_action) + # Update the toolbar actions state self.editor.refresh_menu() + self.refresh_action.setEnabled(data_function is not None) + layout = QVBoxLayout() layout.addWidget(toolbar) layout.addWidget(self.editor) @@ -1481,7 +1494,8 @@ def get_title(self): class CollectionsEditor(BaseDialog): """Collections Editor Dialog""" - def __init__(self, parent=None, namespacebrowser=None): + def __init__(self, parent=None, namespacebrowser=None, + data_function: Optional[Callable[[], Any]] = None): super().__init__(parent) # Destroying the C++ object right after closing the dialog box, @@ -1491,6 +1505,7 @@ def __init__(self, parent=None, namespacebrowser=None): self.setAttribute(Qt.WA_DeleteOnClose) self.namespacebrowser = namespacebrowser + self.data_function = data_function self.data_copy = None self.widget = None self.btn_save_and_close = None @@ -1522,8 +1537,9 @@ def setup(self, data, title='', readonly=False, remote=False, readonly = True self.widget = CollectionsEditorWidget( - self, self.data_copy, self.namespacebrowser, title=title, - readonly=readonly, remote=remote) + self, self.data_copy, self.namespacebrowser, self.data_function, + title=title, readonly=readonly, remote=remote) + self.widget.sig_refresh_requested.connect(self.refresh_editor) self.widget.editor.source_model.sig_setting_data.connect( self.save_and_close_enable) layout = QVBoxLayout() @@ -1574,6 +1590,44 @@ def get_value(self): # already been destroyed, due to the Qt.WA_DeleteOnClose attribute return self.data_copy + def refresh_editor(self) -> None: + """ + Refresh data in editor. + """ + assert self.data_function is not None + + if self.btn_save_and_close and self.btn_save_and_close.isEnabled(): + if not self.ask_for_refresh_confirmation(): + return + + try: + new_value = self.data_function() + except KeyError: + QMessageBox.critical(self, _('Collection editor'), + _('The variable no longer exists.')) + self.reject() + return + + self.widget.set_data(new_value) + self.data_copy = new_value + self.btn_save_and_close.setEnabled(False) + self.btn_close.setAutoDefault(True) + self.btn_close.setDefault(True) + + def ask_for_refresh_confirmation(self) -> bool: + """ + Ask user to confirm refreshing the editor. + + This function is to be called if refreshing the editor would overwrite + changes that the user made previously. The function returns True if + the user confirms that they want to refresh and False otherwise. + """ + message = _('Refreshing the editor will overwrite the changes that ' + 'you made. Do you want to proceed?') + result = QMessageBox.question( + self, _('Refresh collection editor?'), message) + return result == QMessageBox.Yes + #============================================================================== # Remote versions of CollectionsDelegate and CollectionsEditorTableView diff --git a/spyder/widgets/tests/test_collectioneditor.py b/spyder/widgets/tests/test_collectioneditor.py index 4985937c34b..5bdf10fcbf5 100644 --- a/spyder/widgets/tests/test_collectioneditor.py +++ b/spyder/widgets/tests/test_collectioneditor.py @@ -16,7 +16,7 @@ import copy import datetime from xml.dom.minidom import parseString -from unittest.mock import Mock +from unittest.mock import Mock, patch # Third party imports import numpy @@ -24,7 +24,7 @@ import pytest from flaky import flaky from qtpy.QtCore import Qt, QPoint -from qtpy.QtWidgets import QWidget, QDateEdit +from qtpy.QtWidgets import QDateEdit, QMessageBox, QWidget # Local imports from spyder.config.manager import CONF @@ -497,6 +497,74 @@ def test_rename_and_duplicate_item_in_collection_editor(): assert editor.source_model.get_data() == coll_copy + [coll_copy[0]] +def test_collectioneditorwidget_refresh_action_disabled(): + """ + Test that the Refresh button is disabled by default. + """ + lst = [1, 2, 3, 4] + widget = CollectionsEditorWidget(None, lst.copy()) + assert not widget.refresh_action.isEnabled() + + +def test_collectioneditor_refresh(): + """ + Test that after pressing the refresh button, the value of the Array Editor + is replaced by the return value of the data_function. + """ + old_list = [1, 2, 3, 4] + new_list = [3, 1, 4, 1, 5] + editor = CollectionsEditor(None, data_function=lambda: new_list) + editor.setup(old_list) + assert editor.get_value() == old_list + assert editor.widget.refresh_action.isEnabled() + editor.widget.refresh_action.trigger() + assert editor.get_value() == new_list + + +@pytest.mark.parametrize('result', [QMessageBox.Yes, QMessageBox.No]) +def test_collectioneditor_refresh_after_edit(result): + """ + Test that after changing a value in the collection editor, refreshing the + editor opens a dialog box (which asks for confirmation), and that the + editor is only refreshed if the user clicks Yes. + """ + old_list = [1, 2, 3, 4] + edited_list = [1, 2, 3, 5] + new_list = [3, 1, 4, 1, 5] + editor = CollectionsEditor(None, data_function=lambda: new_list) + editor.setup(old_list) + editor.show() + model = editor.widget.editor.source_model + model.setData(model.index(3, 3), '5') + with patch('spyder.widgets.collectionseditor.QMessageBox.question', + return_value=result) as mock_question: + editor.widget.refresh_action.trigger() + mock_question.assert_called_once() + editor.accept() + if result == QMessageBox.Yes: + assert editor.get_value() == new_list + else: + assert editor.get_value() == edited_list + + +def test_collectioneditor_refresh_when_variable_deleted(qtbot): + """ + Test that if the variable is deleted and then the editor is refreshed + (resulting in data_function raising a KeyError), a critical dialog box + is displayed and that the array editor is closed. + """ + def datafunc(): + raise KeyError + lst = [1, 2, 3, 4] + editor = CollectionsEditor(None, data_function=datafunc) + editor.setup(lst) + with patch('spyder.plugins.variableexplorer.widgets.arrayeditor' + '.QMessageBox.critical') as mock_critical, \ + qtbot.waitSignal(editor.rejected, timeout=0): + editor.widget.refresh_action.trigger() + mock_critical.assert_called_once() + + def test_edit_datetime(monkeypatch): """ Test datetimes are editable and NaT values are correctly handled. From 6a59bacc2a06f1f6d375baeaac1eb845427ecb47 Mon Sep 17 00:00:00 2001 From: Jitse Niesen Date: Tue, 3 Oct 2023 21:39:06 +0100 Subject: [PATCH 08/16] Implement refresh of editors opened from collection editor * Pass the `data_function` from `CollectionsEditor`, which can retrieve new data for the object being edited, via `CollectionsEditorTableView` to `CollectionsDelegate`. * Add `CollectionsDelegate.make_data_function()` for retrieving new data for editors opened from the collection editor. * Catch `IndexError` when calling a data_function, because this can be raised if an item in the collection is deleted. * Add tests for new functionality. --- .../variableexplorer/widgets/arrayeditor.py | 2 +- .../widgets/collectionsdelegate.py | 27 ++++++++++++++++--- spyder/widgets/collectionseditor.py | 11 +++++--- spyder/widgets/tests/test_collectioneditor.py | 17 ++++++++++++ 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/spyder/plugins/variableexplorer/widgets/arrayeditor.py b/spyder/plugins/variableexplorer/widgets/arrayeditor.py index 67dc0b759e5..ea31a60566c 100644 --- a/spyder/plugins/variableexplorer/widgets/arrayeditor.py +++ b/spyder/plugins/variableexplorer/widgets/arrayeditor.py @@ -1101,7 +1101,7 @@ def refresh(self) -> None: try: data = self.data_function() - except KeyError: + except (IndexError, KeyError): self.error(_('The variable no longer exists.')) return diff --git a/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py b/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py index 454683522c6..fb2e1d966c7 100644 --- a/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py +++ b/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py @@ -45,9 +45,11 @@ class CollectionsDelegate(QItemDelegate, SpyderFontsMixin): sig_editor_creation_started = Signal() sig_editor_shown = Signal() - def __init__(self, parent=None, namespacebrowser=None): + def __init__(self, parent=None, namespacebrowser=None, + data_function: Optional[Callable[[], Any]] = None): QItemDelegate.__init__(self, parent) self.namespacebrowser = namespacebrowser + self.data_function = data_function self._editors = {} # keep references on opened editors def get_value(self, index): @@ -64,6 +66,11 @@ def make_data_function(self, index: QModelIndex Construct function which returns current value of data. This is used to refresh editors created from this piece of data. + For instance, if `self` is the delegate for an editor that displays + the dict `xxx` and the user opens another editor for `xxx["aaa"]`, + then to refresh the data of the second editor, the nested function + `datafun` first gets the refreshed data for `xxx` and then gets the + item with key "aaa". Parameters ---------- @@ -77,9 +84,21 @@ def make_data_function(self, index: QModelIndex Function which returns the current value of the data, or None if such a function cannot be constructed. """ - # TODO: Implement this to handle refreshing editors opened from other - # editors, e.g., arrays nested inside a list. - return None + if self.data_function is None: + return None + key = index.model().keys[index.row()] + + def datafun(): + data = self.data_function() + if isinstance(data, (tuple, list, dict, set)): + return data[key] + try: + return getattr(data, key) + except (NotImplementedError, AttributeError, + TypeError, ValueError): + return None + + return datafun def show_warning(self, index): """ diff --git a/spyder/widgets/collectionseditor.py b/spyder/widgets/collectionseditor.py index 45b5f95eaf7..8f9f7d41e94 100644 --- a/spyder/widgets/collectionseditor.py +++ b/spyder/widgets/collectionseditor.py @@ -1317,6 +1317,7 @@ class CollectionsEditorTableView(BaseTableView): """CollectionsEditor table view""" def __init__(self, parent, data, namespacebrowser=None, + data_function: Optional[Callable[[], Any]] = None, readonly=False, title="", names=False): BaseTableView.__init__(self, parent) self.dictfilter = None @@ -1333,7 +1334,8 @@ def __init__(self, parent, data, namespacebrowser=None, ) self.model = self.source_model self.setModel(self.source_model) - self.delegate = CollectionsDelegate(self, namespacebrowser) + self.delegate = CollectionsDelegate( + self, namespacebrowser, data_function) self.setItemDelegate(self.delegate) self.setup_table() @@ -1456,7 +1458,7 @@ def __init__(self, parent, data, namespacebrowser=None, self, data, readonly) else: self.editor = CollectionsEditorTableView( - self, data, namespacebrowser, readonly, title) + self, data, namespacebrowser, data_function, readonly, title) toolbar = SpyderToolbar(parent=None, title='Editor toolbar') toolbar.setStyleSheet(str(PANES_TOOLBAR_STYLESHEET)) @@ -1602,7 +1604,7 @@ def refresh_editor(self) -> None: try: new_value = self.data_function() - except KeyError: + except (IndexError, KeyError): QMessageBox.critical(self, _('Collection editor'), _('The variable no longer exists.')) self.reject() @@ -1610,7 +1612,8 @@ def refresh_editor(self) -> None: self.widget.set_data(new_value) self.data_copy = new_value - self.btn_save_and_close.setEnabled(False) + if self.btn_save_and_close: + self.btn_save_and_close.setEnabled(False) self.btn_close.setAutoDefault(True) self.btn_close.setDefault(True) diff --git a/spyder/widgets/tests/test_collectioneditor.py b/spyder/widgets/tests/test_collectioneditor.py index 5bdf10fcbf5..3aceb685c49 100644 --- a/spyder/widgets/tests/test_collectioneditor.py +++ b/spyder/widgets/tests/test_collectioneditor.py @@ -565,6 +565,23 @@ def datafunc(): mock_critical.assert_called_once() +def test_collectioneditor_refresh_nested(): + """ + Open an editor for a list with a tuple nested inside, and then open another + editor for the nested tuple. Test that refreshing the second editor works. + """ + old_list = [1, 2, 3, (4, 5)] + new_list = [1, 2, 3, (4,)] + editor = CollectionsEditor(None, data_function=lambda: new_list) + editor.setup(old_list) + view = editor.widget.editor + view.edit(view.model.index(3, 3)) + nested_editor = list(view.delegate._editors.values())[0]['editor'] + assert nested_editor.get_value() == (4, 5) + nested_editor.widget.refresh_action.trigger() + assert nested_editor.get_value() == (4,) + + def test_edit_datetime(monkeypatch): """ Test datetimes are editable and NaT values are correctly handled. From 2a0804f8beefa3b1ec4bf283fafa32186483db65 Mon Sep 17 00:00:00 2001 From: Jitse Niesen Date: Fri, 6 Oct 2023 15:28:47 +0100 Subject: [PATCH 09/16] Split DataframeEditor.setup_and_check() in two Make two functions. The first is setup_ui(), which sets up the UI and should be called only once. The second is set_data_and_check(), which specifies the data for the editor and can be called repeatedly. This is a refactor to prepare for the implementation of refresh functionality in the next commit. --- .../widgets/dataframeeditor.py | 121 +++++++++++------- 1 file changed, 75 insertions(+), 46 deletions(-) diff --git a/spyder/plugins/variableexplorer/widgets/dataframeeditor.py b/spyder/plugins/variableexplorer/widgets/dataframeeditor.py index cc4a2c8a59e..30cfe79bcd9 100644 --- a/spyder/plugins/variableexplorer/widgets/dataframeeditor.py +++ b/spyder/plugins/variableexplorer/widgets/dataframeeditor.py @@ -1611,15 +1611,25 @@ def __init__(self, parent=None): self.layout = None self.glayout = None self.menu_header_v = None + self.dataTable = None - def setup_and_check(self, data, title=''): + def setup_and_check(self, data, title='') -> bool: """ Setup DataFrameEditor: return False if data is not supported, True otherwise. Supported types for data are DataFrame, Series and Index. """ - self._selection_rec = False - self._model = None + if title: + title = to_text_string(title) + " - %s" % data.__class__.__name__ + else: + title = _("%s editor") % data.__class__.__name__ + self.setup_ui(title) + return self.set_data_and_check(data) + + def setup_ui(self, title: str) -> None: + """ + Create user interface + """ self.layout = QVBoxLayout() self.layout.setSpacing(0) self.glayout = QGridLayout() @@ -1627,19 +1637,6 @@ def setup_and_check(self, data, title=''): self.glayout.setContentsMargins(0, 12, 0, 0) self.setLayout(self.layout) - if title: - title = to_text_string(title) + " - %s" % data.__class__.__name__ - else: - title = _("%s editor") % data.__class__.__name__ - - if isinstance(data, pd.Series): - self.is_series = True - data = data.to_frame() - elif isinstance(data, pd.Index): - data = pd.DataFrame(data) - - self.setWindowTitle(title) - self.hscroll = QScrollBar(Qt.Horizontal) self.vscroll = QScrollBar(Qt.Vertical) @@ -1655,22 +1652,9 @@ def setup_and_check(self, data, title=''): # Create menu to allow edit index self.menu_header_v = self.setup_menu_header(self.table_index) - # Create the model and view of the data - self.dataModel = DataFrameModel(data, parent=self) - self.dataModel.dataChanged.connect(self.save_and_close_enable) - self.create_data_table() - self.glayout.addWidget(self.hscroll, 2, 0, 1, 2) self.glayout.addWidget(self.vscroll, 0, 2, 2, 1) - # autosize columns on-demand - self._autosized_cols = set() - - # Set limit time to calculate column sizeHint to 300ms, - # See spyder-ide/spyder#11060 - self._max_autosize_ms = 300 - self.dataTable.installEventFilter(self) - avg_width = self.fontMetrics().averageCharWidth() self.min_trunc = avg_width * 12 # Minimum size for columns self.max_width = avg_width * 64 # Maximum size for columns @@ -1681,7 +1665,6 @@ def setup_and_check(self, data, title=''): btn_layout.setSpacing(5) btn_format = QPushButton(_("Format")) - # disable format button for int type btn_layout.addWidget(btn_format) btn_format.clicked.connect(self.change_format) @@ -1689,23 +1672,16 @@ def setup_and_check(self, data, title=''): btn_layout.addWidget(btn_resize) btn_resize.clicked.connect(self.resize_to_contents) - bgcolor = QCheckBox(_('Background color')) - bgcolor.setChecked(self.dataModel.bgcolor_enabled) - bgcolor.setEnabled(self.dataModel.bgcolor_enabled) - bgcolor.stateChanged.connect(self.change_bgcolor_enable) - btn_layout.addWidget(bgcolor) + self.bgcolor = QCheckBox(_('Background color')) + self.bgcolor.stateChanged.connect(self.change_bgcolor_enable) + btn_layout.addWidget(self.bgcolor) self.bgcolor_global = QCheckBox(_('Column min/max')) - self.bgcolor_global.setChecked(self.dataModel.colum_avg_enabled) - self.bgcolor_global.setEnabled(not self.is_series and - self.dataModel.bgcolor_enabled) - self.bgcolor_global.stateChanged.connect(self.dataModel.colum_avg) btn_layout.addWidget(self.bgcolor_global) btn_layout.addStretch() self.btn_save_and_close = QPushButton(_('Save and Close')) - self.btn_save_and_close.setDisabled(True) self.btn_save_and_close.clicked.connect(self.accept) btn_layout.addWidget(self.btn_save_and_close) @@ -1717,23 +1693,71 @@ def setup_and_check(self, data, title=''): btn_layout.setContentsMargins(0, 16, 0, 16) self.glayout.addLayout(btn_layout, 4, 0, 1, 2) + + self.toolbar = SpyderToolbar(parent=None, title='Editor toolbar') + self.toolbar.setStyleSheet(str(PANES_TOOLBAR_STYLESHEET)) + self.layout.addWidget(self.toolbar) + self.layout.addLayout(self.glayout) + + self.setWindowTitle(title) + + def set_data_and_check(self, data) -> bool: + """ + Checks whether data is suitable and display it in the editor + + This function returns False if data is not supported (though in fact, + it always returns True). + """ + self._selection_rec = False + self._model = None + + if isinstance(data, pd.Series): + self.is_series = True + data = data.to_frame() + elif isinstance(data, pd.Index): + data = pd.DataFrame(data) + + # Create the model and view of the data + self.dataModel = DataFrameModel(data, parent=self) + self.dataModel.dataChanged.connect(self.save_and_close_enable) + self.create_data_table() + + # autosize columns on-demand + self._autosized_cols = set() + + # Set limit time to calculate column sizeHint to 300ms, + # See spyder-ide/spyder#11060 + self._max_autosize_ms = 300 + self.dataTable.installEventFilter(self) + self.setModel(self.dataModel) self.resizeColumnsToContents() + self.bgcolor.setChecked(self.dataModel.bgcolor_enabled) + self.bgcolor.setEnabled(self.dataModel.bgcolor_enabled) + + try: + self.bgcolor_global.stateChanged.disconnect() + except TypeError: + # Raised when no slots are connected to the signal + pass + + self.bgcolor_global.stateChanged.connect(self.dataModel.colum_avg) + self.bgcolor_global.setChecked(self.dataModel.colum_avg_enabled) + self.bgcolor_global.setEnabled(not self.is_series and + self.dataModel.bgcolor_enabled) + + self.btn_save_and_close.setDisabled(True) self.dataModel.set_format_spec(self.get_conf('dataframe_format')) if self.table_header.rowHeight(0) == 0: self.table_header.setRowHeight(0, self.table_header.height()) - toolbar = SpyderToolbar(parent=None, title='Editor toolbar') - toolbar.setStyleSheet(str(PANES_TOOLBAR_STYLESHEET)) + self.toolbar.clear() for item in self.dataTable.menu_actions: if item is not None: if item.text() != 'Convert to': - toolbar.addAction(item) - - self.layout.addWidget(toolbar) - self.layout.addLayout(self.glayout) + self.toolbar.addAction(item) return True @@ -1833,6 +1857,11 @@ def create_table_index(self): def create_data_table(self): """Create the QTableView that will hold the data model.""" + if self.dataTable: + self.layout.removeWidget(self.dataTable) + self.dataTable.deleteLater() + self.dataTable = None + self.dataTable = DataFrameView(self, self.dataModel, self.table_header.horizontalHeader(), self.hscroll, self.vscroll) From dba6c03b16a735c5b88970dc01a65b9f2de50f0f Mon Sep 17 00:00:00 2001 From: Jitse Niesen Date: Fri, 10 Nov 2023 23:40:00 +0000 Subject: [PATCH 10/16] Implement refresh in dataframe editor * In CollectionDelegate, pass `data_function` when creating a dataframe editor. Pass this along to the `DataFrameView` * Add a refresh action to the `DataFrameView`, which raises a signal when it is triggered. Enable this action if `data_function` is set. * Add a function `DataFrameEditor.refresh_editor()` and connect it to the new signal. In this function, check whether the user edited the dataframe and if so, ask for confirmation first. Call `data_function` and use its return value as the new value for the dataframe to be displayed. Handle exceptions thrown by `data_function`, or when the new value is not a data frame. * Add tests for the new refresh functionality. --- .../widgets/collectionsdelegate.py | 3 +- .../widgets/dataframeeditor.py | 77 ++++++++++++++-- .../widgets/tests/test_dataframeeditor.py | 87 ++++++++++++++++++- 3 files changed, 156 insertions(+), 11 deletions(-) diff --git a/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py b/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py index fb2e1d966c7..6472715e002 100644 --- a/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py +++ b/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py @@ -251,7 +251,8 @@ def createEditor(self, parent, option, index, object_explorer=False): and pd.DataFrame is not FakeObject and not object_explorer): # We need to leave this import here for tests to pass. from .dataframeeditor import DataFrameEditor - editor = DataFrameEditor(parent=parent) + editor = DataFrameEditor( + parent=parent, data_function=self.make_data_function(index)) if not editor.setup_and_check(value, title=key): self.sig_editor_shown.emit() return diff --git a/spyder/plugins/variableexplorer/widgets/dataframeeditor.py b/spyder/plugins/variableexplorer/widgets/dataframeeditor.py index 30cfe79bcd9..d331b882cdc 100644 --- a/spyder/plugins/variableexplorer/widgets/dataframeeditor.py +++ b/spyder/plugins/variableexplorer/widgets/dataframeeditor.py @@ -35,6 +35,7 @@ # Standard library imports import io from time import perf_counter +from typing import Any, Callable, Optional # Third party imports from packaging.version import parse @@ -44,9 +45,9 @@ Signal, Slot) from qtpy.QtGui import QColor, QCursor from qtpy.QtWidgets import ( - QApplication, QCheckBox, QGridLayout, QHBoxLayout, QInputDialog, QLineEdit, - QMenu, QMessageBox, QPushButton, QTableView, QScrollBar, QTableWidget, - QFrame, QItemDelegate, QVBoxLayout, QLabel, QDialog) + QApplication, QCheckBox, QDialog, QFrame, QGridLayout, QHBoxLayout, + QInputDialog, QItemDelegate, QLabel, QLineEdit, QMenu, QMessageBox, + QPushButton, QScrollBar, QTableView, QTableWidget, QVBoxLayout, QWidget) from spyder_kernels.utils.lazymodules import numpy as np, pandas as pd # Local imports @@ -571,10 +572,12 @@ class DataFrameView(QTableView, SpyderConfigurationAccessor): sig_sort_by_column = Signal() sig_fetch_more_columns = Signal() sig_fetch_more_rows = Signal() + sig_refresh_requested = Signal() CONF_SECTION = 'variable_explorer' - def __init__(self, parent, model, header, hscroll, vscroll): + def __init__(self, parent, model, header, hscroll, vscroll, + data_function: Optional[Callable[[], Any]] = None): """Constructor.""" QTableView.__init__(self, parent) @@ -594,6 +597,7 @@ def __init__(self, parent, model, header, hscroll, vscroll): self.duplicate_row_action = None self.duplicate_col_action = None self.convert_to_action = None + self.refresh_action = None self.setModel(model) self.setHorizontalScrollBar(hscroll) @@ -607,6 +611,7 @@ def __init__(self, parent, model, header, hscroll, vscroll): self.header_class.customContextMenuRequested.connect( self.show_header_menu) self.header_class.sectionClicked.connect(self.sortByColumn) + self.data_function = data_function self.menu = self.setup_menu() self.menu_header_h = self.setup_menu_header() self.config_shortcut(self.copy, 'copy', self) @@ -779,6 +784,12 @@ def setup_menu(self): triggered=self.copy, context=Qt.WidgetShortcut ) + self.refresh_action = create_action( + self, _('Refresh'), + icon=ima.icon('refresh'), + tip=_('Refresh editor with current value of variable in console'), + triggered=lambda: self.sig_refresh_requested.emit()) + self.refresh_action.setEnabled(self.data_function is not None) self.convert_to_action = create_action(self, _('Convert to')) menu_actions = [ @@ -797,7 +808,9 @@ def setup_menu(self): self.convert_to_action, MENU_SEPARATOR, resize_action, - resize_columns_action + resize_columns_action, + MENU_SEPARATOR, + self.refresh_action ] self.menu_actions = menu_actions.copy() @@ -1599,8 +1612,10 @@ class DataFrameEditor(BaseDialog, SpyderConfigurationAccessor): """ CONF_SECTION = 'variable_explorer' - def __init__(self, parent=None): + def __init__(self, parent: QWidget = None, + data_function: Optional[Callable[[], Any]] = None): super().__init__(parent) + self.data_function = data_function # Destroying the C++ object right after closing the dialog box, # otherwise it may be garbage-collected in another QThread @@ -1705,9 +1720,11 @@ def set_data_and_check(self, data) -> bool: """ Checks whether data is suitable and display it in the editor - This function returns False if data is not supported (though in fact, - it always returns True). + This function returns False if data is not supported. """ + if not isinstance(data, (pd.DataFrame, pd.Series, pd.Index)): + return False + self._selection_rec = False self._model = None @@ -1864,7 +1881,8 @@ def create_data_table(self): self.dataTable = DataFrameView(self, self.dataModel, self.table_header.horizontalHeader(), - self.hscroll, self.vscroll) + self.hscroll, self.vscroll, + self.data_function) self.dataTable.verticalHeader().hide() self.dataTable.horizontalHeader().hide() self.dataTable.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) @@ -1878,6 +1896,7 @@ def create_data_table(self): self.dataTable.sig_sort_by_column.connect(self._sort_update) self.dataTable.sig_fetch_more_columns.connect(self._fetch_more_columns) self.dataTable.sig_fetch_more_rows.connect(self._fetch_more_rows) + self.dataTable.sig_refresh_requested.connect(self.refresh_editor) def sortByIndex(self, index): """Implement a Index sort.""" @@ -2078,6 +2097,46 @@ def change_bgcolor_enable(self, state): self.dataModel.bgcolor(state) self.bgcolor_global.setEnabled(not self.is_series and state > 0) + def refresh_editor(self) -> None: + """ + Refresh data in editor. + """ + assert self.data_function is not None + + if self.btn_save_and_close.isEnabled(): + if not self.ask_for_refresh_confirmation(): + return + + try: + data = self.data_function() + except (IndexError, KeyError): + self.error(_('The variable no longer exists.')) + return + + if not self.set_data_and_check(data): + self.error( + _('The new value cannot be displayed in the dataframe ' + 'editor.')) + + def ask_for_refresh_confirmation(self) -> bool: + """ + Ask user to confirm refreshing the editor. + + This function is to be called if refreshing the editor would overwrite + changes that the user made previously. The function returns True if + the user confirms that they want to refresh and False otherwise. + """ + message = _('Refreshing the editor will overwrite the changes that ' + 'you made. Do you want to proceed?') + result = QMessageBox.question( + self, _('Refresh dataframe editor?'), message) + return result == QMessageBox.Yes + + def error(self, message): + """An error occurred, closing the dialog box""" + QMessageBox.critical(self, _("Dataframe editor"), message) + self.reject() + @Slot() def change_format(self): """ diff --git a/spyder/plugins/variableexplorer/widgets/tests/test_dataframeeditor.py b/spyder/plugins/variableexplorer/widgets/tests/test_dataframeeditor.py index 3502c9f76a8..c8281628d1c 100644 --- a/spyder/plugins/variableexplorer/widgets/tests/test_dataframeeditor.py +++ b/spyder/plugins/variableexplorer/widgets/tests/test_dataframeeditor.py @@ -14,7 +14,7 @@ import os import sys from datetime import datetime -from unittest.mock import Mock, ANY +from unittest.mock import Mock, patch, ANY # Third party imports from flaky import flaky @@ -23,6 +23,7 @@ from pandas import ( __version__ as pandas_version, DataFrame, date_range, read_csv, concat, Index, RangeIndex, MultiIndex, CategoricalIndex, Series) +from pandas.testing import assert_frame_equal import pytest from qtpy.QtGui import QColor from qtpy.QtCore import Qt, QTimer @@ -427,6 +428,90 @@ def test_dataframemodel_with_format_percent_d_and_nan(): assert data(dfm, 1, 0) == 'nan' +def test_dataframeeditor_refreshaction_disabled(): + """ + Test that the Refresh action is disabled by default. + """ + df = DataFrame([[0]]) + editor = DataFrameEditor(None) + editor.setup_and_check(df) + assert not editor.dataTable.refresh_action.isEnabled() + + +def test_dataframeeditor_refresh(): + """ + Test that after pressing the refresh button, the value of the Array Editor + is replaced by the return value of the data_function. + """ + df_zero = DataFrame([[0]]) + df_new = DataFrame([[0, 10], [1, 20], [2, 40]]) + editor = DataFrameEditor(data_function=lambda: df_new) + editor.setup_and_check(df_zero) + assert_frame_equal(editor.get_value(), df_zero) + assert editor.dataTable.refresh_action.isEnabled() + editor.dataTable.refresh_action.trigger() + assert_frame_equal(editor.get_value(), df_new) + + +@pytest.mark.parametrize('result', [QMessageBox.Yes, QMessageBox.No]) +def test_dataframeeditor_refresh_after_edit(result): + """ + Test that after changing a value in the editor, pressing the Refresh + button opens a dialog box (which asks for confirmation), and that the + editor is only refreshed if the user clicks Yes. + """ + df_zero = DataFrame([[0]]) + df_edited = DataFrame([[2]]) + df_new = DataFrame([[0, 10], [1, 20], [2, 40]]) + editor = DataFrameEditor(data_function=lambda: df_new) + editor.setup_and_check(df_zero) + model = editor.dataModel + model.setData(model.index(0, 0), '2') + with patch('spyder.plugins.variableexplorer.widgets.dataframeeditor' + '.QMessageBox.question', + return_value=result) as mock_question: + editor.dataTable.refresh_action.trigger() + mock_question.assert_called_once() + editor.accept() + if result == QMessageBox.Yes: + assert_frame_equal(editor.get_value(), df_new) + else: + assert_frame_equal(editor.get_value(), df_edited) + + +def test_dataframeeditor_refresh_into_int(qtbot): + """ + Test that if the value after refreshing is not a DataFrame but an integer, + a critical dialog box is displayed and that the editor is closed. + """ + df_zero = DataFrame([[0]]) + editor = DataFrameEditor(data_function=lambda: 1) + editor.setup_and_check(df_zero) + with patch('spyder.plugins.variableexplorer.widgets.dataframeeditor' + '.QMessageBox.critical') as mock_critical, \ + qtbot.waitSignal(editor.rejected, timeout=0): + editor.dataTable.refresh_action.trigger() + mock_critical.assert_called_once() + + +def test_dataframeeditor_refresh_when_variable_deleted(qtbot): + """ + Test that if the variable is deleted and then the editor is refreshed + (resulting in data_function raising a KeyError), a critical dialog box + is displayed and that the dataframe editor is closed. + """ + def datafunc(): + raise KeyError + df_zero = DataFrame([[0]]) + editor = DataFrameEditor(data_function=datafunc) + editor.setup_and_check(df_zero) + with patch('spyder.plugins.variableexplorer.widgets.dataframeeditor' + '.QMessageBox.critical') as mock_critical, \ + qtbot.waitSignal(editor.rejected, timeout=0): + editor.dataTable.refresh_action.trigger() + mock_critical.assert_called_once() + + def test_change_format(qtbot, monkeypatch): mockQInputDialog = Mock() mockQInputDialog.getText = lambda parent, title, label, mode, text: ( From 1265e3d9be641731d77fa2c96a5e068ba1da6c8f Mon Sep 17 00:00:00 2001 From: Jitse Niesen Date: Mon, 9 Oct 2023 14:17:15 +0100 Subject: [PATCH 11/16] Split constructor of object explorer in two Make new function `ObjectExplorer.set_value()` for setting the object displayed in the object explorer, and call this from the constructor. This is a refactor to prepare for implementing the refresh functionality in the next commit. --- .../widgets/objectexplorer/objectexplorer.py | 153 +++++++++++------- 1 file changed, 92 insertions(+), 61 deletions(-) diff --git a/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py b/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py index 5d896cb6f8e..9c7205a782a 100644 --- a/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py +++ b/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py @@ -13,7 +13,7 @@ import traceback # Third-party imports -from qtpy.QtCore import Signal, Slot, QModelIndex, QPoint, QSize, Qt +from qtpy.QtCore import Slot, QModelIndex, QPoint, QSize, Qt from qtpy.QtGui import QKeySequence, QTextOption from qtpy.QtWidgets import (QAbstractItemView, QAction, QButtonGroup, QGroupBox, QHBoxLayout, QHeaderView, @@ -42,6 +42,15 @@ EDITOR_NAME = 'Object' +def safe_disconnect(signal): + """Disconnect a QtSignal, ignoring TypeError""" + try: + signal.disconnect() + except TypeError: + # Raised when no slots are connected to the signal + pass + + class ObjectExplorer(BaseDialog, SpyderConfigurationAccessor, SpyderFontsMixin): """Object explorer main widget window.""" CONF_SECTION = 'variable_explorer' @@ -82,16 +91,50 @@ def __init__(self, show_special_attributes = self.get_conf('show_special_attributes') # Model + self.name = name + self.expanded = expanded + self.namespacebrowser = namespacebrowser self._attr_cols = attribute_columns self._attr_details = attribute_details self.readonly = readonly + self.obj_tree = None self.btn_save_and_close = None self.btn_close = None - self._tree_model = TreeModel(obj, obj_name=name, + # Views + self._setup_actions() + self._setup_menu(show_callable_attributes=show_callable_attributes, + show_special_attributes=show_special_attributes) + self._setup_views() + if self.name: + self.setWindowTitle(f'{self.name} - {EDITOR_NAME}') + else: + self.setWindowTitle(EDITOR_NAME) + self.setWindowFlags(Qt.Window) + + # Load object into editor + self.set_value(obj) + + self._resize_to_contents = resize_to_contents + self._readViewSettings(reset=reset) + + # Update views with model + self.toggle_show_special_attribute_action.setChecked( + show_special_attributes) + self.toggle_show_callable_action.setChecked(show_callable_attributes) + + def get_value(self): + """Get editor current object state.""" + return self._tree_model.inspectedItem.obj + + def set_value(self, obj): + """Set object displayed in the editor.""" + self._tree_model = TreeModel(obj, obj_name=self.name, attr_cols=self._attr_cols) + show_callable_attributes = self.get_conf('show_callable_attributes') + show_special_attributes = self.get_conf('show_special_attributes') self._proxy_tree_model = TreeProxyModel( show_callable_attributes=show_callable_attributes, show_special_attributes=show_special_attributes @@ -103,40 +146,67 @@ def __init__(self, # self._proxy_tree_model.setSortCaseSensitivity(Qt.CaseInsensitive) # Tree widget - self.obj_tree = ToggleColumnTreeView(namespacebrowser) + old_obj_tree = self.obj_tree + self.obj_tree = ToggleColumnTreeView(self.namespacebrowser) self.obj_tree.setAlternatingRowColors(True) self.obj_tree.setModel(self._proxy_tree_model) self.obj_tree.setSelectionBehavior(QAbstractItemView.SelectRows) self.obj_tree.setUniformRowHeights(True) self.obj_tree.add_header_context_menu() - # Views - self._setup_actions() - self._setup_menu(show_callable_attributes=show_callable_attributes, - show_special_attributes=show_special_attributes) - self._setup_views() - if name: - name = "{} -".format(name) - self.setWindowTitle("{} {}".format(name, EDITOR_NAME)) - self.setWindowFlags(Qt.Window) + # Connect signals + safe_disconnect(self.toggle_show_callable_action.toggled) + self.toggle_show_callable_action.toggled.connect( + self._proxy_tree_model.setShowCallables) + self.toggle_show_callable_action.toggled.connect( + self.obj_tree.resize_columns_to_contents) - self._resize_to_contents = resize_to_contents - self._readViewSettings(reset=reset) + safe_disconnect(self.toggle_show_special_attribute_action.toggled) + self.toggle_show_special_attribute_action.toggled.connect( + self._proxy_tree_model.setShowSpecialAttributes) + self.toggle_show_special_attribute_action.toggled.connect( + self.obj_tree.resize_columns_to_contents) - # Update views with model - self.toggle_show_special_attribute_action.setChecked( - show_special_attributes) - self.toggle_show_callable_action.setChecked(show_callable_attributes) + # Keep a temporary reference of the selection_model to prevent + # segfault in PySide. + # See http://permalink.gmane.org/gmane.comp.lib.qt.pyside.devel/222 + selection_model = self.obj_tree.selectionModel() + selection_model.currentChanged.connect(self._update_details) + + # Check if the values of the model have been changed + self._proxy_tree_model.sig_setting_data.connect( + self.save_and_close_enable) + + self._proxy_tree_model.sig_update_details.connect( + self._update_details_for_item) # Select first row so that a hidden root node will not be selected. first_row_index = self._proxy_tree_model.firstItemIndex() self.obj_tree.setCurrentIndex(first_row_index) - if self._tree_model.inspectedNodeIsVisible or expanded: + if self._tree_model.inspectedNodeIsVisible or self.expanded: self.obj_tree.expand(first_row_index) - def get_value(self): - """Get editor current object state.""" - return self._tree_model.inspectedItem.obj + # Stretch last column? + # It doesn't play nice when columns are hidden and then shown again. + obj_tree_header = self.obj_tree.header() + obj_tree_header.setSectionsMovable(True) + obj_tree_header.setStretchLastSection(False) + + # Add menu item for toggling columns to the Options menu + add_actions(self.show_cols_submenu, + self.obj_tree.toggle_column_actions_group.actions()) + column_visible = [col.col_visible for col in self._attr_cols] + for idx, visible in enumerate(column_visible): + elem = self.obj_tree.toggle_column_actions_group.actions()[idx] + elem.setChecked(visible) + + # Place tree widget in editor + if old_obj_tree: + self.central_splitter.replaceWidget(0, self.obj_tree) + old_obj_tree.deleteLater() + else: + self.central_splitter.insertWidget(0, self.obj_tree) + def _make_show_column_function(self, column_idx): """Creates a function that shows or hides a column.""" @@ -155,11 +225,6 @@ def _setup_actions(self): statusTip=_("Shows/hides attributes that are callable " "(functions, methods, etc)") ) - self.toggle_show_callable_action.toggled.connect( - self._proxy_tree_model.setShowCallables) - self.toggle_show_callable_action.toggled.connect( - self.obj_tree.resize_columns_to_contents) - # Show/hide special attributes self.toggle_show_special_attribute_action = QAction( _("Show __special__ attributes"), @@ -168,10 +233,6 @@ def _setup_actions(self): shortcut=QKeySequence("Alt+S"), statusTip=_("Shows or hides __special__ attributes") ) - self.toggle_show_special_attribute_action.toggled.connect( - self._proxy_tree_model.setShowSpecialAttributes) - self.toggle_show_special_attribute_action.toggled.connect( - self.obj_tree.resize_columns_to_contents) def _setup_menu(self, show_callable_attributes=False, show_special_attributes=False): @@ -236,16 +297,6 @@ def _setup_views(self): layout.addWidget(self.central_splitter) self.setLayout(layout) - # Stretch last column? - # It doesn't play nice when columns are hidden and then shown again. - obj_tree_header = self.obj_tree.header() - obj_tree_header.setSectionsMovable(True) - obj_tree_header.setStretchLastSection(False) - add_actions(self.show_cols_submenu, - self.obj_tree.toggle_column_actions_group.actions()) - - self.central_splitter.addWidget(self.obj_tree) - # Bottom pane bottom_pane_widget = QWidget() bottom_layout = QHBoxLayout() @@ -311,20 +362,6 @@ def _setup_views(self): self.central_splitter.setCollapsible(1, True) self.central_splitter.setSizes([500, 320]) - # Connect signals - # Keep a temporary reference of the selection_model to prevent - # segfault in PySide. - # See http://permalink.gmane.org/gmane.comp.lib.qt.pyside.devel/222 - selection_model = self.obj_tree.selectionModel() - selection_model.currentChanged.connect(self._update_details) - - # Check if the values of the model have been changed - self._proxy_tree_model.sig_setting_data.connect( - self.save_and_close_enable) - - self._proxy_tree_model.sig_update_details.connect( - self._update_details_for_item) - # End of setup_methods def _readViewSettings(self, reset=False): """ @@ -356,8 +393,6 @@ def _readViewSettings(self, reset=False): if not header_restored: column_sizes = [col.width for col in self._attr_cols] - column_visible = [col.col_visible for col in self._attr_cols] - for idx, size in enumerate(column_sizes): if not self._resize_to_contents and size > 0: # Just in case header.resizeSection(idx, size) @@ -365,10 +400,6 @@ def _readViewSettings(self, reset=False): header.resizeSections(QHeaderView.ResizeToContents) break - for idx, visible in enumerate(column_visible): - elem = self.obj_tree.toggle_column_actions_group.actions()[idx] - elem.setChecked(visible) - self.resize(window_size) button = self.button_group.button(details_button_idx) From cb46ab7abb5baf8f241cdcf27e64b6b90bc1b3e5 Mon Sep 17 00:00:00 2001 From: Jitse Niesen Date: Sun, 29 Oct 2023 21:00:54 +0000 Subject: [PATCH 12/16] Implement refresh in object explorer * In CollectionDelegate, pass `data_function` when creating an object explorer. * Add a refresh toolbutton to the object explorer, which calls the new function `.refresh_editor()`. * In the new function, call `data_function` and use its return value as the new object to be displayed in the object explorer. Handle exceptions thrown by `data_function`. * Add tests for the new refresh functionality. --- .../widgets/collectionsdelegate.py | 1 + .../widgets/objectexplorer/objectexplorer.py | 35 +++++++++-- .../tests/test_objectexplorer.py | 60 ++++++++++++++++++- 3 files changed, 91 insertions(+), 5 deletions(-) diff --git a/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py b/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py index 6472715e002..eb7762edb64 100644 --- a/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py +++ b/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py @@ -319,6 +319,7 @@ def createEditor(self, parent, option, index, object_explorer=False): name=key, parent=parent, namespacebrowser=self.namespacebrowser, + data_function=self.make_data_function(index), readonly=readonly) self.create_dialog(editor, dict(model=index.model(), editor=editor, diff --git a/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py b/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py index 9c7205a782a..9ff03e94ccd 100644 --- a/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py +++ b/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py @@ -11,14 +11,15 @@ # Standard library imports import logging import traceback +from typing import Any, Callable, Optional # Third-party imports from qtpy.QtCore import Slot, QModelIndex, QPoint, QSize, Qt from qtpy.QtGui import QKeySequence, QTextOption -from qtpy.QtWidgets import (QAbstractItemView, QAction, QButtonGroup, - QGroupBox, QHBoxLayout, QHeaderView, - QMenu, QPushButton, QRadioButton, QSplitter, - QToolButton, QVBoxLayout, QWidget) +from qtpy.QtWidgets import ( + QAbstractItemView, QAction, QButtonGroup, QGroupBox, QHBoxLayout, + QHeaderView, QMenu, QMessageBox, QPushButton, QRadioButton, QSplitter, + QToolButton, QVBoxLayout, QWidget) # Local imports from spyder.api.config.fonts import SpyderFontsMixin, SpyderFontType @@ -62,6 +63,7 @@ def __init__(self, resize_to_contents=True, parent=None, namespacebrowser=None, + data_function: Optional[Callable[[], Any]] = None, attribute_columns=DEFAULT_ATTR_COLS, attribute_details=DEFAULT_ATTR_DETAILS, readonly=None, @@ -94,6 +96,7 @@ def __init__(self, self.name = name self.expanded = expanded self.namespacebrowser = namespacebrowser + self.data_function = data_function self._attr_cols = attribute_columns self._attr_details = attribute_details self.readonly = readonly @@ -258,6 +261,14 @@ def _setup_menu(self, show_callable_attributes=False, self.tools_layout.addSpacing(5) self.tools_layout.addWidget(special_attributes) + self.refresh_button = create_toolbutton( + self, icon=ima.icon('refresh'), + tip=_('Refresh editor with current value of variable in console'), + triggered=self.refresh_editor) + self.refresh_button.setEnabled(self.data_function is not None) + self.tools_layout.addSpacing(5) + self.tools_layout.addWidget(self.refresh_button) + self.tools_layout.addStretch() self.options_button = create_toolbutton( @@ -406,6 +417,22 @@ def _readViewSettings(self, reset=False): if button is not None: button.setChecked(True) + def refresh_editor(self) -> None: + """ + Refresh data in editor. + """ + assert self.data_function is not None + + try: + data = self.data_function() + except (IndexError, KeyError): + QMessageBox.critical(self, _('Collection editor'), + _('The variable no longer exists.')) + self.reject() + return + + self.set_value(data) + @Slot() def save_and_close_enable(self): """Handle the data change event to enable the save and close button.""" diff --git a/spyder/plugins/variableexplorer/widgets/objectexplorer/tests/test_objectexplorer.py b/spyder/plugins/variableexplorer/widgets/objectexplorer/tests/test_objectexplorer.py index ccb3dc26b2c..f431aa0cb45 100644 --- a/spyder/plugins/variableexplorer/widgets/objectexplorer/tests/test_objectexplorer.py +++ b/spyder/plugins/variableexplorer/widgets/objectexplorer/tests/test_objectexplorer.py @@ -11,12 +11,15 @@ """ # Standard library imports +from dataclasses import dataclass import datetime +from unittest.mock import patch # Third party imports -from qtpy.QtCore import Qt import numpy as np import pytest +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QMessageBox # Local imports from spyder.config.manager import CONF @@ -172,5 +175,60 @@ def __init__(self): assert model.columnCount() == 11 +@dataclass +class DataclassForTesting: + name: str + price: float + quantity: int + + +def test_objectexplorer_refreshbutton_disabled(): + """ + Test that the Refresh button is disabled by default. + """ + data = DataclassForTesting('lemon', 0.15, 5) + editor = ObjectExplorer(data, name='data') + assert not editor.refresh_button.isEnabled() + + +def test_objectexplorer_refresh(): + """ + Test that after pressing the refresh button, the value of the Array Editor + is replaced by the return value of the data_function. + """ + data_old = DataclassForTesting('lemon', 0.15, 5) + data_new = range(1, 42, 3) + editor = ObjectExplorer(data_old, name='data', + data_function=lambda: data_new) + model = editor.obj_tree.model() + root = model.index(0, 0) + assert model.data(model.index(0, 0, root), Qt.DisplayRole) == 'name' + assert model.data(model.index(0, 3, root), Qt.DisplayRole) == 'lemon' + assert editor.refresh_button.isEnabled() + editor.refresh_editor() + model = editor.obj_tree.model() + root = model.index(0, 0) + row = model.rowCount(root) - 1 + assert model.data(model.index(row, 0, root), Qt.DisplayRole) == 'stop' + assert model.data(model.index(row, 3, root), Qt.DisplayRole) == '42' + + +def test_objectexplorer_refresh_when_variable_deleted(qtbot): + """ + Test that if the variable is deleted and then the editor is refreshed + (resulting in data_function raising a KeyError), a critical dialog box + is displayed and that the object editor is closed. + """ + def datafunc(): + raise KeyError + data = DataclassForTesting('lemon', 0.15, 5) + editor = ObjectExplorer(data, name='data', data_function=datafunc) + with patch('spyder.plugins.variableexplorer.widgets.objectexplorer' + '.objectexplorer.QMessageBox.critical') as mock_critical: + with qtbot.waitSignal(editor.rejected, timeout=0): + editor.refresh_button.click() + mock_critical.assert_called_once() + + if __name__ == "__main__": pytest.main() From c85c4f111513ffcdac2c78fa3365cb8c88e946d6 Mon Sep 17 00:00:00 2001 From: Jitse Niesen Date: Thu, 26 Oct 2023 17:05:07 +0100 Subject: [PATCH 13/16] Implement refresh of editors opened from object explorer * Pass the `data_function` from the object explorer, which can retrieve new data for the object being edited, via `ToggleColumnTreeView` to `ToggleColumnDelegate`. * Add `ToggleColumnDelegate.make_data_function()` for retrieving new data for editors opened from the object explorer. * Pass the result of the above function to editors opened from the object explorer. * Add tests for new functionality. --- .../widgets/collectionsdelegate.py | 59 +++++++++++++++++-- .../widgets/objectexplorer/objectexplorer.py | 3 +- .../tests/test_objectexplorer.py | 48 +++++++++++++++ .../objectexplorer/toggle_column_mixin.py | 10 +++- 4 files changed, 111 insertions(+), 9 deletions(-) diff --git a/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py b/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py index eb7762edb64..01cf02585ee 100644 --- a/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py +++ b/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py @@ -462,8 +462,11 @@ def updateEditorGeometry(self, editor, option, index): class ToggleColumnDelegate(CollectionsDelegate): """ToggleColumn Item Delegate""" - def __init__(self, parent=None, namespacebrowser=None): - CollectionsDelegate.__init__(self, parent, namespacebrowser) + + def __init__(self, parent=None, namespacebrowser=None, + data_function: Optional[Callable[[], Any]] = None): + CollectionsDelegate.__init__( + self, parent, namespacebrowser, data_function) self.current_index = None self.old_obj = None @@ -483,6 +486,49 @@ def set_value(self, index, value): if index.isValid(): index.model().set_value(index, value) + def make_data_function(self, index: QModelIndex + ) -> Optional[Callable[[], Any]]: + """ + Construct function which returns current value of data. + + This is used to refresh editors created from this piece of data. + For instance, if `self` is the delegate for an editor displays the + object `obj` and the user opens another editor for `obj.xxx.yyy`, + then to refresh the data of the second editor, the nested function + `datafun` first gets the refreshed data for `obj` and then gets the + `xxx` attribute and then the `yyy` attribute. + + Parameters + ---------- + index : QModelIndex + Index of item whose current value is to be returned by the + function constructed here. + + Returns + ------- + Optional[Callable[[], Any]] + Function which returns the current value of the data, or None if + such a function cannot be constructed. + """ + if self.data_function is None: + return None + + obj_path = index.model().get_key(index).obj_path + path_elements = obj_path.split('.') + del path_elements[0] # first entry is variable name + + def datafun(): + data = self.data_function() + try: + for attribute_name in path_elements: + data = getattr(data, attribute_name) + return data + except (NotImplementedError, AttributeError, + TypeError, ValueError): + return None + + return datafun + def createEditor(self, parent, option, index): """Overriding method createEditor""" if self.show_warning(index): @@ -519,7 +565,8 @@ def createEditor(self, parent, option, index): if isinstance(value, (list, set, tuple, dict)): from spyder.widgets.collectionseditor import CollectionsEditor editor = CollectionsEditor( - parent=parent, namespacebrowser=self.namespacebrowser) + parent=parent, namespacebrowser=self.namespacebrowser, + data_function=self.make_data_function(index)) editor.setup(value, key, icon=self.parent().windowIcon(), readonly=readonly) self.create_dialog(editor, dict(model=index.model(), editor=editor, @@ -528,7 +575,8 @@ def createEditor(self, parent, option, index): # ArrayEditor for a Numpy array elif (isinstance(value, (np.ndarray, np.ma.MaskedArray)) and np.ndarray is not FakeObject): - editor = ArrayEditor(parent=parent) + editor = ArrayEditor( + parent=parent, data_function=self.make_data_function(index)) if not editor.setup_and_check(value, title=key, readonly=readonly): return self.create_dialog(editor, dict(model=index.model(), editor=editor, @@ -549,7 +597,8 @@ def createEditor(self, parent, option, index): # DataFrameEditor for a pandas dataframe, series or index elif (isinstance(value, (pd.DataFrame, pd.Index, pd.Series)) and pd.DataFrame is not FakeObject): - editor = DataFrameEditor(parent=parent) + editor = DataFrameEditor( + parent=parent, data_function=self.make_data_function(index)) if not editor.setup_and_check(value, title=key): return self.create_dialog(editor, dict(model=index.model(), editor=editor, diff --git a/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py b/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py index 9ff03e94ccd..8f89dd49d34 100644 --- a/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py +++ b/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py @@ -150,7 +150,8 @@ def set_value(self, obj): # Tree widget old_obj_tree = self.obj_tree - self.obj_tree = ToggleColumnTreeView(self.namespacebrowser) + self.obj_tree = ToggleColumnTreeView( + self.namespacebrowser, self.data_function) self.obj_tree.setAlternatingRowColors(True) self.obj_tree.setModel(self._proxy_tree_model) self.obj_tree.setSelectionBehavior(QAbstractItemView.SelectRows) diff --git a/spyder/plugins/variableexplorer/widgets/objectexplorer/tests/test_objectexplorer.py b/spyder/plugins/variableexplorer/widgets/objectexplorer/tests/test_objectexplorer.py index f431aa0cb45..a5da4b9b7a7 100644 --- a/spyder/plugins/variableexplorer/widgets/objectexplorer/tests/test_objectexplorer.py +++ b/spyder/plugins/variableexplorer/widgets/objectexplorer/tests/test_objectexplorer.py @@ -230,5 +230,53 @@ def datafunc(): mock_critical.assert_called_once() +@dataclass +class Box: + contents: object + + +def test_objectexplorer_refresh_nested(): + """ + Open an editor for an `Box` object containing a list, and then open another + editor for the nested list. Test that refreshing the second editor works. + """ + old_data = Box([1, 2, 3]) + new_data = Box([4, 5]) + editor = ObjectExplorer( + old_data, name='data', data_function=lambda: new_data) + model = editor.obj_tree.model() + root_index = model.index(0, 0) + contents_index = model.index(0, 0, root_index) + editor.obj_tree.edit(contents_index) + delegate = editor.obj_tree.delegate + nested_editor = list(delegate._editors.values())[0]['editor'] + assert nested_editor.get_value() == [1, 2, 3] + nested_editor.widget.refresh_action.trigger() + assert nested_editor.get_value() == [4, 5] + + +def test_objectexplorer_refresh_doubly_nested(): + """ + Open an editor for an `Box` object containing another `Box` object which + in turn contains a list. Then open a second editor for the nested list. + Test that refreshing the second editor works. + """ + old_data = Box(Box([1, 2, 3])) + new_data = Box(Box([4, 5])) + editor = ObjectExplorer( + old_data, name='data', data_function=lambda: new_data) + model = editor.obj_tree.model() + root_index = model.index(0, 0) + inner_box_index = model.index(0, 0, root_index) + editor.obj_tree.expand(inner_box_index) + contents_index = model.index(0, 0, inner_box_index) + editor.obj_tree.edit(contents_index) + delegate = editor.obj_tree.delegate + nested_editor = list(delegate._editors.values())[0]['editor'] + assert nested_editor.get_value() == [1, 2, 3] + nested_editor.widget.refresh_action.trigger() + assert nested_editor.get_value() == [4, 5] + + if __name__ == "__main__": pytest.main() diff --git a/spyder/plugins/variableexplorer/widgets/objectexplorer/toggle_column_mixin.py b/spyder/plugins/variableexplorer/widgets/objectexplorer/toggle_column_mixin.py index b2071f2af3f..33ce5c5ecef 100644 --- a/spyder/plugins/variableexplorer/widgets/objectexplorer/toggle_column_mixin.py +++ b/spyder/plugins/variableexplorer/widgets/objectexplorer/toggle_column_mixin.py @@ -10,9 +10,10 @@ # Standard library imports import logging +from typing import Any, Callable, Optional # Third-party imports -from qtpy.QtCore import Qt, Signal, Slot +from qtpy.QtCore import Qt, Slot from qtpy.QtWidgets import (QAbstractItemView, QAction, QActionGroup, QHeaderView, QTableWidget, QTreeView, QTreeWidget) @@ -155,12 +156,15 @@ class ToggleColumnTreeView(QTreeView, ToggleColumnMixIn): show/hide columns. """ - def __init__(self, namespacebrowser=None, readonly=False): + def __init__(self, namespacebrowser=None, + data_function: Optional[Callable[[], Any]] = None, + readonly=False): QTreeView.__init__(self) self.readonly = readonly from spyder.plugins.variableexplorer.widgets.collectionsdelegate \ import ToggleColumnDelegate - self.delegate = ToggleColumnDelegate(self, namespacebrowser) + self.delegate = ToggleColumnDelegate( + self, namespacebrowser, data_function) self.setItemDelegate(self.delegate) self.setEditTriggers(QAbstractItemView.DoubleClicked) self.expanded.connect(self.resize_columns_to_contents) From 8558e368c13aad9b8f6b73d73cae0c7f52b376fd Mon Sep 17 00:00:00 2001 From: Jitse Niesen Date: Sun, 12 Nov 2023 18:30:40 +0000 Subject: [PATCH 14/16] Make test_dataframe_to_type() more robust For some reason, this test started to fail on Windows, probably because of the new menu item `Refresh`. This should fix the test. --- .../widgets/tests/test_dataframeeditor.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/spyder/plugins/variableexplorer/widgets/tests/test_dataframeeditor.py b/spyder/plugins/variableexplorer/widgets/tests/test_dataframeeditor.py index c8281628d1c..87b1cad2734 100644 --- a/spyder/plugins/variableexplorer/widgets/tests/test_dataframeeditor.py +++ b/spyder/plugins/variableexplorer/widgets/tests/test_dataframeeditor.py @@ -129,13 +129,19 @@ def test_dataframe_to_type(qtbot): view = editor.dataTable view.setCurrentIndex(view.model().index(0, 0)) - # Show context menu and select option `To bool` + # Show context menu, go down until `Convert to`, and open submenu view.menu.show() - qtbot.keyPress(view.menu, Qt.Key_Up) - qtbot.keyPress(view.menu, Qt.Key_Up) - qtbot.keyPress(view.menu, Qt.Key_Up) - qtbot.keyPress(view.menu, Qt.Key_Return) - submenu = view.menu.activeAction().menu() + for _ in range(100): + activeAction = view.menu.activeAction() + if activeAction and activeAction.text() == 'Convert to': + qtbot.keyPress(view.menu, Qt.Key_Return) + break + qtbot.keyPress(view.menu, Qt.Key_Down) + else: + raise RuntimeError('Item "Convert to" not found') + + # Select first option, which is `To bool` + submenu = activeAction.menu() qtbot.keyPress(submenu, Qt.Key_Return) qtbot.wait(1000) From 88861607ba2fa3165b1018d903c62616fb718c9c Mon Sep 17 00:00:00 2001 From: Jitse Niesen Date: Sat, 18 Nov 2023 23:08:07 +0000 Subject: [PATCH 15/16] Move safe_disconnect() to utils/qthelpers.py --- .../plugins/variableexplorer/widgets/arrayeditor.py | 11 ++--------- .../variableexplorer/widgets/dataframeeditor.py | 13 ++++--------- .../widgets/objectexplorer/objectexplorer.py | 12 ++---------- spyder/utils/qthelpers.py | 9 +++++++++ 4 files changed, 17 insertions(+), 28 deletions(-) diff --git a/spyder/plugins/variableexplorer/widgets/arrayeditor.py b/spyder/plugins/variableexplorer/widgets/arrayeditor.py index ea31a60566c..b427c7b9c39 100644 --- a/spyder/plugins/variableexplorer/widgets/arrayeditor.py +++ b/spyder/plugins/variableexplorer/widgets/arrayeditor.py @@ -45,7 +45,8 @@ from spyder.py3compat import (is_binary_string, is_string, is_text_string, to_binary_string, to_text_string) from spyder.utils.icon_manager import ima -from spyder.utils.qthelpers import add_actions, create_action, keybinding +from spyder.utils.qthelpers import ( + add_actions, create_action, keybinding, safe_disconnect) from spyder.utils.stylesheet import PANES_TOOLBAR_STYLESHEET @@ -128,14 +129,6 @@ def get_idx_rect(index_list): return ( min(rows), max(rows), min(cols), max(cols) ) -def safe_disconnect(signal): - """Disconnect a QtSignal, ignoring TypeError""" - try: - signal.disconnect() - except TypeError: - # Raised when no slots are connected to the signal - pass - #============================================================================== # Main classes #============================================================================== diff --git a/spyder/plugins/variableexplorer/widgets/dataframeeditor.py b/spyder/plugins/variableexplorer/widgets/dataframeeditor.py index d331b882cdc..21663d813d0 100644 --- a/spyder/plugins/variableexplorer/widgets/dataframeeditor.py +++ b/spyder/plugins/variableexplorer/widgets/dataframeeditor.py @@ -58,9 +58,9 @@ from spyder.py3compat import (is_text_string, is_type_text_string, to_text_string) from spyder.utils.icon_manager import ima -from spyder.utils.qthelpers import (add_actions, create_action, - MENU_SEPARATOR, keybinding, - qapplication) +from spyder.utils.qthelpers import ( + add_actions, create_action, keybinding, MENU_SEPARATOR, qapplication, + safe_disconnect) from spyder.plugins.variableexplorer.widgets.arrayeditor import get_idx_rect from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog from spyder.utils.palette import QStylePalette @@ -1753,12 +1753,7 @@ def set_data_and_check(self, data) -> bool: self.bgcolor.setChecked(self.dataModel.bgcolor_enabled) self.bgcolor.setEnabled(self.dataModel.bgcolor_enabled) - try: - self.bgcolor_global.stateChanged.disconnect() - except TypeError: - # Raised when no slots are connected to the signal - pass - + safe_disconnect(self.bgcolor_global.stateChanged) self.bgcolor_global.stateChanged.connect(self.dataModel.colum_avg) self.bgcolor_global.setChecked(self.dataModel.colum_avg_enabled) self.bgcolor_global.setEnabled(not self.is_series and diff --git a/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py b/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py index 8f89dd49d34..f4d328e3c79 100644 --- a/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py +++ b/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py @@ -31,7 +31,8 @@ DEFAULT_ATTR_COLS, DEFAULT_ATTR_DETAILS, ToggleColumnTreeView, TreeItem, TreeModel, TreeProxyModel) from spyder.utils.icon_manager import ima -from spyder.utils.qthelpers import add_actions, create_toolbutton, qapplication +from spyder.utils.qthelpers import ( + add_actions, create_toolbutton, qapplication, safe_disconnect) from spyder.utils.stylesheet import PANES_TOOLBAR_STYLESHEET from spyder.widgets.simplecodeeditor import SimpleCodeEditor @@ -43,15 +44,6 @@ EDITOR_NAME = 'Object' -def safe_disconnect(signal): - """Disconnect a QtSignal, ignoring TypeError""" - try: - signal.disconnect() - except TypeError: - # Raised when no slots are connected to the signal - pass - - class ObjectExplorer(BaseDialog, SpyderConfigurationAccessor, SpyderFontsMixin): """Object explorer main widget window.""" CONF_SECTION = 'variable_explorer' diff --git a/spyder/utils/qthelpers.py b/spyder/utils/qthelpers.py index 7c2b5aefd72..0d5e10c33f9 100644 --- a/spyder/utils/qthelpers.py +++ b/spyder/utils/qthelpers.py @@ -886,5 +886,14 @@ def handle_applicationStateChanged(state): app.applicationStateChanged.connect(handle_applicationStateChanged) +def safe_disconnect(signal): + """Disconnect a QtSignal, ignoring TypeError""" + try: + signal.disconnect() + except TypeError: + # Raised when no slots are connected to the signal + pass + + if __name__ == "__main__": show_std_icons() From 35165f151de880e1909fc589cd9d684e74aff568 Mon Sep 17 00:00:00 2001 From: Jitse Niesen Date: Sat, 25 Nov 2023 14:02:12 +0000 Subject: [PATCH 16/16] Apply suggestions from code review * Fix style of refresh button in object explorer * Mock correct function in test * Many style fixes Co-authored-by: Carlos Cordoba --- .../variableexplorer/widgets/arrayeditor.py | 46 ++++++++++------ .../widgets/collectionsdelegate.py | 52 +++++++++++++------ .../widgets/dataframeeditor.py | 32 ++++++++---- .../widgets/objectexplorer/objectexplorer.py | 15 ++++-- .../tests/test_objectexplorer.py | 10 ++-- .../objectexplorer/toggle_column_mixin.py | 12 +++-- .../widgets/tests/test_arrayeditor.py | 4 +- .../widgets/tests/test_dataframeeditor.py | 4 +- spyder/utils/qthelpers.py | 2 +- spyder/widgets/collectionseditor.py | 30 +++++++---- spyder/widgets/tests/test_collectioneditor.py | 18 ++++--- 11 files changed, 147 insertions(+), 78 deletions(-) diff --git a/spyder/plugins/variableexplorer/widgets/arrayeditor.py b/spyder/plugins/variableexplorer/widgets/arrayeditor.py index b427c7b9c39..f7f4a00d094 100644 --- a/spyder/plugins/variableexplorer/widgets/arrayeditor.py +++ b/spyder/plugins/variableexplorer/widgets/arrayeditor.py @@ -8,14 +8,13 @@ NumPy Array Editor Dialog based on Qt """ -from __future__ import annotations - # pylint: disable=C0103 # pylint: disable=R0903 # pylint: disable=R0911 # pylint: disable=R0201 # Standard library imports +from __future__ import annotations import io from typing import Callable, Optional, TYPE_CHECKING @@ -656,8 +655,11 @@ class ArrayEditor(BaseDialog, SpyderWidgetMixin): CONF_SECTION = 'variable_explorer' - def __init__(self, parent: Optional[QWidget] = None, - data_function: Optional[Callable[[], ArrayLike]] = None): + def __init__( + self, + parent: Optional[QWidget] = None, + data_function: Optional[Callable[[], ArrayLike]] = None + ): """ Constructor. @@ -692,15 +694,16 @@ def __init__(self, parent: Optional[QWidget] = None, def setup_and_check(self, data, title='', readonly=False): """ - Setup ArrayEditor: - return False if data is not supported, True otherwise + Setup the editor. + + It returns False if data is not supported, True otherwise. """ self.setup_ui(title, readonly) return self.set_data_and_check(data, readonly) def setup_ui(self, title='', readonly=False): """ - Create user interface + Create the user interface. This creates the necessary widgets and layouts that make up the user interface of the array editor. Some elements need to be hidden @@ -716,8 +719,8 @@ def setup_ui(self, title='', readonly=False): def do_nothing(): # .create_action() needs a toggled= parameter, but we can only - # set it later in .set_data_and_check(), so we use this function - # as a placeholder here. + # set it later in the set_data_and_check method, so we use this + # function as a placeholder here. pass self.copy_action = self.create_action( @@ -778,7 +781,8 @@ def do_nothing(): # ---- Widgets in bottom left for special arrays # # These are normally hidden. When editing masked, record or 3d arrays, - # the relevant elements are made visible in `.set_data_and_check()`. + # the relevant elements are made visible in the set_data_and_check + # method. self.btn_layout = QHBoxLayout() @@ -803,15 +807,17 @@ def do_nothing(): self.btn_layout.addWidget(self.slicing_label) self.masked_label = QLabel( - _('Warning: Changes are applied separately')) + _('Warning: Changes are applied separately') + ) self.masked_label.setToolTip( _("For performance reasons, changes applied to masked arrays won't" - "be reflected in array's data (and vice-versa).")) + "be reflected in array's data (and vice-versa).") + ) self.btn_layout.addWidget(self.masked_label) self.btn_layout.addStretch() - # ---- Push buttons on bottom right + # ---- Push buttons on the bottom right self.btn_save_and_close = QPushButton(_('Save and Close')) self.btn_save_and_close.setDisabled(True) @@ -1024,9 +1030,11 @@ def current_widget_changed(self, index): self.arraywidget = self.stack.widget(index) if self.arraywidget: self.arraywidget.model.dataChanged.connect( - self.save_and_close_enable) + self.save_and_close_enable + ) self.toggle_bgcolor_action.setChecked( - self.arraywidget.model.bgcolor_enabled) + self.arraywidget.model.bgcolor_enabled + ) def change_active_widget(self, index): """ @@ -1100,7 +1108,8 @@ def refresh(self) -> None: if not self.set_data_and_check(data): self.error( - _('The new value cannot be displayed in the array editor.')) + _('The new value cannot be displayed in the array editor.') + ) def ask_for_refresh_confirmation(self) -> bool: """ @@ -1113,7 +1122,10 @@ def ask_for_refresh_confirmation(self) -> bool: message = _('Refreshing the editor will overwrite the changes that ' 'you made. Do you want to proceed?') result = QMessageBox.question( - self, _('Refresh array editor?'), message) + self, + _('Refresh array editor?'), + message + ) return result == QMessageBox.Yes @Slot() diff --git a/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py b/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py index 01cf02585ee..eaf1536fb2a 100644 --- a/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py +++ b/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py @@ -45,8 +45,12 @@ class CollectionsDelegate(QItemDelegate, SpyderFontsMixin): sig_editor_creation_started = Signal() sig_editor_shown = Signal() - def __init__(self, parent=None, namespacebrowser=None, - data_function: Optional[Callable[[], Any]] = None): + def __init__( + self, + parent=None, + namespacebrowser=None, + data_function: Optional[Callable[[], Any]] = None + ): QItemDelegate.__init__(self, parent) self.namespacebrowser = namespacebrowser self.data_function = data_function @@ -60,8 +64,10 @@ def set_value(self, index, value): if index.isValid(): index.model().set_value(index, value) - def make_data_function(self, index: QModelIndex - ) -> Optional[Callable[[], Any]]: + def make_data_function( + self, + index: QModelIndex + ) -> Optional[Callable[[], Any]]: """ Construct function which returns current value of data. @@ -92,6 +98,7 @@ def datafun(): data = self.data_function() if isinstance(data, (tuple, list, dict, set)): return data[key] + try: return getattr(data, key) except (NotImplementedError, AttributeError, @@ -207,8 +214,10 @@ def createEditor(self, parent, option, index, object_explorer=False): elif isinstance(value, (list, set, tuple, dict)) and not object_explorer: from spyder.widgets.collectionseditor import CollectionsEditor editor = CollectionsEditor( - parent=parent, namespacebrowser=self.namespacebrowser, - data_function=self.make_data_function(index)) + parent=parent, + namespacebrowser=self.namespacebrowser, + data_function=self.make_data_function(index) + ) editor.setup(value, key, icon=self.parent().windowIcon(), readonly=readonly) self.create_dialog(editor, dict(model=index.model(), editor=editor, @@ -220,7 +229,9 @@ def createEditor(self, parent, option, index, object_explorer=False): # We need to leave this import here for tests to pass. from .arrayeditor import ArrayEditor editor = ArrayEditor( - parent=parent, data_function=self.make_data_function(index)) + parent=parent, + data_function=self.make_data_function(index) + ) if not editor.setup_and_check(value, title=key, readonly=readonly): self.sig_editor_shown.emit() return @@ -252,7 +263,9 @@ def createEditor(self, parent, option, index, object_explorer=False): # We need to leave this import here for tests to pass. from .dataframeeditor import DataFrameEditor editor = DataFrameEditor( - parent=parent, data_function=self.make_data_function(index)) + parent=parent, + data_function=self.make_data_function(index) + ) if not editor.setup_and_check(value, title=key): self.sig_editor_shown.emit() return @@ -466,7 +479,8 @@ class ToggleColumnDelegate(CollectionsDelegate): def __init__(self, parent=None, namespacebrowser=None, data_function: Optional[Callable[[], Any]] = None): CollectionsDelegate.__init__( - self, parent, namespacebrowser, data_function) + self, parent, namespacebrowser, data_function + ) self.current_index = None self.old_obj = None @@ -486,8 +500,10 @@ def set_value(self, index, value): if index.isValid(): index.model().set_value(index, value) - def make_data_function(self, index: QModelIndex - ) -> Optional[Callable[[], Any]]: + def make_data_function( + self, + index: QModelIndex + ) -> Optional[Callable[[], Any]]: """ Construct function which returns current value of data. @@ -565,8 +581,10 @@ def createEditor(self, parent, option, index): if isinstance(value, (list, set, tuple, dict)): from spyder.widgets.collectionseditor import CollectionsEditor editor = CollectionsEditor( - parent=parent, namespacebrowser=self.namespacebrowser, - data_function=self.make_data_function(index)) + parent=parent, + namespacebrowser=self.namespacebrowser, + data_function=self.make_data_function(index) + ) editor.setup(value, key, icon=self.parent().windowIcon(), readonly=readonly) self.create_dialog(editor, dict(model=index.model(), editor=editor, @@ -576,7 +594,9 @@ def createEditor(self, parent, option, index): elif (isinstance(value, (np.ndarray, np.ma.MaskedArray)) and np.ndarray is not FakeObject): editor = ArrayEditor( - parent=parent, data_function=self.make_data_function(index)) + parent=parent, + data_function=self.make_data_function(index) + ) if not editor.setup_and_check(value, title=key, readonly=readonly): return self.create_dialog(editor, dict(model=index.model(), editor=editor, @@ -598,7 +618,9 @@ def createEditor(self, parent, option, index): elif (isinstance(value, (pd.DataFrame, pd.Index, pd.Series)) and pd.DataFrame is not FakeObject): editor = DataFrameEditor( - parent=parent, data_function=self.make_data_function(index)) + parent=parent, + data_function=self.make_data_function(index) + ) if not editor.setup_and_check(value, title=key): return self.create_dialog(editor, dict(model=index.model(), editor=editor, diff --git a/spyder/plugins/variableexplorer/widgets/dataframeeditor.py b/spyder/plugins/variableexplorer/widgets/dataframeeditor.py index 21663d813d0..7a626df96f7 100644 --- a/spyder/plugins/variableexplorer/widgets/dataframeeditor.py +++ b/spyder/plugins/variableexplorer/widgets/dataframeeditor.py @@ -788,7 +788,8 @@ def setup_menu(self): self, _('Refresh'), icon=ima.icon('refresh'), tip=_('Refresh editor with current value of variable in console'), - triggered=lambda: self.sig_refresh_requested.emit()) + triggered=lambda: self.sig_refresh_requested.emit() + ) self.refresh_action.setEnabled(self.data_function is not None) self.convert_to_action = create_action(self, _('Convert to')) @@ -1612,8 +1613,11 @@ class DataFrameEditor(BaseDialog, SpyderConfigurationAccessor): """ CONF_SECTION = 'variable_explorer' - def __init__(self, parent: QWidget = None, - data_function: Optional[Callable[[], Any]] = None): + def __init__( + self, + parent: QWidget = None, + data_function: Optional[Callable[[], Any]] = None + ): super().__init__(parent) self.data_function = data_function @@ -1630,20 +1634,22 @@ def __init__(self, parent: QWidget = None, def setup_and_check(self, data, title='') -> bool: """ - Setup DataFrameEditor: - return False if data is not supported, True otherwise. - Supported types for data are DataFrame, Series and Index. + Setup editor. + + It returns False if data is not supported, True otherwise. Supported + types for data are DataFrame, Series and Index. """ if title: title = to_text_string(title) + " - %s" % data.__class__.__name__ else: title = _("%s editor") % data.__class__.__name__ + self.setup_ui(title) return self.set_data_and_check(data) def setup_ui(self, title: str) -> None: """ - Create user interface + Create user interface. """ self.layout = QVBoxLayout() self.layout.setSpacing(0) @@ -1718,9 +1724,9 @@ def setup_ui(self, title: str) -> None: def set_data_and_check(self, data) -> bool: """ - Checks whether data is suitable and display it in the editor + Checks whether data is suitable and display it in the editor. - This function returns False if data is not supported. + This method returns False if data is not supported. """ if not isinstance(data, (pd.DataFrame, pd.Series, pd.Index)): return False @@ -2111,7 +2117,8 @@ def refresh_editor(self) -> None: if not self.set_data_and_check(data): self.error( _('The new value cannot be displayed in the dataframe ' - 'editor.')) + 'editor.') + ) def ask_for_refresh_confirmation(self) -> bool: """ @@ -2124,7 +2131,10 @@ def ask_for_refresh_confirmation(self) -> bool: message = _('Refreshing the editor will overwrite the changes that ' 'you made. Do you want to proceed?') result = QMessageBox.question( - self, _('Refresh dataframe editor?'), message) + self, + _('Refresh dataframe editor?'), + message + ) return result == QMessageBox.Yes def error(self, message): diff --git a/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py b/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py index f4d328e3c79..c586d57f002 100644 --- a/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py +++ b/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py @@ -143,7 +143,9 @@ def set_value(self, obj): # Tree widget old_obj_tree = self.obj_tree self.obj_tree = ToggleColumnTreeView( - self.namespacebrowser, self.data_function) + self.namespacebrowser, + self.data_function + ) self.obj_tree.setAlternatingRowColors(True) self.obj_tree.setModel(self._proxy_tree_model) self.obj_tree.setSelectionBehavior(QAbstractItemView.SelectRows) @@ -257,8 +259,10 @@ def _setup_menu(self, show_callable_attributes=False, self.refresh_button = create_toolbutton( self, icon=ima.icon('refresh'), tip=_('Refresh editor with current value of variable in console'), - triggered=self.refresh_editor) + triggered=self.refresh_editor + ) self.refresh_button.setEnabled(self.data_function is not None) + self.refresh_button.setStyleSheet(str(PANES_TOOLBAR_STYLESHEET)) self.tools_layout.addSpacing(5) self.tools_layout.addWidget(self.refresh_button) @@ -419,8 +423,11 @@ def refresh_editor(self) -> None: try: data = self.data_function() except (IndexError, KeyError): - QMessageBox.critical(self, _('Collection editor'), - _('The variable no longer exists.')) + QMessageBox.critical( + self, + _('Object explorer'), + _('The variable no longer exists.') + ) self.reject() return diff --git a/spyder/plugins/variableexplorer/widgets/objectexplorer/tests/test_objectexplorer.py b/spyder/plugins/variableexplorer/widgets/objectexplorer/tests/test_objectexplorer.py index a5da4b9b7a7..1309d32f498 100644 --- a/spyder/plugins/variableexplorer/widgets/objectexplorer/tests/test_objectexplorer.py +++ b/spyder/plugins/variableexplorer/widgets/objectexplorer/tests/test_objectexplorer.py @@ -184,7 +184,7 @@ class DataclassForTesting: def test_objectexplorer_refreshbutton_disabled(): """ - Test that the Refresh button is disabled by default. + Test that the refresh button is disabled by default. """ data = DataclassForTesting('lemon', 0.15, 5) editor = ObjectExplorer(data, name='data') @@ -193,8 +193,8 @@ def test_objectexplorer_refreshbutton_disabled(): def test_objectexplorer_refresh(): """ - Test that after pressing the refresh button, the value of the Array Editor - is replaced by the return value of the data_function. + Test that after pressing the refresh button, the value of the editor is + replaced by the return value of the data_function. """ data_old = DataclassForTesting('lemon', 0.15, 5) data_new = range(1, 42, 3) @@ -237,7 +237,7 @@ class Box: def test_objectexplorer_refresh_nested(): """ - Open an editor for an `Box` object containing a list, and then open another + Open an editor for a `Box` object containing a list, and then open another editor for the nested list. Test that refreshing the second editor works. """ old_data = Box([1, 2, 3]) @@ -257,7 +257,7 @@ def test_objectexplorer_refresh_nested(): def test_objectexplorer_refresh_doubly_nested(): """ - Open an editor for an `Box` object containing another `Box` object which + Open an editor for a `Box` object containing another `Box` object which in turn contains a list. Then open a second editor for the nested list. Test that refreshing the second editor works. """ diff --git a/spyder/plugins/variableexplorer/widgets/objectexplorer/toggle_column_mixin.py b/spyder/plugins/variableexplorer/widgets/objectexplorer/toggle_column_mixin.py index 33ce5c5ecef..8d6362d22d7 100644 --- a/spyder/plugins/variableexplorer/widgets/objectexplorer/toggle_column_mixin.py +++ b/spyder/plugins/variableexplorer/widgets/objectexplorer/toggle_column_mixin.py @@ -156,15 +156,19 @@ class ToggleColumnTreeView(QTreeView, ToggleColumnMixIn): show/hide columns. """ - def __init__(self, namespacebrowser=None, - data_function: Optional[Callable[[], Any]] = None, - readonly=False): + def __init__( + self, + namespacebrowser=None, + data_function: Optional[Callable[[], Any]] = None, + readonly=False + ): QTreeView.__init__(self) self.readonly = readonly from spyder.plugins.variableexplorer.widgets.collectionsdelegate \ import ToggleColumnDelegate self.delegate = ToggleColumnDelegate( - self, namespacebrowser, data_function) + self, namespacebrowser, data_function + ) self.setItemDelegate(self.delegate) self.setEditTriggers(QAbstractItemView.DoubleClicked) self.expanded.connect(self.resize_columns_to_contents) diff --git a/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py b/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py index e9b9acc849b..b482575267e 100644 --- a/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py +++ b/spyder/plugins/variableexplorer/widgets/tests/test_arrayeditor.py @@ -237,8 +237,8 @@ def test_arrayeditor_refreshaction_disabled(): def test_arrayeditor_refresh(): """ - Test that after pressing the refresh button, the value of the Array Editor - is replaced by the return value of the data_function. + Test that after pressing the refresh button, the value of the editor is + replaced by the return value of the data_function. """ arr_ones = np.ones((3, 3)) arr_zeros = np.zeros((4, 4)) diff --git a/spyder/plugins/variableexplorer/widgets/tests/test_dataframeeditor.py b/spyder/plugins/variableexplorer/widgets/tests/test_dataframeeditor.py index 87b1cad2734..c0684ad2a14 100644 --- a/spyder/plugins/variableexplorer/widgets/tests/test_dataframeeditor.py +++ b/spyder/plugins/variableexplorer/widgets/tests/test_dataframeeditor.py @@ -446,8 +446,8 @@ def test_dataframeeditor_refreshaction_disabled(): def test_dataframeeditor_refresh(): """ - Test that after pressing the refresh button, the value of the Array Editor - is replaced by the return value of the data_function. + Test that after pressing the refresh button, the value of the editor is + replaced by the return value of the data_function. """ df_zero = DataFrame([[0]]) df_new = DataFrame([[0, 10], [1, 20], [2, 40]]) diff --git a/spyder/utils/qthelpers.py b/spyder/utils/qthelpers.py index 0d5e10c33f9..37834846b93 100644 --- a/spyder/utils/qthelpers.py +++ b/spyder/utils/qthelpers.py @@ -887,7 +887,7 @@ def handle_applicationStateChanged(state): def safe_disconnect(signal): - """Disconnect a QtSignal, ignoring TypeError""" + """Disconnect a Qt signal, ignoring TypeError.""" try: signal.disconnect() except TypeError: diff --git a/spyder/widgets/collectionseditor.py b/spyder/widgets/collectionseditor.py index 8f9f7d41e94..ffe1b78d5bd 100644 --- a/spyder/widgets/collectionseditor.py +++ b/spyder/widgets/collectionseditor.py @@ -1335,7 +1335,8 @@ def __init__(self, parent, data, namespacebrowser=None, self.model = self.source_model self.setModel(self.source_model) self.delegate = CollectionsDelegate( - self, namespacebrowser, data_function) + self, namespacebrowser, data_function + ) self.setItemDelegate(self.delegate) self.setup_table() @@ -1458,7 +1459,8 @@ def __init__(self, parent, data, namespacebrowser=None, self, data, readonly) else: self.editor = CollectionsEditorTableView( - self, data, namespacebrowser, data_function, readonly, title) + self, data, namespacebrowser, data_function, readonly, title + ) toolbar = SpyderToolbar(parent=None, title='Editor toolbar') toolbar.setStyleSheet(str(PANES_TOOLBAR_STYLESHEET)) @@ -1472,7 +1474,8 @@ def __init__(self, parent, data, namespacebrowser=None, text=_('Refresh'), icon=ima.icon('refresh'), tip=_('Refresh editor with current value of variable in console'), - triggered=lambda: self.sig_refresh_requested.emit()) + triggered=lambda: self.sig_refresh_requested.emit() + ) toolbar.addAction(self.refresh_action) # Update the toolbar actions state @@ -1540,7 +1543,8 @@ def setup(self, data, title='', readonly=False, remote=False, self.widget = CollectionsEditorWidget( self, self.data_copy, self.namespacebrowser, self.data_function, - title=title, readonly=readonly, remote=remote) + title=title, readonly=readonly, remote=remote + ) self.widget.sig_refresh_requested.connect(self.refresh_editor) self.widget.editor.source_model.sig_setting_data.connect( self.save_and_close_enable) @@ -1605,8 +1609,11 @@ def refresh_editor(self) -> None: try: new_value = self.data_function() except (IndexError, KeyError): - QMessageBox.critical(self, _('Collection editor'), - _('The variable no longer exists.')) + QMessageBox.critical( + self, + _('Collection editor'), + _('The variable no longer exists.') + ) self.reject() return @@ -1628,7 +1635,10 @@ def ask_for_refresh_confirmation(self) -> bool: message = _('Refreshing the editor will overwrite the changes that ' 'you made. Do you want to proceed?') result = QMessageBox.question( - self, _('Refresh collection editor?'), message) + self, + _('Refresh collections editor?'), + message + ) return result == QMessageBox.Yes @@ -1653,8 +1663,10 @@ def set_value(self, index, value): name = source_index.model().keys[source_index.row()] self.parent().new_value(name, value) - def make_data_function(self, index: QModelIndex - ) -> Optional[Callable[[], Any]]: + def make_data_function( + self, + index: QModelIndex + ) -> Optional[Callable[[], Any]]: """ Construct function which returns current value of data. diff --git a/spyder/widgets/tests/test_collectioneditor.py b/spyder/widgets/tests/test_collectioneditor.py index 3aceb685c49..18d8defa30a 100644 --- a/spyder/widgets/tests/test_collectioneditor.py +++ b/spyder/widgets/tests/test_collectioneditor.py @@ -267,7 +267,8 @@ def test_filter_rows(qtbot): def test_remote_make_data_function(): """ - Test that the function returned by make_data_function() ... + Test that the function returned by make_data_function() is the expected + one. """ variables = {'a': {'type': 'int', 'size': 1, @@ -276,7 +277,8 @@ def test_remote_make_data_function(): 'numpy_type': 'Unknown'}} mock_shellwidget = Mock() editor = RemoteCollectionsEditorTableView( - None, variables, mock_shellwidget) + None, variables, mock_shellwidget + ) index = editor.model.index(0, 0) data_function = editor.delegate.make_data_function(index) value = data_function() @@ -508,8 +510,8 @@ def test_collectioneditorwidget_refresh_action_disabled(): def test_collectioneditor_refresh(): """ - Test that after pressing the refresh button, the value of the Array Editor - is replaced by the return value of the data_function. + Test that after pressing the refresh button, the value of the editor is + replaced by the return value of the data_function. """ old_list = [1, 2, 3, 4] new_list = [3, 1, 4, 1, 5] @@ -524,7 +526,7 @@ def test_collectioneditor_refresh(): @pytest.mark.parametrize('result', [QMessageBox.Yes, QMessageBox.No]) def test_collectioneditor_refresh_after_edit(result): """ - Test that after changing a value in the collection editor, refreshing the + Test that after changing a value in the collections editor, refreshing the editor opens a dialog box (which asks for confirmation), and that the editor is only refreshed if the user clicks Yes. """ @@ -551,15 +553,15 @@ def test_collectioneditor_refresh_when_variable_deleted(qtbot): """ Test that if the variable is deleted and then the editor is refreshed (resulting in data_function raising a KeyError), a critical dialog box - is displayed and that the array editor is closed. + is displayed and that the editor is closed. """ def datafunc(): raise KeyError lst = [1, 2, 3, 4] editor = CollectionsEditor(None, data_function=datafunc) editor.setup(lst) - with patch('spyder.plugins.variableexplorer.widgets.arrayeditor' - '.QMessageBox.critical') as mock_critical, \ + with patch('spyder.widgets.collectionseditor.QMessageBox' + '.critical') as mock_critical, \ qtbot.waitSignal(editor.rejected, timeout=0): editor.widget.refresh_action.trigger() mock_critical.assert_called_once()