Repository: spyder-ide/spyder

Issue:
Spyder 5.4.1 does not show completions when only a dot is written next to a module
<!--- **PLEASE READ:** When submitting here, please ensure you've completed the following checklist and checked the boxes to confirm. Issue reports without it may be closed. Thanks! --->

### Issue Report Checklist

* [ ] Searched the [issues page](https://github.com/spyder-ide/spyder/issues?q=is%3Aissue) for similar reports
* [ ] Read the relevant sections of the [Spyder Troubleshooting Guide](https://github.com/spyder-ide/spyder/wiki/Troubleshooting-Guide-and-FAQ) and followed its advice
* [ ] Reproduced the issue after updating with ``conda update spyder`` (or ``pip``, if not using Anaconda)
* [ ] Could not reproduce inside ``jupyter qtconsole`` (if console-related)
* [ ] Tried basic troubleshooting (if a bug/error)
    * [ ] Restarted Spyder
    * [ ] Reset preferences with ``spyder --reset``
    * [ ] Reinstalled the latest version of [Anaconda](https://www.anaconda.com/download/)
    * [ ] Tried the other applicable steps from the Troubleshooting Guide
* [ ] Completed the **Problem Description**, **Steps to Reproduce** and **Version** sections below


## Code Completion Issues

Spyder 5.4.1 is supposed to have improvements for code completion and help offered for scientific modules. There seems to be some issues brought about by this change. Code completions do not display after inputting a module name or alias followed by a ```.``` like they used to.

### What steps reproduce the problem?

1.  type in ```import datetime as dt```
2. input ```dt.```
3. 

### What is the expected output? What do you see instead?

The code completion should display as Spyder 5.4.0

![spyder 5.4.0](https://raw.githubusercontent.com/PhilipYip1988/bugsubmissions/main/Screenshot%20from%202022-12-31%2014-05-25.png)

The code completion for a standard module such as datetime is more or less instant. However there is generally a bit of a lag for a scientific library such as numpy, pandas, matplotlib and seaborn.

![spyder 5.4.0](https://raw.githubusercontent.com/PhilipYip1988/bugsubmissions/main/Screenshot%20from%202022-12-31%2014-06-15.png)

In Spyder 5.4.1 nothing displays:

![spyder 5.4.1](https://raw.githubusercontent.com/PhilipYip1988/bugsubmissions/main/Screenshot%20from%202022-12-31%2014-15-41.png)

The code completion now seems to prefer at least one character past the ```.``` in order to show suggestions.
Perhaps this is related to this letter setting which cannot be changed to 0:

![spyder 5.4.1](https://raw.githubusercontent.com/PhilipYip1988/bugsubmissions/main/Screenshot%20from%202022-12-31%2014-30-22.png)

If the following is input:

```
import datetime as dt
dt.a
```

There is no datetime identifier beginning with ```a```. Instead it appears that some builtins identifiers display:

![spyder 5.4.1](https://raw.githubusercontent.com/PhilipYip1988/bugsubmissions/main/Screenshot%20from%202022-12-31%2014-16-00.png)

This differs from the identifiers displayed in the console:

![Spyder 5.4.1](https://raw.githubusercontent.com/PhilipYip1988/bugsubmissions/main/Screenshot%20from%202022-12-31%2014-16-26.png)

Similar behaviour is observed for a scientific library in that at least one character is required for code completion results to display:

![spyder 5.4.1](https://raw.githubusercontent.com/PhilipYip1988/bugsubmissions/main/Screenshot%20from%202022-12-31%2014-17-07.png)


### Paste Traceback/Error Below (if applicable)
<!--- Copy from error dialog or View > Panes > Internal Console --->

```python-traceback

PASTE TRACEBACK HERE

```

## Versions
<!--- You can get this information from Help > About Spyder...
or (if Spyder won't launch) the "conda list" command
from the Anaconda Prompt/Terminal/command line. --->

* Spyder version: 5.4.1
* Python version: Python 3.10.8 64-bit 
* Qt version: Qt 5.15.6
* PyQt version: PyQt5 5.15.7
* Operating System name/version: Linux 6.0.15-300.fc37.x86_64  (Fedora 37)

### Dependencies
<!--- Please go to the menu entry Help > Dependencies,
press the Copy to clipboard button and paste below --->

```
# Mandatory:
atomicwrites >=1.2.0          :  1.4.1 (OK)
chardet >=2.0.0               :  5.1.0 (OK)
cloudpickle >=0.5.0           :  2.2.0 (OK)
cookiecutter >=1.6.0          :  2.1.1 (OK)
diff_match_patch >=20181111   :  20200713 (OK)
intervaltree >=3.0.2          :  3.0.2 (OK)
IPython >=7.31.1;<9.0.0       :  8.7.0 (OK)
jedi >=0.17.2;<0.19.0         :  0.18.2 (OK)
jellyfish >=0.7               :  0.9.0 (OK)
jsonschema >=3.2.0            :  4.17.3 (OK)
keyring >=17.0.0              :  23.13.1 (OK)
nbconvert >=4.0               :  7.2.7 (OK)
numpydoc >=0.6.0              :  1.5.0 (OK)
parso >=0.7.0;<0.9.0          :  0.8.3 (OK)
pexpect >=4.4.0               :  4.8.0 (OK)
pickleshare >=0.4             :  0.7.5 (OK)
psutil >=5.3                  :  5.9.4 (OK)
pygments >=2.0                :  2.13.0 (OK)
pylint >=2.5.0;<3.0           :  2.15.9 (OK)
pylint_venv >=2.1.1           :  2.3.0 (OK)
pyls_spyder >=0.4.0           :  0.4.0 (OK)
pylsp >=1.7.0;<1.8.0          :  1.7.0 (OK)
pylsp_black >=1.2.0           :  1.2.1 (OK)
qdarkstyle >=3.0.2;<3.1.0     :  3.0.3 (OK)
qstylizer >=0.2.2             :  0.2.2 (OK)
qtawesome >=1.2.1             :  1.2.2 (OK)
qtconsole >=5.4.0;<5.5.0      :  5.4.0 (OK)
qtpy >=2.1.0                  :  2.3.0 (OK)
rtree >=0.9.7                 :  1.0.1 (OK)
setuptools >=49.6.0           :  65.6.3 (OK)
sphinx >=0.6.6                :  6.0.0 (OK)
spyder_kernels >=2.4.1;<2.5.0 :  2.4.1 (OK)
textdistance >=4.2.0          :  4.5.0 (OK)
three_merge >=0.1.1           :  0.1.1 (OK)
watchdog >=0.10.3             :  2.2.0 (OK)
xdg >=0.26                    :  0.28 (OK)
zmq >=22.1.0                  :  24.0.1 (OK)

# Optional:
cython >=0.21                 :  0.29.32 (OK)
matplotlib >=3.0.0            :  3.6.2 (OK)
numpy >=1.7                   :  1.24.1 (OK)
pandas >=1.1.1                :  1.5.2 (OK)
scipy >=0.17.0                :  1.9.3 (OK)
sympy >=0.7.3                 :  1.11.1 (OK)
```

The same behaviour is observed on a Windows 11 PC using the standalone Spyder 5.4.1 installer.


Discussion:
Hey @PhilipYip1988, thanks for reporting. I also noticed this regression after we released 5.4.1. We'll fix it in our next version (5.4.2), to be released in February.

Resolving patch:
diff --git a/spyder/plugins/editor/widgets/base.py b/spyder/plugins/editor/widgets/base.py
--- a/spyder/plugins/editor/widgets/base.py
+++ b/spyder/plugins/editor/widgets/base.py
@@ -909,7 +909,8 @@ def select_completion_list(self):
         self.completion_widget.item_selected()
 
     def insert_completion(self, completion, completion_position):
-        """Insert a completion into the editor.
+        """
+        Insert a completion into the editor.
 
         completion_position is where the completion was generated.
 
@@ -944,15 +945,18 @@ def insert_completion(self, completion, completion_position):
                 text = completion['insertText']
             text = to_text_string(text)
 
-            # Get word on the left of the cursor.
-            result = self.get_current_word_and_position(completion=True)
+            # Get word to the left of the cursor.
+            result = self.get_current_word_and_position(
+                completion=True, valid_python_variable=False)
             if result is not None:
                 current_text, start_position = result
                 end_position = start_position + len(current_text)
+
                 # 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)
diff --git a/spyder/plugins/editor/widgets/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor.py
--- a/spyder/plugins/editor/widgets/codeeditor.py
+++ b/spyder/plugins/editor/widgets/codeeditor.py
@@ -605,9 +605,6 @@ def __init__(self, parent=None):
         self.patch = []
         self.leading_whitespaces = {}
 
-        # re-use parent of completion_widget (usually the main window)
-        completion_parent = self.completion_widget.parent()
-
         # Some events should not be triggered during undo/redo
         # such as line stripping
         self.is_undoing = False
@@ -1523,15 +1520,20 @@ def process_completion(self, params):
 
         try:
             completions = params['params']
-            completions = ([] if completions is None else
-                           [completion for completion in completions
-                            if completion.get('insertText')
-                            or completion.get('textEdit', {}).get('newText')])
-            prefix = self.get_current_word(completion=True,
-                                           valid_python_variable=False)
-            if (len(completions) == 1
-                    and completions[0].get('insertText') == prefix
-                    and not completions[0].get('textEdit', {}).get('newText')):
+            completions = (
+                [] if completions is None else
+                [completion for completion in completions
+                 if completion.get('insertText')
+                 or completion.get('textEdit', {}).get('newText')]
+            )
+            prefix = self.get_current_word(
+                completion=True, valid_python_variable=False)
+
+            if (
+                len(completions) == 1 and
+                completions[0].get('insertText') == prefix and
+                not completions[0].get('textEdit', {}).get('newText')
+            ):
                 completions.pop()
 
             replace_end = self.textCursor().position()
@@ -1550,12 +1552,14 @@ def sort_key(completion):
                     text_insertion = completion['textEdit']['newText']
                 else:
                     text_insertion = completion['insertText']
+
                 first_insert_letter = text_insertion[0]
                 case_mismatch = (
                     (first_letter.isupper() and first_insert_letter.islower())
                     or
                     (first_letter.islower() and first_insert_letter.isupper())
                 )
+
                 # False < True, so case matches go first
                 return (case_mismatch, completion['sortText'])
 
@@ -1568,8 +1572,11 @@ def sort_key(completion):
                 if 'textEdit' in completion:
                     c_replace_start = completion['textEdit']['range']['start']
                     c_replace_end = completion['textEdit']['range']['end']
-                    if (c_replace_start == replace_start
-                            and c_replace_end == replace_end):
+
+                    if (
+                        c_replace_start == replace_start and
+                        c_replace_end == replace_end
+                    ):
                         insert_text = completion['textEdit']['newText']
                         completion['filterText'] = insert_text
                         completion['insertText'] = insert_text
@@ -4766,8 +4773,11 @@ def keyPressEvent(self, event):
                 self.completion_widget.hide()
         if key in (Qt.Key_Enter, Qt.Key_Return):
             if not shift and not ctrl:
-                if (self.add_colons_enabled and self.is_python_like() and
-                        self.autoinsert_colons()):
+                if (
+                    self.add_colons_enabled and
+                    self.is_python_like() and
+                    self.autoinsert_colons()
+                ):
                     self.textCursor().beginEditBlock()
                     self.insert_text(':' + self.get_line_separator())
                     if self.strip_trailing_spaces_on_modify:
@@ -4815,16 +4825,21 @@ def keyPressEvent(self, event):
                 trailing_spaces = leading_length - len(leading_text.rstrip())
                 trailing_text = self.get_text('cursor', 'eol')
                 matches = ('()', '[]', '{}', '\'\'', '""')
-                if (not leading_text.strip() and
-                        (leading_length > len(self.indent_chars))):
+                if (
+                    not leading_text.strip() and
+                    (leading_length > len(self.indent_chars))
+                ):
                     if leading_length % len(self.indent_chars) == 0:
                         self.unindent()
                     else:
                         self._handle_keypress_event(event)
                 elif trailing_spaces and not trailing_text.strip():
                     self.remove_suffix(leading_text[-trailing_spaces:])
-                elif (leading_text and trailing_text and
-                        (leading_text[-1] + trailing_text[0] in matches)):
+                elif (
+                    leading_text and
+                    trailing_text and
+                    (leading_text[-1] + trailing_text[0] in matches)
+                ):
                     cursor = self.textCursor()
                     cursor.movePosition(QTextCursor.PreviousCharacter)
                     cursor.movePosition(QTextCursor.NextCharacter,
@@ -4839,27 +4854,36 @@ def keyPressEvent(self, event):
             # redefine this basic action which should have been implemented
             # natively
             self.stdkey_end(shift, ctrl)
-        elif (text in self.auto_completion_characters and
-                self.automatic_completions):
+        elif (
+            text in self.auto_completion_characters and
+            self.automatic_completions
+        ):
             self.insert_text(text)
             if text == ".":
                 if not self.in_comment_or_string():
                     text = self.get_text('sol', 'cursor')
                     last_obj = getobj(text)
                     prev_char = text[-2] if len(text) > 1 else ''
-                    if (prev_char in {')', ']', '}'} or
-                            (last_obj and not last_obj.isdigit())):
+                    if (
+                        prev_char in {')', ']', '}'} or
+                        (last_obj and not last_obj.isdigit())
+                    ):
                         # Completions should be triggered immediately when
                         # an autocompletion character is introduced.
                         self.do_completion(automatic=True)
             else:
                 self.do_completion(automatic=True)
-        elif (text in self.signature_completion_characters and
-                not self.has_selected_text()):
+        elif (
+            text in self.signature_completion_characters and
+            not self.has_selected_text()
+        ):
             self.insert_text(text)
             self.request_signature()
-        elif (key == Qt.Key_Colon and not has_selection and
-                self.auto_unindent_enabled):
+        elif (
+            key == Qt.Key_Colon and
+            not has_selection and
+            self.auto_unindent_enabled
+        ):
             leading_text = self.get_text('sol', 'cursor')
             if leading_text.lstrip() in ('else', 'finally'):
                 ind = lambda txt: len(txt) - len(txt.lstrip())
@@ -4870,8 +4894,13 @@ def keyPressEvent(self, event):
                 if ind(leading_text) == ind(prevtxt):
                     self.unindent(force=True)
             self._handle_keypress_event(event)
-        elif (key == Qt.Key_Space and not shift and not ctrl and not
-                has_selection and self.auto_unindent_enabled):
+        elif (
+            key == Qt.Key_Space and
+            not shift and
+            not ctrl and
+            not has_selection and
+            self.auto_unindent_enabled
+        ):
             self.completion_widget.hide()
             leading_text = self.get_text('sol', 'cursor')
             if leading_text.lstrip() in ('elif', 'except'):
@@ -4956,13 +4985,20 @@ def do_automatic_completions(self):
         is_backspace = (
             self.is_completion_widget_visible() and key == Qt.Key_Backspace)
 
-        if (len(text) >= self.automatic_completions_after_chars
-                and self._last_key_pressed_text or is_backspace):
+        if (
+            (len(text) >= self.automatic_completions_after_chars) and
+            self._last_key_pressed_text or
+            is_backspace
+        ):
             # Perform completion on the fly
             if not self.in_comment_or_string():
                 # Variables can include numbers and underscores
-                if (text.isalpha() or text.isalnum() or '_' in text
-                        or '.' in text):
+                if (
+                    text.isalpha() or
+                    text.isalnum() or
+                    '_' in text
+                    or '.' in text
+                ):
                     self.do_completion(automatic=True)
                     self._last_key_pressed_text = ''
                     self._last_pressed_key = None
diff --git a/spyder/widgets/mixins.py b/spyder/widgets/mixins.py
--- a/spyder/widgets/mixins.py
+++ b/spyder/widgets/mixins.py
@@ -1083,7 +1083,7 @@ def is_special_character(move):
         startpos = cursor.selectionStart()
 
         # Find a valid Python variable name
-        if valid_python_variable and not completion:
+        if valid_python_variable:
             match = re.findall(r'([^\d\W]\w*)', text, re.UNICODE)
             if not match:
                 # This is assumed in several places of our codebase,


Test patch:
diff --git a/spyder/plugins/editor/widgets/tests/test_introspection.py b/spyder/plugins/editor/widgets/tests/test_introspection.py
--- a/spyder/plugins/editor/widgets/tests/test_introspection.py
+++ b/spyder/plugins/editor/widgets/tests/test_introspection.py
@@ -23,6 +23,10 @@
 # Local imports
 from spyder.config.base import running_in_ci, running_in_ci_with_conda
 from spyder.config.utils import is_anaconda
+from spyder.plugins.completion.api import (
+    CompletionRequestTypes, CompletionItemKind)
+from spyder.plugins.completion.providers.languageserver.providers.utils import (
+    path_as_uri)
 from spyder.plugins.completion.providers.kite.utils.status import (
     check_if_kite_installed, check_if_kite_running)
 from spyder.py3compat import PY2
@@ -1178,5 +1182,75 @@ def test_completions_environment(completions_codeeditor, qtbot, tmpdir):
     completion_plugin.after_configuration_update([])
 
 
+@pytest.mark.slow
+@pytest.mark.order(1)
+@flaky(max_runs=5)
+def test_dot_completions(completions_codeeditor, qtbot):
+    """
+    Test that completions after a dot are working as expected.
+
+    This is a regression test for issue spyder-ide/spyder#20285
+    """
+    code_editor, _ = completions_codeeditor
+    completion = code_editor.completion_widget
+
+    # Import module and check completions are shown for it after writing a dot
+    # after it
+    qtbot.keyClicks(code_editor, "import math")
+    qtbot.keyPress(code_editor, Qt.Key_Enter)
+
+    qtbot.wait(500)
+    assert not completion.isVisible()
+
+    with qtbot.waitSignal(completion.sig_show_completions, timeout=10000):
+        qtbot.keyClicks(code_editor, "math.")
+
+    qtbot.wait(500)
+    assert completion.isVisible()
+
+
+@pytest.mark.slow
+@pytest.mark.order(1)
+def test_completions_for_files_that_start_with_numbers(
+        mock_completions_codeeditor, qtbot):
+    """
+    Test that completions for files that start with numbers are handled as
+    expected.
+
+    This is 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'")
+    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}',
+        'kind': CompletionItemKind.FILE,
+        'sortText': (0, f'a{file_name}'),
+        'insertText': f'{file_name}',
+        'data': {'doc_uri': path_as_uri(__file__)},
+        'detail': '',
+        'documentation': '',
+        'filterText': f'{file_name}',
+        'insertTextFormat': 1,
+        'provider': 'LSP',
+        'resolve': True
+    }]} if method == CompletionRequestTypes.DOCUMENT_COMPLETION else None
+
+    with qtbot.waitSignal(completion.sig_show_completions,
+                          timeout=10000):
+        qtbot.keyPress(code_editor, Qt.Key_Tab, delay=300)
+
+    qtbot.wait(500)
+    assert code_editor.get_text_with_eol() == f"'{file_name}'"
+
+
 if __name__ == '__main__':
     pytest.main(['test_introspection.py', '--run-slow'])