Files
pyflowx/tests/test_notification.py
T

563 lines
19 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
import json
from urllib.error import URLError
import pytest
import pyflowx as px
from pyflowx.notification import (
ALL_LEVELS,
CallbackNotifier,
DingTalkNotifier,
FeishuNotifier,
NotificationLevel,
Notifier,
WebhookNotifier,
WeChatNotifier,
_format_event_message,
_status_to_level,
)
from pyflowx.task import TaskEvent, TaskStatus
# ---------------------------------------------------------------------- #
# 测试夹具
# ---------------------------------------------------------------------- #
def _event(status: TaskStatus, **kwargs: object) -> TaskEvent:
"""构造测试事件。"""
return TaskEvent(task="test_task", status=status, **kwargs) # type: ignore[arg-type]
# ---------------------------------------------------------------------- #
# NotificationLevel 与映射
# ---------------------------------------------------------------------- #
def test_notification_level_values() -> None:
"""NotificationLevel 四个值正确。"""
assert NotificationLevel.RUNNING.value == "running"
assert NotificationLevel.SUCCESS.value == "success"
assert NotificationLevel.FAILED.value == "failed"
assert NotificationLevel.SKIPPED.value == "skipped"
def test_all_levels_contains_all() -> None:
"""ALL_LEVELS 包含全部四个级别。"""
assert (
frozenset(
{
NotificationLevel.RUNNING,
NotificationLevel.SUCCESS,
NotificationLevel.FAILED,
NotificationLevel.SKIPPED,
}
)
== ALL_LEVELS
)
def test_status_to_level_mapping() -> None:
"""TaskStatus 正确映射到 NotificationLevel。"""
assert _status_to_level(TaskStatus.RUNNING) == NotificationLevel.RUNNING
assert _status_to_level(TaskStatus.SUCCESS) == NotificationLevel.SUCCESS
assert _status_to_level(TaskStatus.FAILED) == NotificationLevel.FAILED
assert _status_to_level(TaskStatus.SKIPPED) == NotificationLevel.SKIPPED
def test_status_to_level_pending_returns_none() -> None:
"""PENDING 状态无对应级别,返回 None。"""
assert _status_to_level(TaskStatus.PENDING) is None
# ---------------------------------------------------------------------- #
# _format_event_message
# ---------------------------------------------------------------------- #
def test_format_event_message_success() -> None:
"""成功事件格式化包含任务名与状态。"""
msg = _format_event_message(_event(TaskStatus.SUCCESS, duration=0.5))
assert "test_task" in msg
assert "success" in msg
assert "0.500s" in msg
def test_format_event_message_failed_with_error() -> None:
"""失败事件格式化包含错误信息。"""
msg = _format_event_message(_event(TaskStatus.FAILED, error="ValueError", duration=0.1))
assert "test_task" in msg
assert "failed" in msg
assert "ValueError" in msg
def test_format_event_message_skipped_with_reason() -> None:
"""跳过事件格式化包含原因。"""
msg = _format_event_message(_event(TaskStatus.SKIPPED, reason="条件不满足"))
assert "test_task" in msg
assert "skipped" in msg
assert "条件不满足" in msg
def test_format_event_message_with_attempts() -> None:
"""多次尝试事件格式化包含尝试次数。"""
msg = _format_event_message(_event(TaskStatus.FAILED, attempts=3, error="err"))
assert "尝试次数: 3" in msg
def test_format_event_message_running_no_duration() -> None:
"""RUNNING 事件无 duration 时不显示耗时。"""
msg = _format_event_message(_event(TaskStatus.RUNNING))
assert "test_task" in msg
assert "running" in msg
assert "s)" not in msg
# ---------------------------------------------------------------------- #
# Notifier Protocol
# ---------------------------------------------------------------------- #
def _noop(event: TaskEvent) -> None:
"""空回调,用于协议检查。"""
return None
def test_callback_notifier_satisfies_protocol() -> None:
"""CallbackNotifier 满足 Notifier 协议。"""
notifier = CallbackNotifier(_noop)
assert isinstance(notifier, Notifier)
def test_webhook_notifier_satisfies_protocol() -> None:
"""WebhookNotifier 满足 Notifier 协议。"""
notifier = WebhookNotifier(url="http://example.com/hook")
assert isinstance(notifier, Notifier)
def test_feishu_notifier_satisfies_protocol() -> None:
"""FeishuNotifier 满足 Notifier 协议。"""
assert isinstance(FeishuNotifier(url="http://example.com/hook"), Notifier)
def test_dingtalk_notifier_satisfies_protocol() -> None:
"""DingTalkNotifier 满足 Notifier 协议。"""
assert isinstance(DingTalkNotifier(url="http://example.com/hook"), Notifier)
def test_wechat_notifier_satisfies_protocol() -> None:
"""WeChatNotifier 满足 Notifier 协议。"""
assert isinstance(WeChatNotifier(url="http://example.com/hook"), Notifier)
# ---------------------------------------------------------------------- #
# CallbackNotifier
# ---------------------------------------------------------------------- #
def test_callback_notifier_invokes_callback() -> None:
"""CallbackNotifier 在事件触发时调用回调。"""
received: list[TaskEvent] = []
notifier = CallbackNotifier(received.append)
event = _event(TaskStatus.SUCCESS, duration=0.1)
notifier.notify(event)
assert received == [event]
def test_callback_notifier_filters_by_levels() -> None:
"""levels 参数限制触发的事件级别。"""
received: list[TaskEvent] = []
notifier = CallbackNotifier(
received.append,
levels=(NotificationLevel.FAILED, NotificationLevel.SKIPPED),
)
success_event = _event(TaskStatus.SUCCESS, duration=0.1)
failed_event = _event(TaskStatus.FAILED, error="err")
notifier.notify(success_event)
notifier.notify(failed_event)
assert received == [failed_event]
def test_callback_notifier_default_all_levels() -> None:
"""默认 levels 包含全部事件级别。"""
received: list[TaskEvent] = []
notifier = CallbackNotifier(received.append)
for status in (TaskStatus.RUNNING, TaskStatus.SUCCESS, TaskStatus.FAILED, TaskStatus.SKIPPED):
notifier.notify(_event(status))
assert len(received) == 4
def test_callback_notifier_pending_not_notified() -> None:
"""PENDING 事件不触发通知(无对应级别)。"""
received: list[TaskEvent] = []
notifier = CallbackNotifier(received.append)
notifier.notify(_event(TaskStatus.PENDING))
assert received == []
def test_callback_notifier_close_is_noop() -> None:
"""close() 无副作用,可安全调用。"""
notifier = CallbackNotifier(_noop)
notifier.close() # 不应抛异常
# ---------------------------------------------------------------------- #
# WebhookNotifier 基类
# ---------------------------------------------------------------------- #
class _StubResponse:
"""模拟 urllib.response 的上下文管理器响应。"""
def __init__(self, payload: bytes = b"{}") -> None:
self._payload = payload
def __enter__(self) -> _StubResponse:
return self
def __exit__(self, *exc: object) -> None:
return None
def read(self) -> bytes:
return self._payload
def _patch_urlopen(
monkeypatch: pytest.MonkeyPatch,
capture: dict[str, object],
response: _StubResponse | None = None,
raise_exc: BaseException | None = None,
) -> None:
"""patch urllib.request.urlopen,捕获请求参数。"""
def _fake_urlopen(req: object, timeout: float | None = None) -> _StubResponse:
capture["req"] = req
capture["timeout"] = timeout
if raise_exc is not None:
raise raise_exc
return response or _StubResponse()
monkeypatch.setattr("pyflowx.notification.urllib.request.urlopen", _fake_urlopen)
def _get_req_fields(req: object) -> dict[str, object]:
"""从 Request 对象提取 url/data/method/headers。"""
return {
"url": req.full_url, # type: ignore[attr-defined]
"data": req.data, # type: ignore[attr-defined]
"method": req.method, # type: ignore[attr-defined]
"content_type": req.headers.get("Content-type"), # type: ignore[attr-defined]
}
def test_webhook_notifier_send_success(monkeypatch: pytest.MonkeyPatch) -> None:
"""webhook 成功发送 POST 请求。"""
capture: dict[str, object] = {}
_patch_urlopen(monkeypatch, capture)
notifier = WebhookNotifier(url="http://example.com/hook")
notifier.notify(_event(TaskStatus.SUCCESS, duration=0.1))
req = capture["req"]
fields = _get_req_fields(req)
assert fields["url"] == "http://example.com/hook"
assert fields["method"] == "POST"
assert fields["content_type"] == "application/json"
payload = json.loads(fields["data"]) # type: ignore[arg-type]
assert "text" in payload
def test_webhook_notifier_filters_by_levels(monkeypatch: pytest.MonkeyPatch) -> None:
"""levels 过滤:SUCCESS 不在 levels 中时不发送。"""
capture: dict[str, object] = {}
_patch_urlopen(monkeypatch, capture)
notifier = WebhookNotifier(
url="http://example.com/hook",
levels=(NotificationLevel.FAILED,),
)
notifier.notify(_event(TaskStatus.SUCCESS, duration=0.1))
assert "req" not in capture # 未发送
notifier.notify(_event(TaskStatus.FAILED, error="err"))
assert "req" in capture # 发送
def test_webhook_notifier_timeout_passed(monkeypatch: pytest.MonkeyPatch) -> None:
"""timeout 参数传递给 urlopen。"""
capture: dict[str, object] = {}
_patch_urlopen(monkeypatch, capture)
notifier = WebhookNotifier(url="http://example.com/hook", timeout=5.0)
notifier.notify(_event(TaskStatus.SUCCESS, duration=0.1))
assert capture["timeout"] == 5.0
def test_webhook_notifier_failure_logged_not_raised(monkeypatch: pytest.MonkeyPatch) -> None:
"""URLError 发生时仅记录日志,不抛异常。"""
capture: dict[str, object] = {}
_patch_urlopen(monkeypatch, capture, raise_exc=URLError("connection refused"))
notifier = WebhookNotifier(url="http://example.com/hook")
# 不应抛异常
notifier.notify(_event(TaskStatus.FAILED, error="err"))
def test_webhook_notifier_oserror_handled(monkeypatch: pytest.MonkeyPatch) -> None:
"""OSError 同样被捕获,不影响任务执行。"""
capture: dict[str, object] = {}
_patch_urlopen(monkeypatch, capture, raise_exc=OSError("network error"))
notifier = WebhookNotifier(url="http://example.com/hook")
notifier.notify(_event(TaskStatus.SUCCESS, duration=0.1))
def test_webhook_notifier_close_is_noop() -> None:
"""close() 无副作用。"""
notifier = WebhookNotifier(url="http://example.com/hook")
notifier.close()
def test_webhook_notifier_payload_contains_message(monkeypatch: pytest.MonkeyPatch) -> None:
"""基类 _build_payload 返回包含 text 的字典。"""
capture: dict[str, object] = {}
_patch_urlopen(monkeypatch, capture)
notifier = WebhookNotifier(url="http://example.com/hook")
notifier.notify(_event(TaskStatus.FAILED, error="boom", duration=0.2))
fields = _get_req_fields(capture["req"])
payload = json.loads(fields["data"]) # type: ignore[arg-type]
assert "text" in payload
assert "test_task" in payload["text"]
assert "boom" in payload["text"]
# ---------------------------------------------------------------------- #
# FeishuNotifier
# ---------------------------------------------------------------------- #
def test_feishu_notifier_payload_format(monkeypatch: pytest.MonkeyPatch) -> None:
"""飞书消息格式:msg_type/content.text。"""
capture: dict[str, object] = {}
_patch_urlopen(monkeypatch, capture)
notifier = FeishuNotifier(url="http://example.com/hook")
notifier.notify(_event(TaskStatus.SUCCESS, duration=0.1))
fields = _get_req_fields(capture["req"])
payload = json.loads(fields["data"]) # type: ignore[arg-type]
assert payload["msg_type"] == "text"
assert "text" in payload["content"]
assert "test_task" in payload["content"]["text"]
# ---------------------------------------------------------------------- #
# DingTalkNotifier
# ---------------------------------------------------------------------- #
def test_dingtalk_notifier_payload_format(monkeypatch: pytest.MonkeyPatch) -> None:
"""钉钉消息格式:msgtype/text.content。"""
capture: dict[str, object] = {}
_patch_urlopen(monkeypatch, capture)
notifier = DingTalkNotifier(url="http://example.com/hook")
notifier.notify(_event(TaskStatus.FAILED, error="err"))
fields = _get_req_fields(capture["req"])
payload = json.loads(fields["data"]) # type: ignore[arg-type]
assert payload["msgtype"] == "text"
assert "content" in payload["text"]
assert "test_task" in payload["text"]["content"]
# ---------------------------------------------------------------------- #
# WeChatNotifier
# ---------------------------------------------------------------------- #
def test_wechat_notifier_payload_format(monkeypatch: pytest.MonkeyPatch) -> None:
"""企业微信消息格式:msgtype/text.content。"""
capture: dict[str, object] = {}
_patch_urlopen(monkeypatch, capture)
notifier = WeChatNotifier(url="http://example.com/hook")
notifier.notify(_event(TaskStatus.SKIPPED, reason="跳过原因"))
fields = _get_req_fields(capture["req"])
payload = json.loads(fields["data"]) # type: ignore[arg-type]
assert payload["msgtype"] == "text"
assert "content" in payload["text"]
assert "test_task" in payload["text"]["content"]
assert "跳过原因" in payload["text"]["content"]
# ---------------------------------------------------------------------- #
# run() 集成:notifiers 参数
# ---------------------------------------------------------------------- #
def _build_simple_graph() -> px.Graph:
"""构造简单两任务图。"""
@px.task
def a() -> int:
return 1
@px.task(depends_on=("a",))
def b(a: int) -> int:
return a + 1
return px.Graph.from_specs([a, b])
def test_run_with_single_notifier() -> None:
"""run() 接受单个 notifier。"""
received: list[TaskEvent] = []
notifier = CallbackNotifier(received.append)
report = px.run(_build_simple_graph(), notifiers=notifier)
assert report.success
# 至少收到 a/b 的 RUNNING + SUCCESS 事件
statuses = [e.status for e in received]
assert TaskStatus.RUNNING in statuses
assert TaskStatus.SUCCESS in statuses
task_names = {e.task for e in received}
assert "a" in task_names
assert "b" in task_names
def test_run_with_list_of_notifiers() -> None:
"""run() 接受 notifier 列表。"""
received1: list[TaskEvent] = []
received2: list[TaskEvent] = []
notifiers: list[Notifier] = [
CallbackNotifier(received1.append),
CallbackNotifier(received2.append),
]
report = px.run(_build_simple_graph(), notifiers=notifiers)
assert report.success
assert len(received1) > 0
assert len(received2) > 0
assert len(received1) == len(received2)
def test_run_with_notifier_and_progress_coexist() -> None:
"""notifier 与 progress 监控器共存。"""
received: list[TaskEvent] = []
notifier = CallbackNotifier(received.append)
class _NullMonitor:
def __init__(self) -> None:
self.started = False
self.finished = False
def start(self, total: int) -> None:
self.started = True
def on_event(self, event: TaskEvent) -> None:
return None
def finish(self) -> None:
self.finished = True
monitor = _NullMonitor()
report = px.run(_build_simple_graph(), progress=monitor, notifiers=notifier)
assert report.success
assert monitor.started
assert monitor.finished
assert len(received) > 0
def test_run_notifiers_close_called_on_success() -> None:
"""成功结束时调用 notifier.close()。"""
class _TrackingNotifier:
def __init__(self) -> None:
self.closed = False
self.events: list[TaskEvent] = []
def notify(self, event: TaskEvent) -> None:
self.events.append(event)
def close(self) -> None:
self.closed = True
notifier = _TrackingNotifier()
report = px.run(_build_simple_graph(), notifiers=notifier)
assert report.success
assert notifier.closed
assert len(notifier.events) > 0
def test_run_notifiers_close_called_on_failure() -> None:
"""任务失败时也调用 notifier.close()。"""
class _TrackingNotifier:
def __init__(self) -> None:
self.closed = False
def notify(self, event: TaskEvent) -> None:
return None
def close(self) -> None:
self.closed = True
notifier = _TrackingNotifier()
@px.task
def fail_task() -> None:
raise RuntimeError("boom")
graph = px.Graph.from_specs([fail_task])
with pytest.raises(px.TaskFailedError):
px.run(graph, notifiers=notifier)
assert notifier.closed
def test_run_notifiers_default_none() -> None:
"""notifiers 默认 None,不影响执行。"""
report = px.run(_build_simple_graph())
assert report.success
def test_run_notifier_levels_filter() -> None:
"""仅 FAILED 的 notifier 只收到失败事件。"""
received: list[TaskEvent] = []
notifier = CallbackNotifier(
received.append,
levels=(NotificationLevel.FAILED,),
)
@px.task
def ok() -> int:
return 1
@px.task
def bad() -> None:
raise RuntimeError("fail")
graph = px.Graph.from_specs([ok, bad])
with pytest.raises(px.TaskFailedError):
px.run(graph, notifiers=notifier)
# 只收到 bad 的 FAILED 事件
assert len(received) == 1
assert received[0].task == "bad"
assert received[0].status == TaskStatus.FAILED
def test_run_webhook_notifier_integration(monkeypatch: pytest.MonkeyPatch) -> None:
"""run() 集成 WebhookNotifierwebhook 被调用。"""
capture: dict[str, object] = {}
_patch_urlopen(monkeypatch, capture)
notifier = WebhookNotifier(url="http://example.com/hook")
report = px.run(_build_simple_graph(), notifiers=notifier)
assert report.success
assert "req" in capture # 至少一次 webhook 调用
# ---------------------------------------------------------------------- #
# 公共 API 导出
# ---------------------------------------------------------------------- #
def test_notification_classes_exported() -> None:
"""通知类从 pyflowx 顶层导出。"""
assert hasattr(px, "Notifier")
assert hasattr(px, "NotificationLevel")
assert hasattr(px, "CallbackNotifier")
assert hasattr(px, "WebhookNotifier")
assert hasattr(px, "FeishuNotifier")
assert hasattr(px, "DingTalkNotifier")
assert hasattr(px, "WeChatNotifier")
assert hasattr(px, "ALL_LEVELS")
def test_all_levels_exported() -> None:
"""ALL_LEVELS 从顶层导出且与模块内一致。"""
assert px.ALL_LEVELS is ALL_LEVELS