294 lines
10 KiB
Python
294 lines
10 KiB
Python
"""`fluksio sync` against a real engine: what it stores, and what it refuses."""
|
|
|
|
import sys
|
|
from collections.abc import Generator
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from fluksio.core.config import settings
|
|
from fluksio.sdk import FLOWS, MARKER, Declared, SyncError
|
|
from fluksio.sdk.client import ApiError, Client, sync
|
|
|
|
EXAMPLES = Path(__file__).parents[4] / "examples"
|
|
PREFIX = settings.API_V1_STR
|
|
ORIGIN = {
|
|
"kind": "python",
|
|
"repo": str(EXAMPLES),
|
|
"commit": "a1b2c3d4",
|
|
"dirty": False,
|
|
}
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def flows() -> Generator[dict, None, None]:
|
|
"""The example research package, imported the way `sync` imports it."""
|
|
sys.path.insert(0, str(EXAMPLES))
|
|
FLOWS.clear()
|
|
import myresearch.pipeline # noqa: F401
|
|
|
|
yield dict(FLOWS)
|
|
FLOWS.clear()
|
|
sys.path.remove(str(EXAMPLES))
|
|
|
|
|
|
@pytest.fixture
|
|
def api(client: TestClient, superuser_token_headers: dict[str, str]) -> Client:
|
|
return Client(http=client, token=superuser_token_headers["Authorization"][7:])
|
|
|
|
|
|
def flow_id(api: Client, key: str) -> str:
|
|
"""The id sync stored the flow with key *key* under."""
|
|
found = api.find_flow(key)
|
|
assert found is not None, key
|
|
return str(found["id"])
|
|
|
|
|
|
def commits() -> int:
|
|
"""How many commits the flow store has made."""
|
|
from fluksio.flow.store import FlowStore
|
|
|
|
store = FlowStore(settings.FLOWS_DIR)
|
|
result = store._git("rev-list", "--count", "HEAD")
|
|
return int(result.stdout.strip()) if result.returncode == 0 else 0
|
|
|
|
|
|
@pytest.mark.parametrize("repo", ["/", str(Path.home()), "examples"])
|
|
def test_sync_refuses_a_repo_every_run_would_walk_the_disk_for(api, flows, repo):
|
|
with pytest.raises(ApiError) as refused:
|
|
sync([flows["train"]], api, origin={**ORIGIN, "repo": repo})
|
|
|
|
assert refused.value.status == 422
|
|
assert "origin.repo" in str(refused.value.detail)
|
|
|
|
|
|
def test_sync_stores_a_runnable_flow_with_its_origin(api, flows):
|
|
reports = sync([flows["train"]], api, origin=ORIGIN)
|
|
|
|
assert [r.flow for r in reports] == ["train"]
|
|
assert reports[0].created and reports[0].published
|
|
|
|
stored = api.get_flow(flow_id(api, "train"))
|
|
definition = stored["definition"]
|
|
assert definition["origin"]["commit"] == "a1b2c3d4"
|
|
assert [n["id"] for n in definition["nodes"]] == ["prepare", "fit", "evaluate"]
|
|
assert not stored["has_draft"]
|
|
# The store holds a complete definition, and the bodies say where the real
|
|
# code is rather than being a copy of it.
|
|
source = api.get_source(definition["id"], "fit")
|
|
assert source.startswith(MARKER)
|
|
assert "from myresearch.train import fit" in source
|
|
|
|
|
|
def test_a_flow_read_by_another_process_syncs_as_the_flow_does(api, flows):
|
|
"""`fluksio sync` imports in a child and uploads what it handed back."""
|
|
carried = Declared(flows["train"].declaration())
|
|
|
|
sync([carried], api, origin=ORIGIN)
|
|
stored = api.get_flow(flow_id(api, "train"))["definition"]
|
|
assert stored["origin"]["declared_in"] == "myresearch/pipeline.py"
|
|
|
|
# Nothing differs from what the flow itself would have stored.
|
|
assert sync([flows["train"]], api, origin=ORIGIN)[0].unchanged
|
|
|
|
|
|
def test_a_node_carries_the_modules_its_code_reaches(api, flows):
|
|
"""The shim names one function; the cache has to key on what it calls."""
|
|
sync([flows["train"]], api, origin=ORIGIN)
|
|
|
|
definition = api.get_flow(flow_id(api, "train"))["definition"]
|
|
nodes = {n["id"]: n for n in definition["nodes"]}
|
|
files = nodes["fit"]["code_files"]
|
|
|
|
# The module the function is in, and the package whose `__init__` runs on
|
|
# the way to it. Not the engine's own code, and not the standard library.
|
|
assert "myresearch.train" in files
|
|
assert all(path.endswith(".py") for path in files.values())
|
|
assert not any(name.startswith("fluksio") for name in files)
|
|
assert len(nodes["fit"]["code_digest"]) == 64
|
|
|
|
|
|
def test_a_node_whose_helper_moved_is_named_by_sync():
|
|
"""Editing a helper changes no shim, so nothing used to say it happened."""
|
|
from fluksio.sdk.client import _moved_code
|
|
|
|
stored = {
|
|
"definition": {
|
|
"nodes": [
|
|
{"id": "fit", "code_digest": "aaa"},
|
|
{"id": "prepare", "code_digest": "bbb"},
|
|
]
|
|
}
|
|
}
|
|
document = {
|
|
"nodes": [
|
|
{"id": "fit", "code_digest": "ccc"},
|
|
{"id": "prepare", "code_digest": "bbb"},
|
|
]
|
|
}
|
|
|
|
assert _moved_code(stored, document) == ["fit"]
|
|
# Nothing to compare against is not a change.
|
|
assert _moved_code(None, document) == []
|
|
|
|
|
|
def test_an_engine_that_records_no_digest_is_not_a_change_every_time():
|
|
"""It drops the field on parse, so comparing against it reports for ever.
|
|
|
|
Which is what an engine older than this client does, and what a node
|
|
drawn on the canvas looks like — neither is a helper somebody edited.
|
|
"""
|
|
from fluksio.sdk.client import _moved_code
|
|
|
|
older = {"definition": {"nodes": [{"id": "fit"}, {"id": "prepare"}]}}
|
|
document = {
|
|
"nodes": [
|
|
{"id": "fit", "code_digest": "ccc"},
|
|
{"id": "prepare", "code_digest": ""},
|
|
]
|
|
}
|
|
|
|
assert _moved_code(older, document) == []
|
|
|
|
|
|
def test_a_second_sync_changes_nothing_and_commits_nothing(api, flows):
|
|
sync([flows["train"]], api, origin=ORIGIN)
|
|
before = commits()
|
|
|
|
reports = sync([flows["train"]], api, origin=ORIGIN)
|
|
|
|
assert reports[0].unchanged
|
|
assert commits() == before
|
|
|
|
|
|
def test_sync_refuses_to_overwrite_a_canvas_edit(api, flows):
|
|
sync([flows["train"]], api, origin=ORIGIN)
|
|
train = flow_id(api, "train")
|
|
api.put_source(train, "evaluate", "def process(weights):\n return {}\n")
|
|
|
|
with pytest.raises(SyncError, match="edited on the canvas"):
|
|
sync([flows["train"]], api, origin=ORIGIN)
|
|
|
|
reports = sync([flows["train"]], api, origin=ORIGIN, force=True)
|
|
assert "evaluate" in reports[0].changed
|
|
assert api.get_source(train, "evaluate").startswith(MARKER)
|
|
|
|
|
|
def test_an_interrupted_sync_finishes_on_the_next_one(api, flows):
|
|
"""A sync that died between the document and the bodies must be resumable.
|
|
|
|
That state — nodes named by a stored flow, with no source written for them
|
|
yet — used to read as a canvas edit, because the engine answers a new-node
|
|
template for a node with no body and the template carries no marker. The
|
|
advice was `--force`, for a canvas nobody had touched.
|
|
"""
|
|
target = flows["train"]
|
|
target.name = "resumed"
|
|
try:
|
|
# Exactly what creating the document leaves before any body is written.
|
|
resumed = api.create_flow(target.document(ORIGIN))["definition"]["id"]
|
|
assert api.get_source_entry(resumed, "fit")["missing"]
|
|
|
|
reports = sync([target], api, origin=ORIGIN)
|
|
|
|
assert "fit" in reports[0].changed
|
|
assert api.get_source(resumed, "fit").startswith(MARKER)
|
|
assert not api.get_source_entry(resumed, "fit")["missing"]
|
|
assert not api.get_flow(resumed)["has_draft"]
|
|
finally:
|
|
target.name = "train"
|
|
|
|
|
|
def test_sync_refuses_a_flow_it_did_not_create(api, flows):
|
|
drawn = api.create_flow({"name": "drawn", "nodes": [], "mode": "batch"})
|
|
api.publish(drawn["definition"]["id"], 1)
|
|
theirs = flows["train"]
|
|
theirs.name = "drawn"
|
|
|
|
try:
|
|
with pytest.raises(SyncError, match="not created by sync"):
|
|
sync([theirs], api, origin=ORIGIN)
|
|
finally:
|
|
theirs.name = "train"
|
|
|
|
|
|
def test_two_first_syncs_of_one_key_end_with_one_flow(api, flows):
|
|
"""The second looked before the first had created the flow.
|
|
|
|
Its create is refused for the key, so it looks again and updates the
|
|
first one's flow, checked first as any stored flow is: a canvas edit made
|
|
in between is refused rather than overwritten.
|
|
"""
|
|
|
|
class Late(Client):
|
|
looked = False
|
|
|
|
def find_flow(self, key):
|
|
if self.looked:
|
|
return super().find_flow(key)
|
|
# What it saw before the other sync created the flow.
|
|
self.looked = True
|
|
return None
|
|
|
|
target = flows["train"]
|
|
target.name = "raced"
|
|
try:
|
|
first = sync([target], api, origin=ORIGIN)[0]
|
|
second = sync([target], Late(http=api.http, token=api.token), origin=ORIGIN)[0]
|
|
|
|
assert first.created and not second.created
|
|
assert second.id == first.id
|
|
assert [f["id"] for f in api.flows() if f["name"] == "raced"] == [first.id]
|
|
|
|
api.put_source(first.id, "fit", "def process(dataset, lr):\n return {}\n")
|
|
with pytest.raises(SyncError, match="edited on the canvas"):
|
|
sync([target], Late(http=api.http, token=api.token), origin=ORIGIN)
|
|
finally:
|
|
target.name = "train"
|
|
found = api.find_flow("raced")
|
|
if found:
|
|
api.http.delete(f"{PREFIX}/flows/{found['id']}")
|
|
|
|
|
|
def test_a_synced_flow_is_addressed_by_its_key_under_an_issued_id(api, flows):
|
|
"""The id is the engine's own, opaque; the client takes the key and
|
|
sends the id, and the history answers with both."""
|
|
report = sync([flows["train"]], api, origin=ORIGIN)[0]
|
|
train = flow_id(api, "train")
|
|
assert report.id == train
|
|
assert train != "train" and train.startswith("f_")
|
|
|
|
[made] = api.import_runs(
|
|
[
|
|
{
|
|
"flow": "train",
|
|
"external_id": "elsewhere:1",
|
|
"params": {"lr": 0.05},
|
|
"created_at": "2026-03-01T10:00:00Z",
|
|
"result": {"score": 0.9},
|
|
}
|
|
]
|
|
)
|
|
rows = api.runs(flow="train")
|
|
assert made["id"] in {row["id"] for row in rows}
|
|
assert {(row["flow"], row["flow_name"]) for row in rows} == {(train, "train")}
|
|
exported = api.export_runs(flow="train")
|
|
assert {(row["flow"], row["flow_name"]) for row in exported} == {(train, "train")}
|
|
|
|
|
|
def test_use_stores_the_rewiring_it_was_given(api, flows):
|
|
sync([flows["finetune"]], api, origin=ORIGIN)
|
|
|
|
definition = api.get_flow(flow_id(api, "finetune"))["definition"]
|
|
nodes = {n["id"]: n for n in definition["nodes"]}
|
|
assert [p["name"] for p in nodes["fit"]["requires"]] == ["augmented", "lr"]
|
|
assert nodes["fit"]["params"] == {"epochs": 3}
|
|
|
|
|
|
def test_refresh_retires_the_workers(client, superuser_token_headers):
|
|
response = client.post(f"{PREFIX}/modules/refresh", headers=superuser_token_headers)
|
|
|
|
assert response.status_code == 200
|
|
assert "afresh" in response.json()["message"]
|