mirror of
https://github.com/OPM/ResInsight.git
synced 2026-10-25 06:38:13 -05:00
Collect: list and dict inputs accept several connections of their item type. Members are labelled with their position or key, and can be reordered or renamed from the connection context menu. Map: Map Over / Edit Map / Remove Map on tasks. A mapped task is drawn as a stack with a "map over" subtitle, its items are edited in the property view (JSON, or selected cases, well paths or views), and per-item progress is shown during a run. Workflows using map or collect are no longer read-only. The run status no longer shows "Completed" after a successful run.
855 lines
27 KiB
Python
855 lines
27 KiB
Python
"""Tests for the workflow definition subcommands of rips.taskmaestro_helper.
|
|
|
|
Run with ``pytest --noconftest``: these tests do not need a running ResInsight.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import textwrap
|
|
from importlib.metadata import EntryPoint
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
pytest.importorskip("taskmaestro")
|
|
|
|
from rips.taskmaestro_helper import __main__ as helper_main # noqa: E402
|
|
from rips.taskmaestro_helper.catalog import build_catalog, export_registered # noqa: E402
|
|
from rips.taskmaestro_helper.definition import ( # noqa: E402
|
|
load_definition,
|
|
write_definition,
|
|
)
|
|
from rips.taskmaestro_helper.describe import describe_yaml # noqa: E402
|
|
|
|
TEST_DATA = Path(__file__).parent / "test_data" / "taskmaestro_workflows"
|
|
PIPELINE = "tm_helper_pipeline"
|
|
|
|
STRUCTURAL_KEYS = (
|
|
"name",
|
|
"result_task",
|
|
"nodes",
|
|
"edges",
|
|
"inputs",
|
|
"editable",
|
|
"header_comment",
|
|
"passthrough",
|
|
"task_types",
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def pipeline_on_path(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.syspath_prepend(str(TEST_DATA))
|
|
|
|
|
|
def _fake_entry_points(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tasks: dict[str, str],
|
|
workflows: dict[str, str] | None = None,
|
|
) -> None:
|
|
from taskmaestro import discovery
|
|
|
|
groups = {
|
|
discovery.TASK_ENTRY_POINT_GROUP: [
|
|
EntryPoint(name, value, discovery.TASK_ENTRY_POINT_GROUP)
|
|
for name, value in tasks.items()
|
|
],
|
|
discovery.WORKFLOW_ENTRY_POINT_GROUP: [
|
|
EntryPoint(name, value, discovery.WORKFLOW_ENTRY_POINT_GROUP)
|
|
for name, value in (workflows or {}).items()
|
|
],
|
|
}
|
|
|
|
def entry_points(*, group: str) -> list[EntryPoint]:
|
|
return groups.get(group, [])
|
|
|
|
monkeypatch.setattr(discovery, "entry_points", entry_points)
|
|
|
|
|
|
@pytest.fixture
|
|
def plugins(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_fake_entry_points(
|
|
monkeypatch,
|
|
tasks={
|
|
"test.start": f"{PIPELINE}:Start",
|
|
"test.double": f"{PIPELINE}:Double",
|
|
"test.add": f"{PIPELINE}:Add",
|
|
"test.constant": f"{PIPELINE}:Constant",
|
|
"test.broken": "tm_helper_missing_module:Broken",
|
|
},
|
|
workflows={
|
|
"test.flow": f"{PIPELINE}:registered_workflow",
|
|
"test.local": f"{PIPELINE}:local_workflow",
|
|
},
|
|
)
|
|
|
|
|
|
def _write_workflow(directory: Path, workflow: str, inputs: str | None = None) -> Path:
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
(directory / "workflow.yaml").write_text(textwrap.dedent(workflow))
|
|
if inputs is not None:
|
|
(directory / "input.yaml").write_text(textwrap.dedent(inputs))
|
|
return directory
|
|
|
|
|
|
def _structure(definition: dict[str, Any]) -> dict[str, Any]:
|
|
return {key: definition[key] for key in STRUCTURAL_KEYS}
|
|
|
|
|
|
def _describe_summary(description: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"result_task": description["result_task"],
|
|
"tasks": [
|
|
(task["name"], task["depends_on"], task["config_fields"])
|
|
for task in description["tasks"]
|
|
],
|
|
}
|
|
|
|
|
|
def _round_trip(source: Path, out_dir: Path) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
first = load_definition(source, describe=True)
|
|
assert first["editable"], first["readonly_reasons"]
|
|
assert first["describe_error"] is None, first["describe_error"]
|
|
write_definition(first, out_dir)
|
|
second = load_definition(out_dir, describe=True)
|
|
assert second["describe_error"] is None, second["describe_error"]
|
|
assert _structure(second) == _structure(first)
|
|
assert _describe_summary(second["describe"]) == _describe_summary(first["describe"])
|
|
third_dir = out_dir.parent / (out_dir.name + "_again")
|
|
write_definition(second, third_dir)
|
|
assert (third_dir / "workflow.yaml").read_text() == (
|
|
out_dir / "workflow.yaml"
|
|
).read_text()
|
|
return first, second
|
|
|
|
|
|
# --- Catalog ---
|
|
|
|
|
|
def test_catalog_reports_broken_plugin_and_loads_the_rest(plugins: None) -> None:
|
|
catalog = build_catalog()
|
|
ids = [task["id"] for task in catalog["tasks"]]
|
|
assert ids == ["test.add", "test.constant", "test.double", "test.start"]
|
|
assert [error["id"] for error in catalog["errors"]] == ["test.broken"]
|
|
|
|
start = next(task for task in catalog["tasks"] if task["id"] == "test.start")
|
|
assert start["name"] == "start"
|
|
assert start["description"] == "Produce the configured value."
|
|
assert start["python_type"] == f"{PIPELINE}.Start"
|
|
assert start["input_schema"]["properties"]["value"]["type"] == "number"
|
|
|
|
workflows = {workflow["id"]: workflow for workflow in catalog["workflows"]}
|
|
assert set(workflows) == {"test.flow", "test.local"}
|
|
assert workflows["test.flow"]["editable"]
|
|
assert not workflows["test.local"]["editable"]
|
|
assert workflows["test.flow"]["describe"]["result_task"] == "double"
|
|
|
|
|
|
def test_catalog_without_workflows(plugins: None) -> None:
|
|
assert build_catalog(include_workflows=False)["workflows"] == []
|
|
|
|
|
|
def test_catalog_cli_prints_one_json_document(
|
|
plugins: None, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
assert helper_main.main(["catalog", "--no-workflows"]) == 0
|
|
out = capsys.readouterr().out
|
|
document = json.loads(out)
|
|
assert document["status"] == "ok"
|
|
assert len(document["tasks"]) == 4
|
|
|
|
|
|
def test_cli_failure_prints_invalid_document(
|
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
assert helper_main.main(["load", str(tmp_path / "missing")]) == 2
|
|
document = json.loads(capsys.readouterr().out)
|
|
assert document["status"] == "invalid"
|
|
assert "Cannot read" in document["error"]["message"]
|
|
|
|
|
|
# --- Schema ---
|
|
|
|
|
|
def test_schema_records_python_bases() -> None:
|
|
from rips.taskmaestro_helper.schema import task_descriptor
|
|
from tm_helper_pipeline import Special
|
|
|
|
descriptor = task_descriptor("test.special", Special)
|
|
output = descriptor["output_schema"]
|
|
assert output["x-ri-python-type"] == f"{PIPELINE}.SpecialNumber"
|
|
assert output["x-ri-python-bases"] == [
|
|
f"{PIPELINE}.SpecialNumber",
|
|
f"{PIPELINE}.Number",
|
|
]
|
|
|
|
|
|
def test_schema_perforation_output_is_not_a_well_path() -> None:
|
|
pytest.importorskip("taskmaestro_resinsight")
|
|
from taskmaestro import get_registered_task
|
|
|
|
from rips.taskmaestro_helper.schema import task_descriptor
|
|
|
|
add_perforation = task_descriptor(
|
|
"resinsight.add_perforation", get_registered_task("resinsight.add_perforation")
|
|
)
|
|
output_bases = add_perforation["output_schema"]["x-ri-python-bases"]
|
|
well_path = add_perforation["input_schema"]["$defs"]["WellPath"]
|
|
assert well_path["x-ri-python-type"] == "taskmaestro_resinsight.models.WellPath"
|
|
assert well_path["x-ri-python-type"] not in output_bases
|
|
opaque = well_path["properties"]["value"]
|
|
assert opaque["x-taskmaestro-opaque"] is True
|
|
assert "rips.generated.generated_classes.WellPath" in opaque["x-ri-python-bases"]
|
|
|
|
|
|
# --- Round trips ---
|
|
|
|
|
|
def test_round_trip_whole_output_dependency(tmp_path: Path) -> None:
|
|
source = _write_workflow(
|
|
tmp_path / "src",
|
|
f"""\
|
|
workflow:
|
|
name: whole
|
|
tasks:
|
|
- task: {PIPELINE}.Start
|
|
config_fields: [value]
|
|
- task: {PIPELINE}.Double
|
|
depends_on: {PIPELINE}.Start
|
|
""",
|
|
"start:\n value: 2.5\n",
|
|
)
|
|
first, _ = _round_trip(source, tmp_path / "out")
|
|
assert first["edges"] == [
|
|
{"from": "start", "output": None, "to": "double", "input": None}
|
|
]
|
|
assert first["inputs"] == {"start": {"value": 2.5}, "double": {}}
|
|
text = (tmp_path / "out" / "workflow.yaml").read_text()
|
|
assert "name: start" in text and "depends_on: start" in text
|
|
assert "result_task" not in text
|
|
|
|
|
|
def test_round_trip_output_field_routing(tmp_path: Path) -> None:
|
|
source = _write_workflow(
|
|
tmp_path / "src",
|
|
f"""\
|
|
workflow:
|
|
name: routed
|
|
tasks:
|
|
- task: {PIPELINE}.Start
|
|
name: first
|
|
config_fields: [value]
|
|
- task: {PIPELINE}.Wrap
|
|
name: wrap
|
|
depends_on: first
|
|
- task: {PIPELINE}.Double
|
|
name: unwrap
|
|
depends_on: [wrap, inner]
|
|
""",
|
|
"first:\n value: 1\n",
|
|
)
|
|
first, _ = _round_trip(source, tmp_path / "out")
|
|
assert {"from": "wrap", "output": "inner", "to": "unwrap", "input": None} in first[
|
|
"edges"
|
|
]
|
|
assert (
|
|
"depends_on: [wrap, inner]" in (tmp_path / "out" / "workflow.yaml").read_text()
|
|
)
|
|
|
|
|
|
def test_round_trip_fan_in_with_field_routing_and_two_instances(tmp_path: Path) -> None:
|
|
source = _write_workflow(
|
|
tmp_path / "src",
|
|
f"""\
|
|
workflow:
|
|
name: fan_in
|
|
result_task: add
|
|
tasks:
|
|
- task: {PIPELINE}.Start
|
|
name: left
|
|
config_fields: [value]
|
|
- task: {PIPELINE}.Start
|
|
name: right
|
|
config_fields: [value]
|
|
- task: {PIPELINE}.Add
|
|
name: add
|
|
depends_on:
|
|
first: [left, value]
|
|
second: [right, value]
|
|
- task: {PIPELINE}.Double
|
|
name: side
|
|
depends_on: left
|
|
""",
|
|
"left:\n value: 1\nright:\n value: 2\n",
|
|
)
|
|
first, second = _round_trip(source, tmp_path / "out")
|
|
assert first["result_task"] == "add"
|
|
assert [node["name"] for node in second["nodes"]] == [
|
|
"left",
|
|
"right",
|
|
"add",
|
|
"side",
|
|
]
|
|
assert list(second["task_types"]) == [
|
|
f"{PIPELINE}.Start",
|
|
f"{PIPELINE}.Add",
|
|
f"{PIPELINE}.Double",
|
|
]
|
|
text = (tmp_path / "out" / "workflow.yaml").read_text()
|
|
assert "result_task: add" in text
|
|
assert "first: [left, value]" in text
|
|
|
|
|
|
def test_load_resolves_class_paths_to_instance_names(tmp_path: Path) -> None:
|
|
source = _write_workflow(
|
|
tmp_path / "src",
|
|
f"""\
|
|
workflow:
|
|
name: by_path
|
|
tasks:
|
|
- task: {PIPELINE}.Constant
|
|
- task: {PIPELINE}.Double
|
|
depends_on: {PIPELINE}.Constant
|
|
""",
|
|
)
|
|
definition = load_definition(source)
|
|
assert [node["name"] for node in definition["nodes"]] == ["constant", "double"]
|
|
assert definition["edges"][0]["from"] == "constant"
|
|
|
|
|
|
def test_linear_mode_is_converted_to_explicit_edges(tmp_path: Path) -> None:
|
|
source = _write_workflow(
|
|
tmp_path / "src",
|
|
f"""\
|
|
workflow:
|
|
name: linear
|
|
tasks:
|
|
- task: {PIPELINE}.Constant
|
|
- task: {PIPELINE}.Double
|
|
- task: {PIPELINE}.Double
|
|
name: again
|
|
""",
|
|
)
|
|
definition = load_definition(source)
|
|
assert definition["edges"] == [
|
|
{"from": "constant", "output": None, "to": "double", "input": None},
|
|
{"from": "double", "output": None, "to": "again", "input": None},
|
|
]
|
|
assert any("chained" in warning for warning in definition["warnings"])
|
|
write_definition(definition, tmp_path / "out")
|
|
reloaded = load_definition(tmp_path / "out", describe=True)
|
|
assert reloaded["edges"] == definition["edges"]
|
|
assert reloaded["warnings"] == []
|
|
assert reloaded["describe_error"] is None
|
|
|
|
|
|
def test_empty_depends_on_guard_keeps_unconnected_tasks_independent(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
definition = load_definition(
|
|
_write_workflow(
|
|
tmp_path / "src",
|
|
f"""\
|
|
workflow:
|
|
name: independent
|
|
result_task: second
|
|
tasks:
|
|
- task: {PIPELINE}.Constant
|
|
name: first
|
|
- task: {PIPELINE}.Constant
|
|
name: second
|
|
""",
|
|
)
|
|
)
|
|
definition["edges"] = []
|
|
write_definition(definition, tmp_path / "out")
|
|
text = (tmp_path / "out" / "workflow.yaml").read_text()
|
|
assert "depends_on: {}" in text
|
|
reloaded = load_definition(tmp_path / "out", describe=True)
|
|
assert reloaded["edges"] == []
|
|
assert reloaded["describe_error"] is None
|
|
assert [task["depends_on"] for task in reloaded["describe"]["tasks"]] == [{}, None]
|
|
|
|
|
|
def test_save_rejects_mixed_whole_and_field_inputs(tmp_path: Path) -> None:
|
|
from rips.taskmaestro_helper._compat import HelperError
|
|
|
|
definition = load_definition(
|
|
_write_workflow(
|
|
tmp_path / "src",
|
|
f"""\
|
|
workflow:
|
|
name: mixed
|
|
tasks:
|
|
- task: {PIPELINE}.Constant
|
|
name: one
|
|
- task: {PIPELINE}.Add
|
|
name: add
|
|
depends_on:
|
|
first: [one, value]
|
|
second: [one, value]
|
|
""",
|
|
)
|
|
)
|
|
definition["edges"].append(
|
|
{"from": "one", "output": None, "to": "add", "input": None}
|
|
)
|
|
with pytest.raises(HelperError, match="whole input"):
|
|
write_definition(definition, tmp_path / "out")
|
|
|
|
|
|
def test_save_rejects_read_only_and_empty_workflows(tmp_path: Path) -> None:
|
|
from rips.taskmaestro_helper._compat import HelperError
|
|
|
|
definition = load_definition(
|
|
_write_workflow(
|
|
tmp_path / "src",
|
|
f"""\
|
|
workflow:
|
|
name: single
|
|
tasks:
|
|
- task: {PIPELINE}.Constant
|
|
""",
|
|
)
|
|
)
|
|
with pytest.raises(HelperError, match="read-only"):
|
|
write_definition({**definition, "editable": False}, tmp_path / "out")
|
|
with pytest.raises(HelperError, match="no tasks"):
|
|
write_definition({**definition, "nodes": []}, tmp_path / "out")
|
|
|
|
|
|
# --- Mapped tasks and collected inputs ---
|
|
|
|
|
|
def test_round_trip_positional_collect(tmp_path: Path) -> None:
|
|
source = _write_workflow(
|
|
tmp_path / "src",
|
|
f"""\
|
|
workflow:
|
|
name: collected
|
|
tasks:
|
|
- task: {PIPELINE}.Constant
|
|
name: a
|
|
- task: {PIPELINE}.Constant
|
|
name: b
|
|
- task: {PIPELINE}.Sum
|
|
name: total
|
|
depends_on:
|
|
values:
|
|
collect: [[b, value], [a, value]]
|
|
""",
|
|
)
|
|
first, _ = _round_trip(source, tmp_path / "out")
|
|
assert [edge for edge in first["edges"] if edge["to"] == "total"] == [
|
|
{
|
|
"from": "b",
|
|
"output": "value",
|
|
"to": "total",
|
|
"input": "values",
|
|
"collect": "list",
|
|
"key": None,
|
|
},
|
|
{
|
|
"from": "a",
|
|
"output": "value",
|
|
"to": "total",
|
|
"input": "values",
|
|
"collect": "list",
|
|
"key": None,
|
|
},
|
|
]
|
|
raw = yaml.safe_load((tmp_path / "out" / "workflow.yaml").read_text())
|
|
assert raw["workflow"]["tasks"][2]["depends_on"] == {
|
|
"values": {"collect": [["b", "value"], ["a", "value"]]}
|
|
}
|
|
|
|
|
|
def test_round_trip_keyed_collect(tmp_path: Path) -> None:
|
|
source = _write_workflow(
|
|
tmp_path / "src",
|
|
f"""\
|
|
workflow:
|
|
name: keyed
|
|
tasks:
|
|
- task: {PIPELINE}.Constant
|
|
name: a
|
|
- task: {PIPELINE}.Constant
|
|
name: b
|
|
- task: {PIPELINE}.KeyedSum
|
|
name: total
|
|
depends_on:
|
|
values:
|
|
collect: {{first: [a, value], second: [b, value]}}
|
|
""",
|
|
)
|
|
first, _ = _round_trip(source, tmp_path / "out")
|
|
assert [
|
|
(edge["from"], edge["collect"], edge["key"])
|
|
for edge in first["edges"]
|
|
if edge["to"] == "total"
|
|
] == [("a", "dict", "first"), ("b", "dict", "second")]
|
|
raw = yaml.safe_load((tmp_path / "out" / "workflow.yaml").read_text())
|
|
assert raw["workflow"]["tasks"][2]["depends_on"] == {
|
|
"values": {"collect": {"first": ["a", "value"], "second": ["b", "value"]}}
|
|
}
|
|
|
|
|
|
def test_save_rejects_inconsistent_collect(tmp_path: Path) -> None:
|
|
from rips.taskmaestro_helper._compat import HelperError
|
|
|
|
source = _write_workflow(
|
|
tmp_path / "src",
|
|
f"""\
|
|
workflow:
|
|
name: keyed
|
|
tasks:
|
|
- task: {PIPELINE}.Constant
|
|
name: a
|
|
- task: {PIPELINE}.Constant
|
|
name: b
|
|
- task: {PIPELINE}.KeyedSum
|
|
name: total
|
|
depends_on:
|
|
values:
|
|
collect: {{first: [a, value], second: [b, value]}}
|
|
""",
|
|
)
|
|
definition = load_definition(source)
|
|
definition["edges"][-1]["key"] = "first"
|
|
with pytest.raises(HelperError, match="Duplicate key"):
|
|
write_definition(definition, tmp_path / "out")
|
|
definition["edges"][-1]["collect"] = "list"
|
|
with pytest.raises(HelperError, match="mixes"):
|
|
write_definition(definition, tmp_path / "out")
|
|
|
|
|
|
def test_round_trip_mapped_task(tmp_path: Path) -> None:
|
|
source = _write_workflow(
|
|
tmp_path / "src",
|
|
f"""\
|
|
workflow:
|
|
name: mapped
|
|
tasks:
|
|
- task: {PIPELINE}.PerItem
|
|
name: items
|
|
map: {{over: numbers, key_as: key, value_as: value, error_mode: collect_all}}
|
|
""",
|
|
"items:\n numbers:\n a: 1.0\n b: 2.0\n",
|
|
)
|
|
first, _ = _round_trip(source, tmp_path / "out")
|
|
assert first["nodes"][0]["map"] == {
|
|
"over": "numbers",
|
|
"key_as": "key",
|
|
"value_as": "value",
|
|
"error_mode": "collect_all",
|
|
}
|
|
assert first["nodes"][0]["config_fields"] == []
|
|
assert first["inputs"] == {"items": {"numbers": {"a": 1.0, "b": 2.0}}}
|
|
raw = yaml.safe_load((tmp_path / "out" / "workflow.yaml").read_text())
|
|
assert raw["workflow"]["tasks"][0]["map"] == {
|
|
"over": "numbers",
|
|
"key_as": "key",
|
|
"value_as": "value",
|
|
"error_mode": "collect_all",
|
|
}
|
|
assert yaml.safe_load((tmp_path / "out" / "input.yaml").read_text()) == {
|
|
"items": {"numbers": {"a": 1.0, "b": 2.0}}
|
|
}
|
|
|
|
|
|
def test_save_rejects_invalid_map(tmp_path: Path) -> None:
|
|
from rips.taskmaestro_helper._compat import HelperError
|
|
|
|
source = _write_workflow(
|
|
tmp_path / "src",
|
|
f"""\
|
|
workflow:
|
|
name: mapped
|
|
tasks:
|
|
- task: {PIPELINE}.PerItem
|
|
name: items
|
|
map: {{over: numbers, key_as: key, value_as: value}}
|
|
""",
|
|
)
|
|
definition = load_definition(source)
|
|
definition["nodes"][0]["map"]["value_as"] = "key"
|
|
with pytest.raises(HelperError, match="both the key"):
|
|
write_definition(definition, tmp_path / "out")
|
|
|
|
|
|
# --- Read-only workflows ---
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("tasks", "reason"),
|
|
[
|
|
(
|
|
"""\
|
|
- workflow: inner.yaml
|
|
name: nested
|
|
""",
|
|
"nested",
|
|
),
|
|
(
|
|
"""\
|
|
- task: tm_helper_missing_module.Missing
|
|
""",
|
|
"Cannot load task",
|
|
),
|
|
],
|
|
)
|
|
def test_unsupported_features_open_read_only(
|
|
tmp_path: Path, tasks: str, reason: str
|
|
) -> None:
|
|
source = tmp_path / "src"
|
|
source.mkdir()
|
|
(source / "inner.yaml").write_text(
|
|
f"workflow:\n name: inner\n tasks:\n - task: {PIPELINE}.Constant\n"
|
|
)
|
|
(source / "workflow.yaml").write_text(
|
|
"workflow:\n name: read_only\n tasks:\n"
|
|
+ textwrap.indent(textwrap.dedent(tasks), " ")
|
|
)
|
|
definition = load_definition(source)
|
|
assert not definition["editable"]
|
|
assert any(reason in text for text in definition["readonly_reasons"])
|
|
|
|
|
|
# --- Header, passthrough and references ---
|
|
|
|
|
|
def test_header_and_passthrough_preserved_and_refs_stripped(tmp_path: Path) -> None:
|
|
source = _write_workflow(
|
|
tmp_path / "src",
|
|
f"""\
|
|
# My workflow
|
|
#
|
|
# Explains things.
|
|
|
|
workflow:
|
|
name: with_header
|
|
tasks:
|
|
- task: {PIPELINE}.Start
|
|
config_fields: [value]
|
|
# A comment that is lost.
|
|
runner:
|
|
timeout_seconds: 30
|
|
hooks:
|
|
- hook: taskmaestro.hooks.timing.TimingHook
|
|
context:
|
|
correlation_id: abc
|
|
""",
|
|
"""\
|
|
start:
|
|
value: {__resinsight_ref__: EclipseCase, case_id: 0}
|
|
""",
|
|
)
|
|
definition = load_definition(source)
|
|
assert definition["header_comment"] == "# My workflow\n#\n# Explains things."
|
|
assert definition["passthrough"] == {
|
|
"runner": {
|
|
"timeout_seconds": 30,
|
|
"hooks": [{"hook": "taskmaestro.hooks.timing.TimingHook"}],
|
|
},
|
|
"context": {"correlation_id": "abc"},
|
|
}
|
|
write_definition(definition, tmp_path / "out", backup=True)
|
|
text = (tmp_path / "out" / "workflow.yaml").read_text()
|
|
assert text.startswith("# My workflow\n#\n# Explains things.\n\nworkflow:")
|
|
assert "A comment that is lost" not in text
|
|
assert "timeout_seconds: 30" in text
|
|
assert "__resinsight_ref__" not in (tmp_path / "out" / "input.yaml").read_text()
|
|
reloaded = load_definition(tmp_path / "out")
|
|
assert reloaded["passthrough"] == definition["passthrough"]
|
|
assert reloaded["inputs"] == {"start": {}}
|
|
|
|
|
|
def test_backup_is_written_once(tmp_path: Path) -> None:
|
|
source = _write_workflow(
|
|
tmp_path / "src",
|
|
f"""\
|
|
workflow:
|
|
name: backup
|
|
tasks:
|
|
- task: {PIPELINE}.Constant
|
|
""",
|
|
)
|
|
original = (source / "workflow.yaml").read_text()
|
|
definition = load_definition(source)
|
|
result = write_definition(definition, source, backup=True)
|
|
assert result["backups"] == [str(source / "workflow.yaml.bak")]
|
|
write_definition(definition, source, backup=True)
|
|
assert (source / "workflow.yaml.bak").read_text() == original
|
|
|
|
|
|
def test_wired_input_drops_configured_value(tmp_path: Path) -> None:
|
|
definition = load_definition(
|
|
_write_workflow(
|
|
tmp_path / "src",
|
|
f"""\
|
|
workflow:
|
|
name: wired
|
|
tasks:
|
|
- task: {PIPELINE}.Constant
|
|
name: one
|
|
- task: {PIPELINE}.Add
|
|
name: add
|
|
depends_on:
|
|
first: [one, value]
|
|
config_fields: [first, second]
|
|
""",
|
|
"add:\n first: 1\n second: 2\nunknown_task:\n x: 1\n",
|
|
)
|
|
)
|
|
add = next(node for node in definition["nodes"] if node["name"] == "add")
|
|
assert add["config_fields"] == ["second"]
|
|
assert definition["inputs"]["add"] == {"second": 2}
|
|
assert len(definition["warnings"]) == 2
|
|
|
|
|
|
def test_save_cli_reads_definition_from_stdin(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
import io
|
|
|
|
definition = load_definition(
|
|
_write_workflow(
|
|
tmp_path / "src",
|
|
f"""\
|
|
workflow:
|
|
name: via_cli
|
|
tasks:
|
|
- task: {PIPELINE}.Start
|
|
config_fields: [value]
|
|
""",
|
|
)
|
|
)
|
|
monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(definition)))
|
|
out_dir = tmp_path / "out"
|
|
assert helper_main.main(["save", "--out-dir", str(out_dir), "--describe"]) == 0
|
|
result = json.loads(capsys.readouterr().out)
|
|
assert result["status"] == "ok"
|
|
assert result["describe"]["tasks"][0]["missing_config_fields"] == ["value"]
|
|
assert (out_dir / "input.yaml").read_text() == "start: {}\n"
|
|
|
|
|
|
def test_describe_yaml_reports_real_error_message(tmp_path: Path) -> None:
|
|
source = _write_workflow(
|
|
tmp_path / "src",
|
|
f"""\
|
|
workflow:
|
|
name: broken
|
|
tasks:
|
|
- task: {PIPELINE}.Add
|
|
name: add
|
|
depends_on:
|
|
first: {PIPELINE}.Constant
|
|
""",
|
|
)
|
|
description, error = describe_yaml(source / "workflow.yaml")
|
|
assert description is None
|
|
assert error is not None and "not found" in error["message"]
|
|
|
|
|
|
# --- Registered workflows ---
|
|
|
|
|
|
def test_export_registered(plugins: None) -> None:
|
|
definition = export_registered("test.flow", describe=True)
|
|
assert definition["editable"]
|
|
assert definition["source"]["registered_id"] == "test.flow"
|
|
assert definition["nodes"] == [
|
|
{"name": "start", "task": "test.start", "config_fields": ["value"]},
|
|
{"name": "double", "task": "test.double", "config_fields": []},
|
|
]
|
|
assert definition["edges"] == [
|
|
{"from": "start", "output": None, "to": "double", "input": None}
|
|
]
|
|
assert definition["result_task"] is None
|
|
assert definition["describe"]["result_task"] == "double"
|
|
|
|
|
|
def test_export_registered_local_class_is_read_only(plugins: None) -> None:
|
|
definition = export_registered("test.local")
|
|
assert not definition["editable"]
|
|
assert "cannot be referenced" in definition["readonly_reasons"][0]
|
|
|
|
|
|
def test_exported_registered_workflow_saves_and_validates(
|
|
plugins: None, tmp_path: Path
|
|
) -> None:
|
|
definition = export_registered("test.flow")
|
|
definition["inputs"] = {"start": {"value": 3}}
|
|
write_definition(definition, tmp_path / "out")
|
|
description, error = describe_yaml(
|
|
tmp_path / "out" / "workflow.yaml", tmp_path / "out" / "input.yaml"
|
|
)
|
|
assert error is None
|
|
assert description is not None and description["result_task"] == "double"
|
|
|
|
|
|
def test_run_registered_workflow(
|
|
plugins: None,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
import rips
|
|
|
|
from rips.taskmaestro_helper.run import PROGRESS_PREFIX, main
|
|
|
|
input_path = tmp_path / "input.yaml"
|
|
input_path.write_text("start:\n value: 2\n")
|
|
monkeypatch.setattr(rips.Instance, "find", lambda **kwargs: object())
|
|
|
|
assert (
|
|
main(
|
|
[
|
|
"--registered",
|
|
"test.flow",
|
|
"--input",
|
|
str(input_path),
|
|
"--grpc-port",
|
|
"1234",
|
|
"--run-id",
|
|
"rid",
|
|
]
|
|
)
|
|
== 0
|
|
)
|
|
events = [
|
|
json.loads(line.removeprefix(PROGRESS_PREFIX))
|
|
for line in capsys.readouterr().out.splitlines()
|
|
if line.startswith(PROGRESS_PREFIX)
|
|
]
|
|
assert [(event["task"], event["state"]) for event in events] == [
|
|
("start", "running"),
|
|
("start", "completed"),
|
|
("double", "running"),
|
|
("double", "completed"),
|
|
]
|
|
|
|
|
|
def test_run_requires_folder_or_registered_id(tmp_path: Path) -> None:
|
|
from rips.taskmaestro_helper.run import main
|
|
|
|
with pytest.raises(SystemExit):
|
|
main(["--input", str(tmp_path / "input.yaml"), "--grpc-port", "1"])
|
|
|
|
|
|
# --- The taskmaestro-resinsight examples ---
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["resinsight_completions", "resinsight_load_data"])
|
|
def test_example_workflows_round_trip(tmp_path: Path, name: str) -> None:
|
|
pytest.importorskip("taskmaestro_resinsight")
|
|
source = tmp_path / name
|
|
shutil.copytree(TEST_DATA / name, source)
|
|
first, _ = _round_trip(source, tmp_path / "out")
|
|
assert first["warnings"] == []
|
|
assert all(node["task"].startswith("resinsight.") for node in first["nodes"])
|