Repository: OpenMined/PySyft

Issue:
Calling unimplemented numpy methods on tensors causes infinite loop instead of throwing `NotImplemented`
## Description
Calling unimplemented methods such as `np.zeros_like` or `np.full_like` on data pointers causes non terminating recursion instead of throwing `NotImplemented`.

## How to Reproduce

create a data pointer

```py
import syft as sy
import numpy as np

domain_client = sy.login(email="info@openmined.org", password="changethis", port=8081)

arr = np.random.randint(0, 10000, (10000))

data = sy.Tensor(arr).annotate_with_dp_metadata(-1, 11000, data_subjects="Bob")
data_ptr = data.send(domain_client, tags=["my_data"])

domain_client.create_user(name="DS", email="test@email.com", password="password", budget=99999)
ds_client = sy.login(email="test@email.com", password="password", port=8081)

d_ptr = ds_client.store[data_ptr.id_at_location]
```

call `np.zeros_like` which is not implemented

```py
np.zeros_like(d_ptr)
```

```py
---------------------------------------------------------------------------
RecursionError                            Traceback (most recent call last)
Cell In [8], line 1
----> 1 np.zeros_like(d_ptr)

File <__array_function__ internals>:180, in zeros_like(*args, **kwargs)

File ~/pysyft/packages/syft/src/syft/core/tensor/passthrough.py:699, in PassthroughTensor.__array_function__(self, func, types, args, kwargs)
    697 if implementation:
    698     return implementation(*args, **kwargs)
--> 699 return self.__class__(func(*args, **kwargs))

File <__array_function__ internals>:180, in zeros_like(*args, **kwargs)

File ~/pysyft/packages/syft/src/syft/core/tensor/passthrough.py:699, in PassthroughTensor.__array_function__(self, func, types, args, kwargs)
    697 if implementation:
    698     return implementation(*args, **kwargs)
--> 699 return self.__class__(func(*args, **kwargs))

    [... skipping similar frames: zeros_like at line 180 (988 times), PassthroughTensor.__array_function__ at line 699 (987 times)]

File ~/pysyft/packages/syft/src/syft/core/tensor/passthrough.py:699, in PassthroughTensor.__array_function__(self, func, types, args, kwargs)
    697 if implementation:
    698     return implementation(*args, **kwargs)
--> 699 return self.__class__(func(*args, **kwargs))

File <__array_function__ internals>:180, in zeros_like(*args, **kwargs)

File ~/pysyft/packages/syft/src/syft/core/tensor/passthrough.py:693, in PassthroughTensor.__array_function__(self, func, types, args, kwargs)
    684 def __array_function__(
    685     self,
    686     func: Callable,
   (...)
    691     # Note: this allows subclasses that don't override
    692     # __array_function__ to handle PassthroughTensor objects.
--> 693     if not all(issubclass(t, self.__class__) for t in types):
    694         return NotImplemented
    696     implementation = query_implementation(self.__class__, func)

RecursionError: maximum recursion depth exceeded while calling a Python object
```

## Expected Behavior
A clear and concise description of what you expected to happen.

## Screenshots
If applicable, add screenshots to help explain your problem.

## System Information
 - OS: [e.g. iOS]
 - OS Version: [e.g. 22]
 - Language Version: [e.g. Python 3.7, Node 10.18.1]
 - Package Manager Version: [e.g. Conda 4.6.1, NPM 6.14.1]
 - Browser (if applicable): [e.g. Google Chrome]
 - Browser Version (if applicable): [e.g. 81.0.4044.138]

## Additional Context
Add any other context about the problem here.



Discussion:
Before fixing this we need to decide whether we're actually going to try to modify the numpy API or whether we should just make an imitation syft.numpy module where we define our own methods (like zeroes_like()) with no connection to actual numpy. If unsure we should do the latter for now because it's easier to build and easier to change later (and less likely to be buggy)
Yeah doesnt supporting the `np` functional style require us to provide `__array_ufunc__` support?

I believe we started this in `PassthroughTensor` but its probably incomplete and it was super confusing.

Perhaps the easiest thing here is to raise `NotImplementedError` for now?

```python
    def __array_function__(
        self,
        func: Callable,
        types: List[Type],  # what this means =  List of Type(Type())
        args: List[Any],
        kwargs: Dict[str, Any],
    ) -> PassthroughTensor:
        # Note: this allows subclasses that don't override
        # __array_function__ to handle PassthroughTensor objects.
        if not all(issubclass(t, self.__class__) for t in types):
            return NotImplemented

        implementation = query_implementation(self.__class__, func)
        if implementation:
            return implementation(*args, **kwargs)
        return self.__class__(func(*args, **kwargs))

    def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
        implementation = query_implementation(self.__class__, ufunc)
        if implementation:
            return implementation(*inputs, **kwargs)
        return self.__class__(ufunc(*inputs, **kwargs))
```
This feels like a combo of both these issues:

1) calling passthrough tensor methods on a pointer
https://github.com/OpenMined/PySyft/issues/7043

2) triggering ufunc by passing pointers / tensor types to np.functional calls
https://github.com/OpenMined/PySyft/issues/7042
Maybe something like the following:

```
def __getattribute__(self, attr_name: str) -> Any:
     if attr in VISIBLE_ATTRS:
        return object.__getattribute__(self, attr_name)
     raise ValueError(f"Attribute {attr_name} not found!") # Or Not Implemented
```

`VISIBLE_ATTRS` would have to be of string with all the `methods/attributes` we support
@gmuraru this would require us to maintain a separate `VISIBLE_ATTRS`? The issue here is just a non terminating recursion, would detecting non implementation and throw NotImplemented early instead of continuing descending into `self.__class__(func(*args, **kwargs))` a cleaner solution?
Yep, it would imply to keep that list with the implemented methods.
There might be some other workarounds for this problem.

We could also check `func` - it is implemented - but I think you would still need to have a list of implemented methods.
Changed the title coz currently this is the issue with both tensors and pointers because they both subclass `PassthroughTensor` but the issue with pointers is being tracked at #6922.

Resolving patch:
diff --git a/packages/syft/src/syft/core/tensor/passthrough.py b/packages/syft/src/syft/core/tensor/passthrough.py
--- a/packages/syft/src/syft/core/tensor/passthrough.py
+++ b/packages/syft/src/syft/core/tensor/passthrough.py
@@ -696,13 +696,20 @@ def __array_function__(
         implementation = query_implementation(self.__class__, func)
         if implementation:
             return implementation(*args, **kwargs)
-        return self.__class__(func(*args, **kwargs))
 
-    def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
-        implementation = query_implementation(self.__class__, ufunc)
+        method_name = func.__name__
+        implementation = getattr(self.__class__, method_name, None)
         if implementation:
-            return implementation(*inputs, **kwargs)
-        return self.__class__(ufunc(*inputs, **kwargs))
+            return (
+                implementation(*args, **kwargs)
+                if callable(implementation)
+                else self.__getattribute__(method_name)
+            )
+
+        return NotImplemented
+
+    # Set __array_ufunc_ = None for now until we can implement this properly
+    __array_ufunc__ = None
 
     def __repr__(self):
         return f"{self.__class__.__name__}(child={self.child})"


Test patch:
diff --git a/packages/syft/tests/syft/core/tensor/passthrough_test.py b/packages/syft/tests/syft/core/tensor/passthrough_test.py
--- a/packages/syft/tests/syft/core/tensor/passthrough_test.py
+++ b/packages/syft/tests/syft/core/tensor/passthrough_test.py
@@ -8,6 +8,7 @@
 
 # syft absolute
 from syft.core.tensor.passthrough import PassthroughTensor
+from syft.core.tensor.tensor import Tensor
 from syft.core.tensor.util import implements
 
 
@@ -933,6 +934,7 @@ def test__array_function__() -> None:
     assert result_c == expected_c.child
 
 
+@pytest.mark.xfail
 def test__array_ufunc__() -> None:
     data_a = np.array([1, 2, 3], dtype=np.int32)
     tensor_a = PtTensorSubclass(child=data_a, unit="Hedgehogs")
@@ -964,6 +966,7 @@ def test_repr() -> None:
     assert type(result) == str
 
 
+@pytest.mark.xfail
 def test_square() -> None:
     data = np.array([[0, 1], [-2, 3]], dtype=np.int32)
     expected = np.array([[0, 1], [4, 9]], dtype=np.int32)
@@ -972,3 +975,20 @@ def test_square() -> None:
     result = np.square(tensor_a)
 
     assert result == tensor_b
+
+
+def test_unimplemented_array_func() -> None:
+    # test if unimplemented ops correctly return TypeError: no implementation found
+    tensor = Tensor(np.array([0, 1, 2, 3], dtype=np.int32))
+
+    # change to some op other than full_like if this ends up being implemented
+    with pytest.raises(TypeError, match="no implementation found"):
+        np.full_like(tensor, 7)
+
+
+def test_unsupported_ufunc() -> None:
+    # we don't currently support ufunc
+    tensor = Tensor(np.array([0, 1, 2, 3], dtype=np.int32))
+
+    with pytest.raises(TypeError, match="does not support ufuncs"):
+        np.sin(tensor)