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

Discussion:

Patch:
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`


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.