Compare commits

..

70 Commits

Author SHA1 Message Date
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
zhou 58ee84ded6 bump version to 0.4.7
CI / Lint, Typecheck & Test (push) Successful in 2m2s
Release / Build, Publish & Release (push) Successful in 1m5s
2026-07-05 19:49:54 +08:00
zhou 9a96e5d052 fix: 修复 bump_project_version 版本号不同步跳号 bug 并抽离到独立模块
CI / Lint, Typecheck & Test (push) Successful in 1m26s
原实现对每个文件独立 +1, 文件版本号不同步时跳号; 改为先读取所有文件取 max 作为基准再统一写入. 同时修复 git add . 违规 (改按文件名) 与 check=False 吞错误. bumpversion 从 ops/dev.py 抽离到 ops/bumpversion.py, 测试简化为 16 个核心场景.
2026-07-05 19:34:44 +08:00
zhou c9c7529c58 bump version to 0.4.6
CI / Lint, Typecheck & Test (push) Successful in 2m4s
Release / Build, Publish & Release (push) Successful in 46s
2026-07-05 19:09:10 +08:00
zhou c498d9b1c9 feat: 实现方向 B 聚合 job 并消除 pymake/reseticoncache CLI 入口
CI / Lint, Typecheck & Test (push) Successful in 2m3s
放宽 yaml_loader 校验允许有 needs 无 cmd/fn 的聚合 job, 完善 pymake.yaml 覆盖原 pymake.py 所有别名, 新增 reset_icon_cache_run fn 与 reseticoncache.yaml, 删除 pymake.py/reseticoncache.py 及对应 scripts 入口, 修复 --list 在 subcommands 模式下的可达性 bug.
2026-07-05 18:12:59 +08:00
zhou b36e279f92 feat(cli): add pymake project build tool support
add pymake command alias to CLI, create pymake config yaml and move its legacy tool config out of _LEGACY_TOOLS dict
2026-07-05 17:45:08 +08:00
zhou 58d6f1faad refactor: 迁移 cli/_ops/ 到 ops/, 按类别保持 dev/files/media/system 分类
CI / Lint, Typecheck & Test (push) Successful in 1m20s
将 src/pyflowx/cli/_ops/ 整体迁移至 src/pyflowx/ops/, 与 cli/ 平级
(工具函数非 CLI 专属, 可被 YAML 任务编排通用引用). 分类保持不变:
dev (git/pip/bump/autofmt), files (date/level/back/zip),
media (pdf/screenshot), system (ls/pack/ssh).

同步更新 15 个引用文件 (yaml_loader + 14 个测试) 的 import 路径,
README 模块结构表与 test_registry docstring.
2026-07-05 17:32:22 +08:00
zhou d93da0d8b4 refactor: 迁移 cli/_ops/ 到 ops/, 按类别保持 dev/files/media/system 分类
CI / Lint, Typecheck & Test (push) Successful in 1m35s
将 src/pyflowx/cli/_ops/ 整体迁移至 src/pyflowx/ops/, 与 cli/ 平级
(工具函数非 CLI 专属, 可被 YAML 任务编排通用引用). 分类保持不变:
dev (git/pip/bump/autofmt), files (date/level/back/zip),
media (pdf/screenshot), system (ls/pack/ssh).

同步更新 15 个引用文件 (yaml_loader + 14 个测试) 的 import 路径,
README 模块结构表与 test_registry docstring.
2026-07-05 17:30:35 +08:00
zhou 701c455c42 refactor: 用 YamlCliRunner/PfApp class 封装 CLI 入口逻辑
CI / Lint & Typecheck (push) Failing after 11m31s
CI / Test (Python 3.11) (push) Failing after 6m1s
CI / Test (Python 3.13) (push) Failing after 12m44s
CI / Test (Python 3.8) (push) Failing after 12m2s
CI / Docs Build (push) Failing after 6m56s
yaml_loader.py 新增 YamlCliRunner class, 将 run_cli 的 90 行
流程拆为 run/_load_config/_add_global_options/_extract_variables/
_handle_list 等单一职责方法; run_cli 保留为薄包装 (公共 API).
pf.py 新增 PfApp class, 将 main 的路由逻辑拆为 run/_list_tools/
_resolve_tool/_run_legacy/_run_yaml 等方法; main 仅调用
PfApp().run().

附带修复 run_yaml 的 jobs=None 误报 bug: 原代码 jobs is None
时 raise ValueError, 但 None 本意是执行全部任务, 导致
pf folderzip 报 "jobs 不能为空".
2026-07-05 15:59:05 +08:00
zhou e174b64495 refactor(cli): 重构配置文件路径并新增多工具配置
1. 移除旧的cli/configs/__init__.py占位文件
2. 修正pf.py中配置目录的路径指向
3. 新增folderzip、bumpversion等十余种工具的配置文件
4. 更新uv.lock中的依赖版本匹配规则
2026-07-05 15:50:18 +08:00
zhou 3afb25bb5e fix: 修正 typing-extensions 依赖条件为 python_version < '3.13'
CI / Lint & Typecheck (push) Failing after 15m3s
CI / Test (Python 3.11) (push) Failing after 24m15s
CI / Test (Python 3.13) (push) Failing after 27m17s
CI / Test (Python 3.8) (push) Failing after 5m56s
CI / Docs Build (push) Failing after 31s
task.py 在 Python < 3.13 时需要 typing_extensions 的 TypeVar
(PEP 696 default= 参数), 此前条件 < '3.10' 导致 3.10-3.12
环境 import 失败, ReadTheDocs (Python 3.11) 构建报
ModuleNotFoundError: No module named 'typing_extensions'.
2026-07-05 13:16:24 +08:00
zhou fbd17536fd ci: 重写 CI/Release 为 GitHub 兼容版本并加文档构建
CI / Lint & Typecheck (push) Failing after 8m55s
CI / Test (Python 3.11) (push) Failing after 31s
CI / Test (Python 3.13) (push) Failing after 31s
CI / Test (Python 3.8) (push) Failing after 31s
CI / Docs Build (push) Failing after 31s
ci.yml 改用标准 actions (checkout/setup-uv/setup-python), 新增
pyrefly 类型检查、coverage 阈值检查 (>=95%)、Sphinx 文档构建三个
job, 多版本矩阵测试 (py38/py311/py313)。release.yml 改用标准
actions, 发布到 PyPI + GitHub Release (替代原 Gitea Release)。
2026-07-05 12:32:00 +08:00
zhou 32ca8c1208 docs: 搭建 Sphinx 文档站并清理死代码
CI / Lint, Typecheck & Test (push) Successful in 1m57s
1. 新建 docs/ Sphinx 文档结构 (conf.py + 8 个 rst 章节),
   napoleon 支持 Google/NumPy docstring, rtd 主题,
   自动生成 API 参考与错误家族文档
2. 新建 .readthedocs.yaml 配置, pyproject.toml 加 docs 依赖
3. 删除 runner.py 的 _apply_verbose_to_graph 死代码及对应测试
   (功能已移入 executors.run 统一处理)
4. 更新 README: CLI 示例改为 pf 统一入口, 模块结构表补全
   cli/pf.py/cli/configs/cli/_ops 等模块
5. 修复版本不一致 (pyproject.toml 0.3.5 → 0.4.5)
6. 加文档徽章链接到 ReadTheDocs
2026-07-05 12:17:10 +08:00
zhou a7ff68d279 feat: pf 默认显示 verbose 执行过程, --quiet 关闭
CI / Lint, Typecheck & Test (push) Successful in 1m17s
run() 在 verbose=True 时自动把 verbose 标记应用到所有 spec,
使 execute_command 打印执行命令与返回码 (此前只 callback 打印
任务生命周期)。全局选项 --verbose 改为 --quiet (默认 verbose=True,
传 --quiet 关闭)。gittool CLEAN_EXCLUDES 补全 .pytest_cache/
.ruff_cache/.vscode/.trae/.qoder/.editorconfig 等目录。
2026-07-05 08:46:15 +08:00
zhou de368ea810 refactor: 删除冗余 cli 入口脚本, gittool 用数组配置 clean excludes
CI / Lint, Typecheck & Test (push) Successful in 1m11s
1. 删除 13 个已有 YAML 配置的 cli .py 入口脚本, 统一通过 pf 调用
2. gittool.yaml 用 CLEAN_EXCLUDES 数组变量配置 git clean 的 -e 参数,
   保留 .venv/.tox/node_modules/.idea 等目录避免误删
3. run_cli 执行前打印调用信息: [gittool] 执行: c
4. 更新 pyproject.toml 移除 13 个冗余 entry points, 仅保留 pf
5. 清理测试文件中的 TestMain 类 (测 _ops 模块的测试保留)
2026-07-05 08:39:20 +08:00
zhou 6a3e3a57cd fix: cmd 任务成功时打印 stdout
CI / Lint, Typecheck & Test (push) Failing after 50s
execute_command 在非 verbose 模式下捕获 stdout 后直接 return None,
导致 git status --porcelain 等命令的输出被丢弃。
现在成功时若有 stdout 则打印到终端, 保留失败时的 stderr 信息。
2026-07-05 00:52:38 +08:00
zhou 7089944306 build: 调整pyproject.toml中pf命令的脚本位置
将pf命令的脚本配置移至文件末尾,修正脚本条目排序
2026-07-05 00:50:16 +08:00
zhou ec5e348694 feat: 新增 pf 统一入口, YAML 配置自带 CLI 参数定义
CI / Lint, Typecheck & Test (push) Failing after 47s
新增 pf 统一 CLI 入口, 通过 YAML 的 cli: 段定义参数解析规则,
逐步消除工具 .py 入口文件。yaml_loader 新增 build_cli_parser
和 run_cli 函数, 支持 subcommands/positional/options 三级 schema,
内置 --dry-run/--verbose/--strategy/--list 全局选项。
13 个工具 YAML 配置全部添加 cli: 段。
2026-07-04 20:31:40 +08:00
zhou 12d9f2f647 fix: 恢复 gittool 条件逻辑,修复 has_files 检查 git status
CI / Lint, Typecheck & Test (push) Successful in 1m56s
将 gitt a/i 命令改用 fn job 包装(git_add_commit/git_init_add_commit),
内部检查 has_files() 和 not_has_git_repo() 条件,避免无更改时 git commit
报错。修正 has_files() 实现为检查 git status --porcelain 而非目录文件。
2026-07-04 20:00:25 +08:00
zhou 6ffcbecade chore: update 2026-07-04 19:57:20 +08:00
zhou e76d93187b chore: update 2026-07-04 19:55:09 +08:00
zhou 52e20e3f93 style: 统一调整代码格式,将单行列表展开为多行缩进格式 2026-07-04 19:49:10 +08:00
zhou 3f966a230e refactor: 简化 CLI 工具入口为 YAML 加载器
CI / Lint, Typecheck & Test (push) Successful in 2m5s
将 13 个工具入口文件重构为通过 px.run_yaml 调用 YAML 配置,
辅助函数移至 _ops 模块。新增 run_yaml 便捷函数支持 job 选择
和传递依赖收集,修复 _build_cmd 列表变量展开,新增 bump_project_version
高层函数封装版本号更新+git 提交流程。
2026-07-04 19:35:08 +08:00
zhou 5d0b211a44 feat: 新增 13 个 CLI 工具的 YAML 配置并修复 _ops 函数注册
CI / Lint, Typecheck & Test (push) Successful in 1m41s
- 在 cli/configs/ 下创建 13 个 YAML 工作流配置, 覆盖 filedate/filelevel/folderback/
  folderzip/autofmt/bumpversion/piptool/gittool/pdftool/screenshot/lscalc/
  sshcopyid/packtool 工具, 共 51 个 job (cmd 与 fn 混合)
- yaml_loader 模块级导入 _ops 子模块, 使 YAML fn 字段可引用注册函数,
  try/except 守卫避免最小安装场景下的 ImportError
- 修复 test_registry 的 clear_registry fixture: 保存/恢复 _REGISTRY 原始状态,
  避免 teardown 清空 _ops 自动注册的函数导致 TestOpsModules 失败
2026-07-04 18:35:20 +08:00
zhou 6931f36fd1 feat: 新增函数注册机制与 CLI 工具函数模块
CI / Lint, Typecheck & Test (push) Successful in 2m27s
- 新增 registry.py 提供 register_fn/get_fn/has_fn 函数注册机制, 支持 @register_fn 和 @register_fn("name") 两种用法
- 新增 cli/_ops 包 (files/dev/media/system 四个子模块), 聚合 59 个可复用函数供 YAML fn 字段引用
- 扩展 yaml_loader 支持 fn 字段、args/kwargs 传参、${VAR} 变量占位符
- 新增 test_registry.py (20 个测试) 和扩展 test_yaml_loader.py
- 更新自驱动规则: 自动 commit+push, 删除需要用户明确指示的步骤
2026-07-04 18:24:52 +08:00
zhou db02443463 feat: 新增 YAML 任务编排功能
1. 新增 yaml_loader 模块,支持加载 GitHub Actions 风格的 YAML 任务图
2. 新增 Graph.from_yaml 静态方法,支持从 YAML 文件构建任务图
3. 新增 yamlrun CLI 工具,支持执行、预览 YAML 任务流水线
4. 添加 pyyaml 运行时依赖与 types-PyYAML 开发依赖
5. 更新 README 文档与对外暴露的 API 接口
2026-07-04 16:00:04 +08:00
zhou eb8e1402bc docs: 更新自驱动规则文档,补充决策判据与细节
补充自主决策的具体范围、收尾规则,新增决策判据章节,细化暂停条件与沟通要求
2026-07-04 15:29:47 +08:00
zhou c93f45dcb8 refactor: 统一使用px.task/px.cmd替代旧版TaskSpec创建任务
本次提交将项目内所有使用px.TaskSpec创建任务的代码,替换为新的px.task和px.cmd快捷API,简化了任务定义写法,同时更新了版本号到0.3.5。重构过程中保持了原有功能逻辑不变,仅调整了代码书写格式,提升了代码可读性和编写效率。
2026-07-04 15:22:27 +08:00
zhou a0b1814024 style: 格式化sshcopyid.py的列表代码,提升可读性
调整了px.Graph.from_specs的参数列表排版,将多行列表缩进优化为更简洁的单行展开格式,不改变代码实际功能。
2026-07-04 13:43:33 +08:00
zhou 3a2826d3f9 bump version to 0.3.5
CI / Lint, Typecheck & Test (push) Successful in 1m47s
Release / Build, Publish & Release (push) Successful in 1m2s
2026-07-04 11:36:07 +08:00
zhou dbd30689ab chore(ci): 更新release工作流的gitea服务地址
将GITEA_URL从10.0.16.16:3000调整为172.17.0.1:3000,适配新的内网部署地址
2026-07-04 11:36:04 +08:00
zhou 5eb59b8a66 bump version to 0.3.4
CI / Lint, Typecheck & Test (push) Successful in 1m8s
Release / Build, Publish & Release (push) Failing after 30s
2026-07-04 11:24:11 +08:00
zhou 8e7b866de2 更新 .github/workflows/release.yml
CI / Lint, Typecheck & Test (push) Has been cancelled
2026-07-04 03:23:16 +00:00
zhou 1b4f9bfa6a bump version to 0.3.3
CI / Lint, Typecheck & Test (push) Successful in 1m8s
Release / Build, Publish & Release (push) Has been cancelled
2026-07-04 11:16:31 +08:00
zhou 2d39272330 ci(github workflows): update pypi api token secret name
将PyPI发布步骤中的密钥变量名从PYPI_API_TOKEN改为PYPI_TOKEN,保持配置一致性
2026-07-04 11:16:24 +08:00
zhou f699bb9167 chore: 升级pyflowx版本到0.3.2
CI / Lint, Typecheck & Test (push) Successful in 1m19s
2026-07-04 10:50:50 +08:00
zhou 35f07e96e1 ci: 更新CI和release工作流配置
CI / Lint, Typecheck & Test (push) Failing after 1m18s
1. 将CI容器镜像从固定版本改为latest
2. 简化PyPI发布步骤,改用uv publish命令
3. 重构Gitea发布脚本,优化release创建和资产上传流程
2026-07-04 10:45:35 +08:00
zhou 1f274fe828 bump version to 0.3.1
CI / Lint, Typecheck & Test (push) Successful in 1m16s
Release / Build, Publish & Release (push) Has been cancelled
2026-07-04 10:32:45 +08:00
zhou 85793ff9d5 test(cli): 为文件写入错误测试添加root权限跳过逻辑
CI / Lint, Typecheck & Test (push) Has been cancelled
2026-07-04 10:22:35 +08:00
zhou 37ac4b8025 ci: 为CI和release工作流配置国内PyPI源
CI / Lint, Typecheck & Test (push) Failing after 1m18s
添加清华PyPI源配置,加速国内环境下的依赖安装速度
2026-07-04 09:53:35 +08:00
zhou 0edeadb846 build: 配置国内PyPI镜像源加速依赖安装
CI / Lint, Typecheck & Test (push) Failing after 1m51s
2026-07-04 09:50:15 +08:00
zhou f63db6c71a ~
CI / Lint, Typecheck & Test (push) Failing after 1m17s
2026-07-04 09:41:42 +08:00
zhou 4d397606e6 build: 迁移uv配置到pyproject.toml并删除uv.toml文件
CI / Lint, Typecheck & Test (push) Has been cancelled
将原uv.toml中的配置项迁移到pyproject.toml的tool.uv区块,移除冗余的独立uv配置文件
2026-07-04 08:39:22 +08:00
zhou f24388b151 更新 .github/workflows/ci.yml
CI / Lint, Typecheck & Test (push) Failing after 1h42m18s
2026-07-03 15:05:20 +00:00
zhou 535b7cba31 ~uv.toml
CI / Lint, Typecheck & Test (push) Has been cancelled
2026-07-03 21:31:31 +08:00
zhou 3f68bed3fd chore(pyproject): add unused-ignore=false config
CI / Lint, Typecheck & Test (push) Has been cancelled
2026-07-03 21:08:10 +08:00
zhou 2e2ca812a1 build(Dockerfile): 安装Node.js 20.x以支持actions/checkout
CI / Lint, Typecheck & Test (push) Failing after 1h6m49s
为了满足actions/checkout的运行依赖,在Docker镜像中新增安装Node.js 20版本,并验证安装正常
2026-07-03 13:39:57 +08:00
zhou 8de565d0cb ci(ci.yml): 将CI镜像标签从1.0.0改为latest
CI / Lint, Typecheck & Test (push) Failing after 26s
使用latest标签可以自动获取最新的CI镜像版本,无需手动更新版本号
2026-07-03 13:31:40 +08:00
zhou 5480c48e67 ci(github workflows): 移除uv sync的回退命令
CI / Lint, Typecheck & Test (push) Failing after 22s
简化CI依赖同步步骤,去掉失败后重试的uv sync命令
2026-07-03 13:30:23 +08:00
zhou c6653d5117 +docker cmd
CI / Lint, Typecheck & Test (push) Failing after 22s
2026-07-03 12:53:11 +08:00
zhou d194a991a0 chore: 移除llm额外依赖组并更新dev依赖配置
CI / Lint, Typecheck & Test (push) Failing after 27s
删除了llm相关的依赖分组,同时调整dev依赖组移除对llm可选依赖的引用
2026-07-03 12:18:24 +08:00
zhou 4446658170 ci(github workflows): 优化CI/CD流程,使用自定义容器并简化步骤
CI / Lint, Typecheck & Test (push) Failing after 23s
1. 为CI和release任务添加自定义pyflowx-ci容器并配置UV链接模式
2. 移除冗余的setup-uv和setup-python步骤,合并依赖同步、代码检查命令
3. 简化步骤命名和执行逻辑,统一使用uv管理工具链
2026-07-03 07:50:59 +08:00
zhou 1d26f9d3e7 build: 添加dockerignore和Dockerfile配置文件
新增.dockerignore文件忽略不必要的构建文件,同时创建Dockerfile配置容器构建流程,使用国内镜像源加速拉取依赖和基础镜像,预装uv和多版本Python环境
2026-07-03 07:48:26 +08:00
zhou d9644ca5d1 ci(github workflow): 更新uv版本到0.11.26
CI / Lint, Typecheck & Test (push) Has been cancelled
将CI工作流中的uv版本从0.8.0升级到0.11.26,获取最新功能和修复
2026-07-03 07:44:04 +08:00
zhou d3c2d53449 build: 升级pyflowx版本到0.3.0
CI / Lint, Typecheck & Test (push) Has been cancelled
更新项目版本号从0.2.13至0.3.0
2026-07-03 07:39:30 +08:00
zhou 9cfcfb38e4 更新 .github/workflows/ci.yml
CI / Lint, Typecheck & Test (push) Has been cancelled
2026-07-02 15:03:23 +00:00
zhou 69db241611 添加 uv.toml
CI / Lint, Typecheck & Test (push) Failing after 21s
2026-07-02 15:00:39 +00:00
zhou 66e6295a24 ci(github workflow): 固定setup-uv和setup-python的版本
CI / Lint, Typecheck & Test (push) Failing after 7m12s
2026-07-02 22:39:50 +08:00
zhou aebb4fce68 ci: 将CI工作流的依赖action版本切换为main分支
CI / Lint, Typecheck & Test (push) Failing after 20s
更新了checkout、setup-uv、setup-python这几个action的引用标签,从固定版本改为使用main分支
2026-07-02 21:18:49 +08:00
zhou 7784c8ff86 ci: 将github actions源替换为内部gitea仓库地址
CI / Lint, Typecheck & Test (push) Failing after 2m45s
2026-07-02 20:53:56 +08:00
zhou 77918a5568 ci: 替换github actions为国内gitcode镜像源
CI / Lint, Typecheck & Test (push) Failing after 1s
2026-07-02 20:48:48 +08:00
zhou 7e4c615dc7 ci: 将actions/checkout版本从v7降级到v4
CI / Lint, Typecheck & Test (push) Has been cancelled
2026-07-02 20:29:51 +08:00
zhou ac5082523e ci: 更新github workflows中的依赖actions版本
CI / Lint, Typecheck & Test (push) Has been cancelled
2026-07-02 20:11:59 +08:00
zhou 0df6f7c8ac ci(github workflows): 替换官方action为国内镜像仓库地址
CI / Lint, Typecheck & Test (push) Failing after 5m8s
2026-07-02 19:59:01 +08:00
zhou 4b66176ce6 ~ci.yml
CI / Lint, Typecheck & Test (push) Has been cancelled
2026-07-02 18:29:03 +08:00
zhou cf6b6fd059 ~ci.yml
CI / Lint, Typecheck & Test (push) Has been cancelled
2026-07-02 17:57:04 +08:00
124 changed files with 12203 additions and 9790 deletions
+46
View File
@@ -0,0 +1,46 @@
# 版本控制
.git
.gitignore
.github
# Python 缓存与构建产物
__pycache__
*.pyc
*.pyo
*.pyd
*.egg-info
*.egg
dist
build
.eggs
# 测试与覆盖率
.pytest_cache
.coverage
htmlcov
.tox
coverage.xml
# 虚拟环境
.venv
venv
env
# 工具缓存
.uv-cache
.ruff_cache
.pyrefly_cache
.mypy_cache
# IDE 与编辑器
.idea
.vscode
*.swp
*.swo
# 文档(按需保留)
docs
# 系统文件
.DS_Store
Thumbs.db
+18 -34
View File
@@ -9,42 +9,26 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
lint-and-typecheck: ci:
name: Lint & Typecheck name: Lint, Typecheck & Test
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: pyflowx-ci:latest
env:
UV_LINK_MODE: copy
# ---- 国内源 ----
PIP_INDEX_URL: https://pypi.tuna.tsinghua.edu.cn/simple
PIP_TRUSTED_HOST: pypi.tuna.tsinghua.edu.cn
UV_INDEX_URL: https://pypi.tuna.tsinghua.edu.cn/simple
UV_TRUSTED_HOST: pypi.tuna.tsinghua.edu.cn
steps: steps:
- uses: actions/checkout@v4 - uses: http://gitea:3000/zhou/checkout.git@main
- uses: astral-sh/setup-uv@v5 - name: Sync dependencies
with: run: uv sync --frozen
enable-cache: true
- uses: actions/setup-python@v5 - name: Ruff check
with: run: ruff check src tests
python-version: '3.13'
- run: uv sync - name: Tox test (py38, py313)
- run: uv run ruff check src tests run: uvx tox run -e py38,py313
- run: uv run pyrefly check .
test:
name: Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- uses: actions/setup-python@v5
with:
python-version: |
3.8
3.13
- run: uvx tox run -e py38,py313
+44 -47
View File
@@ -6,56 +6,53 @@ on:
permissions: permissions:
contents: write contents: write
id-token: write
jobs: jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- run: uv build
- id: version
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- uses: actions/upload-artifact@v7
with:
name: dist
path: dist/
publish-pypi:
needs: build
runs-on: ubuntu-latest
environment: pypi
steps:
- uses: actions/download-artifact@v8
with:
name: dist
path: dist
- uses: pypa/gh-action-pypi-publish@release/v1
release: release:
needs: [build, publish-pypi] name: Build, Publish & Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: pyflowx-ci:latest
env:
UV_LINK_MODE: copy
# ---- 国内源 ----
PIP_INDEX_URL: https://pypi.tuna.tsinghua.edu.cn/simple
PIP_TRUSTED_HOST: pypi.tuna.tsinghua.edu.cn
UV_INDEX_URL: https://pypi.tuna.tsinghua.edu.cn/simple
UV_TRUSTED_HOST: pypi.tuna.tsinghua.edu.cn
steps: steps:
- uses: actions/download-artifact@v8 - uses: http://gitea:3000/zhou/checkout.git@v4
with:
name: dist
path: dist
- uses: softprops/action-gh-release@v2 - name: Build distributions
with: run: uv build
files: dist/*
generate_release_notes: true - name: Publish to pypi
run: uv publish --token '${{ secrets.PYPI_TOKEN }}'
- name: Create Gitea Release & Upload Assets
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.ref_name }}
REPO: ${{ github.repository }}
GITEA_URL: http://172.17.0.1:3000
run: |
set -e
# 1. 创建 Release
RELEASE_ID=$(curl -sS -X POST "$GITEA_URL/api/v1/repos/$REPO/releases" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG_NAME\",\"name\":\"Release $TAG_NAME\",\"body\":\"Automated release from CI\",\"draft\":false,\"prerelease\":false}" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
echo "Created release id=$RELEASE_ID"
# 2. 上传 dist/ 下所有文件作为附件
for f in dist/*; do
echo "Uploading $f ..."
curl -sS -X POST "$GITEA_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets?name=$(basename $f)" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$f"
done
+4
View File
@@ -11,3 +11,7 @@ wheels/
.coverage .coverage
.idea .idea
*_profile.html *_profile.html
# Sphinx 文档构建输出
docs/_build/
.trae/refs
+23
View File
@@ -0,0 +1,23 @@
# ReadTheDocs 配置
# https://docs.readthedocs.io/en/stable/config-file/v2.html
version: 2
# 构建配置
build:
os: ubuntu-24.04
tools:
python: "3.11"
# Python 依赖与构建命令
python:
install:
- method: pip
path: .
extra_requirements:
- docs
# Sphinx 构建
sphinx:
configuration: docs/conf.py
builder: html
fail_on_warning: false
+108
View File
@@ -0,0 +1,108 @@
# 文档整理与 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。
- 不新增中文文档翻译(文档用中文撰写,与项目既有风格一致)。
+1 -1
View File
@@ -150,7 +150,7 @@ uvx --from pyflowx pymake cov
## Git 与提交 ## Git 与提交
- **自动提交/push**:除非用户明确要求 - **自动提交**:任务完成后自动 `git add`(按文件名)+ `git commit` + `git push`(仅当分支已跟踪远程时执行 push;新分支跳过 push 并在总结中说明)
- **不修改 git config**。 - **不修改 git config**。
- **不运行破坏性命令**`push --force`/`reset --hard`/`clean -f`)除非用户明确要求。 - **不运行破坏性命令**`push --force`/`reset --hard`/`clean -f`)除非用户明确要求。
- **staging**:按文件名添加,不用 `git add -A`/`git add .`,避免误加敏感文件。 - **staging**:按文件名添加,不用 `git add -A`/`git add .`,避免误加敏感文件。
+134
View File
@@ -0,0 +1,134 @@
---
alwaysApply: true
---
# 自驱动开发规则
本规则定义一种"目标驱动、闭环执行"的工作模式:仅在任务开始时与用户确认一次目标与边界,后续由 Agent 自主完成"计划 → 编码 → 测试 → 文档 → 验证"的迭代循环,直到用户目标达成。
## 核心原则
- **目标导向**:始终以用户最终目标为准绳,所有阶段产出都应服务于该目标。
- **闭环执行**:每个子任务必须走完"计划 → 实现 → 测试 → 文档 → 验证"五步;禁止跳步留半成品。
- **自主决策**:初始确认之后,实现路径、API 形态、重构范围、文件命名、测试组织、错误修复策略等由 Agent 自行决断,不再逐项请示。**可逆操作(编辑文件、运行测试、修复 lint、调整实现)直接执行,不询问**;只有不可逆/高风险操作才暂停。
- **透明沟通**:每个阶段开始前用一句话说明意图;关键节点(完成、阻塞、转向)给简短更新;不复述内部思考,**不在收尾时停下询问"是否继续"或"是否提交"**——直接输出总结并结束。
- **安全边界**:仅在高风险、不可逆操作或真正阻塞时才暂停找用户。
## 初始确认(一次性,仅在最开始)
任务启动时,用 `AskUserQuestion` 一次性确认以下信息(已由项目规范覆盖的不必重复确认):
1. **目标与范围**:要解决什么问题?交付物是什么?显式列出不在范围内的内容。
2. **验收标准**:怎样算"完成"?可观测的判定条件(功能、性能、覆盖率阈值)。
3. **特殊约束**:除 `python-standards.md` 之外的约束(兼容性、依赖限制、API 兼容策略等)。
4. **测试要求**:覆盖率门槛(项目默认 ≥95%,branch);是否需要新增 `slow` 标记。
**git commit/push 不在确认范围内**:任务完成后自动 commit + push(仅当分支已跟踪远程时执行 push;新分支跳过 push 并在总结中说明),遵循 `.trae/rules/git-commit-message.md` 风格。仅 force-push、reset --hard、clean -f、修改 git config 等真正破坏性操作才需暂停确认。
确认后,将目标与验收标准固化进 `TaskCreate` 任务列表,后续不再就同一信息反复询问。
## 迭代循环
下列五个阶段构成一个完整闭环。未达验收标准时,回到「计划」开启下一轮;达标准时,进入「收尾」。
### 1. 计划(Plan
- 用 Explore/Glob/Grep 研究相关代码与既有模式,避免凭空设计。
-`TaskCreate` 把目标拆为可独立验证的子任务;每完成一项立即 `TaskUpdate` 为 completed。
- 优先复用现有抽象;不为本轮假想需求设计接口。
- 不过早抽象:三处相似才考虑提取,否则就地写。
### 2. 实现(Code
- 严格遵守 `.trae/rules/python-standards.md` 与既有代码风格。
- 优先 Edit 现有文件;新增文件需有明确职责边界。
- 不引入运行时依赖(项目零依赖原则);确需引入须在计划阶段说明。
- 公共 API 必须有完整类型注解与中文 docstring。
- 不写未被要求的功能、不为未来场景预留扩展点。
### 3. 测试(Test
- 新增/修改的公共 API 必须配套测试;优先通过公共接口测试,故障注入可访问私有属性并在 docstring 注明。
- Mock 优先级:`monkeypatch` > 内联 stub > `unittest.mock` > `pytest-mock`;禁用 `@patch` 装饰器。
- 必跑校验(每次修改后):
```bash
uvx --from pyflowx pymake tc
uvx --from pyflowx pymake cov
```
- 测试失败时定位根因再修复,不通过放宽断言或 `# pragma: no cover` 绕过。
- 覆盖率不得低于上一次的值(项目门槛 95%,branch)。
### 4. 文档(Docs
- 同步更新 docstring、README、模块结构说明。
- 行为变更须同步更新 `.agents/skills/pyflowx-development/SKILL.md` 中的对应章节。
- 跨会话有价值的设计决策、约束、陷阱,追加到 memory(`project_memory.md` 或对应 `topics.md`)。
- 不主动新建 `*.md` 文档;除非用户明确要求。
### 5. 验证(Verify
- 逐条对照初始确认的「验收标准」核验;未满足则回到「计划」继续下一轮。
- 全套门禁通过:ruff、pyrefly、pytest、coverage。
- 给出本轮变更清单(改了哪些文件、为什么)。
## 暂停条件(仅在以下情况中断自驱动找用户)
1. **歧义无法自决**:需求存在多种合理解读且无既有约定可循。
2. **高风险/不可逆操作**:删除非临时文件、`git push --force`、`reset --hard`、删表、修改 CI 配置、修改 git config、卸载依赖等。**普通 `git commit`/`push` 不属于此类**(任务完成后自动执行)。
3. **不可恢复的失败**:根因不在本仓库、需外部环境/权限配合、或经两轮尝试仍无法定位。
4. **超出初始确认范围**:用户目标在执行中发现需要显著扩大范围或改变方向。
5. **用户主动询问**:用户在对话中提出新问题或要求澄清。
**注意**"目标已达成"**不是**暂停条件——验收标准全部满足后直接进入收尾并结束任务,不询问"是否扩展范围"或"是否提交"。
非以上情况,一律继续自驱动,不要为"求确认"而暂停。
## 决策判据:该问还是自决
遇到不确定时,按以下顺序判断:
1. **是否不可逆/高风险?** 是 → 暂停确认(如删除文件、`push --force`、修改 CI 配置、卸载依赖)。否 → 继续。
2. **是否在初始确认范围内?** 是 → 按确认执行,不询问。否 → 视为"超出初始确认范围",暂停。
3. **是否有既有约定可循?** 是 → 按约定执行(参考 `python-standards.md`、`project_memory.md`)。否 → 视为"歧义无法自决",暂停。
4. **是否可逆?** 是 → 直接执行,即使结果可能不完美(可在后续迭代修正)。否 → 暂停。
**可直接自决(不询问)的典型情况**:
- 测试失败、覆盖率不达标、lint/类型检查报错 → 定位根因并修复。
- 代码风格选择(命名、模块划分、参数顺序)→ 自决。
- 文件编辑、运行测试、运行校验命令 → 直接执行。
- 任务完成后输出收尾总结 → 直接输出,不询问下一步。
- 显式指定 `name` 参数以保持测试兼容性 → 自决。
- 重命名局部变量以避免遮蔽 → 自决。
**必须暂停询问的典型情况**
- 删除非临时文件、重命名公共模块/包。
- `git push --force`、`reset --hard`、`clean -f`、修改 git config(普通 commit/push 自动执行,无需询问)。
- 引入新的运行时依赖(违反项目零依赖原则)。
- 修改 CI 配置、pre-commit 钩子、pyproject.toml 的工具链配置。
- 卸载或降级既有依赖。
## 沟通风格
- 阶段切换时一句话说明即可;不要把内部推理写给用户看。
- 完成子任务后用一两句总结改了什么、下一步做什么。
- 遇到阻塞时直接说明:卡在哪、试了什么、需要用户做什么。
- **不在收尾时询问"是否需要提交"或"是否扩展范围"**——直接输出总结并结束。用户后续若有新需求,由用户主动提出。
- 不使用 emoji,除非用户明确要求。
## 工具使用
- 独立操作尽量并行调用(多个 Read/Grep/Glob 一批发出)。
- 用 `TaskCreate`/`TaskUpdate` 维护进度,不批量推迟标记。
- 长命令用后台运行(`run_in_background`),完成会自动通知。
- 文件操作一律用专用工具:Read/Edit/Write/Glob/Grep,不用 `cat`/`sed`/`grep`/`find`。
## 收尾
- 验收标准全部满足后,**直接输出最终总结并结束任务**:交付物、关键决策、遗留事项。
- **自动提交**:收尾时自动 `git add`(按文件名)+ `git commit`(遵循 `.trae/rules/git-commit-message.md` 风格)+ `git push`(仅当分支已跟踪远程时执行;新分支跳过 push 并在总结中说明);**不询问**"是否需要提交"或"是否扩展范围"。
- 若验收标准未全部满足,回到「计划」继续下一轮,不停下询问。
- 将本次会话的关键产出与决策更新到 memory,便于后续会话续接。
+63
View File
@@ -0,0 +1,63 @@
# 使用国内镜像源拉取基础镜像
# 备选镜像源前缀:docker.1ms.run / dockerpull.com / docker.xuanyuan.me
FROM docker.m.daocloud.io/python:3.13-slim
# 国内镜像源(清华)
ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
ENV PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
ENV UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
ENV UV_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
# 环境变量:非交互 + 路径配置
ENV DEBIAN_FRONTEND=noninteractive \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
UV_LINK_MODE=copy \
UV_CACHE_DIR=/uv-cache \
UV_PYTHON_INSTALL_DIR=/uv-python \
UV_PROJECT_ENVIRONMENT=/opt/venv \
PATH="/opt/venv/bin:${PATH}"
# 配置 apt 国内镜像(阿里云)并安装系统依赖
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
jq \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# 配置 pip 国内镜像(阿里云)
RUN mkdir -p /etc/pip \
&& printf '[global]\nindex-url = https://mirrors.aliyun.com/pypi/simple/\ntrusted-host = mirrors.aliyun.com\n' \
> /etc/pip/pip.conf \
&& mkdir -p /root/.config/pip \
&& ln -sf /etc/pip/pip.conf /root/.config/pip/pip.conf
# 安装 uv 并预装 Python 3.8 / 3.13
RUN pip install --no-cache-dir uv -i https://mirrors.aliyun.com/pypi/simple/ \
&& uv python install 3.8 3.13
# 安装 Node.js 20.xactions/checkout 需要)
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y nodejs && \
node --version
# 预装项目 dev 依赖(仅复制依赖描述文件,利用 Docker 层缓存)
WORKDIR /workspace
COPY pyproject.toml tox.ini README.md ./
COPY src/ ./src/
# 同步依赖到 /opt/venv(CI 时直接复用)
RUN uv sync --frozen --no-install-project 2>/dev/null || uv sync --no-install-project
# 预装 tox 环境(py38 + py313
RUN uvx tox run -e py38,py313 --notest 2>/dev/null || true
# 持久化 uv 缓存目录(CI 可挂载到宿主机加速)
VOLUME ["/uv-cache"]
# 默认入口
CMD ["/bin/bash"]
+128 -18
View File
@@ -5,6 +5,7 @@
[![CI](https://github.com/gookeryoung/pyflowx/actions/workflows/ci.yml/badge.svg)](https://github.com/gookeryoung/pyflowx/actions/workflows/ci.yml) [![CI](https://github.com/gookeryoung/pyflowx/actions/workflows/ci.yml/badge.svg)](https://github.com/gookeryoung/pyflowx/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/pyflowx.svg)](https://pypi.org/project/pyflowx/) [![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/) [![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-100%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) [![License](https://img.shields.io/pypi/l/pyflowx.svg)](https://github.com/gookeryoung/pyflowx/blob/main/LICENSE)
@@ -31,7 +32,8 @@ PyFlowX 把"任务依赖"这件事做到极致简单:**参数名就是依赖
- **图级默认值** —— `GraphDefaults` 统一配置 retry/timeout/concurrency 等 - **图级默认值** —— `GraphDefaults` 统一配置 retry/timeout/concurrency 等
- **CLI 运行器** —— `CliRunner` 把多个图映射为命令行子命令,替代 Makefile - **CLI 运行器** —— `CliRunner` 把多个图映射为命令行子命令,替代 Makefile
- **可观测** —— `on_event` 回调(RUNNING/SUCCESS/FAILED/SKIPPED)、`dry_run` 预览、`verbose` 生命周期日志、Mermaid 可视化 - **可观测** —— `on_event` 回调(RUNNING/SUCCESS/FAILED/SKIPPED)、`dry_run` 预览、`verbose` 生命周期日志、Mermaid 可视化
- **零运行时依赖** —— 仅依赖标准库(3.8 需 `graphlib_backport` - **YAML 任务编排** —— GitHub Actions 风格的声明式任务图,支持 `jobs`/`needs`/`strategy.matrix`/`if` 等 CI/CD 概念,从 YAML 文件直接加载执行
- **最小依赖** —— 仅依赖标准库 + PyYAML3.8 需 `graphlib_backport``typing-extensions`
- **97% 测试覆盖** —— 分支覆盖率 >= 95% - **97% 测试覆盖** —— 分支覆盖率 >= 95%
## 安装 ## 安装
@@ -300,31 +302,123 @@ runner.run_cli() # 解析 sys.argv 并执行
命令行用法: 命令行用法:
```bash ```bash
python build.py clean # 执行 clean 图 pf pymake clean # 执行 clean 图
python build.py build --strategy thread # 覆盖执行策略 pf pymake build --strategy thread # 覆盖执行策略
python build.py test --dry-run # 仅打印执行计划 pf pymake test --dry-run # 仅打印执行计划
python build.py --list # 列出所有命令 pf pymake --list # 列出所有命令
python build.py --quiet # 静默模式 pf pymake --quiet # 静默模式
``` ```
`verbose=True`(默认)时打印任务生命周期(开始/成功/失败/跳过)与命令输出;`--quiet` 关闭。 `verbose=True`(默认)时打印任务生命周期(开始/成功/失败/跳过)与命令输出;`--quiet` 关闭。
## 示例 ## YAML 任务编排
仓库 `examples/` 目录包含完整示例: PyFlowX 支持 GitHub Actions 风格的声明式 YAML 任务编排,从 YAML 文件直接加载任务图。
- [`etl_pipeline.py`](examples/etl_pipeline.py) —— ETL 流水线(sequential ### 编程式 API
- [`parallel_run.py`](examples/parallel_run.py) —— 并行执行对比(thread vs sequential
- [`async_aggregation.py`](examples/async_aggregation.py) —— 异步聚合 + Context 注入
运行: ```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 ```bash
python examples/etl_pipeline.py # 执行 YAML 任务图
python examples/parallel_run.py pf yamlrun pipeline.yaml
python examples/async_aggregation.py
# 指定执行策略
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`(追加) | 运行环境标签 |
## 断点续跑 ## 断点续跑
```python ```python
@@ -441,12 +535,14 @@ uv run pytest --cov=pyflowx --cov-fail-under=95
uv run mypy uv run mypy
# 代码风格 # 代码风格
uv run ruff check src tests examples uv run ruff check src tests
uv run ruff format --check src tests examples uv run ruff format --check src tests
``` ```
## 模块结构 ## 模块结构
### 核心
| 模块 | 职责 | | 模块 | 职责 |
|------|------| |------|------|
| `task.py` | 纯数据结构:`TaskSpec``RetryPolicy``TaskHooks``TaskStatus` | | `task.py` | 纯数据结构:`TaskSpec``RetryPolicy``TaskHooks``TaskStatus` |
@@ -455,11 +551,25 @@ uv run ruff format --check src tests examples
| `context.py` | 上下文注入:参数名→依赖解析 | | `context.py` | 上下文注入:参数名→依赖解析 |
| `command.py` | 命令执行:`run_command`list/shell/Callable | | `command.py` | 命令执行:`run_command`list/shell/Callable |
| `conditions.py` | 条件执行:内置条件与组合器 | | `conditions.py` | 条件执行:内置条件与组合器 |
| `executors.py` | 执行器与 `run` 入口:四种策略共享模块级辅助 | | `executors.py` | 执行器与 `run` 入口:四种策略共享模块级辅助verbose 统一应用到 spec |
| `storage.py` | 状态后端:`MemoryBackend` / `JSONBackend`batch flush | | `storage.py` | 状态后端:`MemoryBackend` / `JSONBackend`batch flush |
| `runner.py` | CLI 运行器:`CliRunner` | | `runner.py` | CLI 运行器:`CliRunner` |
| `report.py` | 运行结果:`RunReport` / `TaskResult` | | `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:` 引用) |
| `profiling.py` | 性能分析:`Profiler` 任务耗时统计 |
| `errors.py` | 错误家族:`PyFlowXError` 子类 | | `errors.py` | 错误家族:`PyFlowXError` 子类 |
| `ops/` | 工具函数(dev/files/llm/media/system),被 YAML 的 `fn:` 引用 |
### CLI 工具
| 模块 | 职责 |
|------|------|
| `cli/pf.py` | 统一入口:`pf <tool> [command]`,自动发现 `configs/*.yaml` 并路由 |
| `configs/` | YAML 工具配置(clr/taskkill/which/msdownload/sglang/dockercmd/envdev 等) |
| `cli/yamlrun.py` | YAML pipeline 执行器,`pf yamlrun pipeline.yaml` 调用 |
| `cli/profiler.py` | 性能分析 CLI |
| `cli/emlmanager.py` | 邮件管理 CLI |
## 许可证 ## 许可证
+106
View File
@@ -0,0 +1,106 @@
API 参考
========
任务描述
--------
.. autoclass:: pyflowx.TaskSpec
:members:
:undoc-members:
:show-inheritance:
:exclude-members: args, kwargs
.. autoclass:: pyflowx.RetryPolicy
:members:
:undoc-members:
.. autoclass:: pyflowx.TaskHooks
:members:
:undoc-members:
.. autoclass:: pyflowx.TaskStatus
:members:
:undoc-members:
图构建
------
.. autoclass:: pyflowx.Graph
:members:
:undoc-members:
:exclude-members: from_specs, from_yaml
.. autoclass:: pyflowx.GraphDefaults
:members:
:undoc-members:
.. autofunction:: pyflowx.compose
.. autofunction:: pyflowx.task_template
执行
----
.. autofunction:: pyflowx.run
.. autoclass:: pyflowx.RunReport
:members:
:undoc-members:
.. autoclass:: pyflowx.TaskResult
:members:
:undoc-members:
YAML 编排
---------
.. autofunction:: pyflowx.load_yaml
.. autofunction:: pyflowx.parse_yaml_string
.. autofunction:: pyflowx.run_yaml
.. autofunction:: pyflowx.run_cli
.. autofunction:: pyflowx.build_cli_parser
函数注册
--------
.. autofunction:: pyflowx.register_fn
.. autofunction:: pyflowx.get_fn
.. autofunction:: pyflowx.has_fn
命令执行
--------
.. autofunction:: pyflowx.run_command
CLI 运行器
----------
.. autoclass:: pyflowx.CliRunner
:members:
:undoc-members:
状态后端
--------
.. autoclass:: pyflowx.StateBackend
:members:
:undoc-members:
.. autoclass:: pyflowx.MemoryBackend
:members:
:undoc-members:
.. autoclass:: pyflowx.JSONBackend
:members:
:undoc-members:
错误家族
--------
.. autoexception:: pyflowx.PyFlowXError
.. autoexception:: pyflowx.DuplicateTaskError
.. autoexception:: pyflowx.MissingDependencyError
.. autoexception:: pyflowx.CycleError
.. autoexception:: pyflowx.TaskFailedError
.. autoexception:: pyflowx.TaskTimeoutError
.. autoexception:: pyflowx.InjectionError
.. autoexception:: pyflowx.StorageError
+45
View File
@@ -0,0 +1,45 @@
变更日志
========
0.4.5
-----
CLI 重构
~~~~~~~~
- 新增 ``pf`` 统一入口:通过 ``pf <tool> [command] [options]`` 调用所有工具
- 13 个工具迁移到 YAML 配置(filedate/filelevel/folderback/folderzip/screenshot/sshcopyid/lscalc/bumpversion/autofmt/piptool/packtool/pdftool/gittool
- YAML 配置支持 ``cli:`` 段声明命令行参数 schema,由 ``build_cli_parser`` 自动生成 argparse
- 删除 13 个冗余 ``.py`` 入口脚本,统一通过 ``pf`` 调用
- ``run()````verbose=True`` 时自动把 verbose 标记应用到所有 spec
- 全局选项 ``--verbose`` 改为 ``--quiet``(默认显示执行过程)
- ``cmd`` 任务成功时打印 stdout(此前被静默丢弃)
- ``gittool````CLEAN_EXCLUDES`` 数组变量配置 ``git clean -e`` 参数
YAML 任务编排
~~~~~~~~~~~~~
- 支持 ``variables`` 变量定义,``${VAR}`` 在 cmd/env/cwd 中替换
- 列表变量展开为 cmd 数组多个元素
- ``cli:`` 段支持 subcommands/positional/options 三级 schema
- 支持 ``type: path`` 自动转为 ``pathlib.Path``
文档
~~~~
- 搭建 Sphinx 文档,发布到 ReadTheDocs
- 更新 READMECLI 示例改为 ``pf`` 统一入口,模块结构表补全
0.3.x
-----
- 新增 YAML 任务编排(GitHub Actions 风格 schema
- 新增 ``fn:`` 函数引用与 ``register_fn`` / ``get_fn`` 注册中心
- 新增 ``compose`` / ``GraphComposer`` 多图组合
- 新增 ``task_template`` 任务模板工厂
- 新增 ``concurrency_key`` + ``concurrency_limits`` 并发限制
- 新增 ``JSONBackend`` 断点续跑与 ``batch()`` 批量落盘
- 新增 ``cache_key`` 缓存键函数
- 新增条件执行(``IS_WINDOWS`` / ``HAS_INSTALLED`` / ``ENV_VAR_EQUALS`` 等)
- 四种执行策略:``sequential`` / ``thread`` / ``async`` / ``dependency``
- 参数名即依赖的上下文注入机制
+65
View File
@@ -0,0 +1,65 @@
"""Sphinx 配置.
ReadTheDocs 构建 PyFlowX 文档站。
"""
from __future__ import annotations
import sys
from pathlib import Path
# 确保 src/ 在 sys.path 中, autodoc 能导入 pyflowx
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
from pyflowx import __version__
# -- 项目信息 --------------------------------------------------------------
project = "PyFlowX"
author = "pyflowx"
copyright = "2024, pyflowx"
release = __version__
version = __version__
# -- Sphinx 配置 -----------------------------------------------------------
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.napoleon",
"sphinx.ext.viewcode",
"sphinx.ext.intersphinx",
"myst_parser",
]
# -- 主题 ------------------------------------------------------------------
html_theme = "sphinx_rtd_theme"
html_static_path = ["_static"]
# -- autodoc 配置 ----------------------------------------------------------
autodoc_default_options = {
"members": True,
"undoc-members": True,
"show-inheritance": True,
"member-order": "bysource",
}
autodoc_type_hints = "description"
autodoc_typehints_format = "short"
# -- napoleon 配置 (Google/NumPy docstring 兼容) --------------------------
napoleon_google_docstring = True
napoleon_numpy_docstring = True
napoleon_include_init_with_doc = False
napoleon_include_private_with_doc = False
napoleon_include_special_with_doc = True
# -- intersphinx -----------------------------------------------------------
intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
}
# -- 全局选项 ---------------------------------------------------------------
language = "zh_CN"
master_doc = "index"
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
source_suffix = {
".rst": "restructuredtext",
".md": "markdown",
}
+158
View File
@@ -0,0 +1,158 @@
pf 统一 CLI 入口
================
所有工具通过 ``pf <tool> [command] [options]`` 调用。工具定义在 ``cli/configs/`` 目录下的 YAML 文件中。
基本用法
--------
.. code-block:: bash
pf # 列出所有可用工具
pf filedate # 查看 filedate 工具帮助
pf filedate add a.txt # 调用 filedate 的 add 子命令
pf gitt c # 调用 gittool 的 c 子命令
pf pymake b # 调用 pymake 的 b 别名
全局选项
--------
所有 YAML 工具支持以下全局选项:
.. list-table::
:header-rows: 1
:widths: 25 75
* - 选项
- 说明
* - ``--dry-run``
- 仅打印执行计划,不执行
* - ``--quiet`` / ``-q``
- 减少输出,不显示执行过程
* - ``--strategy``
- 执行策略(``sequential`` / ``thread`` / ``async`` / ``dependency``
* - ``--list``
- 列出所有任务名后退出
默认 ``verbose`` 开启,显示执行过程(任务开始/命令/返回码/任务成功)。``--quiet`` 关闭。
YAML 配置工具
--------------
.. list-table::
:header-rows: 1
:widths: 20 15 65
* - 工具
- 别名
- 说明
* - ``filedate``
- ``fd``
- 文件日期处理
* - ``filelevel``
- ``fl``
- 文件等级重命名
* - ``folderback``
- ``fb``
- 文件夹备份
* - ``folderzip``
- ``fz``
- 文件夹压缩
* - ``gittool``
- ``gitt``
- Git 执行工具
* - ``lscalc``
- ``ls``
- LS-DYNA 计算工具
* - ``packtool``
- ``pack``
- Python 打包工具
* - ``pdftool``
- ``pdf``
- PDF 文件工具集
* - ``piptool``
- ``pip``
- pip 包管理工具
* - ``screenshot``
- ``ss``
- 截图工具
* - ``sshcopyid``
- ``ssh``
- SSH 密钥部署工具
* - ``autofmt``
- ``af``
- 自动格式化工具
* - ``bumpversion``
- ``bump``
- 版本号自动管理工具
传统工具
--------
.. list-table::
:header-rows: 1
:widths: 20 80
* - 工具
- 说明
* - ``pymake``
- 构建工具(替代 Makefile),如 ``pf pymake b`` 构建
* - ``yamlrun``
- YAML pipeline 执行器,``pf yamlrun pipeline.yaml``
* - ``profiler``
- 性能分析
* - ``emlman``
- 邮件管理
* - ``reseticon``
- 重置图标缓存
自定义工具
----------
``cli/configs/`` 目录新建 ``<tool>.yaml`` 即可被 ``pf`` 自动发现:
.. code-block:: yaml
# cli/configs/mytool.yaml
strategy: sequential
variables:
MSG: "hello"
cli:
description: "我的工具"
usage: "pf mytool [command]"
subcommands:
greet:
help: "打招呼"
jobs:
greet:
cmd: ["echo", "${MSG}"]
执行::
pf mytool greet
CliRunner(编程式)
-------------------
``CliRunner`` 把多个 Graph 映射为命令行子命令,适合构建项目专属构建工具:
.. code-block:: python
runner = px.CliRunner(
strategy="sequential",
description="My Build Tool",
graphs={
"clean": clean_graph,
"build": build_graph,
"test": test_graph,
},
)
runner.run_cli() # 解析 sys.argv 并执行
命令行::
pf pymake clean
pf pymake build --strategy thread
pf pymake test --dry-run
pf pymake --list
pf pymake --quiet
+93
View File
@@ -0,0 +1,93 @@
执行策略与 run()
=================
``run()`` 是执行入口,支持四种策略:
.. code-block:: python
report = px.run(
graph,
strategy="async", # sequential | thread | async | dependency
max_workers=8, # thread 策略的线程池大小
concurrency_limits={"db": 2}, # 按 concurrency_key 限流
dry_run=False, # True = 仅打印计划
verbose=True, # True = 打印执行过程
on_event=callback, # 状态转换回调
state=px.JSONBackend("state.json"), # 断点续跑后端
continue_on_error=False, # True = 单任务失败不中断整体
)
策略对比
--------
.. list-table::
:header-rows: 1
:widths: 18 18 30 16 18
* - 策略
- 并发模型
- 适用场景
- 同步任务
- 异步任务
* - ``sequential``
- 串行
- 调试、CPU 密集
- 直接调用
- 事件循环
* - ``thread``
- 线程池
- I/O 密集同步
- 线程池
- 不支持
* - ``async``
- 事件循环
- I/O 密集异步
- 卸载到线程池
- 事件循环
* - ``dependency``
- 依赖驱动
- 最大化并行度
- 卸载到线程池
- 事件循环
所有策略都遵循 ``RetryPolicy````timeout``、上下文注入、状态后端、``concurrency_limits``
并发出 ``TaskEvent``RUNNING/SUCCESS/FAILED/SKIPPED)。``dependency`` 策略无层屏障:
任务在其所有硬依赖完成后立即启动。
上下文注入规则
--------------
按顺序求值:
1. **标注为 ``Context``** 的参数 → 接收完整上游结果映射
2. **名称匹配依赖** 的参数 → 接收该依赖的结果(含软依赖,缺失时注入默认值)
3. **``**kwargs``** 参数 → 接收所有依赖结果(dict)
4. **``TaskSpec.args`` / ``kwargs``** → 为非依赖参数提供静态值
.. code-block:: python
from typing import Any, Dict
def aggregate(ctx: px.Context) -> Dict[str, Any]:
"""ctx 包含所有 depends_on 任务的返回值。"""
return dict(ctx)
def merge(fetch_a: str, fetch_b: str) -> str:
"""fetch_a / fetch_b 自动注入。"""
return fetch_a + fetch_b
断点续跑
--------
.. code-block:: python
from pyflowx import JSONBackend
backend = JSONBackend("state.json", ttl=3600)
report = px.run(graph, strategy="sequential", state=backend)
``run()`` 内部以 ``backend.batch()`` 包裹整个执行:所有 ``save`` 延迟到运行结束时统一落盘一次。
缓存键:默认存储键为任务名。配置 ``cache_key`` 函数后,键为 ``"name:cache_key_value"``
完整 API 说明详见 :doc:`/api`
+50
View File
@@ -0,0 +1,50 @@
Graph —— DAG 构建
=================
``Graph`` 管理任务集合,提供建构建、校验、分层、可视化能力。
构建方式
--------
.. code-block:: python
# 图级默认值:TaskSpec 字段为 None 时回退
defaults = px.GraphDefaults(retry=px.RetryPolicy(max_attempts=2), timeout=60.0)
graph = px.Graph.from_specs([...], defaults=defaults) # 整批校验(推荐)
# 或增量构建
graph = px.Graph(defaults=defaults)
graph.add(px.TaskSpec("a", fn_a))
graph.add(px.TaskSpec("b", fn_b, ("a",)))
常用方法
--------
.. code-block:: python
graph.validate() # 显式校验(环检测)
graph.layers() # 拓扑分层(Kahn 算法)
graph.to_mermaid() # Mermaid 可视化
graph.describe() # 人类可读摘要
graph.subgraph(("api",)) # 按标签切片
graph.subgraph_by_names(("a", "b")) # 按名称切片
graph.map("fetch", [1, 2, 3], lambda i: TaskSpec(f"fetch_{i}", ...)) # 批量 fan-out
图组合
------
``compose`` / ``GraphComposer`` 把带字符串引用的多个图展开为纯 ``Graph``
.. code-block:: python
graphs = {
"build": px.Graph.from_specs([px.TaskSpec("b", cmd=["echo", "b"])]),
"all": px.Graph.from_specs(["build", px.TaskSpec("t", cmd=["echo", "t"])]),
}
resolved = px.compose(graphs) # "all" 图中的 "build" 引用被展开
引用格式:``"command_name"``(整个图)或 ``"command_name.task_name"``(特定任务)。
``CliRunner`` 内部自动调用 ``compose``
完整方法说明详见 :doc:`/api`
+89
View File
@@ -0,0 +1,89 @@
TaskSpec —— 任务描述
=====================
``TaskSpec`` 是不可变的任务描述符(``Generic[T]``,返回类型一路传到 ``RunReport``),是唯一需要配置的东西。
主要参数说明:
.. code-block:: python
px.TaskSpec(
name="fetch_user", # 唯一标识
fn=fetch_user, # 同步或异步函数
cmd=["curl", "..."], # 或: 执行命令(覆盖 fn)
depends_on=("auth",), # 硬依赖(参与拓扑分层)
soft_depends_on=("cache",), # 软依赖(仅注入,不参与分层)
args=(uid,), # 静态位置参数(追加在注入参数后)
kwargs={"timeout": 30}, # 静态关键字参数
retry=px.RetryPolicy(max_attempts=3, delay=1.0, backoff=2.0),
timeout=30.0, # 超时秒数(None = 不限制)
tags=("api", "user"), # 自由标签,用于子图过滤
conditions=(is_prod,), # 条件函数列表(全部为 True 才执行)
priority=10, # 同层内优先级(高优先执行,默认 0)
concurrency_key="db", # 并发分组键(配合 concurrency_limits 限流)
cache_key=lambda ctx: str(ctx.get("uid")), # 缓存键函数
hooks=px.TaskHooks(pre_run=..., post_run=..., on_failure=...),
cwd=Path("/tmp"), # 命令工作目录(仅 cmd 模式)
env={"DEBUG": "1"}, # 环境变量覆盖
verbose=True, # 打印命令输出(仅 cmd 模式)
skip_if_missing=True, # 命令不存在时自动跳过(仅 list[str] cmd
allow_upstream_skip=False, # 上游 SKIPPED/FAILED 时是否仍执行
continue_on_error=False, # 本任务失败是否不中断整体
)
两种任务形态
------------
- **函数任务**``fn``):普通 Python 函数,参数名驱动自动注入
- **命令任务**``cmd``):执行外部命令,支持 ``list[str]````str``shell)、``Callable`` 三种形态
``skip_if_missing=True`` 时,``list[str]`` 类型的 ``cmd`` 会通过 ``shutil.which`` 检查命令是否存在,不存在则跳过任务(标记为 ``SKIPPED``)而非失败。
重试策略
--------
``RetryPolicy`` 配置重试次数、延迟、退避:
.. code-block:: python
retry = px.RetryPolicy(
max_attempts=3, # 最大尝试次数
delay=1.0, # 初始延迟秒数
backoff=2.0, # 退避倍数
jitter=0.1, # 随机抖动(避免惊群)
retry_on=(ConnectionError,), # 仅对这些异常重试
)
任务钩子
--------
``TaskHooks`` 在任务生命周期触发(异常仅记录,不影响任务状态):
.. code-block:: python
hooks = px.TaskHooks(
pre_run=lambda spec: print(f"start {spec.name}"),
post_run=lambda spec, value: print(f"done {spec.name}"),
on_failure=lambda spec, exc: alert(spec.name, exc),
)
px.TaskSpec("task", fn=work, hooks=hooks)
任务模板
--------
``task_template`` 工厂批量生成相似 TaskSpec
.. code-block:: python
fetch = px.task_template(
fn=fetch_url,
retry=px.RetryPolicy(max_attempts=5),
timeout=30.0,
tags=("api",),
)
graph = px.Graph.from_specs([
fetch("users", url="https://api.example.com/users"),
fetch("posts", url="https://api.example.com/posts"),
])
完整字段说明详见 :doc:`/api`
+164
View File
@@ -0,0 +1,164 @@
YAML 任务编排
=============
PyFlowX 支持 GitHub Actions 风格的声明式 YAML 任务编排,从 YAML 文件直接加载任务图。
编程式 API
----------
.. code-block:: 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"]
""")
YAML Schema
-----------
.. code-block:: yaml
strategy: thread # 图级默认策略
defaults: # 图级默认值
retry: {max_attempts: 3}
verbose: true
env: {CI: "true"}
variables: # 变量定义 (可在 cmd/env 中 ${VAR} 引用)
OUTPUT: "dist"
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
字段映射
--------
.. list-table::
:header-rows: 1
:widths: 30 30 40
* - 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``(追加)
- 运行环境标签
CLI 配置段(``cli:``
----------------------
工具 YAML 还可定义 ``cli:`` 段,声明命令行参数 schema,由 ``pf`` 自动解析:
.. code-block:: yaml
cli:
description: "FileDate - 文件日期处理工具"
usage: "pf filedate <command> [files...]"
subcommands:
add:
help: "添加日期前缀"
positional:
- name: FILES
nargs: "+"
type: path
help: "文件路径"
options:
- name: CLEAR
flag: "--clear"
action: store_true
help: "清除已有日期前缀"
支持的 ``type````str`` / ``int`` / ``float`` / ``path``
完整 API 说明详见 :doc:`/api`
+56
View File
@@ -0,0 +1,56 @@
PyFlowX 文档
============
PyFlowX 是一个轻量、类型安全的 DAG 任务调度器:**参数名就是依赖声明**
无需装饰器、无需样板包装器,写一个普通函数,框架按参数名自动注入上游结果。
特性
----
- **零样板** —— 参数名即依赖,框架自动注入上游结果
- **四种执行策略** —— sequential(串行)、thread(线程池)、async(事件循环)、dependency(依赖驱动,最大化并行)
- **类型安全** —— ``TaskSpec[T]`` 把返回类型一路传到 ``RunReport``
- **DAG 校验** —— 构建时即时校验重名、缺失依赖、环
- **自动分层** —— Kahn 算法分组,同层任务可并行
- **重试与超时** —— 每个任务独立配置 ``RetryPolicy````timeout``
- **并发限制** —— ``concurrency_key`` + ``concurrency_limits`` 按组限流
- **断点续跑** —— ``MemoryBackend`` / ``JSONBackend``,成功结果可缓存复用
- **命令任务** —— ``cmd`` 参数直接执行外部命令
- **条件执行** —— ``conditions`` 按平台、环境变量等条件跳过任务
- **YAML 任务编排** —— GitHub Actions 风格声明式任务图
- **pf 统一 CLI** —— ``pf <tool> [command]`` 调用所有工具
- **最小依赖** —— 仅依赖标准库 + PyYAML
文档导航
--------
.. toctree::
:maxdepth: 2
:caption: 入门
installation
quickstart
.. toctree::
:maxdepth: 2
:caption: 用户指南
guide/task
guide/graph
guide/execution
guide/yaml
guide/cli
.. toctree::
:maxdepth: 2
:caption: 参考
api
changelog
索引
----
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
+51
View File
@@ -0,0 +1,51 @@
安装
====
PyFlowX 支持 Python 3.8+,仅依赖标准库与 PyYAML(3.8 额外需要 ``graphlib_backport````typing-extensions``)。
pip 安装
--------
.. code-block:: bash
pip install pyflowx
uv 安装
-------
推荐使用 `uv <https://docs.astral.sh/uv/>`_
.. code-block:: bash
uv add pyflowx
可选依赖
--------
``office`` —— PDF/图片处理(pdftool、screenshot 等工具需要):
.. code-block:: bash
pip install pyflowx[office]
``dev`` —— 开发工具链(ruff、pyrefly、pytest、tox 等):
.. code-block:: bash
pip install pyflowx[dev]
验证安装
--------
.. code-block:: bash
pf --version
输出示例::
PyFlowX 0.4.5
下一步
------
前往 :doc:`quickstart` 开始使用。
+87
View File
@@ -0,0 +1,87 @@
快速上手
========
核心思想:**参数名即依赖**。写一个普通函数,参数名匹配上游任务名,框架自动注入结果。
最小示例
--------
.. code-block:: python
import pyflowx as px
def extract() -> list[int]:
return [1, 2, 3]
# 参数名 extract 自动匹配上游任务名 → 自动注入
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, ("extract",)),
])
report = px.run(graph, strategy="sequential")
print(report["double"]) # [2, 4, 6]
三种任务形态
------------
1. **函数任务**``fn``):普通 Python 函数,参数名驱动自动注入
2. **命令任务**``cmd``):执行外部命令,支持 ``list[str]`` / ``str``shell/ ``Callable``
3. **YAML 声明式**:从 YAML 文件加载任务图
.. code-block:: python
graph = px.Graph.from_specs([
px.TaskSpec("list", cmd=["ls", "-la"]),
px.TaskSpec("greet", fn=lambda: "hello"),
])
执行策略
--------
PyFlowX 提供四种执行策略:
.. list-table::
:header-rows: 1
:widths: 20 20 60
* - 策略
- 并发模型
- 适用场景
* - ``sequential``
- 串行
- 调试、CPU 密集
* - ``thread``
- 线程池
- I/O 密集同步
* - ``async``
- 事件循环
- I/O 密集异步
* - ``dependency``
- 依赖驱动
- 最大化并行度(默认推荐)
.. code-block:: python
report = px.run(graph, strategy="dependency")
结果访问
--------
.. code-block:: python
report["task_name"] # 任务返回值
report.result_of("task_name") # 完整 TaskResult
report.success # 整体是否成功
report.summary() # 统计字典
report.failed_tasks() # 失败任务名列表
下一步
------
- :doc:`guide/task` —— TaskSpec 详细配置
- :doc:`guide/yaml` —— YAML 声明式任务编排
- :doc:`guide/cli` —— ``pf`` 统一 CLI 入口
+14 -32
View File
@@ -13,7 +13,8 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"graphlib_backport >= 1.0.0; python_version < '3.9'", "graphlib_backport >= 1.0.0; python_version < '3.9'",
"typing-extensions>=4.13.2; python_version < '3.10'", "pyyaml>=6.0.1",
"typing-extensions>=4.13.2; python_version < '3.13'",
] ]
description = "Lightweight, type-safe DAG task scheduler with multi-strategy execution." description = "Lightweight, type-safe DAG task scheduler with multi-strategy execution."
keywords = ["async", "dag", "scheduler", "task", "workflow"] keywords = ["async", "dag", "scheduler", "task", "workflow"]
@@ -21,34 +22,13 @@ license = { text = "MIT" }
name = "pyflowx" name = "pyflowx"
readme = "README.md" readme = "README.md"
requires-python = ">=3.8" requires-python = ">=3.8"
version = "0.3.0" version = "0.4.7"
[project.scripts] [project.scripts]
autofmt = "pyflowx.cli.autofmt:main" emlman = "pyflowx.cli.emlmanager:main"
bumpversion = "pyflowx.cli.bumpversion:main" pf = "pyflowx.cli.pf:main"
emlman = "pyflowx.cli.emlmanager:main" pxp = "pyflowx.cli.profiler:main"
filedate = "pyflowx.cli.filedate:main" yamlrun = "pyflowx.cli.yamlrun:main"
filelvl = "pyflowx.cli.filelevel:main"
foldback = "pyflowx.cli.folderback:main"
foldzip = "pyflowx.cli.folderzip:main"
gitt = "pyflowx.cli.gittool:main"
lscalc = "pyflowx.cli.lscalc:main"
msdown = "pyflowx.cli.llm.msdownload:main"
packtool = "pyflowx.cli.packtool:main"
pdftool = "pyflowx.cli.pdftool:main"
piptool = "pyflowx.cli.piptool:main"
pymake = "pyflowx.cli.pymake:main"
pxp = "pyflowx.cli.profiler:main"
reseticon = "pyflowx.cli.reseticoncache:main"
scrcap = "pyflowx.cli.screenshot:main"
sglang = "pyflowx.cli.llm.sglang:main"
sshcopy = "pyflowx.cli.sshcopyid: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"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
@@ -65,10 +45,9 @@ dev = [
"ruff>=0.8.0", "ruff>=0.8.0",
"tox-uv>=1.13.1", "tox-uv>=1.13.1",
"tox>=4.25.0", "tox>=4.25.0",
"types-PyYAML>=6.0.12",
] ]
llm = [ docs = ["myst-parser>=3.0", "sphinx-rtd-theme>=2.0", "sphinx>=7.0"]
"sglang[all]==0.5.10rc0; python_version >= '3.10' and sys_platform == 'linux'",
]
office = [ office = [
"pillow>=10.4.0", "pillow>=10.4.0",
"pymupdf>=1.24.11", "pymupdf>=1.24.11",
@@ -80,6 +59,9 @@ office = [
build-backend = "hatchling.build" build-backend = "hatchling.build"
requires = ["hatchling"] requires = ["hatchling"]
[tool.uv]
required-version = ">=0.5.0"
[[tool.uv.index]] [[tool.uv.index]]
default = true default = true
url = "https://mirrors.aliyun.com/pypi/simple/" url = "https://mirrors.aliyun.com/pypi/simple/"
@@ -94,12 +76,12 @@ packages = ["src/pyflowx"]
pyflowx = { workspace = true } pyflowx = { workspace = true }
[dependency-groups] [dependency-groups]
dev = ["pyflowx[dev,office,llm]"] dev = ["pyflowx[dev,docs,office]"]
[tool.coverage.run] [tool.coverage.run]
branch = true branch = true
concurrency = ["thread"] concurrency = ["thread"]
omit = ["src/pyflowx/cli/*", "src/pyflowx/examples/*", "tests/*"] omit = ["src/pyflowx/cli/*", "tests/*"]
source = ["pyflowx"] source = ["pyflowx"]
[tool.coverage.report] [tool.coverage.report]
+13 -1
View File
@@ -83,6 +83,7 @@ from .errors import (
from .executors import Strategy, run from .executors import Strategy, run
from .graph import Graph, GraphDefaults from .graph import Graph, GraphDefaults
from .profiling import ProfileReport, TaskProfile from .profiling import ProfileReport, TaskProfile
from .registry import FnRegistry, get_fn, has_fn, register_fn
from .report import RunReport from .report import RunReport
from .runner import CliExitCode, CliRunner from .runner import CliExitCode, CliRunner
from .storage import JSONBackend, MemoryBackend, StateBackend from .storage import JSONBackend, MemoryBackend, StateBackend
@@ -99,8 +100,9 @@ from .task import (
task, task,
task_template, task_template,
) )
from .yaml_loader import YamlLoadError, build_cli_parser, load_yaml, parse_yaml_string, run_cli, run_yaml
__version__ = "0.4.0" __version__ = "0.4.7"
__all__ = [ __all__ = [
"IS_LINUX", "IS_LINUX",
@@ -116,6 +118,7 @@ __all__ = [
"Context", "Context",
"CycleError", "CycleError",
"DuplicateTaskError", "DuplicateTaskError",
"FnRegistry",
"Graph", "Graph",
"GraphComposer", "GraphComposer",
"GraphDefaults", "GraphDefaults",
@@ -139,12 +142,21 @@ __all__ = [
"TaskSpec", "TaskSpec",
"TaskStatus", "TaskStatus",
"TaskTimeoutError", "TaskTimeoutError",
"YamlLoadError",
"build_call_args", "build_call_args",
"build_cli_parser",
"cmd", "cmd",
"compose", "compose",
"describe_injection", "describe_injection",
"get_fn",
"has_fn",
"load_yaml",
"parse_yaml_string",
"register_fn",
"run", "run",
"run_cli",
"run_command", "run_command",
"run_yaml",
"task", "task",
"task_template", "task_template",
] ]
-282
View File
@@ -1,282 +0,0 @@
"""自动格式化工具模块.
提供 Python 代码自动格式化的常用功能封装,
支持 docstring 自动生成、pyproject.toml 配置同步等功能.
"""
from __future__ import annotations
import argparse
import ast
import subprocess
from pathlib import Path
import pyflowx as px
try:
import tomllib # noqa: F401
HAS_TOMLLIB = True
except ImportError:
HAS_TOMLLIB = False
# ============================================================================
# 配置
# ============================================================================
IGNORE_PATTERNS = [
"__pycache__",
"*.pyc",
"*.pyo",
".git",
".venv",
".idea",
".vscode",
"*.egg-info",
"dist",
"build",
".pytest_cache",
".tox",
".mypy_cache",
]
# ============================================================================
# 辅助函数
# ============================================================================
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)
# 检查是否已有 docstring
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
# 添加 docstring
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."""'
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
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
# 查找所有子项目的 pyproject.toml
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)} 个子项目配置文件")
# 对每个子项目调用 ruff format
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
根目录
"""
# 使用 ruff format
subprocess.run(["ruff", "format", str(root_dir)], check=True)
# 使用 ruff check
subprocess.run(["ruff", "check", "--fix", "--unsafe-fixes", str(root_dir)], check=True)
print(f"格式化完成: {root_dir}")
# ============================================================================
# CLI Runner
# ============================================================================
def main() -> None:
"""自动格式化工具主函数."""
parser = argparse.ArgumentParser(
description="AutoFmt - 自动格式化工具",
usage="autofmt <command> [options]",
)
subparsers = parser.add_subparsers(dest="command", help="可用命令")
# ruff format 命令
format_parser = subparsers.add_parser("fmt", help="使用 ruff 格式化代码")
format_parser.add_argument("--target", type=str, default=".", help="目标路径")
# ruff check 命令
lint_parser = subparsers.add_parser("lint", help="使用 ruff 检查代码")
lint_parser.add_argument("--target", type=str, default=".", help="目标路径")
lint_parser.add_argument("--fix", action="store_true", help="自动修复")
# 自动添加 docstring 命令
doc_parser = subparsers.add_parser("doc", help="自动添加 docstring")
doc_parser.add_argument("--root-dir", type=str, default=".", help="根目录")
# 同步配置命令
sync_parser = subparsers.add_parser("sync", help="同步 pyproject.toml 配置")
sync_parser.add_argument("--root-dir", type=str, default=".", help="根目录")
args = parser.parse_args()
if args.command == "fmt":
graph = px.Graph.from_specs([px.TaskSpec("ruff_format", cmd=["ruff", "format", args.target], verbose=True)])
elif args.command == "lint":
cmd = ["ruff", "check", args.target]
if args.fix:
cmd.extend(["--fix", "--unsafe-fixes"])
graph = px.Graph.from_specs([px.TaskSpec("ruff_check", cmd=cmd, verbose=True)])
elif args.command == "doc":
graph = px.Graph.from_specs([
px.TaskSpec("auto_docstring", fn=auto_add_docstrings, args=(Path(args.root_dir),), verbose=True)
])
elif args.command == "sync":
graph = px.Graph.from_specs([
px.TaskSpec("sync_config", fn=sync_pyproject_config, args=(Path(args.root_dir),), verbose=True)
])
else:
parser.print_help()
return
px.run(graph, strategy="thread")
-263
View File
@@ -1,263 +0,0 @@
"""版本号自动管理工具.
使用 TaskSpec 模式实现, 支持语义化版本管理和多文件格式的版本号更新.
"""
from __future__ import annotations
import argparse
import re
from pathlib import Path
from typing import Literal, get_args
import pyflowx as px
BumpVersionType = Literal["patch", "minor", "major"]
# 针对不同文件类型的版本号匹配模式
# pyproject.toml: version = "X.Y.Z" 或 version = 'X.Y.Z'
_PYPROJECT_VERSION_PATTERN = re.compile(
r'(?:^|\n)\s*version\s*=\s*["\']'
r"(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
r"(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?"
r"(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?"
r'["\']',
re.MULTILINE,
)
# __init__.py: __version__ = "X.Y.Z" 或 __version__ = 'X.Y.Z'
_INIT_VERSION_PATTERN = re.compile(
r'(?:^|\n)\s*__version__\s*=\s*["\']'
r"(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
r"(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?"
r"(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?"
r'["\']',
re.MULTILINE,
)
def _get_pattern_for_file(file_name: str) -> re.Pattern[str] | None:
"""根据文件类型获取对应的正则表达式.
Parameters
----------
file_name : str
文件名
Returns
-------
re.Pattern[str] | None
对应的正则表达式,如果无法确定则返回 None
"""
if file_name == "pyproject.toml":
return _PYPROJECT_VERSION_PATTERN
if file_name == "__init__.py":
return _INIT_VERSION_PATTERN
return None
def _calculate_new_version(major: int, minor: int, patch: int, part: BumpVersionType) -> str:
"""计算新版本号.
Parameters
----------
major : int
当前主版本号
minor : int
当前次版本号
patch : int
当前补丁版本号
part : BumpVersionType
要更新的部分
Returns
-------
str
新版本号
"""
if part == "major":
return f"{major + 1}.0.0"
if part == "minor":
return f"{major}.{minor + 1}.0"
return f"{major}.{minor}.{patch + 1}"
def _build_replacement_string(original_match: str, new_version: str, file_name: str) -> str:
"""构建替换字符串,保留原始格式.
Parameters
----------
original_match : str
原始匹配的字符串
new_version : str
新版本号
file_name : str
文件名
Returns
-------
str
替换字符串
"""
quote_char = '"' if '"' in original_match else "'"
if file_name == "pyproject.toml":
prefix_match = re.match(r'(\s*version\s*=\s*)["\']', original_match)
prefix = prefix_match.group(1) if prefix_match else "version = "
return f"{prefix}{quote_char}{new_version}{quote_char}"
if file_name == "__init__.py":
prefix_match = re.match(r'(\s*__version__\s*=\s*)["\']', original_match)
prefix = prefix_match.group(1) if prefix_match else "__version__ = "
return f"{prefix}{quote_char}{new_version}{quote_char}"
return new_version
def bump_file_version(file_path: Path, part: BumpVersionType = "patch") -> str | None:
"""更新文件中的版本号.
Parameters
----------
file_path : Path
要更新的文件路径
part : BumpVersionType
版本部分: patch, minor, major
Returns
-------
str | None
更新后的新版本号,如果文件中未找到版本号则返回 None
"""
try:
content = file_path.read_text(encoding="utf-8")
except Exception as e:
print(f"读取文件 {file_path} 时出错: {e}")
raise
# 获取文件对应的正则表达式
pattern = _get_pattern_for_file(file_path.name)
# 对于未知文件类型,尝试两种模式
if pattern:
match = pattern.search(content)
else:
match = _PYPROJECT_VERSION_PATTERN.search(content) or _INIT_VERSION_PATTERN.search(content)
if not match:
print(f"文件 {file_path} 中未找到版本号模式")
return None
# 提取当前版本号
major = int(match.group("major"))
minor = int(match.group("minor"))
patch = int(match.group("patch"))
# 计算新版本号
new_version = _calculate_new_version(major, minor, patch, part)
# 构建替换字符串
original_match = match.group(0)
replacement = _build_replacement_string(original_match, new_version, file_path.name)
# 更新文件内容
content = content.replace(original_match, replacement)
try:
file_path.write_text(content, encoding="utf-8")
except Exception as e:
print(f"更新文件 {file_path} 版本号时出错: {e}")
raise
return new_version
def main() -> None:
"""版本号管理工具主函数."""
parser = argparse.ArgumentParser(description="BumpVersion - 版本号自动管理工具")
parser.add_argument(
"part",
type=str,
nargs="?",
default="patch",
choices=get_args(BumpVersionType),
help=f"版本部分: {get_args(BumpVersionType)}",
)
parser.add_argument(
"--no-tag",
action="store_true",
help="提交后不创建 git tag",
)
args = parser.parse_args()
part = args.part
# 搜索文件,排除常见的虚拟环境和缓存目录
ignore_dirs = {".venv", "venv", ".git", "__pycache__", ".tox", "node_modules", "build", "dist", ".eggs"}
all_files = set()
for pattern in ["__init__.py", "pyproject.toml"]:
for file in Path.cwd().rglob(pattern):
# 检查路径中是否包含需要忽略的目录
if not any(ignore_dir in file.parts for ignore_dir in ignore_dirs):
all_files.add(file)
if not all_files:
print("未找到包含版本号的文件")
return
print(f"找到 {len(all_files)} 个文件需要更新版本号")
for file in sorted(all_files):
print(f" - {file.relative_to(Path.cwd())}")
# 更新所有文件的版本号(使用顺序执行避免竞争条件)
# 使用相对于 cwd 的路径作为任务名,确保唯一性
graph = px.Graph.from_specs([
px.TaskSpec(
f"bump_{file.relative_to(Path.cwd())}".replace("\\", "_").replace("/", "_").replace(".", "_"),
fn=bump_file_version,
args=(file, part),
)
for file in all_files
])
report = px.run(graph, strategy="sequential")
# 收集新版本号(取第一个成功的结果)
new_version = None
for task_name in report:
result = report[task_name]
if result is not None:
new_version = result
break
if not new_version:
print("未能获取新版本号")
return
print(f"版本号已更新为: {new_version}")
# 提交修改并创建标签
tasks = [
px.TaskSpec("git_add", cmd=["git", "add", "."]),
px.TaskSpec(
"git_commit",
cmd=["git", "commit", "-m", f"bump version to {new_version}"],
depends_on=("git_add",),
),
]
if not args.no_tag:
tag_name = f"v{new_version}"
tasks.append(
px.TaskSpec(
"git_tag",
cmd=["git", "tag", "-a", tag_name, "-m", f"Release {tag_name}"],
depends_on=("git_commit",),
)
)
graph = px.Graph.from_specs(tasks)
px.run(graph, strategy="sequential")
if not args.no_tag:
print(f"已创建标签: v{new_version}")
-331
View File
@@ -1,331 +0,0 @@
from __future__ import annotations
import argparse
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,
),
# 设置 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)
+9 -7
View File
@@ -567,13 +567,15 @@ class EmlManagerHandler(BaseHTTPRequestHandler):
emails = self.db.search_emails(keyword, field, limit, offset) emails = self.db.search_emails(keyword, field, limit, offset)
total_count = self.db.get_email_count() total_count = self.db.get_email_count()
self._send_json_response({ self._send_json_response(
"emails": emails, {
"count": len(emails), "emails": emails,
"total": total_count, "count": len(emails),
"limit": limit, "total": total_count,
"offset": offset, "limit": limit,
}) "offset": offset,
}
)
def _api_get_email(self, query_params: dict[str, list[str]]) -> None: def _api_get_email(self, query_params: dict[str, list[str]]) -> None:
"""API: 获取单个邮件详情.""" """API: 获取单个邮件详情."""
-137
View File
@@ -1,137 +0,0 @@
"""文件日期处理工具.
自动检测文件名的日期前缀,
并根据文件的实际创建或修改时间重命名文件.
"""
from __future__ import annotations
import argparse
import re
import time
from pathlib import Path
import pyflowx as px
# ============================================================================
# 配置
# ============================================================================
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)
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)
# ============================================================================
# CLI Runner
# ============================================================================
def main() -> None:
"""文件日期处理工具主函数."""
parser = argparse.ArgumentParser(
description="FileDate - 文件日期处理工具",
usage="filedate <command> [options]",
)
subparsers = parser.add_subparsers(dest="command", help="可用命令")
# 添加日期前缀命令
add_parser = subparsers.add_parser("add", help="添加日期前缀")
add_parser.add_argument("files", nargs="+", help="文件路径")
# 清除日期前缀命令
clear_parser = subparsers.add_parser("clear", help="清除日期前缀")
clear_parser.add_argument("files", nargs="+", help="文件路径")
args = parser.parse_args()
if args.command == "add":
graph = px.Graph.from_specs([
px.TaskSpec(
"process_files_date",
fn=process_files_date,
args=([Path(f) for f in args.files],),
kwargs={"clear": False},
)
])
elif args.command == "clear":
graph = px.Graph.from_specs([
px.TaskSpec(
"process_files_date",
fn=process_files_date,
args=([Path(f) for f in args.files],),
kwargs={"clear": True},
)
])
else:
parser.print_help()
return
px.run(graph, strategy="thread")
-140
View File
@@ -1,140 +0,0 @@
"""文件等级重命名工具.
根据文件等级配置自动重命名文件,
支持多种等级标识和括号格式.
"""
from __future__ import annotations
import argparse
from pathlib import Path
import pyflowx as px
# ============================================================================
# 配置
# ============================================================================
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}")
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)
# ============================================================================
# CLI Runner
# ============================================================================
def main() -> None:
"""文件等级重命名工具主函数."""
parser = argparse.ArgumentParser(
description="FileLevel - 文件等级重命名工具",
usage="filelevel <command> [options]",
)
subparsers = parser.add_subparsers(dest="command", help="可用命令")
# 设置等级命令
level_parser = subparsers.add_parser("set", help="设置文件等级")
level_parser.add_argument("files", nargs="+", help="文件路径")
level_parser.add_argument("--level", type=int, choices=[0, 1, 2, 3, 4], required=True, help="文件等级 (0-4)")
args = parser.parse_args()
if args.command == "set":
graph = px.Graph.from_specs(
[
px.TaskSpec(
"process_files_level", fn=process_files_level, args=([Path(f) for f in args.files], args.level)
)
]
)
else:
parser.print_help()
return
px.run(graph, strategy="thread")
-85
View File
@@ -1,85 +0,0 @@
"""文件夹备份工具.
备份文件和文件夹为 zip 文件,
自动删除超过最大数量的旧备份文件.
"""
from __future__ import annotations
import time
import zipfile
from pathlib import Path
import pyflowx as px
# ============================================================================
# 辅助函数
# ============================================================================
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}")
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.task
def folderback_default() -> None:
"""备份当前目录到 ./backup."""
backup_folder(".", "./backup", 5)
def main() -> None:
"""文件夹备份工具主函数."""
runner = px.CliRunner(
strategy="thread",
description="FolderBack - 文件夹备份工具",
aliases={
# 备份当前目录到 ./backup
"b": folderback_default,
},
)
runner.run_cli()
-76
View File
@@ -1,76 +0,0 @@
"""文件夹压缩工具.
压缩目录下的所有文件/文件夹为 zip 文件,
默认压缩当前目录下的所有子文件夹.
"""
from __future__ import annotations
import shutil
from pathlib import Path
import pyflowx as px
# ============================================================================
# 配置
# ============================================================================
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"]
# ============================================================================
# 辅助函数
# ============================================================================
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")
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.task
def folderzip_default() -> None:
"""压缩当前目录下的所有文件夹."""
zip_folders(".")
def main() -> None:
"""文件夹压缩工具主函数."""
runner = px.CliRunner(
strategy="thread",
description="FolderZip - 文件夹压缩工具",
aliases={
# 压缩当前目录下的所有文件夹
"z": folderzip_default,
},
)
runner.run_cli()
-107
View File
@@ -1,107 +0,0 @@
"""Git 工具模块.
提供 Git 仓库管理的常用操作封装,
支持初始化、提交、清理、推送等功能.
"""
from __future__ import annotations
from pathlib import Path
import pyflowx as px
EXCLUDE_DIRS = [
# 编辑器相关目录
".vscode",
".idea",
".editorconfig",
".trae",
".qoder",
# 项目相关目录
".venv",
".git",
".tox",
".pytest_cache",
"node_modules",
".ruff_cache",
]
EXCLUDE_CMDS = [arg for d in EXCLUDE_DIRS for arg in ["-e", d]]
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.from_specs([
px.TaskSpec(
"init",
cmd=["git", "init"],
conditions=(lambda _: not_has_git_repo(),),
cwd=subdir,
),
px.TaskSpec("add", cmd=["git", "add", "."], depends_on=("init",)),
px.TaskSpec("commit", cmd=["git", "commit", "-m", "init commit"], depends_on=("add",)),
]),
)
@px.task(name="isub")
def isub() -> None:
"""初始化子目录的Git仓库."""
init_sub_dirs()
push: px.TaskSpec = px.TaskSpec("push", cmd=["git", "push"])
pull: px.TaskSpec = px.TaskSpec("pull", cmd=["git", "pull"])
kill_tgit: px.TaskSpec = px.TaskSpec("task_kill", cmd=["taskkill", "/f", "/t", "/im", "tgitcache.exe"])
def not_has_git_repo() -> bool:
"""检查当前目录没有Git仓库."""
return not Path.cwd().exists() or not (Path.cwd() / ".git").is_dir()
def has_files() -> bool:
"""检查当前目录是否有文件."""
return bool(list(Path.cwd().glob("*")))
def main() -> None:
"""Git工具主函数."""
runner = px.CliRunner(
strategy="thread",
description="Gittool - Git 执行工具.",
aliases={
# 添加并提交
"a": px.Graph.from_specs([
px.TaskSpec("add", cmd=["git", "add", "."], conditions=(lambda _: has_files(),)),
px.TaskSpec("commit", cmd=["git", "commit", "-m", "chore: update"], depends_on=("add",)),
]),
# 清理(chain: clean → status
"c": px.Graph().chain(
px.TaskSpec("clean", cmd=["git", "clean", "-xfd", *EXCLUDE_CMDS]),
px.TaskSpec("status", cmd=["git", "status", "--porcelain"]),
),
# 初始化、添加并提交
"i": px.Graph.from_specs([
px.TaskSpec("init", cmd=["git", "init"], conditions=(lambda _: not_has_git_repo(),)),
px.TaskSpec("add", cmd=["git", "add", "."], depends_on=("init",), conditions=(lambda _: has_files(),)),
px.TaskSpec(
"commit",
cmd=["git", "commit", "-m", "init commit"],
depends_on=("add",),
conditions=(lambda _: has_files(),),
),
]),
# 初始化子目录
"isub": isub,
# 推送
"p": push,
# 拉取
"pl": pull,
# 重启TGit缓存
"r": kill_tgit,
},
)
runner.run_cli()
View File
-41
View File
@@ -1,41 +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)
-63
View File
@@ -1,63 +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)
-174
View File
@@ -1,174 +0,0 @@
"""LS-DYNA 计算工具.
用于管理 LS-DYNA 仿真计算任务,
支持启动、监控和管理计算进程.
"""
from __future__ import annotations
import argparse
import subprocess
from pathlib import Path
import pyflowx as px
from pyflowx.conditions import Constants
# ============================================================================
# 配置
# ============================================================================
LS_DYNA_COMMANDS: dict[str, list[str]] = {
"windows": ["ls-dyna_mpp", "i=input.k", "ncpu=4"],
"linux": ["ls-dyna_mpp", "i=input.k", "ncpu=8"],
"macos": ["ls-dyna_mpp", "i=input.k", "ncpu=4"],
}
DEFAULT_INPUT_FILE: str = "input.k"
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 命令列表
"""
if Constants.IS_WINDOWS or Constants.IS_MACOS:
return ["ls-dyna_mpp", f"i={input_file}", f"ncpu={ncpu}"]
else:
return ["ls-dyna_mpp", f"i={input_file}", f"ncpu={ncpu}"]
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}")
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}")
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}")
# ============================================================================
# CLI Runner
# ============================================================================
def main() -> None:
"""LS-DYNA 计算工具主函数."""
parser = argparse.ArgumentParser(
description="LSCalc - LS-DYNA 计算工具",
usage="lscalc <command> [options]",
)
subparsers = parser.add_subparsers(dest="command", help="可用命令")
# 运行计算命令
run_parser = subparsers.add_parser("run", help="运行 LS-DYNA 计算")
run_parser.add_argument("input_file", help="输入文件路径")
run_parser.add_argument("--ncpu", type=int, default=DEFAULT_NCPU, help="CPU 核心数")
# 运行 MPI 计算命令
mpi_parser = subparsers.add_parser("mpi", help="运行 LS-DYNA MPI 计算")
mpi_parser.add_argument("input_file", help="输入文件路径")
mpi_parser.add_argument("--ncpu", type=int, default=DEFAULT_NCPU, help="CPU 核心数")
# 检查进程状态命令
subparsers.add_parser("status", help="检查 LS-DYNA 进程状态")
args = parser.parse_args()
if args.command == "run":
graph = px.Graph.from_specs(
[px.TaskSpec("run_ls_dyna", fn=run_ls_dyna, args=(args.input_file,), kwargs={"ncpu": args.ncpu})]
)
elif args.command == "mpi":
graph = px.Graph.from_specs(
[px.TaskSpec("run_ls_dyna_mpi", fn=run_ls_dyna_mpi, args=(args.input_file,), kwargs={"ncpu": args.ncpu})]
)
elif args.command == "status":
graph = px.Graph.from_specs([px.TaskSpec("check_ls_dyna_status", fn=check_ls_dyna_status)])
else:
parser.print_help()
return
px.run(graph, strategy="thread")
-349
View File
@@ -1,349 +0,0 @@
"""Python 打包工具模块.
提供 Python 项目打包的常用功能封装,
支持源码打包、依赖打包、嵌入式 Python 安装等功能.
"""
from __future__ import annotations
import argparse
import shutil
import subprocess
import zipfile
from pathlib import Path
import pyflowx as px
# ============================================================================
# 配置
# ============================================================================
DEFAULT_BUILD_DIR = ".pypack"
DEFAULT_DIST_DIR = "dist"
DEFAULT_LIB_DIR = "libs"
DEFAULT_CACHE_DIR = ".cache/pypack"
IGNORE_PATTERNS = [
"__pycache__",
"*.pyc",
"*.pyo",
".git",
".venv",
".idea",
".vscode",
"*.egg-info",
"dist",
"build",
".pytest_cache",
".tox",
".mypy_cache",
]
# ============================================================================
# 辅助函数
# ============================================================================
def pack_source(project_dir: Path, output_dir: Path) -> None:
"""打包项目源码.
Parameters
----------
project_dir : Path
项目目录
output_dir : Path
输出目录
"""
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}")
def pack_dependencies(lib_dir: Path, dependencies: list[str]) -> None:
"""打包项目依赖.
Parameters
----------
lib_dir : Path
依赖库目录
dependencies : list[str]
依赖列表
"""
lib_dir.mkdir(parents=True, exist_ok=True)
if not dependencies:
print("没有依赖需要打包")
return
# 使用 pip 安装依赖到目标目录
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}")
def pack_wheel(project_dir: Path, output_dir: Path) -> None:
"""打包项目为 wheel 文件.
Parameters
----------
project_dir : Path
项目目录
output_dir : Path
输出目录
"""
output_dir.mkdir(parents=True, exist_ok=True)
# 使用 pip wheel 打包
cmd = [
"pip",
"wheel",
"--no-deps",
"--wheel-dir",
str(output_dir),
str(project_dir),
]
subprocess.run(cmd, check=True)
print(f"Wheel 打包完成: {output_dir}")
def install_embed_python(version: str, output_dir: Path) -> None:
"""安装嵌入式 Python.
Parameters
----------
version : str
Python 版本 (如: 3.10, 3.11)
output_dir : Path
输出目录
"""
import platform
output_dir.mkdir(parents=True, exist_ok=True)
# 构建下载 URL
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")
# Windows 嵌入式 Python 下载 URL
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}...")
import urllib.request
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}")
def create_zip_package(source_dir: Path, output_file: Path) -> None:
"""创建 ZIP 打包文件.
Parameters
----------
source_dir : Path
源目录
output_file : Path
输出文件
"""
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}")
def clean_build_dir(build_dir: Path) -> None:
"""清理构建目录.
Parameters
----------
build_dir : Path
构建目录
"""
if build_dir.exists():
shutil.rmtree(build_dir)
print(f"清理完成: {build_dir}")
else:
print(f"目录不存在: {build_dir}")
# ============================================================================
# CLI Runner
# ============================================================================
def main() -> None:
"""Python 打包工具主函数."""
parser = argparse.ArgumentParser(
description="PackTool - Python 打包工具",
usage="packtool <command> [options]",
)
subparsers = parser.add_subparsers(dest="command", help="可用命令")
# 源码打包命令
src_parser = subparsers.add_parser("src", help="打包项目源码")
src_parser.add_argument("--project-dir", type=str, default=".", help="项目目录")
src_parser.add_argument("--output-dir", type=str, default=DEFAULT_BUILD_DIR, help="输出目录")
# 依赖打包命令
deps_parser = subparsers.add_parser("deps", help="打包项目依赖")
deps_parser.add_argument("--lib-dir", type=str, default=DEFAULT_LIB_DIR, help="依赖库目录")
deps_parser.add_argument("dependencies", nargs="*", help="依赖列表")
# Wheel 打包命令
wheel_parser = subparsers.add_parser("wheel", help="打包项目为 wheel 文件")
wheel_parser.add_argument("--project-dir", type=str, default=".", help="项目目录")
wheel_parser.add_argument("--output-dir", type=str, default=DEFAULT_DIST_DIR, help="输出目录")
# 嵌入式 Python 安装命令
embed_parser = subparsers.add_parser("embed", help="安装嵌入式 Python")
embed_parser.add_argument("--version", type=str, default="3.10", help="Python 版本")
embed_parser.add_argument("--output-dir", type=str, default="python", help="输出目录")
# ZIP 打包命令
zip_parser = subparsers.add_parser("zip", help="创建 ZIP 打包文件")
zip_parser.add_argument("--source-dir", type=str, default=".", help="源目录")
zip_parser.add_argument("--output-file", type=str, default="package.zip", help="输出文件")
# 清理命令
subparsers.add_parser("clean", help="清理构建目录")
args = parser.parse_args()
if args.command == "src":
graph = px.Graph.from_specs(
[
px.TaskSpec(
"pack_source",
fn=pack_source,
args=(Path(args.project_dir), Path(args.output_dir)),
)
]
)
elif args.command == "deps":
graph = px.Graph.from_specs(
[
px.TaskSpec(
"pack_deps",
fn=pack_dependencies,
args=(Path(args.lib_dir), args.dependencies),
)
]
)
elif args.command == "wheel":
graph = px.Graph.from_specs(
[
px.TaskSpec(
"pack_wheel",
fn=pack_wheel,
args=(Path(args.project_dir), Path(args.output_dir)),
)
]
)
elif args.command == "embed":
graph = px.Graph.from_specs(
[
px.TaskSpec(
"install_embed",
fn=install_embed_python,
args=(args.version, Path(args.output_dir)),
)
]
)
elif args.command == "zip":
graph = px.Graph.from_specs(
[
px.TaskSpec(
"create_zip",
fn=create_zip_package,
args=(Path(args.source_dir), Path(args.output_file)),
)
]
)
elif args.command == "clean":
graph = px.Graph.from_specs([px.TaskSpec("clean_build", fn=clean_build_dir, args=(Path(DEFAULT_BUILD_DIR),))])
else:
parser.print_help()
return
px.run(graph, strategy="thread")
+202
View File
@@ -0,0 +1,202 @@
"""PyFlowX 统一 CLI 入口.
通过 ``pf <tool> [command] [options]`` 调用所有工具,
工具定义在 ``configs/`` 目录下的 YAML 文件中.
用法
----
pf # 列出所有可用工具
pf filedate # 查看 filedate 工具帮助
pf filedate add a.txt # 调用 filedate 的 add 子命令
pf pymake b # 调用 pymake 的 b 别名
"""
from __future__ import annotations
import importlib
import sys
from pathlib import Path
from typing import Sequence
import pyflowx as px
class PfApp:
"""pf 统一入口应用.
路由 ``pf <tool> [command]`` 到 YAML 配置工具或传统 Python 工具.
"""
_CONFIGS_DIR = Path(__file__).parent.parent / "configs"
# 工具名到 YAML 配置文件的映射 (支持短别名)
_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",
"fl": "filelevel",
"folderback": "folderback",
"foldback": "folderback",
"fb": "folderback",
"folderzip": "folderzip",
"foldzip": "folderzip",
"fz": "folderzip",
"git": "gittool",
"gitt": "gittool",
"gittool": "gittool",
"gt": "gittool",
"ls": "lscalc",
"lscalc": "lscalc",
"msdown": "msdownload",
"msdownload": "msdownload",
"msd": "msdownload",
"pack": "packtool",
"packtool": "packtool",
"pk": "packtool",
"pdf": "pdftool",
"pdftool": "pdftool",
"pt": "pdftool",
"pip": "piptool",
"pymake": "pymake",
"piptool": "piptool",
"pp": "piptool",
"reseticon": "reseticoncache",
"reseticoncache": "reseticoncache",
"ric": "reseticoncache",
"screenshot": "screenshot",
"scrcap": "screenshot",
"ss": "screenshot",
"sglang": "sglang",
"sg": "sglang",
"ssh": "sshcopyid",
"sshcopy": "sshcopyid",
"sshcopyid": "sshcopyid",
"sc": "sshcopyid",
"taskk": "taskkill",
"taskkill": "taskkill",
"tk": "taskkill",
"wch": "which",
"which": "which",
}
# 传统工具: 有自己的 main() 函数 (无法 YAML 化的复杂逻辑)
_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",
}
def __init__(self, argv: Sequence[str] | None = None) -> None:
self._argv = list(argv) if argv is not None else sys.argv[1:]
def run(self) -> int:
"""主入口, 返回退出码."""
if not self._argv:
self._list_tools()
return 0
tool_name = self._argv[0]
rest_argv = self._argv[1:]
resolved = self._resolve_tool(tool_name)
if resolved is None:
print(f"错误: 未知工具 '{tool_name}'", file=sys.stderr)
print("运行 'pf' 查看可用工具列表", file=sys.stderr)
return 1
tool_type, target = resolved
if tool_type == "legacy":
return self._run_legacy(target, rest_argv)
return self._run_yaml(target, rest_argv)
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")
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
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
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])
if name in self._LEGACY_TOOLS:
return ("legacy", self._LEGACY_TOOLS[name])
return None
def _run_legacy(self, module_path: str, argv: list[str]) -> int:
"""运行传统工具的 main() 函数."""
module_name, func_name = module_path.split(":", 1)
module = importlib.import_module(module_name)
func = getattr(module, func_name)
original_argv = sys.argv
sys.argv = [f"pf {module_name.split('.')[-1]}", *argv]
try:
func()
return 0
except SystemExit as e:
return int(e.code) if e.code is not None else 0
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)
return 1
print(f"运行配置文件 '{config_path}'")
return px.run_cli(config_path, argv)
def main() -> None:
"""pf 统一入口主函数."""
sys.exit(PfApp().run())
if __name__ == "__main__":
main()
-195
View File
@@ -1,195 +0,0 @@
"""pip 包管理工具模块.
提供 pip 包管理操作的封装,
支持安装、卸载、下载等功能.
"""
from __future__ import annotations
import argparse
import fnmatch
import subprocess
from pathlib import Path
import pyflowx as px
# ============================================================================
# 配置
# ============================================================================
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
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)
def pip_reinstall(pkg_names: list[str], offline: bool = False) -> None:
"""重新安装包."""
safe_pkgs = _filter_protected_packages(pkg_names)
if not safe_pkgs:
print("所有指定的包均为受保护包, 跳过重装")
return
subprocess.run(["pip", "uninstall", "-y", *safe_pkgs], check=True)
options = ["--no-index", "--find-links", "."] if offline else []
subprocess.run(["pip", "install", *options, *safe_pkgs], check=True)
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,
)
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)
# ============================================================================
# CLI Runner
# ============================================================================
def main() -> None:
"""pip 工具主函数."""
parser = argparse.ArgumentParser(
description="PipTool - pip 包管理工具",
usage="piptool <command> [options]",
)
subparsers = parser.add_subparsers(dest="command", help="可用命令")
# 安装命令
install_parser = subparsers.add_parser("i", help="安装包")
install_parser.add_argument("packages", nargs="+", help="要安装的包名")
# 卸载命令
uninstall_parser = subparsers.add_parser("u", help="卸载包")
uninstall_parser.add_argument("packages", nargs="+", help="要卸载的包名 (支持通配符)")
# 重装命令
reinstall_parser = subparsers.add_parser("r", help="重新安装包")
reinstall_parser.add_argument("packages", nargs="+", help="要重装的包名")
reinstall_parser.add_argument("--offline", action="store_true", help="使用离线模式")
# 下载命令
download_parser = subparsers.add_parser("d", help="下载包")
download_parser.add_argument("packages", nargs="+", help="要下载的包名")
download_parser.add_argument("--offline", action="store_true", help="使用离线模式")
# 升级 pip 命令
subparsers.add_parser("up", help="升级 pip")
# 冻结依赖命令
subparsers.add_parser("f", help="冻结依赖到 requirements.txt")
args = parser.parse_args()
if args.command == "i":
graph = px.Graph.from_specs([px.TaskSpec("pip_install", cmd=["pip", "install", *args.packages], verbose=True)])
elif args.command == "u":
graph = px.Graph.from_specs([
px.TaskSpec("pip_uninstall", fn=pip_uninstall, args=(args.packages,), verbose=True)
])
elif args.command == "r":
graph = px.Graph.from_specs([
px.TaskSpec(
"pip_reinstall",
fn=pip_reinstall,
args=(args.packages,),
kwargs={"offline": args.offline},
verbose=True,
)
])
elif args.command == "d":
graph = px.Graph.from_specs([
px.TaskSpec(
"pip_download",
fn=pip_download,
args=(args.packages,),
kwargs={"offline": args.offline},
verbose=True,
)
])
elif args.command == "up":
graph = px.Graph.from_specs([
px.TaskSpec("pip_upgrade", cmd=["python", "-m", "pip", "install", "--upgrade", "pip"], verbose=True)
])
elif args.command == "f":
graph = px.Graph.from_specs([px.TaskSpec("pip_freeze", fn=pip_freeze, verbose=True)])
else:
parser.print_help()
return
px.run(graph, strategy="thread")
-125
View File
@@ -1,125 +0,0 @@
"""Python 构建工具模块.
完全替代传统的 Makefile,
提供更好的跨平台兼容性和 Python 生态集成.
"""
from __future__ import annotations
from pathlib import Path
import pyflowx as px
from pyflowx.conditions import Constants
# 项目根目录(pymake.py 在 src/pyflowx/cli,向上四层到达根目录)
ROOT_DIR = Path(__file__).parent.parent.parent.parent
MATURIN_BUILD_COMMAND = ["maturin", "build", "-r"]
if Constants.IS_WINDOWS:
MATURIN_BUILD_COMMAND.extend(["--target", "x86_64-win7-windows-msvc", "-Zbuild-std", "-i", "python3.8"])
# 扁平注册所有任务(px.cmd 自动从命令前两段推导 name)
# 所有任务指定 cwd=ROOT_DIR,确保在项目根目录执行
tasks: list[px.TaskSpec] = [
px.cmd(["uv", "build"], cwd=ROOT_DIR),
px.cmd(MATURIN_BUILD_COMMAND, cwd=ROOT_DIR),
px.cmd(["uv", "sync"], cwd=ROOT_DIR),
px.cmd(["gitt", "c"], name="git_clean", cwd=ROOT_DIR),
px.cmd(
["pytest", "-m", "not slow", "-n", "8", "--dist", "loadfile", "--color=yes", "--durations=10"],
name="test",
cwd=ROOT_DIR,
),
px.cmd(
["pytest", "-m", "not slow", "--dist", "loadfile", "--color=yes", "--durations=10"],
name="test_fast",
cwd=ROOT_DIR,
),
px.cmd(
["pytest", "--cov", "-n", "8", "--dist", "loadfile", "--tb=short", "-v", "--color=yes", "--durations=10"],
name="test_coverage",
cwd=ROOT_DIR,
),
px.cmd(["pyrefly", "check", "."], cwd=ROOT_DIR),
px.cmd(["git", "add", "-A"], name="git_add_all", cwd=ROOT_DIR),
px.cmd(["bumpversion"], cwd=ROOT_DIR),
px.cmd(["bumpversion", "minor"], cwd=ROOT_DIR),
px.cmd(["git", "push"], cwd=ROOT_DIR),
px.cmd(["git", "push", "--tags"], name="git_push_tags", cwd=ROOT_DIR),
px.cmd(["hatch", "publish"], name="publish_python", cwd=ROOT_DIR),
px.cmd(["twine", "upload", "--disable-progress-bar"], name="twine_publish", cwd=ROOT_DIR),
]
# 单任务别名(alias 名与任务名相同):直接内联 TaskSpec,避免 str 自引用
aliases: dict[str, str | list[str | px.TaskSpec] | px.TaskSpec | px.Graph] = {
# 构建命令
"b": "uv_build",
"bc": "maturin_build",
"ba": ["b", "bc"],
# 安装命令
"sync": "uv_sync",
# 清理命令
"c": "git_clean",
# 开发工具
"bump": ["c", "tc", "git_add_all", "bumpversion"],
"bumpmi": "bumpversion_minor",
"cov": ["git_clean", "test_coverage"],
"doc": px.cmd(["sphinx-build", "-b", "html", "docs", "docs/_build"], name="doc", cwd=ROOT_DIR),
"lint": px.cmd(["ruff", "check", "--fix", "--unsafe-fixes"], name="lint", cwd=ROOT_DIR),
"pb": ["twine_publish", "publish_python"],
"t": "test",
"tf": "test_fast",
"tc": ["pyrefly_check", "lint"],
"tox": px.cmd(["tox", "-p", "auto"], name="tox", cwd=ROOT_DIR),
# 发布命令
"p": ["git_clean", "git_push", "git_push_tags"],
}
def main() -> None:
"""pymake 构建工具.
🔨 构建命令:
pymake b - 构建 Python 主包 (uv build)
pymake bc - 构建 Rust 核心模块 (maturin build)
pymake ba - 构建所有包 (先 Python 后 Rust)
📦 安装命令 (开发模式):
pymake sync - 安装依赖包 (uv sync)
🧹 清理命令:
pymake c - 清理所有构建产物 (gitt c)
🛠️ 开发工具:
pymake t - 运行测试 (pytest)
pymake tc - 运行测试并生成覆盖率报告
pymake tf - 运行快速测试 (pytest -m not slow)
pymake lint - 代码格式化与检查 (ruff)
pymake type - 类型检查 (mypy, ty)
pymake doc - 构建文档 (sphinx)
🔬 多版本测试:
pymake tox - 多版本 Python 测试 (tox -p auto)
📦 发布命令:
pymake pb - 发布到 PyPI (twine + hatch)
🔖 版本管理:
pymake bump - 自动升级版本号并提交修改 (清理 + 检查 + 格式化 + git add + bumpversion)
💡 常用工作流:
1. 日常开发: pymake lint && pymake t
2. 构建发布包: pymake ba
3. 多版本兼容性测试: pymake tox
4. 发布到 PyPI: pymake pb
📝 示例:
pymake ba # 构建所有包
pymake sync # 安装依赖
pymake t # 运行测试
pymake tox # 多版本兼容性测试
pymake lint # 格式化代码
pymake type # 类型检查
"""
runner = px.CliRunner(strategy="sequential", description="PyMake - Python 构建工具", tasks=tasks, aliases=aliases)
runner.run_cli()
-10
View File
@@ -1,10 +0,0 @@
from __future__ import annotations
import pyflowx as px
from pyflowx.tasks.system import reset_icon_cache
def main() -> None:
"""重启图标缓存工具主函数."""
graph = px.Graph.from_specs(reset_icon_cache())
px.run(graph, strategy="thread")
-163
View File
@@ -1,163 +0,0 @@
"""截图工具.
跨平台截图工具, 支持全屏截图和区域截图.
"""
from __future__ import annotations
import argparse
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
def take_screenshot_full(filename: str | None = None) -> None:
"""全屏截图.
Parameters
----------
filename : str | None
文件名
"""
output_path = get_screenshot_path(filename)
if Constants.IS_WINDOWS:
# Windows: 使用 PowerShell 截图
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:
# macOS: 使用 screencapture
subprocess.run(["screencapture", "-x", str(output_path)], check=True)
else:
# Linux: 使用 gnome-screenshot 或 scrot
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}")
def take_screenshot_area(filename: str | None = None) -> None:
"""区域截图.
Parameters
----------
filename : str | None
文件名
"""
output_path = get_screenshot_path(filename)
if Constants.IS_WINDOWS:
# Windows: 使用 PowerShell 截图 (需要用户选择区域)
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:
# macOS: 使用 screencapture 交互模式
subprocess.run(["screencapture", "-i", str(output_path)], check=True)
else:
# Linux: 使用 gnome-screenshot 交互模式
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}")
# ============================================================================
# CLI Runner
# ============================================================================
def main() -> None:
"""截图工具主函数."""
parser = argparse.ArgumentParser(
description="Screenshot - 截图工具",
usage="screenshot <command> [options]",
)
subparsers = parser.add_subparsers(dest="command", help="可用命令")
# 全屏截图命令
full_parser = subparsers.add_parser("full", help="全屏截图")
full_parser.add_argument("--filename", type=str, help="文件名")
# 区域截图命令
area_parser = subparsers.add_parser("area", help="区域截图")
area_parser.add_argument("--filename", type=str, help="文件名")
args = parser.parse_args()
if args.command == "full":
graph = px.Graph.from_specs(
[px.TaskSpec("screenshot_full", fn=take_screenshot_full, kwargs={"filename": args.filename})]
)
elif args.command == "area":
graph = px.Graph.from_specs(
[px.TaskSpec("screenshot_area", fn=take_screenshot_area, kwargs={"filename": args.filename})]
)
else:
parser.print_help()
return
px.run(graph, strategy="thread")
-122
View File
@@ -1,122 +0,0 @@
"""SSH 密钥部署工具.
类似 ssh-copy-id, 自动将 SSH 公钥部署到远程服务器,
支持密码认证和密钥认证两种方式.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
import pyflowx as px
# ============================================================================
# 辅助函数
# ============================================================================
def ssh_copy_id(
hostname: str,
username: str,
password: str,
port: int = 22,
keypath: str = "~/.ssh/id_rsa.pub",
timeout: int = 30,
) -> None:
"""将 SSH 公钥部署到远程服务器.
Parameters
----------
hostname : str
远程服务器主机名或 IP 地址
username : str
远程服务器用户名
password : str
远程服务器密码
port : int
SSH 端口, 默认 22
keypath : str
公钥文件路径, 默认 ~/.ssh/id_rsa.pub
timeout : int
SSH 操作超时秒数, 默认 30
"""
# 读取公钥
pub_key_path = Path(keypath).expanduser()
if not pub_key_path.exists():
print(f"公钥文件不存在: {pub_key_path}")
sys.exit(1)
pub_key = pub_key_path.read_text().strip()
# 构建部署脚本
script = f"""mkdir -p ~/.ssh && chmod 700 ~/.ssh
cd ~/.ssh && touch authorized_keys && chmod 600 authorized_keys
grep -qF '{pub_key.split()[1]}' authorized_keys 2>/dev/null || echo '{pub_key}' >> authorized_keys"""
# 使用 sshpass 执行
try:
subprocess.run(
[
"sshpass",
"-p",
password,
"ssh",
"-p",
str(port),
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
f"ConnectTimeout={timeout}",
f"{username}@{hostname}",
script,
],
check=True,
timeout=timeout,
)
print(f"SSH 密钥已部署到 {username}@{hostname}:{port}")
except FileNotFoundError:
print(f"未找到 sshpass 工具,请手动执行: ssh-copy-id -p {port} {username}@{hostname}")
sys.exit(1)
except subprocess.TimeoutExpired:
print("SSH 连接超时")
sys.exit(1)
except subprocess.CalledProcessError as e:
print(f"SSH 执行失败: {e}")
sys.exit(1)
# ============================================================================
# CLI Runner
# ============================================================================
def main() -> None:
"""SSH 密钥部署工具主函数."""
parser = argparse.ArgumentParser(
description="SSHCopyID - SSH 密钥部署工具",
usage="sshcopyid <hostname> <username> <password> [--port PORT] [--keypath KEYPATH]",
)
parser.add_argument("hostname", type=str, help="远程服务器主机名或 IP 地址")
parser.add_argument("username", type=str, help="远程服务器用户名")
parser.add_argument("password", type=str, help="远程服务器密码")
parser.add_argument("--port", type=int, default=22, help="SSH 端口 (默认: 22)")
parser.add_argument("--keypath", type=str, default="~/.ssh/id_rsa.pub", help="公钥文件路径")
parser.add_argument("--timeout", type=int, default=30, help="SSH 操作超时秒数 (默认: 30)")
args = parser.parse_args()
graph = px.Graph.from_specs(
[
px.TaskSpec(
"ssh_deploy",
fn=ssh_copy_id,
args=(args.hostname, args.username, args.password),
kwargs={"port": args.port, "keypath": args.keypath, "timeout": args.timeout},
)
]
)
px.run(graph, strategy="thread")
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
@@ -0,0 +1,109 @@
"""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()
+2
View File
@@ -90,6 +90,8 @@ def run_command(spec: TaskSpec[Any]) -> Any: # noqa: PLR0912
print(f"[verbose] 返回码: {result.returncode}", flush=True) print(f"[verbose] 返回码: {result.returncode}", flush=True)
if result.returncode == 0: if result.returncode == 0:
if not verbose and result.stdout:
print(result.stdout, end="", flush=True)
return None return None
err_msg = f"{label}执行失败: `{cmd_str}`, 返回码: {result.returncode}" err_msg = f"{label}执行失败: `{cmd_str}`, 返回码: {result.returncode}"
+65
View File
@@ -0,0 +1,65 @@
# 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
@@ -0,0 +1,27 @@
# 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}
+10
View File
@@ -0,0 +1,10 @@
# clr - 清屏工具
# 用法:
# pf clr
strategy: sequential
cli:
description: "清屏工具 (跨平台)"
usage: "pf clr"
jobs:
clear:
fn: clear_screen_run
+24
View File
@@ -0,0 +1,24 @@
# dockercmd - Docker 镜像登录工具
# 用法:
# pf dockercmd login
# pf dockercmd login --username myuser
strategy: sequential
variables:
USERNAME: ""
cli:
description: "DockerCmd - Docker 镜像登录工具"
usage: "pf dockercmd <command> [options]"
subcommands:
login:
help: "登录腾讯云 Docker 镜像仓库"
options:
- name: USERNAME
flag: "--username"
type: str
default: ""
help: "Docker 用户名 (默认: 当前系统用户)"
jobs:
login:
fn: docker_login_tencent
kwargs:
username: ${USERNAME}
+78
View File
@@ -0,0 +1,78 @@
# envdev - 开发环境镜像源配置工具
# 用法:
# pf envdev
# pf envdev --python-mirror aliyun --conda-mirror ustc --rust-mirror ustc --rust-version nightly
# 说明
# 配置 Python / Conda / Rust 镜像源 (Linux 还会安装 Qt 库、中文字体、Docker).
# 所有镜像源参数互不影响, 可单独使用.
# Linux 专用操作 (系统镜像/Qt/字体/Docker) 在非 Linux 平台上由函数内部跳过.
strategy: thread
variables:
PYTHON_MIRROR: tsinghua
CONDA_MIRROR: tsinghua
RUST_MIRROR: tsinghua
RUST_VERSION: stable
cli:
description: "EnvDev - 开发环境镜像源配置工具"
usage: "pf envdev [options]"
options:
- name: PYTHON_MIRROR
flag: "--python-mirror"
type: str
default: tsinghua
help: "Python 镜像源: tsinghua/aliyun/huaweicloud/ustc/zju (默认: tsinghua)"
- name: CONDA_MIRROR
flag: "--conda-mirror"
type: str
default: tsinghua
help: "Conda 镜像源: tsinghua/ustc/bsfu/aliyun (默认: tsinghua)"
- name: RUST_MIRROR
flag: "--rust-mirror"
type: str
default: tsinghua
help: "Rust 镜像源: tsinghua/ustc/aliyun (默认: tsinghua)"
- name: RUST_VERSION
flag: "--rust-version"
type: str
default: stable
help: "Rust 版本: stable/nightly/beta (默认: stable)"
jobs:
# Linux 系统镜像配置 (函数内部判断平台与已配置状态, 非自动跳过)
setup_linux_mirror:
fn: setup_linux_system_mirror
# 安装 Qt 依赖 (仅 Linux, 函数内部判断)
install_qt_libs:
fn: install_linux_qt_libs
needs: [setup_linux_mirror]
allow-upstream-skip: true
# 安装中文字体 (仅 Linux, 函数内部判断)
install_fonts:
fn: install_linux_fonts
needs: [setup_linux_mirror]
allow-upstream-skip: true
# 安装 Docker (仅 Linux, 函数内部判断)
install_docker:
fn: install_linux_docker
needs: [setup_linux_mirror]
allow-upstream-skip: true
# 配置 Python 镜像源 (跨平台)
setup_python:
fn: setup_python_mirror
args: ["${PYTHON_MIRROR}"]
# 配置 Conda 镜像源 (跨平台)
setup_conda:
fn: setup_conda_mirror
args: ["${CONDA_MIRROR}"]
# 配置 Rust 镜像源 (跨平台)
setup_rust:
fn: setup_rust_mirror
args: ["${RUST_MIRROR}", "${RUST_VERSION}"]
# 下载 Rustup 安装脚本 (跨平台, 已安装时由函数内部跳过)
download_rustup:
fn: download_rustup_script
# 安装 Rust 工具链 (rustup 未安装时由函数内部跳过)
install_rust:
fn: install_rust_toolchain
args: ["${RUST_VERSION}"]
needs: [setup_rust, download_rustup]
allow-upstream-skip: true
+36
View File
@@ -0,0 +1,36 @@
# 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
@@ -0,0 +1,28 @@
# 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
@@ -0,0 +1,34 @@
# 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
@@ -0,0 +1,21 @@
# 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
@@ -0,0 +1,51 @@
# 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
@@ -0,0 +1,51 @@
# 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
+34
View File
@@ -0,0 +1,34 @@
# msdownload - ModelScope 下载工具
# 用法:
# pf msdownload Qwen/Qwen2.5-Coder-32B-Instruct
# pf msdownload AI-ModelScope/MNIST --type dataset --dir ./data
strategy: thread
variables:
NAME: ""
TYPE: model
DIR: null
cli:
description: "MSDownload - ModelScope 模型/数据集下载工具"
usage: "pf msdownload <name> [--type TYPE] [--dir DIR]"
positional:
- name: NAME
type: str
help: "目标名称 (如: Qwen/Qwen2.5-Coder-32B-Instruct)"
options:
- name: TYPE
flag: "--type"
type: str
default: model
help: "目标类型: model / dataset / space (默认: model)"
- name: DIR
flag: "--dir"
type: str
default: null
help: "下载目录 (默认: ~/.models/<name>)"
jobs:
download:
fn: msdownload_run
args: ["${NAME}"]
kwargs:
target_type: ${TYPE}
download_dir: ${DIR}
+107
View File
@@ -0,0 +1,107 @@
# 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
@@ -0,0 +1,303 @@
# 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
@@ -0,0 +1,78 @@
# 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
@@ -0,0 +1,125 @@
# 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: ["pf", "bumpversion", "patch"]
needs: [git_add_all]
cwd: ${CWD}
bumpmi:
cmd: ["pf", "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
@@ -0,0 +1,13 @@
# 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
@@ -0,0 +1,34 @@
# 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}"
+60
View File
@@ -0,0 +1,60 @@
# sglang - SGLang 本地模型服务
# 用法:
# pf sglang
# pf sglang --model ~/.models/Qwen2.5-Coder-32B-Instruct-AWQ
# pf sglang --port 9000 --mem 0.8
strategy: sequential
variables:
MODEL: "~/.models/Qwen2.5-Coder-32B-Instruct-AWQ"
PORT: 8000
CTX_LEN: 32768
MEM: 0.75
HOST: "0.0.0.0"
LOG_LEVEL: "info"
cli:
description: "SGLang - 本地模型服务启动工具"
usage: "pf sglang [options]"
options:
- name: MODEL
flag: "--model"
type: str
default: "~/.models/Qwen2.5-Coder-32B-Instruct-AWQ"
help: "模型路径"
- name: PORT
flag: "--port"
type: int
default: 8000
help: "服务端口 (默认: 8000)"
- name: CTX_LEN
flag: "--ctx-len"
type: int
default: 32768
help: "最大上下文长度 (默认: 32768)"
- name: MEM
flag: "--mem"
type: float
default: 0.75
help: "显存占比 0-1 (默认: 0.75)"
- name: HOST
flag: "--host"
type: str
default: "0.0.0.0"
help: "主机地址 (默认: 0.0.0.0)"
- name: LOG_LEVEL
flag: "--log-level"
type: str
default: "info"
help: "日志级别 (默认: info)"
jobs:
install:
fn: install_sglang
run:
fn: run_sglang
needs: [install]
kwargs:
model: ${MODEL}
port: ${PORT}
ctx_len: ${CTX_LEN}
mem_fraction: ${MEM}
host: ${HOST}
log_level: ${LOG_LEVEL}
+49
View File
@@ -0,0 +1,49 @@
# 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}
+18
View File
@@ -0,0 +1,18 @@
# taskkill - 进程终止工具
# 用法:
# pf taskkill chrome.exe python node
strategy: thread
variables:
PROCESS_NAMES: []
cli:
description: "TaskKill - 进程终止工具 (跨平台)"
usage: "pf taskkill <process_name> [process_name ...]"
positional:
- name: PROCESS_NAMES
nargs: "+"
type: str
help: "进程名称 (如: chrome.exe python node)"
jobs:
kill:
fn: taskkill_run
args: ["${PROCESS_NAMES}"]
+18
View File
@@ -0,0 +1,18 @@
# which - 命令查找工具
# 用法:
# pf which python ls ps gcc
strategy: thread
variables:
COMMANDS: []
cli:
description: "Which - 命令查找工具 (跨平台)"
usage: "pf which <command> [command ...]"
positional:
- name: COMMANDS
nargs: "+"
type: str
help: "要查找的命令名称, 如: python ls ps gcc"
jobs:
find:
fn: which_run
args: ["${COMMANDS}"]
View File
-56
View File
@@ -1,56 +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()
-77
View File
@@ -1,77 +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()
-57
View File
@@ -1,57 +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()
+7
View File
@@ -792,6 +792,13 @@ def run(
_print_dry_run(graph, layers) _print_dry_run(graph, layers)
return RunReport(success=True) return RunReport(success=True)
# verbose 模式下, 把所有 spec 的 verbose 标记设为 True,
# 使 execute_command 打印执行命令与返回码 (任务生命周期由 callback 打印)
if verbose:
from dataclasses import replace
graph = Graph.from_specs([replace(s, verbose=True) if not s.verbose else s for s in graph.all_specs().values()])
# 入口统一校验一次:所有策略共用,避免 layers() / dependency 路径 # 入口统一校验一次:所有策略共用,避免 layers() / dependency 路径
# 各自重复调用 validate()。 # 各自重复调用 validate()。
graph.validate() graph.validate()
+33 -1
View File
@@ -20,6 +20,7 @@ __all__ = [
import inspect import inspect
import sys import sys
from dataclasses import dataclass, field, replace from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any, Callable, Iterable, Mapping, Sequence from typing import Any, Callable, Iterable, Mapping, Sequence
from .errors import CycleError, DuplicateTaskError, MissingDependencyError from .errors import CycleError, DuplicateTaskError, MissingDependencyError
@@ -219,7 +220,6 @@ class Graph:
""" """
graph = cls(defaults=defaults or GraphDefaults(), namespace=namespace) graph = cls(defaults=defaults or GraphDefaults(), namespace=namespace)
pending_refs: list[str] = [] pending_refs: list[str] = []
for spec in specs: for spec in specs:
if isinstance(spec, str): if isinstance(spec, str):
pending_refs.append(spec) pending_refs.append(spec)
@@ -235,6 +235,38 @@ class Graph:
graph.validate() graph.validate()
return 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`。
Parameters
----------
path : str | Path
YAML 文件路径
variables : Mapping[str, Any] | None
运行时变量, 用于替换 ``${VAR}`` 占位符
Returns
-------
Graph
构建好的任务图
Raises
------
YamlLoadError
文件不存在、YAML 格式错误、schema 校验失败、循环依赖等
"""
from .yaml_loader import load_yaml
return load_yaml(path, variables=variables)
def add_subgraph(self, sub: Graph, *, namespace: str | None = None) -> Graph: def add_subgraph(self, sub: Graph, *, namespace: str | None = None) -> Graph:
"""将子图合并到当前图,任务名加命名空间前缀避免冲突。 """将子图合并到当前图,任务名加命名空间前缀避免冲突。
+20
View File
@@ -0,0 +1,20 @@
"""工具函数模块.
按类别组织 CLI 工具中可复用的函数, 每个子模块使用 ``@px.register_fn`` 注册函数,
供 YAML 任务编排通过 ``fn`` 字段引用.
子模块
------
- :mod:`files` —— 文件日期/等级/备份/压缩相关函数
- :mod:`dev` —— 开发工具 (ruff/pip/git/envdev/dockercmd) 相关函数
- :mod:`bumpversion` —— 版本号管理相关函数
- :mod:`media` —— PDF/截图相关函数
- :mod:`system` —— LS-DYNA/SSH/打包/清屏/进程终止相关函数
- :mod:`llm` —— ModelScope 下载/SGLang 服务相关函数
"""
from __future__ import annotations
from . import bumpversion, dev, files, llm, media, system
__all__ = ["bumpversion", "dev", "files", "llm", "media", "system"]
+233
View File
@@ -0,0 +1,233 @@
"""版本号管理模块.
提供单文件版本号更新 (``bump_file_version``) 与项目级批量版本号同步
(``bump_project_version``) 能力. 所有公共函数通过 ``@px.register_fn`` 注册,
供 YAML 任务编排引用.
设计要点
--------
``bump_project_version`` 采用 "先读取基准、再统一写入" 的两阶段策略:
先扫描所有 ``__init__.py`` / ``pyproject.toml`` 文件, 读取各自的版本号,
取最大值作为基准版本计算新版本号, 然后把新版本号统一写入所有文件,
避免文件间版本号不同步导致的跳号问题.
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from typing import Literal
import pyflowx as px
__all__ = [
"BumpVersionType",
"bump_file_version",
"bump_project_version",
]
# ============================================================================
# 配置
# ============================================================================
BumpVersionType = Literal["patch", "minor", "major"]
_PYPROJECT_VERSION_PATTERN = re.compile(
r'(?:^|\n)\s*version\s*=\s*["\']'
r"(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
r"(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?"
r"(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?"
r'["\']',
re.MULTILINE,
)
_INIT_VERSION_PATTERN = re.compile(
r'(?:^|\n)\s*__version__\s*=\s*["\']'
r"(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
r"(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?"
r"(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?"
r'["\']',
re.MULTILINE,
)
_IGNORE_DIRS = frozenset({".venv", "venv", ".git", "__pycache__", ".tox", "node_modules", "build", "dist", ".eggs"})
# ============================================================================
# 私有辅助函数
# ============================================================================
def _get_pattern_for_file(file_name: str) -> re.Pattern[str] | None:
"""根据文件类型获取对应的正则表达式."""
if file_name == "pyproject.toml":
return _PYPROJECT_VERSION_PATTERN
if file_name == "__init__.py":
return _INIT_VERSION_PATTERN
return None
def _calculate_new_version(major: int, minor: int, patch: int, part: BumpVersionType) -> str:
"""计算新版本号."""
if part == "major":
return f"{major + 1}.0.0"
if part == "minor":
return f"{major}.{minor + 1}.0"
return f"{major}.{minor}.{patch + 1}"
def _build_replacement_string(original_match: str, new_version: str, file_name: str) -> str:
"""构建替换字符串, 保留原始格式."""
quote_char = '"' if '"' in original_match else "'"
key = "__version__" if file_name == "__init__.py" else "version"
prefix_match = re.match(rf"(\s*{key}\s*=\s*)[\"']", original_match)
prefix = prefix_match.group(1) if prefix_match else f"{key} = "
return f"{prefix}{quote_char}{new_version}{quote_char}"
def _read_version_tuple(file_path: Path) -> tuple[int, int, int] | None:
"""从文件中读取版本号, 返回 (major, minor, patch) 元组; 未找到返回 None.
读取失败时抛出 ``OSError`` / ``UnicodeDecodeError`` 由调用方处理.
"""
pattern = _get_pattern_for_file(file_path.name)
if pattern is None:
return None
content = file_path.read_text(encoding="utf-8")
match = pattern.search(content)
if not match:
return None
return int(match.group("major")), int(match.group("minor")), int(match.group("patch"))
def _write_version_to_file(file_path: Path, new_version: str) -> bool:
"""把新版本号写入指定文件; 成功返回 True, 未匹配到版本号返回 False."""
pattern = _get_pattern_for_file(file_path.name)
if pattern is None: # pragma: no cover - 调用方已保证 pattern 不为 None
return False
content = file_path.read_text(encoding="utf-8")
match = pattern.search(content)
if not match: # pragma: no cover - 调用方已通过 _read_version_tuple 验证
return False
replacement = _build_replacement_string(match.group(0), new_version, file_path.name)
content = content.replace(match.group(0), replacement)
try:
file_path.write_text(content, encoding="utf-8")
except OSError as e:
print(f"更新文件 {file_path} 版本号时出错: {e}")
raise
return True
# ============================================================================
# 公共函数
# ============================================================================
@px.register_fn
def bump_file_version(file_path: Path, part: BumpVersionType = "patch") -> str | None:
"""更新单个文件中的版本号.
读取文件当前版本号, 按 ``part`` 指定的部分递增, 写回文件.
Parameters
----------
file_path : Path
要更新的文件路径 (``pyproject.toml`` 或 ``__init__.py``)
part : BumpVersionType
版本部分: patch, minor, major
Returns
-------
str | None
更新后的新版本号; 文件中未找到版本号或读取失败时返回 None
"""
version_tuple = _read_version_tuple(file_path)
if version_tuple is None:
print(f"文件 {file_path} 中未找到版本号模式")
return None
major, minor, patch = version_tuple
new_version = _calculate_new_version(major, minor, patch, part)
if not _write_version_to_file(file_path, new_version): # pragma: no cover - _read_version_tuple 已验证
return None
return new_version
@px.register_fn
def bump_project_version(part: BumpVersionType = "patch", no_tag: bool = False) -> str | None:
"""批量同步项目所有版本号文件并提交.
扫描当前目录下所有 ``__init__.py`` 和 ``pyproject.toml`` 文件
(排除虚拟环境和缓存目录), 先读取每个文件的当前版本号取最大值作为基准,
计算新版本号后统一写入所有文件, 最后执行 git add (按文件名) + commit + tag.
采用 "先读取基准、再统一写入" 的两阶段策略, 即使某些文件版本号不同步,
也能在一次 bump 后重新对齐, 避免跳号.
Parameters
----------
part : BumpVersionType
版本部分: patch, minor, major
no_tag : bool
提交后不创建 git tag
Returns
-------
str | None
更新后的新版本号; 未找到版本号文件时返回 None
"""
all_files: set[Path] = set()
for pattern in ("__init__.py", "pyproject.toml"):
for file in Path.cwd().rglob(pattern):
if not any(ignore_dir in file.parts for ignore_dir in _IGNORE_DIRS):
all_files.add(file)
if not all_files:
print("未找到包含版本号的文件")
return None
print(f"找到 {len(all_files)} 个文件需要更新版本号")
cwd = Path.cwd()
for file in sorted(all_files):
print(f" - {file.relative_to(cwd)}")
# 阶段 1: 读取所有文件版本号, 取最大值作为基准
versions: list[tuple[int, int, int]] = []
for file in sorted(all_files):
v = _read_version_tuple(file)
if v is not None:
versions.append(v)
if not versions:
print("未能从任何文件读取版本号")
return None
major, minor, patch = max(versions)
new_version = _calculate_new_version(major, minor, patch, part)
print(f"基准版本: {major}.{minor}.{patch} -> 新版本: {new_version}")
# 阶段 2: 统一写入新版本号到所有文件
for file in sorted(all_files):
_write_version_to_file(file, new_version)
# 阶段 3: git add (按文件名) + commit + tag
relative_files = [str(file.relative_to(cwd)) for file in sorted(all_files)]
subprocess.run(["git", "add", *relative_files], check=True)
subprocess.run(["git", "commit", "-m", f"bump version to {new_version}"], check=True)
if not no_tag:
tag_name = f"v{new_version}"
subprocess.run(["git", "tag", "-a", tag_name, "-m", f"Release {tag_name}"], check=True)
print(f"已创建标签: {tag_name}")
return new_version
+823
View File
@@ -0,0 +1,823 @@
"""开发工具类函数模块.
聚合自动格式化 (autofmt)、pip 包管理 (piptool)、git 工具 (gittool)、
开发环境配置 (envdev)、docker 镜像登录 (dockercmd) 的可复用函数.
版本号管理已抽离到 :mod:`pyflowx.ops.bumpversion`. 所有公共函数通过
``@px.register_fn`` 注册, 供 YAML 任务编排引用.
"""
from __future__ import annotations
import ast
import fnmatch
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
__all__ = [
"IGNORE_PATTERNS",
"PACKAGE_DIR",
"REQUIREMENTS_FILE",
"_PROTECTED_PACKAGES",
"add_docstring",
"auto_add_docstrings",
"docker_login_tencent",
"download_rustup_script",
"format_all",
"format_with_ruff",
"generate_module_docstring",
"git_add_commit",
"git_init_add_commit",
"has_files",
"init_sub_dirs",
"install_linux_docker",
"install_linux_fonts",
"install_linux_qt_libs",
"install_rust_toolchain",
"lint_with_ruff",
"not_has_git_repo",
"pip_download",
"pip_freeze",
"pip_reinstall",
"pip_uninstall",
"setup_conda_mirror",
"setup_linux_system_mirror",
"setup_python_mirror",
"setup_rust_mirror",
"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("没有文件需要提交")
# ============================================================================
# envdev 配置 (Python / Conda / Rust 镜像源)
# ============================================================================
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"
def _pip_config_path() -> Path:
"""返回当前平台的 pip 配置文件路径."""
if Constants.IS_LINUX:
return Path.home() / ".pip" / "pip.conf"
return Path.home() / "pip" / "pip.ini"
@px.register_fn
def setup_python_mirror(mirror: str) -> None:
"""配置 Python 镜像源 (设置环境变量 + 写入 pip 配置文件).
设置 ``PIP_INDEX_URL`` / ``PIP_TRUSTED_HOSTS`` / ``UV_INDEX_URL`` /
``UV_PYTHON_INSTALL_MIRROR`` 等环境变量, 并写入 pip 配置文件.
Parameters
----------
mirror : str
镜像源名称, 见 :data:`_PIP_INDEX_URLS`
"""
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.register_fn
def setup_conda_mirror(mirror: str) -> None:
"""配置 Conda 镜像源 (写入 ~/.condarc).
Parameters
----------
mirror : str
镜像源名称, 见 :data:`_CONDA_MIRROR_URLS`
"""
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.register_fn
def setup_rust_mirror(mirror: str, version: str = "stable") -> None:
"""配置 Rust 镜像源 (设置环境变量 + 写入 cargo config + 创建 sccache 目录).
设置 ``RUSTUP_DIST_SERVER`` / ``RUSTUP_UPDATE_ROOT`` / ``RUST_SCCACHE_DIR``
等环境变量, 写入 ``~/.cargo/config.toml``, 并创建 sccache 缓存目录.
Parameters
----------
mirror : str
镜像源名称, 见 :data:`_RUSTUP_MIRRORS`
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}")
# ============================================================================
# dockercmd 函数
# ============================================================================
_DOCKER_MIRROR_TENCENT: str = "ccr.ccs.tencentyun.com"
@px.register_fn
def docker_login_tencent(username: str = "") -> None:
"""登录腾讯云 Docker 镜像仓库.
Parameters
----------
username : str
Docker 用户名 (为空时由 docker 交互式提示输入)
"""
user = username or getpass.getuser()
subprocess.run(["docker", "login", "--username", user, _DOCKER_MIRROR_TENCENT], check=False)
print(f"已尝试登录腾讯云镜像仓库 (用户: {user})")
# ============================================================================
# envdev Linux 专用函数
# ============================================================================
_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"
@px.register_fn
def setup_linux_system_mirror() -> None:
"""下载并安装 Linux 系统镜像源 (仅 Linux, 已配置国内镜像时跳过).
检查 ``/etc/apt/sources.list`` 与 ``/etc/apt/sources.list.d/ubuntu.sources``
是否已配置国内镜像, 已配置则跳过; 未配置则下载并执行 linuxmirrors 脚本.
"""
if not Constants.IS_LINUX:
print("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.register_fn
def install_linux_qt_libs() -> None:
"""安装 Qt 依赖库 (仅 Linux)."""
if not Constants.IS_LINUX:
print("install_linux_qt_libs: 仅在 Linux 上执行")
return
subprocess.run(["sudo", "apt", "install", "-y", *_QT_LIBS], check=False)
print("Qt 依赖库安装完成")
@px.register_fn
def install_linux_fonts() -> None:
"""安装中文字体 (仅 Linux)."""
if not Constants.IS_LINUX:
print("install_linux_fonts: 仅在 Linux 上执行")
return
subprocess.run(["sudo", "apt", "install", "-y", *_CHINESE_FONTS], check=False)
print("中文字体安装完成")
@px.register_fn
def install_linux_docker() -> None:
"""安装 Docker (仅 Linux)."""
if not Constants.IS_LINUX:
print("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 用户组)")
@px.register_fn
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.register_fn
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} 安装完成")
+327
View File
@@ -0,0 +1,327 @@
"""文件类函数模块.
聚合文件日期处理、文件等级重命名、文件夹备份、文件夹压缩工具的可复用函数.
所有公共函数通过 ``@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(".")
+117
View File
@@ -0,0 +1,117 @@
"""LLM 工具类函数模块.
聚合 ModelScope 下载 (msdownload) 与 SGLang 本地模型服务 (sglang) 的可复用函数.
所有公共函数通过 ``@px.register_fn`` 注册, 供 YAML 任务编排引用.
"""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
import pyflowx as px
from pyflowx.conditions import Constants
__all__ = [
"install_sglang",
"msdownload_run",
"run_sglang",
]
@px.register_fn
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}")
subprocess.run(cmd, check=False)
@px.register_fn
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]...")
subprocess.run(["uv", "install", "sglang[all]"], check=False)
@px.register_fn
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 = "python" if Constants.IS_WINDOWS else "python3"
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})")
subprocess.run(cmd, check=False)
@@ -1,15 +1,41 @@
"""PDF 工具模块. """媒体类函数模块.
提供 PDF 文件操作的常用功能封装, 聚合 PDF 工具 (pdftool) 和截图工具 (screenshot) 的可复用函数.
支持合并拆分压缩加密水印OCR等功能. 所有公共函数通过 ``@px.register_fn`` 注册, YAML 任务编排引用.
""" """
from __future__ import annotations from __future__ import annotations
import argparse import subprocess
from datetime import datetime
from pathlib import Path from pathlib import Path
import pyflowx as px 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",
"pdf_decrypt",
"pdf_encrypt",
"pdf_extract_images",
"pdf_extract_text",
"pdf_info",
"pdf_merge",
"pdf_ocr",
"pdf_reorder",
"pdf_repair",
"pdf_rotate",
"pdf_split",
"pdf_to_images",
"take_screenshot_area",
"take_screenshot_full",
]
try: try:
import fitz # PyMuPDF import fitz # PyMuPDF
@@ -36,14 +62,15 @@ DEFAULT_PASSWORD = ""
# ============================================================================ # ============================================================================
# 辅助函数 # PDF 函数
# ============================================================================ # ============================================================================
@px.register_fn
def pdf_merge(input_paths: list[Path], output_path: Path) -> None: def pdf_merge(input_paths: list[Path], output_path: Path) -> None:
"""合并多个 PDF 文件.""" """合并多个 PDF 文件."""
if not HAS_PYPDF: if not HAS_PYPDF:
print("未安装 pypdf 库请安装: pip install pypdf") print("未安装 pypdf 库, 请安装: pip install pypdf")
return return
writer = pypdf.PdfWriter() writer = pypdf.PdfWriter()
@@ -60,10 +87,11 @@ def pdf_merge(input_paths: list[Path], output_path: Path) -> None:
print(f"合并完成: {output_path}") print(f"合并完成: {output_path}")
@px.register_fn
def pdf_split(input_path: Path, output_dir: Path) -> None: def pdf_split(input_path: Path, output_dir: Path) -> None:
"""拆分 PDF 文件为单页.""" """拆分 PDF 文件为单页."""
if not HAS_PYPDF: if not HAS_PYPDF:
print("未安装 pypdf 库请安装: pip install pypdf") print("未安装 pypdf 库, 请安装: pip install pypdf")
return return
reader = pypdf.PdfReader(str(input_path)) reader = pypdf.PdfReader(str(input_path))
@@ -79,10 +107,11 @@ def pdf_split(input_path: Path, output_dir: Path) -> None:
print(f"拆分完成: {output_dir}") print(f"拆分完成: {output_dir}")
@px.register_fn
def pdf_compress(input_path: Path, output_path: Path) -> None: def pdf_compress(input_path: Path, output_path: Path) -> None:
"""压缩 PDF 文件.""" """压缩 PDF 文件."""
if not HAS_PYMUPDF: if not HAS_PYMUPDF:
print("未安装 PyMuPDF 库请安装: pip install PyMuPDF") print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
return return
doc = fitz.open(str(input_path)) doc = fitz.open(str(input_path))
@@ -96,10 +125,11 @@ def pdf_compress(input_path: Path, output_path: Path) -> None:
print(f"压缩完成: {output_path} (缩小 {ratio:.1f}%)") print(f"压缩完成: {output_path} (缩小 {ratio:.1f}%)")
@px.register_fn
def pdf_encrypt(input_path: Path, output_path: Path, password: str) -> None: def pdf_encrypt(input_path: Path, output_path: Path, password: str) -> None:
"""加密 PDF 文件.""" """加密 PDF 文件."""
if not HAS_PYPDF: if not HAS_PYPDF:
print("未安装 pypdf 库请安装: pip install pypdf") print("未安装 pypdf 库, 请安装: pip install pypdf")
return return
reader = pypdf.PdfReader(str(input_path)) reader = pypdf.PdfReader(str(input_path))
@@ -116,10 +146,11 @@ def pdf_encrypt(input_path: Path, output_path: Path, password: str) -> None:
print(f"加密完成: {output_path}") print(f"加密完成: {output_path}")
@px.register_fn
def pdf_decrypt(input_path: Path, output_path: Path, password: str) -> None: def pdf_decrypt(input_path: Path, output_path: Path, password: str) -> None:
"""解密 PDF 文件.""" """解密 PDF 文件."""
if not HAS_PYPDF: if not HAS_PYPDF:
print("未安装 pypdf 库请安装: pip install pypdf") print("未安装 pypdf 库, 请安装: pip install pypdf")
return return
reader = pypdf.PdfReader(str(input_path)) reader = pypdf.PdfReader(str(input_path))
@@ -137,10 +168,11 @@ def pdf_decrypt(input_path: Path, output_path: Path, password: str) -> None:
print(f"解密完成: {output_path}") print(f"解密完成: {output_path}")
@px.register_fn
def pdf_extract_text(input_path: Path, output_path: Path) -> None: def pdf_extract_text(input_path: Path, output_path: Path) -> None:
"""提取 PDF 文本.""" """提取 PDF 文本."""
if not HAS_PYMUPDF: if not HAS_PYMUPDF:
print("未安装 PyMuPDF 库请安装: pip install PyMuPDF") print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
return return
doc = fitz.open(str(input_path)) doc = fitz.open(str(input_path))
@@ -154,10 +186,11 @@ def pdf_extract_text(input_path: Path, output_path: Path) -> None:
print(f"文本提取完成: {output_path}") print(f"文本提取完成: {output_path}")
@px.register_fn
def pdf_extract_images(input_path: Path, output_dir: Path) -> None: def pdf_extract_images(input_path: Path, output_dir: Path) -> None:
"""提取 PDF 图片.""" """提取 PDF 图片."""
if not HAS_PYMUPDF: if not HAS_PYMUPDF:
print("未安装 PyMuPDF 库请安装: pip install PyMuPDF") print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
return return
doc = fitz.open(str(input_path)) doc = fitz.open(str(input_path))
@@ -180,10 +213,11 @@ def pdf_extract_images(input_path: Path, output_dir: Path) -> None:
print(f"图片提取完成: {output_dir} (共 {image_count} 张)") print(f"图片提取完成: {output_dir} (共 {image_count} 张)")
@px.register_fn
def pdf_add_watermark(input_path: Path, output_path: Path, text: str = "CONFIDENTIAL") -> None: def pdf_add_watermark(input_path: Path, output_path: Path, text: str = "CONFIDENTIAL") -> None:
"""添加 PDF 水印.""" """添加 PDF 水印."""
if not HAS_PYMUPDF: if not HAS_PYMUPDF:
print("未安装 PyMuPDF 库请安装: pip install PyMuPDF") print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
return return
doc = fitz.open(str(input_path)) doc = fitz.open(str(input_path))
@@ -200,10 +234,11 @@ def pdf_add_watermark(input_path: Path, output_path: Path, text: str = "CONFIDEN
print(f"水印添加完成: {output_path}") print(f"水印添加完成: {output_path}")
@px.register_fn
def pdf_rotate(input_path: Path, output_path: Path, rotation: int = 90) -> None: def pdf_rotate(input_path: Path, output_path: Path, rotation: int = 90) -> None:
"""旋转 PDF 页面.""" """旋转 PDF 页面."""
if not HAS_PYMUPDF: if not HAS_PYMUPDF:
print("未安装 PyMuPDF 库请安装: pip install PyMuPDF") print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
return return
doc = fitz.open(str(input_path)) doc = fitz.open(str(input_path))
@@ -216,10 +251,11 @@ def pdf_rotate(input_path: Path, output_path: Path, rotation: int = 90) -> None:
print(f"旋转完成: {output_path}") print(f"旋转完成: {output_path}")
@px.register_fn
def pdf_crop(input_path: Path, output_path: Path, margins: tuple[int, int, int, int]) -> None: def pdf_crop(input_path: Path, output_path: Path, margins: tuple[int, int, int, int]) -> None:
"""裁剪 PDF 页面.""" """裁剪 PDF 页面."""
if not HAS_PYMUPDF: if not HAS_PYMUPDF:
print("未安装 PyMuPDF 库请安装: pip install PyMuPDF") print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
return return
doc = fitz.open(str(input_path)) doc = fitz.open(str(input_path))
@@ -241,10 +277,11 @@ def pdf_crop(input_path: Path, output_path: Path, margins: tuple[int, int, int,
print(f"裁剪完成: {output_path}") print(f"裁剪完成: {output_path}")
@px.register_fn
def pdf_info(input_path: Path) -> None: def pdf_info(input_path: Path) -> None:
"""显示 PDF 信息.""" """显示 PDF 信息."""
if not HAS_PYMUPDF: if not HAS_PYMUPDF:
print("未安装 PyMuPDF 库请安装: pip install PyMuPDF") print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
return return
doc = fitz.open(str(input_path)) doc = fitz.open(str(input_path))
@@ -262,17 +299,18 @@ def pdf_info(input_path: Path) -> None:
doc.close() doc.close()
@px.register_fn
def pdf_ocr(input_path: Path, output_path: Path, lang: str = "chi_sim+eng") -> None: def pdf_ocr(input_path: Path, output_path: Path, lang: str = "chi_sim+eng") -> None:
"""PDF OCR 识别.""" """PDF OCR 识别."""
try: try:
import pytesseract import pytesseract
from PIL import Image from PIL import Image
except ImportError: except ImportError:
print("未安装 OCR 相关库请安装: pip install pytesseract pillow") print("未安装 OCR 相关库, 请安装: pip install pytesseract pillow")
return return
if not HAS_PYMUPDF: if not HAS_PYMUPDF:
print("未安装 PyMuPDF 库请安装: pip install PyMuPDF") print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
return return
doc = fitz.open(str(input_path)) doc = fitz.open(str(input_path))
@@ -287,7 +325,7 @@ def pdf_ocr(input_path: Path, output_path: Path, lang: str = "chi_sim+eng") -> N
new_page.insert_image(new_page.rect, pixmap=pix) new_page.insert_image(new_page.rect, pixmap=pix)
text_rect = fitz.Rect(0, 0, page.rect.width, page.rect.height) text_rect = fitz.Rect(0, 0, page.rect.width, page.rect.height)
# pyrefly: ignore [bad-argument-type] # pyrefly: ignore [bad-argument-type]
new_page.insert_textbox(text_rect, ocr_text) new_page.insert_textbox(text_rect, ocr_text, fontname="china-ss", fontsize=11)
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
new_doc.save(str(output_path)) new_doc.save(str(output_path))
@@ -296,10 +334,11 @@ def pdf_ocr(input_path: Path, output_path: Path, lang: str = "chi_sim+eng") -> N
print(f"OCR 识别完成: {output_path}") print(f"OCR 识别完成: {output_path}")
@px.register_fn
def pdf_reorder(input_path: Path, output_path: Path, order: list[int]) -> None: def pdf_reorder(input_path: Path, output_path: Path, order: list[int]) -> None:
"""重排 PDF 页面顺序.""" """重排 PDF 页面顺序."""
if not HAS_PYPDF: if not HAS_PYPDF:
print("未安装 pypdf 库请安装: pip install pypdf") print("未安装 pypdf 库, 请安装: pip install pypdf")
return return
reader = pypdf.PdfReader(str(input_path)) reader = pypdf.PdfReader(str(input_path))
@@ -316,10 +355,11 @@ def pdf_reorder(input_path: Path, output_path: Path, order: list[int]) -> None:
print(f"重排完成: {output_path}") print(f"重排完成: {output_path}")
@px.register_fn
def pdf_to_images(input_path: Path, output_dir: Path, dpi: int = 300) -> None: def pdf_to_images(input_path: Path, output_dir: Path, dpi: int = 300) -> None:
"""PDF 转图片.""" """PDF 转图片."""
if not HAS_PYMUPDF: if not HAS_PYMUPDF:
print("未安装 PyMuPDF 库请安装: pip install PyMuPDF") print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
return return
doc = fitz.open(str(input_path)) doc = fitz.open(str(input_path))
@@ -335,10 +375,11 @@ def pdf_to_images(input_path: Path, output_dir: Path, dpi: int = 300) -> None:
print(f"转换完成: {output_dir}") print(f"转换完成: {output_dir}")
@px.register_fn
def pdf_repair(input_path: Path, output_path: Path) -> None: def pdf_repair(input_path: Path, output_path: Path) -> None:
"""修复 PDF 文件.""" """修复 PDF 文件."""
if not HAS_PYMUPDF: if not HAS_PYMUPDF:
print("未安装 PyMuPDF 库请安装: pip install PyMuPDF") print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
return return
doc = fitz.open(str(input_path)) doc = fitz.open(str(input_path))
@@ -349,175 +390,109 @@ def pdf_repair(input_path: Path, output_path: Path) -> None:
# ============================================================================ # ============================================================================
# CLI Runner # screenshot 函数
# ============================================================================ # ============================================================================
def main() -> None: # noqa: PLR0912 @px.register_fn
"""PDF 工具主函数.""" def get_screenshot_path(filename: str | None = None) -> Path:
parser = argparse.ArgumentParser( """获取截图保存路径.
description="PDFTool - PDF 文件工具集",
usage="pdftool <command> [options]",
)
subparsers = parser.add_subparsers(dest="command", help="可用命令")
# 合并 PDF 命令 Parameters
merge_parser = subparsers.add_parser("m", help="合并 PDF 文件") ----------
merge_parser.add_argument("inputs", nargs="+", help="输入 PDF 文件路径") filename : str | None
merge_parser.add_argument("--output", type=str, default="merged.pdf", help="输出文件路径") 文件名, 如果为 None 则自动生成
# 拆分 PDF 命令 Returns
split_parser = subparsers.add_parser("s", help="拆分 PDF 文件为单页") -------
split_parser.add_argument("input", help="输入 PDF 文件路径") Path
split_parser.add_argument("--output-dir", type=str, default="split", help="输出目录") 截图保存路径
"""
if filename is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"screenshot_{timestamp}.png"
# 压缩 PDF 命令 screenshots_dir = Path.home() / "Pictures" / "screenshots"
compress_parser = subparsers.add_parser("c", help="压缩 PDF 文件") screenshots_dir.mkdir(parents=True, exist_ok=True)
compress_parser.add_argument("input", help="输入 PDF 文件路径") return screenshots_dir / filename
compress_parser.add_argument("--output", type=str, default="compressed.pdf", help="输出文件路径")
# 加密 PDF 命令
encrypt_parser = subparsers.add_parser("e", help="加密 PDF 文件")
encrypt_parser.add_argument("input", help="输入 PDF 文件路径")
encrypt_parser.add_argument("--output", type=str, default="encrypted.pdf", help="输出文件路径")
encrypt_parser.add_argument("--password", type=str, required=True, help="密码")
# 解密 PDF 命令 @px.register_fn
decrypt_parser = subparsers.add_parser("d", help="解密 PDF 文件") def take_screenshot_full(filename: str | None = None) -> None:
decrypt_parser.add_argument("input", help="输入 PDF 文件路径") """全屏截图.
decrypt_parser.add_argument("--output", type=str, default="decrypted.pdf", help="输出文件路径")
decrypt_parser.add_argument("--password", type=str, required=True, help="密码")
# 提取文本命令 Parameters
extract_text_parser = subparsers.add_parser("xt", help="提取 PDF 文本") ----------
extract_text_parser.add_argument("input", help="输入 PDF 文件路径") filename : str | None
extract_text_parser.add_argument("--output", type=str, default="output.txt", help="输出文件路径") 文件名
"""
output_path = get_screenshot_path(filename)
# 提取图片命令 if Constants.IS_WINDOWS:
extract_images_parser = subparsers.add_parser("xi", help="提取 PDF 图片") ps_script = f"""
extract_images_parser.add_argument("input", help="输入 PDF 文件路径") Add-Type -AssemblyName System.Windows.Forms
extract_images_parser.add_argument("--output-dir", type=str, default="images", help="输出目录") Add-Type -AssemblyName System.Drawing
$screen = [System.Windows.Forms.Screen]::PrimaryScreen
# 添加水印命令 $bounds = $screen.Bounds
watermark_parser = subparsers.add_parser("w", help="添加 PDF 水印") $bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
watermark_parser.add_argument("input", help="输入 PDF 文件路径") $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
watermark_parser.add_argument("--output", type=str, default="watermarked.pdf", help="输出文件路径") $graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
watermark_parser.add_argument("--text", type=str, default="CONFIDENTIAL", help="水印文本") $bitmap.Save('{output_path.as_posix()}')
$graphics.Dispose()
# 旋转 PDF 命令 $bitmap.Dispose()
rotate_parser = subparsers.add_parser("r", help="旋转 PDF 页面") """
rotate_parser.add_argument("input", help="输入 PDF 文件路径") subprocess.run(["powershell", "-Command", ps_script], check=True)
rotate_parser.add_argument("--output", type=str, default="rotated.pdf", help="输出文件路径") elif Constants.IS_MACOS:
rotate_parser.add_argument("--rotation", type=int, default=90, help="旋转角度 (90, 180, 270)") subprocess.run(["screencapture", "-x", str(output_path)], check=True)
# 裁剪 PDF 命令
crop_parser = subparsers.add_parser("crop", help="裁剪 PDF 页面")
crop_parser.add_argument("input", help="输入 PDF 文件路径")
crop_parser.add_argument("--output", type=str, default="cropped.pdf", help="输出文件路径")
crop_parser.add_argument("--left", type=int, default=10, help="左边裁剪")
crop_parser.add_argument("--top", type=int, default=10, help="顶部裁剪")
crop_parser.add_argument("--right", type=int, default=10, help="右边裁剪")
crop_parser.add_argument("--bottom", type=int, default=10, help="底部裁剪")
# 显示信息命令
info_parser = subparsers.add_parser("i", help="显示 PDF 信息")
info_parser.add_argument("input", help="输入 PDF 文件路径")
# OCR 识别命令
ocr_parser = subparsers.add_parser("ocr", help="PDF OCR 识别")
ocr_parser.add_argument("input", help="输入 PDF 文件路径")
ocr_parser.add_argument("--output", type=str, default="ocr.pdf", help="输出文件路径")
ocr_parser.add_argument("--lang", type=str, default="chi_sim+eng", help="OCR 语言")
# 转换图片命令
to_images_parser = subparsers.add_parser("img", help="PDF 转图片")
to_images_parser.add_argument("input", help="输入 PDF 文件路径")
to_images_parser.add_argument("--output-dir", type=str, default="images", help="输出目录")
to_images_parser.add_argument("--dpi", type=int, default=300, help="图片 DPI")
# 修复 PDF 命令
repair_parser = subparsers.add_parser("repair", help="修复 PDF 文件")
repair_parser.add_argument("input", help="输入 PDF 文件路径")
repair_parser.add_argument("--output", type=str, default="repaired.pdf", help="输出文件路径")
args = parser.parse_args()
if args.command == "m":
graph = px.Graph.from_specs([
px.TaskSpec("pdf_merge", fn=pdf_merge, args=([Path(p) for p in args.inputs], Path(args.output)))
])
elif args.command == "s":
graph = px.Graph.from_specs([
px.TaskSpec("pdf_split", fn=pdf_split, args=(Path(args.input), Path(args.output_dir)))
])
elif args.command == "c":
graph = px.Graph.from_specs([
px.TaskSpec("pdf_compress", fn=pdf_compress, args=(Path(args.input), Path(args.output)))
])
elif args.command == "e":
graph = px.Graph.from_specs([
px.TaskSpec("pdf_encrypt", fn=pdf_encrypt, args=(Path(args.input), Path(args.output), args.password))
])
elif args.command == "d":
graph = px.Graph.from_specs([
px.TaskSpec("pdf_decrypt", fn=pdf_decrypt, args=(Path(args.input), Path(args.output), args.password))
])
elif args.command == "xt":
graph = px.Graph.from_specs([
px.TaskSpec("pdf_extract_text", fn=pdf_extract_text, args=(Path(args.input), Path(args.output)))
])
elif args.command == "xi":
graph = px.Graph.from_specs([
px.TaskSpec("pdf_extract_images", fn=pdf_extract_images, args=(Path(args.input), Path(args.output_dir)))
])
elif args.command == "w":
graph = px.Graph.from_specs([
px.TaskSpec(
"pdf_watermark",
fn=pdf_add_watermark,
args=(Path(args.input), Path(args.output)),
kwargs={"text": args.text},
)
])
elif args.command == "r":
graph = px.Graph.from_specs([
px.TaskSpec(
"pdf_rotate",
fn=pdf_rotate,
args=(Path(args.input), Path(args.output)),
kwargs={"rotation": args.rotation},
)
])
elif args.command == "crop":
graph = px.Graph.from_specs([
px.TaskSpec(
"pdf_crop",
fn=pdf_crop,
args=(Path(args.input), Path(args.output)),
kwargs={"margins": (args.left, args.top, args.right, args.bottom)},
)
])
elif args.command == "i":
graph = px.Graph.from_specs([px.TaskSpec("pdf_info", fn=pdf_info, args=(Path(args.input),))])
elif args.command == "ocr":
graph = px.Graph.from_specs([
px.TaskSpec("pdf_ocr", fn=pdf_ocr, args=(Path(args.input), Path(args.output)), kwargs={"lang": args.lang})
])
elif args.command == "img":
graph = px.Graph.from_specs([
px.TaskSpec(
"pdf_to_images",
fn=pdf_to_images,
args=(Path(args.input), Path(args.output_dir)),
kwargs={"dpi": args.dpi},
)
])
elif args.command == "repair":
graph = px.Graph.from_specs([
px.TaskSpec("pdf_repair", fn=pdf_repair, args=(Path(args.input), Path(args.output)))
])
else: else:
parser.print_help() try:
return subprocess.run(["gnome-screenshot", "-f", str(output_path)], check=True)
except FileNotFoundError:
subprocess.run(["scrot", str(output_path)], check=True)
px.run(graph, strategy="thread") 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}")
+568
View File
@@ -0,0 +1,568 @@
"""系统类函数模块.
聚合 LS-DYNA 计算 (lscalc)、SSH 密钥部署 (sshcopyid)、Python 打包 (packtool)、
重置图标缓存 (reset_icon_cache) 的可复用函数. 所有公共函数通过 ``@px.register_fn``
注册, 供 YAML 任务编排引用.
"""
from __future__ import annotations
import os
import platform
import shutil
import subprocess
import sys
import urllib.request
import zipfile
from pathlib import Path
import pyflowx as px
from pyflowx.conditions import Constants
__all__ = [
"DEFAULT_BUILD_DIR",
"DEFAULT_CACHE_DIR",
"DEFAULT_DIST_DIR",
"DEFAULT_INPUT_FILE",
"DEFAULT_LIB_DIR",
"DEFAULT_NCPU",
"IGNORE_PATTERNS",
"LS_DYNA_COMMANDS",
"check_ls_dyna_status",
"clean_build_dir",
"clear_screen_run",
"create_zip_package",
"get_ls_dyna_command",
"install_embed_python",
"pack_dependencies",
"pack_source",
"pack_wheel",
"reset_icon_cache_run",
"run_ls_dyna",
"run_ls_dyna_mpi",
"ssh_copy_id",
"taskkill_run",
"which_run",
]
# ============================================================================
# lscalc 配置
# ============================================================================
LS_DYNA_COMMANDS: dict[str, list[str]] = {
"windows": ["ls-dyna_mpp", "i=input.k", "ncpu=4"],
"linux": ["ls-dyna_mpp", "i=input.k", "ncpu=8"],
"macos": ["ls-dyna_mpp", "i=input.k", "ncpu=4"],
}
DEFAULT_INPUT_FILE: str = "input.k"
DEFAULT_NCPU: int = 4
# ============================================================================
# packtool 配置
# ============================================================================
DEFAULT_BUILD_DIR = ".pypack"
DEFAULT_DIST_DIR = "dist"
DEFAULT_LIB_DIR = "libs"
DEFAULT_CACHE_DIR = ".cache/pypack"
IGNORE_PATTERNS = [
"__pycache__",
"*.pyc",
"*.pyo",
".git",
".venv",
".idea",
".vscode",
"*.egg-info",
"dist",
"build",
".pytest_cache",
".tox",
".mypy_cache",
]
# ============================================================================
# lscalc 函数
# ============================================================================
@px.register_fn
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 命令列表
"""
if Constants.IS_WINDOWS or Constants.IS_MACOS:
return ["ls-dyna_mpp", f"i={input_file}", f"ncpu={ncpu}"]
else:
return ["ls-dyna_mpp", f"i={input_file}", f"ncpu={ncpu}"]
@px.register_fn
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.register_fn
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.register_fn
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}")
# ============================================================================
# sshcopyid 函数
# ============================================================================
@px.register_fn
def ssh_copy_id(
hostname: str,
username: str,
password: str,
port: int = 22,
keypath: str = "~/.ssh/id_rsa.pub",
timeout: int = 30,
) -> None:
"""将 SSH 公钥部署到远程服务器.
Parameters
----------
hostname : str
远程服务器主机名或 IP 地址
username : str
远程服务器用户名
password : str
远程服务器密码
port : int
SSH 端口, 默认 22
keypath : str
公钥文件路径, 默认 ~/.ssh/id_rsa.pub
timeout : int
SSH 操作超时秒数, 默认 30
"""
pub_key_path = Path(keypath).expanduser()
if not pub_key_path.exists():
print(f"公钥文件不存在: {pub_key_path}")
sys.exit(1)
pub_key = pub_key_path.read_text().strip()
script = f"""mkdir -p ~/.ssh && chmod 700 ~/.ssh
cd ~/.ssh && touch authorized_keys && chmod 600 authorized_keys
grep -qF '{pub_key.split()[1]}' authorized_keys 2>/dev/null || echo '{pub_key}' >> authorized_keys"""
try:
subprocess.run(
[
"sshpass",
"-p",
password,
"ssh",
"-p",
str(port),
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
f"ConnectTimeout={timeout}",
f"{username}@{hostname}",
script,
],
check=True,
timeout=timeout,
)
print(f"SSH 密钥已部署到 {username}@{hostname}:{port}")
except FileNotFoundError:
print(f"未找到 sshpass 工具, 请手动执行: ssh-copy-id -p {port} {username}@{hostname}")
sys.exit(1)
except subprocess.TimeoutExpired:
print("SSH 连接超时")
sys.exit(1)
except subprocess.CalledProcessError as e:
print(f"SSH 执行失败: {e}")
sys.exit(1)
# ============================================================================
# packtool 函数
# ============================================================================
@px.register_fn
def pack_source(project_dir: Path, output_dir: Path) -> None:
"""打包项目源码.
Parameters
----------
project_dir : Path
项目目录
output_dir : Path
输出目录
"""
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.register_fn
def pack_dependencies(lib_dir: Path, dependencies: list[str]) -> None:
"""打包项目依赖.
Parameters
----------
lib_dir : Path
依赖库目录
dependencies : list[str]
依赖列表
"""
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.register_fn
def pack_wheel(project_dir: Path, output_dir: Path) -> None:
"""打包项目为 wheel 文件.
Parameters
----------
project_dir : Path
项目目录
output_dir : Path
输出目录
"""
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.register_fn
def install_embed_python(version: str, output_dir: Path) -> None:
"""安装嵌入式 Python.
Parameters
----------
version : str
Python 版本 (如: 3.10, 3.11)
output_dir : Path
输出目录
"""
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.register_fn
def create_zip_package(source_dir: Path, output_file: Path) -> None:
"""创建 ZIP 打包文件.
Parameters
----------
source_dir : Path
源目录
output_file : Path
输出文件
"""
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.register_fn
def clean_build_dir(build_dir: Path) -> None:
"""清理构建目录.
Parameters
----------
build_dir : Path
构建目录
"""
if build_dir.exists():
shutil.rmtree(build_dir)
print(f"清理完成: {build_dir}")
else:
print(f"目录不存在: {build_dir}")
# ============================================================================
# reseticoncache 函数
# ============================================================================
@px.register_fn
def reset_icon_cache_run() -> None:
"""重置 Windows 图标缓存.
执行流程: 杀掉 explorer → 删除 IconCache.db → 删除 iconcache* → 重启 explorer.
仅在 Windows 上执行, 非 Windows 平台打印提示并跳过.
"""
if not Constants.IS_WINDOWS:
print("reset_icon_cache: 仅在 Windows 上支持")
return
local_app_data = os.environ.get("LOCALAPPDATA", "")
if not local_app_data:
print("reset_icon_cache: LOCALAPPDATA 环境变量未设置")
return
icon_cache_db = Path(local_app_data) / "IconCache.db"
explorer_cache_dir = Path(local_app_data) / "Microsoft" / "Windows" / "Explorer"
print("正在终止 explorer 进程...")
subprocess.run(["taskkill", "/f", "/im", "explorer.exe"], check=False)
if icon_cache_db.exists():
print(f"删除图标缓存: {icon_cache_db}")
subprocess.run(["cmd", "/c", "del", "/a", "/q", str(icon_cache_db)], check=False)
if explorer_cache_dir.exists():
print(f"清理 Explorer 缓存: {explorer_cache_dir}")
subprocess.run(
["cmd", "/c", "del", "/a", "/q", str(explorer_cache_dir / "iconcache*")],
check=False,
)
print("重启 explorer...")
subprocess.run(["cmd", "/c", "start", "explorer.exe"], check=False)
print("图标缓存已重置")
# ============================================================================
# clearscreen / taskkill / which 函数
# ============================================================================
@px.register_fn
def clear_screen_run() -> None:
"""清屏 (跨平台).
Windows 调用 ``cls``, Linux/macOS 调用 ``clear``.
"""
cmd = ["cls"] if Constants.IS_WINDOWS else ["clear"]
subprocess.run(cmd, check=False)
@px.register_fn
def taskkill_run(process_names: list[str]) -> None:
"""按名称终止进程 (跨平台).
Windows 使用 ``taskkill /f /im <name>*``,
Linux/macOS 使用 ``pkill -f <name>*``.
Parameters
----------
process_names : list[str]
进程名称列表 (如: ``["chrome.exe", "python"]``)
"""
if Constants.IS_WINDOWS:
cmd_prefix: list[str] = ["taskkill", "/f", "/im"]
else:
cmd_prefix = ["pkill", "-f"]
for name in process_names:
print(f"终止进程: {name}")
subprocess.run([*cmd_prefix, f"{name}*"], check=False)
@px.register_fn
def which_run(commands: list[str]) -> None:
"""查找可执行命令路径 (跨平台).
Windows 使用 ``where``, Linux/macOS 使用 ``which``.
对每个命令打印 ``<cmd> -> <path>`` 或 ``<cmd> -> 未找到``.
Parameters
----------
commands : list[str]
要查找的命令名称列表
"""
which_cmd = "where" if Constants.IS_WINDOWS else "which"
for cmd in commands:
result = subprocess.run([which_cmd, cmd], capture_output=True, text=True, check=False)
if result.returncode == 0:
# Windows 的 where 可能返回多行, 取第一个
path = result.stdout.strip().split("\n")[0].strip()
print(f"{cmd} -> {path}")
else:
print(f"{cmd} -> 未找到")
+159
View File
@@ -0,0 +1,159 @@
"""函数注册表.
提供全局函数注册机制, 供 YAML 任务编排通过 ``fn`` 字段引用 Python 函数.
使用方式
--------
import pyflowx as px
@px.register_fn("pack_source")
def pack_source(project_dir, output_dir):
...
# YAML 中引用:
# jobs:
# pack:
# fn: pack_source
# args: ["./project", "./dist"]
"""
from __future__ import annotations
import sys
from typing import Any, Callable, TypeVar, overload
if sys.version_info >= (3, 10):
from typing import ParamSpec
else:
from typing_extensions import ParamSpec # pragma: no cover
__all__ = ["FnRegistry", "get_fn", "has_fn", "register_fn"]
P = ParamSpec("P")
T = TypeVar("T")
_REGISTRY: dict[str, Callable[..., Any]] = {}
@overload
def register_fn(name: Callable[P, T]) -> Callable[P, T]: ...
@overload
def register_fn(name: str | None = None) -> Callable[[Callable[P, T]], Callable[P, T]]: ...
def register_fn(name: str | Callable[..., Any] | None = None) -> Callable[..., Any]:
"""装饰器:将函数注册到全局 registry.
支持两种用法::
@register_fn # 使用函数 __name__ 作为注册名
def my_func(): ...
@register_fn("custom") # 显式指定注册名
def my_func(): ...
Parameters
----------
name : str | Callable | None
注册名或被装饰函数; 为 None 时使用函数 ``__name__``
Returns
-------
Callable
装饰器函数或被装饰函数
Raises
------
ValueError
名称已注册或无法推断函数名
"""
if callable(name):
fn = name
key = getattr(fn, "__name__", None)
if key is None:
raise ValueError("无法推断函数名, 请显式提供 name 参数")
if key in _REGISTRY:
raise ValueError(f"函数 {key!r} 已注册")
_REGISTRY[key] = fn
return fn
def decorator(fn: Callable[P, T]) -> Callable[P, T]:
key = name if name is not None else getattr(fn, "__name__", None)
if key is None:
raise ValueError("无法推断函数名, 请显式提供 name 参数")
if key in _REGISTRY:
raise ValueError(f"函数 {key!r} 已注册")
_REGISTRY[key] = fn
return fn
return decorator
def get_fn(name: str) -> Callable[..., Any]:
"""按名称获取已注册的函数.
Parameters
----------
name : str
函数名
Returns
-------
Callable
已注册的函数
Raises
------
KeyError
函数未注册
"""
if name not in _REGISTRY:
raise KeyError(f"函数 {name!r} 未注册")
return _REGISTRY[name]
def has_fn(name: str) -> bool:
"""检查函数是否已注册.
Parameters
----------
name : str
函数名
Returns
-------
bool
是否已注册
"""
return name in _REGISTRY
class FnRegistry:
"""函数注册表的面向对象访问接口."""
@staticmethod
def register(name: str | None = None) -> Callable[[Callable[..., T]], Callable[..., T]]:
"""注册装饰器, 等价于 :func:`register_fn`."""
return register_fn(name)
@staticmethod
def get(name: str) -> Callable[..., Any]:
"""获取已注册函数, 等价于 :func:`get_fn`."""
return get_fn(name)
@staticmethod
def has(name: str) -> bool:
"""检查是否已注册, 等价于 :func:`has_fn`."""
return has_fn(name)
@staticmethod
def clear() -> None:
"""清空注册表."""
_REGISTRY.clear()
@staticmethod
def names() -> list[str]:
"""返回所有已注册函数名."""
return list(_REGISTRY.keys())
+2 -39
View File
@@ -14,7 +14,7 @@ from __future__ import annotations
import argparse import argparse
import enum import enum
import sys import sys
from dataclasses import dataclass, field, replace from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Sequence, get_args from typing import Any, Sequence, get_args
@@ -35,39 +35,6 @@ class CliExitCode(enum.IntEnum):
INTERRUPTED = 130 # 与 POSIX 信号中断一致 INTERRUPTED = 130 # 与 POSIX 信号中断一致
def _apply_verbose_to_graph(graph: Graph, verbose: bool) -> Graph:
"""创建新图, 其中所有 TaskSpec 的 verbose 字段被设置为指定值.
使用 ``dataclasses.replace`` 在不可变的 TaskSpec 上创建带 verbose 标记的副本.
依赖关系、标签等元数据全部保留.
Note
-----
自 ``_wrap_cmd`` 不再闭包捕获 ``verbose`` 后,此函数不再是必需的——
直接翻转 ``spec.verbose`` 即可生效。保留是为了向后兼容现有调用与测试。
TaskSpec 仍是 frozen dataclass,故仍用 ``replace`` 创建副本。
Parameters
----------
graph : Graph
原始图.
verbose : bool
要设置的 verbose 值.
Returns
-------
Graph
所有 spec 的 verbose 字段已更新的新图.
"""
new_specs: list[TaskSpec[Any]] = []
for spec in graph.all_specs().values():
if spec.verbose == verbose:
new_specs.append(spec)
else:
new_specs.append(replace(spec, verbose=verbose))
return Graph.from_specs(new_specs)
@dataclass @dataclass
class CliRunner: class CliRunner:
"""命令行运行器: 根据用户输入执行对应的任务流图. """命令行运行器: 根据用户输入执行对应的任务流图.
@@ -296,12 +263,8 @@ class CliRunner:
# 确定是否 verbose: --quiet 覆盖默认值 # 确定是否 verbose: --quiet 覆盖默认值
verbose = self.verbose and not parsed.quiet verbose = self.verbose and not parsed.quiet
# 对图应用 verbose 设置 (重建带 verbose 标记的 spec) # 执行对应的图 (verbose 标记由 run() 统一应用到各 spec)
graph = self.graphs[parsed.command] graph = self.graphs[parsed.command]
if verbose:
graph = _apply_verbose_to_graph(graph, verbose=True)
# 执行对应的图
try: try:
report = run( report = run(
graph, graph,
File diff suppressed because it is too large Load Diff
+20 -103
View File
@@ -5,8 +5,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pyflowx as px from pyflowx.ops import dev
from pyflowx.cli import autofmt
# ---------------------------------------------------------------------- # # ---------------------------------------------------------------------- #
@@ -19,14 +18,14 @@ class TestFormatWithRuff:
"""Should format with ruff.""" """Should format with ruff."""
with patch("subprocess.run") as mock_run: with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0) mock_run.return_value = MagicMock(returncode=0)
autofmt.format_with_ruff(tmp_path, fix=True) dev.format_with_ruff(tmp_path, fix=True)
assert mock_run.called assert mock_run.called
def test_format_with_ruff_no_fix(self, tmp_path: Path) -> None: def test_format_with_ruff_no_fix(self, tmp_path: Path) -> None:
"""Should format with ruff without fix.""" """Should format with ruff without fix."""
with patch("subprocess.run") as mock_run: with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0) mock_run.return_value = MagicMock(returncode=0)
autofmt.format_with_ruff(tmp_path, fix=False) dev.format_with_ruff(tmp_path, fix=False)
# Should not include --fix flag # Should not include --fix flag
call_args = mock_run.call_args[0][0] call_args = mock_run.call_args[0][0]
assert "--fix" not in call_args assert "--fix" not in call_args
@@ -42,14 +41,14 @@ class TestLintWithRuff:
"""Should lint with ruff.""" """Should lint with ruff."""
with patch("subprocess.run") as mock_run: with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0) mock_run.return_value = MagicMock(returncode=0)
autofmt.lint_with_ruff(tmp_path, fix=True) dev.lint_with_ruff(tmp_path, fix=True)
assert mock_run.called assert mock_run.called
def test_lint_with_ruff_no_fix(self, tmp_path: Path) -> None: def test_lint_with_ruff_no_fix(self, tmp_path: Path) -> None:
"""Should lint with ruff without fix.""" """Should lint with ruff without fix."""
with patch("subprocess.run") as mock_run: with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0) mock_run.return_value = MagicMock(returncode=0)
autofmt.lint_with_ruff(tmp_path, fix=False) dev.lint_with_ruff(tmp_path, fix=False)
# Should not include --fix flag # Should not include --fix flag
call_args = mock_run.call_args[0][0] call_args = mock_run.call_args[0][0]
assert "--fix" not in call_args assert "--fix" not in call_args
@@ -66,7 +65,7 @@ class TestAddDocstring:
py_file = tmp_path / "test.py" py_file = tmp_path / "test.py"
py_file.write_text("def test():\n pass\n") py_file.write_text("def test():\n pass\n")
result = autofmt.add_docstring(py_file, '"""Test module."""') result = dev.add_docstring(py_file, '"""Test module."""')
assert result is True assert result is True
def test_add_docstring_skips_files_with_docstring(self, tmp_path: Path) -> None: def test_add_docstring_skips_files_with_docstring(self, tmp_path: Path) -> None:
@@ -74,7 +73,7 @@ class TestAddDocstring:
py_file = tmp_path / "test.py" py_file = tmp_path / "test.py"
py_file.write_text('"""Existing docstring."""\ndef test():\n pass\n') py_file.write_text('"""Existing docstring."""\ndef test():\n pass\n')
result = autofmt.add_docstring(py_file, '"""New docstring."""') result = dev.add_docstring(py_file, '"""New docstring."""')
assert result is False assert result is False
def test_add_docstring_empty_file(self, tmp_path: Path) -> None: def test_add_docstring_empty_file(self, tmp_path: Path) -> None:
@@ -82,7 +81,7 @@ class TestAddDocstring:
py_file = tmp_path / "test.py" py_file = tmp_path / "test.py"
py_file.write_text("") py_file.write_text("")
result = autofmt.add_docstring(py_file, '"""Test module."""') result = dev.add_docstring(py_file, '"""Test module."""')
# Should handle empty file # Should handle empty file
assert result is True assert result is True
@@ -98,7 +97,7 @@ class TestGenerateModuleDocstring:
py_file = tmp_path / "test.py" py_file = tmp_path / "test.py"
py_file.write_text("def test():\n pass\n") py_file.write_text("def test():\n pass\n")
result = autofmt.generate_module_docstring(py_file) result = dev.generate_module_docstring(py_file)
# Should contain "Tests for" since stem contains "test" # Should contain "Tests for" since stem contains "test"
assert "Tests for" in result assert "Tests for" in result
@@ -108,7 +107,7 @@ class TestGenerateModuleDocstring:
py_file.parent.mkdir(parents=True) py_file.parent.mkdir(parents=True)
py_file.write_text("def test():\n pass\n") py_file.write_text("def test():\n pass\n")
result = autofmt.generate_module_docstring(py_file) result = dev.generate_module_docstring(py_file)
assert "mypackage" in result assert "mypackage" in result
def test_generate_module_docstring_cli(self, tmp_path: Path) -> None: def test_generate_module_docstring_cli(self, tmp_path: Path) -> None:
@@ -116,7 +115,7 @@ class TestGenerateModuleDocstring:
py_file = tmp_path / "cli.py" py_file = tmp_path / "cli.py"
py_file.write_text("def test():\n pass\n") py_file.write_text("def test():\n pass\n")
result = autofmt.generate_module_docstring(py_file) result = dev.generate_module_docstring(py_file)
assert "Command-line interface" in result assert "Command-line interface" in result
def test_generate_module_docstring_util(self, tmp_path: Path) -> None: def test_generate_module_docstring_util(self, tmp_path: Path) -> None:
@@ -124,7 +123,7 @@ class TestGenerateModuleDocstring:
py_file = tmp_path / "utils.py" py_file = tmp_path / "utils.py"
py_file.write_text("def test():\n pass\n") py_file.write_text("def test():\n pass\n")
result = autofmt.generate_module_docstring(py_file) result = dev.generate_module_docstring(py_file)
assert "Utility functions" in result assert "Utility functions" in result
@@ -139,8 +138,8 @@ class TestAutoAddDocstrings:
py_file = tmp_path / "test.py" py_file = tmp_path / "test.py"
py_file.write_text("def test():\n pass\n") py_file.write_text("def test():\n pass\n")
with patch.object(autofmt, "add_docstring", return_value=True): with patch.object(dev, "add_docstring", return_value=True):
count = autofmt.auto_add_docstrings(tmp_path) count = dev.auto_add_docstrings(tmp_path)
assert count >= 0 assert count >= 0
def test_auto_add_docstrings_skips_ignored(self, tmp_path: Path) -> None: def test_auto_add_docstrings_skips_ignored(self, tmp_path: Path) -> None:
@@ -149,7 +148,7 @@ class TestAutoAddDocstrings:
py_file.parent.mkdir() py_file.parent.mkdir()
py_file.write_text("def test():\n pass\n") py_file.write_text("def test():\n pass\n")
count = autofmt.auto_add_docstrings(tmp_path) count = dev.auto_add_docstrings(tmp_path)
# Should skip __pycache__ # Should skip __pycache__
assert count == 0 assert count == 0
@@ -158,7 +157,7 @@ class TestAutoAddDocstrings:
txt_file = tmp_path / "test.txt" txt_file = tmp_path / "test.txt"
txt_file.write_text("test content") txt_file.write_text("test content")
count = autofmt.auto_add_docstrings(tmp_path) count = dev.auto_add_docstrings(tmp_path)
assert count == 0 assert count == 0
@@ -179,7 +178,7 @@ class TestSyncPyprojectConfig:
with patch("subprocess.run") as mock_run: with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0) mock_run.return_value = MagicMock(returncode=0)
autofmt.sync_pyproject_config(tmp_path) dev.sync_pyproject_config(tmp_path)
assert mock_run.called assert mock_run.called
def test_sync_pyproject_config_updates_file(self, tmp_path: Path) -> None: def test_sync_pyproject_config_updates_file(self, tmp_path: Path) -> None:
@@ -193,7 +192,7 @@ class TestSyncPyprojectConfig:
with patch("subprocess.run") as mock_run: with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0) mock_run.return_value = MagicMock(returncode=0)
autofmt.sync_pyproject_config(tmp_path) dev.sync_pyproject_config(tmp_path)
assert mock_run.called assert mock_run.called
@@ -207,95 +206,13 @@ class TestFormatAll:
"""Should run ruff format.""" """Should run ruff format."""
with patch("subprocess.run") as mock_run: with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0) mock_run.return_value = MagicMock(returncode=0)
autofmt.format_all(tmp_path) dev.format_all(tmp_path)
assert mock_run.called assert mock_run.called
def test_format_all_runs_ruff_check(self, tmp_path: Path) -> None: def test_format_all_runs_ruff_check(self, tmp_path: Path) -> None:
"""Should run ruff check.""" """Should run ruff check."""
with patch("subprocess.run") as mock_run: with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0) mock_run.return_value = MagicMock(returncode=0)
autofmt.format_all(tmp_path) dev.format_all(tmp_path)
# Should call ruff format and ruff check # Should call ruff format and ruff check
assert mock_run.call_count == 2 assert mock_run.call_count == 2
# ---------------------------------------------------------------------- #
# main function
# ---------------------------------------------------------------------- #
class TestMain:
"""Test main function."""
def test_main_fmt_default_target(self) -> None:
"""main() should handle fmt command with default target."""
with patch("sys.argv", ["autofmt", "fmt"]), patch.object(px, "run") as mock_run:
autofmt.main()
assert mock_run.called
def test_main_fmt_custom_target(self) -> None:
"""main() should handle fmt command with custom target."""
with patch("sys.argv", ["autofmt", "fmt", "--target", "src"]), patch.object(px, "run") as mock_run:
autofmt.main()
assert mock_run.called
def test_main_lint_default_target(self) -> None:
"""main() should handle lint command with default target."""
with patch("sys.argv", ["autofmt", "lint"]), patch.object(px, "run") as mock_run:
autofmt.main()
assert mock_run.called
def test_main_lint_with_fix(self) -> None:
"""main() should handle lint command with fix."""
with patch("sys.argv", ["autofmt", "lint", "--fix"]), patch.object(px, "run") as mock_run:
autofmt.main()
assert mock_run.called
def test_main_lint_custom_target(self) -> None:
"""main() should handle lint command with custom target."""
with patch("sys.argv", ["autofmt", "lint", "--target", "src"]), patch.object(px, "run") as mock_run:
autofmt.main()
assert mock_run.called
def test_main_doc_default_root(self) -> None:
"""main() should handle doc command with default root."""
with patch("sys.argv", ["autofmt", "doc"]), patch.object(px, "run") as mock_run:
autofmt.main()
assert mock_run.called
def test_main_doc_custom_root(self) -> None:
"""main() should handle doc command with custom root."""
with patch("sys.argv", ["autofmt", "doc", "--root-dir", "src"]), patch.object(px, "run") as mock_run:
autofmt.main()
assert mock_run.called
def test_main_sync_default_root(self) -> None:
"""main() should handle sync command with default root."""
with patch("sys.argv", ["autofmt", "sync"]), patch.object(px, "run") as mock_run:
autofmt.main()
assert mock_run.called
def test_main_sync_custom_root(self) -> None:
"""main() should handle sync command with custom root."""
with patch("sys.argv", ["autofmt", "sync", "--root-dir", "."]), patch.object(px, "run") as mock_run:
autofmt.main()
assert mock_run.called
def test_main_with_no_args_shows_help(self) -> None:
"""main() with no args should show help."""
with patch("sys.argv", ["autofmt"]), patch.object(autofmt, "main"):
# Just call main, it should show help and return
autofmt.main()
# main() should return without calling px.run
assert True
def test_main_creates_task_specs_with_verbose(self) -> None:
"""main() should create TaskSpecs with verbose=True."""
with patch("sys.argv", ["autofmt", "fmt"]), patch.object(px, "run") as mock_run:
autofmt.main()
assert mock_run.called
def test_main_uses_thread_strategy(self) -> None:
"""main() should use thread strategy."""
with patch("sys.argv", ["autofmt", "fmt"]), patch.object(px, "run") as mock_run:
autofmt.main()
# Check that strategy="thread" was used
assert mock_run.called
+144 -270
View File
@@ -1,14 +1,13 @@
"""Tests for cli.bumpversion module.""" """Tests for ops.bumpversion module."""
from __future__ import annotations from __future__ import annotations
import subprocess
from pathlib import Path from pathlib import Path
from unittest.mock import patch
import pytest import pytest
import pyflowx as px from pyflowx.ops import bumpversion
from pyflowx.cli import bumpversion
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -23,296 +22,171 @@ def auto_use_tmp_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
class TestBumpFileVersion: class TestBumpFileVersion:
"""Test bump_file_version function.""" """Test bump_file_version function."""
def test_bump_patch_version(self, tmp_path: Path) -> None: @pytest.mark.parametrize(
"""Should bump patch version correctly.""" ("part", "expected"),
test_file = tmp_path / "pyproject.toml" [("patch", "1.2.4"), ("minor", "1.3.0"), ("major", "2.0.0")],
test_file.write_text('version = "1.2.3"', encoding="utf-8") )
def test_bump_pyproject(self, tmp_path: Path, part: str, expected: str) -> None:
"""pyproject.toml 三种 part 递增."""
f = tmp_path / "pyproject.toml"
f.write_text('version = "1.2.3"', encoding="utf-8")
assert bumpversion.bump_file_version(f, part) == expected # type: ignore[arg-type]
assert f'version = "{expected}"' in f.read_text(encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "patch") @pytest.mark.parametrize(
("part", "expected"),
[("patch", "1.2.4"), ("minor", "1.3.0"), ("major", "2.0.0")],
)
def test_bump_init_py(self, tmp_path: Path, part: str, expected: str) -> None:
"""__init__.py 三种 part 递增."""
f = tmp_path / "__init__.py"
f.write_text('__version__ = "1.2.3"', encoding="utf-8")
assert bumpversion.bump_file_version(f, part) == expected # type: ignore[arg-type]
assert f'__version__ = "{expected}"' in f.read_text(encoding="utf-8")
assert result == "1.2.4" def test_prerelease_and_build_metadata_stripped(self, tmp_path: Path) -> None:
assert test_file.read_text(encoding="utf-8") == 'version = "1.2.4"' """prerelease 和 build metadata 应被清除."""
f = tmp_path / "pyproject.toml"
def test_bump_minor_version(self, tmp_path: Path) -> None: f.write_text('version = "1.2.3-alpha.1+build.123"', encoding="utf-8")
"""Should bump minor version correctly.""" assert bumpversion.bump_file_version(f, "patch") == "1.2.4"
test_file = tmp_path / "pyproject.toml" content = f.read_text(encoding="utf-8")
test_file.write_text('version = "1.2.3"', encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "minor")
assert result == "1.3.0"
assert test_file.read_text(encoding="utf-8") == 'version = "1.3.0"'
def test_bump_major_version(self, tmp_path: Path) -> None:
"""Should bump major version correctly."""
test_file = tmp_path / "pyproject.toml"
test_file.write_text('version = "1.2.3"', encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "major")
assert result == "2.0.0"
assert test_file.read_text(encoding="utf-8") == 'version = "2.0.0"'
def test_version_pattern_with_prerelease(self, tmp_path: Path) -> None:
"""Should handle version with prerelease suffix."""
test_file = tmp_path / "pyproject.toml"
test_file.write_text('version = "1.2.3-alpha.1"', encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "patch")
assert result == "1.2.4"
# 预发布版本应该被清除
content = test_file.read_text(encoding="utf-8")
assert "alpha" not in content assert "alpha" not in content
def test_version_pattern_with_build_metadata(self, tmp_path: Path) -> None:
"""Should handle version with build metadata."""
test_file = tmp_path / "pyproject.toml"
test_file.write_text('version = "1.2.3+build.123"', encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "patch")
assert result == "1.2.4"
# 构建元数据应该被清除
content = test_file.read_text(encoding="utf-8")
assert "build" not in content assert "build" not in content
def test_no_version_found(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: def test_dependencies_not_modified(self, tmp_path: Path) -> None:
"""Should return None when no version pattern found.""" """只更新 project version, 不动 dependencies 中的版本号."""
test_file = tmp_path / "test.txt" f = tmp_path / "pyproject.toml"
test_file.write_text("no version here", encoding="utf-8") f.write_text(
'[project]\nversion = "1.0.0"\ndependencies = ["lib >= 2.0.0", "other >= 3.0.0"]\n',
encoding="utf-8",
)
assert bumpversion.bump_file_version(f, "patch") == "1.0.1"
content = f.read_text(encoding="utf-8")
assert 'version = "1.0.1"' in content
assert "lib >= 2.0.0" in content
assert "other >= 3.0.0" in content
result = bumpversion.bump_file_version(test_file, "patch") def test_no_version_pattern_returns_none(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""未匹配到版本号模式返回 None (支持类型但无版本 / 不支持的文件类型)."""
f1 = tmp_path / "__init__.py"
f1.write_text("# no version here", encoding="utf-8")
assert bumpversion.bump_file_version(f1, "patch") is None
assert "未找到版本号模式" in capsys.readouterr().out
assert result is None f2 = tmp_path / "test.txt"
captured = capsys.readouterr() f2.write_text("no version here", encoding="utf-8")
assert "未找到版本号模式" in captured.out assert bumpversion.bump_file_version(f2, "patch") is None
def test_utf8_encoding(self, tmp_path: Path) -> None:
"""Should handle UTF-8 encoded files correctly."""
test_file = tmp_path / "__init__.py"
test_file.write_text('__version__ = "1.2.3"', encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "patch")
assert result == "1.2.4"
assert test_file.read_text(encoding="utf-8") == '__version__ = "1.2.4"'
def test_pyproject_toml_format(self, tmp_path: Path) -> None:
"""Should handle pyproject.toml format correctly."""
test_file = tmp_path / "pyproject.toml"
content = """
[project]
name = "test"
version = "0.1.0"
description = "Test project"
"""
test_file.write_text(content, encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "minor")
assert result == "0.2.0"
updated = test_file.read_text(encoding="utf-8")
assert 'version = "0.2.0"' in updated
assert 'name = "test"' in updated
def test_init_py_format(self, tmp_path: Path) -> None:
"""Should handle __init__.py format correctly."""
test_file = tmp_path / "__init__.py"
content = '''"""Package info."""
__version__ = "1.0.0"
'''
test_file.write_text(content, encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "major")
assert result == "2.0.0"
updated = test_file.read_text(encoding="utf-8")
assert '__version__ = "2.0.0"' in updated
def test_multiple_versions_in_file(self, tmp_path: Path) -> None:
"""Should only bump the project version, not dependencies."""
test_file = tmp_path / "pyproject.toml"
content = """
[project]
version = "1.0.0"
dependencies = ["lib >= 2.0.0", "other >= 3.0.0"]
"""
test_file.write_text(content, encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "patch")
assert result == "1.0.1"
updated = test_file.read_text(encoding="utf-8")
assert 'version = "1.0.1"' in updated
# 确保 dependencies 中的版本没有被更新
assert "lib >= 2.0.0" in updated
assert "other >= 3.0.0" in updated
def test_file_read_error(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""Should handle file read errors."""
# 创建一个目录而不是文件
test_file = tmp_path / "test_dir"
test_file.mkdir()
def test_read_directory_raises(self, tmp_path: Path) -> None:
"""读取目录 (名为 __init__.py) 应抛异常."""
f = tmp_path / "__init__.py"
f.mkdir()
with pytest.raises(Exception): # noqa: B017 with pytest.raises(Exception): # noqa: B017
bumpversion.bump_file_version(test_file, "patch") bumpversion.bump_file_version(f, "patch")
def test_file_write_error(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: def test_write_failure_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Should handle file write errors.""" """写入失败应抛 OSError."""
# 在只读目录中创建文件(这个测试在某些系统上可能不适用) f = tmp_path / "__init__.py"
test_file = tmp_path / "readonly.toml" f.write_text('__version__ = "1.0.0"', encoding="utf-8")
test_file.write_text('version = "1.0.0"', encoding="utf-8")
# 设置为只读
test_file.chmod(0o444)
try: def raise_oserror(*_args: object, **_kwargs: object) -> None:
with pytest.raises(Exception): # noqa: B017 raise OSError("write failed")
bumpversion.bump_file_version(test_file, "patch")
finally: monkeypatch.setattr(Path, "write_text", raise_oserror)
# 恢复权限以便清理 with pytest.raises(OSError, match="write failed"):
test_file.chmod(0o644) bumpversion.bump_file_version(f, "patch")
# ---------------------------------------------------------------------- # # ---------------------------------------------------------------------- #
# Version pattern tests # bump_project_version (核心 bug 修复: 不同步文件统一同步)
# ---------------------------------------------------------------------- # # ---------------------------------------------------------------------- #
class TestVersionPattern: class TestBumpProjectVersion:
"""Test version pattern matching.""" """Test bump_project_version function."""
def test_simple_version(self, tmp_path: Path) -> None: @staticmethod
"""Should match simple version.""" def _mock_subprocess(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]:
test_file = tmp_path / "__init__.py" """Mock subprocess.run, 返回调用记录列表."""
test_file.write_text('__version__ = "1.0.0"', encoding="utf-8") calls: list[list[str]] = []
result = bumpversion.bump_file_version(test_file, "patch") def fake_run(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[bytes]:
calls.append(cmd)
return subprocess.CompletedProcess(cmd, 0, b"", b"")
assert result == "1.0.1" monkeypatch.setattr(subprocess, "run", fake_run)
return calls
def test_version_with_zeros(self, tmp_path: Path) -> None: def test_unsynced_files_synchronized(
"""Should handle versions with zeros correctly.""" self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
test_file = tmp_path / "__init__.py" ) -> None:
test_file.write_text('__version__ = "0.0.0"', encoding="utf-8") """核心 bug 修复: 不同步的文件应统一同步到同一新版本号.
result = bumpversion.bump_file_version(test_file, "patch") 场景: __init__.py = 0.4.5, pyproject.toml = 0.3.5 (历史不同步)
期望: bump patch 后两者都变为 0.4.6 (取最大值 0.4.5 作为基准 +1)
"""
init_file = tmp_path / "src" / "pkg" / "__init__.py"
init_file.parent.mkdir(parents=True)
init_file.write_text('__version__ = "0.4.5"', encoding="utf-8")
pyproj = tmp_path / "pyproject.toml"
pyproj.write_text('version = "0.3.5"', encoding="utf-8")
assert result == "0.0.1" calls = self._mock_subprocess(monkeypatch)
def test_large_version_numbers(self, tmp_path: Path) -> None: result = bumpversion.bump_project_version("patch")
"""Should handle large version numbers."""
test_file = tmp_path / "__init__.py"
test_file.write_text('__version__ = "10.20.30"', encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "minor") assert result == "0.4.6"
assert '__version__ = "0.4.6"' in init_file.read_text(encoding="utf-8")
assert 'version = "0.4.6"' in pyproj.read_text(encoding="utf-8")
out = capsys.readouterr().out
assert "基准版本: 0.4.5" in out
assert "新版本: 0.4.6" in out
assert result == "10.21.0" add_calls = [c for c in calls if c[:2] == ["git", "add"]]
assert len(add_calls) == 1
# 跨平台: Windows 上 Path 转换为反斜杠, 统一用正斜杠比较
init_path = str(init_file.relative_to(tmp_path)).replace("\\", "/")
assert init_path in [arg.replace("\\", "/") for arg in add_calls[0]]
assert "pyproject.toml" in add_calls[0]
assert "." not in add_calls[0][2:]
def test_version_in_url(self, tmp_path: Path) -> None: tag_calls = [c for c in calls if c[:2] == ["git", "tag"]]
"""Should not match version in URL or other contexts.""" assert len(tag_calls) == 1
test_file = tmp_path / "test.txt" assert "v0.4.6" in tag_calls[0]
test_file.write_text("https://example.com/v1.2.3/download", encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "patch") def test_no_files_returns_none(self, capsys: pytest.CaptureFixture[str]) -> None:
"""无版本号文件返回 None."""
# 不应该匹配 URL 中的版本号 assert bumpversion.bump_project_version("patch") is None
assert result is None
# ---------------------------------------------------------------------- #
# Edge cases
# ---------------------------------------------------------------------- #
class TestEdgeCases:
"""Test edge cases and error handling."""
def test_empty_file(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""Should handle empty file."""
test_file = tmp_path / "empty.txt"
test_file.write_text("", encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "patch")
assert result is None
captured = capsys.readouterr()
assert "未找到版本号模式" in captured.out
def test_file_with_special_chars(self, tmp_path: Path) -> None:
"""Should handle file with special characters."""
test_file = tmp_path / "__init__.py"
content = '# 中文注释\n__version__ = "1.0.0"\n# 特殊字符: @#$%'
test_file.write_text(content, encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "patch")
assert result == "1.0.1"
updated = test_file.read_text(encoding="utf-8")
assert "# 中文注释" in updated
assert "# 特殊字符: @#$%" in updated
def test_consecutive_bumps(self, tmp_path: Path) -> None:
"""Should handle consecutive version bumps correctly."""
test_file = tmp_path / "__init__.py"
test_file.write_text('__version__ = "1.0.0"', encoding="utf-8")
# 第一次 bump
result1 = bumpversion.bump_file_version(test_file, "patch")
assert result1 == "1.0.1"
# 第二次 bump
result2 = bumpversion.bump_file_version(test_file, "minor")
assert result2 == "1.1.0"
# 第三次 bump
result3 = bumpversion.bump_file_version(test_file, "major")
assert result3 == "2.0.0"
# 验证最终结果
assert test_file.read_text(encoding="utf-8") == '__version__ = "2.0.0"'
class TestBumpVersionCli:
"""Test bumpversion CLI."""
def test_minor(self, tmp_path: Path) -> None:
"""Should handle minor version bump."""
test_file = tmp_path / "__init__.py"
test_file.write_text('__version__ = "1.0.0"', encoding="utf-8")
# Mock px.run: 只真正执行第一次调用(版本更新),其余返回空 dict
with patch("sys.argv", ["bumpversion", "minor", "--no-tag"]), patch("pyflowx.run") as mock_run:
def run_side_effect(graph: px.Graph, strategy: str | None = None):
# 执行实际版本更新任务
results = {}
for spec in graph.specs.values():
if spec.fn is not None and spec.args:
results[spec.name] = spec.fn(*spec.args)
return results
mock_run.side_effect = run_side_effect
bumpversion.main()
# 验证版本号已更新
assert test_file.read_text(encoding="utf-8") == '__version__ = "1.1.0"'
def test_no_valid_files(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""Should handle no valid files."""
test_file = tmp_path / "test.txt"
test_file.write_text("这是一个测试文件", encoding="utf-8")
with patch("sys.argv", ["bumpversion", "minor", "--no-tag"]), patch("pyflowx.run") as mock_run:
def run_side_effect(graph: px.Graph, strategy: str | None = None):
# 执行实际版本更新任务
results = {}
for spec in graph.specs.values():
if spec.fn is not None and spec.args:
results[spec.name] = spec.fn(*spec.args)
return results
mock_run.side_effect = run_side_effect
bumpversion.main()
# 验证未更新任何文件
assert test_file.read_text(encoding="utf-8") == "这是一个测试文件"
assert "未找到包含版本号的文件" in capsys.readouterr().out assert "未找到包含版本号的文件" in capsys.readouterr().out
def test_files_without_version_returns_none(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""有文件但所有文件都无版本号返回 None."""
f = tmp_path / "__init__.py"
f.write_text("# no version here", encoding="utf-8")
self._mock_subprocess(monkeypatch)
assert bumpversion.bump_project_version("patch") is None
assert "未能从任何文件读取版本号" in capsys.readouterr().out
def test_no_tag_skips_tag_creation(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""no_tag=True 跳过 tag 创建."""
pyproj = tmp_path / "pyproject.toml"
pyproj.write_text('version = "1.0.0"', encoding="utf-8")
calls = self._mock_subprocess(monkeypatch)
assert bumpversion.bump_project_version("patch", no_tag=True) == "1.0.1"
assert not any(c[:2] == ["git", "tag"] for c in calls)
def test_ignored_dirs_excluded(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
""".venv 等忽略目录中的版本号文件不被处理."""
venv_init = tmp_path / ".venv" / "lib" / "pkg" / "__init__.py"
venv_init.parent.mkdir(parents=True)
venv_init.write_text('__version__ = "0.1.0"', encoding="utf-8")
pyproj = tmp_path / "pyproject.toml"
pyproj.write_text('version = "1.0.0"', encoding="utf-8")
self._mock_subprocess(monkeypatch)
assert bumpversion.bump_project_version("patch") == "1.0.1"
assert venv_init.read_text(encoding="utf-8") == '__version__ = "0.1.0"'
-21
View File
@@ -1,21 +0,0 @@
"""Tests for cli.clearscreen module."""
from __future__ import annotations
from unittest.mock import patch
import pyflowx as px
from pyflowx.cli.system import clearscreen
# ---------------------------------------------------------------------- #
# main function
# ---------------------------------------------------------------------- #
class TestMain:
"""Test main function."""
def test_main_creates_graph_and_runs(self) -> None:
"""main() should create a Graph and run it."""
with patch.object(px, "run") as mock_run:
clearscreen.main()
assert mock_run.called
+210 -182
View File
@@ -125,19 +125,21 @@ class TestEmailDatabase:
# Insert test emails # Insert test emails
for i in range(5): for i in range(5):
db.insert_email({ db.insert_email(
"file_path": f"/test/path{i}.eml", {
"file_hash": f"hash{i}", "file_path": f"/test/path{i}.eml",
"subject": f"Subject {i}", "file_hash": f"hash{i}",
"sender": f"sender{i}@example.com", "subject": f"Subject {i}",
"recipients": "recipient@example.com", "sender": f"sender{i}@example.com",
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000", "recipients": "recipient@example.com",
"date_parsed": f"2024-01-0{i + 1}T12:00:00", "date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
"body_text": f"Body {i}", "date_parsed": f"2024-01-0{i + 1}T12:00:00",
"body_html": f"<p>Body {i}</p>", "body_text": f"Body {i}",
"has_attachments": 0, "body_html": f"<p>Body {i}</p>",
"file_size": 1024, "has_attachments": 0,
}) "file_size": 1024,
}
)
results = db.search_emails(limit=3) results = db.search_emails(limit=3)
assert len(results) == 3 assert len(results) == 3
@@ -148,33 +150,37 @@ class TestEmailDatabase:
db_path = tmp_path / "test.db" db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path) db = emlmanager.EmailDatabase(db_path)
db.insert_email({ db.insert_email(
"file_path": "/test/path1.eml", {
"file_hash": "hash1", "file_path": "/test/path1.eml",
"subject": "Important Meeting", "file_hash": "hash1",
"sender": "sender1@example.com", "subject": "Important Meeting",
"recipients": "recipient@example.com", "sender": "sender1@example.com",
"date": "Mon, 1 Jan 2024 12:00:00 +0000", "recipients": "recipient@example.com",
"date_parsed": "2024-01-01T12:00:00", "date": "Mon, 1 Jan 2024 12:00:00 +0000",
"body_text": "Meeting body", "date_parsed": "2024-01-01T12:00:00",
"body_html": "<p>Meeting body</p>", "body_text": "Meeting body",
"has_attachments": 0, "body_html": "<p>Meeting body</p>",
"file_size": 1024, "has_attachments": 0,
}) "file_size": 1024,
}
)
db.insert_email({ db.insert_email(
"file_path": "/test/path2.eml", {
"file_hash": "hash2", "file_path": "/test/path2.eml",
"subject": "Casual Chat", "file_hash": "hash2",
"sender": "sender2@example.com", "subject": "Casual Chat",
"recipients": "recipient@example.com", "sender": "sender2@example.com",
"date": "Tue, 2 Jan 2024 12:00:00 +0000", "recipients": "recipient@example.com",
"date_parsed": "2024-01-02T12:00:00", "date": "Tue, 2 Jan 2024 12:00:00 +0000",
"body_text": "Chat body", "date_parsed": "2024-01-02T12:00:00",
"body_html": "<p>Chat body</p>", "body_text": "Chat body",
"has_attachments": 0, "body_html": "<p>Chat body</p>",
"file_size": 1024, "has_attachments": 0,
}) "file_size": 1024,
}
)
results = db.search_emails(keyword="Meeting", field="subject") results = db.search_emails(keyword="Meeting", field="subject")
assert len(results) == 1 assert len(results) == 1
@@ -186,33 +192,37 @@ class TestEmailDatabase:
db_path = tmp_path / "test.db" db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path) db = emlmanager.EmailDatabase(db_path)
db.insert_email({ db.insert_email(
"file_path": "/test/path1.eml", {
"file_hash": "hash1", "file_path": "/test/path1.eml",
"subject": "Test", "file_hash": "hash1",
"sender": "alice@example.com", "subject": "Test",
"recipients": "recipient@example.com", "sender": "alice@example.com",
"date": "Mon, 1 Jan 2024 12:00:00 +0000", "recipients": "recipient@example.com",
"date_parsed": "2024-01-01T12:00:00", "date": "Mon, 1 Jan 2024 12:00:00 +0000",
"body_text": "Body", "date_parsed": "2024-01-01T12:00:00",
"body_html": "<p>Body</p>", "body_text": "Body",
"has_attachments": 0, "body_html": "<p>Body</p>",
"file_size": 1024, "has_attachments": 0,
}) "file_size": 1024,
}
)
db.insert_email({ db.insert_email(
"file_path": "/test/path2.eml", {
"file_hash": "hash2", "file_path": "/test/path2.eml",
"subject": "Test", "file_hash": "hash2",
"sender": "bob@example.com", "subject": "Test",
"recipients": "recipient@example.com", "sender": "bob@example.com",
"date": "Tue, 2 Jan 2024 12:00:00 +0000", "recipients": "recipient@example.com",
"date_parsed": "2024-01-02T12:00:00", "date": "Tue, 2 Jan 2024 12:00:00 +0000",
"body_text": "Body", "date_parsed": "2024-01-02T12:00:00",
"body_html": "<p>Body</p>", "body_text": "Body",
"has_attachments": 0, "body_html": "<p>Body</p>",
"file_size": 1024, "has_attachments": 0,
}) "file_size": 1024,
}
)
results = db.search_emails(keyword="alice", field="sender") results = db.search_emails(keyword="alice", field="sender")
assert len(results) == 1 assert len(results) == 1
@@ -224,19 +234,21 @@ class TestEmailDatabase:
db_path = tmp_path / "test.db" db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path) db = emlmanager.EmailDatabase(db_path)
db.insert_email({ db.insert_email(
"file_path": "/test/path1.eml", {
"file_hash": "hash1", "file_path": "/test/path1.eml",
"subject": "Project Update", "file_hash": "hash1",
"sender": "manager@example.com", "subject": "Project Update",
"recipients": "team@example.com", "sender": "manager@example.com",
"date": "Mon, 1 Jan 2024 12:00:00 +0000", "recipients": "team@example.com",
"date_parsed": "2024-01-01T12:00:00", "date": "Mon, 1 Jan 2024 12:00:00 +0000",
"body_text": "Please review the quarterly report", "date_parsed": "2024-01-01T12:00:00",
"body_html": "<p>Please review the quarterly report</p>", "body_text": "Please review the quarterly report",
"has_attachments": 0, "body_html": "<p>Please review the quarterly report</p>",
"file_size": 1024, "has_attachments": 0,
}) "file_size": 1024,
}
)
# Search for keyword in subject # Search for keyword in subject
results = db.search_emails(keyword="Project", field="all") results = db.search_emails(keyword="Project", field="all")
@@ -253,47 +265,53 @@ class TestEmailDatabase:
db = emlmanager.EmailDatabase(db_path) db = emlmanager.EmailDatabase(db_path)
# Insert emails with same subject (different prefixes) # Insert emails with same subject (different prefixes)
db.insert_email({ db.insert_email(
"file_path": "/test/path1.eml", {
"file_hash": "hash1", "file_path": "/test/path1.eml",
"subject": "Meeting Tomorrow", "file_hash": "hash1",
"sender": "sender1@example.com", "subject": "Meeting Tomorrow",
"recipients": "recipient@example.com", "sender": "sender1@example.com",
"date": "Mon, 1 Jan 2024 12:00:00 +0000", "recipients": "recipient@example.com",
"date_parsed": "2024-01-01T12:00:00", "date": "Mon, 1 Jan 2024 12:00:00 +0000",
"body_text": "Body 1", "date_parsed": "2024-01-01T12:00:00",
"body_html": "<p>Body 1</p>", "body_text": "Body 1",
"has_attachments": 0, "body_html": "<p>Body 1</p>",
"file_size": 1024, "has_attachments": 0,
}) "file_size": 1024,
}
)
db.insert_email({ db.insert_email(
"file_path": "/test/path2.eml", {
"file_hash": "hash2", "file_path": "/test/path2.eml",
"subject": "Re: Meeting Tomorrow", "file_hash": "hash2",
"sender": "sender2@example.com", "subject": "Re: Meeting Tomorrow",
"recipients": "recipient@example.com", "sender": "sender2@example.com",
"date": "Tue, 2 Jan 2024 12:00:00 +0000", "recipients": "recipient@example.com",
"date_parsed": "2024-01-02T12:00:00", "date": "Tue, 2 Jan 2024 12:00:00 +0000",
"body_text": "Body 2", "date_parsed": "2024-01-02T12:00:00",
"body_html": "<p>Body 2</p>", "body_text": "Body 2",
"has_attachments": 0, "body_html": "<p>Body 2</p>",
"file_size": 1024, "has_attachments": 0,
}) "file_size": 1024,
}
)
db.insert_email({ db.insert_email(
"file_path": "/test/path3.eml", {
"file_hash": "hash3", "file_path": "/test/path3.eml",
"subject": "Different Topic", "file_hash": "hash3",
"sender": "sender3@example.com", "subject": "Different Topic",
"recipients": "recipient@example.com", "sender": "sender3@example.com",
"date": "Wed, 3 Jan 2024 12:00:00 +0000", "recipients": "recipient@example.com",
"date_parsed": "2024-01-03T12:00:00", "date": "Wed, 3 Jan 2024 12:00:00 +0000",
"body_text": "Body 3", "date_parsed": "2024-01-03T12:00:00",
"body_html": "<p>Body 3</p>", "body_text": "Body 3",
"has_attachments": 0, "body_html": "<p>Body 3</p>",
"file_size": 1024, "has_attachments": 0,
}) "file_size": 1024,
}
)
grouped = db.get_grouped_emails() grouped = db.get_grouped_emails()
# Should have 2 groups: "Meeting Tomorrow" and "Different Topic" # Should have 2 groups: "Meeting Tomorrow" and "Different Topic"
@@ -322,19 +340,21 @@ class TestEmailDatabase:
assert db.get_email_count() == 0 assert db.get_email_count() == 0
for i in range(3): for i in range(3):
db.insert_email({ db.insert_email(
"file_path": f"/test/path{i}.eml", {
"file_hash": f"hash{i}", "file_path": f"/test/path{i}.eml",
"subject": f"Subject {i}", "file_hash": f"hash{i}",
"sender": f"sender{i}@example.com", "subject": f"Subject {i}",
"recipients": "recipient@example.com", "sender": f"sender{i}@example.com",
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000", "recipients": "recipient@example.com",
"date_parsed": f"2024-01-0{i + 1}T12:00:00", "date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
"body_text": f"Body {i}", "date_parsed": f"2024-01-0{i + 1}T12:00:00",
"body_html": f"<p>Body {i}</p>", "body_text": f"Body {i}",
"has_attachments": 0, "body_html": f"<p>Body {i}</p>",
"file_size": 1024, "has_attachments": 0,
}) "file_size": 1024,
}
)
assert db.get_email_count() == 3 assert db.get_email_count() == 3
db.close() db.close()
@@ -346,19 +366,21 @@ class TestEmailDatabase:
# Insert some emails # Insert some emails
for i in range(3): for i in range(3):
db.insert_email({ db.insert_email(
"file_path": f"/test/path{i}.eml", {
"file_hash": f"hash{i}", "file_path": f"/test/path{i}.eml",
"subject": f"Subject {i}", "file_hash": f"hash{i}",
"sender": f"sender{i}@example.com", "subject": f"Subject {i}",
"recipients": "recipient@example.com", "sender": f"sender{i}@example.com",
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000", "recipients": "recipient@example.com",
"date_parsed": f"2024-01-0{i + 1}T12:00:00", "date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
"body_text": f"Body {i}", "date_parsed": f"2024-01-0{i + 1}T12:00:00",
"body_html": f"<p>Body {i}</p>", "body_text": f"Body {i}",
"has_attachments": 0, "body_html": f"<p>Body {i}</p>",
"file_size": 1024, "has_attachments": 0,
}) "file_size": 1024,
}
)
assert db.get_email_count() == 3 assert db.get_email_count() == 3
@@ -687,19 +709,21 @@ class TestEmlManagerHandler:
# Insert some emails # Insert some emails
for i in range(3): for i in range(3):
db.insert_email({ db.insert_email(
"file_path": f"/test/path{i}.eml", {
"file_hash": f"hash{i}", "file_path": f"/test/path{i}.eml",
"subject": f"Subject {i}", "file_hash": f"hash{i}",
"sender": f"sender{i}@example.com", "subject": f"Subject {i}",
"recipients": "recipient@example.com", "sender": f"sender{i}@example.com",
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000", "recipients": "recipient@example.com",
"date_parsed": f"2024-01-0{i + 1}T12:00:00", "date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
"body_text": f"Body {i}", "date_parsed": f"2024-01-0{i + 1}T12:00:00",
"body_html": f"<p>Body {i}</p>", "body_text": f"Body {i}",
"has_attachments": 0, "body_html": f"<p>Body {i}</p>",
"file_size": 1024, "has_attachments": 0,
}) "file_size": 1024,
}
)
# Create a mock handler instance without calling __init__ # Create a mock handler instance without calling __init__
handler = Mock(spec=emlmanager.EmlManagerHandler) handler = Mock(spec=emlmanager.EmlManagerHandler)
@@ -721,19 +745,21 @@ class TestEmlManagerHandler:
db = emlmanager.EmailDatabase(db_path) db = emlmanager.EmailDatabase(db_path)
# Insert test email # Insert test email
db.insert_email({ db.insert_email(
"file_path": "/test/path.eml", {
"file_hash": "hash", "file_path": "/test/path.eml",
"subject": "Test Subject", "file_hash": "hash",
"sender": "sender@example.com", "subject": "Test Subject",
"recipients": "recipient@example.com", "sender": "sender@example.com",
"date": "Mon, 1 Jan 2024 12:00:00 +0000", "recipients": "recipient@example.com",
"date_parsed": "2024-01-01T12:00:00", "date": "Mon, 1 Jan 2024 12:00:00 +0000",
"body_text": "Test body", "date_parsed": "2024-01-01T12:00:00",
"body_html": "<p>Test body</p>", "body_text": "Test body",
"has_attachments": 0, "body_html": "<p>Test body</p>",
"file_size": 1024, "has_attachments": 0,
}) "file_size": 1024,
}
)
# Create a mock handler instance without calling __init__ # Create a mock handler instance without calling __init__
handler = Mock(spec=emlmanager.EmlManagerHandler) handler = Mock(spec=emlmanager.EmlManagerHandler)
@@ -756,19 +782,21 @@ class TestEmlManagerHandler:
db = emlmanager.EmailDatabase(db_path) db = emlmanager.EmailDatabase(db_path)
# Insert test email # Insert test email
db.insert_email({ db.insert_email(
"file_path": "/test/path.eml", {
"file_hash": "hash", "file_path": "/test/path.eml",
"subject": "Test Subject", "file_hash": "hash",
"sender": "sender@example.com", "subject": "Test Subject",
"recipients": "recipient@example.com", "sender": "sender@example.com",
"date": "Mon, 1 Jan 2024 12:00:00 +0000", "recipients": "recipient@example.com",
"date_parsed": "2024-01-01T12:00:00", "date": "Mon, 1 Jan 2024 12:00:00 +0000",
"body_text": "Test body", "date_parsed": "2024-01-01T12:00:00",
"body_html": "<p>Test body</p>", "body_text": "Test body",
"has_attachments": 0, "body_html": "<p>Test body</p>",
"file_size": 1024, "has_attachments": 0,
}) "file_size": 1024,
}
)
assert db.get_email_count() == 1 assert db.get_email_count() == 1
+389
View File
@@ -0,0 +1,389 @@
"""Tests for ops.dev 模块 envdev/dockercmd 函数 (镜像源配置/Docker 登录)."""
from __future__ import annotations
import subprocess
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from pyflowx.conditions import Constants
from pyflowx.ops.dev import (
docker_login_tencent,
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,
)
# ---------------------------------------------------------------------- #
# setup_python_mirror
# ---------------------------------------------------------------------- #
class TestSetupPythonMirror:
"""``setup_python_mirror`` 函数测试."""
def test_unknown_mirror_skips(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
"""未知镜像源应打印提示并跳过."""
monkeypatch.setattr(subprocess, "run", lambda *_, **__: MagicMock())
setup_python_mirror("unknown_mirror")
captured = capsys.readouterr()
assert "未知 Python 镜像源" in captured.out
def test_known_mirror_writes_config(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""已知镜像源应写入配置文件."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
setup_python_mirror("tsinghua")
# Linux 平台默认配置路径
config_path = tmp_path / ".pip" / "pip.conf"
if not config_path.exists():
config_path = tmp_path / "pip" / "pip.ini"
assert config_path.exists()
content = config_path.read_text(encoding="utf-8")
assert "pypi.tuna.tsinghua.edu.cn" in content
def test_linux_uses_pip_conf(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Linux 平台应写入 ~/.pip/pip.conf."""
monkeypatch.setattr(Constants, "IS_LINUX", True)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
setup_python_mirror("tsinghua")
config_path = tmp_path / ".pip" / "pip.conf"
assert config_path.exists()
def test_non_linux_uses_pip_ini(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""非 Linux 平台应写入 ~/pip/pip.ini."""
monkeypatch.setattr(Constants, "IS_LINUX", False)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
setup_python_mirror("tsinghua")
config_path = tmp_path / "pip" / "pip.ini"
assert config_path.exists()
def test_sets_env_vars(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""应设置 PIP_INDEX_URL 等环境变量."""
import os
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setattr(os, "environ", {})
setup_python_mirror("aliyun")
assert "PIP_INDEX_URL" in os.environ
assert "mirrors.aliyun.com" in os.environ["PIP_INDEX_URL"]
# ---------------------------------------------------------------------- #
# setup_conda_mirror
# ---------------------------------------------------------------------- #
class TestSetupCondaMirror:
"""``setup_conda_mirror`` 函数测试."""
def test_unknown_mirror_skips(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
"""未知镜像源应跳过."""
setup_conda_mirror("unknown")
captured = capsys.readouterr()
assert "未知 Conda 镜像源" in captured.out
def test_known_mirror_writes_condarc(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""已知镜像源应写入 ~/.condarc."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
setup_conda_mirror("tsinghua")
condarc = tmp_path / ".condarc"
assert condarc.exists()
content = condarc.read_text(encoding="utf-8")
assert "tsinghua" in content
assert "channels:" in content
# ---------------------------------------------------------------------- #
# setup_rust_mirror
# ---------------------------------------------------------------------- #
class TestSetupRustMirror:
"""``setup_rust_mirror`` 函数测试."""
def test_unknown_mirror_skips(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
"""未知镜像源应跳过."""
setup_rust_mirror("unknown")
captured = capsys.readouterr()
assert "未知 Rust 镜像源" in captured.out
def test_known_mirror_writes_config(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""已知镜像源应写入 ~/.cargo/config.toml."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
setup_rust_mirror("ustc", "nightly")
config = tmp_path / ".cargo" / "config.toml"
assert config.exists()
content = config.read_text(encoding="utf-8")
assert "ustc" in content
def test_creates_sccache_dir(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""应创建 sccache 缓存目录."""
from pyflowx.ops import dev as dev_module
fake_sccache = tmp_path / ".cargo" / "sccache"
monkeypatch.setattr(dev_module, "_RUST_SCCACHE_DIR", fake_sccache)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
setup_rust_mirror("tsinghua")
assert fake_sccache.exists()
# ---------------------------------------------------------------------- #
# docker_login_tencent
# ---------------------------------------------------------------------- #
class TestDockerLoginTencent:
"""``docker_login_tencent`` 函数测试."""
def test_default_username_uses_getpass(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""未提供 username 时应使用 getpass.getuser."""
monkeypatch.setattr("getpass.getuser", lambda: "testuser")
ran_cmds: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
docker_login_tencent()
assert ran_cmds[0][0] == "docker"
assert ran_cmds[0][1] == "login"
assert "testuser" in ran_cmds[0]
assert "ccr.ccs.tencentyun.com" in ran_cmds[0]
def test_custom_username(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""提供 username 时应使用自定义用户名."""
ran_cmds: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
docker_login_tencent("myuser")
assert "myuser" in ran_cmds[0]
# ---------------------------------------------------------------------- #
# setup_linux_system_mirror
# ---------------------------------------------------------------------- #
class TestSetupLinuxSystemMirror:
"""``setup_linux_system_mirror`` 函数测试."""
def test_non_linux_skips(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
"""非 Linux 平台应跳过."""
monkeypatch.setattr(Constants, "IS_LINUX", False)
called: list[str] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: called.append(cmd))
setup_linux_system_mirror()
captured = capsys.readouterr()
assert "仅在 Linux 上执行" in captured.out
assert called == []
def test_linux_already_configured_skips(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""Linux 上已配置国内镜像时应跳过."""
monkeypatch.setattr(Constants, "IS_LINUX", True)
def fake_read_text(self: Path, encoding: str = "utf-8") -> str:
return "tsinghua mirror configured"
monkeypatch.setattr(Path, "read_text", fake_read_text)
called: list[str] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: called.append(cmd))
setup_linux_system_mirror()
captured = capsys.readouterr()
assert "已配置" in captured.out
assert called == []
def test_linux_not_configured_runs_script(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""Linux 上未配置镜像时应执行下载与安装脚本."""
monkeypatch.setattr(Constants, "IS_LINUX", True)
def fake_read_text(self: Path, encoding: str = "utf-8") -> str:
raise OSError("file not found")
monkeypatch.setattr(Path, "read_text", fake_read_text)
ran_cmds: list[str] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
setup_linux_system_mirror()
captured = capsys.readouterr()
assert "下载" in captured.out
assert "安装" in captured.out
assert len(ran_cmds) == 2
def test_linux_content_without_mirror_runs_script(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""Linux 上文件存在但不包含镜像关键词时应执行脚本."""
monkeypatch.setattr(Constants, "IS_LINUX", True)
def fake_read_text(self: Path, encoding: str = "utf-8") -> str:
return "deb http://archive.ubuntu.com/ubuntu/ jammy main"
monkeypatch.setattr(Path, "read_text", fake_read_text)
ran_cmds: list[str] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
setup_linux_system_mirror()
captured = capsys.readouterr()
assert "下载" in captured.out
assert len(ran_cmds) == 2
# ---------------------------------------------------------------------- #
# install_linux_qt_libs / install_linux_fonts / install_linux_docker
# ---------------------------------------------------------------------- #
class TestLinuxInstallers:
"""Linux 专用安装函数测试."""
def test_qt_libs_non_linux_skips(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
"""非 Linux 上 install_linux_qt_libs 应跳过."""
monkeypatch.setattr(Constants, "IS_LINUX", False)
called: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: called.append(cmd))
install_linux_qt_libs()
captured = capsys.readouterr()
assert "仅在 Linux" in captured.out
assert called == []
def test_qt_libs_linux_runs_apt(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Linux 上应执行 apt install."""
monkeypatch.setattr(Constants, "IS_LINUX", True)
ran_cmds: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
install_linux_qt_libs()
assert ran_cmds[0][0] == "sudo"
assert "apt" in ran_cmds[0]
assert "install" in ran_cmds[0]
def test_fonts_non_linux_skips(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
"""非 Linux 上 install_linux_fonts 应跳过."""
monkeypatch.setattr(Constants, "IS_LINUX", False)
called: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: called.append(cmd))
install_linux_fonts()
captured = capsys.readouterr()
assert "仅在 Linux" in captured.out
assert called == []
def test_fonts_linux_runs_apt(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Linux 上应执行 apt install 字体包."""
monkeypatch.setattr(Constants, "IS_LINUX", True)
ran_cmds: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
install_linux_fonts()
assert "fonts-noto-cjk" in ran_cmds[0]
def test_docker_non_linux_skips(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
"""非 Linux 上 install_linux_docker 应跳过."""
monkeypatch.setattr(Constants, "IS_LINUX", False)
called: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: called.append(cmd))
install_linux_docker()
captured = capsys.readouterr()
assert "仅在 Linux" in captured.out
assert called == []
def test_docker_linux_runs_install_and_usermod(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Linux 上应执行 apt install docker-compose-v2 和 usermod."""
monkeypatch.setattr(Constants, "IS_LINUX", True)
monkeypatch.setattr("getpass.getuser", lambda: "testuser")
ran_cmds: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
install_linux_docker()
assert any("docker-compose-v2" in cmd for cmd in ran_cmds)
assert any("usermod" in cmd and "docker" in cmd for cmd in ran_cmds)
# ---------------------------------------------------------------------- #
# download_rustup_script
# ---------------------------------------------------------------------- #
class TestDownloadRustupScript:
"""``download_rustup_script`` 函数测试."""
def test_rustup_installed_skips(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
"""rustup 已安装时应跳过."""
monkeypatch.setattr("shutil.which", lambda _cmd: "/usr/bin/rustup")
called: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: called.append(cmd))
download_rustup_script()
captured = capsys.readouterr()
assert "已安装" in captured.out
assert called == []
def test_windows_downloads_exe(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Windows 上应下载 rustup-init.exe."""
monkeypatch.setattr("shutil.which", lambda _cmd: None)
monkeypatch.setattr(Constants, "IS_WINDOWS", True)
ran_cmds: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
download_rustup_script()
assert "powershell" in ran_cmds[0]
assert "rustup-init.exe" in ran_cmds[0]
def test_non_windows_downloads_sh(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""非 Windows 上应下载 rustup-init.sh."""
monkeypatch.setattr("shutil.which", lambda _cmd: None)
monkeypatch.setattr(Constants, "IS_WINDOWS", False)
ran_cmds: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
download_rustup_script()
assert "curl" in ran_cmds[0]
assert "rustup-init.sh" in ran_cmds[0]
# ---------------------------------------------------------------------- #
# install_rust_toolchain
# ---------------------------------------------------------------------- #
class TestInstallRustToolchain:
"""``install_rust_toolchain`` 函数测试."""
def test_rustup_not_installed_skips(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""rustup 未安装时应跳过."""
monkeypatch.setattr("shutil.which", lambda _cmd: None)
called: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: called.append(cmd))
install_rust_toolchain()
captured = capsys.readouterr()
assert "未安装" in captured.out
assert called == []
def test_rustup_installed_runs_toolchain_install(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""rustup 已安装时应执行 toolchain install."""
monkeypatch.setattr("shutil.which", lambda _cmd: "/usr/bin/rustup")
ran_cmds: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
install_rust_toolchain("nightly")
assert ran_cmds == [["rustup", "toolchain", "install", "nightly"]]
def test_default_version_stable(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""默认版本应为 stable."""
monkeypatch.setattr("shutil.which", lambda _cmd: "/usr/bin/rustup")
ran_cmds: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
install_rust_toolchain()
assert "stable" in ran_cmds[0]
+10 -43
View File
@@ -3,10 +3,8 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from unittest.mock import patch
import pyflowx as px from pyflowx.ops import files
from pyflowx.cli import filedate
# ---------------------------------------------------------------------- # # ---------------------------------------------------------------------- #
@@ -20,7 +18,7 @@ class TestGetFileTimestamp:
test_file = tmp_path / "test.txt" test_file = tmp_path / "test.txt"
test_file.write_text("test content") test_file.write_text("test content")
timestamp = filedate.get_file_timestamp(test_file) timestamp = files.get_file_timestamp(test_file)
assert len(timestamp) == 8 # YYYYMMDD format assert len(timestamp) == 8 # YYYYMMDD format
assert timestamp.isdigit() assert timestamp.isdigit()
@@ -36,7 +34,7 @@ class TestRemoveDatePrefix:
test_file = tmp_path / "20240101_test.txt" test_file = tmp_path / "20240101_test.txt"
test_file.write_text("test content") test_file.write_text("test content")
new_path = filedate.remove_date_prefix(test_file) new_path = files.remove_date_prefix(test_file)
assert new_path.name == "test.txt" assert new_path.name == "test.txt"
def test_remove_date_prefix_without_date(self, tmp_path: Path) -> None: def test_remove_date_prefix_without_date(self, tmp_path: Path) -> None:
@@ -44,7 +42,7 @@ class TestRemoveDatePrefix:
test_file = tmp_path / "test.txt" test_file = tmp_path / "test.txt"
test_file.write_text("test content") test_file.write_text("test content")
new_path = filedate.remove_date_prefix(test_file) new_path = files.remove_date_prefix(test_file)
assert new_path == test_file assert new_path == test_file
@@ -59,7 +57,7 @@ class TestAddDatePrefix:
test_file = tmp_path / "test.txt" test_file = tmp_path / "test.txt"
test_file.write_text("test content") test_file.write_text("test content")
new_path = filedate.add_date_prefix(test_file) new_path = files.add_date_prefix(test_file)
assert new_path.name.startswith("20") # Starts with year assert new_path.name.startswith("20") # Starts with year
assert "_test.txt" in new_path.name assert "_test.txt" in new_path.name
@@ -75,7 +73,7 @@ class TestProcessFileDate:
test_file = tmp_path / "test.txt" test_file = tmp_path / "test.txt"
test_file.write_text("test content") test_file.write_text("test content")
filedate.process_file_date(test_file, clear=False) files.process_file_date(test_file, clear=False)
# File should be renamed with date prefix # File should be renamed with date prefix
def test_process_file_date_clear(self, tmp_path: Path) -> None: def test_process_file_date_clear(self, tmp_path: Path) -> None:
@@ -83,7 +81,7 @@ class TestProcessFileDate:
test_file = tmp_path / "20240101_test.txt" test_file = tmp_path / "20240101_test.txt"
test_file.write_text("test content") test_file.write_text("test content")
filedate.process_file_date(test_file, clear=True) files.process_file_date(test_file, clear=True)
# File should be renamed without date prefix # File should be renamed without date prefix
@@ -95,42 +93,11 @@ class TestProcessFilesDate:
def test_process_files_date_batch(self, tmp_path: Path) -> None: def test_process_files_date_batch(self, tmp_path: Path) -> None:
"""Should process multiple files.""" """Should process multiple files."""
files = [] file_list = []
for i in range(3): for i in range(3):
test_file = tmp_path / f"test{i}.txt" test_file = tmp_path / f"test{i}.txt"
test_file.write_text(f"content{i}") test_file.write_text(f"content{i}")
files.append(test_file) file_list.append(test_file)
filedate.process_files_date(files, clear=False) files.process_files_date(file_list, clear=False)
# All files should be processed # All files should be processed
# ---------------------------------------------------------------------- #
# main function
# ---------------------------------------------------------------------- #
class TestMain:
"""Test main function."""
def test_main_add_command(self, tmp_path: Path) -> None:
"""main() should handle add command."""
test_file = tmp_path / "test.txt"
test_file.write_text("test content")
with patch("sys.argv", ["filedate", "add", str(test_file)]), patch.object(px, "run") as mock_run:
filedate.main()
assert mock_run.called
def test_main_clear_command(self, tmp_path: Path) -> None:
"""main() should handle clear command."""
test_file = tmp_path / "20240101_test.txt"
test_file.write_text("test content")
with patch("sys.argv", ["filedate", "clear", str(test_file)]), patch.object(px, "run") as mock_run:
filedate.main()
assert mock_run.called
def test_main_with_no_args_shows_help(self) -> None:
"""main() with no args should show help."""
with patch("sys.argv", ["filedate"]):
filedate.main()
# Should print help and return
+12 -49
View File
@@ -3,10 +3,8 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from unittest.mock import patch
import pyflowx as px from pyflowx.ops import files
from pyflowx.cli import filelevel
# ---------------------------------------------------------------------- # # ---------------------------------------------------------------------- #
@@ -18,19 +16,19 @@ class TestRemoveMarks:
def test_remove_marks_single_mark(self) -> None: def test_remove_marks_single_mark(self) -> None:
"""Should remove single mark.""" """Should remove single mark."""
stem = "filename(PUB)" stem = "filename(PUB)"
result = filelevel.remove_marks(stem, ["PUB"]) result = files.remove_marks(stem, ["PUB"])
assert result == "filename" assert result == "filename"
def test_remove_marks_multiple_marks(self) -> None: def test_remove_marks_multiple_marks(self) -> None:
"""Should remove multiple marks.""" """Should remove multiple marks."""
stem = "filename(PUB)(NOR)" stem = "filename(PUB)(NOR)"
result = filelevel.remove_marks(stem, ["PUB", "NOR"]) result = files.remove_marks(stem, ["PUB", "NOR"])
assert result == "filename" assert result == "filename"
def test_remove_marks_no_marks(self) -> None: def test_remove_marks_no_marks(self) -> None:
"""Should not change stem without marks.""" """Should not change stem without marks."""
stem = "filename" stem = "filename"
result = filelevel.remove_marks(stem, ["PUB"]) result = files.remove_marks(stem, ["PUB"])
assert result == "filename" assert result == "filename"
@@ -45,7 +43,7 @@ class TestProcessFileLevel:
test_file = tmp_path / "test.txt" test_file = tmp_path / "test.txt"
test_file.write_text("test content") test_file.write_text("test content")
filelevel.process_file_level(test_file, level=1) files.process_file_level(test_file, level=1)
# File should be renamed with PUB level # File should be renamed with PUB level
def test_process_file_level_set_int(self, tmp_path: Path) -> None: def test_process_file_level_set_int(self, tmp_path: Path) -> None:
@@ -53,7 +51,7 @@ class TestProcessFileLevel:
test_file = tmp_path / "test.txt" test_file = tmp_path / "test.txt"
test_file.write_text("test content") test_file.write_text("test content")
filelevel.process_file_level(test_file, level=2) files.process_file_level(test_file, level=2)
# File should be renamed with INT level # File should be renamed with INT level
def test_process_file_level_clear(self, tmp_path: Path) -> None: def test_process_file_level_clear(self, tmp_path: Path) -> None:
@@ -61,7 +59,7 @@ class TestProcessFileLevel:
test_file = tmp_path / "test(PUB).txt" test_file = tmp_path / "test(PUB).txt"
test_file.write_text("test content") test_file.write_text("test content")
filelevel.process_file_level(test_file, level=0) files.process_file_level(test_file, level=0)
# File should be renamed without level # File should be renamed without level
def test_process_file_level_invalid_level(self, tmp_path: Path) -> None: def test_process_file_level_invalid_level(self, tmp_path: Path) -> None:
@@ -69,14 +67,14 @@ class TestProcessFileLevel:
test_file = tmp_path / "test.txt" test_file = tmp_path / "test.txt"
test_file.write_text("test content") test_file.write_text("test content")
filelevel.process_file_level(test_file, level=5) files.process_file_level(test_file, level=5)
# Should print error message # Should print error message
def test_process_file_level_nonexistent_file(self, tmp_path: Path) -> None: def test_process_file_level_nonexistent_file(self, tmp_path: Path) -> None:
"""Should handle nonexistent file.""" """Should handle nonexistent file."""
test_file = tmp_path / "nonexistent.txt" test_file = tmp_path / "nonexistent.txt"
filelevel.process_file_level(test_file, level=1) files.process_file_level(test_file, level=1)
# Should print error message # Should print error message
@@ -88,46 +86,11 @@ class TestProcessFilesLevel:
def test_process_files_level_batch(self, tmp_path: Path) -> None: def test_process_files_level_batch(self, tmp_path: Path) -> None:
"""Should process multiple files.""" """Should process multiple files."""
files = [] file_list = []
for i in range(3): for i in range(3):
test_file = tmp_path / f"test{i}.txt" test_file = tmp_path / f"test{i}.txt"
test_file.write_text(f"content{i}") test_file.write_text(f"content{i}")
files.append(test_file) file_list.append(test_file)
filelevel.process_files_level(files, level=1) files.process_files_level(file_list, level=1)
# All files should be processed # All files should be processed
# ---------------------------------------------------------------------- #
# main function
# ---------------------------------------------------------------------- #
class TestMain:
"""Test main function."""
def test_main_set_command(self, tmp_path: Path) -> None:
"""main() should handle set command."""
test_file = tmp_path / "test.txt"
test_file.write_text("test content")
with patch("sys.argv", ["filelevel", "set", str(test_file), "--level", "1"]), patch.object(
px, "run"
) as mock_run:
filelevel.main()
assert mock_run.called
def test_main_set_command_level_2(self, tmp_path: Path) -> None:
"""main() should handle set command with level 2."""
test_file = tmp_path / "test.txt"
test_file.write_text("test content")
with patch("sys.argv", ["filelevel", "set", str(test_file), "--level", "2"]), patch.object(
px, "run"
) as mock_run:
filelevel.main()
assert mock_run.called
def test_main_with_no_args_shows_help(self) -> None:
"""main() with no args should show help."""
with patch("sys.argv", ["filelevel"]):
filelevel.main()
# Should print help and return
+19 -33
View File
@@ -5,8 +5,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import pyflowx as px from pyflowx.ops import files
from pyflowx.cli import folderback
# ---------------------------------------------------------------------- # # ---------------------------------------------------------------------- #
@@ -22,7 +21,7 @@ class TestRemoveDump:
dst = tmp_path / "backup" dst = tmp_path / "backup"
dst.mkdir() dst.mkdir()
folderback.remove_dump(src, dst, 5) files.remove_dump(src, dst, 5)
# Should not raise error # Should not raise error
def test_remove_dump_within_limit(self, tmp_path: Path) -> None: def test_remove_dump_within_limit(self, tmp_path: Path) -> None:
@@ -37,7 +36,7 @@ class TestRemoveDump:
zip_file = dst / f"source_20240101_12000{i}.zip" zip_file = dst / f"source_20240101_12000{i}.zip"
zip_file.write_bytes(b"ZIP content") zip_file.write_bytes(b"ZIP content")
folderback.remove_dump(src, dst, 5) files.remove_dump(src, dst, 5)
# All files should remain # All files should remain
assert len(list(dst.glob("*.zip"))) == 3 assert len(list(dst.glob("*.zip"))) == 3
@@ -53,7 +52,7 @@ class TestRemoveDump:
zip_file = dst / f"source_20240101_12000{i}.zip" zip_file = dst / f"source_20240101_12000{i}.zip"
zip_file.write_bytes(b"ZIP content") zip_file.write_bytes(b"ZIP content")
folderback.remove_dump(src, dst, 5) files.remove_dump(src, dst, 5)
# Should have only 5 files # Should have only 5 files
assert len(list(dst.glob("*.zip"))) == 5 assert len(list(dst.glob("*.zip"))) == 5
@@ -73,7 +72,7 @@ class TestZipTarget:
dst.mkdir() dst.mkdir()
with patch("time.strftime", return_value="_20240101_120000"): with patch("time.strftime", return_value="_20240101_120000"):
folderback.zip_target(src, dst, 5) files.zip_target(src, dst, 5)
# Should create zip file # Should create zip file
zip_files = list(dst.glob("*.zip")) zip_files = list(dst.glob("*.zip"))
@@ -91,7 +90,7 @@ class TestZipTarget:
dst.mkdir() dst.mkdir()
with patch("time.strftime", return_value="_20240101_120000"): with patch("time.strftime", return_value="_20240101_120000"):
folderback.zip_target(src, dst, 5) files.zip_target(src, dst, 5)
# Should create zip file # Should create zip file
zip_files = list(dst.glob("*.zip")) zip_files = list(dst.glob("*.zip"))
@@ -111,8 +110,8 @@ class TestBackupFolder:
(source_dir / "test.txt").write_text("test content") (source_dir / "test.txt").write_text("test content")
backup_dir = tmp_path / "backup" backup_dir = tmp_path / "backup"
with patch.object(folderback, "zip_target") as mock_zip: with patch.object(files, "zip_target") as mock_zip:
folderback.backup_folder(str(source_dir), str(backup_dir), 5) files.backup_folder(str(source_dir), str(backup_dir), 5)
assert mock_zip.called assert mock_zip.called
def test_backup_folder_with_max_backups(self, tmp_path: Path) -> None: def test_backup_folder_with_max_backups(self, tmp_path: Path) -> None:
@@ -122,8 +121,8 @@ class TestBackupFolder:
(source_dir / "test.txt").write_text("test content") (source_dir / "test.txt").write_text("test content")
backup_dir = tmp_path / "backup" backup_dir = tmp_path / "backup"
with patch.object(folderback, "zip_target") as mock_zip: with patch.object(files, "zip_target") as mock_zip:
folderback.backup_folder(str(source_dir), str(backup_dir), 10) files.backup_folder(str(source_dir), str(backup_dir), 10)
assert mock_zip.called assert mock_zip.called
def test_backup_folder_source_not_exists(self, tmp_path: Path) -> None: def test_backup_folder_source_not_exists(self, tmp_path: Path) -> None:
@@ -132,7 +131,7 @@ class TestBackupFolder:
backup_dir = tmp_path / "backup" backup_dir = tmp_path / "backup"
backup_dir.mkdir() backup_dir.mkdir()
folderback.backup_folder(str(source_dir), str(backup_dir), 5) files.backup_folder(str(source_dir), str(backup_dir), 5)
# Should print error message and return # Should print error message and return
def test_backup_folder_creates_dst(self, tmp_path: Path) -> None: def test_backup_folder_creates_dst(self, tmp_path: Path) -> None:
@@ -142,32 +141,19 @@ class TestBackupFolder:
(source_dir / "test.txt").write_text("test content") (source_dir / "test.txt").write_text("test content")
backup_dir = tmp_path / "backup" backup_dir = tmp_path / "backup"
with patch.object(folderback, "zip_target") as mock_zip: with patch.object(files, "zip_target") as mock_zip:
folderback.backup_folder(str(source_dir), str(backup_dir), 5) files.backup_folder(str(source_dir), str(backup_dir), 5)
assert backup_dir.exists() assert backup_dir.exists()
assert mock_zip.called assert mock_zip.called
# ---------------------------------------------------------------------- # # ---------------------------------------------------------------------- #
# TaskSpec definitions # 函数注册
# ---------------------------------------------------------------------- # # ---------------------------------------------------------------------- #
class TestTaskSpecDefinitions: class TestRegisteredFunctions:
"""Test that all TaskSpec definitions are valid.""" """Test that folderback functions are registered."""
def test_folderback_default_spec(self) -> None: def test_folderback_default_spec(self) -> None:
"""folderback_default spec should be properly defined.""" """folderback_default should be a registered callable."""
assert folderback.folderback_default.name == "folderback_default" # folderback_default 现在是通过 @px.register_fn 注册的普通函数, 不是 TaskSpec
assert folderback.folderback_default.fn is not None assert callable(files.folderback_default)
# ---------------------------------------------------------------------- #
# main function
# ---------------------------------------------------------------------- #
class TestMain:
"""Test main function."""
def test_main_calls_run_cli(self) -> None:
"""main() should create a CliRunner and call run_cli()."""
with patch.object(px.CliRunner, "run_cli") as mock_run_cli:
folderback.main()
assert mock_run_cli.called
+11 -25
View File
@@ -5,8 +5,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import pyflowx as px from pyflowx.ops import files
from pyflowx.cli import folderzip
# ---------------------------------------------------------------------- # # ---------------------------------------------------------------------- #
@@ -22,7 +21,7 @@ class TestArchiveFolder:
(folder / "test.txt").write_text("test content") (folder / "test.txt").write_text("test content")
with patch("shutil.make_archive") as mock_archive: with patch("shutil.make_archive") as mock_archive:
folderzip.archive_folder(folder) files.archive_folder(folder)
assert mock_archive.called assert mock_archive.called
@@ -39,37 +38,24 @@ class TestZipFolders:
(tmp_path / "folder2").mkdir() (tmp_path / "folder2").mkdir()
(tmp_path / ".git").mkdir() # Should be ignored (tmp_path / ".git").mkdir() # Should be ignored
with patch.object(folderzip, "archive_folder") as mock_archive: with patch.object(files, "archive_folder") as mock_archive:
folderzip.zip_folders(str(tmp_path)) files.zip_folders(str(tmp_path))
# Should archive folder1 and folder2, but not .git # Should archive folder1 and folder2, but not .git
assert mock_archive.call_count == 2 assert mock_archive.call_count == 2
def test_zip_folders_nonexistent_cwd(self, tmp_path: Path) -> None: def test_zip_folders_nonexistent_cwd(self, tmp_path: Path) -> None:
"""Should handle nonexistent cwd.""" """Should handle nonexistent cwd."""
folderzip.zip_folders(str(tmp_path / "nonexistent")) files.zip_folders(str(tmp_path / "nonexistent"))
# Should print error message and return # Should print error message and return
# ---------------------------------------------------------------------- # # ---------------------------------------------------------------------- #
# TaskSpec definitions # 函数注册
# ---------------------------------------------------------------------- # # ---------------------------------------------------------------------- #
class TestTaskSpecDefinitions: class TestRegisteredFunctions:
"""Test that all TaskSpec definitions are valid.""" """Test that folderzip functions are registered."""
def test_folderzip_default_spec(self) -> None: def test_folderzip_default_spec(self) -> None:
"""folderzip_default spec should be properly defined.""" """folderzip_default should be a registered callable."""
assert folderzip.folderzip_default.name == "folderzip_default" # folderzip_default 现在是通过 @px.register_fn 注册的普通函数, 不是 TaskSpec
assert folderzip.folderzip_default.fn is not None assert callable(files.folderzip_default)
# ---------------------------------------------------------------------- #
# main function
# ---------------------------------------------------------------------- #
class TestMain:
"""Test main function."""
def test_main_calls_run_cli(self) -> None:
"""main() should create a CliRunner and call run_cli()."""
with patch.object(px.CliRunner, "run_cli") as mock_run_cli:
folderzip.main()
assert mock_run_cli.called
+23 -66
View File
@@ -8,7 +8,7 @@ from unittest.mock import patch
import pytest import pytest
import pyflowx as px import pyflowx as px
from pyflowx.cli import gittool from pyflowx.ops import dev
# ---------------------------------------------------------------------- # # ---------------------------------------------------------------------- #
@@ -20,7 +20,7 @@ class TestNotHasGitRepo:
def test_not_has_git_repo_true(self, tmp_path: Path) -> None: def test_not_has_git_repo_true(self, tmp_path: Path) -> None:
"""Should return True when no .git directory.""" """Should return True when no .git directory."""
with patch.object(Path, "cwd", return_value=tmp_path): with patch.object(Path, "cwd", return_value=tmp_path):
result = gittool.not_has_git_repo() result = dev.not_has_git_repo()
assert result is True assert result is True
def test_not_has_git_repo_false(self, tmp_path: Path) -> None: def test_not_has_git_repo_false(self, tmp_path: Path) -> None:
@@ -29,7 +29,7 @@ class TestNotHasGitRepo:
git_dir.mkdir() git_dir.mkdir()
with patch.object(Path, "cwd", return_value=tmp_path): with patch.object(Path, "cwd", return_value=tmp_path):
result = gittool.not_has_git_repo() result = dev.not_has_git_repo()
assert result is False assert result is False
def test_not_has_git_repo_cwd_not_exists(self, tmp_path: Path) -> None: def test_not_has_git_repo_cwd_not_exists(self, tmp_path: Path) -> None:
@@ -37,7 +37,7 @@ class TestNotHasGitRepo:
nonexistent = tmp_path / "nonexistent" nonexistent = tmp_path / "nonexistent"
with patch.object(Path, "cwd", return_value=nonexistent): with patch.object(Path, "cwd", return_value=nonexistent):
result = gittool.not_has_git_repo() result = dev.not_has_git_repo()
assert result is True assert result is True
@@ -47,19 +47,25 @@ class TestNotHasGitRepo:
class TestHasFiles: class TestHasFiles:
"""Test has_files function.""" """Test has_files function."""
def test_has_files_true(self, tmp_path: Path) -> None: def test_has_files_true(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Should return True when files exist.""" """Should return True when there are uncommitted changes."""
(tmp_path / "test.txt").write_text("test")
with patch.object(Path, "cwd", return_value=tmp_path): class _FakeResult:
result = gittool.has_files() stdout = " M test.txt\n"
assert result is True
def test_has_files_false(self, tmp_path: Path) -> None: monkeypatch.setattr("subprocess.run", lambda *_, **__: _FakeResult())
"""Should return False when no files.""" result = dev.has_files()
with patch.object(Path, "cwd", return_value=tmp_path): assert result is True
result = gittool.has_files()
assert result is False def test_has_files_false(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Should return False when no uncommitted changes."""
class _FakeResult:
stdout = ""
monkeypatch.setattr("subprocess.run", lambda *_, **__: _FakeResult())
result = dev.has_files()
assert result is False
# ---------------------------------------------------------------------- # # ---------------------------------------------------------------------- #
@@ -76,62 +82,13 @@ class TestInitSubDirs:
subdir2.mkdir() subdir2.mkdir()
with patch.object(Path, "cwd", return_value=tmp_path), patch.object(px, "run") as mock_run: with patch.object(Path, "cwd", return_value=tmp_path), patch.object(px, "run") as mock_run:
gittool.init_sub_dirs() dev.init_sub_dirs()
# Should call px.run for each subdirectory # Should call px.run for each subdirectory
assert mock_run.call_count == 2 assert mock_run.call_count == 2
def test_init_sub_dirs_no_subdirectories(self, tmp_path: Path) -> None: def test_init_sub_dirs_no_subdirectories(self, tmp_path: Path) -> None:
"""Should handle no subdirectories.""" """Should handle no subdirectories."""
with patch.object(Path, "cwd", return_value=tmp_path), patch.object(px, "run") as mock_run: with patch.object(Path, "cwd", return_value=tmp_path), patch.object(px, "run") as mock_run:
gittool.init_sub_dirs() dev.init_sub_dirs()
# Should not call px.run # Should not call px.run
assert mock_run.call_count == 0 assert mock_run.call_count == 0
# ---------------------------------------------------------------------- #
# TaskSpec definitions
# ---------------------------------------------------------------------- #
class TestTaskSpecDefinitions:
"""Test that all TaskSpec definitions are valid."""
def test_push_spec(self) -> None:
"""push spec should be properly defined."""
assert gittool.push.name == "push"
assert gittool.push.cmd == ["git", "push"]
def test_pull_spec(self) -> None:
"""pull spec should be properly defined."""
assert gittool.pull.name == "pull"
assert gittool.pull.cmd == ["git", "pull"]
def test_kill_tgit_spec(self) -> None:
"""kill_tgit spec should be properly defined."""
assert gittool.kill_tgit.name == "task_kill"
assert isinstance(gittool.kill_tgit.cmd, list)
assert "taskkill" in gittool.kill_tgit.cmd
# ---------------------------------------------------------------------- #
# main function
# ---------------------------------------------------------------------- #
class TestMain:
"""Test main function."""
def test_main_calls_run_cli(self) -> None:
"""main() should create a CliRunner and call run_cli()."""
with pytest.raises(SystemExit) as exc_info:
gittool.main()
# run_cli() calls sys.exit(), so we should get SystemExit
assert exc_info.value.code in (0, 1, 2)
def test_main_with_list_argument(self) -> None:
"""main() should handle --list argument."""
with patch("sys.argv", ["gittool", "--list"]), pytest.raises(SystemExit) as exc_info:
gittool.main()
assert exc_info.value.code == 0
def test_main_with_no_args_shows_help(self) -> None:
"""main() with no args should show help and exit."""
with patch("sys.argv", ["gittool"]), pytest.raises(SystemExit) as exc_info:
gittool.main()
assert exc_info.value.code == 1
+168
View File
@@ -0,0 +1,168 @@
"""Tests for ops.llm 模块 (msdownload/sglang)."""
from __future__ import annotations
import subprocess
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from pyflowx.conditions import Constants
from pyflowx.ops.llm import install_sglang, msdownload_run, run_sglang
# ---------------------------------------------------------------------- #
# msdownload_run
# ---------------------------------------------------------------------- #
class TestMsdownloadRun:
"""``msdownload_run`` 函数测试."""
def test_empty_name_does_nothing(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
"""name 为空时应直接返回."""
called: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: called.append(cmd))
msdownload_run("")
captured = capsys.readouterr()
assert "name 不能为空" in captured.out
assert called == []
def test_default_download_dir(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""未提供 download_dir 时默认使用 ~/.models/<name 最后一段>."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
ran_cmds: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
msdownload_run("Qwen/Qwen2.5-Coder")
expected_dir = tmp_path / ".models" / "Qwen2.5-Coder"
assert expected_dir.exists()
assert ran_cmds[0] == [
"uvx",
"modelscope",
"download",
"--model",
"Qwen/Qwen2.5-Coder",
"--local_dir",
str(expected_dir),
]
def test_custom_download_dir(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""提供 download_dir 时应使用指定目录."""
custom_dir = tmp_path / "custom"
ran_cmds: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
msdownload_run("Qwen/Qwen2.5", "dataset", str(custom_dir))
assert custom_dir.exists()
assert ran_cmds[0][3] == "--dataset"
assert str(custom_dir) in ran_cmds[0]
def test_dataset_type(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""target_type=dataset 时应传递 --dataset."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
ran_cmds: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
msdownload_run("foo/bar", "dataset")
assert "--dataset" in ran_cmds[0]
# ---------------------------------------------------------------------- #
# install_sglang
# ---------------------------------------------------------------------- #
class TestInstallSglang:
"""``install_sglang`` 函数测试."""
def test_already_installed_skips(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
"""sglang 已安装时应跳过."""
monkeypatch.setattr("shutil.which", lambda _cmd: "/usr/bin/sglang")
called: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: called.append(cmd))
install_sglang()
captured = capsys.readouterr()
assert "已安装" in captured.out
assert called == []
def test_not_installed_runs_uv_install(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""sglang 未安装时应执行 uv install sglang[all]."""
monkeypatch.setattr("shutil.which", lambda _cmd: None)
ran_cmds: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
install_sglang()
assert ran_cmds == [["uv", "install", "sglang[all]"]]
# ---------------------------------------------------------------------- #
# run_sglang
# ---------------------------------------------------------------------- #
class TestRunSglang:
"""``run_sglang`` 函数测试."""
def test_model_dir_not_exist_skips(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""模型目录不存在时应跳过."""
monkeypatch.setattr(Path, "exists", lambda _self: False)
called: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: called.append(cmd))
run_sglang(model="/nonexistent/path")
captured = capsys.readouterr()
assert "模型目录不存在" in captured.out
assert called == []
def test_windows_uses_python(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Windows 上应使用 python."""
monkeypatch.setattr(Constants, "IS_WINDOWS", True)
monkeypatch.setattr(Path, "expanduser", lambda _self: tmp_path)
monkeypatch.setattr(Path, "exists", lambda _self: True)
ran_cmds: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
run_sglang(model=str(tmp_path))
assert ran_cmds[0][0] == "python"
assert "-m" in ran_cmds[0]
assert "sglang.launch_server" in ran_cmds[0]
def test_non_windows_uses_python3(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""非 Windows 上应使用 python3."""
monkeypatch.setattr(Constants, "IS_WINDOWS", False)
monkeypatch.setattr(Path, "expanduser", lambda _self: tmp_path)
monkeypatch.setattr(Path, "exists", lambda _self: True)
ran_cmds: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
run_sglang(model=str(tmp_path), port=9000, ctx_len=4096, mem_fraction=0.5, host="127.0.0.1", log_level="debug")
cmd = ran_cmds[0]
assert cmd[0] == "python3"
assert "--port" in cmd
assert "9000" in cmd
assert "4096" in cmd
assert "0.5" in cmd
assert "127.0.0.1" in cmd
assert "debug" in cmd
def test_command_includes_qwen_parser(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""命令应包含 --tool-call-parser qwen."""
monkeypatch.setattr(Constants, "IS_WINDOWS", True)
monkeypatch.setattr(Path, "expanduser", lambda _self: tmp_path)
monkeypatch.setattr(Path, "exists", lambda _self: True)
ran_cmds: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
run_sglang(model=str(tmp_path))
cmd = ran_cmds[0]
assert "--tool-call-parser" in cmd
qwen_idx = cmd.index("--tool-call-parser")
assert cmd[qwen_idx + 1] == "qwen"
+10 -57
View File
@@ -5,9 +5,8 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pyflowx as px
from pyflowx.cli import lscalc
from pyflowx.conditions import Constants from pyflowx.conditions import Constants
from pyflowx.ops import system
# ---------------------------------------------------------------------- # # ---------------------------------------------------------------------- #
@@ -19,7 +18,7 @@ class TestGetLsDynaCommand:
def test_get_ls_dyna_command_windows(self) -> None: def test_get_ls_dyna_command_windows(self) -> None:
"""Should get LS-DYNA command for Windows.""" """Should get LS-DYNA command for Windows."""
with patch.object(Constants, "IS_WINDOWS", True), patch.object(Constants, "IS_MACOS", False): with patch.object(Constants, "IS_WINDOWS", True), patch.object(Constants, "IS_MACOS", False):
cmd = lscalc.get_ls_dyna_command("input.k", 4) cmd = system.get_ls_dyna_command("input.k", 4)
assert "ls-dyna_mpp" in cmd assert "ls-dyna_mpp" in cmd
assert "i=input.k" in cmd assert "i=input.k" in cmd
assert "ncpu=4" in cmd assert "ncpu=4" in cmd
@@ -27,7 +26,7 @@ class TestGetLsDynaCommand:
def test_get_ls_dyna_command_linux(self) -> None: def test_get_ls_dyna_command_linux(self) -> None:
"""Should get LS-DYNA command for Linux.""" """Should get LS-DYNA command for Linux."""
with patch.object(Constants, "IS_WINDOWS", False), patch.object(Constants, "IS_MACOS", False): with patch.object(Constants, "IS_WINDOWS", False), patch.object(Constants, "IS_MACOS", False):
cmd = lscalc.get_ls_dyna_command("input.k", 8) cmd = system.get_ls_dyna_command("input.k", 8)
assert "ls-dyna_mpp" in cmd assert "ls-dyna_mpp" in cmd
assert "i=input.k" in cmd assert "i=input.k" in cmd
assert "ncpu=8" in cmd assert "ncpu=8" in cmd
@@ -46,14 +45,14 @@ class TestRunLsDyna:
with patch("subprocess.run") as mock_run: with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0) mock_run.return_value = MagicMock(returncode=0)
lscalc.run_ls_dyna(str(input_file), ncpu=4) system.run_ls_dyna(str(input_file), ncpu=4)
assert mock_run.called assert mock_run.called
def test_run_ls_dyna_file_not_found(self, tmp_path: Path) -> None: def test_run_ls_dyna_file_not_found(self, tmp_path: Path) -> None:
"""Should handle nonexistent input file.""" """Should handle nonexistent input file."""
input_file = tmp_path / "nonexistent.k" input_file = tmp_path / "nonexistent.k"
lscalc.run_ls_dyna(str(input_file), ncpu=4) system.run_ls_dyna(str(input_file), ncpu=4)
# Should print error message # Should print error message
def test_run_ls_dyna_command_not_found(self, tmp_path: Path) -> None: def test_run_ls_dyna_command_not_found(self, tmp_path: Path) -> None:
@@ -62,7 +61,7 @@ class TestRunLsDyna:
input_file.write_text("LS-DYNA input") input_file.write_text("LS-DYNA input")
with patch("subprocess.run", side_effect=FileNotFoundError): with patch("subprocess.run", side_effect=FileNotFoundError):
lscalc.run_ls_dyna(str(input_file), ncpu=4) system.run_ls_dyna(str(input_file), ncpu=4)
# Should print error message # Should print error message
@@ -79,14 +78,14 @@ class TestRunLsDynaMpi:
with patch("subprocess.run") as mock_run: with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0) mock_run.return_value = MagicMock(returncode=0)
lscalc.run_ls_dyna_mpi(str(input_file), ncpu=8) system.run_ls_dyna_mpi(str(input_file), ncpu=8)
assert mock_run.called assert mock_run.called
def test_run_ls_dyna_mpi_file_not_found(self, tmp_path: Path) -> None: def test_run_ls_dyna_mpi_file_not_found(self, tmp_path: Path) -> None:
"""Should handle nonexistent input file.""" """Should handle nonexistent input file."""
input_file = tmp_path / "nonexistent.k" input_file = tmp_path / "nonexistent.k"
lscalc.run_ls_dyna_mpi(str(input_file), ncpu=8) system.run_ls_dyna_mpi(str(input_file), ncpu=8)
# Should print error message # Should print error message
@@ -100,58 +99,12 @@ class TestCheckLsDynaStatus:
"""Should check LS-DYNA status on Windows.""" """Should check LS-DYNA status on Windows."""
with patch.object(Constants, "IS_WINDOWS", True), patch("subprocess.run") as mock_run: with patch.object(Constants, "IS_WINDOWS", True), patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(stdout="ls-dyna_mpp.exe", returncode=0) mock_run.return_value = MagicMock(stdout="ls-dyna_mpp.exe", returncode=0)
lscalc.check_ls_dyna_status() system.check_ls_dyna_status()
assert mock_run.called assert mock_run.called
def test_check_ls_dyna_status_linux(self) -> None: def test_check_ls_dyna_status_linux(self) -> None:
"""Should check LS-DYNA status on Linux.""" """Should check LS-DYNA status on Linux."""
with patch.object(Constants, "IS_WINDOWS", False), patch("subprocess.run") as mock_run: with patch.object(Constants, "IS_WINDOWS", False), patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(stdout="1234", returncode=0) mock_run.return_value = MagicMock(stdout="1234", returncode=0)
lscalc.check_ls_dyna_status() system.check_ls_dyna_status()
assert mock_run.called assert mock_run.called
# ---------------------------------------------------------------------- #
# main function
# ---------------------------------------------------------------------- #
class TestMain:
"""Test main function."""
def test_main_run_command(self, tmp_path: Path) -> None:
"""main() should handle run command."""
input_file = tmp_path / "input.k"
input_file.write_text("LS-DYNA input")
with patch("sys.argv", ["lscalc", "run", str(input_file)]), patch.object(px, "run") as mock_run:
lscalc.main()
assert mock_run.called
def test_main_run_command_with_ncpu(self, tmp_path: Path) -> None:
"""main() should handle run command with ncpu."""
input_file = tmp_path / "input.k"
input_file.write_text("LS-DYNA input")
with patch("sys.argv", ["lscalc", "run", str(input_file), "--ncpu", "8"]), patch.object(px, "run") as mock_run:
lscalc.main()
assert mock_run.called
def test_main_mpi_command(self, tmp_path: Path) -> None:
"""main() should handle mpi command."""
input_file = tmp_path / "input.k"
input_file.write_text("LS-DYNA input")
with patch("sys.argv", ["lscalc", "mpi", str(input_file)]), patch.object(px, "run") as mock_run:
lscalc.main()
assert mock_run.called
def test_main_status_command(self) -> None:
"""main() should handle status command."""
with patch("sys.argv", ["lscalc", "status"]), patch.object(px, "run") as mock_run:
lscalc.main()
assert mock_run.called
def test_main_with_no_args_shows_help(self) -> None:
"""main() with no args should show help."""
with patch("sys.argv", ["lscalc"]):
lscalc.main()
# Should print help and return

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