diff --git a/CHANGELOG.md b/CHANGELOG.md index 8da1fc6..a36ea95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add new fields `workflow_input_schema` and `workflow_output_schema` to the spec + + ## [5.0.0] - 2026-07-01 ### Added diff --git a/doc/howtoguides/change_schema.rst b/doc/howtoguides/change_schema.rst index 08d9fd3..d7d9c34 100644 --- a/doc/howtoguides/change_schema.rst +++ b/doc/howtoguides/change_schema.rst @@ -40,6 +40,9 @@ The ``SchemaMetadata`` creation needs two arguments: - ``ewokscore`` version bounds (stored as a 2-tuple of `Version`_): the first tuple value is the lowest ``ewokscore`` version that supports this schema, the second is the highest version that supports this schema (put ``None`` if there is no upper bound). - a function that converts this schema version to the next version. The function can be defined in the ``update`` submodule. If the version is the latest one, put ``None`` since there is no next version. +Update the schema version changelog +----------------------------------- +Once all the steps above are done, update the :doc:`../reference/specs` page with the new version changes. .. _Version: https://packaging.pypa.io/en/stable/version.html#packaging.version.Version diff --git a/doc/reference/specs.rst b/doc/reference/specs.rst index 0b8843e..e9218b1 100644 --- a/doc/reference/specs.rst +++ b/doc/reference/specs.rst @@ -51,6 +51,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.2") +* *workflow_input_schema* (optional): A `JSON schema `_ defining the inputs of the workflow, representing a subset of the node inputs. +* *workflow_output_schema* (optional): A `JSON schema `_ defining the outputs of the workflow, representing a subset of the node outputs. * *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 @@ -360,6 +362,11 @@ When a task is defined as a *method*, *script* or *notebook*, a class wrapper wi ``schema_version`` changelog ---------------------------- +1.3 +^^^ + +- Added new graph attributes ``workflow_input_schema`` and ``workflow_output_schema`` + 1.2 ^^^ diff --git a/pyproject.toml b/pyproject.toml index 5372cc0..8990a18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "pydantic >= 2", "typing_extensions;python_version < '3.11'", "backports.strenum;python_version < '3.11'", + "jsonschema", ] [project.urls] diff --git a/src/ewokscore/graph/schema/__init__.py b/src/ewokscore/graph/schema/__init__.py index 61e4801..d3cc86e 100644 --- a/src/ewokscore/graph/schema/__init__.py +++ b/src/ewokscore/graph/schema/__init__.py @@ -9,6 +9,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 from_v1_2_to_v1_3 from .update import v0_update _VERSIONS = None @@ -23,7 +24,8 @@ def get_versions() -> Dict[Version, SchemaMetadata]: 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), from_v1_1_to_v1_2), - parse_version("1.2"): SchemaMetadata(("0.1.0-rc", None), None), + parse_version("1.2"): SchemaMetadata(("0.1.0-rc", None), from_v1_2_to_v1_3), + parse_version("1.3"): SchemaMetadata(("0.1.0-rc", None), None), } return _VERSIONS diff --git a/src/ewokscore/graph/schema/model.py b/src/ewokscore/graph/schema/model.py index c6afcd0..ec9442a 100644 --- a/src/ewokscore/graph/schema/model.py +++ b/src/ewokscore/graph/schema/model.py @@ -22,6 +22,7 @@ from pydantic import model_validator from . import LATEST_VERSION +from .parameter_schema import EwoksParameterSchema NodeId = Hashable # Could be recursive: Union[Tuple[str, "Id"], str] @@ -123,6 +124,8 @@ class EwoksGraphAttributes(BaseModel): requirements: Sequence[str] = [] input_nodes: Sequence[EwoksNodeAlias] = [] output_nodes: Sequence[EwoksNodeAlias] = [] + workflow_input_schema: Optional[EwoksParameterSchema] = None + workflow_output_schema: Optional[EwoksParameterSchema] = None class EwoksGraph(BaseModel): diff --git a/src/ewokscore/graph/schema/parameter_schema.py b/src/ewokscore/graph/schema/parameter_schema.py new file mode 100644 index 0000000..a317eda --- /dev/null +++ b/src/ewokscore/graph/schema/parameter_schema.py @@ -0,0 +1,60 @@ +import sys +from typing import Dict +from typing import List +from typing import Literal +from typing import Optional +from typing import Union + +from jsonschema import Draft202012Validator +from pydantic import BaseModel +from pydantic import Field +from pydantic import model_validator + +if sys.version_info < (3, 11): + from typing_extensions import Self +else: + from typing import Self + + +class _Target(BaseModel): + name: str + id: Union[str, int, None] = None + label: Optional[str] = None + task_identifier: Optional[str] = None + all: bool = False + + +class _Property(BaseModel, extra="allow"): + x_ewoks_targets: List[_Target] = Field(default_factory=list) + + +class EwoksParameterSchema(BaseModel, extra="forbid"): + schema_url: Literal["https://json-schema.org/draft/2020-12/schema"] = Field( + default="https://json-schema.org/draft/2020-12/schema", + alias="$schema", + ) + + type: Literal["object"] = "object" + + properties: Dict[str, _Property] + required: List[str] = Field(default_factory=list) + + additionalProperties: Literal[False] = False + + @model_validator(mode="after") + def validate_json_schema(self) -> Self: + schema_dict = self.model_dump(by_alias=True) + + # validates that the structure is a valid JSON Schema + Draft202012Validator.check_schema(schema_dict) + + return self + + @model_validator(mode="after") + def validate_required(self) -> Self: + missing = set(self.required) - self.properties.keys() + if missing: + raise ValueError( + f"'required' contains unknown properties: {sorted(missing)}" + ) + return self diff --git a/src/ewokscore/graph/schema/update.py b/src/ewokscore/graph/schema/update.py index 6372414..1d886c7 100644 --- a/src/ewokscore/graph/schema/update.py +++ b/src/ewokscore/graph/schema/update.py @@ -19,3 +19,8 @@ def from_v1_1_to_v1_2(graph: networkx.DiGraph) -> None: - `required` when set explicitly to `False`, ignoring graph analysis """ graph.graph["schema_version"] = "1.2" + + +def from_v1_2_to_v1_3(graph: networkx.DiGraph) -> None: + """v1.3 adds `workflow_input_schema` and `workflow_output_schema`""" + graph.graph["schema_version"] = "1.3" diff --git a/src/ewokscore/tests/examples/graphs/__init__.py b/src/ewokscore/tests/examples/graphs/__init__.py index ae16eed..663de3b 100644 --- a/src/ewokscore/tests/examples/graphs/__init__.py +++ b/src/ewokscore/tests/examples/graphs/__init__.py @@ -34,7 +34,6 @@ def wrapper(): attrs = g.setdefault("graph", dict()) assert attrs.get("id") == name assert attrs.get("label") == name - assert attrs.get("schema_version") == "1.2" return g, result if _ALL_GRAPHS is None: diff --git a/src/ewokscore/tests/examples/graphs/with_schema.py b/src/ewokscore/tests/examples/graphs/with_schema.py new file mode 100644 index 0000000..6073a4d --- /dev/null +++ b/src/ewokscore/tests/examples/graphs/with_schema.py @@ -0,0 +1,75 @@ +from . import graph + + +@graph +def with_schema(): + graph = { + "id": "with_schema", + "label": "with_schema", + "schema_version": "1.3", + "workflow_input_schema": { + "properties": { + "a": { + "type": "number", + "x_ewoks_targets": [{"name": "a", "id": "task1a"}], + }, + "b": { + "type": "number", + "default": 0, + "x_ewoks_targets": [{"name": "b", "id": "task1b"}], + }, + }, + "required": ["a"], + }, + "workflow_output_schema": { + "properties": { + "result_sum": { + "type": "number", + "x_ewoks_targets": [{"name": "result", "id": "task"}], + }, + }, + }, + } + + nodes = [ + { + "id": "task1a", + "task_type": "class", + "task_identifier": "ewokscore.tests.examples.tasks.sumlist.SumList", + "default_inputs": [{"name": "list", "value": [0, 1, 2]}], + }, + { + "id": "task1b", + "task_type": "class", + "task_identifier": "ewokscore.tests.examples.tasks.sumtask.SumTask", + "default_inputs": [{"name": "a", "value": 10}], + }, + { + "id": "task2", + "task_type": "class", + "task_identifier": "ewokscore.tests.examples.tasks.sumtask.SumTask", + }, + ] + + links = [ + { + "source": "task1a", + "target": "task2", + "data_mapping": [{"source_output": "sum", "target_input": "a"}], + }, + { + "source": "task1b", + "target": "task2", + "data_mapping": [{"source_output": "result", "target_input": "b"}], + }, + ] + + taskgraph = {"graph": graph, "links": links, "nodes": nodes} + + expected_results = { + "task1a": {"sum": 3}, + "task1b": {"result": 10}, + "task2": {"result": 13}, + } + + return taskgraph, expected_results diff --git a/src/ewokscore/tests/test_model.py b/src/ewokscore/tests/test_model.py index 0ed7518..a7424a9 100644 --- a/src/ewokscore/tests/test_model.py +++ b/src/ewokscore/tests/test_model.py @@ -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 @@ -127,3 +128,67 @@ def test_link_with_datamapping_and_map_all_data(): match="1 validation error for EwoksGraph\nlinks.0\n", ): EwoksGraph(**graph_dict) + + +def test_wrong_type_in_schema(): + graph_dict = { + "graph": { + "id": "required", + "workflow_input_schema": {"properties": {"a": {"type": "oo"}}}, + }, + "nodes": [ + { + "id": "node1", + "task_type": "class", + "task_identifier": "task1", + }, + ], + "links": [], + } + with pytest.raises(SchemaError): + EwoksGraph(**graph_dict) + + +def test_missing_name_in_ewoks_target(): + graph_dict = { + "graph": { + "id": "required", + "workflow_input_schema": { + "properties": {"a": {"type": "number", "x_ewoks_targets": [{}]}} + }, + }, + "nodes": [ + { + "id": "node1", + "task_type": "class", + "task_identifier": "task1", + }, + ], + "links": [], + } + + with pytest.raises( + pydantic.ValidationError, + match="1 validation error for EwoksGraph\ngraph.workflow_input_schema.properties.a.x_ewoks_targets.0.name\n", + ): + EwoksGraph(**graph_dict) + + +def test_wrong_required_inputs(): + graph_dict = { + "graph": { + "id": "required", + "workflow_input_schema": { + "properties": {"a": {"type": "number"}}, + "required": ["not_a"], + }, + }, + "nodes": [], + "links": [], + } + + with pytest.raises( + pydantic.ValidationError, + match="1 validation error for EwoksGraph\ngraph.workflow_input_schema\n", + ): + EwoksGraph(**graph_dict)