Files
pyflowx/tests/test_diagnostics.py
T

536 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""失败诊断模块测试。"""
from __future__ import annotations
from datetime import datetime
from typing import Any
import pytest
import pyflowx as px
from pyflowx.diagnostics import DiagnosticReport, diagnose
from pyflowx.task import TaskResult, TaskSpec, TaskStatus
def _fn() -> None:
return None
def _make_result(
name: str,
status: TaskStatus = TaskStatus.SUCCESS,
value: Any = None,
error: BaseException | None = None,
depends_on: tuple[str, ...] = (),
soft_depends_on: tuple[str, ...] = (),
reason: str | None = None,
attempts: int = 1,
) -> TaskResult[Any]:
"""构造测试用 TaskResult。"""
spec: TaskSpec[Any] = TaskSpec[Any](name, _fn, depends_on=depends_on, soft_depends_on=soft_depends_on)
start = datetime(2024, 1, 1, 0, 0, 0)
end = datetime(2024, 1, 1, 0, 0, 1) if status != TaskStatus.PENDING else None
return TaskResult[Any](
spec=spec,
status=status,
value=value,
error=error,
attempts=attempts,
started_at=start,
finished_at=end,
reason=reason,
)
def _make_failed_report(
failures: dict[str, BaseException],
deps: dict[str, tuple[str, ...]] | None = None,
extra_results: dict[str, TaskResult[Any]] | None = None,
) -> px.RunReport:
"""构造包含指定失败任务的 RunReport。
参数
----
failures:
任务名 -> 异常对象。这些任务状态为 FAILED。
deps:
任务名 -> 硬依赖列表。仅对失败任务和额外结果中未指定的任务生效。
extra_results:
额外的 TaskResult(如 SUCCESS/SKIPPED 任务)。
"""
deps = deps or {}
extra_results = extra_results or {}
report = px.RunReport(success=False)
for name, err in failures.items():
report.results[name] = _make_result(
name,
status=TaskStatus.FAILED,
error=err,
depends_on=deps.get(name, ()),
)
for name, result in extra_results.items():
if name not in report.results:
report.results[name] = result
return report
# ---------------------------------------------------------------------- #
# diagnose 基础行为
# ---------------------------------------------------------------------- #
class TestDiagnoseBasic:
"""diagnose() 基础行为测试。"""
def test_diagnose_success_returns_none(self) -> None:
"""成功报告应返回 None。"""
report = px.RunReport(success=True)
assert diagnose(report) is None
def test_diagnose_no_failures_returns_report(self) -> None:
"""success=False 但无 FAILED 任务时仍返回报告(带提示)。"""
report = px.RunReport(success=False)
report.results["a"] = _make_result("a", status=TaskStatus.SKIPPED, reason="手动跳过")
diag = diagnose(report)
assert diag is not None
assert diag.failed_tasks == []
assert any("无失败任务" in h for h in diag.hints)
def test_diagnose_single_failure(self) -> None:
"""单个失败任务:根因即自身,链长度为 1。"""
report = _make_failed_report({"a": ValueError("bad value")})
diag = diagnose(report)
assert diag is not None
assert diag.failed_tasks == ["a"]
assert diag.root_causes == ["a"]
assert len(diag.dependency_chains) == 1
assert diag.dependency_chains[0].failed_task == "a"
assert diag.dependency_chains[0].root_cause == "a"
assert diag.dependency_chains[0].chain == ["a"]
assert diag.dependency_chains[0].broken_at == "a"
def test_diagnose_returns_diagnostic_report_type(self) -> None:
"""返回值应为 DiagnosticReport 类型。"""
report = _make_failed_report({"a": ValueError("x")})
diag = diagnose(report)
assert isinstance(diag, DiagnosticReport)
# ---------------------------------------------------------------------- #
# 依赖链回溯
# ---------------------------------------------------------------------- #
class TestDependencyChainTracing:
"""依赖链回溯测试。"""
def test_chain_single_dependency(self) -> None:
"""a -> b 链:a 失败导致 b 失败时,根因为 a。"""
report = _make_failed_report(
failures={
"a": FileNotFoundError("/tmp/missing.txt"),
"b": RuntimeError("a failed"),
},
deps={"b": ("a",)},
)
diag = diagnose(report)
assert diag is not None
assert diag.root_causes == ["a"]
# b 的链应为 a -> b
b_chain = next(dc for dc in diag.dependency_chains if dc.failed_task == "b")
assert b_chain.root_cause == "a"
assert b_chain.chain == ["a", "b"]
assert b_chain.broken_at == "a"
def test_chain_diamond_dependency(self) -> None:
"""菱形依赖:a -> b, a -> c, b -> d, c -> da 失败导致 b 失败,进而导致 d 失败。"""
report = _make_failed_report(
failures={
"a": ImportError("missing module"),
"b": RuntimeError("a failed"),
"d": RuntimeError("b failed"),
},
deps={"b": ("a",), "c": ("a",), "d": ("b", "c")},
extra_results={
"c": _make_result("c", status=TaskStatus.SUCCESS, depends_on=("a",)),
},
)
diag = diagnose(report)
assert diag is not None
assert diag.root_causes == ["a"]
# d 的链应从 a 开始
d_chain = next(dc for dc in diag.dependency_chains if dc.failed_task == "d")
assert d_chain.root_cause == "a"
assert d_chain.chain[0] == "a"
assert d_chain.chain[-1] == "d"
assert d_chain.broken_at == "a"
def test_chain_multiple_root_causes(self) -> None:
"""多个独立根因:a 和 x 都失败但无依赖关系。"""
report = _make_failed_report(
failures={
"a": ValueError("a error"),
"x": KeyError("x error"),
},
)
diag = diagnose(report)
assert diag is not None
assert set(diag.root_causes) == {"a", "x"}
def test_chain_cascade_failure(self) -> None:
"""级联失败:a -> b -> ca 失败导致 b、c 都失败。"""
report = _make_failed_report(
failures={
"a": ValueError("root"),
"b": RuntimeError("a failed"),
"c": RuntimeError("b failed"),
},
deps={"b": ("a",), "c": ("b",)},
)
diag = diagnose(report)
assert diag is not None
assert diag.root_causes == ["a"]
# c 的链应为 a -> b -> c
c_chain = next(dc for dc in diag.dependency_chains if dc.failed_task == "c")
assert c_chain.chain == ["a", "b", "c"]
assert c_chain.root_cause == "a"
def test_chain_with_successful_dependency(self) -> None:
"""失败任务的依赖若成功,则失败任务自身为根因。"""
report = _make_failed_report(
failures={"b": ValueError("b error")},
deps={"b": ("a",)},
extra_results={
"a": _make_result("a", status=TaskStatus.SUCCESS),
},
)
diag = diagnose(report)
assert diag is not None
assert diag.root_causes == ["b"]
b_chain = next(dc for dc in diag.dependency_chains if dc.failed_task == "b")
assert b_chain.root_cause == "b"
assert b_chain.chain == ["b"]
def test_chain_with_missing_dependency_in_results(self) -> None:
"""失败任务依赖不在 results 中(如 only= 过滤)时,自身为根因。"""
report = _make_failed_report(
failures={"b": ValueError("b error")},
deps={"b": ("a",)}, # a 不在 results 中
)
diag = diagnose(report)
assert diag is not None
assert diag.root_causes == ["b"]
b_chain = next(dc for dc in diag.dependency_chains if dc.failed_task == "b")
assert b_chain.root_cause == "b"
def test_chain_with_skipped_dependency(self) -> None:
"""失败任务的依赖为 SKIPPED 时,自身为根因(SKIPPED 非 FAILED)。"""
report = _make_failed_report(
failures={"b": ValueError("b error")},
deps={"b": ("a",)},
extra_results={
"a": _make_result("a", status=TaskStatus.SKIPPED, reason="条件不满足"),
},
)
diag = diagnose(report)
assert diag is not None
assert diag.root_causes == ["b"]
def test_chain_circular_dependency(self) -> None:
"""循环依赖:a -> b -> adiagnose 不依赖 Graph 校验应安全处理。
构造带环的 results(绕过 Graph 校验),验证 _trace 的 visited 防御
防止无限递归,并以其中一个任务作为断点返回。
"""
report = _make_failed_report(
failures={
"a": ValueError("a error"),
"b": RuntimeError("b error"),
},
deps={"a": ("b",), "b": ("a",)}, # 环:a 依赖 bb 依赖 a
)
diag = diagnose(report)
assert diag is not None
# 两者都有 failed_deps,都不满足根因条件(FAILED 且无 failed_deps
assert diag.root_causes == []
# 依赖链仍应返回(递归遇到 visited 后以当前任务作为断点)
assert len(diag.dependency_chains) == 2
# ---------------------------------------------------------------------- #
# 相似失败聚类
# ---------------------------------------------------------------------- #
class TestFailureClusters:
"""相似失败聚类测试。"""
def test_cluster_same_exception_type(self) -> None:
"""相同异常类型 + 消息应聚类。"""
report = _make_failed_report(
failures={
"a": ValueError("invalid input"),
"b": ValueError("invalid input"),
"c": ValueError("invalid input"),
},
)
diag = diagnose(report)
assert diag is not None
assert len(diag.failure_clusters) == 1
cluster = diag.failure_clusters[0]
assert cluster.pattern.startswith("ValueError")
assert set(cluster.tasks) == {"a", "b", "c"}
def test_cluster_different_messages_separate(self) -> None:
"""相同类型但不同消息应分属不同聚类。"""
report = _make_failed_report(
failures={
"a": ValueError("error one"),
"b": ValueError("error two"),
},
)
diag = diagnose(report)
assert diag is not None
assert len(diag.failure_clusters) == 2
def test_cluster_sorted_by_size(self) -> None:
"""聚类按任务数降序排列。"""
report = _make_failed_report(
failures={
"a": KeyError("k1"),
"b": KeyError("k1"),
"c": ValueError("v1"),
},
)
diag = diagnose(report)
assert diag is not None
# KeyError 聚类(2 个任务)应在 ValueError 聚类(1 个任务)之前
assert len(diag.failure_clusters[0].tasks) >= len(diag.failure_clusters[1].tasks)
def test_cluster_sample_error_populated(self) -> None:
"""聚类应包含代表性错误消息。"""
report = _make_failed_report(
failures={"a": ValueError("test message")},
)
diag = diagnose(report)
assert diag is not None
assert diag.failure_clusters[0].sample_error != ""
# ---------------------------------------------------------------------- #
# 根因提示
# ---------------------------------------------------------------------- #
class TestHints:
"""根因提示测试。"""
@pytest.mark.parametrize(
("exception", "expected_keyword"),
[
(FileNotFoundError("/tmp/missing.txt"), "路径"),
(ImportError("missing_module"), "依赖"),
(ModuleNotFoundError("missing_module"), "模块"),
(TimeoutError(), "超时"),
(ConnectionError("refused"), "网络"),
(PermissionError("denied"), "权限"),
(KeyError("missing_key"), "上下文"),
(MemoryError(), "内存"),
],
)
def test_hint_for_common_exceptions(
self,
exception: BaseException,
expected_keyword: str,
) -> None:
"""常见异常应生成包含关键词的提示。"""
report = _make_failed_report({"a": exception})
diag = diagnose(report)
assert diag is not None
assert any(expected_keyword in h for h in diag.hints)
def test_hint_unknown_exception_no_hint(self) -> None:
"""未知异常类型不应生成提示。"""
report = _make_failed_report({"a": RuntimeError("custom error")})
diag = diagnose(report)
assert diag is not None
assert diag.hints == []
def test_hint_deduplicated_by_type(self) -> None:
"""相同异常类型的多个根因只生成一条提示。"""
report = _make_failed_report(
failures={
"a": FileNotFoundError("/a"),
"b": FileNotFoundError("/b"),
},
)
diag = diagnose(report)
assert diag is not None
file_hints = [h for h in diag.hints if "路径" in h]
assert len(file_hints) == 1
def test_hint_includes_task_name(self) -> None:
"""提示应包含根因任务名。"""
report = _make_failed_report({"my_task": FileNotFoundError("/x")})
diag = diagnose(report)
assert diag is not None
assert any("my_task" in h for h in diag.hints)
def test_hint_parent_class_matching(self) -> None:
"""异常的父类在 _HINT_MAP 中时应匹配父类提示。"""
# BlockingIOError 是 OSError 的子类
report = _make_failed_report({"a": BlockingIOError("resource busy")})
diag = diagnose(report)
assert diag is not None
assert any("系统错误" in h for h in diag.hints)
# ---------------------------------------------------------------------- #
# 跳过的任务识别
# ---------------------------------------------------------------------- #
class TestSkippedDueToFailure:
"""因失败而跳过的任务识别测试。"""
def test_skipped_with_failure_reason(self) -> None:
"""reason 含"失败"的 SKIPPED 任务应被识别。"""
report = _make_failed_report(
failures={"a": ValueError("x")},
extra_results={
"b": _make_result("b", status=TaskStatus.SKIPPED, reason="上游任务失败"),
},
)
diag = diagnose(report)
assert diag is not None
assert "b" in diag.skipped_due_to_failure
def test_skipped_with_cancel_reason(self) -> None:
"""reason 含"取消"的 SKIPPED 任务应被识别。"""
report = _make_failed_report(
failures={"a": ValueError("x")},
extra_results={
"b": _make_result("b", status=TaskStatus.SKIPPED, reason="执行被取消"),
},
)
diag = diagnose(report)
assert diag is not None
assert "b" in diag.skipped_due_to_failure
def test_skipped_with_unrelated_reason_excluded(self) -> None:
"""reason 不含失败/取消关键词的 SKIPPED 任务不应被识别。"""
report = _make_failed_report(
failures={"a": ValueError("x")},
extra_results={
"b": _make_result("b", status=TaskStatus.SKIPPED, reason="条件不满足"),
},
)
diag = diagnose(report)
assert diag is not None
assert "b" not in diag.skipped_due_to_failure
# ---------------------------------------------------------------------- #
# DiagnosticReport.describe()
# ---------------------------------------------------------------------- #
class TestDiagnosticReportDescribe:
"""DiagnosticReport.describe() 测试。"""
def test_describe_includes_sections(self) -> None:
"""describe() 应包含根因、依赖链、聚类、跳过、建议等节。"""
report = _make_failed_report(
failures={
"a": FileNotFoundError("/x"),
"b": RuntimeError("upstream"),
},
deps={"b": ("a",)},
extra_results={
"c": _make_result("c", status=TaskStatus.SKIPPED, reason="上游任务失败"),
},
)
diag = diagnose(report)
assert diag is not None
text = diag.describe()
assert "失败诊断报告" in text
assert "根因任务" in text
assert "依赖链" in text
assert "相似失败聚类" in text
assert "因失败而跳过的任务" in text
assert "建议" in text
assert "路径" in text # FileNotFoundError 提示
def test_describe_no_root_causes(self) -> None:
"""无根因时应显示"无法识别"。"""
report = px.RunReport(success=False)
report.results["a"] = _make_result("a", status=TaskStatus.SKIPPED, reason="手动跳过")
diag = diagnose(report)
assert diag is not None
text = diag.describe()
assert "无法识别" in text
def test_describe_empty_hints(self) -> None:
"""无提示时节标题应省略。"""
report = _make_failed_report({"a": RuntimeError("custom")})
diag = diagnose(report)
assert diag is not None
text = diag.describe()
assert "建议" not in text
# ---------------------------------------------------------------------- #
# RunReport 集成
# ---------------------------------------------------------------------- #
class TestRunReportIntegration:
"""RunReport.diagnose() 集成测试。"""
def test_report_diagnose_method(self) -> None:
"""RunReport.diagnose() 应与模块级 diagnose() 等价。"""
report = _make_failed_report({"a": ValueError("x")})
via_method = report.diagnose()
via_func = diagnose(report)
assert via_method is not None
assert via_func is not None
assert via_method.failed_tasks == via_func.failed_tasks
assert via_method.root_causes == via_func.root_causes
def test_report_describe_includes_diagnostics_on_failure(self) -> None:
"""失败时 describe() 应附诊断摘要。"""
report = _make_failed_report({"a": FileNotFoundError("/x")})
text = report.describe()
assert "失败诊断报告" in text
assert "路径" in text # 提示关键词
def test_report_describe_no_diagnostics_on_success(self) -> None:
"""成功时 describe() 不应包含诊断。"""
report = px.RunReport(success=True)
report.results["a"] = _make_result("a")
text = report.describe()
assert "失败诊断报告" not in text
def test_real_run_failure_diagnosis(self) -> None:
"""实际 run() 失败应可生成诊断报告。"""
def boom() -> None:
raise FileNotFoundError("/tmp/missing.txt")
def downstream(_boom: None) -> int:
return 1
# continue_on_error=True 使失败不抛异常而写入报告(success 保持 True
graph = px.Graph.from_specs(
[
px.TaskSpec("boom", boom, continue_on_error=True),
px.TaskSpec("downstream", downstream, depends_on=("boom",)),
]
)
report = px.run(graph, strategy="sequential")
# continue_on_error 不影响 success 标志,手动标记以触发诊断
report.success = False
diag = report.diagnose()
assert diag is not None
assert "boom" in diag.root_causes
assert any("路径" in h for h in diag.hints)
def test_diagnose_works_with_from_json(self) -> None:
"""反序列化后的报告也应可诊断(不依赖 Graph)。"""
report = _make_failed_report(
failures={"a": ValueError("x"), "b": RuntimeError("upstream")},
deps={"b": ("a",)},
)
text = report.to_json()
restored = px.RunReport.from_json(text)
restored.success = False # from_json 默认 success 来自 JSON
diag = restored.diagnose()
assert diag is not None
assert "a" in diag.root_causes