Repository: ipython/ipython

Issue:
Tab complete for class __call__ not working
<!-- This is the repository for IPython command line, if you can try to make sure this question/bug/feature belong here and not on one of the Jupyter repositories. 

If it's a generic Python/Jupyter question, try other forums or discourse.jupyter.org.

If you are unsure, it's ok to post here, though, there are few maintainer so you might not get a fast response. 

-->

If I make the following two files:
```python
# t.py
class MyClass:
    def my_method(self) -> None:
        print('this is my method')


def foo() -> MyClass: return MyClass()

```
and
```python
# f.py
class MyClass:
    def my_method(self) -> None:
        print('this is my method')

class Foo:
    def __call__(self) -> MyClass:
        return MyClass()

foo = Foo()
```

then `foo().my_method()` works for both

But - I only get the auto-complete suggestion for the first one: `ipython -i t.py`:

![Screenshot 2023-09-30 102929](https://github.com/ipython/ipython/assets/33491632/9d2cc19b-f899-410f-bbd5-4333b0ce5ad5)

`ipython -i f.py`:

![Screenshot 2023-09-30 102832](https://github.com/ipython/ipython/assets/33491632/d29cadab-b4c8-4bc4-9dfb-fe7cf0f8e0d0)

Shouldn't both autocomplete to the method available in `MyClass`?

For reference, this came up here https://github.com/pola-rs/polars/issues/11433

Comments:

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 6af0afb1d..307f6280d 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -2,7 +2,7 @@
 # See https://pre-commit.com/hooks.html for more hooks
 repos:
 -   repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v3.2.0
+    rev: v4.4.0
     hooks:
     -   id: trailing-whitespace
     -   id: end-of-file-fixer
@@ -10,7 +10,6 @@ repos:
     -   id: check-added-large-files
 
 -   repo: https://github.com/akaihola/darker
-    rev: 1.3.1
+    rev: 1.7.2
     hooks:
     -   id: darker
-
diff --git a/IPython/core/guarded_eval.py b/IPython/core/guarded_eval.py
index 5d405b220..a304affc3 100644
--- a/IPython/core/guarded_eval.py
+++ b/IPython/core/guarded_eval.py
@@ -1,3 +1,4 @@
+from inspect import signature, Signature
 from typing import (
     Any,
     Callable,
@@ -335,6 +336,7 @@ def __getitem__(self, key):
 
 IDENTITY_SUBSCRIPT = _IdentitySubscript()
 SUBSCRIPT_MARKER = "__SUBSCRIPT_SENTINEL__"
+UNKNOWN_SIGNATURE = Signature()
 
 
 class GuardRejection(Exception):
@@ -415,6 +417,10 @@ def guarded_eval(code: str, context: EvaluationContext):
 }
 
 
+class Duck:
+    """A dummy class used to create objects of other classes without calling their ``__init__``"""
+
+
 def _find_dunder(node_op, dunders) -> Union[Tuple[str, ...], None]:
     dunder = None
     for op, candidate_dunder in dunders.items():
@@ -584,6 +590,27 @@ def eval_node(node: Union[ast.AST, None], context: EvaluationContext):
         if policy.can_call(func) and not node.keywords:
             args = [eval_node(arg, context) for arg in node.args]
             return func(*args)
+        try:
+            sig = signature(func)
+        except ValueError:
+            sig = UNKNOWN_SIGNATURE
+        # if annotation was not stringized, or it was stringized
+        # but resolved by signature call we know the return type
+        not_empty = sig.return_annotation is not Signature.empty
+        not_stringized = not isinstance(sig.return_annotation, str)
+        if not_empty and not_stringized:
+            duck = Duck()
+            # if allow-listed builtin is on type annotation, instantiate it
+            if policy.can_call(sig.return_annotation) and not node.keywords:
+                args = [eval_node(arg, context) for arg in node.args]
+                return sig.return_annotation(*args)
+            try:
+                # if custom class is in type annotation, mock it;
+                # this only works for heap types, not builtins
+                duck.__class__ = sig.return_annotation
+                return duck
+            except TypeError:
+                pass
         raise GuardRejection(
             "Call for",
             func,  # not joined to avoid calling `repr`
diff --git a/IPython/core/inputsplitter.py b/IPython/core/inputsplitter.py
index a4401184b..af5c3e6b1 100644
--- a/IPython/core/inputsplitter.py
+++ b/IPython/core/inputsplitter.py
@@ -91,7 +91,13 @@ def num_ini_spaces(s):
     -------
     n : int
     """
-
+    warnings.warn(
+        "`num_ini_spaces` is Pending Deprecation since IPython 8.17."
+        "It is considered fro removal in in future version. "
+        "Please open an issue if you believe it should be kept.",
+        stacklevel=2,
+        category=PendingDeprecationWarning,
+    )
     ini_spaces = ini_spaces_re.match(s)
     if ini_spaces:
         return ini_spaces.end()
diff --git a/IPython/core/interactiveshell.py b/IPython/core/interactiveshell.py
index 894403f98..602339754 100644
--- a/IPython/core/interactiveshell.py
+++ b/IPython/core/interactiveshell.py
@@ -21,6 +21,7 @@
 import os
 import re
 import runpy
+import shutil
 import subprocess
 import sys
 import tempfile
@@ -3902,7 +3903,7 @@ def atexit_operations(self):
         del self.tempfiles
         for tdir in self.tempdirs:
             try:
-                tdir.rmdir()
+                shutil.rmtree(tdir)
                 self.tempdirs.remove(tdir)
             except FileNotFoundError:
                 pass
diff --git a/IPython/core/magics/packaging.py b/IPython/core/magics/packaging.py
index 2f7652c16..093b0a2ec 100644
--- a/IPython/core/magics/packaging.py
+++ b/IPython/core/magics/packaging.py
@@ -8,6 +8,7 @@
 #  The full license is in the file COPYING.txt, distributed with this software.
 #-----------------------------------------------------------------------------
 
+import functools
 import re
 import shlex
 import sys
@@ -16,33 +17,49 @@
 from IPython.core.magic import Magics, magics_class, line_magic
 
 
-def _is_conda_environment():
-    """Return True if the current Python executable is in a conda env"""
-    # TODO: does this need to change on windows?
-    return Path(sys.prefix, "conda-meta", "history").exists()
+def is_conda_environment(func):
+    @functools.wraps(func)
+    def wrapper(*args, **kwargs):
+        """Return True if the current Python executable is in a conda env"""
+        # TODO: does this need to change on windows?
+        if not Path(sys.prefix, "conda-meta", "history").exists():
+            raise ValueError(
+                "The python kernel does not appear to be a conda environment.  "
+                "Please use ``%pip install`` instead."
+            )
+        return func(*args, **kwargs)
 
+    return wrapper
 
-def _get_conda_executable():
-    """Find the path to the conda executable"""
+
+def _get_conda_like_executable(command):
+    """Find the path to the given executable
+
+    Parameters
+    ----------
+
+    executable: string
+        Value should be: conda, mamba or micromamba
+    """
     # Check if there is a conda executable in the same directory as the Python executable.
     # This is the case within conda's root environment.
-    conda = Path(sys.executable).parent / "conda"
-    if conda.is_file():
-        return str(conda)
+    executable = Path(sys.executable).parent / command
+    if executable.is_file():
+        return str(executable)
 
     # Otherwise, attempt to extract the executable from conda history.
     # This applies in any conda environment.
     history = Path(sys.prefix, "conda-meta", "history").read_text(encoding="utf-8")
     match = re.search(
-        r"^#\s*cmd:\s*(?P<command>.*conda)\s[create|install]",
+        rf"^#\s*cmd:\s*(?P<command>.*{executable})\s[create|install]",
         history,
         flags=re.MULTILINE,
     )
     if match:
         return match.groupdict()["command"]
 
-    # Fallback: assume conda is available on the system path.
-    return "conda"
+    # Fallback: assume the executable is available on the system path.
+    return command
 
 
 CONDA_COMMANDS_REQUIRING_PREFIX = {
@@ -76,18 +93,7 @@ def pip(self, line):
 
         print("Note: you may need to restart the kernel to use updated packages.")
 
-    @line_magic
-    def conda(self, line):
-        """Run the conda package manager within the current kernel.
-
-        Usage:
-          %conda install [pkgs]
-        """
-        if not _is_conda_environment():
-            raise ValueError("The python kernel does not appear to be a conda environment.  "
-                             "Please use ``%pip install`` instead.")
-
-        conda = _get_conda_executable()
+    def _run_command(self, cmd, line):
         args = shlex.split(line)
         command = args[0] if len(args) > 0 else ""
         args = args[1:] if len(args) > 1 else [""]
@@ -108,5 +114,38 @@ def conda(self, line):
         if needs_prefix and not has_prefix:
             extra_args.extend(["--prefix", sys.prefix])
 
-        self.shell.system(' '.join([conda, command] + extra_args + args))
+        self.shell.system(" ".join([cmd, command] + extra_args + args))
         print("\nNote: you may need to restart the kernel to use updated packages.")
+
+    @line_magic
+    @is_conda_environment
+    def conda(self, line):
+        """Run the conda package manager within the current kernel.
+
+        Usage:
+          %conda install [pkgs]
+        """
+        conda = _get_conda_like_executable("conda")
+        self._run_command(conda, line)
+
+    @line_magic
+    @is_conda_environment
+    def mamba(self, line):
+        """Run the mamba package manager within the current kernel.
+
+        Usage:
+          %mamba install [pkgs]
+        """
+        mamba = _get_conda_like_executable("mamba")
+        self._run_command(mamba, line)
+
+    @line_magic
+    @is_conda_environment
+    def micromamba(self, line):
+        """Run the conda package manager within the current kernel.
+
+        Usage:
+          %micromamba install [pkgs]
+        """
+        micromamba = _get_conda_like_executable("micromamba")
+        self._run_command(micromamba, line)
diff --git a/IPython/utils/text.py b/IPython/utils/text.py
index 74bccddf6..e62ca698b 100644
--- a/IPython/utils/text.py
+++ b/IPython/utils/text.py
@@ -13,16 +13,11 @@
 import string
 import sys
 import textwrap
+import warnings
 from string import Formatter
 from pathlib import Path
 
 
-# datetime.strftime date format for ipython
-if sys.platform == 'win32':
-    date_format = "%B %d, %Y"
-else:
-    date_format = "%B %-d, %Y"
-
 class LSString(str):
     """String derivative with a special access attributes.
 
@@ -336,7 +331,13 @@ def marquee(txt='',width=78,mark='*'):
 
 def num_ini_spaces(strng):
     """Return the number of initial spaces in a string"""
-
+    warnings.warn(
+        "`num_ini_spaces` is Pending Deprecation since IPython 8.17."
+        "It is considered fro removal in in future version. "
+        "Please open an issue if you believe it should be kept.",
+        stacklevel=2,
+        category=PendingDeprecationWarning,
+    )
     ini_spaces = ini_spaces_re.match(strng)
     if ini_spaces:
         return ini_spaces.end()
@@ -391,6 +392,13 @@ def wrap_paragraphs(text, ncols=80):
     -------
     list of complete paragraphs, wrapped to fill `ncols` columns.
     """
+    warnings.warn(
+        "`wrap_paragraphs` is Pending Deprecation since IPython 8.17."
+        "It is considered fro removal in in future version. "
+        "Please open an issue if you believe it should be kept.",
+        stacklevel=2,
+        category=PendingDeprecationWarning,
+    )
     paragraph_re = re.compile(r'\n(\s*\n)+', re.MULTILINE)
     text = dedent(text).strip()
     paragraphs = paragraph_re.split(text)[::2] # every other entry is space
@@ -465,6 +473,14 @@ def strip_ansi(source):
     source : str
         Source to remove the ansi from
     """
+    warnings.warn(
+        "`strip_ansi` is Pending Deprecation since IPython 8.17."
+        "It is considered fro removal in in future version. "
+        "Please open an issue if you believe it should be kept.",
+        stacklevel=2,
+        category=PendingDeprecationWarning,
+    )
+
     return re.sub(r'\033\[(\d|;)+?m', '', source)
 
 
@@ -682,6 +698,13 @@ def compute_item_matrix(items, row_first=False, empty=None, *args, **kwargs) :
         In [5]: all((info[k] == ideal[k] for k in ideal.keys()))
         Out[5]: True
     """
+    warnings.warn(
+        "`compute_item_matrix` is Pending Deprecation since IPython 8.17."
+        "It is considered fro removal in in future version. "
+        "Please open an issue if you believe it should be kept.",
+        stacklevel=2,
+        category=PendingDeprecationWarning,
+    )
     info = _find_optimal(list(map(len, items)), row_first, *args, **kwargs)
     nrow, ncol = info['max_rows'], info['num_columns']
     if row_first:
@@ -709,6 +732,13 @@ def columnize(items, row_first=False, separator="  ", displaywidth=80, spread=Fa
     -------
     The formatted string.
     """
+    warnings.warn(
+        "`columnize` is Pending Deprecation since IPython 8.17."
+        "It is considered fro removal in in future version. "
+        "Please open an issue if you believe it should be kept.",
+        stacklevel=2,
+        category=PendingDeprecationWarning,
+    )
     if not items:
         return '\n'
     matrix, info = compute_item_matrix(items, row_first=row_first, separator_size=len(separator), displaywidth=displaywidth)
diff --git a/tools/release_helper.sh b/tools/release_helper.sh
index ebf809819..19aa668fe 100644
--- a/tools/release_helper.sh
+++ b/tools/release_helper.sh
@@ -1,5 +1,5 @@
 # Simple tool to help for release
-# when releasing with bash, simple source it to get asked questions. 
+# when releasing with bash, simple source it to get asked questions.
 
 # misc check before starting
 BLACK=$(tput setaf 1)
@@ -62,7 +62,7 @@ ask_section(){
 
 maybe_edit(){
     echo
-    echo $BLUE"$1"$NOR 
+    echo $BLUE"$1"$NOR
     echo -n $GREEN"Press ${BLUE}e$GREEN to Edit ${BLUE}$1$GREEN, any other keys to skip: "$NOR
     if [ "$ZSH_NAME" = "zsh" ] ; then
         read -k1 value
@@ -71,7 +71,7 @@ maybe_edit(){
        read -n1 value
     fi
 
-    echo 
+    echo
     if [ $value = 'e' ]  ; then
         $=EDITOR $1
     fi
@@ -79,7 +79,7 @@ maybe_edit(){
 
 
 
-echo 
+echo
 if ask_section "Updating what's new with information from docs/source/whatsnew/pr"
 then
     python tools/update_whatsnew.py
@@ -93,11 +93,11 @@ fi
 if ask_section "Gen Stats, and authors"
 then
 
-    echo 
+    echo
     echo $BLUE"here are all the authors that contributed to this release:"$NOR
     git log --format="%aN <%aE>" $PREV_RELEASE... | sort -u -f
 
-    echo 
+    echo
     echo $BLUE"If you see any duplicates cancel (Ctrl-C), then edit .mailmap."
     echo $GREEN"Press enter to continue:"$NOR
     read
@@ -115,12 +115,12 @@ fi
 if ask_section "Generate API difference (using frapuccino)"
 then
     echo $BLUE"Checking out $PREV_RELEASE"$NOR
-    git checkout $PREV_RELEASE
+    git switch $PREV_RELEASE
     sleep 1
     echo $BLUE"Saving API to file $PREV_RELEASE"$NOR
     frappuccino IPython IPython.kernel IPython.lib IPython.qt IPython.lib.kernel IPython.html IPython.frontend IPython.external --save IPython-$PREV_RELEASE.json
     echo $BLUE"coming back to $BRANCH"$NOR
-    git checkout $BRANCH
+    git switch $BRANCH
     sleep 1
     echo $BLUE"comparing ..."$NOR
     frappuccino IPython IPython.kernel IPython.lib --compare IPython-$PREV_RELEASE.json
@@ -145,7 +145,7 @@ read
 if ask_section "Build the documentation ?"
 then
     make html -C docs
-    echo 
+    echo
     echo $GREEN"Check the docs, press enter to continue"$NOR
     read
 
@@ -158,27 +158,27 @@ then
    echo $GREEN"Press enter to commit"$NOR
    read
    git commit -am "release $VERSION" -S
-   
+
    echo
    echo $BLUE"git push origin \$BRANCH ($BRANCH)?"$NOR
    echo $GREEN"Make sure you can push"$NOR
    echo $GREEN"Press enter to continue"$NOR
    read
    git push origin $BRANCH
-   
+
    echo
    echo "Let's tag : git tag -am \"release $VERSION\" \"$VERSION\" -s"
    echo $GREEN"Press enter to tag commit"$NOR
    read
    git tag -am "release $VERSION" "$VERSION" -s
-   
+
    echo
    echo $BLUE"And push the tag: git push origin \$VERSION ?"$NOR
    echo $GREEN"Press enter to continue"$NOR
    read
    git push origin $VERSION
-   
-   
+
+
    echo $GREEN"please update version number and back to .dev in ${RED}IPython/core/release.py"
    echo $GREEN"I tried ${RED}sed -i bkp -e '/Uncomment/s/^/# /g' IPython/core/release.py${NOR}"
    sed -i bkp -e '/Uncomment/s/^/# /g' IPython/core/release.py
@@ -188,10 +188,10 @@ then
    maybe_edit IPython/core/release.py
    echo ${BLUE}"Do not commit yet – we'll do it later."$NOR
 
-   
+
    echo $GREEN"Press enter to continue"$NOR
    read
-   
+
    echo
    echo "Let's commit : "$BLUE"git commit -am \"back to dev\""$NOR
    echo $GREEN"Press enter to commit"$NOR
@@ -204,17 +204,17 @@ then
    read
    git push origin $BRANCH
 
-   
+
    echo
-   echo $BLUE"let's : git checkout $VERSION"$NOR
+   echo $BLUE"let's : git switch $VERSION"$NOR
    echo $GREEN"Press enter to continue"$NOR
    read
-   git checkout $VERSION
+   git switch $VERSION
 fi
 
 if ask_section "Should we build and release ?"
 then
-    
+
     echo $BLUE"going to set SOURCE_DATE_EPOCH"$NOR
     echo $BLUE'export SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD)'$NOR
     echo $GREEN"Press enter to continue"$NOR
@@ -253,7 +253,7 @@ then
     echo $NOR
 
     if ask_section "upload packages ?"
-    then 
+    then
        tools/release upload
     fi
 fi


Test patch:
diff --git a/IPython/core/tests/test_guarded_eval.py b/IPython/core/tests/test_guarded_eval.py
index 6fb321abe..13f909188 100644
--- a/IPython/core/tests/test_guarded_eval.py
+++ b/IPython/core/tests/test_guarded_eval.py
@@ -253,16 +253,36 @@ def test_method_descriptor():
     assert guarded_eval("list.copy.__name__", context) == "copy"
 
 
+class HeapType:
+    pass
+
+
+class CallCreatesHeapType:
+    def __call__(self) -> HeapType:
+        return HeapType()
+
+
+class CallCreatesBuiltin:
+    def __call__(self) -> frozenset:
+        return frozenset()
+
+
 @pytest.mark.parametrize(
-    "data,good,bad,expected",
+    "data,good,bad,expected, equality",
     [
-        [[1, 2, 3], "data.index(2)", "data.append(4)", 1],
-        [{"a": 1}, "data.keys().isdisjoint({})", "data.update()", True],
+        [[1, 2, 3], "data.index(2)", "data.append(4)", 1, True],
+        [{"a": 1}, "data.keys().isdisjoint({})", "data.update()", True, True],
+        [CallCreatesHeapType(), "data()", "data.__class__()", HeapType, False],
+        [CallCreatesBuiltin(), "data()", "data.__class__()", frozenset, False],
     ],
 )
-def test_evaluates_calls(data, good, bad, expected):
+def test_evaluates_calls(data, good, bad, expected, equality):
     context = limited(data=data)
-    assert guarded_eval(good, context) == expected
+    value = guarded_eval(good, context)
+    if equality:
+        assert value == expected
+    else:
+        assert isinstance(value, expected)
 
     with pytest.raises(GuardRejection):
         guarded_eval(bad, context)
@@ -534,7 +554,7 @@ def index(self, k):
 def test_assumption_instance_attr_do_not_matter():
     """This is semi-specified in Python documentation.
 
-    However, since the specification says 'not guaranted
+    However, since the specification says 'not guaranteed
     to work' rather than 'is forbidden to work', future
     versions could invalidate this assumptions. This test
     is meant to catch such a change if it ever comes true.