Repository: spyder-ide/spyder

Issue:
Completion doc widget and editor/console to help improvements
- [x]  `Ctrl + I` not working for elements with `.` (for example `np.sin`)
- [x] ~~Doc tooltip from current completion not being updated (for example with `pd.Categorical`)~~ I was unable to reproduce this
- [x] Remove `ufunc` signature from completion doc tooltip (for example with `numpy.sign`, `numpy.sin`, `numpy.signbit`, etc).


Discussion:

Resolving patch:
diff --git a/spyder/widgets/mixins.py b/spyder/widgets/mixins.py
--- a/spyder/widgets/mixins.py
+++ b/spyder/widgets/mixins.py
@@ -458,7 +458,11 @@ def _check_signature_and_format(self, signature_or_text, parameter=None,
         signature_or_text = signature_or_text.replace('{', '&#123;')
         signature_or_text = signature_or_text.replace('}', '&#125;')
 
-        lines = signature_or_text.split('\n')
+        # Remove 'ufunc' signature if needed. See spyder-ide/spyder#11821
+        lines = [line for line in signature_or_text.split('\n')
+                 if 'ufunc' not in line]
+        signature_or_text = '\n'.join(lines)
+
         if language == 'python':
             open_func_char = '('
             has_multisignature = False
@@ -942,6 +946,49 @@ def remove_text(self, position_from, position_to):
             self.sig_will_remove_selection.emit(start, end)
         cursor.removeSelectedText()
 
+    def get_current_object(self):
+        """
+        Return current object under cursor.
+
+        Get the text of the current word plus all the characters
+        to the left until a space is found. Used to get text to inspect
+        for Help of elements following dot notation for example
+        np.linalg.norm
+        """
+        cursor = self.textCursor()
+        cursor_pos = cursor.position()
+        current_word = self.get_current_word(help_req=True)
+
+        # Get max position to the left of cursor until space or no more
+        # charaters are left
+        cursor.movePosition(QTextCursor.PreviousCharacter)
+        while self.get_character(cursor.position()).strip():
+            cursor.movePosition(QTextCursor.PreviousCharacter)
+            if cursor.atBlockStart():
+                break
+        cursor_pos_left = cursor.position()
+
+        # Get max position to the right of cursor until space or no more
+        # charaters are left
+        cursor.setPosition(cursor_pos)
+        while self.get_character(cursor.position()).strip():
+            cursor.movePosition(QTextCursor.NextCharacter)
+            if cursor.atBlockEnd():
+                break
+        cursor_pos_right = cursor.position()
+
+        # Get text of the object under the cursor
+        current_text = self.get_text(
+            cursor_pos_left, cursor_pos_right).strip()
+        current_object = current_word
+
+        if current_text and current_word is not None:
+            if current_word != current_text and current_word in current_text:
+                current_object = (
+                    current_text.split(current_word)[0] + current_word)
+
+        return current_object
+
     def get_current_word_and_position(self, completion=False, help_req=False,
                                       valid_python_variable=True):
         """
@@ -971,14 +1018,18 @@ def is_space(move):
                 return not to_text_string(curs.selectedText()).strip()
 
             def is_special_character(move):
-                    curs = self.textCursor()
-                    curs.movePosition(move, QTextCursor.KeepAnchor)
-                    text_cursor = to_text_string(curs.selectedText()).strip()
-                    return len(re.findall(r'([^\d\W]\w*)',
-                                          text_cursor, re.UNICODE)) == 0
+                """Check if a character is a non-letter including numbers."""
+                curs = self.textCursor()
+                curs.movePosition(move, QTextCursor.KeepAnchor)
+                text_cursor = to_text_string(curs.selectedText()).strip()
+                return len(
+                    re.findall(r'([^\d\W]\w*)', text_cursor, re.UNICODE)) == 0
+
             if help_req:
-                if is_special_character(QTextCursor.NoMove):
-                    cursor.movePosition(QTextCursor.WordLeft)
+                if is_special_character(QTextCursor.PreviousCharacter):
+                    cursor.movePosition(QTextCursor.NextCharacter)
+                elif is_special_character(QTextCursor.NextCharacter):
+                    cursor.movePosition(QTextCursor.PreviousCharacter)
             elif not completion:
                 if is_space(QTextCursor.NextCharacter):
                     if is_space(QTextCursor.PreviousCharacter):
@@ -1359,9 +1410,9 @@ def set_help_enabled(self, state):
         self.help_enabled = state
 
     def inspect_current_object(self):
-        text = self.get_current_word(help_req=True)
-        if text:
-            self.show_object_info(text, force=True)
+        current_object = self.get_current_object()
+        if current_object is not None:
+            self.show_object_info(current_object, force=True)
 
     def show_object_info(self, text, call=False, force=False):
         """Show signature calltip and/or docstring in the Help plugin"""


Test patch:
diff --git a/spyder/app/tests/script_unicode.py b/spyder/app/tests/script_unicode.py
--- a/spyder/app/tests/script_unicode.py
+++ b/spyder/app/tests/script_unicode.py
@@ -1,3 +1,5 @@
+import numpy as np
+
 def aaʹbb(a):
     """This function docstring."""
     return True
diff --git a/spyder/app/tests/test_mainwindow.py b/spyder/app/tests/test_mainwindow.py
--- a/spyder/app/tests/test_mainwindow.py
+++ b/spyder/app/tests/test_mainwindow.py
@@ -431,6 +431,45 @@ def test_get_help_combo(main_window, qtbot):
     qtbot.waitUntil(lambda: check_text(webpage, "arange"), timeout=6000)
 
 
+@pytest.mark.slow
+@pytest.mark.skipif(PY2, reason="Invalid definition of function in Python 2.")
+def test_get_help_ipython_console_dot_notation(main_window, qtbot, tmpdir):
+    """
+    Test that Help works when called from the IPython console
+    with dot calls i.e np.sin
+
+    See spyder-ide/spyder#11821
+    """
+    shell = main_window.ipyconsole.get_current_shellwidget()
+    control = shell._control
+    qtbot.waitUntil(lambda: shell._prompt_html is not None,
+                    timeout=SHELL_TIMEOUT)
+
+    # Open test file
+    test_file = osp.join(LOCATION, 'script_unicode.py')
+    main_window.editor.load(test_file)
+    code_editor = main_window.editor.get_focus_widget()
+
+    # Run test file
+    qtbot.keyClick(code_editor, Qt.Key_F5)
+    qtbot.wait(500)
+
+    help_plugin = main_window.help
+    webview = help_plugin.rich_text.webview._webview
+    webpage = webview.page() if WEBENGINE else webview.page().mainFrame()
+
+    # Write function name
+    qtbot.keyClicks(control, u'np.linalg.norm')
+
+    # Get help
+    control.inspect_current_object()
+
+    # Check that a expected text is part of the page
+    qtbot.waitUntil(
+        lambda: check_text(webpage, "Matrix or vector norm."),
+        timeout=6000)
+
+
 @pytest.mark.slow
 @pytest.mark.skipif(PY2, reason="Invalid definition of function in Python 2.")
 def test_get_help_ipython_console_special_characters(
diff --git a/spyder/widgets/tests/test_mixins.py b/spyder/widgets/tests/test_mixins.py
--- a/spyder/widgets/tests/test_mixins.py
+++ b/spyder/widgets/tests/test_mixins.py
@@ -31,6 +31,54 @@ def mixinsbot(qtbot):
 
 # --- Tests
 # -----------------------------------------------------------------------------
+@pytest.mark.parametrize(
+    "test_object", [(u"test.attr = np.linalg.norm()\n",
+                     [(None, 'test'),
+                      (4,    'test'),
+                      (1,    'test.attr'),
+                      (4,    'test.attr'),
+                      (1,     None),
+                      (1,     'np'),
+                      (4,     'np.linalg'),
+                      (6,     'np.linalg'),
+                      (1,     'np.linalg.norm'),
+                      (4,     'np.linalg.norm'),
+                      (1,     None)
+                      ]),
+                    (u"test$attr = np$linalg$norm()\n",
+                     [(None, 'test'),
+                      (4,    'test'),
+                      (1,    'test$attr'),
+                      (4,    'test$attr'),
+                      (1,     None),
+                      (1,     'np'),
+                      (4,     'np$linalg'),
+                      (6,     'np$linalg'),
+                      (1,     'np$linalg$norm'),
+                      (4,     'np$linalg$norm'),
+                      (1,     None)
+                      ])])
+def test_get_current_object(mixinsbot, test_object):
+    """
+    Test that we can get the current object to get help for the console.
+
+    For spyder-ide/spyder#11821
+    """
+    qtbot, widget = mixinsbot
+    get_current_object = widget.get_current_object
+
+    code, matches = test_object
+    widget.setPlainText(code)
+    cursor = widget.textCursor()
+    cursor.setPosition(widget.get_position('sof'))
+
+    for move, match in matches:
+        if move is not None:
+            widget.move_cursor(move)
+        current_word = get_current_object()
+        current_word == match
+
+
 def test_get_current_word(mixinsbot):
     """
     Test that we can get the current word to get help for the editor.