Files
pyflowx/tests/test_context.py
T
zhou bd3651e337
CI / Lint, Typecheck & Test (push) Has been cancelled
feat: 改进 API 简洁性 — depends_on 自动推断 + px.graph() 快捷构造 + 文档现代化
depends_on 为空的纯 fn 任务自动从必需参数名推断依赖(匹配图中任务名),
消除显式声明 depends_on 的样板代码;安全规则:仅必需参数、非 Context 标注、
非 *args/**kwargs、非 soft_depends_on、非自名。新增 px.graph(*specs) 可变
参数快捷构造;_is_context_annotation 重命名为公开 is_context_annotation;
__init__/fileops/compose/task 文档示例统一现代化。1694 测试全绿,覆盖率 97.25%。
2026-07-08 10:20:21 +08:00

295 lines
10 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 typing import Any
import pytest
import pyflowx as px
from pyflowx.context import build_call_args, describe_injection, is_context_annotation
from pyflowx.errors import InjectionError
class TestBuildCallArgs:
"""测试 build_call_args 函数."""
def test_inject_by_parameter_name(self) -> None:
"""参数名匹配依赖名时应注入对应结果."""
def fn(a: int, b: str) -> str:
return f"{a}{b}"
spec = px.TaskSpec("c", fn, depends_on=("a", "b"))
_args, kwargs = build_call_args(spec, {"a": 1, "b": "x"})
assert kwargs == {"a": 1, "b": "x"}
def test_inject_context_annotation(self) -> None:
"""标注为 Context 的参数应接收完整依赖映射."""
def fn(ctx: px.Context) -> int:
return len(ctx)
spec = px.TaskSpec("agg", fn, depends_on=("a", "b"))
_args, kwargs = build_call_args(spec, {"a": 1, "b": 2, "c": 99})
# Only the task's own deps are passed.
assert kwargs == {"ctx": {"a": 1, "b": 2}}
def test_inject_var_keyword(self) -> None:
"""**kwargs 参数应以 dict 形式接收所有依赖结果."""
def fn(**kwargs: Any) -> int: # pyright: ignore[reportExplicitAny, reportAny]
return sum(kwargs.values())
spec = px.TaskSpec("agg", fn, depends_on=("a", "b"))
_args, kwargs = build_call_args(spec, {"a": 1, "b": 2})
assert kwargs == {"a": 1, "b": 2}
def test_static_args_and_kwargs(self) -> None:
"""静态 args/kwargs 应正确填充非依赖参数."""
def fn(uid: int, source: str) -> str:
return f"{source}:{uid}"
spec = px.TaskSpec("fetch", fn, args=(42,), kwargs={"source": "api"})
args, kwargs = build_call_args(spec, {})
assert args == (42,)
assert kwargs == {"source": "api"}
def test_default_param_not_required(self) -> None:
"""有默认值的参数无需依赖或静态值."""
def fn(a: int, flag: bool = True) -> int:
return a if flag else 0
spec = px.TaskSpec("t", fn, depends_on=("a",))
_args, kwargs = build_call_args(spec, {"a": 5})
assert kwargs == {"a": 5}
def test_unresolved_required_param_raises(self) -> None:
"""必需参数无法解析时应抛出 InjectionError."""
def fn(_a: int, _: str) -> None:
return None
spec = px.TaskSpec("t", fn, depends_on=("a",))
with pytest.raises(InjectionError) as exc_info:
_ = build_call_args(spec, {"a": 1})
assert "Cannot inject" in str(exc_info.value)
def test_static_kwargs_collide_with_dependency(self) -> None:
"""静态 kwargs 与依赖名冲突时应抛出 InjectionError."""
def fn(a: int) -> int:
return a
spec = px.TaskSpec("t", fn, depends_on=("a",), kwargs={"a": 99})
with pytest.raises(InjectionError):
_ = build_call_args(spec, {"a": 1})
def test_var_positional_not_required(self) -> None:
"""*args 参数不应触发 InjectionError."""
def fn(*args: Any) -> int: # pyright: ignore[reportExplicitAny, reportAny]
return len(args)
spec = px.TaskSpec("t", fn, args=(1, 2, 3))
args, kwargs = build_call_args(spec, {})
assert args == (1, 2, 3)
assert kwargs == {}
def test_var_keyword_consumes_leftover(self) -> None:
"""**kwargs 应吞掉未被具名参数消费的依赖结果."""
def fn(a: int, **rest: Any) -> int: # pyright: ignore[reportExplicitAny, reportAny]
return a + sum(rest.values())
spec = px.TaskSpec("t", fn, depends_on=("a", "b", "c"))
_args, kwargs = build_call_args(spec, {"a": 1, "b": 2, "c": 3})
assert kwargs == {"a": 1, "b": 2, "c": 3}
def test_no_var_keyword_drops_leftover(self) -> None:
"""无 **kwargs 时,未被消费的依赖结果被丢弃(不报错)."""
def fn(a: int) -> int:
return a
spec = px.TaskSpec("t", fn, depends_on=("a", "b"))
# b 是依赖但 fn 不接收它 —— 应正常工作
_args, kwargs = build_call_args(spec, {"a": 1, "b": 2})
assert kwargs == {"a": 1}
def test_context_annotation_only_deps(self) -> None:
"""Context 标注只接收该任务自身 depends_on 的结果."""
def fn(ctx: px.Context) -> int:
return len(ctx)
spec = px.TaskSpec("t", fn, depends_on=("a", "b"))
_args, kwargs = build_call_args(spec, {"a": 1, "b": 2, "c": 99})
assert kwargs == {"ctx": {"a": 1, "b": 2}}
def test_cmd_task_fast_path(self) -> None:
"""cmd 任务(无 fn)走快速路径,跳过签名内省。"""
spec = px.TaskSpec("cmd_task", cmd=["echo", "hello"])
args, kwargs = build_call_args(spec, {"a": 1, "b": 2})
assert args == ()
assert kwargs == {}
def test_cmd_task_with_depends_fast_path(self) -> None:
"""cmd 任务有依赖时也走快速路径(依赖仅用于排序,不注入)。"""
spec = px.TaskSpec("cmd_task", cmd=["echo", "hello"], depends_on=("a",))
args, kwargs = build_call_args(spec, {"a": 1})
assert args == ()
assert kwargs == {}
def test_fn_no_deps_with_context_annotation(self) -> None:
"""fn 无依赖 + Context 标注走快速路径 2Context 参数收到空映射。"""
def fn(ctx: px.Context) -> int:
return len(ctx)
spec = px.TaskSpec("t", fn) # 无 depends_on
args, kwargs = build_call_args(spec, {})
assert args == ()
assert kwargs == {"ctx": {}}
def test_fn_no_deps_with_default_params(self) -> None:
"""fn 无依赖 + 全部参数有默认值走快速路径 2,无注入。"""
def fn(a: int = 1, b: str = "x") -> int:
return a
spec = px.TaskSpec("t", fn) # 无 depends_on
args, kwargs = build_call_args(spec, {})
assert args == ()
assert kwargs == {}
def test_fn_no_deps_with_required_param_raises(self) -> None:
"""fn 无依赖 + 必填无默认值参数走快速路径 2 时抛 InjectionError。"""
def fn(missing: int) -> int:
return missing
spec = px.TaskSpec("t", fn) # 无 depends_on
with pytest.raises(InjectionError) as exc_info:
build_call_args(spec, {})
assert "parameter 'missing'" in str(exc_info.value)
def test_signature_unhashable_fallback(self) -> None:
"""不可哈希 fn 走慢路径时 _signature 应回退到直接内省。"""
class UnhashableCallable:
__hash__ = None # type: ignore[assignment]
def __call__(self, a: int) -> int:
return a
fn = UnhashableCallable()
# 有依赖才能走慢路径(否则走快速路径 2)
spec = px.TaskSpec("t", fn, depends_on=("a",)) # type: ignore[arg-type]
_args, kwargs = build_call_args(spec, {"a": 1})
assert kwargs == {"a": 1}
class TestDescribeInjection:
"""测试 describe_injection 函数."""
def test_describe_injection(self) -> None:
"""应正确描述依赖注入、Context 标注和默认值."""
def fn(a: int, ctx: px.Context, flag: bool = False) -> None:
return None
spec = px.TaskSpec("t", fn, depends_on=("a",))
desc = describe_injection(spec)
assert "a=<dep:a>" in desc
assert "ctx=<Context>" in desc
assert "flag=<default>" in desc
def test_var_positional(self) -> None:
"""*args 参数应显示为 *args."""
def fn(*args: Any) -> None:
return None
spec = px.TaskSpec("t", fn)
desc = describe_injection(spec)
assert "*args" in desc
def test_var_keyword(self) -> None:
"""**kwargs 参数应显示为 **kwargs=<all-deps>."""
def fn(**kwargs: Any) -> None: # pyright: ignore[reportExplicitAny, reportAny]
return None
spec = px.TaskSpec("t", fn, depends_on=("a",))
desc = describe_injection(spec)
assert "**kwargs=<all-deps>" in desc
def test_unresolved(self) -> None:
"""无依赖、无静态值、无默认的参数应显示为 <UNRESOLVED>."""
def fn(missing: int) -> None:
return None
spec = px.TaskSpec("t", fn)
desc = describe_injection(spec)
assert "missing=<UNRESOLVED>" in desc
def test_static_kwargs(self) -> None:
"""静态 kwargs 应显示具体值."""
def fn(flag: bool = False) -> None:
return None
spec = px.TaskSpec("t", fn, kwargs={"flag": True})
desc = describe_injection(spec)
assert "flag=True" in desc
def test_positional_args_filled(self) -> None:
"""spec.args 填充的位置参数应显示具体值(覆盖 args_filled 分支)."""
def fn(a: int, b: str) -> None:
return None
spec = px.TaskSpec("t", fn, args=(1, "x"))
desc = describe_injection(spec)
assert "a=1" in desc
assert "b='x'" in desc
class TestIsContextAnnotation:
"""测试 is_context_annotation 函数."""
def test_direct_object(self) -> None:
"""直接传入 Context 别名对象应返回 True."""
assert is_context_annotation(px.Context) is True
def test_string(self) -> None:
"""字符串形式的注解应被识别."""
assert is_context_annotation("Context") is True
assert is_context_annotation("px.Context") is True
assert is_context_annotation("pyflowx.Context") is True
assert is_context_annotation("NotContext") is False
assert is_context_annotation("int") is False
def test_typing_alias(self) -> None:
"""具有 __name__/_name 为 Context/Mapping 的 typing 别名应返回 True."""
class FakeAlias:
__name__ = "Context"
assert is_context_annotation(FakeAlias()) is True
class FakeMapping:
__name__ = "Mapping"
assert is_context_annotation(FakeMapping()) is True
def test_other(self) -> None:
"""其他类型注解应返回 False."""
assert is_context_annotation(int) is False
assert is_context_annotation(str) is False
assert is_context_annotation(None) is False