Skip to main content

How Flyte Defines Task Interfaces

Interface discovery starts with the Python signature

When a task's Python function has an unannotated argument, a variable argument list, or a return value whose annotation does not match what the function actually returns, the interface—not the task body—is the first place to investigate. Flyte records the function's signature in a NativeInterface before the task is registered.

For a normal task definition, TaskEnvironment.task passes the callable to NativeInterface.from_callable and stores the result in the task template:

# _task_environment.py
interface=NativeInterface.from_callable(func),

A direct version of the same public operation is:

from flyte.models import NativeInterface


def add(x: int, increment: int = 10) -> int:
return x + increment


interface = NativeInterface.from_callable(add)
print(interface.required_inputs()) # ['x']
print(interface.get_input_types()) # {'x': int, 'increment': int}
print(interface.has_outputs()) # True
print(interface) # (x: int, increment: int = 10) -> o0: int:

from_callable calls inspect.signature(func). For each declared parameter it stores a pair in inputs: (param.annotation, param.default). The mapping is populated in signature order. A missing annotation is accepted, but Flyte logs a warning stating that the data will be pickled. *args and **kwargs are different: from_callable rejects either parameter kind with ValueError, because those parameters cannot become explicitly named task inputs.

Return processing is delegated to extract_return_annotation in _interface.py. A scalar return annotation becomes the output mapping {"o0": annotation}. A return annotation of None, type(None), or no annotation becomes {}, so the interface says that the task has no outputs.

Required inputs and defaults use distinct markers

NativeInterface does not infer requiredness from whether a default value is truthy. It checks identity against inspect.Parameter.empty:

Stored second tuple valueMeaning in NativeInterfaceUsed by required_inputs()?
inspect.Parameter.emptyNo Python default was declared; the input is requiredYes
An actual value such as 10A local Python defaultNo
NoneA default marker that is not inspect.Parameter.empty; runtime filling treats it as an implicit default when the annotation is optionalNo
NativeInterface.has_defaultA default exists remotely, but its Python value is unavailableNo

The last marker is the class object _has_default, not an instance. _has_default is intentionally empty; its only role is to distinguish “the remote side has a default” from both a required input and an ordinary local default. The class-level assignment is:

has_default: ClassVar[Type[_has_default]] = _has_default

That distinction matters when constructing an interface yourself. required_inputs() returns only names whose stored marker is exactly inspect.Parameter.empty, and num_required_inputs() counts those same entries. Both methods therefore preserve the identity-sensitive semantics of the markers.

Two interface construction paths

Use from_callable when the source of truth is a Python function. Use from_types when types and default information have already been obtained from elsewhere:

from inspect import Parameter

from flyte.models import NativeInterface

interface = NativeInterface.from_types(
inputs={
"required_value": (int, Parameter.empty),
"local_or_known_value": (str, NativeInterface.has_default),
},
outputs={"o0": bool},
default_inputs={"local_or_known_value": remote_default_literal},
)

Here remote_default_literal must be a FlyteIDL literals_pb2.Literal; it is not a Python string or other native value. The name in this example represents a literal that has already been obtained from Flyte. from_types validates that every has_default input has a matching key in default_inputs, then retains the mapping as _remote_defaults. If the key is absent, it raises:

ValueError: Input <name> has a default value but no default input provided for remote task.

Remote task metadata follows this path in types/_interface.py. guess_interface extracts actual default literals from NamedParameter protobufs, asks the type engine to guess Python types, and assigns either inspect.Parameter.empty or NativeInterface.has_default:

for name, t in input_types.items():
if name not in default_input_literals:
guessed_inputs[name] = (t, inspect.Parameter.empty)
else:
guessed_inputs[name] = (t, NativeInterface.has_default)

return NativeInterface.from_types(guessed_inputs, guessed_outputs, default_input_literals)

This is why a remote interface can report that an input is not required without pretending that Flyte knows its Python default value. At invocation time, the already-converted literal is placed directly into the literal map. Runtime conversion independently checks that _remote_defaults contains the sentinel-marked input and raises if it does not.

Invocation: bind, validate, fill, convert

Native invocation handling is deliberately split across NativeInterface and _internal/runtime/convert.py. convert_to_kwargs performs only positional binding and a size check:

interface = NativeInterface.from_callable(add)

kwargs = interface.convert_to_kwargs(3)
# {'x': 3}

It associates positional values with the ordered keys in interface.inputs. Too many positional values raise ValueError; a keyword mapping whose total size is larger than the declared input count also raises ValueError. This method does not apply defaults, check that required names are present, or directly reject every unknown keyword name.

The runtime function convert_from_native_to_inputs performs the next stages in order:

kwargs = interface.convert_to_kwargs(*args, **kwargs)

missing = [key for key in interface.required_inputs() if key not in kwargs]
if missing:
raise ValueError(f"Missing required inputs: {', '.join(missing)}")

It then fills missing local defaults, optional None values, and remote defaults. For a remote default, the value in _remote_defaults is already a Flyte literal and is added without converting the Python value again. For the remaining values, get_input_types() removes the default markers and returns only the name-to-annotation mapping consumed by the Flyte type engine:

def get_input_types(self) -> Dict[str, Type]:
return {k: v[0] for k, v in self.inputs.items()}

Finally, input literals are emitted in interface.inputs.keys() order. This ordering is separate from the temporary keyword and literal maps and is used to produce the task's declared input order.

Default-upload edge case

The helper that uploads local defaults, convert_upload_default_inputs, uses this condition:

for input_name, (input_type, default_value) in interface.inputs.items():
if default_value and default_value is not inspect.Parameter.empty:
lt = TypeEngine.to_literal_type(input_type)
literal_coros.append(TypeEngine.to_literal(default_value, input_type, lt))
vars.append((input_name, lt))

Consequently, falsey actual defaults such as 0, False, and empty collections do not enter that helper's conversion path. This behavior is separate from invocation-time default filling, which examines the markers and annotations rather than using that truthiness test.

Return annotations define output names and count

extract_return_annotation in _interface.py applies these concrete rules:

  • int or another scalar annotation produces one output named o0.
  • Tuple[int, str] produces o0 and o1, in annotation order.
  • A named tuple preserves its field names.
  • None, type(None), and a missing return annotation produce no outputs.
  • A one-element tuple annotation raises TypeError; use a scalar annotation for one output.

For example, these are valid interface shapes:

from typing import NamedTuple, Tuple

from flyte.models import NativeInterface


def scalar(value: int) -> int:
return value


def pair(value: int) -> Tuple[int, str]:
return value, str(value)


class Result(NamedTuple):
count: int
label: str


def named(value: int) -> Result:
...


print(NativeInterface.from_callable(scalar).outputs)
# {'o0': <class 'int'>}
print(NativeInterface.from_callable(pair).outputs)
# {'o0': <class 'int'>, 'o1': <class 'str'>}
print(NativeInterface.from_callable(named).outputs)
# {'count': <class 'int'>, 'label': <class 'str'>}

The named-tuple branch uses get_type_hints(..., include_extras=True) and returns the named fields. Ordinary tuple annotations use generated names from output_name_generator, which starts at o0.

Output conversion uses the interface as the contract. convert_from_native_to_outputs wraps a scalar result in a one-item tuple, rejects a non-None result when the interface declares no outputs, and asserts that the returned output count equals the number of declared outputs. Each value is serialized using its corresponding output type. On the way back to Python, multiple outputs are explicitly reconstructed with interface.outputs.keys() because protobuf maps may change ordering:

return tuple(kwargs[k] for k in interface.outputs.keys())

has_outputs() is the compact check for whether the output mapping is non-empty. Remote controller logic uses that interface property when deciding whether trace output files need to be uploaded.

Rendering and inspecting an interface

NativeInterface.__repr__() is useful while debugging registration and remote-interface reconstruction. It renders annotations, actual defaults, and ... for the remote sentinel:

from inspect import Parameter

from flyte.models import NativeInterface

interface = NativeInterface(
inputs={
"x": (int, Parameter.empty),
"limit": (int, 10),
"remote_limit": (int, NativeInterface.has_default),
},
outputs={"o0": int, "o1": str},
)

print(interface)
# (x: int, limit: int = 10, remote_limit: int = ...) -> (o0: int, o1: str):

The dataclass is frozen, so assigning a new value to interface.inputs or interface.outputs is blocked. The dictionaries themselves remain ordinary mutable mappings; freezing the dataclass does not make their contents immutable.

Debugging checklist for complex task signatures

When an interface does not match the task you intended to define, inspect the following in this order:

  1. Check every parameter annotation. from_callable stores annotations directly from inspect.signature; it does not call typing.get_type_hints for parameters. With postponed or string annotations, the stored value can therefore remain a string. An unannotated parameter is accepted with a warning and may be serialized by pickling.
  2. Look for *args and **kwargs. Either causes NativeInterface.from_callable to raise ValueError during task registration.
  3. Distinguish required and remote-default markers. inspect.Parameter.empty means required. NativeInterface.has_default means that _remote_defaults must contain an already-converted Flyte literal. Neither should be replaced by a generic truthy/falsy test.
  4. Remember that binding is not validation. convert_to_kwargs only binds positional values and checks argument counts. Missing required inputs are reported by convert_from_native_to_inputs.
  5. Verify the return annotation and actual result count. No annotation means no declared outputs, while a scalar declares one output. A tuple result must have the same number of values as interface.outputs; multiple outputs are serialized and reconstructed in interface-key order.
  6. Inspect defaults at both phases. A Python default is filled during runtime conversion, whereas a remote default must already exist as an IDL literal. Also check falsey defaults separately when investigating backend default upload.

Raw-container tasks demonstrate the other integration boundary: they do not have a Python callable to inspect. extras/_container.py constructs the interface directly, pairing each declared input type with None and using the supplied output mapping unchanged:

interface=NativeInterface({k: (v, None) for k, v in inputs.items()} if inputs else {}, outputs or {}),

That path contrasts with decorated Python tasks, for which TaskEnvironment.task calls from_callable. In both cases, the resulting NativeInterface is the bridge used by Flyte's type engine and runtime conversion code.

The repository snapshot used for this documentation contains no matching tests, README examples, or Markdown examples for _has_default and NativeInterface. The executable usage patterns above are taken from the implementation and its call sites, including task registration, remote-interface reconstruction, runtime conversion, and raw-container construction.