diff --git a/.github/scripts/install.sh b/.github/scripts/install.sh index 4f7ade252c3..93e2cf42025 100755 --- a/.github/scripts/install.sh +++ b/.github/scripts/install.sh @@ -19,6 +19,9 @@ if [ "$USE_CONDA" = "true" ]; then # Install dependencies per operating system if [ "$OS" = "win" ]; then + # This is necessary for our tests related to conda envs to pass since + # the release of Mamba 1.1.0 + mamba init mamba env update --file requirements/windows.yml elif [ "$OS" = "macos" ]; then mamba env update --file requirements/macos.yml diff --git a/.github/workflows/test-win.yml b/.github/workflows/test-win.yml index 0620cbf2cb1..14f1ad0bffb 100644 --- a/.github/workflows/test-win.yml +++ b/.github/workflows/test-win.yml @@ -110,6 +110,14 @@ jobs: run: | conda info conda list + # Notes: + # 1. This only works for conda, probably because it has the necessary + # MSYS2 packages to create the connection. + # 2. Check https://github.com/marketplace/actions/debugging-with-tmate for + # usage. + #- name: Setup remote ssh connection + # if: env.USE_CONDA == 'true' + # uses: mxschmitt/action-tmate@v3 - name: Run manifest checks if: env.RUN_BUILD == 'true' shell: bash -l {0} diff --git a/external-deps/python-lsp-server/.gitrepo b/external-deps/python-lsp-server/.gitrepo index 88005d35538..cce1f6d6a52 100644 --- a/external-deps/python-lsp-server/.gitrepo +++ b/external-deps/python-lsp-server/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = https://github.com/python-lsp/python-lsp-server.git branch = develop - commit = ce913f16359f03ffe4a60b6a2fe7ccae4b15bb4a - parent = 154269962fba47cd4d2360307e4a25d743e05c30 + commit = 11b54415d4e1f167c75b19804dfd24b32a325ad1 + parent = 814cbbb8f7ec60452855e14e57261ed5ee739613 method = merge cmdver = 0.4.3 diff --git a/external-deps/python-lsp-server/pylsp/plugins/jedi_completion.py b/external-deps/python-lsp-server/pylsp/plugins/jedi_completion.py index 4c4bc94ed8e..90b4c191aa8 100644 --- a/external-deps/python-lsp-server/pylsp/plugins/jedi_completion.py +++ b/external-deps/python-lsp-server/pylsp/plugins/jedi_completion.py @@ -2,7 +2,7 @@ # Copyright 2021- Python Language Server Contributors. import logging -import os.path as osp +import os import parso @@ -219,10 +219,20 @@ def _format_completion(d, markup_kind: str, include_params=True, resolve=False, if resolve: completion = _resolve_completion(completion, d, markup_kind) + # Adjustments for file completions if d.type == 'path': - path = osp.normpath(d.name) + path = os.path.normpath(d.name) path = path.replace('\\', '\\\\') path = path.replace('/', '\\/') + + # If the completion ends with os.sep, it means it's a directory. So we add an escaped os.sep + # at the end to ease additional file completions. + if d.name.endswith(os.sep): + if os.name == 'nt': + path = path + '\\\\' + else: + path = path + '\\/' + completion['insertText'] = path if include_params and not is_exception_class(d.name): diff --git a/external-deps/python-lsp-server/pylsp/plugins/pylint_lint.py b/external-deps/python-lsp-server/pylsp/plugins/pylint_lint.py index 222cdb85848..76e990c51e8 100644 --- a/external-deps/python-lsp-server/pylsp/plugins/pylint_lint.py +++ b/external-deps/python-lsp-server/pylsp/plugins/pylint_lint.py @@ -9,6 +9,7 @@ import re from subprocess import Popen, PIPE import os +import shlex from pylsp import hookimpl, lsp @@ -91,7 +92,7 @@ def lint(cls, document, is_saved, flags=''): '-f', 'json', document.path - ] + (str(flags).split(' ') if flags else []) + ] + (shlex.split(str(flags)) if flags else []) log.debug("Calling pylint with '%s'", ' '.join(cmd)) with Popen(cmd, stdout=PIPE, stderr=PIPE, @@ -126,6 +127,7 @@ def lint(cls, document, is_saved, flags=''): # The type can be any of: # # * convention + # * information # * error # * fatal # * refactor @@ -151,6 +153,8 @@ def lint(cls, document, is_saved, flags=''): if diag['type'] == 'convention': severity = lsp.DiagnosticSeverity.Information + elif diag['type'] == 'information': + severity = lsp.DiagnosticSeverity.Information elif diag['type'] == 'error': severity = lsp.DiagnosticSeverity.Error elif diag['type'] == 'fatal': diff --git a/external-deps/python-lsp-server/pyproject.toml b/external-deps/python-lsp-server/pyproject.toml index 9ada832e48b..8d384345670 100644 --- a/external-deps/python-lsp-server/pyproject.toml +++ b/external-deps/python-lsp-server/pyproject.toml @@ -33,10 +33,10 @@ all = [ "pycodestyle>=2.9.0,<2.11.0", "pydocstyle>=6.2.0,<6.3.0", "pyflakes>=2.5.0,<3.1.0", - "pylint>=2.5.0", + "pylint>=2.5.0,<3", "rope>1.2.0", "yapf", - "whatthepatch" + "whatthepatch>=1.0.2,<2.0.0" ] autopep8 = ["autopep8>=1.6.0,<1.7.0"] flake8 = ["flake8>=5.0.0,<7"] @@ -44,12 +44,12 @@ mccabe = ["mccabe>=0.7.0,<0.8.0"] pycodestyle = ["pycodestyle>=2.9.0,<2.11.0"] pydocstyle = ["pydocstyle>=6.2.0,<6.3.0"] pyflakes = ["pyflakes>=2.5.0,<3.1.0"] -pylint = ["pylint>=2.5.0"] +pylint = ["pylint>=2.5.0,<3"] rope = ["rope>1.2.0"] yapf = ["yapf", "whatthepatch>=1.0.2,<2.0.0"] websockets = ["websockets>=10.3"] test = [ - "pylint>=2.5.0", + "pylint>=2.5.0,<3", "pytest", "pytest-cov", "coverage", diff --git a/external-deps/python-lsp-server/test/plugins/test_completion.py b/external-deps/python-lsp-server/test/plugins/test_completion.py index 16e278e0578..fc22c34ff84 100644 --- a/external-deps/python-lsp-server/test/plugins/test_completion.py +++ b/external-deps/python-lsp-server/test/plugins/test_completion.py @@ -528,3 +528,26 @@ def foo(): com_position = {'line': 1, 'character': 10} completions = pylsp_jedi_completions(doc._config, doc, com_position) assert completions[0]['label'] == 'foo()' + + +def test_file_completions(workspace, tmpdir): + # Create directory and a file to get completions for them. + # Note: `tmpdir`` is the root dir of the `workspace` fixture. That's why we use + # it here. + tmpdir.mkdir('bar') + file = tmpdir.join('foo.txt') + file.write('baz') + + # Content of doc to test completion + doc_content = '"' + doc = Document(DOC_URI, workspace, doc_content) + + # Request for completions + com_position = {'line': 0, 'character': 1} + completions = pylsp_jedi_completions(doc._config, doc, com_position) + + # Check completions + assert len(completions) == 2 + assert [c['kind'] == lsp.CompletionItemKind.File for c in completions] + assert completions[0]['insertText'] == ('bar' + '\\\\') if os.name == 'nt' else ('bar' + '\\/') + assert completions[1]['insertText'] == 'foo.txt"' diff --git a/spyder/plugins/editor/extensions/closequotes.py b/spyder/plugins/editor/extensions/closequotes.py index c956d89c90b..fe5df1e4ec2 100644 --- a/spyder/plugins/editor/extensions/closequotes.py +++ b/spyder/plugins/editor/extensions/closequotes.py @@ -3,8 +3,11 @@ # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) + """This module contains the close quotes editor extension.""" +import re + # Third party imports from qtpy.QtGui import QTextCursor @@ -27,10 +30,14 @@ def unmatched_quotes_in_line(text): Distributed under the terms of the BSD License. """ + # Remove escaped quotes from counting, but only if they are not to the + # right of a backslash because in that case the backslash is the char that + # is escaped. + text = re.sub(r"(? 1: + # This inserts completions for files or directories + # that start with a dot + start_position = end_position - 1 + elif current_text == '.': + # This is needed if users are asking for file + # completions to the right of a dot when some of its + # name is part of the completed text + cursor_1 = self.textCursor() + found_start = False + + # Select text backwards until we find where the file + # name starts + while not found_start: + cursor_1.movePosition( + QTextCursor.PreviousCharacter, + QTextCursor.KeepAnchor, + ) + + selection = str(cursor_1.selectedText()) + if text.startswith(selection): + found_start = True + + current_text = str(cursor_1.selectedText()) + start_position = cursor_1.selectionStart() + end_position = cursor_1.selectionEnd() + + if not is_auto_completion_character: + # Check if the completion position is in the expected range + if not ( + start_position <= completion_position <= end_position + ): + return + cursor.setPosition(start_position) + + # Remove the word under the cursor + cursor.setPosition(end_position, QTextCursor.KeepAnchor) + else: + # Check if we are in the correct position + if cursor.position() != completion_position: + return else: # Check if we are in the correct position if cursor.position() != completion_position: diff --git a/spyder/plugins/editor/widgets/tests/test_introspection.py b/spyder/plugins/editor/widgets/tests/test_introspection.py index 221046bf9e2..651c6b6dbdf 100644 --- a/spyder/plugins/editor/widgets/tests/test_introspection.py +++ b/spyder/plugins/editor/widgets/tests/test_introspection.py @@ -1193,6 +1193,7 @@ def test_dot_completions(completions_codeeditor, qtbot): """ code_editor, _ = completions_codeeditor completion = code_editor.completion_widget + code_editor.toggle_code_snippets(False) # Import module and check completions are shown for it after writing a dot # after it @@ -1208,37 +1209,60 @@ def test_dot_completions(completions_codeeditor, qtbot): qtbot.wait(500) assert completion.isVisible() + # Select a random entry in the completion widget + entry_index = random.randint(0, 30) + inserted_entry = completion.completion_list[entry_index]['insertText'] + for _ in range(entry_index): + qtbot.keyPress(completion, Qt.Key_Down, delay=50) + + # Insert completion and check that the inserted text is the expected one + qtbot.keyPress(completion, Qt.Key_Enter) + qtbot.wait(500) + assert code_editor.toPlainText() == f'import math\nmath.{inserted_entry}' + @pytest.mark.slow @pytest.mark.order(1) -def test_completions_for_files_that_start_with_numbers( - mock_completions_codeeditor, qtbot): +@pytest.mark.parametrize( + "filename", ['000_test.txt', '.hidden', 'any_file.txt', 'abc.py', + 'part.0.parquet']) +def test_file_completions(filename, mock_completions_codeeditor, qtbot): """ - Test that completions for files that start with numbers are handled as - expected. + Test that completions for files are handled as expected. - This is a regression test for issue spyder-ide/spyder#20156 + This includes a regression test for issue spyder-ide/spyder#20156 """ code_editor, mock_response = mock_completions_codeeditor completion = code_editor.completion_widget - file_name = '000_testing.txt' # Set text to complete and move cursor to the position we want to ask for # completions. - qtbot.keyClicks(code_editor, "'0'") + if filename == 'any_file.txt': + # This checks if we're able to introduce file completions as expected + # for any file when requesting them inside a string. + qtbot.keyClicks(code_editor, "''") + elif filename == 'abc.py': + # This checks that we can insert file completions correctly after a + # dot + qtbot.keyClicks(code_editor, "'abc.'") + elif filename == 'part.0.parquet': + # This checks that we can insert file completions next to a dot when a + # filename has several dots. + qtbot.keyClicks(code_editor, "'part.0.'") + else: + qtbot.keyClicks(code_editor, f"'{filename[0]}'") code_editor.moveCursor(QTextCursor.PreviousCharacter) qtbot.wait(500) - # Complete '0' -> '000_testing.txt' mock_response.side_effect = lambda lang, method, params: {'params': [{ - 'label': f'{file_name}', + 'label': f'{filename}', 'kind': CompletionItemKind.FILE, - 'sortText': (0, f'a{file_name}'), - 'insertText': f'{file_name}', + 'sortText': (0, f'a{filename}'), + 'insertText': f'{filename}', 'data': {'doc_uri': path_as_uri(__file__)}, 'detail': '', 'documentation': '', - 'filterText': f'{file_name}', + 'filterText': f'{filename}', 'insertTextFormat': 1, 'provider': 'LSP', 'resolve': True @@ -1249,7 +1273,85 @@ def test_completions_for_files_that_start_with_numbers( qtbot.keyPress(code_editor, Qt.Key_Tab, delay=300) qtbot.wait(500) - assert code_editor.get_text_with_eol() == f"'{file_name}'" + assert code_editor.get_text_with_eol() == f"'{filename}'" + + +@pytest.mark.slow +@pytest.mark.order(1) +@pytest.mark.parametrize( + "directory", + [ + pytest.param( + '/home', + marks=pytest.mark.skipif( + not sys.platform.startswith('linux'), + reason='Only works on Linux' + ) + ), + pytest.param( + 'C:\\Users', + marks=pytest.mark.skipif( + not os.name == 'nt', + reason='Only works on Windows' + ) + ), + pytest.param( + 'C:\\Windows\\System32', + marks=pytest.mark.skipif( + not os.name == 'nt', + reason='Only works on Windows' + ) + ), + pytest.param( + '/Library/Frameworks', + marks=pytest.mark.skipif( + not sys.platform == 'darwin', + reason='Only works on macOS' + ) + ) + ] +) +def test_directory_completions(directory, completions_codeeditor, qtbot): + """ + Test that directory completions work as expected. + """ + code_editor, _ = completions_codeeditor + completion = code_editor.completion_widget + + qtbot.wait(500) + assert not completion.isVisible() + + if directory == '/home': + qtbot.keyClicks(code_editor, "'/'") + elif directory == 'C:\\Users': + qtbot.keyClicks(code_editor, r"'C:\\'") + elif directory == 'C:\\Windows\\System32': + qtbot.keyClicks(code_editor, r"'C:\\Windows\\'") + else: + qtbot.keyClicks(code_editor, "'/Library/'") + + code_editor.moveCursor(QTextCursor.PreviousCharacter) + with qtbot.waitSignal(completion.sig_show_completions, + timeout=10000): + qtbot.keyPress(code_editor, Qt.Key_Tab, delay=300) + + qtbot.wait(500) + assert completion.isVisible() + + # Select the corresponding entry in the completion widget + selected_entry = False + while not selected_entry: + item = completion.currentItem() + label = item.data(Qt.AccessibleTextRole).split()[0] + if directory.split(os.sep)[-1] in label: + selected_entry = True + else: + qtbot.keyPress(completion, Qt.Key_Down, delay=50) + + # Insert completion and check that the inserted text is the expected one + qtbot.keyPress(completion, Qt.Key_Enter) + qtbot.wait(500) + assert osp.normpath(code_editor.toPlainText()) == f"'{directory}{os.sep}'" if __name__ == '__main__': diff --git a/spyder/plugins/ipythonconsole/utils/tests/test_spyder_kernel.py b/spyder/plugins/ipythonconsole/utils/tests/test_spyder_kernel.py index 79e0f3cbc9b..0503af6a6a0 100644 --- a/spyder/plugins/ipythonconsole/utils/tests/test_spyder_kernel.py +++ b/spyder/plugins/ipythonconsole/utils/tests/test_spyder_kernel.py @@ -43,6 +43,7 @@ def test_kernel_pypath(tmpdir, default_interpreter): # Restore default values CONF.set('main_interpreter', 'default', True) CONF.set('pythonpath_manager', 'spyder_pythonpath', []) + del os.environ['PYTHONPATH'] def test_python_interpreter(tmpdir): diff --git a/spyder/plugins/ipythonconsole/widgets/client.py b/spyder/plugins/ipythonconsole/widgets/client.py index e0c290ac26d..f4c6f976729 100644 --- a/spyder/plugins/ipythonconsole/widgets/client.py +++ b/spyder/plugins/ipythonconsole/widgets/client.py @@ -232,9 +232,14 @@ def _when_prompt_is_ready(self): self.shellwidget.sig_remote_execute.disconnect( self._when_prompt_is_ready) - # It's necessary to do this at this point to avoid giving - # focus to _control at startup. - self._connect_control_signals() + # Notes: + # 1. It's necessary to do this at this point to avoid giving focus to + # _control at startup. + # 2. The try except is needed to avoid some errors in our tests. + try: + self._connect_control_signals() + except RuntimeError: + pass if self.give_focus: self.shellwidget._control.setFocus()