Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"sphinx.ext.viewcode",
"sphinxcontrib.mermaid",
"sphinx_autodoc_typehints",
"sphinx_design",
"nbsphinx",
"nbsphinx_link",
]
Expand Down
1 change: 1 addition & 0 deletions doc/howtoguides.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ How-to Guides
howtoguides/execute_io
howtoguides/events
howtoguides/task_discovery
howtoguides/function_task
howtoguides/notebook_task
howtoguides/change_schema
146 changes: 146 additions & 0 deletions doc/howtoguides/function_task.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
Python function as workflow task
================================

A Python function can be used as a task node in a workflow by using ``"method"`` as its ``task_type``.
By default, such task has a single output named ``return_value``.
It is possible to `Define multiple outputs`_.

Use a function as a task
------------------------

Example with a ``range_info`` function returning 3 values in a dictionary:

.. code:: python

def range_info(a: float, b: float):
return {
"extent": abs(b - a),
"minimum": min(a, b),
"maximum": max(a, b),
}

The corresponding workflow node must be declared with ``"method"`` as ``task_type``:

.. code:: python

range_info_node = {
"id": "task_range_info",
"task_type": "method",
"task_identifier": "__main__.range_info",
}

Code to execute the ``range_info`` function as a task in a workflow with ``a=15`` and ``b=10`` as inputs:

.. code:: python

from ewokscore import execute_graph

# Define a workflow which calls the range_info function as a task
workflow = {
"graph": {"id": "range_info_workflow"},
"nodes": [
{
"id": "task_range_info",
"task_type": "method",
"task_identifier": "__main__.range_info",
},
],
"links": [],
}

# Define task inputs
inputs = [
{"id": "task_range_info", "name": "a", "value": 15},
{"id": "task_range_info", "name": "b", "value": 10},
]

# Execute the workflow
result = execute_graph(workflow, inputs=inputs)
print(result)

The task output contains a single ``return_value`` field which is set to the function return value.

In the ``range_info`` example, the result contains a single output field ``return_value``:

.. code:: python

{'return_value': {'extent': 5, 'minimum': 10, 'maximum': 15}}


Define multiple outputs
-----------------------

To declare a function that can be used as a :class:`Task` with multiple output fields:

* Declare the function with one of the following kind of return type:

.. tab-set::

.. tab-item:: namedtuple

.. code:: python

from collections import namedtuple

Result = namedtuple("Result", ["extent", "minimum", "maximum"])

.. tab-item:: dataclass

.. code:: python

from dataclasses import dataclass

@dataclass
class Result:
extent: float
minimum: float
maximum: float

.. tab-item:: typing.NamedTuple

.. code:: python

from typing import NamedTuple

class Result(NamedTuple):
extent: float
minimum: float
maximum: float

.. tab-item:: typing.TypedDict

.. code:: python

from typing import TypedDict

class Result(TypedDict):
extent: float
minimum: float
maximum: float

.. code:: python

def range_info(a: float, b: float) -> Result:

* Add the :func:`task_outputs` decorator to the function declaration:

.. code:: python

from ewokscore.methodtask import task_outputs

@task_outputs
def range_info(a: float, b: float) -> Result:
return Result(
extent=abs(b - a),
minimum=min(a, b),
maximum=max(a, b),
)


When a ``@task_outputs`` decorated function is used as a :class:`Task`, the task output names are defined by the function return type.

In the example, the return value of ``range_info`` function is available through 3 task outputs:

.. code:: python

{'maximum': 15, 'extent': 5, 'minimum': 10}
8 changes: 4 additions & 4 deletions src/ewokscore/inittask.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from ewoksutils.import_utils import import_qualname

from .task import Task
from .methodtask import MethodExecutorTask
from .methodtask import get_method_task
from .scripttask import ScriptExecutorTask
from .ppftasks import PpfMethodExecutorTask
from .ppftasks import PpfPortTask
Expand Down Expand Up @@ -155,8 +155,8 @@ def instantiate_task(
return Task.instantiate(task_info["task_identifier"], **task_kwargs)

if task_type == "method":
task_inputs[MethodExecutorTask.METHOD_ARGUMENT] = task_info["task_identifier"]
return MethodExecutorTask(**task_kwargs)
task_class = get_method_task(task_info["task_identifier"])
return task_class(**task_kwargs)

if task_type == "ppfmethod":
task_inputs[PpfMethodExecutorTask.METHOD_ARGUMENT] = task_info[
Expand Down Expand Up @@ -254,7 +254,7 @@ def get_task_class(node_id: NodeIdType, node_attrs: dict):
if task_type == "class":
return Task.get_subclass(task_info["task_identifier"])
if task_type == "method":
return MethodExecutorTask
return get_method_task(task_info["task_identifier"])
if task_type == "ppfmethod":
return PpfMethodExecutorTask
if task_type == "ppfport":
Expand Down
107 changes: 98 additions & 9 deletions src/ewokscore/methodtask.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,109 @@
from .task import Task
from collections.abc import Mapping
import functools
import inspect
from types import FunctionType
from typing import get_type_hints, Set

from ewoksutils.import_utils import import_method

METHOD_ARGUMENT = "_method"
from .task import Task
from .utils import is_namedtuple, method_arguments


def task_outputs(function: FunctionType) -> FunctionType:
"""Function decorator so ewoks extract task outputs from return type elements/attributes"""

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In GitLab by @woutdenolf on Dec 16, 2024, 16:44 GMT+1:

    """Function decorator so ewoks extracts task outputs from return type elements/attributes"""

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In GitLab by @tvincent on Dec 16, 2024, 16:51 GMT+1:

changed this line in version 2 of the diff

# Report error early
if not _method_output_names(function):
raise ValueError("Function return type does not define any output name")

function._ewoks_unpack_outputs = True # noqa
return function


def _method_output_names(method) -> Set[str]:
sig = inspect.signature(method)
return_type = sig.return_annotation
if return_type is inspect.Signature.empty or not inspect.isclass(return_type):
return set()

return_annotations = get_type_hints(return_type)
if return_annotations:
return set(return_annotations.keys())

if is_namedtuple(return_type):
return set(return_type._fields)

return set()


class MethodExecutorTask(Task):
_METHOD_REQUIRED_INPUTS = tuple()
_METHOD_OPTIONAL_INPUTS = tuple()
_METHOD_UNPACK_OUTPUTS = False

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In GitLab by @woutdenolf on Dec 16, 2024, 16:40 GMT+1:

The base class already has

    _INPUT_NAMES = set()
    _OPTIONAL_INPUT_NAMES = set()
    _OUTPUT_NAMES = set()
    _N_REQUIRED_POSITIONAL_INPUTS = 0

Why no use those?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In GitLab by @woutdenolf on Dec 16, 2024, 16:42 GMT+1:

Ok it is because of required inputs are optional to support passing them as positional inputs


class MethodExecutorTask(
Task, input_names=[METHOD_ARGUMENT], output_names=["return_value"]
):
METHOD_ARGUMENT = METHOD_ARGUMENT
def __init_subclass__(cls, task_identifier: str):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In GitLab by @woutdenolf on Dec 16, 2024, 16:53 GMT+1:

pass **kwargs to super().__init_subclass__

cls._TASK_IDENTIFIER = task_identifier

method = import_method(task_identifier)
cls._METHOD_REQUIRED_INPUTS, cls._METHOD_OPTIONAL_INPUTS = method_arguments(
method
)

cls._METHOD_UNPACK_OUTPUTS = getattr(method, "_ewoks_unpack_outputs", False)
if cls._METHOD_UNPACK_OUTPUTS:
output_names = _method_output_names(method)
if not output_names:
raise RuntimeError(
f"{task_identifier}'s return type do not define any output name"
)
else:
output_names = ["return_value"]

super().__init_subclass__(
# required inputs are optional to support passing them as positional inputs
optional_input_names=cls._METHOD_REQUIRED_INPUTS
+ cls._METHOD_OPTIONAL_INPUTS,
output_names=output_names,
)
cls.__doc__ = inspect.getdoc(method)

# Advertise effective method required/optional input names
# rather than the less constrained task ones

@classmethod
def required_input_names(cls):
return cls._METHOD_REQUIRED_INPUTS

@classmethod
def optional_input_names(cls):
return cls._METHOD_OPTIONAL_INPUTS

def run(self):
kwargs = self.get_named_input_values()
args = self.get_positional_input_values()
fullname = kwargs.pop(self.METHOD_ARGUMENT)
method = import_method(fullname)

method = import_method(self._TASK_IDENTIFIER)

result = method(*args, **kwargs)

self.outputs.return_value = result
if not self._METHOD_UNPACK_OUTPUTS:
self.outputs.return_value = result
return

if isinstance(result, Mapping):
for name in self.output_names():
if name in result:
self.outputs[name] = result[name]
return

for name in self.output_names():
if hasattr(result, name):
self.outputs[name] = getattr(result, name)


@functools.lru_cache()
def get_method_task(task_identifier: str) -> MethodExecutorTask:
class MethodTask(MethodExecutorTask, task_identifier=task_identifier):
pass

return MethodTask
2 changes: 2 additions & 0 deletions src/ewokscore/persistence/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ def instantiate(
raise ValueError(f"Data proxy scheme '{scheme}' is not supported")

def serialize(self) -> Dict[str, str]:
if self.uri is None:
return None
return self.uri.serialize()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In GitLab by @tvincent on Dec 16, 2024, 15:32 GMT+1:

Tests broke here, I didn't fully understand why...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In GitLab by @woutdenolf on Dec 16, 2024, 17:38 GMT+1:

This is caused by hacking the inputs to support the same argument to be positional and named.

If we want to support this, we need to support it on a fundamental level, this cannot be patched.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In GitLab by @tvincent on Dec 16, 2024, 17:50 GMT+1:

OK, then I propose to support named only (and eventually positional only) = A single way to pass arguments.
This would also avoids the required/optional input names workaround I had to do, but would change the current behavior.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In GitLab by @woutdenolf on Dec 17, 2024, 09:59 GMT+1:

We can support it, it just needs to be native support where a variable can have two id's (a string and an integer). But it is outside the scope of this MR. So named-only would be best.


@classmethod
Expand Down
36 changes: 14 additions & 22 deletions src/ewokscore/task_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import logging
from fnmatch import fnmatch
from types import FunctionType, ModuleType
from typing import Generator, Optional, List, Dict, Tuple, Union
from typing import Generator, Optional, List, Dict, Union

if sys.version_info < (3, 9):
from importlib_metadata import entry_points as _entry_points
Expand All @@ -28,7 +28,9 @@ def iter_entry_points(group: str):
from ewoksutils.import_utils import qualname
from ewoksutils.import_utils import import_module

from .methodtask import get_method_task
from .task import Task
from .utils import method_arguments

TaskDict = Dict[str, Union[str, List[str]]]

Expand Down Expand Up @@ -129,9 +131,16 @@ def _iter_method_tasks(
if method_name.startswith("_"):
continue

task_identifier = qualname(method_qn)
task_class = get_method_task(task_identifier)
yield {
"task_type": "method",
**_common_method_task_fields(method_name, method_qn, mod),
"task_identifier": task_identifier,
"required_input_names": list(task_class.required_input_names()),
"optional_input_names": list(task_class.optional_input_names()),
"output_names": list(task_class.output_names()),
"category": task_identifier.split(".")[0],
"description": task_class.__doc__,
}


Expand All @@ -157,7 +166,7 @@ def _iter_ppfmethod_tasks(

yield {
"task_type": "ppfmethod",
**_common_method_task_fields(method_name, method_qn, mod),
**_ppfmethod_task_fields(method_name, method_qn, mod),
}


Expand Down Expand Up @@ -248,30 +257,13 @@ def _onerror(module_name, exception: Optional[Exception] = None):
logger.error(f"Module '{module_name}' cannot be imported: {exception}")


def _method_arguments(method) -> Tuple[List[str], List[str]]:
sig = inspect.signature(method)
required_input_names = list()
optional_input_names = list()
for name, param in sig.parameters.items():
required = param.default is inspect._empty
if param.kind == param.VAR_POSITIONAL:
continue
if param.kind == param.VAR_KEYWORD:
continue
if required:
required_input_names.append(name)
else:
optional_input_names.append(name)
return required_input_names, optional_input_names


def _common_method_task_fields(
def _ppfmethod_task_fields(
method_name: str, method_qn: FunctionType, mod: ModuleType
) -> Dict[str, Union[str, List[str]]]:

task_identifier = qualname(method_qn)
method = getattr(mod, method_name)
required_input_names, optional_input_names = _method_arguments(method)
required_input_names, optional_input_names = method_arguments(method)

return {
"task_identifier": qualname(method_qn),
Expand Down
Loading