Repository: pyccel/pyccel (python)

Issue:
Missing type checks
### Describe the bug
When translating a function where one of the arguments can have multiple types, only the type of the argument whose type can change is checked. This leads to segmentation faults if the wrong type is passed to the other arguments.

### To Reproduce
Provide code to reproduce the behaviour:
```python
@template('T', ['int', 'float'])
def test_types(unknown_type : 'T', known_type : int):
    print(unknown_type, known_type)
```

when called with:
```python
test_types(2.0, 3.0)
```

### Error details
Provide the generated code, or the error message:
```c
int64_t test_types_type_check(PyObject* unknown_type_obj, PyObject* known_type_obj)
{
    int64_t type_indicator;
    type_indicator = INT64_C(0);
    if (PyIs_NativeInt(unknown_type_obj))
    {   
        type_indicator += INT64_C(0);
    }
    else if (PyIs_NativeFloat(unknown_type_obj))
    {   
        type_indicator += INT64_C(1);
    }
    else
    {
        PyErr_SetString(PyExc_TypeError, "Unexpected type for argument unknown_type");
        return -INT64_C(1);
    }
    return type_indicator;
}

PyObject* test_types_wrapper(PyObject* self, PyObject* args, PyObject* kwargs)
{
    PyObject* unknown_type_obj;
    PyObject* known_type_obj;
    int64_t type_indicator; 
    static char *kwlist[] = {
        "unknown_type",
        "known_type",
        NULL
    }; 
    if (!PyArg_ParseTupleAndKeywords(args, kwargs, "OO", kwlist, &unknown_type_obj, &known_type_obj))
    {   
        return NULL;
    }
    type_indicator = test_types_type_check(unknown_type_obj, known_type_obj);
    if (type_indicator == INT64_C(0))
    {   
        return test_types_0000_wrapper(unknown_type_obj, known_type_obj);
    }
    else if (type_indicator == INT64_C(1))
    {
        return test_types_0001_wrapper(unknown_type_obj, known_type_obj);
    }
    else
    {
        PyErr_SetString(PyExc_TypeError, "Unexpected type combination");
        return NULL;
    }
}
```

### Expected behaviour
The function `test_types_type_check` should also check the types of the other arguments and raise an error if they have the wrong type. There could be a special code for this to allow `test_types_type_check` to set the error string.

### Language
Fortran/C


Pull request:
Improve type checks
Improve type checks by:
- Displaying the received type when it does not match expectations
- Displaying a more verbose error message where possible when the wrong types are passed to an `Interface`
- Fixing missing type check for argument of fixed type in an `Interface`. Fixes #2125 

**Commit Summary**
- Add `PyArgumentError` to set the error message with a message containing the type of an object passed from Python
- Use `PyArgumentError` to add more detail to error messages
- Add missing type check
- Add an extra if block to raise recognised errors from `Interface`s instead of always showing the error "Unexpected type combination"
- Add tests which check that the raised error contains the name of the problematic variable and the received type.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f3186479..32dfa169 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -161,6 +161,7 @@ All notable changes to this project will be documented in this file.
 -   #2235 : Fix negative numbers in slice indices when translating to C.
 -   #2144 : Fix accidental imports due to modules making their contents public by default.
 -   #2312 : Fix rounding direction for negative integer elements in `np.linspace`.
+-   #2125 : Fix missing type check for argument of known type in a function with arguments whose type can be one of several types.
 
 ### Changed
 
@@ -178,6 +179,7 @@ All notable changes to this project will be documented in this file.
 -   #2249 : Improve installation docs and recommend virtual environment.
 -   #2242 : Change format of compiler info files.
 -   #2302 : Print the deallocation in a 1 line if statement.
+-   #2125 : Add information about received data type to type errors when calling a function with the wrong type.
 -   \[INTERNALS\] `FunctionDef` is annotated when it is called, or at the end of the `CodeBlock` if it is never called.
 -   \[INTERNALS\] `InlinedFunctionDef` is only annotated if it is called.
 -   \[INTERNALS\] Build `utilities.metaclasses.ArgumentSingleton` on the fly to ensure correct docstrings.
diff --git a/pyccel/ast/cwrapper.py b/pyccel/ast/cwrapper.py
index 65ad73e2..3024810c 100644
--- a/pyccel/ast/cwrapper.py
+++ b/pyccel/ast/cwrapper.py
@@ -9,6 +9,7 @@ between Python code and C code (using Python/C Api and cwrapper.c).
 This file contains classes but also many FunctionDef/Variable instances representing
 objects defined in Python.h.
 """
+import re
 
 from pyccel.utilities.metaclasses import Singleton
 
@@ -55,6 +56,7 @@ __all__ = (
     'PyModule',
     'PyArgKeywords',
     'PyArg_ParseTupleNode',
+    'PyArgumentError',
     'PyBuildValueNode',
     'PyCapsule_New',
     'PyCapsule_Import',
@@ -1029,6 +1031,71 @@ class PyTuple_Pack(PyccelFunction):
     _class_type = PyccelPyObject()
     _shape = None
 
+class PyArgumentError(PyccelAstNode):
+    """
+    Class to display errors related to arguments.
+
+    Class to display errors related to arguments. This class helps
+    format the arguments to display the type of the received argument.
+
+    Parameters
+    ----------
+    error_type : Variable
+        A Variable containing the error type to be raised. E.g. PyTypeError.
+    error_msg : str
+        The message to be displayed containing f-string style type indicators.
+    **kwargs : dict[str, Variable]
+        The arguments whose types will be printed.
+    """
+    __slots__ = ('_error_type', '_error_msg', '_args')
+    _attribute_nodes = ('_args',)
+
+    def __init__(self, error_type, error_msg : str, **kwargs):
+        assert isinstance(error_type, Variable)
+        assert isinstance(error_msg, str)
+        args = []
+        # Find all expressions of the style '{type(var_name)}' in the error message
+        type_indicators = re.findall(r'{type\([a-zA-Z0-9_]+\)}', error_msg)
+        # Save the error message, replacing type indicators with the format string
+        self._error_msg = re.sub(r'{type\([a-zA-Z0-9_]+\)}', '%V', error_msg)
+        # Find the relevant arguments for each type indicator
+        for t in type_indicators:
+            var_name = t.removeprefix('{type(').removesuffix(')}')
+            args.append(ObjectAddress(kwargs[var_name]))
+
+        self._args = tuple(args)
+        self._error_type = error_type
+        super().__init__()
+
+    @property
+    def error_type(self):
+        """
+        The error type that should be raised.
+
+        The error type that should be raised.
+        """
+        return self._error_type
+
+    @property
+    def error_msg(self):
+        """
+        The error message that should be formatted.
+
+        The error message that should be formatted.
+        """
+        return self._error_msg
+
+    @property
+    def args(self):
+        """
+        The arguments whose types are printed in the error message.
+
+        The arguments whose types are printed in the error message.
+        These arguments are displayed in the order they appear in
+        the error message.
+        """
+        return self._args
+
 #-------------------------------------------------------------------
 #                      Python.h Constants
 #-------------------------------------------------------------------
diff --git a/pyccel/codegen/printing/cwrappercode.py b/pyccel/codegen/printing/cwrappercode.py
index d311f74b..3d2c2759 100644
--- a/pyccel/codegen/printing/cwrappercode.py
+++ b/pyccel/codegen/printing/cwrappercode.py
@@ -607,3 +607,8 @@ class CWrapperCodePrinter(CCodePrinter):
             return f'PyList_SetSlice({list_code}, 0, PY_SSIZE_T_MAX, NULL)'
         else:
             return f'PyList_Clear({list_code})'
+
+    def _print_PyArgumentError(self, expr):
+        args = ', '.join([f'"{self._print(expr.error_msg)}"'] + \
+                         [f'PyObject_Str((PyObject*)Py_TYPE({self._print(a)}))' for a in expr.args])
+        return f'PyErr_SetObject({self._print(expr.error_type)}, PyUnicode_FromFormat({args}));\n'
diff --git a/pyccel/codegen/wrapper/c_to_python_wrapper.py b/pyccel/codegen/wrapper/c_to_python_wrapper.py
index d0413a9b..1cc651ba 100644
--- a/pyccel/codegen/wrapper/c_to_python_wrapper.py
+++ b/pyccel/codegen/wrapper/c_to_python_wrapper.py
@@ -37,7 +37,7 @@ from pyccel.ast.cwrapper      import PyTuple_Size, PyTuple_Check, PyTuple_New
 from pyccel.ast.cwrapper      import PyTuple_GetItem, PyTuple_SetItem
 from pyccel.ast.cwrapper      import PySet_New, PySet_Add, PyList_Check, PyList_Size
 from pyccel.ast.cwrapper      import PySet_Size, PySet_Check, PyObject_GetIter, PySet_Clear
-from pyccel.ast.cwrapper      import PyIter_Next, PyList_Clear
+from pyccel.ast.cwrapper      import PyIter_Next, PyList_Clear, PyArgumentError
 from pyccel.ast.cwrapper      import PyDict_New, PyDict_SetItem
 from pyccel.ast.cwrapper      import PyUnicode_AsUTF8, PyUnicode_Check, PyUnicode_GetLength
 from pyccel.ast.c_concepts    import ObjectAddress, PointerCast, CStackArray, CNativeInt
@@ -391,8 +391,9 @@ class CToPythonWrapper(Wrapper):
 
         if raise_error and not isinstance(arg.class_type, NumpyNDArrayType):
             # No error code required for arrays as the error is raised inside pyarray_check
-            message = LiteralString(f"Expected an argument of type {arg.class_type} for argument {arg.name}")
-            python_error = PyErr_SetString(PyTypeError, CStrStr(message))
+            python_error = PyArgumentError(PyTypeError,
+                                f"Expected an argument of type {arg.class_type} for argument {arg.name}. Received {{type(arg)}}",
+                                arg = py_obj)
             error_code = (python_error,)
 
         return type_check_condition, error_code
@@ -485,8 +486,14 @@ class CToPythonWrapper(Wrapper):
                     check_func_call, _ = self._get_type_check_condition(py_arg, type_to_example_arg[t], False, body)
                     if_blocks.append(IfSection(check_func_call, [AugAssign(type_indicator, '+', LiteralInteger(index*step))]))
                 body.append(If(*if_blocks, IfSection(LiteralTrue(),
-                            [PyErr_SetString(PyTypeError, CStrStr(LiteralString(f"Unexpected type for argument {interface_args[0].name}"))),
+                            [PyArgumentError(PyTypeError, f"Unexpected type for argument {interface_args[0].name}. Received {{type(arg)}}",
+                                arg = py_arg),
                              Return(PyccelUnarySub(LiteralInteger(1)))])))
+            elif not orig_funcs[0].arguments[i].has_default:
+                check_func_call, err_body = self._get_type_check_condition(py_arg, type_to_example_arg.popitem()[1], True, body)
+                err_body = err_body + (Return(PyccelUnarySub(LiteralInteger(1))), )
+                if_sec = IfSection(PyccelNot(check_func_call), err_body)
+                body.append(If(if_sec))
 
             # Update the step to ensure unique indices for each argument
             step *= n_possible_types
@@ -1390,6 +1397,8 @@ class CToPythonWrapper(Wrapper):
             if_sections.append(IfSection(PyccelEq(type_indicator, LiteralInteger(index)),
                                 [Return(wrapped_func(*python_arg_objs))]))
             functions.append(wrapped_func)
+        if_sections.append(IfSection(PyccelEq(type_indicator, PyccelUnarySub(LiteralInteger(1))),
+                    [Return(self._error_exit_code)]))
         if_sections.append(IfSection(LiteralTrue(),
                     [PyErr_SetString(PyTypeError, CStrStr(LiteralString("Unexpected type combination"))),
                      Return(self._error_exit_code)]))


Test patch:
diff --git a/tests/epyccel/test_epyccel_functions.py b/tests/epyccel/test_epyccel_functions.py
index 801a9b90..60012060 100644
--- a/tests/epyccel/test_epyccel_functions.py
+++ b/tests/epyccel/test_epyccel_functions.py
@@ -1,6 +1,7 @@
 # pylint: disable=missing-function-docstring, missing-module-docstring
 # coding: utf-8
 import sys
+from typing import TypeVar
 
 import pytest
 import numpy as np
@@ -366,6 +367,86 @@ def test_return_annotation(language):
     f = epyccel(get_2, language=language)
     assert f() == get_2()
 
+@pytest.mark.parametrize( 'language', (
+        pytest.param("fortran", marks = pytest.mark.fortran),
+        pytest.param("c", marks = pytest.mark.c),
+    )
+)
+def test_wrong_argument_type(language):
+    def f(integer_arg : int):
+        return integer_arg + 1
+    epyc_f = epyccel(f, language=language)
+    test_arg = 3.5
+    with pytest.raises(TypeError) as err:
+        epyc_f(test_arg)
+    assert 'integer_arg' in str(err.value)
+    assert str(type(test_arg)) in str(err.value)
+
+@pytest.mark.parametrize( 'language', (
+        pytest.param("fortran", marks = pytest.mark.fortran),
+        pytest.param("c", marks = pytest.mark.c),
+    )
+)
+def test_wrong_known_argument_type_in_interface(language):
+    T = TypeVar('T', int, float)
+
+    def f(a : T, integer_arg : int):
+        return a + 1
+    epyc_f = epyccel(f, language=language)
+    test_arg = 4.5
+    with pytest.raises(TypeError) as err:
+        epyc_f(3.5, test_arg)
+    assert 'integer_arg' in str(err.value)
+    assert str(type(test_arg)) in str(err.value)
+
+@pytest.mark.parametrize( 'language', (
+        pytest.param("fortran", marks = pytest.mark.fortran),
+        pytest.param("c", marks = pytest.mark.c),
+    )
+)
+def test_wrong_known_argument_type_in_interface_with_default(language):
+    T = TypeVar('T', int, float)
+
+    def f(a : T, integer_arg : int = 5):
+        return a + 1
+    epyc_f = epyccel(f, language=language)
+    test_arg = 4.5
+    with pytest.raises(TypeError) as err:
+        epyc_f(3.5, test_arg)
+    assert 'integer_arg' in str(err.value)
+    assert str(type(test_arg)) in str(err.value)
+
+@pytest.mark.parametrize( 'language', (
+        pytest.param("fortran", marks = pytest.mark.fortran),
+        pytest.param("c", marks = pytest.mark.c),
+    )
+)
+def test_wrong_unknown_argument_type_in_interface(language):
+    T = TypeVar('T', int, float)
+
+    def f(templated_arg : T, b : int):
+        return templated_arg + 1
+    epyc_f = epyccel(f, language=language)
+    test_arg = 3.5+1j
+    with pytest.raises(TypeError) as err:
+        epyc_f(test_arg, 4.5)
+    assert 'templated_arg' in str(err.value)
+    assert str(type(test_arg)) in str(err.value)
+
+@pytest.mark.parametrize( 'language', (
+        pytest.param("fortran", marks = pytest.mark.fortran),
+        pytest.param("c", marks = pytest.mark.c),
+    )
+)
+def test_wrong_argument_combination_in_interface(language):
+    T = TypeVar('T', int, float)
+
+    def f(a : T, b : T):
+        return a + 1
+    epyc_f = epyccel(f, language=language)
+    with pytest.raises(TypeError):
+        epyc_f(3.5, 4)
+
 ##==============================================================================
 ## CLEAN UP GENERATED FILES AFTER RUNNING TESTS
 ##==============================================================================