d0ff7d7b4d
本次提交大幅完善了 PyFlowX 的 README 文档,新增了四种执行策略、软依赖、并发限制、任务钩子等多项特性说明,补充了任务模板、图组合、缓存键等新功能的使用示例,同时更新了执行参数、执行策略对照表与模块结构文档。另外新增了 .trae/rules/python-standards.md 规范文档,统一了项目的代码风格、类型检查、测试编写等开发标准。
18 KiB
18 KiB
Python 开发规范
本规范结合 Python 最佳实践,作为编写与审查 Python 代码的统一标准。
工具链(以 pyproject.toml 为准)
| 工具 | 用途 | 配置要点 |
|---|---|---|
| ruff | lint + format | line-length=120,target-version="py38",select 见 pyproject |
| pyrefly | 类型检查 | preset="strict",python-version="3.8" |
| pytest | 测试 | asyncio_default_fixture_loop_scope="function",marker slow |
| coverage | 覆盖率 | branch=true,fail_under=95,concurrency=["thread"] |
| pre-commit | 提交前检查 | ruff --fix + trailing-whitespace + end-of-file-fixer |
验证(每次修改后必做):
uvx --from pyflowx pymake tc
uvx --from pyflowx pymake cov
兼容性
- 最低 Python 3.8:使用
from __future__ import annotations延迟注解求值; 3.9 以下用typing.List/typing.Dict/typing.Union,3.9+ 用内置泛型, 3.10+ 用X | Y,3.12+ 用typing.override。 - 版本守卫:
if sys.version_info >= (3, X):引入高版本 API,else 回退。 低版本回退分支加# pragma: no cover。 - 零运行时依赖:仅依赖标准库(3.8 需
graphlib_backport、typing-extensions)。 新增依赖须审慎,优先用标准库。
类型注解
- 所有公共 API 必须有完整类型注解,包括返回类型。
- 私有函数也应有注解,便于 pyrefly strict 通过。
- 泛型用
TypeVar;使用 PEP 696default=参数时,3.13+ 用typing.TypeVar, 3.8–3.12 用typing_extensions.TypeVar(default=在 3.13 才进入标准库)。 Mapping/Sequence用于只读参数,dict/list用于可变返回。Any仅用于真正动态的场景(如Context跨任务异构映射);单个任务内部类型 必须完全静态,由函数签名保证。- 避免渐进式类型:不要用
# type: ignore掩盖真实类型错误;确需时加 具体规则码注释(如# type: ignore[union-attr])。 TYPE_CHECKING守卫:仅类型检查需要的导入(如为避免循环依赖、 或仅注解用的重类型)放if TYPE_CHECKING:块内,运行期不导入:from typing import TYPE_CHECKING if TYPE_CHECKING: from pyflowx.graph import Graph # 仅注解使用- 类型收窄:分支内需要窄化类型时,用
assert isinstance(x, Y)辅助 pyrefly 推断;避免无谓的cast()(运行期无检查,掩盖 bug)。cast仅用于类型系统无法表达的场景(如第三方无类型存根)。
数据结构
- 不可变优先:配置/描述类用
@dataclass(frozen=True)。 - 可变类属性显式标注
RUF012豁免(如配置默认值field(default_factory=...))。 - 缓存计算结果用
functools.cached_property(实例级)或functools.lru_cache(按参数键控);不可哈希参数需 try/except 回退。 - 缓存失效:修改被缓存的数据源后必须手动清空缓存(如
self._cache.clear()), 否则下游拿到陈旧数据。 - 抽象基类:定义接口用
abc.ABC+@abstractmethod(如StateBackend), 强制子类实现关键方法;非抽象方法提供默认实现。 - 枚举:状态/标志值用
enum.Enum(或IntEnum/Flag,如TaskStatus), 禁止裸字符串/魔术数字;枚举值用UPPER_SNAKE。 __repr__:可变类实现__repr__(含关键字段),便于调试与日志;frozen=Truedataclass 自动生成,无需手写。
模块与导入
- 单一职责:每个模块只做一件事(
task.py纯数据结构、executors.py执行逻辑、command.py命令执行、compose.py图组合)。禁止跨职责边界放代码。 - 导入顺序(ruff isort):
__future__→ 标准库 → 第三方 → 本地,各组间空行。 - 惰性导入:仅为打破循环依赖时使用,在函数体内导入,加注释说明原因。 顶层导入是默认。
__all__:定义__all__,显式声明导出符号。位置在模块顶部,仅次于__future__之后。- 禁用 star imports:
from x import *污染命名空间、隐藏依赖、 破坏类型检查;显式列出导入符号,或用模块路径访问(import x→x.foo)。 例外:__init__.py聚合导出时可经__all__控制。 - 避免
utils.py、helpers.py等杂项工具文件:按职责归入对应模块,不要建杂项工具文件。
函数设计
- 模块级函数优于 Mixin:共享逻辑用模块级函数,不要新建 Mixin 类。 类只持有状态与薄方法,调用模块级函数。
- 静态方法慎用:纯函数直接放模块级,不必塞进类里当
@staticmethod。 - 参数 ≤ 5 个为宜;超出考虑用 dataclass 封装参数对象(ruff
PLR0913已忽略, 但仍是设计信号)。 - 单一职责:一个函数做一件事;过长函数(ruff
PLR0915已忽略但仍是信号) 考虑拆分。 - 异常范围要窄:只捕获预期异常(如
(TypeError, ValueError, KeyError, AttributeError)), 不要except Exception掩盖 bug。捕获后至少logger.warning记录。
异常处理
- 自定义异常家族:继承公共基类(如
PyFlowXError),按错误场景分类。 - 异常包装:低层异常用
raise NewError(...) from exc保留因果链。 - 不要吞异常:捕获后必须处理(记录/包装/重抛),不要空
except: pass。 - 钩子/回调异常:第三方回调异常仅记录,不影响主流程(用
_run_hooks模式)。
并发与线程安全
- 进程全局状态(
os.environ/os.chdir)在并发场景下必须用全局锁 (threading.RLock)序列化"切换→执行→恢复"区间。 - 条件评估不可有可变状态:组合条件(NOT/AND/OR)不得修改共享
_reason, 避免竞态。 - 批量 I/O:循环内多次写盘改为批量一次(用
contextmanager包裹延迟落盘)。 - 信号量限流:
concurrency_key+Semaphore按组限流,避免压垮下游。
测试
总则
- 覆盖率 ≥ 95%(branch coverage),不得下降。
- 公共 API 优先测试:测试用公共接口(
has/get),不访问私有方法 (如_expired)。兼容旧测试的私有方法应删除并迁移测试。 例外:_store/_flush等内部状态在无法用公共 API 触发时(如模拟过期、 故障注入),可临时访问私有属性,并在 docstring 注明原因。 - 命名:
test_<被测对象>_<场景>,如test_storage_key_cache_key_exception_returns_name。 - 每个测试一个断言重点;多个断言要语义相关。
- slow 标记:耗时测试加
@pytest.mark.slow,CI 可-m "not slow"跳过。 - 测试代码也跑 ruff:
tests/**忽略ARG001/ARG002(未用 fixture 参数)。 - 断言风格:用原生
assert+ 比较运算符(assert x == 1), 不用self.assertEqual;pytest 会生成更清晰的 diff。
Mock 工具选择(强制)
优先级:monkeypatch > 内联 stub > unittest.mock > pytest-mock。
| 场景 | 工具 | 示例 |
|---|---|---|
| 替换模块属性 / 环境变量 / 工作目录 | monkeypatch |
monkeypatch.setattr(subprocess, "run", fake_run) |
os.environ["KEY"] 临时设置 |
monkeypatch.setenv |
monkeypatch.setenv("LOCALAPPDATA", "C:\\...") |
| 切换 cwd | monkeypatch.chdir |
monkeypatch.chdir(tmp_path) |
| 一次性 stub 函数 | 内联 lambda / 闭包 | ran = []; monkeypatch.setattr(subprocess, "run", lambda *c, **__: ran.append(c)) |
| 复杂 spy(记录调用次数/参数/返回序列) | unittest.mock.MagicMock |
仅当 lambda 不足以表达时 |
with patch(...) 上下文 |
禁用(用 monkeypatch) | monkeypatch 自动 teardown 更安全 |
禁止:
- 不用
pytest-mock的mockerfixture(项目虽在 dev 依赖声明,但实际 测试代码未使用;为保持风格统一,新代码继续用monkeypatch)。 - 不用
unittest.mock.patch装饰器(@patch("x.y")),它隐藏依赖且 与 pytest fixture 模式不兼容;用monkeypatch.setattr替代。 - 不用
mock.patch.object作为上下文管理器,除非被测代码本身就是 contextmanager(此时用monkeypatch.setattr仍更简单)。
monkeypatch 使用规范
- 类型注解:fixture 参数标注
monkeypatch: pytest.MonkeyPatch。 - 作用域:monkeypatch 自动在测试结束时撤销,禁止手动
monkeypatch.setattr(x, "y", original)恢复(多余且容易遗漏)。 例外:在单个测试内需要中途恢复时,用monkeypatch.undo()全量撤销。 - 替换目标:替换"被测代码看到的对象",而非全局对象本身。
- 错误:
monkeypatch.setattr("os.path.exists", fake)—— 替换全局, 影响其他模块。 - 正确:
monkeypatch.setattr(pyflowx.command.shutil, "which", fake)—— 替换被测模块引用的shutil.which。
- 错误:
- 属性 vs 字符串路径:优先属性访问形式
monkeypatch.setattr(obj, "attr", val)而非字符串路径monkeypatch.setattr("pkg.mod.obj.attr", val), 前者有 IDE 跳转与重构支持。 - 记录调用:用闭包
ran: list[tuple] = []+lambda *a, **k: ran.append((a, k))替代MagicMock,可读性更好且无需导入。
Stub 与 Spy 模式
- 轻量 stub:内联定义
class MockResult: returncode = 0; stdout = "", 替代MagicMock(return_value=...),类型明确且不引入 mock 依赖。 - 状态收集:闭包 + list 比
mock.call_args_list更易断言:calls: list[list[str]] = [] def fake_run(cmd: list[str], **_: Any) -> MockResult: calls.append(cmd) return MockResult() monkeypatch.setattr(subprocess, "run", fake_run) assert calls == [["clear"]] - 副作用序列:需要按调用次数返回不同值时,用
itertools.cycle或 手动计数器,而非side_effect=[...](mock 专有 API)。 - 异常注入:
def raise_oserror(*a, **k): raise OSError("..."), 用pytest.raises(OSError)验证,而非side_effect=OSError。
异常断言
pytest.raises:必填match=正则(除非异常消息完全不可预测), 避免误捕获同类异常:with pytest.raises(StorageError, match="cannot write"): b.save("a", 1)- 异常链:验证
__cause__时用exc_info.value.__cause__, 确认raise X from Y因果链完整。 - 禁止
try/except + assert False:用pytest.raises替代。
Fixture 规范
tmp_path:处理临时文件,自动清理,禁止tempfile.mkdtemp()手动管理。monkeypatch:环境变量、cwd、模块属性 mock(见上)。capsys/capfd:捕获 stdout/stderr,验证日志或命令输出。- autouse fixture:仅在全局必需时用(如
conftest.py的packtool_tmp_workdir自动切到 tmp_path);否则显式声明参数。 - fixture 命名:
snake_case,描述"提供什么"而非"测试什么" (sample_graph优于test_data)。 - fixture 作用域:默认
function;module/session仅当构造昂贵且 只读时,并加注释说明无副作用。
asyncio 测试
- fixture
loop_scope="function"(pyproject 已配置默认值)。 - async 测试:
async def test_x():,pytest-asyncio 自动驱动。 - await 检查:测试异步函数必须
await结果,禁止仅验证返回 coroutine 对象。 - 异步 mock:用
AsyncMock(3.8+ 在unittest.mock)或async def fake(): return value,禁用MagicMock(return_value=coro)。
参数化
@pytest.mark.parametrize:用ids参数提供可读标识:@pytest.mark.parametrize( ("strategy", "expected_workers"), [("sequential", 1), ("thread", 8), ("async", 1)], ids=["seq", "thread-8", "async"], )- 参数命名:参数元组用有意义名称,而非
("a", "b")。 - 组合爆炸:参数组合 > 20 时拆分测试,避免单个测试函数臃肿。
测试组织
- 文件命名:
test_<被测模块>.py(test_storage.py对应storage.py)。 - 类分组:仅在测试逻辑强相关时用
class TestXxx:分组;默认用模块级函数。 - docstring:每个测试函数一句话说明"测试什么场景",复杂场景补充"为什么"。
- setup/teardown:优先 fixture;
setup_method/teardown_method仅在 无法用 fixture 表达时(罕见)。
代码风格
- 行宽 120(ruff formatter 处理,无需手动对齐)。
- docstring:公共 API 必须有 docstring;模块顶部 docstring 说明职责与设计要点。 中文叙述 + 英文代码注释是本项目既有风格,保持一致。
- 命名:
snake_case函数/变量,PascalCase类,UPPER_SNAKE常量,_leading_underscore私有。 - 字符串引号:ruff 默认双引号。
- 末尾换行:文件以单
\n结尾(pre-commitend-of-file-fixer强制)。 - 无尾随空格(pre-commit
trailing-whitespace强制)。 - 不用 emoji:除非用户明确要求。
Pythonic 风格
is比较None/True/False:单例用is,值用==。if x is None:正确;if x == None:禁止(PEP 8 E711/E712)。- EAFP 优于 LBYL:先尝试再处理异常,而非先检查再执行:
例外:检查成本低且无竞态时(如
# 不好(LBYL,竞态窗口) if os.path.exists(path): with open(path) as f: ... # 好(EAFP) try: with open(path) as f: ... except FileNotFoundError: ...if not items:跳过空集合)可用 LBYL。 - truthiness:用
if items:/if not items:而非if len(items) > 0:/if items != []。空容器/空串/0/None 均为假。 - 字符串格式化:首选 f-string(
f"{name=}"调试格式 3.8+);format()用于模板/延迟绑定;%仅用于logging延迟格式化(见"日志")。 - 推导式:list/dict/set 推导式优于
map+filter;但推导式 > 2 层时 拆为显式循环(可读性优先)。 enumerate索引:需要索引时用for i, x in enumerate(seq), 禁止for i in range(len(seq))。zip并行:并行迭代用zip(a, b);需要索引对齐用zip(a, b, strict=True)(3.10+,长度不等抛 ValueError)。- 解包:
a, b = pair优于a = pair[0]; b = pair[1]; 忽略值用_(a, _ = pair)。 - 海象运算符
:=(3.8+):赋值+判断合一,避免重复求值:但不滥用:简单赋值仍用普通if (n := len(data)) > threshold: logger.info("data size %d exceeds %d", n, threshold)=。
日志
- 用
logging.getLogger(__name__):每个模块独立 logger,禁用print调试残留 (提交前用git diff检查)。 - 结构化上下文:用
extra={...}传字段,而非字符串拼接;logger.warning("task %r failed: %s", name, exc)优于 f-string(延迟格式化)。 - 日志级别:
DEBUG详细诊断、INFO关键流程、WARNING可恢复异常、ERROR需人工介入。捕获预期异常至少logger.warning并附exc_info=True当需要堆栈时。 - 禁止日志密码/密钥:脱敏后再记录。
可变默认参数
- 经典坑:
def f(x=[])/def f(x={})的默认值在函数定义时求值一次, 后续调用共享同一对象,导致跨调用状态泄漏。 - 哨兵模式:用
None作默认,函数体内初始化:def f(items: list[int] | None = None) -> list[int]: if items is None: items = [] - dataclass:用
field(default_factory=list)(已在"数据结构"提及, 普通函数同样适用此原则)。
路径处理
- 优先
pathlib.Path:用Path("a") / "b"而非os.path.join("a", "b"); ruffPTH规则已启用并强制。 - 禁止字符串拼接路径:
"a/" + name跨平台不安全(分隔符、注入风险)。 - 类型注解:参数与返回用
Path,仅在边界(如os.environ、CLI argv) 接受str后立即Path(s)包装。 - 临时文件:测试用
tmp_pathfixture;生产代码用tempfile.NamedTemporaryFile或contextlib.ExitStack管理。
资源管理
with语句:文件、锁、连接、临时目录一律用with或contextlib.contextmanager,确保异常路径也释放。- 自定义资源:实现
__enter__/__exit__;多个资源用contextlib.ExitStack动态管理。 - 显式关闭:长生命周期对象(连接池、线程池)实现
close()并在__del__或atexit兜底,但优先with。 - 批量操作:循环内多次 acquire/release 改为批量一次(如
backend.batch()包裹整个执行,见"并发与线程安全")。
安全
- 禁用
eval/exec:处理不可信输入时绝不使用;用ast.literal_eval解析字面量,或专用解析器。 subprocess:禁用shell=True除非命令完全可信;优先list[str]形式cmd,避免 shell 注入。- 凭证不入仓:密钥、token、密码放
.env或环境变量,.gitignore必须包含.env;代码中用os.environ.get("KEY")读取。 - 日志脱敏:记录请求/响应时移除
Authorization、password等字段。 - 依赖审计:
uv lock后审阅新增依赖;避免引入已知 CVE 的包。
性能要点
- 避免重复计算:循环内的
resolved_spec/inspect.signature等查询应缓存 或预构建映射(如{name: spec})。 - 避免双重查找:
has(k)+get(k)改为单次get(k)+KeyError回退。 - 统一校验:入口校验一次,下游路径不重复校验(如
run()统一validate(),layers()不再重复)。 - 事件 emit:任务生命周期必须 emit
RUNNING→SUCCESS/FAILED/SKIPPED, 不要留死分支(# pragma: no cover是清理信号,应激活或删除)。
Git 与提交
- 不自动提交:除非用户明确要求。
- 不自动 push:除非用户明确要求。
- 不修改 git config。
- 不运行破坏性命令(
push --force/reset --hard/clean -f)除非用户明确要求。 - staging:按文件名添加,不用
git add -A/git add .,避免误加敏感文件。 - commit message:简洁,聚焦"为什么"而非"是什么";遵循仓库既有风格。