Repository: SeldonIO/MLServer

Issue:
decode_args with tuple return value
I'm confused about how to use `decode_args()` when the model returns a tuple of, let's say, a numpy array.

If I have an inference function with the following signature

```python
import numpy as np
from mlserver.codecs.decorator import decode_args

def predict(input: np.ndarray) -> tuple[np.ndarray]:
    # simple identity model
    return (input, )

decode_args(predict)
```
I receive a

```
CodecNotFound: Codec not found for output field with type tuple[numpy.ndarray]
```

error message.

I think the issue lies in the following line that is used to unwrap the tuple signature and convert it to a list of types.

https://github.com/SeldonIO/MLServer/blob/9d7045cc3c50a14201733865ca0c3e8480ebd942/mlserver/codecs/decorator.py#L37

However the line checks if the type hint is a tuple instance, which is not true in the above case: `isinstance(tuple[np.ndarray], tuple) == False)`

To make `decode_args` work with a tuple return signature, I have to change the function signature to

```python
def predict(input: np.ndarray) -> tuple([np.ndarray]):
```
which is an actual tuple instance and not a type hint. This looks like a bug to me. 


Discussion:
Hey @sauerburger ,

Thanks for raising this. 

What Python version are you currently using?

Also, just to understand more about what's going - is there any reason you're returning a tuple with a single element?
Hi @adriangonz ,

Thanks for the reply. I'm on Python 3.9.15, but it should be the same with Python 3.11.

I have the tuple with a single argument only for the sake of this post. In my code, it's a tuple of two numpy arrays. I striped the code to the bare minimum for this example and didn't realize that it becomes pointless to have a tuple with just one element. Just imagine the case of `tuple[np.ndarray, np.ndarray]` and `tuple([np.ndarray, np.ndarray])`.
Not sure if it makes any difference, but could you try defining your return value as `Tuple[np.ndarray]`?
Just following up from the above, I just had a deeper look and I think you're right: there's an issue there when using an annotated tuple hint.

It seems to work when you define the signature like (which seems to be the one we test):

```python
def predict(input: np.ndarray) -> (np.ndarray, np.ndarray):
```

But fails with

```python
def predict(input: np.ndarray) ->Tuple[(np.ndarray, np.ndarray]:
```

While we get around fixing this one - a potential workaround is to use the former notation (although I'm not 100% sure how correct is). 
Thanks for following up on this. I'm actually not sure, what the correct annotation is. `(np.ndarray, np.ndarray)` looks ok, but on the other hand, it should be identical to `tuple([np.ndarray, np.ndarray])`.

If you want, I could propose a PR that extends the check from above to instances of `Tuple[np.ndarray, np.ndarray]` and unpacks that structure.
Hey @sauerburger ,

If you get chance to contribute a PR, that would be incredibly appreciated! 

I haven't looked much into it, but my feeling is that - under the hood - `Tuple[np.ndarray]` is represented as some object from the `typing` module (as in, not a _raw_ tuple). So hopefully the change required is just to check for that type as well? 

Resolving patch:
diff --git a/mlserver/codecs/decorator.py b/mlserver/codecs/decorator.py
--- a/mlserver/codecs/decorator.py
+++ b/mlserver/codecs/decorator.py
@@ -38,6 +38,10 @@ def _as_list(a: Optional[Union[Any, Tuple[Any]]]) -> List[Any]:
         # Split into components
         return list(a)
 
+    if get_origin(a) is tuple:
+        # Convert type arguments into list
+        return list(get_args(a))
+
     # Otherwise, assume it's a single element
     return [a]
 


Test patch:
diff --git a/tests/codecs/test_decorator.py b/tests/codecs/test_decorator.py
--- a/tests/codecs/test_decorator.py
+++ b/tests/codecs/test_decorator.py
@@ -2,7 +2,7 @@
 import numpy as np
 import pandas as pd
 
-from typing import Any, Callable, Dict, Optional, List
+from typing import Any, Callable, Dict, Optional, List, Tuple
 
 from mlserver.types import (
     InferenceRequest,
@@ -13,7 +13,7 @@
 )
 from mlserver.codecs.base import InputCodec
 from mlserver.codecs.utils import Codec
-from mlserver.codecs.decorator import SignatureCodec
+from mlserver.codecs.decorator import SignatureCodec, _as_list
 from mlserver.codecs.errors import InputsNotFound, OutputNotFound
 from mlserver.codecs.numpy import NumpyCodec, NumpyRequestCodec
 from mlserver.codecs.string import StringCodec
@@ -287,3 +287,13 @@ async def test_decode_args(
 
     res = NumpyRequestCodec.decode_response(inference_response)
     assert res == output_value
+
+
+def test_as_list_typing_tuple():
+    signature_list = _as_list(Tuple[np.ndarray, np.ndarray])
+    assert signature_list == [np.ndarray, np.ndarray]
+
+
+def test_as_list_native_tuple():
+    signature_list = _as_list((np.ndarray, np.ndarray))
+    assert signature_list == [np.ndarray, np.ndarray]