Repository: spyder-ide/spyder

Issue:
`ValueError` when accessing second level Numpy objects in Help pane
## Description

### What steps will reproduce the problem?

<!--- You can use Markdown here --->

when you go to help in environment console and in object you type libraryname.sublibraryname (Example: numpy.where in my case) it prompts error

### Traceback
```python-traceback
  File "C:\Users\admin\anaconda3\Lib\site-packages\spyder\plugins\help\widgets.py", line 906, in force_refresh
    self.set_object_text(None, force_refresh=True)
  File "C:\Users\admin\anaconda3\Lib\site-packages\spyder\plugins\help\widgets.py", line 936, in set_object_text
    found = self.show_help(text, ignore_unknown=ignore_unknown)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\admin\anaconda3\Lib\site-packages\spyder\plugins\help\widgets.py", line 1060, in show_help
    doc = shell.get_doc(obj_text)
          ^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\admin\anaconda3\Lib\site-packages\spyder\plugins\ipythonconsole\widgets\help.py", line 140, in get_doc
    return self.call_kernel(blocking=True).get_doc(objtxt)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\admin\anaconda3\Lib\site-packages\spyder_kernels\comms\commbase.py", line 557, in __call__
    return self._comms_wrapper._get_call_return_value(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\admin\anaconda3\Lib\site-packages\spyder\plugins\ipythonconsole\comms\kernelcomm.py", line 224, in _get_call_return_value
    return super(KernelComm, self)._get_call_return_value(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\admin\anaconda3\Lib\site-packages\spyder_kernels\comms\commbase.py", line 440, in _get_call_return_value
    return self._sync_error(reply['value'])
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\admin\anaconda3\Lib\site-packages\spyder_kernels\comms\commbase.py", line 496, in _sync_error
    error_wrapper.raise_error()
  File "C:\Users\admin\anaconda3\Lib\site-packages\spyder_kernels\comms\commbase.py", line 90, in raise_error
    raise self.etype(self)
Exception in comms call get_doc:

  File "C:\Users\admin\anaconda3\Lib\site-packages\spyder_kernels\comms\commbase.py", line 343, in _handle_remote_call
    return_value = self._remote_callback(
                   ^^^^^^^^^^^^^^^^^^^^^^

  File "C:\Users\admin\anaconda3\Lib\site-packages\spyder_kernels\comms\frontendcomm.py", line 273, in _remote_callback
    return super(FrontendComm, self)._remote_callback(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  File "C:\Users\admin\anaconda3\Lib\site-packages\spyder_kernels\comms\commbase.py", line 356, in _remote_callback
    return self._remote_call_handlers[call_name](
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  File "C:\Users\admin\anaconda3\Lib\site-packages\spyder_kernels\console\kernel.py", line 370, in get_doc
    return getdoc(obj)
           ^^^^^^^^^^^

  File "C:\Users\admin\anaconda3\Lib\site-packages\spyder_kernels\utils\dochelpers.py", line 123, in getdoc
    sig = inspect.signature(obj)
          ^^^^^^^^^^^^^^^^^^^^^^

  File "C:\Users\admin\anaconda3\Lib\inspect.py", line 3279, in signature
    return Signature.from_callable(obj, follow_wrapped=follow_wrapped,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  File "C:\Users\admin\anaconda3\Lib\inspect.py", line 3027, in from_callable
    return _signature_from_callable(obj, sigcls=cls,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  File "C:\Users\admin\anaconda3\Lib\inspect.py", line 2519, in _signature_from_callable
    return _signature_from_builtin(sigcls, obj,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  File "C:\Users\admin\anaconda3\Lib\inspect.py", line 2326, in _signature_from_builtin
    raise ValueError("no signature found for builtin {!r}".format(func))

ValueError: no signature found for builtin <built-in function where>

```

## Versions

* Spyder version: 5.4.3  (conda)
* Python version: 3.11.3 64-bit
* Qt version: 5.15.2
* PyQt5 version: 5.15.7
* Operating System: Windows 10

### Dependencies

```
# Mandatory:
atomicwrites >=1.2.0                             :  1.4.0 (OK)
chardet >=2.0.0                                  :  4.0.0 (OK)
cloudpickle >=0.5.0                              :  2.2.1 (OK)
cookiecutter >=1.6.0                             :  1.7.3 (OK)
diff_match_patch >=20181111                      :  20200713 (OK)
intervaltree >=3.0.2                             :  3.1.0 (OK)
IPython >=7.31.1,<9.0.0,!=8.8.0,!=8.9.0,!=8.10.0 :  8.12.0 (OK)
jedi >=0.17.2,<0.19.0                            :  0.18.1 (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                                  :  6.5.4 (OK)
numpydoc >=0.6.0                                 :  1.5.0 (OK)
paramiko >=2.4.0                                 :  2.8.1 (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.0 (OK)
pygments >=2.0                                   :  2.15.1 (OK)
pylint >=2.5.0,<3.0                              :  2.16.2 (OK)
pylint_venv >=2.1.1                              :  2.3.0 (OK)
pyls_spyder >=0.4.0                              :  0.4.0 (OK)
pylsp >=1.7.2,<1.8.0                             :  1.7.2 (OK)
pylsp_black >=1.2.0                              :  1.2.1 (OK)
qdarkstyle >=3.0.2,<3.2.0                        :  3.0.2 (OK)
qstylizer >=0.2.2                                :  0.2.2 (OK)
qtawesome >=1.2.1                                :  1.2.2 (OK)
qtconsole >=5.4.2,<5.5.0                         :  5.4.2 (OK)
qtpy >=2.1.0                                     :  2.2.0 (OK)
rtree >=0.9.7                                    :  1.0.1 (OK)
setuptools >=49.6.0                              :  67.8.0 (OK)
sphinx >=0.6.6                                   :  5.0.2 (OK)
spyder_kernels >=2.4.3,<2.5.0                    :  2.4.3 (OK)
textdistance >=4.2.0                             :  4.2.1 (OK)
three_merge >=0.1.1                              :  0.1.1 (OK)
watchdog >=0.10.3                                :  2.1.6 (OK)
zmq >=22.1.0                                     :  25.1.0 (OK)

# Optional:
cython >=0.21                                    :  None (NOK)
matplotlib >=3.0.0                               :  3.7.1 (OK)
numpy >=1.7                                      :  1.24.3 (OK)
pandas >=1.1.1                                   :  1.5.3 (OK)
scipy >=0.17.0                                   :  1.10.1 (OK)
sympy >=0.7.3                                    :  1.11.1 (OK)
```



Discussion:
Hey @being-legen9, thanks for reporting. I could easily reproduce this problem by following the instructions you posted above.

We'll fix it in our next version (5.5.0), to be released in a couple of months.
Thanks @ccordoba12 . I just wanted to say that this was the first time I have ever found out a bug. So can I please get some details whether it's severity was low or high ? and also I will love to contribute.
> I just wanted to say that this was the first time I have ever found out a bug.

Ok, that's very good to know. Thanks for mentioning it!

> So can I please get some details whether it's severity was low or high ?

I'd say it's medium because it affects the Help pane for all objects that don't have a signature (like `numpy.where`).

> and also I will love to contribute.

Great! From this line in the traceback above:

```python-traceback
    File "C:\Users\admin\anaconda3\Lib\site-packages\spyder_kernels\utils\dochelpers.py", line 123, in getdoc
        sig = inspect.signature(obj)
              ^^^^^^^^^^^^^^^^^^^^^^
```

you can see that the error is generated in the [Spyder-kernels](https://github.com/spyder-ide/spyder-kernels) package. So, I think the way to solve it is by adding a try/except around line 123 and perhaps assing an empty string to `sig`.

But for this to be added to our code, you need to do a coordinated pull request between Spyder-kernels and Spyder. That process is [described](https://github.com/spyder-ide/spyder/blob/master/CONTRIBUTING.md#making-contributions-that-depend-on-pull-requests-in-spyder-kernels) in our Contributing guide.

Let us know how that goes!
Hello @ccordoba12 , I tried fixing it but I am sorry I can't fix it. 
No worries, that's fine. We'll fix it in our next version (5.5.0), to be released in a couple of months.

Resolving patch:
diff --git a/external-deps/spyder-kernels/spyder_kernels/utils/dochelpers.py b/external-deps/spyder-kernels/spyder_kernels/utils/dochelpers.py
--- a/external-deps/spyder-kernels/spyder_kernels/utils/dochelpers.py
+++ b/external-deps/spyder-kernels/spyder_kernels/utils/dochelpers.py
@@ -120,7 +120,15 @@ def getdoc(obj):
                     args, varargs, varkw, defaults,
                     formatvalue=lambda o:'='+repr(o))
             else:
-                sig = inspect.signature(obj)
+                # This is necessary to catch errors for objects without a
+                # signature, like numpy.where.
+                # Fixes spyder-ide/spyder#21148
+                try:
+                    sig = inspect.signature(obj)
+                except ValueError:
+                    sig = getargspecfromtext(doc['docstring'])
+                    if not sig:
+                        sig = '(...)'
                 doc['argspec'] = str(sig)
             if name == '<lambda>':
                 doc['name'] = name + ' lambda '
@@ -171,51 +179,87 @@ def getsource(obj):
 
 
 def getsignaturefromtext(text, objname):
-    """Get object signatures from text (object documentation)
-    Return a list containing a single string in most cases
-    Example of multiple signatures: PyQt5 objects"""
+    """Get object signature from text (i.e. object documentation)."""
     if isinstance(text, dict):
         text = text.get('docstring', '')
+
     # Regexps
-    oneline_re = objname + r'\([^\)].+?(?<=[\w\]\}\'"])\)(?!,)'
-    multiline_re = objname + r'\([^\)]+(?<=[\w\]\}\'"])\)(?!,)'
-    multiline_end_parenleft_re = r'(%s\([^\)]+(\),\n.+)+(?<=[\w\]\}\'"])\))'
+    args_re = r'(\(.+?\))'
+    if objname:
+        signature_re = objname + args_re
+    else:
+        identifier_re = r'(\w+)'
+        signature_re = identifier_re + args_re
+
     # Grabbing signatures
     if not text:
         text = ''
-    sigs_1 = re.findall(oneline_re + '|' + multiline_re, text)
-    sigs_2 = [g[0] for g in re.findall(multiline_end_parenleft_re % objname, text)]
-    all_sigs = sigs_1 + sigs_2
+
+    sigs = re.findall(signature_re, text)
+
     # The most relevant signature is usually the first one. There could be
-    # others in doctests but those are not so important
-    if all_sigs:
-        return all_sigs[0]
-    else:
-        return ''
+    # others in doctests or other places, but those are not so important.
+    sig = ''
+    if sigs:
+        if PY2:
+            # We don't have an easy way to check if the identifier detected by
+            # signature_re is a valid one in Python 2. So, we simply select the
+            # first match.
+            sig = sigs[0] if objname else sigs[0][1]
+        else:
+            # Default signatures returned by IPython.
+            # Notes:
+            # * These are not real signatures but only used to provide a
+            #   placeholder.
+            # * We skip them if we can find other signatures in `text`.
+            # * This is necessary because we also use this function in Spyder
+            #   to parse the content of inspect replies that come from the
+            #   kernel, which can include these signatures.
+            default_ipy_sigs = [
+                '(*args, **kwargs)',
+                '(self, /, *args, **kwargs)'
+            ]
+
+            if objname:
+                real_sigs = [s for s in sigs if s not in default_ipy_sigs]
+
+                if real_sigs:
+                    sig = real_sigs[0]
+                else:
+                    sig = sigs[0]
+            else:
+                valid_sigs = [s for s in sigs if s[0].isidentifier()]
+
+                if valid_sigs:
+                    real_sigs = [
+                        s for s in valid_sigs if s[1] not in default_ipy_sigs
+                    ]
 
-# Fix for Issue 1953
-# TODO: Add more signatures and remove this hack in 2.4
-getsignaturesfromtext = getsignaturefromtext
+                    if real_sigs:
+                        sig = real_sigs[0][1]
+                    else:
+                        sig = valid_sigs[0][1]
+
+    return sig
 
 
 def getargspecfromtext(text):
     """
     Try to get the formatted argspec of a callable from the first block of its
-    docstring
+    docstring.
     
-    This will return something like
-    '(foo, bar, k=1)'
+    This will return something like `(x, y, k=1)`.
     """
     blocks = text.split("\n\n")
-    first_block = blocks[0].strip()
+    first_block = blocks[0].strip().replace('\n', '')
     return getsignaturefromtext(first_block, '')
 
 
 def getargsfromtext(text, objname):
-    """Get arguments from text (object documentation)"""
+    """Get arguments from text (object documentation)."""
     signature = getsignaturefromtext(text, objname)
     if signature:
-        argtxt = signature[signature.find('(')+1:-1]
+        argtxt = signature[signature.find('(') + 1:-1]
         return argtxt.split(',')
 
 
@@ -330,20 +374,3 @@ def isdefined(obj, force_import=False, namespace=None):
                 return False
         base += '.'+attr
     return True
-    
-
-if __name__ == "__main__":
-    class Test(object):
-        def method(self, x, y=2):
-            pass
-    print(getargtxt(Test.__init__))  # spyder: test-skip
-    print(getargtxt(Test.method))  # spyder: test-skip
-    print(isdefined('numpy.take', force_import=True))  # spyder: test-skip
-    print(isdefined('__import__'))  # spyder: test-skip
-    print(isdefined('.keys', force_import=True))  # spyder: test-skip
-    print(getobj('globals'))  # spyder: test-skip
-    print(getobj('globals().keys'))  # spyder: test-skip
-    print(getobj('+scipy.signal.'))  # spyder: test-skip
-    print(getobj('4.'))  # spyder: test-skip
-    print(getdoc(sorted))  # spyder: test-skip
-    print(getargtxt(sorted))  # spyder: test-skip
diff --git a/spyder/plugins/application/container.py b/spyder/plugins/application/container.py
--- a/spyder/plugins/application/container.py
+++ b/spyder/plugins/application/container.py
@@ -345,14 +345,19 @@ def _check_updates_ready(self):
 
                 if os.name == "nt":
                     if is_anaconda():
-                        msg += _("Run the following commands in the Anaconda "
-                                 "prompt to update manually:<br><br>")
+                        msg += _("Run the following command or commands in "
+                                 "the Anaconda prompt to update manually:"
+                                 "<br><br>")
                     else:
-                        msg += _("Run the following commands in a cmd prompt "
+                        msg += _("Run the following command in a cmd prompt "
                                  "to update manually:<br><br>")
                 else:
-                    msg += _("Run the following commands in a terminal to "
-                             "update manually:<br><br>")
+                    if is_anaconda():
+                        msg += _("Run the following command or commands in a "
+                                 "terminal to update manually:<br><br>")
+                    else:
+                        msg += _("Run the following command in a terminal to "
+                                 "update manually:<br><br>")
 
                 if is_anaconda():
                     channel, __ = get_spyder_conda_channel()
diff --git a/spyder/plugins/ipythonconsole/widgets/help.py b/spyder/plugins/ipythonconsole/widgets/help.py
--- a/spyder/plugins/ipythonconsole/widgets/help.py
+++ b/spyder/plugins/ipythonconsole/widgets/help.py
@@ -14,9 +14,8 @@
 
 # Third party imports
 from pickle import UnpicklingError
-from qtconsole.ansi_code_processor import ANSI_OR_SPECIAL_PATTERN, ANSI_PATTERN
+from qtconsole.ansi_code_processor import ANSI_PATTERN
 from qtconsole.rich_jupyter_widget import RichJupyterWidget
-from qtpy.QtCore import QEventLoop
 
 # Local imports
 from spyder_kernels.utils.dochelpers import (getargspecfromtext,
@@ -38,31 +37,39 @@ def clean_invalid_var_chars(self, var):
         """
         return re.sub(r'\W|^(?=\d)', '_', var)
 
-    def get_documentation(self, content):
+    def get_documentation(self, content, signature):
         """Get documentation from inspect reply content."""
         data = content.get('data', {})
         text = data.get('text/plain', '')
+
         if text:
-            if (self.language_name is not None
-                    and self.language_name == 'python'):
-                text = re.compile(ANSI_PATTERN).sub('', text)
-                signature = self.get_signature(content).split('(')[-1]
+            # Remove ANSI characters from text
+            text = re.compile(ANSI_PATTERN).sub('', text)
 
+            if (
+                self.language_name is not None
+                and self.language_name == 'python'
+            ):
                 # Base value for the documentation
-                documentation = (text.split('Docstring:')[-1].
-                                 split('Type:')[0].split('File:')[0])
-
+                documentation = (
+                    text.split('Docstring:')[-1].
+                    split('Type:')[0].
+                    split('File:')[0]
+                ).strip()
+
+                # Check if the signature is at the beginning of the docstring
+                # to remove it
                 if signature:
-                    # Check if the signature is in the Docstring
-                    doc_from_signature = documentation.split(signature)
-                    if len(doc_from_signature) > 1:
-                        return (doc_from_signature[-1].split('Docstring:')[-1].
-                                split('Type:')[0].
-                                split('File:')[0]).strip('\r\n')
+                    signature_and_doc = documentation.split("\n\n")
+
+                    if (
+                        len(signature_and_doc) > 1
+                        and signature_and_doc[0].replace('\n', '') == signature
+                    ):
+                        return "\n\n".join(signature_and_doc[1:]).strip('\r\n')
 
                 return documentation.strip('\r\n')
             else:
-                text = re.compile(ANSI_PATTERN).sub('', text)
                 return text.strip('\r\n')
         else:
             return ''
@@ -71,23 +78,34 @@ def _get_signature(self, name, text):
         """Get signature from text using a given function name."""
         signature = ''
         argspec = getargspecfromtext(text)
+
         if argspec:
             # This covers cases like np.abs, whose docstring is
             # the same as np.absolute and because of that a proper
             # signature can't be obtained correctly
             signature = name + argspec
         else:
-            signature = getsignaturefromtext(text, name)
+            signature = name + getsignaturefromtext(text, name)
+
         return signature
 
     def get_signature(self, content):
         """Get signature from inspect reply content"""
         data = content.get('data', {})
         text = data.get('text/plain', '')
+
         if text:
-            if (self.language_name is not None
-                    and self.language_name == 'python'):
+            # Remove ANSI characters from text
+            text = re.compile(ANSI_PATTERN).sub('', text)
+
+            if (
+                self.language_name is not None
+                and self.language_name == 'python'
+            ):
+                signature = ''
                 self._control.current_prompt_pos = self._prompt_pos
+
+                # Get object's name
                 line = self._control.get_current_line_to_cursor()
                 name = line[:-1].split('(')[-1]   # Take last token after a (
                 name = name.split('.')[-1]   # Then take last token after a .
@@ -98,29 +116,37 @@ def get_signature(self, content):
                 except Exception:
                     pass
 
-                text = text.split('Docstring:')
-
-                # Try signature from text before 'Docstring:'
-                before_text = text[0]
-                before_signature = self._get_signature(name, before_text)
-
-                # Try signature from text after 'Docstring:'
-                after_text = text[-1]
-                after_signature = self._get_signature(name, after_text)
-
-                # Stay with the longest signature
-                if len(before_signature) > len(after_signature):
-                    signature = before_signature
+                # Split between docstring and text before it
+                if 'Docstring:' in text:
+                    before_text, after_text = text.split('Docstring:')
                 else:
-                    signature = after_signature
-
-                # Prevent special characters. Applied here to ensure
-                # recognizing the signature in the logic above.
-                signature = ANSI_OR_SPECIAL_PATTERN.sub('', signature)
+                    before_text, after_text = '', text
+
+                if before_text:
+                    # This is the case for objects for which IPython was able
+                    # to get a signature (e.g. np.vectorize)
+                    before_text = before_text.strip().replace('\n', '')
+                    signature = self._get_signature(name, before_text)
+
+                # Default signatures returned by IPython
+                default_sigs = [
+                    name + '(*args, **kwargs)',
+                    name + '(self, /, *args, **kwargs)'
+                ]
+
+                # This is the case for objects without signature (e.g.
+                # np.where). For them, we try to find it from their docstrings.
+                if not signature or signature in default_sigs:
+                    after_signature = self._get_signature(
+                        name, after_text.strip()
+                    )
+                    if after_signature:
+                        signature = after_signature
+
+                    signature = signature.replace('\n', '')
 
                 return signature.strip('\r\n')
             else:
-                text = re.compile(ANSI_PATTERN).sub('', text)
                 return text.strip('\r\n')
         else:
             return ''
@@ -156,14 +182,19 @@ def _handle_inspect_reply(self, rep):
         """
         cursor = self._get_cursor()
         info = self._request_info.get('call_tip')
-        if (info and info.id == rep['parent_header']['msg_id'] and
-                info.pos == cursor.position()):
+
+        if (
+            info
+            and info.id == rep['parent_header']['msg_id']
+            and info.pos == cursor.position()
+        ):
             content = rep['content']
             if content.get('status') == 'ok' and content.get('found', False):
                 signature = self.get_signature(content)
-                documentation = self.get_documentation(content)
+                documentation = self.get_documentation(content, signature)
                 new_line = (self.language_name is not None
                             and self.language_name == 'python')
+
                 self._control.show_calltip(
                     signature,
                     documentation=documentation,


Test patch:
diff --git a/external-deps/spyder-kernels/spyder_kernels/utils/tests/test_dochelpers.py b/external-deps/spyder-kernels/spyder_kernels/utils/tests/test_dochelpers.py
--- a/external-deps/spyder-kernels/spyder_kernels/utils/tests/test_dochelpers.py
+++ b/external-deps/spyder-kernels/spyder_kernels/utils/tests/test_dochelpers.py
@@ -18,8 +18,9 @@
 import pytest
 
 # Local imports
-from spyder_kernels.utils.dochelpers import (getargtxt, getdoc, getobj,
-                                             isdefined)
+from spyder_kernels.utils.dochelpers import (
+    getargtxt, getargspecfromtext, getdoc, getobj, getsignaturefromtext,
+    isdefined)
 from spyder_kernels.py3compat import PY2
 
 
@@ -60,5 +61,100 @@ def test_dochelpers():
     assert getobj('4.') == '4'
 
 
+@pytest.mark.skipif(PY2, reason="Fails in Python 2")
+def test_no_signature():
+    """
+    Test that we can get documentation for objects for which Python can't get a
+    signature directly because it gives an error.
+
+    This is a regression test for issue spyder-ide/spyder#21148
+    """
+    import numpy as np
+    doc = getdoc(np.where)
+    signature = doc['argspec']
+    assert signature and signature != "(...)" and signature.startswith("(")
+    assert doc['docstring']
+
+
+@pytest.mark.parametrize(
+    'text, name, expected',
+    [
+         ('foo(x, y)', 'foo', '(x, y)'),
+         ('foo(x, y)', '', '(x, y)'),
+    ]
+)
+def test_getsignaturefromtext_py2(text, name, expected):
+    assert getsignaturefromtext(text, name) == expected
+
+
+@pytest.mark.skipif(PY2, reason="Don't work in Python 2")
+@pytest.mark.parametrize(
+    'text, name, expected',
+    [
+         # Simple text with and without name
+         ('foo(x, y)', 'foo', '(x, y)'),
+         ('foo(x, y)', '', '(x, y)'),
+         # Single arg
+         ('foo(x)', '', '(x)'),
+         ('foo(x = {})', '', '(x = {})'),
+         # Not a valid identifier
+         ('1a(x, y)', '', ''),
+         # Valid identifier
+         ('a1(x, y=2)', '', '(x, y=2)'),
+         # Unicode identifier with and without name
+         ('ΣΔ(x, y)', 'ΣΔ', '(x, y)'),
+         ('ΣΔ(x, y)', '', '(x, y)'),
+         # Multiple signatures in a single line
+         ('ΣΔ(x, y) foo(a, b)', '', '(x, y)'),
+         ('1a(x, y) foo(a, b)', '', '(a, b)'),
+         # Multiple signatures in multiple lines
+         ('foo(a, b = 1)\n\nΣΔ(x, y=2)', '', '(a, b = 1)'),
+         ('1a(a, b = 1)\n\nΣΔ(x, y=2)', '', '(x, y=2)'),
+         # Signature after math operations
+         ('2(3 + 5) 3*(99) ΣΔ(x, y)', '', '(x, y)'),
+         # No identifier
+         ('(x, y)', '', ''),
+         ('foo (a=1, b = 2)', '', ''),
+         # Empty signature
+         ('foo()', '', ''),
+         ('foo()', 'foo', ''),
+    ]
+)
+def test_getsignaturefromtext(text, name, expected):
+    assert getsignaturefromtext(text, name) == expected
+
+
+def test_multisignature():
+    """
+    Test that we can get at least one signature from an object with multiple
+    ones declared in its docstring.
+    """
+    def foo():
+        """
+        foo(x, y) foo(a, b)
+        foo(c, d)
+        """
+
+    signature = getargspecfromtext(foo.__doc__)
+    assert signature == "(x, y)"
+
+
+def test_multiline_signature():
+    """
+    Test that we can get signatures splitted into multiple lines in a
+    docstring.
+    """
+    def foo():
+        """
+        foo(x,
+            y)
+
+        This is a docstring.
+        """
+
+    signature = getargspecfromtext(foo.__doc__)
+    assert signature.startswith("(x, ")
+
+
 if __name__ == "__main__":
     pytest.main()
diff --git a/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py b/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py
--- a/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py
+++ b/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py
@@ -77,21 +77,40 @@ def test_banners(ipyconsole, qtbot):
 
 @flaky(max_runs=3)
 @pytest.mark.parametrize(
-    "function,signature,documentation",
-    [("arange",
+    "function, signature, documentation",
+    [("np.arange",  # Check we get the signature from the object's docstring
       ["start", "stop"],
       ["Return evenly spaced values within a given interval.<br>",
        "open interval ..."]),
-     ("vectorize",
+     ("np.vectorize",  # Numpy function with a proper signature
       ["pyfunc", "otype", "signature"],
       ["Returns an object that acts like pyfunc, but takes arrays as<br>input."
        "<br>",
        "Define a vectorized function which takes a nested sequence ..."]),
-     ("absolute",
+     ("np.abs",  # np.abs has the same signature as np.absolute
       ["x", "/", "out"],
-      ["Parameters<br>", "x : array_like ..."])]
-    )
-@pytest.mark.skipif(not os.name == 'nt',
+      ["Calculate the absolute value"]),
+     ("np.where",  # Python gives an error when getting its signature
+      ["condition", "/"],
+      ["Return elements chosen from `x`"]),
+     ("np.array",  # Signature is splitted into several lines
+      ["object", "dtype=None"],
+      ["Create an array.<br><br>", "Parameters"]),
+     ("np.linalg.norm",  # Includes IPython default signature in inspect reply
+      ["x", "ord=None"],
+      ["Matrix or vector norm"]),
+     ("range",  # Check we display the first signature among several
+      ["stop"],
+      ["range(stop) -> range object"]),
+     ("dict",  # Check we skip an empty signature
+      ["mapping"],
+      ["dict() -> new empty dictionary"]),
+     ("foo",  # Check we display the right tooltip for interactive objects
+      ["x", "y"],
+      ["My function"])
+    ]
+)
+@pytest.mark.skipif(running_in_ci() and not os.name == 'nt',
                     reason="Times out on macOS and fails on Linux")
 @pytest.mark.skipif(parse(np.__version__) < parse('1.25.0'),
                     reason="Documentation for np.vectorize is different")
@@ -104,10 +123,22 @@ def test_get_calltips(ipyconsole, qtbot, function, signature, documentation):
     with qtbot.waitSignal(shell.executed):
         shell.execute('import numpy as np')
 
+    if function == "foo":
+        with qtbot.waitSignal(shell.executed):
+            code = dedent('''
+            def foo(x, y):
+                """
+                My function
+                """
+                return x + y
+            ''')
+
+            shell.execute(code)
+
     # Write an object in the console that should generate a calltip
     # and wait for the kernel to send its response.
     with qtbot.waitSignal(shell.kernel_client.shell_channel.message_received):
-        qtbot.keyClicks(control, 'np.' + function + '(')
+        qtbot.keyClicks(control, function + '(')
 
     # Wait a little bit for the calltip to appear
     qtbot.waitUntil(lambda: control.calltip_widget.isVisible())
diff --git a/spyder/workers/tests/test_update.py b/spyder/workers/tests/test_update.py
--- a/spyder/workers/tests/test_update.py
+++ b/spyder/workers/tests/test_update.py
@@ -4,6 +4,8 @@
 # Licensed under the terms of the MIT License
 # (see spyder/__init__.py for details)
 
+import sys
+
 import pytest
 
 from spyder.workers.updates import WorkerUpdates
@@ -48,6 +50,7 @@ def test_updates(qtbot, mocker, is_anaconda, is_pypi, version,
     assert len(worker.releases) == 1
 
 
+@pytest.mark.skipif(sys.platform == 'darwin', reason="Fails frequently on Mac")
 @pytest.mark.parametrize("version", ["1.0.0", "1000.0.0"])
 def test_updates_for_installers(qtbot, mocker, version):
     """