# -*- coding: utf-8 -*- # ----------------------------------------------------------------------------- # Copyright © Spyder Project Contributors # # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) # ---------------------------------------------------------------------------- """ Collections (i.e. dictionary, list, set and tuple) editor widget and dialog. """ #TODO: Multiple selection: open as many editors (array/dict/...) as necessary, # at the same time # pylint: disable=C0103 # pylint: disable=R0903 # pylint: disable=R0911 # pylint: disable=R0201 # Standard library imports from __future__ import print_function import datetime import re import sys import warnings # Third party imports from qtpy.compat import getsavefilename, to_qvariant from qtpy.QtCore import (QAbstractTableModel, QModelIndex, Qt, Signal, Slot) from qtpy.QtGui import QColor, QKeySequence from qtpy.QtWidgets import (QAbstractItemView, QApplication, QDialog, QHBoxLayout, QHeaderView, QInputDialog, QLineEdit, QMenu, QMessageBox, QPushButton, QTableView, QVBoxLayout, QWidget) from spyder_kernels.utils.lazymodules import ( FakeObject, numpy as np, pandas as pd, PIL) from spyder_kernels.utils.misc import fix_reference_name from spyder_kernels.utils.nsview import ( display_to_value, get_human_readable_type, get_numeric_numpy_types, get_numpy_type_string, get_object_attrs, get_size, get_type_string, sort_against, try_to_eval, unsorted_unique, value_to_display ) # Local imports from spyder.api.config.mixins import SpyderConfigurationAccessor from spyder.config.base import _ from spyder.config.fonts import DEFAULT_SMALL_DELTA from spyder.config.gui import get_font from spyder.py3compat import (io, is_binary_string, PY3, to_text_string, is_type_text_string, NUMERIC_TYPES) from spyder.utils.icon_manager import ima from spyder.utils.misc import getcwd_or_home from spyder.utils.qthelpers import add_actions, create_action, mimedata2url from spyder.utils.stringmatching import get_search_scores, get_search_regex from spyder.plugins.variableexplorer.widgets.collectionsdelegate import ( CollectionsDelegate) from spyder.plugins.variableexplorer.widgets.importwizard import ImportWizard from spyder.widgets.helperwidgets import CustomSortFilterProxy from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog from spyder.utils.palette import SpyderPalette # Maximum length of a serialized variable to be set in the kernel MAX_SERIALIZED_LENGHT = 1e6 LARGE_NROWS = 100 ROWS_TO_LOAD = 50 def natsort(s): """ Natural sorting, e.g. test3 comes before test100. Taken from https://stackoverflow.com/a/16090640/3110740 """ if not isinstance(s, (str, bytes)): return s x = [int(t) if t.isdigit() else t.lower() for t in re.split('([0-9]+)', s)] return x class ProxyObject(object): """Dictionary proxy to an unknown object.""" def __init__(self, obj): """Constructor.""" self.__obj__ = obj def __len__(self): """Get len according to detected attributes.""" return len(get_object_attrs(self.__obj__)) def __getitem__(self, key): """Get the attribute corresponding to the given key.""" # Catch NotImplementedError to fix spyder-ide/spyder#6284 in pandas # MultiIndex due to NA checking not being supported on a multiindex. # Catch AttributeError to fix spyder-ide/spyder#5642 in certain special # classes like xml when this method is called on certain attributes. # Catch TypeError to prevent fatal Python crash to desktop after # modifying certain pandas objects. Fix spyder-ide/spyder#6727. # Catch ValueError to allow viewing and editing of pandas offsets. # Fix spyder-ide/spyder#6728- try: attribute_toreturn = getattr(self.__obj__, key) except (NotImplementedError, AttributeError, TypeError, ValueError): attribute_toreturn = None return attribute_toreturn def __setitem__(self, key, value): """Set attribute corresponding to key with value.""" # Catch AttributeError to gracefully handle inability to set an # attribute due to it not being writeable or set-table. # Fix spyder-ide/spyder#6728. # Also, catch NotImplementedError for safety. try: setattr(self.__obj__, key, value) except (TypeError, AttributeError, NotImplementedError): pass except Exception as e: if "cannot set values for" not in str(e): raise class ReadOnlyCollectionsModel(QAbstractTableModel): """CollectionsEditor Read-Only Table Model""" sig_setting_data = Signal() def __init__(self, parent, data, title="", names=False, minmax=False, remote=False): QAbstractTableModel.__init__(self, parent) if data is None: data = {} self._parent = parent self.scores = [] self.names = names self.minmax = minmax self.remote = remote self.header0 = None self._data = None self.total_rows = None self.showndata = None self.keys = None self.title = to_text_string(title) # in case title is not a string if self.title: self.title = self.title + ' - ' self.sizes = [] self.types = [] self.set_data(data) def get_data(self): """Return model data""" return self._data def set_data(self, data, coll_filter=None): """Set model data""" self._data = data if (coll_filter is not None and not self.remote and isinstance(data, (tuple, list, dict, set))): data = coll_filter(data) self.showndata = data self.header0 = _("Index") if self.names: self.header0 = _("Name") if isinstance(data, tuple): self.keys = list(range(len(data))) self.title += _("Tuple") elif isinstance(data, list): self.keys = list(range(len(data))) self.title += _("List") elif isinstance(data, set): self.keys = list(range(len(data))) self.title += _("Set") self._data = list(data) elif isinstance(data, dict): try: self.keys = sorted(list(data.keys()), key=natsort) except TypeError: # This is necessary to display dictionaries with mixed # types as keys. # Fixes spyder-ide/spyder#13481 self.keys = list(data.keys()) self.title += _("Dictionary") if not self.names: self.header0 = _("Key") else: self.keys = get_object_attrs(data) self._data = data = self.showndata = ProxyObject(data) if not self.names: self.header0 = _("Attribute") if not isinstance(self._data, ProxyObject): if len(self.keys) > 1: elements = _("elements") else: elements = _("element") self.title += (' (' + str(len(self.keys)) + ' ' + elements + ')') else: data_type = get_type_string(data) self.title += data_type self.total_rows = len(self.keys) if self.total_rows > LARGE_NROWS: self.rows_loaded = ROWS_TO_LOAD else: self.rows_loaded = self.total_rows self.sig_setting_data.emit() self.set_size_and_type() if len(self.keys): # Needed to update search scores when # adding values to the namespace self.update_search_letters() self.reset() def set_size_and_type(self, start=None, stop=None): data = self._data if start is None and stop is None: start = 0 stop = self.rows_loaded fetch_more = False else: fetch_more = True # Ignore pandas warnings that certain attributes are deprecated # and will be removed, since they will only be accessed if they exist. with warnings.catch_warnings(): warnings.filterwarnings( "ignore", message=(r"^\w+\.\w+ is deprecated and " "will be removed in a future version")) if self.remote: sizes = [data[self.keys[index]]['size'] for index in range(start, stop)] types = [data[self.keys[index]]['type'] for index in range(start, stop)] else: sizes = [get_size(data[self.keys[index]]) for index in range(start, stop)] types = [get_human_readable_type(data[self.keys[index]]) for index in range(start, stop)] if fetch_more: self.sizes = self.sizes + sizes self.types = self.types + types else: self.sizes = sizes self.types = types def load_all(self): """Load all the data.""" self.fetchMore(number_to_fetch=self.total_rows) def sort(self, column, order=Qt.AscendingOrder): """Overriding sort method""" def all_string(listlike): return all([isinstance(x, str) for x in listlike]) reverse = (order == Qt.DescendingOrder) sort_key = natsort if all_string(self.keys) else None if column == 0: self.sizes = sort_against(self.sizes, self.keys, reverse=reverse, sort_key=natsort) self.types = sort_against(self.types, self.keys, reverse=reverse, sort_key=natsort) try: self.keys.sort(reverse=reverse, key=sort_key) except: pass elif column == 1: self.keys[:self.rows_loaded] = sort_against(self.keys, self.types, reverse=reverse) self.sizes = sort_against(self.sizes, self.types, reverse=reverse) try: self.types.sort(reverse=reverse) except: pass elif column == 2: self.keys[:self.rows_loaded] = sort_against(self.keys, self.sizes, reverse=reverse) self.types = sort_against(self.types, self.sizes, reverse=reverse) try: self.sizes.sort(reverse=reverse) except: pass elif column in [3, 4]: values = [self._data[key] for key in self.keys] self.keys = sort_against(self.keys, values, reverse=reverse) self.sizes = sort_against(self.sizes, values, reverse=reverse) self.types = sort_against(self.types, values, reverse=reverse) self.beginResetModel() self.endResetModel() def columnCount(self, qindex=QModelIndex()): """Array column number""" if self._parent.proxy_model: return 5 else: return 4 def rowCount(self, index=QModelIndex()): """Array row number""" if self.total_rows <= self.rows_loaded: return self.total_rows else: return self.rows_loaded def canFetchMore(self, index=QModelIndex()): if self.total_rows > self.rows_loaded: return True else: return False def fetchMore(self, index=QModelIndex(), number_to_fetch=None): reminder = self.total_rows - self.rows_loaded if number_to_fetch is not None: items_to_fetch = min(reminder, number_to_fetch) else: items_to_fetch = min(reminder, ROWS_TO_LOAD) self.set_size_and_type(self.rows_loaded, self.rows_loaded + items_to_fetch) self.beginInsertRows(QModelIndex(), self.rows_loaded, self.rows_loaded + items_to_fetch - 1) self.rows_loaded += items_to_fetch self.endInsertRows() def get_index_from_key(self, key): try: return self.createIndex(self.keys.index(key), 0) except (RuntimeError, ValueError): return QModelIndex() def get_key(self, index): """Return current key""" return self.keys[index.row()] def get_value(self, index): """Return current value""" if index.column() == 0: return self.keys[ index.row() ] elif index.column() == 1: return self.types[ index.row() ] elif index.column() == 2: return self.sizes[ index.row() ] else: return self._data[ self.keys[index.row()] ] def get_bgcolor(self, index): """Background color depending on value""" if index.column() == 0: color = QColor(Qt.lightGray) color.setAlphaF(.05) elif index.column() < 3: color = QColor(Qt.lightGray) color.setAlphaF(.2) else: color = QColor(Qt.lightGray) color.setAlphaF(.3) return color def update_search_letters(self, text=""): """Update search letters with text input in search box.""" self.letters = text names = [str(key) for key in self.keys] results = get_search_scores(text, names, template='{0}') if results: self.normal_text, _, self.scores = zip(*results) self.reset() def row_key(self, row_num): """ Get row name based on model index. Needed for the custom proxy model. """ return self.keys[row_num] def row_type(self, row_num): """ Get row type based on model index. Needed for the custom proxy model. """ return self.types[row_num] def data(self, index, role=Qt.DisplayRole): """Cell content""" if not index.isValid(): return to_qvariant() value = self.get_value(index) if index.column() == 4 and role == Qt.DisplayRole: # TODO: Check the effect of not hiding the column # Treating search scores as a table column simplifies the # sorting once a score for a specific string in the finder # has been defined. This column however should always remain # hidden. return to_qvariant(self.scores[index.row()]) if index.column() == 3 and self.remote: value = value['view'] if index.column() == 3: display = value_to_display(value, minmax=self.minmax) else: if is_type_text_string(value): display = to_text_string(value, encoding="utf-8") elif not isinstance( value, NUMERIC_TYPES + get_numeric_numpy_types() ): display = to_text_string(value) else: display = value if role == Qt.UserRole: if isinstance(value, NUMERIC_TYPES + get_numeric_numpy_types()): return to_qvariant(value) else: return to_qvariant(display) elif role == Qt.DisplayRole: return to_qvariant(display) elif role == Qt.EditRole: return to_qvariant(value_to_display(value)) elif role == Qt.TextAlignmentRole: if index.column() == 3: if len(display.splitlines()) < 3: return to_qvariant(int(Qt.AlignLeft|Qt.AlignVCenter)) else: return to_qvariant(int(Qt.AlignLeft|Qt.AlignTop)) else: return to_qvariant(int(Qt.AlignLeft|Qt.AlignVCenter)) elif role == Qt.BackgroundColorRole: return to_qvariant( self.get_bgcolor(index) ) elif role == Qt.FontRole: return to_qvariant(get_font(font_size_delta=DEFAULT_SMALL_DELTA)) return to_qvariant() def headerData(self, section, orientation, role=Qt.DisplayRole): """Overriding method headerData""" if role != Qt.DisplayRole: return to_qvariant() i_column = int(section) if orientation == Qt.Horizontal: headers = (self.header0, _("Type"), _("Size"), _("Value"), _("Score")) return to_qvariant( headers[i_column] ) else: return to_qvariant() def flags(self, index): """Overriding method flags""" # This method was implemented in CollectionsModel only, but to enable # tuple exploration (even without editing), this method was moved here if not index.isValid(): return Qt.ItemIsEnabled return Qt.ItemFlags(int(QAbstractTableModel.flags(self, index) | Qt.ItemIsEditable)) def reset(self): self.beginResetModel() self.endResetModel() class CollectionsModel(ReadOnlyCollectionsModel): """Collections Table Model""" def set_value(self, index, value): """Set value""" self._data[ self.keys[index.row()] ] = value self.showndata[ self.keys[index.row()] ] = value self.sizes[index.row()] = get_size(value) self.types[index.row()] = get_human_readable_type(value) self.sig_setting_data.emit() def type_to_color(self, python_type, numpy_type): """Get the color that corresponds to a Python type.""" # Color for unknown types color = SpyderPalette.GROUP_12 if numpy_type != 'Unknown': if numpy_type == 'Array': color = SpyderPalette.GROUP_9 elif numpy_type == 'Scalar': color = SpyderPalette.GROUP_2 elif python_type == 'bool': color = SpyderPalette.GROUP_1 elif python_type in ['int', 'float', 'complex']: color = SpyderPalette.GROUP_2 elif python_type in ['str', 'unicode']: color = SpyderPalette.GROUP_3 elif 'datetime' in python_type: color = SpyderPalette.GROUP_4 elif python_type == 'list': color = SpyderPalette.GROUP_5 elif python_type == 'set': color = SpyderPalette.GROUP_6 elif python_type == 'tuple': color = SpyderPalette.GROUP_7 elif python_type == 'dict': color = SpyderPalette.GROUP_8 elif python_type in ['MaskedArray', 'Matrix', 'NDArray']: color = SpyderPalette.GROUP_9 elif (python_type in ['DataFrame', 'Series'] or 'Index' in python_type): color = SpyderPalette.GROUP_10 elif python_type == 'PIL.Image.Image': color = SpyderPalette.GROUP_11 else: color = SpyderPalette.GROUP_12 return color def get_bgcolor(self, index): """Background color depending on value.""" value = self.get_value(index) if index.column() < 3: color = ReadOnlyCollectionsModel.get_bgcolor(self, index) else: if self.remote: python_type = value['python_type'] numpy_type = value['numpy_type'] else: python_type = get_type_string(value) numpy_type = get_numpy_type_string(value) color_name = self.type_to_color(python_type, numpy_type) color = QColor(color_name) color.setAlphaF(0.5) return color def setData(self, index, value, role=Qt.EditRole): """Cell content change""" if not index.isValid(): return False if index.column() < 3: return False value = display_to_value(value, self.get_value(index), ignore_errors=True) self.set_value(index, value) self.dataChanged.emit(index, index) return True class BaseHeaderView(QHeaderView): """ A header view for the BaseTableView that emits a signal when the width of one of its sections is resized by the user. """ sig_user_resized_section = Signal(int, int, int) def __init__(self, parent=None): super(BaseHeaderView, self).__init__(Qt.Horizontal, parent) self._handle_section_is_pressed = False self.sectionResized.connect(self.sectionResizeEvent) # Needed to enable sorting by column # See spyder-ide/spyder#9835 self.setSectionsClickable(True) def mousePressEvent(self, e): super(BaseHeaderView, self).mousePressEvent(e) self._handle_section_is_pressed = (self.cursor().shape() == Qt.SplitHCursor) def mouseReleaseEvent(self, e): super(BaseHeaderView, self).mouseReleaseEvent(e) self._handle_section_is_pressed = False def sectionResizeEvent(self, logicalIndex, oldSize, newSize): if self._handle_section_is_pressed: self.sig_user_resized_section.emit(logicalIndex, oldSize, newSize) class BaseTableView(QTableView, SpyderConfigurationAccessor): """Base collection editor table view""" CONF_SECTION = 'variable_explorer' sig_files_dropped = Signal(list) redirect_stdio = Signal(bool) sig_free_memory_requested = Signal() sig_editor_creation_started = Signal() sig_editor_shown = Signal() def __init__(self, parent): super().__init__(parent=parent) self.array_filename = None self.menu = None self.empty_ws_menu = None self.paste_action = None self.copy_action = None self.edit_action = None self.plot_action = None self.hist_action = None self.imshow_action = None self.save_array_action = None self.insert_action = None self.insert_action_above = None self.insert_action_below = None self.remove_action = None self.minmax_action = None self.rename_action = None self.duplicate_action = None self.last_regex = '' self.view_action = None self.delegate = None self.proxy_model = None self.source_model = None self.setAcceptDrops(True) self.automatic_column_width = True self.setHorizontalHeader(BaseHeaderView(parent=self)) self.horizontalHeader().sig_user_resized_section.connect( self.user_resize_columns) def setup_table(self): """Setup table""" self.horizontalHeader().setStretchLastSection(True) self.adjust_columns() # Sorting columns self.setSortingEnabled(True) self.sortByColumn(0, Qt.AscendingOrder) def setup_menu(self): """Setup context menu""" resize_action = create_action(self, _("Resize rows to contents"), triggered=self.resizeRowsToContents) resize_columns_action = create_action( self, _("Resize columns to contents"), triggered=self.resize_column_contents) self.paste_action = create_action(self, _("Paste"), icon=ima.icon('editpaste'), triggered=self.paste) self.copy_action = create_action(self, _("Copy"), icon=ima.icon('editcopy'), triggered=self.copy) self.edit_action = create_action(self, _("Edit"), icon=ima.icon('edit'), triggered=self.edit_item) self.plot_action = create_action(self, _("Plot"), icon=ima.icon('plot'), triggered=lambda: self.plot_item('plot')) self.plot_action.setVisible(False) self.hist_action = create_action(self, _("Histogram"), icon=ima.icon('hist'), triggered=lambda: self.plot_item('hist')) self.hist_action.setVisible(False) self.imshow_action = create_action(self, _("Show image"), icon=ima.icon('imshow'), triggered=self.imshow_item) self.imshow_action.setVisible(False) self.save_array_action = create_action(self, _("Save array"), icon=ima.icon('filesave'), triggered=self.save_array) self.save_array_action.setVisible(False) self.insert_action = create_action( self, _("Insert"), icon=ima.icon('insert'), triggered=lambda: self.insert_item(below=False) ) self.insert_action_above = create_action( self, _("Insert above"), icon=ima.icon('insert'), triggered=lambda: self.insert_item(below=False) ) self.insert_action_below = create_action( self, _("Insert below"), icon=ima.icon('insert'), triggered=lambda: self.insert_item(below=True) ) self.remove_action = create_action(self, _("Remove"), icon=ima.icon('editdelete'), triggered=self.remove_item) self.rename_action = create_action(self, _("Rename"), icon=ima.icon('rename'), triggered=self.rename_item) self.duplicate_action = create_action(self, _("Duplicate"), icon=ima.icon('edit_add'), triggered=self.duplicate_item) self.view_action = create_action( self, _("View with the Object Explorer"), icon=ima.icon('outline_explorer'), triggered=self.view_item) menu = QMenu(self) menu_actions = [self.edit_action, self.plot_action, self.hist_action, self.imshow_action, self.save_array_action, self.insert_action, self.insert_action_above, self.insert_action_below, self.remove_action, self.copy_action, self.paste_action, self.view_action, None, self.rename_action, self.duplicate_action, None, resize_action, resize_columns_action] add_actions(menu, menu_actions) self.empty_ws_menu = QMenu(self) add_actions( self.empty_ws_menu, [self.insert_action, self.paste_action] ) return menu # ------ Remote/local API ------------------------------------------------- def remove_values(self, keys): """Remove values from data""" raise NotImplementedError def copy_value(self, orig_key, new_key): """Copy value""" raise NotImplementedError def new_value(self, key, value): """Create new value in data""" raise NotImplementedError def is_list(self, key): """Return True if variable is a list, a set or a tuple""" raise NotImplementedError def get_len(self, key): """Return sequence length""" raise NotImplementedError def is_array(self, key): """Return True if variable is a numpy array""" raise NotImplementedError def is_image(self, key): """Return True if variable is a PIL.Image image""" raise NotImplementedError def is_dict(self, key): """Return True if variable is a dictionary""" raise NotImplementedError def get_array_shape(self, key): """Return array's shape""" raise NotImplementedError def get_array_ndim(self, key): """Return array's ndim""" raise NotImplementedError def oedit(self, key): """Edit item""" raise NotImplementedError def plot(self, key, funcname): """Plot item""" raise NotImplementedError def imshow(self, key): """Show item's image""" raise NotImplementedError def show_image(self, key): """Show image (item is a PIL image)""" raise NotImplementedError #-------------------------------------------------------------------------- def refresh_menu(self): """Refresh context menu""" index = self.currentIndex() condition = index.isValid() self.edit_action.setEnabled(condition) self.remove_action.setEnabled(condition) self.refresh_plot_entries(index) def refresh_plot_entries(self, index): if index.isValid(): if self.proxy_model: key = self.proxy_model.get_key(index) else: key = self.source_model.get_key(index) is_list = self.is_list(key) is_array = self.is_array(key) and self.get_len(key) != 0 condition_plot = (is_array and len(self.get_array_shape(key)) <= 2) condition_hist = (is_array and self.get_array_ndim(key) == 1) condition_imshow = condition_plot and self.get_array_ndim(key) == 2 condition_imshow = condition_imshow or self.is_image(key) else: is_array = condition_plot = condition_imshow = is_list \ = condition_hist = False is_list_instance = isinstance(self.source_model.get_data(), list) self.plot_action.setVisible(condition_plot or is_list) self.hist_action.setVisible(condition_hist or is_list) self.insert_action.setVisible(not is_list_instance) self.insert_action_above.setVisible(is_list_instance) self.insert_action_below.setVisible(is_list_instance) self.imshow_action.setVisible(condition_imshow) self.save_array_action.setVisible(is_array) def resize_column_contents(self): """Resize columns to contents.""" self.automatic_column_width = True self.adjust_columns() def user_resize_columns(self, logical_index, old_size, new_size): """Handle the user resize action.""" self.automatic_column_width = False def adjust_columns(self): """Resize two first columns to contents""" if self.automatic_column_width: for col in range(3): self.resizeColumnToContents(col) def set_data(self, data): """Set table data""" if data is not None: self.source_model.set_data(data, self.dictfilter) self.source_model.reset() self.sortByColumn(0, Qt.AscendingOrder) def mousePressEvent(self, event): """Reimplement Qt method""" if event.button() != Qt.LeftButton: QTableView.mousePressEvent(self, event) return index_clicked = self.indexAt(event.pos()) if index_clicked.isValid(): if index_clicked == self.currentIndex() \ and index_clicked in self.selectedIndexes(): self.clearSelection() else: QTableView.mousePressEvent(self, event) else: self.clearSelection() event.accept() def mouseDoubleClickEvent(self, event): """Reimplement Qt method""" index_clicked = self.indexAt(event.pos()) if index_clicked.isValid(): row = index_clicked.row() # TODO: Remove hard coded "Value" column number (3 here) index_clicked = index_clicked.child(row, 3) self.edit(index_clicked) else: event.accept() def keyPressEvent(self, event): """Reimplement Qt methods""" if event.key() == Qt.Key_Delete: self.remove_item() elif event.key() == Qt.Key_F2: self.rename_item() elif event == QKeySequence.Copy: self.copy() elif event == QKeySequence.Paste: self.paste() else: QTableView.keyPressEvent(self, event) def contextMenuEvent(self, event): """Reimplement Qt method""" if self.source_model.showndata: self.refresh_menu() self.menu.popup(event.globalPos()) event.accept() else: self.empty_ws_menu.popup(event.globalPos()) event.accept() def dragEnterEvent(self, event): """Allow user to drag files""" if mimedata2url(event.mimeData()): event.accept() else: event.ignore() def dragMoveEvent(self, event): """Allow user to move files""" if mimedata2url(event.mimeData()): event.setDropAction(Qt.CopyAction) event.accept() else: event.ignore() def dropEvent(self, event): """Allow user to drop supported files""" urls = mimedata2url(event.mimeData()) if urls: event.setDropAction(Qt.CopyAction) event.accept() self.sig_files_dropped.emit(urls) else: event.ignore() @Slot() def edit_item(self): """Edit item""" index = self.currentIndex() if not index.isValid(): return # TODO: Remove hard coded "Value" column number (3 here) self.edit(index.child(index.row(), 3)) @Slot() def remove_item(self, force=False): """Remove item""" indexes = self.selectedIndexes() if not indexes: return for index in indexes: if not index.isValid(): return if not force: one = _("Do you want to remove the selected item?") more = _("Do you want to remove all selected items?") answer = QMessageBox.question(self, _("Remove"), one if len(indexes) == 1 else more, QMessageBox.Yes | QMessageBox.No) if force or answer == QMessageBox.Yes: if self.proxy_model: idx_rows = unsorted_unique( [self.proxy_model.mapToSource(idx).row() for idx in indexes]) else: idx_rows = unsorted_unique([idx.row() for idx in indexes]) keys = [self.source_model.keys[idx_row] for idx_row in idx_rows] self.remove_values(keys) def copy_item(self, erase_original=False, new_name=None): """Copy item""" indexes = self.selectedIndexes() if not indexes: return if self.proxy_model: idx_rows = unsorted_unique( [self.proxy_model.mapToSource(idx).row() for idx in indexes]) else: idx_rows = unsorted_unique([idx.row() for idx in indexes]) if len(idx_rows) > 1 or not indexes[0].isValid(): return orig_key = self.source_model.keys[idx_rows[0]] if erase_original: title = _('Rename') field_text = _('New variable name:') else: title = _('Duplicate') field_text = _('Variable name:') data = self.source_model.get_data() if isinstance(data, (list, set)): new_key, valid = len(data), True elif new_name is not None: new_key, valid = new_name, True else: new_key, valid = QInputDialog.getText(self, title, field_text, QLineEdit.Normal, orig_key) if valid and to_text_string(new_key): new_key = try_to_eval(to_text_string(new_key)) if new_key == orig_key: return self.copy_value(orig_key, new_key) if erase_original: self.remove_values([orig_key]) @Slot() def duplicate_item(self): """Duplicate item""" self.copy_item() @Slot() def rename_item(self, new_name=None): """Rename item""" self.copy_item(erase_original=True, new_name=new_name) @Slot() def insert_item(self, below=True): """Insert item""" index = self.currentIndex() if not index.isValid(): row = self.source_model.rowCount() else: if self.proxy_model: if below: row = self.proxy_model.mapToSource(index).row() + 1 else: row = self.proxy_model.mapToSource(index).row() else: if below: row = index.row() + 1 else: row = index.row() data = self.source_model.get_data() if isinstance(data, list): key = row data.insert(row, '') elif isinstance(data, dict): key, valid = QInputDialog.getText(self, _( 'Insert'), _( 'Key:'), QLineEdit.Normal) if valid and to_text_string(key): key = try_to_eval(to_text_string(key)) else: return else: return value, valid = QInputDialog.getText(self, _('Insert'), _('Value:'), QLineEdit.Normal) if valid and to_text_string(value): self.new_value(key, try_to_eval(to_text_string(value))) @Slot() def view_item(self): """View item with the Object Explorer""" index = self.currentIndex() if not index.isValid(): return # TODO: Remove hard coded "Value" column number (3 here) index = index.child(index.row(), 3) self.delegate.createEditor(self, None, index, object_explorer=True) def __prepare_plot(self): try: import guiqwt.pyplot #analysis:ignore return True except: try: if 'matplotlib' not in sys.modules: import matplotlib return True except Exception: QMessageBox.warning(self, _("Import error"), _("Please install matplotlib" " or guiqwt.")) def plot_item(self, funcname): """Plot item""" index = self.currentIndex() if self.__prepare_plot(): if self.proxy_model: key = self.source_model.get_key( self.proxy_model.mapToSource(index)) else: key = self.source_model.get_key(index) try: self.plot(key, funcname) except (ValueError, TypeError) as error: QMessageBox.critical(self, _( "Plot"), _("Unable to plot data." "

Error message:
%s" ) % str(error)) @Slot() def imshow_item(self): """Imshow item""" index = self.currentIndex() if self.__prepare_plot(): if self.proxy_model: key = self.source_model.get_key( self.proxy_model.mapToSource(index)) else: key = self.source_model.get_key(index) try: if self.is_image(key): self.show_image(key) else: self.imshow(key) except (ValueError, TypeError) as error: QMessageBox.critical(self, _( "Plot"), _("Unable to show image." "

Error message:
%s" ) % str(error)) @Slot() def save_array(self): """Save array""" title = _( "Save array") if self.array_filename is None: self.array_filename = getcwd_or_home() self.redirect_stdio.emit(False) filename, _selfilter = getsavefilename(self, title, self.array_filename, _("NumPy arrays")+" (*.npy)") self.redirect_stdio.emit(True) if filename: self.array_filename = filename data = self.delegate.get_value( self.currentIndex() ) try: import numpy as np np.save(self.array_filename, data) except Exception as error: QMessageBox.critical(self, title, _("Unable to save array" "

Error message:
%s" ) % str(error)) @Slot() def copy(self): """Copy text to clipboard""" clipboard = QApplication.clipboard() clipl = [] for idx in self.selectedIndexes(): if not idx.isValid(): continue obj = self.delegate.get_value(idx) # Check if we are trying to copy a numpy array, and if so make sure # to copy the whole thing in a tab separated format if (isinstance(obj, (np.ndarray, np.ma.MaskedArray)) and np.ndarray is not FakeObject): if PY3: output = io.BytesIO() else: output = io.StringIO() try: np.savetxt(output, obj, delimiter='\t') except Exception: QMessageBox.warning(self, _("Warning"), _("It was not possible to copy " "this array")) return obj = output.getvalue().decode('utf-8') output.close() elif (isinstance(obj, (pd.DataFrame, pd.Series)) and pd.DataFrame is not FakeObject): output = io.StringIO() try: obj.to_csv(output, sep='\t', index=True, header=True) except Exception: QMessageBox.warning(self, _("Warning"), _("It was not possible to copy " "this dataframe")) return if PY3: obj = output.getvalue() else: obj = output.getvalue().decode('utf-8') output.close() elif is_binary_string(obj): obj = to_text_string(obj, 'utf8') else: obj = to_text_string(obj) clipl.append(obj) clipboard.setText('\n'.join(clipl)) def import_from_string(self, text, title=None): """Import data from string""" data = self.source_model.get_data() # Check if data is a dict if not hasattr(data, "keys"): return editor = ImportWizard( self, text, title=title, contents_title=_("Clipboard contents"), varname=fix_reference_name("data", blacklist=list(data.keys()))) if editor.exec_(): var_name, clip_data = editor.get_data() self.new_value(var_name, clip_data) @Slot() def paste(self): """Import text/data/code from clipboard""" clipboard = QApplication.clipboard() cliptext = '' if clipboard.mimeData().hasText(): cliptext = to_text_string(clipboard.text()) if cliptext.strip(): self.import_from_string(cliptext, title=_("Import from clipboard")) else: QMessageBox.warning(self, _( "Empty clipboard"), _("Nothing to be imported from clipboard.")) class CollectionsEditorTableView(BaseTableView): """CollectionsEditor table view""" def __init__(self, parent, data, readonly=False, title="", names=False): BaseTableView.__init__(self, parent) self.dictfilter = None self.readonly = readonly or isinstance(data, (tuple, set)) CollectionsModelClass = (ReadOnlyCollectionsModel if self.readonly else CollectionsModel) self.source_model = CollectionsModelClass( self, data, title, names=names, minmax=self.get_conf('minmax') ) self.model = self.source_model self.setModel(self.source_model) self.delegate = CollectionsDelegate(self) self.setItemDelegate(self.delegate) self.setup_table() self.menu = self.setup_menu() if isinstance(data, set): self.horizontalHeader().hideSection(0) #------ Remote/local API -------------------------------------------------- def remove_values(self, keys): """Remove values from data""" data = self.source_model.get_data() for key in sorted(keys, reverse=True): data.pop(key) self.set_data(data) def copy_value(self, orig_key, new_key): """Copy value""" data = self.source_model.get_data() if isinstance(data, list): data.append(data[orig_key]) if isinstance(data, set): data.add(data[orig_key]) else: data[new_key] = data[orig_key] self.set_data(data) def new_value(self, key, value): """Create new value in data""" data = self.source_model.get_data() data[key] = value self.set_data(data) def is_list(self, key): """Return True if variable is a list or a tuple""" data = self.source_model.get_data() return isinstance(data[key], (tuple, list)) def is_set(self, key): """Return True if variable is a set""" data = self.source_model.get_data() return isinstance(data[key], set) def get_len(self, key): """Return sequence length""" data = self.source_model.get_data() return len(data[key]) def is_array(self, key): """Return True if variable is a numpy array""" data = self.source_model.get_data() return isinstance(data[key], (np.ndarray, np.ma.MaskedArray)) def is_image(self, key): """Return True if variable is a PIL.Image image""" data = self.source_model.get_data() return isinstance(data[key], PIL.Image.Image) def is_dict(self, key): """Return True if variable is a dictionary""" data = self.source_model.get_data() return isinstance(data[key], dict) def get_array_shape(self, key): """Return array's shape""" data = self.source_model.get_data() return data[key].shape def get_array_ndim(self, key): """Return array's ndim""" data = self.source_model.get_data() return data[key].ndim def oedit(self, key): """Edit item""" data = self.source_model.get_data() from spyder.plugins.variableexplorer.widgets.objecteditor import ( oedit) oedit(data[key]) def plot(self, key, funcname): """Plot item""" data = self.source_model.get_data() import spyder.pyplot as plt plt.figure() getattr(plt, funcname)(data[key]) plt.show() def imshow(self, key): """Show item's image""" data = self.source_model.get_data() import spyder.pyplot as plt plt.figure() plt.imshow(data[key]) plt.show() def show_image(self, key): """Show image (item is a PIL image)""" data = self.source_model.get_data() data[key].show() #-------------------------------------------------------------------------- def refresh_menu(self): """Refresh context menu""" data = self.source_model.get_data() index = self.currentIndex() condition = (not isinstance(data, (tuple, set))) and index.isValid() \ and not self.readonly self.edit_action.setEnabled( condition ) self.remove_action.setEnabled( condition ) self.insert_action.setEnabled(not self.readonly) self.insert_action_above.setEnabled(not self.readonly) self.insert_action_below.setEnabled(not self.readonly) self.duplicate_action.setEnabled(condition) condition_rename = not isinstance(data, (tuple, list, set)) self.rename_action.setEnabled(condition_rename) self.refresh_plot_entries(index) def set_filter(self, dictfilter=None): """Set table dict filter""" self.dictfilter = dictfilter class CollectionsEditorWidget(QWidget): """Dictionary Editor Widget""" def __init__(self, parent, data, readonly=False, title="", remote=False): QWidget.__init__(self, parent) if remote: self.editor = RemoteCollectionsEditorTableView(self, data, readonly) else: self.editor = CollectionsEditorTableView(self, data, readonly, title) layout = QVBoxLayout() layout.addWidget(self.editor) self.setLayout(layout) def set_data(self, data): """Set DictEditor data""" self.editor.set_data(data) def get_title(self): """Get model title""" return self.editor.source_model.title class CollectionsEditor(BaseDialog): """Collections Editor Dialog""" def __init__(self, parent=None): super().__init__(parent) # Destroying the C++ object right after closing the dialog box, # otherwise it may be garbage-collected in another QThread # (e.g. the editor's analysis thread in Spyder), thus leading to # a segmentation fault on UNIX or an application crash on Windows self.setAttribute(Qt.WA_DeleteOnClose) self.data_copy = None self.widget = None self.btn_save_and_close = None self.btn_close = None def setup(self, data, title='', readonly=False, remote=False, icon=None, parent=None): """Setup editor.""" if isinstance(data, (dict, set)): # dictionary, set self.data_copy = data.copy() datalen = len(data) elif isinstance(data, (tuple, list)): # list, tuple self.data_copy = data[:] datalen = len(data) else: # unknown object import copy try: self.data_copy = copy.deepcopy(data) except NotImplementedError: self.data_copy = copy.copy(data) except (TypeError, AttributeError): readonly = True self.data_copy = data datalen = len(get_object_attrs(data)) # If the copy has a different type, then do not allow editing, because # this would change the type after saving; cf. spyder-ide/spyder#6936. if type(self.data_copy) != type(data): readonly = True self.widget = CollectionsEditorWidget(self, self.data_copy, title=title, readonly=readonly, remote=remote) self.widget.editor.source_model.sig_setting_data.connect( self.save_and_close_enable) layout = QVBoxLayout() layout.addWidget(self.widget) self.setLayout(layout) # Buttons configuration btn_layout = QHBoxLayout() btn_layout.setContentsMargins(4, 4, 4, 4) 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) 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) layout.addLayout(btn_layout) self.setWindowTitle(self.widget.get_title()) if icon is None: self.setWindowIcon(ima.icon('dictedit')) if sys.platform == 'darwin': # See: https://github.com/spyder-ide/spyder/issues/9051 self.setWindowFlags(Qt.Tool) else: # Make the dialog act as a window self.setWindowFlags(Qt.Window) @Slot() def save_and_close_enable(self): """Handle the data change event to enable the save and close button.""" if self.btn_save_and_close: self.btn_save_and_close.setEnabled(True) self.btn_save_and_close.setAutoDefault(True) self.btn_save_and_close.setDefault(True) def get_value(self): """Return modified copy of dictionary or list""" # It is import to avoid accessing Qt C++ object as it has probably # already been destroyed, due to the Qt.WA_DeleteOnClose attribute return self.data_copy #============================================================================== # Remote versions of CollectionsDelegate and CollectionsEditorTableView #============================================================================== class RemoteCollectionsDelegate(CollectionsDelegate): """CollectionsEditor Item Delegate""" def __init__(self, parent=None): CollectionsDelegate.__init__(self, parent) def get_value(self, index): if index.isValid(): source_index = index.model().mapToSource(index) name = source_index.model().keys[source_index.row()] return self.parent().get_value(name) def set_value(self, index, value): if index.isValid(): source_index = index.model().mapToSource(index) name = source_index.model().keys[source_index.row()] self.parent().new_value(name, value) class RemoteCollectionsEditorTableView(BaseTableView): """DictEditor table view""" def __init__(self, parent, data, shellwidget=None, remote_editing=False, create_menu=False): BaseTableView.__init__(self, parent) self.shellwidget = shellwidget self.var_properties = {} self.dictfilter = None self.delegate = None self.readonly = False self.finder = None self.source_model = CollectionsModel( self, data, names=True, minmax=self.get_conf('minmax'), remote=True) self.horizontalHeader().sectionClicked.connect( self.source_model.load_all) self.proxy_model = CollectionsCustomSortFilterProxy(self) self.model = self.proxy_model self.proxy_model.setSourceModel(self.source_model) self.proxy_model.setDynamicSortFilter(True) self.proxy_model.setFilterKeyColumn(0) # Col 0 for Name self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive) self.proxy_model.setSortRole(Qt.UserRole) self.setModel(self.proxy_model) self.hideColumn(4) # Column 4 for Score self.delegate = RemoteCollectionsDelegate(self) self.delegate.sig_free_memory_requested.connect( self.sig_free_memory_requested) self.delegate.sig_editor_creation_started.connect( self.sig_editor_creation_started) self.delegate.sig_editor_shown.connect(self.sig_editor_shown) self.setItemDelegate(self.delegate) self.setup_table() if create_menu: self.menu = self.setup_menu() # ------ Remote/local API ------------------------------------------------- def get_value(self, name): """Get the value of a variable""" value = self.shellwidget.get_value(name) return value def new_value(self, name, value): """Create new value in data""" try: self.shellwidget.set_value(name, value) except TypeError as e: QMessageBox.critical(self, _("Error"), "TypeError: %s" % to_text_string(e)) self.shellwidget.refresh_namespacebrowser() def remove_values(self, names): """Remove values from data""" for name in names: self.shellwidget.remove_value(name) self.shellwidget.refresh_namespacebrowser() def copy_value(self, orig_name, new_name): """Copy value""" self.shellwidget.copy_value(orig_name, new_name) self.shellwidget.refresh_namespacebrowser() def is_list(self, name): """Return True if variable is a list, a tuple or a set""" return self.var_properties[name]['is_list'] def is_dict(self, name): """Return True if variable is a dictionary""" return self.var_properties[name]['is_dict'] def get_len(self, name): """Return sequence length""" return self.var_properties[name]['len'] def is_array(self, name): """Return True if variable is a NumPy array""" return self.var_properties[name]['is_array'] def is_image(self, name): """Return True if variable is a PIL.Image image""" return self.var_properties[name]['is_image'] def is_data_frame(self, name): """Return True if variable is a DataFrame""" return self.var_properties[name]['is_data_frame'] def is_series(self, name): """Return True if variable is a Series""" return self.var_properties[name]['is_series'] def get_array_shape(self, name): """Return array's shape""" return self.var_properties[name]['array_shape'] def get_array_ndim(self, name): """Return array's ndim""" return self.var_properties[name]['array_ndim'] def plot(self, name, funcname): """Plot item""" sw = self.shellwidget sw.execute("%%varexp --%s %s" % (funcname, name)) def imshow(self, name): """Show item's image""" sw = self.shellwidget sw.execute("%%varexp --imshow %s" % name) def show_image(self, name): """Show image (item is a PIL image)""" command = "%s.show()" % name sw = self.shellwidget sw.execute(command) # ------ Other ------------------------------------------------------------ def setup_menu(self): """Setup context menu.""" menu = BaseTableView.setup_menu(self) return menu def set_regex(self, regex=None, reset=False): """Update the regex text for the variable finder.""" if reset or self.finder is None or not self.finder.text(): text = '' else: text = self.finder.text().replace(' ', '').lower() self.proxy_model.set_filter(text) self.source_model.update_search_letters(text) if text: # TODO: Use constants for column numbers self.sortByColumn(4, Qt.DescendingOrder) # Col 4 for index self.last_regex = regex def next_row(self): """Move to next row from currently selected row.""" row = self.currentIndex().row() rows = self.proxy_model.rowCount() if row + 1 == rows: row = -1 self.selectRow(row + 1) def previous_row(self): """Move to previous row from currently selected row.""" row = self.currentIndex().row() rows = self.proxy_model.rowCount() if row == 0: row = rows self.selectRow(row - 1) class CollectionsCustomSortFilterProxy(CustomSortFilterProxy): """ Custom column filter based on regex and model data. Reimplements 'filterAcceptsRow' to follow NamespaceBrowser model. Reimplements 'set_filter' to allow sorting while filtering """ def get_key(self, index): """Return current key from source model.""" source_index = self.mapToSource(index) return self.sourceModel().get_key(source_index) def get_index_from_key(self, key): """Return index using key from source model.""" source_index = self.sourceModel().get_index_from_key(key) return self.mapFromSource(source_index) def get_value(self, index): """Return current value from source model.""" source_index = self.mapToSource(index) return self.sourceModel().get_value(source_index) def set_value(self, index, value): """Set value in source model.""" try: source_index = self.mapToSource(index) self.sourceModel().set_value(source_index, value) except AttributeError: # Read-only models don't have set_value method pass def set_filter(self, text): """Set regular expression for filter.""" self.pattern = get_search_regex(text) self.invalidateFilter() def filterAcceptsRow(self, row_num, parent): """ Qt override. Reimplemented from base class to allow the use of custom filtering using to columns (name and type). """ model = self.sourceModel() name = to_text_string(model.row_key(row_num)) variable_type = to_text_string(model.row_type(row_num)) r_name = re.search(self.pattern, name) r_type = re.search(self.pattern, variable_type) if r_name is None and r_type is None: return False else: return True def lessThan(self, left, right): """ Implements ordering in a natural way, as a human would sort. This functions enables sorting of the main variable editor table, which does not rely on 'self.sort()'. """ leftData = self.sourceModel().data(left) rightData = self.sourceModel().data(right) try: if isinstance(leftData, str) and isinstance(rightData, str): return natsort(leftData) < natsort(rightData) else: return leftData < rightData except TypeError: # This is needed so all the elements that cannot be compared such # as dataframes and numpy arrays are grouped together in the # variable explorer. For more info see spyder-ide/spyder#14527 return True # ============================================================================= # Tests # ============================================================================= def get_test_data(): """Create test data.""" image = PIL.Image.fromarray(np.random.randint(256, size=(100, 100)), mode='P') testdict = {'d': 1, 'a': np.random.rand(10, 10), 'b': [1, 2]} testdate = datetime.date(1945, 5, 8) test_timedelta = datetime.timedelta(days=-1, minutes=42, seconds=13) try: import pandas as pd except (ModuleNotFoundError, ImportError): test_df = None test_timestamp = test_pd_td = test_dtindex = test_series = None else: test_timestamp = pd.Timestamp("1945-05-08T23:01:00.12345") test_pd_td = pd.Timedelta(days=2193, hours=12) test_dtindex = pd.date_range(start="1939-09-01T", end="1939-10-06", freq="12H") test_series = pd.Series({"series_name": [0, 1, 2, 3, 4, 5]}) test_df = pd.DataFrame({"string_col": ["a", "b", "c", "d"], "int_col": [0, 1, 2, 3], "float_col": [1.1, 2.2, 3.3, 4.4], "bool_col": [True, False, False, True]}) class Foobar(object): def __init__(self): self.text = "toto" self.testdict = testdict self.testdate = testdate foobar = Foobar() return {'object': foobar, 'module': np, 'str': 'kjkj kj k j j kj k jkj', 'unicode': to_text_string('éù', 'utf-8'), 'list': [1, 3, [sorted, 5, 6], 'kjkj', None], 'set': {1, 2, 1, 3, None, 'A', 'B', 'C', True, False}, 'tuple': ([1, testdate, testdict, test_timedelta], 'kjkj', None), 'dict': testdict, 'float': 1.2233, 'int': 223, 'bool': True, 'array': np.random.rand(10, 10).astype(np.int64), 'masked_array': np.ma.array([[1, 0], [1, 0]], mask=[[True, False], [False, False]]), '1D-array': np.linspace(-10, 10).astype(np.float16), '3D-array': np.random.randint(2, size=(5, 5, 5)).astype(np.bool_), 'empty_array': np.array([]), 'image': image, 'date': testdate, 'datetime': datetime.datetime(1945, 5, 8, 23, 1, 0, int(1.5e5)), 'timedelta': test_timedelta, 'complex': 2+1j, 'complex64': np.complex64(2+1j), 'complex128': np.complex128(9j), 'int8_scalar': np.int8(8), 'int16_scalar': np.int16(16), 'int32_scalar': np.int32(32), 'int64_scalar': np.int64(64), 'float16_scalar': np.float16(16), 'float32_scalar': np.float32(32), 'float64_scalar': np.float64(64), 'bool_scalar': np.bool(8), 'bool__scalar': np.bool_(8), 'timestamp': test_timestamp, 'timedelta_pd': test_pd_td, 'datetimeindex': test_dtindex, 'series': test_series, 'ddataframe': test_df, 'None': None, 'unsupported1': np.arccos, 'unsupported2': np.cast, # Test for spyder-ide/spyder#3518. 'big_struct_array': np.zeros(1000, dtype=[('ID', 'f8'), ('param1', 'f8', 5000)]), } def editor_test(): """Test Collections editor.""" from spyder.utils.qthelpers import qapplication app = qapplication() #analysis:ignore dialog = CollectionsEditor() dialog.setup(get_test_data()) dialog.show() app.exec_() def remote_editor_test(): """Test remote collections editor.""" from spyder.utils.qthelpers import qapplication app = qapplication() from spyder.config.manager import CONF from spyder_kernels.utils.nsview import (make_remote_view, REMOTE_SETTINGS) settings = {} for name in REMOTE_SETTINGS: settings[name] = CONF.get('variable_explorer', name) remote = make_remote_view(get_test_data(), settings) dialog = CollectionsEditor() dialog.setup(remote, remote=True) dialog.show() app.exec_() if __name__ == "__main__": editor_test() remote_editor_test()