1# -*- coding: utf-8 -*-
2#
3# Copyright © Spyder Project Contributors
4# Licensed under the terms of the MIT License
5# (see spyder/__init__.py for details)
6
7"""
8Namespace browser widget
9
10This is the main widget used in the Variable Explorer plugin
11"""
12
13# Standard library imports
14import os.path as osp
15
16# Third library imports (qtpy)
17from qtpy.compat import getsavefilename, getopenfilenames
18from qtpy.QtCore import Qt, Signal, Slot
19from qtpy.QtGui import QCursor
20from qtpy.QtWidgets import (QApplication, QHBoxLayout, QInputDialog, QMenu,
21                            QMessageBox, QToolButton, QVBoxLayout, QWidget)
22
23# Third party imports (others)
24import cloudpickle
25
26# Local imports
27from spyder.config.base import _, get_supported_types
28from spyder.config.main import CONF
29from spyder.py3compat import is_text_string, to_text_string
30from spyder.utils import encoding
31from spyder.utils import icon_manager as ima
32from spyder.utils.iofuncs import iofunctions
33from spyder.utils.misc import fix_reference_name, getcwd_or_home
34from spyder.utils.programs import is_module_installed
35from spyder.utils.qthelpers import (add_actions, create_action,
36                                    create_toolbutton, create_plugin_layout)
37from spyder.widgets.variableexplorer.collectionseditor import (
38    RemoteCollectionsEditorTableView)
39from spyder.widgets.variableexplorer.importwizard import ImportWizard
40from spyder.widgets.variableexplorer.utils import REMOTE_SETTINGS
41
42
43SUPPORTED_TYPES = get_supported_types()
44
45# To be able to get and set variables between Python 2 and 3
46PICKLE_PROTOCOL = 2
47
48
49class NamespaceBrowser(QWidget):
50    """Namespace browser (global variables explorer widget)"""
51    sig_option_changed = Signal(str, object)
52    sig_collapse = Signal()
53
54    def __init__(self, parent):
55        QWidget.__init__(self, parent)
56
57        self.shellwidget = None
58        self.is_visible = True
59        self.setup_in_progress = None
60
61        # Remote dict editor settings
62        self.check_all = None
63        self.exclude_private = None
64        self.exclude_uppercase = None
65        self.exclude_capitalized = None
66        self.exclude_unsupported = None
67        self.excluded_names = None
68        self.minmax = None
69
70        # Other setting
71        self.dataframe_format = None
72
73        self.editor = None
74        self.exclude_private_action = None
75        self.exclude_uppercase_action = None
76        self.exclude_capitalized_action = None
77        self.exclude_unsupported_action = None
78
79        self.filename = None
80
81        self.var_properties = {}
82
83    def setup(self, check_all=None, exclude_private=None,
84              exclude_uppercase=None, exclude_capitalized=None,
85              exclude_unsupported=None, excluded_names=None,
86              minmax=None, dataframe_format=None):
87        """
88        Setup the namespace browser with provided settings.
89
90        Args:
91            dataframe_format (string): default floating-point format for
92                DataFrame editor
93        """
94        assert self.shellwidget is not None
95
96        self.check_all = check_all
97        self.exclude_private = exclude_private
98        self.exclude_uppercase = exclude_uppercase
99        self.exclude_capitalized = exclude_capitalized
100        self.exclude_unsupported = exclude_unsupported
101        self.excluded_names = excluded_names
102        self.minmax = minmax
103        self.dataframe_format = dataframe_format
104
105        if self.editor is not None:
106            self.editor.setup_menu(minmax)
107            self.editor.set_dataframe_format(dataframe_format)
108            self.exclude_private_action.setChecked(exclude_private)
109            self.exclude_uppercase_action.setChecked(exclude_uppercase)
110            self.exclude_capitalized_action.setChecked(exclude_capitalized)
111            self.exclude_unsupported_action.setChecked(exclude_unsupported)
112            self.refresh_table()
113            return
114
115        self.editor = RemoteCollectionsEditorTableView(
116                        self,
117                        None,
118                        minmax=minmax,
119                        dataframe_format=dataframe_format,
120                        get_value_func=self.get_value,
121                        set_value_func=self.set_value,
122                        new_value_func=self.set_value,
123                        remove_values_func=self.remove_values,
124                        copy_value_func=self.copy_value,
125                        is_list_func=self.is_list,
126                        get_len_func=self.get_len,
127                        is_array_func=self.is_array,
128                        is_image_func=self.is_image,
129                        is_dict_func=self.is_dict,
130                        is_data_frame_func=self.is_data_frame,
131                        is_series_func=self.is_series,
132                        get_array_shape_func=self.get_array_shape,
133                        get_array_ndim_func=self.get_array_ndim,
134                        plot_func=self.plot, imshow_func=self.imshow,
135                        show_image_func=self.show_image)
136
137        self.editor.sig_option_changed.connect(self.sig_option_changed.emit)
138        self.editor.sig_files_dropped.connect(self.import_data)
139
140        # Setup layout
141        blayout = QHBoxLayout()
142        toolbar = self.setup_toolbar(exclude_private, exclude_uppercase,
143                                     exclude_capitalized, exclude_unsupported)
144        for widget in toolbar:
145            blayout.addWidget(widget)
146
147        # Options menu
148        options_button = create_toolbutton(self, text=_('Options'),
149                                           icon=ima.icon('tooloptions'))
150        options_button.setPopupMode(QToolButton.InstantPopup)
151        menu = QMenu(self)
152        editor = self.editor
153        actions = [self.exclude_private_action, self.exclude_uppercase_action,
154                   self.exclude_capitalized_action,
155                   self.exclude_unsupported_action, None]
156        if is_module_installed('numpy'):
157            actions.append(editor.minmax_action)
158        add_actions(menu, actions)
159        options_button.setMenu(menu)
160
161        blayout.addStretch()
162        blayout.addWidget(options_button)
163
164        layout = create_plugin_layout(blayout, self.editor)
165        self.setLayout(layout)
166
167        self.sig_option_changed.connect(self.option_changed)
168
169    def set_shellwidget(self, shellwidget):
170        """Bind shellwidget instance to namespace browser"""
171        self.shellwidget = shellwidget
172        shellwidget.set_namespacebrowser(self)
173
174    def setup_toolbar(self, exclude_private, exclude_uppercase,
175                      exclude_capitalized, exclude_unsupported):
176        """Setup toolbar"""
177        self.setup_in_progress = True
178
179        toolbar = []
180
181        load_button = create_toolbutton(self, text=_('Import data'),
182                                        icon=ima.icon('fileimport'),
183                                        triggered=lambda: self.import_data())
184        self.save_button = create_toolbutton(self, text=_("Save data"),
185                            icon=ima.icon('filesave'),
186                            triggered=lambda: self.save_data(self.filename))
187        self.save_button.setEnabled(False)
188        save_as_button = create_toolbutton(self,
189                                           text=_("Save data as..."),
190                                           icon=ima.icon('filesaveas'),
191                                           triggered=self.save_data)
192        reset_namespace_button = create_toolbutton(
193                self, text=_("Remove all variables"),
194                icon=ima.icon('editdelete'), triggered=self.reset_namespace)
195
196        toolbar += [load_button, self.save_button, save_as_button,
197                    reset_namespace_button]
198
199        self.exclude_private_action = create_action(self,
200                _("Exclude private references"),
201                tip=_("Exclude references which name starts"
202                            " with an underscore"),
203                toggled=lambda state:
204                self.sig_option_changed.emit('exclude_private', state))
205        self.exclude_private_action.setChecked(exclude_private)
206
207        self.exclude_uppercase_action = create_action(self,
208                _("Exclude all-uppercase references"),
209                tip=_("Exclude references which name is uppercase"),
210                toggled=lambda state:
211                self.sig_option_changed.emit('exclude_uppercase', state))
212        self.exclude_uppercase_action.setChecked(exclude_uppercase)
213
214        self.exclude_capitalized_action = create_action(self,
215                _("Exclude capitalized references"),
216                tip=_("Exclude references which name starts with an "
217                      "uppercase character"),
218                toggled=lambda state:
219                self.sig_option_changed.emit('exclude_capitalized', state))
220        self.exclude_capitalized_action.setChecked(exclude_capitalized)
221
222        self.exclude_unsupported_action = create_action(self,
223                _("Exclude unsupported data types"),
224                tip=_("Exclude references to unsupported data types"
225                            " (i.e. which won't be handled/saved correctly)"),
226                toggled=lambda state:
227                self.sig_option_changed.emit('exclude_unsupported', state))
228        self.exclude_unsupported_action.setChecked(exclude_unsupported)
229
230        self.setup_in_progress = False
231
232        return toolbar
233
234    def option_changed(self, option, value):
235        """Option has changed"""
236        setattr(self, to_text_string(option), value)
237        self.shellwidget.set_namespace_view_settings()
238        self.refresh_table()
239
240    def visibility_changed(self, enable):
241        """Notify the widget whether its container (the namespace browser
242        plugin is visible or not"""
243        # This is slowing down Spyder a lot if too much data is present in
244        # the Variable Explorer, and users give focus to it after being hidden.
245        # This also happens when the Variable Explorer is visible and users
246        # give focus to Spyder after using another application (like Chrome
247        # or Firefox).
248        # That's why we've decided to remove this feature
249        # Fixes Issue 2593
250        #
251        # self.is_visible = enable
252        # if enable:
253        #     self.refresh_table()
254        pass
255
256    def get_view_settings(self):
257        """Return dict editor view settings"""
258        settings = {}
259        for name in REMOTE_SETTINGS:
260            settings[name] = getattr(self, name)
261        return settings
262
263    def refresh_table(self):
264        """Refresh variable table"""
265        if self.is_visible and self.isVisible():
266            self.shellwidget.refresh_namespacebrowser()
267            try:
268                self.editor.resizeRowToContents()
269            except TypeError:
270                pass
271
272    def process_remote_view(self, remote_view):
273        """Process remote view"""
274        if remote_view is not None:
275            self.set_data(remote_view)
276
277    def set_var_properties(self, properties):
278        """Set properties of variables"""
279        if properties is not None:
280            self.var_properties = properties
281
282    #------ Remote commands ------------------------------------
283    def get_value(self, name):
284        value = self.shellwidget.get_value(name)
285
286        # Reset temporal variable where value is saved to
287        # save memory
288        self.shellwidget._kernel_value = None
289        return value
290
291    def set_value(self, name, value):
292        """Set value for a variable."""
293        try:
294            # We need to enclose values in a list to be able to send
295            # them to the kernel in Python 2
296            svalue = [cloudpickle.dumps(value, protocol=PICKLE_PROTOCOL)]
297            self.shellwidget.set_value(name, svalue)
298        except TypeError as e:
299            QMessageBox.critical(self, _("Error"),
300                                 "TypeError: %s" % to_text_string(e))
301        self.refresh_table()
302
303    def remove_values(self, names):
304        for name in names:
305            self.shellwidget.remove_value(name)
306        self.refresh_table()
307
308    def copy_value(self, orig_name, new_name):
309        self.shellwidget.copy_value(orig_name, new_name)
310        self.refresh_table()
311
312    def is_list(self, name):
313        """Return True if variable is a list or a tuple"""
314        return self.var_properties[name]['is_list']
315
316    def is_dict(self, name):
317        """Return True if variable is a dictionary"""
318        return self.var_properties[name]['is_dict']
319
320    def get_len(self, name):
321        """Return sequence length"""
322        return self.var_properties[name]['len']
323
324    def is_array(self, name):
325        """Return True if variable is a NumPy array"""
326        return self.var_properties[name]['is_array']
327
328    def is_image(self, name):
329        """Return True if variable is a PIL.Image image"""
330        return self.var_properties[name]['is_image']
331
332    def is_data_frame(self, name):
333        """Return True if variable is a DataFrame"""
334        return self.var_properties[name]['is_data_frame']
335
336    def is_series(self, name):
337        """Return True if variable is a Series"""
338        return self.var_properties[name]['is_series']
339
340    def get_array_shape(self, name):
341        """Return array's shape"""
342        return self.var_properties[name]['array_shape']
343
344    def get_array_ndim(self, name):
345        """Return array's ndim"""
346        return self.var_properties[name]['array_ndim']
347
348    def plot(self, name, funcname):
349        sw = self.shellwidget
350        if sw._reading:
351            sw.dbg_exec_magic('varexp', '--%s %s' % (funcname, name))
352        else:
353            sw.execute("%%varexp --%s %s" % (funcname, name))
354
355    def imshow(self, name):
356        sw = self.shellwidget
357        if sw._reading:
358            sw.dbg_exec_magic('varexp', '--imshow %s' % name)
359        else:
360            sw.execute("%%varexp --imshow %s" % name)
361
362    def show_image(self, name):
363        command = "%s.show()" % name
364        sw = self.shellwidget
365        if sw._reading:
366            sw.kernel_client.input(command)
367        else:
368            sw.execute(command)
369
370    # ------ Set, load and save data ------------------------------------------
371    def set_data(self, data):
372        """Set data."""
373        if data != self.editor.model.get_data():
374            self.editor.set_data(data)
375            self.editor.adjust_columns()
376
377    def collapse(self):
378        """Collapse."""
379        self.sig_collapse.emit()
380
381    @Slot(bool)
382    @Slot(list)
383    def import_data(self, filenames=None):
384        """Import data from text file."""
385        title = _("Import data")
386        if filenames is None:
387            if self.filename is None:
388                basedir = getcwd_or_home()
389            else:
390                basedir = osp.dirname(self.filename)
391            filenames, _selfilter = getopenfilenames(self, title, basedir,
392                                                     iofunctions.load_filters)
393            if not filenames:
394                return
395        elif is_text_string(filenames):
396            filenames = [filenames]
397
398        for filename in filenames:
399            self.filename = to_text_string(filename)
400            ext = osp.splitext(self.filename)[1].lower()
401
402            if ext not in iofunctions.load_funcs:
403                buttons = QMessageBox.Yes | QMessageBox.Cancel
404                answer = QMessageBox.question(self, title,
405                            _("<b>Unsupported file extension '%s'</b><br><br>"
406                              "Would you like to import it anyway "
407                              "(by selecting a known file format)?"
408                              ) % ext, buttons)
409                if answer == QMessageBox.Cancel:
410                    return
411                formats = list(iofunctions.load_extensions.keys())
412                item, ok = QInputDialog.getItem(self, title,
413                                                _('Open file as:'),
414                                                formats, 0, False)
415                if ok:
416                    ext = iofunctions.load_extensions[to_text_string(item)]
417                else:
418                    return
419
420            load_func = iofunctions.load_funcs[ext]
421
422            # 'import_wizard' (self.setup_io)
423            if is_text_string(load_func):
424                # Import data with import wizard
425                error_message = None
426                try:
427                    text, _encoding = encoding.read(self.filename)
428                    base_name = osp.basename(self.filename)
429                    editor = ImportWizard(self, text, title=base_name,
430                                  varname=fix_reference_name(base_name))
431                    if editor.exec_():
432                        var_name, clip_data = editor.get_data()
433                        self.set_value(var_name, clip_data)
434                except Exception as error:
435                    error_message = str(error)
436            else:
437                QApplication.setOverrideCursor(QCursor(Qt.WaitCursor))
438                QApplication.processEvents()
439                error_message = self.shellwidget.load_data(self.filename,
440                                                            ext)
441                self.shellwidget._kernel_reply = None
442                QApplication.restoreOverrideCursor()
443                QApplication.processEvents()
444
445            if error_message is not None:
446                QMessageBox.critical(self, title,
447                                     _("<b>Unable to load '%s'</b>"
448                                       "<br><br>Error message:<br>%s"
449                                       ) % (self.filename, error_message))
450            self.refresh_table()
451
452    @Slot()
453    def reset_namespace(self):
454        warning = CONF.get('ipython_console', 'show_reset_namespace_warning')
455        self.shellwidget.reset_namespace(warning=warning, silent=True,
456                                         message=True)
457
458    @Slot()
459    def save_data(self, filename=None):
460        """Save data"""
461        if filename is None:
462            filename = self.filename
463            if filename is None:
464                filename = getcwd_or_home()
465            filename, _selfilter = getsavefilename(self, _("Save data"),
466                                                   filename,
467                                                   iofunctions.save_filters)
468            if filename:
469                self.filename = filename
470            else:
471                return False
472        QApplication.setOverrideCursor(QCursor(Qt.WaitCursor))
473        QApplication.processEvents()
474        error_message = self.shellwidget.save_namespace(self.filename)
475        self.shellwidget._kernel_reply = None
476        QApplication.restoreOverrideCursor()
477        QApplication.processEvents()
478        if error_message is not None:
479            QMessageBox.critical(self, _("Save data"),
480                            _("<b>Unable to save current workspace</b>"
481                              "<br><br>Error message:<br>%s") % error_message)
482        self.save_button.setEnabled(self.filename is not None)
483