Repository: GridTools/gt4py
Pull request #1248 (2023-05-01 07:58:24)

feature[next]: Add support for positional-or-keyword arguments
This PR adds support for using positional-or-keyword arguments when calling Field and Scan-Operators (both in the context of  Programs, and Field-Operators), i.e. the following now works.
```python
@field_operator(backend=fieldview_backend)
def foo(inp: Field[[IDim], float64]) -> Field[[IDim], float64]:
    return inp

@field_operator(backend=fieldview_backend)
def bar(inp: Field[[IDim], float64]) -> Field[[IDim], float64]:
    return foo(inp1=inp1)

@program
def testee(
    inp: Field[[IDim], float64], out: Field[[IDim], float64]
):
    foo(inp=inp, out=out)
```

Additionally error messages are improved such that instead of errors like
`Expected 0-th argument to be of type Field[[K], int64], but got Field[[K], float64]`
the user gets a message with the name of the argument where the error occured:
` Expected` argument `a` to be of type `Field[[K], int64]`, but got `Field[[K], float64]`",

DropD: From Conversation earlier:
- Some apparent complexity in the type info functions comes from collecting error messages. If it was more explicit that this is what's happening, it would help readability.
tehrengruber: As mentioned by @DropD understanding the details of the algorithms in `type_info` in this PR (i.e. `canonicalize_function_arguments`, `structural_function_signature_incompatibilities`, `function_signature_incompatibilities_func`) is very hard and time consuming. I decomposed it into as many functions that still have meaning as a unit of their own as possible, but after all a rich call interface results in a complex algorithm. I further increased the test coverage (both unit and integration tests) so the review doesn't need to go into each and every detail.

@egparedes This PR is not time critical at all. While it is touching many files, the parts it is touching are rarely changed or the changes there are minor. Review whenever you like.

Patch:
diff --git a/src/gt4py/next/ffront/decorator.py b/src/gt4py/next/ffront/decorator.py
index 734893f6..a4efd6c1 100644
--- a/src/gt4py/next/ffront/decorator.py
+++ b/src/gt4py/next/ffront/decorator.py
@@ -90,32 +90,6 @@ def _filter_closure_vars_by_type(closure_vars: dict[str, Any], *types: type) ->
     return {name: value for name, value in closure_vars.items() if isinstance(value, types)}
 
 
-def _canonicalize_args(
-    node_params: list[foast.DataSymbol] | list[past.DataSymbol],
-    args: tuple[Any],
-    kwargs: dict[str, Any],
-) -> tuple[tuple, dict]:
-    new_args = []
-    new_kwargs = {**kwargs}
-
-    for param_i, param in enumerate(node_params):
-        if param.id in new_kwargs:
-            if param_i < len(args):
-                raise ProgramTypeError(f"got multiple values for argument {param.id}.")
-            new_args.append(kwargs[param.id])
-            new_kwargs.pop(param.id)
-        elif param_i < len(args):
-            new_args.append(args[param_i])
-        else:
-            # case when param in function definition but not in function call
-            # e.g. function expects 3 parameters, but only 2 were given.
-            # Error covered later in `accept_args`.
-            pass
-
-    args = tuple(new_args)
-    return args, new_kwargs
-
-
 def _deduce_grid_type(
     requested_grid_type: Optional[GridType],
     offsets_and_dimensions: Iterable[FieldOffset | Dimension],
@@ -305,9 +279,6 @@ class Program:
         )
 
     def _validate_args(self, *args, **kwargs) -> None:
-        if kwargs:
-            raise NotImplementedError("Keyword-only arguments are not supported yet.")
-
         arg_types = [type_translation.from_value(arg) for arg in args]
         kwarg_types = {k: type_translation.from_value(v) for k, v in kwargs.items()}
 
@@ -324,10 +295,10 @@ class Program:
             ) from err
 
     def _process_args(self, args: tuple, kwargs: dict) -> tuple[tuple, tuple, dict[str, Any]]:
-        args, kwargs = _canonicalize_args(self.past_node.params, args, kwargs)
-
         self._validate_args(*args, **kwargs)
 
+        args, kwargs = type_info.canonicalize_arguments(self.past_node.type, args, kwargs)
+
         implicit_domain = any(
             isinstance(stmt, past.Call) and "domain" not in stmt.kwargs
             for stmt in self.past_node.body
@@ -587,7 +558,7 @@ class FieldOperator(GTCallable, Generic[OperatorNodeT]):
         offset_provider: dict[str, Dimension],
         **kwargs,
     ) -> None:
-        args, kwargs = _canonicalize_args(self.foast_node.definition.params, args, kwargs)
+        args, kwargs = type_info.canonicalize_arguments(self.foast_node.type, args, kwargs)
         # TODO(tehrengruber): check all offset providers are given
         # deduce argument types
         arg_types = []
diff --git a/src/gt4py/next/ffront/experimental.py b/src/gt4py/next/ffront/experimental.py
index 7dbd4331..39da80a5 100644
--- a/src/gt4py/next/ffront/experimental.py
+++ b/src/gt4py/next/ffront/experimental.py
@@ -30,11 +30,12 @@ class BuiltInFunction:
 
 as_offset = BuiltInFunction(
     ts.FunctionType(
-        args=[
+        pos_only_args=[
             ts.DeferredType(constraint=ts.OffsetType),
             ts.DeferredType(constraint=ts.FieldType),
         ],
-        kwargs={},
+        pos_or_kw_args={},
+        kw_only_args={},
         returns=ts.DeferredType(constraint=ts.OffsetType),
     )
 )
diff --git a/src/gt4py/next/ffront/fbuiltins.py b/src/gt4py/next/ffront/fbuiltins.py
index 919b49c1..52842336 100644
--- a/src/gt4py/next/ffront/fbuiltins.py
+++ b/src/gt4py/next/ffront/fbuiltins.py
@@ -50,8 +50,9 @@ class BuiltInFunction:
 
 _reduction_like = BuiltInFunction(
     ts.FunctionType(
-        args=[ts.DeferredType(constraint=ts.FieldType)],
-        kwargs={"axis": ts.DeferredType(constraint=ts.DimensionType)},
+        pos_only_args=[ts.DeferredType(constraint=ts.FieldType)],
+        pos_or_kw_args={"axis": ts.DeferredType(constraint=ts.DimensionType)},
+        kw_only_args={},
         returns=ts.DeferredType(constraint=ts.FieldType),
     )
 )
@@ -62,42 +63,46 @@ min_over = _reduction_like
 
 broadcast = BuiltInFunction(
     ts.FunctionType(
-        args=[
+        pos_only_args=[
             ts.DeferredType(constraint=(ts.FieldType, ts.ScalarType)),
             ts.DeferredType(constraint=ts.TupleType),
         ],
-        kwargs={},
+        pos_or_kw_args={},
+        kw_only_args={},
         returns=ts.DeferredType(constraint=ts.FieldType),
     )
 )
 
 where = BuiltInFunction(
     ts.FunctionType(
-        args=[
+        pos_only_args=[
             ts.DeferredType(constraint=ts.FieldType),
             ts.DeferredType(constraint=(ts.FieldType, ts.ScalarType, ts.TupleType)),
             ts.DeferredType(constraint=(ts.FieldType, ts.ScalarType, ts.TupleType)),
         ],
-        kwargs={},
+        pos_or_kw_args={},
+        kw_only_args={},
         returns=ts.DeferredType(constraint=(ts.FieldType, ts.TupleType)),
     )
 )
 
 astype = BuiltInFunction(
     ts.FunctionType(
-        args=[
+        pos_only_args=[
             ts.DeferredType(constraint=ts.FieldType),
             ts.DeferredType(constraint=ts.FunctionType),
         ],
-        kwargs={},
+        pos_or_kw_args={},
+        kw_only_args={},
         returns=ts.DeferredType(constraint=ts.FieldType),
     )
 )
 
 _unary_math_builtin = BuiltInFunction(
     ts.FunctionType(
-        args=[ts.DeferredType(constraint=(ts.ScalarType, ts.FieldType))],
-        kwargs={},
+        pos_only_args=[ts.DeferredType(constraint=(ts.ScalarType, ts.FieldType))],
+        pos_or_kw_args={},
+        kw_only_args={},
         returns=ts.DeferredType(constraint=(ts.ScalarType, ts.FieldType)),
     )
 )
@@ -155,8 +160,9 @@ UNARY_MATH_FP_BUILTIN_NAMES = [
 # unary math predicates (float) -> bool
 _unary_math_predicate_builtin = BuiltInFunction(
     ts.FunctionType(
-        args=[ts.DeferredType(constraint=(ts.ScalarType, ts.FieldType))],
-        kwargs={},
+        pos_only_args=[ts.DeferredType(constraint=(ts.ScalarType, ts.FieldType))],
+        pos_or_kw_args={},
+        kw_only_args={},
         returns=ts.DeferredType(constraint=(ts.ScalarType, ts.FieldType)),
     )
 )
@@ -170,11 +176,12 @@ UNARY_MATH_FP_PREDICATE_BUILTIN_NAMES = ["isfinite", "isinf", "isnan"]
 # binary math builtins (number, number) -> number
 _binary_math_builtin = BuiltInFunction(
     ts.FunctionType(
-        args=[
+        pos_only_args=[
             ts.DeferredType(constraint=(ts.ScalarType, ts.FieldType)),
             ts.DeferredType(constraint=(ts.ScalarType, ts.FieldType)),
         ],
-        kwargs={},
+        pos_or_kw_args={},
+        kw_only_args={},
         returns=ts.DeferredType(constraint=(ts.ScalarType, ts.FieldType)),
     )
 )
diff --git a/src/gt4py/next/ffront/foast_passes/type_deduction.py b/src/gt4py/next/ffront/foast_passes/type_deduction.py
index 75a1805c..4876b34c 100644
--- a/src/gt4py/next/ffront/foast_passes/type_deduction.py
+++ b/src/gt4py/next/ffront/foast_passes/type_deduction.py
@@ -252,7 +252,10 @@ class FieldOperatorTypeDeduction(traits.VisitorWithSymbolTableTrait, NodeTransla
                 msg=f"Function must return `DataType`, `DeferredType`, or `VoidType`, got `{return_type}`.",
             )
         new_type = ts.FunctionType(
-            args=[new_param.type for new_param in new_params], kwargs={}, returns=return_type
+            pos_only_args=[],
+            pos_or_kw_args={str(new_param.id): new_param.type for new_param in new_params},
+            kw_only_args={},
+            returns=return_type,
         )
         return foast.FunctionDefinition(
             id=node.id,
@@ -862,7 +865,7 @@ class FieldOperatorTypeDeduction(traits.VisitorWithSymbolTableTrait, NodeTransla
             raise FieldOperatorTypeDeductionError.from_foast_node(
                 node,
                 msg=f"Incompatible argument in call to `{str(node.func)}`. Expected "
-                f"a field with dtype bool, but got `{mask_type}`.",
+                f"a field with dtype `bool`, but got `{mask_type}`.",
             )
 
         try:
diff --git a/src/gt4py/next/ffront/foast_pretty_printer.py b/src/gt4py/next/ffront/foast_pretty_printer.py
index 56e5afb8..3b81c852 100644
--- a/src/gt4py/next/ffront/foast_pretty_printer.py
+++ b/src/gt4py/next/ffront/foast_pretty_printer.py
@@ -151,7 +151,11 @@ class _PrettyPrinter(TemplatedGenerator):
         false_expr = self._parenthesize(node.false_expr, node, Group.RIGHT)
         return f"{true_expr} if {cond} else {false_expr}"
 
-    Call = as_fmt("{func}({', '.join(args)})")  # TODO: kwargs
+    def visit_Call(self, node: foast.Call, **kwargs):
+        args = self.visit(node.args, **kwargs)
+        for k, v in node.kwargs.items():
+            args.append(f"{self.visit(k, **kwargs)}={self.visit(v, **kwargs)}")
+        return f"{node.func}({', '.join(args)})"
 
     Assign = as_fmt("{target} = {value}")
 
diff --git a/src/gt4py/next/ffront/foast_to_itir.py b/src/gt4py/next/ffront/foast_to_itir.py
index 2cc71e9b..1902d71b 100644
--- a/src/gt4py/next/ffront/foast_to_itir.py
+++ b/src/gt4py/next/ffront/foast_to_itir.py
@@ -285,11 +285,31 @@ class FieldOperatorLowering(NodeTranslator):
         ):
             # Operators are lowered into lifted stencils.
             lowered_func = self.visit(node.func, **kwargs)
-            lowered_args = [self.visit(arg, **kwargs) for arg in node.args]
-            args = [f"__arg{i}" for i in range(len(lowered_args))]
-            return im.lift(im.lambda_(*args)(im.call(lowered_func)(*args)))(*lowered_args)
+            # ITIR has no support for keyword arguments. Instead, we concatenate both positional
+            # and keyword arguments and use the unique order as given in the function signature.
+            lowered_args, lowered_kwargs = type_info.canonicalize_arguments(
+                node.func.type,
+                [self.visit(arg, **kwargs) for arg in node.args],
+                {name: self.visit(arg, **kwargs) for name, arg in node.kwargs.items()},
+                use_signature_ordering=True,
+            )
+            call_args = [f"__arg{i}" for i in range(len(lowered_args))]
+            call_kwargs = [f"__kwarg_{name}" for name in lowered_kwargs.keys()]
+            return im.lift(
+                im.lambda_(*call_args, *call_kwargs)(
+                    im.call(lowered_func)(*call_args, *call_kwargs)
+                )
+            )(*lowered_args, *lowered_kwargs.values())
         elif isinstance(node.func.type, ts.FunctionType):
-            return im.call(self.visit(node.func, **kwargs))(*self.visit(node.args, **kwargs))
+            # ITIR has no support for keyword arguments. Instead, we concatenate both positional
+            # and keyword arguments and use the unique order as given in the function signature.
+            lowered_args, lowered_kwargs = type_info.canonicalize_arguments(
+                node.func.type,
+                self.visit(node.args, **kwargs),
+                self.visit(node.kwargs, **kwargs),
+                use_signature_ordering=True,
+            )
+            return im.call(self.visit(node.func, **kwargs))(*lowered_args, *lowered_kwargs.values())
 
         raise AssertionError(
             f"Call to object of type {type(node.func.type).__name__} not understood."
diff --git a/src/gt4py/next/ffront/func_to_foast.py b/src/gt4py/next/ffront/func_to_foast.py
index 2ae9a85f..7ef4f597 100644
--- a/src/gt4py/next/ffront/func_to_foast.py
+++ b/src/gt4py/next/ffront/func_to_foast.py
@@ -136,10 +136,11 @@ class FieldOperatorParser(DialectParser[foast.FunctionDefinition]):
                 foast.Symbol(
                     id=name,
                     type=ts.FunctionType(
-                        args=[
+                        pos_only_args=[
                             ts.DeferredType(constraint=ts.ScalarType)
                         ],  # this is a constraint type that will not be inferred (as the function is polymorphic)
-                        kwargs={},
+                        pos_or_kw_args={},
+                        kw_only_args={},
                         returns=cast(ts.DataType, type_translation.from_type_hint(value)),
                     ),
                     namespace=dialect_ast_enums.Namespace.CLOSURE,
@@ -436,33 +437,8 @@ class FieldOperatorParser(DialectParser[foast.FunctionDefinition]):
     def visit_NotEq(self, node: ast.NotEq, **kwargs) -> foast.CompareOperator:
         return foast.CompareOperator.NOTEQ
 
-    def _verify_builtin_function(self, node: ast.Call):
-        func_name = self._func_name(node)
-        func_info = getattr(fbuiltins, func_name).__gt_type__()
-        if not len(node.args) == len(func_info.args):
-            raise FieldOperatorSyntaxError.from_AST(
-                node,
-                msg=f"{func_name}() expected {len(func_info.args)} positional arguments, {len(node.args)} given!",
-            )
-        elif unexpected_kwargs := set(k.arg for k in node.keywords) - set(func_info.kwargs):
-            raise FieldOperatorSyntaxError.from_AST(
-                node,
-                msg=f"{self._func_name(node)}() got unexpected keyword arguments: {unexpected_kwargs}!",
-            )
-
     def _verify_builtin_type_constructor(self, node: ast.Call):
-        if not len(node.args) == 1:
-            raise FieldOperatorSyntaxError.from_AST(
-                node,
-                msg=f"{self._func_name(node)}() expected 1 positional argument, {len(node.args)} given!",
-            )
-        elif node.keywords:
-            unexpected_kwargs = set(k.arg for k in node.keywords)
-            raise FieldOperatorSyntaxError.from_AST(
-                node,
-                msg=f"{self._func_name(node)}() got unexpected keyword arguments: {unexpected_kwargs}!",
-            )
-        elif not isinstance(node.args[0], ast.Constant):
+        if len(node.args) > 0 and not isinstance(node.args[0], ast.Constant):
             raise FieldOperatorSyntaxError.from_AST(
                 node,
                 msg=f"{self._func_name(node)}() only takes literal arguments!",
@@ -475,9 +451,6 @@ class FieldOperatorParser(DialectParser[foast.FunctionDefinition]):
         # TODO(tehrengruber): is this still needed or redundant with the checks in type deduction?
         if isinstance(node.func, ast.Name):
             func_name = self._func_name(node)
-
-            if func_name in fbuiltins.FUN_BUILTIN_NAMES:
-                self._verify_builtin_function(node)
             if func_name in fbuiltins.TYPE_BUILTIN_NAMES:
                 self._verify_builtin_type_constructor(node)
 
diff --git a/src/gt4py/next/ffront/past_passes/type_deduction.py b/src/gt4py/next/ffront/past_passes/type_deduction.py
index 3c8c362b..4beb5dd8 100644
--- a/src/gt4py/next/ffront/past_passes/type_deduction.py
+++ b/src/gt4py/next/ffront/past_passes/type_deduction.py
@@ -102,7 +102,10 @@ class ProgramTypeDeduction(traits.VisitorWithSymbolTableTrait, NodeTranslator):
         params = self.visit(node.params, **kwargs)
 
         definition_type = ts.FunctionType(
-            args=[param.type for param in params], kwargs={}, returns=ts.VoidType()
+            pos_only_args=[],
+            pos_or_kw_args={str(param.id): param.type for param in params},
+            kw_only_args={},
+            returns=ts.VoidType(),
         )
         return past.Program(
             id=self.visit(node.id, **kwargs),
@@ -195,7 +198,7 @@ class ProgramTypeDeduction(traits.VisitorWithSymbolTableTrait, NodeTranslator):
             kwarg_types = {
                 name: expr.type
                 for name, expr in new_kwargs.items()
-                if not is_operator and name != "out" and name != "domain"
+                if not (is_operator and name in ["out", "domain"])
             }
 
             type_info.accepts_args(
diff --git a/src/gt4py/next/ffront/past_to_itir.py b/src/gt4py/next/ffront/past_to_itir.py
index 0b68c18c..44a408e9 100644
--- a/src/gt4py/next/ffront/past_to_itir.py
+++ b/src/gt4py/next/ffront/past_to_itir.py
@@ -18,7 +18,7 @@ from typing import Optional, cast
 
 from gt4py.eve import NodeTranslator, concepts, traits
 from gt4py.next.common import Dimension, DimensionKind, GridType, GTTypeError
-from gt4py.next.ffront import program_ast as past
+from gt4py.next.ffront import program_ast as past, type_specifications as ts_ffront
 from gt4py.next.iterator import ir as itir
 from gt4py.next.type_system import type_info, type_specifications as ts
 
@@ -64,7 +64,7 @@ class ProgramLowering(traits.VisitorWithSymbolTableTrait, NodeTranslator):
     >>> fieldop_def = ir.FunctionDefinition(
     ...     id="fieldop",
     ...     params=[ir.Sym(id="inp")],
-    ...     expr=ir.FunCall(fun=ir.SymRef(id="deref"), args=[ir.SymRef(id="inp")])
+    ...     expr=ir.FunCall(fun=ir.SymRef(id="deref"), pos_only_args=[ir.SymRef(id="inp")])
     ... )  # doctest: +SKIP
     >>> lowered = ProgramLowering.apply(parsed, [fieldop_def],
     ...     grid_type=GridType.CARTESIAN)  # doctest: +SKIP
@@ -133,15 +133,25 @@ class ProgramLowering(traits.VisitorWithSymbolTableTrait, NodeTranslator):
         assert isinstance(node.kwargs["out"].type, ts.TypeSpec)
         assert type_info.is_type_or_tuple_of_type(node.kwargs["out"].type, ts.FieldType)
 
-        domain = node.kwargs.get("domain", None)
+        node_kwargs = {**node.kwargs}
+        domain = node_kwargs.pop("domain", None)
         output, lowered_domain = self._visit_stencil_call_out_arg(
-            node.kwargs["out"], domain, **kwargs
+            node_kwargs.pop("out"), domain, **kwargs
+        )
+
+        assert isinstance(node.func.type, (ts_ffront.FieldOperatorType, ts_ffront.ScanOperatorType))
+
+        lowered_args, lowered_kwargs = type_info.canonicalize_arguments(
+            node.func.type,
+            self.visit(node.args, **kwargs),
+            self.visit(node_kwargs, **kwargs),
+            use_signature_ordering=True,
         )
 
         return itir.StencilClosure(
             domain=lowered_domain,
             stencil=itir.SymRef(id=node.func.id),
-            inputs=self.visit(node.args, **kwargs),
+            inputs=[*lowered_args, *lowered_kwargs.values()],
             output=output,
         )
 
diff --git a/src/gt4py/next/ffront/type_info.py b/src/gt4py/next/ffront/type_info.py
index 72955962..f82c5bb1 100644
--- a/src/gt4py/next/ffront/type_info.py
+++ b/src/gt4py/next/ffront/type_info.py
@@ -13,7 +13,7 @@
 # SPDX-License-Identifier: GPL-3.0-or-later
 
 from functools import reduce
-from typing import Iterable, Iterator, cast
+from typing import Iterator, cast
 
 import gt4py.next.ffront.type_specifications as ts_ffront
 import gt4py.next.type_system.type_specifications as ts
@@ -22,41 +22,41 @@ from gt4py.next.type_system import type_info
 
 
 def _is_zero_dim_field(field: ts.TypeSpec) -> bool:
-    return (
-        isinstance(field, ts.FieldType)
-        and isinstance(field.dims, Iterable)
-        and len(field.dims) == 0
-    )
+    return isinstance(field, ts.FieldType) and len(field.dims) == 0
 
 
 def promote_zero_dims(
-    args: list[ts.TypeSpec],
-    function_type: ts_ffront.FieldOperatorType | ts_ffront.ProgramType | ts.FunctionType,
-) -> list:
-    """Promote arg types to zero dimensional fields if compatible and required by function signature."""
-    new_args = []
-    for arg_i, arg in enumerate(args):
-        def_type = (
-            function_type.args[arg_i]
-            if isinstance(function_type, ts.FunctionType)
-            else function_type.definition.args[arg_i]
-        )
+    function_type: ts.FunctionType, args: list[ts.TypeSpec], kwargs: dict[str, ts.TypeSpec]
+) -> tuple[list, dict]:
+    """
+    Promote arg types to zero dimensional fields if compatible and required by function signature.
 
-        def _as_field(arg: ts.TypeSpec, path: tuple):
-            el_def_type = reduce(lambda type_, idx: type_.types[idx], path, def_type)  # noqa: B023
+    This function expects the arguments to already be canonicalized using `canonicalize_arguments`.
+    """
 
-            if _is_zero_dim_field(el_def_type) and type_info.is_number(arg):
-                if type_info.extract_dtype(el_def_type) == type_info.extract_dtype(arg):
-                    return el_def_type
+    def promote_arg(param: ts.TypeSpec, arg: ts.TypeSpec) -> ts.TypeSpec:
+        def _as_field(arg_el: ts.TypeSpec, path: tuple[int, ...]) -> ts.TypeSpec:
+            param_el = reduce(lambda type_, idx: type_.types[idx], path, param)  # type: ignore[attr-defined]
+
+            if _is_zero_dim_field(param_el) and type_info.is_number(arg_el):
+                if type_info.extract_dtype(param_el) == type_info.extract_dtype(arg_el):
+                    return param_el
                 else:
-                    raise GTTypeError(f"{arg} is not compatible with {el_def_type}.")
-            return arg
+                    raise GTTypeError(f"{arg_el} is not compatible with {param_el}.")
+            return arg_el
 
-        new_args.append(
-            type_info.apply_to_primitive_constituents(arg, _as_field, with_path_arg=True)
-        )
+        return type_info.apply_to_primitive_constituents(arg, _as_field, with_path_arg=True)
 
-    return new_args
+    new_args = [*args]
+    for i, (param, arg) in enumerate(
+        zip(function_type.pos_only_args + list(function_type.pos_or_kw_args.values()), args)
+    ):
+        new_args[i] = promote_arg(param, arg)
+    new_kwargs = {**kwargs}
+    for name in set(function_type.kw_only_args.keys()) & set(kwargs.keys()):
+        new_kwargs[name] = promote_arg(function_type.kw_only_args[name], kwargs[name])
+
+    return new_args, new_kwargs
 
 
 @type_info.return_type.register
@@ -72,18 +72,103 @@ def return_type_fieldop(
     return ret_type
 
 
+@type_info.canonicalize_arguments.register(ts_ffront.FieldOperatorType)
+@type_info.canonicalize_arguments.register(ts_ffront.ProgramType)
+def canonicalize_program_or_fieldop_arguments(
+    program_type: ts_ffront.ProgramType,
+    args: tuple | list,
+    kwargs: dict,
+    *,
+    ignore_errors=False,
+    use_signature_ordering=False,
+) -> tuple[list, dict]:
+    return type_info.canonicalize_arguments(
+        program_type.definition,
+        args,
+        kwargs,
+        ignore_errors=ignore_errors,
+        use_signature_ordering=use_signature_ordering,
+    )
+
+
+@type_info.canonicalize_arguments.register
+def canonicalize_scanop_arguments(
+    scanop_type: ts_ffront.ScanOperatorType,
+    args: tuple | list,
+    kwargs: dict,
+    *,
+    ignore_errors=False,
+    use_signature_ordering=False,
+) -> tuple[list, dict]:
+    (_, *cargs), ckwargs = type_info.canonicalize_arguments(
+        scanop_type.definition,
+        (None, *args),
+        kwargs,
+        ignore_errors=ignore_errors,
+        use_signature_ordering=use_signature_ordering,
+    )
+    return cargs, ckwargs
+
+
 @type_info.function_signature_incompatibilities.register
 def function_signature_incompatibilities_fieldop(
     fieldop_type: ts_ffront.FieldOperatorType,
     args: list[ts.TypeSpec],
     kwargs: dict[str, ts.TypeSpec],
 ) -> Iterator[str]:
-    new_args = promote_zero_dims(args, fieldop_type)
+    args, kwargs = type_info.canonicalize_arguments(
+        fieldop_type.definition, args, kwargs, ignore_errors=True
+    )
+
+    error_list = list(
+        type_info.structural_function_signature_incompatibilities(
+            fieldop_type.definition, args, kwargs
+        )
+    )
+    if len(error_list) > 0:
+        yield from error_list
+        return
+
+    new_args, new_kwargs = promote_zero_dims(fieldop_type.definition, args, kwargs)
     yield from type_info.function_signature_incompatibilities_func(
-        fieldop_type.definition, new_args, kwargs
+        fieldop_type.definition, new_args, new_kwargs, skip_canonicalization=True
     )
 
 
+def _scan_param_promotion(param: ts.TypeSpec, arg: ts.TypeSpec) -> ts.FieldType | ts.TupleType:
+    """
+    Promote parameter of a scan pass to match dimensions of respective scan operator argument.
+
+    More specifically: Given a scalar type `param` and a field type `arg` return a field with the
+    dtype of the `param` and the dimensions of `arg`. If `param` is a composite of scalars
+    the promotion is element-wise.
+
+    Example:
+    --------
+    >>> _scan_param_promotion(
+    ...     ts.ScalarType(kind=ts.ScalarKind.INT64),
+    ...     ts.FieldType(dims=[Dimension("I")], dtype=ts.ScalarKind.FLOAT64)
+    ... )
+    FieldType(dims=[Dimension(value='I', kind=<DimensionKind.HORIZONTAL: 'horizontal'>)], dtype=ScalarType(kind=<ScalarKind.INT64: 64>, shape=None))
+    """
+
+    def _as_field(dtype: ts.TypeSpec, path: tuple[int, ...]) -> ts.FieldType:
+        assert isinstance(dtype, ts.ScalarType)
+        try:
+            el_type = reduce(
+                lambda type_, idx: type_.types[idx], path, arg  # type: ignore[attr-defined]
+            )
+            return ts.FieldType(dims=type_info.extract_dims(el_type), dtype=dtype)
+        except (IndexError, AttributeError):
+            # The structure of the scan passes argument and the requested
+            # argument type differ. As such we can not extract the dimensions
+            # and just return a generic field shown in the error later on.
+            # TODO: we want some generic field type here, but our type system does not support it yet.
+            return ts.FieldType(dims=[Dimension("...")], dtype=dtype)
+
+    return type_info.apply_to_primitive_constituents(param, _as_field, with_path_arg=True)
+
+
 @type_info.function_signature_incompatibilities.register
 def function_signature_incompatibilities_scanop(
     scanop_type: ts_ffront.ScanOperatorType, args: list[ts.TypeSpec], kwargs: dict[str, ts.TypeSpec]
@@ -94,49 +179,63 @@ def function_signature_incompatibilities_scanop(
         yield "Arguments to scan operator must be fields, scalars or tuples thereof."
         return
 
+    scan_pass_type: ts.FunctionType = scanop_type.definition
+    assert len(scan_pass_type.pos_only_args) == 0
+
+    # canonicalize function arguments
+    cargs, ckwargs = type_info.canonicalize_arguments(scanop_type, args, kwargs, ignore_errors=True)
+
+    # check for structural errors
+    num_pos_args = len(cargs) - cargs.count(type_info.UNDEFINED_ARG)
+    if num_pos_args != len(scan_pass_type.pos_or_kw_args) - 1:
+        yield f"Scan operator takes {len(scan_pass_type.pos_or_kw_args) - 1} positional arguments, but {num_pos_args} were given."
+        return
+    error_list = list(
+        type_info.structural_function_signature_incompatibilities(
+            scan_pass_type, [None, *cargs], ckwargs
+        )
+    )
+    if len(error_list) > 0:
+        yield from error_list
+        return
+    assert ckwargs.keys() == scan_pass_type.kw_only_args.keys()
+
+    # ensure the dimensions of all arguments can be promoted to a common list of dimensions
     arg_dims = [
-        type_info.extract_dims(el) for arg in args for el in type_info.primitive_constituents(arg)
+        type_info.extract_dims(el)
+        for arg in [*cargs, *ckwargs.values()]
+        for el in type_info.primitive_constituents(arg)
     ]
     try:
         type_info.promote_dims(*arg_dims)
     except GTTypeError as e:
         yield e.args[0]
 
-    if len(args) != len(scanop_type.definition.args) - 1:
-        yield f"Scan operator takes {len(scanop_type.definition.args)-1} arguments, but {len(args)} were given."
-        return
+    assert len(scan_pass_type.pos_only_args) == 0
 
-    promoted_args = []
-    for i, scan_pass_arg in enumerate(scanop_type.definition.args[1:]):
-        # Helper function that given a scalar type in the signature of the scan
-        # pass return a field type with that dtype and the dimensions of the
-        # corresponding field type in the requested `args` type. Defined here
-        # as we capture `i`.
-        def _as_field(dtype: ts.ScalarType, path: tuple[int, ...]) -> ts.FieldType:
-            try:
-                el_type = reduce(lambda type_, idx: type_.types[idx], path, args[i])  # type: ignore[attr-defined] # noqa: B023
-                return ts.FieldType(dims=type_info.extract_dims(el_type), dtype=dtype)
-            except (IndexError, AttributeError):
-                # The structure of the scan passes argument and the requested
-                # argument type differ. As such we can not extract the dimensions
-                # and just return a generic field shown in the error later on.
-                # TODO: we want some generic field type here, but our type system does not support it yet.
-                return ts.FieldType(dims=[Dimension("...")], dtype=dtype)
-
-        promoted_args.append(
-            type_info.apply_to_primitive_constituents(scan_pass_arg, _as_field, with_path_arg=True)  # type: ignore[arg-type]
-        )
+    # promote parameters
+    promoted_params = {}
+    for (name, param), arg in zip(list(scan_pass_type.pos_or_kw_args.items())[1:], cargs):
+        promoted_params[name] = _scan_param_promotion(param, arg)
+    promoted_kwparams = {}
+    for name, param, arg in zip(
+        ckwargs.keys(), scan_pass_type.kw_only_args.values(), ckwargs.values()
+    ):
+        promoted_kwparams[name] = _scan_param_promotion(param, arg)
 
-    # build a function type to leverage the already existing signature checking
-    #  capabilities
+    # build a function type to leverage the already existing signature checking capabilities
     function_type = ts.FunctionType(
-        args=promoted_args,
-        kwargs={},
+        pos_only_args=[],
+        pos_or_kw_args=promoted_params,  # type: ignore[arg-type] # dict is invariant, but we don't care here.
+        kw_only_args=promoted_kwparams,  # type: ignore[arg-type] # same as above
         returns=ts.DeferredType(constraint=None),
     )
 
-    yield from type_info.function_signature_incompatibilities(
-        function_type, promote_zero_dims(args, function_type), kwargs
+    yield from type_info.function_signature_incompatibilities_func(
+        function_type,
+        *promote_zero_dims(function_type, cargs, ckwargs),
+        skip_canonicalization=True,
+        skip_structural_checks=True,
     )
 
 
@@ -144,9 +243,22 @@ def function_signature_incompatibilities_scanop(
 def function_signature_incompatibilities_program(
     program_type: ts_ffront.ProgramType, args: list[ts.TypeSpec], kwargs: dict[str, ts.TypeSpec]
 ) -> Iterator[str]:
-    new_args = promote_zero_dims(args, program_type)
+    args, kwargs = type_info.canonicalize_arguments(
+        program_type.definition, args, kwargs, ignore_errors=True
+    )
+
+    error_list = list(
+        type_info.structural_function_signature_incompatibilities(
+            program_type.definition, args, kwargs
+        )
+    )
+    if len(error_list) > 0:
+        yield from error_list
+        return
+
+    new_args, new_kwargs = promote_zero_dims(program_type.definition, args, kwargs)
     yield from type_info.function_signature_incompatibilities_func(
-        program_type.definition, new_args, kwargs
+        program_type.definition, new_args, new_kwargs, skip_canonicalization=True
     )
 
 
@@ -161,7 +273,7 @@ def return_type_scanop(
     promoted_dims = type_info.promote_dims(
         *(
             type_info.extract_dims(el)
-            for arg in with_args
+            for arg in with_args + list(with_kwargs.values())
             for el in type_info.primitive_constituents(arg)
         ),
         # the vertical axis is always added to the dimension of the returned
diff --git a/src/gt4py/next/type_system/type_info.py b/src/gt4py/next/type_system/type_info.py
index 5024e0fe..ebc0921e 100644
--- a/src/gt4py/next/type_system/type_info.py
+++ b/src/gt4py/next/type_system/type_info.py
@@ -13,7 +13,8 @@
 # SPDX-License-Identifier: GPL-3.0-or-later
 
 import functools
-from typing import Callable, Iterator, Type, TypeGuard, cast
+import types
+from typing import Any, Callable, Iterator, Type, TypeGuard, cast
 
 import numpy as np
 
@@ -540,6 +541,113 @@ def return_type_field(
     return ts.FieldType(dims=new_dims, dtype=field_type.dtype)
 
 
+UNDEFINED_ARG = types.new_class("UNDEFINED_ARG")
+
+
+@functools.singledispatch
+def canonicalize_arguments(
+    func_type: ts.CallableType,
+    args: tuple | list,
+    kwargs: dict,
+    *,
+    ignore_errors=False,
+    use_signature_ordering=False,
+) -> tuple[list, dict]:
+    raise NotImplementedError(f"Not implemented for type {type(func_type).__name__}.")
+
+
+@canonicalize_arguments.register
+def canonicalize_function_arguments(
+    func_type: ts.FunctionType,
+    args: tuple | list,
+    kwargs: dict,
+    *,
+    ignore_errors=False,
+    use_signature_ordering=False,
+) -> tuple[list, dict]:
+    num_pos_params = len(func_type.pos_only_args) + len(func_type.pos_or_kw_args)
+    cargs = [UNDEFINED_ARG] * max(num_pos_params, len(args))
+    ckwargs = {**kwargs}
+    for i, arg in enumerate(args):
+        cargs[i] = arg
+    for name in kwargs.keys():
+        if name in func_type.pos_or_kw_args:
+            args_idx = len(func_type.pos_only_args) + list(func_type.pos_or_kw_args.keys()).index(
+                name
+            )
+            if cargs[args_idx] is UNDEFINED_ARG:
+                cargs[args_idx] = ckwargs.pop(name)
+            elif not ignore_errors:
+                raise AssertionError(
+                    f"Error canonicalizing function arguments. Got multiple values for argument `{name}`."
+                )
+
+    a, b = set(func_type.kw_only_args.keys()), set(ckwargs.keys())
+    invalid_kw_args = (a - b) | (b - a)
+    if invalid_kw_args and (not ignore_errors or use_signature_ordering):
+        # this error can not be ignored as otherwise the invariant that no arguments are dropped
+        # is invalidated.
+        raise AssertionError(f"Invalid keyword arguments {', '.join(invalid_kw_args)}.")
+
+    if use_signature_ordering:
+        ckwargs = {k: ckwargs[k] for k in func_type.kw_only_args.keys() if k in ckwargs}
+
+    return list(cargs), ckwargs
+
+
+def structural_function_signature_incompatibilities(
+    func_type: ts.FunctionType, args: list, kwargs: dict[str, Any]
+) -> Iterator[str]:
+    """
+    Return structural incompatibilities for a call to ``func_type`` with given arguments.
+
+    This function requires `args` and `kwargs` to be canonicalized using
+    `canonicalize_function_arguments`.
+
+    Contrary to `function_signature_incompatibilities` the arguments don't need to be types, but
+    can be anything. This function merely checks whether their structure matches (e.g. for every
+    parameter in the signature there is a corresponding argument value).
+    """
+    kwargs = {**kwargs}
+
+    # check positional arguments
+    for name in {**kwargs}.keys():
+        if name in func_type.pos_or_kw_args:
+            args_idx = len(func_type.pos_only_args) + list(func_type.pos_or_kw_args.keys()).index(
+                name
+            )
+            if args_idx < len(args):
+                # remove the argument here such that later errors stay comprehensible
+                kwargs.pop(name)
+                yield f"Got multiple values for argument `{name}`."
+
+    num_pos_params = len(func_type.pos_only_args) + len(func_type.pos_or_kw_args)
+    num_pos_args = len(args) - args.count(UNDEFINED_ARG)
+    if num_pos_params != num_pos_args:
+        if len(kwargs) > 0:
+            kwargs_msg = f"positional argument{'s' if num_pos_params != 1 else ''} (and {len(kwargs)} keyword-only argument{'s' if len(kwargs) != 1 else ''}) "
+        else:
+            kwargs_msg = ""
+        yield f"Function takes {num_pos_params} positional argument{'s' if num_pos_params != 1 else ''}, but {num_pos_args} {kwargs_msg}were given."
+
+    missing_positional_args = []
+    for i, arg_type in zip(
+        range(len(func_type.pos_only_args), num_pos_params), func_type.pos_or_kw_args.keys()
+    ):
+        if args[i] is UNDEFINED_ARG:
+            missing_positional_args.append(f"`{arg_type}`")
+    if missing_positional_args:
+        yield f"Missing {len(missing_positional_args)} required positional argument{'s' if len(missing_positional_args) != 1 else ''}: {', '.join(missing_positional_args)}"
+
+    # check for missing or extra keyword arguments
+    kw_a_m_b = set(func_type.kw_only_args.keys()) - set(kwargs.keys())
+    if len(kw_a_m_b) > 0:
+        yield f"Missing required keyword argument{'s' if len(kw_a_m_b) != 1 else ''} `{'`, `'.join(kw_a_m_b)}`."
+    kw_b_m_a = set(kwargs.keys()) - set(func_type.kw_only_args.keys())
+    if len(kw_b_m_a) > 0:
+        yield f"Got unexpected keyword argument{'s' if len(kw_b_m_a) != 1 else ''} `{'`, `'.join(kw_b_m_a)}`."
+
+
 @functools.singledispatch
 def function_signature_incompatibilities(
     func_type: ts.CallableType, args: list[ts.TypeSpec], kwargs: dict[str, ts.TypeSpec]
@@ -553,29 +661,45 @@ def function_signature_incompatibilities(
 
 
 @function_signature_incompatibilities.register
-def function_signature_incompatibilities_func(
-    func_type: ts.FunctionType, args: list[ts.TypeSpec], kwargs: dict[str, ts.TypeSpec]
+def function_signature_incompatibilities_func(  # noqa: C901
+    func_type: ts.FunctionType,
+    args: list[ts.TypeSpec],
+    kwargs: dict[str, ts.TypeSpec],
+    *,
+    skip_canonicalization=False,
+    skip_structural_checks=False,
 ) -> Iterator[str]:
-    # check positional arguments
-    if len(func_type.args) != len(args):
-        yield f"Function takes {len(func_type.args)} argument(s), but {len(args)} were given."
-    for i, (a_arg, b_arg) in enumerate(zip(func_type.args, args)):
-        if a_arg != b_arg and not is_concretizable(a_arg, to_type=b_arg):
-            yield f"Expected {i}-th argument to be of type {a_arg}, but got {b_arg}."
+    if not skip_canonicalization:
+        args, kwargs = canonicalize_arguments(func_type, args, kwargs, ignore_errors=True)
 
-    # check for missing or extra keyword arguments
-    kw_a_m_b = set(func_type.kwargs.keys()) - set(kwargs.keys())
-    if len(kw_a_m_b) > 0:
-        yield f"Missing required keyword argument(s) `{'`, `'.join(kw_a_m_b)}`."
-    kw_b_m_a = set(kwargs.keys()) - set(func_type.kwargs.keys())
-    if len(kw_b_m_a) > 0:
-        yield f"Got unexpected keyword argument(s) `{'`, `'.join(kw_b_m_a)}`."
+    # check for structural errors (e.g. wrong number of arguments)
+    if not skip_structural_checks:
+        error_list = list(structural_function_signature_incompatibilities(func_type, args, kwargs))
+        if len(error_list) > 0:
+            yield from error_list
+            return
 
-    for kwarg in set(func_type.kwargs.keys()) & set(kwargs.keys()):
-        if (a_kwarg := func_type.kwargs[kwarg]) != (
+    num_pos_params = len(func_type.pos_only_args) + len(func_type.pos_or_kw_args)
+    assert len(args) >= num_pos_params
+    for i, (a_arg, b_arg) in enumerate(
+        zip(func_type.pos_only_args + list(func_type.pos_or_kw_args.values()), args)
+    ):
+        if (
+            b_arg is not UNDEFINED_ARG
+            and a_arg != b_arg
+            and not is_concretizable(a_arg, to_type=b_arg)
+        ):
+            if i < len(func_type.pos_only_args):
+                arg_repr = f"{i}-th argument"
+            else:
+                arg_repr = f"argument `{list(func_type.pos_or_kw_args.keys())[i-len(func_type.pos_only_args)]}`"
+            yield f"Expected {arg_repr} to be of type `{a_arg}`, but got `{b_arg}`."
+
+    for kwarg in set(func_type.kw_only_args.keys()) & set(kwargs.keys()):
+        if (a_kwarg := func_type.kw_only_args[kwarg]) != (
             b_kwarg := kwargs[kwarg]
         ) and not is_concretizable(a_kwarg, to_type=b_kwarg):
-            yield f"Expected keyword argument {kwarg} to be of type {func_type.kwargs[kwarg]}, but got {kwargs[kwarg]}."
+            yield f"Expected keyword argument `{kwarg}` to be of type `{func_type.kw_only_args[kwarg]}`, but got `{kwargs[kwarg]}`."
 
 
 @function_signature_incompatibilities.register
@@ -622,8 +746,9 @@ def accepts_args(
     Examples:
         >>> bool_type = ts.ScalarType(kind=ts.ScalarKind.BOOL)
         >>> func_type = ts.FunctionType(
-        ...     args=[bool_type],
-        ...     kwargs={"foo": bool_type},
+        ...     pos_only_args=[bool_type],
+        ...     pos_or_kw_args={"foo": bool_type},
+        ...     kw_only_args={},
         ...     returns=ts.VoidType()
         ... )
         >>> accepts_args(func_type, with_args=[bool_type], with_kwargs={"foo": bool_type})
diff --git a/src/gt4py/next/type_system/type_specifications.py b/src/gt4py/next/type_system/type_specifications.py
index 10d2aa57..3456a3ce 100644
--- a/src/gt4py/next/type_system/type_specifications.py
+++ b/src/gt4py/next/type_system/type_specifications.py
@@ -112,12 +112,13 @@ class FieldType(DataType, CallableType):
 
 @dataclass(frozen=True)
 class FunctionType(TypeSpec, CallableType):
-    args: list[DataType | DeferredType]
-    kwargs: dict[str, DataType | DeferredType]
+    pos_only_args: list[DataType | DeferredType]
+    pos_or_kw_args: dict[str, DataType | DeferredType]
+    kw_only_args: dict[str, DataType | DeferredType]
     returns: DataType | DeferredType | VoidType
 
     def __str__(self):
-        arg_strs = [str(arg) for arg in self.args]
-        kwarg_strs = [f"{key}: {value}" for key, value in self.kwargs.items()]
+        arg_strs = [str(arg) for arg in self.pos_only_args]
+        kwarg_strs = [f"{key}: {value}" for key, value in self.pos_or_kw_args.items()]
         args_str = ", ".join((*arg_strs, *kwarg_strs))
         return f"({args_str}) -> {self.returns}"
diff --git a/src/gt4py/next/type_system/type_translation.py b/src/gt4py/next/type_system/type_translation.py
index b60ea115..3d054c07 100644
--- a/src/gt4py/next/type_system/type_translation.py
+++ b/src/gt4py/next/type_system/type_translation.py
@@ -149,7 +149,10 @@ def from_type_hint(
 
             # TODO(tehrengruber): print better error when no return type annotation is given
             return ts.FunctionType(
-                args=args, kwargs=kwargs, returns=recursive_make_symbol(return_type)
+                pos_only_args=args,
+                pos_or_kw_args=kwargs,
+                kw_only_args={},  # TODO
+                returns=recursive_make_symbol(return_type),
             )
 
     raise TypingError(f"'{type_hint}' type is not supported")


Test patch:
diff --git a/tests/next_tests/integration_tests/feature_tests/cases.py b/tests/next_tests/integration_tests/feature_tests/cases.py
index 49137808..d7c035ea 100644
--- a/tests/next_tests/integration_tests/feature_tests/cases.py
+++ b/tests/next_tests/integration_tests/feature_tests/cases.py
@@ -367,9 +367,9 @@ def verify(
             does not take an ``out`` keyword argument, so some of the other
             arguments must be in/out parameters. Pass the according field
             or tuple of fields here and they will be compared to ``ref`` under
-            the assumption that the fieldview code stores it's results in
+            the assumption that the fieldview code stores its results in
             them.
-        offset_provied: An override for the test case's offset_provider.
+        offset_provider: An override for the test case's offset_provider.
             Use with care!
         comparison: A comparison function, which will be called as
             ``comparison(ref, <out | inout>)`` and should return a boolean.
diff --git a/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_arg_call_interface.py b/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_arg_call_interface.py
new file mode 100644
index 00000000..a3e96771
--- /dev/null
+++ b/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_arg_call_interface.py
@@ -0,0 +1,232 @@
+# GT4Py - GridTools Framework
+#
+# Copyright (c) 2014-2023, ETH Zurich
+# All rights reserved.
+#
+# This file is part of the GT4Py project and the GridTools framework.
+# GT4Py is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the
+# Free Software Foundation, either version 3 of the License, or any later
+# version. See the LICENSE.txt file at the top-level directory of this
+# distribution for a copy of the license or check <https://www.gnu.org/licenses/>.
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+import itertools
+import typing
+
+import numpy as np
+import pytest
+
+from gt4py.next.common import Field
+from gt4py.next.ffront.decorator import field_operator, program, scan_operator
+from gt4py.next.ffront.fbuiltins import int64
+from gt4py.next.program_processors.runners import gtfn_cpu
+
+from next_tests.integration_tests.feature_tests import cases
+from next_tests.integration_tests.feature_tests.cases import (
+    IDim,
+    IField,
+    IJKField,
+    IJKFloatField,
+    JDim,
+    KDim,
+    cartesian_case,
+)
+from next_tests.integration_tests.feature_tests.ffront_tests.ffront_test_utils import (
+    fieldview_backend,
+)
+
+
+def _generate_arg_permutations(
+    parameters: tuple[str],
+) -> typing.Iterable[tuple[tuple[str], tuple[str]]]:
+    """
+    Given a set of parameters generate all permutations of arguments and keyword arguments.
+
+    In other words give all possible combinations to call a function with positional-or-keyword
+    arguments. The return value is an iterable with each element being a tuple of the parameters
+    passed as positional arguments and keyword arguments.
+    """
+    for num_args in range(len(parameters)):
+        for kwarg_names in itertools.permutations(parameters[num_args:]):
+            yield (parameters[:num_args], kwarg_names)
+
+
+@pytest.mark.parametrize("arg_spec", _generate_arg_permutations(("a", "b", "c")))
+def test_call_field_operator_from_python(cartesian_case, arg_spec: tuple[tuple[str], tuple[str]]):
+    @field_operator
+    def testee(a: IField, b: IField, c: IField) -> IField:
+        return a * 2 * b - c
+
+    args = {name: cases.allocate(cartesian_case, testee, name)() for name in ("a", "b", "c")}
+    out = cases.allocate(cartesian_case, testee, cases.RETURN)()
+
+    # split into arguments we want to pass as positional and keyword
+    arg_names, kwarg_names = arg_spec
+    pos_args = [args[name] for name in arg_names]
+    kw_args = {name: args[name] for name in kwarg_names}
+
+    testee.with_backend(cartesian_case.backend)(
+        *pos_args, **kw_args, out=out, offset_provider=cartesian_case.offset_provider
+    )
+
+    expected = np.asarray(args["a"]) * 2 * np.asarray(args["b"]) - np.asarray(args["c"])
+
+    assert np.allclose(out, expected)
+
+
+@pytest.mark.parametrize("arg_spec", _generate_arg_permutations(("a", "b", "out")))
+def test_call_program_from_python(cartesian_case, arg_spec):
+    @field_operator
+    def foo(a: IField, b: IField) -> IField:
+        return a + 2 * b
+
+    @program
+    def testee(a: IField, b: IField, out: IField):
+        foo(a, b, out=out)
+
+    args = {name: cases.allocate(cartesian_case, testee, name)() for name in ("a", "b", "out")}
+
+    # split into arguments we want to pass as positional and keyword
+    arg_names, kwarg_names = arg_spec
+    pos_args = [args[name] for name in arg_names]
+    kw_args = {name: args[name] for name in kwarg_names}
+
+    testee.with_backend(cartesian_case.backend)(
+        *pos_args, **kw_args, offset_provider=cartesian_case.offset_provider
+    )
+
+    expected = np.asarray(args["a"]) + 2 * np.asarray(args["b"])
+
+    assert np.allclose(args["out"], expected)
+
+
+def test_call_field_operator_from_field_operator(cartesian_case):
+    @field_operator
+    def foo(x: IField, y: IField, z: IField):
+        return x + 2 * y + 3 * z
+
+    @field_operator
+    def testee(a: IField, b: IField, c: IField) -> IField:
+        return foo(a, b, c) + 5 * foo(a, y=b, z=c) + 7 * foo(a, z=c, y=b) + 11 * foo(a, b, z=c)
+
+    def foo_np(x, y, z):
+        return x + 2 * y + 3 * z
+
+    def testee_np(a, b, c):
+        return (
+            foo_np(a, b, c)
+            + 5 * foo_np(a, y=b, z=c)
+            + 7 * foo_np(a, z=c, y=b)
+            + 11 * foo_np(a, b, z=c)
+        )
+
+    cases.verify_with_default_data(cartesian_case, testee, ref=testee_np)
+
+
+def test_call_field_operator_from_program(cartesian_case):
+    @field_operator
+    def foo(x: IField, y: IField, z: IField) -> IField:
+        return x + 2 * y + 3 * z
+
+    @program
+    def testee(
+        a: IField,
+        b: IField,
+        c: IField,
+        out1: IField,
+        out2: IField,
+        out3: IField,
+        out4: IField,
+    ):
+        foo(a, b, c, out=out1)
+        foo(a, y=b, z=c, out=out2)
+        foo(a, z=c, y=b, out=out3)
+        foo(a, b, z=c, out=out4)
+
+    a, b, c = (cases.allocate(cartesian_case, testee, name)() for name in ("a", "b", "c"))
+    out = (
+        cases.allocate(cartesian_case, testee, name, strategy=cases.ZeroInitializer())()
+        for name in ("out1", "out2", "out3", "out4")
+    )
+
+    ref = np.asarray(a) + 2 * np.asarray(b) + 3 * np.asarray(c)
+
+    cases.verify(
+        cartesian_case,
+        testee,
+        a,
+        b,
+        c,
+        *out,
+        inout=out,
+        ref=(ref, ref, ref, ref),
+        comparison=lambda out, ref: all(map(np.allclose, zip(out, ref))),
+    )
+
+
+def test_call_scan_operator_from_field_operator(cartesian_case):
+    if cartesian_case.backend in [gtfn_cpu.run_gtfn, gtfn_cpu.run_gtfn_imperative]:
+        pytest.xfail("Calling scan from field operator not fully supported in gtfn.")
+
+    @scan_operator(axis=KDim, forward=True, init=0.0)
+    def testee_scan(state: float, x: float, y: float) -> float:
+        return state + x + 2.0 * y
+
+    @field_operator
+    def testee(a: IJKFloatField, b: IJKFloatField) -> IJKFloatField:
+        return (
+            testee_scan(a, b)
+            + 3.0 * testee_scan(a, y=b)
+            + 5.0 * testee_scan(x=a, y=b)
+            + 7.0 * testee_scan(y=b, x=a)
+        )
+
+    a, b, out = (
+        cases.allocate(cartesian_case, testee, name)() for name in ("a", "b", cases.RETURN)
+    )
+    expected = (1.0 + 3.0 + 5.0 + 7.0) * np.add.accumulate(
+        np.asarray(a) + 2.0 * np.asarray(b), axis=2
+    )
+
+    cases.verify(cartesian_case, testee, a, b, out=out, ref=expected)
+
+
+def test_call_scan_operator_from_program(cartesian_case):
+    @scan_operator(axis=KDim, forward=True, init=0.0)
+    def testee_scan(state: float, x: float, y: float) -> float:
+        return state + x + 2.0 * y
+
+    @program
+    def testee(
+        a: IJKFloatField,
+        b: IJKFloatField,
+        out1: IJKFloatField,
+        out2: IJKFloatField,
+        out3: IJKFloatField,
+        out4: IJKFloatField,
+    ):
+        testee_scan(a, b, out=out1)
+        testee_scan(a, y=b, out=out2)
+        testee_scan(x=a, y=b, out=out3)
+        testee_scan(y=b, x=a, out=out4)
+
+    a, b = (cases.allocate(cartesian_case, testee, name)() for name in ("a", "b"))
+    out = (
+        cases.allocate(cartesian_case, testee, name, strategy=cases.ZeroInitializer())()
+        for name in ("out1", "out2", "out3", "out4")
+    )
+
+    ref = np.add.accumulate(np.asarray(a) + 2 * np.asarray(b), axis=2)
+
+    cases.verify(
+        cartesian_case,
+        testee,
+        a,
+        b,
+        *out,
+        inout=out,
+        ref=(ref, ref, ref, ref),
+        comparison=lambda out, ref: all(map(np.allclose, zip(out, ref))),
+    )
diff --git a/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_foast_pretty_printer.py b/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_foast_pretty_printer.py
index 84b2f1e3..13212107 100644
--- a/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_foast_pretty_printer.py
+++ b/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_foast_pretty_printer.py
@@ -51,19 +51,21 @@ def test_one_to_one(test_case: str):
 
 
 def test_fieldop():
+    I = Dimension("I")
+
     @field_operator
-    def foo(inp: Field[[], int64]):
-        return inp
+    def foo(inp1: Field[[I], int64], inp2: Field[[I], int64]):
+        return inp1 + inp2
 
     @field_operator
-    def bar(inp: Field[[], int64]) -> Field[[], int64]:
-        return foo(inp)
+    def bar(inp1: Field[[I], int64], inp2: Field[[I], int64]) -> Field[[I], int64]:
+        return foo(inp1, inp2=inp2)
 
     expected = textwrap.dedent(
         """
         @field_operator
-        def bar(inp: Field[[], int64]) -> Field[[], int64]:
-          return foo(inp)
+        def bar(inp1: Field[[I], int64], inp2: Field[[I], int64]) -> Field[[I], int64]:
+          return foo(inp1, inp2=inp2)
         """
     ).strip()
 
diff --git a/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_program.py b/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_program.py
index bc6642c5..875b89e9 100644
--- a/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_program.py
+++ b/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_program.py
@@ -231,8 +231,8 @@ def test_wrong_argument_type(cartesian_case, copy_program_def):
         copy_program(inp, out, offset_provider={})
 
     msgs = [
-        "- Expected 0-th argument to be of type Field\[\[IDim], float64\],"
-        " but got Field\[\[JDim\], float64\].",
+        "- Expected argument `in_field` to be of type `Field\[\[IDim], float64\]`,"
+        " but got `Field\[\[JDim\], float64\]`.",
     ]
     for msg in msgs:
         assert re.search(msg, exc_info.value.__cause__.args[0]) is not None
@@ -255,46 +255,3 @@ def test_dimensions_domain(cartesian_case):
         match=(r"Dimensions in out field and field domain are not equivalent"),
     ):
         empty_domain_program(a, out_field, offset_provider={})
-
-
-def test_input_kwargs(cartesian_case):
-    @gtx.field_operator(backend=cartesian_case.backend)
-    def fieldop_input_kwargs(
-        a: cases.IJField,
-        b: cases.IJField,
-        c: cases.IJField,
-    ) -> cases.IJField:
-        return c * a - b
-
-    input_1 = cases.allocate(cartesian_case, fieldop_input_kwargs, "a")()
-    input_2 = cases.allocate(cartesian_case, fieldop_input_kwargs, "b")()
-    input_3 = cases.allocate(cartesian_case, fieldop_input_kwargs, "c")()
-    expected = np.asarray(input_3) * np.asarray(input_1) - np.asarray(input_2)
-    out = cases.allocate(cartesian_case, fieldop_input_kwargs, cases.RETURN)()
-
-    fieldop_input_kwargs(input_1, b=input_2, c=input_3, out=out, offset_provider={})
-    assert np.allclose(expected, out)
-
-    @gtx.program(backend=cartesian_case.backend)
-    def program_input_kwargs(
-        a: cases.IJField,
-        b: cases.IJField,
-        c: cases.IJField,
-        out: cases.IJField,
-    ):
-        fieldop_input_kwargs(a, b, c, out=out)
-
-    out = cases.allocate(cartesian_case, program_input_kwargs, "out").strategy(
-        cases.ConstInitializer(0)
-    )()
-    program_input_kwargs(input_1, b=input_2, c=input_3, out=out, offset_provider={})
-    assert np.allclose(expected, out)
-
-    out = cases.allocate(cartesian_case, program_input_kwargs, "out").strategy(
-        cases.ConstInitializer(0)
-    )()
-    program_input_kwargs(a=input_1, b=input_2, c=input_3, out=out, offset_provider={})
-    assert np.allclose(expected, out)
-
-    with pytest.raises(GTTypeError, match="got multiple values for argument"):
-        program_input_kwargs(input_2, input_3, a=input_1, out=out, offset_provider={})
diff --git a/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_type_deduction.py b/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_type_deduction.py
index b7a44c41..897bab9d 100644
--- a/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_type_deduction.py
+++ b/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_type_deduction.py
@@ -101,70 +101,188 @@ def callable_type_info_cases():
     float_type = ts.ScalarType(kind=ts.ScalarKind.FLOAT64)
     int_type = ts.ScalarType(kind=ts.ScalarKind.INT64)
     field_type = ts.FieldType(dims=[Dimension("I")], dtype=float_type)
-    nullary_func_type = ts.FunctionType(args=[], kwargs={}, returns=ts.VoidType())
-    unary_func_type = ts.FunctionType(args=[bool_type], kwargs={}, returns=ts.VoidType())
-    kwarg_func_type = ts.FunctionType(args=[], kwargs={"foo": bool_type}, returns=ts.VoidType())
+    nullary_func_type = ts.FunctionType(
+        pos_only_args=[], pos_or_kw_args={}, kw_only_args={}, returns=ts.VoidType()
+    )
+    unary_func_type = ts.FunctionType(
+        pos_only_args=[bool_type], pos_or_kw_args={}, kw_only_args={}, returns=ts.VoidType()
+    )
+    kw_only_arg_func_type = ts.FunctionType(
+        pos_only_args=[], pos_or_kw_args={}, kw_only_args={"foo": bool_type}, returns=ts.VoidType()
+    )
+    kw_or_pos_arg_func_type = ts.FunctionType(
+        pos_only_args=[], pos_or_kw_args={"foo": bool_type}, kw_only_args={}, returns=ts.VoidType()
+    )
+    pos_arg_and_kw_or_pos_arg_and_kw_only_arg_func_type = ts.FunctionType(
+        pos_only_args=[bool_type],
+        pos_or_kw_args={"foo": int_type},
+        kw_only_args={"bar": float_type},
+        returns=ts.VoidType(),
+    )
     fieldop_type = gt4py.next.ffront.type_specifications.FieldOperatorType(
-        definition=ts.FunctionType(args=[field_type, float_type], kwargs={}, returns=field_type)
+        definition=ts.FunctionType(
+            pos_only_args=[field_type, float_type],
+            pos_or_kw_args={},
+            kw_only_args={},
+            returns=field_type,
+        )
     )
     scanop_type = gt4py.next.ffront.type_specifications.ScanOperatorType(
         axis=KDim,
         definition=ts.FunctionType(
-            args=[float_type, int_type, int_type], kwargs={}, returns=float_type
+            pos_only_args=[],
+            pos_or_kw_args={"carry": float_type, "a": int_type, "b": int_type},
+            kw_only_args={},
+            returns=float_type,
         ),
     )
     tuple_scanop_type = gt4py.next.ffront.type_specifications.ScanOperatorType(
         axis=KDim,
         definition=ts.FunctionType(
-            args=[float_type, ts.TupleType(types=[int_type, int_type])],
-            kwargs={},
+            pos_only_args=[],
+            pos_or_kw_args={"carry": float_type, "a": ts.TupleType(types=[int_type, int_type])},
+            kw_only_args={},
             returns=float_type,
         ),
     )
 
     return [
-        # func_type, args, kwargs, expected incompatibilities, return type
+        # func_type, pos_only_args, kwargs, expected incompatibilities, return type
         *not_callable,
         (nullary_func_type, [], {}, [], ts.VoidType()),
         (
             nullary_func_type,
             [bool_type],
             {},
-            [r"Function takes 0 argument\(s\), but 1 were given."],
+            [r"Function takes 0 positional arguments, but 1 were given."],
             None,
         ),
         (
             nullary_func_type,
             [],
             {"foo": bool_type},
-            [r"Got unexpected keyword argument\(s\) `foo`."],
+            [r"Got unexpected keyword argument `foo`."],
+            None,
+        ),
+        (
+            unary_func_type,
+            [],
+            {},
+            [r"Function takes 1 positional argument, but 0 were given."],
             None,
         ),
-        (unary_func_type, [], {}, [r"Function takes 1 argument\(s\), but 0 were given."], None),
         (unary_func_type, [bool_type], {}, [], ts.VoidType()),
         (
             unary_func_type,
             [float_type],
             {},
-            [r"Expected 0-th argument to be of type bool, but got float64."],
+            [r"Expected 0-th argument to be of type `bool`, but got `float64`."],
             None,
         ),
-        (kwarg_func_type, [], {}, [r"Missing required keyword argument\(s\) `foo`."], None),
-        (kwarg_func_type, [], {"foo": bool_type}, [], ts.VoidType()),
         (
-            kwarg_func_type,
+            kw_or_pos_arg_func_type,
+            [],
+            {},
+            [
+                r"Missing 1 required positional argument: `foo`",
+                r"Function takes 1 positional argument, but 0 were given.",
+            ],
+            None,
+        ),
+        # function with keyword-or-positional argument
+        (kw_or_pos_arg_func_type, [], {"foo": bool_type}, [], ts.VoidType()),
+        (
+            kw_or_pos_arg_func_type,
+            [],
+            {"foo": float_type},
+            [r"Expected argument `foo` to be of type `bool`, but got `float64`."],
+            None,
+        ),
+        (
+            kw_or_pos_arg_func_type,
+            [],
+            {"bar": bool_type},
+            [r"Got unexpected keyword argument `bar`."],
+            None,
+        ),
+        # function with keyword-only argument
+        (kw_only_arg_func_type, [], {}, [r"Missing required keyword argument `foo`."], None),
+        (kw_only_arg_func_type, [], {"foo": bool_type}, [], ts.VoidType()),
+        (
+            kw_only_arg_func_type,
             [],
             {"foo": float_type},
-            [r"Expected keyword argument foo to be of type bool, but got float64."],
+            [r"Expected keyword argument `foo` to be of type `bool`, but got `float64`."],
             None,
         ),
         (
-            kwarg_func_type,
+            kw_only_arg_func_type,
             [],
             {"bar": bool_type},
-            [r"Got unexpected keyword argument\(s\) `bar`."],
+            [r"Got unexpected keyword argument `bar`."],
             None,
         ),
+        # function with positional, keyword-or-positional, and keyword-only argument
+        (
+            pos_arg_and_kw_or_pos_arg_and_kw_only_arg_func_type,
+            [],
+            {},
+            [
+                r"Missing 1 required positional argument: `foo`",
+                r"Function takes 2 positional arguments, but 0 were given.",
+                r"Missing required keyword argument `bar`",
+            ],
+            None,
+        ),
+        (
+            pos_arg_and_kw_or_pos_arg_and_kw_only_arg_func_type,
+            [bool_type],
+            {},
+            [
+                r"Function takes 2 positional arguments, but 1 were given.",
+                r"Missing required keyword argument `bar`",
+            ],
+            None,
+        ),
+        (
+            pos_arg_and_kw_or_pos_arg_and_kw_only_arg_func_type,
+            [bool_type],
+            {"foo": int_type},
+            [r"Missing required keyword argument `bar`"],
+            None,
+        ),
+        (
+            pos_arg_and_kw_or_pos_arg_and_kw_only_arg_func_type,
+            [bool_type],
+            {"foo": int_type},
+            [r"Missing required keyword argument `bar`"],
+            None,
+        ),
+        (
+            pos_arg_and_kw_or_pos_arg_and_kw_only_arg_func_type,
+            [bool_type, bool_type],
+            {"bar": float_type, "foo": int_type},
+            [r"G"],
+            None,
+        ),
+        (
+            pos_arg_and_kw_or_pos_arg_and_kw_only_arg_func_type,
+            [int_type],
+            {"bar": bool_type, "foo": bool_type},
+            [
+                r"Expected 0-th argument to be of type `bool`, but got `int64`",
+                r"Expected argument `foo` to be of type `int64`, but got `bool`",
+                r"Expected keyword argument `bar` to be of type `float64`, but got `bool`",
+            ],
+            None,
+        ),
+        (
+            pos_arg_and_kw_or_pos_arg_and_kw_only_arg_func_type,
+            [bool_type],
+            {"bar": float_type, "foo": int_type},
+            [],
+            ts.VoidType(),
+        ),
         # field operator
         (fieldop_type, [field_type, float_type], {}, [], field_type),
         # scan operator
@@ -172,7 +290,7 @@ def callable_type_info_cases():
             scanop_type,
             [],
             {},
-            [r"Scan operator takes 2 arguments, but 0 were given."],
+            [r"Scan operator takes 2 positional arguments, but 0 were given."],
             ts.FieldType(dims=[KDim], dtype=float_type),
         ),
         (
@@ -183,8 +301,8 @@ def callable_type_info_cases():
             ],
             {},
             [
-                r"Expected 0-th argument to be of type Field\[\[K\], int64\], but got Field\[\[K\], float64\]",
-                r"Expected 1-th argument to be of type Field\[\[K\], int64\], but got Field\[\[K\], float64\]",
+                r"Expected argument `a` to be of type `Field\[\[K\], int64\]`, but got `Field\[\[K\], float64\]`",
+                r"Expected argument `b` to be of type `Field\[\[K\], int64\]`, but got `Field\[\[K\], float64\]`",
             ],
             ts.FieldType(dims=[KDim], dtype=float_type),
         ),
@@ -246,8 +364,8 @@ def callable_type_info_cases():
             ],
             {},
             [
-                r"Expected 0-th argument to be of type tuple\[Field\[\[I, J, K\], int64\], "
-                r"Field\[\[\.\.\.\], int64\]\], but got tuple\[Field\[\[I, J, K\], int64\]\]."
+                r"Expected argument `a` to be of type `tuple\[Field\[\[I, J, K\], int64\], "
+                r"Field\[\[\.\.\.\], int64\]\]`, but got `tuple\[Field\[\[I, J, K\], int64\]\]`."
             ],
             ts.FieldType(dims=[IDim, JDim, KDim], dtype=float_type),
         ),
diff --git a/tests/next_tests/unit_tests/ffront_tests/test_foast_to_itir.py b/tests/next_tests/unit_tests/ffront_tests/test_foast_to_itir.py
index bcc86830..dd66beb5 100644
--- a/tests/next_tests/unit_tests/ffront_tests/test_foast_to_itir.py
+++ b/tests/next_tests/unit_tests/ffront_tests/test_foast_to_itir.py
@@ -235,7 +235,9 @@ def test_call():
     field_type = type_translation.from_type_hint(gtx.Field[[TDim], float64])
     identity = SimpleNamespace(
         __gt_type__=lambda: ts_ffront.FieldOperatorType(
-            definition=ts.FunctionType(args=[field_type], kwargs={}, returns=field_type)
+            definition=ts.FunctionType(
+                pos_only_args=[field_type], pos_or_kw_args={}, kw_only_args={}, returns=field_type
+            )
         )
     )
 
diff --git a/tests/next_tests/unit_tests/ffront_tests/test_func_to_foast.py b/tests/next_tests/unit_tests/ffront_tests/test_func_to_foast.py
index cb86ef3e..3f6e5ef5 100644
--- a/tests/next_tests/unit_tests/ffront_tests/test_func_to_foast.py
+++ b/tests/next_tests/unit_tests/ffront_tests/test_func_to_foast.py
@@ -251,7 +251,7 @@ def test_conditional_wrong_mask_type():
     ) -> gtx.Field[[TDim], float64]:
         return where(a, a, a)
 
-    msg = r"Expected a field with dtype bool."
+    msg = r"Expected a field with dtype `bool`."
     with pytest.raises(FieldOperatorTypeDeductionError, match=msg):
         _ = FieldOperatorParser.apply_to_function(conditional_wrong_mask_type)
 
diff --git a/tests/next_tests/unit_tests/type_system_tests/test_type_translation.py b/tests/next_tests/unit_tests/type_system_tests/test_type_translation.py
index ab3ef80b..59f53a30 100644
--- a/tests/next_tests/unit_tests/type_system_tests/test_type_translation.py
+++ b/tests/next_tests/unit_tests/type_system_tests/test_type_translation.py
@@ -100,11 +100,12 @@ def test_invalid_scalar_kind():
                 typing.Callable[["float", int], int], xtyping.CallableKwargsInfo(data={})
             ],
             ts.FunctionType(
-                args=[
+                pos_only_args=[
                     ts.ScalarType(kind=ts.ScalarKind.FLOAT64),
                     ts.ScalarType(kind=ts.ScalarKind.INT64),
                 ],
-                kwargs={},
+                pos_or_kw_args={},
+                kw_only_args={},
                 returns=ts.ScalarType(kind=ts.ScalarKind.INT64),
             ),
         ),