Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `GraphSerializer.json`: explicit serialization like python's `json` module does
- `GraphSerializer.json_pickle` (default for `JsonProxy`): preserve JSON type, pickle others types
- `GraphSerializer.hdf5_pickle` (default for `NexusProxy`): preserve JSON+numpy type, pickle others types
- Add new field `inputs` to the spec


### Fixed

Expand Down
3 changes: 2 additions & 1 deletion doc/reference/specs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ Graph attributes
^^^^^^^^^^^^^^^^
* *id* (optional): graph identifier unique to a database of graphs (Default: "notspecified")
* *label* (optional): non-unique label to be used when identifying a graph for human consumption
* *schema_version* (optional): the schema version of this graph representation (Default: "1.0")
* *schema_version* (optional): the schema version of this graph representation (Default: "1.2")

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.

Schema version was updated to 1.2

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

FYI There is a ticket about this: #456

* *inputs* (optional): inputs of the workflow itself. Must be a JSON schema representing a subset of the node inputs.
* *requirements* (optional): a list of projects that should be present in the Python environment for the
graph to be executed.
* *input_nodes* (optional): nodes that are expected to be used as link targets when the graph
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dependencies = [
"pydantic >= 2",
"typing_extensions;python_version < '3.11'",
"backports.strenum;python_version < '3.11'",
"jsonschema",
]

[project.urls]
Expand Down
2 changes: 1 addition & 1 deletion src/ewokscore/_serialization/common/utils/constants.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
EWOKS_KEY = "__ewoks__"
EWOKS_KEY = "__ewoks_type__"
EWOKS_FORMAT_KEY = "__ewoks_serialize__"
4 changes: 3 additions & 1 deletion src/ewokscore/graph/schema/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from .metadata import SchemaMetadata
from .update import from_v1_0_to_v1_1
from .update import from_v1_1_to_v1_2
from .update import v0_update

_VERSIONS = None
Expand All @@ -21,7 +22,8 @@ def get_versions() -> Dict[Version, SchemaMetadata]:
_VERSIONS = {
parse_version("0.0"): SchemaMetadata(("0.0", "0.0.1"), v0_update),
parse_version("1.0"): SchemaMetadata(("0.1.0-rc", None), from_v1_0_to_v1_1),
parse_version("1.1"): SchemaMetadata(("0.1.0-rc", None), None),
parse_version("1.1"): SchemaMetadata(("0.1.0-rc", None), from_v1_1_to_v1_2),
parse_version("1.2"): SchemaMetadata(("0.1.0-rc1", None), None),
}
return _VERSIONS

Expand Down
30 changes: 30 additions & 0 deletions src/ewokscore/graph/schema/model.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import sys
from typing import Any
from typing import Dict
from typing import Hashable
from typing import Literal
from typing import Optional
Expand All @@ -17,8 +18,12 @@
from typing import Self


from jsonschema import Draft7Validator
from jsonschema.exceptions import SchemaError
from pydantic import BaseModel
from pydantic import Field
from pydantic import ValidationError
from pydantic import field_validator
from pydantic import model_validator

from . import LATEST_VERSION
Expand Down Expand Up @@ -109,6 +114,14 @@ class PpfPortNode(_EwoksBaseNode):
]


class EwoksWorkflowInput(BaseModel):
name: str
task_identifier: Optional[str] = None
id: Optional[NodeId] = None
label: Optional[str] = None
all_nodes: bool = False


class EwoksNodeAlias(EwoksNodeAttributes):
id: NodeId
node: NodeId
Expand All @@ -123,6 +136,23 @@ class EwoksGraphAttributes(BaseModel):
requirements: Sequence[str] = []
input_nodes: Sequence[EwoksNodeAlias] = []
output_nodes: Sequence[EwoksNodeAlias] = []
inputs: Dict[str, Any] = {}

@woutdenolf woutdenolf Jun 8, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What is this dict? It maps what to what? I would expect this to be a List[EwoksWorkflowInput].

Also when defining aliases, we'll need 1-to-many (1 parameter alias, many targets).

@woutdenolf woutdenolf Jun 8, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ok so inputs is a JSON Schema which is an instance of the JSON Schema meta-schema https://json-schema.org/draft/2020-12/schema which itself is a JSON Schema.

Draft 2020-12 meta-schema
(a JSON Schema)

validates JSON Schemas

which validate JSON instances

Examples

JSON schema for a single integer

meta_schema = {
    "type": "object",
    "properties": {
        "type": {
            "type": "string"
        }
    }
}
schema = {
    "type": "number"
}
instance = 42

JSON schema for a str->int dictionary with keys required value1 and optional value2

meta_schema = {
    "type": "object",
    "properties": {
        "type": {
            "type": "string"
        }
    }
}
schema = {
    "type": "object",
    "properties": {
        "value1": {
            "type": "number"
        },
        "value2": {
            "type": "number"
        },
    },
    "required": ["value"]
}
instance = {
    "value1": 42,
    "value2": 99,  # optional
}

Ewoks graph attribute inputs

So this is a schema, not meta_schema or instance. But it cannot be just any schema because ultimately we want to define some kind of parameter mapping on the one hand but then the user provides parameter values on the other hand.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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


@field_validator("inputs")
@classmethod
def validate_schema(cls, inputs):
try:
Draft7Validator.check_schema(inputs)
except SchemaError:
raise
Comment thread
woutdenolf marked this conversation as resolved.
Comment on lines +146 to +147

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Capture and re-raise. What is the purpose?


properties = inputs.get("properties", {})
for input in properties.values():
try:
EwoksWorkflowInput(**input.get("__ewoks__"))
Comment thread
woutdenolf marked this conversation as resolved.
except ValidationError:
raise
Comment on lines +153 to +154

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Capture and re-raise. What is the purpose?

return inputs
Comment thread
woutdenolf marked this conversation as resolved.


class EwoksGraph(BaseModel):
Expand Down
5 changes: 5 additions & 0 deletions src/ewokscore/graph/schema/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ def v0_update(graph: networkx.DiGraph) -> None:
def from_v1_0_to_v1_1(graph: networkx.DiGraph) -> None:
"""This version does not have the requirements field."""
graph.graph["schema_version"] = "1.1"


def from_v1_1_to_v1_2(graph: networkx.DiGraph) -> None:
"""This version does not have the inputs field."""
graph.graph["schema_version"] = "1.2"
2 changes: 1 addition & 1 deletion src/ewokscore/tests/examples/graphs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def wrapper():
attrs = g.setdefault("graph", dict())
assert attrs.get("id") == name
assert attrs.get("label") == name
assert attrs.get("schema_version") == "1.1"
assert attrs.get("schema_version") == "1.2"
return g, result

if _ALL_GRAPHS is None:
Expand Down
2 changes: 1 addition & 1 deletion src/ewokscore/tests/examples/graphs/acyclic1.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ def acyclic1():
},
]
}
graph = {"id": "acyclic1", "label": "acyclic1", "schema_version": "1.1", "ows": ows}
graph = {"id": "acyclic1", "label": "acyclic1", "schema_version": "1.2", "ows": ows}

taskgraph = {"graph": graph, "links": links, "nodes": nodes}

Expand Down
2 changes: 1 addition & 1 deletion src/ewokscore/tests/examples/graphs/acyclic2.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

@graph
def acyclic2():
graph = {"id": "acyclic2", "label": "acyclic2", "schema_version": "1.1"}
graph = {"id": "acyclic2", "label": "acyclic2", "schema_version": "1.2"}

task = "ewokscore.tests.examples.tasks.errorsumtask.ErrorSumTask"
nodes = [
Expand Down
2 changes: 1 addition & 1 deletion src/ewokscore/tests/examples/graphs/acyclic3.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

@graph
def acyclic3():
graph = {"id": "acyclic3", "label": "acyclic3", "schema_version": "1.1"}
graph = {"id": "acyclic3", "label": "acyclic3", "schema_version": "1.2"}

task = "ewokscore.tests.examples.tasks.sumtask.SumTask"
nodes = [
Expand Down
2 changes: 1 addition & 1 deletion src/ewokscore/tests/examples/graphs/cyclic1.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

@graph
def cyclic1():
graph = {"id": "cyclic1", "label": "cyclic1", "schema_version": "1.1"}
graph = {"id": "cyclic1", "label": "cyclic1", "schema_version": "1.2"}

task = "ewokscore.tests.examples.tasks.condsumtask.CondSumTask"
nodes = [
Expand Down
26 changes: 24 additions & 2 deletions src/ewokscore/tests/examples/graphs/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,25 @@

@graph
def demo():
graph = {"id": "demo", "label": "demo", "schema_version": "1.1"}
graph = {
"id": "demo",
"label": "demo",
"schema_version": "1.2",
"inputs": {
"properties": {
"task0a": {
"type": "number",
"__ewoks__": {"name": "a", "id": "task0"},
},
"task0b": {
"type": "number",
"default": 0,
"__ewoks__": {"name": "b", "id": 2},
},
Comment on lines +11 to +20

@woutdenolf woutdenolf Jun 8, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Using the word "task" or "node" in the parameter name is confusing. I would propose something else. Maybe all-caps for workflow inputs and smallcaps for task inputs. Like global and local variable names in python. Just for the example I mean, this is not something enforced by ewoks.

What happens if I have parameter "B" and I want this to be parameter "b" of task1 and task2? Can I do that?

Also, "task0" does not have parameter "a" and "b" and there is no "id=2":

╒════════╤════════════════╤═══════════════════╤═══════╕
│ Name   │ Value          │ Task identifier   │ Id    │
╞════════╪════════════════╪═══════════════════╪═══════╡
│ list   │ [0, 1, 2]      │ SumList           │ task0 │
├────────┼────────────────┼───────────────────┼───────┤
│ delay  │ 0              │ SumList           │ task0 │
├────────┼────────────────┼───────────────────┼───────┤
│ b      │ <MISSING_DATA> │ SumTask           │ task1 │
├────────┼────────────────┼───────────────────┼───────┤
│ delay  │ 0              │ SumTask           │ task1 │
├────────┼────────────────┼───────────────────┼───────┤
│ a      │ 2              │ SumTask           │ task2 │
├────────┼────────────────┼───────────────────┼───────┤
│ b      │ <MISSING_DATA> │ SumTask           │ task2 │
├────────┼────────────────┼───────────────────┼───────┤
│ delay  │ 0              │ SumTask           │ task2 │
├────────┼────────────────┼───────────────────┼───────┤
│ b      │ 3              │ SumTask           │ task3 │
├────────┼────────────────┼───────────────────┼───────┤
│ delay  │ 0              │ SumTask           │ task3 │
├────────┼────────────────┼───────────────────┼───────┤
│ b      │ 4              │ SumTask           │ task4 │
├────────┼────────────────┼───────────────────┼───────┤
│ delay  │ 0              │ SumTask           │ task4 │
├────────┼────────────────┼───────────────────┼───────┤
│ delay  │ 0              │ SumTask           │ task5 │
├────────┼────────────────┼───────────────────┼───────┤
│ b      │ 6              │ SumTask           │ task6 │
├────────┼────────────────┼───────────────────┼───────┤
│ delay  │ 0              │ SumTask           │ task6 │
╘════════╧════════════════╧═══════════════════╧═══════╛

},
"required": ["task0a"],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Required in what sense? The workflow will fail if this parameter is not provided? What happens if I do not provide workflow inputs and I just provide node inputs?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

For the GUI it can be useful to know whether something is required or not.

},
}

sumtask = "ewokscore.tests.examples.tasks.sumtask.SumTask"
sumlist = "ewokscore.tests.examples.tasks.sumlist.SumList"
Expand Down Expand Up @@ -120,7 +138,11 @@ def demo():
},
]

taskgraph = {"graph": graph, "links": links, "nodes": nodes}
taskgraph = {
"graph": graph,
"links": links,
"nodes": nodes,
}

expected_results = {
"task0": {"sum": 3},
Expand Down
2 changes: 1 addition & 1 deletion src/ewokscore/tests/examples/graphs/empty.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@

@graph
def empty():
graph = {"id": "empty", "label": "empty", "schema_version": "1.1"}
graph = {"id": "empty", "label": "empty", "schema_version": "1.2"}
return {"graph": graph}, dict()
2 changes: 1 addition & 1 deletion src/ewokscore/tests/examples/graphs/self_trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

@graph
def self_trigger():
graph = {"id": "self_trigger", "label": "self_trigger", "schema_version": "1.1"}
graph = {"id": "self_trigger", "label": "self_trigger", "schema_version": "1.2"}

task = "ewokscore.tests.examples.tasks.condsumtask.CondSumTask"
nodes = [
Expand Down
2 changes: 1 addition & 1 deletion src/ewokscore/tests/examples/graphs/triangle1.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

@graph
def triangle1():
graph = {"id": "triangle1", "label": "triangle1", "schema_version": "1.1"}
graph = {"id": "triangle1", "label": "triangle1", "schema_version": "1.2"}

task = "ewokscore.tests.examples.tasks.condsumtask.CondSumTask"
nodes = [
Expand Down
15 changes: 15 additions & 0 deletions src/ewokscore/tests/serialization/test_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
import pytest
import yaml

from ..._serialization.common.utils.types import GraphSerializer
from ...bindings import save_graph
from ...graph import load_graph
from ..test_examples import get_graph
from ..test_examples import graph_names


@pytest.mark.parametrize("with_ext", [True, False])
Expand Down Expand Up @@ -56,6 +60,17 @@ def test_graph_discovery_json_module(with_representation):
assert ewoksgraph.graph.graph["id"] == "ewokscore.tests.examples.loadtest.graph"


@pytest.mark.parametrize("graph_name", graph_names())
@pytest.mark.parametrize("serializer", [None, *GraphSerializer])
def test_graph_save(tmp_path, graph_name, serializer):
graph_dict, _ = get_graph("demo")
graph = load_graph(graph_dict, representation="json_dict")
save_graph(
graph, destination=tmp_path / f"{graph_name}.json", serializer=serializer
)
assert (tmp_path / f"{graph_name}.json").exists()


def _dump_graph_and_subgraph(tmp_path, format, with_ext):
if format == "yaml":
dump = yaml.dump
Expand Down
43 changes: 43 additions & 0 deletions src/ewokscore/tests/test_model.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import pydantic
import pytest
from jsonschema.exceptions import SchemaError

from ewokscore import load_graph
from ewokscore.graph.schema.model import EwoksGraph
Expand Down Expand Up @@ -127,3 +128,45 @@ def test_link_with_datamapping_and_map_all_data():
match="1 validation error for EwoksGraph\nlinks.0\n",
):
EwoksGraph(**graph_dict)


def test_input_schema():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The test is only testing malformed inputs. Perhaps reflect this in the name of the test.

# Wrong type in schema
graph_dict = {
"graph": {
"id": "required",
"inputs": {"properties": {"a": {"type": "oo", "__ewoks__": {"name": "a"}}}},
},
"nodes": [
{
"id": "node1",
"task_type": "class",
"task_identifier": "task1",
},
],
"links": [],
}
with pytest.raises(SchemaError):
EwoksGraph(**graph_dict)

# Missing name in __ewoks__
graph_dict = {
"graph": {
"id": "required",
"inputs": {"properties": {"a": {"type": "number", "__ewoks__": {}}}},
},
"nodes": [
{
"id": "node1",
"task_type": "class",
"task_identifier": "task1",
},
],
"links": [],
}

with pytest.raises(
pydantic.ValidationError,
match="1 validation error for EwoksGraph\ngraph.inputs.name\n",
):
EwoksGraph(**graph_dict)
Loading