diff --git a/installers/Windows/req-extras-pull-request.txt b/installers/Windows/req-extras-pull-request.txt index b92de1d875d..3cc07fec1d1 100644 --- a/installers/Windows/req-extras-pull-request.txt +++ b/installers/Windows/req-extras-pull-request.txt @@ -17,9 +17,9 @@ matplotlib cython sympy +# Spyder external plugins +spyder-terminal>=1.2.1 + # Spyder external dependencies (spyder-kernels and qdarkstyle) ./external-deps/spyder-kernels ./external-deps/qdarkstyle - -# There are no wheels for version 3.3 -cryptography==3.2.1 diff --git a/installers/Windows/req-extras-release.txt b/installers/Windows/req-extras-release.txt index 7a2a436c027..7ed3847979a 100644 --- a/installers/Windows/req-extras-release.txt +++ b/installers/Windows/req-extras-release.txt @@ -17,5 +17,5 @@ matplotlib cython sympy -# There are no wheels for version 3.3 -cryptography==3.2.1 +# Spyder external plugins +spyder-terminal>=1.2.1 diff --git a/installers/Windows/req-pull-request.txt b/installers/Windows/req-pull-request.txt index fb042c7cd28..3c37e9dbec2 100644 --- a/installers/Windows/req-pull-request.txt +++ b/installers/Windows/req-pull-request.txt @@ -1,6 +1,6 @@ +# Spyder external plugins +spyder-terminal>=1.2.1 + # Spyder external dependencies (spyder-kernels and qdarkstyle) ./external-deps/spyder-kernels ./external-deps/qdarkstyle - -# There are no wheels for version 3.3 -cryptography==3.2.1 diff --git a/installers/Windows/req-release.txt b/installers/Windows/req-release.txt index 9f9533086be..11f1aa588a1 100644 --- a/installers/Windows/req-release.txt +++ b/installers/Windows/req-release.txt @@ -1,2 +1,2 @@ -# There are no wheels for version 3.3 -cryptography==3.2.1 +# Spyder external plugins +spyder-terminal>=1.2.1 diff --git a/setup.py b/setup.py index b5d96ec06be..984ceb1ea81 100644 --- a/setup.py +++ b/setup.py @@ -50,6 +50,8 @@ # ============================================================================= NAME = 'spyder' LIBNAME = 'spyder' +WINDOWS_INSTALLER_NAME = os.environ.get('EXE_NAME') + from spyder import __version__, __website_url__ #analysis:ignore @@ -243,6 +245,12 @@ def run(self): 'watchdog>=0.10.3' ] +# Replace spyder-kernels constraint to enable +# building Windows installers on PRs +if 'dev' in __version__ and WINDOWS_INSTALLER_NAME: + install_requires.remove('spyder-kernels>=2.2.1,<2.3.0') + install_requires.append('spyder-kernels>=2.2.1,<=2.3.0.dev0') + extras_require = { 'test:platform_system == "Windows"': ['pywin32'], 'test': [ diff --git a/spyder/app/tests/test_mainwindow.py b/spyder/app/tests/test_mainwindow.py index 9e478e5758c..40d892a523c 100644 --- a/spyder/app/tests/test_mainwindow.py +++ b/spyder/app/tests/test_mainwindow.py @@ -345,6 +345,19 @@ def main_window(request, tmpdir, qtbot): shell = window.ipyconsole.get_current_shellwidget() qtbot.waitUntil(lambda: shell._prompt_html is not None, timeout=SHELL_TIMEOUT) + # Wait until console is up + shell = window.ipyconsole.get_current_shellwidget() + try: + qtbot.waitUntil(lambda: shell._prompt_html is not None, + timeout=SHELL_TIMEOUT) + except Exception: + # Print content of shellwidget and close window + print(shell._control.toPlainText()) + client = window.ipyconsole.get_current_client() + if client.info_page != client.blank_page: + print('info_page') + print(client.info_page) + raise if os.name != 'nt': # _DummyThread are created if current_thread() is called from them. @@ -4607,6 +4620,7 @@ def foo(x): @pytest.mark.slow @flaky(max_runs=3) +@pytest.mark.skipif(os.name == 'nt', reason="Tour messes up focus on Windows") def test_focus_to_consoles(main_window, qtbot): """ Check that we give focus to the text widget of our consoles after focus diff --git a/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py b/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py index d666cef2602..d22cf533628 100644 --- a/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py +++ b/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py @@ -215,8 +215,18 @@ def __getattr__(self, attr): # Wait until the window is fully up qtbot.waitUntil(lambda: console.get_current_shellwidget() is not None) shell = console.get_current_shellwidget() - qtbot.waitUntil(lambda: shell._prompt_html is not None, - timeout=SHELL_TIMEOUT) + try: + qtbot.waitUntil(lambda: shell._prompt_html is not None, + timeout=SHELL_TIMEOUT) + except Exception: + # Print content of shellwidget and close window + print(console.get_current_shellwidget( + )._control.toPlainText()) + client = console.get_current_client() + if client.info_page != client.blank_page: + print('info_page') + print(client.info_page) + raise # Check for thread or open file leaks known_leak = request.node.get_closest_marker('known_leak') @@ -1378,30 +1388,33 @@ def test_kernel_crash(ipyconsole, qtbot): # Create an IPython kernel config file with a bad config ipy_kernel_cfg = osp.join(get_ipython_dir(), 'profile_default', 'ipython_kernel_config.py') - with open(ipy_kernel_cfg, 'w') as f: - # This option must be a string, not an int - f.write("c.InteractiveShellApp.extra_extension = 1") - - ipyconsole.create_new_client() - - # Assert that the console is showing an error - qtbot.waitUntil(lambda: ipyconsole.get_clients()[-1].is_error_shown, - timeout=6000) - error_client = ipyconsole.get_clients()[-1] - assert error_client.is_error_shown - - # Assert the error contains the text we expect - webview = error_client.infowidget - if WEBENGINE: - webpage = webview.page() - else: - webpage = webview.page().mainFrame() - qtbot.waitUntil( - lambda: check_text(webpage, "Bad config encountered"), - timeout=6000) + try: + with open(ipy_kernel_cfg, 'w') as f: + # This option must be a string, not an int + f.write("c.InteractiveShellApp.extra_extension = 1") + + ipyconsole.get_widget().close_cached_kernel() + ipyconsole.create_new_client() + + # Assert that the console is showing an error + qtbot.waitUntil(lambda: ipyconsole.get_clients()[-1].is_error_shown, + timeout=6000) + error_client = ipyconsole.get_clients()[-1] + assert error_client.is_error_shown + + # Assert the error contains the text we expect + webview = error_client.infowidget + if WEBENGINE: + webpage = webview.page() + else: + webpage = webview.page().mainFrame() - # Remove bad kernel config file - os.remove(ipy_kernel_cfg) + qtbot.waitUntil( + lambda: check_text(webpage, "Bad config encountered"), + timeout=6000) + finally: + # Remove bad kernel config file + os.remove(ipy_kernel_cfg) @flaky(max_runs=3) diff --git a/spyder/plugins/ipythonconsole/utils/stdfile.py b/spyder/plugins/ipythonconsole/utils/stdfile.py index ae8fb05e38a..c0cb22fa454 100644 --- a/spyder/plugins/ipythonconsole/utils/stdfile.py +++ b/spyder/plugins/ipythonconsole/utils/stdfile.py @@ -13,15 +13,34 @@ # Standard library imports. import codecs import os +import os.path as osp # Local imports from spyder.py3compat import to_text_string from spyder.utils.encoding import get_coding +from spyder.utils.programs import get_temp_dir + + +def std_filename(connection_file, extension, std_dir=None): + """Filename to save kernel output.""" + json_file = osp.basename(connection_file) + file = json_file.split('.json')[0] + extension + if std_dir is not None: + file = osp.join(std_dir, file) + else: + try: + file = osp.join(get_temp_dir(), file) + except (IOError, OSError): + file = None + return file class StdFile: - def __init__(self, filename): - self.filename = filename + def __init__(self, connection_file, extension=None, std_dir=None): + if extension is None: + self.filename = connection_file + else: + self.filename = std_filename(connection_file, extension, std_dir) self._mtime = 0 self._cursor = 0 self._handle = None @@ -92,3 +111,7 @@ def poll_file_change(self): ret_text = text[self._cursor:] self._cursor = len(text) return ret_text + + def copy(self): + """Return a copy.""" + return StdFile(self.filename) diff --git a/spyder/plugins/ipythonconsole/widgets/client.py b/spyder/plugins/ipythonconsole/widgets/client.py index 228b08fd6b1..7e2645b80de 100644 --- a/spyder/plugins/ipythonconsole/widgets/client.py +++ b/spyder/plugins/ipythonconsole/widgets/client.py @@ -39,10 +39,8 @@ from spyder.utils.encoding import get_coding from spyder.utils.environ import RemoteEnvDialog from spyder.utils.palette import QStylePalette -from spyder.utils.programs import get_temp_dir from spyder.utils.qthelpers import add_actions, DialogManager from spyder.py3compat import to_text_string -from spyder.plugins.ipythonconsole.utils.stdfile import StdFile from spyder.plugins.ipythonconsole.widgets import ShellWidget from spyder.widgets.collectionseditor import CollectionsEditor from spyder.widgets.mixins import SaveHistoryMixin @@ -109,7 +107,9 @@ def __init__(self, parent, id_, css_path=None, configuration=None, handlers={}, - std_dir=None): + stderr_obj=None, + stdout_obj=None, + fault_obj=None): super(ClientWidget, self).__init__(parent) SaveHistoryMixin.__init__(self, history_filename) @@ -132,7 +132,6 @@ def __init__(self, parent, id_, self.options_button = options_button self.history = [] self.allow_rename = True - self.std_dir = std_dir self.is_error_shown = False self.error_text = None self.restart_thread = None @@ -180,22 +179,16 @@ def __init__(self, parent, id_, self.dialog_manager = DialogManager() # --- Standard files handling - self.stderr_obj = None - self.stdout_obj = None - self.fault_obj = None + self.stderr_obj = stderr_obj + self.stdout_obj = stdout_obj + self.fault_obj = fault_obj self.std_poll_timer = None - if not self.is_external_kernel: - # Cannot create std files for external kernels - self.stderr_obj = StdFile(self.std_filename('.stderr')) - self.stdout_obj = StdFile(self.std_filename('.stdout')) + if self.stderr_obj is not None or self.stdout_obj is not None: self.std_poll_timer = QTimer(self) self.std_poll_timer.timeout.connect(self.poll_std_file_change) self.std_poll_timer.setInterval(1000) self.std_poll_timer.start() self.shellwidget.executed.connect(self.poll_std_file_change) - if self.hostname is None: - # Cannot read file that is not on this computer - self.fault_obj = StdFile(self.std_filename('.fault')) self.start_successful = False @@ -356,20 +349,6 @@ def _connect_control_signals(self): self.container.find_widget.show) # ----- Public API -------------------------------------------------------- - def std_filename(self, extension): - """Filename to save kernel output.""" - file = None - if self.connection_file is not None: - file = self.kernel_id + extension - if self.std_dir is not None: - file = osp.join(self.std_dir, file) - else: - try: - file = osp.join(get_temp_dir(), file) - except (IOError, OSError): - file = None - return file - @property def kernel_id(self): """Get kernel id.""" @@ -397,31 +376,33 @@ def remove_std_files(self, is_last_client=True): def poll_std_file_change(self): """Check if the stderr or stdout file just changed.""" self.shellwidget.call_kernel().flush_std() - stderr = self.stderr_obj.poll_file_change() starting = self.shellwidget._starting - if stderr: - if self.is_benign_error(stderr): - return - if self.shellwidget.isHidden(): - # Avoid printing the same thing again - if self.error_text != '%s' % stderr: - full_stderr = self.stderr_obj.get_contents() - self.show_kernel_error('%s' % full_stderr) - if starting: - self.shellwidget.banner = ( - stderr + '\n' + self.shellwidget.banner) - else: - self.shellwidget._append_plain_text( - '\n' + stderr, before_prompt=True) - - stdout = self.stdout_obj.poll_file_change() - if stdout: - if starting: - self.shellwidget.banner = ( - stdout + '\n' + self.shellwidget.banner) - else: - self.shellwidget._append_plain_text( - '\n' + stdout, before_prompt=True) + if self.stderr_obj is not None: + stderr = self.stderr_obj.poll_file_change() + if stderr: + if self.is_benign_error(stderr): + return + if self.shellwidget.isHidden(): + # Avoid printing the same thing again + if self.error_text != '%s' % stderr: + full_stderr = self.stderr_obj.get_contents() + self.show_kernel_error('%s' % full_stderr) + if starting: + self.shellwidget.banner = ( + stderr + '\n' + self.shellwidget.banner) + else: + self.shellwidget._append_plain_text( + '\n' + stderr, before_prompt=True) + + if self.stdout_obj is not None: + stdout = self.stdout_obj.poll_file_change() + if stdout: + if starting: + self.shellwidget.banner = ( + stdout + '\n' + self.shellwidget.banner) + else: + self.shellwidget._append_plain_text( + '\n' + stdout, before_prompt=True) def configure_shellwidget(self, give_focus=True): """Configure shellwidget after kernel is connected.""" diff --git a/spyder/plugins/ipythonconsole/widgets/main_widget.py b/spyder/plugins/ipythonconsole/widgets/main_widget.py index ee071eff24a..2d73e56ac6a 100644 --- a/spyder/plugins/ipythonconsole/widgets/main_widget.py +++ b/spyder/plugins/ipythonconsole/widgets/main_widget.py @@ -48,6 +48,7 @@ from spyder.widgets.browser import FrameWebView from spyder.widgets.findreplace import FindReplace from spyder.widgets.tabs import Tabs +from spyder.plugins.ipythonconsole.utils.stdfile import StdFile # Localization @@ -386,6 +387,7 @@ def __init__(self, name=None, plugin=None, parent=None, def on_close(self): self.mainwindow_close = True self.close_clients() + self._cached_kernel_properties = None # ---- PluginMainWidget API and settings handling # ------------------------------------------------------------------------ @@ -1056,6 +1058,9 @@ def _create_client_for_kernel(self, connection_file, hostname, sshkey, known_spyder_kernel = False slave_ord = ord('A') - 1 kernel_manager = None + stderr_obj = None + stdout_obj = None + fault_obj = None for cl in self.clients: if connection_file in cl.connection_file: @@ -1066,6 +1071,12 @@ def _create_client_for_kernel(self, connection_file, hostname, sshkey, master_id = cl.id_['int_id'] is_external_kernel = cl.shellwidget.is_external_kernel known_spyder_kernel = cl.shellwidget.is_spyder_kernel + if cl.stderr_obj: + stderr_obj = cl.stderr_obj.copy() + if cl.stdout_obj: + stdout_obj = cl.stdout_obj.copy() + if cl.fault_obj: + fault_obj = cl.fault_obj.copy() given_name = cl.given_name new_slave_ord = ord(cl.id_['str_id']) if new_slave_ord > slave_ord: @@ -1085,7 +1096,6 @@ def _create_client_for_kernel(self, connection_file, hostname, sshkey, show_elapsed_time = self.get_conf('show_elapsed_time') reset_warning = self.get_conf('show_reset_namespace_warning') ask_before_restart = self.get_conf('ask_before_restart') - std_dir = self._test_dir if self._test_dir else None client = ClientWidget(self, id_=client_id, given_name=given_name, @@ -1105,7 +1115,9 @@ def _create_client_for_kernel(self, connection_file, hostname, sshkey, css_path=self.css_path, configuration=self.CONFIGURATION, handlers=self.registered_spyder_kernel_handlers, - std_dir=std_dir) + stderr_obj=stderr_obj, + stdout_obj=stdout_obj, + fault_obj=fault_obj) # Create kernel client kernel_client = QtKernelClient(connection_file=connection_file) @@ -1489,12 +1501,19 @@ def create_new_client(self, give_focus=True, filename='', is_cython=False, self.master_clients += 1 client_id = dict(int_id=str(self.master_clients), str_id='A') - cf = self._new_connection_file() + std_dir = self._test_dir if self._test_dir else None + cf, km, kc, stderr_obj, stdout_obj = self.get_new_kernel( + is_cython, is_pylab, is_sympy, std_dir=std_dir) + + if cf is not None: + fault_obj = StdFile(cf, '.fault', std_dir) + else: + fault_obj = None + show_elapsed_time = self.get_conf('show_elapsed_time') reset_warning = self.get_conf('show_reset_namespace_warning') ask_before_restart = self.get_conf('ask_before_restart') ask_before_closing = self.get_conf('ask_before_closing') - std_dir = self._test_dir if self._test_dir else None client = ClientWidget(self, id_=client_id, history_filename=get_conf_path('history.py'), config_options=self.config_options(), @@ -1514,7 +1533,9 @@ def create_new_client(self, give_focus=True, filename='', is_cython=False, css_path=self.css_path, configuration=self.CONFIGURATION, handlers=self.registered_spyder_kernel_handlers, - std_dir=std_dir) + stderr_obj=stderr_obj, + stdout_obj=stdout_obj, + fault_obj=fault_obj) self.add_tab( client, name=client.get_name(), filename=filename, @@ -1561,8 +1582,7 @@ def create_new_client(self, give_focus=True, filename='', is_cython=False, ) return - self.connect_client_to_kernel(client, is_cython=is_cython, - is_pylab=is_pylab, is_sympy=is_sympy) + self.connect_client_to_kernel(client, km, kc) if client.shellwidget.kernel_manager is None: return self.register_client(client, give_focus=give_focus) @@ -1590,23 +1610,95 @@ def create_client_for_kernel(self): self._create_client_for_kernel(connection_file, hostname, sshkey, password) - def connect_client_to_kernel(self, client, is_cython=False, - is_pylab=False, is_sympy=False): - """Connect a client to its kernel.""" - connection_file = client.connection_file - stderr_handle = ( - None if self._test_no_stderr else client.stderr_obj.handle) - stdout_handle = ( - None if self._test_no_stderr else client.stdout_obj.handle) + def get_new_kernel(self, is_cython=False, is_pylab=False, + is_sympy=False, std_dir=None): + """Get a new kernel, and cache one for next time.""" + # Cache another kernel for next time. + kernel_spec = self.create_kernel_spec( + is_cython=is_cython, + is_pylab=is_pylab, + is_sympy=is_sympy + ) + + new_kernel = self.create_new_kernel(kernel_spec, std_dir) + if new_kernel[2] is None: + # error + self.close_cached_kernel() + return new_kernel + + # Check cached kernel has the same configuration as is being asked + cached_kernel = None + if self._cached_kernel_properties is not None: + (cached_spec, + cached_env, + cached_argv, + cached_dir, + cached_kernel) = self._cached_kernel_properties + # Call interrupt_mode so the dict will be the same + kernel_spec.interrupt_mode + cached_spec.interrupt_mode + valid = (std_dir == cached_dir + and cached_spec.__dict__ == kernel_spec.__dict__ + and kernel_spec.argv == cached_argv + and kernel_spec.env == cached_env) + if not valid: + # Close the kernel + self.close_cached_kernel() + cached_kernel = None + + # Cache the new kernel + self._cached_kernel_properties = ( + kernel_spec, + kernel_spec.env, + kernel_spec.argv, + std_dir, + new_kernel) + + if cached_kernel is None: + return self.create_new_kernel(kernel_spec, std_dir) + + return cached_kernel + + def close_cached_kernel(self): + """Close the cached kernel.""" + if self._cached_kernel_properties is None: + return + cached_kernel = self._cached_kernel_properties[-1] + _, kernel_manager, _, stderr_obj, stdout_obj = cached_kernel + kernel_manager.stop_restarter() + kernel_manager.shutdown_kernel(now=True) + self._cached_kernel_properties = None + if stderr_obj: + stderr_obj.remove() + if stdout_obj: + stdout_obj.remove() + + def create_new_kernel(self, kernel_spec, std_dir=None): + """Create a new kernel.""" + connection_file = self._new_connection_file() + if connection_file is None: + return None, None, None, None, None + + stderr_obj = None + stderr_handle = None + stdout_obj = None + stdout_handle = None + if not self._test_no_stderr: + stderr_obj = StdFile(connection_file, '.stderr', std_dir) + stderr_handle = stderr_obj.handle + stdout_obj = StdFile(connection_file, '.stdout', std_dir) + stdout_handle = stdout_obj.handle + km, kc = self.create_kernel_manager_and_kernel_client( connection_file, stderr_handle, stdout_handle, - is_cython=is_cython, - is_pylab=is_pylab, - is_sympy=is_sympy, + kernel_spec, ) + return connection_file, km, kc, stderr_obj, stdout_obj + def connect_client_to_kernel(self, client, km, kc): + """Connect a client to its kernel.""" # An error occurred if this is True if isinstance(km, str) and kc is None: client.shellwidget.kernel_manager = None @@ -1821,6 +1913,8 @@ def close_clients(self): open_clients.remove(client) # Close all closing shellwidgets. ShellWidget.wait_all_shutdown() + # Close cached kernel + self.close_cached_kernel() return True def get_client_index_from_id(self, client_id): @@ -1965,15 +2059,8 @@ def create_kernel_spec(self, is_cython=False, def create_kernel_manager_and_kernel_client(self, connection_file, stderr_handle, stdout_handle, - is_cython=False, - is_pylab=False, - is_sympy=False): + kernel_spec): """Create kernel manager and client.""" - # Kernel spec - kernel_spec = self.create_kernel_spec(is_cython=is_cython, - is_pylab=is_pylab, - is_sympy=is_sympy) - # Kernel manager try: kernel_manager = SpyderKernelManager(