Files

726 lines
26 KiB
Python

"""Ids: who a flow or dashboard is, apart from what it is called.
The store keeps them, and the startup migration gives every document from
before them its key as its id. A mistake in either loses data silently, which
is why the migration is driven through crashes, refusals and stores without git.
"""
import json
import logging
import subprocess
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import pytest
from fluksio import cli
from fluksio.flow import identity
from fluksio.flow.dashboards import (
DASHBOARD_DIR,
DashboardDef,
DashboardExists,
DashboardNotFound,
DashboardStore,
default_dashboard,
results_dashboard,
)
from fluksio.flow.identity import COMMIT_MESSAGE, IdsRefused, assign_ids
from fluksio.flow.schemas import FlowCreate, FlowDef
from fluksio.flow.store import (
ISSUED_ID,
FlowExists,
FlowNotFound,
FlowStore,
IdentityChange,
IdTaken,
RecordedKey,
ReservedKey,
StaleVersion,
new_id,
)
# -----------------------------------------------------------------------------
# The store
# -----------------------------------------------------------------------------
class Graves:
"""Tombstones and history, as `fluksio.flow.tombstones.Tombstones` keeps
them in the database, which store tests have none of."""
def __init__(self) -> None:
self.stones: dict[str, str] = {}
#: Ids that history rows name.
self.history: set[str] = set()
def bury(self, flow_id: str, name: str) -> None:
self.stones[flow_id] = name
def lift(self, flow_id: str) -> None:
self.stones.pop(flow_id, None)
def recorded(self, flow_id: str) -> str | None:
if flow_id in self.stones:
return self.stones[flow_id]
return flow_id if flow_id in self.history else None
@pytest.fixture
def graves() -> Graves:
return Graves()
@pytest.fixture
def store(tmp_path: Path, graves: Graves) -> FlowStore:
return FlowStore(tmp_path / "flows", tombstones=graves) # type: ignore[arg-type]
def test_a_new_flow_gets_an_issued_id_and_keeps_it(store: FlowStore):
created = store.create(FlowDef(name="heating"))
flow_id = created.id
assert ISSUED_ID.fullmatch(flow_id) and flow_id.startswith("f_")
assert created.name == "heating" and created.version == 1
assert store.key_index() == {"heating": flow_id}
published = store.publish_flow(flow_id, created.version)
store.write_draft(published.model_copy(update={"title": "Warm"}), 1)
discarded = store.discard_draft(flow_id)
on_disk = json.loads((store.root / flow_id / "flow.json").read_text())
assert (on_disk["id"], on_disk["name"]) == (flow_id, "heating")
assert (discarded.id, discarded.name) == (flow_id, "heating")
def test_a_document_from_before_ids_reads_with_its_directory_as_id(store: FlowStore):
(store.root / "heating").mkdir()
(store.root / "heating" / "flow.json").write_text('{"name": "heating"}')
(store.root / DASHBOARD_DIR / "home").mkdir(parents=True)
(store.root / DASHBOARD_DIR / "home" / "dashboard.json").write_text(
'{"name": "home"}'
)
assert store.read_flow("heating").id == "heating"
assert DashboardStore(store).read("home").id == "home"
# And a save of it, without an id, is the same flow and not a new one.
assert store.write_draft(FlowDef(name="heating", title="Warm"), 1).version == 2
def test_a_save_neither_creates_nor_changes_an_id_or_a_key(store: FlowStore):
with pytest.raises(FlowNotFound):
store.write_draft(FlowDef(name="nobody"), 0)
with pytest.raises(DashboardNotFound):
DashboardStore(store).write_draft(default_dashboard("nobody"), 0)
store.write_flow(FlowDef(id="x_heating", name="heating"))
with pytest.raises(IdentityChange):
store.write_draft(FlowDef(id="x_heating", name="warmth"), 1)
with pytest.raises(IdentityChange):
store.write_flow(FlowDef(id="x_heating", name="warmth"))
dashboards = DashboardStore(store)
dashboards.write(DashboardDef(id="x_home", name="home"))
with pytest.raises(IdentityChange):
dashboards.write_draft(DashboardDef(id="x_home", name="house"), 1)
assert dashboards.read("x_home").name == "home"
def test_a_key_belongs_to_one_flow_drafts_included(store: FlowStore):
"""A draft nobody published holds its key, and a delete lets it go."""
first = store.create(FlowDef(name="heating"))
assert store.key_index() == {"heating": first.id}
with pytest.raises(FlowExists):
store.create(FlowDef(name="heating"))
with pytest.raises(FlowExists):
store.create(FlowDef(name="heating"), "x_heating")
with pytest.raises(FlowExists):
store.write_flow(FlowDef(id="y_heating", name="heating"))
store.delete_flow(first.id)
second = store.create(FlowDef(name="heating"))
assert second.id != first.id
assert store.key_index() == {"heating": second.id}
def test_a_key_belongs_to_one_dashboard_drafts_included(store: FlowStore):
dashboards = DashboardStore(store)
first = dashboards.create(default_dashboard("home"))
assert ISSUED_ID.fullmatch(first.id) and first.id.startswith("d_")
# Its own id space: a flow may share the key.
store.create(FlowDef(name="home"))
with pytest.raises(DashboardExists):
dashboards.create(default_dashboard("home"))
with pytest.raises(DashboardExists):
dashboards.write(DashboardDef(id="y_home", name="home"))
dashboards.delete(first.id)
assert dashboards.create(default_dashboard("home"), "home").id == "home"
def test_a_pin_is_an_id_nothing_holds(store: FlowStore):
store.create(FlowDef(name="heating"), "x_heating")
with pytest.raises(IdTaken):
store.create(FlowDef(name="cooling"), "x_heating")
# Another flow's key would make naming either ambiguous.
with pytest.raises(IdTaken):
store.create(FlowDef(name="cooling"), "heating")
# And a directory left behind would hand the new flow what is in it.
(store.root / "leftover" / "nodes").mkdir(parents=True)
with pytest.raises(IdTaken):
store.create(FlowDef(name="leftover"), "leftover")
dashboards = DashboardStore(store)
dashboards.create(default_dashboard("home"), "x_home")
with pytest.raises(IdTaken):
dashboards.create(default_dashboard("house"), "x_home")
def test_an_issued_id_avoids_whatever_history_or_a_directory_holds(
store: FlowStore, graves: Graves, monkeypatch: pytest.MonkeyPatch
):
candidates = iter(["f_aaaaaaaa", "f_bbbbbbbb", "f_cccccccc", "f_dddddddd"])
monkeypatch.setattr(
"fluksio.flow.store.new_id",
lambda kind, taken: next(c for c in candidates if not taken(c)),
)
graves.bury("f_aaaaaaaa", "gone")
graves.history.add("f_bbbbbbbb")
(store.root / "f_cccccccc").mkdir()
assert store.create(FlowDef(name="heating")).id == "f_dddddddd"
def test_a_deleted_flow_is_buried_before_its_files_go(
store: FlowStore, graves: Graves, monkeypatch: pytest.MonkeyPatch
):
flow_id = store.create(FlowDef(name="heating")).id
def crash(path: Path) -> None:
raise OSError("disk gone")
monkeypatch.setattr("fluksio.flow.store.shutil.rmtree", crash)
with pytest.raises(OSError):
store.delete_flow(flow_id)
# The name is safe, and the live document still wins over it.
assert graves.stones == {flow_id: "heating"}
assert store.read_flow(flow_id, draft=True).name == "heating"
monkeypatch.undo()
assert store.delete_flow(flow_id) == "heating"
assert graves.stones == {flow_id: "heating"}
assert not store.exists(flow_id)
def test_a_delete_that_stops_after_the_files_has_kept_the_name(
store: FlowStore, graves: Graves, monkeypatch: pytest.MonkeyPatch
):
flow_id = store.create(FlowDef(name="heating")).id
def killed(message: str) -> None:
raise OSError("killed before the commit")
monkeypatch.setattr(store, "_commit", killed)
with pytest.raises(OSError):
store.delete_flow(flow_id)
monkeypatch.undo()
assert not store.exists(flow_id)
assert graves.stones == {flow_id: "heating"}
with pytest.raises(FlowNotFound):
store.delete_flow(flow_id)
def test_a_pinned_restore_takes_up_its_history_under_its_own_key(
store: FlowStore, graves: Graves
):
flow_id = store.create(FlowDef(name="heating")).id
store.delete_flow(flow_id)
with pytest.raises(RecordedKey):
store.create(FlowDef(name="warmth"), flow_id)
assert graves.stones == {flow_id: "heating"}
restored = store.create(FlowDef(name="heating"), flow_id)
assert restored.id == flow_id and graves.stones == {}
# History from before tombstones is filed under an id that was its key.
graves.history.add("legacy")
with pytest.raises(RecordedKey):
store.create(FlowDef(name="other"), "legacy")
assert store.create(FlowDef(name="legacy"), "legacy").id == "legacy"
def test_a_key_reused_after_a_delete_starts_a_new_history(
store: FlowStore, graves: Graves
):
old = store.create(FlowDef(name="heating")).id
store.delete_flow(old)
new = store.create(FlowDef(name="heating")).id
assert new != old
assert graves.stones == {old: "heating"}
def test_two_creates_of_one_key_make_one_flow(store: FlowStore):
def create(_: int) -> str:
try:
return store.create(FlowDef(name="heating")).id
except FlowExists:
return ""
with ThreadPoolExecutor(max_workers=8) as pool:
made = [flow_id for flow_id in pool.map(create, range(8)) if flow_id]
assert len(made) == 1
assert store.list_flows() == made
def test_two_results_dashboards_of_one_flow_make_one(store: FlowStore):
dashboards = DashboardStore(store)
flow = FlowDef(id="x_lab", name="lab", mode="batch")
def generate(_: int) -> str:
try:
return dashboards.create(results_dashboard(flow), "", True).id
except DashboardExists:
return ""
with ThreadPoolExecutor(max_workers=4) as pool:
made = [board for board in pool.map(generate, range(4)) if board]
assert len(made) == 1
assert dashboards.read(made[0]).name == "lab_results"
def test_a_discard_leaves_no_old_draft_version_to_match(store: FlowStore):
flow_id = store.create(FlowDef(name="heating")).id
published = store.publish_flow(flow_id, 1)
held = store.write_draft(published.model_copy(update={"title": "A"}), 1)
store.write_draft(held.model_copy(update={"title": "B"}), held.version)
restored = store.discard_draft(flow_id)
# Above every draft version handed out, so an editor holding one is stale.
assert restored.version == 4 and restored.name == "heating"
with pytest.raises(StaleVersion):
store.write_draft(held.model_copy(update={"title": "late"}), held.version)
assert store.write_draft(restored.model_copy(update={"title": "C"}), 4).version == 5
dashboards = DashboardStore(store)
board = dashboards.create(default_dashboard("home"))
dashboards.publish(board.id, 1)
dashboards.write_draft(dashboards.read(board.id).model_copy(), 1)
assert dashboards.discard_draft(board.id).version == 3
# A direct write goes above a draft too.
dashboards.write_draft(dashboards.read(board.id).model_copy(), 3)
assert dashboards.write(dashboards.read(board.id)).version == 5
def test_a_copy_gets_an_id_of_its_own_and_a_key_nobody_holds(store: FlowStore):
store.write_flow(FlowDef(name="heating"))
store.create(FlowDef(name="heating_copy"))
dashboards = DashboardStore(store)
dashboards.write(default_dashboard("home"))
dashboards.create(default_dashboard("home_copy"))
copy = store.duplicate_flow("heating", busy=lambda key: key == "heating_copy_2")
board = dashboards.duplicate("home")
# `_copy_2` still had work behind it, so the copy went past it.
assert copy.name == "heating_copy_3" and ISSUED_ID.fullmatch(copy.id)
assert board.name == "home_copy_2" and ISSUED_ID.fullmatch(board.id)
def test_a_new_key_shaped_like_an_issued_id_is_refused(store: FlowStore):
with pytest.raises(ReservedKey):
store.create(FlowDef(name="f_k3q7m2xa"))
with pytest.raises(ReservedKey):
DashboardStore(store).create(default_dashboard("d_k3q7m2xa"))
assert store.create(FlowDef(name="f_k3q7m2xa_lab")).name == "f_k3q7m2xa_lab"
def test_an_existing_key_shaped_like_an_issued_id_stays_saveable(store: FlowStore):
(store.root / "f_k3q7m2xa").mkdir()
(store.root / "f_k3q7m2xa" / "flow.json").write_text('{"name": "f_k3q7m2xa"}')
saved = store.write_draft(FlowDef(name="f_k3q7m2xa", title="Old"), 1)
assert (saved.id, saved.title) == ("f_k3q7m2xa", "Old")
def test_a_new_id_is_random_and_avoids_whatever_is_taken():
asked: list[str] = []
def taken(candidate: str) -> bool:
asked.append(candidate)
return len(asked) < 4
issued = new_id("d", taken)
assert issued == asked[-1] and len(asked) == 4
assert all(ISSUED_ID.fullmatch(c) and c.startswith("d_") for c in asked)
fresh = {new_id("f", lambda _: False) for _ in range(50)}
assert len(fresh) == 50
assert all(ISSUED_ID.fullmatch(c) and c.startswith("f_") for c in fresh)
# -----------------------------------------------------------------------------
# The migration
# -----------------------------------------------------------------------------
def put(root: Path, rel: str, data: dict | str) -> None:
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(data if isinstance(data, str) else json.dumps(data))
def git(root: Path, *args: str) -> str:
return subprocess.run(
["git", "-C", str(root), *args], capture_output=True, text=True, check=True
).stdout
#: Every document a store from before ids holds, by path. The id each gets.
GOLDEN = {
# Published and a draft, and a field this version does not model.
"heating/flow.json": {
"name": "heating",
"version": 3,
"later": {"kept": True},
"nodes": [{"id": "sensor", "provides": [{"name": "temp"}]}],
},
"heating/flow.draft.json": {
"name": "heating",
"version": 5,
"nodes": [{"id": "reader", "requires": [{"name": "solar.watts"}]}],
},
# Only a draft.
"lab/flow.draft.json": {"name": "lab", "version": 1},
# Stopped, which is a file beside it that has no id to get.
"stopped/flow.json": {"name": "stopped"},
# A dashboard and a flow share a key: separate id spaces.
"home/flow.json": {"name": "home", "version": 2},
f"{DASHBOARD_DIR}/home/dashboard.json": {"name": "home", "version": 4},
f"{DASHBOARD_DIR}/home/dashboard.draft.json": {
"name": "home",
"version": 4,
"widgets": [],
},
# Pages and sections, as a dashboard was written before it was one grid.
f"{DASHBOARD_DIR}/legacy/dashboard.json": {
"name": "legacy",
"version": 2,
"pages": [
{
"sections": [
{"widgets": [{"id": "t", "type": "stat", "config": {}}]},
]
}
],
},
f"{DASHBOARD_DIR}/draft_only/dashboard.draft.json": {"name": "draft_only"},
}
#: What is not a flow or a dashboard, and so is never touched.
BYSTANDERS = {
"stopped/runtime.json": '{"enabled": false}',
# A device called "flow", which `*/flow.json` would otherwise find.
"_devices/flow.json": '{"id": "flow", "title": "Hall"}',
"_lib/shared.py": "def process():\n return {}\n",
"heating/nodes/sensor.py": "def process():\n return {'temp': 1}\n",
}
#: The versions the golden store moves to: past published and draft alike.
VERSIONS = {
"heating/flow.json": 6,
"heating/flow.draft.json": 6,
"lab/flow.draft.json": 2,
"stopped/flow.json": 2,
"home/flow.json": 3,
f"{DASHBOARD_DIR}/home/dashboard.json": 5,
f"{DASHBOARD_DIR}/home/dashboard.draft.json": 5,
f"{DASHBOARD_DIR}/legacy/dashboard.json": 3,
f"{DASHBOARD_DIR}/draft_only/dashboard.draft.json": 2,
}
def golden(root: Path, extra: dict | None = None) -> Path:
"""A store in the shape it had before ids, committed."""
FlowStore(root)
for rel, data in {**GOLDEN, **BYSTANDERS, **(extra or {})}.items():
put(root, rel, data)
git(root, "add", "-A")
git(root, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "Old")
return root
def snapshot(root: Path) -> dict[str, bytes]:
"""Every file, git's own included, so a write anywhere shows."""
return {
str(p.relative_to(root)): p.read_bytes()
for p in sorted(root.rglob("*"))
if p.is_file()
}
def documents(root: Path) -> dict[str, dict]:
return {rel: json.loads((root / rel).read_text()) for rel in GOLDEN}
def assert_migrated(root: Path) -> None:
for rel, data in documents(root).items():
before = GOLDEN[rel]
assert data["id"] == Path(rel).parent.name, rel
assert data["version"] == VERSIONS[rel], rel
# Nothing else about it changed, the fields nobody models included.
assert {k: v for k, v in data.items() if k not in ("id", "version")} == {
k: v for k, v in before.items() if k != "version"
}, rel
for rel, text in BYSTANDERS.items():
assert (root / rel).read_text() == text, rel
assert not (root / ".git" / ".assign-ids.json").exists()
def test_a_store_from_before_ids_gets_each_key_as_id(tmp_path: Path, caplog):
root = golden(tmp_path / "flows")
commits = git(root, "rev-list", "--count", "HEAD")
with caplog.at_level(logging.WARNING, logger=identity.__name__):
changed = assign_ids(root)
assert changed == sorted(GOLDEN)
assert_migrated(root)
assert "heating/flow.json" in caplog.text
# One commit of those documents and nothing else.
assert git(root, "log", "-1", "--format=%s").strip() == COMMIT_MESSAGE
assert sorted(git(root, "show", "--name-only", "--format=", "HEAD").split()) == (
sorted(GOLDEN)
)
assert int(git(root, "rev-list", "--count", "HEAD")) == int(commits) + 1
# What the stores read is what they read before, but for the version.
flows, dashboards = FlowStore(root), DashboardStore(FlowStore(root))
assert flows.read_flow("heating", draft=True).nodes[0].id == "reader"
assert flows.key_index() == {n: n for n in ("heating", "home", "lab", "stopped")}
assert [w.id for w in dashboards.read("legacy").widgets] == ["t"]
assert dashboards.key_index() == {n: n for n in ("draft_only", "home", "legacy")}
# Once is all: a second start changes nothing and says nothing.
before = snapshot(root)
caplog.clear()
with caplog.at_level(logging.WARNING, logger=identity.__name__):
assert assign_ids(root) == []
assert snapshot(root) == before
assert caplog.text == ""
@pytest.mark.parametrize("survives", range(len(GOLDEN) + 1))
def test_a_run_interrupted_part_way_is_finished_by_the_next(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, survives: int
):
"""The note of the versions is the first write, so it is always there."""
root = golden(tmp_path / "flows")
real = identity._replace
writes: list[Path] = []
def crash(path: Path, text: str) -> None:
if len(writes) == survives:
# Half of the temporary file, and never the replace.
path.with_name(f".{path.name}.tmp").write_text(text[:20])
raise OSError("power cut")
writes.append(path)
real(path, text)
monkeypatch.setattr(identity, "_replace", crash)
with pytest.raises(OSError):
assign_ids(root)
monkeypatch.setattr(identity, "_replace", real)
assert assign_ids(root) == sorted(GOLDEN)
assert_migrated(root)
assert git(root, "log", "-1", "--format=%s").strip() == COMMIT_MESSAGE
assert git(root, "status", "--porcelain") == ""
assert not list(root.rglob("*.tmp"))
def test_a_run_interrupted_before_its_commit_is_committed_by_the_next(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
"""Every document has its id by then, and the note is what is left."""
root = golden(tmp_path / "flows")
def crash(*_: object) -> bool:
raise OSError("power cut")
monkeypatch.setattr(identity, "_commit", crash)
with pytest.raises(OSError):
assign_ids(root)
monkeypatch.undo()
assert git(root, "log", "-1", "--format=%s").strip() == "Old"
assert assign_ids(root) == sorted(GOLDEN)
assert_migrated(root)
assert git(root, "log", "-1", "--format=%s").strip() == COMMIT_MESSAGE
assert assign_ids(root) == []
def test_documents_that_have_their_id_keep_their_version(tmp_path: Path):
"""A pair half given its id moves the other half past both."""
root = golden(
tmp_path / "flows",
{
"heating/flow.json": {"id": "heating", "name": "heating", "version": 3},
# An older engine saved this after the published one got its id.
"heating/flow.draft.json": {"name": "heating", "version": 7},
"lab/flow.draft.json": {"id": "lab", "name": "lab", "version": 1},
# Moved apart by a rename, with its id: nothing to do with it.
"warmth/flow.json": {"id": "warmth", "name": "radiators"},
},
)
changed = assign_ids(root)
assert "heating/flow.json" not in changed and "lab/flow.draft.json" not in changed
assert "warmth/flow.json" not in changed
assert json.loads((root / "heating/flow.json").read_text())["version"] == 3
assert json.loads((root / "heating/flow.draft.json").read_text())["version"] == 8
assert json.loads((root / "lab/flow.draft.json").read_text())["version"] == 1
@pytest.mark.parametrize(
("extra", "named"),
[
# Which one the flow is cannot be told: the engine runs it as `warmth`.
({"heating/flow.json": {"name": "warmth"}}, "heating/flow.json"),
({"heating/flow.json": {"id": "warmth", "name": "heating"}}, "'warmth'"),
(
{
"pair/flow.json": {"id": "pair", "name": "one"},
"pair/flow.draft.json": {"id": "pair", "name": "two"},
},
"'one' and 'two'",
),
(
{
"a/flow.json": {"id": "a", "name": "same"},
"c/flow.draft.json": {"id": "c", "name": "same"},
},
"'a' and 'c' share the key 'same'",
),
({"lab/flow.draft.json": "{not json"}, "lab/flow.draft.json"),
({f"{DASHBOARD_DIR}/home/dashboard.json": {"name": "hall"}}, "'hall'"),
],
)
def test_a_store_in_doubt_is_refused_and_left_as_it_was(
tmp_path: Path, extra: dict, named: str
):
root = golden(tmp_path / "flows", extra)
before = snapshot(root)
with pytest.raises(IdsRefused) as refused:
assign_ids(root)
assert named in str(refused.value)
assert f"fluksio store migrate --dry-run {root}" in str(refused.value)
assert snapshot(root) == before
def test_uncommitted_changes_beside_it_stay_uncommitted(tmp_path: Path):
root = golden(tmp_path / "flows")
(root / "_lib" / "shared.py").write_text("edited behind the API\n")
(root / "notes.txt").write_text("someone's notes\n")
git(root, "add", "notes.txt")
assign_ids(root)
assert sorted(git(root, "show", "--name-only", "--format=", "HEAD").split()) == (
sorted(GOLDEN)
)
assert git(root, "status", "--porcelain").splitlines() == [
" M _lib/shared.py",
"A notes.txt",
]
def test_a_store_without_a_repository(tmp_path: Path):
root = tmp_path / "flows"
for rel, data in {**GOLDEN, **BYSTANDERS}.items():
put(root, rel, data)
assert assign_ids(root) == sorted(GOLDEN)
assert_migrated(root)
assert not (root / ".assign-ids.json").exists()
assert not (root / ".git").exists()
def test_a_machine_without_git(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
root = golden(tmp_path / "flows")
real = subprocess.run
def no_git(cmd, *args, **kwargs):
if cmd and cmd[0] == "git":
raise FileNotFoundError(2, "No such file or directory", "git")
return real(cmd, *args, **kwargs)
monkeypatch.setattr(identity.subprocess, "run", no_git)
assert assign_ids(root) == sorted(GOLDEN)
assert_migrated(root)
# -----------------------------------------------------------------------------
# `fluksio store`
# -----------------------------------------------------------------------------
def test_the_dry_run_changes_no_bytes(tmp_path: Path, capsys):
root = golden(tmp_path / "flows")
before = snapshot(root)
assert cli.main(["store", "migrate", "--dry-run", str(root)]) == 0
out = capsys.readouterr().out
assert "4 flows, 3 dashboards" in out
assert "heating/flow.draft.json (version 6)" in out
assert snapshot(root) == before
def test_the_dry_run_creates_no_repository_and_names_what_it_refuses(
tmp_path: Path, capsys
):
root = tmp_path / "flows"
for rel, data in {**GOLDEN, "odd/flow.json": {"name": "even"}}.items():
put(root, rel, data)
before = snapshot(root)
assert cli.main(["store", "migrate", "--dry-run", str(root)]) == 1
assert "odd/flow.json: its name 'even' is not its directory 'odd'" in (
capsys.readouterr().out
)
assert snapshot(root) == before
assert not (root / ".git").exists()
def test_ls_lists_ids_keys_and_kinds(tmp_path: Path, capsys):
root = golden(
tmp_path / "flows", {"warmth/flow.json": {"id": "warmth", "name": "radiators"}}
)
assert cli.main(["store", "ls", str(root)]) == 0
rows = [line.split() for line in capsys.readouterr().out.splitlines()]
assert rows[0] == ["ID", "KEY", "KIND"]
assert ["home", "home", "dashboard"] in rows and ["home", "home", "flow"] in rows
assert ["warmth", "radiators", "flow"] in rows
def test_what_a_create_takes_is_a_document_but_for_its_version():
"""`FlowCreate` lists the fields again; one added to `FlowDef` belongs there."""
assert set(FlowCreate.model_fields) == set(FlowDef.model_fields) - {"version"}
with pytest.raises(ValueError):
FlowCreate(name="Not A Key")