diff --git a/spyder/plugins/editor/widgets/base.py b/spyder/plugins/editor/widgets/base.py index 8236949a0c0..505a5d12eb9 100644 --- a/spyder/plugins/editor/widgets/base.py +++ b/spyder/plugins/editor/widgets/base.py @@ -512,10 +512,11 @@ def get_cell_list(self): return [] def get_selection_as_executable_code(self, cursor=None): - """Get selected text in a way that allows other plugins executed it.""" + """ + Get selected text in a way that allows other plugins to execute it. + """ ls = self.get_line_separator() - - _indent = lambda line: len(line)-len(line.lstrip()) + _indent = lambda line: len(line) - len(line.lstrip()) line_from, line_to = self.get_selection_bounds(cursor) line_col_from, line_col_to = self.get_selection_start_end(cursor) @@ -529,7 +530,7 @@ def get_selection_as_executable_code(self, cursor=None): if len(lines) > 1: # Multiline selection -> eventually fixing indentation original_indent = _indent(self.get_text_line(line_from)) - text = (" "*(original_indent-_indent(lines[0])))+text + text = (" " * (original_indent - _indent(lines[0]))) + text # If there is a common indent to all lines, find it. # Moving from bottom line to top line ensures that blank @@ -538,7 +539,7 @@ def get_selection_as_executable_code(self, cursor=None): min_indent = 999 current_indent = 0 lines = text.split(ls) - for i in range(len(lines)-1, -1, -1): + for i in range(len(lines) - 1, -1, -1): line = lines[i] if line.strip(): current_indent = _indent(line) @@ -577,18 +578,22 @@ def get_cell_as_executable_code(self, cursor=None): """Return cell contents as executable code.""" if cursor is None: cursor = self.textCursor() + ls = self.get_line_separator() cursor, __ = self.select_current_cell(cursor) line_from, __ = self.get_selection_bounds(cursor) + # Get the block for the first cell line start = cursor.selectionStart() block = self.document().findBlock(start) if not is_cell_header(block) and start > 0: block = self.document().findBlock(start - 1) + # Get text text, off_pos, col_pos = self.get_selection_as_executable_code(cursor) if text is not None: text = ls * line_from + text + return text, block, off_pos, col_pos def select_current_cell(self, cursor=None): diff --git a/spyder/plugins/editor/widgets/editorstack/editorstack.py b/spyder/plugins/editor/widgets/editorstack/editorstack.py index 3083a6c86b9..a297ae5cc9d 100644 --- a/spyder/plugins/editor/widgets/editorstack/editorstack.py +++ b/spyder/plugins/editor/widgets/editorstack/editorstack.py @@ -2731,7 +2731,7 @@ def format_document_or_selection(self, index=None): # ------ Run def _get_lines_cursor(self, direction): - """ Select and return all lines from cursor in given direction""" + """Select and return all lines from cursor in a given direction.""" editor = self.get_current_editor() finfo = self.get_current_finfo() enc = finfo.encoding @@ -2766,28 +2766,20 @@ def get_from_current_line(self): return self._get_lines_cursor(direction='down') def get_selection(self): - """ - Get selected text or current line in console. - - If some text is selected, then execute that text in console. - - If no text is selected, then execute current line, unless current line - is empty. Then, advance cursor to next line. If cursor is on last line - and that line is not empty, then add a new blank line and move the - cursor there. If cursor is on last line and that line is empty, then do - not move cursor. - """ + """Get selected text or current line in the editor.""" editor = self.get_current_editor() encoding = self.get_current_finfo().encoding + + # Get selection selection = editor.get_selection_as_executable_code() if selection: text, off_pos, line_col_pos = selection return text, off_pos, line_col_pos, encoding + # Get current line if no selection line_col_from, line_col_to = editor.get_current_line_bounds() line_off_from, line_off_to = editor.get_current_line_offsets() - line = editor.get_current_line() - text = line.lstrip() + text = editor.get_current_line() return ( text, (line_off_from, line_off_to), @@ -2807,7 +2799,7 @@ def advance_line(self): editor.move_cursor_to_next('line', 'down') def get_current_cell(self): - """Get current cell attributes.""" + """Get current cell.""" text, block, off_pos, line_col_pos = ( self.get_current_editor().get_cell_as_executable_code()) encoding = self.get_current_finfo().encoding @@ -2815,9 +2807,13 @@ def get_current_cell(self): return text, off_pos, line_col_pos, name, encoding def advance_cell(self, reverse=False): - """Advance to the next cell. + """ + Advance to the next cell. - reverse = True --> go to previous cell. + Parameters + ---------- + reverse: bool, optional + If True, go to the previous cell. """ if not reverse: move_func = self.get_current_editor().go_to_next_cell diff --git a/spyder/plugins/ipythonconsole/plugin.py b/spyder/plugins/ipythonconsole/plugin.py index 7c32cc7baea..fc888bb11e1 100644 --- a/spyder/plugins/ipythonconsole/plugin.py +++ b/spyder/plugins/ipythonconsole/plugin.py @@ -817,9 +817,16 @@ def execute_code(self, lines, current_client=True, clear_variables=False): clear_variables=clear_variables) def run_selection(self, lines): - """Execute selected lines in the current console.""" + """ + Execute selected lines in the current console. + + Parameters + ---------- + lines : str + Code lines to run. + """ self.sig_unmaximize_plugin_requested.emit() - self.get_widget().execute_code(lines) + self.get_widget().execute_code(lines, check_line_by_line=True) # ---- For working directory and path management def set_current_client_working_directory(self, directory): diff --git a/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py b/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py index de62590f99a..768842b3fba 100644 --- a/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py +++ b/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py @@ -16,7 +16,7 @@ import re import shutil import sys -from textwrap import dedent +from textwrap import dedent, indent # Third party imports from ipykernel._version import __version__ as ipykernel_version @@ -2151,11 +2151,6 @@ def test_run_script(ipyconsole, qtbot, tmp_path): not is_anaconda(), reason="Only works with Anaconda") def test_show_spyder_kernels_error_on_restart(ipyconsole, qtbot): """Test that we show Spyder-kernels error message on restarts.""" - # Wait until the window is fully up - shell = ipyconsole.get_current_shellwidget() - qtbot.waitUntil( - lambda: shell._prompt_html is not None, timeout=SHELL_TIMEOUT) - # Point to an interpreter without Spyder-kernels ipyconsole.set_conf('default', False, section='main_interpreter') pyexec = get_list_conda_envs()['conda: base'][0] @@ -2187,5 +2182,115 @@ def test_show_spyder_kernels_error_on_restart(ipyconsole, qtbot): assert not main_widget.show_time_action.isEnabled() +def test_line_by_line_execution(ipyconsole, qtbot): + """Check that we can run multiline statements line by line.""" + shell = ipyconsole.get_current_shellwidget() + control = shell._control + + # Check that running code line by line works as expected + code = dedent(""" + for i in range(2): + for j in range(2): + print(i, j) + print('foo') + + """) + + with qtbot.waitSignal(shell.executed): + for line in code.splitlines()[1:]: + ipyconsole.run_selection(line) + + assert '0 1\nfoo' in control.toPlainText() + + with qtbot.waitSignal(shell.sig_prompt_ready): + shell.clear_console() + + # Check that running different complete statements executes them + # immediately and don't show the continuation prompt + with qtbot.waitSignal(shell.executed): + ipyconsole.run_selection('a = 10') + + assert shell.get_value('a') == 10 + assert '...' not in control.toPlainText() + + with qtbot.waitSignal(shell.sig_prompt_ready): + shell.clear_console() + + with qtbot.waitSignal(shell.executed): + ipyconsole.run_selection("print('foo')") + + assert 'foo' == control.toPlainText().splitlines()[-3] + assert '...' not in control.toPlainText() + + with qtbot.waitSignal(shell.sig_prompt_ready): + shell.clear_console() + + with qtbot.waitSignal(shell.executed): + ipyconsole.run_selection("import math") + + assert 'error' not in control.toPlainText().lower() + assert '...' not in control.toPlainText() + + with qtbot.waitSignal(shell.sig_prompt_ready): + shell.clear_console() + + # Check that running indented code works as expected + code1 = indent(code, ' ' * 4) + + with qtbot.waitSignal(shell.executed): + for line in code1.splitlines()[1:]: + ipyconsole.run_selection(line) + + assert '0 1\nfoo' in control.toPlainText() + + with qtbot.waitSignal(shell.sig_prompt_ready): + shell.clear_console() + + # Check that trying to run lines with less indentation than the initial + # block with which we started runs what's in the buffer. + code2 = dedent(""" + for i in range(2): + print('foo') + for j in range(2): + if j > 0: + print(j) + else: + print('bar') + print('baz') + """) + + with qtbot.waitSignal(shell.executed): + for line in code2.splitlines()[3:]: + ipyconsole.run_selection(line) + + assert 'foo' not in control.toPlainText() + assert 'bar\n1' in control.toPlainText() + assert 'baz' not in control.toPlainText() + + with qtbot.waitSignal(shell.sig_prompt_ready): + shell.clear_console() + + # Check that we can execute complex multiline assignments + code3 = dedent(""" + d = { + 'a': { + 'b': 1, + 'c': 2 + }, + 'd': { + 'e': 3, + 'f': 4 + } + } + + """) + + with qtbot.waitSignal(shell.executed): + for line in code3.splitlines()[1:]: + ipyconsole.run_selection(line) + + assert shell.get_value('d') + + if __name__ == "__main__": pytest.main() diff --git a/spyder/plugins/ipythonconsole/widgets/main_widget.py b/spyder/plugins/ipythonconsole/widgets/main_widget.py index 6e1bc063174..41f646f93d1 100644 --- a/spyder/plugins/ipythonconsole/widgets/main_widget.py +++ b/spyder/plugins/ipythonconsole/widgets/main_widget.py @@ -2157,16 +2157,16 @@ def update_active_project_path(self, active_project_path): # ---- For execution def execute_code(self, lines, current_client=True, clear_variables=False, - shellwidget=None): + shellwidget=None, check_line_by_line=False): """Execute code instructions.""" if current_client: sw = self.get_current_shellwidget() else: sw = shellwidget + if sw is not None: if not current_client: - # Clear console and reset namespace for - # dedicated clients. + # Clear console and reset namespace for dedicated clients. # See spyder-ide/spyder#5748. try: sw.sig_prompt_ready.disconnect() @@ -2177,6 +2177,13 @@ def execute_code(self, lines, current_client=True, clear_variables=False, elif current_client and clear_variables: sw.reset_namespace(warning=False) + # If the user is trying to execute code line by line, we need to + # call a special method to do it. + # Fixes spyder-ide/spyder#4431. + if check_line_by_line and len(lines.splitlines()) <= 1: + sw.execute_line_by_line(lines) + return + # Needed to handle an error when kernel_client is none. # See spyder-ide/spyder#6308. try: diff --git a/spyder/plugins/ipythonconsole/widgets/shell.py b/spyder/plugins/ipythonconsole/widgets/shell.py index e7179fd76e2..467ede5f80f 100644 --- a/spyder/plugins/ipythonconsole/widgets/shell.py +++ b/spyder/plugins/ipythonconsole/widgets/shell.py @@ -15,6 +15,7 @@ from textwrap import dedent # Third party imports +from IPython.core.inputtransformer2 import TransformerManager from qtpy.QtCore import Signal, Slot from qtpy.QtWidgets import QMessageBox from qtpy import QtCore, QtWidgets, QtGui @@ -142,11 +143,13 @@ def __init__(self, ipyclient, additional_options, interpreter_versions, self.custom_page_control = PageControlWidget self.custom_edit = True - super(ShellWidget, self).__init__(*args, **kw) + super().__init__(*args, **kw) self.ipyclient = ipyclient self.additional_options = additional_options self.interpreter_versions = interpreter_versions self.special_kernel = special_kernel + self._initial_line_indent_level = None + self._transformer_manager = TransformerManager() # Keyboard shortcuts # Registered here to use shellwidget as the parent @@ -473,20 +476,73 @@ def interrupt_kernel(self): "kernel I did not start.
") ) - def execute(self, source=None, hidden=False, interactive=False): - """ - Executes source or the input buffer, possibly prompting for more - input. - """ - # Needed for cases where there is no kernel initialized but - # an execution is triggered like when setting initial configs. - # See spyder-ide/spyder#16896 - if self.kernel_client is None: - return + def execute_line_by_line(self, line: str): + """Execute code when sent from the editor in a line by line basis.""" + # If the console is busy, we can't send lines for execution because the + # input buffer is already filled. if self._executing: - self._execute_queue.append((source, hidden, interactive)) return - super(ShellWidget, self).execute(source, hidden, interactive) + + if line.lstrip().startswith("#"): + # Treat comments as complete statements. `check_complete` doesn't + # do that in some cases. + complete_state = "complete" + else: + complete_state, __ = self._transformer_manager.check_complete(line) + + line_indent_level = len(line) - len(line.lstrip()) + if self._initial_line_indent_level is None: + self._initial_line_indent_level = line_indent_level + + indent_diff = line_indent_level - self._initial_line_indent_level + + if complete_state == 'incomplete' and indent_diff >= 0: + # If the line is incomplete and has the same or greater indentation + # level than the one with which we started, we insert it without + # that level. + self._insert_plain_text_into_buffer( + self._get_cursor(), + line[self._initial_line_indent_level:] + '\n' + ) + self._set_cursor(self._get_end_cursor()) + else: + execute = False + add_eol = True + + if ( + # If the buffer is empty and the line is complete, we can + # directly execute it. This is useful to evaluate simple + # assignments. + self._get_input_buffer() == '' + # If the indentation level of the current line is less than the + # one with which we started, it means the block is over and we + # need to execute it. + or indent_diff < 0 + # If the buffer has content but the line is empty, we execute + # what's present in the buffer. This means that we assume an + # empty line works as a block separator. + or (self._get_input_buffer() != '' and line.strip() == '') + ): + execute = True + + # Don't add an eol if the line is complete and can be executed + # immediately. + if self._get_input_buffer() == '': + add_eol = False + + # Don't insert empty lines because they serve as block separators. + # Also only insert lines at the same or greater indentation level + # than the block we're trying to execute. + if line.strip() != '' and indent_diff >= 0: + self._insert_plain_text_into_buffer( + self._get_cursor(), + line[self._initial_line_indent_level:] + + ('\n' if add_eol else '') + ) + + if execute: + self._initial_line_indent_level = None + self.execute() def is_running(self): """Check if shell is running.""" @@ -1037,15 +1093,20 @@ def insert_horizontal_ruler(self): self._control.insert_horizontal_ruler() # ---- Public methods (overrode by us) ------------------------------------ - def _event_filter_console_keypress(self, event): - """Filter events to send to qtconsole code.""" - key = event.key() - if self._control_key_down(event.modifiers(), include_command=False): - if key == QtCore.Qt.Key_Period: - # Do not use ctrl + . to restart kernel - # Handled by IPythonConsoleWidget - return False - return super()._event_filter_console_keypress(event) + def execute(self, source=None, hidden=False, interactive=False): + """ + Executes source or the input buffer, possibly prompting for more + input. + """ + # Needed for cases where there is no kernel initialized but + # an execution is triggered like when setting initial configs. + # See spyder-ide/spyder#16896 + if self.kernel_client is None: + return + if self._executing: + self._execute_queue.append((source, hidden, interactive)) + return + super().execute(source, hidden, interactive) def adjust_indentation(self, line, indent_adjustment): """Adjust indentation.""" @@ -1148,6 +1209,16 @@ def cut(self): self._save_clipboard_indentation() # ---- Private API (overrode by us) --------------------------------------- + def _event_filter_console_keypress(self, event): + """Filter events to send to qtconsole code.""" + key = event.key() + if self._control_key_down(event.modifiers(), include_command=False): + if key == QtCore.Qt.Key_Period: + # Do not use ctrl + . to restart kernel + # Handled by IPythonConsoleWidget + return False + return super()._event_filter_console_keypress(event) + def _handle_execute_reply(self, msg): """ Reimplemented to handle communications between Spyder diff --git a/spyder/widgets/mixins.py b/spyder/widgets/mixins.py index 10a1afcf290..892c42f1048 100644 --- a/spyder/widgets/mixins.py +++ b/spyder/widgets/mixins.py @@ -1116,7 +1116,13 @@ def get_current_line(self): """Return current line's text.""" cursor = self.textCursor() cursor.select(QTextCursor.BlockUnderCursor) - return to_text_string(cursor.selectedText()) + line = str(cursor.selectedText()) + + # Remove EOL's at the beginning of the line added by Qt + if line and line[0] in EOL_SYMBOLS: + line = line[1:] + + return line def get_current_line_bounds(self): """Return the (line, column) bounds for the current line."""