• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

spyder-ide / spyder-notebook / 839

pending completion
839

Pull #250

circle-ci

Jitse Niesen
Plugin: Handle I/O errors when doing "Save As"
Pull Request #250: PR: Handle I/O errors when doing "Save As"

12 of 12 new or added lines in 1 file covered. (100.0%)

415 of 560 relevant lines covered (74.11%)

0.74 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

72.64
/spyder_notebook/notebookplugin.py
1
# -*- coding: utf-8 -*-
2
#
3
# Copyright (c) Spyder Project Contributors
4
# Licensed under the terms of the MIT License
5

6
"""Notebook plugin."""
1✔
7

8
# Stdlib imports
9
import os
1✔
10
import os.path as osp
1✔
11
import subprocess
1✔
12
import sys
1✔
13

14
# Qt imports
15
from qtpy import PYQT4, PYSIDE
1✔
16
from qtpy.compat import getsavefilename, getopenfilenames
1✔
17
from qtpy.QtCore import Qt, QEventLoop, QTimer, Signal
1✔
18
from qtpy.QtGui import QIcon
1✔
19
from qtpy.QtWidgets import QApplication, QMessageBox, QVBoxLayout, QMenu
1✔
20

21
# Third-party imports
22
import nbformat
1✔
23

24
# Spyder imports
25
from spyder.api.plugins import SpyderPluginWidget
1✔
26
from spyder.config.base import _
1✔
27
from spyder.utils import icon_manager as ima
1✔
28
from spyder.utils.programs import get_temp_dir
1✔
29
from spyder.utils.qthelpers import (create_action, create_toolbutton,
1✔
30
                                    add_actions, MENU_SEPARATOR)
31
from spyder.utils.switcher import shorten_paths
1✔
32
from spyder.widgets.tabs import Tabs
1✔
33

34

35
# Local imports
36
from .utils.nbopen import nbopen, NBServerError
1✔
37
from .widgets.client import NotebookClient
1✔
38

39

40
NOTEBOOK_TMPDIR = osp.join(get_temp_dir(), 'notebooks')
1✔
41
FILTER_TITLE = _("Jupyter notebooks")
1✔
42
FILES_FILTER = "{} (*.ipynb)".format(FILTER_TITLE)
1✔
43
PACKAGE_PATH = osp.dirname(__file__)
1✔
44
WELCOME = osp.join(PACKAGE_PATH, 'utils', 'templates', 'welcome.html')
1✔
45

46

47
class NotebookPlugin(SpyderPluginWidget):
1✔
48
    """IPython Notebook plugin."""
49

50
    CONF_SECTION = 'notebook'
1✔
51
    CONF_DEFAULTS = [(CONF_SECTION, {'recent_notebooks': []})]
1✔
52
    focus_changed = Signal()
1✔
53

54
    def __init__(self, parent, testing=False):
1✔
55
        """Constructor."""
56
        if testing:
1✔
57
            self.CONF_FILE = False
1✔
58

59
        SpyderPluginWidget.__init__(self, parent)
1✔
60
        self.testing = testing
1✔
61

62
        self.fileswitcher_dlg = None
1✔
63
        self.tabwidget = None
1✔
64
        self.menu_actions = None
1✔
65

66
        self.main = parent
1✔
67

68
        self.clients = []
1✔
69
        self.untitled_num = 0
1✔
70
        self.recent_notebooks = self.get_option('recent_notebooks', default=[])
1✔
71
        self.recent_notebook_menu = QMenu(_("Open recent"), self)
1✔
72
        self.options_menu = QMenu(self)
1✔
73

74
        layout = QVBoxLayout()
1✔
75

76
        new_notebook_btn = create_toolbutton(self,
1✔
77
                                             icon=ima.icon('options_more'),
78
                                             tip=_('Open a new notebook'),
79
                                             triggered=self.create_new_client)
80
        menu_btn = create_toolbutton(self, icon=ima.icon('tooloptions'),
1✔
81
                                     tip=_('Options'))
82

83
        menu_btn.setMenu(self.options_menu)
1✔
84
        menu_btn.setPopupMode(menu_btn.InstantPopup)
1✔
85
        corner_widgets = {Qt.TopRightCorner: [new_notebook_btn, menu_btn]}
1✔
86
        self.tabwidget = Tabs(self, menu=self.options_menu,
1✔
87
                              actions=self.menu_actions,
88
                              corner_widgets=corner_widgets)
89

90
        if hasattr(self.tabwidget, 'setDocumentMode') \
1✔
91
           and not sys.platform == 'darwin':
92
            # Don't set document mode to true on OSX because it generates
93
            # a crash when the console is detached from the main window
94
            # Fixes Issue 561
95
            self.tabwidget.setDocumentMode(True)
1✔
96
        self.tabwidget.currentChanged.connect(self.refresh_plugin)
1✔
97
        self.tabwidget.move_data.connect(self.move_tab)
1✔
98

99
        self.tabwidget.set_close_function(self.close_client)
1✔
100

101
        layout.addWidget(self.tabwidget)
1✔
102
        self.setLayout(layout)
1✔
103

104
    # ------ SpyderPluginMixin API --------------------------------------------
105
    def on_first_registration(self):
1✔
106
        """Action to be performed on first plugin registration."""
107
        self.main.tabify_plugins(self.main.editor, self)
×
108

109
    def update_font(self):
1✔
110
        """Update font from Preferences."""
111
        # For now we're passing. We need to create an nbextension for
112
        # this.
113
        pass
×
114

115
    # ------ SpyderPluginWidget API -------------------------------------------
116
    def get_plugin_title(self):
1✔
117
        """Return widget title."""
118
        title = _('Notebook')
×
119
        return title
×
120

121
    def get_plugin_icon(self):
1✔
122
        """Return widget icon."""
123
        return ima.icon('ipython_console')
×
124

125
    def get_focus_widget(self):
1✔
126
        """Return the widget to give focus to."""
127
        client = self.tabwidget.currentWidget()
×
128
        if client is not None:
×
129
            return client.notebookwidget
×
130

131
    def closing_plugin(self, cancelable=False):
1✔
132
        """Perform actions before parent main window is closed."""
133
        for cl in self.clients:
×
134
            cl.close()
×
135
        self.set_option('recent_notebooks', self.recent_notebooks)
×
136
        return True
×
137

138
    def refresh_plugin(self):
1✔
139
        """Refresh tabwidget."""
140
        nb = None
1✔
141
        if self.tabwidget.count():
1✔
142
            client = self.tabwidget.currentWidget()
1✔
143
            nb = client.notebookwidget
1✔
144
            nb.setFocus()
1✔
145
        else:
146
            nb = None
×
147
        self.update_notebook_actions()
1✔
148

149
    def get_plugin_actions(self):
1✔
150
        """Return a list of actions related to plugin."""
151
        create_nb_action = create_action(self,
1✔
152
                                         _("New notebook"),
153
                                         icon=ima.icon('filenew'),
154
                                         triggered=self.create_new_client)
155
        self.save_as_action = create_action(self,
1✔
156
                                            _("Save as..."),
157
                                            icon=ima.icon('filesaveas'),
158
                                            triggered=self.save_as)
159
        open_action = create_action(self,
1✔
160
                                    _("Open..."),
161
                                    icon=ima.icon('fileopen'),
162
                                    triggered=self.open_notebook)
163
        self.open_console_action = create_action(self,
1✔
164
                                                 _("Open console"),
165
                                                 icon=ima.icon(
166
                                                         'ipython_console'),
167
                                                 triggered=self.open_console)
168
        self.clear_recent_notebooks_action =\
1✔
169
            create_action(self, _("Clear this list"),
170
                          triggered=self.clear_recent_notebooks)
171
        # Plugin actions
172
        self.menu_actions = [create_nb_action, open_action,
1✔
173
                             self.recent_notebook_menu, MENU_SEPARATOR,
174
                             self.save_as_action, MENU_SEPARATOR,
175
                             self.open_console_action]
176
        self.setup_menu_actions()
1✔
177

178
        return self.menu_actions
1✔
179

180
    def register_plugin(self):
1✔
181
        """Register plugin in Spyder's main window."""
182
        super(NotebookPlugin, self).register_plugin()
×
183
        self.focus_changed.connect(self.main.plugin_focus_changed)
×
184
        self.ipyconsole = self.main.ipyconsole
×
185
        self.create_new_client(give_focus=False)
×
186

187
        # Connect to switcher
188
        self.switcher = self.main.switcher
×
189
        self.switcher.sig_mode_selected.connect(self.handle_switcher_modes)
×
190
        self.switcher.sig_item_selected.connect(
×
191
            self.handle_switcher_selection)
192

193
        self.recent_notebook_menu.aboutToShow.connect(self.setup_menu_actions)
×
194

195
    def check_compatibility(self):
1✔
196
        """Check compatibility for PyQt and sWebEngine."""
197
        message = ''
1✔
198
        value = True
1✔
199
        if PYQT4 or PYSIDE:
1✔
200
            message = _("You are working with Qt4 and in order to use this "
×
201
                        "plugin you need to have Qt5.<br><br>"
202
                        "Please update your Qt and/or PyQt packages to "
203
                        "meet this requirement.")
204
            value = False
×
205
        return value, message
1✔
206

207
    # ------ Public API (for clients) -----------------------------------------
208
    def setup_menu_actions(self):
1✔
209
        """Setup and update the menu actions."""
210
        self.recent_notebook_menu.clear()
1✔
211
        self.recent_notebooks_actions = []
1✔
212
        if self.recent_notebooks:
1✔
213
            for notebook in self.recent_notebooks:
1✔
214
                name = notebook
1✔
215
                action = \
1✔
216
                    create_action(self,
217
                                  name,
218
                                  icon=ima.icon('filenew'),
219
                                  triggered=lambda v,
220
                                  path=notebook:
221
                                      self.create_new_client(filename=path))
222
                self.recent_notebooks_actions.append(action)
1✔
223
            self.recent_notebooks_actions += \
1✔
224
                [None, self.clear_recent_notebooks_action]
225
        else:
226
            self.recent_notebooks_actions = \
1✔
227
                [self.clear_recent_notebooks_action]
228
        add_actions(self.recent_notebook_menu, self.recent_notebooks_actions)
1✔
229
        self.update_notebook_actions()
1✔
230

231
    def update_notebook_actions(self):
1✔
232
        """Update actions of the recent notebooks menu."""
233
        if self.recent_notebooks:
1✔
234
            self.clear_recent_notebooks_action.setEnabled(True)
1✔
235
        else:
236
            self.clear_recent_notebooks_action.setEnabled(False)
1✔
237
        client = self.get_current_client()
1✔
238
        if client:
1✔
239
            if client.get_filename() != WELCOME:
1✔
240
                self.save_as_action.setEnabled(True)
1✔
241
                self.open_console_action.setEnabled(True)
1✔
242
                self.options_menu.clear()
1✔
243
                add_actions(self.options_menu, self.menu_actions)
1✔
244
                return
1✔
245
        self.save_as_action.setEnabled(False)
1✔
246
        self.open_console_action.setEnabled(False)
1✔
247
        self.options_menu.clear()
1✔
248
        add_actions(self.options_menu, self.menu_actions)
1✔
249

250
    def add_to_recent(self, notebook):
1✔
251
        """
252
        Add an entry to recent notebooks.
253

254
        We only maintain the list of the 20 most recent notebooks.
255
        """
256
        if notebook not in self.recent_notebooks:
1✔
257
            self.recent_notebooks.insert(0, notebook)
1✔
258
            self.recent_notebooks = self.recent_notebooks[:20]
1✔
259

260
    def clear_recent_notebooks(self):
1✔
261
        """Clear the list of recent notebooks."""
262
        self.recent_notebooks = []
×
263
        self.setup_menu_actions()
×
264

265
    def get_clients(self):
1✔
266
        """Return notebooks list."""
267
        return [cl for cl in self.clients if isinstance(cl, NotebookClient)]
×
268

269
    def get_focus_client(self):
1✔
270
        """Return current notebook with focus, if any."""
271
        widget = QApplication.focusWidget()
×
272
        for client in self.get_clients():
×
273
            if widget is client or widget is client.notebookwidget:
×
274
                return client
×
275

276
    def get_current_client(self):
1✔
277
        """Return the currently selected notebook."""
278
        try:
1✔
279
            client = self.tabwidget.currentWidget()
1✔
280
        except AttributeError:
×
281
            client = None
×
282
        if client is not None:
1✔
283
            return client
1✔
284

285
    def get_current_nbwidget(self):
1✔
286
        """Return the notebookwidget of the current client."""
287
        client = self.get_current_client()
1✔
288
        if client is not None:
1✔
289
            return client.notebookwidget
1✔
290

291
    def get_current_client_name(self, short=False):
1✔
292
        """Get the current client name."""
293
        client = self.get_current_client()
×
294
        if client:
×
295
            if short:
×
296
                return client.get_short_name()
×
297
            else:
298
                return client.get_filename()
×
299

300
    def create_new_client(self, filename=None, give_focus=True):
1✔
301
        """Create a new notebook or load a pre-existing one."""
302
        # Generate the notebook name (in case of a new one)
303
        if not filename:
1✔
304
            if not osp.isdir(NOTEBOOK_TMPDIR):
1✔
305
                os.makedirs(NOTEBOOK_TMPDIR)
1✔
306
            nb_name = 'untitled' + str(self.untitled_num) + '.ipynb'
1✔
307
            filename = osp.join(NOTEBOOK_TMPDIR, nb_name)
1✔
308
            nb_contents = nbformat.v4.new_notebook()
1✔
309
            nbformat.write(nb_contents, filename)
1✔
310
            self.untitled_num += 1
1✔
311

312
        # Save spyder_pythonpath before creating a client
313
        # because it's needed by our kernel spec.
314
        if not self.testing:
1✔
315
            self.set_option('main/spyder_pythonpath',
×
316
                            self.main.get_spyder_pythonpath())
317

318
        # Open the notebook with nbopen and get the url we need to render
319
        try:
1✔
320
            server_info = nbopen(filename)
1✔
321
        except (subprocess.CalledProcessError, NBServerError):
×
322
            QMessageBox.critical(
×
323
                self,
324
                _("Server error"),
325
                _("The Jupyter Notebook server failed to start or it is "
326
                  "taking too much time to do it. Please start it in a "
327
                  "system terminal with the command 'jupyter notebook' to "
328
                  "check for errors."))
329
            # Create a welcome widget
330
            # See issue 93
331
            self.untitled_num -= 1
×
332
            self.create_welcome_client()
×
333
            return
×
334

335
        welcome_client = self.create_welcome_client()
1✔
336
        client = NotebookClient(self, filename)
1✔
337
        self.add_tab(client)
1✔
338
        if NOTEBOOK_TMPDIR not in filename:
1✔
339
            self.add_to_recent(filename)
1✔
340
            self.setup_menu_actions()
1✔
341
        client.register(server_info)
1✔
342
        client.load_notebook()
1✔
343
        if welcome_client and not self.testing:
1✔
344
            self.tabwidget.setCurrentIndex(0)
×
345

346
    def close_client(self, index=None, client=None, save=False):
1✔
347
        """
348
        Close client tab from index or widget (or close current tab).
349

350
        The notebook is saved if `save` is `False`.
351
        """
352
        if not self.tabwidget.count():
1✔
353
            return
×
354
        if client is not None:
1✔
355
            index = self.tabwidget.indexOf(client)
×
356
        if index is None and client is None:
1✔
357
            index = self.tabwidget.currentIndex()
1✔
358
        if index is not None:
1✔
359
            client = self.tabwidget.widget(index)
1✔
360

361
        is_welcome = client.get_filename() == WELCOME
1✔
362
        if not save and not is_welcome:
1✔
363
            self.save_notebook(client)
1✔
364
        if not is_welcome:
1✔
365
            client.shutdown_kernel()
1✔
366
        client.close()
1✔
367

368
        # Delete notebook file if it is in temporary directory
369
        filename = client.get_filename()
1✔
370
        if filename.startswith(get_temp_dir()):
1✔
371
            try:
1✔
372
                os.remove(filename)
1✔
373
            except EnvironmentError:
×
374
                pass
×
375

376
        # Note: notebook index may have changed after closing related widgets
377
        self.tabwidget.removeTab(self.tabwidget.indexOf(client))
1✔
378
        self.clients.remove(client)
1✔
379

380
        self.create_welcome_client()
1✔
381

382
    def create_welcome_client(self):
1✔
383
        """Create a welcome client with some instructions."""
384
        if self.tabwidget.count() == 0:
1✔
385
            welcome = open(WELCOME).read()
1✔
386
            client = NotebookClient(self, WELCOME, ini_message=welcome)
1✔
387
            self.add_tab(client)
1✔
388
            return client
1✔
389

390
    def save_notebook(self, client):
1✔
391
        """
392
        Save notebook corresponding to given client.
393

394
        If the notebook is newly created and not empty, then ask the user for
395
        a new filename and save under that name.
396

397
        This function is called when the user closes a tab.
398
        """
399
        client.save()
1✔
400

401
        # Check filename to find out whether notebook is newly created
402
        path = client.get_filename()
1✔
403
        dirname, basename = osp.split(path)
1✔
404
        if dirname != NOTEBOOK_TMPDIR or not basename.startswith('untitled'):
1✔
405
            return
1✔
406

407
        # Read file to see whether notebook is empty
408
        wait_save = QEventLoop()
1✔
409
        QTimer.singleShot(1000, wait_save.quit)
1✔
410
        wait_save.exec_()
1✔
411
        nb_contents = nbformat.read(path, as_version=4)
1✔
412
        if (len(nb_contents['cells']) == 0
1✔
413
                or len(nb_contents['cells'][0]['source']) == 0):
414
            return
1✔
415

416
        # Ask user to save notebook with new filename
417
        buttons = QMessageBox.Yes | QMessageBox.No
×
418
        text = _("<b>{0}</b> has been modified.<br>"
×
419
                 "Do you want to save changes?").format(basename)
420
        answer = QMessageBox.question(
×
421
            self, self.get_plugin_title(), text, buttons)
422
        if answer == QMessageBox.Yes:
×
423
            self.save_as(close=True)
×
424

425
    def save_as(self, name=None, close=False):
1✔
426
        """Save notebook as."""
427
        current_client = self.get_current_client()
1✔
428
        current_client.save()
1✔
429
        original_path = current_client.get_filename()
1✔
430
        if not name:
1✔
431
            original_name = osp.basename(original_path)
1✔
432
        else:
433
            original_name = name
1✔
434
        filename, _selfilter = getsavefilename(self, _("Save notebook"),
1✔
435
                                               original_name, FILES_FILTER)
436
        if filename:
1✔
437
            try:
1✔
438
                nb_contents = nbformat.read(original_path, as_version=4)
1✔
439
            except EnvironmentError as error:
×
440
                txt = (_("Error while reading {}<p>{}")
×
441
                       .format(original_path, str(error)))
442
                QMessageBox.critical(self, _("File Error"), txt)
×
443
                return
×
444
            try:
1✔
445
                nbformat.write(nb_contents, filename)
1✔
446
            except EnvironmentError as error:
1✔
447
                txt = (_("Error while writing {}<p>{}")
1✔
448
                       .format(filename, str(error)))
449
                QMessageBox.critical(self, _("File Error"), txt)
1✔
450
                return
1✔
451
            if not close:
1✔
452
                self.close_client(save=True)
1✔
453
            self.create_new_client(filename=filename)
1✔
454

455
    def open_notebook(self, filenames=None):
1✔
456
        """Open a notebook from file."""
457
        if not filenames:
1✔
458
            filenames, _selfilter = getopenfilenames(self, _("Open notebook"),
×
459
                                                     '', FILES_FILTER)
460
        if filenames:
1✔
461
            for filename in filenames:
1✔
462
                self.create_new_client(filename=filename)
1✔
463

464
    def open_console(self, client=None):
1✔
465
        """Open an IPython console for the given client or the current one."""
466
        if not client:
1✔
467
            client = self.get_current_client()
×
468
        if self.ipyconsole is not None:
1✔
469
            kernel_id = client.get_kernel_id()
1✔
470
            if not kernel_id:
1✔
471
                QMessageBox.critical(
1✔
472
                    self, _('Error opening console'),
473
                    _('There is no kernel associated to this notebook.'))
474
                return
1✔
475
            self.ipyconsole._create_client_for_kernel(kernel_id, None, None,
×
476
                                                      None)
477
            ipyclient = self.ipyconsole.get_current_client()
×
478
            ipyclient.allow_rename = False
×
479
            self.ipyconsole.rename_client_tab(ipyclient,
×
480
                                              client.get_short_name())
481

482
    # ------ Public API (for tabs) --------------------------------------------
483
    def add_tab(self, widget):
1✔
484
        """Add tab."""
485
        self.clients.append(widget)
1✔
486
        index = self.tabwidget.addTab(widget, widget.get_short_name())
1✔
487
        self.tabwidget.setCurrentIndex(index)
1✔
488
        self.tabwidget.setTabToolTip(index, widget.get_filename())
1✔
489
        if self.dockwidget:
1✔
490
            self.switch_to_plugin()
×
491
        self.activateWindow()
1✔
492

493
    def move_tab(self, index_from, index_to):
1✔
494
        """Move tab."""
495
        client = self.clients.pop(index_from)
×
496
        self.clients.insert(index_to, client)
×
497

498
    # ------ Public API (for FileSwitcher) ------------------------------------
499
    def handle_switcher_modes(self, mode):
1✔
500
        """
501
        Populate switcher with opened notebooks.
502

503
        List the file names of the opened notebooks with their directories in
504
        the switcher. Only handle file mode, where `mode` is empty string.
505
        """
506
        if mode != '':
×
507
            return
×
508

509
        paths = [client.get_filename() for client in self.clients]
×
510
        is_unsaved = [False for client in self.clients]
×
511
        short_paths = shorten_paths(paths, is_unsaved)
×
512
        icon = QIcon(os.path.join(PACKAGE_PATH, 'images', 'icon.svg'))
×
513
        section = self.get_plugin_title()
×
514

515
        for path, short_path, client in zip(paths, short_paths, self.clients):
×
516
            title = osp.basename(path)
×
517
            description = osp.dirname(path)
×
518
            if len(path) > 75:
×
519
                description = short_path
×
520
            is_last_item = (client == self.clients[-1])
×
521
            self.switcher.add_item(
×
522
                title=title, description=description, icon=icon,
523
                section=section, data=client, last_item=is_last_item)
524

525
    def handle_switcher_selection(self, item, mode, search_text):
1✔
526
        """
527
        Handle user selecting item in switcher.
528

529
        If the selected item is not in the section of the switcher that
530
        corresponds to this plugin, then ignore it. Otherwise, switch to
531
        selected item in notebook plugin and hide the switcher.
532
        """
533
        if item.get_section() != self.get_plugin_title():
×
534
            return
×
535

536
        client = item.get_data()
×
537
        index = self.clients.index(client)
×
538
        self.tabwidget.setCurrentIndex(index)
×
539
        self.switch_to_plugin()
×
540
        self.switcher.hide()
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc