"""持久化与恢复测试:检查点恢复 + 运行历史管理。 覆盖: * run(resume_from=RunReport) 跳过已成功任务。 * run(resume_from=Path) 从 JSON 文件恢复。 * 恢复后 FAILED/SKIPPED 任务被重新执行。 * 恢复后下游任务能获取上游恢复结果。 * RunHistory: save/load/list/latest/delete。 * RunHistory: __len__ / __contains__。 """ from __future__ import annotations from pathlib import Path import pytest import pyflowx as px from pyflowx import Graph, RunHistory, TaskSpec def test_resume_skips_successful_tasks() -> None: """resume_from 中的 SUCCESS 任务被跳过,不重新执行。""" call_count: dict[str, int] = {"a": 0, "b": 0} def make_a(): def _a() -> int: call_count["a"] += 1 return 1 return _a def make_b(): def _b(a: int) -> int: call_count["b"] += 1 return a + 1 return _b graph = Graph.from_specs( [ TaskSpec("a", fn=make_a()), TaskSpec("b", fn=make_b(), depends_on=("a",)), ] ) # 第一次运行 report1 = px.run(graph, strategy="sequential") assert call_count == {"a": 1, "b": 1} # 第二次运行,从 report1 恢复 report2 = px.run(graph, strategy="sequential", resume_from=report1) assert call_count == {"a": 1, "b": 1} # 没有重新执行 assert report2["a"] == 1 assert report2["b"] == 2 def test_resume_from_json_path(tmp_path: Path) -> None: """resume_from 接受 JSON 文件路径。""" graph = Graph.from_specs([TaskSpec("a", fn=lambda: 42)]) report1 = px.run(graph, strategy="sequential") json_path = tmp_path / "report.json" json_path.write_text(report1.to_json(), encoding="utf-8") call_count = 0 def _a() -> int: nonlocal call_count call_count += 1 return 42 graph2 = Graph.from_specs([TaskSpec("a", fn=_a)]) report2 = px.run(graph2, strategy="sequential", resume_from=json_path) assert call_count == 0 # 从检查点恢复,未重新执行 assert report2["a"] == 42 def test_resume_reruns_failed_tasks() -> None: """resume_from 中 FAILED 的任务被重新执行。""" state: dict[str, bool] = {"should_fail": True} def _a() -> str: if state["should_fail"]: raise RuntimeError("第一次失败") return "success" graph = Graph.from_specs([TaskSpec("a", fn=_a)]) # 第一次运行失败 with pytest.raises(px.TaskFailedError): px.run(graph, strategy="sequential") # 修复问题 state["should_fail"] = False # 第二次运行:从头开始(无 resume_from) report2 = px.run(graph, strategy="sequential") assert report2["a"] == "success" def test_resume_downstream_gets_restored_result() -> None: """恢复后下游任务能获取上游恢复结果。 a 第一次成功,b 第一次失败;第二次 a 被恢复,b 重新执行获取 a 的恢复值。 """ state: dict[str, bool] = {"b_fail": True} b_call_count = 0 def _a() -> int: return 10 def _b(a: int) -> int: nonlocal b_call_count b_call_count += 1 if state["b_fail"]: raise RuntimeError("b 第一次失败") return a * 2 graph = Graph.from_specs( [ TaskSpec("a", fn=_a), TaskSpec("b", fn=_b, depends_on=("a",)), ] ) # 第一次运行:a 成功,b 失败 with pytest.raises(px.TaskFailedError): px.run(graph, strategy="sequential") # 修复 b state["b_fail"] = False b_call_count = 0 # 重置计数 # 第二次运行:a 被恢复(值=10),b 重新执行获取 a=10 # 需要构造一个只含 a 成功结果的 prev_report prev_report = px.run( Graph.from_specs([TaskSpec("a", fn=_a)]), strategy="sequential", ) report2 = px.run(graph, strategy="sequential", resume_from=prev_report) assert report2["a"] == 10 # a 被恢复 assert b_call_count == 1 # b 被重新执行一次 assert report2["b"] == 20 # b 获取 a=10 def test_resume_with_dependency_strategy() -> None: """dependency 策略下 resume_from 也正常工作。""" graph = Graph.from_specs( [ TaskSpec("a", fn=lambda: 1), TaskSpec("b", fn=lambda a: a + 1, depends_on=("a",)), ] ) report1 = px.run(graph, strategy="dependency") assert report1["a"] == 1 assert report1["b"] == 2 call_count = 0 def _a() -> int: nonlocal call_count call_count += 1 return 1 graph2 = Graph.from_specs( [ TaskSpec("a", fn=_a), TaskSpec("b", fn=lambda a: a + 1, depends_on=("a",)), ] ) report2 = px.run(graph2, strategy="dependency", resume_from=report1) assert call_count == 0 # a 被恢复 assert report2["a"] == 1 assert report2["b"] == 2 def test_resume_ignores_tasks_not_in_graph() -> None: """resume_from 中不在当前图里的任务被忽略。""" graph1 = Graph.from_specs([TaskSpec("a", fn=lambda: 1), TaskSpec("b", fn=lambda: 2)]) report1 = px.run(graph1, strategy="sequential") # 新图只有 a,没有 b graph2 = Graph.from_specs([TaskSpec("a", fn=lambda: 999)]) report2 = px.run(graph2, strategy="sequential", resume_from=report1) assert "b" not in report2.results assert report2["a"] == 1 # a 被恢复 class TestRunHistory: """RunHistory 测试。""" def test_save_and_load(self, tmp_path: Path) -> None: """保存后能正确加载。""" history = RunHistory(tmp_path / "runs") graph = Graph.from_specs([TaskSpec("a", fn=lambda: 42)]) report = px.run(graph, strategy="sequential") path = history.save(report) assert path.exists() loaded = history.load(report.run_id) assert loaded["a"] == 42 assert loaded.run_id == report.run_id def test_list_runs(self, tmp_path: Path) -> None: """list_runs 返回全部 run_id。""" history = RunHistory(tmp_path / "runs") for i in range(3): graph = Graph.from_specs([TaskSpec(f"t{i}", fn=lambda i=i: i)]) report = px.run(graph, strategy="sequential") history.save(report) runs = history.list_runs() assert len(runs) == 3 def test_latest(self, tmp_path: Path) -> None: """latest 返回最近保存的报告。""" history = RunHistory(tmp_path / "runs") assert history.latest() is None graph1 = Graph.from_specs([TaskSpec("a", fn=lambda: 1)]) r1 = px.run(graph1, strategy="sequential") history.save(r1) graph2 = Graph.from_specs([TaskSpec("b", fn=lambda: 2)]) r2 = px.run(graph2, strategy="sequential") history.save(r2) latest = history.latest() assert latest is not None assert latest.run_id == r2.run_id def test_delete(self, tmp_path: Path) -> None: """delete 删除指定报告。""" history = RunHistory(tmp_path / "runs") graph = Graph.from_specs([TaskSpec("a", fn=lambda: 1)]) report = px.run(graph, strategy="sequential") history.save(report) assert report.run_id in history assert history.delete(report.run_id) assert report.run_id not in history assert not history.delete(report.run_id) # 再删返回 False def test_load_not_found(self, tmp_path: Path) -> None: """加载不存在的 run_id 抛 FileNotFoundError。""" history = RunHistory(tmp_path / "runs") with pytest.raises(FileNotFoundError, match="不存在"): history.load("nonexistent") def test_len(self, tmp_path: Path) -> None: """__len__ 返回存储数量。""" history = RunHistory(tmp_path / "runs") assert len(history) == 0 graph = Graph.from_specs([TaskSpec("a", fn=lambda: 1)]) report = px.run(graph, strategy="sequential") history.save(report) assert len(history) == 1 def test_contains(self, tmp_path: Path) -> None: """__contains__ 检查 run_id 是否存在。""" history = RunHistory(tmp_path / "runs") assert "abc" not in history graph = Graph.from_specs([TaskSpec("a", fn=lambda: 1)]) report = px.run(graph, strategy="sequential") history.save(report) assert report.run_id in history def test_creates_dir(self, tmp_path: Path) -> None: """目录不存在时自动创建。""" dir = tmp_path / "nested" / "runs" assert not dir.exists() RunHistory(dir) assert dir.exists() def test_resume_from_history(self, tmp_path: Path) -> None: """从 RunHistory 加载报告用于 resume_from。""" history = RunHistory(tmp_path / "runs") graph = Graph.from_specs([TaskSpec("a", fn=lambda: 42)]) report1 = px.run(graph, strategy="sequential") history.save(report1) call_count = 0 def _a() -> int: nonlocal call_count call_count += 1 return 999 graph2 = Graph.from_specs([TaskSpec("a", fn=_a)]) prev = history.latest() assert prev is not None report2 = px.run(graph2, strategy="sequential", resume_from=prev) assert call_count == 0 assert report2["a"] == 42