mirror of
https://github.com/OPM/ResInsight.git
synced 2026-10-26 07:00:17 -05:00
398 lines
13 KiB
Python
398 lines
13 KiB
Python
"""Unit tests for rips.taskmaestro_helper.
|
|
|
|
These tests do not require a running ResInsight; they exercise the
|
|
Pydantic introspection logic in isolation against a synthetic workflow
|
|
written into a tmp_path so they stay self-contained.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
pytest.importorskip("taskmaestro")
|
|
|
|
|
|
SYNTHETIC_PIPELINE = '''
|
|
"""Synthetic workflow for taskmaestro_helper tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import pathlib
|
|
|
|
import pydantic
|
|
from pydantic import BaseModel, Field
|
|
|
|
from taskmaestro import EmptyConfig, ExecutionContext, Task
|
|
|
|
|
|
class GreetInput(BaseModel):
|
|
name: str = Field(description="Person to greet", default="world")
|
|
times: int = Field(description="How many times", ge=1)
|
|
when: datetime.date = Field(description="Greet date", default=datetime.date(2024, 1, 1))
|
|
out_file: pathlib.Path = Field(description="Where to write greeting", default=pathlib.Path("/tmp/hi.txt"))
|
|
out_dir: pydantic.DirectoryPath = Field(description="Where to write logs")
|
|
|
|
|
|
class GreetOutput(BaseModel):
|
|
message: str
|
|
|
|
|
|
class Greet(Task[GreetInput, GreetOutput]):
|
|
name = "greet"
|
|
|
|
def run(self, input: GreetInput, ctx: ExecutionContext) -> GreetOutput: # pragma: no cover
|
|
return GreetOutput(message=f"hi {input.name}" * input.times)
|
|
|
|
|
|
class StartInput(BaseModel):
|
|
pass
|
|
|
|
|
|
class StartOutput(BaseModel):
|
|
pass
|
|
|
|
|
|
class Start(Task[StartInput, StartOutput]):
|
|
name = "start"
|
|
|
|
def run(self, input: StartInput, ctx: ExecutionContext) -> StartOutput: # pragma: no cover
|
|
return StartOutput()
|
|
'''
|
|
|
|
|
|
SYNTHETIC_WORKFLOW_YAML = """workflow:
|
|
name: synthetic
|
|
tasks:
|
|
- task: pipeline.Start
|
|
- task: pipeline.Greet
|
|
depends_on: pipeline.Start
|
|
config_fields: [name, times, when, out_file, out_dir]
|
|
"""
|
|
|
|
|
|
SYNTHETIC_INPUT_YAML = """greet:
|
|
name: alice
|
|
times: 3
|
|
out_dir: /tmp
|
|
"""
|
|
|
|
|
|
@pytest.fixture
|
|
def workflow_dir(tmp_path: Path) -> Path:
|
|
(tmp_path / "pipeline.py").write_text(SYNTHETIC_PIPELINE)
|
|
(tmp_path / "workflow.yaml").write_text(SYNTHETIC_WORKFLOW_YAML)
|
|
(tmp_path / "input.yaml").write_text(SYNTHETIC_INPUT_YAML)
|
|
return tmp_path
|
|
|
|
|
|
def test_collect_schema_returns_workflow_name_and_tasks(workflow_dir: Path) -> None:
|
|
from rips.taskmaestro_helper.introspect import collect_schema
|
|
|
|
schema = collect_schema(workflow_dir)
|
|
assert schema["name"] == "synthetic"
|
|
task_names = [t["name"] for t in schema["tasks"]]
|
|
assert task_names == ["start", "greet"]
|
|
assert schema["tasks"][0]["config_fields"] == []
|
|
assert schema["tasks"][0]["inputs"] == []
|
|
assert schema["tasks"][0]["outputs"] == []
|
|
assert schema["tasks"][1]["inputs"] == [
|
|
"name",
|
|
"out_dir",
|
|
"out_file",
|
|
"times",
|
|
"when",
|
|
]
|
|
assert schema["tasks"][1]["outputs"] == ["message"]
|
|
assert schema["edges"] == [{"from": "start", "to": "greet"}]
|
|
|
|
|
|
def test_collect_schema_extracts_field_types_and_metadata(workflow_dir: Path) -> None:
|
|
from rips.taskmaestro_helper.introspect import collect_schema
|
|
|
|
schema = collect_schema(workflow_dir)
|
|
greet = schema["tasks"][1]
|
|
fields_by_name = {f["name"]: f for f in greet["config_fields"]}
|
|
|
|
# input.yaml has `name: alice`, so that wins over the Pydantic default "world"
|
|
assert fields_by_name["name"]["type"] == "string"
|
|
assert fields_by_name["name"]["default"] == "alice"
|
|
assert fields_by_name["name"]["description"] == "Person to greet"
|
|
assert fields_by_name["name"]["required"] is False
|
|
|
|
# input.yaml has `times: 3`; field has no Pydantic default but is required
|
|
assert fields_by_name["times"]["type"] == "integer"
|
|
assert fields_by_name["times"]["required"] is True
|
|
assert fields_by_name["times"]["default"] == 3
|
|
assert "resinsight_type" not in fields_by_name["times"]
|
|
|
|
# input.yaml has no `when:` block, so Pydantic default kicks in
|
|
assert fields_by_name["when"]["type"] == "string"
|
|
assert fields_by_name["when"]["format"] == "date"
|
|
assert fields_by_name["when"]["default"] == "2024-01-01"
|
|
|
|
# input.yaml has no out_file; Pydantic default flows through
|
|
assert fields_by_name["out_file"]["type"] == "string"
|
|
assert fields_by_name["out_file"]["format"] == "path"
|
|
assert fields_by_name["out_file"]["default"] == "/tmp/hi.txt"
|
|
|
|
# input.yaml has `out_dir: /tmp`; pre-fills even though field is required
|
|
assert fields_by_name["out_dir"]["type"] == "string"
|
|
assert fields_by_name["out_dir"]["format"] == "directory-path"
|
|
assert fields_by_name["out_dir"]["required"] is True
|
|
assert fields_by_name["out_dir"]["default"] == "/tmp"
|
|
|
|
|
|
def test_field_schema_preserves_arrays_and_null_defaults() -> None:
|
|
from pydantic import BaseModel, Field
|
|
|
|
from rips.taskmaestro_helper.introspect import _field_schema
|
|
|
|
class Config(BaseModel):
|
|
values: list[int] = Field(default_factory=lambda: [1, 2])
|
|
optional_count: int | None = None
|
|
|
|
values = _field_schema("values", Config.model_fields["values"])
|
|
optional_count = _field_schema(
|
|
"optional_count", Config.model_fields["optional_count"]
|
|
)
|
|
|
|
assert values["type"] == "array"
|
|
assert values["default"] == [1, 2]
|
|
assert optional_count["type"] == "integer"
|
|
assert "default" in optional_count
|
|
assert optional_count["default"] is None
|
|
|
|
|
|
def test_field_schema_preserves_explicit_null_input() -> None:
|
|
from pydantic import BaseModel
|
|
|
|
from rips.taskmaestro_helper.introspect import _field_schema
|
|
|
|
class Config(BaseModel):
|
|
optional_name: str | None = "fallback"
|
|
|
|
schema = _field_schema(
|
|
"optional_name", Config.model_fields["optional_name"], input_value=None
|
|
)
|
|
assert "default" in schema
|
|
assert schema["default"] is None
|
|
|
|
|
|
def test_object_model_value_is_not_an_output_port() -> None:
|
|
from pydantic import BaseModel
|
|
from taskmaestro import ObjectModel
|
|
|
|
from rips.taskmaestro_helper.introspect import _output_fields
|
|
|
|
class WrappedOutput(ObjectModel[str]):
|
|
other: int
|
|
|
|
class PlainOutput(BaseModel):
|
|
value: str
|
|
other: int
|
|
|
|
assert _output_fields(WrappedOutput) == ["other"]
|
|
assert _output_fields(PlainOutput) == ["other", "value"]
|
|
|
|
|
|
def test_dependency_edges_cover_fan_in_and_field_routing() -> None:
|
|
from rips.taskmaestro_helper.introspect import _dependency_edges
|
|
|
|
assert _dependency_edges("root", None) == []
|
|
assert _dependency_edges("next", ("producer", "value")) == [
|
|
{"from": "producer", "to": "next", "output": "value"}
|
|
]
|
|
assert _dependency_edges("merge", {"b": ("second", "value"), "a": "first"}) == [
|
|
{"from": "first", "to": "merge", "input": "a"},
|
|
{"from": "second", "to": "merge", "input": "b", "output": "value"},
|
|
]
|
|
|
|
|
|
def test_resolve_refs_substitutes_object_model_value() -> None:
|
|
from rips.taskmaestro_helper.run import resolve_refs
|
|
|
|
sentinel_case = object()
|
|
sentinel_well = object()
|
|
|
|
def resolver(ri_type: str, extras: dict) -> object:
|
|
if ri_type == "EclipseCase" and extras == {"case_id": 0}:
|
|
return sentinel_case
|
|
if ri_type == "WellPath" and extras == {"well_path_name": "B-2H"}:
|
|
return sentinel_well
|
|
raise AssertionError(f"unexpected ref: {ri_type} {extras}")
|
|
|
|
raw = {
|
|
"load_model": {
|
|
"case": {"__resinsight_ref__": "EclipseCase", "case_id": 0},
|
|
"label": "x",
|
|
},
|
|
"load_well": {
|
|
"well": {"__resinsight_ref__": "WellPath", "well_path_name": "B-2H"},
|
|
},
|
|
"untouched": "stays",
|
|
}
|
|
out = resolve_refs(raw, resolver)
|
|
assert out["load_model"]["case"] == {"value": sentinel_case}
|
|
assert out["load_model"]["label"] == "x"
|
|
assert out["load_well"]["well"] == {"value": sentinel_well}
|
|
assert out["untouched"] == "stays"
|
|
|
|
|
|
def test_progress_hook_reports_named_task_instances(
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
from types import SimpleNamespace
|
|
|
|
from rips.taskmaestro_helper.run import PROGRESS_PREFIX, ProgressHook
|
|
|
|
hook = ProgressHook("run-123")
|
|
task = SimpleNamespace(name="add_perf_1")
|
|
hook.on_task_start(None, task)
|
|
hook.on_task_complete(None, task, None)
|
|
hook.on_task_fail(None, task, ValueError("bad range"))
|
|
|
|
lines = capsys.readouterr().out.splitlines()
|
|
assert len(lines) == 3
|
|
events = [json.loads(line.removeprefix(PROGRESS_PREFIX)) for line in lines]
|
|
assert all(line.startswith(PROGRESS_PREFIX) for line in lines)
|
|
assert [event["state"] for event in events] == ["running", "completed", "failed"]
|
|
assert all(
|
|
event["task"] == "add_perf_1" and event["run_id"] == "run-123"
|
|
for event in events
|
|
)
|
|
assert events[-1]["error"] == "bad range"
|
|
assert all("output" not in event for event in events)
|
|
|
|
|
|
def test_progress_hook_follows_runner_instance_names(
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
from taskmaestro import EmptyConfig, ExecutionContext, Job, Runner, Task, Workflow
|
|
|
|
from rips.taskmaestro_helper.run import PROGRESS_PREFIX, ProgressHook
|
|
|
|
class Step(Task[EmptyConfig, EmptyConfig]):
|
|
name = "step"
|
|
|
|
def run(self, input: EmptyConfig, ctx: ExecutionContext) -> EmptyConfig:
|
|
return EmptyConfig()
|
|
|
|
workflow = (
|
|
Workflow.builder("two_steps")
|
|
.add_task(Step, name="first")
|
|
.add_task(Step, name="second", depends_on="first")
|
|
.build()
|
|
)
|
|
Runner(hooks=[ProgressHook("run-456")]).run(
|
|
Job(workflow, EmptyConfig()), ctx=ExecutionContext()
|
|
)
|
|
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] == [
|
|
("first", "running"),
|
|
("first", "completed"),
|
|
("second", "running"),
|
|
("second", "completed"),
|
|
]
|
|
|
|
|
|
def test_progress_hook_reports_runner_failure(
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
from taskmaestro import EmptyConfig, ExecutionContext, Job, Runner, Task, Workflow
|
|
|
|
from rips.taskmaestro_helper.run import PROGRESS_PREFIX, ProgressHook
|
|
|
|
class Fails(Task[EmptyConfig, EmptyConfig]):
|
|
name = "fails"
|
|
|
|
def run(self, input: EmptyConfig, ctx: ExecutionContext) -> EmptyConfig:
|
|
raise ValueError("bad range")
|
|
|
|
workflow = Workflow.builder("failure").add_task(Fails, name="add_perf_1").build()
|
|
result = Runner(hooks=[ProgressHook("run-789")]).run(
|
|
Job(workflow, EmptyConfig()), ctx=ExecutionContext()
|
|
)
|
|
assert result.error == "bad range"
|
|
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] == [
|
|
("add_perf_1", "running"),
|
|
("add_perf_1", "failed"),
|
|
]
|
|
assert events[-1]["error"] == "bad range"
|
|
|
|
|
|
def test_run_helper_emits_progress_without_grpc(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
import rips
|
|
|
|
from rips.taskmaestro_helper.run import PROGRESS_PREFIX, run_workflow
|
|
|
|
(tmp_path / "progress_pipeline.py").write_text(
|
|
"from taskmaestro import EmptyConfig, ExecutionContext, Task\n"
|
|
"class Start(Task[EmptyConfig, EmptyConfig]):\n"
|
|
" name = 'start'\n"
|
|
" def run(self, input: EmptyConfig, ctx: ExecutionContext) -> EmptyConfig:\n"
|
|
" return EmptyConfig()\n"
|
|
"class Done(Task[EmptyConfig, EmptyConfig]):\n"
|
|
" name = 'done'\n"
|
|
" def run(self, input: EmptyConfig, ctx: ExecutionContext) -> EmptyConfig:\n"
|
|
" return EmptyConfig()\n"
|
|
)
|
|
(tmp_path / "workflow.yaml").write_text(
|
|
"workflow:\n name: progress_test\n tasks:\n"
|
|
" - task: progress_pipeline.Start\n - task: progress_pipeline.Done\n"
|
|
)
|
|
input_path = tmp_path / "input.yaml"
|
|
input_path.write_text("{}\n")
|
|
monkeypatch.setattr(rips.Instance, "find", lambda **kwargs: object())
|
|
|
|
assert run_workflow(tmp_path, input_path, 1234, "run-456") == 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"),
|
|
("done", "running"),
|
|
("done", "completed"),
|
|
]
|
|
assert all(event["run_id"] == "run-456" for event in events)
|
|
|
|
|
|
def test_main_emits_json_to_stdout(workflow_dir: Path) -> None:
|
|
rips_root = Path(__file__).resolve().parents[2]
|
|
proc = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"rips.taskmaestro_helper",
|
|
"introspect",
|
|
str(workflow_dir),
|
|
],
|
|
cwd=rips_root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
payload = json.loads(proc.stdout)
|
|
assert payload["name"] == "synthetic"
|
|
assert {t["name"] for t in payload["tasks"]} == {"start", "greet"}
|
|
assert payload["edges"] == [{"from": "start", "to": "greet"}]
|