diff --git a/spyder_notebook/notebookplugin.py b/spyder_notebook/notebookplugin.py index 35c41625..ff95bf3b 100644 --- a/spyder_notebook/notebookplugin.py +++ b/spyder_notebook/notebookplugin.py @@ -6,6 +6,7 @@ """Notebook plugin.""" # Standard library imports +import logging import os.path as osp # Spyder imports @@ -20,6 +21,8 @@ from spyder_notebook.widgets.main_widget import NotebookMainWidget from spyder_notebook.utils.localization import _ +logger = logging.getLogger(__name__) + class NotebookPlugin(SpyderDockablePlugin): """Spyder Notebook plugin.""" @@ -94,10 +97,11 @@ def open_notebook(self, filenames=None): self.get_widget().open_notebook(filenames) # ------ Private API ------------------------------------------------------ - def _open_console(self, kernel_id, tab_name): + def _open_console(self, connection_file, tab_name): """Open an IPython console as requested.""" + logger.info(f'Opening console with {connection_file=}') ipyconsole = self.get_plugin(Plugins.IPythonConsole) - ipyconsole.create_client_for_kernel(kernel_id) + ipyconsole.create_client_for_kernel(connection_file) ipyclient = ipyconsole.get_current_client() ipyclient.allow_rename = False ipyconsole.rename_client_tab(ipyclient, tab_name) diff --git a/spyder_notebook/tests/test_plugin.py b/spyder_notebook/tests/test_plugin.py index e50988f1..cb7b31f3 100644 --- a/spyder_notebook/tests/test_plugin.py +++ b/spyder_notebook/tests/test_plugin.py @@ -11,17 +11,14 @@ import json import os import os.path as osp -import shutil -import sys from unittest.mock import Mock # Third-party library imports from flaky import flaky import pytest -import requests -from qtpy.QtCore import Qt, QTimer from qtpy.QtWebEngineWidgets import WEBENGINE -from qtpy.QtWidgets import QFileDialog, QApplication, QLineEdit, QMainWindow +from qtpy.QtWidgets import QMainWindow +import requests # Spyder imports from spyder.api.plugins import Plugins @@ -35,7 +32,6 @@ # ============================================================================= NOTEBOOK_UP = 40000 CALLBACK_TIMEOUT = 10000 -INTERACTION_CLICK = 100 LOCATION = osp.realpath(osp.join(os.getcwd(), osp.dirname(__file__))) @@ -57,24 +53,6 @@ def text_present(nbwidget, qtbot, text="Test"): return text in nbwidget.dom.toHtml() -def manage_save_dialog(qtbot, fname, directory=LOCATION): - """ - Manage the QFileDialog when saving. - - You can use this with QTimer to manage the QFileDialog. - Before calling anything that may show a QFileDialog for save call: - QTimer.singleShot(1000, lambda: manage_save_dialog(qtbot)) - """ - top_level_widgets = QApplication.topLevelWidgets() - for w in top_level_widgets: - if isinstance(w, QFileDialog): - if directory is not None: - w.setDirectory(directory) - input_field = w.findChildren(QLineEdit)[0] - input_field.setText(fname) - qtbot.keyClick(w, Qt.Key_Enter) - - def is_kernel_up(kernel_id, sessions_url): """Determine if the kernel with the id is up.""" sessions_req = requests.get(sessions_url).content.decode() @@ -141,172 +119,6 @@ def fake_get_server(filename, interpreter, start): # ============================================================================= # Tests # ============================================================================= -@flaky(max_runs=5) -def test_shutdown_notebook_kernel(notebook, qtbot): - """Test that kernel is shutdown from server when closing a notebook.""" - # Wait for prompt - nbwidget = notebook.get_widget().tabwidget.currentWidget().notebookwidget - qtbot.waitUntil(lambda: prompt_present(nbwidget, qtbot), - timeout=NOTEBOOK_UP) - - # Get kernel id for the client - client = notebook.get_widget().tabwidget.currentWidget() - qtbot.waitUntil(lambda: client.get_kernel_id() is not None, - timeout=NOTEBOOK_UP) - kernel_id = client.get_kernel_id() - sessions_url = client.get_session_url() - - # Close the current client - notebook.get_widget().tabwidget.close_client() - - # Assert that the kernel is down for the closed client - assert not is_kernel_up(kernel_id, sessions_url) - - -def test_file_in_temp_dir_deleted_after_notebook_closed(notebook, qtbot): - """Test that notebook file in temporary directory is deleted after the - notebook is closed.""" - # Wait for prompt - nbwidget = notebook.get_widget().tabwidget.currentWidget().notebookwidget - qtbot.waitUntil(lambda: prompt_present(nbwidget, qtbot), - timeout=NOTEBOOK_UP) - - # Get file name - client = notebook.get_widget().tabwidget.currentWidget() - filename = client.get_filename() - - # Close the current client - notebook.get_widget().tabwidget.close_client() - - # Assert file is deleted - assert not osp.exists(filename) - - -@flaky(max_runs=3) -def test_close_nonexisting_notebook(notebook, qtbot): - """Test that we can close a tab if the notebook file does not exist. - Regression test for spyder-ide/spyder-notebook#187.""" - # Set up tab with non-existingg notebook - filename = osp.join(LOCATION, 'does-not-exist.ipynb') - notebook.open_notebook(filenames=[filename]) - nbwidget = notebook.get_widget().tabwidget.currentWidget().notebookwidget - qtbot.waitUntil(lambda: prompt_present(nbwidget, qtbot), - timeout=NOTEBOOK_UP) - client = notebook.get_widget().tabwidget.currentWidget() - - # Close tab - notebook.get_widget().tabwidget.close_client() - - # Assert tab is closed (without raising an exception) - for client_index in range(notebook.get_widget().tabwidget.count()): - assert client != notebook.get_widget().tabwidget.widget(client_index) - - -# TODO Find out what goes wrong on Mac -@flaky(max_runs=3) -@pytest.mark.skipif(sys.platform == 'darwin', reason='Prompt never comes up') -def test_open_notebook_in_non_ascii_dir(notebook, qtbot, tmpdir): - """Test that a notebook can be opened from a non-ascii directory.""" - # Move the test file to non-ascii directory - test_notebook = osp.join(LOCATION, 'test.ipynb') - test_notebook_non_ascii = osp.join(str(tmpdir), u'äöüß', 'test.ipynb') - os.mkdir(os.path.join(str(tmpdir), u'äöüß')) - shutil.copyfile(test_notebook, test_notebook_non_ascii) - - # Wait for prompt - notebook.open_notebook(filenames=[test_notebook_non_ascii]) - nbwidget = notebook.get_widget().tabwidget.currentWidget().notebookwidget - qtbot.waitUntil(lambda: prompt_present(nbwidget, qtbot), - timeout=NOTEBOOK_UP) - - # Assert that the In prompt has "Test" in it - # and the client has the correct name - qtbot.waitUntil(lambda: text_present(nbwidget, qtbot), - timeout=NOTEBOOK_UP) - assert text_present(nbwidget, qtbot) - assert notebook.get_widget().tabwidget.currentWidget().get_short_name() ==\ - "test" - - -@flaky(max_runs=3) -@pytest.mark.skipif(not sys.platform.startswith('linux'), - reason='Test hangs on CI on Windows and MacOS') -def test_save_notebook(notebook, qtbot, tmpdir): - """Test that a notebook can be saved.""" - # Wait for prompt - nbwidget = notebook.get_widget().tabwidget.currentWidget().notebookwidget - qtbot.waitUntil(lambda: prompt_present(nbwidget, qtbot), - timeout=NOTEBOOK_UP) - - # Writes: a = "test" - qtbot.keyClick(nbwidget, Qt.Key_A, delay=INTERACTION_CLICK) - qtbot.keyClick(nbwidget, Qt.Key_Space, delay=INTERACTION_CLICK) - qtbot.keyClick(nbwidget, Qt.Key_Equal, delay=INTERACTION_CLICK) - qtbot.keyClick(nbwidget, Qt.Key_Space, delay=INTERACTION_CLICK) - qtbot.keyClick(nbwidget, Qt.Key_QuoteDbl, delay=INTERACTION_CLICK) - qtbot.keyClick(nbwidget, Qt.Key_T, delay=INTERACTION_CLICK) - qtbot.keyClick(nbwidget, Qt.Key_E, delay=INTERACTION_CLICK) - qtbot.keyClick(nbwidget, Qt.Key_S, delay=INTERACTION_CLICK) - qtbot.keyClick(nbwidget, Qt.Key_T, delay=INTERACTION_CLICK) - qtbot.keyClick(nbwidget, Qt.Key_QuoteDbl, delay=INTERACTION_CLICK) - - # Save the notebook - name = osp.join(str(tmpdir), 'save.ipynb') - QTimer.singleShot(1000, lambda: manage_save_dialog(qtbot, fname=name)) - notebook.get_widget().save_as() - - # Wait for prompt - nbwidget = notebook.get_widget().tabwidget.currentWidget().notebookwidget - qtbot.waitUntil(lambda: prompt_present(nbwidget, qtbot), - timeout=NOTEBOOK_UP) - - # Assert that the In prompt has "test" in it - # and the client has the correct name - qtbot.waitUntil(lambda: text_present(nbwidget, qtbot, text="test"), - timeout=NOTEBOOK_UP) - assert text_present(nbwidget, qtbot, text="test") - assert notebook.get_widget().tabwidget.currentWidget().get_short_name() ==\ - "save" - - -@flaky(max_runs=3) -@pytest.mark.skipif(os.name == 'nt', - reason='Test hangs often on CI on Windows') -def test_save_notebook_as_with_error(mocker, notebook, qtbot, tmpdir): - """Test that errors are handled in save_as().""" - # Wait for prompt - nbwidget = notebook.get_widget().tabwidget.currentWidget().notebookwidget - qtbot.waitUntil(lambda: prompt_present(nbwidget, qtbot), - timeout=NOTEBOOK_UP) - - # Set up mocks - name = osp.join(str(tmpdir), 'save.ipynb') - mocker.patch('spyder_notebook.widgets.notebooktabwidget.getsavefilename', - return_value=(name, 'ignored')) - mocker.patch('spyder_notebook.widgets.notebooktabwidget.nbformat.write', - side_effect=PermissionError) - mock_critical = mocker.patch('spyder_notebook.widgets.notebooktabwidget' - '.QMessageBox.critical') - - # Save the notebook - notebook.get_widget().save_as() - - # Assert that message box is displayed (reporting error raised by write) - assert mock_critical.called - - -@flaky(max_runs=3) -def test_new_notebook(notebook, qtbot): - """Test that a new client is really a notebook.""" - # Wait for prompt - nbwidget = notebook.get_widget().tabwidget.currentWidget().notebookwidget - qtbot.waitUntil(lambda: prompt_present(nbwidget, qtbot), - timeout=NOTEBOOK_UP) - - # Assert that we have one notebook and the welcome page - assert notebook.get_widget().tabwidget.count() == 2 - - # Teardown sometimes fails on Mac with Python 3.8 due to NoProcessException # in shutdown_server() in notebookapp.py in external notebook library @flaky diff --git a/spyder_notebook/widgets/main_widget.py b/spyder_notebook/widgets/main_widget.py index e403d364..e76c7394 100644 --- a/spyder_notebook/widgets/main_widget.py +++ b/spyder_notebook/widgets/main_widget.py @@ -4,7 +4,11 @@ # Licensed under the terms of the MIT License # (see LICENSE.txt for details) +# Standard library imports +import os.path as osp + # Third-party imports +from jupyter_core.paths import jupyter_runtime_dir from qtpy.QtCore import Signal from qtpy.QtWidgets import QMessageBox, QVBoxLayout @@ -55,8 +59,8 @@ class NotebookMainWidget(PluginMainWidget): Parameters ----------- - kernel_id: str - Id of the kernel to open a console for. + connection_file: str + Name of the connection file for the kernel to open a console for. tab_name: str Tab name to set for the created console. """ @@ -317,8 +321,10 @@ def open_console(self, client=None): ) return + connection_file = f'kernel-{kernel_id}.json' + connection_file = osp.join(jupyter_runtime_dir(), connection_file) self.sig_open_console_requested.emit( - kernel_id, + connection_file, client.get_short_name() ) diff --git a/spyder_notebook/tests/test.ipynb b/spyder_notebook/widgets/tests/test.ipynb similarity index 100% rename from spyder_notebook/tests/test.ipynb rename to spyder_notebook/widgets/tests/test.ipynb diff --git a/spyder_notebook/widgets/tests/test_main_widget.py b/spyder_notebook/widgets/tests/test_main_widget.py new file mode 100644 index 00000000..bff6e5f5 --- /dev/null +++ b/spyder_notebook/widgets/tests/test_main_widget.py @@ -0,0 +1,250 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# + +"""Tests for NotebookMainWidget""" + +# Standard library imports +import os +import os.path as osp +import shutil +import sys +from unittest.mock import MagicMock + +# Third-party library imports +from flaky import flaky +import pytest +from qtpy.QtCore import Qt, QTimer +from qtpy.QtWidgets import QFileDialog, QApplication, QLineEdit + +# Local imports +from spyder_notebook.tests.test_plugin import ( + is_kernel_up, prompt_present, text_present) +from spyder_notebook.widgets.main_widget import NotebookMainWidget + +# ============================================================================= +# Constants +# ============================================================================= +NOTEBOOK_UP = 40000 +INTERACTION_CLICK = 100 +LOCATION = osp.realpath(osp.join(os.getcwd(), osp.dirname(__file__))) + + +# ============================================================================= +# Utility functions +# ============================================================================= +def manage_save_dialog(qtbot, fname, directory=LOCATION): + """ + Manage the QFileDialog when saving. + + You can use this with QTimer to manage the QFileDialog. + Before calling anything that may show a QFileDialog for save call: + QTimer.singleShot(1000, lambda: manage_save_dialog(qtbot)) + """ + top_level_widgets = QApplication.topLevelWidgets() + for w in top_level_widgets: + if isinstance(w, QFileDialog): + if directory is not None: + w.setDirectory(directory) + input_field = w.findChildren(QLineEdit)[0] + input_field.setText(fname) + qtbot.keyClick(w, Qt.Key_Enter) + + +# ============================================================================= +# Fixtures +# ============================================================================= +@pytest.fixture +def main_widget(qtbot): + """Set up a NotebookMainWidget, with no tabs.""" + mock_plugin = MagicMock() + mock_plugin.CONF_SECTION = 'mock conf section' + + main_widget = NotebookMainWidget('testwidget', mock_plugin, None) + main_widget.setup() + main_widget.show() # Prompt only appears if widget is displayed + + yield main_widget + + main_widget.close() + + +# ============================================================================= +# Tests +# ============================================================================= +@flaky(max_runs=3) +def test_new_notebook(main_widget, qtbot): + """Test that a new client is really a notebook.""" + # Create new notebook tab and check that there is a prompt + main_widget.create_new_client() + nbwidget = main_widget.tabwidget.currentWidget().notebookwidget + qtbot.waitUntil(lambda: prompt_present(nbwidget, qtbot), + timeout=NOTEBOOK_UP) + + +@flaky(max_runs=3) +@pytest.mark.skipif(sys.platform == 'darwin', reason='Prompt never comes up') +def test_open_notebook_in_non_ascii_dir(main_widget, qtbot, tmpdir): + """Test that a notebook can be opened from a non-ascii directory.""" + # Copy the test file to non-ascii directory + test_notebook = osp.join(LOCATION, 'test.ipynb') + test_notebook_non_ascii = osp.join(str(tmpdir), u'äöüß', 'test.ipynb') + os.mkdir(os.path.join(str(tmpdir), u'äöüß')) + shutil.copyfile(test_notebook, test_notebook_non_ascii) + + # Open the test notebook and wait for prompt + main_widget.open_notebook(filenames=[test_notebook_non_ascii]) + client = main_widget.tabwidget.currentWidget() + nbwidget = client.notebookwidget + qtbot.waitUntil(lambda: prompt_present(nbwidget, qtbot), + timeout=NOTEBOOK_UP) + + # Assert that the In prompt has "Test" in it + # and the client has the correct name + qtbot.waitUntil(lambda: text_present(nbwidget, qtbot), + timeout=NOTEBOOK_UP) + assert text_present(nbwidget, qtbot) + assert client.get_short_name() == "test" + + +@flaky(max_runs=3) +@pytest.mark.skipif(not sys.platform.startswith('linux'), + reason='Test hangs on CI on Windows and MacOS') +def test_save_notebook(main_widget, qtbot, tmpdir): + """Test that a notebook can be saved.""" + # Create new notebook tab and wait for prompt + main_widget.create_new_client() + nbwidget = main_widget.tabwidget.currentWidget().notebookwidget + qtbot.waitUntil(lambda: prompt_present(nbwidget, qtbot), + timeout=NOTEBOOK_UP) + + # Writes: a = "test" + qtbot.keyClick(nbwidget, Qt.Key_A, delay=INTERACTION_CLICK) + qtbot.keyClick(nbwidget, Qt.Key_Space, delay=INTERACTION_CLICK) + qtbot.keyClick(nbwidget, Qt.Key_Equal, delay=INTERACTION_CLICK) + qtbot.keyClick(nbwidget, Qt.Key_Space, delay=INTERACTION_CLICK) + qtbot.keyClick(nbwidget, Qt.Key_QuoteDbl, delay=INTERACTION_CLICK) + qtbot.keyClick(nbwidget, Qt.Key_T, delay=INTERACTION_CLICK) + qtbot.keyClick(nbwidget, Qt.Key_E, delay=INTERACTION_CLICK) + qtbot.keyClick(nbwidget, Qt.Key_S, delay=INTERACTION_CLICK) + qtbot.keyClick(nbwidget, Qt.Key_T, delay=INTERACTION_CLICK) + qtbot.keyClick(nbwidget, Qt.Key_QuoteDbl, delay=INTERACTION_CLICK) + + # Save the notebook + name = osp.join(str(tmpdir), 'save.ipynb') + QTimer.singleShot(1000, lambda: manage_save_dialog(qtbot, fname=name)) + main_widget.save_as() + + # Wait for prompt + client = main_widget.tabwidget.currentWidget() + nbwidget = client.notebookwidget + qtbot.waitUntil(lambda: prompt_present(nbwidget, qtbot), + timeout=NOTEBOOK_UP) + + # Assert that the In prompt has "test" in it + # and the client has the correct name + qtbot.waitUntil(lambda: text_present(nbwidget, qtbot, text="test"), + timeout=NOTEBOOK_UP) + assert text_present(nbwidget, qtbot, text="test") + assert client.get_short_name() == "save" + + +@flaky(max_runs=3) +@pytest.mark.skipif(os.name == 'nt', + reason='Test hangs often on CI on Windows') +def test_save_notebook_as_with_error(main_widget, mocker, qtbot, tmpdir): + """Test that errors are handled in save_as().""" + # Create new notebook tab and wait for prompt + main_widget.create_new_client() + nbwidget = main_widget.tabwidget.currentWidget().notebookwidget + qtbot.waitUntil(lambda: prompt_present(nbwidget, qtbot), + timeout=NOTEBOOK_UP) + + # Set up mocks + name = osp.join(str(tmpdir), 'save.ipynb') + mocker.patch('spyder_notebook.widgets.notebooktabwidget.getsavefilename', + return_value=(name, 'ignored')) + mocker.patch('spyder_notebook.widgets.notebooktabwidget.nbformat.write', + side_effect=PermissionError) + mock_critical = mocker.patch('spyder_notebook.widgets.notebooktabwidget' + '.QMessageBox.critical') + + # Save the notebook + main_widget.save_as() + + # Assert that message box is displayed (reporting error raised by write) + assert mock_critical.called + + +@flaky(max_runs=5) +def test_shutdown_notebook_kernel(main_widget, qtbot): + """Test that kernel is shut down when closing a notebook.""" + # Create new notebook tab and wait for prompt + main_widget.create_new_client() + client = main_widget.tabwidget.currentWidget() + nbwidget = client.notebookwidget + qtbot.waitUntil(lambda: prompt_present(nbwidget, qtbot), + timeout=NOTEBOOK_UP) + + # Get kernel id for the client + qtbot.waitUntil(lambda: client.get_kernel_id() is not None, + timeout=NOTEBOOK_UP) + kernel_id = client.get_kernel_id() + + # Assert that kernel is up + sessions_url = client.get_session_url() + assert is_kernel_up(kernel_id, sessions_url) + + # Close the current client + main_widget.tabwidget.close_client() + + # Assert that the kernel is down for the closed client + assert not is_kernel_up(kernel_id, sessions_url) + + +def test_file_in_temp_dir_deleted_after_notebook_closed(main_widget, qtbot): + """Test that notebook file in temporary directory is deleted after the + notebook is closed.""" + # Create new notebook tab and wait for prompt + main_widget.create_new_client() + client = main_widget.tabwidget.currentWidget() + nbwidget = client.notebookwidget + qtbot.waitUntil(lambda: prompt_present(nbwidget, qtbot), + timeout=NOTEBOOK_UP) + + # Get file name + filename = client.get_filename() + + # Close the current client + main_widget.tabwidget.close_client() + + # Assert file is deleted + assert not osp.exists(filename) + + +@flaky(max_runs=3) +def test_close_nonexisting_notebook(main_widget, qtbot, tmpdir): + """Test that we can close a tab if the notebook file does not exist. + Regression test for spyder-ide/spyder-notebook#187.""" + # Set up tab with non-existing notebook + test_notebook_original = osp.join(LOCATION, 'test.ipynb') + test_notebook = osp.join(str(tmpdir), 'test.ipynb') + shutil.copyfile(test_notebook_original, test_notebook) + main_widget.open_notebook(filenames=[test_notebook]) + + # Wait for prompt + client = main_widget.tabwidget.currentWidget() + nbwidget = client.notebookwidget + qtbot.waitUntil(lambda: prompt_present(nbwidget, qtbot), + timeout=NOTEBOOK_UP) + + os.remove(test_notebook) + + # Close tab + main_widget.tabwidget.close_client() + + # Assert tab is closed (without raising an exception) + for client_index in range(main_widget.tabwidget.count()): + assert client != main_widget.tabwidget.widget(client_index)