Compare commits

...

55 Commits

Author SHA1 Message Date
zhou e781bb1f4f bump version to 0.4.9
CI / Lint, Typecheck & Test (push) Failing after 1m52s
Release / Build, Publish & Release (push) Successful in 53s
2026-07-08 15:31:28 +08:00
zhou 9b7ca42761 fix: 修复 pf pymake -h 不识别和 pf bumpversion 位置参数报错
pymake 的 bumpversion/bumpmi 子命令调用 pf bumpversion 时用 --part 选项而非位置参数;typer app 添加 -h 作为 --help 短别名。
2026-07-08 15:31:23 +08:00
zhou bb57d85a10 style(test_pipelines): 格式化测试文件中的列表缩进排版
CI / Lint, Typecheck & Test (push) Failing after 2m53s
统一调整测试文件里函数调用参数列表的缩进格式,让代码排版更统一易读
2026-07-08 15:22:41 +08:00
zhou 8bfa0da590 fix: 修复 _make_dispatcher 前向引用解析导致的 NameError
CI / Lint, Typecheck & Test (push) Has been cancelled
from __future__ import annotations 使注解变为字符串,dispatcher.__globals__ 是 tools.py 命名空间不含用户模块自定义类型(如 BumpVersionType),typer 调 get_type_hints 时 NameError。用原始函数 __globals__ 预解析后写入实际类型。
2026-07-08 15:22:03 +08:00
zhou ae4cc3aeeb feat: 新增 px.sh 命令执行封装 — 支持 list[str](直接执行)/ str(shell=True),统一中文错误处理,失败返回 None 不抛异常;sglang.py 首个迁移示例
CI / Lint, Typecheck & Test (push) Failing after 2m8s
2026-07-08 14:56:54 +08:00
zhou fbabbc98ad feat: 任务优先级调度 + 条件分支构造器 + 大图增量就绪集优化 — DependencyRunner 重写为事件驱动优先级调度;新增 px.switch/px.branch DAG 构造器;_build_dependency_index 替代 O(N) 每轮扫描使大图调度从 O(N²) 降至 O(N),10k 任务 <1s
CI / Lint, Typecheck & Test (push) Failing after 1m38s
2026-07-08 12:28:43 +08:00
zhou bd3651e337 feat: 改进 API 简洁性 — depends_on 自动推断 + px.graph() 快捷构造 + 文档现代化
CI / Lint, Typecheck & Test (push) Failing after 1m37s
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
zhou 05ca3950a2 perf: 优化执行热路径,sequential 吞吐提升 22%
CI / Lint, Typecheck & Test (push) Failing after 1m27s
env_context 快速路径跳过无 env/cwd 任务的上下文管理器创建;
should_execute 无条件时早退;_filter_and_sort 返回 specs dict
消除 Layer runner 重复 resolved_spec 调用;storage_key 内联判断。
2026-07-08 08:29:42 +08:00
zhou 6c0b6af21b feat: P12 通用 DAG 构造器 — pipelines.py 提供 data_pipeline(函数式数据流水线)/command_chain(命令链)/fan_out_fan_in(map-reduce)3 个构造器,减少手动 TaskSpec 样板代码;37 测试含 4 个 fileops 联用示例,pipelines.py 100% 覆盖率
CI / Lint, Typecheck & Test (push) Failing after 1m59s
2026-07-07 22:41:23 +08:00
zhou 9964db771e feat: P11 文件操作底层 API — fileops.py 20 个纯函数(查找遍历/读写/复制移动删除/元数据校验)供 DAG 组合调用,返回数据不打印;80 测试含 5 个 DAG 组合示例,1640 测试全绿,覆盖率 97.17%
CI / Lint, Typecheck & Test (push) Has been cancelled
2026-07-07 21:39:13 +08:00
zhou 919c6a4544 docs: P10 图片处理文档同步 — iter-22 迭代记录、SKILL.md 第十五章(可复用模式/设计决策/踩坑总结)、project_memory P10 约定;与 8e3ddc5 代码提交一并推送
CI / Lint, Typecheck & Test (push) Failing after 2m58s
2026-07-07 20:10:44 +08:00
zhou 8e3ddc5a1e feat: 新增图片处理工具链与imagetool CLI命令
新增完整的图片处理能力:
1. 新增imagetool CLI工具,支持resize/crop/rotate/flip/convert/watermark/compress等基础操作,以及info/exif/histogram/colors元数据查看功能
2. 新增image_pipeline流水线构造器,支持链式编排多步图片处理DAG
3. 注册CLI别名image/img到imagetool,导出image_pipeline到顶层API
4. 配套新增完整单元测试与文档
2026-07-07 19:57:35 +08:00
zhou be2273722f docs: 同步 SKILL.md 至 P9 — 新增第十四章(任务编排/数据流/检查点恢复/RunHistory)与第九章监控导出小节,覆盖 iter-21 行为变更
CI / Lint, Typecheck & Test (push) Failing after 1m34s
2026-07-07 18:39:54 +08:00
zhou 5ac7fd33c6 feat: P9 功能扩展 — 任务编排(group/dynamic)/数据流(output_of/pipeline)/持久化(resume_from/RunHistory)/监控导出(MetricsCollector/health_check/HTTP server);1525 测试全绿,覆盖率 97.58%
CI / Lint, Typecheck & Test (push) Failing after 1m31s
2026-07-07 17:55:38 +08:00
zhou 07adbad847 refactor: 移除 graphlib 相关引用 — graph.py docstring 不再提 graphlib 对比、python-standards.md 删除 graphlib_backport 依赖说明(项目已自实现 Kahn 算法)、SKILL.md 归档章节清理 graphlib 对比描述
CI / Lint, Typecheck & Test (push) Has been cancelled
2026-07-07 17:17:13 +08:00
zhou 8b8598fa41 perf: P8 性能优化 — 自实现 Kahn 拓扑排序替换 graphlib(diamond-1000 1.62x)、_json.py 抽象层支持 orjson 可选加速、dataclass slots=True 内存优化(TaskSpec 15.5% 减少)、线程池跨层复用与 get_running_loop 兼容 3.12+;iter-16~20 归档到 skills
CI / Lint, Typecheck & Test (push) Has been cancelled
2026-07-07 17:11:34 +08:00
zhou a9f15e236d feat: P7 功能增强 — 通知器扩展(Slack/Discord/Telegram)、HTML 报告导出(to_html)、YAML 增强(matrix include-exclude/条件依赖/outputs)、缓存增强(invalidate 单键失效/MemoryBackend LRU 驱逐)
CI / Lint, Typecheck & Test (push) Has been cancelled
2026-07-07 16:26:46 +08:00
zhou 9573f1f2a2 refactor: 质量收尾 — diagnostics.py pragma 清理(4 处:1 激活 3 删除)、context.py 与 reseticoncache.py 覆盖率提升至 100%、README 同步 pyrefly、iter-06~15 归档到 skills/pyflowx-development/SKILL.md。
CI / Lint, Typecheck & Test (push) Has been cancelled
2026-07-07 15:45:21 +08:00
zhou 18ffae8390 perf: 性能优化深入 — build_call_args fn-no-deps 快速路径(3.7x)、JSONBackend batch 延迟验证(16.5%)、from_specs 批量注册;新增 8 个高级基准场景;pyproject 加 search-path 解析 benchmarks 包。
CI / Lint, Typecheck & Test (push) Has been cancelled
2026-07-07 15:28:52 +08:00
zhou 5831c92f97 fix(ops): 修复 dockercmd/msdownload/sglang 的 check=False 静默失败问题,改用 check=True + try/except 打印 rich 错误信息;taskkill 检查 returncode 打印结果;clr/which 加注释说明 check=False 原因;补 9 个错误路径测试覆盖 CalledProcessError/FileNotFoundError。
CI / Lint, Typecheck & Test (push) Has been cancelled
2026-07-07 14:44:48 +08:00
zhou 02ef858718 feat: CLI 体验增强 — yamlrun --list 过滤, graph --color-by tag 着色, pf info 工具详情, pf completion shell 补全
CI / Lint, Typecheck & Test (push) Has been cancelled
2026-07-07 14:35:16 +08:00
zhou 5e6daa554f feat: 观测性增强 — RunReport.run_id 贯穿日志/序列化, 结构化 extra 字段, profile() 便捷方法
CI / Lint, Typecheck & Test (push) Has been cancelled
2026-07-07 14:08:18 +08:00
zhou 520694f2df feat: 失败诊断增强 — RunReport.diagnose() 根因分析与依赖链回溯
CI / Lint, Typecheck & Test (push) Has been cancelled
新增 diagnostics 模块:根因识别、依赖链 BFS 回溯、相似失败聚类、
常见异常可操作建议;TaskFailedError 携带 report 供 CLI 打印诊断摘要。
2026-07-07 13:52:43 +08:00
zhou 8d626f606e feat: 新增 pf graph 终端 DAG 可视化命令与 yamlrun --progress 选项
CI / Lint, Typecheck & Test (push) Has been cancelled
pf graph 支持 ASCII 分层树/Mermaid/list/deps 四种输出格式;
yaml_loader._safe_load 包装 YAMLError 为 ValueError 统一错误处理。
2026-07-07 12:58:44 +08:00
zhou 6499210dc9 feat: RunReport 结果序列化支持 to_json/to_dict/to_csv/from_json
CI / Lint, Typecheck & Test (push) Has been cancelled
新增 RunReport 序列化方法,支持 JSON/dict/CSV 多格式导出与反序列化重建;
非 JSON 可序列化值默认回退到 repr,可通过 value_serializer 自定义。
2026-07-07 12:51:06 +08:00
zhou 7b5e4864c2 feat: 新增子图执行 — Graph.subgraph_with_deps 计算传递闭包, run(only=/tags=) 按名称或标签运行图子集, yamlrun CLI 支持 --only/--tags; 修复 test_executors.py 中 TaskStatus 缺少 px. 前缀的预存问题
CI / Lint, Typecheck & Test (push) Has been cancelled
2026-07-07 12:44:50 +08:00
zhou 6614dc5a8d feat(cancellation): 新增任务取消与优雅停止 — CancelToken 线程安全取消令牌, run(cancel_event=) 支持外部取消与 KeyboardInterrupt 优雅停止; 各策略在层/任务边界检查取消信号, 待运行任务标记 SKIPPED; 提取 _dispatch_strategy 辅助函数控制 run() PLR0912 分支数
CI / Lint, Typecheck & Test (push) Has been cancelled
2026-07-07 11:53:27 +08:00
zhou 98c3d84334 perf: 添加性能基准套件并优化 layers 缓存与 cmd 快速路径
CI / Lint, Typecheck & Test (push) Has been cancelled
新增 benchmarks 模块覆盖图构建/执行/上下文注入/状态后端四个维度;
Graph.layers() 结果缓存避免重复拓扑排序,cmd 任务跳过签名内省。
2026-07-07 11:38:37 +08:00
zhou 9f50bb3e7c feat: 实现 GitHub Actions 风格 YAML 任务编排
CI / Lint, Typecheck & Test (push) Has been cancelled
新增 yaml_loader 模块支持 jobs/needs/strategy.matrix/if 条件/env/defaults 等 CI/CD 概念,
Graph.from_yaml classmethod 与 pf yamlrun CLI 命令;矩阵笛卡尔积展开与依赖自动展开,
${{ matrix.key }} 占位符替换,字段名 hyphen/underscore 兼容。
2026-07-07 11:27:16 +08:00
zhou 8cfdef7516 docs: 对齐 README 与实际代码状态, 清理过期文档 — 移除未实现的 YAML 编排特性声明与整章, 修正覆盖率徽章 100%→97%, 修正依赖描述(实际 rich+typer+typing-extensions, 无 PyYAML), 修正 CLI 模块路径(profiler/emlmanager 已迁至 cli/legacy/); 归档 iter-01~05 迭代记录(设计决策已存 project_memory.md), 删除过期重构计划文档; 保留 typing_extensions 守卫(3.12/3.13 前向兼容非 3.8 死代码)
CI / Lint, Typecheck & Test (push) Failing after 1m26s
2026-07-07 11:11:04 +08:00
zhou 505e40012e feat(executors): 新增 run_iter() 流式执行 API — 生成器逐个 yield (name, TaskResult), 后台线程+队列实现, 支持 4 种策略; _finalize_failure 在抛出前写入 report.results 使失败任务可被 yield; run() 新增内部 _report 参数支持结果共享
CI / Lint, Typecheck & Test (push) Failing after 1m50s
2026-07-07 10:45:41 +08:00
zhou a32c65c8a0 feat(notification): 新增任务通知系统 — Notifier 协议 + CallbackNotifier/WebhookNotifier 及飞书/钉钉/企业微信实现, run(notifiers=) 支持单值/列表, 事件级别过滤, 提取 _finish_monitoring 控制 run 分支数, 通知失败仅记录日志不影响任务执行
CI / Lint, Typecheck & Test (push) Failing after 1m44s
2026-07-07 10:29:36 +08:00
zhou 936e0d2a26 feat(progress): 新增实时进度监控 — ProgressCallback 协议 + RichProgressMonitor, run(progress=) 支持 bool/自定义监控器, 与 on_event/verbose 共存, 提取 _resolve_progress 控制 run 分支数
CI / Lint, Typecheck & Test (push) Failing after 1m26s
2026-07-07 10:17:42 +08:00
zhou 0ecda36759 feat(storage): 新增 SQLiteBackend 状态后端 — 基于 sqlite3 按键查询, 支持 TTL/batch/线程安全, 解决 JSONBackend 全量加载瓶颈, 作为 drop-in 替换
CI / Lint, Typecheck & Test (push) Failing after 1m22s
2026-07-07 10:10:40 +08:00
zhou b71c90ba5c refactor(ops): 整合 tasks/ 到 ops/system — setenv/writefile 转为 @px.tool, 丢弃重叠的 clr/reset_icon_cache/which 工厂, 删除 tasks 目录
CI / Lint, Typecheck & Test (push) Failing after 14m13s
2026-07-07 09:43:27 +08:00
zhou d733452c8a refactor(ops): 按功能划分重构 ops/ 结构 — 22 个工具归入 files/dev/system/infra 子包, pf 动态导入注册表, 测试导入路径同步
CI / Lint, Typecheck & Test (push) Failing after 1m32s
2026-07-07 08:43:36 +08:00
zhou c502556db9 refactor(cli): legacy 工具归入 cli/legacy/ — emlmanager 与 profiler 移至子目录, 入口点与导入路径同步更新 2026-07-07 08:33:25 +08:00
zhou e1824b9f5a fix(cli): 修复 pf 工具缺失必填参数时无错误提示 — standalone_mode=False 下手动调用 rich_format_error 打印 ClickException
CI / Lint, Typecheck & Test (push) Failing after 1m20s
2026-07-07 08:17:04 +08:00
zhou 5e93a75987 refactor(ops): 清理无用代码与过时文档 — 删除 gittool 兼容别名、folderzip 未用 IGNORE 变量、过时迁移计划文档
CI / Lint, Typecheck & Test (push) Failing after 1m3s
2026-07-07 03:02:24 +08:00
zhou 529a024ea1 feat(cli): rich 深度集成 — 执行生命周期 ✓/✗/▸/○ 符号 + dry-run + 命令输出
CI / Lint, Typecheck & Test (push) Failing after 1m59s
2026-07-06 22:27:25 +08:00
zhou 4721b59f40 feat(cli): 重构CLI并添加富文本输出与版本/帮助优化
CI / Lint, Typecheck & Test (push) Failing after 5m41s
- 使用rich库重构工具列表展示为美观的表格形式
- 添加--version/-V参数显示版本号
- 新增未知工具的模糊匹配提示功能
- 统一使用标准错误输出和富文本样式打印错误信息
- 优化命令行参数处理逻辑,支持先解析help/version参数
2026-07-06 22:16:12 +08:00
zhou fca6f17a5c chore: 升级项目适配 Python 3.10+ 并重构代码
1.  移除对 Python 3.8/3.9 的支持,更新 tox、pyproject.toml 配置
2.  替换 typing 导入为 collections.abc 标准库类型
3.  重构 CLI 系统从 argparse 迁移到 typer
4.  优化代码格式与类型注解,修复多处类型兼容问题
5.  更新依赖声明,移除 graphlib_backport 兼容包
2026-07-06 22:14:59 +08:00
zhou d493c6233e docs(rules): 补充多阶段项目约束与更新规则说明
新增开发流程约束文档dev-workflow.md,完善self-driven.md中的多阶段项目规则,
补充阶段迭代、收尾的判定逻辑,修正目标达成的暂停条件说明
2026-07-06 21:16:07 +08:00
zhou 7a87a4177e feat(ops): 新增 _common 共享辅助模块消除跨平台重复代码
CI / Lint, Typecheck & Test (push) Failing after 3m39s
新增 src/pyflowx/ops/_common.py 提供 platform_command/ensure_platform/IGNORE_PATTERNS 三个公共 API, 配套 12 个单元测试; 修复上个提交中 8 个 ops 模块引用缺失模块导致的 ImportError.
2026-07-06 20:40:38 +08:00
zhou 04d6871e8d chore: 发布v0.4.8版本,清理冗余代码并优化跨平台实现
1. 移除folderzip和folderback模块中的默认导出函数与测试用例
2. 重构跨平台命令获取逻辑,统一使用platform_common工具函数
3. 移动IGNORE_PATTERNS常量到公共模块,精简packtool和autofmt代码
4. 重构平台检查逻辑,复用ensure_platform减少重复代码
5. 优化pdftool模块的依赖检查逻辑,提取公共检查函数
6. 修复测试用例中的字符串断言冗余内容
2026-07-06 20:22:59 +08:00
zhou 08149d990d bump version to 0.4.8
CI / Lint, Typecheck & Test (push) Failing after 1m49s
Release / Build, Publish & Release (push) Successful in 46s
2026-07-06 13:19:24 +08:00
zhou 54aef98f3a chore(gittool): 新增ruff缓存目录到排除列表
CI / Lint, Typecheck & Test (push) Failing after 1m52s
将.ruff_cache添加到EXCLUDE_DIRS列表中,避免git工具扫描该缓存目录
2026-07-06 13:11:59 +08:00
zhou e31646e281 feat: 新增envdev、gittool、msdownload、pymake工具模块及对应测试用例
新增四个业务工具模块:
1. envdev: 开发环境镜像源配置工具,支持Python/Conda/Rust镜像配置及Linux系统环境配置
2. gittool: Git操作工具,提供提交、初始化、清理、推送等快捷子命令
3. msdownload: ModelScope模型/数据集下载工具
4. pymake: 项目构建工具,覆盖构建、测试、发布等全流程开发操作

同时为每个工具模块编写了完整的注册验证与功能测试用例
2026-07-06 13:11:09 +08:00
zhou f7fb95af83 refactor: 重构项目架构,移除YAML编排与旧注册系统
1.  将所有YAML配置的工具迁移到`@px.tool`装饰器实现
2.  拆分`llm`模块为`msdownload`和`sglang`子模块
3.  移除废弃的`registry.py`、`yaml_loader.py`和`yamlrun.py`模块
4.  清理项目依赖,移除PyYAML相关包
5.  更新文档与测试用例适配新架构
2026-07-06 13:04:57 +08:00
zhou 6da42ec5ff refactor: 移除YAML配置,统一使用@px.tool装饰器实现CLI工具
1.  新增tools.py模块实现@px.tool装饰器与工具注册表
2.  将所有configs下的YAML工具迁移为ops/xxx.py模块形式
3.  重构cli路由逻辑,优先加载Python工具实现回退YAML
4.  删除所有YAML配置文件与旧的yaml_loader相关代码
5.  调整__init__.py导出API,移除YAML相关依赖
2026-07-06 07:53:22 +08:00
zhou c55a37173a test: 新增 YamlCliRunner 错误分支测试使覆盖率达标 95%
CI / Lint, Typecheck & Test (push) Successful in 3m5s
2026-07-05 22:25:15 +08:00
zhou 960b8672f4 test: 新增截图、PDF工具相关的单元测试
1. 新增Linux平台下截图工具回退到scrot的测试用例
2. 新增pdf加密、解密、重排、PDF转图片的完整测试用例
3. 补充各PDF工具未安装依赖时的分支测试
2026-07-05 22:21:47 +08:00
zhou 4fd1d70b58 test: 修复跨平台路径断言不兼容问题
1. 调整测试用例中的路径比较逻辑,统一替换反斜杠为正斜杠适配Windows平台
2. 将原生echo/true/false命令替换为跨平台的python执行命令,避免环境依赖问题
2026-07-05 22:18:16 +08:00
zhou 6fb9223066 fix: pymake bump 调用 pf bumpversion 而非已移除的外部 bumpversion 命令
CI / Lint, Typecheck & Test (push) Successful in 1m50s
2026-07-05 22:09:28 +08:00
zhou 1f7127357e refactor: 将 cli/system、cli/llm、cli/dev 脚本迁移为 YAML 配置 + register_fn 模式
CI / Lint, Typecheck & Test (push) Successful in 1m44s
删除 cli/system (clearscreen/taskkill/which)、cli/llm (msdownload/sglang)、cli/dev (dockercmd/envdev) 三个目录; 新增 7 个 YAML 配置; 新增 ops/llm.py 模块; 扩展 ops/system.py 和 ops/dev.py; pf.py 添加工具别名; 删除 examples/ 目录
2026-07-05 21:12:41 +08:00
187 changed files with 25070 additions and 11938 deletions
+1 -2
View File
@@ -38,9 +38,8 @@ env
*.swp
*.swo
# 文档与示例(按需保留)
# 文档(按需保留)
docs
examples
# 系统文件
.DS_Store
+1
View File
@@ -14,3 +14,4 @@ wheels/
# Sphinx 文档构建输出
docs/_build/
.trae/refs
@@ -0,0 +1,93 @@
# 迭代 21 — P9 功能扩展(任务编排/数据流/持久化/监控)
## 本轮目标
延续 P8 性能优化后,扩展 PyFlowX 在四个方向的核心能力:
1. **P9.1 任务编排增强**:任务分组(`Graph.group`+ 动态任务生成(`TaskSpec(dynamic=True)`)。
2. **P9.2 数据流增强**`RunReport.output_of` 命名输出提取 + `Graph.pipeline` 语义别名。
3. **P9.3 持久化与恢复**`run(resume_from=...)` 检查点恢复 + `RunHistory` 历史管理。
4. **P9.4 监控导出**`MetricsCollector`Prometheus 文本)+ `health_check` + `start_metrics_server`(零依赖 HTTP)。
## 改动文件清单
### 新增
- `src/pyflowx/monitoring.py``MetricsCollector` / `health_check` / `start_metrics_server`
- `src/pyflowx/history.py``RunHistory` 文件系统历史管理。
- `tests/test_group.py` — 13 用例(P9.1.2 任务分组)。
- `tests/test_dynamic.py` — 11 用例(P9.1.3 动态任务)。
- `tests/test_dataflow.py` — 12 用例(P9.2 数据流)。
- `tests/test_persistence.py` — 15 用例(P9.3 检查点/历史)。
- `tests/test_monitoring.py` — 16 用例(P9.4 监控导出)。
### 修改
- `src/pyflowx/task.py``TaskSpec` 新增 `dynamic: bool = False` 字段;`task()` 工厂新增 `dynamic` 参数。
- `src/pyflowx/graph.py` — 新增 `group()` 方法;`_rewrite_deps_for_loops` 改名 `_rewrite_deps`,统一处理 `_loop_groups` + `_groups`;新增 `pipeline()` 语义别名。
- `src/pyflowx/report.py` — 新增 `_resolve_output_path` 辅助 + `RunReport.output_of(name, output)` 方法。
- `src/pyflowx/executors.py`
- `DependencyRunner.execute` 增加 while 循环 + `f.exception()` 手动传播(替代 `asyncio.gather`)。
- 新增 `_extract_dynamic_specs` 辅助;`_run_task` 检测动态生成、注册派生 spec、启动派生 task。
- `_dispatch_strategy` 增加 `has_dynamic` 校验(仅 `dependency` 策略支持)。
- 新增 `_load_resume_report` + `_apply_resume` 辅助;`run()` 接受 `resume_from` 参数。
- `_filter_and_sort` + `_run_task` 检查点跳过逻辑。
- `src/pyflowx/__init__.py` — 导出 `RunHistory` / `MetricsCollector` / `health_check` / `start_metrics_server`
## 关键决策与依据
### 1. 动态任务仅支持 `dependency` 策略
`dynamic=True` 允许任务 `fn` 运行时返回 `TaskSpec | list[TaskSpec]`DependencyRunner 中途注册并调度派生任务。层模型(sequential/thread/async)的层屏障与动态扩展语义冲突,因此 `_dispatch_strategy` 显式拒绝其他策略。
依据:层模型按拓扑分层执行,动态任务可能跨层;依赖驱动模型无层屏障,可自然容纳运行时新增节点。
### 2. `asyncio.wait` 替代 `asyncio.gather` + 手动异常传播
`asyncio.gather` 在 first-failure 时取消其他任务并 re-raise,但所有 task 必须在调用前确定。动态任务场景下 `futures` 字典会随执行增长,必须用 while 循环 + `asyncio.wait(return_when=FIRST_COMPLETED)` 增量调度。
陷阱:`asyncio.wait` **不会**自动 re-raise task 异常。需手动 `f.exception()` 检查并显式 `raise`,同时取消未完成任务,才能与 `gather` 的 fail-fast 行为一致。该陷阱导致 7 个测试失败后定位修复。
### 3. 检查点恢复:仅恢复 SUCCESS 任务
`resume_from` 接受 `RunReport | str | Path`,仅恢复 `status == SUCCESS` 且名称在当前图中的任务。FAILED/SKIPPED 任务会重新执行。`_apply_resume` 抽取为独立函数以控制 `run()` 的 PLR0912 分支数(≤12)。
### 4. `metrics_text` 按 section 条件化输出
最初实现总是输出 `# HELP` / `# TYPE` 行(即便无数据)。这导致 `reset()` 后文本仍包含 `pyflowx_task_total` 字样,违反测试预期。改为所有 section(含 task_total/duration/duration_sum)都按数据存在性条件化,与 retries/run_total 一致。
### 5. `start_metrics_server` 用 `http.server` 零依赖
未引入 `prometheus_client` 等三方库,仅用标准库 `http.server.HTTPServer` + `BaseHTTPRequestHandler`。生产环境可叠加反向代理或替换为 `prometheus_client``@override` 装饰器按 Python 版本条件导入(`typing` 3.12+ / `typing_extensions` <3.12)。
### 6. `RunHistory` 文件命名与 `__contains__`/`__len__`
`{run_id}.json` 命名存储;`__contains__` / `__len__` 委托文件存在性检查,避免维护内存索引。`latest()` 按 mtime 排序取最新。
## 验证结果
```
ruff check . → All checks passed!
ruff format --check → 126 files already formatted
pyrefly check . → 0 errors (68 suppressed)
pytest --cov=pyflowx → 1525 passed in 11.56s
coverage → 97.58%branch;门槛 95%
```
各模块覆盖率(P9 新增部分):
- `monitoring.py` 99%(仅 `typing_extensions` 回退分支未覆盖)
- `history.py` 95%
- `executors.py` 97%dynamic/resume 分支已覆盖)
- `report.py` 99%`output_of` 路径解析已覆盖)
- `graph.py` 98%`group`/`pipeline`/`_rewrite_deps` 已覆盖)
## 遗留事项
- 监控导出未实现 OpenTelemetry 协议(当前仅 Prometheus 文本格式)。如需 OTLP,可在后续迭代引入 `opentelemetry-sdk`(违反零依赖原则,需用户确认)。
- `RunHistory` 未实现压缩/归档;大量历史运行可能占用较多磁盘。后续可加 `max_entries` 或滚动归档。
- 动态任务当前仅在 DependencyRunner 中实现;如层模型用户也需要,需额外设计跨层调度策略。
- `_apply_resume` 仅恢复 `report.results`,未恢复 `backend` 缓存;如需检查点+缓存联合恢复,需额外设计。
## 归档说明
iter-21 暂不归档(前 5 次 iter-16~20 已归档至 `.trae/skills/pyflowx-development/SKILL.md`)。下次清理窗口为 iter-26 前夕。
+174
View File
@@ -0,0 +1,174 @@
# 迭代 22 — P10 图片处理(imagetool + image_pipeline
## 本轮目标
延续 P9 功能扩展后,补齐 PyFlowX 在图片处理场景的能力,覆盖三个方向:
1. **P10.1 基础操作子命令**`imagetool` 工具的 7 个基础操作子命令
resize/crop/rotate/flip/convert/watermark/compress)。
2. **P10.2 元数据与信息子命令**4 个元数据子命令
info/exif/histogram/colors)。
3. **P10.3 批量 DAG 编排**`image_pipeline` 链式 DAG 构造器,
将多步图片操作封装为 `Graph`,前一步输出作为后一步输入。
工具形态:单一 `imagetool` + `office` extraPillow 通过 `pyflowx[office]` 安装),
不破坏项目零运行时依赖原则。
## 改动文件清单
### 新增
- `src/pyflowx/ops/files/imagetool.py` — 11 个 `@px.tool("imagetool", ...)`
子命令 + `_save_image`/`_load_font`/`_resolve_position`/`_print_exif`/
`_apply_exif_modifications`/`_apply_single_exif_set`/`_save_exif`/
`_print_histogram_buckets` 辅助函数(520 行)。
- `src/pyflowx/imaging.py``image_pipeline()` 链式 DAG 构造器 +
`_make_step_fn` 闭包工厂(130 行)。
- `tests/cli/test_imagetool.py` — 25 用例覆盖 11 子命令 + `_require_pil`
守卫(316 行)。
- `tests/test_imaging.py` — 9 用例覆盖 DAG 拓扑与执行(158 行)。
### 修改
- `src/pyflowx/__init__.py` — 导出 `image_pipeline`,加入 `__all__`
- `src/pyflowx/cli/pf.py``_TOOL_MODULES` 注册
`"imagetool": "pyflowx.ops.files.imagetool"``_TOOL_ALIASES` 添加
`"image"`/`"imagetool"`/`"img"` 三个别名。
## 关键决策与依据
### 1. 单一 `imagetool` + office extra
不拆分为 `imageops`/`imagemeta` 等多工具,所有图片操作集中在
`ops/files/imagetool.py`。Pillow 通过 `pyproject.toml``[office]`
extra 安装(`pillow>=10.4.0`),运行时 try/except 导入 + `HAS_PIL` 标志,
未安装时 `_require_pil()` 打印 `pip install pyflowx[office]` 提示而非崩溃。
依据:项目零运行时依赖原则不可破坏;pdftool 已有相同模式
`pypdf` 通过 `[office]` extra)。
### 2. `_save_image` 统一保存逻辑
所有需要保存图片的子命令(resize/crop/rotate/flip/convert/watermark/compress
委托 `_save_image(img, output, fmt, quality)` 统一处理:
- **JPG → JPEG 规范化**`save_fmt == "JPG"` 时改写为 `"JPEG"`
否则 Pillow `img.save(format="JPG")``KeyError: 'JPG'`
- **JPEG 强制 RGB 转换**JPEG 不支持 alpha 通道,保存前
`img.convert("RGB")` 去 alpha,避免 `cannot write mode RGBA as JPEG`
- **JPEG/WEBP 应用 quality**:其他格式(PNG/BMP/GIF 等)忽略 quality。
依据:避免每个子命令重复处理格式规范化与 alpha 通道问题。
### 3. EXIF 用 `exif.tobytes()` 而非 `bytes(exif)`
Pillow 10+ 的 `Image.Exif` 对象,`bytes(exif)` 调用抛
`ValueError: bytes must be in range(0, 256)`。必须用 `exif.tobytes()`
序列化为字节串后再传给 `img.save(exif=exif_bytes)`
`_save_exif` 辅助函数封装此逻辑:
```python
exif_bytes = exif.tobytes() if exif else b""
img.save(output_path, exif=exif_bytes)
```
### 4. `quantize(colors=N)` 实际色数可能 < N
`Image.quantize(colors=count)` 对简单图片(如纯色)可能返回少于
请求色数的调色板。直接按 `count` 遍历会 `IndexError: list index out of range`
修复:用 `actual_count = len(palette) // 3` 动态计算实际色数,
`min(count, actual_count)` 防越界:
```python
actual_count = len(palette) // 3
print(f"主色调 (前 {min(count, actual_count)} 色):")
for i in range(min(count, actual_count)):
...
```
### 5. Pillow 10+ Transpose 枚举迁移
`Image.FLIP_LEFT_RIGHT`(模块级常量)在 Pillow 10+ 已弃用,
pyrefly 无法解析。必须用 `Image.Transpose.FLIP_LEFT_RIGHT`
(枚举形式)。
`image_flip` 中根据 direction 选择枚举:
```python
method = Image.Transpose.FLIP_LEFT_RIGHT if direction == "horizontal" else Image.Transpose.FLIP_TOP_BOTTOM
flipped = img.transpose(method)
```
### 6. `_make_step_fn` 闭包工厂避免循环变量捕获
`image_pipeline` 中若直接在 for 循环内定义闭包:
```python
for i, (op_name, params) in enumerate(steps):
def _run():
base_fn(input_path, output_path, **params) # 陷阱!
...
```
所有任务的 `_run` 会捕获**最后一次循环**的 `input_path`/`output_path`/`params`
导致所有步骤操作同一文件。
修复:提取为独立函数 `_make_step_fn(base_fn, input_path, output_path, params)`
利用每次调用的独立作用域正确捕获:
```python
def _make_step_fn(base_fn, input_path, output_path, params):
def _run() -> None:
base_fn(input_path, output_path, **params)
return _run
```
每次循环调用 `_make_step_fn(...)` 创建新作用域,闭包绑定该次调用的参数。
### 7. PLR0912 分支数控制
`image_exif` 函数最初 13 分支(show/clear/set 各分支 + 错误处理)超过
ruff `PLR0912` 限制(≤12)。提取 4 个辅助函数降至合规:
- `_print_exif(exif)` — 打印 EXIF 标签
- `_apply_exif_modifications(exif, set_items, clear)` — 应用 clear + set
- `_apply_single_exif_set(exif, item)` — 解析单个 KEY=VALUE
- `_save_exif(img, exif, output_path)` — 保存图片与 EXIF
## 验证结果
```
ruff check . → All checks passed!
ruff format --check → 131 files already formatted
pyrefly check . → 0 errors (68 suppressed)
pytest --cov=pyflowx → 1560 passed in 11.72s
coverage → 97.15%branch;门槛 95%
```
各模块覆盖率(P10 新增部分):
- `imagetool.py` 88%213 stmts, 19 miss — 主要是 ImportError 回退分支
与少量错误路径;总覆盖率 97.15% 不受影响)
- `imaging.py` 100%`image_pipeline` 拓扑/执行/convert 扩展名/naming 模板
全覆盖)
CLI 注册验证:
- `imagetool``_TOOL_MODULES`
- `img`/`image`/`imagetool` 三个别名在 `_TOOL_ALIASES`
- `tests/cli/test_tool_modules.py` 自动覆盖新工具注册
## 遗留事项
- **P11 候选方向**(用户已确认为后续阶段输入,不在 P10 范围内):
- OCR 文字识别(需 `pytesseract` 新依赖,违反零依赖原则,需用户确认)
- 滤镜与增强(模糊/锐化/边缘检测/浮雕/去噪,纯 Pillow 可实现)
- 批量文件夹处理(遍历目录批量应用同一操作,生成缩略图网格)
- 图片拼接与 GIF(多图水平/垂直拼接,GIF 帧拆分与合成)
- **`imagetool.py` 覆盖率 88%**ImportError 回退分支(`HAS_PIL = False`
在已安装 Pillow 的环境中无法触发,属可接受缺口。
- **`image_pipeline` 仅支持链式 DAG**:当前实现是单链(每步依赖前一步),
不支持分叉/汇合(如同一源图应用多操作后合并)。如需分叉,可扩展
`steps` 为 DAG 描述结构。
## 归档说明
iter-22 暂不归档(前 5 次 iter-16~20 已归档至
`.trae/skills/pyflowx-development/SKILL.md`iter-21/22 保留在 docs/)。
下次清理窗口为 iter-26 前夕,届时 iter-21/22 一并归档。
+135
View File
@@ -0,0 +1,135 @@
# 迭代 23 — P11 文件操作底层 APIfileops 模块)
## 本轮目标
补齐 PyFlowX 在文件操作场景的**底层 API**能力,供 DAG 组合调用。
`ops/files/` 下的 CLI 工具(pdftool/imagetool/filedate 等)分层:
本模块函数**返回数据**(不打印),可被 `px.task` / `px.TaskSpec` 包装后在
DAG 中通过 `outputs` 声明 + `report.output_of()` 提取,或通过参数名注入
传递给下游任务。
四个方向:
1. **查找遍历**`find` / `glob` / `walk_files`
2. **读写**`read_text` / `read_bytes` / `write_text` / `write_bytes` /
`append_text`
3. **复制移动删除**`copy` / `move` / `delete` / `copy_tree`
4. **元数据校验**`size` / `mtime` / `exists` / `is_file` / `is_dir` /
`sha256` / `stem` / `suffix`
## 改动文件清单
### 新增
- `src/pyflowx/fileops.py` — 20 个纯函数 API(157 行),仅依赖标准库
`pathlib` / `hashlib` / `shutil`)。
- `tests/test_fileops.py` — 80 用例,覆盖 4 类 API + 5 个 DAG 组合调用示例
`outputs` + `output_of` 数据流验证)。
### 修改
- `src/pyflowx/__init__.py``from . import fileops` + `"fileops"` 加入
`__all__`(导出模块本身,不污染顶层命名空间)。
## 关键决策与依据
### 1. 纯函数模块,不带 `@px.tool` 装饰器
`fileops.py` 的函数全部是纯函数,返回数据(`list[Path]` / `str` / `bytes` /
`int` / `bool` / `Path` 等),不打印,不注册为 CLI 子命令。
依据:用户明确要求"底层 api 供组合调用"。与 CLI 工具分层:
- CLI 工具(pdftool/imagetool):面向终端用户,函数返回 `None`,打印结果
- 底层 API(fileops):面向编程组合,函数返回数据,可被 `px.task` 包装
### 2. 导出模块而非 20 个函数到顶层 `__all__`
`fileops` 有 20 个函数,名字较通用(`find` / `exists` / `size` / `suffix`)。
全部导出到 `px.find` / `px.exists` 会污染顶层命名空间,与用户代码命名冲突。
决策:`from . import fileops` + `"fileops"` 加入 `__all__`
- `import pyflowx as px; px.fileops.find(...)` 可用
- `from pyflowx.fileops import find, read_text` 可用(推荐写法)
- `px.find` / `px.exists` 不可用(避免污染)
### 3. `copy` / `move` 的 `overwrite` 语义统一
初版 `copy` / `move``overwrite=True` 逻辑有 bug:当 `dst` 是目录时,
先走 `dst_path.is_dir()` 分支(`dst_path = dst / src.name`),再检查
`overwrite`。这导致 `overwrite=True``dst` 本身没被删除,而是处理
`dst/src.name`
修复:`overwrite=True``dst` 存在时,**先删除 `dst`**,不走 `is_dir`
分支。语义统一为:
- `overwrite=False``dst` 是目录:`src` 移入/复制到 `dst / src.name`
- `overwrite=False``dst` 是已存在文件:抛 `FileExistsError`
- `overwrite=True``dst` 存在:先删除 `dst`,再把 `src` 放到 `dst` 路径
依据:`overwrite=True` 的用户意图是"替换目标",不是"移到目标内部"。
两个函数语义一致,降低认知负担。
### 4. `find` 的 `max_depth` 语义
`max_depth` 语义:1 = root 直接子项,2 = root 孙项。
`max_depth=0` 表示仅 root 自身用 pattern 匹配(不递归子目录)。
`max_depth=None`(默认)表示无限制递归。
实现用辅助函数 `_glob_with_depth(root, pattern, max_depth)` 递归 glob
`max_depth < 0` 返回空,`max_depth == 0` 仅 root.glob(pattern)
`max_depth > 0` 递归子目录。
### 5. `walk_files` 返回生成器(惰性)
`walk_files` 返回 `Iterator[Path]`,惰性求值,适用于大目录遍历。
`find` 区别:`find` 返回排序后的 `list[Path]`(支持 pattern 匹配),
`walk_files` 仅返回文件(不返回目录),不做 pattern 匹配。
### 6. `sha256` 流式读取
`sha256(path, chunk_size=65536)` 分块读取,适用于大文件。
默认 chunk_size 64KB,平衡内存与 IO 效率。
### 7. DAG 组合调用模式
测试覆盖 5 个 DAG 组合示例,验证数据流注入:
- `find → read_text`:上游 `outputs={"paths": "$"}`,下游参数名匹配任务名注入
- `find → copy` 批量复制
- `find → sha256` 校验
- `write_text → read_text` 往返
- `find → summarize → write_summary` 三阶段流水线
关键:下游任务的**参数名**必须匹配上游**任务名**,PyFlowX 自动注入
上游结果值。
## 验证结果
```
ruff check . → All checks passed!
ruff format --check → 133 files already formatted
pyrefly check . → 0 errors (68 suppressed)
pytest --cov=pyflowx → 1640 passed in 12.15s
coverage → 97.17%branch;门槛 95%
```
各模块覆盖率(P11 新增部分):
- `fileops.py` 98%157 stmts, 2 miss, 70 branch, 3 brpart —
缺失行:132 `seen` 去重分支、146 `_glob_with_depth` max_depth<0 边界、
206->203 `walk_files` 非文件非目录分支)
- `test_fileops.py` 80 用例全绿,含 5 个 DAG 组合示例
## 遗留事项
- **`fileops.py` 未注册为 CLI 工具**:纯函数模块,无 `@px.tool` 装饰器。
如需 CLI 访问,可后续新增 `ops/files/fileopscli.py` 包装层。
- **`find``seen` 去重分支(132 行)未覆盖**`rglob` / `_glob_with_depth`
在正常使用下不产生重复路径,去重是防御性代码。
- **`walk_files` 的符号链接分支(206->203)未覆盖**:正常文件系统中
`iterdir()` 返回的条目要么是文件要么是目录,损坏的符号链接才会触发
`is_file()=False and is_dir()=False` 分支。
- **后续可扩展**:文件监听(watch)、文件锁、压缩解压、文件比较等高级能力。
## 归档说明
iter-23 暂不归档(前 5 次 iter-16~20 已归档至
`.trae/skills/pyflowx-development/SKILL.md`iter-21/22/23 保留在 docs/)。
下次清理窗口为 iter-26 前夕,届时 iter-21/22/23 一并归档。
+142
View File
@@ -0,0 +1,142 @@
# 迭代 24 — P12 通用 DAG 构造器集合(pipelines 模块)
## 本轮目标
补齐 PyFlowX 在通用 DAG 编排场景的高层构造器能力, 提供 3 个函数式 DAG
构造器, 封装常见的任务编排模式, 减少手动创建 `TaskSpec` 的样板代码:
1. **`data_pipeline`** —— 函数式数据流水线 (链式函数组合, 参数名驱动注入)
2. **`command_chain`** —— 命令链构造器 (串行命令执行, CI/CD 场景)
3. **`fan_out_fan_in`** —— map-reduce 构造器 (扇出处理 + 扇入聚合)
`imaging.image_pipeline` 同级, 复用 `Graph.chain` / `Graph.map` 的依赖
链接模式, 但提供更高层次的函数式 API.
## 改动文件清单
### 新增
- `src/pyflowx/pipelines.py` — 3 个 DAG 构造器 + 2 个闭包工厂辅助函数
(190 行), 仅依赖标准库 + 项目核心抽象 (`TaskSpec` / `Graph` / `Context`).
- `tests/test_pipelines.py` — 37 用例, 覆盖 3 个构造器的拓扑/执行/命名/
错误处理 + 4 个与 fileops 联用组合示例.
### 修改
- `src/pyflowx/__init__.py` — 导入 `command_chain` / `data_pipeline` /
`fan_out_fan_in` 并加入 `__all__`.
## 关键决策与依据
### 1. 新建 `pipelines.py` 模块而非扩展 `compose.py`
`compose.py` 当前职责是**多图引用解析** (`GraphComposer` / `compose`),
与单图 DAG 构造器正交. 将 3 个构造器放入 `compose.py` 会混淆职责.
决策: 新建 `pipelines.py`, 与 `imaging.py` 同级, 存放通用 DAG 构造器.
`imaging.py` 是图片专用构造器, `pipelines.py` 是通用构造器, 职责清晰.
### 2. `data_pipeline` 用函数 `__name__` 作为默认任务名
`data_pipeline` 的核心价值是参数名注入: 下游函数参数名匹配前驱任务名,
自动接收前驱返回值. 任务名必须与函数签名一致.
决策: 默认用 `fn.__name__` 作为任务名; `<lambda>` 回退为 `step`;
重名时追加索引 (`extract_1`). 用户可通过 `names` 参数自定义命名, 但
需确保下游函数参数名匹配自定义名.
依据: 鼓励用户用命名函数 (而非 lambda) 构造流水线, 提升可读性 +
可调试性. lambda 仅用于简单场景.
### 3. `command_chain` 自动命名用 `cmd_{i:02d}_{first_arg}`
命令链的命名需兼顾唯一性与可读性. 用 `cmd_00_echo` / `cmd_01_ls`
格式: 索引保证唯一性, 首参数提供可读性.
边界处理:
- 空 list 命令 (`[]`) → `first = "cmd"`
- 空字符串命令 (`""`) → `first = "cmd"`
- shell 字符串命令 (`"echo hello"`) → `first = "echo"`
### 4. `fan_out_fan_in` 用 `Context` 标注实现 reduce 聚合
map-reduce 的核心难题: reduce 任务如何接收所有 worker 的结果?
PyFlowX 的参数名注入要求参数名匹配上游任务名, 但 fan-out 有 N 个
worker (名字不同), reduce 无法通过单个参数名接收所有.
决策: `fan_out_fan_in` 内部生成 reduce 包装函数, 标注 `ctx: Context`
接收完整依赖上下文, 然后按 `worker_names` 顺序提取结果, 传递给用户
`reduce` 函数.
```python
def _make_reduce_fn(reduce, worker_names):
def _run(ctx: Context) -> Any:
results = [ctx[name] for name in worker_names]
return reduce(results)
return _run
```
依据: `Context` 标注是 PyFlowX 文档化的机制, 用于接收完整依赖上下文.
闭包捕获 `worker_names` 列表, 保证结果按 item 顺序聚合.
### 5. `reduce` 可选, 支持纯 fan-out 模式
`fan_out_fan_in``reduce` 参数为 `Callable | None`, `None` 时仅生成
fan-out (无 reduce 任务). 适用于不需要聚合的并行批处理场景.
依据: 并非所有 fan-out 都需要 reduce (如批量文件复制, 每个 worker
独立写文件, 无需聚合).
### 6. 闭包工厂模式绑定参数 (参照 `imaging._make_step_fn`)
`_make_worker_fn(worker, item)``_make_reduce_fn(reduce, worker_names)`
采用与 `imaging._make_step_fn` 相同的闭包工厂模式: 每次调用独立作用域,
不受外层循环变量重绑定影响.
依据: 闭包捕获的是函数参数 (非循环变量), 避免了 Python 闭包经典的
"延迟绑定"陷阱.
### 7. 与 `Graph.chain` / `Graph.map` 的差异化定位
| 构造器 | 与 Graph 方法区别 |
|--------|------------------|
| `data_pipeline` | 直接传函数, 自动生成 TaskSpec + 命名, 无需手动装饰 |
| `command_chain` | 直接传命令列表, 自动命名 + 串联, 无需手动构造 spec |
| `fan_out_fan_in` | 一站式封装 source → map → reduce, 自动生成 reduce 包装 |
`Graph.chain` / `Graph.map` 是方法式 API (需先创建 Graph + TaskSpec),
本模块是函数式 API (直接传函数/命令/数据, 返回 Graph), 更高层抽象.
## 验证结果
```
ruff check . → All checks passed!
ruff format --check → reformatted
pyrefly check . → 0 errors
pytest tests/test_pipelines.py → 37 passed in 0.30s
coverage (pipelines.py) → 100% (86 stmts, 0 miss, 32 branch, 0 brpart)
pytest (全套) → 1677 passed (含 1 个 flaky 时间测试重跑通过)
```
各构造器测试覆盖:
- `TestDataPipeline` — 9 用例 (拓扑/执行/自定义命名/lambda 回退/重名/错误)
- `TestCommandChain` — 12 用例 (拓扑/自动命名/字符串命令/透传/失败处理)
- `TestFanOutFanIn` — 11 用例 (map-reduce/纯 fan-out/自定义命名/顺序/复杂对象/多策略)
- `TestPipelinesWithFileops` — 4 组合示例 (与 fileops 联用)
## 遗留事项
- **`data_pipeline` 不支持条件分支**: 当前是线性链式, 不支持 if/else 分支.
如需条件分支, 用户应直接用 `Graph.from_specs` 手动构造.
- **`command_chain` 不支持并行命令**: 当前是串行链, 不支持并行命令组.
如需并行, 用户应配合 `Graph.map``fan_out_fan_in`.
- **`fan_out_fan_in` 的 worker 不支持额外参数**: worker 函数仅接收单个 item.
如需额外参数, 用户应用闭包或 `functools.partial` 绑定.
- **后续可扩展**: 条件分支构造器、循环构造器、子图嵌套构造器等.
## 归档说明
iter-24 暂不归档 (前 5 次 iter-16~20 已归档至
`.trae/skills/pyflowx-development/SKILL.md`, iter-21/22/23/24 保留在 docs/).
下次清理窗口为 iter-26 前夕, 届时 iter-21/22/23/24 一并归档.
+71
View File
@@ -0,0 +1,71 @@
# 迭代 25:执行热路径性能优化
## 本轮目标
针对 `px.run` 执行热路径中的冗余开销进行 4 项优化,提升 sequential/thread 策略吞吐。
## 改动文件清单
- `src/pyflowx/task.py``should_execute` 加早退快速路径
- `src/pyflowx/executors.py``env_context` 快速路径、`storage_key` 内联、`resolved_spec` 去重
## 关键决策与依据
### 1. env_context 快速路径(最大开销项)
**问题**:每个 fn 任务执行时 `with spec.env_context():` 都会创建 `_GeneratorContextManager` 对象 + `__enter__`/`__exit__`,即使任务无 env/cwd。500 noop 任务 = 500 次无谓对象创建。
**方案**:在 `SyncTaskRunner.run``_submit_sync_task.fn_call` 内联判断 `spec.env is None and spec.cwd is None`,直接调用 `effective_fn`,跳过上下文管理器。
**依据**`_env_and_cwd` 本身已有 `if not env and cwd is None: yield; return` 快速路径,但 contextlib.contextmanager 包装器的对象创建 + 协议开销仍存在。内联判断完全消除这层开销。
### 2. should_execute 早退快速路径
**问题**:无 conditions 且无 skip_if_missing 的任务(最常见场景)仍分配空 list `failed_conditions` + 迭代空序列。
**方案**:在 `should_execute` 开头加 `if not self.conditions and not self.skip_if_missing: return True, None`
### 3. resolved_spec 去重
**问题**`_filter_and_sort` 已为每个任务计算 `graph.resolved_spec(name)` 并存入 `specs` dict,但三个 Layer runnerSequential/Threaded/Async)各自又调用一次。500 任务 = 500 次冗余缓存查询。
**方案**
- `_filter_and_sort` 返回 `(to_run, specs)` 元组,将 specs dict 透传给 runner
- `_build_semaphores` 签名从 `graph: Graph` 改为 `specs: Mapping[str, TaskSpec]`
- 三个 Layer runner 用 `specs[name]` 替代 `graph.resolved_spec(name)`
- `DependencyRunner` 启动时构建 `all_specs` dict 传入 `_build_semaphores``_run_task` 内保持 `graph.resolved_spec(name)`(动态任务不在 all_specs 中)
**依据**`resolved_spec` 虽有 `cached_property`/`lru_cache`,但每次仍有字典查找 + 函数调用开销。消除冗余调用后每任务省 1 次查找。
### 4. storage_key 内联快速路径
**问题**`_apply_cached``_store_result` 各调用 `spec.storage_key(ctx)`。无 cache_key 时(最常见场景)该函数仅返回 `self.name`,但有函数调用开销。
**方案**:内联 `spec.name if spec.cache_key is None else spec.storage_key(ctx)`,避免函数调用。
**依据**`storage_key` 内部首行已是 `if self.cache_key is None: return self.name`,内联仅省函数调用开销,但对 500 任务累积可见。
## 验证结果
### 门禁
- ruff: All checks passed
- pyrefly: 0 errors
- pytest: 1677 passed
- coverage: 97.23%(≥95% 门槛)
### 基准对比(500 noop 任务)
| 策略 | 优化前 | 优化后 | 提升 |
|------|--------|--------|------|
| sequential | 764 ops/s | 930 ops/s | +22% |
| thread | 108 ops/s | 119 ops/s | +10% |
| async | 49 ops/s | 50 ops/s | ~持平(固有开销主导) |
| dependency | 49 ops/s | 44 ops/s | ~持平(固有开销主导,噪声范围) |
sequential 路径提升最显著(+22%),因每任务节省的开销在总耗时中占比最大。thread 次之(+10%)。async/dependency 受事件循环与线程池提交的固有开销主导,per-task 优化相对影响小。
## 遗留事项
- async/dependency 策略对 noop 任务的固有并发开销仍较高,需真实 I/O 任务才能体现并行优势(属设计预期,非缺陷)。
- `datetime.now()` 每任务调用 2 次(started_at + finished_at),约占 sequential 预算的可观比例,但涉及 duration 正确性,本轮未优化。
+65
View File
@@ -0,0 +1,65 @@
# 迭代 26:API 简洁性与易用性
## 本轮目标
改进 PyFlowX 的 API 简洁性和易用性,减少样板代码,让用户用最少的代码表达 DAG 任务图。
## 改动文件清单
| 文件 | 改动 |
|------|------|
| `src/pyflowx/graph.py` | 新增 `_auto_infer_deps_single` 方法;`add()``from_specs()` 调用自动推断 |
| `src/pyflowx/context.py` | `_is_context_annotation` 重命名为公开 `is_context_annotation`(解决 pyrefly 私有名解析问题) |
| `src/pyflowx/__init__.py` | 新增 `px.graph(*specs)` 快捷构造;重写快速上手 docstring |
| `src/pyflowx/fileops.py` | DAG 组合示例现代化(`@px.task` + `px.graph()` |
| `src/pyflowx/compose.py` | `compose()` 示例现代化(`px.graph` + `px.cmd` |
| `src/pyflowx/task.py` | `@px.task` 装饰器示例现代化 |
| `tests/test_context.py` | 更新 `is_context_annotation` 引用 |
| `tests/test_graph.py` | 新增 16 个测试(自动推断 11 个 + px.graph 5 个) |
| `.trae/skills/pyflowx-development/SKILL.md` | 第十章新增自动推断与 px.graph 模式 |
## 关键决策与依据
### 1. depends_on 自动推断
**决策**`depends_on` 为空的纯 fn 任务,从必需参数名自动推断依赖。
**依据**:用户最常写的模式是 `def double(extract: list[int]) -> ...`,参数名
`extract` 就是依赖名,但还要显式写 `depends_on=("extract",)` 是冗余的。Airflow
DAGs 也有类似的 `auto_follow` 机制。
**安全规则**(避免意外行为):
- 仅当 `depends_on` 为空时触发——显式声明优先
- 仅匹配必需参数(有默认值的不推断)——避免把可选参数误认为依赖
- 仅对纯 fn 任务(cmd 任务不推断)——cmd 任务的参数无签名语义
- 排除 Context/Mapping 标注参数——这些参数接收完整上下文映射
- 排除 `*args`/`**kwargs`——可变参数不映射具体依赖
- 排除已在 `soft_depends_on` 中的参数名——避免硬/软依赖重叠报错
- 排除与任务自身同名的参数——避免自依赖
### 2. _is_context_annotation → is_context_annotation
**决策**:将私有 `_is_context_annotation` 重命名为公开 `is_context_annotation`
**依据**:pyrefly 无法解析跨模块导入的私有名(即使 `__all__` 包含),重命名为
公开名是最简解决方案。同时该函数在 graph.py 的自动推断逻辑中也被使用,公开化
是合理的。
### 3. px.graph(*specs) 快捷构造
**决策**:新增 `px.graph(*specs)` 作为 `Graph.from_specs(list)` 的可变参数版本。
**依据**`px.graph(a, b, c)``px.Graph.from_specs([a, b, c])` 更简洁,
特别是结合 `@px.task` 装饰器后,链式写法非常自然。
## 验证结果
- ruff check:通过
- ruff format:通过(131 files formatted
- pyrefly check0 errors15 suppressed
- pytest1694 passed
- coverage97.25%(≥95% 门槛)
## 遗留事项
无。三项改进(自动推断 + px.graph + 文档现代化)均已完成并验证。
+84
View File
@@ -0,0 +1,84 @@
# iter-27:功能扩展与性能优化
## 本轮目标
用户请求"继续扩展功能和优化性能",选定 3 个方向:
1. **P1:任务优先级调度** —— DependencyRunner 支持按 `priority` 降序调度就绪任务
2. **P2:条件分支/switch 模式** —— 新增 `px.switch()``px.branch()` DAG 构造器
3. **P3:大图内存与并发优化** —— 消除 `_find_ready()` 的 O(N) 每轮扫描
## 改动文件清单
| 文件 | 变更 |
|------|------|
| `src/pyflowx/executors.py` | DependencyRunner 重写为事件驱动优先级调度;`_build_dependency_index` 模块级函数;`_on_complete`/`_register_dynamic` 闭包;模块 docstring 更新 |
| `src/pyflowx/pipelines.py` | 新增 `switch()``branch()` 函数 |
| `src/pyflowx/__init__.py` | 导出 `switch`/`branch` |
| `tests/test_advanced_features.py` | 新增 2 个优先级调度测试 |
| `tests/test_pipelines.py` | 新增 `TestSwitch`5 测试)和 `TestBranch`4 测试) |
| `benchmarks/__main__.py` | 新增 `bench_large_graph()` 大图基准(1k/5k/10k 任务) |
| `.trae/skills/pyflowx-development/SKILL.md` | 第三节新增"事件驱动优先级调度"和"增量就绪集优化";第十七节新增 switch/branch |
## 关键决策与依据
### P1:事件驱动优先级调度
**决策**:重写 DependencyRunner 从"一次性创建所有 asyncio Task"为"事件驱动按需创建"。
**依据**
- 旧实现 `asyncio.gather(*[create_task(n) for n in all])` 一次性创建所有 Task
无法按优先级排序创建顺序(Task 一旦创建即进入事件循环调度队列)。
- 新实现用 `completed`/`in_flight`/`remaining` 三集合跟踪生命周期,
仅当任务依赖全部完成时才创建 asyncio Task,按 `priority` 降序创建。
- 测试验证:`concurrency_key="g"` + `concurrency_limits={"g": 1}` 串行化无依赖
任务,使优先级顺序可观测(`order == ["high", "mid", "low"]`)。
### P2:条件分支构造器
**决策**:基于已有 `BuiltinConditions` 原语构建 switch/branch,不引入新条件类型。
**依据**
- `DEP_EQUALS`/`DEP_MATCHES`/`NOT`/`AND` 已覆盖 switch/branch 所需语义。
- `switch(selector, cases, *, default=None)`:每个 case 追加
`DEP_EQUALS(selector.name, case_key)`default 追加
`AND(NOT(DEP_EQUALS(..., k)) for k in cases)`
- `branch(selector, predicate, if_true, *, if_false=None)`if_true 追加
`DEP_MATCHES`if_false 追加 `NOT(DEP_MATCHES)`
-`dataclasses.replace` 追加 `depends_on``conditions`,不修改原 spec。
### P3:增量就绪集优化
**决策**:用 `in_degree` 计数器 + `dependents` 反向邻接表替代每轮 O(N) 扫描。
**依据**
-`_find_ready()` 每轮扫描全部 `remaining` 任务检查依赖是否满足,
O(N·D) per round、O(N²·D) total。10k 任务链式图(10k 轮)成为瓶颈。
- 新方案预计算 `in_degree[name]`(尚未完成的硬+软依赖数)+ `dependents[d]`
(反向邻接表)。任务完成时 `_on_complete` 仅遍历 `dependents[name]`O(D_out)),
递减 `in_degree`;降为 0 时加入 `ready` 集合。
- 动态任务通过 `_register_dynamic` 接入增量结构:spawner 此时未标记 completed
(仍在 in_flight),故计入 unsatisfiedspawner 完成后 `_on_complete` 递减并触发就绪。
- 提取 `_build_dependency_index` 为模块级函数,控制 `execute` 的 PLR0912 分支数 ≤12。
## 验证结果
### 基准(dependency 策略,noop 任务)
| 图形状 | 1k | 5k | 10k |
|--------|-----|-----|------|
| chain | 87ms | 459ms | 948ms |
| diamond | 53ms | 320ms | 741ms |
| wide | 37ms | 247ms | 789ms |
chain 线性扩展(1k→5k→10k ≈ 5.3x→10.9x),确认 O(N) 调度复杂度。
### 门禁
- pytest1705 passed
- coverage97.21%branch,≥95% 门槛)
- ruffAll checks passed
- pyrefly0 errors
## 遗留事项
- 无。P1/P2/P3 全部交付完毕。
+70
View File
@@ -0,0 +1,70 @@
# iter-28px.sh 命令执行封装
## 本轮目标
ops/ 目录下 57 处 `subprocess.run` 调用存在重复的 `try/except CalledProcessError +
FileNotFoundError + print` 模式。封装一个轻量的 `px.sh` 辅助函数,统一错误处理
与中文输出,支持 `list[str]`(直接执行)和 `str``shell=True`)两种输入。
## 改动文件清单
| 文件 | 变更 |
|------|------|
| `src/pyflowx/shell.py` | 新建模块,实现 `sh()` 函数 |
| `src/pyflowx/__init__.py` | 导出 `sh`,更新 docstring |
| `tests/test_shell.py` | 新建,16 个测试(list/str/mocked/exported |
| `src/pyflowx/ops/infra/sglang.py` | 重构为使用 `px.sh`,移除 try/except 样板 |
| `tests/cli/test_llm.py` | 适配 sglang.py 重构后的错误信息格式 |
## 关键决策与依据
### 命名与定位
- **`px.sh` 而非 `px.run`**`px.run` 已被占用(执行 Graph DAG),`sh` 简短易记,
语义清晰("执行 shell 命令")。
- **轻量封装,不替代所有 `subprocess.run`**:需要 `capture_output` + 手动检查
返回码等精细控制的场景(如 `lscalc.py`/`piptool.py`/`which.py`)保留原样。
`px.sh` 定位为"简单执行 + 报错"场景的快捷方式。
### 输入类型
- **`list[str]``shell=False`**:直接执行,安全无注入风险。
- **`str``shell=True`**:支持管道/重定向等 shell 语法;ops 工具场景下命令可信。
- **不支持 `func`**ops 工具本身就是 Python 函数,直接 `func()` 调用即可,
封装反而增加复杂度。
### 返回值设计
- **成功返回 `CompletedProcess[str]`**`text=True` 始终启用,输出为字符串。
- **失败返回 `None` + 打印中文错误**:不抛异常,调用方用 `result is None` 判断。
统一 `CalledProcessError`(打印 returncode)和 `FileNotFoundError`(打印"命令未找到")。
- **`capture` 参数**`False`(默认)输出到终端;`True` 捕获 stdout/stderr。
### sglang.py 迁移示例
重构前(12 行 try/except 样板):
```python
try:
subprocess.run(["uv", "install", "sglang[all]"], check=True)
except subprocess.CalledProcessError as e:
print(f"安装失败 (returncode={e.returncode}): {e}")
except FileNotFoundError:
print("uv 命令未找到,请确认 uv 已安装并在 PATH 中")
```
重构后(1 行):
```python
px.sh(["uv", "install", "sglang[all]"], label="安装 sglang")
```
## 验证结果
- pytest1721 passed(新增 16 个 shell 测试)
- coverage97.20%shell.py 100%、sglang.py 100%
- ruffAll checks passed
- pyrefly0 errors
## 遗留事项
- 其余 ~55 处 `subprocess.run` 可在后续迭代中按需迁移到 `px.sh`
优先迁移"简单执行 + 报错"模式的调用;`capture_output` 等精细控制场景保留原样。
@@ -0,0 +1,167 @@
# P10 图片处理收尾计划(文档同步 + 提交)
## Context
P10 图片处理代码已完整实现并通过全部门禁,但**尚未提交**,且过程文档、SKILL 行为同步、project_memory 约定记录三项收尾工作未完成。本计划聚焦于把 P10 工作闭环:编写 iter-22 迭代记录、同步 SKILL.md 行为变更、追加 project_memory 约定、git commit + push。
用户已确认本次计划范围 = 仅收尾 P10(不含 P11 扩展)。P11 高级功能(OCR/滤镜/批量文件夹/拼接GIF)作为后续阶段输入参考,不在本计划内。
## 当前状态分析
### 已完成(未提交)
```
git status --short
M src/pyflowx/__init__.py # 导出 image_pipeline
M src/pyflowx/cli/pf.py # 注册 imagetool + 别名
?? src/pyflowx/imaging.py # image_pipeline DAG 构造器 (130 行)
?? src/pyflowx/ops/files/imagetool.py # 11 子命令 (520 行)
?? tests/cli/test_imagetool.py # 25 测试 (316 行)
?? tests/test_imaging.py # 9 测试 (158 行)
?? .trae/documents/ # 本计划文件所在目录
```
### 门禁状态(已验证通过)
- ruff check / format → 全绿
- pyrefly check → 0 错误(68 suppressed
- pytest → 1560 passed
- coverage → 97.15%branch;门槛 95%
- CLI 注册 → `imagetool``_TOOL_MODULES``img`/`image`/`imagetool``_TOOL_ALIASES`
### 分支跟踪
- 当前分支:`develop`
- 跟踪远程:`origin/develop`push 允许,无需询问)
## 实现步骤
### 步骤 1:创建 iter-22 迭代记录
**新建 `.trae/docs/iter-22-image-processing.md`**,参照 [iter-21-p9-feature-extension.md](file:///home/zhou/pyflowx/.trae/docs/iter-21-p9-feature-extension.md) 的结构:
1. **本轮目标** — P10 三方向:基础操作 7 子命令 / 元数据 4 子命令 / `image_pipeline` DAG 构造器
2. **改动文件清单** — 新增 4 文件 + 修改 2 文件(列出每个文件的职责)
3. **关键决策与依据**6 条,基于实际实现踩坑):
- **JPG → JPEG 规范化**`_save_image``save_fmt == "JPG"` 时改写为 `"JPEG"`,否则 Pillow `img.save(format="JPG")``KeyError`
- **JPEG 强制 RGB 转换**JPEG 不支持 alpha 通道,保存前 `img.convert("RGB")` 去 alpha
- **EXIF 用 `exif.tobytes()` 而非 `bytes(exif)`**Pillow 10+ 的 `Image.Exif` 对象 `bytes()` 调用抛 `ValueError: bytes must be in range(0, 256)`,必须用 `tobytes()`
- **`quantize(colors=N)` 实际色数可能 < N**:简单图片量化后调色板可能少于请求色数,用 `actual_count = len(palette) // 3` 动态计算并 `min(count, actual_count)` 防越界
- **Pillow 10+ Transpose 枚举**`Image.FLIP_LEFT_RIGHT`(旧)已弃用,用 `Image.Transpose.FLIP_LEFT_RIGHT`(新)
- **`_make_step_fn` 闭包工厂**`image_pipeline` 中若直接在循环内定义闭包,所有任务会捕获最后一次循环的 `input_path`/`output_path`/`params`;提取为独立函数 `_make_step_fn(base_fn, input_path, output_path, params)` 利用每次调用的独立作用域正确捕获
4. **验证结果** — 贴上门禁输出(ruff/pyrefly/pytest 1560 passed/coverage 97.15%
5. **遗留事项** — P11 候选方向(OCR/滤镜/批量文件夹/拼接GIF);`imagetool.py` 覆盖率 88%ImportError 回退分支未覆盖,可接受)
6. **归档说明** — iter-22 暂不归档(下次清理窗口为 iter-26 前夕,与 iter-21 一并归档)
### 步骤 2:同步 SKILL.md 行为变更
**修改 [.trae/skills/pyflowx-development/SKILL.md](file:///home/zhou/pyflowx/.trae/skills/pyflowx-development/SKILL.md)**
1. **更新 frontmatter description** — 末尾追加 "并同步迭代 22(P10)的行为变更",提及图片处理工具
2. **更新开头说明**(第 8 行附近)— "并同步迭代 21(P9 功能扩展)与迭代 22(P10 图片处理)的行为变更"
3. **新增第十五章 `## 十五、图片处理(P10,iter-22`**(追加在第十四 章之后),包含 4 个小节:
#### 可复用模式
- **可选依赖 try/except + `_require_*` 守卫**:顶部 `try: from PIL import Image; HAS_PIL = True; except ImportError: HAS_PIL = False`,每个子命令入口 `if not _require_pil(): return` 打印 `pip install pyflowx[office]` 提示
- **`@px.tool` 装饰器注册子命令**`@px.tool("imagetool", subcommand="r", help="调整尺寸")`subcommand 短别名(r/c/ro/fl/cv/wm/cp/i/e/hi/co
- **`image_pipeline` DAG 构造器**:参照 `compose()` 函数式构造器,每个 step 生成 `TaskSpec``depends_on` 链式依赖,`_make_step_fn` 闭包工厂正确捕获每轮参数
- **`naming` 模板占位符**`{stem}`(累积 stem/`{step}`(操作名)/`{ext}`(当前扩展名,convert 步骤可能改变)
#### 设计决策
- **单一 `imagetool` + office extra**:不拆分为多个工具,所有图片操作集中在 `ops/files/imagetool.py`Pillow 通过 `pyflowx[office]` extra 安装,零运行时依赖原则不破坏
- **`_save_image` 统一保存逻辑**JPG→JPEG 规范化 + JPEG 强制 RGB + JPEG/WEBP 应用 quality 参数;避免每个子命令重复处理
- **`image_pipeline` 委托 imagetool 函数**:不重新实现图片操作,`operations` 字典映射操作名到 `imagetool.image_*` 函数,保持单一职责
#### 踩坑总结
- **Pillow 10+ API 迁移**`Image.FLIP_LEFT_RIGHT` 已弃用 → `Image.Transpose.FLIP_LEFT_RIGHT``bytes(exif)` 抛错 → `exif.tobytes()`
- **`quantize(colors=N)` 实际色数可能 < N**:简单图片量化后调色板少于请求色数,必须用 `actual_count = len(palette) // 3` 动态计算
- **闭包捕获循环变量陷阱**`image_pipeline` 中直接在循环内定义闭包会捕获最后一次循环的变量;提取 `_make_step_fn(base_fn, input_path, output_path, params)` 为独立函数利用每次调用的独立作用域
- **PLR0912 分支数限制**`image_exif` 函数最初 13 分支超过 12 限制,提取 `_print_exif`/`_apply_exif_modifications`/`_apply_single_exif_set`/`_save_exif` 4 个辅助函数降至合规
### 步骤 3:更新 project_memory.md
**修改 [project_memory.md](file:///home/zhou/.trae-cn/memory/projects/-home-zhou-pyflowx/project_memory.md)**
1. **Engineering Conventions 末尾追加** P10 约定(约 8 条):
- `imagetool` 模式:try/except PIL 导入 + `HAS_PIL` 标志 + `_require_pil()` 守卫 + `@px.tool` 装饰器
- `_save_image` 统一保存:JPG→JPEG 规范化 + JPEG 强制 RGB + JPEG/WEBP 应用 quality
- EXIF 序列化用 `exif.tobytes()`Pillow 10+),不用 `bytes(exif)`
- `quantize(colors=N)` 实际色数可能 < N,用 `actual_count = len(palette) // 3` 动态计算
- Pillow 10+ 用 `Image.Transpose.FLIP_LEFT_RIGHT`(非弃用的 `Image.FLIP_LEFT_RIGHT`
- `image_pipeline``_make_step_fn(base_fn, input_path, output_path, params)` 闭包工厂避免循环变量捕获陷阱
- `image_pipeline` 委托 `imagetool.image_*` 函数,不重新实现操作(单一职责)
- `pytest.importorskip("PIL")` 跳过未安装 Pillow 的测试模块
2. **Lessons Learned 末尾追加** iter-22 总结条目:
- `iter-22 (P10 image processing) completed: imagetool 11 subcommands (resize/crop/rotate/flip/convert/watermark/compress/info/exif/histogram/colors) + image_pipeline DAG constructor; 1560 tests green, coverage 97.15%; SKILL.md synced (added section 15); next archive window at iter-26`
### 步骤 4Git commit + push
按文件名逐个 `git add`(不用 `git add .`/`-A`),然后 commit + push。
**暂存文件**8 个):
```
git add src/pyflowx/ops/files/imagetool.py
git add src/pyflowx/imaging.py
git add src/pyflowx/__init__.py
git add src/pyflowx/cli/pf.py
git add tests/cli/test_imagetool.py
git add tests/test_imaging.py
git add .trae/docs/iter-22-image-processing.md
git add .trae/skills/pyflowx-development/SKILL.md
```
**Commit message**(遵循 [.trae/rules/git-commit-message.md](file:///home/zhou/pyflowx/.trae/rules/git-commit-message.md) 中文风格,参照 `5ac7fd3` P9 提交):
```
feat: P10 图片处理 — imagetool 11 子命令(resize/crop/rotate/flip/convert/watermark/compress/info/exif/histogram/colors) + image_pipeline DAG 构造器;1560 测试全绿,覆盖率 97.15%
```
**Push**`git push`develop 跟踪 origin/develop,直接 push
## 关键文件
| 操作 | 文件 |
|------|------|
| 新建 | `.trae/docs/iter-22-image-processing.md`iter-22 迭代记录) |
| 修改 | `.trae/skills/pyflowx-development/SKILL.md`(新增第十五章 + frontmatter 更新) |
| 修改 | `/home/zhou/.trae-cn/memory/projects/-home-zhou-pyflowx/project_memory.md`(追加 P10 约定) |
| 提交 | 上述 3 文件 + 已实现的 5 文件(imagetool.py/imaging.py/__init__.py/pf.py/2 测试文件) |
## 复用的现有模式
- **iter-21 迭代记录结构**[iter-21-p9-feature-extension.md](file:///home/zhou/pyflowx/.trae/docs/iter-21-p9-feature-extension.md)(本轮目标 / 改动文件清单 / 关键决策与依据 / 验证结果 / 遗留事项 / 归档说明)
- **SKILL.md 章节结构**:参照第十四章 `## 十四、任务编排与持久化(P9,iter-21)``### 可复用模式` / `### 设计决策` / `### 踩坑总结` 三段式
- **commit message 风格**:参照 `5ac7fd3` P9 提交(`feat: P9 功能扩展 — ...1525 测试全绿,覆盖率 97.58%`
- **project_memory 条目风格**:参照第 125 行 iter-21 总结条目
## 验证步骤
1. **文档完整性**
- `iter-22-image-processing.md` 包含全部 6 个小节(目标/文件/决策/验证/遗留/归档)
- `SKILL.md` 第十五章包含可复用模式/设计决策/踩坑总结三小节
- `project_memory.md` Engineering Conventions 追加 8 条、Lessons Learned 追加 1 条
2. **Git 状态**
- `git status` 干净(无未提交文件)
- `git log --oneline -1` 显示新 commit
- `git push` 成功(origin/develop 已更新)
3. **门禁复跑**(可选,确保文档修改未误改代码):
```bash
uvx --from pyflowx pymake tc # ruff + pyrefly + pytest
```
## 验收标准
- `.trae/docs/iter-22-image-processing.md` 创建完成,结构对齐 iter-21
- `.trae/skills/pyflowx-development/SKILL.md` 新增第十五章,frontmatter description 更新
- `project_memory.md` 追加 P10 约定(Engineering Conventions + Lessons Learned
- Git commit 成功,push 到 origin/develop 成功
- `git status` 干净
## 不在范围内
- P11 高级功能(OCR/滤镜/批量文件夹/拼接GIF)— 用户已确认为后续阶段输入,本次不实现
- 代码修改 — P10 代码已实现完成,本次仅文档同步 + 提交
- 归档 — iter-22 暂不归档(下次清理窗口 iter-26 前夕,与 iter-21 一并归档到 SKILL.md
@@ -0,0 +1,174 @@
# P10 图片处理开发计划
## Context
PyFlowX 现有 `ops/files/` 工具集已覆盖 PDFpdftool)、截图(screenshot)、文件日期(filedate)等场景,但缺少图片处理能力。`pyproject.toml``office` extra 已声明 `pillow>=10.4.0`,却无对应工具消费它。
本计划新增 `imagetool` 工具,提供基础操作(resize/crop/rotate/flip/convert/watermark/compress)、元数据与信息(info/exif/histogram/colors)、批量 DAG 编排(`image_pipeline` 便捷构造器)三类能力。用户已确认范围:基础操作 + 元数据 + 批量 DAG(不含 OCR),工具形态为单一 `imagetool` + office extra。
## 实现步骤
### P10.1 基础操作子命令
**新建 `src/pyflowx/ops/files/imagetool.py`**,参照 [pdftool.py](file:///home/zhou/pyflowx/src/pyflowx/ops/files/pdftool.py) 模式:
- 顶部 `try: from PIL import Image; HAS_PIL = True; except ImportError: HAS_PIL = False`
- `_require_pil() -> bool` 检查并打印 `pip install pyflowx[office]` 提示
- 7 个 `@px.tool("imagetool", subcommand=..., help=...)` 子命令:
- `resize` (`r`) — `image_resize(input: Path, output: Path, width: int, height: int | None = None, keep_ratio: bool = True)`
- `crop` (`c`) — `image_crop(input: Path, output: Path, left: int, top: int, right: int, bottom: int)`
- `rotate` (`ro`) — `image_rotate(input: Path, output: Path, degrees: float, expand: bool = False)`
- `flip` (`fl`) — `image_flip(input: Path, output: Path, direction: str = "horizontal")`
- `convert` (`cv`) — `image_convert(input: Path, output: Path, format: str | None = None, quality: int = 85)`
- `watermark` (`wm`) — `image_watermark(input: Path, output: Path, text: str, position: str = "bottom-right", opacity: float = 0.5, font_size: int = 32)`
- `compress` (`cp`) — `image_compress(input: Path, output: Path, quality: int = 85)`
实现要点:
-`Image.open(input)` 打开,操作后 `img.save(output, format=..., quality=...)` 保存
- `resize` + `keep_ratio=True` 时用 `Image.thumbnail((width, height))` 保比缩放;`keep_ratio=False``Image.resize((width, height))`
- `watermark``ImageDraw.Draw(img).text(...)` + `ImageFont.truetype()`(字体缺失回退 `load_default()`
- `convert` 根据 `output.suffix` 推断格式;JPEG 系列需先 `img.convert("RGB")` 去 alpha 通道
- `__all__` 列出所有 `image_*` 函数名
### P10.2 元数据与信息子命令
继续在 `imagetool.py` 追加 4 个子命令:
- `info` (`i`) — `image_info(input: Path, json: bool = False)`:打印尺寸/格式/模式/色彩空间/EXIF 摘要。`json=True` 输出 JSON 格式
- `exif` (`e`) — `image_exif(input: Path, output: Path | None = None, show: bool = True, set: list[str] | None = None, clear: bool = False)`:读写 EXIF。`--set KEY=VAL` 修改,`--clear` 清空,`output` 另存
- `histogram` (`hi`) — `image_histogram(input: Path, channel: str = "rgb")`:打印 RGB/Luminance 直方图统计(每通道 8 桶,纯文本表格输出)
- `colors` (`co`) — `image_colors(input: Path, count: int = 5)`:主色调提取,用 `Image.quantize(colors=count).getpalette()` 提取并打印十六进制色值
实现要点:
- `info``img.size` / `img.format` / `img.mode` / `img.info.get("exif")`
- `exif``img.getexif()` / `img.save(exif=bytes(exif))`
- `histogram``img.histogram()` 分桶统计
- `colors``img.quantize(colors=count).getpalette()` 取前 N 色
### P10.3 批量 DAG 编排
**新建 `src/pyflowx/imaging.py`**(类似 [compose.py](file:///home/zhou/pyflowx/src/pyflowx/compose.py) 的独立模块),提供链式 DAG 构造器:
```python
def image_pipeline(
source: str | Path,
steps: list[tuple[str, dict[str, Any]]],
*,
output_dir: str | Path | None = None,
naming: str = "{stem}_{step}{ext}",
) -> Graph:
"""图片处理流水线 DAG 构造器。
每个 step 是 (操作名, 参数字典),操作名对应 imagetool 子命令
resize/crop/rotate/flip/convert/watermark/compress)。
前一步输出作为后一步输入,形成链式 DAG。
"""
```
示例:
```python
graph = px.image_pipeline(
"input.jpg",
steps=[
("resize", {"width": 800}),
("watermark", {"text": "© 2026"}),
("convert", {"format": "webp", "quality": 85}),
],
)
report = px.run(graph, strategy="sequential")
```
实现要点:
- 每个 step 生成一个 `TaskSpec``fn` 调用对应的 `image_*` 函数
- 任务名按 `naming` 模板生成(默认 `{stem}_{step}{ext}`,如 `input_resize.jpg`
- 任务间用 `depends_on` 链式依赖,上游输出路径 = 下游输入路径
- `output_dir` 默认为源文件所在目录
-`__init__.py` 导出 `image_pipeline`
### P10.4 验证与文档
**测试 `tests/cli/test_imagetool.py`**
-`pytest.importorskip("PIL")` 跳过未安装 Pillow 的环境
-`tmp_path` 创建真实测试图片(`Image.new("RGB", (100, 100), "red").save(tmp_path / "test.png")`
- 每个子命令至少 1 个测试(基础操作验证输出文件存在 + 尺寸/格式正确)
- 元数据测试验证输出内容(info 的 JSON 结构、exif 读写往返、histogram 桶数、colors 色值数)
- `image_pipeline` 测试验证 DAG 拓扑(3 步链式,每步输出存在且最终输出为 webp)
- 测试 mock 优先级遵循 `python-standards.md`:优先用真实 Pillow 操作(非 subprocess),仅字体加载等不可控部分用 `monkeypatch`
**新增 `tests/imaging/test_image_pipeline.py`**(或在 `tests/test_imaging.py`):
- 测试 `image_pipeline` 生成的 Graph 拓扑正确(任务数、依赖链)
- 测试实际执行后输出文件链完整
**注册到 CLI**
- 编辑 [src/pyflowx/cli/pf.py](file:///home/zhou/pyflowx/src/pyflowx/cli/pf.py) 的 `_TOOL_MODULES`:添加 `"imagetool": "pyflowx.ops.files.imagetool"`
- 编辑 `_TOOL_ALIASES`:添加 `"imagetool": "imagetool"``"image": "imagetool"``"img": "imagetool"`
- 编辑 [src/pyflowx/__init__.py](file:///home/zhou/pyflowx/src/pyflowx/__init__.py) 导出 `image_pipeline`
**文档同步**
- `.trae/docs/iter-22-image-processing.md` 迭代记录
- `.trae/skills/pyflowx-development/SKILL.md` 同步行为变更(新增"十五、图片处理工具"小节,或在十二章 CLI 工具下追加)
- `project_memory.md` 追加 P10 工程约定
## 关键文件
| 操作 | 文件 |
|------|------|
| 新建 | `src/pyflowx/ops/files/imagetool.py`11 子命令) |
| 新建 | `src/pyflowx/imaging.py``image_pipeline` 构造器) |
| 新建 | `tests/cli/test_imagetool.py`(子命令测试) |
| 新建 | `tests/test_imaging.py`DAG 构造器测试) |
| 修改 | `src/pyflowx/cli/pf.py`(注册 imagetool + 别名) |
| 修改 | `src/pyflowx/__init__.py`(导出 `image_pipeline` |
| 新建 | `.trae/docs/iter-22-image-processing.md` |
| 修改 | `.trae/skills/pyflowx-development/SKILL.md`(行为变更同步) |
## 复用的现有模式
- **`@px.tool` 装饰器 + ToolSpec**[tools.py L116-L180](file:///home/zhou/pyflowx/src/pyflowx/tools.py#L116-L180),每个子命令一个装饰器调用
- **可选依赖 try/except + `_require_*`**[pdftool.py L31-L59](file:///home/zhou/pyflowx/src/pyflowx/ops/files/pdftool.py#L31-L59)
- **`_TOOL_MODULES` 注册**[pf.py L112-L137](file:///home/zhou/pyflowx/src/pyflowx/cli/pf.py#L112-L137)
- **`pytest.importorskip` 跳过缺依赖**[test_pdftool.py L22](file:///home/zhou/pyflowx/tests/cli/test_pdftool.py#L22)
- **`image_pipeline` 委托模式**:参照 [compose.py](file:///home/zhou/pyflowx/src/pyflowx/compose.py) 的 `compose()` 函数式构造器
- **TaskSpec 链式依赖**:用 `Graph.from_specs` + `depends_on` 构建链式 DAGP9.2 `pipeline()` 同模式)
## 验证方法
```bash
# 1. 单元测试
uv run pytest tests/cli/test_imagetool.py tests/test_imaging.py -v
# 2. 全套门禁
uv run ruff check .
uv run ruff format --check .
uv run pyrefly check .
uv run pytest --cov=pyflowx --cov-branch
# 3. CLI 实测(需 pip install pyflowx[office]
pf imagetool info test.png
pf imagetool resize test.png out.png --width 50
pf imagetool watermark test.png wm.png --text "TEST"
pf imagetool convert test.png out.webp --quality 80
# 4. DAG 编排实测
python -c "
import pyflowx as px
g = px.image_pipeline('test.png', steps=[
('resize', {'width': 50}),
('watermark', {'text': 'X'}),
('convert', {'format': 'webp', 'quality': 80}),
])
r = px.run(g, strategy='sequential')
print(r.success)
"
# 5. 覆盖率门槛 ≥ 95%branch
```
## 验收标准
- ruff/pyrefly 0 错误,pytest 全绿,覆盖率 ≥ 95%branch
- `pf imagetool --help` 列出全部 11 个子命令
- `pf imagetool <sub> --help` 显示参数说明
- `image_pipeline()` 生成的 DAG 可执行,链式输出完整
- 未安装 office extra 时 `pf imagetool <sub>` 打印友好提示而非崩溃
- `tests/cli/test_tool_modules.py` 自动覆盖新工具注册(无需额外修改)
-108
View File
@@ -1,108 +0,0 @@
# 文档整理与 Sphinx 文档搭建计划
## Context
最近完成 CLI 重构:新增 `pf` 统一入口,13 个工具迁移到 YAML 配置并删除了对应 .py 入口脚本,`run()` 的 verbose 统一应用到 spec。但文档未同步:README 仍引用旧命令(`yamlrun``python build.py`),模块结构表缺漏;`runner.py``_apply_verbose_to_graph` 成为死代码;项目缺少可发布的 Sphinx 文档。本次任务整理这些遗留,并搭建 ReadTheDocs 文档站。
## 任务范围
### 1. 清理死代码
- 删除 `src/pyflowx/runner.py``_apply_verbose_to_graph` 函数(line 38-68),功能已移入 `executors.py``run()`
- 删除 `tests/test_runner.py` 中对应测试(line 610-636`TestApplyVerboseToGraph` 类)。
- 清理 `runner.py` 顶部 `from dataclasses import replace` 若变为未使用。
### 2. 修复版本不一致
- `src/pyflowx/__init__.py:105` 硬编码 `__version__ = "0.4.5"``pyproject.toml:25``0.3.5`
- 统一为 `0.4.5``__init__.py` 为准,pyproject.toml 是源但 bumpversion 工具应同时更新两者)。
### 3. 更新 README.md
- L304-308`python build.py clean/build/test``pf pymake clean/build/test`
- L335-351、L435`yamlrun pipeline.yaml ...``pf yamlrun pipeline.yaml ...`6 处)。
- L311`verbose=True(默认)` 描述保留,但 CLI 示例改为 `pf`
- L558-574 模块结构表:补充 `cli/pf.py`(统一入口)、`cli/configs/`YAML 工具配置)、`cli/_ops/`(工具函数)、`profiling.py``registry.py`
- 顶部增加「文档」徽章链接到 ReadTheDocs。
### 4. 搭建 Sphinx 文档结构
新建 `docs/` 目录:
```
docs/
├── conf.py # Sphinx 配置
├── index.rst # 首页与目录
├── installation.rst # 安装
├── quickstart.rst # 快速上手(从 README 提炼)
├── guide/
│ ├── task.rst # TaskSpec 任务描述
│ ├── graph.rst # Graph DAG 构建
│ ├── execution.rst # 执行策略与 run()
│ ├── yaml.rst # YAML 任务编排
│ └── cli.rst # pf 统一入口与工具列表
├── api.rst # API 参考(automodule 自动生成)
└── changelog.rst # 变更日志摘要
```
**conf.py 要点**
- 扩展:`sphinx.ext.autodoc``sphinx.ext.napoleon`(支持 Google/NumPy docstring)、`sphinx.ext.viewcode``myst_parser`(支持 Markdown
- 主题:`sphinx_rtd_theme`
- 项目版本从 `pyflowx.__version__` 动态读取
- `autodoc_default_options``members: True, undoc-members: True, show-inheritance: True`
**api.rst**:用 `automodule:: pyflowx` 抓取 `__all__` 的 56 个公共符号。
### 5. ReadTheDocs 配置
- 新建 `.readthedocs.yaml`Python 3.11`pip install -e .[docs]``sphinx -b html docs/ docs/_build/`
- `.gitignore` 增加 `docs/_build/`
### 6. pyproject.toml 补充 docs 依赖
```toml
docs = [
"sphinx>=7.0",
"sphinx-rtd-theme>=2.0",
"myst-parser>=3.0",
]
```
并在 `[dependency-groups]` 的 dev 中加入 `pyflowx[docs]`
## 关键文件
| 文件 | 操作 |
|------|------|
| `src/pyflowx/runner.py` | 删除 `_apply_verbose_to_graph` |
| `tests/test_runner.py` | 删除 `TestApplyVerboseToGraph` |
| `src/pyflowx/__init__.py` | 版本统一(已 0.4.5,确认) |
| `pyproject.toml` | 版本 → 0.4.5;加 docs 依赖 |
| `README.md` | 更新 CLI 示例与模块结构表 |
| `docs/conf.py` | 新建 |
| `docs/*.rst` | 新建 |
| `.readthedocs.yaml` | 新建 |
| `.gitignore` | 加 docs/_build/ |
## 验证
1. **测试与 lint**
```bash
uv run pytest tests/ -q
uv run ruff check src/ tests/ docs/conf.py
uv run pyrefly check src/pyflowx/runner.py
```
2. **Sphinx 构建本地验证**
```bash
uv sync --extra docs
uv run sphinx-build -b html docs/ docs/_build/
```
确认无 warning,打开 `docs/_build/index.html` 检查页面。
3. **pf 功能回归**
```bash
pf gitt c
pf pymake b --dry-run
```
4. **RTD 配置校验**`.readthedocs.yaml` 语法正确,`docs/conf.py` 能独立构建。
## 不在范围
- 不统一各模块 docstring 风格(napoleon 兼容 Google/NumPy,够用)。
- 不重构现有 CLI 工具 YAML。
- 不新增中文文档翻译(文档用中文撰写,与项目既有风格一致)。
+23
View File
@@ -0,0 +1,23 @@
# 开发流程约束
本规则补充 `self-driven.md`,定义执行过程记录与规则变更的两条硬约束。
## 执行过程记录
- **每次迭代**(一个完整的"计划 → 实现 → 测试 → 文档 → 验证"闭环)必须记录到
`.trae/docs/` 目录,文件名形如 `iter-NN-<主题>.md``NN` 为零填充序号。
- 记录内容至少包括:本轮目标、改动文件清单、关键决策与依据、验证结果、遗留事项。
- **每 5 次迭代后**清理 `.trae/docs/`
- 把需要长期保留的整合性内容(可复用模式、踩坑总结、设计决策)
归档到 `.trae/skills/` 对应技能文档中;
- 其余过程性记录删除;
- docs 目录只保留最近未归档的迭代记录(即第 6 次迭代开始前,清理 1–5 号记录)。
- 归档动作需在迭代记录中标注"已归档至 skills/<文件>"。
## 规则变更约束
- 任何对 `.trae/rules/` 下文件的修改(新增、编辑、删除)**必须先询问用户**,
获得明确授权后方可变动;**未授权前不得擅自修改 rules**。
- 用户在对话中显式要求写入的规则内容(如"把 X 写入 rules")视为已授权,
可直接写入,写入后在回复中说明改了哪个文件、加了什么。
- 规则变更后需同步更新 `project_memory.md` 中的"开发约定"小节。
+1 -1
View File
@@ -25,7 +25,7 @@ uvx --from pyflowx pymake cov
- **最低 Python 3.8**:用 `from __future__ import annotations` 延迟注解求值;
按版本用 `typing.List`(3.8) → 内置泛型(3.9) → `X | Y`(3.10) → `typing.override`(3.12)。
- **版本守卫**`if sys.version_info >= (3, X):` 引入高版本 API;低版本回退分支加 `# pragma: no cover`
- **零运行时依赖**:仅依赖标准库(3.8 需 `graphlib_backport``typing-extensions`)。
- **零运行时依赖**:仅依赖标准库(`typing-extensions` 用于 `override`/`TypeVar` 前向兼容)。
新增依赖须审慎,优先用标准库。
## 类型注解
+19 -2
View File
@@ -73,6 +73,22 @@ alwaysApply: true
- 全套门禁通过:ruff、pyrefly、pytest、coverage。
- 给出本轮变更清单(改了哪些文件、为什么)。
## 多阶段项目
当项目按阶段(P0/P1/P2/...)划分时,初始确认的目标是**整个项目**,而非单个阶段。
- **阶段是里程碑,不是边界**:确认"推进 P1"只是指定下一步重点,不缩小整体目标范围;
也不意味着 P1 完成后就可以停下。整体目标 = 所有阶段交付完毕。
- **阶段完成不是停止条件**:某阶段验收标准全部满足后,**自动进入下一阶段的「计划」步骤**,
不停下来输出"是否继续"或"是否扩展范围"的询问。仅在阶段切换时用一句话说明
"进入 Pn,重点:…",然后继续自驱动迭代。
- **「收尾」仅适用于整个项目完成**:只有所有阶段(含最后一个)都交付后,才执行
「收尾」输出最终总结。单阶段完成 → 进入下一阶段的「计划」,不触发收尾。
- **跨阶段依赖**:若下一阶段依赖外部资源(如新依赖审批、环境配置),属"不可恢复的失败"
暂停条件;否则一律连续推进。
- **阶段范围超出预期**:执行中发现某阶段需要显著扩大范围或改变方向(如 P2 本来只做
表格抽取,发现必须先重写 IR),属"超出初始确认范围"暂停条件,需找用户确认。
## 暂停条件(仅在以下情况中断自驱动找用户)
1. **歧义无法自决**:需求存在多种合理解读且无既有约定可循。
@@ -81,7 +97,7 @@ alwaysApply: true
4. **超出初始确认范围**:用户目标在执行中发现需要显著扩大范围或改变方向。
5. **用户主动询问**:用户在对话中提出新问题或要求澄清。
**注意**"目标已达成"**不是**暂停条件——验收标准全部满足后直接进入收尾并结束任务,不询问"是否扩展范围"或"是否提交"
**注意**"目标已达成"**不是**暂停条件——但"目标已达成"指**整个项目目标**(所有阶段交付完毕),而非单个阶段验收满足。单阶段验收满足后应自动进入下一阶段(见「多阶段项目」),不触发收尾
非以上情况,一律继续自驱动,不要为"求确认"而暂停。
@@ -128,7 +144,8 @@ alwaysApply: true
## 收尾
- 验收标准全部满足后,**直接输出最终总结并结束任务**交付物、关键决策、遗留事项。
- **仅当整个项目所有阶段都交付后**,才执行收尾:直接输出最终总结并结束任务交付物、关键决策、遗留事项
- 单阶段完成**不属于收尾**——见「多阶段项目」,应自动进入下一阶段的「计划」。
- **自动提交**:收尾时自动 `git add`(按文件名)+ `git commit`(遵循 `.trae/rules/git-commit-message.md` 风格)+ `git push`(仅当分支已跟踪远程时执行;新分支跳过 push 并在总结中说明);**不询问**"是否需要提交"或"是否扩展范围"。
- 若验收标准未全部满足,回到「计划」继续下一轮,不停下询问。
- 将本次会话的关键产出与决策更新到 memory,便于后续会话续接。
+967
View File
@@ -0,0 +1,967 @@
---
name: "pyflowx-development"
description: "PyFlowX 项目(DAG 任务调度库)的开发知识库。归档迭代 06-20 中可复用的架构模式、踩坑总结与设计决策,并同步迭代 21(P9)、迭代 22(P10 图片处理)、迭代 23(P11 文件操作底层 API)、迭代 24(P12 通用 DAG 构造器)与迭代 25(执行热路径性能优化)的行为变更。在设计与调度器、CLI、YAML 编排、取消机制、序列化、观测性、错误诊断、状态后端、性能优化、任务编排、检查点恢复、监控导出、图片处理、文件操作底层 API、通用 DAG 构造器相关的功能时参考。"
---
# PyFlowX 开发知识库
本技能归档自迭代 06-20 的过程记录,并同步迭代 21(P9 功能扩展)、
迭代 22(P10 图片处理)、迭代 23(P11 文件操作底层 API)、迭代 24
(P12 通用 DAG 构造器)与迭代 25(执行热路径性能优化)的行为变更。
按主题分类整理可复用知识。
过程性细节(覆盖率数字、命令输出)已剔除,仅保留架构模式、设计依据
与陷阱总结。
## 一、兼容性与代码清理
### 设计决策
- **`typing_extensions` 版本守卫不可移除**`override`3.12+)与
`TypeVar(default=)`3.13+)在 3.10/3.11/3.12 上仍需 `typing_extensions`
回退。守卫是前向兼容代码,不是 3.8 死代码。
`pyproject.toml` 声明 `typing-extensions>=4.13.2; python_version < '3.13'`
- **`from __future__ import annotations` 保留**PEP 563 在 3.10+ 仍提供
前向引用与延迟求值价值。统一移除需逐文件审计前向引用、自引用类型,
风险高收益低。
- **`rules/` 修改必须先获用户授权**`.trae/rules/` 下文件修改受
`dev-workflow.md` 约束,未授权前不得擅自变动,即便描述已过时
(如 "最低 Python 3.8" 实际已升至 >=3.10)。
### 踩坑总结
- **不要凭"3.8 兼容代码"假设做清理**:调研后才发现 `typing_extensions`
守卫实际是 3.12/3.13 前向兼容,删除会破坏 3.10-3.12 上的 `override`
`TypeVar(default=)`。清理前必须验证"过期代码"的真实用途。
- **README/文档与代码状态必须同步**:未实现的特性声明(如未落地的
YAML 编排章节)、过期的覆盖率徽章、错误的依赖描述都会误导用户。
清理时优先对齐 README 与 `pyproject.toml`
## 二、YAML 任务编排(GitHub Actions 风格)
### 可复用模式
- **Matrix 笛卡尔积展开**:多键矩阵用 `itertools.product` 展开为笛卡尔积,
命名 `{job_id}_{key}-{value}`(多键用 `_` 连接)。
`version: ["3.10","3.11"]` × `os: ["linux","macos"]` 展开 4 个任务
`test_version-3.10_os-linux` 等。
- **矩阵依赖自动展开**:若 job A `needs: [B]`,B 是矩阵 job,A 依赖 B 的
所有变体(无需手动列出每个变体)。
- **`${{ matrix.key }}` 占位符替换**:在 cmd/run/cwd/env 的字符串值中
统一替换矩阵占位符。
- **字段名 hyphen/underscore 兼容**YAML 中 `continue-on-error`
`continue_on_error` 等价,读取时统一规范化。
- **cmd vs run 区分**
- `cmd: [...]` → list 形式(无 shell,直接 exec
- `run: "..."` → shell 字符串形式
### 设计决策
- **条件解析映射到内置 Conditions**
- `success()` / `always()` → 无条件(空 conditions
- `env.VAR``ENV_VAR_EXISTS("VAR")`
- `env.VAR == 'x'``ENV_VAR_EQUALS("VAR", "x")`
- `env.VAR != 'x'``NOT(ENV_VAR_EQUALS("VAR", "x"))`
- `failure()` → 不支持,抛 `ValueError`PyFlowX 不支持 failure 触发)
- **yamlrun 作为特殊命令而非 `@px.tool`**CLI 路由
`pf yamlrun <file> [--strategy S] [--dry-run] [--list] [--quiet] [--progress]`
作为 `PfApp` 的 builtin 特殊命令注册,与 `pf graph` 同级,不通过 `@px.tool` 装饰器。
- **Graph.from_yaml 委托 yaml_loader**`Graph.from_yaml(path)` classmethod
委托给 `yaml_loader.load_yaml(path)` 函数式 API,保持 Graph 类职责单一。
### Matrix include/exclude 语义(iter-19
- **exclude**: 组合的所有 key-value 对都匹配则剔除(`combo.items() >= exclude.items()`)。
`exclude: [{os: macos, version: "3.8"}]` 剔除 macos+3.8 组合。
- **include**: 追加额外组合,可引入基础矩阵之外的键。
如基础矩阵 `version: ["3.10"]``include: [{version: "3.11", experimental: true}]`
追加 3.11 实验组合。
- **无基础矩阵键时返回空列表而非 `[{}]`**:避免产生空名任务(`job_`)。
### 条件依赖(iter-19
- **`needs` 项支持 `{job: name, if: expr}` 格式**:条件不满足时跳过该依赖
(不加入 `depends_on`)。
- 复用 `_parse_condition` 解析 `if` 表达式;条件求值时传空上下文 `{}`
`env` 条件直接读 `os.environ`)。
- **典型场景**:仅在特定矩阵变体下才需要的依赖,如
`needs: [{job: setup, if: env.DEPLOY == "true"}]`
### outputs 字段(iter-19
- `TaskSpec` 新增 `outputs: Mapping[str, str] | None` 作为元数据,
仅携带不参与执行,可通过 `spec.outputs` 查询。
- 支持 `${{ matrix.* }}` 占位符替换。
- `to_dict` / `from_json` 往返保留;序列化向前兼容(旧 JSON 无此字段时回退 None)。
### 踩坑总结
- **YAML 加载失败统一包装**`yaml_loader._safe_load` 应将 `yaml.YAMLError`
包装为 `ValueError` 抛出,CLI 层统一捕获处理。否则 YAML 语法错误会
以底层异常形式泄漏到用户面前。
- **不可达防御代码应清理**`_cartesian_product``_parse_condition`
的防御性分支若逻辑上不可达,应删除而非保留 `# pragma: no cover`
## 三、性能优化与基准套件
### 可复用模式
- **独立 `benchmarks/` 目录**(非 pytest 测试),用 `time.perf_counter` 计时,
通过 `python -m benchmarks` CLI 入口运行。覆盖:
- 图构建:10/100/500/1000 节点 DAG 的构建 + validate + layers
- 任务执行:空 fn 任务 × 100/500,四种策略对比
- 上下文注入:有/无依赖、有/无 Context 标注
- 状态后端:MemoryBackend vs JSONBackend vs SQLiteBackend
- **`layers()` 缓存模式**Graph.layers() 每次 run() 都重算拓扑排序,
应缓存。模式:
- 添加 `_layers_cache` 私有字段
- 首次调用计算并缓存
- `add()` / `clear()` 等变更方法中失效缓存
- 参照 `resolved_spec` 已有缓存模式
- **cmd 任务快速路径**:cmd 任务的 `effective_fn` 是无参闭包 `_run()`
无需上下文注入。检测 `spec.fn is None and spec.cmd is not None`
直接返回 `((), {})`,跳过 `build_call_args` 的签名内省与 dep_context 构建。
### 设计决策
- **性能特征(500 空任务)**:
- `sequential`:514 ops/s(最快,无并发开销)—— 小任务/无 I/O 场景首选
- `thread`:93 ops/s(线程池开销)—— I/O 密集型场景
- `async`:44 ops/s(事件循环开销)—— 高并发 I/O 场景
- `dependency`:42 ops/s(最大并行度但调度开销高)—— 复杂依赖图
- **状态后端性能特征**
- `MemoryBackend`save/load 600万/447万 ops/s(首选,无持久化需求时)
- `JSONBackend`save(batch=10)/load 3913/11.9万 ops/s
- `SQLiteBackend`save(batch=10)/load 9657/1.5万 ops/ssave 更快,load 较慢)
- **缓存优化收益**`layers()` 缓存命中 ~1500万 ops/s~50000x 加速);
cmd 快速路径 1130万 ops/s vs fn 无依赖 144万 ops/s~8x 加速)。
### 自实现 Kahn 算法拓扑排序(iter-20
- **方案**:模块级 `_topological_layers(deps)` 函数用 dict + list 直接计算
入度与反向邻接,按层 BFS 处理。返回 `(layers, cycle_nodes)`
- 无环时 `cycle_nodes = None`
- 有环时 `cycle_nodes` 为入度仍 >0 的节点列表(非精确环路径,仅指示存在环)
- **额外优化**`validate()` 顺带填充 `_layers_cache`,使后续 `layers()`
直接命中缓存,避免二次计算 `_topological_layers`
- **结果**diamond(1000) 图构建约 2.21ms~452 ops/s)。
### build_call_args 双快速路径(iter-17
- **快速路径 1cmd 任务)**`spec.fn is None and spec.cmd is not None and
not spec.args and not spec.kwargs` → 直接返回 `((), {})`。
- **快速路径 2(fn 无依赖)**:`not spec.depends_on and not spec.soft_depends_on
and not spec.args and not spec.kwargs` → 调用 `_fn_no_dep_injection(fn)`
获取 `@lru_cache(maxsize=1024)` 缓存的注入计划 `(context_params, error_param)`。
- `context_params`:Context 标注参数名元组;存在必填无默认值参数时返回 `None`
(需走慢路径)。
- `error_param`:第一个无默认值且非 Context 标注的参数名(用于错误信息)。
- **不变量**`error_param is None ⟺ context_params 非 None`,用 `assert`
辅助 pyrefly 类型收窄。
- **提取到 `_try_fast_path` 函数**:避免 `build_call_args` 分支数超 PLR0912
阈值 12。
- **不可哈希 fn 回退**`_cached_signature` 抛 TypeError 时回退到直接内省。
### JSONBackend batch 延迟验证(iter-17
- **问题**`save` 原本每次都 `json.dumps(value)` 验证可序列化性,batch
模式下 N 次 save 产生 N 次验证开销,但 `flush` 只做一次 `json.dump`。
- **方案**`_defer_flush=True` 时跳过 `json.dumps` 验证,让 `flush` 的
`json.dump` 统一捕获 `TypeError`/`ValueError`。非 batch 模式仍即时验证
以提供精确错误位置。
- **依据**:非 batch 调用方期望即时错误反馈;batch 调用方接受延迟错误以换吞吐。
### from_specs 批量注册(iter-17
- **问题**`from_specs` 原本对每个 spec 调 `_register`,而 `_register`
每次都 `_resolved_cache.clear()` + `_layers_cache = None`N 个 spec
产生 N 次缓存清空。
- **方案**:直接写入 `graph.specs` / `graph.deps` 字典,重复名检测在循环内做。
缓存失效由后续 `validate()` / `layers()` 首次调用自然触发(cache 为空时
自动重算)。
- **不扩展到 `add`/`chain`**:增量 API 需即时校验以快速失败;批量构造可
接受延迟校验。
### orjson 可选依赖抽象层(iter-20
- **方案**`_json.py` 模块 `try: import orjson except ImportError: import json`
回退。导出 `dumps`/`loads`/`dump`/`load`/`JSONDecodeError`,签名与 stdlib
兼容。
- **安装**`pip install pyflowx[fast]` 启用 orjson`[project.optional-dependencies]
fast = ["orjson>=3.10.0"]`)。
- **orjson 兼容性差异**
- `dumps` 返回 `bytes` → wrapper 中 `.decode("utf-8")` 统一为 `str`
- 无 `ensure_ascii` 参数 → wrapper 接受并忽略(`# noqa: ARG001`),orjson 始终 UTF-8
- 无 `indent` 参数 → 映射为 `OPT_INDENT_2`
- 紧凑模式无空格(`[1,2,3]` vs stdlib `[1, 2, 3]`)→ 测试断言改为格式无关
- `JSONDecodeError` 直接复用 orjson 的(是 stdlib `json.JSONDecodeError` 子类)
- **pyrefly 兼容**`import orjson` 在 try 块内,所有 `orjson.xxx` 引用加
`# type: ignore[possibly-unbound]`orjson 未安装时 `# type: ignore[import-not-found]`。
### slots=True 内存优化(iter-20
- **方案**:所有 dataclass 添加 `slots=True`,消除 per-instance `__dict__`
开销(约 100-150 bytes/instance)。
- **应用范围**`Graph`/`GraphDefaults`/`TaskSpec`/`TaskResult`/`RetryPolicy`/
`TaskHooks`/`TaskEvent`。
- **冲突处理**`frozen=True` + `slots=True` + `cached_property` 不兼容
(无 `__dict__` 存缓存,frozen 禁止设 slot)→ 将 `TaskSpec.effective_fn`
从 `@cached_property` 改为 `@property`。闭包创建极轻量(仅捕获 `self`),
重复访问开销可忽略。
- **结果**10k TaskSpec 929 → 785 bytes/instance15.5% 减少,1.38 MB 节省)。
### 线程池跨层复用(iter-20
- **问题**`thread` 策略每层创建新 `ThreadPoolExecutor`N 层图创建/销毁
N 次线程池。
- **方案**`_drive_threaded` 创建一个池,跨所有层复用;`max_workers` 取
最大层宽 `max((len(layer) for layer in layers), default=1)`,上限 32。
- **API 变更**`ThreadedLayerRunner.execute` 签名 `max_workers: int` →
`pool: concurrent.futures.ThreadPoolExecutor`,调用方负责池生命周期。
- **额外**`asyncio.get_event_loop()` → `asyncio.get_running_loop()`
兼容 Python 3.12+ 弃用警告(调用点均在 `asyncio.run()` 内的协程中)。
### 执行热路径 4 项优化(iter-25)
sequential(500) 从 764 → 930 ops/s+22%),thread(500) 从 108 → 119 ops/s+10%)。
- **env_context 快速路径**`SyncTaskRunner.run` 与 `_submit_sync_task.fn_call`
内联 `if spec.env is None and spec.cwd is None: effective_fn(...)`,跳过
`with spec.env_context():` 的 `_GeneratorContextManager` 对象创建。即使
`_env_and_cwd` 内部已有 `if not env and cwd is None: yield` 快速路径,
contextlib 包装器的对象创建 + `__enter__`/`__exit__` 协议开销仍存在,
内联判断完全消除这层开销。对 fn 任务(最常见场景)每任务省一次上下文管理器。
- **should_execute 早退**`not self.conditions and not self.skip_if_missing`
时直接 `return True, None`,避免空 list 分配 + 空序列迭代。
- **resolved_spec 去重**`_filter_and_sort` 返回 `(to_run, specs)` 元组,
specs dict 透传给 Layer runner`_build_semaphores` 签名 `graph: Graph` →
`specs: Mapping[str, TaskSpec]`。消除 Sequential/Threaded/AsyncLayerRunner
内的重复 `graph.resolved_spec(name)` 调用。`DependencyRunner._run_task` 仍
调 `resolved_spec`(动态任务不在初始 specs 中)。
- **storage_key 内联**`_apply_cached`/`_store_result` 内联
`spec.name if spec.cache_key is None else spec.storage_key(ctx)`,避免无
cache_key 场景(最常见)的函数调用开销。
- **适用边界**async/dependency 策略对 noop 任务的固有并发开销(事件循环 +
线程池提交)主导,per-task 优化相对影响小;真实 I/O 任务才能体现并行优势。
### 事件驱动优先级调度(iter-27)
DependencyRunner 从"一次性创建所有 asyncio Task"重写为"事件驱动按需创建":
仅当任务依赖全部完成时才创建 asyncio Task,多个就绪任务按 `priority` 降序
创建,高优先级任务优先获取事件循环与线程池资源。
- **`completed` / `in_flight` / `remaining` 三集合**:跟踪任务生命周期。
`completed` = 已完成(含 SUCCESS/SKIPPED/FAILED);`in_flight` = asyncio
Task 已创建未完成;`remaining` = 尚未调度。
- **`concurrency_key=1` 串行化验证优先级**:测试中用 `concurrency_key="g"`
+ `concurrency_limits={"g": 1}` 使无依赖任务串行执行,优先级顺序可观测。
无 concurrency_key 时任务真并行,优先级仅影响 asyncio Task 创建顺序。
- **与层策略优先级的差异**:层策略(sequential/thread/async)仅在同层内
按优先级排序;dependency 策略跨层,按就绪时刻排序——依赖完成后立即就绪,
不等同层其他任务。
### 增量就绪集优化(iter-27
DependencyRunner 主循环每轮调用 `_find_ready()` 扫描全部 `remaining` 任务
检查依赖是否满足,O(N*D) per round、O(N²·D) total。10k 任务链式图
10k 轮调度)下成为瓶颈。
- **`_build_dependency_index` 模块级函数**:预计算 `in_degree[name]`
(尚未完成的硬+软依赖数)+ `dependents[d]`(依赖 d 的任务列表,反向邻接表)
+ `ready`in_degree=0 的初始就绪集)。复杂度从 O(N²·D) 降至 O(N·D)。
- **`_on_complete(name)`**:任务完成后遍历 `dependents[name]`,逐个递减
`in_degree`;降为 0 时加入 `ready` 集合。每完成一个任务仅 O(D_out) 操作。
- **`_register_dynamic(raw_spec, spawner)`**:动态生成任务接入增量结构。
spawner 此时未标记 completed(仍在 in_flight),故计入 unsatisfied
spawner 完成后 `_on_complete` 会递减并触发就绪。
- **基准验证**chain(10000) ~950ms、diamond(10000) ~740ms、wide(10000)
~790ms。chain 线性扩展(1k:87ms → 5k:459ms → 10k:948ms),确认 O(N) 调度。
### 踩坑总结
- **`lru_cache` 对签名内省有 dict lookup 开销**:即便 `functools.lru_cache`
缓存了 `_signature(fn)`,每次仍有 dict lookup。对热路径(如 cmd 任务)
应在更外层短路,避免进入 `build_call_args`。
- **`frozen=True` + `slots=True` + `cached_property` 三者不兼容**:选其中
两个。若需 slots 内存优化,将 `cached_property` 改为 `@property`(轻量
闭包场景)或外部 dict 缓存(重计算场景)。
- **orjson 紧凑格式与 stdlib 不同**:跨后端测试需用格式无关断言
`assert "[1,2,3]" in s or "[1, 2, 3]" in s`),不能硬编码空格。
- **monkeypatch 目标需跟随 import 路径**:当模块从 `import json` 改为
`from ._json import dump`,测试中 `monkeypatch.setattr(json, "dump", ...)`
失效,需改为 `monkeypatch.setattr(storage_mod, "dump", ...)`。
## 四、任务取消与优雅停止
### 可复用模式
- **`CancelToken` 基于 `threading.Event`**:线程安全取消令牌,可跨线程/协程使用。
接口:
- `cancel()` 设置取消状态
- `is_cancelled` 属性检查
- `wait(timeout)` 等待取消信号
- **统一取消检查**`_is_cancelled(effective_cancel) or internal_cancel.is_cancelled`
统一外部取消与 KeyboardInterrupt 的内部取消,避免各处写两段检查逻辑。
- **各策略取消点设计**
- `sequential`:每个任务执行前检查
- `thread`:每层提交前检查;已提交任务等待完成(不强制中断)
- `async`:每个 task 创建前检查;用 `asyncio.CancelledError`
- `dependency`:每个 `_run_task` 创建前检查
- **KeyInterrupt 优雅处理模式**
```python
try:
execute(...)
except KeyboardInterrupt:
cancel_event.set()
# 等待运行中任务完成(最多 5 秒)
# 标记未完成任务为 SKIPPED
report.success = False
finally:
cleanup(...) # 进程池、监控器、通知器
```
### 设计决策
- **取消后待运行任务标记 SKIPPED,运行中任务等待完成**:不强制中断运行中
的任务,避免资源泄漏与状态不一致。这是"优雅停止"的核心定义。
- **`run()` 接受可选 `cancel_event`**:调用方未提供时内部创建。这让
外部代码可以预先持有 cancel_event 引用,在任意时刻触发取消。
- **提取 `_dispatch_strategy` 辅助函数控制分支数**`run()` 函数中
四种策略的分发若用 if/elif 链会导致 PLR0912(分支过多)。提取辅助
函数将 `run()` 的 return 数量控制在 12 以内。
### 踩坑总结
- **资源清理必须在 finally 块**:进程池、监控器、通知器若在 try 块内
清理,KeyboardInterrupt 会跳过清理导致泄漏。
- **`PLR0912`/`PLR0911` 分支/返回过多**:复杂调度逻辑容易触发。优先
提取辅助函数(如 `_dispatch_strategy`、`_print_diagnostics`),
而非用 `# noqa` 抑制。合并语义相同的分支(如 no-args 与 --help
合并)也是有效手段。
## 五、子图执行
### 可复用模式
- **`Graph.subgraph_with_deps(names)` 传递闭包**:从 `names` 出发,
沿 `depends_on` + `soft_depends_on` 向上遍历,收集所有必须先完成的任务。
返回新的 Graph 或任务名集合。
- **`run(only, tags)` 参数组合**
- `only`:任务名列表,运行这些任务及其传递依赖
- `tags`:标签列表,运行匹配任意标签的任务及其传递依赖
- 两者可组合:并集后计算传递闭包
- 都为 None 时运行全图(保持现有行为不变)
- **CLI 选项设计**`pf yamlrun pipeline.yaml --only task1,task2 --tags ingest,report`
逗号分隔,与执行过滤解耦。
### 设计决策
- **传递闭包同时包含软依赖**`subgraph_with_deps` 沿 `depends_on` 与
`soft_depends_on` 双向遍历。软依赖也是必须先完成的任务,不可遗漏。
## 六、结果序列化
### 可复用模式
- **值序列化默认策略**(处理任意 Python 对象):
1. `None` 直接返回 `None`
2. 可 JSON 序列化的原样返回
3. 不可序列化的回退到 `repr(value)`
4. 调用方可通过 `value_serializer` 自定义
- **`to_dict` 结构用列表保序**`results` 为列表(非 dict),便于
CSV/JSON 数组导出,保留执行顺序。
字段:`name/status/value/error/attempts/started_at/finished_at/duration_seconds/reason/tags/depends_on`。
- **CSV 导出省略 value 列**`include_value=False` 时省略 value 列,
因为值可能含逗号/换行符干扰 CSV 解析。
### 设计决策
- **`from_json` 仅供查询,不能重新执行**:反序列化只重建可序列化字段,
`TaskSpec` 的 `fn`/`cmd` 等不可恢复,使用 noop 占位。重建后的 report
仅供查询/分析,不能用于重新执行。这是明确的契约边界。
- **`datetime` 通过 `fromisoformat` 还原**:ISO 8601 字符串往返转换,
避免自定义时间格式。
### HTML 报告导出设计(iter-19
- **自包含 HTML5 文档**:内联 CSS,无外部依赖,便于邮件附件/嵌入式展示。
- **XSS 防护**:所有用户内容(任务名/错误/原因/值)经 `html.escape` 转义。
- **状态徽章着色**success 绿 / failed 红 / skipped 黄 / running 蓝,
通过 CSS class 区分。
- **`_render_summary_card` 静态方法**:渲染汇总卡片(总数/成功/失败/跳过/
耗时),避免在 `to_html` 主流程中重复 HTML 拼接。
- **value 列渲染**:尝试 `dumps(value)` 序列化后转义;失败回退 `repr(value)`。
### 踩坑总结
- **`_noop_fn` 占位函数体无法覆盖**:与 `task.py:_task_noop` 同模式,
占位函数的函数体在测试中无法触发,属于可接受的覆盖率缺口。
- **orjson 紧凑格式与 stdlib 不同**HTML 报告中 value 列断言需格式无关
`"[1,2,3]" in s or "[1, 2, 3]" in s`),不能硬编码空格。
## 七、CLI 可视化与体验
### 可复用模式
- **`pf graph` 多格式输出**
- `--format ascii`(默认):`rich.tree.Tree` 按层分组 + `rich.table.Table` 显示依赖/标签
- `--format mermaid`:复用 `Graph.to_mermaid()` 直接打印(不加颜色,便于复制到 mermaid.live
- `--format list`:仅列任务名(与 `yamlrun --list` 一致)
- `--format deps`:纯依赖关系表
- `--orientation`Mermaid 方向(TD/TB/BT/LR/RL
- **`--color-by tag` 着色模式**:维护 `tag_colors` 字典(标签 → rich 颜色名),
首次见到标签时从调色板循环分配。无标签任务回退默认色。`color_by="none"`
时统一颜色,不显示图例。
- **`--list` 过滤与执行过滤解耦**
- `--only`/`--tags` 控制执行范围
- `--list-tag`/`--list-name` 仅控制 `--list` 模式的显示
- 名称匹配大小写不敏感(子串),标签匹配精确,两者可组合(交集)
- **`pf info [tool]` 工具详情**
- 无参数 → 所有工具简表(工具/别名/子命令数/描述)
- 带参数 → 工具详情(别名、描述、子命令表)
- 支持别名解析(`pf info clearscreen` 等价于 `pf info clr`
### 设计决策
- **shell 补全不依赖第三方库**:直接生成静态脚本
- bash: `complete -F _pf_complete pf` + `compgen -W "..."`
- zsh: `#compdef pf` + `_describe 'command' cmds`
- fish: 每个命令一行 `complete -c pf -n "__fish_use_subcommand" -a "..."`
工具名来自 `_TOOL_ALIASES` 规范名集合,二级补全交给子命令的 `--help`。
- **`--progress` 与 `--quiet` 互斥**:quiet 时无进度条,避免输出冲突。
### 踩坑总结
- **闭包内可变计数器用 `counter[0]` 单元素 list**pyrefly 在闭包内对
父作用域变量的读-写支持不佳,`nonlocal` 会触发类型检查告警。改用
`counter = [0]` 单元素 list,闭包内 `counter[0] += 1` 修改的是 list
内容(非绑定),pyrefly 接受。
- **`_TOOL_REGISTRY` 的 key 类型为 `str | None`**:排序时必须过滤 `None`
否则 `sorted()` 会因类型比较失败抛错。
## 八、错误诊断
### 可复用模式
- **诊断数据结构(frozen dataclass**
- `FailureCluster``pattern`(异常类型+消息前缀)/`tasks`/`sample_error`
- `DependencyChain``failed_task`/`root_cause`/`chain`/`broken_at`
- `DiagnosticReport``failed_tasks`/`skipped_due_to_failure`/`root_causes`/`dependency_chains`/`failure_clusters`/`hints`
- **根因识别算法**
- **根因任务**:FAILED 状态且其所有硬依赖都是 SUCCESS(或无依赖)
- **依赖链**:从根因到失败任务的路径,用 BFS 反向遍历依赖图
- **broken_at**:链中第一个非 SUCCESS 的任务(通常是根因本身)
- **相似失败聚类**:按 `(异常类型名, 消息前 50 字符)` 聚类,
便于发现批量失败模式。
- **根因提示表**:识别常见异常类型并给出建议
- `FileNotFoundError` → 检查路径配置
- `ImportError`/`ModuleNotFoundError` → 检查依赖安装
- `TimeoutError`/`TaskTimeoutError` → 考虑增加 timeout 或优化性能
- `ConnectionError` → 检查网络连通性
- `PermissionError` → 检查权限
- `KeyError` → 检查上下文注入参数
### 设计决策
- **`TaskFailedError` 携带 `report` 字段**:失败时 `_finalize_failure`
传入 report,CLI 层可从异常对象直接获取诊断信息,无需重复构造。
`report` 字段可选(`Optional[RunReport]`),向后兼容。
- **`report.diagnose()` 成功时返回 `None`**:明确区分"无诊断"与"空诊断"
避免调用方误判。
- **CLI 失败时自动打印诊断摘要到 stderr**:`pf yamlrun` 失败时
`report.success == False`)自动调用 `_print_diagnostics` 辅助方法,
无需用户额外参数。
### 踩坑总结
- **CLI 异常处理合并同类**:`TaskFailedError` 与 `PyFlowXError` 的
处理逻辑相似,应合并到同一 except 块,提取 `_print_diagnostics`
辅助方法。重复的 except 分支会增加 PLR0911 风险。
## 九、观测性
### 可复用模式
- **`run_id` 短码生成**`uuid4().hex[:8]` 生成 8 字符十六进制短码,
足以区分并发运行,便于人工阅读。`RunReport.run_id` 字段默认自动生成,
显式指定时尊重调用方。
- **结构化日志 extra 字段标准化**(不用 `LoggerAdapter`):
- `run_id` — 运行 ID(所有日志)
- `task_name` — 任务名(任务级日志)
- `status` — 任务状态(success/failed/skipped/cancelled
- `attempts` — 尝试次数(失败日志)
- `error_type` — 异常类型名(失败日志)
- `strategy` / `total_tasks` — run() 入口/结束日志
- `run_id` 从 `report` 参数读取,无 report 上下文时填 `"-"`
### 设计决策
- **放弃 `LoggerAdapter` 方案**:会强制改写所有 `logger.xxx` 调用点,
且对模块级函数(`_apply_cached`/`_handle_failure`)签名侵入大。
改用直接在每条日志显式 `extra={...}`,散点 extra 已足够,无需过度抽象。
- **`profile()` 便捷方法委托 ProfileReport**
```python
def profile(self, graph: Graph) -> ProfileReport:
from .profiling import ProfileReport
return ProfileReport.from_report(self, graph)
```
`Graph` / `ProfileReport` 仅在 `TYPE_CHECKING` 块导入,避免循环依赖;
`from __future__ import annotations` 使注解延迟求值。
- **序列化向前兼容**`from_json()` 优先恢复 `run_id`,缺失时自动生成
新 ID。旧版 JSON(无 `run_id`)可正常加载。
### 通知器平台扩展(iter-19
- **复用 `WebhookNotifier` 基类**Slack/Discord/Telegram/Feishu/DingTalk/
WeChatNotifier 均继承 `WebhookNotifier`,仅覆盖 `_build_payload(payload: dict) -> dict`
适配平台格式。零新依赖(stdlib `urllib.request`)。
- **TelegramNotifier 覆盖 `__init__`**:额外需要 `chat_id` 参数,
URL 中拼接 `bot_token`。
- **`Notifier` Protocol 生命周期**`notify(level, payload)` + `close()`。
`NotificationLevel` 枚举(RUNNING/SUCCESS/FAILED/SKIPPED+ `ALL_LEVELS`
frozenset`levels` 参数内部过滤。
- **WebhookNotifier 通用字段**`url`/`secret`/`levels`/`timeout`
`_send` 用 `urllib.request.urlopen` 发送 JSON POST`_build_payload`
默认透传(子类按平台格式重写)。
### 踩坑总结
- **循环依赖用 `TYPE_CHECKING` 守卫 + 延迟导入**`report.py` 依赖
`profiling.py` 的 `ProfileReport` 与 `graph.py` 的 `Graph`,但运行时
不需要。`TYPE_CHECKING` 块导入 + `from __future__ import annotations`
注解延迟求值,是打破循环依赖的标准模式。
- **不要为未来场景预留扩展点**:`LoggerAdapter` + `extra` 的统一封装
属于"未来可考虑"项,当前散点 extra 已足够,不应预先抽象。
### 监控导出(iter-21P9.4
- **`MetricsCollector` 作为 `on_event` 回调**:收集任务生命周期事件,
聚合为 Prometheus 文本格式。线程安全(多线程策略下回调可并发调用)。
- 指标:`pyflowx_task_total{task,status}` (counter)、
`pyflowx_task_duration_seconds{task}` (gauge,最近值)、
`pyflowx_task_duration_seconds_sum{task}` (counter)、
`pyflowx_task_retries_total{task}` (counter)、
`pyflowx_run_total{status}` (counter)
- `record_run(report)` 单独记录运行级指标;`reset()` 清空全部
- **`metrics_text()` 按 section 条件化输出**:每个指标 section 仅在
有数据时才输出 `# HELP` / `# TYPE` 行。避免 `reset()` 后仍残留
HELP/TYPE 行误导调用方判断"是否还有数据"。
- **`health_check(report)` 状态判定**
- `healthy` — 全部 SUCCESS
- `degraded` — 部分(非全部)FAILED
- `unhealthy` — 全部 FAILED
- `unknown` — 无任务结果
返回 dict 含 `status`/`total`/`success`/`failed`/`skipped`/`duration`。
- **`start_metrics_server` 用 stdlib `http.server`**:零依赖暴露
Prometheus 端点(`/metrics` 路径)。返回 `stop()` 函数关闭服务器。
生产环境可叠加反向代理或替换为 `prometheus_client`。
- `@override` 装饰 `log_message` 静默访问日志,按 Python 版本条件导入
`typing` 3.12+ / `typing_extensions` <3.12
## 十、API 设计通用模式
### 可复用模式
- **classmethod 委托函数式 API**:如 `Graph.from_yaml(path)` 委托
`yaml_loader.load_yaml(path)`,保持类职责单一,函数式 API 可独立复用。
- **frozen dataclass 表达不可变结果**`FailureCluster`/
`DependencyChain`/`DiagnosticReport` 等只读结果用 `@dataclass(frozen=True)`
自动获得 `__hash__` 与 `__eq__`,适合作为字典 key 或集合元素。
- **列表保序而非 dict**:当顺序有意义(如执行顺序、依赖链路径)时,
用 list 而非 dict 存储结果,便于序列化数组导出。
- **可选参数 None 哨兵**`run(only=None, tags=None)`None 表示
"不限制",与空列表 `[]` 语义区分(空列表通常表示"无匹配")。
- **提取辅助函数控制分支数**`run()`、CLI 命令分发等复杂函数容易
触发 PLR0911/PLR0912。优先提取 `_dispatch_strategy`/
`_print_diagnostics`/`_print_task_list` 等辅助函数,而非用 noqa 抑制
或合并语义不同的分支。
- **depends_on 自动推断(iter-26**`depends_on` 为空的纯 fn 任务,
从必需参数名(无默认值、非 Context 标注、非 `*args`/`**kwargs`
匹配图中任务名自动推断依赖。安全规则:仅当 `depends_on` 为空时触发
(显式声明优先);已在 `soft_depends_on` 中的参数名不重复加入硬依赖;
cmd 任务与无 fn 任务不推断。`Graph.add()` 和 `Graph.from_specs()` 均
调用 `_auto_infer_deps_single`,前者用图中已有任务名,后者用全部任务名。
- **`px.graph(*specs)` 快捷构造(iter-26**:等价于 `Graph.from_specs`
但接受可变参数而非列表,减少 `[...]` 样板。透传 `defaults`/`namespace`。
### 设计决策
- **公共 API 必须有完整类型注解与中文 docstring**:项目既有风格,
`python-standards.md` 硬约束。
- **零运行时依赖原则(YAML 例外)**:除 PyYAMLYAML 编排必需)、
rich + typerCLI 必需)、typing-extensions(前向兼容)、orjson(可选
加速,`pip install pyflowx[fast]`)外,不引入运行时依赖。新增依赖须
审慎,优先用标准库。
## 十一、状态后端与缓存
### 可复用模式
- **`StateBackend` 抽象基类**`get`/`put`/`has`/`iter`/`clear` +
`invalidate(key) -> bool`iter-19 新增)。`invalidate` 返回是否实际
删除,便于调用方判断缓存命中。
- **`_TTLStateBackendMixin` 4 原语**`_get_raw`/`_put_raw`/`_iter_raw`/
`_clear_raw`/`_delete_raw`(iter-19 新增)。子类只需实现这 5 个原语,
`get`/`put`/`has`/`iter`/`clear`/`invalidate` 由 mixin 统一组合 TTL 判断。
- **`MemoryBackend` LRU 驱逐(iter-19**
- 用 `OrderedDict` 替代普通 dict
- `_get_raw` 命中时 `move_to_end(key)` 更新访问顺序
- `_put_raw` 超限时 `popitem(last=False)` 驱逐头部(最旧)
- `maxsize=None` 时不驱逐(默认行为,向后兼容)
- **`SQLiteBackend` WAL 模式 + RLock**:线程安全;`batch()` 通过
`_in_batch` 标志延迟 commit,不持锁。
### 设计决策
- **`JSONBackend.invalidate` 覆盖**:非 batch 模式下需 `flush()` 落盘,
否则删除只在内存中生效,进程崩溃后状态丢失。batch 模式下标记
`_dirty=True`,由 `flush()` 统一处理。
- **`SQLiteBackend._delete_raw` 用 `cursor.rowcount > 0`**:判断是否
实际删除,避免先 `has` 后 `delete` 的双次查询。
- **`maxsize` 默认 None 而非 0**:0 会立即驱逐所有项,等价于禁用缓存;
None 表示无限制,与历史行为兼容。
## 十二、CLI 工具错误处理
### 可复用模式
- **`subprocess.run` 的 `check` 参数分级策略**iter-16):
- **失败需感知**dockercmd/msdownload/sglang)→ `check=True` +
`try/except subprocess.CalledProcessError` 打印 rich 错误
- **失败可容忍但需检查**(taskkill)→ 保持 `check=False`,但检查
`returncode` 打印结果(pkill 返回 1 表示无匹配进程,非错误)
- **失败无关紧要**clr/which)→ `check=False`,加注释说明原因
- **错误路径统一处理模式**
```python
try:
subprocess.run(cmd, check=True, ...)
except subprocess.CalledProcessError as e:
_console.print(f"[red]命令失败 (returncode={e.returncode})[/]")
except FileNotFoundError:
_console.print(f"[red]命令未找到: {cmd[0]}[/]")
```
### 设计决策
- **rich Console 而非 print**CLI 工具错误用 `rich.console.Console`
打印带颜色输出,与项目其他 CLI 工具一致。
- **`check=True` + try/except 优于 `check=False` + 手动检查**:除非
命令返回非零是预期行为(如 taskkill pkill 无匹配、which 未找到命令),
否则用 `check=True` 让 subprocess 抛异常,统一在 except 中处理。
### 踩坑总结
- **`@patch` 装饰器禁用**:项目 `python-standards.md` 禁用 `@patch`
装饰器与 `mock.patch.object` 上下文。mock subprocess 异常用
`monkeypatch.setattr(subprocess, "run", lambda *_, **__: (_ for _ in ()).throw(err))`
—— 用生成器抛异常的技巧让 lambda 能抛出指定异常。
- **mock 异常抛出技巧**`lambda *_, **__: (_ for _ in ()).throw(err)`
利用生成器仅在迭代时抛异常的特性,让无参 lambda 能抛出指定异常。
## 十三、测试与质量保障
### 可复用模式
- **`# pragma: no cover` 清理策略**iter-18):按 `python-standards.md`
"pragma: no cover 是清理信号,应激活或删除"原则,逐处分析:
- **逻辑上不可达** → 删除(如 `if result is None` 在已过滤的列表后)
- **防御性代码但实际可触发** → 激活:移除 pragma,写测试覆盖该分支
- **调用方已保证的前置条件** → 保留 pragma,加注释说明依赖契约
- **覆盖率缺口分析方法**
1. 跑 `pytest --cov=src/pyflowx --cov-report=term-missing`
2. 找出 `Stmts`/`Miss` 列中 Miss > 0 的文件
3. 看 `Missing` 列定位未覆盖行号
4. 判断是"未触发分支"还是"不可达代码"
5. 前者补测试,后者清 pragma 或删代码
- **测试命名**`test_<被测对象>_<场景>`,如 `test_json_backend_flush_type_error`。
### 设计决策
- **公共 API 优先测试**:用 `has`/`get` 等公共接口测试,不访问私有方法。
故障注入等场景可临时访问私有属性,docstring 注明原因。
- **Mock 优先级**`monkeypatch` > 内联 stub > `unittest.mock` >
`pytest-mock`。禁用 `@patch` 装饰器、`mock.patch.object` 上下文、
`pytest-mock` 的 `mocker` fixture。
- **`slow` 标记**:耗时测试加 `@pytest.mark.slow`CI 可 `-m "not slow"` 跳过。
### 踩坑总结
- **monkeypatch 目标需跟随 import 路径**:当模块从 `import json` 改为
`from ._json import dump`,测试中 `monkeypatch.setattr(json, "dump", ...)`
失效,需改为 `monkeypatch.setattr(storage_mod, "dump", ...)`。
- **占位函数体无法覆盖**`_noop_fn` 等占位函数的函数体在测试中无法触发
(参数已校验),属可接受的覆盖率缺口,加 `# pragma: no cover` 并注释原因。
- **不可哈希参数回退分支**`lru_cache` 缓存的函数遇到不可哈希参数会抛
TypeError,需 try/except 回退到慢路径。该回退分支需单独写测试(构造
不可哈希 fn 触发)。
## 十四、任务编排与持久化(P9,iter-21)
### 任务分组(P9.1.2
- **`Graph.group(name, tasks)` 逻辑分组**`name` 不能与已注册任务名或
已有组名冲突;`tasks` 必须是已注册任务。组名可作为其他任务的
`depends_on` / `soft_depends_on` 引用,等价于依赖组内全部成员。
- **统一依赖重写 `_rewrite_deps`**`_rewrite_deps_for_loops` 改名为
`_rewrite_deps`,合并 `_loop_groups` 与 `_groups` 字典后统一重写
依赖。所有调用点(`_register` / `_expand_loop_to_specs` / `from_specs`
都改为调用新名。
- **`from_specs` 立即校验引用**:在 `from_specs` 中引用尚未声明的组名
会立即抛 `MissingDependencyError`。若需引用稍后声明的组,改用
`graph.add()` 在 `graph.group()` 之后注册。
### 动态任务生成(P9.1.3
- **`TaskSpec(dynamic=True)`**`fn` 运行时可返回 `TaskSpec | list[TaskSpec]`
DependencyRunner 中途注册并调度派生任务。
- **仅 `dependency` 策略支持**:层模型(sequential/thread/async)的层屏障
与动态扩展语义冲突。`_dispatch_strategy` 检测 `has_dynamic` 后显式拒绝
其他策略,抛 `ValueError`。
- **派生任务的依赖自动补充**:若派生 spec 未显式声明对父任务的依赖,
DependencyRunner 用 `dataclasses.replace` 自动追加父任务名到
`depends_on`,确保派生任务在父任务之后执行。
- **`asyncio.wait` + 手动异常传播**:动态任务场景下 `futures` 字典会随
执行增长,必须用 while 循环 + `asyncio.wait(return_when=FIRST_COMPLETED)`
增量调度。**陷阱**`asyncio.wait` 不会自动 re-raise task 异常,需手动
`f.exception()` 检查并显式 `raise`,同时 `cancel()` 未完成任务,才能
与 `asyncio.gather` 的 fail-fast 行为一致。
### 数据流(P9.2
- **`TaskSpec.outputs` 元数据字段**`Mapping[str, str] | None`,仅作为
元数据携带不参与执行。支持 `${{ matrix.* }}` 占位符替换(YAML 场景)。
- **`RunReport.output_of(task_name, output_name)`**:按 spec.outputs 中
声明的 path 从 `result.value` 提取命名输出。
- path 语法:`"$"` 取整个 result`"key"` 取 dict 键;`"a.b.c"` 嵌套取值
- 未声明 output 或任务不在报告中抛 `KeyError`
- **`Graph.pipeline(*specs)` 语义别名**:等价于 `chain()`,强调数据流
上下文注入的语义场景。
### 检查点恢复(P9.3
- **`run(resume_from=RunReport | str | Path)`**:预填充 `report.results`
与 `context`executor 跳过已 SUCCESS 的任务。
- 仅恢复 `status == SUCCESS` 且名称在当前图中的任务
- FAILED / SKIPPED 任务会重新执行
- 接受 `RunReport` 对象、JSON 字符串路径、`Path` 对象
- **`_apply_resume` 抽取为独立函数**:避免 `run()` 的 PLR0912 分支数
(≤12)超限。`_load_resume_report` 处理 `RunReport | str | Path` 三种
输入形态。
- **`_filter_and_sort` + `_run_task` 双重跳过**`_filter_and_sort` 在
层模型中跳过已恢复任务;`_run_task` 在 DependencyRunner 中检查
`name in report.results and status == SUCCESS` 直接返回。
- **下游获取上游恢复值**:恢复的任务结果写入 `context`,下游任务通过
上下文注入正常获取上游返回值,无需重新执行上游。
### RunHistoryP9.3
- **文件系统历史管理**`RunHistory(dir)` 按 `{run_id}.json` 命名存储
`RunReport` JSON。
- **API**`save(report) -> Path` / `load(run_id) -> RunReport` /
`list_runs() -> list[str]` / `latest() -> RunReport | None` /
`delete(run_id) -> bool` / `__len__` / `__contains__`。
- **目录不存在自动创建**:构造函数 `mkdir(parents=True, exist_ok=True)`。
- **`latest()` 按 mtime 排序**:取最新 mtime 的 JSON 文件加载。
- **未实现压缩/归档**:大量历史运行可能占用较多磁盘,后续可加
`max_entries` 或滚动归档。
### 踩坑总结
- **`asyncio.wait` vs `gather` 异常传播**:从 `gather` 切换到 `wait` 后
7 个测试失败(`TaskFailedError` 被吞)。根因是 `wait` 不自动 re-raise
必须手动检查 `f.exception()` 并显式 raise + cancel 剩余任务。
- **检查点恢复测试设计**:测试 `test_resume_downstream_gets_restored_result`
需让上游 a 第一次成功、下游 b 第一次失败;第二次恢复时只恢复 a,
重新执行 b。若 a/b 都成功则都被恢复,b 不会重新执行,无法验证
"下游获取上游恢复值"。
- **`from_specs` 与 `group` 的声明顺序**`from_specs` 立即校验依赖引用,
组名必须在 `from_specs` 调用前已声明。否则需用 `add()` 在 `group()`
之后注册引用组的任务。
- **`metrics_text` 条件化输出**:最初实现总是输出 HELP/TYPE 行(即便无数据),
导致 `reset()` 后文本仍含 `pyflowx_task_total`,违反测试预期。所有
section 都应按数据存在性条件化,与 retries/run_total 一致。
## 十五、图片处理(P10iter-22
### 可复用模式
- **可选依赖 try/except + `_require_*` 守卫**:模块顶部
`try: from PIL import Image, ImageDraw, ImageFont; HAS_PIL = True;
except ImportError: HAS_PIL = False`,每个子命令入口
`if not _require_pil(): return` 打印 `pip install pyflowx[office]`
提示而非崩溃。与 `pdftool.py` 的 `pypdf` 守卫模式一致。
- **`@px.tool` 装饰器注册子命令**`@px.tool("imagetool", subcommand="r",
help="调整尺寸")`subcommand 短别名(r/c/ro/fl/cv/wm/cp/i/e/hi/co
供 CLI 快捷调用(如 `pf img r input.png out.png --width 100`)。
- **`image_pipeline` DAG 构造器**:参照 `compose()` 函数式构造器,
每个 step 生成 `TaskSpec``depends_on` 链式依赖形成单链 DAG
`operations` 字典映射操作名到 `imagetool.image_*` 函数,不重新实现操作。
- **`naming` 模板占位符**`{stem}`(累积 stem,每步更新为输出文件 stem)/
`{step}`(操作名)/`{ext}`(当前扩展名,`convert` 步骤可能改变)。
默认 `"{stem}_{step}{ext}"`(如 `input_resize.jpg`)。
- **`_make_step_fn` 闭包工厂**:提取为独立函数
`_make_step_fn(base_fn, input_path, output_path, params)` 返回 `_run`
闭包,利用每次调用的独立作用域正确捕获每轮参数,避免循环变量捕获陷阱。
- **`pytest.importorskip("PIL")`**:测试模块顶部跳过未安装 Pillow 的环境,
避免在未安装 `[office]` extra 的 CI 上失败。
### 设计决策
- **单一 `imagetool` + office extra**:不拆分为 `imageops`/`imagemeta`
等多工具,所有图片操作集中在 `ops/files/imagetool.py`。Pillow 通过
`pyproject.toml` 的 `[office]` extra 安装(`pillow>=10.4.0`),
零运行时依赖原则不破坏。
- **`_save_image` 统一保存逻辑**:所有需要保存图片的子命令委托
`_save_image(img, output, fmt, quality)` 处理 JPG→JPEG 规范化 +
JPEG 强制 RGB 转换 + JPEG/WEBP 应用 quality 参数,避免每个子命令
重复处理格式与 alpha 通道问题。
- **`image_pipeline` 委托 imagetool 函数**:不重新实现图片操作,
`operations` 字典映射操作名到 `imagetool.image_*` 函数,保持单一职责
与单一实现源。
- **EXIF 用 `exif.tobytes()`**Pillow 10+ 的 `Image.Exif` 对象
`bytes(exif)` 抛 `ValueError`,必须用 `exif.tobytes()` 序列化。
`_save_exif` 辅助函数封装此逻辑。
- **PLR0912 分支数控制**`image_exif` 函数最初 13 分支超过 12 限制,
提取 `_print_exif`/`_apply_exif_modifications`/`_apply_single_exif_set`/
`_save_exif` 4 个辅助函数降至合规。
### 踩坑总结
- **Pillow 10+ API 迁移**`Image.FLIP_LEFT_RIGHT`(模块级常量)已弃用,
pyrefly 无法解析 → 改用 `Image.Transpose.FLIP_LEFT_RIGHT`(枚举形式);
`bytes(exif)` 抛 `ValueError: bytes must be in range(0, 256)` →
改用 `exif.tobytes()`。
- **`quantize(colors=N)` 实际色数可能 < N**:简单图片(如纯色)量化后
调色板可能少于请求色数,直接按 `count` 遍历会 `IndexError`。必须用
`actual_count = len(palette) // 3` 动态计算并 `min(count, actual_count)`。
- **JPEG 不支持 alpha 通道**RGBA 模式图片直接保存为 JPEG 抛
`cannot write mode RGBA as JPEG`。`_save_image` 中 JPEG 分支强制
`img.convert("RGB")` 去 alpha。
- **JPG 不是 Pillow 合法格式名**`img.save(format="JPG")` 抛
`KeyError: 'JPG'`。`_save_image` 中 `save_fmt == "JPG"` 时改写为
`"JPEG"` 规范化。
- **闭包捕获循环变量陷阱**`image_pipeline` 中若直接在 for 循环内
定义闭包 `def _run(): base_fn(input_path, output_path, **params)`
所有任务的 `_run` 会捕获**最后一次循环**的变量。必须提取为独立函数
`_make_step_fn(base_fn, input_path, output_path, params)` 利用每次
调用的独立作用域。
- **`draw.textbbox()` 返回 float**`text_w = bbox[2] - bbox[0]` 是
float,传给 `_resolve_position`(期望 int)会触发类型警告。用
`int(bbox[2] - bbox[0])` 显式转换。
## 十六、文件操作底层 APIP11iter-23
### 可复用模式
- **纯函数模块 + 导出模块本身**:`fileops.py` 20 个函数全部纯函数
(返回数据,不打印),不带 `@px.tool` 装饰器。`__init__.py` 用
`from . import fileops` + `"fileops"` 加入 `__all__`,导出模块而非
20 个函数(避免 `find`/`exists`/`size` 等通用名污染顶层命名空间)。
用户用 `from pyflowx.fileops import find, read_text` 或
`px.fileops.find(...)` 访问。
- **与 CLI 工具分层**CLI 工具(pdftool/imagetool)面向终端用户,函数
返回 `None` 打印结果;底层 API(fileops)面向编程组合,函数返回数据
可被 `px.task` 包装。
- **DAG 组合调用模式**:上游任务 `TaskSpec("find", fn=..., outputs={"paths": "$"})`
声明输出,下游任务**参数名匹配上游任务名**自动注入上游结果值。
`report.output_of("find", "paths")` 按 `outputs` 路径提取(`"$"` 整个结果 /
`"key"` dict 键 / `"a.b.c"` 嵌套)。
- **`find` 的 `max_depth` 语义**`None` 无限制 / `0` 仅 root 直接 glob /
`1` 直接子项 / `2` 孙项。用辅助函数 `_glob_with_depth(root, pattern, max_depth)`
递归实现,`max_depth < 0` 返回空。
- **`walk_files` 返回生成器**`Iterator[Path]` 惰性求值,仅返回文件不返回
目录,不做 pattern 匹配。与 `find`(返回排序 `list[Path]`,支持 pattern
互补。
- **`sha256` 流式读取**`sha256(path, chunk_size=65536)` 分块读取,适用
大文件。默认 64KB chunk 平衡内存与 IO。
- **写操作返回字节数/路径**`write_text`/`write_bytes`/`append_text`
返回写入字节数;`copy`/`move` 返回目标 `Path``delete` 返回 `bool`。
便于下游任务使用。
### 设计决策
- **仅依赖标准库**`pathlib` / `hashlib` / `shutil`,不引入新依赖,
遵守项目零运行时依赖原则。
- **EAFP 优于 LBYL**:文件不存在等错误由标准库异常表达
`FileNotFoundError` / `FileExistsError`),不预先检查。
- **`copy` / `move` 的 `overwrite` 语义统一**
- `overwrite=False` 且 `dst` 是目录:`src` 移入/复制到 `dst / src.name`
- `overwrite=False` 且 `dst` 是已存在文件:抛 `FileExistsError`
- `overwrite=True` 且 `dst` 存在:**先删除 `dst`**,再把 `src` 放到
`dst` 路径(不走 `is_dir` 分支)
两个函数语义一致,`overwrite=True` 意为"替换目标"而非"移到内部"。
- **父目录自动创建**`write_text`/`write_bytes`/`append_text`/`copy`/`move`
都用 `dst_path.parent.mkdir(parents=True, exist_ok=True)` 自动创建父目录,
避免调用方预处理。
### 踩坑总结
- **`copy`/`move` 的 `overwrite=True` 与 `dst.is_dir()` 顺序陷阱**
初版先走 `dst_path.is_dir()` 分支(`dst_path = dst / src.name`),再检查
`overwrite`。这导致 `overwrite=True` 时 `dst` 本身没被删除,而是处理
`dst/src.name`。修复:`overwrite=True` 且 `dst` 存在时,**先删除 `dst`**
不走 `is_dir` 分支。
- **DAG 数据流注入要求参数名匹配任务名**:下游任务参数名必须与上游任务名
一致才能自动注入。如 `TaskSpec("summarize", ...)` 的下游任务参数名必须是
`summarize`,写成 `count_and_summarize` 会抛 `InjectionError: parameter
has no dependency`。
- **批量复制到目录不应使用 `overwrite=True`**`copy(p, dst_dir, overwrite=True)`
会删除整个 `dst_dir` 目录,导致后续复制失败。批量复制应用默认
`overwrite=False`(复制到 `dst_dir / p.name`)。
## 十七、通用 DAG 构造器集合(P12iter-24
### 可复用模式
- **函数式 DAG 构造器模式**:`pipelines.py` 提供 3 个构造器,输入函数/
命令/数据,返回 `Graph`,无副作用。与 `imaging.image_pipeline` 同级,
复用 `Graph.chain` / `Graph.map` 的依赖链接模式,但提供更高层次抽象。
- **`data_pipeline` 用函数 `__name__` 作默认任务名**:下游函数参数名匹配
前驱任务名自动注入返回值。`<lambda>` 回退为 `step`;重名追加索引
`extract_1`)。用户可通过 `names` 参数自定义命名,但需确保下游函数
参数名匹配自定义名。
- **`command_chain` 自动命名 `cmd_{i:02d}_{first_arg}`**:索引保证唯一性,
首参数提供可读性。空 list/空字符串回退为 `cmd`。shell 字符串命令取首个
token。支持 `cwd`/`env`/`continue_on_error` 透传。
- **`fan_out_fan_in` 用 `Context` 标注实现 reduce 聚合**reduce 包装函数
标注 `ctx: Context` 接收完整依赖上下文,按 `worker_names` 顺序提取结果,
传递给用户的 `reduce` 函数。`reduce=None` 时仅生成 fan-out(无聚合)。
- **闭包工厂模式绑定参数**`_make_worker_fn(worker, item)` 与
`_make_reduce_fn(reduce, worker_names)` 参照 `imaging._make_step_fn` 模式,
闭包捕获函数参数(非循环变量),避免 Python 闭包"延迟绑定"陷阱。
- **`switch`/`branch` 条件分支构造器(iter-27)**:基于已有条件原语
`DEP_EQUALS`/`DEP_MATCHES`/`NOT`/`AND`)构建,不引入新条件类型。
`switch(selector, cases, *, default=None)` 为每个 case 追加
`DEP_EQUALS(selector.name, case_key)` 条件;`default` 追加
`AND(NOT(DEP_EQUALS(..., k)) for k in cases)` 条件。
`branch(selector, predicate, if_true, *, if_false=None)` 为 `if_true` 追加
`DEP_MATCHES(selector.name, predicate)`,为 `if_false` 追加 `NOT(...)`。
用 `dataclasses.replace` 在不修改原 spec 的前提下追加 `depends_on` 和
`conditions`。
### 设计决策
- **新建 `pipelines.py` 而非扩展 `compose.py`**`compose.py` 职责是多图
引用解析(`GraphComposer`/`compose`),与单图 DAG 构造器正交。新建模块
保持职责清晰:`imaging.py` 图片专用,`pipelines.py` 通用构造器。
- **`reduce` 可选支持纯 fan-out**`fan_out_fan_in` 的 `reduce` 参数为
`Callable | None`。`None` 时仅生成 fan-out,适用于不需要聚合的并行批处理
(如批量文件复制,每个 worker 独立写文件)。
- **与 `Graph.chain`/`Graph.map` 差异化定位**:Graph 方法是方法式 API
(需先创建 Graph + TaskSpec),本模块是函数式 API(直接传函数/命令/数据,
返回 Graph),更高层抽象,减少样板代码。
### 踩坑总结
- **`data_pipeline` 自定义 names 后参数名必须匹配**:若用 `names=["alpha",
"beta"]`,下游函数参数名必须是 `alpha`(不是函数 `__name__`),否则抛
`InjectionError: parameter has no dependency`。
- **`command_chain` + `continue_on_error` 的下游仍被 SKIPPED**
`continue_on_error=True` 时失败任务标记 FAILED 不抛异常,但其硬依赖下游
仍被 SKIPPED`reason="上游任务状态为 failed"`)。`continue_on_error` 只
影响"是否中止整图",不影响"下游是否跳过"。`report.success` 在
`continue_on_error=True` 时仍为 `True`(不抛异常即视为成功)。
- **命令失败时 `px.run` 抛 `TaskFailedError` 而非返回失败 report**
`continue_on_error=False`(默认)时,命令失败会抛 `TaskFailedError`
需用 `pytest.raises(px.TaskFailedError)` 捕获测试,不能检查
`report.success`。
+369 -147
View File
@@ -6,7 +6,7 @@
[![PyPI](https://img.shields.io/pypi/v/pyflowx.svg)](https://pypi.org/project/pyflowx/)
[![Python](https://img.shields.io/pypi/pyversions/pyflowx.svg)](https://pypi.org/project/pyflowx/)
[![Documentation Status](https://readthedocs.org/projects/pyflowx/badge/?version=latest)](https://pyflowx.readthedocs.io/zh/latest/)
[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/gookeryoung/pyflowx)
[![Coverage](https://img.shields.io/badge/coverage-97%25-brightgreen.svg)](https://github.com/gookeryoung/pyflowx)
[![License](https://img.shields.io/pypi/l/pyflowx.svg)](https://github.com/gookeryoung/pyflowx/blob/main/LICENSE)
PyFlowX 把"任务依赖"这件事做到极致简单:**参数名就是依赖声明**。无需装饰器、
@@ -23,8 +23,9 @@ PyFlowX 把"任务依赖"这件事做到极致简单:**参数名就是依赖
- **软依赖** —— `soft_depends_on` 仅用于上下文注入,不参与拓扑分层
- **并发限制** —— `concurrency_key` + `concurrency_limits` 按组限流
- **任务钩子** —— `TaskHooks`pre_run/post_run/on_failure)生命周期回调
- **断点续跑** —— `MemoryBackend` / `JSONBackend`,成功结果可缓存复用;`batch()` 批量落盘
- **断点续跑** —— `MemoryBackend` / `JSONBackend`,成功结果可缓存复用;`batch()` 批量落盘`invalidate(key)` 单键失效
- **缓存键** —— `cache_key` 函数基于输入计算稳定键,使不同输入产生独立缓存
- **缓存驱逐** —— `MemoryBackend(maxsize=N)` LRU 驱逐最旧条目;TTL 过期自动忽略
- **命令任务** —— `cmd` 参数直接执行外部命令,支持列表/shell/可调用对象
- **条件执行** —— `conditions` 参数按平台、环境变量、应用安装等条件跳过任务
- **图组合** —— `compose` / `GraphComposer` 编程式展开多图字符串引用
@@ -32,8 +33,18 @@ PyFlowX 把"任务依赖"这件事做到极致简单:**参数名就是依赖
- **图级默认值** —— `GraphDefaults` 统一配置 retry/timeout/concurrency 等
- **CLI 运行器** —— `CliRunner` 把多个图映射为命令行子命令,替代 Makefile
- **可观测** —— `on_event` 回调(RUNNING/SUCCESS/FAILED/SKIPPED)、`dry_run` 预览、`verbose` 生命周期日志、Mermaid 可视化
- **YAML 任务编排** —— GitHub Actions 风格的声明式任务图,支持 `jobs`/`needs`/`strategy.matrix`/`if` 等 CI/CD 概念,从 YAML 文件直接加载执行
- **最小依赖** —— 仅依赖标准库 + PyYAML3.8 需 `graphlib_backport``typing-extensions`
- **进度监控** —— `run(progress=True)` 开箱即用的 rich 进度条;`ProgressCallback` 协议供程序化集成
- **任务通知** —— `Notifier` 协议支持飞书/钉钉/企业微信/Slack/Discord/Telegram webhook 与 Python 回调,全生命周期事件可配置
- **流式获取** —— `run_iter()` 生成器逐个 yield 任务结果,适用于大量任务逐个处理
- **任务取消** —— `CancelToken` 线程安全取消令牌,支持外部取消与 `KeyboardInterrupt` 优雅停止
- **子图执行** —— `run(only=[...], tags=[...])` 按名称或标签运行图的子集,自动包含传递依赖
- **结果序列化** —— `RunReport.to_json()` / `to_dict()` / `to_csv()` / `to_html()` 多格式导出运行结果,`from_json()` 支持反序列化重建;HTML 报告自包含内联 CSS,适合浏览器直接打开或邮件分享
- **CLI 可视化** —— `pf graph <yaml>` 终端 DAG 渲染(ASCII 分层树按标签着色 / Mermaid / 依赖表),`pf yamlrun --progress` rich 进度条,`pf info [tool]` 工具详情,`pf completion bash|zsh|fish` shell 补全
- **失败诊断** —— `RunReport.diagnose()` 自动分析根因、依赖链回溯、相似失败聚类与可操作建议;CLI 失败时自动打印诊断摘要
- **可观测性** —— `run_id` 贯穿日志/序列化/诊断;结构化日志 `extra``task_name`/`status`/`attempts`/`error_type``profile(graph)` 离线性能分析(关键路径/并行度/瓶颈)
- **状态后端** —— `MemoryBackend` / `JSONBackend` / `SQLiteBackend`WAL 模式,适合大规模任务)
- **YAML 任务编排** —— GitHub Actions 风格 `jobs`/`needs`/`strategy.matrix`/`if` 条件,`pf yamlrun pipeline.yaml` 一键执行
- **最小依赖** —— `rich` + `typer` + `typing-extensions`3.13 以下)+ `pyyaml`;安装 `orjson` 可加速 JSON 序列化(`pip install pyflowx[fast]`
- **97% 测试覆盖** —— 分支覆盖率 >= 95%
## 安装
@@ -147,6 +158,86 @@ resolved = px.compose(graphs) # "all" 图中的 "build" 引用被展开
引用格式:`"command_name"`(整个图)或 `"command_name.task_name"`(特定任务)。
`CliRunner` 内部自动调用 `compose`
### YAML 任务编排
GitHub Actions 风格的 YAML 文件直接加载为 `Graph`,支持 `jobs`/`needs`/`strategy.matrix`/`if`/`continue-on-error`/`env`/`defaults`,以及 `matrix.include`/`exclude` 过滤、条件依赖(`needs: [{job: ..., if: ...}]`)、`outputs` 声明:
```yaml
# pipeline.yaml
strategy: thread
defaults:
retry: {max_attempts: 3}
env: {CI: "true"}
jobs:
setup:
cmd: ["echo", "setup"]
runs-on: linux
build:
needs: [setup]
cmd: ["echo", "build-${{ matrix.version }}"]
strategy:
matrix:
version: ["3.10", "3.11", "3.12"]
os: ["linux", "macos"]
exclude:
- version: "3.10"
os: "macos"
include:
- version: "3.13"
os: "linux"
if: "env.CI"
outputs:
version: "${{ matrix.version }}"
deploy:
needs:
- job: build
if: "env.BRANCH == 'main'"
cmd: ["echo", "deploy"]
test:
needs: [build]
cmd: ["echo", "test"]
continue-on-error: true
```
```python
import pyflowx as px
graph = px.Graph.from_yaml("pipeline.yaml")
# 或从字符串解析:graph = px.parse_yaml_string("...")
report = px.run(graph, strategy="thread")
```
CLI 一键执行:
```bash
pf yamlrun pipeline.yaml # 执行
pf yamlrun pipeline.yaml --dry-run # 仅预览,不执行
pf yamlrun pipeline.yaml --list # 列出所有任务
pf yamlrun pipeline.yaml --quiet # 静默模式
pf yamlrun pipeline.yaml --strategy sequential # 覆盖策略
```
**字段映射**
| YAML 字段 | TaskSpec 字段 | 说明 |
|-----------|---------------|------|
| `cmd` / `run` | `cmd` | `cmd` 为列表,`run` 为 shell 字符串 |
| `needs` | `depends_on` | 矩阵 job 依赖自动展开为所有变体 |
| `if` | `conditions` | `success()`/`always()`/`env.VAR`/`env.VAR == 'x'`/`env.VAR != 'x'` |
| `strategy.matrix` | 矩阵扇出 | 笛卡尔积,命名 `{job}_{key}-{value}` |
| `strategy.matrix.include` | 矩阵追加 | 额外组合(可引入新键) |
| `strategy.matrix.exclude` | 矩阵剔除 | 匹配所有 key-value 的组合被移除 |
| `${{ matrix.key }}` | 占位符 | `cmd`/`run`/`cwd`/`env`/`outputs` 中替换 |
| `outputs` | `outputs` | 任务声明的输出键值映射(元数据,不参与执行) |
| `needs: [{job: ..., if: ...}]` | 条件依赖 | `if` 不满足时跳过该依赖 |
| `continue-on-error` | `continue_on_error` | 字段名支持 hyphen/underscore |
| `runs-on` | `tags` | 追加到 tags 元组 |
| `defaults` | `GraphDefaults` | 图级默认值(retry/timeout/env/cwd 等) |
### 任务模板 —— task_template
`task_template` 工厂批量生成相似 TaskSpec:
@@ -311,133 +402,6 @@ pf pymake --quiet # 静默模式
`verbose=True`(默认)时打印任务生命周期(开始/成功/失败/跳过)与命令输出;`--quiet` 关闭。
## YAML 任务编排
PyFlowX 支持 GitHub Actions 风格的声明式 YAML 任务编排,从 YAML 文件直接加载任务图。
### 编程式 API
```python
import pyflowx as px
# 从 YAML 文件加载任务图
graph = px.Graph.from_yaml("pipeline.yaml")
report = px.run(graph, strategy="thread")
# 或用函数式 API
graph = px.load_yaml("pipeline.yaml")
graph = px.parse_yaml_string("""
jobs:
hello:
cmd: ["echo", "hello"]
""")
```
### CLI 入口
通过 `pf` 统一入口调用(详见 [pf 工具](#cli-工具) 章节):
```bash
# 执行 YAML 任务图
pf yamlrun pipeline.yaml
# 指定执行策略
pf yamlrun pipeline.yaml --strategy thread
# 仅打印任务分层,不执行
pf yamlrun pipeline.yaml --dry-run
# 列出所有任务名
pf yamlrun pipeline.yaml --list
# 静默模式
pf yamlrun pipeline.yaml --quiet
```
### YAML SchemaGitHub Actions 风格)
```yaml
strategy: thread # 图级默认策略
defaults: # 图级默认值
retry: {max_attempts: 3}
verbose: true
env: {CI: "true"}
jobs:
setup:
cmd: ["git", "clone", "..."]
runs-on: linux
build:
needs: [setup] # 依赖列表
cmd: ["python", "-m", "build"]
timeout: 300
retry: {max_attempts: 2, delay: 1.0}
test:
needs: [build]
cmd: ["python${{ matrix.version }}", "-m", "pytest"] # 矩阵占位符
strategy:
matrix: # 笛卡尔积展开为 6 个任务
version: ["3.8", "3.9", "3.10"]
os: ["linux", "macos"]
if: "env.CI" # 条件: 环境变量存在
lint:
needs: [build]
cmd: ["ruff", "check"]
if: "env.CI == 'true'" # 条件: 环境变量等于
deploy:
needs: [test, lint] # 矩阵依赖自动展开
cmd: ["twine", "upload"]
if: "env.DEPLOY_TOKEN != ''"
allow-upstream-skip: true
concurrency-key: deploy_lock
```
### 字段映射
| YAML 字段 | TaskSpec 字段 | 说明 |
|-----------|---------------|------|
| `jobs.<id>` | `name` | job ID 作为任务名 |
| `cmd` / `run` | `cmd` | `cmd` 为列表形式,`run` 为 shell 字符串 |
| `needs` | `depends_on` | 依赖列表(矩阵任务自动展开) |
| `if` | `conditions` | `success()` / `always()` / `env.VAR` / `env.VAR == 'x'` |
| `strategy.matrix` | 矩阵扇出 | 笛卡尔积展开为多个任务 |
| `${{ matrix.key }}` | 占位符 | 在 cmd/run/cwd/env 中替换 |
| `timeout` | `timeout` | 超时秒数 |
| `retry` | `retry` | `{max_attempts, delay, backoff, jitter}` |
| `cwd` | `cwd` | 工作目录 |
| `env` | `env` | 环境变量 |
| `verbose` | `verbose` | 详细输出 |
| `continue-on-error` | `continue_on_error` | 失败不中止整图 |
| `skip-if-missing` | `skip_if_missing` | 命令不存在时跳过 |
| `allow-upstream-skip` | `allow_upstream_skip` | 上游跳过时仍执行 |
| `priority` | `priority` | 同层优先级 |
| `concurrency-key` | `concurrency_key` | 并发限制键 |
| `tags` | `tags` | 自由标签 |
| `runs-on` | `tags`(追加) | 运行环境标签 |
## 示例
仓库 `examples/` 目录包含完整示例:
- [`etl_pipeline.py`](examples/etl_pipeline.py) —— ETL 流水线(sequential
- [`parallel_run.py`](examples/parallel_run.py) —— 并行执行对比(thread vs sequential
- [`async_aggregation.py`](examples/async_aggregation.py) —— 异步聚合 + Context 注入
- [`yaml_pipeline.yaml`](examples/yaml_pipeline.yaml) + [`yaml_pipeline.py`](examples/yaml_pipeline.py) —— YAML 声明式 CI/CD 流水线(矩阵扇出 + 条件执行)
运行:
```bash
python examples/etl_pipeline.py
python examples/parallel_run.py
python examples/async_aggregation.py
python -m pyflowx.examples.yaml_pipeline
pf yamlrun src/pyflowx/examples/yaml_pipeline.yaml --dry-run
```
## 断点续跑
```python
@@ -465,6 +429,22 @@ px.TaskSpec(
)
```
**LRU 驱逐**`MemoryBackend(maxsize=N)` 限制最大条目数,超限时按 LRU(最近最少使用)
策略驱逐最旧条目。`get`/`has` 命中会将条目移到最近使用位置:
```python
from pyflowx import MemoryBackend
backend = MemoryBackend(maxsize=100) # 最多 100 条,超出驱逐最旧
```
**手动失效**`invalidate(key)` 删除单个键的缓存,`clear()` 清除全部:
```python
backend.invalidate("fetch_user") # 删除单个键,返回是否已删除
backend.clear() # 清除全部
```
## 错误处理
所有错误都是 `PyFlowXError` 的子类:
@@ -494,7 +474,7 @@ except px.PyFlowXError:
| 特性 | PyFlowX | Airflow | Prefect | Dask |
|------|---------|---------|---------|------|
| 零样板 | 参数名即依赖 | 装饰器 + XCom | 装饰器 | 装饰器 |
| 运行时依赖 | 仅标准库 | 重型 | 中型 | 中型 |
| 运行时依赖 | rich + typer | 重型 | 中型 | 中型 |
| 类型安全 | mypy strict | 弱 | 中 | 中 |
| 异步原生 | 是 | 否 | 部分 | 否 |
| 断点续跑 | 内置 | 需配置 | 需配置 | 需配置 |
@@ -541,6 +521,253 @@ px.TaskSpec("low", fn=work, priority=0)
px.TaskSpec("high", fn=work, priority=10) # 同层内先执行
```
### 任务取消
`CancelToken` 提供线程安全的取消信号,传入 `run(cancel_event=...)` 即可外部取消正在执行的图。
`KeyboardInterrupt`(Ctrl+C)也会触发相同的优雅取消流程:
```python
token = px.CancelToken()
# 在另一个线程中触发取消
import threading
def cancel_later():
import time
time.sleep(2)
token.cancel("超时取消")
threading.Thread(target=cancel_later, daemon=True).start()
# 取消后:待运行任务标记 SKIPPED,运行中任务等待完成,report.success = False
report = px.run(graph, strategy="dependency", cancel_event=token)
for name, result in report.results.items():
print(f"{name}: {result.status.value}")
```
也支持标准 `threading.Event` 作为取消信号:
```python
event = threading.Event()
px.run(graph, cancel_event=event)
```
### 子图执行
`run(only=...)` / `run(tags=...)` 只运行指定任务及其传递依赖,适用于调试单个任务或增量执行:
```python
# 只运行 "report" 任务及其所有上游依赖
report = px.run(graph, only=["report"])
# 按标签过滤:只运行带 "ingest" 标签的任务及其依赖
report = px.run(graph, tags=["ingest"])
# 两者可组合(取并集)
report = px.run(graph, only=["report"], tags=["ingest"])
```
CLI 也支持 `--only` / `--tags`
```bash
pf yamlrun pipeline.yaml --only report,deploy
pf yamlrun pipeline.yaml --tags ingest,report
```
### 结果序列化
`RunReport` 支持将运行结果导出为 JSON / dict / CSV,便于持久化、跨进程传输或对接外部工具:
```python
report = px.run(graph, strategy="sequential")
# JSON 字符串(含 success/summary/results
text = report.to_json(indent=2)
# JSON 兼容字典
data = report.to_dict()
# CSV 表格导出(不含 value 列以避免特殊字符干扰)
csv_text = report.to_csv(include_value=False)
```
任务返回值可能是任意 Python 对象(不一定 JSON 可序列化)。默认策略:
可序列化原样保留,不可序列化回退到 `repr(value)`;调用方可通过
`value_serializer` 自定义:
```python
# 自定义值序列化(如把 Path 转为字符串)
text = report.to_json(value_serializer=str)
```
`from_json()` 可从 JSON 字符串重建 `RunReport`**仅供查询/分析**,不能重新
执行(`TaskSpec``fn`/`cmd` 等不可序列化字段无法恢复):
```python
restored = px.RunReport.from_json(text)
print(restored["double"]) # 任务返回值
print(restored.result_of("a").status) # TaskStatus
```
### CLI 可视化
`pf graph <yaml>` 在终端可视化任务图 DAG,无需运行即可检查依赖关系:
```bash
# 默认 ASCII 渲染:分层树(按标签着色) + 依赖关系表
pf graph pipeline.yaml
# 输出 Mermaid 语法(可粘贴到 mermaid.live
pf graph pipeline.yaml --format mermaid
# 自定义方向
pf graph pipeline.yaml --format mermaid --orientation LR
# 仅列出任务名
pf graph pipeline.yaml --format list
# 纯依赖关系表
pf graph pipeline.yaml --format deps
# 关闭标签着色(统一单色)
pf graph pipeline.yaml --color-by none
```
ASCII 模式默认 `--color-by tag`,按任务首标签选择 rich 颜色并在图下方
显示标签颜色图例,便于视觉分组。
`pf yamlrun` 新增 `--progress` 选项,启用 rich 进度条显示运行中任务、完成
数与耗时:
```bash
pf yamlrun pipeline.yaml --progress
```
### 任务列表过滤
`pf yamlrun --list` 支持 `--list-tag` / `--list-name` 过滤,便于大型 YAML
中快速定位任务:
```bash
# 列出所有任务
pf yamlrun pipeline.yaml --list
# 只列出含 ingest 标签的任务
pf yamlrun pipeline.yaml --list --list-tag ingest
# 按任务名子串过滤(大小写不敏感)
pf yamlrun pipeline.yaml --list --list-name extract
# 组合过滤(交集)
pf yamlrun pipeline.yaml --list --list-tag ingest --list-name extract
```
### 工具详情与 Shell 补全
`pf info [tool]` 显示工具详情:
```bash
# 列出所有工具的简表(含子命令数、描述)
pf info
# 显示指定工具的子命令详情
pf info clr
pf info gittool
```
`pf completion <shell>` 生成 shell 补全脚本,提升日常使用体验:
```bash
# bash —— 添加到 ~/.bashrc
pf completion bash >> ~/.bashrc
# zsh —— 添加到 ~/.zshrc
pf completion zsh >> ~/.zshrc
# fish —— 保存到 ~/.config/fish/completions/pf.fish
pf completion fish > ~/.config/fish/completions/pf.fish
```
补全脚本覆盖所有内建命令(`yamlrun`/`graph`/`info`/`completion`)与
`@px.tool` 注册的工具名;二级补全交给具体子命令的 `--help` 自描述。
### 失败诊断
运行失败时,`RunReport.diagnose()` 自动生成结构化诊断报告,包含:
- **根因任务**FAILED 且所有硬依赖非 FAILED 的任务(依赖链中最先出问题的环节)
- **依赖链回溯**:从根因到每个失败任务的路径
- **相似失败聚类**:按 `(异常类型, 消息前缀)` 聚类,发现批量失败模式
- **可操作建议**:识别 FileNotFoundError/ImportError/TimeoutError 等常见异常并给出提示
```python
report = px.run(graph, strategy="sequential")
if not report.success:
diag = report.diagnose()
if diag is not None:
print(diag.describe())
# 根因任务
print(diag.root_causes)
# 依赖链
for chain in diag.dependency_chains:
print(f"{chain.failed_task}: {' -> '.join(chain.chain)}")
```
`pf yamlrun` 失败时自动打印诊断摘要到 stderr:
```bash
pf yamlrun pipeline.yaml # 失败时自动输出诊断报告
```
`report.describe()` 在失败时也会附诊断摘要。
### 可观测性
每次 `run()` 自动生成 8 字符十六进制 `run_id`,作为本次运行的唯一追踪 ID
贯穿日志、序列化结果与诊断报告:
```python
report = px.run(graph)
print(report.run_id) # 如 "a3f4b2c1"
print(report.summary()["run_id"]) # summary 也含 run_id
```
`run_id` 通过结构化日志的 `extra` 字段注入所有执行器日志,便于跨系统关联。
日志格式遵循 `logging` 标准延迟格式化,`extra` 字段包括:
- `run_id` —— 本次运行 ID
- `task_name` —— 任务名(任务级日志)
- `status` —— 任务状态(`running`/`success`/`failed`/`skipped`
- `attempts` —— 尝试次数(失败日志)
- `error_type` —— 异常类型名(失败日志)
```python
import logging
logging.basicConfig(level=logging.INFO)
# 日志输出示例:
# INFO 运行开始: run_id=a3f4b2c1 strategy=dependency tasks=3
# INFO task 'a' skipped (cached)
# WARNING task 'b' failed (attempt 1/3): RuntimeError('boom'); retrying
# INFO 运行结束: run_id=a3f4b2c1 success=False tasks=3
```
`to_json()` / `to_dict()` / `from_json()` 同步保留 `run_id`,反序列化时可还原:
旧版 JSON(无 `run_id` 字段)会自动生成新 ID,向前兼容。
`report.profile(graph)` 返回 `ProfileReport`,基于 `started_at`/`finished_at`
做离线性能分析(关键路径、并行度、Top 瓶颈),零运行时开销:
```python
profile = report.profile(graph)
print(profile.describe()) # 人类可读报告
print(profile.critical_path) # 关键路径任务序列
bottlenecks = profile.top_bottlenecks(5) # Top-5 瓶颈任务
```
## 开发
```bash
@@ -551,11 +778,11 @@ uv sync --extra dev
uv run pytest --cov=pyflowx --cov-fail-under=95
# 类型检查
uv run mypy
uv run pyrefly check .
# 代码风格
uv run ruff check src tests examples
uv run ruff format --check src tests examples
uv run ruff check .
uv run ruff format --check .
```
## 模块结构
@@ -569,30 +796,25 @@ uv run ruff format --check src tests examples
| `compose.py` | 多图组合:`GraphComposer` / `compose` |
| `context.py` | 上下文注入:参数名→依赖解析 |
| `command.py` | 命令执行:`run_command`list/shell/Callable |
| `cancellation.py` | 任务取消:`CancelToken` 线程安全取消令牌 |
| `conditions.py` | 条件执行:内置条件与组合器 |
| `executors.py` | 执行器与 `run` 入口:四种策略共享模块级辅助;verbose 统一应用到 spec |
| `storage.py` | 状态后端:`MemoryBackend` / `JSONBackend`batch flush |
| `yaml_loader.py` | YAML 任务编排:`load_yaml` / `parse_yaml_string`GitHub Actions 风格) |
| `runner.py` | CLI 运行器:`CliRunner` |
| `report.py` | 运行结果:`RunReport` / `TaskResult` |
| `yaml_loader.py` | YAML 任务编排:GitHub Actions 风格 schema 解析(`load_yaml` / `parse_yaml_string` / `run_cli` |
| `registry.py` | 函数注册中心:`register_fn` / `get_fn` / `has_fn`YAML 的 `fn:` 引用) |
| `tools.py` | `@px.tool` 装饰器:`ToolSpec` / `run_tool` / `list_tools` / `list_subcommands` |
| `profiling.py` | 性能分析:`Profiler` 任务耗时统计 |
| `errors.py` | 错误家族:`PyFlowXError` 子类 |
| `ops/` | 工具函数(dev/files/media/system),被 YAML 的 `fn:` 引用 |
| `ops/` | CLI 工具模块集合(每个子模块用 `@px.tool` 注册一个工具) |
### CLI 工具
| 模块 | 职责 |
|------|------|
| `cli/pf.py` | 统一入口:`pf <tool> [command]`自动发现 `configs/*.yaml` 并路由 |
| `cli/configs/` | YAML 工具配置(gittool/filedate/pdftool 等 13 个工具的 `cli:` schema |
| `cli/pymake.py` | 构建工具(替代 Makefile),`pf pymake b` 调用 |
| `cli/yamlrun.py` | YAML pipeline 执行器,`pf yamlrun pipeline.yaml` 调用 |
| `cli/profiler.py` | 性能分析 CLI |
| `cli/emlmanager.py` | 邮件管理 CLI |
| `cli/dev/` | 开发工具(dockercmd/envdev |
| `cli/llm/` | LLM 工具(msdownload/sglang |
| `cli/system/` | 系统工具(clearscreen/taskkill/which |
| `cli/pf.py` | 统一入口:`pf <tool> [command]`按需导入 `ops/<tool>.py` 触发 `@px.tool` 注册 |
| `cli/legacy/profiler.py` | 性能分析 CLIlegacy |
| `cli/legacy/emlmanager.py` | 邮件管理 CLIlegacy |
## 许可证
+51
View File
@@ -0,0 +1,51 @@
"""PyFlowX 性能基准套件.
用法::
python -m benchmarks # 运行全部基准
python -m benchmarks graph # 仅图构建基准
python -m benchmarks execution # 仅执行基准
python -m benchmarks context # 仅上下文注入基准
python -m benchmarks storage # 仅状态后端基准
python -m benchmarks advanced # 仅高级基准
"""
from __future__ import annotations
import time
from collections.abc import Callable
from typing import Any
from rich.console import Console
from rich.table import Table
__all__ = ["print_results", "time_it"]
def time_it(fn: Callable[[], Any], iterations: int = 100, warmup: int = 5) -> tuple[float, float]:
"""计时工具:返回 (平均耗时 ms, 吞吐 ops/sec)."""
for _ in range(warmup):
fn()
times: list[float] = []
for _ in range(iterations):
t0 = time.perf_counter()
fn()
times.append(time.perf_counter() - t0)
avg = sum(times) / len(times)
return avg * 1000, 1.0 / avg if avg > 0 else float("inf")
def print_results(title: str, results: list[tuple[str, int, float, float]]) -> None:
"""打印格式化基准结果表."""
console = Console()
table = Table(title=title, show_header=True, header_style="bold")
table.add_column("场景", style="cyan", no_wrap=True)
table.add_column("迭代", justify="right")
table.add_column("平均耗时", justify="right", style="yellow")
table.add_column("吞吐", justify="right", style="green")
for name, iters, ms, ops in results:
ms_str = f"{ms:.3f} ms" if ms < 1 else f"{ms:.2f} ms"
ops_str = f"{ops:.0f} ops/s" if ops > 1000 else f"{ops:.1f} ops/s"
table.add_row(name, str(iters), ms_str, ops_str)
console.print(table)
console.print()
+475
View File
@@ -0,0 +1,475 @@
"""PyFlowX 性能基准套件.
用法::
python -m benchmarks # 运行全部基准
python -m benchmarks graph # 仅图构建基准
python -m benchmarks execution # 仅执行基准
python -m benchmarks context # 仅上下文注入基准
python -m benchmarks storage # 仅状态后端基准
python -m benchmarks advanced # 仅高级基准
"""
from __future__ import annotations
import math
import sys
import tempfile
from collections.abc import Callable
from pathlib import Path
from typing import Any
from rich.console import Console
import pyflowx as px
from benchmarks import print_results, time_it
from benchmarks.bench_advanced import run_advanced
from pyflowx import Graph, GraphDefaults, RetryPolicy, TaskSpec
from pyflowx._json import _HAS_ORJSON
from pyflowx._json import dumps as _dumps
from pyflowx._json import loads as _loads
from pyflowx.context import build_call_args
from pyflowx.storage import JSONBackend, MemoryBackend, SQLiteBackend
# ============================================================================
# 图生成工具
# ============================================================================
def make_chain(n: int) -> list[TaskSpec]:
"""生成 n 个任务的链式 DAG。"""
specs = [TaskSpec(f"t{i}", cmd=["true"]) for i in range(n)]
for i in range(1, n):
specs[i] = TaskSpec(f"t{i}", cmd=["true"], depends_on=(f"t{i - 1}",))
return specs
def make_diamond(n: int) -> list[TaskSpec]:
"""生成 n 个任务的菱形 DAG(每层宽度约 sqrt(n))。"""
width = max(1, int(math.sqrt(n)))
specs: list[TaskSpec] = []
prev_layer: list[str] = []
layer = 0
count = 0
while count < n:
cur_layer: list[str] = []
for j in range(width):
if count >= n:
break
name = f"l{layer}_t{j}"
deps = tuple(prev_layer) if prev_layer else ()
specs.append(TaskSpec(name, cmd=["true"], depends_on=deps))
cur_layer.append(name)
count += 1
prev_layer = cur_layer
layer += 1
return specs
def make_wide(n: int) -> list[TaskSpec]:
"""生成 n 个独立任务(无依赖,最大并行度)。"""
return [TaskSpec(f"t{i}", cmd=["true"]) for i in range(n)]
# ============================================================================
# 基准:图构建
# ============================================================================
def bench_construction() -> None:
"""图构建(from_specs + validate)基准。"""
results = []
for n in (10, 100, 500, 1000):
specs = make_chain(n)
ms, _ = time_it(lambda s=specs: Graph.from_specs(s), iterations=20)
results.append((f"chain({n})", 20, ms, 1.0 / (ms / 1000) if ms > 0 else 0))
for n in (10, 100, 500, 1000):
specs = make_diamond(n)
ms, _ = time_it(lambda s=specs: Graph.from_specs(s), iterations=20)
results.append((f"diamond({n})", 20, ms, 1.0 / (ms / 1000) if ms > 0 else 0))
print_results("图构建 (from_specs + validate)", results)
def bench_layers() -> None:
"""拓扑分层基准(冷启动 vs 缓存命中)。"""
results = []
for n in (100, 500, 1000):
specs = make_diamond(n)
graph = Graph.from_specs(specs)
def _cold(g: Graph = graph) -> None:
g._layers_cache = None # type: ignore[attr-defined]
g.layers()
ms_cold, ops_cold = time_it(_cold, iterations=50, warmup=5)
results.append((f"layers(cold,{n})", 50, ms_cold, ops_cold))
ms_hot, ops_hot = time_it(lambda g=graph: g.layers(), iterations=200, warmup=10)
results.append((f"layers(cached,{n})", 200, ms_hot, ops_hot))
print_results("拓扑分层 (layers)", results)
def bench_resolved_spec() -> None:
"""resolved_spec 缓存命中基准。"""
results = []
for n in (100, 500, 1000):
specs = make_chain(n)
defaults = GraphDefaults(retry=RetryPolicy(max_attempts=2))
graph = Graph.from_specs(specs, defaults=defaults)
name = f"t{n // 2}"
ms, ops = time_it(lambda g=graph, nm=name: g.resolved_spec(nm), iterations=500, warmup=20)
results.append((f"resolved_spec(cached,{n})", 500, ms, ops))
print_results("resolved_spec (缓存命中)", results)
def run_graph() -> None:
"""运行全部图基准。"""
bench_construction()
bench_layers()
bench_resolved_spec()
# ============================================================================
# 基准:任务执行
# ============================================================================
def bench_sequential() -> None:
"""sequential 策略执行基准。"""
results = []
def noop() -> None:
pass
for n in (50, 200, 500):
specs = [TaskSpec(f"t{i}", fn=noop) for i in range(n)]
graph = Graph.from_specs(specs)
ms, ops = time_it(lambda g=graph: px.run(g, strategy="sequential"), iterations=10, warmup=2)
results.append((f"sequential({n})", 10, ms, ops))
print_results("执行策略: sequential", results)
def bench_thread() -> None:
"""thread 策略执行基准。"""
results = []
def noop() -> None:
pass
for n in (50, 200, 500):
specs = [TaskSpec(f"t{i}", fn=noop) for i in range(n)]
graph = Graph.from_specs(specs)
ms, ops = time_it(lambda g=graph: px.run(g, strategy="thread"), iterations=10, warmup=2)
results.append((f"thread({n})", 10, ms, ops))
print_results("执行策略: thread", results)
def bench_async() -> None:
"""async 策略执行基准。"""
results = []
def noop() -> None:
pass
for n in (50, 200, 500):
specs = [TaskSpec(f"t{i}", fn=noop) for i in range(n)]
graph = Graph.from_specs(specs)
ms, ops = time_it(lambda g=graph: px.run(g, strategy="async"), iterations=10, warmup=2)
results.append((f"async({n})", 10, ms, ops))
print_results("执行策略: async", results)
def bench_dependency() -> None:
"""dependency 策略执行基准。"""
results = []
def noop() -> None:
pass
for n in (50, 200, 500):
specs = [TaskSpec(f"t{i}", fn=noop) for i in range(n)]
graph = Graph.from_specs(specs)
ms, ops = time_it(lambda g=graph: px.run(g, strategy="dependency"), iterations=10, warmup=2)
results.append((f"dependency({n})", 10, ms, ops))
print_results("执行策略: dependency", results)
def bench_large_graph() -> None:
"""大图调度基准:验证增量就绪集优化效果(1k–10k 任务)。
覆盖三种图形状:
* chain —— 深链(每轮仅 1 个就绪,调度轮数 = N)
* wide —— 完全并行(首轮全部就绪,仅 1 轮调度)
* diamond —— 菱形(多层,每层多任务)
"""
results = []
def noop() -> None:
pass
for shape, maker in (("chain", make_chain), ("diamond", make_diamond), ("wide", make_wide)):
for n in (1000, 5000, 10000):
specs = maker(n)
# 替换为 noop fn(避免子进程开销,纯测调度性能)
specs = [TaskSpec(s.name, fn=noop, depends_on=s.depends_on) for s in specs]
graph = Graph.from_specs(specs)
ms, ops = time_it(lambda g=graph: px.run(g, strategy="dependency"), iterations=3, warmup=1)
results.append((f"dependency-{shape}({n})", 3, ms, ops))
print_results("大图调度 (dependency, 增量就绪集)", results)
def bench_cmd_execution() -> None:
"""cmd 任务执行基准(真实子进程)。"""
results = []
for n in (10, 50, 100):
specs = [TaskSpec(f"t{i}", cmd=["true"]) for i in range(n)]
graph = Graph.from_specs(specs)
ms, ops = time_it(lambda g=graph: px.run(g, strategy="sequential"), iterations=5, warmup=1)
results.append((f"cmd-sequential({n})", 5, ms, ops))
for n in (10, 50, 100):
specs = [TaskSpec(f"t{i}", cmd=["true"]) for i in range(n)]
graph = Graph.from_specs(specs)
ms, ops = time_it(lambda g=graph: px.run(g, strategy="thread", max_workers=8), iterations=5, warmup=1)
results.append((f"cmd-thread({n})", 5, ms, ops))
print_results("cmd 任务执行 (['true'])", results)
def run_execution() -> None:
"""运行全部执行基准。"""
bench_sequential()
bench_thread()
bench_async()
bench_dependency()
bench_large_graph()
bench_cmd_execution()
# ============================================================================
# 基准:上下文注入
# ============================================================================
def bench_context_no_deps() -> None:
"""无依赖 fn 任务的上下文注入基准。"""
results = []
def noop() -> None:
pass
spec = TaskSpec("noop", fn=noop)
context: dict[str, Any] = {}
ms, ops = time_it(lambda s=spec, c=context: build_call_args(s, c), iterations=2000, warmup=100)
results.append(("fn(no-deps)", 2000, ms, ops))
# cmd 任务快速路径
spec_cmd = TaskSpec("cmd", cmd=["true"])
ms, ops = time_it(lambda s=spec_cmd, c=context: build_call_args(s, c), iterations=2000, warmup=100)
results.append(("cmd(fast-path)", 2000, ms, ops))
print_results("上下文注入 (build_call_args)", results)
def bench_context_with_deps() -> None:
"""有依赖 fn 任务的上下文注入基准。"""
results = []
def consumer(a: int, b: int) -> int:
return a + b
spec = TaskSpec("consumer", fn=consumer, depends_on=("a", "b"))
context = {"a": 1, "b": 2, "c": 3}
ms, ops = time_it(lambda s=spec, c=context: build_call_args(s, c), iterations=2000, warmup=100)
results.append(("fn(2-deps)", 2000, ms, ops))
# Context 标注
from pyflowx.task import Context
def ctx_fn(ctx: Context) -> int:
return sum(ctx.values())
spec_ctx = TaskSpec("ctx", fn=ctx_fn, depends_on=("a", "b"))
ms, ops = time_it(lambda s=spec_ctx, c=context: build_call_args(s, c), iterations=2000, warmup=100)
results.append(("fn(Context-annotated)", 2000, ms, ops))
# **kwargs
def kwargs_fn(**kwargs: int) -> int:
return sum(kwargs.values())
spec_kw = TaskSpec("kw", fn=kwargs_fn, depends_on=("a", "b"))
ms, ops = time_it(lambda s=spec_kw, c=context: build_call_args(s, c), iterations=2000, warmup=100)
results.append(("fn(**kwargs)", 2000, ms, ops))
print_results("上下文注入 (有依赖)", results)
def run_context() -> None:
"""运行全部上下文注入基准。"""
bench_context_no_deps()
bench_context_with_deps()
# ============================================================================
# 基准:状态后端
# ============================================================================
def bench_storage() -> None:
"""状态后端 save/load 基准。"""
results = []
# MemoryBackend
mem = MemoryBackend()
ms, ops = time_it(lambda: mem.save("key", "value"), iterations=1000, warmup=50)
results.append(("MemoryBackend.save", 1000, ms, ops))
ms, ops = time_it(mem.load, iterations=1000, warmup=50)
results.append(("MemoryBackend.load", 1000, ms, ops))
# JSONBackendbatch 模式)
tmp_dir = tempfile.mkdtemp()
json_path = str(Path(tmp_dir) / "state.json")
json_backend = JSONBackend(json_path)
with json_backend.batch():
for i in range(100):
json_backend.save(f"task_{i}", f"result_{i}")
def _json_save() -> None:
jb = JSONBackend(json_path)
with jb.batch():
for i in range(10):
jb.save(f"bench_{i}", f"val_{i}")
ms, ops = time_it(_json_save, iterations=50, warmup=5)
results.append(("JSONBackend.save(batch=10)", 50, ms, ops))
# 复杂 value(嵌套 dict)—— 展示 batch 模式延迟验证的优化效果
complex_value = {
"output": {"path": "/data/result.json", "size": 1024, "checksum": "abc123"},
"metrics": {"accuracy": 0.95, "latency_ms": 120, "samples": 1000},
"artifacts": [f"artifact_{j}.bin" for j in range(10)],
"metadata": {"version": "1.0", "tags": ["trained", "validated"], "created_at": "2026-07-07"},
}
def _json_save_complex() -> None:
jb = JSONBackend(json_path)
with jb.batch():
for i in range(10):
jb.save(f"bench_complex_{i}", complex_value)
ms, ops = time_it(_json_save_complex, iterations=50, warmup=5)
results.append(("JSONBackend.save(batch=10,complex)", 50, ms, ops))
ms, ops = time_it(json_backend.load, iterations=200, warmup=10)
results.append(("JSONBackend.load", 200, ms, ops))
# SQLiteBackend
db_path = str(Path(tmp_dir) / "state.db")
sqlite_backend = SQLiteBackend(db_path)
with sqlite_backend.batch():
for i in range(100):
sqlite_backend.save(f"task_{i}", f"result_{i}")
def _sqlite_save() -> None:
sb = SQLiteBackend(db_path)
with sb.batch():
for i in range(10):
sb.save(f"bench_{i}", f"val_{i}")
ms, ops = time_it(_sqlite_save, iterations=50, warmup=5)
results.append(("SQLiteBackend.save(batch=10)", 50, ms, ops))
ms, ops = time_it(sqlite_backend.load, iterations=200, warmup=10)
results.append(("SQLiteBackend.load", 200, ms, ops))
print_results("状态后端 (save/load)", results)
# 序列化基准:_json 抽象层 dumps/loads
ser_results = []
report_like = {
"run_id": "abc12345",
"success": True,
"results": [
{
"name": f"task_{i}",
"status": "success",
"attempts": 1,
"duration_seconds": 0.123 + i * 0.001,
"value": {"output": [i, i + 1, i + 2], "meta": {"tag": "api"}},
}
for i in range(100)
],
}
serialized = _dumps(report_like)
ms, ops = time_it(lambda: _dumps(report_like), iterations=200, warmup=10)
ser_results.append(("dumps(report-100)", 200, ms, ops))
ms, ops = time_it(lambda: _loads(serialized), iterations=200, warmup=10)
ser_results.append(("loads(report-100)", 200, ms, ops))
backend_name = "orjson" if _HAS_ORJSON else "stdlib"
print_results(f"JSON 序列化 (后端={backend_name})", ser_results)
# 清理临时目录
import shutil
shutil.rmtree(tmp_dir, ignore_errors=True)
def run_storage() -> None:
"""运行全部状态后端基准。"""
bench_storage()
# ============================================================================
# 主入口
# ============================================================================
BENCH_MODULES: dict[str, Callable[[], None]] = {
"graph": run_graph,
"execution": run_execution,
"context": run_context,
"storage": run_storage,
"advanced": run_advanced,
}
def main(argv: list[str] | None = None) -> int:
"""CLI 入口。"""
args = argv if argv is not None else sys.argv[1:]
console = Console()
console.print("[bold cyan]PyFlowX 性能基准套件[/bold cyan]\n")
if not args or args[0] in ("--all", "-a"):
for name, fn in BENCH_MODULES.items():
console.print(f"[bold]运行: {name}[/bold]")
fn()
elif args[0] in BENCH_MODULES:
BENCH_MODULES[args[0]]()
elif args[0] in ("--help", "-h"):
console.print("用法: python -m benchmarks [graph|execution|context|storage]")
console.print(" 无参数 = 运行全部基准")
else:
console.print(f"[red]未知基准模块: {args[0]}[/red]")
console.print(f"可用: {', '.join(BENCH_MODULES)}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+309
View File
@@ -0,0 +1,309 @@
"""高级基准:条件评估/YAML 加载/通知器/run_iter/子图/取消/CPU+I/O 密集型任务.
这些基准覆盖核心调度引擎的扩展路径,与 bench_graph/bench_execution/bench_context/
bench_storage 互补,构成完整的性能度量体系。
"""
from __future__ import annotations
import contextlib
import tempfile
import time
from pathlib import Path
from typing import Any
import pyflowx as px
from benchmarks import print_results, time_it
from pyflowx import Graph, TaskSpec
from pyflowx.cancellation import CancelToken
from pyflowx.conditions import BuiltinConditions
from pyflowx.notification import CallbackNotifier, NotificationLevel
# ============================================================================
# 基准:条件评估
# ============================================================================
def bench_conditions() -> None:
"""条件评估基准(静态/上下文/复合)."""
results: list[tuple[str, int, float, float]] = []
# 静态条件(IS_LINUX
cond_static = BuiltinConditions.IS_LINUX()
ctx: dict[str, Any] = {}
ms, ops = time_it(lambda c=cond_static, x=ctx: c(x), iterations=10000, warmup=500)
results.append(("static(IS_LINUX)", 10000, ms, ops))
# 上下文条件(DEP_EQUALS
cond_dep = BuiltinConditions.DEP_EQUALS("a", 1)
ctx_dep = {"a": 1, "b": 2}
ms, ops = time_it(lambda c=cond_dep, x=ctx_dep: c(x), iterations=10000, warmup=500)
results.append(("DEP_EQUALS", 10000, ms, ops))
# 复合条件(AND(OR(NOT, DEP_TRUTHY), DEP_PRESENT)
cond_complex = BuiltinConditions.AND(
BuiltinConditions.OR(
BuiltinConditions.NOT(BuiltinConditions.IS_WINDOWS()),
BuiltinConditions.DEP_TRUTHY("a"),
),
BuiltinConditions.DEP_PRESENT("b"),
)
ms, ops = time_it(lambda c=cond_complex, x=ctx_dep: c(x), iterations=10000, warmup=500)
results.append(("AND(OR(NOT,DEP_TRUTHY),DEP_PRESENT)", 10000, ms, ops))
print_results("条件评估", results)
# ============================================================================
# 基准:YAML 加载
# ============================================================================
_YAML_TEMPLATE = """\
jobs:
{jobs}
defaults:
retry:
max_attempts: 2
backoff: 0.0
"""
def _make_yaml(n: int) -> str:
"""生成 n 个任务的 YAML(链式依赖)."""
lines = []
for i in range(n):
deps = f"needs: [t{i - 1}]" if i > 0 else ""
lines.append(f" t{i}:\n cmd: ['true']\n {deps}".rstrip())
return _YAML_TEMPLATE.format(jobs="\n".join(lines))
def bench_yaml_load() -> None:
"""YAML 加载基准(解析 + Graph 构建)."""
results: list[tuple[str, int, float, float]] = []
tmp_dir = tempfile.mkdtemp()
for n in (10, 50, 100):
yaml_text = _make_yaml(n)
yaml_path = Path(tmp_dir) / f"bench_{n}.yaml"
yaml_path.write_text(yaml_text, encoding="utf-8")
ms, _ops = time_it(lambda p=yaml_path: Graph.from_yaml(p), iterations=20, warmup=2)
results.append((f"yaml_load({n})", 20, ms, 1.0 / (ms / 1000) if ms > 0 else 0))
print_results("YAML 加载 (from_yaml)", results)
import shutil
shutil.rmtree(tmp_dir, ignore_errors=True)
# ============================================================================
# 基准:通知器
# ============================================================================
def bench_notifiers() -> None:
"""通知器 notify 调用开销基准."""
results: list[tuple[str, int, float, float]] = []
# 构造一个真实 TaskEvent
from pyflowx.task import TaskEvent, TaskStatus
event = TaskEvent(task="t0", status=TaskStatus.SUCCESS, attempts=1)
# CallbackNotifier(无级别过滤)
calls: list[int] = [0]
def _cb(_e: Any) -> None:
calls[0] += 1
notifier = CallbackNotifier(_cb)
ms, ops = time_it(lambda e=event, n=notifier: n.notify(e), iterations=50000, warmup=1000)
results.append(("CallbackNotifier.notify", 50000, ms, ops))
# 级别过滤(仅 SUCCESS
notifier_filtered = CallbackNotifier(_cb, levels={NotificationLevel.SUCCESS})
ms, ops = time_it(lambda e=event, n=notifier_filtered: n.notify(e), iterations=50000, warmup=1000)
results.append(("CallbackNotifier(filtered).notify", 50000, ms, ops))
print_results("通知器 notify", results)
# ============================================================================
# 基准:run_iter 流式 API
# ============================================================================
def bench_run_iter() -> None:
"""run_iter 流式执行基准."""
results: list[tuple[str, int, float, float]] = []
def noop() -> None:
pass
for n in (50, 200, 500):
specs = [TaskSpec(f"t{i}", fn=noop) for i in range(n)]
graph = Graph.from_specs(specs)
def _run_iter(g: Graph = graph) -> None:
list(px.run_iter(g, strategy="sequential"))
ms, ops = time_it(_run_iter, iterations=10, warmup=2)
results.append((f"run_iter(sequential,{n})", 10, ms, ops))
print_results("run_iter 流式执行", results)
# ============================================================================
# 基准:子图过滤
# ============================================================================
def bench_subgraph() -> None:
"""子图过滤基准(subgraph_with_deps 传递闭包计算)."""
results: list[tuple[str, int, float, float]] = []
for n in (100, 500, 1000):
specs = []
for i in range(n):
deps = (f"t{i - 1}",) if i > 0 else ()
specs.append(TaskSpec(f"t{i}", cmd=["true"], depends_on=deps))
graph = Graph.from_specs(specs)
# 取中点任务,触发前半部分传递闭包
target = f"t{n // 2}"
ms, ops = time_it(
lambda g=graph, t=target: g.subgraph_with_deps([t]),
iterations=50,
warmup=5,
)
results.append((f"subgraph_with_deps({n})", 50, ms, ops))
print_results("子图过滤 (subgraph_with_deps)", results)
# ============================================================================
# 基准:取消机制
# ============================================================================
def bench_cancellation() -> None:
"""取消机制基准(cancel_event 触发到返回)."""
results: list[tuple[str, int, float, float]] = []
def slow() -> None:
time.sleep(0.5)
for n in (50, 200):
specs = [TaskSpec(f"t{i}", fn=slow) for i in range(n)]
graph = Graph.from_specs(specs)
def _cancel_after(g: Graph = graph) -> None:
token = CancelToken()
# 立即取消,让所有任务变 SKIPPED
token.cancel()
with contextlib.suppress(Exception):
px.run(g, strategy="sequential", cancel_event=token)
ms, ops = time_it(_cancel_after, iterations=5, warmup=1)
results.append((f"cancel(immediate,{n})", 5, ms, ops))
print_results("取消机制 (cancel_event)", results)
# ============================================================================
# 基准:CPU 密集型任务
# ============================================================================
def _fib(n: int) -> int:
"""递归斐波那契(CPU 密集型)."""
if n < 2:
return n
return _fib(n - 1) + _fib(n - 2)
def bench_cpu_intensive() -> None:
"""CPU 密集型任务基准(递归斐波那契)."""
results: list[tuple[str, int, float, float]] = []
for n_tasks, fib_n in ((10, 20), (20, 20), (10, 25)):
specs = [TaskSpec(f"t{i}", fn=_fib, args=(fib_n,)) for i in range(n_tasks)]
graph = Graph.from_specs(specs)
# sequential
ms, ops = time_it(
lambda g=graph: px.run(g, strategy="sequential"),
iterations=3,
warmup=1,
)
results.append((f"cpu-seq({n_tasks}x fib{fib_n})", 3, ms, ops))
# threadCPU 密集型受 GIL 限制,验证是否退化)
ms, ops = time_it(
lambda g=graph: px.run(g, strategy="thread", max_workers=4),
iterations=3,
warmup=1,
)
results.append((f"cpu-thread({n_tasks}x fib{fib_n})", 3, ms, ops))
print_results("CPU 密集型 (递归斐波那契)", results)
# ============================================================================
# 基准:I/O 密集型任务
# ============================================================================
def bench_io_intensive() -> None:
"""I/O 密集型任务基准(sleep 模拟)."""
results: list[tuple[str, int, float, float]] = []
def io_sleep() -> None:
time.sleep(0.01) # 10ms 模拟 I/O
for n in (20, 50):
specs = [TaskSpec(f"t{i}", fn=io_sleep) for i in range(n)]
graph = Graph.from_specs(specs)
# sequential(总耗时 ≈ n * 10ms
ms, ops = time_it(
lambda g=graph: px.run(g, strategy="sequential"),
iterations=3,
warmup=1,
)
results.append((f"io-seq({n})", 3, ms, ops))
# thread(I/O 密集型应能并行,总耗时 ≈ 10ms)
ms, ops = time_it(
lambda g=graph: px.run(g, strategy="thread", max_workers=8),
iterations=3,
warmup=1,
)
results.append((f"io-thread({n})", 3, ms, ops))
# async(I/O 密集型应能并行)
ms, ops = time_it(
lambda g=graph: px.run(g, strategy="async"),
iterations=3,
warmup=1,
)
results.append((f"io-async({n})", 3, ms, ops))
print_results("I/O 密集型 (sleep 10ms)", results)
# ============================================================================
# 主入口
# ============================================================================
def run_advanced() -> None:
"""运行全部高级基准."""
bench_conditions()
bench_yaml_load()
bench_notifiers()
bench_run_iter()
bench_subgraph()
bench_cancellation()
bench_cpu_intensive()
bench_io_intensive()
+9 -13
View File
@@ -50,21 +50,17 @@ API 参考
:members:
:undoc-members:
YAML 编排
---------
@px.tool 工具
-------------
.. autofunction:: pyflowx.load_yaml
.. autofunction:: pyflowx.parse_yaml_string
.. autofunction:: pyflowx.run_yaml
.. autofunction:: pyflowx.run_cli
.. autofunction:: pyflowx.build_cli_parser
.. autoclass:: pyflowx.ToolSpec
:members:
:undoc-members:
函数注册
--------
.. autofunction:: pyflowx.register_fn
.. autofunction:: pyflowx.get_fn
.. autofunction:: pyflowx.has_fn
.. autofunction:: pyflowx.tool
.. autofunction:: pyflowx.run_tool
.. autofunction:: pyflowx.list_tools
.. autofunction:: pyflowx.list_subcommands
命令执行
--------
+1 -3
View File
@@ -17,9 +17,8 @@ PyFlowX 是一个轻量、类型安全的 DAG 任务调度器:**参数名就
- **断点续跑** —— ``MemoryBackend`` / ``JSONBackend``,成功结果可缓存复用
- **命令任务** —— ``cmd`` 参数直接执行外部命令
- **条件执行** —— ``conditions`` 按平台、环境变量等条件跳过任务
- **YAML 任务编排** —— GitHub Actions 风格声明式任务图
- **pf 统一 CLI** —— ``pf <tool> [command]`` 调用所有工具
- **最小依赖** —— 仅依赖标准库 + PyYAML
- **最小依赖** —— ``rich`` + ``typer`` + ``typing-extensions``3.13 以下)
文档导航
--------
@@ -38,7 +37,6 @@ PyFlowX 是一个轻量、类型安全的 DAG 任务调度器:**参数名就
guide/task
guide/graph
guide/execution
guide/yaml
guide/cli
.. toctree::
+1 -1
View File
@@ -1,7 +1,7 @@
安装
====
PyFlowX 支持 Python 3.8+,仅依赖标准库与 PyYAML(3.8 额外需要 ``graphlib_backport`` ``typing-extensions``)。
PyFlowX 支持 Python 3.10+,运行时依赖 ``rich````typer````pyyaml`` ``typing-extensions``3.13 以下)。
pip 安装
--------
+15 -24
View File
@@ -7,13 +7,12 @@ classifiers = [
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Topic :: Software Development :: Libraries :: Application Frameworks",
]
dependencies = [
"graphlib_backport >= 1.0.0; python_version < '3.9'",
"pyyaml>=6.0.1",
"rich>=13.7.0",
"typer>=0.24.0",
"typing-extensions>=4.13.2; python_version < '3.13'",
]
description = "Lightweight, type-safe DAG task scheduler with multi-strategy execution."
@@ -21,23 +20,13 @@ keywords = ["async", "dag", "scheduler", "task", "workflow"]
license = { text = "MIT" }
name = "pyflowx"
readme = "README.md"
requires-python = ">=3.8"
version = "0.4.7"
requires-python = ">=3.10"
version = "0.4.9"
[project.scripts]
dockercmd = "pyflowx.cli.dev.dockercmd:main"
emlman = "pyflowx.cli.emlmanager:main"
msdown = "pyflowx.cli.llm.msdownload:main"
pf = "pyflowx.cli.pf:main"
pxp = "pyflowx.cli.profiler:main"
sglang = "pyflowx.cli.llm.sglang:main"
yamlrun = "pyflowx.cli.yamlrun:main"
# dev
envdev = "pyflowx.cli.dev.envdev:main"
# system
clr = "pyflowx.cli.system.clearscreen:main"
taskk = "pyflowx.cli.system.taskkill:main"
wch = "pyflowx.cli.system.which:main"
emlman = "pyflowx.cli.legacy.emlmanager:main"
pf = "pyflowx.cli.pf:main"
pxp = "pyflowx.cli.legacy.profiler:main"
[project.optional-dependencies]
dev = [
@@ -54,9 +43,9 @@ dev = [
"ruff>=0.8.0",
"tox-uv>=1.13.1",
"tox>=4.25.0",
"types-PyYAML>=6.0.12",
]
docs = ["myst-parser>=3.0", "sphinx-rtd-theme>=2.0", "sphinx>=7.0"]
fast = ["orjson>=3.10.0"]
office = [
"pillow>=10.4.0",
"pymupdf>=1.24.11",
@@ -85,12 +74,12 @@ packages = ["src/pyflowx"]
pyflowx = { workspace = true }
[dependency-groups]
dev = ["pyflowx[dev,docs,office]"]
dev = ["pyflowx[dev,docs,fast,office]"]
[tool.coverage.run]
branch = true
concurrency = ["thread"]
omit = ["src/pyflowx/cli/*", "src/pyflowx/examples/*", "tests/*"]
omit = ["src/pyflowx/cli/*", "tests/*"]
source = ["pyflowx"]
[tool.coverage.report]
@@ -110,7 +99,7 @@ markers = ["slow: marks tests as slow (deselect with
# Ruff 配置 - 与 .pre-commit-config.yaml 保持一致
[tool.ruff]
line-length = 120
target-version = "py38"
target-version = "py310"
[tool.ruff.lint]
ignore = [
@@ -143,9 +132,11 @@ select = [
]
[tool.ruff.lint.per-file-ignores]
"**/tests/**" = ["ARG001", "ARG002"]
"**/tests/**" = ["ARG001", "ARG002"]
"src/pyflowx/tools.py" = ["PLR0911"]
[tool.pyrefly]
preset = "strict"
project-includes = ["**/*.ipynb", "**/*.py*"]
python-version = "3.8"
python-version = "3.10"
search-path = ["."]
+112 -48
View File
@@ -2,31 +2,35 @@
公共 API
--------
* :func:`task` / :func:`cmd` —— 装饰器/工厂,从函数或命令快速创建 TaskSpec。
* :func:`graph` —— 快捷构造图(等价于 ``Graph.from_specs``,接受可变参数)。
* :class:`TaskSpec` —— 不可变任务描述符(唯一需要配置的东西)。
* :class:`Graph` —— 由一组 spec 构建的 DAG;负责校验、分层、可视化。
* :func:`run` ——以 ``sequential`` / ``thread`` / ``async`` / ``dependency``
策略执行图。
策略执行图(默认 ``dependency``
* :class:`RunReport` —— 类型化、可查询的运行结果。
* :class:`Context` —— 整体上下文注入的标注标记。
* :class:`RetryPolicy` —— 重试策略(max_attempts/delay/backoff/jitter/retry_on)。
* :class:`TaskHooks` —— 任务生命周期钩子(pre_run/post_run/on_failure)。
* :class:`GraphDefaults` —— 图级默认值。
* :func:`compose` —— 编程式组合多图。
* :func:`switch` / :func:`branch` —— 条件分支 DAG 构造器。
* :func:`task_template` —— 批量生成相似 TaskSpec 的工厂。
* 状态后端::class:`StateBackend`、:class:`MemoryBackend`、:class:`JSONBackend`
* :func:`sh` —— Shell 命令执行辅助(支持 ``list[str]`` / ``str``,统一中文错误处理)
* 状态后端::class:`StateBackend`、:class:`MemoryBackend`、:class:`JSONBackend`、:class:`SQLiteBackend`。
快速上手
--------
import pyflowx as px
@px.task
def extract() -> list[int]: return [1, 2, 3]
@px.task
def double(extract: list[int]) -> list[int]: return [x * 2 for x in extract]
graph = px.Graph.from_specs([
px.TaskSpec("extract", extract),
px.TaskSpec("double", double, depends_on=("extract",)),
])
report = px.run(graph, strategy="sequential")
graph = px.graph(extract, double) # double 自动依赖 extract
report = px.run(graph)
print(report["double"]) # [2, 4, 6]
命令行任务示例
@@ -34,42 +38,28 @@
import pyflowx as px
from pyflowx.conditions import IS_WINDOWS, BuiltinConditions
graph = px.Graph.from_specs([
px.TaskSpec("list_files", cmd=["ls", "-la"]),
px.TaskSpec("check_git", cmd="git status"),
px.TaskSpec(
"win_only",
cmd=["dir"],
conditions=(IS_WINDOWS,)
),
px.TaskSpec(
"git_check",
cmd=["git", "--version"],
conditions=(BuiltinConditions.HAS_INSTALLED("git"),)
),
px.TaskSpec(
"optional_build",
cmd=["maturin", "build"],
skip_if_missing=True
),
])
graph = px.graph(
px.cmd(["ls", "-la"]),
px.cmd("git status", name="check_git"),
px.cmd(["dir"], name="win_only", conditions=(IS_WINDOWS,)),
px.cmd(["git", "--version"], name="git_check",
conditions=(BuiltinConditions.HAS_INSTALLED("git"),)),
px.cmd(["maturin", "build"], name="optional_build", skip_if_missing=True),
)
report = px.run(graph)
"""
from __future__ import annotations
from typing import Any
from . import fileops
from .cancellation import CancelToken
from .command import run_command
from .compose import GraphComposer, compose
from .conditions import (
IS_LINUX,
IS_MACOS,
IS_POSIX,
IS_WINDOWS,
BuiltinConditions,
Condition,
Constants,
)
from .conditions import IS_LINUX, IS_MACOS, IS_POSIX, IS_WINDOWS, BuiltinConditions, Condition, Constants
from .context import Context, build_call_args, describe_injection
from .diagnostics import DependencyChain, DiagnosticReport, FailureCluster, diagnose
from .errors import (
CycleError,
DuplicateTaskError,
@@ -80,15 +70,34 @@ from .errors import (
TaskFailedError,
TaskTimeoutError,
)
from .executors import Strategy, run
from .executors import Strategy, run, run_iter
from .graph import Graph, GraphDefaults
from .history import RunHistory
from .imaging import image_pipeline
from .monitoring import MetricsCollector, health_check, start_metrics_server
from .notification import (
ALL_LEVELS,
CallbackNotifier,
DingTalkNotifier,
DiscordNotifier,
FeishuNotifier,
NotificationLevel,
Notifier,
SlackNotifier,
TelegramNotifier,
WebhookNotifier,
WeChatNotifier,
)
from .pipelines import branch, command_chain, data_pipeline, fan_out_fan_in, switch
from .profiling import ProfileReport, TaskProfile
from .registry import FnRegistry, get_fn, has_fn, register_fn
from .progress import ProgressCallback, RichProgressMonitor
from .report import RunReport
from .runner import CliExitCode, CliRunner
from .storage import JSONBackend, MemoryBackend, StateBackend
from .shell import sh
from .storage import JSONBackend, MemoryBackend, SQLiteBackend, StateBackend
from .task import (
CacheKeyFn,
LoopSpec,
RetryPolicy,
TaskCmd,
TaskEvent,
@@ -100,36 +109,77 @@ from .task import (
task,
task_template,
)
from .yaml_loader import YamlLoadError, build_cli_parser, load_yaml, parse_yaml_string, run_cli, run_yaml
from .tools import ToolSpec, list_subcommands, list_tools, run_tool, tool
from .yaml_loader import load_yaml, parse_yaml_string
__version__ = "0.4.9"
def graph(
*specs: TaskSpec[Any] | str,
defaults: GraphDefaults | None = None,
namespace: str | None = None,
) -> Graph:
"""快捷构造图:等价于 :meth:`Graph.from_specs`,接受可变参数而非列表。
对 ``depends_on`` 为空的纯 fn 任务,自动从必需参数名推断依赖
(匹配图中任务名的参数被加入 ``depends_on``)。
Examples
--------
>>> import pyflowx as px
>>> @px.task
... def extract() -> list[int]: return [1, 2, 3]
>>> @px.task
... def double(extract: list[int]) -> list[int]: return [x * 2 for x in extract]
>>> g = px.graph(extract, double) # double 自动依赖 extract
"""
return Graph.from_specs(specs, defaults=defaults, namespace=namespace)
__version__ = "0.4.7"
__all__ = [
"ALL_LEVELS",
"IS_LINUX",
"IS_MACOS",
"IS_POSIX",
"IS_WINDOWS",
"BuiltinConditions",
"CacheKeyFn",
"CallbackNotifier",
"CancelToken",
"CliExitCode",
"CliRunner",
"Condition",
"Constants",
"Context",
"CycleError",
"DependencyChain",
"DiagnosticReport",
"DingTalkNotifier",
"DiscordNotifier",
"DuplicateTaskError",
"FnRegistry",
"FailureCluster",
"FeishuNotifier",
"Graph",
"GraphComposer",
"GraphDefaults",
"InjectionError",
"JSONBackend",
"LoopSpec",
"MemoryBackend",
"MetricsCollector",
"MissingDependencyError",
"NotificationLevel",
"Notifier",
"ProfileReport",
"ProgressCallback",
"PyFlowXError",
"RetryPolicy",
"RichProgressMonitor",
"RunHistory",
"RunReport",
"SQLiteBackend",
"SlackNotifier",
"StateBackend",
"StorageError",
"Strategy",
@@ -142,21 +192,35 @@ __all__ = [
"TaskSpec",
"TaskStatus",
"TaskTimeoutError",
"YamlLoadError",
"TelegramNotifier",
"ToolSpec",
"WeChatNotifier",
"WebhookNotifier",
"branch",
"build_call_args",
"build_cli_parser",
"cmd",
"command_chain",
"compose",
"data_pipeline",
"describe_injection",
"get_fn",
"has_fn",
"diagnose",
"fan_out_fan_in",
"fileops",
"graph",
"health_check",
"image_pipeline",
"list_subcommands",
"list_tools",
"load_yaml",
"parse_yaml_string",
"register_fn",
"run",
"run_cli",
"run_command",
"run_yaml",
"run_iter",
"run_tool",
"sh",
"start_metrics_server",
"switch",
"task",
"task_template",
"tool",
]
+100
View File
@@ -0,0 +1,100 @@
"""JSON 序列化抽象层。
优先使用 orjson(性能更高),回退到标准库 json。对外提供与标准库
json 兼容的 ``dumps`` / ``loads`` / ``dump`` / ``load`` / ``JSONDecodeError``
接口,使调用方无感知切换。
orjson 与标准库的差异由本模块吸收:
- ``dumps`` 返回 ``str``orjson 原生返回 ``bytes``,此处统一 decode
- ``ensure_ascii`` 参数对 orjson 无效(始终输出 UTF-8),此处忽略
- ``indent`` 映射为 ``orjson.OPT_INDENT_2``
"""
from __future__ import annotations
__all__ = ["JSONDecodeError", "dump", "dumps", "load", "loads"]
from typing import IO, Any
try:
import orjson # type: ignore[import-not-found]
_HAS_ORJSON = True
except ImportError: # pragma: no cover
_HAS_ORJSON = False
if _HAS_ORJSON:
JSONDecodeError = orjson.JSONDecodeError # type: ignore[possibly-unbound]
_OPT_NON_STR_KEYS = orjson.OPT_NON_STR_KEYS # type: ignore[possibly-unbound]
def dumps(
obj: Any,
*,
ensure_ascii: bool = False, # noqa: ARG001 — 兼容标准库签名
indent: int | None = None,
default: Any = None,
**_kwargs: Any,
) -> str:
"""序列化为 JSON 字符串(orjson 后端)。"""
opts = _OPT_NON_STR_KEYS
if indent is not None:
opts |= orjson.OPT_INDENT_2 # type: ignore[possibly-unbound]
return orjson.dumps(obj, default=default, option=opts).decode("utf-8") # type: ignore[possibly-unbound]
def loads(s: str | bytes) -> Any:
"""从 JSON 字符串/字节反序列化(orjson 后端)。"""
return orjson.loads(s) # type: ignore[possibly-unbound]
def dump(
obj: Any,
fh: IO[str],
*,
ensure_ascii: bool = False,
indent: int | None = None,
default: Any = None,
**_kwargs: Any,
) -> None:
"""序列化并写入文件句柄(orjson 后端)。"""
fh.write(dumps(obj, ensure_ascii=ensure_ascii, indent=indent, default=default))
def load(fh: IO[str]) -> Any:
"""从文件句柄反序列化(orjson 后端)。"""
return orjson.loads(fh.read()) # type: ignore[possibly-unbound]
else: # pragma: no cover
import json as _stdlib
JSONDecodeError = _stdlib.JSONDecodeError
def dumps(
obj: Any,
*,
ensure_ascii: bool = False,
indent: int | None = None,
default: Any = None,
**_kwargs: Any,
) -> str:
"""序列化为 JSON 字符串(标准库后端)。"""
return _stdlib.dumps(obj, ensure_ascii=ensure_ascii, indent=indent, default=default)
def loads(s: str | bytes) -> Any:
"""从 JSON 字符串/字节反序列化(标准库后端)。"""
return _stdlib.loads(s)
def dump(
obj: Any,
fh: IO[str],
*,
ensure_ascii: bool = False,
indent: int | None = None,
default: Any = None,
**_kwargs: Any,
) -> None:
"""序列化并写入文件句柄(标准库后端)。"""
_stdlib.dump(obj, fh, ensure_ascii=ensure_ascii, indent=indent, default=default)
def load(fh: IO[str]) -> Any:
"""从文件句柄反序列化(标准库后端)。"""
return _stdlib.load(fh)
+114
View File
@@ -0,0 +1,114 @@
"""任务取消与优雅停止。
提供 :class:`CancelToken` 作为线程安全的取消信号,可传入 :func:`pyflowx.run`
实现外部取消或响应 ``KeyboardInterrupt``。
设计要点
--------
* 基于 :class:`threading.Event`,跨线程/协程安全。
* 轻量:不引入额外线程或回调,仅维护一个事件标志。
* 可组合:多个 CancelToken 可通过 :meth:`link` 联动。
"""
from __future__ import annotations
import threading
from typing import Any
__all__ = ["CancelToken"]
class CancelToken:
"""线程安全的取消令牌。
用法::
token = CancelToken()
# 在另一个线程中:
token.cancel()
# 传入 run()
report = px.run(graph, cancel_event=token)
也可直接用 :class:`threading.Event` 代替——本类仅提供语义更清晰的 API。
"""
__slots__ = ("_event", "_reason")
def __init__(self) -> None:
self._event = threading.Event()
self._reason: str | None = None
def cancel(self, reason: str | None = None) -> None:
"""设置取消信号。
Parameters
----------
reason:
可选的取消原因描述,供日志或调试使用。
"""
if reason is not None:
self._reason = reason
self._event.set()
@property
def is_cancelled(self) -> bool:
"""是否已取消。"""
return self._event.is_set()
@property
def reason(self) -> str | None:
"""取消原因(未取消时为 None)。"""
return self._reason
def wait(self, timeout: float | None = None) -> bool:
"""等待取消信号。
Returns
-------
bool
``True`` 表示已取消,``False`` 表示超时。
"""
return self._event.wait(timeout)
def link(self, event: threading.Event) -> None:
"""联动一个 ``threading.Event``:当 ``event`` 被设置时,本 token 也被取消。
适用于将标准 ``threading.Event`` 桥接到 CancelToken。
"""
def _watcher() -> None:
event.wait()
self.cancel()
t = threading.Thread(target=_watcher, daemon=True)
t.start()
def __repr__(self) -> str:
status = "cancelled" if self._event.is_set() else "active"
reason = f", reason={self._reason!r}" if self._reason else ""
return f"CancelToken({status}{reason})"
# 兼容 threading.Event 接口:run() 内部用 .is_set() 检查
def is_set(self) -> bool:
"""兼容 :meth:`threading.Event.is_set`。"""
return self._event.is_set()
# 允许 ``CancelToken | threading.Event`` 统一类型检查
def __bool__(self) -> bool:
return self._event.is_set()
def _is_cancelled(token: Any) -> bool:
"""统一检查 CancelToken 或 threading.Event 是否已取消。
Parameters
----------
token:
``None``、``CancelToken`` 或 ``threading.Event``。
"""
if token is None:
return False
if isinstance(token, CancelToken):
return token.is_cancelled
return bool(token.is_set())
View File
-26
View File
@@ -1,26 +0,0 @@
from __future__ import annotations
from typing import Literal
import pyflowx as px
DockerMirrorType = Literal["tencent"]
DOCKER_MIRROR_URLS: dict[DockerMirrorType, str] = {"tencent": "ccr.ccs.tencentyun.com"}
def main():
# parser = argparse.ArgumentParser(description="Docker 命令行工具")
# parser.add_argument("--username", nargs="?", default="", type=str, help="Docker 用户名")
# args = parser.parse_args()
tasks: list[px.TaskSpec] = [
px.cmd(["docker", "login", "--username", "xxx", DOCKER_MIRROR_URLS["tencent"]], name="docker_login_tencent"),
]
alias: dict[str, str | list[str | px.TaskSpec] | px.TaskSpec | px.Graph] = {
"login": "docker_login_tencent",
}
runner = px.CliRunner(strategy="sequential", tasks=tasks, aliases=alias)
runner.run_cli()
-366
View File
@@ -1,366 +0,0 @@
from __future__ import annotations
import argparse
import getpass
from pathlib import Path
from typing import Literal, get_args
import pyflowx as px
from pyflowx.conditions import BuiltinConditions
from pyflowx.tasks.system import setenv_group, write_file
# ============================================================================
# Mirror 配置
# ============================================================================
DOWNLOAD_MIRROR_SCRIPT: str = "curl -sSL https://linuxmirrors.cn/main.sh -o /tmp/linuxmirrors.sh"
INSTALL_MIRROR_SCRIPT: str = "sudo bash /tmp/linuxmirrors.sh"
# ============================================================================
# Python 配置
# ============================================================================
PyMirrorType = Literal["tsinghua", "aliyun", "huaweicloud", "ustc", "zju"]
PIP_INDEX_URLS: dict[PyMirrorType, str] = {
"tsinghua": "https://pypi.tuna.tsinghua.edu.cn/simple",
"aliyun": "https://mirrors.aliyun.com/pypi/simple/",
"huaweicloud": "https://mirrors.huaweicloud.com/repository/pypi/simple/",
"ustc": "https://pypi.mirrors.ustc.edu.cn/simple/",
"zju": "https://mirrors.zju.edu.cn/pypi/simple/",
}
PIP_TRUSTED_HOSTS: dict[PyMirrorType, str] = {
"tsinghua": "pypi.tuna.tsinghua.edu.cn",
"aliyun": "mirrors.aliyun.com",
"huaweicloud": "mirrors.huaweicloud.com",
"ustc": "pypi.mirrors.ustc.edu.cn",
"zju": "mirrors.zju.edu.cn",
}
PIP_CONFIG_PATH = Path.home() / ".pip" / "pip.conf" if BuiltinConditions.IS_LINUX() else Path.home() / "pip" / "pip.ini"
UV_INDEX_URLS = PIP_INDEX_URLS
UV_PYTHON_INSTALL_MIRROR: str = "https://registry.npmmirror.com/-/binary/python-build-standalone"
# ============================================================================
# Conda 配置
# ============================================================================
CondaMirrorType = Literal["tsinghua", "ustc", "bsfu", "aliyun"]
CONDA_MIRROR_URLS: dict[CondaMirrorType, list[str]] = {
"tsinghua": [
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/",
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/",
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/r/",
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/msys2/",
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/pro/",
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/",
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/bioconda/",
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/menpo/",
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/pytorch/",
],
"ustc": [
"https://mirrors.ustc.edu.cn/anaconda/pkgs/main/",
"https://mirrors.ustc.edu.cn/anaconda/pkgs/free/",
"https://mirrors.ustc.edu.cn/anaconda/pkgs/r/",
"https://mirrors.ustc.edu.cn/anaconda/pkgs/msys2/",
"https://mirrors.ustc.edu.cn/anaconda/pkgs/pro/",
"https://mirrors.ustc.edu.cn/anaconda/pkgs/dev/",
"https://mirrors.ustc.edu.cn/anaconda/cloud/conda-forge/",
"https://mirrors.ustc.edu.cn/anaconda/cloud/bioconda/",
"https://mirrors.ustc.edu.cn/anaconda/cloud/menpo/",
"https://mirrors.ustc.edu.cn/anaconda/cloud/pytorch/",
],
"bsfu": [
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/main/",
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/free/",
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/r/",
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/msys2/",
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/pro/",
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/dev/",
"https://mirrors.bsfu.edu.cn/anaconda/cloud/conda-forge/",
"https://mirrors.bsfu.edu.cn/anaconda/cloud/bioconda/",
"https://mirrors.bsfu.edu.cn/anaconda/cloud/menpo/",
"https://mirrors.bsfu.edu.cn/anaconda/cloud/pytorch/",
],
"aliyun": [
"https://mirrors.aliyun.com/anaconda/pkgs/main/",
"https://mirrors.aliyun.com/anaconda/pkgs/free/",
"https://mirrors.aliyun.com/anaconda/pkgs/r/",
"https://mirrors.aliyun.com/anaconda/pkgs/msys2/",
"https://mirrors.aliyun.com/anaconda/pkgs/pro/",
"https://mirrors.aliyun.com/anaconda/pkgs/dev/",
"https://mirrors.aliyun.com/anaconda/cloud/conda-forge/",
"https://mirrors.aliyun.com/anaconda/cloud/bioconda/",
"https://mirrors.aliyun.com/anaconda/cloud/menpo/",
"https://mirrors.aliyun.com/anaconda/cloud/pytorch/",
],
}
CONDA_CONFIG_PATH = Path.home() / ".condarc"
# ============================================================================
# Qt 配置
# ============================================================================
QT_LIBS: list[str] = [
"build-essential",
"libgl1",
"libegl1",
"libglib2.0-0",
"libfontconfig1",
"libfreetype6",
"libxkbcommon0",
"libdbus-1-3",
"libxcb-xinerama0",
"libxcb-icccm4",
"libxcb-image0",
"libxcb-keysyms1",
"libxcb-randr0",
"libxcb-render-util0",
"libxcb-shape0",
"libxcb-xfixes0",
"libxcb-cursor0",
]
CHINESE_FONTS: list[str] = [
"fonts-noto-cjk",
"fonts-wqy-microhei",
"fonts-wqy-zenhei",
"fonts-noto-color-emoji",
]
# ============================================================================
# Rust 配置
# ============================================================================
RustMirrorType = Literal["tsinghua", "ustc", "aliyun"]
RustVersionType = Literal["stable", "nightly", "beta"]
DEFAULT_RUST_VERSION: RustVersionType = "stable"
DEFAULT_MIRROR: RustMirrorType = "tsinghua"
RUSTUP_MIRRORS: dict[RustMirrorType, dict[str, str]] = {
"tsinghua": {
"RUSTUP_DIST_SERVER": "https://mirrors.tuna.tsinghua.edu.cn/rustup",
"RUSTUP_UPDATE_ROOT": "https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup",
"TOML_REGISTRY": "https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/",
},
"aliyun": {
"RUSTUP_DIST_SERVER": "https://mirrors.aliyun.com/rustup",
"RUSTUP_UPDATE_ROOT": "https://mirrors.aliyun.com/rustup/rustup",
"TOML_REGISTRY": "https://mirrors.aliyun.com/crates.io-index/",
},
"ustc": {
"RUSTUP_DIST_SERVER": "https://mirrors.ustc.edu.cn/rust-static",
"RUSTUP_UPDATE_ROOT": "https://mirrors.ustc.edu.cn/rust-static/rustup",
"TOML_REGISTRY": "https://mirrors.ustc.edu.cn/crates.io-index/",
},
}
RUSTUP_DOWNLOAD_URL_LINUX = "https://mirrors.aliyun.com/repo/rust/rustup-init.sh"
RUSTUP_DOWNLOAD_URL_WINDOWS = "https://static.rust-lang.org/rustup/dist/x86_64-pc-windows-msvc/rustup-init.exe"
RUST_CONFIG_PATH = Path.home() / ".cargo" / "config.toml"
RUST_SCCACHE_DIR: Path = Path.home() / ".cargo" / "sccache"
RUST_SCCACHE_CACHE_SIZE: str = "20G"
def main() -> None:
"""主函数."""
parser = argparse.ArgumentParser(description="环境开发工具")
parser.add_argument(
"--python-mirror",
nargs="?",
type=str,
default="tsinghua",
choices=get_args(PyMirrorType),
help="Python 镜像源",
)
parser.add_argument(
"--conda-mirror",
nargs="?",
type=str,
default="tsinghua",
choices=get_args(CondaMirrorType),
help="Conda 镜镜像源",
)
parser.add_argument(
"--rust-mirror",
nargs="?",
type=str,
default=DEFAULT_MIRROR,
choices=get_args(RustMirrorType),
help="Rust 镜像源",
)
parser.add_argument(
"--rust-version",
nargs="?",
type=str,
default=DEFAULT_RUST_VERSION,
choices=get_args(RustVersionType),
help=f"Rust 版本, 推荐: {get_args(RustVersionType)}",
)
args = parser.parse_args()
python_mirror = args.python_mirror
conda_mirror_urls = CONDA_MIRROR_URLS[args.conda_mirror]
rust_mirror = args.rust_mirror
rust_version = args.rust_version
# 确保配置文件目录存在
PIP_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONDA_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
RUST_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
RUST_SCCACHE_DIR.mkdir(parents=True, exist_ok=True)
# 使用 conditions 自动控制任务执行
graph = px.Graph.from_specs(
[
# 系统镜像配置(仅 Linux 且未配置国内镜像)
px.TaskSpec(
"download_mirror",
cmd=DOWNLOAD_MIRROR_SCRIPT,
conditions=(
BuiltinConditions.IS_LINUX(),
BuiltinConditions.NOT(
BuiltinConditions.OR(
*[
BuiltinConditions.FILE_CONTENT_EXISTS(f, m)
for f in [
"/etc/apt/sources.list",
"/etc/apt/sources.list.d/ubuntu.sources",
]
for m in get_args(PyMirrorType)
],
)
),
),
verbose=True,
),
px.TaskSpec(
"install_mirror",
cmd=INSTALL_MIRROR_SCRIPT,
depends_on=("download_mirror",),
verbose=True,
),
# 安装 Qt 依赖(仅 Linux
px.TaskSpec(
"install_qt_libs",
cmd=["sudo", "apt", "install", "-y", *QT_LIBS],
conditions=(BuiltinConditions.IS_LINUX(),),
depends_on=("install_mirror",),
allow_upstream_skip=True,
verbose=True,
),
# 安装中文字体(仅 Linux
px.TaskSpec(
"install_fonts",
cmd=["sudo", "apt", "install", "-y", *CHINESE_FONTS],
conditions=(BuiltinConditions.IS_LINUX(),),
depends_on=("install_mirror",),
allow_upstream_skip=True,
verbose=True,
),
# 安装 Docker
px.TaskSpec(
"install_docker",
cmd=["sudo", "apt", "install", "-y", "docker-compose-v2"],
conditions=(BuiltinConditions.IS_LINUX(),),
depends_on=("install_mirror",),
allow_upstream_skip=True,
verbose=True,
),
px.TaskSpec(
"add_docker_group",
cmd=["sudo", "usermod", "-aG", "docker", getpass.getuser()],
conditions=(BuiltinConditions.IS_LINUX(),),
depends_on=("install_docker",),
allow_upstream_skip=True,
verbose=True,
),
px.TaskSpec(
"refresh_docker_group",
cmd=["newgrp", "docker"],
conditions=(BuiltinConditions.IS_LINUX(),),
depends_on=("add_docker_group",),
allow_upstream_skip=True,
verbose=True,
),
# 设置 Python 环境变量
*setenv_group(
{
"PIP_INDEX_URL": PIP_INDEX_URLS[python_mirror],
"PIP_TRUSTED_HOSTS": PIP_TRUSTED_HOSTS[python_mirror],
"UV_INDEX_URL": UV_INDEX_URLS[python_mirror],
"UV_PYTHON_INSTALL_MIRROR": UV_PYTHON_INSTALL_MIRROR,
"UV_HTTP_TIMEOUT": "600",
"UV_LINK_MODE": "copy",
}
),
# 写入 Python 配置(仅当未配置)
write_file(
str(PIP_CONFIG_PATH),
f"[global]\nindex-url = {PIP_INDEX_URLS[python_mirror]}\ntrusted-host = {PIP_TRUSTED_HOSTS[python_mirror]}",
),
# 写入 Conda 配置(仅当未配置)
write_file(
str(CONDA_CONFIG_PATH),
"show_channel_urls: true\nchannels:\n - " + "\n - ".join(conda_mirror_urls) + "\n - defaults",
),
# 设置 Rust 镜像源
*setenv_group(
{
"RUSTUP_DIST_SERVER": RUSTUP_MIRRORS[rust_mirror]["RUSTUP_DIST_SERVER"],
"RUSTUP_UPDATE_ROOT": RUSTUP_MIRRORS[rust_mirror]["RUSTUP_UPDATE_ROOT"],
"RUST_SCCACHE_DIR": str(RUST_SCCACHE_DIR),
"RUST_SCCACHE_CACHE_SIZE": RUST_SCCACHE_CACHE_SIZE,
}
),
# 写入 Rust 配置(仅当未配置)
write_file(
str(RUST_CONFIG_PATH),
f"""
[source.crates-io]
replace-with = '{rust_mirror}'
[source.{rust_mirror}]
registry = "sparse+{RUSTUP_MIRRORS[rust_mirror]["TOML_REGISTRY"]}"
[registries.{rust_mirror}]
index = "sparse+{RUSTUP_MIRRORS[rust_mirror]["TOML_REGISTRY"]}"
""",
),
# 下载 Rustup 安装脚本
px.TaskSpec(
"download_rustup",
cmd=["curl", "-fsSL", RUSTUP_DOWNLOAD_URL_LINUX, "-o", "rustup-init.sh"],
conditions=(
BuiltinConditions.IS_LINUX(),
BuiltinConditions.NOT(BuiltinConditions.HAS_INSTALLED("rustup")),
),
verbose=True,
),
px.TaskSpec(
"download_rustup_win",
cmd=[
"powershell",
"-Command",
"Invoke-WebRequest",
"-Uri",
RUSTUP_DOWNLOAD_URL_WINDOWS,
"-OutFile",
"rustup-init.exe",
],
conditions=(
BuiltinConditions.IS_WINDOWS(),
BuiltinConditions.NOT(BuiltinConditions.HAS_INSTALLED("rustup")),
),
verbose=True,
),
# 安装 Rust 工具链
px.TaskSpec(
"install_rust",
cmd=["rustup", "toolchain", "install", rust_version],
conditions=(BuiltinConditions.HAS_INSTALLED("rustup"),),
depends_on=("setenv_rustup_dist_server",),
allow_upstream_skip=True,
verbose=True,
),
]
)
px.run(graph, strategy="thread", verbose=True)
+15
View File
@@ -0,0 +1,15 @@
"""legacy 子包 — 未迁移到 @px.tool 的独立工具.
每个子模块有自己的 ``main()`` 函数, 由 ``pf`` 入口通过
``_LEGACY_TOOLS`` 路由调用. 这些工具因逻辑复杂 (web 应用、runpy 注入等)
暂未用 ``@px.tool`` 装饰器重写.
子模块
------
- :mod:`emlmanager` —— EML 邮件管理 Web 应用
- :mod:`profiler` —— pxp 性能分析器 (monkey-patch + runpy)
"""
from __future__ import annotations
__all__: list[str] = []
@@ -154,7 +154,7 @@ class EmailDatabase:
cursor.execute(query, (f"%{keyword}%", f"%{keyword}%", f"%{keyword}%", f"%{keyword}%", limit, offset))
columns = [description[0] for description in cursor.description]
return [dict(zip(columns, row)) for row in cursor.fetchall()]
return [dict(zip(columns, row, strict=False)) for row in cursor.fetchall()]
def get_grouped_emails(self) -> dict[str, list[dict[str, Any]]]:
"""获取按主题分组的邮件."""
@@ -165,7 +165,7 @@ class EmailDatabase:
cursor.execute(f"SELECT * FROM {TABLE_NAME} ORDER BY subject, date_parsed DESC")
columns = [description[0] for description in cursor.description]
emails = [dict(zip(columns, row)) for row in cursor.fetchall()]
emails = [dict(zip(columns, row, strict=False)) for row in cursor.fetchall()]
# 按主题分组
grouped: dict[str, list[dict[str, Any]]] = {}
@@ -602,7 +602,7 @@ class EmlManagerHandler(BaseHTTPRequestHandler):
self._send_json_response({"error": "邮件不存在"}, 404)
return
email_data = dict(zip(columns, row))
email_data = dict(zip(columns, row, strict=False))
self._send_json_response({"email": email_data})
def _api_get_grouped_emails(self) -> None:
@@ -40,10 +40,10 @@ import webbrowser
from pathlib import Path
from typing import Any
from .. import executors as _executors
from .. import runner as _runner
from ..profiling import ProfileReport
from ..report import RunReport
from ... import executors as _executors
from ... import runner as _runner
from ...profiling import ProfileReport
from ...report import RunReport
def _build_parser() -> argparse.ArgumentParser:
View File
-43
View File
@@ -1,43 +0,0 @@
"""Download from ModelScopeHub."""
import argparse
from pathlib import Path
from typing import Literal, get_args
import pyflowx as px
DownloadType = Literal["model", "dataset", "space"]
def main():
parser = argparse.ArgumentParser(description="Download a model from ModelScopeHub.")
parser.add_argument("name", help="Target name.")
parser.add_argument("--type", "-t", nargs="?", default="model", choices=get_args(DownloadType), help="Target type.")
parser.add_argument("--dir", default=None, help="Download directory.")
args = parser.parse_args()
if not args.name:
parser.error("name is required")
download_dir: Path = Path(args.dir) if args.dir else Path.home() / ".models" / args.name.split("/")[-1]
download_dir.mkdir(parents=True, exist_ok=True)
graph = px.Graph.from_specs(
[
px.TaskSpec(
name="download",
cmd=[
"uvx",
"modelscope",
"download",
f"--{args.type}",
args.name,
"--local_dir",
str(download_dir),
],
verbose=True,
),
]
)
px.run(graph, strategy="thread", verbose=True)
-65
View File
@@ -1,65 +0,0 @@
"""使用 SGLang 运行本地模型."""
import argparse
from pathlib import Path
import pyflowx as px
from pyflowx.conditions import BuiltinConditions, Constants
def main():
parser = argparse.ArgumentParser(description="启动 SGLang 服务")
parser.add_argument("--model", default="~/.models/Qwen2.5-Coder-32B-Instruct-AWQ", help="模型路径")
parser.add_argument("--port", type=int, default=8000, help="服务端口")
parser.add_argument("--ctx-len", type=int, default=28672, help="最大上下文长度")
parser.add_argument("--mem", type=float, default=0.75, help="显存占比 (0-1)")
parser.add_argument("--host", default="0.0.0.0", help="主机地址")
parser.add_argument("--log-level", default="info", help="日志级别")
args = parser.parse_args()
if not args.model:
parser.error("model is required")
model_dir = Path(args.model).expanduser()
if not model_dir.exists():
parser.error(f"Model directory {model_dir} does not exist.")
graph = px.Graph.from_specs(
[
px.TaskSpec(
name="download",
cmd=[
"uv",
"install",
"sglang[all]",
],
conditions=(BuiltinConditions.NOT(BuiltinConditions.HAS_INSTALLED("sglang")),),
verbose=True,
),
px.TaskSpec(
name="run",
cmd=[
"python" if Constants.IS_WINDOWS else "python3",
"-m",
"sglang.launch_server",
"--model-path",
str(model_dir),
"--host",
str(args.host),
"--port",
"8000",
"--mem-fraction-static",
str(args.mem),
"--context-length",
"32768",
"--tool-call-parser",
"qwen",
"--log-level",
str(args.log_level),
],
verbose=True,
),
]
)
px.run(graph, strategy="sequential", verbose=True)
+538 -63
View File
@@ -1,7 +1,7 @@
"""PyFlowX 统一 CLI 入口.
通过 ``pf <tool> [command] [options]`` 调用所有工具,
工具定义在 ``configs/`` 目录下的 YAML 文件中.
工具定义在 ``pyflowx.ops`` 子包中, 每个模块用 ``@px.tool`` 装饰器注册.
用法
----
@@ -13,29 +13,42 @@
from __future__ import annotations
import contextlib
import difflib
import importlib
import sys
from pathlib import Path
from typing import Sequence
from collections.abc import Sequence
from typing import Any
import pyflowx as px
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from rich.tree import Tree
from pyflowx import __version__
from pyflowx.tools import _TOOL_REGISTRY, run_tool
class PfApp:
"""pf 统一入口应用.
路由 ``pf <tool> [command]`` 到 YAML 配置工具或传统 Python 工具.
路由 ``pf <tool> [command]`` 到 ``@px.tool`` 注册的工具或传统 Python 工具.
"""
_CONFIGS_DIR = Path(__file__).parent.parent / "configs"
# 工具名到 YAML 配置文件的映射 (支持短别名)
# 工具名到 ops 模块名的映射 (支持短别名)
_TOOL_ALIASES: dict[str, str] = {
"autofmt": "autofmt",
"af": "autofmt",
"bump": "bumpversion",
"bumpversion": "bumpversion",
"bv": "bumpversion",
"clr": "clr",
"clearscreen": "clr",
"dockercmd": "dockercmd",
"docker": "dockercmd",
"envdev": "envdev",
"env": "envdev",
"filedate": "filedate",
"fd": "filedate",
"filelevel": "filelevel",
@@ -50,8 +63,14 @@ class PfApp:
"gitt": "gittool",
"gittool": "gittool",
"gt": "gittool",
"image": "imagetool",
"imagetool": "imagetool",
"img": "imagetool",
"ls": "lscalc",
"lscalc": "lscalc",
"msdown": "msdownload",
"msdownload": "msdownload",
"msd": "msdownload",
"pack": "packtool",
"packtool": "packtool",
"pk": "packtool",
@@ -68,87 +87,172 @@ class PfApp:
"screenshot": "screenshot",
"scrcap": "screenshot",
"ss": "screenshot",
"se": "setenv",
"setenv": "setenv",
"sglang": "sglang",
"sg": "sglang",
"ssh": "sshcopyid",
"sshcopy": "sshcopyid",
"sshcopyid": "sshcopyid",
"sc": "sshcopyid",
"taskk": "taskkill",
"taskkill": "taskkill",
"tk": "taskkill",
"wch": "which",
"which": "which",
"wf": "writefile",
"writefile": "writefile",
}
# 传统工具: 有自己的 main() 函数 (无法 YAML 化的复杂逻辑)
# 传统工具: 有自己的 main() 函数 (无法 @px.tool 化的复杂逻辑)
_LEGACY_TOOLS: dict[str, str] = {
"emlman": "pyflowx.cli.emlmanager:main",
"profiler": "pyflowx.cli.profiler:main",
"pxp": "pyflowx.cli.profiler:main",
"yamlrun": "pyflowx.cli.yamlrun:main",
"emlman": "pyflowx.cli.legacy.emlmanager:main",
"profiler": "pyflowx.cli.legacy.profiler:main",
"pxp": "pyflowx.cli.legacy.profiler:main",
}
# 规范工具名 → 完整模块路径 (ops/ 按功能分组后的动态导入映射)
_TOOL_MODULES: dict[str, str] = {
"autofmt": "pyflowx.ops.dev.autofmt",
"bumpversion": "pyflowx.ops.dev.bumpversion",
"clr": "pyflowx.ops.system.clr",
"dockercmd": "pyflowx.ops.infra.dockercmd",
"envdev": "pyflowx.ops.infra.envdev",
"filedate": "pyflowx.ops.files.filedate",
"filelevel": "pyflowx.ops.files.filelevel",
"folderback": "pyflowx.ops.files.folderback",
"folderzip": "pyflowx.ops.files.folderzip",
"gittool": "pyflowx.ops.dev.gittool",
"imagetool": "pyflowx.ops.files.imagetool",
"lscalc": "pyflowx.ops.dev.lscalc",
"msdownload": "pyflowx.ops.infra.msdownload",
"packtool": "pyflowx.ops.dev.packtool",
"pdftool": "pyflowx.ops.files.pdftool",
"piptool": "pyflowx.ops.dev.piptool",
"pymake": "pyflowx.ops.dev.pymake",
"reseticoncache": "pyflowx.ops.system.reseticoncache",
"screenshot": "pyflowx.ops.files.screenshot",
"setenv": "pyflowx.ops.system.setenv",
"sglang": "pyflowx.ops.infra.sglang",
"sshcopyid": "pyflowx.ops.infra.sshcopyid",
"taskkill": "pyflowx.ops.system.taskkill",
"which": "pyflowx.ops.system.which",
"writefile": "pyflowx.ops.system.writefile",
}
def __init__(self, argv: Sequence[str] | None = None) -> None:
self._argv = list(argv) if argv is not None else sys.argv[1:]
self._console = Console()
self._err = Console(stderr=True)
def run(self) -> int:
"""主入口, 返回退出码."""
if not self._argv:
if not self._argv or self._argv[0] in ("--help", "-h"):
self._list_tools()
return 0
tool_name = self._argv[0]
rest_argv = self._argv[1:]
first = self._argv[0]
if first in ("--version", "-V"):
self._console.print(f"PyFlowX [bold cyan]{__version__}[/bold cyan]")
return 0
resolved = self._resolve_tool(tool_name)
# 内建子命令(与 @px.tool 注册的 ops 工具同级)
builtin = {
"yamlrun": self._run_yaml,
"graph": self._run_graph,
"info": self._run_info,
"completion": self._run_completion,
}
if first in builtin:
return builtin[first](self._argv[1:])
rest = self._argv[1:]
resolved = self._resolve_tool(first)
if resolved is None:
print(f"错误: 未知工具 '{tool_name}'", file=sys.stderr)
print("运行 'pf' 查看可用工具列表", file=sys.stderr)
self._print_unknown_tool(first)
return 1
tool_type, target = resolved
if tool_type == "legacy":
return self._run_legacy(target, rest_argv)
return self._run_yaml(target, rest_argv)
kind, target = resolved
if kind == "legacy":
return self._run_legacy(target, rest)
return self._run_tool(target, rest)
# ------------------------------------------------------------------ #
# 工具列表 (rich)
# ------------------------------------------------------------------ #
def _list_tools(self) -> None:
"""列出所有可用工具."""
print("PyFlowX 工具列表:")
print()
print("YAML 配置工具:")
yaml_tools = sorted(set(self._TOOL_ALIASES.values()))
for tool in yaml_tools:
print(f" pf {tool:<15} - {self._tool_description(tool)}")
print()
print("传统工具:")
for tool in sorted(self._LEGACY_TOOLS):
print(f" pf {tool:<15}")
print()
print("示例:")
print(" pf filedate add a.txt")
print(" pf pymake b")
"""rich 表格列出所有可用工具."""
self._console.print(
Panel(
Text(f"PyFlowX v{__version__}", style="bold cyan", justify="center"),
subtitle="[dim]pf <tool> [command] [options][/dim]",
)
)
table = Table(title="@px.tool 工具", show_header=True, header_style="bold", show_lines=False)
table.add_column("命令", style="cyan", no_wrap=True)
table.add_column("别名", style="dim", no_wrap=True)
table.add_column("说明")
for tool in sorted(set(self._TOOL_ALIASES.values())):
aliases = self._aliases_for(tool)
table.add_row(f"pf {tool}", ", ".join(aliases), self._tool_description(tool))
self._console.print(table)
if self._LEGACY_TOOLS:
legacy = Table(title="传统工具", show_header=True, header_style="bold", show_lines=False)
legacy.add_column("命令", style="cyan", no_wrap=True)
for tool in sorted(self._LEGACY_TOOLS):
legacy.add_row(f"pf {tool}")
self._console.print(legacy)
self._console.print("\n[bold]示例:[/bold]")
self._console.print(" [cyan]pf filedate add a.txt[/cyan] # 给文件添加日期前缀")
self._console.print(" [cyan]pf pymake b[/cyan] # 构建 Python 包")
self._console.print(" [cyan]pf gitt c[/cyan] # 清理并查看 git 状态")
self._console.print(" [cyan]pf yamlrun pipeline.yaml[/cyan] # 执行 YAML 任务图")
def _aliases_for(self, canonical: str) -> list[str]:
"""获取工具的别名 (不含规范名本身)."""
return sorted(a for a, t in self._TOOL_ALIASES.items() if t == canonical and a != canonical)
def _tool_description(self, tool_name: str) -> str:
"""获取工具描述 (从 YAML cli.description)."""
config_path = self._CONFIGS_DIR / f"{tool_name}.yaml"
if not config_path.exists():
return ""
try:
import yaml
"""获取工具描述 (从 _TOOL_REGISTRY 中已注册 ToolSpec 的 description/help)."""
with contextlib.suppress(ImportError, KeyError):
importlib.import_module(self._TOOL_MODULES[tool_name])
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
if isinstance(data, dict) and isinstance(data.get("cli"), dict):
return str(data["cli"].get("description", ""))
except Exception:
pass
if tool_name not in _TOOL_REGISTRY:
return ""
subs = _TOOL_REGISTRY[tool_name]
for spec in subs.values():
if spec.description:
return spec.description
for spec in subs.values():
if not spec.hidden and spec.help:
return spec.help
return ""
# ------------------------------------------------------------------ #
# 路由
# ------------------------------------------------------------------ #
def _resolve_tool(self, name: str) -> tuple[str, str] | None:
"""解析工具名, 返回 (类型, 目标).
类型: "yaml""legacy"
目标: YAML 文件名 (不含 .yaml) 或 legacy 模块路径
"""
"""解析工具名, 返回 (类型, 目标)."""
if name in self._TOOL_ALIASES:
return ("yaml", self._TOOL_ALIASES[name])
return ("tool", self._TOOL_ALIASES[name])
if name in self._LEGACY_TOOLS:
return ("legacy", self._LEGACY_TOOLS[name])
return None
def _print_unknown_tool(self, name: str) -> None:
"""打印未知工具错误 + 模糊匹配建议."""
self._err.print(f"[red]错误:[/red] 未知工具 [yellow]{name!r}[/yellow]")
suggestions = difflib.get_close_matches(name, list(self._TOOL_ALIASES), n=3, cutoff=0.5)
if suggestions:
self._err.print(f"[dim]是否想用: {', '.join(suggestions)}[/dim]")
self._err.print("[dim]运行 'pf' 查看可用工具列表[/dim]")
def _run_legacy(self, module_path: str, argv: list[str]) -> int:
"""运行传统工具的 main() 函数."""
module_name, func_name = module_path.split(":", 1)
@@ -165,16 +269,387 @@ class PfApp:
finally:
sys.argv = original_argv
def _run_yaml(self, target: str, argv: list[str]) -> int:
"""YAML 配置工具."""
config_path = self._CONFIGS_DIR / f"{target}.yaml"
if not config_path.exists():
print(f"错误: 未找到配置文件 '{config_path}'", file=sys.stderr)
print("运行 'pf' 查看可用工具列表", file=sys.stderr)
def _run_yaml(self, argv: list[str]) -> int:
"""yamlrun 命令: 从 YAML 文件加载任务图并执行."""
import argparse
from typing import get_args
from pyflowx import Graph, run
from pyflowx.errors import PyFlowXError, TaskFailedError
from pyflowx.executors import Strategy
from pyflowx.runner import CliExitCode
parser = argparse.ArgumentParser(prog="pf yamlrun", description="执行 YAML 任务图")
_ = parser.add_argument("file", help="YAML 文件路径")
_ = parser.add_argument(
"--strategy",
choices=list(get_args(Strategy)),
default="dependency",
help="执行策略 (默认: %(default)s)",
)
_ = parser.add_argument("--dry-run", action="store_true", help="仅打印执行计划")
_ = parser.add_argument("--list", action="store_true", help="列出所有任务名")
_ = parser.add_argument("--list-tag", help="--list 模式下只显示含此标签的任务")
_ = parser.add_argument(
"--list-name",
help="--list 模式下按子串过滤任务名(大小写不敏感)",
)
_ = parser.add_argument("--quiet", action="store_true", help="静默模式")
_ = parser.add_argument("--progress", action="store_true", help="显示 rich 进度条")
_ = parser.add_argument("--only", help="只运行指定任务(逗号分隔)及其依赖")
_ = parser.add_argument("--tags", help="只运行匹配标签的任务(逗号分隔)及其依赖")
parsed = parser.parse_args(argv)
try:
graph = Graph.from_yaml(parsed.file)
except (OSError, ValueError, PyFlowXError) as e:
self._err.print(f"[red]错误:[/red] 加载 YAML 失败: {e}")
return CliExitCode.FAILURE.value
if parsed.list:
self._print_task_list(
graph,
tag_filter=parsed.list_tag,
name_filter=parsed.list_name,
)
return CliExitCode.SUCCESS.value
if parsed.dry_run:
self._console.print(graph.describe())
return CliExitCode.SUCCESS.value
only = parsed.only.split(",") if parsed.only else None
tags = parsed.tags.split(",") if parsed.tags else None
progress = parsed.progress and not parsed.quiet
try:
report = run(
graph,
strategy=parsed.strategy,
verbose=not parsed.quiet,
only=only,
tags=tags,
progress=progress,
)
if not report.success and not parsed.quiet:
self._print_diagnostics(report)
return CliExitCode.SUCCESS.value if report.success else CliExitCode.FAILURE.value
except KeyboardInterrupt:
print("\n操作已取消", file=sys.stderr)
return CliExitCode.INTERRUPTED.value
except PyFlowXError as e:
self._err.print(f"[red]错误:[/red] {e}")
# TaskFailedError 携带 report 时打印诊断摘要
if isinstance(e, TaskFailedError) and e.report is not None and not parsed.quiet:
self._print_diagnostics(e.report)
return CliExitCode.FAILURE.value
def _print_diagnostics(self, report: Any) -> None:
"""打印失败诊断摘要到 stderr。"""
diag = report.diagnose()
if diag is not None:
self._err.print()
self._err.print(diag.describe(), style="red")
def _print_task_list(
self,
graph: Any,
tag_filter: str | None,
name_filter: str | None,
) -> None:
"""打印任务列表,可按标签/名称子串过滤。"""
name_lower = name_filter.lower() if name_filter else None
for name in graph.names:
if name_lower is not None and name_filter is not None and name_lower not in name.lower():
continue
if tag_filter is not None:
spec = graph.spec(name)
if tag_filter not in spec.tags:
continue
self._console.print(name)
def _run_graph(self, argv: list[str]) -> int:
"""执行 graph 命令: 终端可视化 YAML 任务图 DAG."""
import argparse
from pyflowx import Graph
from pyflowx.errors import PyFlowXError
from pyflowx.runner import CliExitCode
parser = argparse.ArgumentParser(prog="pf graph", description="可视化 YAML 任务图 DAG")
_ = parser.add_argument("file", help="YAML 文件路径")
_ = parser.add_argument(
"--format",
choices=["ascii", "mermaid", "list", "deps"],
default="ascii",
help="输出格式 (默认: %(default)s)",
)
_ = parser.add_argument(
"--orientation",
default="TD",
help="Mermaid 方向: TD/TB/BT/LR/RL (默认: %(default)s)",
)
_ = parser.add_argument(
"--color-by",
choices=["tag", "none"],
default="tag",
help="ASCII 格式节点着色策略: tag(按首标签着色) / none(单色) (默认: %(default)s)",
)
parsed = parser.parse_args(argv)
try:
graph = Graph.from_yaml(parsed.file)
except (OSError, ValueError, PyFlowXError) as e:
self._err.print(f"[red]错误:[/red] 加载 YAML 失败: {e}")
return CliExitCode.FAILURE.value
fmt = parsed.format
try:
if fmt == "mermaid":
# Mermaid 输出不加颜色,便于复制粘贴到 mermaid.live
self._console.print(graph.to_mermaid(parsed.orientation), markup=False)
elif fmt == "list":
for name in graph.names:
self._console.print(name)
elif fmt == "deps":
self._render_deps_table(graph)
else:
self._render_ascii(graph, color_by=parsed.color_by)
except ValueError as e:
self._err.print(f"[red]错误:[/red] {e}")
return CliExitCode.FAILURE.value
return CliExitCode.SUCCESS.value
def _render_ascii(self, graph: Any, color_by: str = "tag") -> None:
"""用 rich 渲染分层 DAG 视图与依赖关系表。
``color_by="tag"`` 时按任务首标签选择 rich 颜色,便于视觉分组;
``color_by="none"`` 时统一使用 cyan。
"""
layers = graph.layers()
if not layers:
self._console.print("[dim]空图(无任务)[/dim]")
return
palette = ("cyan", "magenta", "yellow", "green", "blue", "red", "bright_cyan")
tag_colors: dict[str, str] = {}
counter = [0]
def _color_for(name: str) -> str:
if color_by == "none":
return "cyan"
spec = graph.spec(name)
if not spec.tags:
return "cyan"
tag = spec.tags[0]
if tag not in tag_colors:
tag_colors[tag] = palette[counter[0] % len(palette)]
counter[0] += 1
return tag_colors[tag]
tree = Tree(f"[bold cyan]Graph[/bold cyan] ({len(graph)} 任务, {len(layers)} 层)")
for i, layer in enumerate(layers, 1):
names = ", ".join(f"[{_color_for(n)}]{n}[/{_color_for(n)}]" for n in layer)
tree.add(f"[bold]Layer {i}[/bold] ({len(layer)}): {names}")
self._console.print(tree)
if tag_colors and color_by == "tag":
legend = " ".join(f"[{c}]{t}[/{c}]" for t, c in tag_colors.items())
self._console.print(f"[dim]标签颜色:[/dim] {legend}")
self._console.print()
self._render_deps_table(graph)
def _render_deps_table(self, graph: Any) -> None:
"""渲染任务依赖关系表."""
table = Table(title="任务依赖关系", show_header=True, header_style="bold")
table.add_column("任务", style="cyan", no_wrap=True)
table.add_column("硬依赖", style="yellow")
table.add_column("软依赖", style="dim")
table.add_column("标签", style="green")
for name in graph.names:
spec = graph.spec(name)
hard = ", ".join(spec.depends_on) if spec.depends_on else "-"
soft = ", ".join(spec.soft_depends_on) if spec.soft_depends_on else "-"
tags = ", ".join(spec.tags) if spec.tags else "-"
table.add_row(name, hard, soft, tags)
self._console.print(table)
def _run_tool(self, target: str, argv: list[str]) -> int:
"""运行 @px.tool 工具.
导入 ``pyflowx.ops.<group>.<target>`` 触发 ``@px.tool`` 注册, 然后调用 ``run_tool``.
"""
with contextlib.suppress(ImportError, KeyError):
importlib.import_module(self._TOOL_MODULES[target])
if target not in _TOOL_REGISTRY:
self._err.print(f"[red]错误:[/red] 未找到工具 [yellow]{target!r}[/yellow]")
self._err.print("[dim]运行 'pf' 查看可用工具列表[/dim]")
return 1
print(f"运行配置文件 '{config_path}'")
return px.run_cli(config_path, argv)
return run_tool(target, argv)
# ------------------------------------------------------------------ #
# info: 工具详情
# ------------------------------------------------------------------ #
def _run_info(self, argv: list[str]) -> int:
"""显示工具详情: ``pf info [tool]``。
无参数时列出所有工具的简表(含子命令数、描述);
指定工具时显示该工具的所有子命令详情。
"""
if not argv:
self._print_all_tools_detail()
return 0
name = argv[0]
resolved = self._resolve_tool(name)
if resolved is None or resolved[0] != "tool":
self._err.print(f"[red]错误:[/red] 未知工具 [yellow]{name!r}[/yellow]")
self._err.print("[dim]运行 'pf' 查看可用工具列表[/dim]")
return 1
canonical = resolved[1]
self._print_tool_detail(canonical)
return 0
def _print_all_tools_detail(self) -> None:
"""打印所有工具的详情表。"""
# 触发工具模块导入以填充 _TOOL_REGISTRY
for mod_path in self._TOOL_MODULES.values():
with contextlib.suppress(ImportError):
importlib.import_module(mod_path)
table = Table(title="工具详情", show_header=True, header_style="bold")
table.add_column("工具", style="cyan", no_wrap=True)
table.add_column("别名", style="dim")
table.add_column("子命令数", justify="right")
table.add_column("描述")
for canonical in sorted(set(self._TOOL_ALIASES.values())):
aliases = ", ".join(self._aliases_for(canonical))
subs = _TOOL_REGISTRY.get(canonical, {})
desc = self._tool_description(canonical)
table.add_row(canonical, aliases or "-", str(len(subs)), desc or "-")
self._console.print(table)
def _print_tool_detail(self, canonical: str) -> None:
"""打印指定工具的子命令详情。"""
with contextlib.suppress(ImportError, KeyError):
importlib.import_module(self._TOOL_MODULES[canonical])
subs = _TOOL_REGISTRY.get(canonical, {})
aliases = self._aliases_for(canonical)
self._console.print(f"[bold cyan]{canonical}[/bold cyan]", end="")
if aliases:
self._console.print(f" [dim]别名: {', '.join(aliases)}[/dim]")
else:
self._console.print()
desc = self._tool_description(canonical)
if desc:
self._console.print(f"[dim]描述: {desc}[/dim]")
if not subs:
self._console.print("[dim]无子命令(直接调用)[/dim]")
return
table = Table(title=f"{canonical} 子命令", show_header=True, header_style="bold")
table.add_column("子命令", style="cyan", no_wrap=True)
table.add_column("描述")
table.add_column("隐藏", justify="center")
for name in sorted(n for n in subs if n is not None):
spec = subs[name]
table.add_row(
name,
spec.description or spec.help or "-",
"" if spec.hidden else "-",
)
self._console.print(table)
# ------------------------------------------------------------------ #
# completion: shell 补全脚本生成
# ------------------------------------------------------------------ #
def _run_completion(self, argv: list[str]) -> int:
"""生成 shell 补全脚本: ``pf completion bash|zsh|fish``。"""
import argparse
parser = argparse.ArgumentParser(prog="pf completion", description="生成 shell 补全脚本")
_ = parser.add_argument(
"shell",
choices=["bash", "zsh", "fish"],
help="目标 shell",
)
parsed = parser.parse_args(argv)
tools = sorted(set(self._TOOL_ALIASES.values()))
builtin_cmds = ["yamlrun", "graph", "info", "completion"]
all_cmds = builtin_cmds + tools
if parsed.shell == "bash":
self._console.print(self._bash_completion(all_cmds), markup=False)
elif parsed.shell == "zsh":
self._console.print(self._zsh_completion(all_cmds), markup=False)
else:
self._console.print(self._fish_completion(all_cmds), markup=False)
return 0
@staticmethod
def _bash_completion(cmds: list[str]) -> str:
"""生成 bash 补全脚本。"""
words = " ".join(cmds)
return f"""# pf bash 补全 —— 添加到 ~/.bashrc 或保存到 /etc/bash_completion.d/pf
_pf_complete() {{
local cur prev
cur="${{COMP_WORDS[COMP_CWORD]}}"
prev="${{COMP_WORDS[COMP_CWORD-1]}}"
if [ "$COMP_CWORD" -eq 1 ]; then
COMPREPLY=($(compgen -W "{words}" -- "$cur"))
return 0
fi
# 二级补全:交给具体子命令(pf <tool> --help 自描述)
return 0
}}
complete -F _pf_complete pf
"""
@staticmethod
def _zsh_completion(cmds: list[str]) -> str:
"""生成 zsh 补全脚本。"""
words = " ".join(cmds)
return f"""# pf zsh 补全 —— 添加到 ~/.zshrc
#compdef pf
_pf() {{
local -a cmds
cmds=({words})
if [ "$CURRENT" -eq 2 ]; then
_describe 'command' cmds
return 0
fi
return 0
}}
_pf "$@"
"""
@staticmethod
def _fish_completion(cmds: list[str]) -> str:
"""生成 fish 补全脚本。"""
lines = ["# pf fish 补全 —— 保存到 ~/.config/fish/completions/pf.fish"]
for c in cmds:
lines.append(f'complete -c pf -n "__fish_use_subcommand" -a "{c}"')
return "\n".join(lines) + "\n"
def main() -> None:
View File
-15
View File
@@ -1,15 +0,0 @@
"""清屏工具.
跨平台清屏工具, 支持终端和控制台清屏.
"""
from __future__ import annotations
import pyflowx as px
from pyflowx.tasks.system import clr
def main() -> None:
"""清屏工具主函数."""
graph = px.Graph.from_specs([clr()])
px.run(graph, strategy="thread")
-40
View File
@@ -1,40 +0,0 @@
"""进程终止工具.
跨平台进程终止工具, 支持按名称终止进程.
用法: taskkill proc_name [proc_name ...]
"""
from __future__ import annotations
import argparse
import pyflowx as px
from pyflowx.conditions import Constants
def main() -> None:
"""进程终止工具主函数."""
parser = argparse.ArgumentParser(
description="TaskKill - 进程终止工具",
usage="taskkill <process_name> [process_name ...]",
)
parser.add_argument(
"process_names",
type=str,
nargs="+",
help="进程名称 (如: chrome.exe python node)",
)
args = parser.parse_args()
if Constants.IS_WINDOWS:
cmd = ["taskkill", "/f", "/im"]
else:
cmd = ["pkill", "-f"]
graph = px.Graph.from_specs(
[
px.TaskSpec(f"kill_{proc_name}", cmd=[*cmd, f"{proc_name}*"], verbose=True)
for proc_name in args.process_names
],
)
px.run(graph, strategy="thread")
-21
View File
@@ -1,21 +0,0 @@
"""命令查找工具.
跨平台查找可执行命令路径, 类似 Unix 的 which 命令.
"""
from __future__ import annotations
import argparse
import pyflowx as px
from pyflowx.tasks.system import which
def main() -> None:
"""命令查找工具主函数."""
parser = argparse.ArgumentParser(description="Which - 命令查找工具")
parser.add_argument("commands", nargs="+", help="要查找的命令名称, 如: python ls ps gcc...")
args = parser.parse_args()
graph = px.Graph.from_specs([which(cmd) for cmd in args.commands])
px.run(graph, strategy="thread")
-109
View File
@@ -1,109 +0,0 @@
"""YAML 任务编排执行工具.
从 YAML 文件加载 GitHub Actions 风格的任务图并执行.
支持串并行编排、矩阵扇出、条件执行等 CI/CD 核心概念.
用法
----
yamlrun pipeline.yaml # 执行 YAML 任务图
yamlrun pipeline.yaml --strategy thread # 指定执行策略
yamlrun pipeline.yaml --dry-run # 仅打印任务分层, 不执行
yamlrun pipeline.yaml --list # 列出所有任务名
yamlrun pipeline.yaml --quiet # 静默模式
示例 YAML
----------
::
strategy: thread
jobs:
setup:
cmd: ["git", "clone", "https://github.com/foo/bar"]
build:
needs: [setup]
cmd: ["python", "-m", "build"]
test:
needs: [build]
cmd: ["pytest"]
strategy:
matrix:
python: ["3.8", "3.9"]
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import cast
import pyflowx as px
from pyflowx.executors import Strategy
def main() -> None:
"""YAML 任务编排执行工具主函数."""
parser = argparse.ArgumentParser(
description="YamlRun - 从 YAML 文件加载并执行任务图",
usage="yamlrun <file.yaml> [--strategy STRATEGY] [--dry-run] [--list] [--quiet]",
)
parser.add_argument("file", type=str, help="YAML 任务图文件路径")
parser.add_argument(
"--strategy",
type=str,
default=None,
help="执行策略: sequential/thread/async/dependency (默认: YAML 中指定的策略或 dependency)",
)
parser.add_argument("--dry-run", action="store_true", help="仅打印任务分层, 不执行")
parser.add_argument("--list", action="store_true", help="列出所有任务名后退出")
parser.add_argument("--quiet", action="store_true", help="静默模式, 不打印详细输出")
args = parser.parse_args()
file_path = Path(args.file)
if not file_path.exists():
print(f"错误: 文件不存在: {file_path}", file=sys.stderr)
sys.exit(1)
try:
graph = px.Graph.from_yaml(file_path)
except px.YamlLoadError as e:
print(f"错误: YAML 加载失败: {e}", file=sys.stderr)
sys.exit(1)
if args.list:
print("任务列表:")
for name in graph.names:
spec = graph.spec(name)
deps = ", ".join(spec.depends_on) if spec.depends_on else "(无依赖)"
print(f" - {name} (依赖: {deps})")
sys.exit(0)
layers = graph.layers()
print(f"任务分层 ({len(layers)} 层):")
for i, layer in enumerate(layers):
print(f"{i + 1}: {layer}")
if args.dry_run:
print("\n[dry-run] 跳过执行")
sys.exit(0)
strategy = args.strategy or graph.defaults.strategy or "dependency"
print(f"\n执行策略: {strategy}")
print(f"任务总数: {len(graph.names)}")
print("-" * 40)
report = px.run(graph, strategy=cast(Strategy, strategy), verbose=not args.quiet)
print("-" * 40)
succeeded = report.succeeded_tasks()
failed = report.failed_tasks()
skipped = report.skipped_tasks()
print(f"完成: {len(succeeded)} 成功 / {len(failed)} 失败 / {len(skipped)} 跳过 (共 {len(graph.names)})")
if failed:
print(f"失败任务: {failed}")
sys.exit(1)
if __name__ == "__main__":
main()
+12 -7
View File
@@ -10,12 +10,16 @@ from __future__ import annotations
import os
import subprocess
from typing import Any, List, Union, cast
from typing import Any, cast
from rich.console import Console
from .task import TaskSpec
__all__ = ["run_command"]
_cmd_console = Console()
def run_command(spec: TaskSpec[Any]) -> Any: # noqa: PLR0912
"""执行 ``spec.cmd`` 指定的命令(list / shell 字符串 / 可调用对象)。
@@ -39,9 +43,9 @@ def run_command(spec: TaskSpec[Any]) -> Any: # noqa: PLR0912
if callable(cmd) and not isinstance(cmd, (list, str)):
name = getattr(cmd, "__name__", "callable")
if verbose:
print(f"[verbose] 执行可调用命令: {name}", flush=True)
_cmd_console.print(f"[cyan]▸[/cyan] 执行可调用命令: [bold]{name}[/bold]")
if cwd is not None:
print(f"[verbose] 工作目录: {cwd}", flush=True)
_cmd_console.print(f" [dim]工作目录: {cwd}[/dim]")
try:
return cmd()
except Exception as e:
@@ -58,9 +62,9 @@ def run_command(spec: TaskSpec[Any]) -> Any: # noqa: PLR0912
label = "Shell 命令"
if verbose:
print(f"[verbose] {verb}: {cmd_str}", flush=True)
_cmd_console.print(f"[cyan]▸[/cyan] {verb}: [bold]{cmd_str}[/bold]")
if cwd is not None:
print(f"[verbose] 工作目录: {cwd}", flush=True)
_cmd_console.print(f" [dim]工作目录: {cwd}[/dim]")
# 合并环境变量
run_env: dict[str, str] | None = None
@@ -70,7 +74,7 @@ def run_command(spec: TaskSpec[Any]) -> Any: # noqa: PLR0912
try:
result = subprocess.run(
cast(Union[str, List[str]], cmd),
cast(str | list[str], cmd),
shell=not is_list,
cwd=cwd,
env=run_env,
@@ -87,7 +91,8 @@ def run_command(spec: TaskSpec[Any]) -> Any: # noqa: PLR0912
raise RuntimeError(f"{label}执行异常: {cmd_str}: {e}") from e
if verbose:
print(f"[verbose] 返回码: {result.returncode}", flush=True)
style = "green" if result.returncode == 0 else "red"
_cmd_console.print(f"[{style}]返回码: {result.returncode}[/{style}]")
if result.returncode == 0:
if not verbose and result.stdout:
+2 -2
View File
@@ -105,8 +105,8 @@ def compose(
Examples
--------
>>> graphs = {
... "build": px.Graph.from_specs([px.TaskSpec("b", cmd=["echo", "b"])]),
... "all": px.Graph.from_specs(["build", px.TaskSpec("t", cmd=["echo", "t"])]),
... "build": px.graph(px.cmd(["echo", "b"], name="b")),
... "all": px.graph("build", px.cmd(["echo", "t"], name="t")),
... }
>>> resolved = px.compose(graphs)
>>> "b" in resolved["all"].all_specs()
+2 -1
View File
@@ -16,8 +16,9 @@ import os
import shutil
import subprocess
import sys
from collections.abc import Callable
from pathlib import Path
from typing import Any, Callable
from typing import Any
from .task import Condition, Context
-65
View File
@@ -1,65 +0,0 @@
# autofmt - 自动格式化工具
# 用法:
# pf autofmt fmt --target .
# pf autofmt lint --target .
# pf autofmt lint --target . --fix
# pf autofmt doc --root-dir .
# pf autofmt sync --root-dir .
strategy: thread
variables:
TARGET: "."
ROOT_DIR: "."
FIX: false
cli:
description: "AutoFmt - 自动格式化工具"
usage: "pf autofmt <command> [options]"
subcommands:
fmt:
help: "格式化代码"
options:
- name: TARGET
flag: "--target"
type: str
default: "."
help: "目标路径 (默认: .)"
lint:
help: "代码检查"
options:
- name: TARGET
flag: "--target"
type: str
default: "."
help: "目标路径 (默认: .)"
- name: FIX
flag: "--fix"
action: "store_true"
help: "自动修复问题"
doc:
help: "自动添加文档字符串"
options:
- name: ROOT_DIR
flag: "--root-dir"
type: str
default: "."
help: "根目录 (默认: .)"
sync:
help: "同步 pyproject 配置"
options:
- name: ROOT_DIR
flag: "--root-dir"
type: str
default: "."
help: "根目录 (默认: .)"
jobs:
fmt:
cmd: ["ruff", "format", "${TARGET}"]
lint:
cmd: ["ruff", "check", "${TARGET}"]
lint_fix:
cmd: ["ruff", "check", "--fix", "--unsafe-fixes", "${TARGET}"]
doc:
fn: auto_add_docstrings
args: ["${ROOT_DIR}"]
sync:
fn: sync_pyproject_config
args: ["${ROOT_DIR}"]
-27
View File
@@ -1,27 +0,0 @@
# bumpversion - 版本号自动管理工具
# 用法:
# pf bumpversion
# pf bumpversion minor --no-tag
strategy: sequential
variables:
PART: patch
NO_TAG: false
cli:
description: "BumpVersion - 版本号自动管理工具"
usage: "pf bumpversion [part] [options]"
positional:
- name: PART
type: str
default: patch
help: "版本部分: patch, minor, major"
options:
- name: NO_TAG
flag: "--no-tag"
action: "store_true"
help: "提交后不创建 git tag"
jobs:
bump:
fn: bump_project_version
args: ["${PART}"]
kwargs:
no_tag: ${NO_TAG}
-36
View File
@@ -1,36 +0,0 @@
# filedate - 文件日期处理工具
# 用法:
# pf filedate add file1.txt file2.txt
# pf filedate clear file1.txt file2.txt
strategy: thread
variables:
FILES: []
cli:
description: "FileDate - 文件日期处理工具"
usage: "pf filedate <command> [files...]"
subcommands:
add:
help: "添加日期前缀"
positional:
- name: FILES
nargs: "+"
type: path
help: "文件路径"
clear:
help: "清除日期前缀"
positional:
- name: FILES
nargs: "+"
type: path
help: "文件路径"
jobs:
add:
fn: process_files_date
args: ["${FILES}"]
kwargs:
clear: false
clear:
fn: process_files_date
args: ["${FILES}"]
kwargs:
clear: true
-28
View File
@@ -1,28 +0,0 @@
# filelevel - 文件等级重命名工具
# 用法:
# pf filelevel set file.txt --level 2
strategy: thread
variables:
FILES: []
LEVEL: 0
cli:
description: "FileLevel - 文件等级重命名工具"
usage: "pf filelevel <command> [files...] [options]"
subcommands:
set:
help: "设置文件等级"
positional:
- name: FILES
nargs: "+"
type: path
help: "文件路径"
options:
- name: LEVEL
flag: "--level"
type: int
required: true
help: "文件等级 (0-4)"
jobs:
set:
fn: process_files_level
args: ["${FILES}", "${LEVEL}"]
-34
View File
@@ -1,34 +0,0 @@
# folderback - 文件夹备份工具
# 用法:
# pf folderback
# pf folderback --src ./project --dst ./backup --max-zip 10
strategy: thread
variables:
SRC: "."
DST: "./backup"
MAX_ZIP: 5
cli:
description: "FolderBack - 文件夹备份工具"
usage: "pf folderback [options]"
options:
- name: SRC
flag: "--src"
type: str
default: "."
help: "源文件夹路径 (默认: 当前目录)"
- name: DST
flag: "--dst"
type: str
default: "./backup"
help: "目标文件夹路径 (默认: ./backup)"
- name: MAX_ZIP
flag: "--max-zip"
type: int
default: 5
help: "最大备份数量 (默认: 5)"
jobs:
backup:
fn: backup_folder
args: ["${SRC}", "${DST}"]
kwargs:
max_zip: ${MAX_ZIP}
-21
View File
@@ -1,21 +0,0 @@
# folderzip - 文件夹压缩工具
# 用法:
# pf folderzip
# pf folderzip --cwd ./project
strategy: thread
variables:
CWD: "."
cli:
description: "FolderZip - 文件夹压缩工具"
usage: "pf folderzip [options]"
options:
- name: CWD
flag: "--cwd"
type: str
required: false
default: "."
help: "工作目录 (默认: 当前目录)"
jobs:
zip:
fn: zip_folders
args: ["${CWD}"]
-51
View File
@@ -1,51 +0,0 @@
# gittool - Git 执行工具
# 用法:
# pf gittool a
# pf gittool c
# pf gittool i
# pf gittool isub
# pf gittool p
# pf gittool pl
strategy: thread
variables:
# git clean -e 参数列表 (展开为 cmd 数组元素)
CLEAN_EXCLUDES: ["-e", ".venv", "-e", ".tox", "-e", ".pytest_cache",
"-e", ".ruff_cache", "-e", "node_modules",
"-e", ".idea", "-e", ".vscode",
"-e", ".trae", "-e", ".qoder",
"-e", ".editorconfig", "-e", "idea.config",
"-e", "idea_modules.xml", "-e", "vcs.xml"]
cli:
description: "GitTool - Git 执行工具"
usage: "pf gittool <command>"
subcommands:
a:
help: "添加并提交"
c:
help: "清理并查看状态"
i:
help: "初始化并提交"
isub:
help: "初始化子目录"
p:
help: "推送"
pl:
help: "拉取"
jobs:
a:
fn: git_add_commit
args: ["chore: update"]
clean:
cmd: ["git", "clean", "-xfd", "${CLEAN_EXCLUDES}"]
c:
needs: [clean]
cmd: ["git", "status", "--porcelain"]
i:
fn: git_init_add_commit
args: ["init commit"]
isub:
fn: init_sub_dirs
p:
cmd: ["git", "push"]
pl:
cmd: ["git", "pull"]
-51
View File
@@ -1,51 +0,0 @@
# lscalc - LS-DYNA 计算工具
# 用法:
# pf lscalc run input.k --ncpu 4
# pf lscalc status
strategy: thread
variables:
INPUT_FILE: input.k
NCPU: 4
cli:
description: "LSCalc - LS-DYNA 计算工具"
usage: "pf lscalc <command> [options]"
subcommands:
run:
help: "运行 LS-DYNA 计算"
positional:
- name: INPUT_FILE
type: str
help: "输入文件路径"
options:
- name: NCPU
flag: "--ncpu"
type: int
default: 4
help: "CPU 核心数 (默认: 4)"
mpi:
help: "运行 LS-DYNA MPI 计算"
positional:
- name: INPUT_FILE
type: str
help: "输入文件路径"
options:
- name: NCPU
flag: "--ncpu"
type: int
default: 4
help: "CPU 核心数 (默认: 4)"
status:
help: "检查 LS-DYNA 进程状态"
jobs:
run:
fn: run_ls_dyna
args: ["${INPUT_FILE}"]
kwargs:
ncpu: ${NCPU}
mpi:
fn: run_ls_dyna_mpi
args: ["${INPUT_FILE}"]
kwargs:
ncpu: ${NCPU}
status:
fn: check_ls_dyna_status
-107
View File
@@ -1,107 +0,0 @@
# packtool - Python 打包工具
# 用法:
# pf packtool src --project-dir . --output-dir .pypack
# pf packtool deps requests numpy --lib-dir libs
# pf packtool wheel --project-dir . --output-dir dist
# pf packtool embed --version 3.10 --output-dir python
# pf packtool zip --source-dir . --output-file package.zip
# pf packtool clean
strategy: thread
variables:
PROJECT_DIR: "."
OUTPUT_DIR: ".pypack"
LIB_DIR: "libs"
DEPENDENCIES: []
VERSION: "3.10"
OUTPUT_FILE: "package.zip"
SOURCE_DIR: "."
cli:
description: "PackTool - Python 打包工具"
usage: "pf packtool <command> [options]"
subcommands:
src:
help: "打包源码"
options:
- name: PROJECT_DIR
flag: "--project-dir"
type: path
default: "."
help: "项目目录 (默认: .)"
- name: OUTPUT_DIR
flag: "--output-dir"
type: str
default: ".pypack"
help: "输出目录 (默认: .pypack)"
deps:
help: "打包依赖"
positional:
- name: DEPENDENCIES
nargs: "*"
type: str
help: "依赖包列表"
options:
- name: LIB_DIR
flag: "--lib-dir"
type: path
default: "libs"
help: "依赖库目录 (默认: libs)"
wheel:
help: "构建 wheel"
options:
- name: PROJECT_DIR
flag: "--project-dir"
type: path
default: "."
help: "项目目录 (默认: .)"
- name: OUTPUT_DIR
flag: "--output-dir"
type: path
default: "dist"
help: "输出目录 (默认: dist)"
embed:
help: "安装嵌入式 Python"
options:
- name: VERSION
flag: "--version"
type: str
default: "3.10"
help: "Python 版本 (默认: 3.10)"
- name: OUTPUT_DIR
flag: "--output-dir"
type: path
default: "python"
help: "输出目录 (默认: python)"
zip:
help: "创建 zip 包"
options:
- name: SOURCE_DIR
flag: "--source-dir"
type: path
default: "."
help: "源目录 (默认: .)"
- name: OUTPUT_FILE
flag: "--output-file"
type: path
default: "package.zip"
help: "输出文件 (默认: package.zip)"
clean:
help: "清理构建目录"
jobs:
src:
fn: pack_source
args: ["${PROJECT_DIR}", "${OUTPUT_DIR}"]
deps:
fn: pack_dependencies
args: ["${LIB_DIR}", "${DEPENDENCIES}"]
wheel:
fn: pack_wheel
args: ["${PROJECT_DIR}", "${OUTPUT_DIR}"]
embed:
fn: install_embed_python
args: ["${VERSION}", "${OUTPUT_DIR}"]
zip:
fn: create_zip_package
args: ["${SOURCE_DIR}", "${OUTPUT_FILE}"]
clean:
fn: clean_build_dir
args: ["${OUTPUT_DIR}"]
-303
View File
@@ -1,303 +0,0 @@
# pdftool - PDF 文件工具集
# 用法:
# pf pdftool m a.pdf b.pdf --output merged.pdf
# pf pdftool s input.pdf --output-dir split
# pf pdftool c input.pdf --output compressed.pdf
# pf pdftool e input.pdf --output encrypted.pdf --password 123456
# pf pdftool d input.pdf --output decrypted.pdf --password 123456
# pf pdftool xt input.pdf --output output.txt
# pf pdftool xi input.pdf --output-dir images
# pf pdftool w input.pdf --output watermarked.pdf --text CONFIDENTIAL
# pf pdftool r input.pdf --output rotated.pdf --rotation 90
# pf pdftool crop input.pdf --output cropped.pdf --left 10 --top 10 --right 10 --bottom 10
# pf pdftool i input.pdf
# pf pdftool ocr input.pdf --output ocr.pdf --lang chi_sim+eng
# pf pdftool img input.pdf --output-dir images --dpi 300
# pf pdftool repair input.pdf --output repaired.pdf
strategy: thread
variables:
INPUT: input.pdf
INPUTS: []
OUTPUT: output.pdf
OUTPUT_DIR: output
PASSWORD: ""
TEXT: CONFIDENTIAL
ROTATION: 90
MARGINS: [10, 10, 10, 10]
DPI: 300
LANG: chi_sim+eng
ORDER: []
LEFT: 10
TOP: 10
RIGHT: 10
BOTTOM: 10
cli:
description: "PdfTool - PDF 文件工具集"
usage: "pf pdftool <command> [options]"
subcommands:
m:
help: "合并 PDF"
positional:
- name: INPUTS
nargs: "+"
type: path
help: "输入 PDF 文件列表"
options:
- name: OUTPUT
flag: "--output"
type: path
default: "merged.pdf"
help: "输出文件 (默认: merged.pdf)"
s:
help: "拆分 PDF"
positional:
- name: INPUT
type: path
help: "输入 PDF 文件"
options:
- name: OUTPUT_DIR
flag: "--output-dir"
type: path
default: "split"
help: "输出目录 (默认: split)"
c:
help: "压缩 PDF"
positional:
- name: INPUT
type: path
help: "输入 PDF 文件"
options:
- name: OUTPUT
flag: "--output"
type: path
default: "compressed.pdf"
help: "输出文件 (默认: compressed.pdf)"
e:
help: "加密 PDF"
positional:
- name: INPUT
type: path
help: "输入 PDF 文件"
options:
- name: OUTPUT
flag: "--output"
type: path
default: "encrypted.pdf"
help: "输出文件 (默认: encrypted.pdf)"
- name: PASSWORD
flag: "--password"
type: str
required: true
help: "密码 (必填)"
d:
help: "解密 PDF"
positional:
- name: INPUT
type: path
help: "输入 PDF 文件"
options:
- name: OUTPUT
flag: "--output"
type: path
default: "decrypted.pdf"
help: "输出文件 (默认: decrypted.pdf)"
- name: PASSWORD
flag: "--password"
type: str
required: true
help: "密码 (必填)"
xt:
help: "提取文本"
positional:
- name: INPUT
type: path
help: "输入 PDF 文件"
options:
- name: OUTPUT
flag: "--output"
type: path
default: "output.txt"
help: "输出文件 (默认: output.txt)"
xi:
help: "提取图片"
positional:
- name: INPUT
type: path
help: "输入 PDF 文件"
options:
- name: OUTPUT_DIR
flag: "--output-dir"
type: path
default: "images"
help: "输出目录 (默认: images)"
w:
help: "添加水印"
positional:
- name: INPUT
type: path
help: "输入 PDF 文件"
options:
- name: OUTPUT
flag: "--output"
type: path
default: "watermarked.pdf"
help: "输出文件 (默认: watermarked.pdf)"
- name: TEXT
flag: "--text"
type: str
default: "CONFIDENTIAL"
help: "水印文字 (默认: CONFIDENTIAL)"
r:
help: "旋转 PDF"
positional:
- name: INPUT
type: path
help: "输入 PDF 文件"
options:
- name: OUTPUT
flag: "--output"
type: path
default: "rotated.pdf"
help: "输出文件 (默认: rotated.pdf)"
- name: ROTATION
flag: "--rotation"
type: int
default: 90
help: "旋转角度 (默认: 90)"
crop:
help: "裁剪 PDF"
positional:
- name: INPUT
type: path
help: "输入 PDF 文件"
options:
- name: OUTPUT
flag: "--output"
type: path
default: "cropped.pdf"
help: "输出文件 (默认: cropped.pdf)"
- name: LEFT
flag: "--left"
type: int
default: 10
help: "左边距 (默认: 10)"
- name: TOP
flag: "--top"
type: int
default: 10
help: "上边距 (默认: 10)"
- name: RIGHT
flag: "--right"
type: int
default: 10
help: "右边距 (默认: 10)"
- name: BOTTOM
flag: "--bottom"
type: int
default: 10
help: "下边距 (默认: 10)"
i:
help: "查看 PDF 信息"
positional:
- name: INPUT
type: path
help: "输入 PDF 文件"
ocr:
help: "PDF OCR 识别"
positional:
- name: INPUT
type: path
help: "输入 PDF 文件"
options:
- name: OUTPUT
flag: "--output"
type: path
default: "ocr.pdf"
help: "输出文件 (默认: ocr.pdf)"
- name: LANG
flag: "--lang"
type: str
default: "chi_sim+eng"
help: "识别语言 (默认: chi_sim+eng)"
img:
help: "PDF 转图片"
positional:
- name: INPUT
type: path
help: "输入 PDF 文件"
options:
- name: OUTPUT_DIR
flag: "--output-dir"
type: path
default: "images"
help: "输出目录 (默认: images)"
- name: DPI
flag: "--dpi"
type: int
default: 300
help: "DPI (默认: 300)"
repair:
help: "修复 PDF"
positional:
- name: INPUT
type: path
help: "输入 PDF 文件"
options:
- name: OUTPUT
flag: "--output"
type: path
default: "repaired.pdf"
help: "输出文件 (默认: repaired.pdf)"
jobs:
m:
fn: pdf_merge
args: ["${INPUTS}", "${OUTPUT}"]
s:
fn: pdf_split
args: ["${INPUT}", "${OUTPUT_DIR}"]
c:
fn: pdf_compress
args: ["${INPUT}", "${OUTPUT}"]
e:
fn: pdf_encrypt
args: ["${INPUT}", "${OUTPUT}", "${PASSWORD}"]
d:
fn: pdf_decrypt
args: ["${INPUT}", "${OUTPUT}", "${PASSWORD}"]
xt:
fn: pdf_extract_text
args: ["${INPUT}", "${OUTPUT}"]
xi:
fn: pdf_extract_images
args: ["${INPUT}", "${OUTPUT_DIR}"]
w:
fn: pdf_add_watermark
args: ["${INPUT}", "${OUTPUT}"]
kwargs:
text: "${TEXT}"
r:
fn: pdf_rotate
args: ["${INPUT}", "${OUTPUT}"]
kwargs:
rotation: ${ROTATION}
crop:
fn: pdf_crop
args: ["${INPUT}", "${OUTPUT}"]
kwargs:
margins: "${MARGINS}"
i:
fn: pdf_info
args: ["${INPUT}"]
ocr:
fn: pdf_ocr
args: ["${INPUT}", "${OUTPUT}"]
kwargs:
lang: "${LANG}"
img:
fn: pdf_to_images
args: ["${INPUT}", "${OUTPUT_DIR}"]
kwargs:
dpi: ${DPI}
repair:
fn: pdf_repair
args: ["${INPUT}", "${OUTPUT}"]
-78
View File
@@ -1,78 +0,0 @@
# piptool - pip 包管理工具
# 用法:
# pf piptool i requests
# pf piptool u requests
# pf piptool r requests
# pf piptool d requests
# pf piptool up
# pf piptool f
strategy: thread
variables:
PACKAGES: []
OFFLINE: false
cli:
description: "PipTool - pip 包管理工具"
usage: "pf piptool <command> [packages...] [options]"
subcommands:
i:
help: "安装包"
positional:
- name: PACKAGES
nargs: "+"
type: str
help: "包名列表"
u:
help: "卸载包"
positional:
- name: PACKAGES
nargs: "+"
type: str
help: "包名列表"
r:
help: "重装包"
positional:
- name: PACKAGES
nargs: "+"
type: str
help: "包名列表"
options:
- name: OFFLINE
flag: "--offline"
action: "store_true"
help: "离线模式"
d:
help: "下载包"
positional:
- name: PACKAGES
nargs: "+"
type: str
help: "包名列表"
options:
- name: OFFLINE
flag: "--offline"
action: "store_true"
help: "离线模式"
up:
help: "升级 pip"
f:
help: "导出依赖"
jobs:
i:
cmd: ["pip", "install", "${PACKAGES}"]
u:
fn: pip_uninstall
args: ["${PACKAGES}"]
r:
fn: pip_reinstall
args: ["${PACKAGES}"]
kwargs:
offline: ${OFFLINE}
d:
fn: pip_download
args: ["${PACKAGES}"]
kwargs:
offline: ${OFFLINE}
up:
cmd: ["python", "-m", "pip", "install", "--upgrade", "pip"]
f:
fn: pip_freeze
-125
View File
@@ -1,125 +0,0 @@
# pymake - 项目构建工具
# 用法
# pf pymake <command>
# 命令
# b: 构建 Python 主包 (uv build)
# ba: 构建所有包 (Python + Rust)
# bc: 构建 Rust 核心模块 (maturin build)
# bump: 升级版本号 (清理 + 检查 + add + bumpversion)
# bumpmi: 升级次版本号 (bumpversion minor)
# c: 清理构建产物 (调用 gitt c)
# cov: 测试并生成覆盖率
# doc: 构建 Sphinx 文档
# lint: 代码格式化与检查 (ruff)
# p: 推送代码 (清理 + push + push tags)
# pb: 发布到 PyPI (twine + hatch)
# sync: 同步依赖 (uv sync)
# t: 运行测试
# tc: 类型检查 (pyrefly + ruff)
# tf: 快速测试 (无 slow)
# tox: 多版本测试 (tox)
strategy: thread
variables:
CWD: "."
cli:
description: "PyMake - 项目构建工具"
usage: "pf pymake <command>"
options:
- name: CWD
flag: "--cwd"
type: path
required: false
default: "."
help: "工作目录 (默认: 当前目录)"
subcommands:
b: {help: "构建 Python 主包 (uv build)"}
ba: {help: "构建所有包 (Python + Rust)"}
bc: {help: "构建 Rust 核心模块 (maturin build)"}
bump: {help: "升级版本号 (清理 + 检查 + add + bumpversion)"}
bumpmi: {help: "升级次版本号 (bumpversion minor)"}
c: {help: "清理构建产物 (调用 gitt c)"}
cov: {help: "测试并生成覆盖率"}
doc: {help: "构建 Sphinx 文档"}
lint: {help: "代码格式化与检查 (ruff)"}
p: {help: "推送代码 (清理 + push + push tags)"}
pb: {help: "发布到 PyPI (twine + hatch)"}
sync: {help: "同步依赖 (uv sync)"}
t: {help: "运行测试"}
tc: {help: "类型检查 (pyrefly + ruff)"}
tf: {help: "快速测试 (无 slow)"}
tox: {help: "多版本测试 (tox)"}
jobs:
# 单任务别名
b:
cmd: ["uv", "build"]
cwd: ${CWD}
bc:
cmd: ["maturin", "build", "-r"]
cwd: ${CWD}
sync:
cmd: ["uv", "sync"]
cwd: ${CWD}
c:
cmd: ["pf", "gitt", "c"]
cwd: ${CWD}
t:
cmd: ["pytest", "-m", "not slow", "-n", "8", "--dist", "loadfile", "--color=yes", "--durations=10"]
cwd: ${CWD}
tf:
cmd: ["pytest", "-m", "not slow", "--dist", "loadfile", "--color=yes", "--durations=10"]
cwd: ${CWD}
bumpversion:
cmd: ["bumpversion", "patch"]
needs: [git_add_all]
cwd: ${CWD}
bumpmi:
cmd: ["bumpversion", "minor"]
cwd: ${CWD}
doc:
cmd: ["sphinx-build", "-b", "html", "docs", "docs/_build"]
cwd: ${CWD}
lint:
cmd: ["ruff", "check", "--fix", "--unsafe-fixes"]
cwd: ${CWD}
tox:
cmd: ["tox", "-p", "auto"]
cwd: ${CWD}
# 内部 job (不暴露为 subcommand)
test_coverage:
cmd: ["pytest", "--cov", "-n", "8", "--dist", "loadfile", "--tb=short", "-v", "--color=yes", "--durations=10"]
needs: [c]
cwd: ${CWD}
pyrefly_check:
cmd: ["pyrefly", "check", "."]
cwd: ${CWD}
git_add_all:
cmd: ["git", "add", "-A"]
needs: [tc]
cwd: ${CWD}
git_push:
cmd: ["git", "push"]
cwd: ${CWD}
git_push_tags:
cmd: ["git", "push", "--tags"]
cwd: ${CWD}
twine_publish:
cmd: ["twine", "upload", "--disable-progress-bar"]
cwd: ${CWD}
publish_python:
cmd: ["hatch", "publish"]
cwd: ${CWD}
# 聚合 job (方向 B: 有 needs 无 cmd/fn)
ba:
needs: [b, bc]
bump:
needs: [bumpversion]
cov:
needs: [test_coverage]
tc:
needs: [c, pyrefly_check, lint]
p:
needs: [c, git_push, git_push_tags]
pb:
needs: [twine_publish, publish_python]
-13
View File
@@ -1,13 +0,0 @@
# reseticoncache - 重置 Windows 图标缓存
# 用法
# pf reseticon
# 说明
# 杀掉 explorer → 删除 IconCache.db → 删除 iconcache* → 重启 explorer
# 仅在 Windows 上有效, 非 Windows 平台打印提示并跳过
strategy: sequential
cli:
description: "重置 Windows 图标缓存"
usage: "pf reseticon"
jobs:
reset:
fn: reset_icon_cache_run
-34
View File
@@ -1,34 +0,0 @@
# screenshot - 截图工具
# 用法:
# pf screenshot full
# pf screenshot area --filename custom.png
strategy: thread
variables:
FILENAME: null
cli:
description: "Screenshot - 截图工具"
usage: "pf screenshot <command> [options]"
subcommands:
full:
help: "全屏截图"
options:
- name: FILENAME
flag: "--filename"
type: str
help: "文件名"
area:
help: "区域截图"
options:
- name: FILENAME
flag: "--filename"
type: str
help: "文件名"
jobs:
full:
fn: take_screenshot_full
kwargs:
filename: "${FILENAME}"
area:
fn: take_screenshot_area
kwargs:
filename: "${FILENAME}"
-49
View File
@@ -1,49 +0,0 @@
# sshcopyid - SSH 密钥部署工具
# 用法:
# pf sshcopyid hostname username password
# pf sshcopyid server user pass --port 2222
strategy: thread
variables:
HOSTNAME: ""
USERNAME: ""
PASSWORD: ""
PORT: 22
KEYPATH: "~/.ssh/id_rsa.pub"
TIMEOUT: 30
cli:
description: "SSHCopyID - SSH 密钥部署工具"
usage: "pf sshcopyid <hostname> <username> <password> [options]"
positional:
- name: HOSTNAME
type: str
help: "远程服务器主机名或 IP 地址"
- name: USERNAME
type: str
help: "远程服务器用户名"
- name: PASSWORD
type: str
help: "远程服务器密码"
options:
- name: PORT
flag: "--port"
type: int
default: 22
help: "SSH 端口 (默认: 22)"
- name: KEYPATH
flag: "--keypath"
type: str
default: "~/.ssh/id_rsa.pub"
help: "公钥文件路径"
- name: TIMEOUT
flag: "--timeout"
type: int
default: 30
help: "SSH 操作超时秒数 (默认: 30)"
jobs:
deploy:
fn: ssh_copy_id
args: ["${HOSTNAME}", "${USERNAME}", "${PASSWORD}"]
kwargs:
port: ${PORT}
keypath: "${KEYPATH}"
timeout: ${TIMEOUT}
+54 -5
View File
@@ -16,13 +16,14 @@ DAG 库中泛滥的样板包装器。
from __future__ import annotations
import inspect
from collections.abc import Mapping
from functools import lru_cache
from typing import Any, Mapping
from typing import Any
from .errors import InjectionError
from .task import Context, TaskSpec
__all__ = ["Context", "_is_context_annotation", "build_call_args", "describe_injection"]
__all__ = ["Context", "build_call_args", "describe_injection", "is_context_annotation"]
@lru_cache(maxsize=1024)
@@ -43,7 +44,29 @@ def _signature(fn: Any) -> inspect.Signature:
return inspect.signature(fn)
def _is_context_annotation(annotation: Any) -> bool:
@lru_cache(maxsize=1024)
def _fn_no_dep_injection(fn: Any) -> tuple[tuple[str, ...] | None, str | None]:
"""预计算 fn 在无依赖/无静态参数时的注入计划。
返回 ``(context_params, error_param)``
- ``context_params``: Context 标注参数名元组;``None`` 表示存在必填参数需走慢路径。
- ``error_param``: 第一个无默认值且非 Context 标注的参数名(用于错误信息);``None`` 表示无此类参数。
"""
sig = inspect.signature(fn)
context_params: list[str] = []
for pname, param in sig.parameters.items():
if is_context_annotation(param.annotation):
context_params.append(pname)
continue
if param.default is inspect.Parameter.empty and param.kind not in (
inspect.Parameter.VAR_POSITIONAL,
inspect.Parameter.VAR_KEYWORD,
):
return None, pname
return tuple(context_params), None
def is_context_annotation(annotation: Any) -> bool:
"""判断参数标注是否为(或指向)``Context``。"""
if annotation is Context:
return True
@@ -53,6 +76,28 @@ def _is_context_annotation(annotation: Any) -> bool:
return name in ("Context", "Mapping")
def _try_fast_path(spec: TaskSpec[Any]) -> tuple[tuple[Any, ...], dict[str, Any]] | None:
"""尝试快速路径:cmd 无参任务或 fn 无依赖任务。
返回 ``None`` 表示快速路径不适用,需走慢路径。
"""
# 快速路径 1cmd 任务(无 fn)的 effective_fn 是无参闭包。
if spec.fn is None and spec.cmd is not None and not spec.args and not spec.kwargs:
return (), {}
# 快速路径 2:fn 任务无依赖、无静态 args/kwargs 时,跳过 dep_context/collisions/leftover 构建。
if not spec.depends_on and not spec.soft_depends_on and not spec.args and not spec.kwargs:
fn = spec.effective_fn
context_params, error_param = _fn_no_dep_injection(fn)
if error_param is not None:
raise InjectionError(
spec.name,
f"parameter {error_param!r} has no dependency, static value, or default.",
)
assert context_params is not None # error_param is None ⟺ context_params 非 None
return (), {p: {} for p in context_params}
return None
def build_call_args(
spec: TaskSpec[Any],
context: Mapping[str, Any],
@@ -62,6 +107,10 @@ def build_call_args(
``context`` 必须已包含所有硬依赖与软依赖的结果(软依赖被跳过时由
执行器填入 :attr:`TaskSpec.defaults` 中的默认值)。
"""
fast = _try_fast_path(spec)
if fast is not None:
return fast
fn = spec.effective_fn
sig = _signature(fn)
params = sig.parameters
@@ -100,7 +149,7 @@ def build_call_args(
if pname in args_filled:
continue
if _is_context_annotation(param.annotation):
if is_context_annotation(param.annotation):
injected_kwargs[pname] = dep_context
continue
@@ -151,7 +200,7 @@ def describe_injection(spec: TaskSpec[Any]) -> str:
if pname in args_filled:
idx = positional_params.index(pname)
parts.append(f"{pname}={spec.args[idx]!r}")
elif _is_context_annotation(param.annotation):
elif is_context_annotation(param.annotation):
parts.append(f"{pname}=<Context>")
elif pname in all_deps:
tag = "soft" if pname in spec.soft_depends_on else "dep"
+324
View File
@@ -0,0 +1,324 @@
"""失败诊断:从 :class:`RunReport` 提取结构化的根因分析。
本模块不依赖 :class:`Graph`,仅通过 ``TaskResult.spec.depends_on`` 重建
依赖关系,因此可对反序列化后的报告进行诊断。
核心概念
--------
* **根因任务(root cause**FAILED 状态且其所有硬依赖都是 SUCCESS
(或无依赖)的任务。它是依赖链中"最先出问题"的环节。
* **依赖链(dependency chain)**:从根因到某个失败任务的路径。
* **相似失败聚类**:按 ``(异常类型, 消息前缀)`` 聚类,发现批量失败模式。
* **根因提示**:识别常见异常类型(FileNotFoundError/ImportError 等),
给出可操作建议。
"""
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from .report import RunReport
from .task import TaskStatus
@dataclass(frozen=True)
class FailureCluster:
"""相似失败的聚类。
属性
----
pattern:
异常类型名 + 消息前缀(如 ``"ValueError: invalid input"``)。
tasks:
同类失败的任务名列表。
sample_error:
代表性错误消息(``repr(error)``)。
"""
pattern: str
tasks: list[str]
sample_error: str
@dataclass(frozen=True)
class DependencyChain:
"""失败任务的依赖链分析。
属性
----
failed_task:
失败的任务名。
root_cause:
依赖链中最先失败的任务(无失败依赖的失败任务)。
``failed_task`` 自身即为根因时与 ``failed_task`` 相同。
chain:
从 ``root_cause`` 到 ``failed_task`` 的任务名路径(含两端)。
broken_at:
链中第一个非 SUCCESS 的任务(通常等于 ``root_cause``)。
"""
failed_task: str
root_cause: str | None
chain: list[str]
broken_at: str | None
@dataclass(frozen=True)
class DiagnosticReport:
"""运行失败的诊断报告。
属性
----
failed_tasks:
所有 FAILED 状态的任务名。
skipped_due_to_failure:
因上游失败而 SKIPPED 的任务名(reason 含"失败""取消")。
root_causes:
根因任务列表(FAILED 且所有硬依赖 SUCCESS)。
dependency_chains:
每个失败任务的依赖链分析。
failure_clusters:
相似失败聚类。
hints:
针对根因异常的可操作建议。
"""
failed_tasks: list[str]
skipped_due_to_failure: list[str]
root_causes: list[str]
dependency_chains: list[DependencyChain]
failure_clusters: list[FailureCluster]
hints: list[str] = field(default_factory=list)
def describe(self) -> str:
"""人类可读的多行诊断报告。"""
lines: list[str] = ["=== 失败诊断报告 ==="]
# 根因
if self.root_causes:
lines.append(f"\n根因任务 ({len(self.root_causes)}):")
for rc in self.root_causes:
lines.append(f" - {rc}")
else:
lines.append("\n根因任务: 无法识别(可能所有失败任务的依赖均未成功)")
# 依赖链
if self.dependency_chains:
lines.append(f"\n依赖链 ({len(self.dependency_chains)}):")
for dc in self.dependency_chains:
chain_str = " -> ".join(dc.chain) if dc.chain else dc.failed_task
lines.append(f" {dc.failed_task}: {chain_str}")
# 相似失败聚类
if self.failure_clusters:
lines.append(f"\n相似失败聚类 ({len(self.failure_clusters)}):")
for cluster in self.failure_clusters:
tasks_str = ", ".join(cluster.tasks)
lines.append(f" [{cluster.pattern}] ({len(cluster.tasks)} 任务): {tasks_str}")
# 跳过的任务
if self.skipped_due_to_failure:
lines.append(f"\n因失败而跳过的任务 ({len(self.skipped_due_to_failure)}):")
for name in self.skipped_due_to_failure:
lines.append(f" - {name}")
# 提示
if self.hints:
lines.append("\n建议:")
for hint in self.hints:
lines.append(f" * {hint}")
return "\n".join(lines)
# ---------------------------------------------------------------------- #
# 内部辅助
# ---------------------------------------------------------------------- #
_HINT_MAP: dict[str, str] = {
"FileNotFoundError": "文件不存在,检查路径配置与工作目录",
"ImportError": "模块导入失败,检查依赖是否安装(pip install / uv add",
"ModuleNotFoundError": "模块不存在,检查依赖安装或 PYTHONPATH",
"TimeoutError": "任务超时,考虑增加 timeout 配置或优化任务性能",
"TaskTimeoutError": "任务超时,考虑增加 timeout 配置或优化任务性能",
"ConnectionError": "网络连接失败,检查目标地址可达性与防火墙设置",
"PermissionError": "权限不足,检查文件/目录权限或以管理员身份运行",
"KeyError": "键缺失,检查上下文注入参数名是否匹配任务签名",
"InjectionError": "上下文注入失败,检查任务签名参数与上游任务名",
"StorageError": "状态后端错误,检查存储路径权限与磁盘空间",
"CycleError": "依赖图存在环,检查 depends_on 是否形成循环",
"MemoryError": "内存不足,考虑分批处理或减少并发数",
"OSError": "系统错误,检查文件描述符/磁盘空间/权限",
}
def _classify_error(error: BaseException | None) -> tuple[str, str]:
"""返回 ``(异常类型名, 消息前缀)`` 用于聚类。"""
if error is None:
return ("Unknown", "")
type_name = type(error).__name__
msg = str(error)
prefix = msg[:50] if len(msg) > 50 else msg
return (type_name, prefix)
def _find_root_cause(
failed_task: str,
report: RunReport,
) -> tuple[str | None, list[str]]:
"""回溯 ``failed_task`` 的依赖链,返回 (根因任务, 路径)。
路径从根因到 ``failed_task``(含两端)。根因为 FAILED 且所有硬依赖
非 FAILED 的任务;若 ``failed_task`` 自身满足条件,则为其自身。
"""
# BFS 反向遍历,找到链中"最深处"的根因
visited: set[str] = set()
# 用 DFS 记录路径:path 始终以当前 task 开头,向根因方向延伸
def _trace(task: str, path: list[str]) -> tuple[str | None, list[str]]:
if task in visited: # 防止循环依赖(diagnose 不依赖 Graph 校验,results 可能有环)
return (None, path)
visited.add(task)
result = report.results[task] # _trace 只接收 failed_tasks 或 failed_deps,均保证在 results 中
# 检查此任务是否为根因:FAILED 且所有硬依赖非 FAILED
deps = result.spec.depends_on
failed_deps = [d for d in deps if d in report.results and report.results[d].status == TaskStatus.FAILED]
if result.status == TaskStatus.FAILED and not failed_deps:
return (task, path)
# 递归向上找
for dep in failed_deps:
root, new_path = _trace(dep, [dep, *path])
if root is not None:
return (root, new_path)
# 递归未找到明确根因时,当前任务作为断点返回
return (task, path)
return _trace(failed_task, [failed_task])
def _generate_hints(report: RunReport, root_causes: list[str]) -> list[str]:
"""针对根因异常生成可操作建议。"""
hints: list[str] = []
seen_types: set[str] = set()
for rc_name in root_causes:
result = report.results.get(rc_name)
if result is None or result.error is None:
continue
type_name = type(result.error).__name__
if type_name in seen_types:
continue
seen_types.add(type_name)
# 精确匹配
if type_name in _HINT_MAP:
hints.append(f"{rc_name}: {_HINT_MAP[type_name]}")
continue
# 父类匹配
for parent in type(result.error).__mro__:
parent_name = parent.__name__
if parent_name in _HINT_MAP:
hints.append(f"{rc_name}: {_HINT_MAP[parent_name]}")
break
return hints
# ---------------------------------------------------------------------- #
# 公共 API
# ---------------------------------------------------------------------- #
def diagnose(report: RunReport) -> DiagnosticReport | None:
"""生成失败诊断报告。
运行成功(``report.success`` 为 ``True``)时返回 ``None``
失败时返回 :class:`DiagnosticReport`,包含根因分析、依赖链回溯、
相似失败聚类与可操作建议。
本函数仅依赖 ``report.results`` 中的 ``spec.depends_on`` 重建依赖
关系,不依赖 :class:`Graph` 对象,因此可对反序列化后的报告进行诊断。
"""
if report.success:
return None
failed_tasks = report.failed_tasks()
if not failed_tasks:
# success=False 但无 FAILED 任务(可能是用户中断等情况)
return DiagnosticReport(
failed_tasks=[],
skipped_due_to_failure=report.skipped_tasks(),
root_causes=[],
dependency_chains=[],
failure_clusters=[],
hints=["运行未成功但无失败任务,可能是被取消或中断"],
)
# 识别根因:FAILED 且所有硬依赖非 FAILED
root_causes: list[str] = []
for name in failed_tasks:
result = report.results[name]
failed_deps = [
d for d in result.spec.depends_on if d in report.results and report.results[d].status == TaskStatus.FAILED
]
if not failed_deps:
root_causes.append(name)
# 依赖链回溯
chains: list[DependencyChain] = []
for failed in failed_tasks:
root, path = _find_root_cause(failed, report)
# broken_at 是路径中第一个非 SUCCESS 的任务
broken_at: str | None = None
for task in path:
r = report.results.get(task)
if r is not None and r.status != TaskStatus.SUCCESS:
broken_at = task
break
chains.append(
DependencyChain(
failed_task=failed,
root_cause=root,
chain=path,
broken_at=broken_at,
)
)
# 相似失败聚类
clusters_map: dict[tuple[str, str], list[str]] = defaultdict(list)
sample_errors: dict[tuple[str, str], str] = {}
for name in failed_tasks:
result = report.results[name]
key = _classify_error(result.error)
clusters_map[key].append(name)
if key not in sample_errors:
sample_errors[key] = repr(result.error) if result.error else ""
clusters: list[FailureCluster] = [
FailureCluster(
pattern=f"{type_name}: {prefix}" if prefix else type_name,
tasks=sorted(tasks),
sample_error=sample_errors.get((type_name, prefix), ""),
)
for (type_name, prefix), tasks in sorted(clusters_map.items(), key=lambda x: -len(x[1]))
]
# 因失败而跳过的任务(reason 包含"失败"或"取消"或"skip"
skipped_due_to_failure: list[str] = []
for name, result in report.results.items():
if result.status == TaskStatus.SKIPPED:
reason = (result.reason or "").lower()
if any(kw in reason for kw in ("失败", "取消", "fail", "cancel", "abort")):
skipped_due_to_failure.append(name)
# 根因提示
hints = _generate_hints(report, root_causes)
return DiagnosticReport(
failed_tasks=failed_tasks,
skipped_due_to_failure=skipped_due_to_failure,
root_causes=root_causes,
dependency_chains=chains,
failure_clusters=clusters,
hints=hints,
)
+9 -2
View File
@@ -6,7 +6,11 @@
from __future__ import annotations
from typing import Any, Iterable
from collections.abc import Iterable
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .report import RunReport
class PyFlowXError(Exception):
@@ -47,7 +51,8 @@ class TaskFailedError(PyFlowXError):
"""任务耗尽所有重试后仍失败时抛出。
原始异常保留在 :attr:`__cause__` 上,同时通过 :attr:`cause` 暴露,
便于用户代码访问。
便于用户代码访问。``report`` 字段(可选)携带失败时的 :class:`RunReport`
便于调用方生成诊断报告。
"""
def __init__(
@@ -56,6 +61,7 @@ class TaskFailedError(PyFlowXError):
cause: BaseException,
attempts: int,
layer: int | None = None,
report: RunReport | None = None,
) -> None:
location = f" (layer {layer})" if layer is not None else ""
super().__init__(f"Task '{task}' failed after {attempts} attempt(s){location}: {cause}")
@@ -63,6 +69,7 @@ class TaskFailedError(PyFlowXError):
self.cause = cause
self.attempts = attempts
self.layer = layer
self.report = report
class TaskTimeoutError(PyFlowXError):
View File
-58
View File
@@ -1,58 +0,0 @@
"""Example 3: async aggregation with static args and Context injection.
Shows:
* async task functions executed with strategy="async".
* static positional args (TaskSpec.args) for parameterised tasks.
* Context annotation to receive the full upstream result mapping.
* on_event callback for real-time progress.
"""
from __future__ import annotations
import asyncio
from typing import Any
import pyflowx as px
async def fetch_user(uid: int) -> dict[str, Any]:
await asyncio.sleep(0.2)
return {"id": uid, "name": f"User{uid}"}
async def fetch_posts(uid: int) -> list[int]:
await asyncio.sleep(0.2)
return [uid, uid + 1]
# Context annotation → receives the full mapping of upstream results.
def aggregate(ctx: px.Context) -> dict[str, Any]:
return dict(ctx)
def main() -> None:
graph = px.Graph.from_specs(
[
# Static positional args parameterise the same function twice.
px.TaskSpec("fetch_user", fetch_user, args=(1,)),
px.TaskSpec("fetch_posts", fetch_posts, args=(1,)),
px.TaskSpec("aggregate", aggregate, depends_on=("fetch_user", "fetch_posts")),
]
)
print("=== Dry run ===")
_ = px.run(graph, strategy="async", dry_run=True)
events: list[px.TaskEvent] = []
print("\n=== Async execution ===")
report = px.run(graph, strategy="async", on_event=events.append)
for ev in events:
print(f" event: {ev.task} -> {ev.status.value}")
print(f"\naggregate = {report['aggregate']}")
print(report.describe())
if __name__ == "__main__":
main()
-79
View File
@@ -1,79 +0,0 @@
"""Example 1: ETL pipeline (sequential strategy).
Demonstrates the core PyFlowX workflow:
* Define tasks as plain functions.
* Declare the DAG with a list of TaskSpec.
* Parameter names == dependency names → automatic context injection,
no wrappers needed (contrast with flowweaver's get_task_result boilerplate).
* dry_run to preview, then execute and read typed results from RunReport.
"""
from __future__ import annotations
from typing import Any
import pyflowx as px
# --- task functions: pure, testable, no framework coupling ------------- #
def extract_customers() -> list[dict[str, Any]]:
return [
{"id": "C001", "name": "Alice"},
{"id": "C002", "name": "Bob"},
]
def extract_orders() -> list[dict[str, Any]]:
return [
{"id": "O001", "customer_id": "C001", "amount": 150.0},
{"id": "O002", "customer_id": "C002", "amount": 200.5},
]
# Parameter names match dependency names → automatic injection.
def transform(
extract_customers: list[dict[str, Any]],
extract_orders: list[dict[str, Any]],
) -> list[dict[str, Any]]:
cmap = {c["id"]: c for c in extract_customers}
return [{**o, "customer_name": cmap[o["customer_id"]]["name"]} for o in extract_orders if o["customer_id"] in cmap]
def load(transform: list[dict[str, Any]]) -> int:
print(f" loaded {len(transform)} records")
return len(transform)
def main() -> None:
graph = px.Graph.from_specs(
[
px.TaskSpec("extract_customers", extract_customers, tags=("extract",)),
px.TaskSpec("extract_orders", extract_orders, tags=("extract",)),
px.TaskSpec(
"transform",
transform,
depends_on=("extract_customers", "extract_orders"),
tags=("transform",),
),
px.TaskSpec(
"load", load, depends_on=("transform",), retry=px.RetryPolicy(max_attempts=1, delay=1.0), tags=("load",)
),
]
)
print("=== Execution plan ===")
print(graph.describe())
print("\n=== Dry run (no execution) ===")
_ = px.run(graph, strategy="sequential", dry_run=True)
print("\n=== Sequential execution ===")
report = px.run(graph, strategy="sequential")
print(report.describe())
print(f"\nload result = {report['load']}")
print(f"summary = {report.summary()}")
if __name__ == "__main__":
main()
-59
View File
@@ -1,59 +0,0 @@
"""Example 2: parallel execution (thread strategy).
Same DAG run with sequential vs. thread strategy to show layer-internal
parallelism. Tasks within a layer run concurrently; layers are barriers.
Layer 1: [fetch_a, fetch_b] (parallel)
Layer 2: [merge] (waits for both)
"""
from __future__ import annotations
import time
import pyflowx as px
def fetch_a() -> str:
time.sleep(0.5)
return "a"
def fetch_b() -> str:
time.sleep(0.5)
return "b"
def merge(fetch_a: str, fetch_b: str) -> str:
return fetch_a + fetch_b
def main() -> None:
graph = px.Graph.from_specs(
[
px.TaskSpec("fetch_a", fetch_a),
px.TaskSpec("fetch_b", fetch_b),
px.TaskSpec("merge", merge, depends_on=("fetch_a", "fetch_b")),
]
)
print("=== Mermaid diagram ===")
print(graph.to_mermaid("LR"))
print("\n=== Sequential (expect ~1.0s) ===")
start = time.time()
report_seq = px.run(graph, strategy="sequential")
t_seq = time.time() - start
print(f" result={report_seq['merge']} time={t_seq:.2f}s")
print("\n=== Threaded (expect ~0.5s) ===")
start = time.time()
report_thr = px.run(graph, strategy="thread", max_workers=2)
t_thr = time.time() - start
print(f" result={report_thr['merge']} time={t_thr:.2f}s")
print(f"\nspeedup = {t_seq / t_thr:.2f}x")
if __name__ == "__main__":
main()
+651 -97
View File
File diff suppressed because it is too large Load Diff
+678
View File
@@ -0,0 +1,678 @@
"""文件操作底层 API —— 纯函数式, 供 DAG 组合调用.
与 ``ops/files/`` 下的 CLI 工具(pdftool/imagetool/filedate 等)分层:
本模块函数**返回数据**(不打印),可被 :func:`px.task` / :class:`px.TaskSpec`
包装后在 DAG 中通过 ``outputs`` 声明 + :meth:`RunReport.output_of` 提取,
或通过参数名注入传递给下游任务。
四类能力
--------
* **查找遍历**:func:`find` / :func:`glob` / :func:`walk_files`
* **读写**:func:`read_text` / :func:`read_bytes` / :func:`write_text`
/ :func:`write_bytes` / :func:`append_text`
* **复制移动删除**:func:`copy` / :func:`move` / :func:`delete`
/ :func:`copy_tree`
* **元数据校验**:func:`size` / :func:`mtime` / :func:`exists`
/ :func:`is_file` / :func:`is_dir` / :func:`sha256` / :func:`stem`
/ :func:`suffix`
设计原则
--------
- 纯函数, 返回数据, 无副作用优先(写操作返回目标路径或写入字节数)
- 仅依赖标准库(pathlib / hashlib / shutil
- 完整类型注解 + 中文 docstring
- EAFP 优于 LBYL:文件不存在等错误由标准库异常表达
DAG 组合示例
------------
>>> import pyflowx as px
>>> from pyflowx.fileops import find, read_text
>>> @px.task
... def find_logs() -> list[Path]: return find("/var/log", "*.log")
>>> @px.task
... def read(find_logs: list[Path]) -> list[str]: return [read_text(p) for p in find_logs]
>>> graph = px.graph(find_logs, read) # read 自动依赖 find_logs
>>> report = px.run(graph)
>>> logs = report["find_logs"]
"""
from __future__ import annotations
import hashlib
import shutil
from collections.abc import Iterator
from pathlib import Path
__all__ = [
"append_text",
"copy",
"copy_tree",
"delete",
"exists",
"find",
"glob",
"is_dir",
"is_file",
"move",
"mtime",
"read_bytes",
"read_text",
"sha256",
"size",
"stem",
"suffix",
"walk_files",
"write_bytes",
"write_text",
]
# ---------------------------------------------------------------------- #
# 查找与遍历
# ---------------------------------------------------------------------- #
def find(
root: str | Path,
pattern: str = "*",
*,
recursive: bool = True,
max_depth: int | None = None,
only_files: bool = False,
only_dirs: bool = False,
) -> list[Path]:
"""查找匹配 ``pattern`` 的路径.
Parameters
----------
root : str | Path
查找根目录
pattern : str
glob 模式 (默认 ``"*"`` 匹配全部), 如 ``"*.py"`` / ``"test_*.txt"``
recursive : bool
是否递归子目录 (默认 True)
max_depth : int | None
递归最大深度 (None 表示无限制; 0 表示仅 root 自身用 pattern 匹配).
仅 ``recursive=True`` 时生效. 深度语义: 1 = root 直接子项,
2 = root 孙项, 以此类推.
only_files : bool
仅返回文件 (默认 False)
only_dirs : bool
仅返回目录 (默认 False); ``only_files`` 与 ``only_dirs`` 同时为 True
时返回空列表
Returns
-------
list[Path]
匹配的路径列表, 按路径名字典序排序
Examples
--------
>>> find("src", "*.py") # 递归查找所有 .py 文件
>>> find("tests", "test_*.py", recursive=False) # 仅 tests 顶层
>>> find(".", max_depth=1) # 仅当前目录直接子项
"""
root_path = Path(root)
if not root_path.is_dir():
raise FileNotFoundError(f"查找根目录不存在或非目录: {root_path}")
if only_files and only_dirs:
return []
if recursive:
if max_depth is not None:
matches = _glob_with_depth(root_path, pattern, max_depth)
else:
matches = list(root_path.rglob(pattern))
else:
matches = list(root_path.glob(pattern))
result: list[Path] = []
seen: set[Path] = set()
for p in matches:
if p in seen:
continue
seen.add(p)
if only_files and not p.is_file():
continue
if only_dirs and not p.is_dir():
continue
result.append(p)
result.sort()
return result
def _glob_with_depth(root: Path, pattern: str, max_depth: int) -> list[Path]:
"""在 ``max_depth`` 深度内递归 glob 匹配."""
if max_depth < 0:
return []
matches = list(root.glob(pattern))
if max_depth == 0:
return matches
for child in root.iterdir():
if child.is_dir():
matches.extend(_glob_with_depth(child, pattern, max_depth - 1))
return matches
def glob(pattern: str, root: str | Path | None = None) -> list[Path]:
"""简化 glob 接口.
与 :func:`find` 区别: ``pattern`` 可包含路径分隔符 (如
``"src/**/*.py"``), ``root`` 默认为当前目录.
Parameters
----------
pattern : str
glob 模式, 支持递归 ``**``
root : str | Path | None
根目录 (None 时用 ``Path(".")``)
Returns
-------
list[Path]
匹配的路径列表, 按路径名字典序排序
"""
base = Path(root) if root is not None else Path()
return sorted(base.glob(pattern))
def walk_files(root: str | Path, *, max_depth: int | None = None) -> Iterator[Path]:
"""生成器: 深度优先遍历 ``root`` 下所有文件.
与 :func:`find` 区别: 返回迭代器 (惰性), 且**仅返回文件** (不返回目录),
不做 pattern 匹配.
Parameters
----------
root : str | Path
遍历根目录
max_depth : int | None
最大深度 (None 无限制; 0 表示仅 root 直接子文件)
Yields
------
Path
文件路径
"""
root_path = Path(root)
if not root_path.is_dir():
raise FileNotFoundError(f"遍历根目录不存在或非目录: {root_path}")
def _walk(path: Path, depth: int) -> Iterator[Path]:
if max_depth is not None and depth > max_depth:
return
for child in sorted(path.iterdir()):
if child.is_file():
yield child
elif child.is_dir():
yield from _walk(child, depth + 1)
yield from _walk(root_path, 0)
# ---------------------------------------------------------------------- #
# 读写
# ---------------------------------------------------------------------- #
def read_text(path: str | Path, encoding: str = "utf-8") -> str:
"""读取文本文件.
Parameters
----------
path : str | Path
文件路径
encoding : str
文本编码 (默认 ``"utf-8"``)
Returns
-------
str
文件文本内容
Raises
------
FileNotFoundError
文件不存在
"""
return Path(path).read_text(encoding=encoding)
def read_bytes(path: str | Path) -> bytes:
"""读取二进制文件.
Parameters
----------
path : str | Path
文件路径
Returns
-------
bytes
文件字节内容
Raises
------
FileNotFoundError
文件不存在
"""
return Path(path).read_bytes()
def write_text(path: str | Path, content: str, encoding: str = "utf-8") -> int:
"""写入文本文件.
父目录不存在时自动创建.
Parameters
----------
path : str | Path
文件路径
content : str
文本内容
encoding : str
文本编码 (默认 ``"utf-8"``)
Returns
-------
int
写入字节数
"""
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
data = content.encode(encoding)
p.write_bytes(data)
return len(data)
def write_bytes(path: str | Path, content: bytes) -> int:
"""写入二进制文件.
父目录不存在时自动创建.
Parameters
----------
path : str | Path
文件路径
content : bytes
字节内容
Returns
-------
int
写入字节数
"""
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(content)
return len(content)
def append_text(path: str | Path, content: str, encoding: str = "utf-8") -> int:
"""追加文本到文件.
父目录不存在时自动创建; 文件不存在时创建新文件.
Parameters
----------
path : str | Path
文件路径
content : str
追加文本内容
encoding : str
文本编码 (默认 ``"utf-8"``)
Returns
-------
int
追加字节数
"""
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
data = content.encode(encoding)
with open(p, "ab") as f:
f.write(data)
return len(data)
# ---------------------------------------------------------------------- #
# 复制 / 移动 / 删除
# ---------------------------------------------------------------------- #
def copy(src: str | Path, dst: str | Path, *, overwrite: bool = False) -> Path:
"""复制文件或目录树.
语义:
- ``overwrite=False`` 且 ``dst`` 是目录: ``src`` 复制到 ``dst / src.name``
- ``overwrite=False`` 且 ``dst`` 是已存在文件: 抛 ``FileExistsError``
- ``overwrite=True`` 且 ``dst`` 存在: 先删除 ``dst``, 再把 ``src`` 复制到 ``dst`` 路径
``src`` 为目录时递归复制 (等价于 :func:`copy_tree`).
Parameters
----------
src : str | Path
源路径
dst : str | Path
目标路径
overwrite : bool
目标已存在时是否覆盖 (默认 False; False 时目标存在抛 ``FileExistsError``)
Returns
-------
Path
实际目标路径
Raises
------
FileNotFoundError
``src`` 不存在
FileExistsError
``overwrite=False`` 且目标已存在 (且非目录)
"""
src_path = Path(src)
dst_path = Path(dst)
if not src_path.exists():
raise FileNotFoundError(f"源路径不存在: {src_path}")
if overwrite and dst_path.exists():
if dst_path.is_dir():
shutil.rmtree(dst_path)
else:
dst_path.unlink()
elif not overwrite and dst_path.is_dir():
dst_path = dst_path / src_path.name
if dst_path.exists() and not overwrite:
raise FileExistsError(f"目标已存在 (overwrite=False): {dst_path}")
dst_path.parent.mkdir(parents=True, exist_ok=True)
if src_path.is_dir():
shutil.copytree(src_path, dst_path)
else:
shutil.copy2(src_path, dst_path)
return dst_path
def move(src: str | Path, dst: str | Path, *, overwrite: bool = False) -> Path:
"""移动文件或目录.
跨文件系统时自动 fallback 到 复制+删除.
语义:
- ``overwrite=False`` 且 ``dst`` 是目录: ``src`` 移入 ``dst / src.name``
- ``overwrite=False`` 且 ``dst`` 是已存在文件: 抛 ``FileExistsError``
- ``overwrite=True`` 且 ``dst`` 存在: 先删除 ``dst``, 再把 ``src`` 移到 ``dst`` 路径
Parameters
----------
src : str | Path
源路径
dst : str | Path
目标路径
overwrite : bool
目标已存在时是否覆盖 (默认 False)
Returns
-------
Path
实际目标路径
Raises
------
FileNotFoundError
``src`` 不存在
FileExistsError
``overwrite=False`` 且目标已存在 (且非目录)
"""
src_path = Path(src)
dst_path = Path(dst)
if not src_path.exists():
raise FileNotFoundError(f"源路径不存在: {src_path}")
if overwrite and dst_path.exists():
if dst_path.is_dir():
shutil.rmtree(dst_path)
else:
dst_path.unlink()
elif not overwrite and dst_path.is_dir():
dst_path = dst_path / src_path.name
if dst_path.exists() and not overwrite:
raise FileExistsError(f"目标已存在 (overwrite=False): {dst_path}")
dst_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src_path), str(dst_path))
return dst_path
def delete(path: str | Path, *, missing_ok: bool = False) -> bool:
"""删除文件或目录树.
Parameters
----------
path : str | Path
待删路径
missing_ok : bool
路径不存在时是否静默返回 False (默认 False; False 时抛 ``FileNotFoundError``)
Returns
-------
bool
是否实际删除 (``missing_ok=True`` 且不存在时返回 False)
Raises
------
FileNotFoundError
``missing_ok=False`` 且路径不存在
"""
p = Path(path)
if not p.exists():
if missing_ok:
return False
raise FileNotFoundError(f"路径不存在: {p}")
if p.is_dir():
shutil.rmtree(p)
else:
p.unlink()
return True
def copy_tree(src: str | Path, dst: str | Path, *, overwrite: bool = False) -> list[Path]:
"""递归复制目录树, 返回所有已复制文件路径.
Parameters
----------
src : str | Path
源目录
dst : str | Path
目标目录 (不存在则创建)
overwrite : bool
目标已存在时是否覆盖整个目录 (默认 False)
Returns
-------
list[Path]
已复制的文件路径列表 (相对于 ``dst``, 按字典序)
Raises
------
FileNotFoundError
``src`` 不存在或非目录
FileExistsError
``overwrite=False`` 且 ``dst`` 已存在
"""
src_path = Path(src)
dst_path = Path(dst)
if not src_path.is_dir():
raise FileNotFoundError(f"源目录不存在或非目录: {src_path}")
if dst_path.exists() and not overwrite:
raise FileExistsError(f"目标已存在 (overwrite=False): {dst_path}")
if dst_path.exists() and overwrite:
shutil.rmtree(dst_path)
shutil.copytree(src_path, dst_path)
return sorted(p for p in dst_path.rglob("*") if p.is_file())
# ---------------------------------------------------------------------- #
# 元数据与校验
# ---------------------------------------------------------------------- #
def size(path: str | Path) -> int:
"""返回文件大小 (字节数).
Parameters
----------
path : str | Path
文件路径
Returns
-------
int
字节数
Raises
------
FileNotFoundError
文件不存在
"""
return Path(path).stat().st_size
def mtime(path: str | Path) -> float:
"""返回文件修改时间 (Unix 时间戳, 秒).
Parameters
----------
path : str | Path
文件路径
Returns
-------
float
修改时间戳
Raises
------
FileNotFoundError
文件不存在
"""
return Path(path).stat().st_mtime
def exists(path: str | Path) -> bool:
"""判断路径是否存在.
Parameters
----------
path : str | Path
路径
Returns
-------
bool
是否存在 (跟随符号链接)
"""
return Path(path).exists()
def is_file(path: str | Path) -> bool:
"""判断是否为常规文件.
Parameters
----------
path : str | Path
路径
Returns
-------
bool
是否为文件 (不存在返回 False)
"""
return Path(path).is_file()
def is_dir(path: str | Path) -> bool:
"""判断是否为目录.
Parameters
----------
path : str | Path
路径
Returns
-------
bool
是否为目录 (不存在返回 False)
"""
return Path(path).is_dir()
def sha256(path: str | Path, *, chunk_size: int = 65536) -> str:
"""计算文件 SHA256 哈希.
流式读取, 适用于大文件.
Parameters
----------
path : str | Path
文件路径
chunk_size : int
分块大小 (默认 64KB)
Returns
-------
str
十六进制哈希字符串
Raises
------
FileNotFoundError
文件不存在
"""
p = Path(path)
h = hashlib.sha256()
with open(p, "rb") as f:
while True:
chunk = f.read(chunk_size)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
def stem(path: str | Path) -> str:
"""返回文件名 (不含扩展名).
与 :attr:`pathlib.PurePath.stem` 一致: ``"a.txt"`` → ``"a"``,
``"archive.tar.gz"`` → ``"archive.tar"``.
Parameters
----------
path : str | Path
路径
Returns
-------
str
文件名主体
"""
return Path(path).stem
def suffix(path: str | Path) -> str:
"""返回扩展名 (含点).
与 :attr:`pathlib.PurePath.suffix` 一致: ``"a.txt"`` → ``".txt"``.
Parameters
----------
path : str | Path
路径
Returns
-------
str
扩展名 (含前导点; 无扩展名返回空串)
"""
return Path(path).suffix
+296 -47
View File
@@ -1,7 +1,7 @@
"""DAG 构建、校验、分层与可视化。
使用标准库的 :mod:`graphlib`3.9+)或 :mod:`graphlib_backport`3.8
进行拓扑排序。图以增量方式构建并即时校验,使配置错误在构建时(而非执行时)快速失败。
使用自实现的 Kahn 算法进行拓扑排序。图以增量方式构建并即时校验,
使配置错误在构建时(而非执行时)快速失败。
支持:
* 图级默认值 :class:`GraphDefaults`TaskSpec 字段为 ``None`` 时回退。
@@ -18,25 +18,61 @@ __all__ = [
]
import inspect
import sys
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any, Callable, Iterable, Mapping, Sequence
from typing import Any
from .context import is_context_annotation
from .errors import CycleError, DuplicateTaskError, MissingDependencyError
from .task import Context, RetryPolicy, TaskSpec
if sys.version_info >= (3, 9): # pragma: no cover
import graphlib # pyright: ignore[reportUnreachable]
_TopologicalSorter = graphlib.TopologicalSorter
else: # pragma: no cover
import graphlib # type: ignore[import-untyped]
def _topological_layers(deps: Mapping[str, tuple[str, ...]]) -> tuple[list[list[str]], list[str] | None]:
"""Kahn 算法分层拓扑排序。
_TopologicalSorter = graphlib.TopologicalSorter # pragma: no cover
返回 ``(layers, cycle_nodes)``:无环时 ``cycle_nodes`` 为 ``None``
有环时为参与环的未处理节点列表(非精确环路径,仅指示存在环)。
直接用 dict + list 计算入度与反向邻接,在 diamond(1000) 图上约
0.45 万 ops/s(含 Graph 构造开销)。
"""
# 入度(依赖数)与反向邻接表
in_degree: dict[str, int] = {}
dependents: dict[str, list[str]] = {}
for name, d in deps.items():
in_degree[name] = len(d)
dependents.setdefault(name, [])
for name, d in deps.items():
for dep in d:
if dep in dependents:
dependents[dep].append(name)
# 初始层:入度为 0 的节点
current = sorted(name for name, deg in in_degree.items() if deg == 0)
layers: list[list[str]] = []
processed = 0
while current:
layers.append(current)
nxt: list[str] = []
for node in current:
for dependent in dependents[node]:
in_degree[dependent] -= 1
if in_degree[dependent] == 0:
nxt.append(dependent)
processed += 1
nxt.sort()
current = nxt
if processed < len(deps):
cycle_nodes = [name for name, deg in in_degree.items() if deg > 0]
return layers, cycle_nodes
return layers, None
@dataclass
@dataclass(slots=True)
class GraphDefaults:
"""图级默认值。TaskSpec 对应字段为 ``None`` 时回退到此处。
@@ -133,7 +169,7 @@ def _make_namespaced_fn(orig_fn: Any, ns: str, dep_names: set[str]) -> Any:
return wrapper
@dataclass
@dataclass(slots=True)
class Graph:
"""校验后的有向无环任务图。
@@ -157,11 +193,67 @@ class Graph:
# 在 specs / defaults 变更时失效。
_resolved_cache: dict[str, TaskSpec[Any]] = field(default_factory=dict)
# layers() 缓存:避免重复 run() 调用时重算拓扑排序。
# 在 specs 变更时失效。
_layers_cache: list[list[str]] | None = field(default=None)
# loop 组映射:原 spec 名 → 展开后的实例名列表。
# 用于依赖重写:其他任务引用 loop 原名时,自动展开为依赖所有实例。
_loop_groups: dict[str, list[str]] = field(default_factory=dict)
# 任务分组映射:组名 → 组内任务名元组。
# 用于依赖重写:引用组名等价于依赖组内所有任务。
_groups: dict[str, tuple[str, ...]] = field(default_factory=dict)
# ------------------------------------------------------------------ #
# 构建
# ------------------------------------------------------------------ #
def _auto_infer_deps_single(self, spec: TaskSpec[Any], all_names: set[str]) -> TaskSpec[Any]:
"""对 ``depends_on`` 为空的纯 fn 任务,从必需参数名自动推断依赖。
仅匹配 ``all_names`` 中任务名的必需参数(无默认值、非 Context 标注、
非 ``*args``/``**kwargs``)被加入 ``depends_on``。显式声明
``depends_on`` 的任务、cmd 任务、无 fn 的任务不受影响。
已在 ``soft_depends_on`` 中声明的参数名不会被重复加入 ``depends_on``。
Parameters
----------
spec:
待推断的 TaskSpec。
all_names:
当前可见的任务名集合(``from_specs`` 时为全部任务名,
``add`` 时为图中已有任务名)。
"""
if spec.depends_on or spec.cmd is not None or spec.fn is None:
return spec
try:
sig = inspect.signature(spec.fn)
except (TypeError, ValueError):
return spec
soft = set(spec.soft_depends_on)
inferred: list[str] = []
for pname, param in sig.parameters.items():
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
continue
if param.default is not inspect.Parameter.empty:
continue
if is_context_annotation(param.annotation):
continue
if pname in soft:
continue
if pname in all_names and pname != spec.name:
inferred.append(pname)
if inferred:
return replace(spec, depends_on=tuple(inferred))
return spec
def add(self, spec: TaskSpec[Any]) -> Graph:
"""注册一个任务 spec,并即时校验。返回 ``self`` 支持链式调用。"""
"""注册一个任务 spec,并即时校验。返回 ``self`` 支持链式调用。
对 ``depends_on`` 为空的纯 fn 任务,自动从必需参数名推断依赖
(匹配图中已有任务名的参数被加入 ``depends_on``)。
"""
spec = self._auto_infer_deps_single(spec, set(self.specs))
self._register(spec)
self._validate_references()
return self
@@ -188,13 +280,123 @@ class Graph:
prev_name = current.name
return self
def pipeline(self, *specs: TaskSpec[Any]) -> Graph:
"""创建数据流水线:每个任务的结果通过上下文注入传给下一个。
与 :meth:`chain` 行为一致(依赖关系相同),但语义上强调数据流:
前驱任务的返回值会自动注入后继任务的同名参数。
后继任务的 ``fn`` 参数名需匹配前驱任务名才能接收数据。
``outputs`` 字段声明的命名输出可通过
:meth:`RunReport.output_of` 查询。
Examples
--------
>>> graph = px.Graph().pipeline(extract_spec, transform_spec, load_spec)
>>> report = px.run(graph)
>>> report.output_of("extract", "items")
"""
return self.chain(*specs)
def group(self, name: str, tasks: Sequence[str]) -> Graph:
"""声明任务分组,引用组名等价于依赖组内所有任务。
组名必须不与任何已注册任务名冲突。组内任务必须已注册(或在本批
``from_specs`` 收集阶段先于引用方注册)。声明后,后续 ``add`` 或
``from_specs`` 中 ``depends_on``/``soft_depends_on`` 引用组名时,
会被重写为依赖组内全部任务。
参数
----
name:
组名。不能与 ``self.specs`` 中任务名重复,也不能与已有组名重复。
tasks:
组内任务名序列。所有任务必须已在图中注册。
返回 ``self`` 支持链式调用。
Raises
------
ValueError
组名与任务名冲突、组名已存在、或组内任务未注册时。
"""
if not name:
raise ValueError("group.name 必须为非空字符串。")
if name in self.specs:
raise ValueError(f"组名 {name!r} 与已注册任务名冲突。")
if name in self._groups:
raise ValueError(f"组名 {name!r} 已存在。")
missing = [t for t in tasks if t not in self.specs]
if missing:
raise ValueError(f"{name!r} 引用了未注册的任务: {missing}")
if not tasks:
raise ValueError(f"{name!r} 不能为空。")
self._groups[name] = tuple(tasks)
return self
def _register(self, spec: TaskSpec[Any]) -> None:
"""注册单个 spec。若 ``spec.loop`` 非 ``None``,展开为多实例。"""
if spec.loop is not None:
for expanded in self._expand_loop_to_specs(spec):
self._register_single(expanded)
return
self._register_single(self._rewrite_deps(spec))
def _register_single(self, spec: TaskSpec[Any]) -> None:
"""注册单个已展开的 spec(无 loop)。"""
if spec.name in self.specs:
raise DuplicateTaskError(spec.name)
self.specs[spec.name] = spec
# 拓扑依赖仅含硬依赖;软依赖仅用于注入,不影响分层。
self.deps[spec.name] = spec.depends_on
self._resolved_cache.clear()
self._layers_cache = None
def _expand_loop_to_specs(self, spec: TaskSpec[Any]) -> list[TaskSpec[Any]]:
"""展开 ``spec.loop`` 为多实例列表,并记录到 ``_loop_groups``。
返回展开后的 spec 列表(``loop`` 字段已清空,``args`` 已追加 item)。
不写入 ``specs``/``deps``,由调用方注册。
"""
loop = spec.loop
assert loop is not None # 由 _register 保证
expanded: list[TaskSpec[Any]] = []
expanded_names: list[str] = []
for i, item in enumerate(loop.items):
key = loop.key_fn(i, item) if loop.key_fn is not None else f"{spec.name}_{i}"
new_spec = replace(spec, name=key, args=(*spec.args, item), loop=None)
new_spec = self._rewrite_deps(new_spec)
expanded.append(new_spec)
expanded_names.append(key)
self._loop_groups[spec.name] = expanded_names
return expanded
def _rewrite_deps(self, spec: TaskSpec[Any]) -> TaskSpec[Any]:
"""重写 spec 的依赖:引用 loop 组名或 group 组名时展开为所有实例。"""
if not self._loop_groups and not self._groups:
return spec
# 合并 loop 组与普通组:组名 → 展开后的任务名列表
combined: dict[str, Sequence[str]] = {}
combined.update(self._loop_groups)
combined.update(self._groups)
new_hard: list[str] = []
new_soft: list[str] = []
changed = False
for dep in spec.depends_on:
if dep in combined:
new_hard.extend(combined[dep])
changed = True
else:
new_hard.append(dep)
for dep in spec.soft_depends_on:
if dep in combined:
new_soft.extend(combined[dep])
changed = True
else:
new_soft.append(dep)
if not changed:
return spec
return replace(spec, depends_on=tuple(new_hard), soft_depends_on=tuple(new_soft))
@classmethod
def from_specs(
@@ -220,14 +422,36 @@ class Graph:
"""
graph = cls(defaults=defaults or GraphDefaults(), namespace=namespace)
pending_refs: list[str] = []
collected: list[TaskSpec[Any]] = []
for spec in specs:
if isinstance(spec, str):
pending_refs.append(spec)
elif isinstance(spec, TaskSpec):
graph._register(spec)
collected.append(spec)
else:
raise TypeError(f"from_specs 只接受 TaskSpec 或 str,收到: {type(spec)}")
# 两阶段:先展开所有 loop(记录 _loop_groups),再统一重写依赖并批量注册。
# 批量注册跳过每次 _register 的 cache 清空(N 次清空降为 0 次);
# cache 失效由后续 validate()/layers() 首次调用自然触发。
expanded: list[TaskSpec[Any]] = []
for spec in collected:
if spec.loop is not None:
expanded.extend(graph._expand_loop_to_specs(spec))
else:
expanded.append(graph._rewrite_deps(spec))
# 自动推断 depends_on:对 depends_on 为空的纯 fn 任务,
# 从必需参数名匹配图中任务名,消除显式声明 depends_on 的样板代码。
all_names = {spec.name for spec in expanded}
expanded = [graph._auto_infer_deps_single(spec, all_names) for spec in expanded]
for spec in expanded:
if spec.name in graph.specs:
raise DuplicateTaskError(spec.name)
graph.specs[spec.name] = spec
graph.deps[spec.name] = spec.depends_on
if pending_refs:
graph._pending_refs = pending_refs
@@ -236,36 +460,28 @@ class Graph:
return graph
@classmethod
def from_yaml(
cls,
path: str | Path,
variables: Mapping[str, Any] | None = None,
) -> Graph:
"""从 YAML 文件构建任务图。
参考 GitHub Actions 风格 schema, 支持 jobs/needs/strategy.matrix/if
等 CI/CD 概念。详见 :mod:`pyflowx.yaml_loader`。
def from_yaml(cls, path: str | Path) -> Graph:
"""从 YAML 文件加载任务图(GitHub Actions 风格)。
Parameters
----------
path : str | Path
YAML 文件路径
variables : Mapping[str, Any] | None
运行时变量, 用于替换 ``${VAR}`` 占位符
path:
YAML 文件路径
Returns
-------
Graph
构建好的任务图
解析后的任务图,支持 ``jobs``/``needs``/``strategy.matrix``/
``if``/``continue-on-error``/``env``/``defaults`` 等字段。
Raises
------
YamlLoadError
文件不存在、YAML 格式错误、schema 校验失败、循环依赖等
ValueError
YAML 结构不符合 schema 时。
"""
from .yaml_loader import load_yaml
return load_yaml(path, variables=variables)
return load_yaml(path)
def add_subgraph(self, sub: Graph, *, namespace: str | None = None) -> Graph:
"""将子图合并到当前图,任务名加命名空间前缀避免冲突。
@@ -321,14 +537,16 @@ class Graph:
raise MissingDependencyError(name, dep)
def validate(self) -> None:
"""执行完整 DAG 校验。存在环时抛出 :class:`CycleError`。"""
"""执行完整 DAG 校验。存在环时抛出 :class:`CycleError`。
顺带填充 :attr:`_layers_cache`(无环时),使后续 :meth:`layers`
直接命中缓存,避免 :func:`_topological_layers` 二次计算。
"""
self._validate_references()
sorter = _TopologicalSorter(self.deps)
try:
sorter.prepare()
except graphlib.CycleError as exc: # type: ignore[name-defined]
cycle: Sequence[str] = exc.args[1] if len(exc.args) > 1 else []
raise CycleError(list(cycle)) from exc
layers, cycle_nodes = _topological_layers(self.deps)
if cycle_nodes is not None:
raise CycleError(cycle_nodes)
self._layers_cache = layers
# ------------------------------------------------------------------ #
# 内省
@@ -400,19 +618,18 @@ class Graph:
同层任务无相互硬依赖,可并发执行。软依赖不参与分层。
层按执行顺序返回。图有环时抛出 :class:`CycleError`。
结果按实例缓存;specs 变更时失效(:meth:`add` / :meth:`_register`)。
.. note::
本方法假定图已通过 :meth:`validate` 校验(由 :func:`pyflowx.run`
在入口统一执行一次)。若直接调用本方法,需自行先校验。
"""
sorter = _TopologicalSorter(self.deps)
result: list[list[str]] = []
sorter.prepare()
while sorter.is_active():
ready = list(sorter.get_ready())
ready.sort()
result.append(ready)
for node in ready:
sorter.done(node)
if self._layers_cache is not None:
return self._layers_cache
result, cycle_nodes = _topological_layers(self.deps)
if cycle_nodes is not None:
raise CycleError(cycle_nodes)
self._layers_cache = result
return result
# ------------------------------------------------------------------ #
@@ -441,6 +658,38 @@ class Graph:
]
return Graph.from_specs(kept, defaults=self.defaults)
def subgraph_with_deps(self, names: Iterable[str]) -> Graph:
"""返回包含 ``names`` 及其所有传递依赖的新图。
与 :meth:`subgraph_by_names` 不同,本方法会沿 ``depends_on`` 和
``soft_depends_on`` 向上遍历,确保被选中的任务所需的上游全部包含在内,
使子图可独立执行。
Parameters
----------
names:
需要执行的任务名。每个名称必须存在于当前图中。
"""
seeds: set[str] = set(names)
for n in seeds:
if n not in self.specs:
raise KeyError(f"Unknown task name: {n!r}")
# BFS 收集传递依赖(硬依赖 + 软依赖)
closure: set[str] = set()
queue: list[str] = list(seeds)
while queue:
name = queue.pop()
if name in closure:
continue
closure.add(name)
for dep in self.all_deps(name):
if dep not in closure:
queue.append(dep)
kept: list[TaskSpec[Any]] = [
_prune_deps(spec, lambda d: d in closure) for spec in self.specs.values() if spec.name in closure
]
return Graph.from_specs(kept, defaults=self.defaults)
# ------------------------------------------------------------------ #
# Fan-out / map-reduce
# ------------------------------------------------------------------ #
+87
View File
@@ -0,0 +1,87 @@
"""运行历史管理:存储、查询与比较多次运行的 :class:`RunReport`。
适用于:
* 断点续跑:从最近一次失败运行恢复,跳过已成功任务。
* 趋势分析:对比多次运行的任务耗时、状态变化。
* 审计追溯:按 run_id 查询历史报告。
用法
----
history = RunHistory(".pyflowx/runs")
history.save(report)
latest = history.latest()
if latest is not None and not latest.success:
# 从最近失败处恢复
px.run(graph, resume_from=latest)
"""
from __future__ import annotations
__all__ = ["RunHistory"]
from pathlib import Path
from .report import RunReport
class RunHistory:
"""运行历史记录管理器。
将 :class:`RunReport` 以 JSON 文件存储到指定目录,文件名为
``{run_id}.json``。支持按 run_id 加载、列出全部、查询最近一次、删除。
Parameters
----------
dir:
存储目录。不存在时自动创建。
"""
def __init__(self, dir: str | Path) -> None:
self.dir = Path(dir)
self.dir.mkdir(parents=True, exist_ok=True)
def save(self, report: RunReport) -> Path:
"""保存运行报告,返回文件路径。"""
path = self.dir / f"{report.run_id}.json"
path.write_text(report.to_json(), encoding="utf-8")
return path
def load(self, run_id: str) -> RunReport:
"""按 run_id 加载报告。
Raises
------
FileNotFoundError
指定 run_id 的文件不存在。
"""
path = self.dir / f"{run_id}.json"
if not path.exists():
raise FileNotFoundError(f"运行报告 {run_id!r} 不存在: {path}")
return RunReport.from_json(path.read_text(encoding="utf-8"))
def list_runs(self) -> list[str]:
"""列出全部 run_id(按文件名排序)。"""
return sorted(p.stem for p in self.dir.glob("*.json"))
def latest(self) -> RunReport | None:
"""返回最近修改的报告;目录为空时返回 ``None``。"""
runs = sorted(self.dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
if not runs:
return None
return RunReport.from_json(runs[0].read_text(encoding="utf-8"))
def delete(self, run_id: str) -> bool:
"""删除指定 run_id 的报告。返回是否实际删除。"""
path = self.dir / f"{run_id}.json"
if path.exists():
path.unlink()
return True
return False
def __len__(self) -> int:
return sum(1 for _ in self.dir.glob("*.json"))
def __contains__(self, run_id: object) -> bool:
if not isinstance(run_id, str):
return False
return (self.dir / f"{run_id}.json").exists()
+130
View File
@@ -0,0 +1,130 @@
"""图片处理流水线 DAG 构造器.
将 :mod:`pyflowx.ops.files.imagetool` 的操作封装为链式 :class:`Graph`,
便于在 DAG 中组合多步图片处理 (如 resize → watermark → convert).
设计参照 :mod:`pyflowx.compose` 的函数式构造器模式.
"""
from __future__ import annotations
__all__ = ["image_pipeline"]
from pathlib import Path
from typing import Any
from .graph import Graph
from .task import TaskSpec
def image_pipeline(
source: str | Path,
steps: list[tuple[str, dict[str, Any]]],
*,
output_dir: str | Path | None = None,
naming: str = "{stem}_{step}{ext}",
) -> Graph:
"""图片处理流水线 DAG 构造器.
每个 step 是 ``(操作名, 参数字典)``, 操作名对应 imagetool 子命令
(resize/crop/rotate/flip/convert/watermark/compress).
前一步输出作为后一步输入, 形成链式 DAG.
Parameters
----------
source : str | Path
源图片路径
steps : list[tuple[str, dict[str, Any]]]
操作步骤列表, 如 ``[("resize", {"width": 800}), ("watermark", {"text": "X"})]``
output_dir : str | Path | None
输出目录 (None 时用源文件所在目录)
naming : str
输出文件名模板, 可用占位符 ``{stem}`` (累积 stem) / ``{step}`` (操作名) /
``{ext}`` (当前扩展名, convert 步骤可能改变). 默认 ``"{stem}_{step}{ext}"``
Returns
-------
Graph
链式 DAG, 每步为一个 :class:`TaskSpec`, 通过 ``depends_on`` 链接
Raises
------
ValueError
操作名不在支持列表中时
Example
-------
>>> graph = px.image_pipeline(
... "input.jpg",
... steps=[
... ("resize", {"width": 800}),
... ("watermark", {"text": "© 2026"}),
... ("convert", {"format": "webp", "quality": 85}),
... ],
... )
>>> report = px.run(graph, strategy="sequential")
"""
from .ops.files import imagetool
operations = {
"resize": imagetool.image_resize,
"crop": imagetool.image_crop,
"rotate": imagetool.image_rotate,
"flip": imagetool.image_flip,
"convert": imagetool.image_convert,
"watermark": imagetool.image_watermark,
"compress": imagetool.image_compress,
}
source_path = Path(source)
out_dir = Path(output_dir) if output_dir else source_path.parent
specs: list[TaskSpec[Any]] = []
prev_name: str | None = None
prev_output: Path | None = None
current_stem = source_path.stem
current_ext = source_path.suffix
for i, (op_name, params) in enumerate(steps):
if op_name not in operations:
raise ValueError(f"未知图片操作: {op_name!r}, 支持: {sorted(operations)}")
if op_name == "convert" and "format" in params:
current_ext = f".{params['format'].lower()}"
output_name = naming.format(stem=current_stem, step=op_name, ext=current_ext)
output_path = out_dir / output_name
input_path = source_path if prev_output is None else prev_output
task_name = f"step_{i:02d}_{op_name}"
base_fn = operations[op_name]
fn = _make_step_fn(base_fn, input_path, output_path, params)
fn.__name__ = task_name
depends_on = (prev_name,) if prev_name else ()
spec = TaskSpec(task_name, fn=fn, depends_on=depends_on)
specs.append(spec)
prev_name = task_name
prev_output = output_path
current_stem = output_path.stem
return Graph.from_specs(specs)
def _make_step_fn(
base_fn: Any,
input_path: Path,
output_path: Path,
params: dict[str, Any],
) -> Any:
"""创建步骤执行闭包, 绑定输入/输出路径与参数.
闭包捕获的是 :func:`_make_step_fn` 的参数 (每次调用独立作用域),
不会受外层循环变量重绑定影响.
"""
def _run() -> None:
base_fn(input_path, output_path, **params)
return _run
+217
View File
@@ -0,0 +1,217 @@
"""监控导出:Prometheus 指标收集与健康检查。
提供零依赖的轻量监控方案:
* :class:`MetricsCollector` —— 通过 ``on_event`` 回调收集任务指标,
导出 Prometheus 文本格式。可配合 ``http.server`` 或 ``prometheus_client``
暴露指标端点。
* :func:`health_check` —— 分析 :class:`RunReport` 返回结构化健康状态。
用法
----
collector = px.MetricsCollector()
report = px.run(graph, on_event=collector.on_event)
print(collector.metrics_text())
print(px.health_check(report))
"""
from __future__ import annotations
__all__ = ["MetricsCollector", "health_check", "start_metrics_server"]
from collections.abc import Callable
from typing import Any
from .report import RunReport
from .task import TaskEvent, TaskStatus
class MetricsCollector:
"""收集任务执行指标,导出 Prometheus 文本格式。
作为 :func:`run` 的 ``on_event`` 回调使用,收集每个任务的生命周期
事件并聚合为 Prometheus 指标。线程安全(回调可能从多个线程调用)。
指标列表
--------
- ``pyflowx_task_total{task, status}`` — 任务计数器
- ``pyflowx_task_duration_seconds{task}`` — 任务耗时(gauge,最近值)
- ``pyflowx_task_duration_seconds_sum{task}`` — 任务累计耗时
- ``pyflowx_task_retries_total{task}`` — 重试次数
- ``pyflowx_run_total{status}`` — 运行计数器
"""
def __init__(self) -> None:
self._task_count: dict[str, dict[str, int]] = {}
self._task_duration_last: dict[str, float] = {}
self._task_duration_sum: dict[str, float] = {}
self._task_retries: dict[str, int] = {}
self._lock_counts: dict[str, int] = {}
def on_event(self, event: TaskEvent) -> None:
"""``on_event`` 回调:记录任务生命周期事件。"""
task = event.task
status = event.status.value
# 任务计数
self._task_count.setdefault(task, {})
self._task_count[task][status] = self._task_count[task].get(status, 0) + 1
# 耗时(仅在终态时记录)
if event.duration is not None and event.status in (TaskStatus.SUCCESS, TaskStatus.FAILED):
self._task_duration_last[task] = event.duration
self._task_duration_sum[task] = self._task_duration_sum.get(task, 0.0) + event.duration
# 重试次数
if event.attempts > 1 and event.status in (TaskStatus.SUCCESS, TaskStatus.FAILED):
self._task_retries[task] = self._task_retries.get(task, 0) + event.attempts - 1
def record_run(self, report: RunReport) -> None:
"""记录一次完整运行的结果。"""
status = "success" if report.success else "failed"
self._lock_counts[status] = self._lock_counts.get(status, 0) + 1
def metrics_text(self) -> str:
"""导出 Prometheus 文本格式指标字符串。"""
lines: list[str] = []
# pyflowx_task_total
if self._task_count:
lines.append("# HELP pyflowx_task_total Total tasks by status.")
lines.append("# TYPE pyflowx_task_total counter")
for task in sorted(self._task_count):
for status in sorted(self._task_count[task]):
count = self._task_count[task][status]
lines.append(f'pyflowx_task_total{{task="{task}",status="{status}"}} {count}')
# pyflowx_task_duration_seconds (last)
if self._task_duration_last:
lines.append("# HELP pyflowx_task_duration_seconds Last task duration in seconds.")
lines.append("# TYPE pyflowx_task_duration_seconds gauge")
for task in sorted(self._task_duration_last):
lines.append(f'pyflowx_task_duration_seconds{{task="{task}"}} {self._task_duration_last[task]}')
# pyflowx_task_duration_seconds_sum
if self._task_duration_sum:
lines.append("# HELP pyflowx_task_duration_seconds_sum Total task duration in seconds.")
lines.append("# TYPE pyflowx_task_duration_seconds_sum counter")
for task in sorted(self._task_duration_sum):
lines.append(f'pyflowx_task_duration_seconds_sum{{task="{task}"}} {self._task_duration_sum[task]}')
# pyflowx_task_retries_total
if self._task_retries:
lines.append("# HELP pyflowx_task_retries_total Total task retries.")
lines.append("# TYPE pyflowx_task_retries_total counter")
for task in sorted(self._task_retries):
lines.append(f'pyflowx_task_retries_total{{task="{task}"}} {self._task_retries[task]}')
# pyflowx_run_total
if self._lock_counts:
lines.append("# HELP pyflowx_run_total Total runs by status.")
lines.append("# TYPE pyflowx_run_total counter")
for status in sorted(self._lock_counts):
lines.append(f'pyflowx_run_total{{status="{status}"}} {self._lock_counts[status]}')
return "\n".join(lines) + "\n"
def reset(self) -> None:
"""清空所有收集的指标。"""
self._task_count.clear()
self._task_duration_last.clear()
self._task_duration_sum.clear()
self._task_retries.clear()
self._lock_counts.clear()
def health_check(report: RunReport) -> dict[str, Any]:
"""分析运行报告,返回结构化健康状态。
状态判定:
- ``healthy`` — 全部任务 SUCCESS
- ``degraded`` — 部分(非全部)任务 FAILED
- ``unhealthy`` — 全部任务 FAILED
- ``unknown`` — 无任务
Returns
-------
dict
包含 ``status``/``total``/``success``/``failed``/``skipped``
/``duration`` 字段。
"""
total = len(report.results)
if total == 0:
return {"status": "unknown", "total": 0, "message": "无任务结果"}
counts: dict[str, int] = {}
total_duration = 0.0
for r in report.results.values():
status = r.status.value
counts[status] = counts.get(status, 0) + 1
if r.duration is not None:
total_duration += r.duration
success = counts.get("success", 0)
failed = counts.get("failed", 0)
skipped = counts.get("skipped", 0)
if failed == 0:
status = "healthy"
elif failed < total:
status = "degraded"
else:
status = "unhealthy"
return {
"status": status,
"total": total,
"success": success,
"failed": failed,
"skipped": skipped,
"duration": round(total_duration, 3),
}
def start_metrics_server(collector: MetricsCollector, port: int = 9100, host: str = "0.0.0.0") -> Callable[[], None]:
"""启动简单 HTTP 服务器暴露 Prometheus 指标。
返回停止函数,调用后关闭服务器。阻塞调用方线程,建议在后台线程运行。
.. note::
使用标准库 ``http.server``,零依赖。生产环境建议配合
``prometheus_client`` 或反向代理使用。
"""
import sys
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override # pragma: no cover
class _Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
if self.path == "/metrics":
body = collector.metrics_text().encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
self.send_response(404)
self.end_headers()
@override
def log_message(self, format: str, *args: object) -> None:
pass # 静默访问日志
server = HTTPServer((host, port), _Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
def stop() -> None:
server.shutdown()
server.server_close()
return stop
+320
View File
@@ -0,0 +1,320 @@
"""任务通知系统。
在任务生命周期事件(RUNNING/SUCCESS/FAILED/SKIPPED)发生时发送通知到
外部系统。支持两类 sink
* :class:`CallbackNotifier` —— 包装 Python 回调,零外部依赖
* :class:`WebhookNotifier` 及其子类 —— 通过 HTTP POST 发送到飞书/钉钉/企业微信
通知系统与 :class:`pyflowx.progress.ProgressCallback` 独立且可共存。
``notifiers`` 参数在 :func:`pyflowx.run` 中传入,通知器的 ``notify`` 方法
被加入回调链,``close`` 在执行结束时调用。
设计要点
--------
* 事件过滤在 Notifier 内部,通过 ``levels`` 参数控制哪些事件触发通知。
* webhook 使用 stdlib :mod:`urllib.request`,零新依赖。
* 通知发送失败仅记录日志,不影响任务执行。
快速上手
--------
import pyflowx as px
# 1. 回调通知
notifier = px.CallbackNotifier(
lambda e: print(f"[通知] {e.task}: {e.status.value}")
)
px.run(graph, notifiers=notifier)
# 2. 飞书 webhook(仅失败时通知)
feishu = px.FeishuNotifier(
url="https://open.feishu.cn/open-apis/bot/v2/hook/xxx",
levels=(px.NotificationLevel.FAILED,),
)
px.run(graph, notifiers=[feishu])
"""
from __future__ import annotations
__all__ = [
"ALL_LEVELS",
"CallbackNotifier",
"DingTalkNotifier",
"DiscordNotifier",
"FeishuNotifier",
"NotificationLevel",
"Notifier",
"SlackNotifier",
"TelegramNotifier",
"WeChatNotifier",
"WebhookNotifier",
]
import logging
import sys
import urllib.error
import urllib.request
from collections.abc import Callable, Iterable
from enum import Enum
from typing import Any, Protocol, runtime_checkable
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override # pragma: no cover
from ._json import dumps
from .task import TaskEvent, TaskStatus
logger = logging.getLogger(__name__)
class NotificationLevel(Enum):
"""通知触发的事件级别。"""
RUNNING = "running"
SUCCESS = "success"
FAILED = "failed"
SKIPPED = "skipped"
_LEVEL_STATUS_MAP: dict[NotificationLevel, TaskStatus] = {
NotificationLevel.RUNNING: TaskStatus.RUNNING,
NotificationLevel.SUCCESS: TaskStatus.SUCCESS,
NotificationLevel.FAILED: TaskStatus.FAILED,
NotificationLevel.SKIPPED: TaskStatus.SKIPPED,
}
#: 所有事件级别的冻结集合。
ALL_LEVELS: frozenset[NotificationLevel] = frozenset(_LEVEL_STATUS_MAP.keys())
#: 各平台消息前缀图标。
_STATUS_ICONS: dict[TaskStatus, str] = {
TaskStatus.RUNNING: "",
TaskStatus.SUCCESS: "",
TaskStatus.FAILED: "",
TaskStatus.SKIPPED: "",
}
def _status_to_level(status: TaskStatus) -> NotificationLevel | None:
"""将 TaskStatus 映射为 NotificationLevel,无映射时返回 None。"""
for level, st in _LEVEL_STATUS_MAP.items():
if st == status:
return level
return None
def _format_event_message(event: TaskEvent) -> str:
"""格式化事件为人类可读消息文本(各平台共享)。"""
icon = _STATUS_ICONS.get(event.status, "")
dur = f" ({event.duration:.3f}s)" if event.duration is not None else ""
parts = [f"{icon} 任务 {event.task} {event.status.value}{dur}"]
if event.error:
parts.append(f" 错误: {event.error}")
if event.reason:
parts.append(f" 原因: {event.reason}")
if event.attempts > 1:
parts.append(f" 尝试次数: {event.attempts}")
return "\n".join(parts)
@runtime_checkable
class Notifier(Protocol):
"""通知器协议。
生命周期:执行期间多次 ``notify`` → 结束时 ``close``。
实现者应确保 ``notify`` 不抛异常(或仅在不可恢复时抛),
通知失败不应影响任务执行。
"""
def notify(self, event: TaskEvent) -> None:
"""处理单个任务事件。"""
...
def close(self) -> None:
"""释放资源(如关闭连接)。"""
...
class CallbackNotifier:
"""包装 Python 回调为 :class:`Notifier`。
Parameters
----------
callback:
接收 :class:`TaskEvent` 的回调函数。
levels:
触发通知的事件级别集合。默认 :data:`ALL_LEVELS`(全生命周期)。
"""
def __init__(
self,
callback: Callable[[TaskEvent], None],
levels: Iterable[NotificationLevel] = ALL_LEVELS,
) -> None:
self._callback = callback
self._levels = frozenset(levels)
def notify(self, event: TaskEvent) -> None:
level = _status_to_level(event.status)
if level is not None and level in self._levels:
self._callback(event)
def close(self) -> None:
"""无资源需释放。"""
class WebhookNotifier:
"""通过 HTTP POST 发送通知的基类。
使用 stdlib :mod:`urllib.request`,零外部依赖。子类覆盖
:meth:`_build_payload` 以适配不同平台的消息格式。
Parameters
----------
url:
webhook 接收地址。
levels:
触发通知的事件级别集合。默认 :data:`ALL_LEVELS`。
timeout:
HTTP 请求超时秒数。默认 ``10``。
"""
def __init__(
self,
url: str,
levels: Iterable[NotificationLevel] = ALL_LEVELS,
timeout: float = 10.0,
) -> None:
self._url = url
self._levels = frozenset(levels)
self._timeout = timeout
def notify(self, event: TaskEvent) -> None:
level = _status_to_level(event.status)
if level is None or level not in self._levels:
return
payload = self._build_payload(event)
self._send(payload)
def _build_payload(self, event: TaskEvent) -> dict[str, Any]:
"""构建请求体。子类覆盖以适配平台格式。"""
return {"text": _format_event_message(event)}
def _send(self, payload: dict[str, Any]) -> None:
"""发送 JSON POST 请求,失败仅记录日志。"""
data = dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
self._url,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
_ = resp.read()
except (urllib.error.URLError, OSError) as exc:
logger.warning("webhook 通知发送失败 (%s): %s", self._url, exc)
def close(self) -> None:
"""无持久连接需关闭。"""
class FeishuNotifier(WebhookNotifier):
"""飞书(Lark)自定义机器人通知器。
消息格式:``{"msg_type": "text", "content": {"text": ...}}``。
"""
@override
def _build_payload(self, event: TaskEvent) -> dict[str, Any]:
return {
"msg_type": "text",
"content": {"text": _format_event_message(event)},
}
class DingTalkNotifier(WebhookNotifier):
"""钉钉自定义机器人通知器。
消息格式:``{"msgtype": "text", "text": {"content": ...}}``。
"""
@override
def _build_payload(self, event: TaskEvent) -> dict[str, Any]:
return {
"msgtype": "text",
"text": {"content": _format_event_message(event)},
}
class WeChatNotifier(WebhookNotifier):
"""企业微信群机器人通知器。
消息格式:``{"msgtype": "text", "text": {"content": ...}}``。
"""
@override
def _build_payload(self, event: TaskEvent) -> dict[str, Any]:
return {
"msgtype": "text",
"text": {"content": _format_event_message(event)},
}
class SlackNotifier(WebhookNotifier):
"""Slack Incoming Webhook 通知器。
消息格式:``{"text": ...}``。Slack webhook 仅需 ``text`` 字段,
与基类格式一致,独立成类便于类型区分与后续扩展(如 channel/username)。
"""
@override
def _build_payload(self, event: TaskEvent) -> dict[str, Any]:
return {"text": _format_event_message(event)}
class DiscordNotifier(WebhookNotifier):
"""Discord Webhook 通知器。
消息格式:``{"content": ...}``。Discord webhook 使用 ``content`` 字段
承载消息文本。
"""
@override
def _build_payload(self, event: TaskEvent) -> dict[str, Any]:
return {"content": _format_event_message(event)}
class TelegramNotifier(WebhookNotifier):
"""Telegram Bot 通知器。
通过 ``sendMessage`` API 发送消息。需要在 ``__init__`` 中传入
``chat_id``(聊天/群组 ID),并使用 bot token 拼接 webhook URL
``https://api.telegram.org/bot<TOKEN>/sendMessage``。
消息格式:``{"chat_id": ..., "text": ...}``。
Parameters
----------
chat_id:
接收消息的聊天或群组 ID(数字字符串或 @channelusername)。
"""
def __init__(
self,
url: str,
chat_id: str,
levels: Iterable[NotificationLevel] = ALL_LEVELS,
timeout: float = 10.0,
) -> None:
super().__init__(url=url, levels=levels, timeout=timeout)
self._chat_id = chat_id
@override
def _build_payload(self, event: TaskEvent) -> dict[str, Any]:
return {
"chat_id": self._chat_id,
"text": _format_event_message(event),
}
+11 -12
View File
@@ -1,19 +1,18 @@
"""工具函数模块.
"""ops 子包 — CLI 工具模块集合 (按功能分组).
按类别组织 CLI 工具中可复用的函数, 每个子模块使用 ``@px.register_fn`` 注册函数,
供 YAML 任务编排通过 ``fn`` 字段引用.
每个子模块使用 ``@px.tool`` 装饰器注册 CLI 工具, 由 ``pf`` 入口按需
``importlib.import_module`` 触发注册. 子模块按功能分组到子目录.
模块
目录
------
- :mod:`files` —— 文件日期/等级/备份/压缩相关函数
- :mod:`dev` —— 开发工具 (ruff/pip/git) 相关函数
- :mod:`bumpversion` —— 版本号管理相关函数
- :mod:`media` —— PDF/截图相关函数
- :mod:`system` —— LS-DYNA/SSH/打包相关函数
- :mod:`files` —— 文件与文档操作 (filedate/filelevel/folderback/folderzip/pdftool/screenshot)
- :mod:`dev` —— 开发与构建工具 (autofmt/bumpversion/gittool/packtool/piptool/pymake/lscalc)
- :mod:`system` —— 系统工具 (clr/reseticoncache/taskkill/which)
- :mod:`infra` —— 基础设施与服务部署 (dockercmd/envdev/sshcopyid/msdownload/sglang)
:mod:`_common` 保留在根目录, 提供跨平台命令选择、平台守卫等共享辅助.
"""
from __future__ import annotations
from . import bumpversion, dev, files, media, system
__all__ = ["bumpversion", "dev", "files", "media", "system"]
__all__: list[str] = []
+66
View File
@@ -0,0 +1,66 @@
"""ops 共享辅助: 跨平台命令选择、平台守卫、共享忽略模式."""
from __future__ import annotations
from collections.abc import Sequence
from pyflowx.conditions import Constants
__all__ = ["IGNORE_PATTERNS", "ensure_platform", "platform_command"]
IGNORE_PATTERNS: list[str] = [
"__pycache__",
"*.pyc",
"*.pyo",
".git",
".venv",
".idea",
".vscode",
"*.egg-info",
"dist",
"build",
".pytest_cache",
".tox",
".mypy_cache",
]
def platform_command(windows: Sequence[str], posix: Sequence[str]) -> list[str]:
"""按当前平台返回对应命令.
Parameters
----------
windows : Sequence[str]
Windows 平台命令 (含参数).
posix : Sequence[str]
Linux/macOS 平台命令 (含参数).
Returns
-------
list[str]
当前平台对应的命令列表 (副本, 可安全修改).
"""
return list(windows) if Constants.IS_WINDOWS else list(posix)
def ensure_platform(predicate: bool, tool_name: str, platform_name: str) -> bool:
"""平台不满足时打印提示, 返回是否满足.
Parameters
----------
predicate : bool
平台匹配谓词 (如 ``Constants.IS_WINDOWS``).
tool_name : str
工具名称, 用于提示信息前缀.
platform_name : str
目标平台名称, 用于提示信息.
Returns
-------
bool
平台满足时返回 True, 否则打印 ``{tool_name}: 仅在 {platform_name} 上支持`` 并返回 False.
"""
if not predicate:
print(f"{tool_name}: 仅在 {platform_name} 上支持")
return False
return True
-433
View File
@@ -1,433 +0,0 @@
"""开发工具类函数模块.
聚合自动格式化 (autofmt)、pip 包管理 (piptool)、git 工具 (gittool) 的可复用函数.
版本号管理已抽离到 :mod:`pyflowx.ops.bumpversion`. 所有公共函数通过
``@px.register_fn`` 注册, 供 YAML 任务编排引用.
"""
from __future__ import annotations
import ast
import fnmatch
import subprocess
from pathlib import Path
import pyflowx as px
__all__ = [
"IGNORE_PATTERNS",
"PACKAGE_DIR",
"REQUIREMENTS_FILE",
"_PROTECTED_PACKAGES",
"add_docstring",
"auto_add_docstrings",
"format_all",
"format_with_ruff",
"generate_module_docstring",
"git_add_commit",
"git_init_add_commit",
"has_files",
"init_sub_dirs",
"lint_with_ruff",
"not_has_git_repo",
"pip_download",
"pip_freeze",
"pip_reinstall",
"pip_uninstall",
"sync_pyproject_config",
]
# ============================================================================
# autofmt 配置
# ============================================================================
IGNORE_PATTERNS = [
"__pycache__",
"*.pyc",
"*.pyo",
".git",
".venv",
".idea",
".vscode",
"*.egg-info",
"dist",
"build",
".pytest_cache",
".tox",
".mypy_cache",
]
# ============================================================================
# piptool 配置
# ============================================================================
PACKAGE_DIR = "packages"
REQUIREMENTS_FILE = "requirements.txt"
_PROTECTED_PACKAGES: frozenset[str] = frozenset(
{
"pyflowx",
"bitool",
}
)
# ============================================================================
# autofmt 私有辅助函数
# ============================================================================
# ============================================================================
# autofmt 函数
# ============================================================================
@px.register_fn
def format_with_ruff(target: Path, fix: bool = True) -> None:
"""使用 ruff 格式化代码.
Parameters
----------
target : Path
目标路径
fix : bool
是否自动修复
"""
cmd = ["ruff", "format", str(target)]
if fix:
cmd.append("--fix")
subprocess.run(cmd, check=True)
print(f"ruff format 完成: {target}")
@px.register_fn
def lint_with_ruff(target: Path, fix: bool = True) -> None:
"""使用 ruff 检查代码.
Parameters
----------
target : Path
目标路径
fix : bool
是否自动修复
"""
cmd = ["ruff", "check", str(target)]
if fix:
cmd.extend(["--fix", "--unsafe-fixes"])
subprocess.run(cmd, check=True)
print(f"ruff check 完成: {target}")
@px.register_fn
def add_docstring(file_path: Path, docstring: str) -> bool:
"""为文件添加 docstring.
Parameters
----------
file_path : Path
文件路径
docstring : str
docstring 内容
Returns
-------
bool
是否成功添加
"""
try:
content = file_path.read_text(encoding="utf-8")
tree = ast.parse(content)
first_node = tree.body[0] if tree.body else None
if first_node and isinstance(first_node, ast.Expr) and isinstance(first_node.value, ast.Constant):
return False
lines = content.splitlines()
doc_lines = docstring.splitlines()
doc_lines.append("")
new_content = "\n".join(doc_lines + lines)
file_path.write_text(new_content, encoding="utf-8")
print(f"添加 docstring: {file_path}")
return True
except (OSError, UnicodeDecodeError, SyntaxError) as e:
print(f"处理失败: {file_path} - {e}")
return False
@px.register_fn
def generate_module_docstring(file_path: Path) -> str:
"""生成模块 docstring.
Parameters
----------
file_path : Path
文件路径
Returns
-------
str
生成的 docstring
"""
stem = file_path.stem
parent = file_path.parent.name
keywords = {
"cli": f"Command-line interface for {parent}",
"gui": f"Graphical user interface for {parent}",
"core": f"Core functionality for {parent}",
"util": f"Utility functions for {parent}",
"model": f"Data models for {parent}",
"test": f"Tests for {parent}",
}
for key, desc in keywords.items():
if key in stem.lower():
return f'"""{desc}."""'
return f'"""{stem.replace("_", " ").title()} module."""'
@px.register_fn
def auto_add_docstrings(root_dir: Path) -> int:
"""自动为所有 Python 文件添加 docstring.
Parameters
----------
root_dir : Path
根目录
Returns
-------
int
添加的 docstring 数量
"""
count = 0
for py_file in root_dir.rglob("*.py"):
if any(pattern in str(py_file) for pattern in IGNORE_PATTERNS):
continue
docstring = generate_module_docstring(py_file)
if add_docstring(py_file, docstring):
count += 1
print(f"共添加 {count} 个 docstring")
return count
@px.register_fn
def sync_pyproject_config(root_dir: Path) -> None:
"""同步 pyproject.toml 配置到子项目.
Parameters
----------
root_dir : Path
根目录
"""
main_toml = root_dir / "pyproject.toml"
if not main_toml.exists():
print(f"主项目配置文件不存在: {main_toml}")
return
sub_tomls = [p for p in root_dir.rglob("pyproject.toml") if p != main_toml and ".venv" not in str(p)]
if not sub_tomls:
print("没有找到子项目的 pyproject.toml")
return
print(f"找到 {len(sub_tomls)} 个子项目配置文件")
for sub_toml in sub_tomls:
subprocess.run(["ruff", "format", str(sub_toml)], check=False)
print("配置同步完成")
@px.register_fn
def format_all(root_dir: Path) -> None:
"""格式化所有 Python 文件.
Parameters
----------
root_dir : Path
根目录
"""
subprocess.run(["ruff", "format", str(root_dir)], check=True)
subprocess.run(["ruff", "check", "--fix", "--unsafe-fixes", str(root_dir)], check=True)
print(f"格式化完成: {root_dir}")
# ============================================================================
# piptool 私有辅助函数
# ============================================================================
def _get_installed_packages() -> list[str]:
"""获取当前环境中所有已安装的包名."""
try:
result = subprocess.run(
["pip", "list", "--format=freeze"],
capture_output=True,
text=True,
check=True,
)
packages: list[str] = []
for line in result.stdout.strip().split("\n"):
if line and "==" in line:
pkg_name = line.split("==")[0].strip()
packages.append(pkg_name)
except (subprocess.SubprocessError, OSError):
return []
return packages
def _expand_wildcard_packages(pattern: str) -> list[str]:
"""展开通配符模式为实际的包名列表."""
if not any(char in pattern for char in ["*", "?", "[", "]"]):
return [pattern]
installed_packages = _get_installed_packages()
matched = [pkg for pkg in installed_packages if fnmatch.fnmatchcase(pkg.lower(), pattern.lower())]
return matched
def _filter_protected_packages(packages: list[str]) -> list[str]:
"""过滤掉受保护的包名."""
safe = [p for p in packages if p.lower() not in {p.lower() for p in _PROTECTED_PACKAGES}]
filtered = [p for p in packages if p.lower() in {p.lower() for p in _PROTECTED_PACKAGES}]
if filtered:
print(f"跳过受保护的包: {', '.join(filtered)}")
return safe
# ============================================================================
# piptool 函数
# ============================================================================
@px.register_fn
def pip_uninstall(pkg_names: list[str]) -> None:
"""卸载包."""
packages_to_uninstall: list[str] = []
for pattern in pkg_names:
packages_to_uninstall.extend(_expand_wildcard_packages(pattern))
packages_to_uninstall = _filter_protected_packages(packages_to_uninstall)
if not packages_to_uninstall:
return
subprocess.run(["pip", "uninstall", "-y", *packages_to_uninstall], check=True)
@px.register_fn
def pip_reinstall(pkg_names: list[str], offline: bool = False) -> None:
"""重新安装包."""
safe_ps = _filter_protected_packages(pkg_names)
if not safe_ps:
print("所有指定的包均为受保护包, 跳过重装")
return
subprocess.run(["pip", "uninstall", "-y", *safe_ps], check=True)
options = ["--no-index", "--find-links", "."] if offline else []
subprocess.run(["pip", "install", *options, *safe_ps], check=True)
@px.register_fn
def pip_download(pkg_names: list[str], offline: bool = False) -> None:
"""下载包."""
options = ["--no-index", "--find-links", "."] if offline else []
subprocess.run(
["pip", "download", *pkg_names, *options, "-d", PACKAGE_DIR],
check=True,
)
@px.register_fn
def pip_freeze() -> None:
"""冻结依赖."""
result = subprocess.run(
["pip", "freeze", "--exclude-editable"],
capture_output=True,
text=True,
check=True,
)
Path(REQUIREMENTS_FILE).write_text(result.stdout)
# ============================================================================
# gittool 函数
# ============================================================================
@px.register_fn
def init_sub_dirs() -> None:
"""初始化子目录的 Git 仓库."""
sub_dirs = [subdir for subdir in Path.cwd().iterdir() if subdir.is_dir()]
for subdir in sub_dirs:
px.run(
px.Graph().chain(
px.cmd(["git", "init"], conditions=(lambda _: not_has_git_repo(),), cwd=subdir),
px.cmd(["git", "add", "."]),
px.cmd(["git", "commit", "-m", "init commit"]),
),
)
@px.register_fn
def not_has_git_repo() -> bool:
"""检查当前目录没有 Git 仓库."""
return not Path.cwd().exists() or not (Path.cwd() / ".git").is_dir()
@px.register_fn
def has_files() -> bool:
"""检查当前 Git 仓库是否有未提交的更改."""
try:
result = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True,
text=True,
check=False,
)
return bool(result.stdout.strip())
except (subprocess.SubprocessError, OSError):
return False
@px.register_fn
def git_add_commit(message: str = "chore: update") -> None:
"""执行 git add + git commit (仅当有未提交更改时).
Parameters
----------
message : str
提交信息
"""
if not has_files():
print("没有文件需要提交")
return
subprocess.run(["git", "add", "."], check=True)
subprocess.run(["git", "commit", "-m", message], check=True)
@px.register_fn
def git_init_add_commit(message: str = "init commit") -> None:
"""执行 git init (若需) + git add + git commit (若有更改).
Parameters
----------
message : str
提交信息
"""
if not_has_git_repo():
subprocess.run(["git", "init"], check=True)
if has_files():
subprocess.run(["git", "add", "."], check=True)
subprocess.run(["git", "commit", "-m", message], check=True)
else:
print("没有文件需要提交")
+5
View File
@@ -0,0 +1,5 @@
"""dev 子包 — 开发与构建工具."""
from __future__ import annotations
__all__: list[str] = []
+228
View File
@@ -0,0 +1,228 @@
"""autofmt - 自动格式化工具.
提供格式化代码/代码检查/自动添加文档/同步配置 子命令.
"""
from __future__ import annotations
import ast
import subprocess
from pathlib import Path
import pyflowx as px
from pyflowx.ops._common import IGNORE_PATTERNS
__all__ = [
"add_docstring",
"auto_add_docstrings",
"fmt",
"format_all",
"format_with_ruff",
"generate_module_docstring",
"lint",
"lint_with_ruff",
"sync_pyproject_config",
]
# ============================================================================
# 辅助函数
# ============================================================================
@px.tool("autofmt", subcommand="fmt", help="格式化代码")
def fmt(target: str = ".") -> None:
"""格式化代码 (ruff format).
Parameters
----------
target : str
目标路径 (默认: .)
"""
format_with_ruff(Path(target), fix=False)
@px.tool("autofmt", subcommand="lint", help="代码检查")
def lint(target: str = ".", fix: bool = False) -> None:
"""代码检查 (ruff check).
Parameters
----------
target : str
目标路径 (默认: .)
fix : bool
自动修复问题
"""
lint_with_ruff(Path(target), fix=fix)
def format_with_ruff(target: Path, fix: bool = True) -> None:
"""使用 ruff 格式化代码.
Parameters
----------
target : Path
目标路径
fix : bool
是否自动修复
"""
cmd = ["ruff", "format", str(target)]
if fix:
cmd.append("--fix")
subprocess.run(cmd, check=True)
print(f"ruff format 完成: {target}")
def lint_with_ruff(target: Path, fix: bool = True) -> None:
"""使用 ruff 检查代码.
Parameters
----------
target : Path
目标路径
fix : bool
是否自动修复
"""
cmd = ["ruff", "check", str(target)]
if fix:
cmd.extend(["--fix", "--unsafe-fixes"])
subprocess.run(cmd, check=True)
print(f"ruff check 完成: {target}")
def add_docstring(file_path: Path, docstring: str) -> bool:
"""为文件添加 docstring.
Parameters
----------
file_path : Path
文件路径
docstring : str
docstring 内容
Returns
-------
bool
是否成功添加
"""
try:
content = file_path.read_text(encoding="utf-8")
tree = ast.parse(content)
first_node = tree.body[0] if tree.body else None
if first_node and isinstance(first_node, ast.Expr) and isinstance(first_node.value, ast.Constant):
return False
lines = content.splitlines()
doc_lines = docstring.splitlines()
doc_lines.append("")
new_content = "\n".join(doc_lines + lines)
file_path.write_text(new_content, encoding="utf-8")
print(f"添加 docstring: {file_path}")
return True
except (OSError, UnicodeDecodeError, SyntaxError) as e:
print(f"处理失败: {file_path} - {e}")
return False
def generate_module_docstring(file_path: Path) -> str:
"""生成模块 docstring.
Parameters
----------
file_path : Path
文件路径
Returns
-------
str
生成的 docstring
"""
stem = file_path.stem
parent = file_path.parent.name
keywords = {
"cli": f"Command-line interface for {parent}",
"gui": f"Graphical user interface for {parent}",
"core": f"Core functionality for {parent}",
"util": f"Utility functions for {parent}",
"model": f"Data models for {parent}",
"test": f"Tests for {parent}",
}
for key, desc in keywords.items():
if key in stem.lower():
return f'"""{desc}."""'
return f'"""{stem.replace("_", " ").title()} module."""'
@px.tool("autofmt", subcommand="doc", help="自动添加文档字符串")
def auto_add_docstrings(root_dir: Path = Path()) -> int:
"""自动为所有 Python 文件添加 docstring.
Parameters
----------
root_dir : Path
根目录 (默认: 当前目录)
Returns
-------
int
添加的 docstring 数量
"""
count = 0
for py_file in root_dir.rglob("*.py"):
if any(pattern in str(py_file) for pattern in IGNORE_PATTERNS):
continue
docstring = generate_module_docstring(py_file)
if add_docstring(py_file, docstring):
count += 1
print(f"共添加 {count} 个 docstring")
return count
@px.tool("autofmt", subcommand="sync", help="同步 pyproject 配置")
def sync_pyproject_config(root_dir: Path = Path()) -> None:
"""同步 pyproject.toml 配置到子项目.
Parameters
----------
root_dir : Path
根目录 (默认: 当前目录)
"""
main_toml = root_dir / "pyproject.toml"
if not main_toml.exists():
print(f"主项目配置文件不存在: {main_toml}")
return
sub_tomls = [p for p in root_dir.rglob("pyproject.toml") if p != main_toml and ".venv" not in str(p)]
if not sub_tomls:
print("没有找到子项目的 pyproject.toml")
return
print(f"找到 {len(sub_tomls)} 个子项目配置文件")
for sub_toml in sub_tomls:
subprocess.run(["ruff", "format", str(sub_toml)], check=False)
print("配置同步完成")
def format_all(root_dir: Path) -> None:
"""格式化所有 Python 文件.
Parameters
----------
root_dir : Path
根目录
"""
subprocess.run(["ruff", "format", str(root_dir)], check=True)
subprocess.run(["ruff", "check", "--fix", "--unsafe-fixes", str(root_dir)], check=True)
print(f"格式化完成: {root_dir}")
@@ -1,8 +1,8 @@
"""版本号管理模块.
提供单文件版本号更新 (``bump_file_version``) 与项目级批量版本号同步
(``bump_project_version``) 能力. 所有公共函数通过 ``@px.register_fn`` 注册,
YAML 任务编排引.
(``bump_project_version``) 能力. ``bump_project_version`` 通过 ``@px.tool``
注册为 CLI 工具, 可通过 ``pf bumpversion`` .
设计要点
--------
@@ -131,7 +131,6 @@ def _write_version_to_file(file_path: Path, new_version: str) -> bool:
# ============================================================================
@px.register_fn
def bump_file_version(file_path: Path, part: BumpVersionType = "patch") -> str | None:
"""更新单个文件中的版本号.
@@ -163,7 +162,7 @@ def bump_file_version(file_path: Path, part: BumpVersionType = "patch") -> str |
return new_version
@px.register_fn
@px.tool("bumpversion", help="版本号自动管理")
def bump_project_version(part: BumpVersionType = "patch", no_tag: bool = False) -> str | None:
"""批量同步项目所有版本号文件并提交.
+142
View File
@@ -0,0 +1,142 @@
"""gittool - Git 执行工具.
提供添加提交/清理/初始化/推送/拉取 子命令.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
import pyflowx as px
EXCLUDE_DIRS = [
# 编辑器相关目录
".vscode",
".idea",
".editorconfig",
".trae",
".qoder",
# 项目相关目录
".venv",
".git",
".ruff_cache",
".tox",
"node_modules",
]
EXCLUDE_CMDS = [arg for d in EXCLUDE_DIRS for arg in ["-e", d]]
# ============================================================================
# 私有辅助函数
# ============================================================================
def not_has_git_repo() -> bool:
"""检查当前目录没有 Git 仓库."""
return not Path.cwd().exists() or not (Path.cwd() / ".git").is_dir()
def has_files() -> bool:
"""检查当前 Git 仓库是否有未提交的更改."""
try:
result = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True,
text=True,
check=False,
)
return bool(result.stdout.strip())
except (subprocess.SubprocessError, OSError):
return False
# ============================================================================
# fn 子命令
# ============================================================================
@px.tool("gittool", subcommand="a", help="添加并提交")
def git_add_commit(message: str = "chore: update") -> None:
"""执行 git add + git commit (仅当有未提交更改时).
Parameters
----------
message : str
提交信息 (默认: ``chore: update``)
"""
if not has_files():
print("没有文件需要提交")
return
subprocess.run(["git", "add", "."], check=True)
subprocess.run(["git", "commit", "-m", message], check=True)
@px.tool("gittool", subcommand="i", help="初始化并提交")
def git_init_add_commit(message: str = "init commit") -> None:
"""执行 git init (若需) + git add + git commit (若有更改).
Parameters
----------
message : str
提交信息 (默认: ``init commit``)
"""
if not_has_git_repo():
subprocess.run(["git", "init"], check=True)
if has_files():
subprocess.run(["git", "add", "."], check=True)
subprocess.run(["git", "commit", "-m", message], check=True)
else:
print("没有文件需要提交")
@px.tool("gittool", subcommand="isub", help="初始化子目录")
def init_sub_dirs() -> None:
"""初始化子目录的 Git 仓库."""
sub_dirs = [subdir for subdir in Path.cwd().iterdir() if subdir.is_dir()]
for subdir in sub_dirs:
px.run(
px.Graph().chain(
px.cmd(["git", "init"], conditions=(lambda _: not_has_git_repo(),), cwd=subdir),
px.cmd(["git", "add", "."]),
px.cmd(["git", "commit", "-m", "init commit"]),
),
)
# ============================================================================
# cmd 子命令
# ============================================================================
@px.tool(
"gittool",
subcommand="clean",
help="清理未跟踪文件",
cmd=["git", "clean", "-xfd", *EXCLUDE_CMDS],
hidden=True,
)
def clean() -> None:
"""清理 Git 未跟踪文件 (隐藏命令, 被 c 依赖)."""
@px.tool(
"gittool",
subcommand="c",
help="清理并查看状态",
cmd=["git", "status", "--porcelain"],
needs=["clean"],
)
def c() -> None:
"""清理未跟踪文件并查看 Git 状态."""
@px.tool("gittool", subcommand="p", help="推送", cmd=["git", "push"])
def p() -> None:
"""推送代码到远程仓库."""
@px.tool("gittool", subcommand="pl", help="拉取", cmd=["git", "pull"])
def pl() -> None:
"""从远程仓库拉取代码."""
+111
View File
@@ -0,0 +1,111 @@
"""lscalc - LS-DYNA 计算工具.
运行 LS-DYNA 计算 (单机/MPI) 与进程状态检查.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
import pyflowx as px
from pyflowx.conditions import Constants
_DEFAULT_NCPU: int = 4
def get_ls_dyna_command(input_file: str, ncpu: int) -> list[str]:
"""获取 LS-DYNA 命令.
Parameters
----------
input_file : str
输入文件路径
ncpu : int
CPU 核心数
Returns
-------
list[str]
LS-DYNA 命令列表
"""
return ["ls-dyna_mpp", f"i={input_file}", f"ncpu={ncpu}"]
@px.tool("lscalc", subcommand="run", help="运行 LS-DYNA 计算")
def run_ls_dyna(input_file: str, ncpu: int = _DEFAULT_NCPU) -> None:
"""运行 LS-DYNA 计算.
Parameters
----------
input_file : str
输入文件路径
ncpu : int
CPU 核心数
"""
input_path = Path(input_file)
if not input_path.exists():
print(f"输入文件不存在: {input_path}")
return
cmd = get_ls_dyna_command(input_file, ncpu)
try:
subprocess.run(cmd, check=True)
print(f"LS-DYNA 计算完成: {input_file}")
except FileNotFoundError:
print("未找到 ls-dyna_mpp 命令")
except subprocess.CalledProcessError as e:
print(f"LS-DYNA 计算失败: {e}")
@px.tool("lscalc", subcommand="mpi", help="运行 LS-DYNA MPI 计算")
def run_ls_dyna_mpi(input_file: str, ncpu: int = _DEFAULT_NCPU) -> None:
"""运行 LS-DYNA MPI 计算.
Parameters
----------
input_file : str
输入文件路径
ncpu : int
CPU 核心数
"""
input_path = Path(input_file)
if not input_path.exists():
print(f"输入文件不存在: {input_path}")
return
cmd = ["mpirun", "-np", str(ncpu), "ls-dyna_mpp", f"i={input_file}"]
try:
subprocess.run(cmd, check=True)
print(f"LS-DYNA MPI 计算完成: {input_file}")
except FileNotFoundError:
print("未找到 mpirun 或 ls-dyna_mpp 命令")
except subprocess.CalledProcessError as e:
print(f"LS-DYNA MPI 计算失败: {e}")
@px.tool("lscalc", subcommand="status", help="检查 LS-DYNA 进程状态")
def check_ls_dyna_status() -> None:
"""检查 LS-DYNA 进程状态."""
try:
if Constants.IS_WINDOWS:
result = subprocess.run(
["tasklist", "/fi", "imagename eq ls-dyna_mpp.exe"],
capture_output=True,
text=True,
check=True,
)
print(result.stdout)
else:
result = subprocess.run(
["pgrep", "-f", "ls-dyna"],
capture_output=True,
text=True,
check=False,
)
if result.stdout.strip():
print(f"运行中的 LS-DYNA 进程 PID: {result.stdout.strip()}")
else:
print("没有运行中的 LS-DYNA 进程")
except subprocess.CalledProcessError as e:
print(f"检查进程状态失败: {e}")
+236
View File
@@ -0,0 +1,236 @@
"""packtool - Python 打包工具.
提供源码打包/依赖打包/wheel 构建/嵌入式 Python 安装/zip 包创建/清理 子命令.
"""
from __future__ import annotations
import platform
import shutil
import subprocess
import urllib.request
import zipfile
from pathlib import Path
import pyflowx as px
from pyflowx.ops._common import IGNORE_PATTERNS
__all__ = [
"clean_build_dir",
"create_zip_package",
"install_embed_python",
"pack_dependencies",
"pack_source",
"pack_wheel",
]
# ============================================================================
# 配置
# ============================================================================
DEFAULT_BUILD_DIR = ".pypack"
DEFAULT_DIST_DIR = "dist"
DEFAULT_LIB_DIR = "libs"
DEFAULT_CACHE_DIR = ".cache/pypack"
# ============================================================================
# 公共函数
# ============================================================================
@px.tool("packtool", subcommand="src", help="打包源码")
def pack_source(project_dir: Path = Path(), output_dir: Path = Path(".pypack")) -> None:
"""打包项目源码.
Parameters
----------
project_dir : Path
项目目录 (默认: 当前目录)
output_dir : Path
输出目录 (默认: .pypack)
"""
output_dir.mkdir(parents=True, exist_ok=True)
pyproject_file = project_dir / "pyproject.toml"
project_name = project_dir.name
if pyproject_file.exists():
try:
import tomllib
content = pyproject_file.read_text(encoding="utf-8")
data = tomllib.loads(content)
project_name = data.get("project", {}).get("name", project_name)
except ImportError:
pass
source_dir = output_dir / "src" / project_name
source_dir.mkdir(parents=True, exist_ok=True)
src_subdir = project_dir / "src"
if src_subdir.exists():
shutil.copytree(
src_subdir,
source_dir / "src",
ignore=shutil.ignore_patterns(*IGNORE_PATTERNS),
dirs_exist_ok=True,
)
else:
for item in project_dir.iterdir():
if item.name in IGNORE_PATTERNS or item.name.startswith("."):
continue
dst_item = source_dir / item.name
if item.is_dir():
shutil.copytree(
item,
dst_item,
ignore=shutil.ignore_patterns(*IGNORE_PATTERNS),
dirs_exist_ok=True,
)
else:
shutil.copy2(item, dst_item)
print(f"源码打包完成: {source_dir}")
@px.tool("packtool", subcommand="deps", help="打包依赖")
def pack_dependencies(lib_dir: Path = Path("libs"), dependencies: list[str] | None = None) -> None:
"""打包项目依赖.
Parameters
----------
lib_dir : Path
依赖库目录 (默认: libs)
dependencies : list[str] | None
依赖列表
"""
if dependencies is None:
dependencies = []
lib_dir.mkdir(parents=True, exist_ok=True)
if not dependencies:
print("没有依赖需要打包")
return
cmd = [
"pip",
"install",
"--target",
str(lib_dir),
"--no-compile",
"--no-warn-script-location",
]
cmd.extend(dependencies)
subprocess.run(cmd, check=True)
print(f"依赖打包完成: {lib_dir}")
@px.tool("packtool", subcommand="wheel", help="构建 wheel")
def pack_wheel(project_dir: Path = Path(), output_dir: Path = Path("dist")) -> None:
"""打包项目为 wheel 文件.
Parameters
----------
project_dir : Path
项目目录 (默认: 当前目录)
output_dir : Path
输出目录 (默认: dist)
"""
output_dir.mkdir(parents=True, exist_ok=True)
cmd = [
"pip",
"wheel",
"--no-deps",
"--wheel-dir",
str(output_dir),
str(project_dir),
]
subprocess.run(cmd, check=True)
print(f"Wheel 打包完成: {output_dir}")
@px.tool("packtool", subcommand="embed", help="安装嵌入式 Python")
def install_embed_python(version: str = "3.10", output_dir: Path = Path("python")) -> None:
"""安装嵌入式 Python.
Parameters
----------
version : str
Python 版本 (如: 3.10, 3.11), 默认 3.10
output_dir : Path
输出目录 (默认: python)
"""
output_dir.mkdir(parents=True, exist_ok=True)
arch = platform.machine().lower()
if arch in ["x86_64", "amd64"]:
arch = "amd64"
elif arch in ["arm64", "aarch64"]:
arch = "arm64"
version_map = {
"3.8": "3.8.10",
"3.9": "3.9.13",
"3.10": "3.10.11",
"3.11": "3.11.9",
"3.12": "3.12.4",
}
full_version = version_map.get(version, f"{version}.0")
url = f"https://www.python.org/ftp/python/{full_version}/python-{full_version}-embed-{arch}.zip"
cache_file = Path(DEFAULT_CACHE_DIR) / f"python-{full_version}-embed-{arch}.zip"
cache_file.parent.mkdir(parents=True, exist_ok=True)
if not cache_file.exists():
print(f"正在下载嵌入式 Python {full_version}...")
urllib.request.urlretrieve(url, cache_file)
print(f"下载完成: {cache_file}")
with zipfile.ZipFile(cache_file, "r") as zf:
zf.extractall(output_dir)
print(f"嵌入式 Python 安装完成: {output_dir}")
@px.tool("packtool", subcommand="zip", help="创建 zip 包")
def create_zip_package(source_dir: Path = Path(), output_file: Path = Path("package.zip")) -> None:
"""创建 ZIP 打包文件.
Parameters
----------
source_dir : Path
源目录 (默认: 当前目录)
output_file : Path
输出文件 (默认: package.zip)
"""
output_file.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(output_file, "w", zipfile.ZIP_DEFLATED) as zf:
for file in source_dir.rglob("*"):
if file.is_file():
arcname = file.relative_to(source_dir)
zf.write(file, arcname)
print(f"ZIP 打包完成: {output_file}")
@px.tool("packtool", subcommand="clean", help="清理构建目录")
def clean_build_dir(build_dir: Path = Path(".pypack")) -> None:
"""清理构建目录.
Parameters
----------
build_dir : Path
构建目录 (默认: .pypack)
"""
if build_dir.exists():
shutil.rmtree(build_dir)
print(f"清理完成: {build_dir}")
else:
print(f"目录不存在: {build_dir}")
+177
View File
@@ -0,0 +1,177 @@
"""piptool - pip 包管理工具.
提供安装/卸载/重装/下载/升级 pip/冻结依赖 子命令.
"""
from __future__ import annotations
import fnmatch
import subprocess
from pathlib import Path
import pyflowx as px
__all__ = [
"pip_download",
"pip_freeze",
"pip_install",
"pip_reinstall",
"pip_uninstall",
"pip_upgrade",
]
# ============================================================================
# 配置
# ============================================================================
PACKAGE_DIR = "packages"
REQUIREMENTS_FILE = "requirements.txt"
_PROTECTED_PACKAGES: frozenset[str] = frozenset(
{
"pyflowx",
"bitool",
}
)
# ============================================================================
# 私有辅助函数
# ============================================================================
def _get_installed_packages() -> list[str]:
"""获取当前环境中所有已安装的包名."""
try:
result = subprocess.run(
["pip", "list", "--format=freeze"],
capture_output=True,
text=True,
check=True,
)
packages: list[str] = []
for line in result.stdout.strip().split("\n"):
if line and "==" in line:
pkg_name = line.split("==")[0].strip()
packages.append(pkg_name)
except (subprocess.SubprocessError, OSError):
return []
return packages
def _expand_wildcard_packages(pattern: str) -> list[str]:
"""展开通配符模式为实际的包名列表."""
if not any(char in pattern for char in ["*", "?", "[", "]"]):
return [pattern]
installed_packages = _get_installed_packages()
matched = [pkg for pkg in installed_packages if fnmatch.fnmatchcase(pkg.lower(), pattern.lower())]
return matched
def _filter_protected_packages(packages: list[str]) -> list[str]:
"""过滤掉受保护的包名."""
safe = [p for p in packages if p.lower() not in {p.lower() for p in _PROTECTED_PACKAGES}]
filtered = [p for p in packages if p.lower() in {p.lower() for p in _PROTECTED_PACKAGES}]
if filtered:
print(f"跳过受保护的包: {', '.join(filtered)}")
return safe
# ============================================================================
# 公共函数
# ============================================================================
@px.tool("piptool", subcommand="i", help="安装包")
def pip_install(packages: list[str]) -> None:
"""安装包.
Parameters
----------
packages : list[str]
包名列表
"""
subprocess.run(["pip", "install", *packages], check=True)
print(f"安装完成: {', '.join(packages)}")
@px.tool("piptool", subcommand="u", help="卸载包")
def pip_uninstall(packages: list[str]) -> None:
"""卸载包.
Parameters
----------
packages : list[str]
包名列表
"""
packages_to_uninstall: list[str] = []
for pattern in packages:
packages_to_uninstall.extend(_expand_wildcard_packages(pattern))
packages_to_uninstall = _filter_protected_packages(packages_to_uninstall)
if not packages_to_uninstall:
return
subprocess.run(["pip", "uninstall", "-y", *packages_to_uninstall], check=True)
@px.tool("piptool", subcommand="r", help="重装包")
def pip_reinstall(packages: list[str], offline: bool = False) -> None:
"""重新安装包.
Parameters
----------
packages : list[str]
包名列表
offline : bool
离线模式
"""
safe_ps = _filter_protected_packages(packages)
if not safe_ps:
print("所有指定的包均为受保护包, 跳过重装")
return
subprocess.run(["pip", "uninstall", "-y", *safe_ps], check=True)
options = ["--no-index", "--find-links", "."] if offline else []
subprocess.run(["pip", "install", *options, *safe_ps], check=True)
@px.tool("piptool", subcommand="d", help="下载包")
def pip_download(packages: list[str], offline: bool = False) -> None:
"""下载包.
Parameters
----------
packages : list[str]
包名列表
offline : bool
离线模式
"""
options = ["--no-index", "--find-links", "."] if offline else []
subprocess.run(
["pip", "download", *packages, *options, "-d", PACKAGE_DIR],
check=True,
)
@px.tool("piptool", subcommand="up", help="升级 pip")
def pip_upgrade() -> None:
"""升级 pip 到最新版本."""
subprocess.run(["python", "-m", "pip", "install", "--upgrade", "pip"], check=True)
print("pip 升级完成")
@px.tool("piptool", subcommand="f", help="导出依赖")
def pip_freeze() -> None:
"""冻结依赖到 requirements.txt."""
result = subprocess.run(
["pip", "freeze", "--exclude-editable"],
capture_output=True,
text=True,
check=True,
)
Path(REQUIREMENTS_FILE).write_text(result.stdout)
print(f"依赖已导出到 {REQUIREMENTS_FILE}")
+248
View File
@@ -0,0 +1,248 @@
"""pymake - 项目构建工具.
提供构建/测试/清理/文档/格式化/发布等子命令.
"""
from __future__ import annotations
__all__ = [
"b",
"ba",
"bc",
"bump",
"bumpmi",
"bumpversion",
"c",
"cov",
"doc",
"git_add_all",
"git_push",
"git_push_tags",
"lint",
"p",
"pb",
"publish_python",
"pyrefly_check",
"sync",
"t",
"tc",
"test_coverage",
"tf",
"tox",
"twine_publish",
]
from pathlib import Path
import pyflowx as px
# ============================================================================
# 单任务别名 (cmd 任务)
# ============================================================================
@px.tool("pymake", subcommand="b", help="构建 Python 主包 (uv build)", cmd=["uv", "build"])
def b(cwd: Path = Path()) -> None:
"""构建 Python 主包."""
@px.tool("pymake", subcommand="bc", help="构建 Rust 核心模块 (maturin build)", cmd=["maturin", "build", "-r"])
def bc(cwd: Path = Path()) -> None:
"""构建 Rust 核心模块."""
@px.tool("pymake", subcommand="sync", help="同步依赖 (uv sync)", cmd=["uv", "sync"])
def sync(cwd: Path = Path()) -> None:
"""同步依赖."""
@px.tool("pymake", subcommand="c", help="清理构建产物 (调用 gitt c)", cmd=["pf", "gitt", "c"])
def c(cwd: Path = Path()) -> None:
"""清理构建产物."""
@px.tool(
"pymake",
subcommand="t",
help="运行测试",
cmd=["pytest", "-m", "not slow", "-n", "8", "--dist", "loadfile", "--color=yes", "--durations=10"],
)
def t(cwd: Path = Path()) -> None:
"""运行测试 (含并行)."""
@px.tool(
"pymake",
subcommand="tf",
help="快速测试 (无 slow)",
cmd=["pytest", "-m", "not slow", "--dist", "loadfile", "--color=yes", "--durations=10"],
)
def tf(cwd: Path = Path()) -> None:
"""快速测试 (无 slow, 无并行)."""
@px.tool(
"pymake",
subcommand="bumpmi",
help="升级次版本号 (bumpversion minor)",
cmd=["pf", "bumpversion", "--part", "minor"],
)
def bumpmi(cwd: Path = Path()) -> None:
"""升级次版本号."""
@px.tool(
"pymake",
subcommand="doc",
help="构建 Sphinx 文档",
cmd=["sphinx-build", "-b", "html", "docs", "docs/_build"],
)
def doc(cwd: Path = Path()) -> None:
"""构建 Sphinx 文档."""
@px.tool("pymake", subcommand="lint", help="代码格式化与检查 (ruff)", cmd=["ruff", "check", "--fix", "--unsafe-fixes"])
def lint(cwd: Path = Path()) -> None:
"""代码格式化与检查."""
@px.tool("pymake", subcommand="tox", help="多版本测试 (tox)", cmd=["tox", "-p", "auto"])
def tox(cwd: Path = Path()) -> None:
"""多版本测试."""
# ============================================================================
# 内部 job (hidden, 不暴露为 subcommand)
# ============================================================================
@px.tool(
"pymake",
subcommand="bumpversion",
help="升级版本号 (patch)",
cmd=["pf", "bumpversion", "--part", "patch"],
needs=["git_add_all"],
hidden=True,
)
def bumpversion(cwd: Path = Path()) -> None:
"""升级版本号 (patch, 内部 job)."""
@px.tool(
"pymake",
subcommand="test_coverage",
help="测试并生成覆盖率",
cmd=["pytest", "--cov", "-n", "8", "--dist", "loadfile", "--tb=short", "-v", "--color=yes", "--durations=10"],
needs=["c"],
hidden=True,
)
def test_coverage(cwd: Path = Path()) -> None:
"""测试并生成覆盖率 (内部 job)."""
@px.tool(
"pymake",
subcommand="pyrefly_check",
help="pyrefly 类型检查",
cmd=["pyrefly", "check", "."],
hidden=True,
)
def pyrefly_check(cwd: Path = Path()) -> None:
"""pyrefly 类型检查 (内部 job)."""
@px.tool(
"pymake",
subcommand="git_add_all",
help="git add -A",
cmd=["git", "add", "-A"],
needs=["tc"],
hidden=True,
)
def git_add_all(cwd: Path = Path()) -> None:
"""git add -A (内部 job)."""
@px.tool("pymake", subcommand="git_push", help="git push", cmd=["git", "push"], hidden=True)
def git_push(cwd: Path = Path()) -> None:
"""git push (内部 job)."""
@px.tool(
"pymake",
subcommand="git_push_tags",
help="git push --tags",
cmd=["git", "push", "--tags"],
hidden=True,
)
def git_push_tags(cwd: Path = Path()) -> None:
"""git push --tags (内部 job)."""
@px.tool(
"pymake",
subcommand="twine_publish",
help="twine upload",
cmd=["twine", "upload", "--disable-progress-bar"],
hidden=True,
)
def twine_publish(cwd: Path = Path()) -> None:
"""twine upload (内部 job)."""
@px.tool("pymake", subcommand="publish_python", help="hatch publish", cmd=["hatch", "publish"], hidden=True)
def publish_python(cwd: Path = Path()) -> None:
"""hatch publish (内部 job)."""
# ============================================================================
# 聚合 job (有 needs 无 cmd)
# ============================================================================
@px.tool("pymake", subcommand="ba", help="构建所有包 (Python + Rust)", needs=["b", "bc"], strategy="thread")
def ba(cwd: Path = Path()) -> None:
"""构建所有包 (Python + Rust)."""
@px.tool("pymake", subcommand="bump", help="升级版本号 (清理 + 检查 + add + bumpversion)", needs=["bumpversion"])
def bump(cwd: Path = Path()) -> None:
"""升级版本号 (聚合)."""
@px.tool("pymake", subcommand="cov", help="测试并生成覆盖率", needs=["test_coverage"])
def cov(cwd: Path = Path()) -> None:
"""测试并生成覆盖率 (聚合)."""
@px.tool(
"pymake",
subcommand="tc",
help="类型检查 (pyrefly + ruff)",
needs=["c", "pyrefly_check", "lint"],
strategy="thread",
)
def tc(cwd: Path = Path()) -> None:
"""类型检查 (聚合)."""
@px.tool(
"pymake",
subcommand="p",
help="推送代码 (清理 + push + push tags)",
needs=["c", "git_push", "git_push_tags"],
strategy="thread",
)
def p(cwd: Path = Path()) -> None:
"""推送代码 (聚合)."""
@px.tool(
"pymake",
subcommand="pb",
help="发布到 PyPI (twine + hatch)",
needs=["twine_publish", "publish_python"],
strategy="thread",
)
def pb(cwd: Path = Path()) -> None:
"""发布到 PyPI (聚合)."""
-327
View File
@@ -1,327 +0,0 @@
"""文件类函数模块.
聚合文件日期处理、文件等级重命名、文件夹备份、文件夹压缩工具的可复用函数.
所有公共函数通过 ``@px.register_fn`` 注册, 供 YAML 任务编排引用.
"""
from __future__ import annotations
import re
import shutil
import time
import zipfile
from pathlib import Path
import pyflowx as px
__all__ = [
"BRACKETS",
"DATE_PATTERN",
"IGNORE_DIRS",
"IGNORE_EXT",
"IGNORE_FILES",
"LEVELS",
"SEP",
"add_date_prefix",
"archive_folder",
"backup_folder",
"folderback_default",
"folderzip_default",
"get_file_timestamp",
"process_file_date",
"process_file_level",
"process_files_date",
"process_files_level",
"remove_date_prefix",
"remove_dump",
"remove_marks",
"zip_folders",
"zip_target",
]
# ============================================================================
# filedate 配置
# ============================================================================
DATE_PATTERN = re.compile(r"(20|19)\d{2}[-_#.~]?((0[1-9])|(1[012]))[-_#.~]?((0[1-9])|([12]\d)|(3[01]))[-_#.~]?")
SEP = "_"
# ============================================================================
# filelevel 配置
# ============================================================================
LEVELS: dict[str, str] = {
"0": "",
"1": "PUB,NOR",
"2": "INT",
"3": "CON",
"4": "CLA",
}
BRACKETS: tuple[str, str] = (" ([_(【-", " )]_)】")
# ============================================================================
# folderzip 配置
# ============================================================================
IGNORE_DIRS: list[str] = [".git", ".idea", ".vscode", "__pycache__"]
IGNORE_FILES: list[str] = [".gitignore"]
IGNORE: list[str] = [*IGNORE_DIRS, *IGNORE_FILES]
IGNORE_EXT: list[str] = [".zip", ".rar", ".7z", ".tar", ".gz"]
# ============================================================================
# filedate 函数
# ============================================================================
@px.register_fn
def get_file_timestamp(filepath: Path) -> str:
"""获取文件时间戳."""
modified_time = filepath.stat().st_mtime
created_time = filepath.stat().st_ctime
return time.strftime("%Y%m%d", time.localtime(max((modified_time, created_time))))
@px.register_fn
def remove_date_prefix(filepath: Path) -> Path:
"""移除文件日期前缀."""
stem = filepath.stem
new_stem = DATE_PATTERN.sub("", stem)
if new_stem != stem:
new_path = filepath.with_name(new_stem + filepath.suffix)
filepath.rename(new_path)
return new_path
return filepath
@px.register_fn
def add_date_prefix(filepath: Path) -> Path:
"""添加文件日期前缀."""
timestamp = get_file_timestamp(filepath)
stem = filepath.stem
new_stem = f"{timestamp}{SEP}{stem}"
new_path = filepath.with_name(new_stem + filepath.suffix)
if new_path != filepath:
filepath.rename(new_path)
return new_path
return filepath
@px.register_fn
def process_file_date(filepath: Path, clear: bool = False) -> None:
"""处理单个文件的日期前缀.
Parameters
----------
filepath : Path
文件路径
clear : bool
是否清除日期前缀
"""
if clear:
remove_date_prefix(filepath)
else:
new_path = remove_date_prefix(filepath)
add_date_prefix(new_path)
@px.register_fn
def process_files_date(targets: list[Path], clear: bool = False) -> None:
"""批量处理文件日期前缀.
Parameters
----------
targets : list[Path]
文件路径列表
clear : bool
是否清除日期前缀
"""
for target in targets:
if target.exists() and not target.name.startswith("."):
process_file_date(target, clear)
# ============================================================================
# filelevel 函数
# ============================================================================
@px.register_fn
def remove_marks(stem: str, marks: list[str]) -> str:
"""从文件名主干中移除所有标记."""
left_brackets, right_brackets = BRACKETS
for mark in marks:
pos = 0
while True:
pos = stem.find(mark, pos)
if pos == -1:
break
b, e = pos - 1, pos + len(mark)
if b >= 0 and e < len(stem) and stem[b] in left_brackets and stem[e] in right_brackets:
stem = stem[:b] + stem[e + 1 :]
else:
pos = e
return stem
@px.register_fn
def process_file_level(filepath: Path, level: int = 0) -> None:
"""处理单个文件的等级标记.
Parameters
----------
filepath : Path
文件路径
level : int
文件等级 (0-4), 0 用于清除等级
"""
if not (0 <= level < len(LEVELS)):
print(f"无效的等级 {level}, 必须在 0 和 {len(LEVELS) - 1} 之间")
return
if not filepath.exists():
print(f"文件不存在: {filepath}")
return
filestem = filepath.stem
original_stem = filestem
for level_names in LEVELS.values():
if level_names:
filestem = remove_marks(filestem, level_names.split(","))
for digit in map(str, range(1, 10)):
filestem = remove_marks(filestem, [digit])
if level > 0:
levelstr = LEVELS.get(str(level), "").split(",")[0]
if levelstr:
filestem = f"{filestem}({levelstr})"
if filestem != original_stem:
new_path = filepath.with_name(filestem + filepath.suffix)
filepath.rename(new_path)
print(f"重命名: {filepath} -> {new_path}")
@px.register_fn
def process_files_level(targets: list[Path], level: int = 0) -> None:
"""批量处理文件等级标记.
Parameters
----------
targets : list[Path]
文件路径列表
level : int
文件等级 (0-4)
"""
for target in targets:
process_file_level(target, level)
# ============================================================================
# folderback 函数
# ============================================================================
@px.register_fn
def remove_dump(src: Path, dst: Path, max_zip: int) -> None:
"""递归删除旧的备份 zip 文件."""
zip_paths = [filepath for filepath in dst.rglob("*.zip") if src.stem in str(filepath)]
zip_files = sorted(zip_paths, key=lambda fn: str(fn)[-19:-4])
if len(zip_files) > max_zip:
zip_files[0].unlink()
remove_dump(src, dst, max_zip)
@px.register_fn
def zip_target(src: Path, dst: Path, max_zip: int) -> None:
"""将单个文件或文件夹压缩为 zip 文件."""
files = [str(_) for _ in src.rglob("*")]
timestamp = time.strftime("_%Y%m%d_%H%M%S")
target_path = dst / (src.stem + timestamp + ".zip")
with zipfile.ZipFile(target_path, "w") as zip_file:
for file in files:
zip_file.write(file, arcname=file.replace(str(src.parent), ""))
remove_dump(src, dst, max_zip)
print(f"备份完成: {target_path}")
@px.register_fn
def backup_folder(src: str, dst: str, max_zip: int = 5) -> None:
"""备份文件夹.
Parameters
----------
src : str
源文件夹路径
dst : str
目标文件夹路径
max_zip : int
最大备份数量
"""
src_path = Path(src)
dst_path = Path(dst)
if not src_path.exists():
print(f"源文件夹不存在: {src_path}")
return
if not dst_path.exists():
dst_path.mkdir(parents=True, exist_ok=True)
print(f"创建目标文件夹: {dst_path}")
zip_target(src_path, dst_path, max_zip)
@px.register_fn("folderback_default")
def folderback_default() -> None:
"""备份当前目录到 ./backup."""
backup_folder(".", "./backup", 5)
# ============================================================================
# folderzip 函数
# ============================================================================
@px.register_fn
def archive_folder(folder: Path) -> None:
"""压缩单个文件夹."""
shutil.make_archive(
str(folder.with_name(folder.name)),
format="zip",
base_dir=folder,
)
print(f"压缩完成: {folder.name}.zip")
@px.register_fn
def zip_folders(cwd: str = ".") -> None:
"""压缩目录下的所有文件夹.
Parameters
----------
cwd : str
工作目录
"""
cwd_path = Path(cwd)
if not cwd_path.exists():
print(f"目录不存在: {cwd_path}")
return
dirs: list[Path] = [
e for e in cwd_path.iterdir() if e.is_dir() and e.name not in IGNORE_DIRS and e.suffix not in IGNORE_EXT
]
for dir_path in dirs:
archive_folder(dir_path)
@px.register_fn("folderzip_default")
def folderzip_default() -> None:
"""压缩当前目录下的所有文件夹."""
zip_folders(".")
+5
View File
@@ -0,0 +1,5 @@
"""files 子包 — 文件与文档操作工具."""
from __future__ import annotations
__all__: list[str] = []
+120
View File
@@ -0,0 +1,120 @@
"""filedate - 文件日期处理工具.
提供添加日期前缀/清除日期前缀 子命令.
"""
from __future__ import annotations
import re
import time
from pathlib import Path
import pyflowx as px
__all__ = [
"DATE_PATTERN",
"SEP",
"add_date_prefix",
"get_file_timestamp",
"process_file_date",
"process_files_date",
"remove_date_prefix",
]
# ============================================================================
# 配置
# ============================================================================
DATE_PATTERN = re.compile(r"(20|19)\d{2}[-_#.~]?((0[1-9])|(1[012]))[-_#.~]?((0[1-9])|([12]\d)|(3[01]))[-_#.~]?")
SEP = "_"
# ============================================================================
# 公共函数
# ============================================================================
def get_file_timestamp(filepath: Path) -> str:
"""获取文件时间戳."""
modified_time = filepath.stat().st_mtime
created_time = filepath.stat().st_ctime
return time.strftime("%Y%m%d", time.localtime(max((modified_time, created_time))))
def remove_date_prefix(filepath: Path) -> Path:
"""移除文件日期前缀."""
stem = filepath.stem
new_stem = DATE_PATTERN.sub("", stem)
if new_stem != stem:
new_path = filepath.with_name(new_stem + filepath.suffix)
filepath.rename(new_path)
return new_path
return filepath
def add_date_prefix(filepath: Path) -> Path:
"""添加文件日期前缀."""
timestamp = get_file_timestamp(filepath)
stem = filepath.stem
new_stem = f"{timestamp}{SEP}{stem}"
new_path = filepath.with_name(new_stem + filepath.suffix)
if new_path != filepath:
filepath.rename(new_path)
return new_path
return filepath
def process_file_date(filepath: Path, clear: bool = False) -> None:
"""处理单个文件的日期前缀.
Parameters
----------
filepath : Path
文件路径
clear : bool
是否清除日期前缀
"""
if clear:
remove_date_prefix(filepath)
else:
new_path = remove_date_prefix(filepath)
add_date_prefix(new_path)
@px.tool("filedate", subcommand="add", help="添加日期前缀")
def process_files_date_add(files: list[Path]) -> None:
"""添加日期前缀.
Parameters
----------
files : list[Path]
文件路径列表
"""
process_files_date(files, clear=False)
@px.tool("filedate", subcommand="clear", help="清除日期前缀")
def process_files_date_clear(files: list[Path]) -> None:
"""清除日期前缀.
Parameters
----------
files : list[Path]
文件路径列表
"""
process_files_date(files, clear=True)
def process_files_date(targets: list[Path], clear: bool = False) -> None:
"""批量处理文件日期前缀.
Parameters
----------
targets : list[Path]
文件路径列表
clear : bool
是否清除日期前缀
"""
for target in targets:
if target.exists() and not target.name.startswith("."):
process_file_date(target, clear)
+108
View File
@@ -0,0 +1,108 @@
"""filelevel - 文件等级重命名工具.
提供设置文件等级 子命令.
"""
from __future__ import annotations
from pathlib import Path
import pyflowx as px
__all__ = [
"BRACKETS",
"LEVELS",
"process_file_level",
"process_files_level",
"remove_marks",
]
# ============================================================================
# 配置
# ============================================================================
LEVELS: dict[str, str] = {
"0": "",
"1": "PUB,NOR",
"2": "INT",
"3": "CON",
"4": "CLA",
}
BRACKETS: tuple[str, str] = (" ([_(【-", " )]_)】")
# ============================================================================
# 公共函数
# ============================================================================
def remove_marks(stem: str, marks: list[str]) -> str:
"""从文件名主干中移除所有标记."""
left_brackets, right_brackets = BRACKETS
for mark in marks:
pos = 0
while True:
pos = stem.find(mark, pos)
if pos == -1:
break
b, e = pos - 1, pos + len(mark)
if b >= 0 and e < len(stem) and stem[b] in left_brackets and stem[e] in right_brackets:
stem = stem[:b] + stem[e + 1 :]
else:
pos = e
return stem
def process_file_level(filepath: Path, level: int = 0) -> None:
"""处理单个文件的等级标记.
Parameters
----------
filepath : Path
文件路径
level : int
文件等级 (0-4), 0 用于清除等级
"""
if not (0 <= level < len(LEVELS)):
print(f"无效的等级 {level}, 必须在 0 和 {len(LEVELS) - 1} 之间")
return
if not filepath.exists():
print(f"文件不存在: {filepath}")
return
filestem = filepath.stem
original_stem = filestem
for level_names in LEVELS.values():
if level_names:
filestem = remove_marks(filestem, level_names.split(","))
for digit in map(str, range(1, 10)):
filestem = remove_marks(filestem, [digit])
if level > 0:
levelstr = LEVELS.get(str(level), "").split(",")[0]
if levelstr:
filestem = f"{filestem}({levelstr})"
if filestem != original_stem:
new_path = filepath.with_name(filestem + filepath.suffix)
filepath.rename(new_path)
print(f"重命名: {filepath} -> {new_path}")
@px.tool("filelevel", subcommand="set", help="设置文件等级")
def process_files_level(files: list[Path], level: int = 0) -> None:
"""批量处理文件等级标记.
Parameters
----------
files : list[Path]
文件路径列表
level : int
文件等级 (0-4)
"""
for target in files:
process_file_level(target, level)
+73
View File
@@ -0,0 +1,73 @@
"""folderback - 文件夹备份工具.
备份当前文件夹到指定目录, 自动清理旧备份.
"""
from __future__ import annotations
import time
import zipfile
from pathlib import Path
import pyflowx as px
__all__ = [
"backup_folder",
"remove_dump",
"zip_target",
]
# ============================================================================
# 公共函数
# ============================================================================
def remove_dump(src: Path, dst: Path, max_zip: int) -> None:
"""递归删除旧的备份 zip 文件."""
zip_paths = [filepath for filepath in dst.rglob("*.zip") if src.stem in str(filepath)]
zip_files = sorted(zip_paths, key=lambda fn: str(fn)[-19:-4])
if len(zip_files) > max_zip:
zip_files[0].unlink()
remove_dump(src, dst, max_zip)
def zip_target(src: Path, dst: Path, max_zip: int) -> None:
"""将单个文件或文件夹压缩为 zip 文件."""
files = [str(_) for _ in src.rglob("*")]
timestamp = time.strftime("_%Y%m%d_%H%M%S")
target_path = dst / (src.stem + timestamp + ".zip")
with zipfile.ZipFile(target_path, "w") as zip_file:
for file in files:
zip_file.write(file, arcname=file.replace(str(src.parent), ""))
remove_dump(src, dst, max_zip)
print(f"备份完成: {target_path}")
@px.tool("folderback", help="备份文件夹")
def backup_folder(src: str = ".", dst: str = "./backup", max_zip: int = 5) -> None:
"""备份文件夹.
Parameters
----------
src : str
源文件夹路径 (默认: 当前目录)
dst : str
目标文件夹路径 (默认: ./backup)
max_zip : int
最大备份数量 (默认: 5)
"""
src_path = Path(src)
dst_path = Path(dst)
if not src_path.exists():
print(f"源文件夹不存在: {src_path}")
return
if not dst_path.exists():
dst_path.mkdir(parents=True, exist_ok=True)
print(f"创建目标文件夹: {dst_path}")
zip_target(src_path, dst_path, max_zip)
+64
View File
@@ -0,0 +1,64 @@
"""folderzip - 文件夹压缩工具.
压缩当前目录下的所有子文件夹为 zip 文件.
"""
from __future__ import annotations
import shutil
from pathlib import Path
import pyflowx as px
__all__ = [
"IGNORE_DIRS",
"IGNORE_EXT",
"IGNORE_FILES",
"archive_folder",
"zip_folders",
]
# ============================================================================
# 配置
# ============================================================================
IGNORE_DIRS: list[str] = [".git", ".idea", ".vscode", "__pycache__"]
IGNORE_FILES: list[str] = [".gitignore"]
IGNORE_EXT: list[str] = [".zip", ".rar", ".7z", ".tar", ".gz"]
# ============================================================================
# 公共函数
# ============================================================================
def archive_folder(folder: Path) -> None:
"""压缩单个文件夹."""
shutil.make_archive(
str(folder.with_name(folder.name)),
format="zip",
base_dir=folder,
)
print(f"压缩完成: {folder.name}.zip")
@px.tool("folderzip", help="压缩文件夹")
def zip_folders(cwd: str = ".") -> None:
"""压缩目录下的所有文件夹.
Parameters
----------
cwd : str
工作目录 (默认: 当前目录)
"""
cwd_path = Path(cwd)
if not cwd_path.exists():
print(f"目录不存在: {cwd_path}")
return
dirs: list[Path] = [
e for e in cwd_path.iterdir() if e.is_dir() and e.name not in IGNORE_DIRS and e.suffix not in IGNORE_EXT
]
for dir_path in dirs:
archive_folder(dir_path)
+520
View File
@@ -0,0 +1,520 @@
"""imagetool - 图片处理工具集.
提供 resize/crop/rotate/flip/convert/watermark/compress/info/exif/
histogram/colors 子命令, 基于 Pillow 实现.
依赖 ``pyflowx[office]`` extra 中的 ``pillow``.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pyflowx as px
__all__ = [
"image_colors",
"image_compress",
"image_convert",
"image_crop",
"image_exif",
"image_flip",
"image_histogram",
"image_info",
"image_resize",
"image_rotate",
"image_watermark",
]
try:
from PIL import Image, ImageDraw, ImageFont
HAS_PIL = True
except ImportError:
HAS_PIL = False
def _require_pil() -> bool:
"""Pillow 未安装时打印提示, 返回是否可用."""
if not HAS_PIL:
print("未安装 Pillow 库, 请安装: pip install pyflowx[office]")
return False
return True
def _save_image(img: Any, output: Path, fmt: str | None = None, quality: int = 85) -> None:
"""保存图片, 自动处理 JPEG 不支持 alpha 通道的情况."""
output.parent.mkdir(parents=True, exist_ok=True)
save_fmt = fmt or output.suffix.lstrip(".").upper()
if save_fmt == "JPG":
save_fmt = "JPEG"
if save_fmt == "JPEG":
img = img.convert("RGB")
if save_fmt in ("JPEG", "WEBP"):
img.save(output, format=save_fmt, quality=quality)
else:
img.save(output, format=save_fmt)
# ---------------------------------------------------------------------- #
# 基础操作
# ---------------------------------------------------------------------- #
@px.tool("imagetool", subcommand="r", help="调整尺寸")
def image_resize(
input_path: Path,
output_path: Path,
width: int,
height: int | None = None,
keep_ratio: bool = True,
) -> None:
"""调整图片尺寸.
Parameters
----------
input_path : Path
输入图片
output_path : Path
输出图片
width : int
目标宽度
height : int | None
目标高度 (keep_ratio=True 时仅作上限, 默认 None 表示按宽度等比)
keep_ratio : bool
是否保持宽高比 (默认 True)
"""
if not _require_pil():
return
img = Image.open(input_path)
if keep_ratio:
target_height = height if height is not None else width
img.thumbnail((width, target_height))
else:
if height is None:
height = width
img = img.resize((width, height))
_save_image(img, output_path)
print(f"调整尺寸完成: {output_path} ({img.size[0]}x{img.size[1]})")
@px.tool("imagetool", subcommand="c", help="裁剪图片")
def image_crop(
input_path: Path,
output_path: Path,
left: int,
top: int,
right: int,
bottom: int,
) -> None:
"""裁剪图片到指定矩形.
Parameters
----------
input_path : Path
输入图片
output_path : Path
输出图片
left, top, right, bottom : int
裁剪矩形坐标 (左上为原点)
"""
if not _require_pil():
return
img = Image.open(input_path)
cropped = img.crop((left, top, right, bottom))
_save_image(cropped, output_path)
print(f"裁剪完成: {output_path} ({right - left}x{bottom - top})")
@px.tool("imagetool", subcommand="ro", help="旋转图片")
def image_rotate(
input_path: Path,
output_path: Path,
degrees: float,
expand: bool = False,
) -> None:
"""旋转图片.
Parameters
----------
input_path : Path
输入图片
output_path : Path
输出图片
degrees : float
旋转角度 (正值逆时针)
expand : bool
是否扩展画布以容纳整个旋转后的图片 (默认 False)
"""
if not _require_pil():
return
img = Image.open(input_path)
rotated = img.rotate(degrees, expand=expand)
_save_image(rotated, output_path)
print(f"旋转完成: {output_path} ({degrees}度)")
@px.tool("imagetool", subcommand="fl", help="翻转图片")
def image_flip(
input_path: Path,
output_path: Path,
direction: str = "horizontal",
) -> None:
"""翻转图片.
Parameters
----------
input_path : Path
输入图片
output_path : Path
输出图片
direction : str
翻转方向: "horizontal" (水平镜像) / "vertical" (垂直镜像)
"""
if not _require_pil():
return
img = Image.open(input_path)
method = Image.Transpose.FLIP_LEFT_RIGHT if direction == "horizontal" else Image.Transpose.FLIP_TOP_BOTTOM
flipped = img.transpose(method)
_save_image(flipped, output_path)
print(f"翻转完成: {output_path} ({direction})")
@px.tool("imagetool", subcommand="cv", help="格式转换")
def image_convert(
input_path: Path,
output_path: Path,
format: str | None = None,
quality: int = 85,
) -> None:
"""转换图片格式.
Parameters
----------
input_path : Path
输入图片
output_path : Path
输出图片 (后缀决定格式, 除非 format 显式指定)
format : str | None
目标格式 (如 "PNG"/"JPEG"/"WEBP"), None 时按 output_path 后缀推断
quality : int
压缩质量 (1-100, 仅对 JPEG/WEBP 有效)
"""
if not _require_pil():
return
img = Image.open(input_path)
_save_image(img, output_path, fmt=format, quality=quality)
actual_fmt = format or output_path.suffix.lstrip(".").upper()
print(f"格式转换完成: {output_path} ({actual_fmt})")
@px.tool("imagetool", subcommand="wm", help="添加文字水印")
def image_watermark(
input_path: Path,
output_path: Path,
text: str,
position: str = "bottom-right",
opacity: float = 0.5,
font_size: int = 32,
) -> None:
"""添加文字水印.
Parameters
----------
input_path : Path
输入图片
output_path : Path
输出图片
text : str
水印文字
position : str
水印位置: top-left/top-right/bottom-left/bottom-right/center
opacity : float
不透明度 (0.0-1.0)
font_size : int
字体大小
"""
if not _require_pil():
return
img = Image.open(input_path).convert("RGBA")
overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
font = _load_font(font_size)
bbox = draw.textbbox((0, 0), text, font=font)
text_w = int(bbox[2] - bbox[0])
text_h = int(bbox[3] - bbox[1])
margin = 10
x, y = _resolve_position(position, img.size, text_w, text_h, margin)
alpha = int(255 * max(0.0, min(1.0, opacity)))
draw.text((x, y), text, font=font, fill=(255, 255, 255, alpha))
result = Image.alpha_composite(img, overlay)
_save_image(result, output_path)
print(f"水印添加完成: {output_path}")
def _load_font(size: int) -> Any:
"""加载字体, 优先 truetype, 失败回退默认字体."""
candidates = ("DejaVuSans.ttf", "Arial.ttf", "LiberationSans-Regular.ttf")
for name in candidates:
try:
return ImageFont.truetype(name, size)
except OSError:
continue
return ImageFont.load_default()
def _resolve_position(
position: str,
img_size: tuple[int, int],
text_w: int,
text_h: int,
margin: int,
) -> tuple[int, int]:
"""根据位置描述符计算水印坐标."""
w, h = img_size
pos_map = {
"top-left": (margin, margin),
"top-right": (w - text_w - margin, margin),
"bottom-left": (margin, h - text_h - margin),
"bottom-right": (w - text_w - margin, h - text_h - margin),
"center": ((w - text_w) // 2, (h - text_h) // 2),
}
return pos_map.get(position, pos_map["bottom-right"])
@px.tool("imagetool", subcommand="cp", help="压缩图片")
def image_compress(
input_path: Path,
output_path: Path,
quality: int = 85,
) -> None:
"""压缩图片 (重新编码以减小体积).
Parameters
----------
input_path : Path
输入图片
output_path : Path
输出图片
quality : int
压缩质量 (1-100)
"""
if not _require_pil():
return
img = Image.open(input_path)
fmt = input_path.suffix.lstrip(".").upper()
_save_image(img, output_path, fmt=fmt, quality=quality)
in_size = input_path.stat().st_size
out_size = output_path.stat().st_size
ratio = (1 - out_size / in_size) * 100 if in_size > 0 else 0.0
print(f"压缩完成: {output_path} (原 {in_size}B → 新 {out_size}B, 节省 {ratio:.1f}%)")
# ---------------------------------------------------------------------- #
# 元数据与信息
# ---------------------------------------------------------------------- #
@px.tool("imagetool", subcommand="i", help="查看图片信息")
def image_info(input_path: Path, json: bool = False) -> None:
"""打印图片信息 (尺寸/格式/模式/EXIF 摘要).
Parameters
----------
input_path : Path
输入图片
json : bool
是否以 JSON 格式输出 (默认纯文本表格)
"""
if not _require_pil():
return
img = Image.open(input_path)
exif = img.getexif()
exif_count = len(exif) if exif else 0
import json as json_mod
data = {
"path": str(input_path),
"format": img.format,
"mode": img.mode,
"width": img.size[0],
"height": img.size[1],
"exif_tags": exif_count,
}
if json:
print(json_mod.dumps(data, ensure_ascii=False, indent=2))
else:
print(f"文件: {data['path']}")
print(f"格式: {data['format']}")
print(f"模式: {data['mode']}")
print(f"尺寸: {data['width']}x{data['height']}")
print(f"EXIF 标签数: {data['exif_tags']}")
@px.tool("imagetool", subcommand="e", help="读取/修改 EXIF")
def image_exif(
input_path: Path,
output_path: Path | None = None,
show: bool = True,
set: list[str] | None = None,
clear: bool = False,
) -> None:
"""读取或修改 EXIF 元数据.
Parameters
----------
input_path : Path
输入图片
output_path : Path | None
输出路径 (None 时原地覆盖; 仅 show=True 时可省略)
show : bool
打印全部 EXIF 标签 (默认 True)
set : list[str] | None
设置标签, 格式 ["KEY=VALUE", ...] (KEY 为数字标签号)
clear : bool
清空所有 EXIF 标签 (在 set 之前执行)
"""
if not _require_pil():
return
img = Image.open(input_path)
exif = img.getexif()
if show:
_print_exif(exif)
modified = _apply_exif_modifications(exif, set, clear)
if modified:
_save_exif(img, exif, output_path if output_path is not None else input_path)
def _print_exif(exif: Any) -> None:
"""打印 EXIF 标签."""
if exif:
for tag, value in exif.items():
print(f" {tag}: {value}")
else:
print(" (无 EXIF 数据)")
def _apply_exif_modifications(exif: Any, set_items: list[str] | None, clear: bool) -> bool:
"""应用 EXIF 修改 (clear + set), 返回是否有改动."""
if clear:
for tag in list(exif.keys()):
del exif[tag]
if set_items:
for item in set_items:
_apply_single_exif_set(exif, item)
return bool(set_items or clear)
def _apply_single_exif_set(exif: Any, item: str) -> None:
"""解析并应用单个 KEY=VALUE 设置项."""
if "=" not in item:
print(f"跳过无效项 (缺少 =): {item}")
return
key_str, value = item.split("=", 1)
try:
tag = int(key_str)
except ValueError:
print(f"跳过无效标签号: {key_str}")
return
exif[tag] = value
def _save_exif(img: Any, exif: Any, output_path: Path) -> None:
"""保存图片与 EXIF."""
exif_bytes = exif.tobytes() if exif else b""
img.save(output_path, exif=exif_bytes)
print(f"EXIF 已保存: {output_path}")
@px.tool("imagetool", subcommand="hi", help="颜色直方图")
def image_histogram(
input_path: Path,
channel: str = "rgb",
) -> None:
"""打印颜色直方图统计 (每通道 8 桶).
Parameters
----------
input_path : Path
输入图片
channel : str
通道: "rgb" (R/G/B 三通道) / "luminance" (亮度单通道)
"""
if not _require_pil():
return
img = Image.open(input_path)
hist = img.histogram()
buckets = 8
if channel == "luminance":
gray = img.convert("L")
gray_hist = gray.histogram()
print("亮度直方图 (8 桶):")
_print_histogram_buckets(gray_hist, buckets, "L")
else:
print("RGB 直方图 (8 桶):")
if len(hist) == 256:
_print_histogram_buckets(hist, buckets, "L")
else:
for idx, name in enumerate(("R", "G", "B")):
start = idx * 256
_print_histogram_buckets(hist[start : start + 256], buckets, name)
def _print_histogram_buckets(channel_hist: list[int], buckets: int, name: str) -> None:
"""将单通道 256 桶直方图聚合为指定桶数并打印."""
bucket_size = max(1, len(channel_hist) // buckets)
print(f" {name}:")
for i in range(buckets):
start = i * bucket_size
end = min((i + 1) * bucket_size, len(channel_hist))
count = sum(channel_hist[start:end])
slice_max = max(channel_hist[start:end] or [1])
bar = "#" * min(40, count * 40 // max(1, slice_max))
print(f" [{start:3d}-{end:3d}] {count:>8d} {bar}")
@px.tool("imagetool", subcommand="co", help="提取主色调")
def image_colors(
input_path: Path,
count: int = 5,
) -> None:
"""提取并打印主色调.
Parameters
----------
input_path : Path
输入图片
count : int
提取的颜色数 (默认 5)
"""
if not _require_pil():
return
img = Image.open(input_path).convert("RGB")
quantized = img.quantize(colors=count)
palette = quantized.getpalette()
if palette is None:
print("无法提取调色板")
return
actual_count = len(palette) // 3
print(f"主色调 (前 {min(count, actual_count)} 色):")
for i in range(min(count, actual_count)):
r = palette[i * 3]
g = palette[i * 3 + 1]
b = palette[i * 3 + 2]
hex_color = f"#{r:02X}{g:02X}{b:02X}"
print(f" {i + 1}. {hex_color} rgb({r}, {g}, {b})")
@@ -1,23 +1,16 @@
"""媒体类函数模块.
"""pdftool - PDF 文件工具集.
聚合 PDF 工具 (pdftool) 和截图工具 (screenshot) 的可复用函数.
所有公共函数通过 ``@px.register_fn`` 注册, YAML 任务编排引用.
提供 PDF 合并/拆分/压缩/加密/解密/提取文本/提取图片/水印/旋转/裁剪/
信息/OCR/转图片/修复 等子命令.
"""
from __future__ import annotations
import subprocess
from datetime import datetime
from pathlib import Path
import pyflowx as px
from pyflowx.conditions import Constants
__all__ = [
"DEFAULT_PASSWORD",
"DEFAULT_QUALITY",
"PDF_SUFFIX",
"get_screenshot_path",
"pdf_add_watermark",
"pdf_compress",
"pdf_crop",
@@ -33,8 +26,6 @@ __all__ = [
"pdf_rotate",
"pdf_split",
"pdf_to_images",
"take_screenshot_area",
"take_screenshot_full",
]
try:
@@ -52,25 +43,34 @@ except ImportError:
HAS_PYPDF = False
# ============================================================================
# 配置
# ============================================================================
PDF_SUFFIX = ".pdf"
DEFAULT_QUALITY = 75
DEFAULT_PASSWORD = ""
def _require_pymupdf() -> bool:
"""PyMuPDF 未安装时打印提示, 返回是否可用."""
if not HAS_PYMUPDF:
print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
return False
return True
# ============================================================================
# PDF 函数
# ============================================================================
@px.register_fn
def pdf_merge(input_paths: list[Path], output_path: Path) -> None:
"""合并多个 PDF 文件."""
def _require_pypdf() -> bool:
"""pypdf 未安装时打印提示, 返回是否可用."""
if not HAS_PYPDF:
print("未安装 pypdf 库, 请安装: pip install pypdf")
return False
return True
@px.tool("pdftool", subcommand="m", help="合并 PDF")
def pdf_merge(input_paths: list[Path], output_path: Path = Path("merged.pdf")) -> None:
"""合并多个 PDF 文件.
Parameters
----------
input_paths : list[Path]
输入 PDF 文件列表
output_path : Path
输出文件 (默认: merged.pdf)
"""
if not _require_pypdf():
return
writer = pypdf.PdfWriter()
@@ -87,11 +87,18 @@ def pdf_merge(input_paths: list[Path], output_path: Path) -> None:
print(f"合并完成: {output_path}")
@px.register_fn
def pdf_split(input_path: Path, output_dir: Path) -> None:
"""拆分 PDF 文件为单页."""
if not HAS_PYPDF:
print("未安装 pypdf 库, 请安装: pip install pypdf")
@px.tool("pdftool", subcommand="s", help="拆分 PDF")
def pdf_split(input_path: Path, output_dir: Path = Path("split")) -> None:
"""拆分 PDF 文件为单页.
Parameters
----------
input_path : Path
输入 PDF 文件
output_dir : Path
输出目录 (默认: split)
"""
if not _require_pypdf():
return
reader = pypdf.PdfReader(str(input_path))
@@ -107,11 +114,18 @@ def pdf_split(input_path: Path, output_dir: Path) -> None:
print(f"拆分完成: {output_dir}")
@px.register_fn
def pdf_compress(input_path: Path, output_path: Path) -> None:
"""压缩 PDF 文件."""
if not HAS_PYMUPDF:
print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
@px.tool("pdftool", subcommand="c", help="压缩 PDF")
def pdf_compress(input_path: Path, output_path: Path = Path("compressed.pdf")) -> None:
"""压缩 PDF 文件.
Parameters
----------
input_path : Path
输入 PDF 文件
output_path : Path
输出文件 (默认: compressed.pdf)
"""
if not _require_pymupdf():
return
doc = fitz.open(str(input_path))
@@ -125,11 +139,23 @@ def pdf_compress(input_path: Path, output_path: Path) -> None:
print(f"压缩完成: {output_path} (缩小 {ratio:.1f}%)")
@px.register_fn
def pdf_encrypt(input_path: Path, output_path: Path, password: str) -> None:
"""加密 PDF 文件."""
if not HAS_PYPDF:
print("未安装 pypdf 库, 请安装: pip install pypdf")
@px.tool("pdftool", subcommand="e", help="加密 PDF")
def pdf_encrypt(input_path: Path, output_path: Path = Path("encrypted.pdf"), password: str = "") -> None:
"""加密 PDF 文件.
Parameters
----------
input_path : Path
输入 PDF 文件
output_path : Path
输出文件 (默认: encrypted.pdf)
password : str
密码 (必填)
"""
if not password:
print("错误: --password 为必填参数")
return
if not _require_pypdf():
return
reader = pypdf.PdfReader(str(input_path))
@@ -146,11 +172,23 @@ def pdf_encrypt(input_path: Path, output_path: Path, password: str) -> None:
print(f"加密完成: {output_path}")
@px.register_fn
def pdf_decrypt(input_path: Path, output_path: Path, password: str) -> None:
"""解密 PDF 文件."""
if not HAS_PYPDF:
print("未安装 pypdf 库, 请安装: pip install pypdf")
@px.tool("pdftool", subcommand="d", help="解密 PDF")
def pdf_decrypt(input_path: Path, output_path: Path = Path("decrypted.pdf"), password: str = "") -> None:
"""解密 PDF 文件.
Parameters
----------
input_path : Path
输入 PDF 文件
output_path : Path
输出文件 (默认: decrypted.pdf)
password : str
密码 (必填)
"""
if not password:
print("错误: --password 为必填参数")
return
if not _require_pypdf():
return
reader = pypdf.PdfReader(str(input_path))
@@ -168,11 +206,18 @@ def pdf_decrypt(input_path: Path, output_path: Path, password: str) -> None:
print(f"解密完成: {output_path}")
@px.register_fn
def pdf_extract_text(input_path: Path, output_path: Path) -> None:
"""提取 PDF 文本."""
if not HAS_PYMUPDF:
print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
@px.tool("pdftool", subcommand="xt", help="提取文本")
def pdf_extract_text(input_path: Path, output_path: Path = Path("output.txt")) -> None:
"""提取 PDF 文本.
Parameters
----------
input_path : Path
输入 PDF 文件
output_path : Path
输出文件 (默认: output.txt)
"""
if not _require_pymupdf():
return
doc = fitz.open(str(input_path))
@@ -186,11 +231,18 @@ def pdf_extract_text(input_path: Path, output_path: Path) -> None:
print(f"文本提取完成: {output_path}")
@px.register_fn
def pdf_extract_images(input_path: Path, output_dir: Path) -> None:
"""提取 PDF 图片."""
if not HAS_PYMUPDF:
print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
@px.tool("pdftool", subcommand="xi", help="提取图片")
def pdf_extract_images(input_path: Path, output_dir: Path = Path("images")) -> None:
"""提取 PDF 图片.
Parameters
----------
input_path : Path
输入 PDF 文件
output_dir : Path
输出目录 (默认: images)
"""
if not _require_pymupdf():
return
doc = fitz.open(str(input_path))
@@ -213,11 +265,22 @@ def pdf_extract_images(input_path: Path, output_dir: Path) -> None:
print(f"图片提取完成: {output_dir} (共 {image_count} 张)")
@px.register_fn
def pdf_add_watermark(input_path: Path, output_path: Path, text: str = "CONFIDENTIAL") -> None:
"""添加 PDF 水印."""
if not HAS_PYMUPDF:
print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
@px.tool("pdftool", subcommand="w", help="添加水印")
def pdf_add_watermark(
input_path: Path, output_path: Path = Path("watermarked.pdf"), text: str = "CONFIDENTIAL"
) -> None:
"""添加 PDF 水印.
Parameters
----------
input_path : Path
输入 PDF 文件
output_path : Path
输出文件 (默认: watermarked.pdf)
text : str
水印文字 (默认: CONFIDENTIAL)
"""
if not _require_pymupdf():
return
doc = fitz.open(str(input_path))
@@ -234,11 +297,20 @@ def pdf_add_watermark(input_path: Path, output_path: Path, text: str = "CONFIDEN
print(f"水印添加完成: {output_path}")
@px.register_fn
def pdf_rotate(input_path: Path, output_path: Path, rotation: int = 90) -> None:
"""旋转 PDF 页面."""
if not HAS_PYMUPDF:
print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
@px.tool("pdftool", subcommand="r", help="旋转 PDF")
def pdf_rotate(input_path: Path, output_path: Path = Path("rotated.pdf"), rotation: int = 90) -> None:
"""旋转 PDF 页面.
Parameters
----------
input_path : Path
输入 PDF 文件
output_path : Path
输出文件 (默认: rotated.pdf)
rotation : int
旋转角度 (默认: 90)
"""
if not _require_pymupdf():
return
doc = fitz.open(str(input_path))
@@ -251,11 +323,24 @@ def pdf_rotate(input_path: Path, output_path: Path, rotation: int = 90) -> None:
print(f"旋转完成: {output_path}")
@px.register_fn
def pdf_crop(input_path: Path, output_path: Path, margins: tuple[int, int, int, int]) -> None:
"""裁剪 PDF 页面."""
if not HAS_PYMUPDF:
print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
@px.tool("pdftool", subcommand="crop", help="裁剪 PDF")
def pdf_crop(
input_path: Path,
output_path: Path = Path("cropped.pdf"),
margins: tuple[int, int, int, int] = (10, 10, 10, 10),
) -> None:
"""裁剪 PDF 页面.
Parameters
----------
input_path : Path
输入 PDF 文件
output_path : Path
输出文件 (默认: cropped.pdf)
margins : tuple[int, int, int, int]
边距 (, , , ), 默认 (10, 10, 10, 10)
"""
if not _require_pymupdf():
return
doc = fitz.open(str(input_path))
@@ -277,11 +362,16 @@ def pdf_crop(input_path: Path, output_path: Path, margins: tuple[int, int, int,
print(f"裁剪完成: {output_path}")
@px.register_fn
@px.tool("pdftool", subcommand="i", help="查看 PDF 信息")
def pdf_info(input_path: Path) -> None:
"""显示 PDF 信息."""
if not HAS_PYMUPDF:
print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
"""显示 PDF 信息.
Parameters
----------
input_path : Path
输入 PDF 文件
"""
if not _require_pymupdf():
return
doc = fitz.open(str(input_path))
@@ -299,9 +389,19 @@ def pdf_info(input_path: Path) -> None:
doc.close()
@px.register_fn
def pdf_ocr(input_path: Path, output_path: Path, lang: str = "chi_sim+eng") -> None:
"""PDF OCR 识别."""
@px.tool("pdftool", subcommand="ocr", help="PDF OCR 识别")
def pdf_ocr(input_path: Path, output_path: Path = Path("ocr.pdf"), lang: str = "chi_sim+eng") -> None:
"""PDF OCR 识别.
Parameters
----------
input_path : Path
输入 PDF 文件
output_path : Path
输出文件 (默认: ocr.pdf)
lang : str
识别语言 (默认: chi_sim+eng)
"""
try:
import pytesseract
from PIL import Image
@@ -309,8 +409,7 @@ def pdf_ocr(input_path: Path, output_path: Path, lang: str = "chi_sim+eng") -> N
print("未安装 OCR 相关库, 请安装: pip install pytesseract pillow")
return
if not HAS_PYMUPDF:
print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
if not _require_pymupdf():
return
doc = fitz.open(str(input_path))
@@ -334,11 +433,19 @@ def pdf_ocr(input_path: Path, output_path: Path, lang: str = "chi_sim+eng") -> N
print(f"OCR 识别完成: {output_path}")
@px.register_fn
def pdf_reorder(input_path: Path, output_path: Path, order: list[int]) -> None:
"""重排 PDF 页面顺序."""
if not HAS_PYPDF:
print("未安装 pypdf 库, 请安装: pip install pypdf")
"""重排 PDF 页面顺序.
Parameters
----------
input_path : Path
输入 PDF 文件
output_path : Path
输出文件
order : list[int]
页面顺序列表 (0-based)
"""
if not _require_pypdf():
return
reader = pypdf.PdfReader(str(input_path))
@@ -355,11 +462,20 @@ def pdf_reorder(input_path: Path, output_path: Path, order: list[int]) -> None:
print(f"重排完成: {output_path}")
@px.register_fn
def pdf_to_images(input_path: Path, output_dir: Path, dpi: int = 300) -> None:
"""PDF 转图片."""
if not HAS_PYMUPDF:
print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
@px.tool("pdftool", subcommand="img", help="PDF 转图片")
def pdf_to_images(input_path: Path, output_dir: Path = Path("images"), dpi: int = 300) -> None:
"""PDF 转图片.
Parameters
----------
input_path : Path
输入 PDF 文件
output_dir : Path
输出目录 (默认: images)
dpi : int
DPI (默认: 300)
"""
if not _require_pymupdf():
return
doc = fitz.open(str(input_path))
@@ -375,11 +491,18 @@ def pdf_to_images(input_path: Path, output_dir: Path, dpi: int = 300) -> None:
print(f"转换完成: {output_dir}")
@px.register_fn
def pdf_repair(input_path: Path, output_path: Path) -> None:
"""修复 PDF 文件."""
if not HAS_PYMUPDF:
print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
@px.tool("pdftool", subcommand="repair", help="修复 PDF")
def pdf_repair(input_path: Path, output_path: Path = Path("repaired.pdf")) -> None:
"""修复 PDF 文件.
Parameters
----------
input_path : Path
输入 PDF 文件
output_path : Path
输出文件 (默认: repaired.pdf)
"""
if not _require_pymupdf():
return
doc = fitz.open(str(input_path))
@@ -387,112 +510,3 @@ def pdf_repair(input_path: Path, output_path: Path) -> None:
doc.save(str(output_path), garbage=4, deflate=True, clean=True)
doc.close()
print(f"修复完成: {output_path}")
# ============================================================================
# screenshot 函数
# ============================================================================
@px.register_fn
def get_screenshot_path(filename: str | None = None) -> Path:
"""获取截图保存路径.
Parameters
----------
filename : str | None
文件名, 如果为 None 则自动生成
Returns
-------
Path
截图保存路径
"""
if filename is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"screenshot_{timestamp}.png"
screenshots_dir = Path.home() / "Pictures" / "screenshots"
screenshots_dir.mkdir(parents=True, exist_ok=True)
return screenshots_dir / filename
@px.register_fn
def take_screenshot_full(filename: str | None = None) -> None:
"""全屏截图.
Parameters
----------
filename : str | None
文件名
"""
output_path = get_screenshot_path(filename)
if Constants.IS_WINDOWS:
ps_script = f"""
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$screen = [System.Windows.Forms.Screen]::PrimaryScreen
$bounds = $screen.Bounds
$bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
$bitmap.Save('{output_path.as_posix()}')
$graphics.Dispose()
$bitmap.Dispose()
"""
subprocess.run(["powershell", "-Command", ps_script], check=True)
elif Constants.IS_MACOS:
subprocess.run(["screencapture", "-x", str(output_path)], check=True)
else:
try:
subprocess.run(["gnome-screenshot", "-f", str(output_path)], check=True)
except FileNotFoundError:
subprocess.run(["scrot", str(output_path)], check=True)
print(f"截图已保存: {output_path}")
@px.register_fn
def take_screenshot_area(filename: str | None = None) -> None:
"""区域截图.
Parameters
----------
filename : str | None
文件名
"""
output_path = get_screenshot_path(filename)
if Constants.IS_WINDOWS:
ps_script = f"""
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$form = New-Object System.Windows.Forms.Form
$form.WindowState = 'Maximized'
$form.FormBorderStyle = 'None'
$form.BackColor = [System.Drawing.Color]::FromArgb(1, 0, 0)
$form.Opacity = 0.5
$form.TopMost = $true
$form.Show()
Start-Sleep -Milliseconds 100
$screen = [System.Windows.Forms.Screen]::PrimaryScreen
$bounds = $screen.Bounds
$bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
$form.Close()
$bitmap.Save('{output_path.as_posix()}')
$graphics.Dispose()
$bitmap.Dispose()
"""
subprocess.run(["powershell", "-Command", ps_script], check=True)
elif Constants.IS_MACOS:
subprocess.run(["screencapture", "-i", str(output_path)], check=True)
else:
try:
subprocess.run(["gnome-screenshot", "-a", "-f", str(output_path)], check=True)
except FileNotFoundError:
subprocess.run(["scrot", "-s", str(output_path)], check=True)
print(f"截图已保存: {output_path}")
+116
View File
@@ -0,0 +1,116 @@
"""screenshot - 截图工具.
跨平台截图: Windows PowerShell, macOS screencapture, Linux gnome-screenshot/scrot.
"""
from __future__ import annotations
import subprocess
from datetime import datetime
from pathlib import Path
import pyflowx as px
from pyflowx.conditions import Constants
def get_screenshot_path(filename: str | None = None) -> Path:
"""获取截图保存路径.
Parameters
----------
filename : str | None
文件名, 如果为 None 则自动生成
Returns
-------
Path
截图保存路径
"""
if filename is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"screenshot_{timestamp}.png"
screenshots_dir = Path.home() / "Pictures" / "screenshots"
screenshots_dir.mkdir(parents=True, exist_ok=True)
return screenshots_dir / filename
@px.tool("screenshot", subcommand="full", help="全屏截图")
def take_screenshot_full(filename: str | None = None) -> None:
"""全屏截图.
Parameters
----------
filename : str | None
文件名
"""
output_path = get_screenshot_path(filename)
if Constants.IS_WINDOWS:
ps_script = f"""
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$screen = [System.Windows.Forms.Screen]::PrimaryScreen
$bounds = $screen.Bounds
$bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
$bitmap.Save('{output_path.as_posix()}')
$graphics.Dispose()
$bitmap.Dispose()
"""
subprocess.run(["powershell", "-Command", ps_script], check=True)
elif Constants.IS_MACOS:
subprocess.run(["screencapture", "-x", str(output_path)], check=True)
else:
try:
subprocess.run(["gnome-screenshot", "-f", str(output_path)], check=True)
except FileNotFoundError:
subprocess.run(["scrot", str(output_path)], check=True)
print(f"截图已保存: {output_path}")
@px.tool("screenshot", subcommand="area", help="区域截图")
def take_screenshot_area(filename: str | None = None) -> None:
"""区域截图.
Parameters
----------
filename : str | None
文件名
"""
output_path = get_screenshot_path(filename)
if Constants.IS_WINDOWS:
ps_script = f"""
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$form = New-Object System.Windows.Forms.Form
$form.WindowState = 'Maximized'
$form.FormBorderStyle = 'None'
$form.BackColor = [System.Drawing.Color]::FromArgb(1, 0, 0)
$form.Opacity = 0.5
$form.TopMost = $true
$form.Show()
Start-Sleep -Milliseconds 100
$screen = [System.Windows.Forms.Screen]::PrimaryScreen
$bounds = $screen.Bounds
$bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
$form.Close()
$bitmap.Save('{output_path.as_posix()}')
$graphics.Dispose()
$bitmap.Dispose()
"""
subprocess.run(["powershell", "-Command", ps_script], check=True)
elif Constants.IS_MACOS:
subprocess.run(["screencapture", "-i", str(output_path)], check=True)
else:
try:
subprocess.run(["gnome-screenshot", "-a", "-f", str(output_path)], check=True)
except FileNotFoundError:
subprocess.run(["scrot", "-s", str(output_path)], check=True)
print(f"截图已保存: {output_path}")
+5
View File
@@ -0,0 +1,5 @@
"""infra 子包 — 基础设施与服务部署工具."""
from __future__ import annotations
__all__: list[str] = []
+37
View File
@@ -0,0 +1,37 @@
"""dockercmd - Docker 镜像登录工具.
登录腾讯云 Docker 镜像仓库.
"""
from __future__ import annotations
import getpass
import subprocess
import pyflowx as px
_DOCKER_MIRROR_TENCENT: str = "ccr.ccs.tencentyun.com"
@px.tool("dockercmd", subcommand="login", help="登录腾讯云 Docker 镜像仓库")
def docker_login_tencent(username: str = "") -> None:
"""登录腾讯云 Docker 镜像仓库.
Parameters
----------
username : str
Docker 用户名 (为空时由 docker 交互式提示输入)
"""
user = username or getpass.getuser()
try:
subprocess.run(
["docker", "login", "--username", user, _DOCKER_MIRROR_TENCENT],
check=True,
)
except subprocess.CalledProcessError as e:
print(f"docker login 失败 (returncode={e.returncode}): {e}")
return
except FileNotFoundError:
print("docker 命令未找到,请确认 Docker 已安装并在 PATH 中")
return
print(f"已登录腾讯云镜像仓库 (用户: {user})")
+418
View File
@@ -0,0 +1,418 @@
"""envdev - 开发环境镜像源配置工具.
配置 Python / Conda / Rust 镜像源 (Linux 还会安装 Qt 中文字体Docker).
所有镜像源参数互不影响, 可单独使用.
Linux 专用操作 (系统镜像/Qt/字体/Docker) 在非 Linux 平台上由函数内部跳过.
"""
from __future__ import annotations
import getpass
import os
import shutil
import subprocess
from pathlib import Path
from typing import Literal
import pyflowx as px
from pyflowx.conditions import Constants
from pyflowx.ops._common import ensure_platform
__all__ = [
"download_rustup_script",
"install_linux_docker",
"install_linux_fonts",
"install_linux_qt_libs",
"install_rust_toolchain",
"setup_conda_mirror",
"setup_linux_system_mirror",
"setup_python_mirror",
"setup_rust_mirror",
]
# ============================================================================
# 配置常量
# ============================================================================
PyMirrorType = Literal["tsinghua", "aliyun", "huaweicloud", "ustc", "zju"]
CondaMirrorType = Literal["tsinghua", "ustc", "bsfu", "aliyun"]
RustMirrorType = Literal["tsinghua", "ustc", "aliyun"]
_PIP_INDEX_URLS: dict[str, str] = {
"tsinghua": "https://pypi.tuna.tsinghua.edu.cn/simple",
"aliyun": "https://mirrors.aliyun.com/pypi/simple/",
"huaweicloud": "https://mirrors.huaweicloud.com/repository/pypi/simple/",
"ustc": "https://pypi.mirrors.ustc.edu.cn/simple/",
"zju": "https://mirrors.zju.edu.cn/pypi/simple/",
}
_PIP_TRUSTED_HOSTS: dict[str, str] = {
"tsinghua": "pypi.tuna.tsinghua.edu.cn",
"aliyun": "mirrors.aliyun.com",
"huaweicloud": "mirrors.huaweicloud.com",
"ustc": "pypi.mirrors.ustc.edu.cn",
"zju": "mirrors.zju.edu.cn",
}
_UV_PYTHON_INSTALL_MIRROR: str = "https://registry.npmmirror.com/-/binary/python-build-standalone"
_CONDA_MIRROR_URLS: dict[str, list[str]] = {
"tsinghua": [
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/",
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/",
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/r/",
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/msys2/",
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/pro/",
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/",
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/bioconda/",
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/menpo/",
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/pytorch/",
],
"ustc": [
"https://mirrors.ustc.edu.cn/anaconda/pkgs/main/",
"https://mirrors.ustc.edu.cn/anaconda/pkgs/free/",
"https://mirrors.ustc.edu.cn/anaconda/pkgs/r/",
"https://mirrors.ustc.edu.cn/anaconda/pkgs/msys2/",
"https://mirrors.ustc.edu.cn/anaconda/pkgs/pro/",
"https://mirrors.ustc.edu.cn/anaconda/pkgs/dev/",
"https://mirrors.ustc.edu.cn/anaconda/cloud/conda-forge/",
"https://mirrors.ustc.edu.cn/anaconda/cloud/bioconda/",
"https://mirrors.ustc.edu.cn/anaconda/cloud/menpo/",
"https://mirrors.ustc.edu.cn/anaconda/cloud/pytorch/",
],
"bsfu": [
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/main/",
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/free/",
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/r/",
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/msys2/",
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/pro/",
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/dev/",
"https://mirrors.bsfu.edu.cn/anaconda/cloud/conda-forge/",
"https://mirrors.bsfu.edu.cn/anaconda/cloud/bioconda/",
"https://mirrors.bsfu.edu.cn/anaconda/cloud/menpo/",
"https://mirrors.bsfu.edu.cn/anaconda/cloud/pytorch/",
],
"aliyun": [
"https://mirrors.aliyun.com/anaconda/pkgs/main/",
"https://mirrors.aliyun.com/anaconda/pkgs/free/",
"https://mirrors.aliyun.com/anaconda/pkgs/r/",
"https://mirrors.aliyun.com/anaconda/pkgs/msys2/",
"https://mirrors.aliyun.com/anaconda/pkgs/pro/",
"https://mirrors.aliyun.com/anaconda/pkgs/dev/",
"https://mirrors.aliyun.com/anaconda/cloud/conda-forge/",
"https://mirrors.aliyun.com/anaconda/cloud/bioconda/",
"https://mirrors.aliyun.com/anaconda/cloud/menpo/",
"https://mirrors.aliyun.com/anaconda/cloud/pytorch/",
],
}
_RUSTUP_MIRRORS: dict[str, dict[str, str]] = {
"tsinghua": {
"RUSTUP_DIST_SERVER": "https://mirrors.tuna.tsinghua.edu.cn/rustup",
"RUSTUP_UPDATE_ROOT": "https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup",
"TOML_REGISTRY": "https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/",
},
"aliyun": {
"RUSTUP_DIST_SERVER": "https://mirrors.aliyun.com/rustup",
"RUSTUP_UPDATE_ROOT": "https://mirrors.aliyun.com/rustup/rustup",
"TOML_REGISTRY": "https://mirrors.aliyun.com/crates.io-index/",
},
"ustc": {
"RUSTUP_DIST_SERVER": "https://mirrors.ustc.edu.cn/rust-static",
"RUSTUP_UPDATE_ROOT": "https://mirrors.ustc.edu.cn/rust-static/rustup",
"TOML_REGISTRY": "https://mirrors.ustc.edu.cn/crates.io-index/",
},
}
_RUST_SCCACHE_DIR: Path = Path.home() / ".cargo" / "sccache"
_RUST_SCCACHE_CACHE_SIZE: str = "20G"
_QT_LIBS: list[str] = [
"build-essential",
"libgl1",
"libegl1",
"libglib2.0-0",
"libfontconfig1",
"libfreetype6",
"libxkbcommon0",
"libdbus-1-3",
"libxcb-xinerama0",
"libxcb-icccm4",
"libxcb-image0",
"libxcb-keysyms1",
"libxcb-randr0",
"libxcb-render-util0",
"libxcb-shape0",
"libxcb-xfixes0",
"libxcb-cursor0",
]
_CHINESE_FONTS: list[str] = [
"fonts-noto-cjk",
"fonts-wqy-microhei",
"fonts-wqy-zenhei",
"fonts-noto-color-emoji",
]
_DOWNLOAD_MIRROR_SCRIPT: str = "curl -sSL https://linuxmirrors.cn/main.sh -o /tmp/linuxmirrors.sh"
_INSTALL_MIRROR_SCRIPT: str = "sudo bash /tmp/linuxmirrors.sh"
_RUSTUP_DOWNLOAD_URL_LINUX: str = "https://mirrors.aliyun.com/repo/rust/rustup-init.sh"
_RUSTUP_DOWNLOAD_URL_WINDOWS: str = "https://static.rust-lang.org/rustup/dist/x86_64-pc-windows-msvc/rustup-init.exe"
# ============================================================================
# 私有辅助
# ============================================================================
def _pip_config_path() -> Path:
"""返回当前平台的 pip 配置文件路径."""
if Constants.IS_LINUX:
return Path.home() / ".pip" / "pip.conf"
return Path.home() / "pip" / "pip.ini"
# ============================================================================
# 镜像源配置函数
# ============================================================================
@px.tool("envdev", subcommand="setup-python", help="配置 Python 镜像源")
def setup_python_mirror(mirror: str = "tsinghua") -> None:
"""配置 Python 镜像源 (设置环境变量 + 写入 pip 配置文件).
设置 ``PIP_INDEX_URL`` / ``PIP_TRUSTED_HOSTS`` / ``UV_INDEX_URL`` /
``UV_PYTHON_INSTALL_MIRROR`` 等环境变量, 并写入 pip 配置文件.
Parameters
----------
mirror : str
镜像源名称: tsinghua/aliyun/huaweicloud/ustc/zju (默认: tsinghua)
"""
if mirror not in _PIP_INDEX_URLS:
print(f"未知 Python 镜像源: {mirror}")
return
index_url = _PIP_INDEX_URLS[mirror]
trusted_host = _PIP_TRUSTED_HOSTS[mirror]
os.environ["PIP_INDEX_URL"] = index_url
os.environ["PIP_TRUSTED_HOSTS"] = trusted_host
os.environ["UV_INDEX_URL"] = index_url
os.environ["UV_PYTHON_INSTALL_MIRROR"] = _UV_PYTHON_INSTALL_MIRROR
os.environ["UV_HTTP_TIMEOUT"] = "600"
os.environ["UV_LINK_MODE"] = "copy"
config_path = _pip_config_path()
config_path.parent.mkdir(parents=True, exist_ok=True)
content = f"[global]\nindex-url = {index_url}\ntrusted-host = {trusted_host}\n"
config_path.write_text(content, encoding="utf-8")
print(f"Python 镜像源已配置: {mirror} -> {config_path}")
@px.tool("envdev", subcommand="setup-conda", help="配置 Conda 镜像源")
def setup_conda_mirror(mirror: str = "tsinghua") -> None:
"""配置 Conda 镜像源 (写入 ~/.condarc).
Parameters
----------
mirror : str
镜像源名称: tsinghua/ustc/bsfu/aliyun (默认: tsinghua)
"""
if mirror not in _CONDA_MIRROR_URLS:
print(f"未知 Conda 镜像源: {mirror}")
return
urls = _CONDA_MIRROR_URLS[mirror]
config_path = Path.home() / ".condarc"
config_path.parent.mkdir(parents=True, exist_ok=True)
content = "show_channel_urls: true\nchannels:\n - " + "\n - ".join(urls) + "\n - defaults\n"
config_path.write_text(content, encoding="utf-8")
print(f"Conda 镜像源已配置: {mirror} -> {config_path}")
@px.tool("envdev", subcommand="setup-rust", help="配置 Rust 镜像源")
def setup_rust_mirror(mirror: str = "tsinghua", version: str = "stable") -> None:
"""配置 Rust 镜像源 (设置环境变量 + 写入 cargo config + 创建 sccache 目录).
设置 ``RUSTUP_DIST_SERVER`` / ``RUSTUP_UPDATE_ROOT`` / ``RUST_SCCACHE_DIR``
等环境变量, 写入 ``~/.cargo/config.toml``, 并创建 sccache 缓存目录.
Parameters
----------
mirror : str
镜像源名称: tsinghua/ustc/aliyun (默认: tsinghua)
version : str
Rust 版本 (未使用, 保留以与原 envdev 参数对齐)
"""
del version # 兼容旧参数, 实际安装由独立 job 处理
if mirror not in _RUSTUP_MIRRORS:
print(f"未知 Rust 镜像源: {mirror}")
return
mirrors = _RUSTUP_MIRRORS[mirror]
os.environ["RUSTUP_DIST_SERVER"] = mirrors["RUSTUP_DIST_SERVER"]
os.environ["RUSTUP_UPDATE_ROOT"] = mirrors["RUSTUP_UPDATE_ROOT"]
os.environ["RUST_SCCACHE_DIR"] = str(_RUST_SCCACHE_DIR)
os.environ["RUST_SCCACHE_CACHE_SIZE"] = _RUST_SCCACHE_CACHE_SIZE
_RUST_SCCACHE_DIR.mkdir(parents=True, exist_ok=True)
config_path = Path.home() / ".cargo" / "config.toml"
config_path.parent.mkdir(parents=True, exist_ok=True)
registry = mirrors["TOML_REGISTRY"]
content = (
f"\n[source.crates-io]\nreplace-with = '{mirror}'\n\n"
f'[source.{mirror}]\nregistry = "sparse+{registry}"\n\n'
f'[registries.{mirror}]\nindex = "sparse+{registry}"\n'
)
config_path.write_text(content, encoding="utf-8")
print(f"Rust 镜像源已配置: {mirror} -> {config_path}")
# ============================================================================
# Rust 工具链安装
# ============================================================================
@px.tool("envdev", subcommand="download-rustup", help="下载 Rustup 安装脚本")
def download_rustup_script() -> None:
"""下载 Rustup 安装脚本 (跨平台, 已安装 rustup 时跳过).
Linux 下载 ``rustup-init.sh``, Windows 下载 ``rustup-init.exe``.
"""
if shutil.which("rustup") is not None:
print("rustup 已安装, 跳过下载")
return
if Constants.IS_WINDOWS:
print("下载 rustup-init.exe...")
subprocess.run(
[
"powershell",
"-Command",
"Invoke-WebRequest",
"-Uri",
_RUSTUP_DOWNLOAD_URL_WINDOWS,
"-OutFile",
"rustup-init.exe",
],
check=False,
)
else:
print("下载 rustup-init.sh...")
subprocess.run(
["curl", "-fsSL", _RUSTUP_DOWNLOAD_URL_LINUX, "-o", "rustup-init.sh"],
check=False,
)
@px.tool(
"envdev",
subcommand="install-rust",
help="安装 Rust 工具链",
needs=["setup-rust", "download-rustup"],
allow_upstream_skip=True,
)
def install_rust_toolchain(version: str = "stable") -> None:
"""安装 Rust 工具链 (rustup 未安装时跳过).
Parameters
----------
version : str
Rust 版本: ``stable`` / ``nightly`` / ``beta`` (默认: ``stable``)
"""
if shutil.which("rustup") is None:
print("rustup 未安装, 跳过工具链安装")
return
subprocess.run(["rustup", "toolchain", "install", version], check=False)
print(f"Rust 工具链 {version} 安装完成")
# ============================================================================
# Linux 专用函数
# ============================================================================
@px.tool("envdev", subcommand="setup-linux-mirror", help="配置 Linux 系统镜像源")
def setup_linux_system_mirror() -> None:
"""下载并安装 Linux 系统镜像源 (仅 Linux, 已配置国内镜像时跳过).
检查 ``/etc/apt/sources.list`` ``/etc/apt/sources.list.d/ubuntu.sources``
是否已配置国内镜像, 已配置则跳过; 未配置则下载并执行 linuxmirrors 脚本.
"""
if not ensure_platform(Constants.IS_LINUX, "setup_linux_system_mirror", "Linux"):
return
apt_files = ["/etc/apt/sources.list", "/etc/apt/sources.list.d/ubuntu.sources"]
mirror_keys = list(_PIP_INDEX_URLS.keys())
already_configured = False
for apt_file in apt_files:
try:
content = Path(apt_file).read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
if any(mirror in content for mirror in mirror_keys):
already_configured = True
break
if already_configured:
print("已配置国内镜像源, 跳过系统镜像配置")
return
print("下载 linuxmirrors 脚本...")
subprocess.run(_DOWNLOAD_MIRROR_SCRIPT, shell=True, check=False)
print("安装 linuxmirrors...")
subprocess.run(_INSTALL_MIRROR_SCRIPT, shell=True, check=False)
@px.tool(
"envdev",
subcommand="install-qt-libs",
help="安装 Qt 依赖库",
needs=["setup-linux-mirror"],
allow_upstream_skip=True,
)
def install_linux_qt_libs() -> None:
"""安装 Qt 依赖库 (仅 Linux)."""
if not ensure_platform(Constants.IS_LINUX, "install_linux_qt_libs", "Linux"):
return
subprocess.run(["sudo", "apt", "install", "-y", *_QT_LIBS], check=False)
print("Qt 依赖库安装完成")
@px.tool(
"envdev",
subcommand="install-fonts",
help="安装中文字体",
needs=["setup-linux-mirror"],
allow_upstream_skip=True,
)
def install_linux_fonts() -> None:
"""安装中文字体 (仅 Linux)."""
if not ensure_platform(Constants.IS_LINUX, "install_linux_fonts", "Linux"):
return
subprocess.run(["sudo", "apt", "install", "-y", *_CHINESE_FONTS], check=False)
print("中文字体安装完成")
@px.tool(
"envdev",
subcommand="install-docker",
help="安装 Docker",
needs=["setup-linux-mirror"],
allow_upstream_skip=True,
)
def install_linux_docker() -> None:
"""安装 Docker (仅 Linux)."""
if not ensure_platform(Constants.IS_LINUX, "install_linux_docker", "Linux"):
return
subprocess.run(["sudo", "apt", "install", "-y", "docker-compose-v2"], check=False)
subprocess.run(["sudo", "usermod", "-aG", "docker", getpass.getuser()], check=False)
print("Docker 安装完成 (需重新登录以生效 docker 用户组)")
+46
View File
@@ -0,0 +1,46 @@
"""msdownload - ModelScope 模型/数据集下载工具.
ModelScope 下载模型数据集或空间.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
import pyflowx as px
__all__ = ["msdownload_run"]
@px.tool("msdownload", help="ModelScope 模型/数据集下载工具")
def msdownload_run(name: str, target_type: str = "model", download_dir: str | None = None) -> None:
"""从 ModelScope 下载模型/数据集/空间.
Parameters
----------
name : str
目标名称 (: ``Qwen/Qwen2.5-Coder-32B-Instruct``)
target_type : str
目标类型: ``model`` / ``dataset`` / ``space`` (默认: ``model``)
download_dir : str | None
下载目录; None 时默认 ``~/.models/<name 最后一段>``
"""
if not name:
print("msdownload: name 不能为空")
return
if download_dir:
out_dir = Path(download_dir)
else:
out_dir = Path.home() / ".models" / name.rsplit("/", 1)[-1]
out_dir.mkdir(parents=True, exist_ok=True)
cmd = ["uvx", "modelscope", "download", f"--{target_type}", name, "--local_dir", str(out_dir)]
print(f"下载 {target_type}: {name} -> {out_dir}")
try:
subprocess.run(cmd, check=True)
except subprocess.CalledProcessError as e:
print(f"下载失败 (returncode={e.returncode}): {e}")
except FileNotFoundError:
print("uvx 命令未找到,请确认 uv 已安装并在 PATH 中")
+86
View File
@@ -0,0 +1,86 @@
"""sglang - SGLang 本地模型服务工具.
提供安装与启动 SGLang 服务的子命令.
"""
from __future__ import annotations
import shutil
from pathlib import Path
import pyflowx as px
from pyflowx.ops._common import platform_command
__all__ = [
"install_sglang",
"run_sglang",
]
@px.tool("sglang", subcommand="install", help="安装 sglang")
def install_sglang() -> None:
"""安装 sglang (若未安装).
通过 ``shutil.which`` 检测 sglang 是否已安装, 未安装时执行 ``uv install sglang[all]``.
"""
if shutil.which("sglang") is not None:
print("sglang 已安装, 跳过安装步骤")
return
print("正在安装 sglang[all]...")
px.sh(["uv", "install", "sglang[all]"], label="安装 sglang")
@px.tool("sglang", subcommand="run", help="启动 SGLang 服务", needs=["install"])
def run_sglang(
model: str = "~/.models/Qwen2.5-Coder-32B-Instruct-AWQ",
port: int = 8000,
ctx_len: int = 32768,
mem_fraction: float = 0.75,
host: str = "0.0.0.0",
log_level: str = "info",
) -> None:
"""启动 SGLang 本地模型服务.
Parameters
----------
model : str
模型路径 (默认: ``~/.models/Qwen2.5-Coder-32B-Instruct-AWQ``)
port : int
服务端口 (默认: 8000)
ctx_len : int
最大上下文长度 (默认: 32768)
mem_fraction : float
显存占比 0-1 (默认: 0.75)
host : str
主机地址 (默认: 0.0.0.0)
log_level : str
日志级别 (默认: info)
"""
model_dir = Path(model).expanduser()
if not model_dir.exists():
print(f"模型目录不存在: {model_dir}")
return
python_bin = platform_command(["python"], ["python3"])[0]
cmd = [
python_bin,
"-m",
"sglang.launch_server",
"--model-path",
str(model_dir),
"--host",
host,
"--port",
str(port),
"--mem-fraction-static",
str(mem_fraction),
"--context-length",
str(ctx_len),
"--tool-call-parser",
"qwen",
"--log-level",
log_level,
]
print(f"启动 SGLang: {model_dir} (port={port}, ctx={ctx_len}, mem={mem_fraction})")
px.sh(cmd, label="SGLang 启动")

Some files were not shown because too many files have changed in this diff Show More