Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 529a024ea1 | |||
| 4721b59f40 | |||
| fca6f17a5c | |||
| d493c6233e | |||
| 7a87a4177e | |||
| 04d6871e8d | |||
| 08149d990d | |||
| 54aef98f3a | |||
| e31646e281 | |||
| f7fb95af83 | |||
| 6da42ec5ff | |||
| c55a37173a | |||
| 960b8672f4 | |||
| 4fd1d70b58 | |||
| 6fb9223066 | |||
| 1f7127357e | |||
| 58ee84ded6 | |||
| 9a96e5d052 | |||
| c9c7529c58 | |||
| c498d9b1c9 | |||
| b36e279f92 | |||
| 58d6f1faad | |||
| d93da0d8b4 | |||
| 701c455c42 | |||
| e174b64495 | |||
| 3afb25bb5e | |||
| fbd17536fd | |||
| 32ca8c1208 | |||
| a7ff68d279 | |||
| de368ea810 | |||
| 6a3e3a57cd | |||
| 7089944306 | |||
| ec5e348694 | |||
| 12d9f2f647 | |||
| 6ffcbecade | |||
| e76d93187b | |||
| 52e20e3f93 | |||
| 3f966a230e | |||
| 5d0b211a44 | |||
| 6931f36fd1 | |||
| db02443463 | |||
| eb8e1402bc | |||
| c93f45dcb8 | |||
| a0b1814024 | |||
| 3a2826d3f9 | |||
| dbd30689ab | |||
| 5eb59b8a66 | |||
| 8e7b866de2 | |||
| 1b4f9bfa6a | |||
| 2d39272330 | |||
| f699bb9167 | |||
| 35f07e96e1 |
+1
-2
@@ -38,9 +38,8 @@ env
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# 文档与示例(按需保留)
|
||||
# 文档(按需保留)
|
||||
docs
|
||||
examples
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
name: Lint, Typecheck & Test
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: pyflowx-ci:1.0.0
|
||||
image: pyflowx-ci:latest
|
||||
env:
|
||||
UV_LINK_MODE: copy
|
||||
# ---- 国内源 ----
|
||||
|
||||
@@ -25,24 +25,34 @@ jobs:
|
||||
|
||||
- name: Build distributions
|
||||
run: uv build
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: http://gitea:3000/zhou/gh-action-pypi-publish.git@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
|
||||
- name: Create Gitea Release
|
||||
|
||||
- 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: |
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
REPO=${GITHUB_REPOSITORY}
|
||||
UPLOAD_URL=$(curl -s -X POST "https://git.gookeryoung.cn/api/v1/repos/${REPO}/releases" \
|
||||
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||
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\": \"v${VERSION}\", \"name\": \"v${VERSION}\", \"draft\": false, \"prerelease\": false}" \
|
||||
| jq -r '.upload_url')
|
||||
for file in dist/*; do
|
||||
curl -s -X POST "${UPLOAD_URL}?name=$(basename $file)" \
|
||||
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||
-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 @"$file"
|
||||
--data-binary "@$f"
|
||||
done
|
||||
|
||||
|
||||
|
||||
@@ -11,3 +11,7 @@ wheels/
|
||||
.coverage
|
||||
.idea
|
||||
*_profile.html
|
||||
|
||||
# Sphinx 文档构建输出
|
||||
docs/_build/
|
||||
.trae/refs
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,62 @@
|
||||
# 迭代 01: typer/rich 迁移
|
||||
|
||||
## 本轮目标
|
||||
|
||||
采用 typer + rich 简化 CLI 设计, 提高显示效果:
|
||||
1. 修复 Python 3.8 兼容性测试失败
|
||||
2. 全量重写 tools.py 为 typer 内核 (保留 @px.tool 装饰器 API)
|
||||
3. 重写 pf.py 为 rich 驱动主入口
|
||||
4. rich 深度集成 (执行生命周期/错误/工具列表/dry-run)
|
||||
5. 放弃 Python 3.8/3.9 支持 (requires-python >=3.10)
|
||||
|
||||
## 改动文件清单
|
||||
|
||||
### 依赖与配置
|
||||
- `pyproject.toml`: requires-python >=3.10, 添加 typer>=0.24.0 + rich>=13.7.0 依赖, ruff target-version=py310, pyrefly python-version=3.10
|
||||
- `tox.ini`: envlist 改为 py310-py314
|
||||
|
||||
### 核心重写
|
||||
- `src/pyflowx/tools.py`: 全量重写 — argparse → typer.Typer; _make_dispatcher 构建命令 dispatcher; _build_typer_app 单命令/多命令路由; _execute_dag DAG 编排; standalone_mode=False + 捕获 typer._click 异常处理退出码; _handle_list_rich rich 表格 --list
|
||||
- `src/pyflowx/cli/pf.py`: 重写 — rich Panel+Table 工具列表 (含别名/说明列), --help/-h/--version/-V 全局选项, 未知工具模糊匹配建议 (difflib), rich Console stderr 错误
|
||||
|
||||
### rich 深度集成
|
||||
- `src/pyflowx/executors.py`: _make_verbose_callback 用 ✓/✗/▸/○ 符号 + 颜色替代 [verbose] 前缀; _print_dry_run rich styled
|
||||
- `src/pyflowx/command.py`: 执行命令/返回码用 rich Console + 颜色 (green=0/red=非0)
|
||||
|
||||
### Python 3.10+ 现代化
|
||||
- `src/pyflowx/graph.py`: 移除 graphlib_backport, 直接 import graphlib
|
||||
- `src/pyflowx/task.py`: Union → X|Y, typing.ContextManager → contextlib.AbstractContextManager
|
||||
- `src/pyflowx/storage.py`: 同上
|
||||
- `src/pyflowx/command.py`: typing.List → list, Union → X|Y
|
||||
- `src/pyflowx/cli/emlmanager.py`: zip() 加 strict= 参数
|
||||
- `src/pyflowx/executors.py`: zip() 加 strict=True
|
||||
|
||||
### 测试
|
||||
- `tests/test_tools.py`: 全量重写 (84 测试) — 覆盖 typer 内核 (_make_dispatcher/_build_typer_app/_execute_dag/退出码三态)
|
||||
- `tests/test_runner.py`: verbose 断言更新 (✓ 替代 [verbose])
|
||||
|
||||
## 关键决策与依据
|
||||
|
||||
1. **保留 @px.tool 装饰器 API**: 用户既有 ops/ 模块全部使用 @px.tool, 改 API 会破坏所有工具. 内部替换 argparse→typer, 外部 API 不变.
|
||||
|
||||
2. **standalone_mode=False**: typer 默认 standalone_mode=True 会 sys.exit() 退出进程, 测试中无法捕获. 用 False + 手动捕获 typer._click 异常 (NoArgsIsHelpError/ClickException/Abort) 处理退出码.
|
||||
|
||||
3. **typer._click 私有导入**: typer 内置 click (typer._click) 与公共 click 是两套独立类层次, 必须从 typer._click.exceptions 导入 NoArgsIsHelpError/ClickException 才能捕获.
|
||||
|
||||
4. **--list 预处理而非 typer callback**: typer 的 no_args_is_help=True 在多命令工具中会拦截 --list (要求先给 subcommand). 在 run_tool 中预处理 --list (任意位置出现即列出任务) 绕过此限制.
|
||||
|
||||
5. **单命令 vs 多命令**: 单命令工具 (仅 subcommand=None) 用 callback(invoke_without_command=True), no_args_is_help=False; 多命令工具用各 subcommand 注册为 command, no_args_is_help=True.
|
||||
|
||||
6. **legacy 工具不迁移**: emlmanager.py 是 Web 应用 (sqlite3+threading+HTTP), profiler.py 用 runpy.run_path, 均不适配 @px.tool DAG 模型. 保留 pf._run_legacy() 路由是更简方案.
|
||||
|
||||
## 验证结果
|
||||
|
||||
- ruff check: 0 errors
|
||||
- ruff format: 96 files already formatted
|
||||
- pyrefly: 0 errors (12 suppressed)
|
||||
- pytest: 1003 passed
|
||||
- coverage: 97.00% (门槛 95%)
|
||||
|
||||
## 遗留事项
|
||||
|
||||
- 无
|
||||
@@ -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。
|
||||
- 不新增中文文档翻译(文档用中文撰写,与项目既有风格一致)。
|
||||
@@ -0,0 +1,171 @@
|
||||
# 方案C:移除 YAML,统一 @px.tool 装饰器配置
|
||||
|
||||
## Context
|
||||
|
||||
现状:YAML 配置(22 个文件)+ `register_fn` 函数(79 个)分裂严重。fn 模式下 YAML 沦为冗余参数表——参数要三处重复声明(`variables`/`cli.options`/`jobs.args`),表达力不足(跨平台逻辑被迫下沉 `register_fn`),失类型检查/IDE 支持,函数签名与 YAML `args:` 顺序远端耦合。**矩阵 0 个实际使用、`if:` 0 个实际使用**,YAML 的声明式 DAG 优势在 fn 工具集场景完全没发挥。
|
||||
|
||||
目标:完全移除 YAML 支持,统一用 `@px.tool` 装饰器——函数签名驱动 CLI 生成,函数体即逻辑,DAG 编排通过装饰器参数(`needs`/`strategy`)表达。用户无感知迁移(别名/子命令/参数名全保持)。验收:全部门禁通过(ruff/pyrefly/pytest)且覆盖率 ≥95%。
|
||||
|
||||
## 设计要点
|
||||
|
||||
### 新模块 `src/pyflowx/tools.py`
|
||||
|
||||
职责:`@px.tool` 装饰器 + `ToolSpec`(frozen dataclass)+ 全局注册表 + CLI 生成 + 执行入口。**不扩展 `registry.py`**(职责不同,@px.tool 直接持有函数对象)。
|
||||
|
||||
### `@px.tool` API
|
||||
|
||||
```python
|
||||
@px.tool("pdftool", subcommand="m", help="合并 PDF",
|
||||
needs=["setup"], strategy="thread", # DAG 编排参数
|
||||
cmd=["..."], # 可选:有 cmd 执行命令,无 cmd 执行函数体
|
||||
hidden=False, allow_upstream_skip=False)
|
||||
def pdf_merge(
|
||||
inputs: list[Path], # positional(无默认值)→ nargs="+"
|
||||
output: Path = Path("merged.pdf"), # option(有默认值)→ --output
|
||||
password: str = "", # --password
|
||||
dry_run: bool = False, # bool 默认 False → --dry-run store_true
|
||||
level: int = 5, # --level type=int
|
||||
mode: Literal["a","b"] = "a", # --mode choices=["a","b"]
|
||||
) -> None:
|
||||
"""函数体即逻辑(无 cmd 时执行;有 cmd 时签名仅驱动 CLI)."""
|
||||
...
|
||||
```
|
||||
|
||||
**装饰器关键字参数**:`subcommand`、`help`、`description`、`cmd`、`needs`、`strategy`、`cwd`、`allow_upstream_skip`、`hidden`、`env`/`retry`/`timeout`(透传 TaskSpec)。
|
||||
|
||||
**CLI 生成规则**(inspect.signature + typing.get_type_hints):
|
||||
- 无默认值 → positional;有默认值 → `--kebab-case` 选项
|
||||
- 类型映射:`list[X]`→`nargs="+"`、`Path`→`type=Path`、`bool`默认 False→`store_true`、`Literal`→`choices`、`int`/`float`→对应 type
|
||||
- 全局选项 `--dry-run`/`--quiet`/`--strategy`/`--list` 注入每个 subparser
|
||||
|
||||
**执行分支**:
|
||||
- 有 `cmd`:TaskSpec 用 `cmd=cmd`,函数体不执行(签名仅驱动 CLI);cwd/env 从装饰器 + CLI kwargs 合并
|
||||
- 无 `cmd`:TaskSpec 用 `fn=func`,kwargs 透传
|
||||
- 聚合任务(有 needs 无 cmd 无函数逻辑):fn=noop
|
||||
|
||||
**needs 传递依赖**:从 `_TOOL_REGISTRY[tool_name]` BFS 收集(复用 `_collect_with_deps` 思路),每个依赖生成 TaskSpec,构建子图 → `px.run(graph, strategy)`。
|
||||
|
||||
**动态 cmd 改 fn 模式**:`piptool i`/`gittool clean` 等原 `cmd+${VAR}` 改为函数体内 `subprocess.run([...])`,消除 `${VAR}` 列表展开,模型更纯。
|
||||
|
||||
### pf.py 路由改造
|
||||
|
||||
- `_TOOL_ALIASES`(57 个)保留全量,值改为 tool_name(去掉 .yaml 含义)
|
||||
- `_run_yaml` → `_run_tool`:`importlib.import_module(f"pyflowx.ops.{tool_name}")` 触发注册 → 从 `_TOOL_REGISTRY[tool_name]` 取 subcommand → `run_tool(tool, subcommand, argv)` → 退出码 0/1/130
|
||||
- `_LEGACY_TOOLS` 保留 emlmanager/profiler;**删除 yamlrun**
|
||||
- `_list_tools` 改为遍历 `_TOOL_REGISTRY` 取 description
|
||||
|
||||
### ops 模块重组
|
||||
|
||||
从 6 个功能模块(`files/dev/media/system/llm/bumpversion`)重组为 **22 个工具模块**:`ops/pdftool.py`、`ops/pymake.py`、`ops/envdev.py` 等,一文件一工具,文件内多个 `@px.tool` = 多 subcommand。
|
||||
|
||||
- 跨工具复用函数(如 `add_date_prefix` 被 filedate 用)放 `ops/_common.py`
|
||||
- 未被 YAML 引用的约 20 个 `register_fn`(辅助函数如 `get_file_timestamp`/`has_files`)去掉装饰器,降级为模块级私有函数
|
||||
- `register_fn`/`get_fn`/`has_fn` 在阶段5 一并移除(YAML 配套,失去用途)
|
||||
|
||||
## 迁移路径(6 阶段)
|
||||
|
||||
### 阶段1:核心实现 + 单元测试
|
||||
- 新增 `src/pyflowx/tools.py`、`tests/test_tools.py`
|
||||
- 覆盖:注册/重复检测/CLI 生成(各类型)/单命令执行/cmd 分支/fn 分支/聚合/DAG/hidden/全局选项/退出码三态
|
||||
- 验证:`tools.py` 单元测试通过,覆盖率 ≥95%
|
||||
|
||||
### 阶段2:迁移简单单命令工具
|
||||
- 工具:clr/which/taskkill/reseticoncache/screenshot/dockercmd/lscalc
|
||||
- 新增 `ops/<tool>.py`,每个用 `@px.tool` 重写;删除对应 `configs/*.yaml`
|
||||
- 改 `tests/cli/test_*.py` 的 import 路径(函数体不变,测试基本不动)
|
||||
- 验证:`pf clr`/`pf which python` 端到端 + 测试通过
|
||||
|
||||
### 阶段3:迁移多 subcommand fn 工具
|
||||
- 工具:pdftool(14子命令)/piptool/packtool/filedate/filelevel/folderback/folderzip/sshcopyid/autofmt/bumpversion
|
||||
- pdftool:函数签名已有完整类型注解,迁移最直接
|
||||
- piptool:`i`/`up` 的 cmd 改 fn 模式(`subprocess.run`)
|
||||
- bumpversion:positional `PART` 默认 "patch" + `--no-tag` store_true
|
||||
- 验证:每工具 `pf <tool> --list` + 至少一个 subcommand 端到端
|
||||
|
||||
### 阶段4:迁移复杂工具
|
||||
- pymake:12 cmd job + 6 聚合(ba/bump/cov/tc/p/pb)+ 4 内部 job(`hidden=True`:pyrefly_check/git_add_all/git_push/test_coverage)
|
||||
- envdev:9 job,4 处 `allow_upstream_skip`;`install_rust` 用 needs + allow_upstream_skip
|
||||
- gittool:`a`/`i`/`isub` 是 fn,`c`/`p`/`pl` 是 cmd,`clean` 是 hidden cmd
|
||||
- sglang/msdownload:跨平台 + 多参数
|
||||
- 验证:`pf pymake tc`/`pf pymake b`/`pf envdev` 端到端
|
||||
|
||||
### 阶段5:清理 YAML
|
||||
- 删除:`yaml_loader.py`、`configs/*.yaml`(22 个)、`cli/yamlrun.py`、`tests/test_yaml_loader.py`
|
||||
- 删除:`ops/{files,dev,media,system,llm,bumpversion}.py`(被 `ops/<tool>.py` 替代后)
|
||||
- 删除:`registry.py` 的 `register_fn`/`get_fn`/`has_fn`/`FnRegistry`(YAML 配套)
|
||||
- `__init__.py`:移除 `YamlLoadError`/`build_cli_parser`/`load_yaml`/`parse_yaml_string`/`run_cli`/`run_yaml`/`Graph.from_yaml`/`register_fn`/`get_fn`/`has_fn`/`FnRegistry`;新增 `tool`/`ToolSpec`/`run_tool`
|
||||
- `graph.py`:删 `from_yaml` 类方法
|
||||
- `pyproject.toml`:删 `yamlrun` 脚本入口、`pyyaml` 运行时依赖、`types-PyYAML` dev 依赖
|
||||
- 验证:全局无 `import yaml`、无 `load_yaml`/`run_cli` 残留引用
|
||||
|
||||
### 阶段6:补充测试,覆盖率达标
|
||||
- `tests/test_tools.py` 扩展:覆盖所有装饰器分支
|
||||
- `tests/cli/test_*.py`(16 个):改为通过 `PfApp([tool, subcommand, ...]).run()` 或 `run_tool(...)` 测试
|
||||
- 删除 `tests/test_yaml_loader.py`
|
||||
- 验证:`pf pymake cov` ≥95% branch
|
||||
|
||||
## 关键文件
|
||||
|
||||
**新增**:
|
||||
- `src/pyflowx/tools.py`(核心,@px.tool 装饰器)
|
||||
- `src/pyflowx/ops/<tool>.py`(22 个工具模块)
|
||||
- `src/pyflowx/ops/_common.py`(跨工具复用函数)
|
||||
- `tests/test_tools.py`
|
||||
|
||||
**修改**:
|
||||
- `src/pyflowx/cli/pf.py`(_run_yaml → _run_tool,加载 ops/<tool>.py)
|
||||
- `src/pyflowx/__init__.py`(API 导出调整)
|
||||
- `pyproject.toml`(删 yamlrun/pyyaml)
|
||||
- `tests/cli/test_*.py`(import 路径 + 测试入口)
|
||||
|
||||
**删除**:
|
||||
- `src/pyflowx/yaml_loader.py`
|
||||
- `src/pyflowx/configs/*.yaml`(22 个)
|
||||
- `src/pyflowx/cli/yamlrun.py`
|
||||
- `tests/test_yaml_loader.py`
|
||||
- `src/pyflowx/ops/{files,dev,media,system,llm,bumpversion}.py`(被替代后)
|
||||
- `src/pyflowx/registry.py`(YAML 配套)
|
||||
|
||||
## 可复用的现有实现
|
||||
|
||||
- `_collect_with_deps`(yaml_loader.py:287):needs 传递依赖收集,迁到 tools.py
|
||||
- `_add_global_options`(yaml_loader.py:1017):全局选项注入,思路复用
|
||||
- `CliExitCode`(runner.py:30):退出码三态,直接复用
|
||||
- `px.Graph.from_specs` + `px.run`:DAG 构建与执行,直接复用
|
||||
- `TaskSpec`(task.py):任务描述符,@px.tool 内部构建它
|
||||
|
||||
## CLI 兼容性
|
||||
|
||||
- 57 个 pf 别名不变
|
||||
- 子命令名不变(pdftool m/s/c/...、pymake b/ba/tc/...)
|
||||
- 参数名不变(`--output`/`--password`/...,靠 snake_case→kebab-case 转换)
|
||||
- `--dry-run`/`--quiet`/`--strategy`/`--list` 全局选项保留
|
||||
- 退出码 0/1/130 三态保留
|
||||
|
||||
## 风险与权衡
|
||||
|
||||
- **动态 cmd 改 fn**:失去 cmd 任务的 verbose 输出(命令打印/returncode),但模型更统一;verbose 可在 fn 内手动 print
|
||||
- **Graph.from_yaml 移除**:破坏性公共 API 变更,changelog 说明;yamlrun 用户迁移到 pf
|
||||
- **ops 重组工作量大**:22 个工具模块从 6 个功能模块拆出,跨工具函数需识别;分阶段(2-4)渐进,每阶段可独立验证
|
||||
- **register_fn 移除**:20 个未被引用的函数降级私有,被 @px.tool 间接调用的内联或放 `_common.py`
|
||||
|
||||
## 验证
|
||||
|
||||
每阶段结束运行:
|
||||
```bash
|
||||
uv run ruff check . && uv run ruff format --check .
|
||||
uv run pyrefly check .
|
||||
uv run pytest --cov=pyflowx --cov-branch --cov-report=term-missing
|
||||
```
|
||||
|
||||
端到端验证(阶段4 后):
|
||||
```bash
|
||||
pf clr # 单命令
|
||||
pf which python # 单命令 + positional
|
||||
pf pdftool m a.pdf b.pdf --output merged.pdf # 多 subcommand + positional nargs+
|
||||
pf pymake tc # DAG + 聚合 + 内部 job
|
||||
pf envdev --python-mirror aliyun # 跨平台 fn
|
||||
pf # 列出所有工具
|
||||
```
|
||||
|
||||
最终:`pf pymake cov` 通过,覆盖率 ≥95%。
|
||||
@@ -0,0 +1,228 @@
|
||||
# 方案C 续接执行计划
|
||||
|
||||
## Summary
|
||||
|
||||
承接已批准的 `.trae/documents/方案C-迁移执行计划.md`。前序会话已完成**阶段1**(tools.py 测试 + 公共 API 导出)与**阶段2 的大部分工作**(7 个新 ops 模块创建 + pf.py 双路由 + 4 个测试文件 import 更新),但因上下文中断尚未收尾。本计划从当前状态起,完成阶段2 收尾,并继续执行阶段3-6,最终完全移除 YAML 支持,覆盖率 ≥95%。
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### 阶段1(已完成)
|
||||
- `src/pyflowx/tools.py`:完整实现 @px.tool 装饰器、ToolSpec、_TOOL_REGISTRY、CLI 生成、依赖收集、run_tool、三态退出码
|
||||
- `tests/test_tools.py`:110 测试全通过,覆盖率 96.91%
|
||||
- `src/pyflowx/__init__.py`:已导出 `tool`/`ToolSpec`/`run_tool`/`list_tools`/`list_subcommands`(同时保留 YAML API 供过渡)
|
||||
- `pyproject.toml`:已加 `[tool.ruff.lint.per-file-ignores]` 中 `src/pyflowx/tools.py = ["PLR0911"]`
|
||||
|
||||
### 阶段2(进行中,待收尾)
|
||||
**已完成**:
|
||||
- 7 个新 ops 模块创建完成,均使用 `@px.tool` 装饰器:
|
||||
- `ops/clr.py`(`clear_screen_run`)
|
||||
- `ops/which.py`(`which_run`)
|
||||
- `ops/taskkill.py`(`taskkill_run`)
|
||||
- `ops/reseticoncache.py`(`reset_icon_cache_run`)
|
||||
- `ops/screenshot.py`(`take_screenshot_full`/`take_screenshot_area`,双 subcommand)
|
||||
- `ops/dockercmd.py`(`docker_login_tencent`,subcommand="login")
|
||||
- `ops/lscalc.py`(`run_ls_dyna`/`run_ls_dyna_mpi`/`check_ls_dyna_status`,三 subcommand)
|
||||
- `pf.py` 已改双路由:`_run_yaml` → `_run_tool_or_yaml`(@px.tool 优先,YAML 回退)
|
||||
- 4 个测试文件 import 路径已更新:`test_reseticoncache.py`、`test_screenshot.py`、`test_lscalc.py`、`test_system_run.py`
|
||||
- 旧功能模块(`ops/system.py`、`ops/media.py`、`ops/dev.py`)中的原函数仍保留(`@px.register_fn`),供未迁移的 YAML 工具使用
|
||||
|
||||
**待完成**:
|
||||
1. **test_envdev.py 无需改动**:该文件测试 `ops/dev.py` 中的 `docker_login_tencent` 等 10 个函数,这些函数在阶段4 前仍在 `dev.py` 中(`@px.register_fn` 装饰),`dockercmd.py` 中的 `@px.tool` 版本是独立副本,两者共存无冲突
|
||||
2. **删除 7 个 YAML 配置文件**:`configs/{clr,which,taskkill,reseticoncache,screenshot,dockercmd,lscalc}.yaml`
|
||||
3. **验证**:运行 ruff/pyrefly/pytest,确认阶段2 通过
|
||||
|
||||
### 阶段3-6(未开始)
|
||||
- 阶段3:迁移 10 个多 subcommand fn 工具(pdftool/piptool/packtool/filedate/filelevel/folderback/folderzip/sshcopyid/autofmt/bumpversion)
|
||||
- 阶段4:迁移 5 个复杂工具(pymake/envdev/gittool/sglang/msdownload)
|
||||
- 阶段5:清理 YAML 与配套代码(yaml_loader.py/registry.py/configs 目录/旧 ops 功能模块)
|
||||
- 阶段6:补充测试,覆盖率达标 95%
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 阶段2 收尾:删除 YAML + 验证
|
||||
|
||||
**删除文件**(7 个):
|
||||
- `src/pyflowx/configs/clr.yaml`
|
||||
- `src/pyflowx/configs/which.yaml`
|
||||
- `src/pyflowx/configs/taskkill.yaml`
|
||||
- `src/pyflowx/configs/reseticoncache.yaml`
|
||||
- `src/pyflowx/configs/screenshot.yaml`
|
||||
- `src/pyflowx/configs/dockercmd.yaml`
|
||||
- `src/pyflowx/configs/lscalc.yaml`
|
||||
|
||||
**验证**:
|
||||
```bash
|
||||
uv run ruff check . && uv run ruff format --check .
|
||||
uv run pyrefly check .
|
||||
uv run pytest tests/cli/test_{reseticoncache,screenshot,lscalc,system_run}.py -v
|
||||
uv run pytest tests/test_tools.py -v
|
||||
```
|
||||
|
||||
端到端冒烟(可选,若环境允许):
|
||||
```bash
|
||||
pf clr
|
||||
pf which python
|
||||
pf lscalc --list
|
||||
```
|
||||
|
||||
### 阶段3:迁移多 subcommand fn 工具(10 个)
|
||||
|
||||
**工具清单**:`pdftool`(14 子命令)/`piptool`/`packtool`/`filedate`/`filelevel`/`folderback`/`folderzip`/`sshcopyid`/`autofmt`/`bumpversion`
|
||||
|
||||
**新建文件**(10 个,`ops/<tool>.py`,一文件一工具):
|
||||
- `ops/pdftool.py`:14 个 `@px.tool("pdftool", subcommand="...")`,函数体来自 `ops/media.py`(`pdf_merge`/`pdf_split`/`pdf_compress`/`pdf_encrypt`/`pdf_decrypt`/`pdf_extract_text`/`pdf_extract_images`/`pdf_add_watermark`/`pdf_rotate`/`pdf_crop`/`pdf_info`/`pdf_ocr`/`pdf_reorder`/`pdf_to_images`/`pdf_repair`)
|
||||
- `ops/piptool.py`:多 subcommand(`i`/`up`/`un`/`re`/`fr`/`dl`),`i`/`up` 原 cmd 模式改 fn 模式
|
||||
- `ops/packtool.py`:来自 `ops/system.py`(`pack_source`/`pack_wheel`/`pack_dependencies`/`clean_build_dir`)
|
||||
- `ops/filedate.py`/`ops/filelevel.py`/`ops/folderback.py`/`ops/folderzip.py`:来自 `ops/files.py`
|
||||
- `ops/sshcopyid.py`:来自 `ops/system.py`(`ssh_copy_id`)
|
||||
- `ops/autofmt.py`:4 subcommand(`fmt`/`lint`/`doc`/`sync`),`fmt`/`lint` 原 cmd 改 fn 模式
|
||||
- `ops/bumpversion.py`:positional `part: str = "patch"` + `no_tag: bool = False`,直接调用 `bump_project_version`
|
||||
|
||||
**关键决策**:
|
||||
- 函数签名保持不变(测试兼容),仅装饰器 `@px.register_fn` → `@px.tool(name, subcommand=...)` 变化
|
||||
- 参数名 snake_case,CLI 自动转 kebab-case,**保持 CLI 兼容**
|
||||
- `piptool i`/`up`:原 `cmd: ["pip","install","${PKG}"]` 改为 `def i(packages: list[str]) -> None: subprocess.run(["pip","install",*packages], check=True)`
|
||||
- `autofmt fmt`/`lint`:原 `cmd: ["ruff","format","${TARGET}"]` 改为 `def fmt(target: str = ".") -> None: subprocess.run(["ruff","format",target], check=True)`
|
||||
- `bumpversion`:原 `cmd: ["pf","bumpversion","patch"]` 改为直接调用 `bump_project_version` 函数(避免递归调用 pf)
|
||||
|
||||
**测试更新**(10 个):
|
||||
- `tests/cli/test_pdftool.py`、`test_piptool.py`、`test_packtool.py`、`test_filedate.py`、`test_filelevel.py`、`test_folderback.py`、`test_folderzip.py`、`test_sshcopyid.py`、`test_autofmt.py`、`test_bumpversion.py`
|
||||
- import 路径:`from pyflowx.ops import <tool>` 或 `from pyflowx.ops.<tool> import <func>`
|
||||
- 函数调用不变(函数体未变)
|
||||
|
||||
**删除文件**(10 个):
|
||||
- `configs/{pdftool,piptool,packtool,filedate,filelevel,folderback,folderzip,sshcopyid,autofmt,bumpversion}.yaml`
|
||||
|
||||
**验证**:每工具 `pf <tool> --list` + 对应 cli 测试通过
|
||||
|
||||
### 阶段4:迁移复杂工具(5 个)
|
||||
|
||||
**工具清单**:`pymake`/`envdev`/`gittool`/`sglang`/`msdownload`
|
||||
|
||||
**新建文件**(5 个):
|
||||
- `ops/pymake.py`:
|
||||
- 12 个 cmd subcommand(`b`/`bc`/`sync`/`c`/`t`/`tf`/`bumpversion`/`bumpmi`/`doc`/`lint`/`tox`):`@px.tool("pymake", subcommand="b", cmd=["uv","build"])` + 函数体 `pass` 或 docstring
|
||||
- hidden 内部 job(`test_coverage`/`pyrefly_check`/`git_add_all`/`git_push`/`git_push_tags`/`twine_publish`/`publish_python`):`hidden=True`,`cmd=[...]`
|
||||
- 6 个聚合(`ba`/`bump`/`cov`/`tc`/`p`/`pb`):`needs=[...]` + 函数体 `pass`
|
||||
- `cwd: Path = Path(".")` 作为函数签名参数,CLI `--cwd` 覆盖
|
||||
- `ops/envdev.py`:9 个 fn subcommand,来自 `ops/dev.py`;`install_rust` 用 `needs=["setup_rust","download_rustup"]` + `allow_upstream_skip=True`
|
||||
- `ops/gittool.py`:`a`/`i`/`isub` 是 fn,`c`/`p`/`pl` 是 cmd,`clean` 是 `hidden=True` cmd
|
||||
- `ops/sglang.py`/`ops/msdownload.py`:跨平台 fn,来自 `ops/llm.py`,`Constants.IS_LINUX`/`IS_WINDOWS` 判断在函数体内
|
||||
|
||||
**关键决策**:
|
||||
- pymake 的 `cwd` 参数:`TaskSpec` 的 `cwd` 由 `_build_task_spec` 从函数签名参数 `cwd: Path = Path(".")` 取,CLI `--cwd` 透传
|
||||
- pymake `bumpversion` 子命令:保持 cmd 模式 `cmd=["pf","bumpversion","patch"]`(调用外部 pf 命令)
|
||||
- envdev 跨平台逻辑保留在函数体内,仅装饰器迁移
|
||||
- gittool `c`(clean)是 hidden,被 `tc`/`p` 通过 needs 引用
|
||||
|
||||
**测试更新**(5 个):
|
||||
- `tests/cli/test_envdev.py`:`docker_login_tencent` import 从 `pyflowx.ops.dev` 改 `pyflowx.ops.dockercmd`;其余 envdev 函数从 `pyflowx.ops.envdev` 导入
|
||||
- `tests/cli/test_gittool.py`、`test_pymake.py`(若存在)、`test_llm.py`:import 路径改 `from pyflowx.ops import <tool>`
|
||||
|
||||
**删除文件**(5 个):
|
||||
- `configs/{pymake,envdev,gittool,sglang,msdownload}.yaml`
|
||||
|
||||
**验证**:`pf pymake tc`、`pf envdev --python-mirror aliyun`、`pf gittool a -m "msg"` 端到端;cli 测试通过
|
||||
|
||||
### 阶段5:清理 YAML 与配套代码
|
||||
|
||||
**删除文件**:
|
||||
- `src/pyflowx/yaml_loader.py`
|
||||
- `src/pyflowx/cli/yamlrun.py`
|
||||
- `src/pyflowx/registry.py`
|
||||
- `src/pyflowx/configs/`(整个目录,22 个 .yaml 已在前阶段删完,删目录本身)
|
||||
- `src/pyflowx/ops/{files,dev,media,system,llm,bumpversion}.py`(6 个功能模块,被 22 个工具模块替代)
|
||||
- `tests/test_yaml_loader.py`
|
||||
- `tests/test_registry.py`
|
||||
|
||||
**修改文件**:
|
||||
- `src/pyflowx/__init__.py`:
|
||||
- 删导入:`YamlLoadError`/`build_cli_parser`/`load_yaml`/`parse_yaml_string`/`run_cli`/`run_yaml`/`FnRegistry`/`get_fn`/`has_fn`/`register_fn`
|
||||
- 删 `__all__` 对应条目
|
||||
- `src/pyflowx/graph.py`:删 `from_yaml` 类方法(若存在)
|
||||
- `src/pyflowx/cli/pf.py`:
|
||||
- `_run_tool_or_yaml` → `_run_tool`:仅走 `importlib.import_module` + `run_tool`,删除 YAML 回退分支
|
||||
- `_LEGACY_TOOLS` 删 `yamlrun`(保留 `emlman`/`profiler`/`pxp`)
|
||||
- `_list_tools` 改为遍历 `_TOOL_REGISTRY` 取 description(不再读 YAML)
|
||||
- `_tool_description` 删除(不再读 YAML)
|
||||
- 模块 docstring 更新(去掉"YAML 配置"描述)
|
||||
- `src/pyflowx/ops/__init__.py`:从导入 6 个功能模块改为导入 22 个工具模块
|
||||
- `pyproject.toml`:
|
||||
- `[project.scripts]` 删 `yamlrun`
|
||||
- `[project] dependencies` 删 `pyyaml>=6.0.1`
|
||||
- `[project.optional-dependencies] dev` 删 `types-PyYAML>=6.0.12`
|
||||
|
||||
**全局检查**:
|
||||
```bash
|
||||
# 无残留 YAML 引用
|
||||
grep -rE "import yaml|load_yaml|run_cli|register_fn|YamlLoadError|build_cli_parser|parse_yaml_string|from_yaml|yamlrun" src/ tests/ || echo "OK"
|
||||
```
|
||||
|
||||
### 阶段6:补充测试 + 覆盖率达标
|
||||
|
||||
**扩展 `tests/test_tools.py`**:
|
||||
- 补齐阶段2-4 迁移中发现的边界(如 `cwd` 从 CLI 透传、`env`/`retry`/`timeout` 透传 TaskSpec、`_has_function_logic` 对 async 函数/类方法、`_unwrap_optional` 对 `X | None` 等)
|
||||
- 若 `tools.py` 覆盖率未达 95%,补齐
|
||||
|
||||
**新增 `tests/cli/test_pf_routing.py`**:
|
||||
- 测试 `PfApp` 路由:已知别名 → `_run_tool`、legacy → `_run_legacy`、未知 → 退出码 1
|
||||
- 测试 `_list_tools` 输出格式
|
||||
|
||||
**更新 `tests/cli/test_*.py`**:
|
||||
- 阶段2-4 已更新 import 路径,本阶段补充通过 `run_tool(...)` 或 `PfApp([tool, subcommand]).run()` 的端到端测试
|
||||
|
||||
**覆盖率验证**:
|
||||
```bash
|
||||
uv run pytest --cov=pyflowx --cov-branch --cov-report=term-missing
|
||||
```
|
||||
- 目标:`pyflowx.tools` ≥95%、整体 ≥95%(branch)
|
||||
- 若 `ops/<tool>.py` 因跨平台分支未覆盖,补 mock 测试(`Constants.IS_LINUX`/`IS_WINDOWS`)
|
||||
|
||||
**最终门禁**:
|
||||
```bash
|
||||
uv run ruff check . && uv run ruff format --check .
|
||||
uv run pyrefly check .
|
||||
uv run pytest --cov=pyflowx --cov-branch --cov-report=term-missing
|
||||
```
|
||||
全部门禁通过,覆盖率 ≥95%。
|
||||
|
||||
## Assumptions & Decisions
|
||||
|
||||
1. **延续已批准计划**:本计划是 `.trae/documents/方案C-迁移执行计划.md` 的续接,不改变其架构决策,仅推进执行
|
||||
2. **test_envdev.py 阶段2 不改**:该文件测试 `ops/dev.py` 函数,dev.py 在阶段4 前仍存在,无需改动;阶段4 迁移 envdev 时再统一更新
|
||||
3. **函数体复制策略**:阶段3-4 从旧功能模块复制函数体到新工具模块,旧模块在阶段5 统一删除,避免提前破坏未迁移工具
|
||||
4. **CLI 兼容**:57 个 pf 别名不变、子命令名不变、参数名(snake_case → kebab-case)不变、全局选项保留、退出码三态保留
|
||||
5. **cmd 任务改 fn 模式**:`piptool i`/`autofmt fmt` 等原 `cmd+${VAR}` 改为函数体内 `subprocess.run`,verbose 由函数内 `print` 补偿
|
||||
6. **聚合任务判定**:`_has_function_logic` 用 ast 分析函数体(pass/.../docstring 视为无逻辑),迁移时聚合任务的函数体写 `pass`
|
||||
7. **每阶段独立验证**:每阶段结束运行 ruff + pyrefly + pytest,确保不累积错误
|
||||
8. **自动提交**:每阶段完成且门禁通过后自动 git commit + push(分支已跟踪远程),遵循中文提交信息风格
|
||||
|
||||
## Verification Steps
|
||||
|
||||
每阶段结束运行:
|
||||
```bash
|
||||
uv run ruff check . && uv run ruff format --check .
|
||||
uv run pyrefly check .
|
||||
uv run pytest tests/ --cov=pyflowx --cov-branch --cov-report=term-missing -x
|
||||
```
|
||||
|
||||
端到端验证(阶段4 后):
|
||||
```bash
|
||||
pf clr # 单命令
|
||||
pf which python # 单命令 + positional
|
||||
pf pdftool m a.pdf b.pdf --output merged.pdf # 多 subcommand + positional nargs+
|
||||
pf pdftool --list # --list 输出
|
||||
pf pymake tc # DAG + 聚合 + 内部 job(hidden)
|
||||
pf pymake b --cwd /tmp # cwd CLI 透传
|
||||
pf envdev --python-mirror aliyun # 跨平台 fn
|
||||
pf gittool a -m "msg" # fn subcommand
|
||||
pf # 列出所有工具(从 _TOOL_REGISTRY)
|
||||
pf unknown_tool # 退出码 1
|
||||
```
|
||||
|
||||
阶段5 后全局清理验证:
|
||||
```bash
|
||||
grep -rE "import yaml|load_yaml|run_cli|register_fn|YamlLoadError|build_cli_parser|parse_yaml_string|from_yaml|yamlrun" src/ tests/ || echo "OK"
|
||||
```
|
||||
|
||||
阶段6 最终:`pf pymake cov` 通过,覆盖率 ≥95%(branch)。
|
||||
@@ -0,0 +1,251 @@
|
||||
# 方案C 迁移执行计划
|
||||
|
||||
## Summary
|
||||
|
||||
承接已批准的高层设计 `.trae/documents/方案C-移除YAML-统一px-tool装饰器.md`。当前 `src/pyflowx/tools.py` 已完整实现(@px.tool 装饰器 + ToolSpec + 注册表 + CLI 生成 + 依赖收集 + run_tool 入口),其余所有代码仍处于旧 YAML 模式。本计划从当前状态起,执行 6 个阶段,完全移除 YAML 支持,统一用 `@px.tool` 装饰器,覆盖率 ≥95%。
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
**已完成**:
|
||||
- `src/pyflowx/tools.py`(607 行):ToolSpec/`@tool`/注册表/`_add_func_args_to_parser`/`_collect_with_deps`/`_build_task_spec`/`run_tool`,完整实现三态退出码(0/1/130)
|
||||
|
||||
**未完成(本计划覆盖)**:
|
||||
1. `tests/test_tools.py` 不存在
|
||||
2. `__init__.py` 仍导出 YAML API(`load_yaml`/`run_cli`/`YamlLoadError`/`build_cli_parser`/`parse_yaml_string`/`run_yaml`)+ `register_fn`/`get_fn`/`has_fn`/`FnRegistry`,未导出 `tool`/`ToolSpec`/`run_tool`/`list_tools`/`list_subcommands`
|
||||
3. `pf.py` 仍走 `_run_yaml` → `px.run_cli(config_path, argv)`,57 个别名指向 .yaml 文件;`_LEGACY_TOOLS` 含 `yamlrun`
|
||||
4. `ops/` 仍是 6 个功能模块(`files/dev/media/system/llm/bumpversion`),共 79 个 `@px.register_fn`;无 22 个工具模块
|
||||
5. `configs/` 仍有 22 个 .yaml 文件
|
||||
6. `tests/cli/test_*.py`(16 个)+ `tests/test_yaml_loader.py`(49KB)+ `tests/test_registry.py` 仍依赖旧结构
|
||||
7. `pyproject.toml` 仍含 `yamlrun` 脚本入口、`pyyaml` 运行时依赖、`types-PyYAML` dev 依赖
|
||||
8. `src/pyflowx/yaml_loader.py`、`src/pyflowx/cli/yamlrun.py`、`src/pyflowx/registry.py` 仍存在
|
||||
9. `graph.py` 仍含 `from_yaml` 类方法(若存在)
|
||||
|
||||
**关键依赖关系**:
|
||||
- `register_fn` 函数被 YAML `fn:` 字段引用,迁移后改用 `@px.tool` 直接持有函数对象,不再需要 `register_fn`/`get_fn`/`has_fn`
|
||||
- 现有 `tests/cli/test_*.py` 直接调用 `media.pdf_merge(...)`/`dev._get_installed_packages()` 等函数体,迁移后函数体不变,仅 import 路径与装饰器变化
|
||||
- `tools.py` 中 `run_tool` 依赖 `px.Graph.from_specs` + `px.run`,与 YAML 无耦合,可直接使用
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 阶段1:tools.py 单元测试 + 公共 API 导出
|
||||
|
||||
**文件**:
|
||||
- 新建 `tests/test_tools.py`
|
||||
- 修改 `src/pyflowx/__init__.py`
|
||||
|
||||
**test_tools.py 覆盖点**(对应 `tools.py` 各分支):
|
||||
1. 注册:`@tool` 装饰器注册到 `_TOOL_REGISTRY`,`list_tools`/`list_subcommands`/`get_tool`/`clear_tool_registry`
|
||||
2. 重复检测:同名同 subcommand 二次注册抛 `ValueError`
|
||||
3. CLI 生成各类型:
|
||||
- positional(无默认值)/ option(有默认值)
|
||||
- `list[Path]` → `nargs="+"`
|
||||
- `Literal["a","b"]` → `choices`
|
||||
- `int`/`float`/`Path`/`str` → `type=`
|
||||
- `bool` 默认 False → `--flag store_true`;默认 True → `--no-flag store_false`
|
||||
- `Optional[X]` → `_unwrap_optional`
|
||||
4. 单命令工具执行:`run_tool` 返回 0(成功)/1(失败)/130(KeyboardInterrupt)
|
||||
5. cmd 分支:`cmd=["..."]` 时构建 TaskSpec 用 cmd,函数体不执行
|
||||
6. fn 分支:无 cmd 时执行函数体,kwargs 透传
|
||||
7. 聚合任务:`_is_aggregate` 判定 pass/.../docstring,fn=`_noop`
|
||||
8. DAG:`_collect_with_deps` BFS 拓扑顺序(依赖在前)
|
||||
9. hidden:`list_subcommands(include_hidden=False)` 排除,`_build_parser` 不暴露
|
||||
10. 全局选项:`--dry-run`/`--quiet`/`--strategy`/`--list`
|
||||
11. 退出码三态:成功 0、`TaskFailedError` → 1、`PyFlowXError` → 1、`KeyboardInterrupt` → 130
|
||||
12. 未注册工具:`run_tool("unknown")` → 1,stderr 提示
|
||||
13. `_handle_list` 输出格式
|
||||
14. `_snake_to_kebab` 转换
|
||||
|
||||
**__init__.py 改动**:
|
||||
- 新增导入:`from .tools import ToolSpec, list_subcommands, list_tools, run_tool, tool`
|
||||
- 新增 `__all__` 条目:`ToolSpec`/`list_subcommands`/`list_tools`/`run_tool`/`tool`
|
||||
- 暂时**保留** YAML/register_fn 导出(阶段5 才删),避免提前破坏
|
||||
|
||||
**验证**:`uv run pytest tests/test_tools.py --cov=pyflowx.tools --cov-branch` 通过,`tools.py` 覆盖率 ≥95%
|
||||
|
||||
### 阶段2:迁移简单单命令工具(7 个)
|
||||
|
||||
**工具**:`clr`/`which`/`taskkill`/`reseticoncache`/`screenshot`/`dockercmd`/`lscalc`
|
||||
|
||||
**新建文件**(`ops/<tool>.py`,一文件一工具):
|
||||
- `ops/clr.py`:`@px.tool("clr")` 包装 `clear_screen_run`(来自 `ops/system.py`)
|
||||
- `ops/which.py`:`@px.tool("which")` 包装 `which_run`
|
||||
- `ops/taskkill.py`:`@px.tool("taskkill")` 包装 `taskkill_run`
|
||||
- `ops/reseticoncache.py`:`@px.tool("reseticoncache")` 包装 `reset_icon_cache_run`
|
||||
- `ops/screenshot.py`:`@px.tool("screenshot")` 包装 `take_screenshot_full`/`take_screenshot_area`(两个 subcommand)
|
||||
- `ops/dockercmd.py`:`@px.tool("dockercmd")` 包装 `docker_login_tencent`
|
||||
- `ops/lscalc.py`:`@px.tool("lscalc")` 包装 `run_ls_dyna`/`run_ls_dyna_mpi`/`check_ls_dyna_status`(多 subcommand)
|
||||
|
||||
**函数迁移策略**:
|
||||
- 从 `ops/system.py` 把对应函数体复制到新 `ops/<tool>.py`,改 `@px.register_fn` 为 `@px.tool(...)`
|
||||
- 函数签名保持不变(测试兼容),仅装饰器变化
|
||||
- 跨工具复用的常量(`LS_DYNA_COMMANDS` 等)若被多工具引用,放 `ops/_common.py`;否则就地保留
|
||||
|
||||
**测试更新**:
|
||||
- `tests/cli/test_reseticoncache.py`、`test_screenshot.py`、`test_lscalc.py`、`test_system_run.py`:import 路径从 `from pyflowx.ops import system` 改为 `from pyflowx.ops import <tool>`
|
||||
- 函数调用不变(`system.clear_screen_run()` → `clr.clear_screen_run()` 或类似)
|
||||
|
||||
**删除**:`configs/{clr,which,taskkill,reseticoncache,screenshot,dockercmd,lscalc}.yaml`(7 个)
|
||||
|
||||
**验证**:`pf clr`、`pf which python`、`pf lscalc --list` 端到端可用;`uv run pytest tests/cli/test_{reseticoncache,screenshot,lscalc,system_run}.py` 通过
|
||||
|
||||
### 阶段3:迁移多 subcommand fn 工具(10 个)
|
||||
|
||||
**工具**:`pdftool`(14 子命令)/`piptool`/`packtool`/`filedate`/`filelevel`/`folderback`/`folderzip`/`sshcopyid`/`autofmt`/`bumpversion`
|
||||
|
||||
**新建文件**:
|
||||
- `ops/pdftool.py`:14 个 `@px.tool("pdftool", subcommand="m"/"s"/"c"/...)`,函数来自 `ops/media.py`
|
||||
- `ops/piptool.py`:多 subcommand(`i`/`up`/`un`/`re`/`fr`/`dl`),`i`/`up` 原 cmd 模式改 fn 模式(`subprocess.run` 在函数体内)
|
||||
- `ops/packtool.py`:多 subcommand,来自 `ops/system.py`(`pack_source`/`pack_wheel`/`pack_dependencies`/`clean_build_dir`)
|
||||
- `ops/filedate.py`/`filelevel.py`/`folderback.py`/`folderzip.py`:来自 `ops/files.py`
|
||||
- `ops/sshcopyid.py`:来自 `ops/system.py`(`ssh_copy_id`)
|
||||
- `ops/autofmt.py`:4 subcommand(`fmt`/`lint`/`doc`/`sync`),`fmt`/`lint` 原 cmd 改 fn 模式
|
||||
- `ops/bumpversion.py`:positional `PART` 默认 "patch" + `--no-tag store_true`,来自 `ops/bumpversion.py`
|
||||
|
||||
**关键决策**:
|
||||
- pdftool 函数签名已有完整类型注解,迁移最直接;`pdf_merge(input_paths: list[Path], output_path: Path)` → `@px.tool("pdftool", subcommand="m")` + `inputs: list[Path]`(positional nargs="+") + `output: Path = Path("merged.pdf")`
|
||||
- 参数名映射:YAML 的 `INPUTS`/`OUTPUT`/`PASSWORD` → snake_case `inputs`/`output`/`password`,CLI 自动转 kebab-case `--output`/`--password`,**保持 CLI 兼容**
|
||||
- `piptool i`/`up`:原 `cmd: ["pip", "install", "${PKG}"]` 改为 `def i(packages: list[str]) -> None: subprocess.run(["pip", "install", *packages], check=True)`
|
||||
- `autofmt fmt`/`lint`:原 `cmd: ["ruff", "format", "${TARGET}"]` 改为 `def fmt(target: str = ".") -> None: subprocess.run(["ruff", "format", target], check=True)`
|
||||
- bumpversion:positional `part: str = "patch"` + `no_tag: bool = False`(store_true),原 `cmd: ["pf", "bumpversion", "patch"]` 改为直接调用 `bump_project_version` 函数
|
||||
|
||||
**测试更新**:
|
||||
- `tests/cli/test_pdftool.py`、`test_piptool.py`、`test_packtool.py`、`test_filedate.py`、`test_filelevel.py`、`test_folderback.py`、`test_folderzip.py`、`test_sshcopyid.py`、`test_autofmt.py`、`test_bumpversion.py`:import 路径改 `from pyflowx.ops import <tool>`
|
||||
- 函数调用不变(函数体未变)
|
||||
|
||||
**删除**:`configs/{pdftool,piptool,packtool,filedate,filelevel,folderback,folderzip,sshcopyid,autofmt,bumpversion}.yaml`(10 个)
|
||||
|
||||
**验证**:每工具 `pf <tool> --list` + 至少一个 subcommand 端到端;对应 cli 测试通过
|
||||
|
||||
### 阶段4:迁移复杂工具(5 个)
|
||||
|
||||
**工具**:`pymake`/`envdev`/`gittool`/`sglang`/`msdownload`
|
||||
|
||||
**新建文件**:
|
||||
- `ops/pymake.py`:
|
||||
- 12 个 cmd subcommand(`b`/`bc`/`sync`/`c`/`t`/`tf`/`bumpversion`/`bumpmi`/`doc`/`lint`/`tox`):`@px.tool("pymake", subcommand="b", cmd=["uv","build"])` + `def b(cwd: Path = Path(".")) -> None: pass`
|
||||
- 4 个 hidden 内部 job(`test_coverage`/`pyrefly_check`/`git_add_all`/`git_push`/`git_push_tags`/`twine_publish`/`publish_python`):`hidden=True`,`cmd=[...]`
|
||||
- 6 个聚合(`ba`/`bump`/`cov`/`tc`/`p`/`pb`):`needs=[...]` + 函数体 `pass`
|
||||
- `cwd: Path = Path(".")` 作为函数签名参数,CLI `--cwd` 覆盖
|
||||
- `ops/envdev.py`:9 个 fn subcommand,4 处 `allow_upstream_skip=True`;`install_rust` 用 `needs=["setup_rust","download_rustup"]` + `allow_upstream_skip=True`
|
||||
- `ops/gittool.py`:`a`/`i`/`isub` 是 fn,`c`/`p`/`pl` 是 cmd,`clean` 是 `hidden=True` cmd
|
||||
- `ops/sglang.py`/`ops/msdownload.py`:跨平台 fn(来自 `ops/llm.py`),`Constants.IS_LINUX`/`IS_WINDOWS` 判断在函数体内
|
||||
|
||||
**关键决策**:
|
||||
- pymake 的 `cwd` 参数:`TaskSpec` 的 `cwd` 由 `_build_task_spec` 从 `variables["cwd"]` 取,CLI `--cwd` 透传;装饰器级 `cwd=None`
|
||||
- pymake `bumpversion` 子命令:原 `cmd: ["pf","bumpversion","patch"]` 改为直接 `cmd=["pf","bumpversion","patch"]`(保持 cmd 模式,因调用外部 pf 命令)
|
||||
- envdev 跨平台逻辑保留在函数体内(已实现),仅装饰器迁移
|
||||
- gittool `c`(clean)是 hidden,被 `tc`/`p` 通过 needs 引用
|
||||
|
||||
**测试更新**:
|
||||
- `tests/cli/test_envdev.py`、`test_gittool.py`、`test_llm.py`:import 路径改 `from pyflowx.ops import <tool>`
|
||||
- `test_pdftool.py` 等(若引用 gittool/pymake 的函数)同步改
|
||||
|
||||
**删除**:`configs/{pymake,envdev,gittool,sglang,msdownload}.yaml`(5 个)
|
||||
|
||||
**验证**:`pf pymake tc`、`pf pymake b`、`pf envdev --python-mirror aliyun`、`pf gittool a -m "msg"` 端到端;cli 测试通过
|
||||
|
||||
### 阶段5:清理 YAML 与配套代码
|
||||
|
||||
**删除文件**:
|
||||
- `src/pyflowx/yaml_loader.py`
|
||||
- `src/pyflowx/cli/yamlrun.py`
|
||||
- `src/pyflowx/registry.py`
|
||||
- `src/pyflowx/configs/`(整个目录,22 个 .yaml 已在前阶段删完,删目录本身)
|
||||
- `src/pyflowx/ops/{files,dev,media,system,llm,bumpversion}.py`(6 个功能模块,被 22 个工具模块替代)
|
||||
- `tests/test_yaml_loader.py`
|
||||
- `tests/test_registry.py`(若专为 register_fn 写;若含其他内容则保留并更新)
|
||||
|
||||
**修改文件**:
|
||||
- `src/pyflowx/__init__.py`:
|
||||
- 删导入:`YamlLoadError`/`build_cli_parser`/`load_yaml`/`parse_yaml_string`/`run_cli`/`run_yaml`/`FnRegistry`/`get_fn`/`has_fn`/`register_fn`
|
||||
- 删 `__all__` 对应条目
|
||||
- `src/pyflowx/graph.py`:删 `from_yaml` 类方法(若存在)
|
||||
- `src/pyflowx/cli/pf.py`:
|
||||
- `_TOOL_ALIASES` 保留 57 个别名(值已是工具名,无 .yaml 含义)
|
||||
- `_run_yaml` → `_run_tool`:`importlib.import_module(f"pyflowx.ops.{tool_name}")` 触发 `@px.tool` 注册 → `from pyflowx.tools import run_tool; return run_tool(tool_name, argv)`
|
||||
- `_LEGACY_TOOLS` 删 `yamlrun`(保留 `emlman`/`profiler`/`pxp`)
|
||||
- `_list_tools` 改为遍历 `_TOOL_REGISTRY` 取 description
|
||||
- `_tool_description` 删除(不再读 YAML)
|
||||
- `src/pyflowx/ops/__init__.py`:从导入 6 个功能模块改为导入 22 个工具模块
|
||||
- `pyproject.toml`:
|
||||
- `[project.scripts]` 删 `yamlrun`
|
||||
- `[project] dependencies` 删 `pyyaml>=6.0.1`
|
||||
- `[project.optional-dependencies] dev` 删 `types-PyYAML>=6.0.12`
|
||||
- `src/pyflowx/yaml_loader.py` 内 `import pyflowx.ops.*` 触发注册的副作用:迁移后由 `pf.py` 的 `importlib.import_module` 显式触发
|
||||
|
||||
**全局检查**:
|
||||
- `Grep` 全局 `import yaml`、`load_yaml`、`run_cli`、`run_yaml`、`register_fn`、`get_fn`、`has_fn`、`FnRegistry`、`YamlLoadError`、`build_cli_parser`、`parse_yaml_string`、`from_yaml`、`yamlrun`,确认无残留引用
|
||||
- `uv run ruff check .` + `uv run pyrefly check .` 通过
|
||||
|
||||
### 阶段6:补充测试 + 覆盖率达标
|
||||
|
||||
**扩展 `tests/test_tools.py`**:
|
||||
- 阶段1 已覆盖核心分支,本阶段补齐阶段2-4 迁移中发现的边界(如 `cwd` 从 CLI 透传、`env`/`retry`/`timeout` 透传 TaskSpec、`_has_function_logic` 对 async 函数/类方法、`_unwrap_optional` 对 `X | None`(3.10+) 等)
|
||||
- 阶段1 后若 `tools.py` 覆盖率未达 95%,本阶段补齐
|
||||
|
||||
**新增 `tests/cli/test_pf_routing.py`**:
|
||||
- 测试 `PfApp` 路由:已知别名 → `_run_tool`、legacy → `_run_legacy`、未知 → 退出码 1
|
||||
- 测试 `_list_tools` 输出格式
|
||||
|
||||
**更新 `tests/cli/test_*.py`**(16 个):
|
||||
- 阶段2-4 已更新 import 路径,本阶段补充通过 `run_tool(...)` 或 `PfApp([tool, subcommand]).run()` 的端到端测试
|
||||
- 删除 `tests/test_yaml_loader.py`、`tests/test_registry.py`(若纯 register_fn 测试)
|
||||
|
||||
**覆盖率验证**:
|
||||
```bash
|
||||
uv run pytest --cov=pyflowx --cov-branch --cov-report=term-missing
|
||||
```
|
||||
- 目标:`pyflowx.tools` ≥95%、整体 ≥95%(branch)
|
||||
- 若 `ops/<tool>.py` 因跨平台分支未覆盖,补 mock 测试(`Constants.IS_LINUX`/`IS_WINDOWS`)
|
||||
|
||||
**最终门禁**:
|
||||
```bash
|
||||
uv run ruff check . && uv run ruff format --check .
|
||||
uv run pyrefly check .
|
||||
uv run pytest --cov=pyflowx --cov-branch --cov-report=term-missing
|
||||
```
|
||||
全部门禁通过,覆盖率 ≥95%。
|
||||
|
||||
## Assumptions & Decisions
|
||||
|
||||
1. **tools.py 实现不重写**:已存在的 `tools.py` 经审查实现完整,本计划仅补测试,不改实现(除非测试发现 bug)
|
||||
2. **函数体保持不变**:迁移仅改装饰器(`@px.register_fn` → `@px.tool`)与文件位置,函数体逻辑、签名、类型注解保持不变,最大化测试兼容
|
||||
3. **CLI 兼容**:57 个 pf 别名不变、子命令名不变、参数名(snake_case → kebab-case)不变、全局选项保留、退出码三态保留
|
||||
4. **cmd 任务改 fn 模式**:`piptool i`/`autofmt fmt` 等原 `cmd+${VAR}` 改为函数体内 `subprocess.run`,失去 cmd 任务的 verbose 输出,但模型更统一;verbose 由函数内 `print` 补偿
|
||||
5. **聚合任务判定**:`_has_function_logic` 用 ast 分析函数体(pass/.../docstring 视为无逻辑),迁移时聚合任务的函数体写 `pass`
|
||||
6. **ops 重组**:6 功能模块 → 22 工具模块,跨工具复用函数(如 `add_date_prefix` 被 filedate 用)放 `ops/_common.py`;未被 YAML 引用的约 20 个 `register_fn` 辅助函数(如 `get_file_timestamp`/`has_files`)去掉装饰器降级为模块级私有
|
||||
7. **register_fn 移除时机**:阶段5 统一移除,避免阶段2-4 迁移中破坏未迁移工具
|
||||
8. **pyyaml 依赖移除**:阶段5 从 `pyproject.toml` 删除,需确认无其他模块隐式依赖(全局 grep `import yaml` 验证)
|
||||
9. **测试更新策略**:阶段2-4 每阶段更新对应 cli 测试 import,阶段6 补充端到端测试;`tests/test_yaml_loader.py` 阶段5 删除
|
||||
|
||||
## Verification Steps
|
||||
|
||||
每阶段结束运行:
|
||||
```bash
|
||||
uv run ruff check . && uv run ruff format --check .
|
||||
uv run pyrefly check .
|
||||
uv run pytest tests/ --cov=pyflowx --cov-branch --cov-report=term-missing -x
|
||||
```
|
||||
|
||||
端到端验证(阶段4 后):
|
||||
```bash
|
||||
pf clr # 单命令
|
||||
pf which python # 单命令 + positional
|
||||
pf pdftool m a.pdf b.pdf --output merged.pdf # 多 subcommand + positional nargs+
|
||||
pf pdftool --list # --list 输出
|
||||
pf pymake tc # DAG + 聚合 + 内部 job(hidden)
|
||||
pf pymake b --cwd /tmp # cwd CLI 透传
|
||||
pf envdev --python-mirror aliyun # 跨平台 fn
|
||||
pf gittool a -m "msg" # fn subcommand
|
||||
pf # 列出所有工具(从 _TOOL_REGISTRY)
|
||||
pf unknown_tool # 退出码 1
|
||||
```
|
||||
|
||||
阶段5 后全局清理验证:
|
||||
```bash
|
||||
# 无残留 YAML 引用
|
||||
grep -r "import yaml" src/ tests/ || echo "OK"
|
||||
grep -r "load_yaml\|run_cli\|register_fn\|YamlLoadError" src/ tests/ || echo "OK"
|
||||
```
|
||||
|
||||
阶段6 最终:`pf pymake cov` 通过,覆盖率 ≥95%(branch)。
|
||||
@@ -0,0 +1,23 @@
|
||||
# 开发流程约束
|
||||
|
||||
本规则补充 `self-driven.md`,定义执行过程记录与规则变更的两条硬约束。
|
||||
|
||||
## 执行过程记录
|
||||
|
||||
- **每次迭代**(一个完整的"计划 → 实现 → 测试 → 文档 → 验证"闭环)必须记录到
|
||||
`.trae/docs/` 目录,文件名形如 `iter-NN-<主题>.md`,`NN` 为零填充序号。
|
||||
- 记录内容至少包括:本轮目标、改动文件清单、关键决策与依据、验证结果、遗留事项。
|
||||
- **每 5 次迭代后**清理 `.trae/docs/`:
|
||||
- 把需要长期保留的整合性内容(可复用模式、踩坑总结、设计决策)
|
||||
归档到 `.trae/skills/` 对应技能文档中;
|
||||
- 其余过程性记录删除;
|
||||
- docs 目录只保留最近未归档的迭代记录(即第 6 次迭代开始前,清理 1–5 号记录)。
|
||||
- 归档动作需在迭代记录中标注"已归档至 skills/<文件>"。
|
||||
|
||||
## 规则变更约束
|
||||
|
||||
- 任何对 `.trae/rules/` 下文件的修改(新增、编辑、删除)**必须先询问用户**,
|
||||
获得明确授权后方可变动;**未授权前不得擅自修改 rules**。
|
||||
- 用户在对话中显式要求写入的规则内容(如"把 X 写入 rules")视为已授权,
|
||||
可直接写入,写入后在回复中说明改了哪个文件、加了什么。
|
||||
- 规则变更后需同步更新 `project_memory.md` 中的"开发约定"小节。
|
||||
@@ -150,7 +150,7 @@ uvx --from pyflowx pymake cov
|
||||
|
||||
## Git 与提交
|
||||
|
||||
- **不自动提交/push**:除非用户明确要求。
|
||||
- **自动提交**:任务完成后自动 `git add`(按文件名)+ `git commit` + `git push`(仅当分支已跟踪远程时执行 push;新分支跳过 push 并在总结中说明)。
|
||||
- **不修改 git config**。
|
||||
- **不运行破坏性命令**(`push --force`/`reset --hard`/`clean -f`)除非用户明确要求。
|
||||
- **staging**:按文件名添加,不用 `git add -A`/`git add .`,避免误加敏感文件。
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
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。
|
||||
- 给出本轮变更清单(改了哪些文件、为什么)。
|
||||
|
||||
## 多阶段项目
|
||||
|
||||
当项目按阶段(P0/P1/P2/...)划分时,初始确认的目标是**整个项目**,而非单个阶段。
|
||||
|
||||
- **阶段是里程碑,不是边界**:确认"推进 P1"只是指定下一步重点,不缩小整体目标范围;
|
||||
也不意味着 P1 完成后就可以停下。整体目标 = 所有阶段交付完毕。
|
||||
- **阶段完成不是停止条件**:某阶段验收标准全部满足后,**自动进入下一阶段的「计划」步骤**,
|
||||
不停下来输出"是否继续"或"是否扩展范围"的询问。仅在阶段切换时用一句话说明
|
||||
"进入 Pn,重点:…",然后继续自驱动迭代。
|
||||
- **「收尾」仅适用于整个项目完成**:只有所有阶段(含最后一个)都交付后,才执行
|
||||
「收尾」输出最终总结。单阶段完成 → 进入下一阶段的「计划」,不触发收尾。
|
||||
- **跨阶段依赖**:若下一阶段依赖外部资源(如新依赖审批、环境配置),属"不可恢复的失败"
|
||||
暂停条件;否则一律连续推进。
|
||||
- **阶段范围超出预期**:执行中发现某阶段需要显著扩大范围或改变方向(如 P2 本来只做
|
||||
表格抽取,发现必须先重写 IR),属"超出初始确认范围"暂停条件,需找用户确认。
|
||||
|
||||
## 暂停条件(仅在以下情况中断自驱动找用户)
|
||||
|
||||
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,便于后续会话续接。
|
||||
@@ -5,6 +5,7 @@
|
||||
[](https://github.com/gookeryoung/pyflowx/actions/workflows/ci.yml)
|
||||
[](https://pypi.org/project/pyflowx/)
|
||||
[](https://pypi.org/project/pyflowx/)
|
||||
[](https://pyflowx.readthedocs.io/zh/latest/)
|
||||
[](https://github.com/gookeryoung/pyflowx)
|
||||
[](https://github.com/gookeryoung/pyflowx/blob/main/LICENSE)
|
||||
|
||||
@@ -31,7 +32,8 @@ PyFlowX 把"任务依赖"这件事做到极致简单:**参数名就是依赖
|
||||
- **图级默认值** —— `GraphDefaults` 统一配置 retry/timeout/concurrency 等
|
||||
- **CLI 运行器** —— `CliRunner` 把多个图映射为命令行子命令,替代 Makefile
|
||||
- **可观测** —— `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 文件直接加载执行
|
||||
- **最小依赖** —— 仅依赖标准库 + PyYAML(3.8 需 `graphlib_backport`、`typing-extensions`)
|
||||
- **97% 测试覆盖** —— 分支覆盖率 >= 95%
|
||||
|
||||
## 安装
|
||||
@@ -300,31 +302,123 @@ runner.run_cli() # 解析 sys.argv 并执行
|
||||
命令行用法:
|
||||
|
||||
```bash
|
||||
python build.py clean # 执行 clean 图
|
||||
python build.py build --strategy thread # 覆盖执行策略
|
||||
python build.py test --dry-run # 仅打印执行计划
|
||||
python build.py --list # 列出所有命令
|
||||
python build.py --quiet # 静默模式
|
||||
pf pymake clean # 执行 clean 图
|
||||
pf pymake build --strategy thread # 覆盖执行策略
|
||||
pf pymake test --dry-run # 仅打印执行计划
|
||||
pf pymake --list # 列出所有命令
|
||||
pf pymake --quiet # 静默模式
|
||||
```
|
||||
|
||||
`verbose=True`(默认)时打印任务生命周期(开始/成功/失败/跳过)与命令输出;`--quiet` 关闭。
|
||||
|
||||
## 示例
|
||||
## YAML 任务编排
|
||||
|
||||
仓库 `examples/` 目录包含完整示例:
|
||||
PyFlowX 支持 GitHub Actions 风格的声明式 YAML 任务编排,从 YAML 文件直接加载任务图。
|
||||
|
||||
- [`etl_pipeline.py`](examples/etl_pipeline.py) —— ETL 流水线(sequential)
|
||||
- [`parallel_run.py`](examples/parallel_run.py) —— 并行执行对比(thread vs sequential)
|
||||
- [`async_aggregation.py`](examples/async_aggregation.py) —— 异步聚合 + Context 注入
|
||||
### 编程式 API
|
||||
|
||||
运行:
|
||||
```python
|
||||
import pyflowx as px
|
||||
|
||||
# 从 YAML 文件加载任务图
|
||||
graph = px.Graph.from_yaml("pipeline.yaml")
|
||||
report = px.run(graph, strategy="thread")
|
||||
|
||||
# 或用函数式 API
|
||||
graph = px.load_yaml("pipeline.yaml")
|
||||
graph = px.parse_yaml_string("""
|
||||
jobs:
|
||||
hello:
|
||||
cmd: ["echo", "hello"]
|
||||
""")
|
||||
```
|
||||
|
||||
### CLI 入口
|
||||
|
||||
通过 `pf` 统一入口调用(详见 [pf 工具](#cli-工具) 章节):
|
||||
|
||||
```bash
|
||||
python examples/etl_pipeline.py
|
||||
python examples/parallel_run.py
|
||||
python examples/async_aggregation.py
|
||||
# 执行 YAML 任务图
|
||||
pf yamlrun pipeline.yaml
|
||||
|
||||
# 指定执行策略
|
||||
pf yamlrun pipeline.yaml --strategy thread
|
||||
|
||||
# 仅打印任务分层,不执行
|
||||
pf yamlrun pipeline.yaml --dry-run
|
||||
|
||||
# 列出所有任务名
|
||||
pf yamlrun pipeline.yaml --list
|
||||
|
||||
# 静默模式
|
||||
pf yamlrun pipeline.yaml --quiet
|
||||
```
|
||||
|
||||
### YAML Schema(GitHub 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
|
||||
@@ -441,12 +535,14 @@ uv run pytest --cov=pyflowx --cov-fail-under=95
|
||||
uv run mypy
|
||||
|
||||
# 代码风格
|
||||
uv run ruff check src tests examples
|
||||
uv run ruff format --check src tests examples
|
||||
uv run ruff check src tests
|
||||
uv run ruff format --check src tests
|
||||
```
|
||||
|
||||
## 模块结构
|
||||
|
||||
### 核心
|
||||
|
||||
| 模块 | 职责 |
|
||||
|------|------|
|
||||
| `task.py` | 纯数据结构:`TaskSpec`、`RetryPolicy`、`TaskHooks`、`TaskStatus` |
|
||||
@@ -455,11 +551,22 @@ uv run ruff format --check src tests examples
|
||||
| `context.py` | 上下文注入:参数名→依赖解析 |
|
||||
| `command.py` | 命令执行:`run_command`(list/shell/Callable) |
|
||||
| `conditions.py` | 条件执行:内置条件与组合器 |
|
||||
| `executors.py` | 执行器与 `run` 入口:四种策略共享模块级辅助 |
|
||||
| `executors.py` | 执行器与 `run` 入口:四种策略共享模块级辅助;verbose 统一应用到 spec |
|
||||
| `storage.py` | 状态后端:`MemoryBackend` / `JSONBackend`(batch flush) |
|
||||
| `runner.py` | CLI 运行器:`CliRunner` |
|
||||
| `report.py` | 运行结果:`RunReport` / `TaskResult` |
|
||||
| `tools.py` | `@px.tool` 装饰器:`ToolSpec` / `run_tool` / `list_tools` / `list_subcommands` |
|
||||
| `profiling.py` | 性能分析:`Profiler` 任务耗时统计 |
|
||||
| `errors.py` | 错误家族:`PyFlowXError` 子类 |
|
||||
| `ops/` | CLI 工具模块集合(每个子模块用 `@px.tool` 注册一个工具) |
|
||||
|
||||
### CLI 工具
|
||||
|
||||
| 模块 | 职责 |
|
||||
|------|------|
|
||||
| `cli/pf.py` | 统一入口:`pf <tool> [command]`,按需导入 `ops/<tool>.py` 触发 `@px.tool` 注册 |
|
||||
| `cli/profiler.py` | 性能分析 CLI |
|
||||
| `cli/emlmanager.py` | 邮件管理 CLI |
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
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:
|
||||
|
||||
@px.tool 工具
|
||||
-------------
|
||||
|
||||
.. autoclass:: pyflowx.ToolSpec
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
.. autofunction:: pyflowx.tool
|
||||
.. autofunction:: pyflowx.run_tool
|
||||
.. autofunction:: pyflowx.list_tools
|
||||
.. autofunction:: pyflowx.list_subcommands
|
||||
|
||||
命令执行
|
||||
--------
|
||||
|
||||
.. 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
|
||||
@@ -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
|
||||
- 更新 README:CLI 示例改为 ``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``
|
||||
- 参数名即依赖的上下文注入机制
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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`。
|
||||
@@ -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`。
|
||||
@@ -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`。
|
||||
@@ -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`。
|
||||
@@ -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`
|
||||
@@ -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` 开始使用。
|
||||
@@ -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 入口
|
||||
+15
-37
@@ -7,49 +7,25 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
||||
]
|
||||
dependencies = [
|
||||
"graphlib_backport >= 1.0.0; python_version < '3.9'",
|
||||
"typing-extensions>=4.13.2; python_version < '3.10'",
|
||||
"rich>=13.7.0",
|
||||
"typer>=0.24.0",
|
||||
"typing-extensions>=4.13.2; python_version < '3.13'",
|
||||
]
|
||||
description = "Lightweight, type-safe DAG task scheduler with multi-strategy execution."
|
||||
keywords = ["async", "dag", "scheduler", "task", "workflow"]
|
||||
license = { text = "MIT" }
|
||||
name = "pyflowx"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
version = "0.3.1"
|
||||
requires-python = ">=3.10"
|
||||
version = "0.4.8"
|
||||
|
||||
[project.scripts]
|
||||
autofmt = "pyflowx.cli.autofmt:main"
|
||||
bumpversion = "pyflowx.cli.bumpversion:main"
|
||||
dockercmd = "pyflowx.cli.dev.dockercmd:main"
|
||||
emlman = "pyflowx.cli.emlmanager:main"
|
||||
filedate = "pyflowx.cli.filedate: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"
|
||||
pxp = "pyflowx.cli.profiler:main"
|
||||
pymake = "pyflowx.cli.pymake: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"
|
||||
emlman = "pyflowx.cli.emlmanager:main"
|
||||
pf = "pyflowx.cli.pf:main"
|
||||
pxp = "pyflowx.cli.profiler:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
@@ -67,6 +43,7 @@ dev = [
|
||||
"tox-uv>=1.13.1",
|
||||
"tox>=4.25.0",
|
||||
]
|
||||
docs = ["myst-parser>=3.0", "sphinx-rtd-theme>=2.0", "sphinx>=7.0"]
|
||||
office = [
|
||||
"pillow>=10.4.0",
|
||||
"pymupdf>=1.24.11",
|
||||
@@ -95,12 +72,12 @@ packages = ["src/pyflowx"]
|
||||
pyflowx = { workspace = true }
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pyflowx[dev,office]"]
|
||||
dev = ["pyflowx[dev,docs,office]"]
|
||||
|
||||
[tool.coverage.run]
|
||||
branch = true
|
||||
concurrency = ["thread"]
|
||||
omit = ["src/pyflowx/cli/*", "src/pyflowx/examples/*", "tests/*"]
|
||||
omit = ["src/pyflowx/cli/*", "tests/*"]
|
||||
source = ["pyflowx"]
|
||||
|
||||
[tool.coverage.report]
|
||||
@@ -120,7 +97,7 @@ markers = ["slow: marks tests as slow (deselect with
|
||||
# Ruff 配置 - 与 .pre-commit-config.yaml 保持一致
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py38"
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
ignore = [
|
||||
@@ -153,9 +130,10 @@ select = [
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"**/tests/**" = ["ARG001", "ARG002"]
|
||||
"**/tests/**" = ["ARG001", "ARG002"]
|
||||
"src/pyflowx/tools.py" = ["PLR0911"]
|
||||
|
||||
[tool.pyrefly]
|
||||
preset = "strict"
|
||||
project-includes = ["**/*.ipynb", "**/*.py*"]
|
||||
python-version = "3.8"
|
||||
python-version = "3.10"
|
||||
|
||||
+8
-10
@@ -60,15 +60,7 @@ from __future__ import annotations
|
||||
|
||||
from .command import run_command
|
||||
from .compose import GraphComposer, compose
|
||||
from .conditions import (
|
||||
IS_LINUX,
|
||||
IS_MACOS,
|
||||
IS_POSIX,
|
||||
IS_WINDOWS,
|
||||
BuiltinConditions,
|
||||
Condition,
|
||||
Constants,
|
||||
)
|
||||
from .conditions import IS_LINUX, IS_MACOS, IS_POSIX, IS_WINDOWS, BuiltinConditions, Condition, Constants
|
||||
from .context import Context, build_call_args, describe_injection
|
||||
from .errors import (
|
||||
CycleError,
|
||||
@@ -99,8 +91,9 @@ from .task import (
|
||||
task,
|
||||
task_template,
|
||||
)
|
||||
from .tools import ToolSpec, list_subcommands, list_tools, run_tool, tool
|
||||
|
||||
__version__ = "0.4.1"
|
||||
__version__ = "0.4.8"
|
||||
|
||||
__all__ = [
|
||||
"IS_LINUX",
|
||||
@@ -139,12 +132,17 @@ __all__ = [
|
||||
"TaskSpec",
|
||||
"TaskStatus",
|
||||
"TaskTimeoutError",
|
||||
"ToolSpec",
|
||||
"build_call_args",
|
||||
"cmd",
|
||||
"compose",
|
||||
"describe_injection",
|
||||
"list_subcommands",
|
||||
"list_tools",
|
||||
"run",
|
||||
"run_command",
|
||||
"run_tool",
|
||||
"task",
|
||||
"task_template",
|
||||
"tool",
|
||||
]
|
||||
|
||||
@@ -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}")
|
||||
@@ -1,26 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
import pyflowx as px
|
||||
|
||||
DockerMirrorType = Literal["tencent"]
|
||||
|
||||
DOCKER_MIRROR_URLS: dict[DockerMirrorType, str] = {"tencent": "ccr.ccs.tencentyun.com"}
|
||||
|
||||
|
||||
def main():
|
||||
# parser = argparse.ArgumentParser(description="Docker 命令行工具")
|
||||
# parser.add_argument("--username", nargs="?", default="", type=str, help="Docker 用户名")
|
||||
# args = parser.parse_args()
|
||||
|
||||
tasks: list[px.TaskSpec] = [
|
||||
px.cmd(["docker", "login", "--username", "xxx", DOCKER_MIRROR_URLS["tencent"]], name="docker_login_tencent"),
|
||||
]
|
||||
|
||||
alias: dict[str, str | list[str | px.TaskSpec] | px.TaskSpec | px.Graph] = {
|
||||
"login": "docker_login_tencent",
|
||||
}
|
||||
|
||||
runner = px.CliRunner(strategy="sequential", tasks=tasks, aliases=alias)
|
||||
runner.run_cli()
|
||||
@@ -1,357 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
from pathlib import Path
|
||||
from typing import Literal, get_args
|
||||
|
||||
import pyflowx as px
|
||||
from pyflowx.conditions import BuiltinConditions
|
||||
from pyflowx.tasks.system import setenv_group, write_file
|
||||
|
||||
# ============================================================================
|
||||
# Mirror 配置
|
||||
# ============================================================================
|
||||
DOWNLOAD_MIRROR_SCRIPT: str = "curl -sSL https://linuxmirrors.cn/main.sh -o /tmp/linuxmirrors.sh"
|
||||
INSTALL_MIRROR_SCRIPT: str = "sudo bash /tmp/linuxmirrors.sh"
|
||||
|
||||
# ============================================================================
|
||||
# Python 配置
|
||||
# ============================================================================
|
||||
PyMirrorType = Literal["tsinghua", "aliyun", "huaweicloud", "ustc", "zju"]
|
||||
|
||||
PIP_INDEX_URLS: dict[PyMirrorType, str] = {
|
||||
"tsinghua": "https://pypi.tuna.tsinghua.edu.cn/simple",
|
||||
"aliyun": "https://mirrors.aliyun.com/pypi/simple/",
|
||||
"huaweicloud": "https://mirrors.huaweicloud.com/repository/pypi/simple/",
|
||||
"ustc": "https://pypi.mirrors.ustc.edu.cn/simple/",
|
||||
"zju": "https://mirrors.zju.edu.cn/pypi/simple/",
|
||||
}
|
||||
|
||||
PIP_TRUSTED_HOSTS: dict[PyMirrorType, str] = {
|
||||
"tsinghua": "pypi.tuna.tsinghua.edu.cn",
|
||||
"aliyun": "mirrors.aliyun.com",
|
||||
"huaweicloud": "mirrors.huaweicloud.com",
|
||||
"ustc": "pypi.mirrors.ustc.edu.cn",
|
||||
"zju": "mirrors.zju.edu.cn",
|
||||
}
|
||||
PIP_CONFIG_PATH = Path.home() / ".pip" / "pip.conf" if BuiltinConditions.IS_LINUX() else Path.home() / "pip" / "pip.ini"
|
||||
|
||||
UV_INDEX_URLS = PIP_INDEX_URLS
|
||||
UV_PYTHON_INSTALL_MIRROR: str = "https://registry.npmmirror.com/-/binary/python-build-standalone"
|
||||
|
||||
# ============================================================================
|
||||
# Conda 配置
|
||||
# ============================================================================
|
||||
CondaMirrorType = Literal["tsinghua", "ustc", "bsfu", "aliyun"]
|
||||
|
||||
CONDA_MIRROR_URLS: dict[CondaMirrorType, list[str]] = {
|
||||
"tsinghua": [
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/",
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/",
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/r/",
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/msys2/",
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/pro/",
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/",
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/bioconda/",
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/menpo/",
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/pytorch/",
|
||||
],
|
||||
"ustc": [
|
||||
"https://mirrors.ustc.edu.cn/anaconda/pkgs/main/",
|
||||
"https://mirrors.ustc.edu.cn/anaconda/pkgs/free/",
|
||||
"https://mirrors.ustc.edu.cn/anaconda/pkgs/r/",
|
||||
"https://mirrors.ustc.edu.cn/anaconda/pkgs/msys2/",
|
||||
"https://mirrors.ustc.edu.cn/anaconda/pkgs/pro/",
|
||||
"https://mirrors.ustc.edu.cn/anaconda/pkgs/dev/",
|
||||
"https://mirrors.ustc.edu.cn/anaconda/cloud/conda-forge/",
|
||||
"https://mirrors.ustc.edu.cn/anaconda/cloud/bioconda/",
|
||||
"https://mirrors.ustc.edu.cn/anaconda/cloud/menpo/",
|
||||
"https://mirrors.ustc.edu.cn/anaconda/cloud/pytorch/",
|
||||
],
|
||||
"bsfu": [
|
||||
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/main/",
|
||||
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/free/",
|
||||
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/r/",
|
||||
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/msys2/",
|
||||
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/pro/",
|
||||
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/dev/",
|
||||
"https://mirrors.bsfu.edu.cn/anaconda/cloud/conda-forge/",
|
||||
"https://mirrors.bsfu.edu.cn/anaconda/cloud/bioconda/",
|
||||
"https://mirrors.bsfu.edu.cn/anaconda/cloud/menpo/",
|
||||
"https://mirrors.bsfu.edu.cn/anaconda/cloud/pytorch/",
|
||||
],
|
||||
"aliyun": [
|
||||
"https://mirrors.aliyun.com/anaconda/pkgs/main/",
|
||||
"https://mirrors.aliyun.com/anaconda/pkgs/free/",
|
||||
"https://mirrors.aliyun.com/anaconda/pkgs/r/",
|
||||
"https://mirrors.aliyun.com/anaconda/pkgs/msys2/",
|
||||
"https://mirrors.aliyun.com/anaconda/pkgs/pro/",
|
||||
"https://mirrors.aliyun.com/anaconda/pkgs/dev/",
|
||||
"https://mirrors.aliyun.com/anaconda/cloud/conda-forge/",
|
||||
"https://mirrors.aliyun.com/anaconda/cloud/bioconda/",
|
||||
"https://mirrors.aliyun.com/anaconda/cloud/menpo/",
|
||||
"https://mirrors.aliyun.com/anaconda/cloud/pytorch/",
|
||||
],
|
||||
}
|
||||
CONDA_CONFIG_PATH = Path.home() / ".condarc"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Qt 配置
|
||||
# ============================================================================
|
||||
|
||||
QT_LIBS: list[str] = [
|
||||
"build-essential",
|
||||
"libgl1",
|
||||
"libegl1",
|
||||
"libglib2.0-0",
|
||||
"libfontconfig1",
|
||||
"libfreetype6",
|
||||
"libxkbcommon0",
|
||||
"libdbus-1-3",
|
||||
"libxcb-xinerama0",
|
||||
"libxcb-icccm4",
|
||||
"libxcb-image0",
|
||||
"libxcb-keysyms1",
|
||||
"libxcb-randr0",
|
||||
"libxcb-render-util0",
|
||||
"libxcb-shape0",
|
||||
"libxcb-xfixes0",
|
||||
"libxcb-cursor0",
|
||||
]
|
||||
|
||||
CHINESE_FONTS: list[str] = [
|
||||
"fonts-noto-cjk",
|
||||
"fonts-wqy-microhei",
|
||||
"fonts-wqy-zenhei",
|
||||
"fonts-noto-color-emoji",
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Rust 配置
|
||||
# ============================================================================
|
||||
RustMirrorType = Literal["tsinghua", "ustc", "aliyun"]
|
||||
RustVersionType = Literal["stable", "nightly", "beta"]
|
||||
DEFAULT_RUST_VERSION: RustVersionType = "stable"
|
||||
DEFAULT_MIRROR: RustMirrorType = "tsinghua"
|
||||
|
||||
RUSTUP_MIRRORS: dict[RustMirrorType, dict[str, str]] = {
|
||||
"tsinghua": {
|
||||
"RUSTUP_DIST_SERVER": "https://mirrors.tuna.tsinghua.edu.cn/rustup",
|
||||
"RUSTUP_UPDATE_ROOT": "https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup",
|
||||
"TOML_REGISTRY": "https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/",
|
||||
},
|
||||
"aliyun": {
|
||||
"RUSTUP_DIST_SERVER": "https://mirrors.aliyun.com/rustup",
|
||||
"RUSTUP_UPDATE_ROOT": "https://mirrors.aliyun.com/rustup/rustup",
|
||||
"TOML_REGISTRY": "https://mirrors.aliyun.com/crates.io-index/",
|
||||
},
|
||||
"ustc": {
|
||||
"RUSTUP_DIST_SERVER": "https://mirrors.ustc.edu.cn/rust-static",
|
||||
"RUSTUP_UPDATE_ROOT": "https://mirrors.ustc.edu.cn/rust-static/rustup",
|
||||
"TOML_REGISTRY": "https://mirrors.ustc.edu.cn/crates.io-index/",
|
||||
},
|
||||
}
|
||||
RUSTUP_DOWNLOAD_URL_LINUX = "https://mirrors.aliyun.com/repo/rust/rustup-init.sh"
|
||||
RUSTUP_DOWNLOAD_URL_WINDOWS = "https://static.rust-lang.org/rustup/dist/x86_64-pc-windows-msvc/rustup-init.exe"
|
||||
RUST_CONFIG_PATH = Path.home() / ".cargo" / "config.toml"
|
||||
RUST_SCCACHE_DIR: Path = Path.home() / ".cargo" / "sccache"
|
||||
RUST_SCCACHE_CACHE_SIZE: str = "20G"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""主函数."""
|
||||
parser = argparse.ArgumentParser(description="环境开发工具")
|
||||
parser.add_argument(
|
||||
"--python-mirror",
|
||||
nargs="?",
|
||||
type=str,
|
||||
default="tsinghua",
|
||||
choices=get_args(PyMirrorType),
|
||||
help="Python 镜像源",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--conda-mirror",
|
||||
nargs="?",
|
||||
type=str,
|
||||
default="tsinghua",
|
||||
choices=get_args(CondaMirrorType),
|
||||
help="Conda 镜镜像源",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rust-mirror",
|
||||
nargs="?",
|
||||
type=str,
|
||||
default=DEFAULT_MIRROR,
|
||||
choices=get_args(RustMirrorType),
|
||||
help="Rust 镜像源",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rust-version",
|
||||
nargs="?",
|
||||
type=str,
|
||||
default=DEFAULT_RUST_VERSION,
|
||||
choices=get_args(RustVersionType),
|
||||
help=f"Rust 版本, 推荐: {get_args(RustVersionType)}",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
python_mirror = args.python_mirror
|
||||
conda_mirror_urls = CONDA_MIRROR_URLS[args.conda_mirror]
|
||||
rust_mirror = args.rust_mirror
|
||||
rust_version = args.rust_version
|
||||
|
||||
# 确保配置文件目录存在
|
||||
PIP_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
CONDA_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
RUST_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
RUST_SCCACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 使用 conditions 自动控制任务执行
|
||||
graph = px.Graph.from_specs([
|
||||
# 系统镜像配置(仅 Linux 且未配置国内镜像)
|
||||
px.TaskSpec(
|
||||
"download_mirror",
|
||||
cmd=DOWNLOAD_MIRROR_SCRIPT,
|
||||
conditions=(
|
||||
BuiltinConditions.IS_LINUX(),
|
||||
BuiltinConditions.NOT(
|
||||
BuiltinConditions.OR(
|
||||
*[
|
||||
BuiltinConditions.FILE_CONTENT_EXISTS(f, m)
|
||||
for f in [
|
||||
"/etc/apt/sources.list",
|
||||
"/etc/apt/sources.list.d/ubuntu.sources",
|
||||
]
|
||||
for m in get_args(PyMirrorType)
|
||||
],
|
||||
)
|
||||
),
|
||||
),
|
||||
verbose=True,
|
||||
),
|
||||
px.TaskSpec(
|
||||
"install_mirror",
|
||||
cmd=INSTALL_MIRROR_SCRIPT,
|
||||
depends_on=("download_mirror",),
|
||||
verbose=True,
|
||||
),
|
||||
# 安装 Qt 依赖(仅 Linux)
|
||||
px.TaskSpec(
|
||||
"install_qt_libs",
|
||||
cmd=["sudo", "apt", "install", "-y", *QT_LIBS],
|
||||
conditions=(BuiltinConditions.IS_LINUX(),),
|
||||
depends_on=("install_mirror",),
|
||||
allow_upstream_skip=True,
|
||||
verbose=True,
|
||||
),
|
||||
# 安装中文字体(仅 Linux)
|
||||
px.TaskSpec(
|
||||
"install_fonts",
|
||||
cmd=["sudo", "apt", "install", "-y", *CHINESE_FONTS],
|
||||
conditions=(BuiltinConditions.IS_LINUX(),),
|
||||
depends_on=("install_mirror",),
|
||||
allow_upstream_skip=True,
|
||||
verbose=True,
|
||||
),
|
||||
# 安装 Docker
|
||||
px.TaskSpec(
|
||||
"install_docker",
|
||||
cmd=["sudo", "apt", "install", "-y", "docker-compose-v2"],
|
||||
conditions=(BuiltinConditions.IS_LINUX(),),
|
||||
depends_on=("install_mirror",),
|
||||
allow_upstream_skip=True,
|
||||
verbose=True,
|
||||
),
|
||||
px.TaskSpec(
|
||||
"add_docker_group",
|
||||
cmd=["sudo", "usermod", "-aG", "docker", getpass.getuser()],
|
||||
conditions=(BuiltinConditions.IS_LINUX(),),
|
||||
depends_on=("install_docker",),
|
||||
allow_upstream_skip=True,
|
||||
verbose=True,
|
||||
),
|
||||
px.TaskSpec(
|
||||
"refresh_docker_group",
|
||||
cmd=["newgrp", "docker"],
|
||||
conditions=(BuiltinConditions.IS_LINUX(),),
|
||||
depends_on=("add_docker_group",),
|
||||
allow_upstream_skip=True,
|
||||
verbose=True,
|
||||
),
|
||||
# 设置 Python 环境变量
|
||||
*setenv_group({
|
||||
"PIP_INDEX_URL": PIP_INDEX_URLS[python_mirror],
|
||||
"PIP_TRUSTED_HOSTS": PIP_TRUSTED_HOSTS[python_mirror],
|
||||
"UV_INDEX_URL": UV_INDEX_URLS[python_mirror],
|
||||
"UV_PYTHON_INSTALL_MIRROR": UV_PYTHON_INSTALL_MIRROR,
|
||||
"UV_HTTP_TIMEOUT": "600",
|
||||
"UV_LINK_MODE": "copy",
|
||||
}),
|
||||
# 写入 Python 配置(仅当未配置)
|
||||
write_file(
|
||||
str(PIP_CONFIG_PATH),
|
||||
f"[global]\nindex-url = {PIP_INDEX_URLS[python_mirror]}\ntrusted-host = {PIP_TRUSTED_HOSTS[python_mirror]}",
|
||||
),
|
||||
# 写入 Conda 配置(仅当未配置)
|
||||
write_file(
|
||||
str(CONDA_CONFIG_PATH),
|
||||
"show_channel_urls: true\nchannels:\n - " + "\n - ".join(conda_mirror_urls) + "\n - defaults",
|
||||
),
|
||||
# 设置 Rust 镜像源
|
||||
*setenv_group({
|
||||
"RUSTUP_DIST_SERVER": RUSTUP_MIRRORS[rust_mirror]["RUSTUP_DIST_SERVER"],
|
||||
"RUSTUP_UPDATE_ROOT": RUSTUP_MIRRORS[rust_mirror]["RUSTUP_UPDATE_ROOT"],
|
||||
"RUST_SCCACHE_DIR": str(RUST_SCCACHE_DIR),
|
||||
"RUST_SCCACHE_CACHE_SIZE": RUST_SCCACHE_CACHE_SIZE,
|
||||
}),
|
||||
# 写入 Rust 配置(仅当未配置)
|
||||
write_file(
|
||||
str(RUST_CONFIG_PATH),
|
||||
f"""
|
||||
[source.crates-io]
|
||||
replace-with = '{rust_mirror}'
|
||||
|
||||
[source.{rust_mirror}]
|
||||
registry = "sparse+{RUSTUP_MIRRORS[rust_mirror]["TOML_REGISTRY"]}"
|
||||
|
||||
[registries.{rust_mirror}]
|
||||
index = "sparse+{RUSTUP_MIRRORS[rust_mirror]["TOML_REGISTRY"]}"
|
||||
""",
|
||||
),
|
||||
# 下载 Rustup 安装脚本
|
||||
px.TaskSpec(
|
||||
"download_rustup",
|
||||
cmd=["curl", "-fsSL", RUSTUP_DOWNLOAD_URL_LINUX, "-o", "rustup-init.sh"],
|
||||
conditions=(BuiltinConditions.IS_LINUX(), BuiltinConditions.NOT(BuiltinConditions.HAS_INSTALLED("rustup"))),
|
||||
verbose=True,
|
||||
),
|
||||
px.TaskSpec(
|
||||
"download_rustup_win",
|
||||
cmd=[
|
||||
"powershell",
|
||||
"-Command",
|
||||
"Invoke-WebRequest",
|
||||
"-Uri",
|
||||
RUSTUP_DOWNLOAD_URL_WINDOWS,
|
||||
"-OutFile",
|
||||
"rustup-init.exe",
|
||||
],
|
||||
conditions=(
|
||||
BuiltinConditions.IS_WINDOWS(),
|
||||
BuiltinConditions.NOT(BuiltinConditions.HAS_INSTALLED("rustup")),
|
||||
),
|
||||
verbose=True,
|
||||
),
|
||||
# 安装 Rust 工具链
|
||||
px.TaskSpec(
|
||||
"install_rust",
|
||||
cmd=["rustup", "toolchain", "install", rust_version],
|
||||
conditions=(BuiltinConditions.HAS_INSTALLED("rustup"),),
|
||||
depends_on=("setenv_rustup_dist_server",),
|
||||
allow_upstream_skip=True,
|
||||
verbose=True,
|
||||
),
|
||||
])
|
||||
px.run(graph, strategy="thread", verbose=True)
|
||||
@@ -154,7 +154,7 @@ class EmailDatabase:
|
||||
cursor.execute(query, (f"%{keyword}%", f"%{keyword}%", f"%{keyword}%", f"%{keyword}%", limit, offset))
|
||||
|
||||
columns = [description[0] for description in cursor.description]
|
||||
return [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||
return [dict(zip(columns, row, strict=False)) for row in cursor.fetchall()]
|
||||
|
||||
def get_grouped_emails(self) -> dict[str, list[dict[str, Any]]]:
|
||||
"""获取按主题分组的邮件."""
|
||||
@@ -165,7 +165,7 @@ class EmailDatabase:
|
||||
cursor.execute(f"SELECT * FROM {TABLE_NAME} ORDER BY subject, date_parsed DESC")
|
||||
|
||||
columns = [description[0] for description in cursor.description]
|
||||
emails = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||
emails = [dict(zip(columns, row, strict=False)) for row in cursor.fetchall()]
|
||||
|
||||
# 按主题分组
|
||||
grouped: dict[str, list[dict[str, Any]]] = {}
|
||||
@@ -567,13 +567,15 @@ class EmlManagerHandler(BaseHTTPRequestHandler):
|
||||
|
||||
emails = self.db.search_emails(keyword, field, limit, offset)
|
||||
total_count = self.db.get_email_count()
|
||||
self._send_json_response({
|
||||
"emails": emails,
|
||||
"count": len(emails),
|
||||
"total": total_count,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
self._send_json_response(
|
||||
{
|
||||
"emails": emails,
|
||||
"count": len(emails),
|
||||
"total": total_count,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
)
|
||||
|
||||
def _api_get_email(self, query_params: dict[str, list[str]]) -> None:
|
||||
"""API: 获取单个邮件详情."""
|
||||
@@ -600,7 +602,7 @@ class EmlManagerHandler(BaseHTTPRequestHandler):
|
||||
self._send_json_response({"error": "邮件不存在"}, 404)
|
||||
return
|
||||
|
||||
email_data = dict(zip(columns, row))
|
||||
email_data = dict(zip(columns, row, strict=False))
|
||||
self._send_json_response({"email": email_data})
|
||||
|
||||
def _api_get_grouped_emails(self) -> None:
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -1,523 +0,0 @@
|
||||
"""PDF 工具模块.
|
||||
|
||||
提供 PDF 文件操作的常用功能封装,
|
||||
支持合并、拆分、压缩、加密、水印、OCR等功能.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import pyflowx as px
|
||||
|
||||
try:
|
||||
import fitz # PyMuPDF
|
||||
|
||||
HAS_PYMUPDF = True
|
||||
except ImportError:
|
||||
HAS_PYMUPDF = False
|
||||
|
||||
try:
|
||||
import pypdf
|
||||
|
||||
HAS_PYPDF = True
|
||||
except ImportError:
|
||||
HAS_PYPDF = False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 配置
|
||||
# ============================================================================
|
||||
|
||||
PDF_SUFFIX = ".pdf"
|
||||
DEFAULT_QUALITY = 75
|
||||
DEFAULT_PASSWORD = ""
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 辅助函数
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def pdf_merge(input_paths: list[Path], output_path: Path) -> None:
|
||||
"""合并多个 PDF 文件."""
|
||||
if not HAS_PYPDF:
|
||||
print("未安装 pypdf 库,请安装: pip install pypdf")
|
||||
return
|
||||
|
||||
writer = pypdf.PdfWriter()
|
||||
for input_path in input_paths:
|
||||
if input_path.exists():
|
||||
reader = pypdf.PdfReader(str(input_path))
|
||||
for page in reader.pages:
|
||||
writer.add_page(page)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "wb") as f:
|
||||
writer.write(f)
|
||||
|
||||
print(f"合并完成: {output_path}")
|
||||
|
||||
|
||||
def pdf_split(input_path: Path, output_dir: Path) -> None:
|
||||
"""拆分 PDF 文件为单页."""
|
||||
if not HAS_PYPDF:
|
||||
print("未安装 pypdf 库,请安装: pip install pypdf")
|
||||
return
|
||||
|
||||
reader = pypdf.PdfReader(str(input_path))
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for i, page in enumerate(reader.pages):
|
||||
writer = pypdf.PdfWriter()
|
||||
writer.add_page(page)
|
||||
output_file = output_dir / f"{input_path.stem}_page_{i + 1}.pdf"
|
||||
with open(output_file, "wb") as f:
|
||||
writer.write(f)
|
||||
|
||||
print(f"拆分完成: {output_dir}")
|
||||
|
||||
|
||||
def pdf_compress(input_path: Path, output_path: Path) -> None:
|
||||
"""压缩 PDF 文件."""
|
||||
if not HAS_PYMUPDF:
|
||||
print("未安装 PyMuPDF 库,请安装: pip install PyMuPDF")
|
||||
return
|
||||
|
||||
doc = fitz.open(str(input_path))
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
doc.save(str(output_path), garbage=4, deflate=True, clean=True)
|
||||
doc.close()
|
||||
|
||||
original_size = input_path.stat().st_size
|
||||
new_size = output_path.stat().st_size
|
||||
ratio = (1 - new_size / original_size) * 100
|
||||
print(f"压缩完成: {output_path} (缩小 {ratio:.1f}%)")
|
||||
|
||||
|
||||
def pdf_encrypt(input_path: Path, output_path: Path, password: str) -> None:
|
||||
"""加密 PDF 文件."""
|
||||
if not HAS_PYPDF:
|
||||
print("未安装 pypdf 库,请安装: pip install pypdf")
|
||||
return
|
||||
|
||||
reader = pypdf.PdfReader(str(input_path))
|
||||
writer = pypdf.PdfWriter()
|
||||
|
||||
for page in reader.pages:
|
||||
writer.add_page(page)
|
||||
|
||||
writer.encrypt(user_password=password, owner_password=password, use_128bit=True)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "wb") as f:
|
||||
writer.write(f)
|
||||
|
||||
print(f"加密完成: {output_path}")
|
||||
|
||||
|
||||
def pdf_decrypt(input_path: Path, output_path: Path, password: str) -> None:
|
||||
"""解密 PDF 文件."""
|
||||
if not HAS_PYPDF:
|
||||
print("未安装 pypdf 库,请安装: pip install pypdf")
|
||||
return
|
||||
|
||||
reader = pypdf.PdfReader(str(input_path))
|
||||
if reader.is_encrypted:
|
||||
reader.decrypt(password)
|
||||
|
||||
writer = pypdf.PdfWriter()
|
||||
for page in reader.pages:
|
||||
writer.add_page(page)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "wb") as f:
|
||||
writer.write(f)
|
||||
|
||||
print(f"解密完成: {output_path}")
|
||||
|
||||
|
||||
def pdf_extract_text(input_path: Path, output_path: Path) -> None:
|
||||
"""提取 PDF 文本."""
|
||||
if not HAS_PYMUPDF:
|
||||
print("未安装 PyMuPDF 库,请安装: pip install PyMuPDF")
|
||||
return
|
||||
|
||||
doc = fitz.open(str(input_path))
|
||||
text = ""
|
||||
for page in doc:
|
||||
text += str(page.get_text()) + "\n\n"
|
||||
doc.close()
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(text, encoding="utf-8")
|
||||
print(f"文本提取完成: {output_path}")
|
||||
|
||||
|
||||
def pdf_extract_images(input_path: Path, output_dir: Path) -> None:
|
||||
"""提取 PDF 图片."""
|
||||
if not HAS_PYMUPDF:
|
||||
print("未安装 PyMuPDF 库,请安装: pip install PyMuPDF")
|
||||
return
|
||||
|
||||
doc = fitz.open(str(input_path))
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
image_count = 0
|
||||
# pyrefly: ignore [bad-argument-type]
|
||||
for page_num, page in enumerate(doc):
|
||||
images = page.get_images(full=True)
|
||||
for img_idx, img in enumerate(images):
|
||||
xref = img[0]
|
||||
base_image = doc.extract_image(xref)
|
||||
image_data = base_image["image"]
|
||||
image_ext = base_image["ext"]
|
||||
image_path = output_dir / f"page_{page_num + 1}_img_{img_idx + 1}.{image_ext}"
|
||||
image_path.write_bytes(image_data)
|
||||
image_count += 1
|
||||
|
||||
doc.close()
|
||||
print(f"图片提取完成: {output_dir} (共 {image_count} 张)")
|
||||
|
||||
|
||||
def pdf_add_watermark(input_path: Path, output_path: Path, text: str = "CONFIDENTIAL") -> None:
|
||||
"""添加 PDF 水印."""
|
||||
if not HAS_PYMUPDF:
|
||||
print("未安装 PyMuPDF 库,请安装: pip install PyMuPDF")
|
||||
return
|
||||
|
||||
doc = fitz.open(str(input_path))
|
||||
for page in doc:
|
||||
rect = page.rect
|
||||
text_width = fitz.get_text_length(text, fontsize=48)
|
||||
x = (rect.width - text_width) / 2
|
||||
y = rect.height / 2
|
||||
page.insert_text((x, y), text, fontsize=48, rotate=45, color=(0, 0, 0))
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
doc.save(str(output_path))
|
||||
doc.close()
|
||||
print(f"水印添加完成: {output_path}")
|
||||
|
||||
|
||||
def pdf_rotate(input_path: Path, output_path: Path, rotation: int = 90) -> None:
|
||||
"""旋转 PDF 页面."""
|
||||
if not HAS_PYMUPDF:
|
||||
print("未安装 PyMuPDF 库,请安装: pip install PyMuPDF")
|
||||
return
|
||||
|
||||
doc = fitz.open(str(input_path))
|
||||
for page in doc:
|
||||
page.set_rotation(rotation)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
doc.save(str(output_path))
|
||||
doc.close()
|
||||
print(f"旋转完成: {output_path}")
|
||||
|
||||
|
||||
def pdf_crop(input_path: Path, output_path: Path, margins: tuple[int, int, int, int]) -> None:
|
||||
"""裁剪 PDF 页面."""
|
||||
if not HAS_PYMUPDF:
|
||||
print("未安装 PyMuPDF 库,请安装: pip install PyMuPDF")
|
||||
return
|
||||
|
||||
doc = fitz.open(str(input_path))
|
||||
left, top, right, bottom = margins
|
||||
|
||||
for page in doc:
|
||||
rect = page.rect
|
||||
new_rect = fitz.Rect(
|
||||
rect.x0 + left,
|
||||
rect.y0 + top,
|
||||
rect.x1 - right,
|
||||
rect.y1 - bottom,
|
||||
)
|
||||
page.set_cropbox(new_rect)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
doc.save(str(output_path))
|
||||
doc.close()
|
||||
print(f"裁剪完成: {output_path}")
|
||||
|
||||
|
||||
def pdf_info(input_path: Path) -> None:
|
||||
"""显示 PDF 信息."""
|
||||
if not HAS_PYMUPDF:
|
||||
print("未安装 PyMuPDF 库,请安装: pip install PyMuPDF")
|
||||
return
|
||||
|
||||
doc = fitz.open(str(input_path))
|
||||
print(f"文件: {input_path}")
|
||||
print(f"页数: {doc.page_count}")
|
||||
# pyrefly: ignore [missing-attribute]
|
||||
print(f"标题: {doc.metadata.get('title', 'N/A')}")
|
||||
# pyrefly: ignore [missing-attribute]
|
||||
print(f"作者: {doc.metadata.get('author', 'N/A')}")
|
||||
# pyrefly: ignore [missing-attribute]
|
||||
print(f"创建日期: {doc.metadata.get('creationDate', 'N/A')}")
|
||||
# pyrefly: ignore [missing-attribute]
|
||||
print(f"修改日期: {doc.metadata.get('modDate', 'N/A')}")
|
||||
print(f"文件大小: {input_path.stat().st_size / 1024:.1f} KB")
|
||||
doc.close()
|
||||
|
||||
|
||||
def pdf_ocr(input_path: Path, output_path: Path, lang: str = "chi_sim+eng") -> None:
|
||||
"""PDF OCR 识别."""
|
||||
try:
|
||||
import pytesseract
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
print("未安装 OCR 相关库,请安装: pip install pytesseract pillow")
|
||||
return
|
||||
|
||||
if not HAS_PYMUPDF:
|
||||
print("未安装 PyMuPDF 库,请安装: pip install PyMuPDF")
|
||||
return
|
||||
|
||||
doc = fitz.open(str(input_path))
|
||||
new_doc = fitz.open()
|
||||
|
||||
for page in doc:
|
||||
pix = page.get_pixmap()
|
||||
img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
|
||||
ocr_text = pytesseract.image_to_string(img, lang=lang)
|
||||
|
||||
new_page = new_doc.new_page(width=page.rect.width, height=page.rect.height)
|
||||
new_page.insert_image(new_page.rect, pixmap=pix)
|
||||
text_rect = fitz.Rect(0, 0, page.rect.width, page.rect.height)
|
||||
# pyrefly: ignore [bad-argument-type]
|
||||
new_page.insert_textbox(text_rect, ocr_text, fontname="china-ss", fontsize=11)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
new_doc.save(str(output_path))
|
||||
new_doc.close()
|
||||
doc.close()
|
||||
print(f"OCR 识别完成: {output_path}")
|
||||
|
||||
|
||||
def pdf_reorder(input_path: Path, output_path: Path, order: list[int]) -> None:
|
||||
"""重排 PDF 页面顺序."""
|
||||
if not HAS_PYPDF:
|
||||
print("未安装 pypdf 库,请安装: pip install pypdf")
|
||||
return
|
||||
|
||||
reader = pypdf.PdfReader(str(input_path))
|
||||
writer = pypdf.PdfWriter()
|
||||
|
||||
for page_num in order:
|
||||
if 0 <= page_num < len(reader.pages):
|
||||
writer.add_page(reader.pages[page_num])
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "wb") as f:
|
||||
writer.write(f)
|
||||
|
||||
print(f"重排完成: {output_path}")
|
||||
|
||||
|
||||
def pdf_to_images(input_path: Path, output_dir: Path, dpi: int = 300) -> None:
|
||||
"""PDF 转图片."""
|
||||
if not HAS_PYMUPDF:
|
||||
print("未安装 PyMuPDF 库,请安装: pip install PyMuPDF")
|
||||
return
|
||||
|
||||
doc = fitz.open(str(input_path))
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# pyrefly: ignore [bad-argument-type]
|
||||
for page_num, page in enumerate(doc):
|
||||
pix = page.get_pixmap(dpi=dpi)
|
||||
image_path = output_dir / f"{input_path.stem}_page_{page_num + 1}.png"
|
||||
pix.save(str(image_path))
|
||||
|
||||
doc.close()
|
||||
print(f"转换完成: {output_dir}")
|
||||
|
||||
|
||||
def pdf_repair(input_path: Path, output_path: Path) -> None:
|
||||
"""修复 PDF 文件."""
|
||||
if not HAS_PYMUPDF:
|
||||
print("未安装 PyMuPDF 库,请安装: pip install PyMuPDF")
|
||||
return
|
||||
|
||||
doc = fitz.open(str(input_path))
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
doc.save(str(output_path), garbage=4, deflate=True, clean=True)
|
||||
doc.close()
|
||||
print(f"修复完成: {output_path}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CLI Runner
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def main() -> None: # noqa: PLR0912
|
||||
"""PDF 工具主函数."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="PDFTool - PDF 文件工具集",
|
||||
usage="pdftool <command> [options]",
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", help="可用命令")
|
||||
|
||||
# 合并 PDF 命令
|
||||
merge_parser = subparsers.add_parser("m", help="合并 PDF 文件")
|
||||
merge_parser.add_argument("inputs", nargs="+", help="输入 PDF 文件路径")
|
||||
merge_parser.add_argument("--output", type=str, default="merged.pdf", help="输出文件路径")
|
||||
|
||||
# 拆分 PDF 命令
|
||||
split_parser = subparsers.add_parser("s", help="拆分 PDF 文件为单页")
|
||||
split_parser.add_argument("input", help="输入 PDF 文件路径")
|
||||
split_parser.add_argument("--output-dir", type=str, default="split", help="输出目录")
|
||||
|
||||
# 压缩 PDF 命令
|
||||
compress_parser = subparsers.add_parser("c", help="压缩 PDF 文件")
|
||||
compress_parser.add_argument("input", help="输入 PDF 文件路径")
|
||||
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 命令
|
||||
decrypt_parser = subparsers.add_parser("d", help="解密 PDF 文件")
|
||||
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="密码")
|
||||
|
||||
# 提取文本命令
|
||||
extract_text_parser = subparsers.add_parser("xt", help="提取 PDF 文本")
|
||||
extract_text_parser.add_argument("input", help="输入 PDF 文件路径")
|
||||
extract_text_parser.add_argument("--output", type=str, default="output.txt", help="输出文件路径")
|
||||
|
||||
# 提取图片命令
|
||||
extract_images_parser = subparsers.add_parser("xi", help="提取 PDF 图片")
|
||||
extract_images_parser.add_argument("input", help="输入 PDF 文件路径")
|
||||
extract_images_parser.add_argument("--output-dir", type=str, default="images", help="输出目录")
|
||||
|
||||
# 添加水印命令
|
||||
watermark_parser = subparsers.add_parser("w", help="添加 PDF 水印")
|
||||
watermark_parser.add_argument("input", help="输入 PDF 文件路径")
|
||||
watermark_parser.add_argument("--output", type=str, default="watermarked.pdf", help="输出文件路径")
|
||||
watermark_parser.add_argument("--text", type=str, default="CONFIDENTIAL", help="水印文本")
|
||||
|
||||
# 旋转 PDF 命令
|
||||
rotate_parser = subparsers.add_parser("r", help="旋转 PDF 页面")
|
||||
rotate_parser.add_argument("input", help="输入 PDF 文件路径")
|
||||
rotate_parser.add_argument("--output", type=str, default="rotated.pdf", help="输出文件路径")
|
||||
rotate_parser.add_argument("--rotation", type=int, default=90, help="旋转角度 (90, 180, 270)")
|
||||
|
||||
# 裁剪 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:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
px.run(graph, strategy="thread")
|
||||
@@ -0,0 +1,249 @@
|
||||
"""PyFlowX 统一 CLI 入口.
|
||||
|
||||
通过 ``pf <tool> [command] [options]`` 调用所有工具,
|
||||
工具定义在 ``pyflowx.ops`` 子包中, 每个模块用 ``@px.tool`` 装饰器注册.
|
||||
|
||||
用法
|
||||
----
|
||||
pf # 列出所有可用工具
|
||||
pf filedate # 查看 filedate 工具帮助
|
||||
pf filedate add a.txt # 调用 filedate 的 add 子命令
|
||||
pf pymake b # 调用 pymake 的 b 别名
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import difflib
|
||||
import importlib
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from pyflowx import __version__
|
||||
from pyflowx.tools import _TOOL_REGISTRY, run_tool
|
||||
|
||||
|
||||
class PfApp:
|
||||
"""pf 统一入口应用.
|
||||
|
||||
路由 ``pf <tool> [command]`` 到 ``@px.tool`` 注册的工具或传统 Python 工具.
|
||||
"""
|
||||
|
||||
# 工具名到 ops 模块名的映射 (支持短别名)
|
||||
_TOOL_ALIASES: dict[str, str] = {
|
||||
"autofmt": "autofmt",
|
||||
"af": "autofmt",
|
||||
"bump": "bumpversion",
|
||||
"bumpversion": "bumpversion",
|
||||
"bv": "bumpversion",
|
||||
"clr": "clr",
|
||||
"clearscreen": "clr",
|
||||
"dockercmd": "dockercmd",
|
||||
"docker": "dockercmd",
|
||||
"envdev": "envdev",
|
||||
"env": "envdev",
|
||||
"filedate": "filedate",
|
||||
"fd": "filedate",
|
||||
"filelevel": "filelevel",
|
||||
"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() 函数 (无法 @px.tool 化的复杂逻辑)
|
||||
_LEGACY_TOOLS: dict[str, str] = {
|
||||
"emlman": "pyflowx.cli.emlmanager:main",
|
||||
"profiler": "pyflowx.cli.profiler:main",
|
||||
"pxp": "pyflowx.cli.profiler:main",
|
||||
}
|
||||
|
||||
def __init__(self, argv: Sequence[str] | None = None) -> None:
|
||||
self._argv = list(argv) if argv is not None else sys.argv[1:]
|
||||
self._console = Console()
|
||||
self._err = Console(stderr=True)
|
||||
|
||||
def run(self) -> int:
|
||||
"""主入口, 返回退出码."""
|
||||
if not self._argv:
|
||||
self._list_tools()
|
||||
return 0
|
||||
|
||||
first = self._argv[0]
|
||||
|
||||
if first in ("--help", "-h"):
|
||||
self._list_tools()
|
||||
return 0
|
||||
if first in ("--version", "-V"):
|
||||
self._console.print(f"PyFlowX [bold cyan]{__version__}[/bold cyan]")
|
||||
return 0
|
||||
|
||||
rest = self._argv[1:]
|
||||
resolved = self._resolve_tool(first)
|
||||
if resolved is None:
|
||||
self._print_unknown_tool(first)
|
||||
return 1
|
||||
|
||||
kind, target = resolved
|
||||
if kind == "legacy":
|
||||
return self._run_legacy(target, rest)
|
||||
return self._run_tool(target, rest)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 工具列表 (rich)
|
||||
# ------------------------------------------------------------------ #
|
||||
def _list_tools(self) -> None:
|
||||
"""rich 表格列出所有可用工具."""
|
||||
self._console.print(
|
||||
Panel(
|
||||
Text(f"PyFlowX v{__version__}", style="bold cyan", justify="center"),
|
||||
subtitle="[dim]pf <tool> [command] [options][/dim]",
|
||||
)
|
||||
)
|
||||
|
||||
table = Table(title="@px.tool 工具", show_header=True, header_style="bold", show_lines=False)
|
||||
table.add_column("命令", style="cyan", no_wrap=True)
|
||||
table.add_column("别名", style="dim", no_wrap=True)
|
||||
table.add_column("说明")
|
||||
|
||||
for tool in sorted(set(self._TOOL_ALIASES.values())):
|
||||
aliases = self._aliases_for(tool)
|
||||
table.add_row(f"pf {tool}", ", ".join(aliases), self._tool_description(tool))
|
||||
|
||||
self._console.print(table)
|
||||
|
||||
if self._LEGACY_TOOLS:
|
||||
legacy = Table(title="传统工具", show_header=True, header_style="bold", show_lines=False)
|
||||
legacy.add_column("命令", style="cyan", no_wrap=True)
|
||||
for tool in sorted(self._LEGACY_TOOLS):
|
||||
legacy.add_row(f"pf {tool}")
|
||||
self._console.print(legacy)
|
||||
|
||||
self._console.print("\n[bold]示例:[/bold]")
|
||||
self._console.print(" [cyan]pf filedate add a.txt[/cyan] # 给文件添加日期前缀")
|
||||
self._console.print(" [cyan]pf pymake b[/cyan] # 构建 Python 包")
|
||||
self._console.print(" [cyan]pf gitt c[/cyan] # 清理并查看 git 状态")
|
||||
|
||||
def _aliases_for(self, canonical: str) -> list[str]:
|
||||
"""获取工具的别名 (不含规范名本身)."""
|
||||
return sorted(a for a, t in self._TOOL_ALIASES.items() if t == canonical and a != canonical)
|
||||
|
||||
def _tool_description(self, tool_name: str) -> str:
|
||||
"""获取工具描述 (从 _TOOL_REGISTRY 中已注册 ToolSpec 的 description/help)."""
|
||||
with contextlib.suppress(ImportError):
|
||||
importlib.import_module(f"pyflowx.ops.{tool_name}")
|
||||
|
||||
if tool_name not in _TOOL_REGISTRY:
|
||||
return ""
|
||||
|
||||
subs = _TOOL_REGISTRY[tool_name]
|
||||
for spec in subs.values():
|
||||
if spec.description:
|
||||
return spec.description
|
||||
for spec in subs.values():
|
||||
if not spec.hidden and spec.help:
|
||||
return spec.help
|
||||
return ""
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 路由
|
||||
# ------------------------------------------------------------------ #
|
||||
def _resolve_tool(self, name: str) -> tuple[str, str] | None:
|
||||
"""解析工具名, 返回 (类型, 目标)."""
|
||||
if name in self._TOOL_ALIASES:
|
||||
return ("tool", self._TOOL_ALIASES[name])
|
||||
if name in self._LEGACY_TOOLS:
|
||||
return ("legacy", self._LEGACY_TOOLS[name])
|
||||
return None
|
||||
|
||||
def _print_unknown_tool(self, name: str) -> None:
|
||||
"""打印未知工具错误 + 模糊匹配建议."""
|
||||
self._err.print(f"[red]错误:[/red] 未知工具 [yellow]{name!r}[/yellow]")
|
||||
suggestions = difflib.get_close_matches(name, list(self._TOOL_ALIASES), n=3, cutoff=0.5)
|
||||
if suggestions:
|
||||
self._err.print(f"[dim]是否想用: {', '.join(suggestions)}[/dim]")
|
||||
self._err.print("[dim]运行 'pf' 查看可用工具列表[/dim]")
|
||||
|
||||
def _run_legacy(self, module_path: str, argv: list[str]) -> int:
|
||||
"""运行传统工具的 main() 函数."""
|
||||
module_name, func_name = module_path.split(":", 1)
|
||||
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_tool(self, target: str, argv: list[str]) -> int:
|
||||
"""运行 @px.tool 工具.
|
||||
|
||||
导入 ``pyflowx.ops.<target>`` 触发 ``@px.tool`` 注册, 然后调用 ``run_tool``.
|
||||
"""
|
||||
with contextlib.suppress(ImportError):
|
||||
importlib.import_module(f"pyflowx.ops.{target}")
|
||||
|
||||
if target not in _TOOL_REGISTRY:
|
||||
self._err.print(f"[red]错误:[/red] 未找到工具 [yellow]{target!r}[/yellow]")
|
||||
self._err.print("[dim]运行 'pf' 查看可用工具列表[/dim]")
|
||||
return 1
|
||||
|
||||
return run_tool(target, argv)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""pf 统一入口主函数."""
|
||||
sys.exit(PfApp().run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
+14
-7
@@ -10,12 +10,16 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Any, List, Union, cast
|
||||
from typing import Any, cast
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from .task import TaskSpec
|
||||
|
||||
__all__ = ["run_command"]
|
||||
|
||||
_cmd_console = Console()
|
||||
|
||||
|
||||
def run_command(spec: TaskSpec[Any]) -> Any: # noqa: PLR0912
|
||||
"""执行 ``spec.cmd`` 指定的命令(list / shell 字符串 / 可调用对象)。
|
||||
@@ -39,9 +43,9 @@ def run_command(spec: TaskSpec[Any]) -> Any: # noqa: PLR0912
|
||||
if callable(cmd) and not isinstance(cmd, (list, str)):
|
||||
name = getattr(cmd, "__name__", "callable")
|
||||
if verbose:
|
||||
print(f"[verbose] 执行可调用命令: {name}", flush=True)
|
||||
_cmd_console.print(f"[cyan]▸[/cyan] 执行可调用命令: [bold]{name}[/bold]")
|
||||
if cwd is not None:
|
||||
print(f"[verbose] 工作目录: {cwd}", flush=True)
|
||||
_cmd_console.print(f" [dim]工作目录: {cwd}[/dim]")
|
||||
try:
|
||||
return cmd()
|
||||
except Exception as e:
|
||||
@@ -58,9 +62,9 @@ def run_command(spec: TaskSpec[Any]) -> Any: # noqa: PLR0912
|
||||
label = "Shell 命令"
|
||||
|
||||
if verbose:
|
||||
print(f"[verbose] {verb}: {cmd_str}", flush=True)
|
||||
_cmd_console.print(f"[cyan]▸[/cyan] {verb}: [bold]{cmd_str}[/bold]")
|
||||
if cwd is not None:
|
||||
print(f"[verbose] 工作目录: {cwd}", flush=True)
|
||||
_cmd_console.print(f" [dim]工作目录: {cwd}[/dim]")
|
||||
|
||||
# 合并环境变量
|
||||
run_env: dict[str, str] | None = None
|
||||
@@ -70,7 +74,7 @@ def run_command(spec: TaskSpec[Any]) -> Any: # noqa: PLR0912
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cast(Union[str, List[str]], cmd),
|
||||
cast(str | list[str], cmd),
|
||||
shell=not is_list,
|
||||
cwd=cwd,
|
||||
env=run_env,
|
||||
@@ -87,9 +91,12 @@ def run_command(spec: TaskSpec[Any]) -> Any: # noqa: PLR0912
|
||||
raise RuntimeError(f"{label}执行异常: {cmd_str}: {e}") from e
|
||||
|
||||
if verbose:
|
||||
print(f"[verbose] 返回码: {result.returncode}", flush=True)
|
||||
style = "green" if result.returncode == 0 else "red"
|
||||
_cmd_console.print(f"[{style}]返回码: {result.returncode}[/{style}]")
|
||||
|
||||
if result.returncode == 0:
|
||||
if not verbose and result.stdout:
|
||||
print(result.stdout, end="", flush=True)
|
||||
return None
|
||||
|
||||
err_msg = f"{label}执行失败: `{cmd_str}`, 返回码: {result.returncode}"
|
||||
|
||||
@@ -16,8 +16,9 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from typing import Any
|
||||
|
||||
from .task import Condition, Context
|
||||
|
||||
|
||||
@@ -16,8 +16,9 @@ DAG 库中泛滥的样板包装器。
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from collections.abc import Mapping
|
||||
from functools import lru_cache
|
||||
from typing import Any, Mapping
|
||||
from typing import Any
|
||||
|
||||
from .errors import InjectionError
|
||||
from .task import Context, TaskSpec
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
|
||||
class PyFlowXError(Exception):
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
+24
-12
@@ -48,8 +48,11 @@ import inspect
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from datetime import datetime
|
||||
from typing import Any, Awaitable, Callable, Literal, Mapping, cast
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from .context import build_call_args, describe_injection
|
||||
from .errors import TaskFailedError, TaskTimeoutError
|
||||
@@ -60,6 +63,9 @@ from .task import TaskEvent, TaskHooks, TaskResult, TaskSpec, TaskStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# verbose 模式输出用的 Console (不抢占用户 stdout, 仅格式化)
|
||||
_verbose_console = Console()
|
||||
|
||||
# 进程池复用:同一次 run() 内的 process 任务共享一个 ProcessPoolExecutor。
|
||||
# 模块级缓存避免每次任务都创建/销毁进程池的开销。
|
||||
# run() 结束后通过 _shutdown_process_pool() 关闭(shutdown(wait=False) +
|
||||
@@ -666,7 +672,7 @@ class AsyncLayerRunner:
|
||||
return task_ctx, result
|
||||
|
||||
results = await asyncio.gather(*[_run_async_task(name) for name in to_run])
|
||||
for name, (task_ctx, result) in zip(to_run, results):
|
||||
for name, (task_ctx, result) in zip(to_run, results, strict=True):
|
||||
_store_result(name, result, graph.resolved_spec(name), task_ctx, context, report, backend, on_event)
|
||||
|
||||
|
||||
@@ -723,23 +729,22 @@ class DependencyRunner:
|
||||
# 公共 API
|
||||
# ---------------------------------------------------------------------- #
|
||||
def _make_verbose_callback(on_event: EventCallback | None) -> EventCallback:
|
||||
"""包装 on_event 回调, 在 verbose 模式下打印任务生命周期。"""
|
||||
"""包装 on_event 回调, 在 verbose 模式下用 rich 打印任务生命周期。"""
|
||||
|
||||
def _verbose_callback(event: TaskEvent) -> None:
|
||||
dur = f" ({event.duration:.3f}s)" if event.duration is not None else ""
|
||||
if event.status == TaskStatus.RUNNING:
|
||||
print(f"[verbose] 任务 {event.task!r} 开始执行...", flush=True)
|
||||
_verbose_console.print(f"[cyan]▸[/cyan] [bold]{event.task!r}[/bold] 开始执行...")
|
||||
elif event.status == TaskStatus.SUCCESS:
|
||||
print(f"[verbose] 任务 {event.task!r} 成功{dur}", flush=True)
|
||||
_verbose_console.print(f"[green]✓[/green] [bold]{event.task!r}[/bold] 成功[dim]{dur}[/dim]")
|
||||
elif event.status == TaskStatus.FAILED:
|
||||
err = f": {event.error}" if event.error else ""
|
||||
print(
|
||||
f"[verbose] 任务 {event.task!r} 失败{dur} (尝试 {event.attempts} 次){err}",
|
||||
flush=True,
|
||||
_verbose_console.print(
|
||||
f"[red]✗[/red] [bold]{event.task!r}[/bold] 失败[dim]{dur} (尝试 {event.attempts} 次)[/dim][red]{err}[/red]"
|
||||
)
|
||||
elif event.status == TaskStatus.SKIPPED:
|
||||
reason = f" ({event.reason})" if event.reason else ""
|
||||
print(f"[verbose] 任务 {event.task!r} 跳过{reason}", flush=True)
|
||||
_verbose_console.print(f"[yellow]○[/yellow] [bold]{event.task!r}[/bold] 跳过[dim]{reason}[/dim]")
|
||||
if on_event is not None:
|
||||
on_event(event)
|
||||
|
||||
@@ -792,6 +797,13 @@ def run(
|
||||
_print_dry_run(graph, layers)
|
||||
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 路径
|
||||
# 各自重复调用 validate()。
|
||||
graph.validate()
|
||||
@@ -833,11 +845,11 @@ def run(
|
||||
|
||||
def _print_dry_run(graph: Graph, layers: list[list[str]]) -> None:
|
||||
"""打印执行计划但不运行任何任务。"""
|
||||
print(f"Dry run: {len(graph)} tasks, {len(layers)} layers")
|
||||
_verbose_console.print(f"[bold]Dry run:[/bold] [cyan]{len(graph)}[/cyan] tasks, [cyan]{len(layers)}[/cyan] layers")
|
||||
for idx, layer in enumerate(layers, 1):
|
||||
print(f" Layer {idx}: {layer}")
|
||||
_verbose_console.print(f" [dim]Layer {idx}:[/dim] {layer}")
|
||||
for name in layer:
|
||||
print(f" - {describe_injection(graph.resolved_spec(name))}")
|
||||
_verbose_console.print(f" [cyan]-[/cyan] {describe_injection(graph.resolved_spec(name))}")
|
||||
|
||||
|
||||
def _drive_sequential(
|
||||
|
||||
+7
-14
@@ -1,7 +1,7 @@
|
||||
"""DAG 构建、校验、分层与可视化。
|
||||
|
||||
使用标准库的 :mod:`graphlib`(3.9+)或 :mod:`graphlib_backport`(3.8)
|
||||
进行拓扑排序。图以增量方式构建并即时校验,使配置错误在构建时(而非执行时)快速失败。
|
||||
使用标准库的 :mod:`graphlib` 进行拓扑排序。图以增量方式构建并即时校验,
|
||||
使配置错误在构建时(而非执行时)快速失败。
|
||||
|
||||
支持:
|
||||
* 图级默认值 :class:`GraphDefaults`,TaskSpec 字段为 ``None`` 时回退。
|
||||
@@ -17,22 +17,16 @@ __all__ = [
|
||||
"GraphDefaults",
|
||||
]
|
||||
|
||||
import graphlib
|
||||
import inspect
|
||||
import sys
|
||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any, Callable, Iterable, Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from .errors import CycleError, DuplicateTaskError, MissingDependencyError
|
||||
from .task import Context, RetryPolicy, TaskSpec
|
||||
|
||||
if sys.version_info >= (3, 9): # pragma: no cover
|
||||
import graphlib # pyright: ignore[reportUnreachable]
|
||||
|
||||
_TopologicalSorter = graphlib.TopologicalSorter
|
||||
else: # pragma: no cover
|
||||
import graphlib # type: ignore[import-untyped]
|
||||
|
||||
_TopologicalSorter = graphlib.TopologicalSorter # pragma: no cover
|
||||
_TopologicalSorter = graphlib.TopologicalSorter
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -219,7 +213,6 @@ class Graph:
|
||||
"""
|
||||
graph = cls(defaults=defaults or GraphDefaults(), namespace=namespace)
|
||||
pending_refs: list[str] = []
|
||||
|
||||
for spec in specs:
|
||||
if isinstance(spec, str):
|
||||
pending_refs.append(spec)
|
||||
@@ -294,7 +287,7 @@ class Graph:
|
||||
sorter = _TopologicalSorter(self.deps)
|
||||
try:
|
||||
sorter.prepare()
|
||||
except graphlib.CycleError as exc: # type: ignore[name-defined]
|
||||
except graphlib.CycleError as exc:
|
||||
cycle: Sequence[str] = exc.args[1] if len(exc.args) > 1 else []
|
||||
raise CycleError(list(cycle)) from exc
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""ops 子包 - CLI 工具模块集合.
|
||||
|
||||
每个子模块使用 ``@px.tool`` 装饰器注册 CLI 工具, 由 ``pf`` 入口按需
|
||||
``importlib.import_module`` 触发注册. 子模块按工具名组织 (一文件一工具).
|
||||
|
||||
子模块
|
||||
------
|
||||
- :mod:`autofmt` —— 代码格式化 (ruff)
|
||||
- :mod:`bumpversion` —— 版本号管理
|
||||
- :mod:`clr` —— 清屏
|
||||
- :mod:`dockercmd` —— Docker 镜像登录
|
||||
- :mod:`envdev` —— 开发环境镜像源配置
|
||||
- :mod:`filedate` —— 文件日期修改
|
||||
- :mod:`filelevel` —— 文件等级
|
||||
- :mod:`folderback` —— 文件夹备份
|
||||
- :mod:`folderzip` —— 文件夹压缩
|
||||
- :mod:`gittool` —— Git 工具
|
||||
- :mod:`lscalc` —— LS-DYNA 计算
|
||||
- :mod:`msdownload` —— ModelScope 下载
|
||||
- :mod:`packtool` —— 打包工具
|
||||
- :mod:`pdftool` —— PDF 工具
|
||||
- :mod:`piptool` —— pip 工具
|
||||
- :mod:`pymake` —— Python 项目构建
|
||||
- :mod:`reseticoncache` —— 重置图标缓存
|
||||
- :mod:`screenshot` —— 截图
|
||||
- :mod:`sglang` —— SGLang 服务
|
||||
- :mod:`sshcopyid` —— SSH 公钥拷贝
|
||||
- :mod:`taskkill` —— 进程终止
|
||||
- :mod:`which` —— 命令查找
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__: list[str] = []
|
||||
@@ -0,0 +1,66 @@
|
||||
"""ops 共享辅助: 跨平台命令选择、平台守卫、共享忽略模式."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from pyflowx.conditions import Constants
|
||||
|
||||
__all__ = ["IGNORE_PATTERNS", "ensure_platform", "platform_command"]
|
||||
|
||||
IGNORE_PATTERNS: list[str] = [
|
||||
"__pycache__",
|
||||
"*.pyc",
|
||||
"*.pyo",
|
||||
".git",
|
||||
".venv",
|
||||
".idea",
|
||||
".vscode",
|
||||
"*.egg-info",
|
||||
"dist",
|
||||
"build",
|
||||
".pytest_cache",
|
||||
".tox",
|
||||
".mypy_cache",
|
||||
]
|
||||
|
||||
|
||||
def platform_command(windows: Sequence[str], posix: Sequence[str]) -> list[str]:
|
||||
"""按当前平台返回对应命令.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
windows : Sequence[str]
|
||||
Windows 平台命令 (含参数).
|
||||
posix : Sequence[str]
|
||||
Linux/macOS 平台命令 (含参数).
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[str]
|
||||
当前平台对应的命令列表 (副本, 可安全修改).
|
||||
"""
|
||||
return list(windows) if Constants.IS_WINDOWS else list(posix)
|
||||
|
||||
|
||||
def ensure_platform(predicate: bool, tool_name: str, platform_name: str) -> bool:
|
||||
"""平台不满足时打印提示, 返回是否满足.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
predicate : bool
|
||||
平台匹配谓词 (如 ``Constants.IS_WINDOWS``).
|
||||
tool_name : str
|
||||
工具名称, 用于提示信息前缀.
|
||||
platform_name : str
|
||||
目标平台名称, 用于提示信息.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
平台满足时返回 True, 否则打印 ``{tool_name}: 仅在 {platform_name} 上支持`` 并返回 False.
|
||||
"""
|
||||
if not predicate:
|
||||
print(f"{tool_name}: 仅在 {platform_name} 上支持")
|
||||
return False
|
||||
return True
|
||||
@@ -1,52 +1,60 @@
|
||||
"""自动格式化工具模块.
|
||||
"""autofmt - 自动格式化工具.
|
||||
|
||||
提供 Python 代码自动格式化的常用功能封装,
|
||||
支持 docstring 自动生成、pyproject.toml 配置同步等功能.
|
||||
提供格式化代码/代码检查/自动添加文档/同步配置 子命令.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pyflowx as px
|
||||
from pyflowx.ops._common import IGNORE_PATTERNS
|
||||
|
||||
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",
|
||||
__all__ = [
|
||||
"add_docstring",
|
||||
"auto_add_docstrings",
|
||||
"fmt",
|
||||
"format_all",
|
||||
"format_with_ruff",
|
||||
"generate_module_docstring",
|
||||
"lint",
|
||||
"lint_with_ruff",
|
||||
"sync_pyproject_config",
|
||||
]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 辅助函数
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@px.tool("autofmt", subcommand="fmt", help="格式化代码")
|
||||
def fmt(target: str = ".") -> None:
|
||||
"""格式化代码 (ruff format).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
target : str
|
||||
目标路径 (默认: .)
|
||||
"""
|
||||
format_with_ruff(Path(target), fix=False)
|
||||
|
||||
|
||||
@px.tool("autofmt", subcommand="lint", help="代码检查")
|
||||
def lint(target: str = ".", fix: bool = False) -> None:
|
||||
"""代码检查 (ruff check).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
target : str
|
||||
目标路径 (默认: .)
|
||||
fix : bool
|
||||
自动修复问题
|
||||
"""
|
||||
lint_with_ruff(Path(target), fix=fix)
|
||||
|
||||
|
||||
def format_with_ruff(target: Path, fix: bool = True) -> None:
|
||||
"""使用 ruff 格式化代码.
|
||||
|
||||
@@ -102,12 +110,10 @@ def add_docstring(file_path: Path, docstring: str) -> bool:
|
||||
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("")
|
||||
@@ -138,7 +144,6 @@ def generate_module_docstring(file_path: Path) -> str:
|
||||
stem = file_path.stem
|
||||
parent = file_path.parent.name
|
||||
|
||||
# 关键词匹配
|
||||
keywords = {
|
||||
"cli": f"Command-line interface for {parent}",
|
||||
"gui": f"Graphical user interface for {parent}",
|
||||
@@ -155,13 +160,14 @@ def generate_module_docstring(file_path: Path) -> str:
|
||||
return f'"""{stem.replace("_", " ").title()} module."""'
|
||||
|
||||
|
||||
def auto_add_docstrings(root_dir: Path) -> int:
|
||||
@px.tool("autofmt", subcommand="doc", help="自动添加文档字符串")
|
||||
def auto_add_docstrings(root_dir: Path = Path()) -> int:
|
||||
"""自动为所有 Python 文件添加 docstring.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
root_dir : Path
|
||||
根目录
|
||||
根目录 (默认: 当前目录)
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -170,7 +176,6 @@ def auto_add_docstrings(root_dir: Path) -> int:
|
||||
"""
|
||||
count = 0
|
||||
for py_file in root_dir.rglob("*.py"):
|
||||
# 跳过忽略的文件
|
||||
if any(pattern in str(py_file) for pattern in IGNORE_PATTERNS):
|
||||
continue
|
||||
|
||||
@@ -182,20 +187,20 @@ def auto_add_docstrings(root_dir: Path) -> int:
|
||||
return count
|
||||
|
||||
|
||||
def sync_pyproject_config(root_dir: Path) -> None:
|
||||
@px.tool("autofmt", subcommand="sync", help="同步 pyproject 配置")
|
||||
def sync_pyproject_config(root_dir: Path = Path()) -> None:
|
||||
"""同步 pyproject.toml 配置到子项目.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
root_dir : Path
|
||||
根目录
|
||||
根目录 (默认: 当前目录)
|
||||
"""
|
||||
main_toml = root_dir / "pyproject.toml"
|
||||
if not main_toml.exists():
|
||||
print(f"主项目配置文件不存在: {main_toml}")
|
||||
return
|
||||
|
||||
# 查找所有子项目的 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:
|
||||
@@ -204,7 +209,6 @@ def sync_pyproject_config(root_dir: Path) -> None:
|
||||
|
||||
print(f"找到 {len(sub_tomls)} 个子项目配置文件")
|
||||
|
||||
# 对每个子项目调用 ruff format
|
||||
for sub_toml in sub_tomls:
|
||||
subprocess.run(["ruff", "format", str(sub_toml)], check=False)
|
||||
|
||||
@@ -219,64 +223,6 @@ def format_all(root_dir: Path) -> None:
|
||||
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")
|
||||
@@ -0,0 +1,232 @@
|
||||
"""版本号管理模块.
|
||||
|
||||
提供单文件版本号更新 (``bump_file_version``) 与项目级批量版本号同步
|
||||
(``bump_project_version``) 能力. ``bump_project_version`` 通过 ``@px.tool``
|
||||
注册为 CLI 工具, 可通过 ``pf bumpversion`` 调用.
|
||||
|
||||
设计要点
|
||||
--------
|
||||
``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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 公共函数
|
||||
# ============================================================================
|
||||
|
||||
|
||||
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.tool("bumpversion", help="版本号自动管理")
|
||||
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
|
||||
@@ -0,0 +1,20 @@
|
||||
"""clr - 清屏工具.
|
||||
|
||||
跨平台清屏: Windows 调用 ``cls``, Linux/macOS 调用 ``clear``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
import pyflowx as px
|
||||
from pyflowx.ops._common import platform_command
|
||||
|
||||
|
||||
@px.tool("clr", help="清屏 (跨平台)")
|
||||
def clear_screen_run() -> None:
|
||||
"""清屏 (跨平台).
|
||||
|
||||
Windows 调用 ``cls``, Linux/macOS 调用 ``clear``.
|
||||
"""
|
||||
subprocess.run(platform_command(["cls"], ["clear"]), check=False)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""dockercmd - Docker 镜像登录工具.
|
||||
|
||||
登录腾讯云 Docker 镜像仓库.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import getpass
|
||||
import subprocess
|
||||
|
||||
import pyflowx as px
|
||||
|
||||
_DOCKER_MIRROR_TENCENT: str = "ccr.ccs.tencentyun.com"
|
||||
|
||||
|
||||
@px.tool("dockercmd", subcommand="login", help="登录腾讯云 Docker 镜像仓库")
|
||||
def docker_login_tencent(username: str = "") -> None:
|
||||
"""登录腾讯云 Docker 镜像仓库.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
username : str
|
||||
Docker 用户名 (为空时由 docker 交互式提示输入)
|
||||
"""
|
||||
user = username or getpass.getuser()
|
||||
subprocess.run(["docker", "login", "--username", user, _DOCKER_MIRROR_TENCENT], check=False)
|
||||
print(f"已尝试登录腾讯云镜像仓库 (用户: {user})")
|
||||
@@ -0,0 +1,418 @@
|
||||
"""envdev - 开发环境镜像源配置工具.
|
||||
|
||||
配置 Python / Conda / Rust 镜像源 (Linux 还会安装 Qt 库、中文字体、Docker).
|
||||
所有镜像源参数互不影响, 可单独使用.
|
||||
Linux 专用操作 (系统镜像/Qt/字体/Docker) 在非 Linux 平台上由函数内部跳过.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import getpass
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import pyflowx as px
|
||||
from pyflowx.conditions import Constants
|
||||
from pyflowx.ops._common import ensure_platform
|
||||
|
||||
__all__ = [
|
||||
"download_rustup_script",
|
||||
"install_linux_docker",
|
||||
"install_linux_fonts",
|
||||
"install_linux_qt_libs",
|
||||
"install_rust_toolchain",
|
||||
"setup_conda_mirror",
|
||||
"setup_linux_system_mirror",
|
||||
"setup_python_mirror",
|
||||
"setup_rust_mirror",
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# 配置常量
|
||||
# ============================================================================
|
||||
|
||||
PyMirrorType = Literal["tsinghua", "aliyun", "huaweicloud", "ustc", "zju"]
|
||||
CondaMirrorType = Literal["tsinghua", "ustc", "bsfu", "aliyun"]
|
||||
RustMirrorType = Literal["tsinghua", "ustc", "aliyun"]
|
||||
|
||||
_PIP_INDEX_URLS: dict[str, str] = {
|
||||
"tsinghua": "https://pypi.tuna.tsinghua.edu.cn/simple",
|
||||
"aliyun": "https://mirrors.aliyun.com/pypi/simple/",
|
||||
"huaweicloud": "https://mirrors.huaweicloud.com/repository/pypi/simple/",
|
||||
"ustc": "https://pypi.mirrors.ustc.edu.cn/simple/",
|
||||
"zju": "https://mirrors.zju.edu.cn/pypi/simple/",
|
||||
}
|
||||
|
||||
_PIP_TRUSTED_HOSTS: dict[str, str] = {
|
||||
"tsinghua": "pypi.tuna.tsinghua.edu.cn",
|
||||
"aliyun": "mirrors.aliyun.com",
|
||||
"huaweicloud": "mirrors.huaweicloud.com",
|
||||
"ustc": "pypi.mirrors.ustc.edu.cn",
|
||||
"zju": "mirrors.zju.edu.cn",
|
||||
}
|
||||
|
||||
_UV_PYTHON_INSTALL_MIRROR: str = "https://registry.npmmirror.com/-/binary/python-build-standalone"
|
||||
|
||||
_CONDA_MIRROR_URLS: dict[str, list[str]] = {
|
||||
"tsinghua": [
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/",
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/",
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/r/",
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/msys2/",
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/pro/",
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/",
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/bioconda/",
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/menpo/",
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/pytorch/",
|
||||
],
|
||||
"ustc": [
|
||||
"https://mirrors.ustc.edu.cn/anaconda/pkgs/main/",
|
||||
"https://mirrors.ustc.edu.cn/anaconda/pkgs/free/",
|
||||
"https://mirrors.ustc.edu.cn/anaconda/pkgs/r/",
|
||||
"https://mirrors.ustc.edu.cn/anaconda/pkgs/msys2/",
|
||||
"https://mirrors.ustc.edu.cn/anaconda/pkgs/pro/",
|
||||
"https://mirrors.ustc.edu.cn/anaconda/pkgs/dev/",
|
||||
"https://mirrors.ustc.edu.cn/anaconda/cloud/conda-forge/",
|
||||
"https://mirrors.ustc.edu.cn/anaconda/cloud/bioconda/",
|
||||
"https://mirrors.ustc.edu.cn/anaconda/cloud/menpo/",
|
||||
"https://mirrors.ustc.edu.cn/anaconda/cloud/pytorch/",
|
||||
],
|
||||
"bsfu": [
|
||||
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/main/",
|
||||
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/free/",
|
||||
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/r/",
|
||||
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/msys2/",
|
||||
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/pro/",
|
||||
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/dev/",
|
||||
"https://mirrors.bsfu.edu.cn/anaconda/cloud/conda-forge/",
|
||||
"https://mirrors.bsfu.edu.cn/anaconda/cloud/bioconda/",
|
||||
"https://mirrors.bsfu.edu.cn/anaconda/cloud/menpo/",
|
||||
"https://mirrors.bsfu.edu.cn/anaconda/cloud/pytorch/",
|
||||
],
|
||||
"aliyun": [
|
||||
"https://mirrors.aliyun.com/anaconda/pkgs/main/",
|
||||
"https://mirrors.aliyun.com/anaconda/pkgs/free/",
|
||||
"https://mirrors.aliyun.com/anaconda/pkgs/r/",
|
||||
"https://mirrors.aliyun.com/anaconda/pkgs/msys2/",
|
||||
"https://mirrors.aliyun.com/anaconda/pkgs/pro/",
|
||||
"https://mirrors.aliyun.com/anaconda/pkgs/dev/",
|
||||
"https://mirrors.aliyun.com/anaconda/cloud/conda-forge/",
|
||||
"https://mirrors.aliyun.com/anaconda/cloud/bioconda/",
|
||||
"https://mirrors.aliyun.com/anaconda/cloud/menpo/",
|
||||
"https://mirrors.aliyun.com/anaconda/cloud/pytorch/",
|
||||
],
|
||||
}
|
||||
|
||||
_RUSTUP_MIRRORS: dict[str, dict[str, str]] = {
|
||||
"tsinghua": {
|
||||
"RUSTUP_DIST_SERVER": "https://mirrors.tuna.tsinghua.edu.cn/rustup",
|
||||
"RUSTUP_UPDATE_ROOT": "https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup",
|
||||
"TOML_REGISTRY": "https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/",
|
||||
},
|
||||
"aliyun": {
|
||||
"RUSTUP_DIST_SERVER": "https://mirrors.aliyun.com/rustup",
|
||||
"RUSTUP_UPDATE_ROOT": "https://mirrors.aliyun.com/rustup/rustup",
|
||||
"TOML_REGISTRY": "https://mirrors.aliyun.com/crates.io-index/",
|
||||
},
|
||||
"ustc": {
|
||||
"RUSTUP_DIST_SERVER": "https://mirrors.ustc.edu.cn/rust-static",
|
||||
"RUSTUP_UPDATE_ROOT": "https://mirrors.ustc.edu.cn/rust-static/rustup",
|
||||
"TOML_REGISTRY": "https://mirrors.ustc.edu.cn/crates.io-index/",
|
||||
},
|
||||
}
|
||||
|
||||
_RUST_SCCACHE_DIR: Path = Path.home() / ".cargo" / "sccache"
|
||||
_RUST_SCCACHE_CACHE_SIZE: str = "20G"
|
||||
|
||||
_QT_LIBS: list[str] = [
|
||||
"build-essential",
|
||||
"libgl1",
|
||||
"libegl1",
|
||||
"libglib2.0-0",
|
||||
"libfontconfig1",
|
||||
"libfreetype6",
|
||||
"libxkbcommon0",
|
||||
"libdbus-1-3",
|
||||
"libxcb-xinerama0",
|
||||
"libxcb-icccm4",
|
||||
"libxcb-image0",
|
||||
"libxcb-keysyms1",
|
||||
"libxcb-randr0",
|
||||
"libxcb-render-util0",
|
||||
"libxcb-shape0",
|
||||
"libxcb-xfixes0",
|
||||
"libxcb-cursor0",
|
||||
]
|
||||
|
||||
_CHINESE_FONTS: list[str] = [
|
||||
"fonts-noto-cjk",
|
||||
"fonts-wqy-microhei",
|
||||
"fonts-wqy-zenhei",
|
||||
"fonts-noto-color-emoji",
|
||||
]
|
||||
|
||||
_DOWNLOAD_MIRROR_SCRIPT: str = "curl -sSL https://linuxmirrors.cn/main.sh -o /tmp/linuxmirrors.sh"
|
||||
_INSTALL_MIRROR_SCRIPT: str = "sudo bash /tmp/linuxmirrors.sh"
|
||||
|
||||
_RUSTUP_DOWNLOAD_URL_LINUX: str = "https://mirrors.aliyun.com/repo/rust/rustup-init.sh"
|
||||
_RUSTUP_DOWNLOAD_URL_WINDOWS: str = "https://static.rust-lang.org/rustup/dist/x86_64-pc-windows-msvc/rustup-init.exe"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 私有辅助
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _pip_config_path() -> Path:
|
||||
"""返回当前平台的 pip 配置文件路径."""
|
||||
if Constants.IS_LINUX:
|
||||
return Path.home() / ".pip" / "pip.conf"
|
||||
return Path.home() / "pip" / "pip.ini"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 镜像源配置函数
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@px.tool("envdev", subcommand="setup-python", help="配置 Python 镜像源")
|
||||
def setup_python_mirror(mirror: str = "tsinghua") -> None:
|
||||
"""配置 Python 镜像源 (设置环境变量 + 写入 pip 配置文件).
|
||||
|
||||
设置 ``PIP_INDEX_URL`` / ``PIP_TRUSTED_HOSTS`` / ``UV_INDEX_URL`` /
|
||||
``UV_PYTHON_INSTALL_MIRROR`` 等环境变量, 并写入 pip 配置文件.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
mirror : str
|
||||
镜像源名称: tsinghua/aliyun/huaweicloud/ustc/zju (默认: tsinghua)
|
||||
"""
|
||||
if mirror not in _PIP_INDEX_URLS:
|
||||
print(f"未知 Python 镜像源: {mirror}")
|
||||
return
|
||||
|
||||
index_url = _PIP_INDEX_URLS[mirror]
|
||||
trusted_host = _PIP_TRUSTED_HOSTS[mirror]
|
||||
|
||||
os.environ["PIP_INDEX_URL"] = index_url
|
||||
os.environ["PIP_TRUSTED_HOSTS"] = trusted_host
|
||||
os.environ["UV_INDEX_URL"] = index_url
|
||||
os.environ["UV_PYTHON_INSTALL_MIRROR"] = _UV_PYTHON_INSTALL_MIRROR
|
||||
os.environ["UV_HTTP_TIMEOUT"] = "600"
|
||||
os.environ["UV_LINK_MODE"] = "copy"
|
||||
|
||||
config_path = _pip_config_path()
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = f"[global]\nindex-url = {index_url}\ntrusted-host = {trusted_host}\n"
|
||||
config_path.write_text(content, encoding="utf-8")
|
||||
print(f"Python 镜像源已配置: {mirror} -> {config_path}")
|
||||
|
||||
|
||||
@px.tool("envdev", subcommand="setup-conda", help="配置 Conda 镜像源")
|
||||
def setup_conda_mirror(mirror: str = "tsinghua") -> None:
|
||||
"""配置 Conda 镜像源 (写入 ~/.condarc).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
mirror : str
|
||||
镜像源名称: tsinghua/ustc/bsfu/aliyun (默认: tsinghua)
|
||||
"""
|
||||
if mirror not in _CONDA_MIRROR_URLS:
|
||||
print(f"未知 Conda 镜像源: {mirror}")
|
||||
return
|
||||
|
||||
urls = _CONDA_MIRROR_URLS[mirror]
|
||||
config_path = Path.home() / ".condarc"
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = "show_channel_urls: true\nchannels:\n - " + "\n - ".join(urls) + "\n - defaults\n"
|
||||
config_path.write_text(content, encoding="utf-8")
|
||||
print(f"Conda 镜像源已配置: {mirror} -> {config_path}")
|
||||
|
||||
|
||||
@px.tool("envdev", subcommand="setup-rust", help="配置 Rust 镜像源")
|
||||
def setup_rust_mirror(mirror: str = "tsinghua", version: str = "stable") -> None:
|
||||
"""配置 Rust 镜像源 (设置环境变量 + 写入 cargo config + 创建 sccache 目录).
|
||||
|
||||
设置 ``RUSTUP_DIST_SERVER`` / ``RUSTUP_UPDATE_ROOT`` / ``RUST_SCCACHE_DIR``
|
||||
等环境变量, 写入 ``~/.cargo/config.toml``, 并创建 sccache 缓存目录.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
mirror : str
|
||||
镜像源名称: tsinghua/ustc/aliyun (默认: tsinghua)
|
||||
version : str
|
||||
Rust 版本 (未使用, 保留以与原 envdev 参数对齐)
|
||||
"""
|
||||
del version # 兼容旧参数, 实际安装由独立 job 处理
|
||||
|
||||
if mirror not in _RUSTUP_MIRRORS:
|
||||
print(f"未知 Rust 镜像源: {mirror}")
|
||||
return
|
||||
|
||||
mirrors = _RUSTUP_MIRRORS[mirror]
|
||||
os.environ["RUSTUP_DIST_SERVER"] = mirrors["RUSTUP_DIST_SERVER"]
|
||||
os.environ["RUSTUP_UPDATE_ROOT"] = mirrors["RUSTUP_UPDATE_ROOT"]
|
||||
os.environ["RUST_SCCACHE_DIR"] = str(_RUST_SCCACHE_DIR)
|
||||
os.environ["RUST_SCCACHE_CACHE_SIZE"] = _RUST_SCCACHE_CACHE_SIZE
|
||||
|
||||
_RUST_SCCACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config_path = Path.home() / ".cargo" / "config.toml"
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
registry = mirrors["TOML_REGISTRY"]
|
||||
content = (
|
||||
f"\n[source.crates-io]\nreplace-with = '{mirror}'\n\n"
|
||||
f'[source.{mirror}]\nregistry = "sparse+{registry}"\n\n'
|
||||
f'[registries.{mirror}]\nindex = "sparse+{registry}"\n'
|
||||
)
|
||||
config_path.write_text(content, encoding="utf-8")
|
||||
print(f"Rust 镜像源已配置: {mirror} -> {config_path}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Rust 工具链安装
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@px.tool("envdev", subcommand="download-rustup", help="下载 Rustup 安装脚本")
|
||||
def download_rustup_script() -> None:
|
||||
"""下载 Rustup 安装脚本 (跨平台, 已安装 rustup 时跳过).
|
||||
|
||||
Linux 下载 ``rustup-init.sh``, Windows 下载 ``rustup-init.exe``.
|
||||
"""
|
||||
if shutil.which("rustup") is not None:
|
||||
print("rustup 已安装, 跳过下载")
|
||||
return
|
||||
|
||||
if Constants.IS_WINDOWS:
|
||||
print("下载 rustup-init.exe...")
|
||||
subprocess.run(
|
||||
[
|
||||
"powershell",
|
||||
"-Command",
|
||||
"Invoke-WebRequest",
|
||||
"-Uri",
|
||||
_RUSTUP_DOWNLOAD_URL_WINDOWS,
|
||||
"-OutFile",
|
||||
"rustup-init.exe",
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
else:
|
||||
print("下载 rustup-init.sh...")
|
||||
subprocess.run(
|
||||
["curl", "-fsSL", _RUSTUP_DOWNLOAD_URL_LINUX, "-o", "rustup-init.sh"],
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
@px.tool(
|
||||
"envdev",
|
||||
subcommand="install-rust",
|
||||
help="安装 Rust 工具链",
|
||||
needs=["setup-rust", "download-rustup"],
|
||||
allow_upstream_skip=True,
|
||||
)
|
||||
def install_rust_toolchain(version: str = "stable") -> None:
|
||||
"""安装 Rust 工具链 (rustup 未安装时跳过).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
version : str
|
||||
Rust 版本: ``stable`` / ``nightly`` / ``beta`` (默认: ``stable``)
|
||||
"""
|
||||
if shutil.which("rustup") is None:
|
||||
print("rustup 未安装, 跳过工具链安装")
|
||||
return
|
||||
|
||||
subprocess.run(["rustup", "toolchain", "install", version], check=False)
|
||||
print(f"Rust 工具链 {version} 安装完成")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Linux 专用函数
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@px.tool("envdev", subcommand="setup-linux-mirror", help="配置 Linux 系统镜像源")
|
||||
def setup_linux_system_mirror() -> None:
|
||||
"""下载并安装 Linux 系统镜像源 (仅 Linux, 已配置国内镜像时跳过).
|
||||
|
||||
检查 ``/etc/apt/sources.list`` 与 ``/etc/apt/sources.list.d/ubuntu.sources``
|
||||
是否已配置国内镜像, 已配置则跳过; 未配置则下载并执行 linuxmirrors 脚本.
|
||||
"""
|
||||
if not ensure_platform(Constants.IS_LINUX, "setup_linux_system_mirror", "Linux"):
|
||||
return
|
||||
|
||||
apt_files = ["/etc/apt/sources.list", "/etc/apt/sources.list.d/ubuntu.sources"]
|
||||
mirror_keys = list(_PIP_INDEX_URLS.keys())
|
||||
already_configured = False
|
||||
for apt_file in apt_files:
|
||||
try:
|
||||
content = Path(apt_file).read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
if any(mirror in content for mirror in mirror_keys):
|
||||
already_configured = True
|
||||
break
|
||||
|
||||
if already_configured:
|
||||
print("已配置国内镜像源, 跳过系统镜像配置")
|
||||
return
|
||||
|
||||
print("下载 linuxmirrors 脚本...")
|
||||
subprocess.run(_DOWNLOAD_MIRROR_SCRIPT, shell=True, check=False)
|
||||
print("安装 linuxmirrors...")
|
||||
subprocess.run(_INSTALL_MIRROR_SCRIPT, shell=True, check=False)
|
||||
|
||||
|
||||
@px.tool(
|
||||
"envdev",
|
||||
subcommand="install-qt-libs",
|
||||
help="安装 Qt 依赖库",
|
||||
needs=["setup-linux-mirror"],
|
||||
allow_upstream_skip=True,
|
||||
)
|
||||
def install_linux_qt_libs() -> None:
|
||||
"""安装 Qt 依赖库 (仅 Linux)."""
|
||||
if not ensure_platform(Constants.IS_LINUX, "install_linux_qt_libs", "Linux"):
|
||||
return
|
||||
|
||||
subprocess.run(["sudo", "apt", "install", "-y", *_QT_LIBS], check=False)
|
||||
print("Qt 依赖库安装完成")
|
||||
|
||||
|
||||
@px.tool(
|
||||
"envdev",
|
||||
subcommand="install-fonts",
|
||||
help="安装中文字体",
|
||||
needs=["setup-linux-mirror"],
|
||||
allow_upstream_skip=True,
|
||||
)
|
||||
def install_linux_fonts() -> None:
|
||||
"""安装中文字体 (仅 Linux)."""
|
||||
if not ensure_platform(Constants.IS_LINUX, "install_linux_fonts", "Linux"):
|
||||
return
|
||||
|
||||
subprocess.run(["sudo", "apt", "install", "-y", *_CHINESE_FONTS], check=False)
|
||||
print("中文字体安装完成")
|
||||
|
||||
|
||||
@px.tool(
|
||||
"envdev",
|
||||
subcommand="install-docker",
|
||||
help="安装 Docker",
|
||||
needs=["setup-linux-mirror"],
|
||||
allow_upstream_skip=True,
|
||||
)
|
||||
def install_linux_docker() -> None:
|
||||
"""安装 Docker (仅 Linux)."""
|
||||
if not ensure_platform(Constants.IS_LINUX, "install_linux_docker", "Linux"):
|
||||
return
|
||||
|
||||
subprocess.run(["sudo", "apt", "install", "-y", "docker-compose-v2"], check=False)
|
||||
subprocess.run(["sudo", "usermod", "-aG", "docker", getpass.getuser()], check=False)
|
||||
print("Docker 安装完成 (需重新登录以生效 docker 用户组)")
|
||||
@@ -1,18 +1,26 @@
|
||||
"""文件日期处理工具.
|
||||
"""filedate - 文件日期处理工具.
|
||||
|
||||
自动检测文件名的日期前缀,
|
||||
并根据文件的实际创建或修改时间重命名文件.
|
||||
提供添加日期前缀/清除日期前缀 子命令.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pyflowx as px
|
||||
|
||||
__all__ = [
|
||||
"DATE_PATTERN",
|
||||
"SEP",
|
||||
"add_date_prefix",
|
||||
"get_file_timestamp",
|
||||
"process_file_date",
|
||||
"process_files_date",
|
||||
"remove_date_prefix",
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# 配置
|
||||
# ============================================================================
|
||||
@@ -22,7 +30,7 @@ SEP = "_"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 辅助函数
|
||||
# 公共函数
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@@ -69,11 +77,34 @@ def process_file_date(filepath: Path, clear: bool = False) -> None:
|
||||
if clear:
|
||||
remove_date_prefix(filepath)
|
||||
else:
|
||||
# 先移除旧日期前缀,再添加新日期前缀
|
||||
new_path = remove_date_prefix(filepath)
|
||||
add_date_prefix(new_path)
|
||||
|
||||
|
||||
@px.tool("filedate", subcommand="add", help="添加日期前缀")
|
||||
def process_files_date_add(files: list[Path]) -> None:
|
||||
"""添加日期前缀.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
files : list[Path]
|
||||
文件路径列表
|
||||
"""
|
||||
process_files_date(files, clear=False)
|
||||
|
||||
|
||||
@px.tool("filedate", subcommand="clear", help="清除日期前缀")
|
||||
def process_files_date_clear(files: list[Path]) -> None:
|
||||
"""清除日期前缀.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
files : list[Path]
|
||||
文件路径列表
|
||||
"""
|
||||
process_files_date(files, clear=True)
|
||||
|
||||
|
||||
def process_files_date(targets: list[Path], clear: bool = False) -> None:
|
||||
"""批量处理文件日期前缀.
|
||||
|
||||
@@ -87,51 +118,3 @@ def process_files_date(targets: list[Path], clear: bool = False) -> None:
|
||||
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")
|
||||
@@ -1,16 +1,22 @@
|
||||
"""文件等级重命名工具.
|
||||
"""filelevel - 文件等级重命名工具.
|
||||
|
||||
根据文件等级配置自动重命名文件,
|
||||
支持多种等级标识和括号格式.
|
||||
提供设置文件等级 子命令.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import pyflowx as px
|
||||
|
||||
__all__ = [
|
||||
"BRACKETS",
|
||||
"LEVELS",
|
||||
"process_file_level",
|
||||
"process_files_level",
|
||||
"remove_marks",
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# 配置
|
||||
# ============================================================================
|
||||
@@ -27,7 +33,7 @@ BRACKETS: tuple[str, str] = (" ([_(【-", " )]_)】")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 辅助函数
|
||||
# 公共函数
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@@ -69,72 +75,34 @@ def process_file_level(filepath: Path, level: int = 0) -> None:
|
||||
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:
|
||||
@px.tool("filelevel", subcommand="set", help="设置文件等级")
|
||||
def process_files_level(files: list[Path], level: int = 0) -> None:
|
||||
"""批量处理文件等级标记.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
targets : list[Path]
|
||||
files : list[Path]
|
||||
文件路径列表
|
||||
level : int
|
||||
文件等级 (0-4)
|
||||
"""
|
||||
for target in targets:
|
||||
for target in files:
|
||||
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")
|
||||
@@ -1,7 +1,6 @@
|
||||
"""文件夹备份工具.
|
||||
"""folderback - 文件夹备份工具.
|
||||
|
||||
备份文件和文件夹为 zip 文件,
|
||||
自动删除超过最大数量的旧备份文件.
|
||||
备份当前文件夹到指定目录, 自动清理旧备份.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,8 +11,15 @@ from pathlib import Path
|
||||
|
||||
import pyflowx as px
|
||||
|
||||
__all__ = [
|
||||
"backup_folder",
|
||||
"remove_dump",
|
||||
"zip_target",
|
||||
]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 辅助函数
|
||||
# 公共函数
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@@ -40,17 +46,18 @@ def zip_target(src: Path, dst: Path, max_zip: int) -> None:
|
||||
print(f"备份完成: {target_path}")
|
||||
|
||||
|
||||
def backup_folder(src: str, dst: str, max_zip: int = 5) -> None:
|
||||
@px.tool("folderback", help="备份文件夹")
|
||||
def backup_folder(src: str = ".", dst: str = "./backup", max_zip: int = 5) -> None:
|
||||
"""备份文件夹.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
src : str
|
||||
源文件夹路径
|
||||
源文件夹路径 (默认: 当前目录)
|
||||
dst : str
|
||||
目标文件夹路径
|
||||
目标文件夹路径 (默认: ./backup)
|
||||
max_zip : int
|
||||
最大备份数量
|
||||
最大备份数量 (默认: 5)
|
||||
"""
|
||||
src_path = Path(src)
|
||||
dst_path = Path(dst)
|
||||
@@ -64,22 +71,3 @@ def backup_folder(src: str, dst: str, max_zip: int = 5) -> None:
|
||||
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()
|
||||
@@ -1,7 +1,6 @@
|
||||
"""文件夹压缩工具.
|
||||
"""folderzip - 文件夹压缩工具.
|
||||
|
||||
压缩目录下的所有文件/文件夹为 zip 文件,
|
||||
默认压缩当前目录下的所有子文件夹.
|
||||
压缩当前目录下的所有子文件夹为 zip 文件.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -11,6 +10,14 @@ from pathlib import Path
|
||||
|
||||
import pyflowx as px
|
||||
|
||||
__all__ = [
|
||||
"IGNORE_DIRS",
|
||||
"IGNORE_EXT",
|
||||
"IGNORE_FILES",
|
||||
"archive_folder",
|
||||
"zip_folders",
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# 配置
|
||||
# ============================================================================
|
||||
@@ -22,7 +29,7 @@ IGNORE_EXT: list[str] = [".zip", ".rar", ".7z", ".tar", ".gz"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 辅助函数
|
||||
# 公共函数
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@@ -36,13 +43,14 @@ def archive_folder(folder: Path) -> None:
|
||||
print(f"压缩完成: {folder.name}.zip")
|
||||
|
||||
|
||||
@px.tool("folderzip", help="压缩文件夹")
|
||||
def zip_folders(cwd: str = ".") -> None:
|
||||
"""压缩目录下的所有文件夹.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
cwd : str
|
||||
工作目录
|
||||
工作目录 (默认: 当前目录)
|
||||
"""
|
||||
cwd_path = Path(cwd)
|
||||
if not cwd_path.exists():
|
||||
@@ -55,22 +63,3 @@ def zip_folders(cwd: str = ".") -> None:
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""gittool - Git 执行工具.
|
||||
|
||||
提供添加提交/清理/初始化/推送/拉取 子命令.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pyflowx as px
|
||||
|
||||
EXCLUDE_DIRS = [
|
||||
# 编辑器相关目录
|
||||
".vscode",
|
||||
".idea",
|
||||
".editorconfig",
|
||||
".trae",
|
||||
".qoder",
|
||||
# 项目相关目录
|
||||
".venv",
|
||||
".git",
|
||||
".ruff_cache",
|
||||
".tox",
|
||||
"node_modules",
|
||||
]
|
||||
|
||||
EXCLUDE_CMDS = [arg for d in EXCLUDE_DIRS for arg in ["-e", d]]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 私有辅助函数
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def not_has_git_repo() -> bool:
|
||||
"""检查当前目录没有 Git 仓库."""
|
||||
return not Path.cwd().exists() or not (Path.cwd() / ".git").is_dir()
|
||||
|
||||
|
||||
def has_files() -> bool:
|
||||
"""检查当前 Git 仓库是否有未提交的更改."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return bool(result.stdout.strip())
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# fn 子命令
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@px.tool("gittool", subcommand="a", help="添加并提交")
|
||||
def git_add_commit(message: str = "chore: update") -> None:
|
||||
"""执行 git add + git commit (仅当有未提交更改时).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
message : str
|
||||
提交信息 (默认: ``chore: update``)
|
||||
"""
|
||||
if not has_files():
|
||||
print("没有文件需要提交")
|
||||
return
|
||||
subprocess.run(["git", "add", "."], check=True)
|
||||
subprocess.run(["git", "commit", "-m", message], check=True)
|
||||
|
||||
|
||||
@px.tool("gittool", subcommand="i", help="初始化并提交")
|
||||
def git_init_add_commit(message: str = "init commit") -> None:
|
||||
"""执行 git init (若需) + git add + git commit (若有更改).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
message : str
|
||||
提交信息 (默认: ``init commit``)
|
||||
"""
|
||||
if not_has_git_repo():
|
||||
subprocess.run(["git", "init"], check=True)
|
||||
if has_files():
|
||||
subprocess.run(["git", "add", "."], check=True)
|
||||
subprocess.run(["git", "commit", "-m", message], check=True)
|
||||
else:
|
||||
print("没有文件需要提交")
|
||||
|
||||
|
||||
@px.tool("gittool", subcommand="isub", help="初始化子目录")
|
||||
def init_sub_dirs() -> None:
|
||||
"""初始化子目录的 Git 仓库."""
|
||||
sub_dirs = [subdir for subdir in Path.cwd().iterdir() if subdir.is_dir()]
|
||||
for subdir in sub_dirs:
|
||||
px.run(
|
||||
px.Graph().chain(
|
||||
px.cmd(["git", "init"], conditions=(lambda _: not_has_git_repo(),), cwd=subdir),
|
||||
px.cmd(["git", "add", "."]),
|
||||
px.cmd(["git", "commit", "-m", "init commit"]),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# 兼容别名: 与 dev.py 旧函数名对齐, 便于外部调用
|
||||
a = git_add_commit
|
||||
i = git_init_add_commit
|
||||
isub = init_sub_dirs
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# cmd 子命令
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@px.tool(
|
||||
"gittool",
|
||||
subcommand="clean",
|
||||
help="清理未跟踪文件",
|
||||
cmd=["git", "clean", "-xfd", *EXCLUDE_CMDS],
|
||||
hidden=True,
|
||||
)
|
||||
def clean() -> None:
|
||||
"""清理 Git 未跟踪文件 (隐藏命令, 被 c 依赖)."""
|
||||
|
||||
|
||||
@px.tool(
|
||||
"gittool",
|
||||
subcommand="c",
|
||||
help="清理并查看状态",
|
||||
cmd=["git", "status", "--porcelain"],
|
||||
needs=["clean"],
|
||||
)
|
||||
def c() -> None:
|
||||
"""清理未跟踪文件并查看 Git 状态."""
|
||||
|
||||
|
||||
@px.tool("gittool", subcommand="p", help="推送", cmd=["git", "push"])
|
||||
def p() -> None:
|
||||
"""推送代码到远程仓库."""
|
||||
|
||||
|
||||
@px.tool("gittool", subcommand="pl", help="拉取", cmd=["git", "pull"])
|
||||
def pl() -> None:
|
||||
"""从远程仓库拉取代码."""
|
||||
@@ -0,0 +1,111 @@
|
||||
"""lscalc - LS-DYNA 计算工具.
|
||||
|
||||
运行 LS-DYNA 计算 (单机/MPI) 与进程状态检查.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pyflowx as px
|
||||
from pyflowx.conditions import Constants
|
||||
|
||||
_DEFAULT_NCPU: int = 4
|
||||
|
||||
|
||||
def get_ls_dyna_command(input_file: str, ncpu: int) -> list[str]:
|
||||
"""获取 LS-DYNA 命令.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_file : str
|
||||
输入文件路径
|
||||
ncpu : int
|
||||
CPU 核心数
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[str]
|
||||
LS-DYNA 命令列表
|
||||
"""
|
||||
return ["ls-dyna_mpp", f"i={input_file}", f"ncpu={ncpu}"]
|
||||
|
||||
|
||||
@px.tool("lscalc", subcommand="run", help="运行 LS-DYNA 计算")
|
||||
def run_ls_dyna(input_file: str, ncpu: int = _DEFAULT_NCPU) -> None:
|
||||
"""运行 LS-DYNA 计算.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_file : str
|
||||
输入文件路径
|
||||
ncpu : int
|
||||
CPU 核心数
|
||||
"""
|
||||
input_path = Path(input_file)
|
||||
if not input_path.exists():
|
||||
print(f"输入文件不存在: {input_path}")
|
||||
return
|
||||
|
||||
cmd = get_ls_dyna_command(input_file, ncpu)
|
||||
try:
|
||||
subprocess.run(cmd, check=True)
|
||||
print(f"LS-DYNA 计算完成: {input_file}")
|
||||
except FileNotFoundError:
|
||||
print("未找到 ls-dyna_mpp 命令")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"LS-DYNA 计算失败: {e}")
|
||||
|
||||
|
||||
@px.tool("lscalc", subcommand="mpi", help="运行 LS-DYNA MPI 计算")
|
||||
def run_ls_dyna_mpi(input_file: str, ncpu: int = _DEFAULT_NCPU) -> None:
|
||||
"""运行 LS-DYNA MPI 计算.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_file : str
|
||||
输入文件路径
|
||||
ncpu : int
|
||||
CPU 核心数
|
||||
"""
|
||||
input_path = Path(input_file)
|
||||
if not input_path.exists():
|
||||
print(f"输入文件不存在: {input_path}")
|
||||
return
|
||||
|
||||
cmd = ["mpirun", "-np", str(ncpu), "ls-dyna_mpp", f"i={input_file}"]
|
||||
try:
|
||||
subprocess.run(cmd, check=True)
|
||||
print(f"LS-DYNA MPI 计算完成: {input_file}")
|
||||
except FileNotFoundError:
|
||||
print("未找到 mpirun 或 ls-dyna_mpp 命令")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"LS-DYNA MPI 计算失败: {e}")
|
||||
|
||||
|
||||
@px.tool("lscalc", subcommand="status", help="检查 LS-DYNA 进程状态")
|
||||
def check_ls_dyna_status() -> None:
|
||||
"""检查 LS-DYNA 进程状态."""
|
||||
try:
|
||||
if Constants.IS_WINDOWS:
|
||||
result = subprocess.run(
|
||||
["tasklist", "/fi", "imagename eq ls-dyna_mpp.exe"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
print(result.stdout)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", "ls-dyna"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.stdout.strip():
|
||||
print(f"运行中的 LS-DYNA 进程 PID: {result.stdout.strip()}")
|
||||
else:
|
||||
print("没有运行中的 LS-DYNA 进程")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"检查进程状态失败: {e}")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""msdownload - ModelScope 模型/数据集下载工具.
|
||||
|
||||
从 ModelScope 下载模型、数据集或空间.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pyflowx as px
|
||||
|
||||
__all__ = ["msdownload_run"]
|
||||
|
||||
|
||||
@px.tool("msdownload", help="ModelScope 模型/数据集下载工具")
|
||||
def msdownload_run(name: str, target_type: str = "model", download_dir: str | None = None) -> None:
|
||||
"""从 ModelScope 下载模型/数据集/空间.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
目标名称 (如: ``Qwen/Qwen2.5-Coder-32B-Instruct``)
|
||||
target_type : str
|
||||
目标类型: ``model`` / ``dataset`` / ``space`` (默认: ``model``)
|
||||
download_dir : str | None
|
||||
下载目录; 为 None 时默认 ``~/.models/<name 最后一段>``
|
||||
"""
|
||||
if not name:
|
||||
print("msdownload: name 不能为空")
|
||||
return
|
||||
|
||||
if download_dir:
|
||||
out_dir = Path(download_dir)
|
||||
else:
|
||||
out_dir = Path.home() / ".models" / name.rsplit("/", 1)[-1]
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cmd = ["uvx", "modelscope", "download", f"--{target_type}", name, "--local_dir", str(out_dir)]
|
||||
print(f"下载 {target_type}: {name} -> {out_dir}")
|
||||
subprocess.run(cmd, check=False)
|
||||
@@ -0,0 +1,236 @@
|
||||
"""packtool - Python 打包工具.
|
||||
|
||||
提供源码打包/依赖打包/wheel 构建/嵌入式 Python 安装/zip 包创建/清理 子命令.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pyflowx as px
|
||||
from pyflowx.ops._common import IGNORE_PATTERNS
|
||||
|
||||
__all__ = [
|
||||
"clean_build_dir",
|
||||
"create_zip_package",
|
||||
"install_embed_python",
|
||||
"pack_dependencies",
|
||||
"pack_source",
|
||||
"pack_wheel",
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# 配置
|
||||
# ============================================================================
|
||||
|
||||
DEFAULT_BUILD_DIR = ".pypack"
|
||||
DEFAULT_DIST_DIR = "dist"
|
||||
DEFAULT_LIB_DIR = "libs"
|
||||
DEFAULT_CACHE_DIR = ".cache/pypack"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 公共函数
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@px.tool("packtool", subcommand="src", help="打包源码")
|
||||
def pack_source(project_dir: Path = Path(), output_dir: Path = Path(".pypack")) -> None:
|
||||
"""打包项目源码.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project_dir : Path
|
||||
项目目录 (默认: 当前目录)
|
||||
output_dir : Path
|
||||
输出目录 (默认: .pypack)
|
||||
"""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
pyproject_file = project_dir / "pyproject.toml"
|
||||
project_name = project_dir.name
|
||||
|
||||
if pyproject_file.exists():
|
||||
try:
|
||||
import tomllib
|
||||
|
||||
content = pyproject_file.read_text(encoding="utf-8")
|
||||
data = tomllib.loads(content)
|
||||
project_name = data.get("project", {}).get("name", project_name)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
source_dir = output_dir / "src" / project_name
|
||||
source_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
src_subdir = project_dir / "src"
|
||||
if src_subdir.exists():
|
||||
shutil.copytree(
|
||||
src_subdir,
|
||||
source_dir / "src",
|
||||
ignore=shutil.ignore_patterns(*IGNORE_PATTERNS),
|
||||
dirs_exist_ok=True,
|
||||
)
|
||||
else:
|
||||
for item in project_dir.iterdir():
|
||||
if item.name in IGNORE_PATTERNS or item.name.startswith("."):
|
||||
continue
|
||||
dst_item = source_dir / item.name
|
||||
if item.is_dir():
|
||||
shutil.copytree(
|
||||
item,
|
||||
dst_item,
|
||||
ignore=shutil.ignore_patterns(*IGNORE_PATTERNS),
|
||||
dirs_exist_ok=True,
|
||||
)
|
||||
else:
|
||||
shutil.copy2(item, dst_item)
|
||||
|
||||
print(f"源码打包完成: {source_dir}")
|
||||
|
||||
|
||||
@px.tool("packtool", subcommand="deps", help="打包依赖")
|
||||
def pack_dependencies(lib_dir: Path = Path("libs"), dependencies: list[str] | None = None) -> None:
|
||||
"""打包项目依赖.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
lib_dir : Path
|
||||
依赖库目录 (默认: libs)
|
||||
dependencies : list[str] | None
|
||||
依赖列表
|
||||
"""
|
||||
if dependencies is None:
|
||||
dependencies = []
|
||||
|
||||
lib_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not dependencies:
|
||||
print("没有依赖需要打包")
|
||||
return
|
||||
|
||||
cmd = [
|
||||
"pip",
|
||||
"install",
|
||||
"--target",
|
||||
str(lib_dir),
|
||||
"--no-compile",
|
||||
"--no-warn-script-location",
|
||||
]
|
||||
cmd.extend(dependencies)
|
||||
|
||||
subprocess.run(cmd, check=True)
|
||||
print(f"依赖打包完成: {lib_dir}")
|
||||
|
||||
|
||||
@px.tool("packtool", subcommand="wheel", help="构建 wheel")
|
||||
def pack_wheel(project_dir: Path = Path(), output_dir: Path = Path("dist")) -> None:
|
||||
"""打包项目为 wheel 文件.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project_dir : Path
|
||||
项目目录 (默认: 当前目录)
|
||||
output_dir : Path
|
||||
输出目录 (默认: dist)
|
||||
"""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cmd = [
|
||||
"pip",
|
||||
"wheel",
|
||||
"--no-deps",
|
||||
"--wheel-dir",
|
||||
str(output_dir),
|
||||
str(project_dir),
|
||||
]
|
||||
|
||||
subprocess.run(cmd, check=True)
|
||||
print(f"Wheel 打包完成: {output_dir}")
|
||||
|
||||
|
||||
@px.tool("packtool", subcommand="embed", help="安装嵌入式 Python")
|
||||
def install_embed_python(version: str = "3.10", output_dir: Path = Path("python")) -> None:
|
||||
"""安装嵌入式 Python.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
version : str
|
||||
Python 版本 (如: 3.10, 3.11), 默认 3.10
|
||||
output_dir : Path
|
||||
输出目录 (默认: python)
|
||||
"""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
arch = platform.machine().lower()
|
||||
if arch in ["x86_64", "amd64"]:
|
||||
arch = "amd64"
|
||||
elif arch in ["arm64", "aarch64"]:
|
||||
arch = "arm64"
|
||||
|
||||
version_map = {
|
||||
"3.8": "3.8.10",
|
||||
"3.9": "3.9.13",
|
||||
"3.10": "3.10.11",
|
||||
"3.11": "3.11.9",
|
||||
"3.12": "3.12.4",
|
||||
}
|
||||
full_version = version_map.get(version, f"{version}.0")
|
||||
|
||||
url = f"https://www.python.org/ftp/python/{full_version}/python-{full_version}-embed-{arch}.zip"
|
||||
|
||||
cache_file = Path(DEFAULT_CACHE_DIR) / f"python-{full_version}-embed-{arch}.zip"
|
||||
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not cache_file.exists():
|
||||
print(f"正在下载嵌入式 Python {full_version}...")
|
||||
urllib.request.urlretrieve(url, cache_file)
|
||||
print(f"下载完成: {cache_file}")
|
||||
|
||||
with zipfile.ZipFile(cache_file, "r") as zf:
|
||||
zf.extractall(output_dir)
|
||||
|
||||
print(f"嵌入式 Python 安装完成: {output_dir}")
|
||||
|
||||
|
||||
@px.tool("packtool", subcommand="zip", help="创建 zip 包")
|
||||
def create_zip_package(source_dir: Path = Path(), output_file: Path = Path("package.zip")) -> None:
|
||||
"""创建 ZIP 打包文件.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
source_dir : Path
|
||||
源目录 (默认: 当前目录)
|
||||
output_file : Path
|
||||
输出文件 (默认: package.zip)
|
||||
"""
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with zipfile.ZipFile(output_file, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for file in source_dir.rglob("*"):
|
||||
if file.is_file():
|
||||
arcname = file.relative_to(source_dir)
|
||||
zf.write(file, arcname)
|
||||
|
||||
print(f"ZIP 打包完成: {output_file}")
|
||||
|
||||
|
||||
@px.tool("packtool", subcommand="clean", help="清理构建目录")
|
||||
def clean_build_dir(build_dir: Path = Path(".pypack")) -> None:
|
||||
"""清理构建目录.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
build_dir : Path
|
||||
构建目录 (默认: .pypack)
|
||||
"""
|
||||
if build_dir.exists():
|
||||
shutil.rmtree(build_dir)
|
||||
print(f"清理完成: {build_dir}")
|
||||
else:
|
||||
print(f"目录不存在: {build_dir}")
|
||||
@@ -0,0 +1,512 @@
|
||||
"""pdftool - PDF 文件工具集.
|
||||
|
||||
提供 PDF 合并/拆分/压缩/加密/解密/提取文本/提取图片/水印/旋转/裁剪/
|
||||
信息/OCR/转图片/修复 等子命令.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pyflowx as px
|
||||
|
||||
__all__ = [
|
||||
"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",
|
||||
]
|
||||
|
||||
try:
|
||||
import fitz # PyMuPDF
|
||||
|
||||
HAS_PYMUPDF = True
|
||||
except ImportError:
|
||||
HAS_PYMUPDF = False
|
||||
|
||||
try:
|
||||
import pypdf
|
||||
|
||||
HAS_PYPDF = True
|
||||
except ImportError:
|
||||
HAS_PYPDF = False
|
||||
|
||||
|
||||
def _require_pymupdf() -> bool:
|
||||
"""PyMuPDF 未安装时打印提示, 返回是否可用."""
|
||||
if not HAS_PYMUPDF:
|
||||
print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _require_pypdf() -> bool:
|
||||
"""pypdf 未安装时打印提示, 返回是否可用."""
|
||||
if not HAS_PYPDF:
|
||||
print("未安装 pypdf 库, 请安装: pip install pypdf")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@px.tool("pdftool", subcommand="m", help="合并 PDF")
|
||||
def pdf_merge(input_paths: list[Path], output_path: Path = Path("merged.pdf")) -> None:
|
||||
"""合并多个 PDF 文件.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_paths : list[Path]
|
||||
输入 PDF 文件列表
|
||||
output_path : Path
|
||||
输出文件 (默认: merged.pdf)
|
||||
"""
|
||||
if not _require_pypdf():
|
||||
return
|
||||
|
||||
writer = pypdf.PdfWriter()
|
||||
for input_path in input_paths:
|
||||
if input_path.exists():
|
||||
reader = pypdf.PdfReader(str(input_path))
|
||||
for page in reader.pages:
|
||||
writer.add_page(page)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "wb") as f:
|
||||
writer.write(f)
|
||||
|
||||
print(f"合并完成: {output_path}")
|
||||
|
||||
|
||||
@px.tool("pdftool", subcommand="s", help="拆分 PDF")
|
||||
def pdf_split(input_path: Path, output_dir: Path = Path("split")) -> None:
|
||||
"""拆分 PDF 文件为单页.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_path : Path
|
||||
输入 PDF 文件
|
||||
output_dir : Path
|
||||
输出目录 (默认: split)
|
||||
"""
|
||||
if not _require_pypdf():
|
||||
return
|
||||
|
||||
reader = pypdf.PdfReader(str(input_path))
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for i, page in enumerate(reader.pages):
|
||||
writer = pypdf.PdfWriter()
|
||||
writer.add_page(page)
|
||||
output_file = output_dir / f"{input_path.stem}_page_{i + 1}.pdf"
|
||||
with open(output_file, "wb") as f:
|
||||
writer.write(f)
|
||||
|
||||
print(f"拆分完成: {output_dir}")
|
||||
|
||||
|
||||
@px.tool("pdftool", subcommand="c", help="压缩 PDF")
|
||||
def pdf_compress(input_path: Path, output_path: Path = Path("compressed.pdf")) -> None:
|
||||
"""压缩 PDF 文件.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_path : Path
|
||||
输入 PDF 文件
|
||||
output_path : Path
|
||||
输出文件 (默认: compressed.pdf)
|
||||
"""
|
||||
if not _require_pymupdf():
|
||||
return
|
||||
|
||||
doc = fitz.open(str(input_path))
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
doc.save(str(output_path), garbage=4, deflate=True, clean=True)
|
||||
doc.close()
|
||||
|
||||
original_size = input_path.stat().st_size
|
||||
new_size = output_path.stat().st_size
|
||||
ratio = (1 - new_size / original_size) * 100
|
||||
print(f"压缩完成: {output_path} (缩小 {ratio:.1f}%)")
|
||||
|
||||
|
||||
@px.tool("pdftool", subcommand="e", help="加密 PDF")
|
||||
def pdf_encrypt(input_path: Path, output_path: Path = Path("encrypted.pdf"), password: str = "") -> None:
|
||||
"""加密 PDF 文件.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_path : Path
|
||||
输入 PDF 文件
|
||||
output_path : Path
|
||||
输出文件 (默认: encrypted.pdf)
|
||||
password : str
|
||||
密码 (必填)
|
||||
"""
|
||||
if not password:
|
||||
print("错误: --password 为必填参数")
|
||||
return
|
||||
if not _require_pypdf():
|
||||
return
|
||||
|
||||
reader = pypdf.PdfReader(str(input_path))
|
||||
writer = pypdf.PdfWriter()
|
||||
|
||||
for page in reader.pages:
|
||||
writer.add_page(page)
|
||||
|
||||
writer.encrypt(user_password=password, owner_password=password, use_128bit=True)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "wb") as f:
|
||||
writer.write(f)
|
||||
|
||||
print(f"加密完成: {output_path}")
|
||||
|
||||
|
||||
@px.tool("pdftool", subcommand="d", help="解密 PDF")
|
||||
def pdf_decrypt(input_path: Path, output_path: Path = Path("decrypted.pdf"), password: str = "") -> None:
|
||||
"""解密 PDF 文件.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_path : Path
|
||||
输入 PDF 文件
|
||||
output_path : Path
|
||||
输出文件 (默认: decrypted.pdf)
|
||||
password : str
|
||||
密码 (必填)
|
||||
"""
|
||||
if not password:
|
||||
print("错误: --password 为必填参数")
|
||||
return
|
||||
if not _require_pypdf():
|
||||
return
|
||||
|
||||
reader = pypdf.PdfReader(str(input_path))
|
||||
if reader.is_encrypted:
|
||||
reader.decrypt(password)
|
||||
|
||||
writer = pypdf.PdfWriter()
|
||||
for page in reader.pages:
|
||||
writer.add_page(page)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "wb") as f:
|
||||
writer.write(f)
|
||||
|
||||
print(f"解密完成: {output_path}")
|
||||
|
||||
|
||||
@px.tool("pdftool", subcommand="xt", help="提取文本")
|
||||
def pdf_extract_text(input_path: Path, output_path: Path = Path("output.txt")) -> None:
|
||||
"""提取 PDF 文本.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_path : Path
|
||||
输入 PDF 文件
|
||||
output_path : Path
|
||||
输出文件 (默认: output.txt)
|
||||
"""
|
||||
if not _require_pymupdf():
|
||||
return
|
||||
|
||||
doc = fitz.open(str(input_path))
|
||||
text = ""
|
||||
for page in doc:
|
||||
text += str(page.get_text()) + "\n\n"
|
||||
doc.close()
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(text, encoding="utf-8")
|
||||
print(f"文本提取完成: {output_path}")
|
||||
|
||||
|
||||
@px.tool("pdftool", subcommand="xi", help="提取图片")
|
||||
def pdf_extract_images(input_path: Path, output_dir: Path = Path("images")) -> None:
|
||||
"""提取 PDF 图片.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_path : Path
|
||||
输入 PDF 文件
|
||||
output_dir : Path
|
||||
输出目录 (默认: images)
|
||||
"""
|
||||
if not _require_pymupdf():
|
||||
return
|
||||
|
||||
doc = fitz.open(str(input_path))
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
image_count = 0
|
||||
# pyrefly: ignore [bad-argument-type]
|
||||
for page_num, page in enumerate(doc):
|
||||
images = page.get_images(full=True)
|
||||
for img_idx, img in enumerate(images):
|
||||
xref = img[0]
|
||||
base_image = doc.extract_image(xref)
|
||||
image_data = base_image["image"]
|
||||
image_ext = base_image["ext"]
|
||||
image_path = output_dir / f"page_{page_num + 1}_img_{img_idx + 1}.{image_ext}"
|
||||
image_path.write_bytes(image_data)
|
||||
image_count += 1
|
||||
|
||||
doc.close()
|
||||
print(f"图片提取完成: {output_dir} (共 {image_count} 张)")
|
||||
|
||||
|
||||
@px.tool("pdftool", subcommand="w", help="添加水印")
|
||||
def pdf_add_watermark(
|
||||
input_path: Path, output_path: Path = Path("watermarked.pdf"), text: str = "CONFIDENTIAL"
|
||||
) -> None:
|
||||
"""添加 PDF 水印.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_path : Path
|
||||
输入 PDF 文件
|
||||
output_path : Path
|
||||
输出文件 (默认: watermarked.pdf)
|
||||
text : str
|
||||
水印文字 (默认: CONFIDENTIAL)
|
||||
"""
|
||||
if not _require_pymupdf():
|
||||
return
|
||||
|
||||
doc = fitz.open(str(input_path))
|
||||
for page in doc:
|
||||
rect = page.rect
|
||||
text_width = fitz.get_text_length(text, fontsize=48)
|
||||
x = (rect.width - text_width) / 2
|
||||
y = rect.height / 2
|
||||
page.insert_text((x, y), text, fontsize=48, rotate=45, color=(0, 0, 0))
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
doc.save(str(output_path))
|
||||
doc.close()
|
||||
print(f"水印添加完成: {output_path}")
|
||||
|
||||
|
||||
@px.tool("pdftool", subcommand="r", help="旋转 PDF")
|
||||
def pdf_rotate(input_path: Path, output_path: Path = Path("rotated.pdf"), rotation: int = 90) -> None:
|
||||
"""旋转 PDF 页面.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_path : Path
|
||||
输入 PDF 文件
|
||||
output_path : Path
|
||||
输出文件 (默认: rotated.pdf)
|
||||
rotation : int
|
||||
旋转角度 (默认: 90)
|
||||
"""
|
||||
if not _require_pymupdf():
|
||||
return
|
||||
|
||||
doc = fitz.open(str(input_path))
|
||||
for page in doc:
|
||||
page.set_rotation(rotation)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
doc.save(str(output_path))
|
||||
doc.close()
|
||||
print(f"旋转完成: {output_path}")
|
||||
|
||||
|
||||
@px.tool("pdftool", subcommand="crop", help="裁剪 PDF")
|
||||
def pdf_crop(
|
||||
input_path: Path,
|
||||
output_path: Path = Path("cropped.pdf"),
|
||||
margins: tuple[int, int, int, int] = (10, 10, 10, 10),
|
||||
) -> None:
|
||||
"""裁剪 PDF 页面.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_path : Path
|
||||
输入 PDF 文件
|
||||
output_path : Path
|
||||
输出文件 (默认: cropped.pdf)
|
||||
margins : tuple[int, int, int, int]
|
||||
边距 (左, 上, 右, 下), 默认 (10, 10, 10, 10)
|
||||
"""
|
||||
if not _require_pymupdf():
|
||||
return
|
||||
|
||||
doc = fitz.open(str(input_path))
|
||||
left, top, right, bottom = margins
|
||||
|
||||
for page in doc:
|
||||
rect = page.rect
|
||||
new_rect = fitz.Rect(
|
||||
rect.x0 + left,
|
||||
rect.y0 + top,
|
||||
rect.x1 - right,
|
||||
rect.y1 - bottom,
|
||||
)
|
||||
page.set_cropbox(new_rect)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
doc.save(str(output_path))
|
||||
doc.close()
|
||||
print(f"裁剪完成: {output_path}")
|
||||
|
||||
|
||||
@px.tool("pdftool", subcommand="i", help="查看 PDF 信息")
|
||||
def pdf_info(input_path: Path) -> None:
|
||||
"""显示 PDF 信息.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_path : Path
|
||||
输入 PDF 文件
|
||||
"""
|
||||
if not _require_pymupdf():
|
||||
return
|
||||
|
||||
doc = fitz.open(str(input_path))
|
||||
print(f"文件: {input_path}")
|
||||
print(f"页数: {doc.page_count}")
|
||||
# pyrefly: ignore [missing-attribute]
|
||||
print(f"标题: {doc.metadata.get('title', 'N/A')}")
|
||||
# pyrefly: ignore [missing-attribute]
|
||||
print(f"作者: {doc.metadata.get('author', 'N/A')}")
|
||||
# pyrefly: ignore [missing-attribute]
|
||||
print(f"创建日期: {doc.metadata.get('creationDate', 'N/A')}")
|
||||
# pyrefly: ignore [missing-attribute]
|
||||
print(f"修改日期: {doc.metadata.get('modDate', 'N/A')}")
|
||||
print(f"文件大小: {input_path.stat().st_size / 1024:.1f} KB")
|
||||
doc.close()
|
||||
|
||||
|
||||
@px.tool("pdftool", subcommand="ocr", help="PDF OCR 识别")
|
||||
def pdf_ocr(input_path: Path, output_path: Path = Path("ocr.pdf"), lang: str = "chi_sim+eng") -> None:
|
||||
"""PDF OCR 识别.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_path : Path
|
||||
输入 PDF 文件
|
||||
output_path : Path
|
||||
输出文件 (默认: ocr.pdf)
|
||||
lang : str
|
||||
识别语言 (默认: chi_sim+eng)
|
||||
"""
|
||||
try:
|
||||
import pytesseract
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
print("未安装 OCR 相关库, 请安装: pip install pytesseract pillow")
|
||||
return
|
||||
|
||||
if not _require_pymupdf():
|
||||
return
|
||||
|
||||
doc = fitz.open(str(input_path))
|
||||
new_doc = fitz.open()
|
||||
|
||||
for page in doc:
|
||||
pix = page.get_pixmap()
|
||||
img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
|
||||
ocr_text = pytesseract.image_to_string(img, lang=lang)
|
||||
|
||||
new_page = new_doc.new_page(width=page.rect.width, height=page.rect.height)
|
||||
new_page.insert_image(new_page.rect, pixmap=pix)
|
||||
text_rect = fitz.Rect(0, 0, page.rect.width, page.rect.height)
|
||||
# pyrefly: ignore [bad-argument-type]
|
||||
new_page.insert_textbox(text_rect, ocr_text, fontname="china-ss", fontsize=11)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
new_doc.save(str(output_path))
|
||||
new_doc.close()
|
||||
doc.close()
|
||||
print(f"OCR 识别完成: {output_path}")
|
||||
|
||||
|
||||
def pdf_reorder(input_path: Path, output_path: Path, order: list[int]) -> None:
|
||||
"""重排 PDF 页面顺序.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_path : Path
|
||||
输入 PDF 文件
|
||||
output_path : Path
|
||||
输出文件
|
||||
order : list[int]
|
||||
页面顺序列表 (0-based)
|
||||
"""
|
||||
if not _require_pypdf():
|
||||
return
|
||||
|
||||
reader = pypdf.PdfReader(str(input_path))
|
||||
writer = pypdf.PdfWriter()
|
||||
|
||||
for page_num in order:
|
||||
if 0 <= page_num < len(reader.pages):
|
||||
writer.add_page(reader.pages[page_num])
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "wb") as f:
|
||||
writer.write(f)
|
||||
|
||||
print(f"重排完成: {output_path}")
|
||||
|
||||
|
||||
@px.tool("pdftool", subcommand="img", help="PDF 转图片")
|
||||
def pdf_to_images(input_path: Path, output_dir: Path = Path("images"), dpi: int = 300) -> None:
|
||||
"""PDF 转图片.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_path : Path
|
||||
输入 PDF 文件
|
||||
output_dir : Path
|
||||
输出目录 (默认: images)
|
||||
dpi : int
|
||||
DPI (默认: 300)
|
||||
"""
|
||||
if not _require_pymupdf():
|
||||
return
|
||||
|
||||
doc = fitz.open(str(input_path))
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# pyrefly: ignore [bad-argument-type]
|
||||
for page_num, page in enumerate(doc):
|
||||
pix = page.get_pixmap(dpi=dpi)
|
||||
image_path = output_dir / f"{input_path.stem}_page_{page_num + 1}.png"
|
||||
pix.save(str(image_path))
|
||||
|
||||
doc.close()
|
||||
print(f"转换完成: {output_dir}")
|
||||
|
||||
|
||||
@px.tool("pdftool", subcommand="repair", help="修复 PDF")
|
||||
def pdf_repair(input_path: Path, output_path: Path = Path("repaired.pdf")) -> None:
|
||||
"""修复 PDF 文件.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_path : Path
|
||||
输入 PDF 文件
|
||||
output_path : Path
|
||||
输出文件 (默认: repaired.pdf)
|
||||
"""
|
||||
if not _require_pymupdf():
|
||||
return
|
||||
|
||||
doc = fitz.open(str(input_path))
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
doc.save(str(output_path), garbage=4, deflate=True, clean=True)
|
||||
doc.close()
|
||||
print(f"修复完成: {output_path}")
|
||||
@@ -0,0 +1,177 @@
|
||||
"""piptool - pip 包管理工具.
|
||||
|
||||
提供安装/卸载/重装/下载/升级 pip/冻结依赖 子命令.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pyflowx as px
|
||||
|
||||
__all__ = [
|
||||
"pip_download",
|
||||
"pip_freeze",
|
||||
"pip_install",
|
||||
"pip_reinstall",
|
||||
"pip_uninstall",
|
||||
"pip_upgrade",
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# 配置
|
||||
# ============================================================================
|
||||
|
||||
PACKAGE_DIR = "packages"
|
||||
REQUIREMENTS_FILE = "requirements.txt"
|
||||
|
||||
_PROTECTED_PACKAGES: frozenset[str] = frozenset(
|
||||
{
|
||||
"pyflowx",
|
||||
"bitool",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 私有辅助函数
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _get_installed_packages() -> list[str]:
|
||||
"""获取当前环境中所有已安装的包名."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pip", "list", "--format=freeze"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
packages: list[str] = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line and "==" in line:
|
||||
pkg_name = line.split("==")[0].strip()
|
||||
packages.append(pkg_name)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return []
|
||||
return packages
|
||||
|
||||
|
||||
def _expand_wildcard_packages(pattern: str) -> list[str]:
|
||||
"""展开通配符模式为实际的包名列表."""
|
||||
if not any(char in pattern for char in ["*", "?", "[", "]"]):
|
||||
return [pattern]
|
||||
|
||||
installed_packages = _get_installed_packages()
|
||||
matched = [pkg for pkg in installed_packages if fnmatch.fnmatchcase(pkg.lower(), pattern.lower())]
|
||||
return matched
|
||||
|
||||
|
||||
def _filter_protected_packages(packages: list[str]) -> list[str]:
|
||||
"""过滤掉受保护的包名."""
|
||||
safe = [p for p in packages if p.lower() not in {p.lower() for p in _PROTECTED_PACKAGES}]
|
||||
filtered = [p for p in packages if p.lower() in {p.lower() for p in _PROTECTED_PACKAGES}]
|
||||
if filtered:
|
||||
print(f"跳过受保护的包: {', '.join(filtered)}")
|
||||
return safe
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 公共函数
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@px.tool("piptool", subcommand="i", help="安装包")
|
||||
def pip_install(packages: list[str]) -> None:
|
||||
"""安装包.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
packages : list[str]
|
||||
包名列表
|
||||
"""
|
||||
subprocess.run(["pip", "install", *packages], check=True)
|
||||
print(f"安装完成: {', '.join(packages)}")
|
||||
|
||||
|
||||
@px.tool("piptool", subcommand="u", help="卸载包")
|
||||
def pip_uninstall(packages: list[str]) -> None:
|
||||
"""卸载包.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
packages : list[str]
|
||||
包名列表
|
||||
"""
|
||||
packages_to_uninstall: list[str] = []
|
||||
for pattern in packages:
|
||||
packages_to_uninstall.extend(_expand_wildcard_packages(pattern))
|
||||
|
||||
packages_to_uninstall = _filter_protected_packages(packages_to_uninstall)
|
||||
|
||||
if not packages_to_uninstall:
|
||||
return
|
||||
|
||||
subprocess.run(["pip", "uninstall", "-y", *packages_to_uninstall], check=True)
|
||||
|
||||
|
||||
@px.tool("piptool", subcommand="r", help="重装包")
|
||||
def pip_reinstall(packages: list[str], offline: bool = False) -> None:
|
||||
"""重新安装包.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
packages : list[str]
|
||||
包名列表
|
||||
offline : bool
|
||||
离线模式
|
||||
"""
|
||||
safe_ps = _filter_protected_packages(packages)
|
||||
if not safe_ps:
|
||||
print("所有指定的包均为受保护包, 跳过重装")
|
||||
return
|
||||
|
||||
subprocess.run(["pip", "uninstall", "-y", *safe_ps], check=True)
|
||||
|
||||
options = ["--no-index", "--find-links", "."] if offline else []
|
||||
subprocess.run(["pip", "install", *options, *safe_ps], check=True)
|
||||
|
||||
|
||||
@px.tool("piptool", subcommand="d", help="下载包")
|
||||
def pip_download(packages: list[str], offline: bool = False) -> None:
|
||||
"""下载包.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
packages : list[str]
|
||||
包名列表
|
||||
offline : bool
|
||||
离线模式
|
||||
"""
|
||||
options = ["--no-index", "--find-links", "."] if offline else []
|
||||
subprocess.run(
|
||||
["pip", "download", *packages, *options, "-d", PACKAGE_DIR],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
@px.tool("piptool", subcommand="up", help="升级 pip")
|
||||
def pip_upgrade() -> None:
|
||||
"""升级 pip 到最新版本."""
|
||||
subprocess.run(["python", "-m", "pip", "install", "--upgrade", "pip"], check=True)
|
||||
print("pip 升级完成")
|
||||
|
||||
|
||||
@px.tool("piptool", subcommand="f", help="导出依赖")
|
||||
def pip_freeze() -> None:
|
||||
"""冻结依赖到 requirements.txt."""
|
||||
result = subprocess.run(
|
||||
["pip", "freeze", "--exclude-editable"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
Path(REQUIREMENTS_FILE).write_text(result.stdout)
|
||||
print(f"依赖已导出到 {REQUIREMENTS_FILE}")
|
||||
@@ -0,0 +1,243 @@
|
||||
"""pymake - 项目构建工具.
|
||||
|
||||
提供构建/测试/清理/文档/格式化/发布等子命令.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"b",
|
||||
"ba",
|
||||
"bc",
|
||||
"bump",
|
||||
"bumpmi",
|
||||
"bumpversion",
|
||||
"c",
|
||||
"cov",
|
||||
"doc",
|
||||
"git_add_all",
|
||||
"git_push",
|
||||
"git_push_tags",
|
||||
"lint",
|
||||
"p",
|
||||
"pb",
|
||||
"publish_python",
|
||||
"pyrefly_check",
|
||||
"sync",
|
||||
"t",
|
||||
"tc",
|
||||
"test_coverage",
|
||||
"tf",
|
||||
"tox",
|
||||
"twine_publish",
|
||||
]
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pyflowx as px
|
||||
|
||||
# ============================================================================
|
||||
# 单任务别名 (cmd 任务)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@px.tool("pymake", subcommand="b", help="构建 Python 主包 (uv build)", cmd=["uv", "build"])
|
||||
def b(cwd: Path = Path()) -> None:
|
||||
"""构建 Python 主包."""
|
||||
|
||||
|
||||
@px.tool("pymake", subcommand="bc", help="构建 Rust 核心模块 (maturin build)", cmd=["maturin", "build", "-r"])
|
||||
def bc(cwd: Path = Path()) -> None:
|
||||
"""构建 Rust 核心模块."""
|
||||
|
||||
|
||||
@px.tool("pymake", subcommand="sync", help="同步依赖 (uv sync)", cmd=["uv", "sync"])
|
||||
def sync(cwd: Path = Path()) -> None:
|
||||
"""同步依赖."""
|
||||
|
||||
|
||||
@px.tool("pymake", subcommand="c", help="清理构建产物 (调用 gitt c)", cmd=["pf", "gitt", "c"])
|
||||
def c(cwd: Path = Path()) -> None:
|
||||
"""清理构建产物."""
|
||||
|
||||
|
||||
@px.tool(
|
||||
"pymake",
|
||||
subcommand="t",
|
||||
help="运行测试",
|
||||
cmd=["pytest", "-m", "not slow", "-n", "8", "--dist", "loadfile", "--color=yes", "--durations=10"],
|
||||
)
|
||||
def t(cwd: Path = Path()) -> None:
|
||||
"""运行测试 (含并行)."""
|
||||
|
||||
|
||||
@px.tool(
|
||||
"pymake",
|
||||
subcommand="tf",
|
||||
help="快速测试 (无 slow)",
|
||||
cmd=["pytest", "-m", "not slow", "--dist", "loadfile", "--color=yes", "--durations=10"],
|
||||
)
|
||||
def tf(cwd: Path = Path()) -> None:
|
||||
"""快速测试 (无 slow, 无并行)."""
|
||||
|
||||
|
||||
@px.tool("pymake", subcommand="bumpmi", help="升级次版本号 (bumpversion minor)", cmd=["pf", "bumpversion", "minor"])
|
||||
def bumpmi(cwd: Path = Path()) -> None:
|
||||
"""升级次版本号."""
|
||||
|
||||
|
||||
@px.tool(
|
||||
"pymake",
|
||||
subcommand="doc",
|
||||
help="构建 Sphinx 文档",
|
||||
cmd=["sphinx-build", "-b", "html", "docs", "docs/_build"],
|
||||
)
|
||||
def doc(cwd: Path = Path()) -> None:
|
||||
"""构建 Sphinx 文档."""
|
||||
|
||||
|
||||
@px.tool("pymake", subcommand="lint", help="代码格式化与检查 (ruff)", cmd=["ruff", "check", "--fix", "--unsafe-fixes"])
|
||||
def lint(cwd: Path = Path()) -> None:
|
||||
"""代码格式化与检查."""
|
||||
|
||||
|
||||
@px.tool("pymake", subcommand="tox", help="多版本测试 (tox)", cmd=["tox", "-p", "auto"])
|
||||
def tox(cwd: Path = Path()) -> None:
|
||||
"""多版本测试."""
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 内部 job (hidden, 不暴露为 subcommand)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@px.tool(
|
||||
"pymake",
|
||||
subcommand="bumpversion",
|
||||
help="升级版本号 (patch)",
|
||||
cmd=["pf", "bumpversion", "patch"],
|
||||
needs=["git_add_all"],
|
||||
hidden=True,
|
||||
)
|
||||
def bumpversion(cwd: Path = Path()) -> None:
|
||||
"""升级版本号 (patch, 内部 job)."""
|
||||
|
||||
|
||||
@px.tool(
|
||||
"pymake",
|
||||
subcommand="test_coverage",
|
||||
help="测试并生成覆盖率",
|
||||
cmd=["pytest", "--cov", "-n", "8", "--dist", "loadfile", "--tb=short", "-v", "--color=yes", "--durations=10"],
|
||||
needs=["c"],
|
||||
hidden=True,
|
||||
)
|
||||
def test_coverage(cwd: Path = Path()) -> None:
|
||||
"""测试并生成覆盖率 (内部 job)."""
|
||||
|
||||
|
||||
@px.tool(
|
||||
"pymake",
|
||||
subcommand="pyrefly_check",
|
||||
help="pyrefly 类型检查",
|
||||
cmd=["pyrefly", "check", "."],
|
||||
hidden=True,
|
||||
)
|
||||
def pyrefly_check(cwd: Path = Path()) -> None:
|
||||
"""pyrefly 类型检查 (内部 job)."""
|
||||
|
||||
|
||||
@px.tool(
|
||||
"pymake",
|
||||
subcommand="git_add_all",
|
||||
help="git add -A",
|
||||
cmd=["git", "add", "-A"],
|
||||
needs=["tc"],
|
||||
hidden=True,
|
||||
)
|
||||
def git_add_all(cwd: Path = Path()) -> None:
|
||||
"""git add -A (内部 job)."""
|
||||
|
||||
|
||||
@px.tool("pymake", subcommand="git_push", help="git push", cmd=["git", "push"], hidden=True)
|
||||
def git_push(cwd: Path = Path()) -> None:
|
||||
"""git push (内部 job)."""
|
||||
|
||||
|
||||
@px.tool(
|
||||
"pymake",
|
||||
subcommand="git_push_tags",
|
||||
help="git push --tags",
|
||||
cmd=["git", "push", "--tags"],
|
||||
hidden=True,
|
||||
)
|
||||
def git_push_tags(cwd: Path = Path()) -> None:
|
||||
"""git push --tags (内部 job)."""
|
||||
|
||||
|
||||
@px.tool(
|
||||
"pymake",
|
||||
subcommand="twine_publish",
|
||||
help="twine upload",
|
||||
cmd=["twine", "upload", "--disable-progress-bar"],
|
||||
hidden=True,
|
||||
)
|
||||
def twine_publish(cwd: Path = Path()) -> None:
|
||||
"""twine upload (内部 job)."""
|
||||
|
||||
|
||||
@px.tool("pymake", subcommand="publish_python", help="hatch publish", cmd=["hatch", "publish"], hidden=True)
|
||||
def publish_python(cwd: Path = Path()) -> None:
|
||||
"""hatch publish (内部 job)."""
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 聚合 job (有 needs 无 cmd)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@px.tool("pymake", subcommand="ba", help="构建所有包 (Python + Rust)", needs=["b", "bc"], strategy="thread")
|
||||
def ba(cwd: Path = Path()) -> None:
|
||||
"""构建所有包 (Python + Rust)."""
|
||||
|
||||
|
||||
@px.tool("pymake", subcommand="bump", help="升级版本号 (清理 + 检查 + add + bumpversion)", needs=["bumpversion"])
|
||||
def bump(cwd: Path = Path()) -> None:
|
||||
"""升级版本号 (聚合)."""
|
||||
|
||||
|
||||
@px.tool("pymake", subcommand="cov", help="测试并生成覆盖率", needs=["test_coverage"])
|
||||
def cov(cwd: Path = Path()) -> None:
|
||||
"""测试并生成覆盖率 (聚合)."""
|
||||
|
||||
|
||||
@px.tool(
|
||||
"pymake",
|
||||
subcommand="tc",
|
||||
help="类型检查 (pyrefly + ruff)",
|
||||
needs=["c", "pyrefly_check", "lint"],
|
||||
strategy="thread",
|
||||
)
|
||||
def tc(cwd: Path = Path()) -> None:
|
||||
"""类型检查 (聚合)."""
|
||||
|
||||
|
||||
@px.tool(
|
||||
"pymake",
|
||||
subcommand="p",
|
||||
help="推送代码 (清理 + push + push tags)",
|
||||
needs=["c", "git_push", "git_push_tags"],
|
||||
strategy="thread",
|
||||
)
|
||||
def p(cwd: Path = Path()) -> None:
|
||||
"""推送代码 (聚合)."""
|
||||
|
||||
|
||||
@px.tool(
|
||||
"pymake",
|
||||
subcommand="pb",
|
||||
help="发布到 PyPI (twine + hatch)",
|
||||
needs=["twine_publish", "publish_python"],
|
||||
strategy="thread",
|
||||
)
|
||||
def pb(cwd: Path = Path()) -> None:
|
||||
"""发布到 PyPI (聚合)."""
|
||||
@@ -0,0 +1,52 @@
|
||||
"""reseticoncache - 重置 Windows 图标缓存工具.
|
||||
|
||||
执行流程: 杀掉 explorer → 删除 IconCache.db → 删除 iconcache* → 重启 explorer.
|
||||
仅在 Windows 上执行, 非 Windows 平台打印提示并跳过.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pyflowx as px
|
||||
from pyflowx.conditions import Constants
|
||||
from pyflowx.ops._common import ensure_platform
|
||||
|
||||
|
||||
@px.tool("reseticoncache", help="重置 Windows 图标缓存")
|
||||
def reset_icon_cache_run() -> None:
|
||||
"""重置 Windows 图标缓存.
|
||||
|
||||
执行流程: 杀掉 explorer → 删除 IconCache.db → 删除 iconcache* → 重启 explorer.
|
||||
仅在 Windows 上执行, 非 Windows 平台打印提示并跳过.
|
||||
"""
|
||||
if not ensure_platform(Constants.IS_WINDOWS, "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("图标缓存已重置")
|
||||
@@ -1,11 +1,10 @@
|
||||
"""截图工具.
|
||||
"""screenshot - 截图工具.
|
||||
|
||||
跨平台截图工具, 支持全屏截图和区域截图.
|
||||
跨平台截图: Windows 用 PowerShell, macOS 用 screencapture, Linux 用 gnome-screenshot/scrot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -13,10 +12,6 @@ from pathlib import Path
|
||||
import pyflowx as px
|
||||
from pyflowx.conditions import Constants
|
||||
|
||||
# ============================================================================
|
||||
# 辅助函数
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_screenshot_path(filename: str | None = None) -> Path:
|
||||
"""获取截图保存路径.
|
||||
@@ -40,6 +35,7 @@ def get_screenshot_path(filename: str | None = None) -> Path:
|
||||
return screenshots_dir / filename
|
||||
|
||||
|
||||
@px.tool("screenshot", subcommand="full", help="全屏截图")
|
||||
def take_screenshot_full(filename: str | None = None) -> None:
|
||||
"""全屏截图.
|
||||
|
||||
@@ -51,7 +47,6 @@ def take_screenshot_full(filename: str | None = None) -> 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
|
||||
@@ -66,10 +61,8 @@ $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:
|
||||
@@ -78,6 +71,7 @@ $bitmap.Dispose()
|
||||
print(f"截图已保存: {output_path}")
|
||||
|
||||
|
||||
@px.tool("screenshot", subcommand="area", help="区域截图")
|
||||
def take_screenshot_area(filename: str | None = None) -> None:
|
||||
"""区域截图.
|
||||
|
||||
@@ -89,7 +83,6 @@ def take_screenshot_area(filename: str | None = None) -> 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
|
||||
@@ -113,51 +106,11 @@ $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")
|
||||
@@ -0,0 +1,87 @@
|
||||
"""sglang - SGLang 本地模型服务工具.
|
||||
|
||||
提供安装与启动 SGLang 服务的子命令.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pyflowx as px
|
||||
from pyflowx.ops._common import platform_command
|
||||
|
||||
__all__ = [
|
||||
"install_sglang",
|
||||
"run_sglang",
|
||||
]
|
||||
|
||||
|
||||
@px.tool("sglang", subcommand="install", help="安装 sglang")
|
||||
def install_sglang() -> None:
|
||||
"""安装 sglang (若未安装).
|
||||
|
||||
通过 ``shutil.which`` 检测 sglang 是否已安装, 未安装时执行 ``uv install sglang[all]``.
|
||||
"""
|
||||
if shutil.which("sglang") is not None:
|
||||
print("sglang 已安装, 跳过安装步骤")
|
||||
return
|
||||
|
||||
print("正在安装 sglang[all]...")
|
||||
subprocess.run(["uv", "install", "sglang[all]"], check=False)
|
||||
|
||||
|
||||
@px.tool("sglang", subcommand="run", help="启动 SGLang 服务", needs=["install"])
|
||||
def run_sglang(
|
||||
model: str = "~/.models/Qwen2.5-Coder-32B-Instruct-AWQ",
|
||||
port: int = 8000,
|
||||
ctx_len: int = 32768,
|
||||
mem_fraction: float = 0.75,
|
||||
host: str = "0.0.0.0",
|
||||
log_level: str = "info",
|
||||
) -> None:
|
||||
"""启动 SGLang 本地模型服务.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
model : str
|
||||
模型路径 (默认: ``~/.models/Qwen2.5-Coder-32B-Instruct-AWQ``)
|
||||
port : int
|
||||
服务端口 (默认: 8000)
|
||||
ctx_len : int
|
||||
最大上下文长度 (默认: 32768)
|
||||
mem_fraction : float
|
||||
显存占比 0-1 (默认: 0.75)
|
||||
host : str
|
||||
主机地址 (默认: 0.0.0.0)
|
||||
log_level : str
|
||||
日志级别 (默认: info)
|
||||
"""
|
||||
model_dir = Path(model).expanduser()
|
||||
if not model_dir.exists():
|
||||
print(f"模型目录不存在: {model_dir}")
|
||||
return
|
||||
|
||||
python_bin = platform_command(["python"], ["python3"])[0]
|
||||
cmd = [
|
||||
python_bin,
|
||||
"-m",
|
||||
"sglang.launch_server",
|
||||
"--model-path",
|
||||
str(model_dir),
|
||||
"--host",
|
||||
host,
|
||||
"--port",
|
||||
str(port),
|
||||
"--mem-fraction-static",
|
||||
str(mem_fraction),
|
||||
"--context-length",
|
||||
str(ctx_len),
|
||||
"--tool-call-parser",
|
||||
"qwen",
|
||||
"--log-level",
|
||||
log_level,
|
||||
]
|
||||
print(f"启动 SGLang: {model_dir} (port={port}, ctx={ctx_len}, mem={mem_fraction})")
|
||||
subprocess.run(cmd, check=False)
|
||||
@@ -1,23 +1,20 @@
|
||||
"""SSH 密钥部署工具.
|
||||
"""sshcopyid - SSH 密钥部署工具.
|
||||
|
||||
类似 ssh-copy-id, 自动将 SSH 公钥部署到远程服务器,
|
||||
支持密码认证和密钥认证两种方式.
|
||||
将本地 SSH 公钥部署到远程服务器.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pyflowx as px
|
||||
|
||||
# ============================================================================
|
||||
# 辅助函数
|
||||
# ============================================================================
|
||||
__all__ = ["ssh_copy_id"]
|
||||
|
||||
|
||||
@px.tool("sshcopyid", help="部署 SSH 公钥到远程服务器")
|
||||
def ssh_copy_id(
|
||||
hostname: str,
|
||||
username: str,
|
||||
@@ -43,7 +40,6 @@ def ssh_copy_id(
|
||||
timeout : int
|
||||
SSH 操作超时秒数, 默认 30
|
||||
"""
|
||||
# 读取公钥
|
||||
pub_key_path = Path(keypath).expanduser()
|
||||
if not pub_key_path.exists():
|
||||
print(f"公钥文件不存在: {pub_key_path}")
|
||||
@@ -51,12 +47,10 @@ def ssh_copy_id(
|
||||
|
||||
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(
|
||||
[
|
||||
@@ -80,7 +74,7 @@ grep -qF '{pub_key.split()[1]}' authorized_keys 2>/dev/null || echo '{pub_key}'
|
||||
)
|
||||
print(f"SSH 密钥已部署到 {username}@{hostname}:{port}")
|
||||
except FileNotFoundError:
|
||||
print(f"未找到 sshpass 工具,请手动执行: ssh-copy-id -p {port} {username}@{hostname}")
|
||||
print(f"未找到 sshpass 工具, 请手动执行: ssh-copy-id -p {port} {username}@{hostname}")
|
||||
sys.exit(1)
|
||||
except subprocess.TimeoutExpired:
|
||||
print("SSH 连接超时")
|
||||
@@ -88,35 +82,3 @@ grep -qF '{pub_key.split()[1]}' authorized_keys 2>/dev/null || echo '{pub_key}'
|
||||
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")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""taskkill - 进程终止工具.
|
||||
|
||||
跨平台按名称终止进程: Windows 用 ``taskkill``, Linux/macOS 用 ``pkill``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
import pyflowx as px
|
||||
from pyflowx.ops._common import platform_command
|
||||
|
||||
|
||||
@px.tool("taskkill", help="按名称终止进程 (跨平台)")
|
||||
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"]``)
|
||||
"""
|
||||
cmd_prefix = platform_command(["taskkill", "/f", "/im"], ["pkill", "-f"])
|
||||
|
||||
for name in process_names:
|
||||
print(f"终止进程: {name}")
|
||||
subprocess.run([*cmd_prefix, f"{name}*"], check=False)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""which - 命令查找工具.
|
||||
|
||||
跨平台查找可执行命令路径: Windows 用 ``where``, Linux/macOS 用 ``which``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
import pyflowx as px
|
||||
from pyflowx.ops._common import platform_command
|
||||
|
||||
|
||||
@px.tool("which", help="查找可执行命令路径 (跨平台)")
|
||||
def which_run(commands: list[str]) -> None:
|
||||
"""查找可执行命令路径 (跨平台).
|
||||
|
||||
Windows 使用 ``where``, Linux/macOS 使用 ``which``.
|
||||
对每个命令打印 ``<cmd> -> <path>`` 或 ``<cmd> -> 未找到``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
commands : list[str]
|
||||
要查找的命令名称列表
|
||||
"""
|
||||
which_cmd = platform_command(["where"], ["which"])[0]
|
||||
|
||||
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} -> 未找到")
|
||||
@@ -6,8 +6,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterator
|
||||
from typing import Any
|
||||
|
||||
from .task import TaskResult, TaskStatus
|
||||
|
||||
|
||||
+4
-40
@@ -14,9 +14,10 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import enum
|
||||
import sys
|
||||
from dataclasses import dataclass, field, replace
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Sequence, get_args
|
||||
from typing import Any, get_args
|
||||
|
||||
from .compose import GraphComposer
|
||||
from .errors import PyFlowXError
|
||||
@@ -35,39 +36,6 @@ class CliExitCode(enum.IntEnum):
|
||||
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
|
||||
class CliRunner:
|
||||
"""命令行运行器: 根据用户输入执行对应的任务流图.
|
||||
@@ -296,12 +264,8 @@ class CliRunner:
|
||||
# 确定是否 verbose: --quiet 覆盖默认值
|
||||
verbose = self.verbose and not parsed.quiet
|
||||
|
||||
# 对图应用 verbose 设置 (重建带 verbose 标记的 spec)
|
||||
# 执行对应的图 (verbose 标记由 run() 统一应用到各 spec)
|
||||
graph = self.graphs[parsed.command]
|
||||
if verbose:
|
||||
graph = _apply_verbose_to_graph(graph, verbose=True)
|
||||
|
||||
# 执行对应的图
|
||||
try:
|
||||
report = run(
|
||||
graph,
|
||||
|
||||
@@ -17,10 +17,10 @@ import json
|
||||
import sys
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager, nullcontext
|
||||
from collections.abc import Iterator, Mapping
|
||||
from contextlib import AbstractContextManager, contextmanager, nullcontext
|
||||
from pathlib import Path
|
||||
from typing import Any, ContextManager, Mapping
|
||||
from typing import Any
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override
|
||||
@@ -63,7 +63,7 @@ class StateBackend(ABC):
|
||||
:class:`JSONBackend` 在 :meth:`batch` 期间会延迟落盘,需在退出时调用。
|
||||
"""
|
||||
|
||||
def batch(self) -> ContextManager[None]:
|
||||
def batch(self) -> AbstractContextManager[None]:
|
||||
"""返回一个上下文管理器,期间 :meth:`save` 可延迟 :meth:`flush`。
|
||||
|
||||
默认实现为 no-op(如 :class:`MemoryBackend`)。:class:`JSONBackend`
|
||||
|
||||
+6
-19
@@ -22,7 +22,8 @@ import os
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from collections.abc import Callable, Coroutine, Generator, Mapping
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
@@ -30,14 +31,7 @@ from functools import cached_property
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
ContextManager,
|
||||
Coroutine,
|
||||
Generator,
|
||||
Generic,
|
||||
List,
|
||||
Mapping,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
||||
@@ -49,25 +43,18 @@ else:
|
||||
T = TypeVar("T", default=Any)
|
||||
|
||||
# 任务可调用对象可以是同步或异步的。显式保留联合类型,让 mypy 理解两种形态。
|
||||
TaskFn = Union[
|
||||
Callable[..., T],
|
||||
Callable[..., Coroutine[Any, Any, T]],
|
||||
]
|
||||
TaskFn = Callable[..., T] | Callable[..., Coroutine[Any, Any, T]]
|
||||
|
||||
# 跨任务结果映射。值刻意使用 ``Any``,因为不同任务返回不同类型;
|
||||
# 单任务类型由函数签名本身保留。
|
||||
Context = Mapping[str, Any]
|
||||
|
||||
# 命令类型支持
|
||||
TaskCmd = Union[
|
||||
List[str], # 命令列表, 如 ["ls", "-la"]
|
||||
str, # shell 命令字符串
|
||||
Callable[..., Any], # Python 函数
|
||||
]
|
||||
TaskCmd = list[str] | str | Callable[..., Any]
|
||||
|
||||
# 执行策略:sequential/thread/async 为层屏障模型,dependency 为依赖驱动模型。
|
||||
Strategy = Union[str, "StrategyKind"]
|
||||
StrategyKind = Any # 占位,避免循环;executors 模块用 Literal 约束
|
||||
Strategy = str | StrategyKind
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -376,7 +363,7 @@ class TaskSpec(Generic[T]):
|
||||
return shutil.which(cmd[0]) is not None
|
||||
return True
|
||||
|
||||
def env_context(self) -> ContextManager[None]:
|
||||
def env_context(self) -> AbstractContextManager[None]:
|
||||
"""返回临时应用 ``env`` 与 ``cwd`` 的上下文管理器。
|
||||
|
||||
对 ``fn`` 任务生效。``cmd`` 任务在 :func:`_run_command` 中直接
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
"""@px.tool 装饰器: typer 驱动 CLI + DAG 编排.
|
||||
|
||||
替代 YAML 配置模式, 用 .py 装饰器统一描述工具.
|
||||
函数签名 → typer 自动生成 CLI, 函数体即任务逻辑, needs/strategy 表达 DAG.
|
||||
|
||||
示例
|
||||
----
|
||||
::
|
||||
@px.tool("pdftool", subcommand="m", help="合并 PDF")
|
||||
def pdf_merge(
|
||||
inputs: list[Path], # positional, nargs="+"
|
||||
output: Path = Path("merged.pdf"), # --output 选项
|
||||
) -> None:
|
||||
... # 函数体即逻辑
|
||||
|
||||
# CLI: pf pdftool m a.pdf b.pdf --output merged.pdf
|
||||
|
||||
cmd 任务 (执行外部命令, 函数体不执行)::
|
||||
|
||||
@px.tool("pymake", subcommand="b", help="构建", cmd=["uv", "build"])
|
||||
def b(cwd: Path = Path(".")) -> None:
|
||||
pass # 签名仅驱动 CLI, cwd 注入到 cmd 工作目录
|
||||
|
||||
聚合任务 (有 needs 无 cmd 无函数逻辑)::
|
||||
|
||||
@px.tool("pymake", subcommand="tc", help="类型检查",
|
||||
needs=["c", "pyrefly_check", "lint"], strategy="thread")
|
||||
def tc(cwd: Path = Path(".")) -> None:
|
||||
pass # 仅作依赖聚合点
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"ToolExitCode",
|
||||
"ToolSpec",
|
||||
"clear_tool_registry",
|
||||
"get_tool",
|
||||
"list_subcommands",
|
||||
"list_tools",
|
||||
"run_tool",
|
||||
"tool",
|
||||
]
|
||||
|
||||
import ast
|
||||
import enum
|
||||
import inspect
|
||||
import sys
|
||||
import textwrap
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
# typer 内置 click 异常 (typer._click 与公共 click 是两套独立类层次)
|
||||
from typer._click.exceptions import ClickException as _TyperClickException
|
||||
from typer._click.exceptions import NoArgsIsHelpError as _NoArgsIsHelpError
|
||||
|
||||
from pyflowx.errors import PyFlowXError
|
||||
from pyflowx.executors import run
|
||||
from pyflowx.graph import Graph
|
||||
from pyflowx.task import RetryPolicy, TaskSpec
|
||||
|
||||
|
||||
class ToolExitCode(enum.IntEnum):
|
||||
"""工具执行退出码."""
|
||||
|
||||
SUCCESS = 0
|
||||
FAILURE = 1
|
||||
INTERRUPTED = 130 # 与 POSIX 信号中断一致
|
||||
|
||||
|
||||
def _noop() -> None:
|
||||
"""聚合任务的占位函数."""
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# ToolSpec: 工具描述符
|
||||
# ---------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class ToolSpec:
|
||||
"""工具描述符: 由 ``@px.tool`` 装饰器注册.
|
||||
|
||||
封装函数 + CLI 元数据 + DAG 编排参数, 运行时映射为 :class:`TaskSpec`.
|
||||
"""
|
||||
|
||||
name: str # 工具名 (如 "pdftool")
|
||||
subcommand: str | None # 子命令名 (None=单命令工具)
|
||||
func: Callable[..., Any] # 函数 (签名驱动 CLI, 体即逻辑)
|
||||
help: str = "" # 帮助文本
|
||||
description: str = "" # 工具描述 (pf 列表用)
|
||||
cmd: tuple[str, ...] | str | None = None # 命令 (有则执行 cmd, 无则执行函数体)
|
||||
needs: tuple[str, ...] = () # 依赖任务 (引用同 tool 的 subcommand)
|
||||
strategy: Literal["sequential", "thread", "async", "dependency"] | None = None # 执行策略
|
||||
cwd: str | Path | None = None # 工作目录 (cmd 任务, 装饰器级默认)
|
||||
allow_upstream_skip: bool = False
|
||||
hidden: bool = False # 不暴露为 subcommand (内部 job)
|
||||
env: Mapping[str, str] | None = None
|
||||
retry: RetryPolicy | None = None
|
||||
timeout: float | None = None
|
||||
|
||||
|
||||
# 全局工具注册表: {tool_name: {subcommand: ToolSpec}}
|
||||
_TOOL_REGISTRY: dict[str, dict[str | None, ToolSpec]] = {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# @tool 装饰器 + 注册表
|
||||
# ---------------------------------------------------------------------- #
|
||||
def tool(
|
||||
name: str,
|
||||
*,
|
||||
subcommand: str | None = None,
|
||||
help: str = "",
|
||||
description: str = "",
|
||||
cmd: Sequence[str] | str | None = None,
|
||||
needs: Sequence[str] | None = None,
|
||||
strategy: Literal["sequential", "thread", "async", "dependency"] | None = None,
|
||||
cwd: str | Path | None = None,
|
||||
allow_upstream_skip: bool = False,
|
||||
hidden: bool = False,
|
||||
env: Mapping[str, str] | None = None,
|
||||
retry: RetryPolicy | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||
"""装饰器: 将函数注册为 ``@px.tool`` 工具.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
工具名 (如 "pdftool"); 多个 ``@px.tool`` 共用同名即多 subcommand 工具
|
||||
subcommand : str | None
|
||||
子命令名; ``None`` 表示单命令工具 (整个工具仅一个函数)
|
||||
help : str
|
||||
子命令帮助文本; 默认用函数 docstring
|
||||
description : str
|
||||
工具描述, 用于 pf 工具列表
|
||||
cmd : Sequence[str] | str | None
|
||||
命令列表或 shell 字符串; 有 ``cmd`` 执行命令, 函数体不执行 (签名仅驱动 CLI)
|
||||
needs : Sequence[str] | None
|
||||
依赖任务名 (引用同 tool 的其他 subcommand)
|
||||
strategy : str | None
|
||||
执行策略: ``sequential`` / ``thread`` / ``async`` / ``dependency``
|
||||
cwd : str | Path | None
|
||||
工作目录 (cmd 任务装饰器级默认); 若函数签名有 ``cwd`` 参数则被 CLI 值覆盖
|
||||
allow_upstream_skip : bool
|
||||
上游 SKIPPED 时本任务仍执行
|
||||
hidden : bool
|
||||
不暴露为 subcommand (内部 job, 仅被 needs 引用)
|
||||
env / retry / timeout
|
||||
透传 :class:`TaskSpec` 对应字段
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
spec = ToolSpec(
|
||||
name=name,
|
||||
subcommand=subcommand,
|
||||
func=func,
|
||||
help=help or inspect.getdoc(func) or "",
|
||||
description=description,
|
||||
cmd=cast("tuple[str, ...] | str | None", tuple(cmd) if isinstance(cmd, (list, tuple)) else cmd),
|
||||
needs=tuple(needs) if needs else (),
|
||||
strategy=strategy,
|
||||
cwd=cwd,
|
||||
allow_upstream_skip=allow_upstream_skip,
|
||||
hidden=hidden,
|
||||
env=dict(env) if env else None,
|
||||
retry=retry,
|
||||
timeout=timeout,
|
||||
)
|
||||
_register_tool(spec)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _register_tool(spec: ToolSpec) -> None:
|
||||
"""注册工具到全局注册表, 校验重复."""
|
||||
if spec.name not in _TOOL_REGISTRY:
|
||||
_TOOL_REGISTRY[spec.name] = {}
|
||||
if spec.subcommand in _TOOL_REGISTRY[spec.name]:
|
||||
raise ValueError(f"工具 {spec.name!r} 的子命令 {spec.subcommand!r} 已注册")
|
||||
_TOOL_REGISTRY[spec.name][spec.subcommand] = spec
|
||||
|
||||
|
||||
def get_tool(name: str, subcommand: str | None = None) -> ToolSpec:
|
||||
"""获取已注册工具.
|
||||
|
||||
Raises
|
||||
------
|
||||
KeyError
|
||||
工具或子命令未注册
|
||||
"""
|
||||
if name not in _TOOL_REGISTRY:
|
||||
raise KeyError(f"工具 {name!r} 未注册")
|
||||
subs = _TOOL_REGISTRY[name]
|
||||
if subcommand not in subs:
|
||||
raise KeyError(f"工具 {name!r} 没有子命令 {subcommand!r}")
|
||||
return subs[subcommand]
|
||||
|
||||
|
||||
def list_tools() -> list[str]:
|
||||
"""列出所有已注册工具名."""
|
||||
return sorted(_TOOL_REGISTRY.keys())
|
||||
|
||||
|
||||
def list_subcommands(name: str, include_hidden: bool = False) -> list[str]:
|
||||
"""列出工具的子命令 (hidden 默认排除).
|
||||
|
||||
单命令工具 (subcommand=None) 返回空列表.
|
||||
"""
|
||||
if name not in _TOOL_REGISTRY:
|
||||
return []
|
||||
return sorted(
|
||||
sc for sc, spec in _TOOL_REGISTRY[name].items() if sc is not None and (include_hidden or not spec.hidden)
|
||||
)
|
||||
|
||||
|
||||
def clear_tool_registry() -> None:
|
||||
"""清空注册表 (测试用)."""
|
||||
_TOOL_REGISTRY.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# 依赖收集 + TaskSpec 构建
|
||||
# ---------------------------------------------------------------------- #
|
||||
def _collect_with_deps(name: str, target: str | None) -> list[str | None]:
|
||||
"""BFS 收集 target 及其传递依赖 (subcommand 名).
|
||||
|
||||
返回顺序: 依赖在前, target 在后 (符合 DAG 拓扑).
|
||||
"""
|
||||
if name not in _TOOL_REGISTRY:
|
||||
return [target]
|
||||
subs = _TOOL_REGISTRY[name]
|
||||
result: list[str | None] = []
|
||||
seen: set[str | None] = set()
|
||||
queue: list[str | None] = [target]
|
||||
while queue:
|
||||
sc = queue.pop(0)
|
||||
if sc in seen:
|
||||
continue
|
||||
seen.add(sc)
|
||||
result.append(sc)
|
||||
if sc in subs:
|
||||
queue.extend(subs[sc].needs)
|
||||
# 反转: 依赖在前, target 在后
|
||||
result.reverse()
|
||||
return result
|
||||
|
||||
|
||||
def _has_function_logic(func: Callable[..., Any]) -> bool:
|
||||
"""判断函数体是否有实际逻辑 (非 pass/.../docstring).
|
||||
|
||||
用 ast 分析, 避免 exec 函数.
|
||||
"""
|
||||
try:
|
||||
src = inspect.getsource(func)
|
||||
src = textwrap.dedent(src)
|
||||
tree = ast.parse(src)
|
||||
except (OSError, TypeError, SyntaxError): # pragma: no cover
|
||||
return True
|
||||
func_def = next((n for n in ast.walk(tree) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))), None)
|
||||
if func_def is None: # pragma: no cover
|
||||
return True
|
||||
for stmt in func_def.body:
|
||||
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant):
|
||||
continue # docstring
|
||||
if isinstance(stmt, ast.Pass):
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_aggregate(spec: ToolSpec) -> bool:
|
||||
"""判断是否为聚合任务 (有 needs 无 cmd 无函数逻辑)."""
|
||||
if spec.cmd is not None or not spec.needs:
|
||||
return False
|
||||
return not _has_function_logic(spec.func)
|
||||
|
||||
|
||||
def _build_task_spec(spec: ToolSpec, variables: Mapping[str, Any]) -> TaskSpec:
|
||||
"""将 ToolSpec + 解析后的变量转为 TaskSpec.
|
||||
|
||||
- cmd 任务: 执行命令, cwd 从 variables["cwd"] 或装饰器 cwd 取
|
||||
- 聚合任务 (有 needs 无 cmd 无函数逻辑): fn=noop
|
||||
- fn 任务: 执行函数, kwargs 按签名从 variables 取
|
||||
"""
|
||||
task_name = spec.subcommand if spec.subcommand is not None else spec.name
|
||||
|
||||
# cmd 任务
|
||||
if spec.cmd is not None:
|
||||
cwd_value = variables.get("cwd", spec.cwd)
|
||||
cwd = Path(cwd_value) if cwd_value is not None else None
|
||||
cmd_value: Any = list(spec.cmd) if isinstance(spec.cmd, tuple) else spec.cmd
|
||||
return TaskSpec(
|
||||
name=task_name,
|
||||
cmd=cmd_value,
|
||||
depends_on=spec.needs,
|
||||
cwd=cwd,
|
||||
env=spec.env,
|
||||
retry=spec.retry or RetryPolicy(),
|
||||
timeout=spec.timeout,
|
||||
allow_upstream_skip=spec.allow_upstream_skip,
|
||||
strategy=spec.strategy,
|
||||
)
|
||||
|
||||
# 聚合任务
|
||||
if _is_aggregate(spec):
|
||||
return TaskSpec(
|
||||
name=task_name,
|
||||
fn=_noop,
|
||||
depends_on=spec.needs,
|
||||
allow_upstream_skip=spec.allow_upstream_skip,
|
||||
strategy=spec.strategy,
|
||||
)
|
||||
|
||||
# fn 任务
|
||||
sig = inspect.signature(spec.func)
|
||||
kwargs: dict[str, Any] = {}
|
||||
for pname in sig.parameters:
|
||||
if pname in variables:
|
||||
kwargs[pname] = variables[pname]
|
||||
cwd_value = variables.get("cwd")
|
||||
cwd = Path(cwd_value) if cwd_value is not None else None
|
||||
return TaskSpec(
|
||||
name=task_name,
|
||||
fn=spec.func,
|
||||
kwargs=kwargs,
|
||||
depends_on=spec.needs,
|
||||
cwd=cwd,
|
||||
env=spec.env,
|
||||
retry=spec.retry or RetryPolicy(),
|
||||
timeout=spec.timeout,
|
||||
allow_upstream_skip=spec.allow_upstream_skip,
|
||||
strategy=spec.strategy,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Typer app 构建 (函数签名 → typer 命令)
|
||||
# ---------------------------------------------------------------------- #
|
||||
def _global_option_params() -> list[inspect.Parameter]:
|
||||
"""构建全局选项的 inspect.Parameter 列表.
|
||||
|
||||
各命令 dispatcher 追加这些参数到用户函数签名末尾, 使 ``--dry-run`` / ``--quiet`` /
|
||||
``--strategy`` 出现在每个子命令上. ``--list`` 由 :func:`run_tool` 预处理.
|
||||
"""
|
||||
return [
|
||||
inspect.Parameter(
|
||||
"dry_run",
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
default=typer.Option(False, "--dry-run", help="仅打印执行计划, 不执行"),
|
||||
annotation=bool,
|
||||
),
|
||||
inspect.Parameter(
|
||||
"quiet",
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
default=typer.Option(False, "--quiet", "-q", help="减少输出"),
|
||||
annotation=bool,
|
||||
),
|
||||
inspect.Parameter(
|
||||
"strategy",
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
default=typer.Option(None, "--strategy", help="执行策略 (sequential/thread/dependency)"),
|
||||
annotation=str | None,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _make_dispatcher(tool_name: str, spec: ToolSpec) -> Callable[..., None]:
|
||||
"""为 spec 创建 typer 命令 dispatcher.
|
||||
|
||||
dispatcher 签名 = 用户函数签名 (过滤 ``*args``/``**kwargs``) + 全局选项;
|
||||
body 从 kwargs 分离全局选项后调用 :func:`_execute_dag`.
|
||||
"""
|
||||
func = spec.func
|
||||
subcommand = spec.subcommand
|
||||
user_sig = inspect.signature(func)
|
||||
|
||||
# 过滤 *args / **kwargs, 追加全局选项
|
||||
params = [
|
||||
p
|
||||
for p in user_sig.parameters.values()
|
||||
if p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
||||
]
|
||||
params.extend(_global_option_params())
|
||||
combined_sig = user_sig.replace(parameters=params)
|
||||
|
||||
def dispatcher(**kwargs: Any) -> None:
|
||||
global_args = {
|
||||
"dry_run": kwargs.pop("dry_run", False),
|
||||
"verbose": not kwargs.pop("quiet", False),
|
||||
"strategy": kwargs.pop("strategy", None),
|
||||
}
|
||||
_execute_dag(tool_name, subcommand, kwargs, global_args)
|
||||
|
||||
dispatcher.__signature__ = combined_sig # type: ignore[attr-defined]
|
||||
dispatcher.__doc__ = spec.help or func.__doc__ or ""
|
||||
dispatcher.__name__ = func.__name__
|
||||
dispatcher.__annotations__ = {p.name: p.annotation for p in params}
|
||||
return dispatcher
|
||||
|
||||
|
||||
def _build_typer_app(name: str, all_subs: dict[str | None, ToolSpec]) -> typer.Typer:
|
||||
"""构建 typer app.
|
||||
|
||||
单命令工具 (仅 subcommand=None) → callback(invoke_without_command=True);
|
||||
多 subcommand 工具 → 各可见 subcommand 注册为命令 (``--list`` 由 run_tool 预处理).
|
||||
"""
|
||||
first_spec = next(iter(all_subs.values()))
|
||||
description = first_spec.description or first_spec.help or name
|
||||
is_single = None in all_subs and len(all_subs) == 1
|
||||
|
||||
app = typer.Typer(
|
||||
name=f"pf {name}",
|
||||
help=description,
|
||||
# 单命令工具: 无参数时直接执行 callback; 多命令工具: 无 subcommand 时打印 help
|
||||
no_args_is_help=not is_single,
|
||||
add_completion=False,
|
||||
rich_markup_mode="rich",
|
||||
)
|
||||
|
||||
if is_single:
|
||||
spec = all_subs[None]
|
||||
dispatcher = _make_dispatcher(name, spec)
|
||||
app.callback(invoke_without_command=True)(dispatcher)
|
||||
return app
|
||||
|
||||
@app.callback()
|
||||
def _callback() -> None:
|
||||
"""工具入口; ``--list`` 由 :func:`run_tool` 预处理."""
|
||||
|
||||
for sc, spec in sorted(all_subs.items(), key=lambda x: x[0] or ""):
|
||||
if sc is None or spec.hidden:
|
||||
continue
|
||||
dispatcher = _make_dispatcher(name, spec)
|
||||
app.command(name=sc, help=spec.help)(dispatcher)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# 执行入口
|
||||
# ---------------------------------------------------------------------- #
|
||||
def _execute_dag(
|
||||
tool_name: str,
|
||||
subcommand: str | None,
|
||||
variables: dict[str, Any],
|
||||
global_args: dict[str, Any],
|
||||
) -> None:
|
||||
"""收集依赖, 构建 Graph, 执行 DAG.
|
||||
|
||||
成功正常返回; 失败时打印 rich 错误并 ``raise typer.Exit``.
|
||||
"""
|
||||
all_subs = _TOOL_REGISTRY[tool_name]
|
||||
|
||||
if subcommand not in all_subs:
|
||||
Console(stderr=True).print(f"[red]错误:[/red] 未知子命令 [yellow]{subcommand!r}[/yellow]")
|
||||
raise typer.Exit(ToolExitCode.FAILURE)
|
||||
|
||||
target_spec = all_subs[subcommand]
|
||||
|
||||
# 收集依赖 + 构建子图
|
||||
all_names = _collect_with_deps(tool_name, subcommand)
|
||||
specs: list[TaskSpec] = []
|
||||
for sc in all_names:
|
||||
dep_spec = all_subs[sc]
|
||||
specs.append(_build_task_spec(dep_spec, variables))
|
||||
|
||||
graph = Graph.from_specs(specs)
|
||||
|
||||
console = Console()
|
||||
console.print(f"[bold cyan][{tool_name}][/bold cyan] 执行: [yellow]{subcommand or '全部任务'}[/yellow]")
|
||||
|
||||
try:
|
||||
run(
|
||||
graph,
|
||||
strategy=global_args["strategy"] or target_spec.strategy or "dependency",
|
||||
dry_run=global_args["dry_run"],
|
||||
verbose=global_args["verbose"],
|
||||
)
|
||||
except PyFlowXError as e:
|
||||
Console(stderr=True).print(f"[red]错误:[/red] {e}")
|
||||
raise typer.Exit(ToolExitCode.FAILURE) from e
|
||||
except KeyboardInterrupt:
|
||||
Console(stderr=True).print("\n[yellow]已中断[/yellow]")
|
||||
raise typer.Exit(ToolExitCode.INTERRUPTED) from None
|
||||
|
||||
|
||||
def _handle_list_rich(name: str, all_subs: dict[str | None, ToolSpec]) -> None:
|
||||
"""rich 表格打印任务列表."""
|
||||
console = Console()
|
||||
console.print(f"[bold]工具 {name} 的任务列表[/bold]")
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("任务")
|
||||
table.add_column("依赖")
|
||||
table.add_column("类型")
|
||||
table.add_column("状态")
|
||||
for sc, spec in sorted(all_subs.items(), key=lambda x: (x[0] is not None, x[0] or "")):
|
||||
display = sc if sc is not None else name
|
||||
deps = ", ".join(spec.needs) if spec.needs else "—"
|
||||
if spec.cmd is not None:
|
||||
task_type = "cmd"
|
||||
elif _is_aggregate(spec):
|
||||
task_type = "聚合"
|
||||
else:
|
||||
task_type = "fn"
|
||||
hidden = "[dim]隐藏[/dim]" if spec.hidden else ""
|
||||
table.add_row(display, deps, task_type, hidden)
|
||||
console.print(table)
|
||||
|
||||
|
||||
def run_tool(name: str, argv: Sequence[str] | None = None) -> int:
|
||||
"""执行工具, 返回退出码 (0 成功 / 1 失败 / 130 中断).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
工具名 (已通过 ``@px.tool`` 注册)
|
||||
argv : Sequence[str] | None
|
||||
命令行参数 (不含工具名); None 用 ``sys.argv[1:]``
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
退出码
|
||||
"""
|
||||
if name not in _TOOL_REGISTRY:
|
||||
Console(stderr=True).print(f"[red]错误:[/red] 未注册工具 [yellow]{name!r}[/yellow]")
|
||||
return ToolExitCode.FAILURE
|
||||
|
||||
all_subs = _TOOL_REGISTRY[name]
|
||||
argv = list(argv) if argv is not None else sys.argv[1:]
|
||||
|
||||
# --list 预处理: 出现在任意位置即列出任务后退出
|
||||
if "--list" in argv:
|
||||
_handle_list_rich(name, all_subs)
|
||||
return ToolExitCode.SUCCESS
|
||||
|
||||
app = _build_typer_app(name, all_subs)
|
||||
|
||||
try:
|
||||
# standalone_mode=False: click 捕获 typer.Exit 后返回 exit_code (而非抛出);
|
||||
# ClickException/Abort 则向上抛出, 由下方 except 分支处理
|
||||
result = app(args=argv, standalone_mode=False)
|
||||
# 正常完成返回 None; typer.Exit 返回 int exit_code
|
||||
if isinstance(result, int) and result != 0:
|
||||
return (
|
||||
ToolExitCode(result)
|
||||
if result in (ToolExitCode.SUCCESS, ToolExitCode.FAILURE, ToolExitCode.INTERRUPTED)
|
||||
else ToolExitCode.FAILURE
|
||||
)
|
||||
return ToolExitCode.SUCCESS
|
||||
except _NoArgsIsHelpError:
|
||||
# 多命令工具无 subcommand → 打印 help, 视为成功
|
||||
return ToolExitCode.SUCCESS
|
||||
except _TyperClickException as e:
|
||||
return ToolExitCode.SUCCESS if e.exit_code == 0 else ToolExitCode.FAILURE
|
||||
except typer.Abort:
|
||||
return ToolExitCode.INTERRUPTED
|
||||
except SystemExit as e:
|
||||
code = e.code
|
||||
if isinstance(code, int):
|
||||
return code
|
||||
return ToolExitCode.SUCCESS if code in (None, "0") else ToolExitCode.FAILURE
|
||||
except KeyboardInterrupt:
|
||||
return ToolExitCode.INTERRUPTED
|
||||
+20
-103
@@ -5,8 +5,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pyflowx as px
|
||||
from pyflowx.cli import autofmt
|
||||
from pyflowx.ops import autofmt as dev
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
@@ -19,14 +18,14 @@ class TestFormatWithRuff:
|
||||
"""Should format with ruff."""
|
||||
with patch("subprocess.run") as mock_run:
|
||||
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
|
||||
|
||||
def test_format_with_ruff_no_fix(self, tmp_path: Path) -> None:
|
||||
"""Should format with ruff without fix."""
|
||||
with patch("subprocess.run") as mock_run:
|
||||
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
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert "--fix" not in call_args
|
||||
@@ -42,14 +41,14 @@ class TestLintWithRuff:
|
||||
"""Should lint with ruff."""
|
||||
with patch("subprocess.run") as mock_run:
|
||||
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
|
||||
|
||||
def test_lint_with_ruff_no_fix(self, tmp_path: Path) -> None:
|
||||
"""Should lint with ruff without fix."""
|
||||
with patch("subprocess.run") as mock_run:
|
||||
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
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert "--fix" not in call_args
|
||||
@@ -66,7 +65,7 @@ class TestAddDocstring:
|
||||
py_file = tmp_path / "test.py"
|
||||
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
|
||||
|
||||
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.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
|
||||
|
||||
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.write_text("")
|
||||
|
||||
result = autofmt.add_docstring(py_file, '"""Test module."""')
|
||||
result = dev.add_docstring(py_file, '"""Test module."""')
|
||||
# Should handle empty file
|
||||
assert result is True
|
||||
|
||||
@@ -98,7 +97,7 @@ class TestGenerateModuleDocstring:
|
||||
py_file = tmp_path / "test.py"
|
||||
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"
|
||||
assert "Tests for" in result
|
||||
|
||||
@@ -108,7 +107,7 @@ class TestGenerateModuleDocstring:
|
||||
py_file.parent.mkdir(parents=True)
|
||||
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
|
||||
|
||||
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.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
|
||||
|
||||
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.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
|
||||
|
||||
|
||||
@@ -139,8 +138,8 @@ class TestAutoAddDocstrings:
|
||||
py_file = tmp_path / "test.py"
|
||||
py_file.write_text("def test():\n pass\n")
|
||||
|
||||
with patch.object(autofmt, "add_docstring", return_value=True):
|
||||
count = autofmt.auto_add_docstrings(tmp_path)
|
||||
with patch.object(dev, "add_docstring", return_value=True):
|
||||
count = dev.auto_add_docstrings(tmp_path)
|
||||
assert count >= 0
|
||||
|
||||
def test_auto_add_docstrings_skips_ignored(self, tmp_path: Path) -> None:
|
||||
@@ -149,7 +148,7 @@ class TestAutoAddDocstrings:
|
||||
py_file.parent.mkdir()
|
||||
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__
|
||||
assert count == 0
|
||||
|
||||
@@ -158,7 +157,7 @@ class TestAutoAddDocstrings:
|
||||
txt_file = tmp_path / "test.txt"
|
||||
txt_file.write_text("test content")
|
||||
|
||||
count = autofmt.auto_add_docstrings(tmp_path)
|
||||
count = dev.auto_add_docstrings(tmp_path)
|
||||
assert count == 0
|
||||
|
||||
|
||||
@@ -179,7 +178,7 @@ class TestSyncPyprojectConfig:
|
||||
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
autofmt.sync_pyproject_config(tmp_path)
|
||||
dev.sync_pyproject_config(tmp_path)
|
||||
assert mock_run.called
|
||||
|
||||
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:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
autofmt.sync_pyproject_config(tmp_path)
|
||||
dev.sync_pyproject_config(tmp_path)
|
||||
assert mock_run.called
|
||||
|
||||
|
||||
@@ -207,95 +206,13 @@ class TestFormatAll:
|
||||
"""Should run ruff format."""
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
autofmt.format_all(tmp_path)
|
||||
dev.format_all(tmp_path)
|
||||
assert mock_run.called
|
||||
|
||||
def test_format_all_runs_ruff_check(self, tmp_path: Path) -> None:
|
||||
"""Should run ruff check."""
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
autofmt.format_all(tmp_path)
|
||||
dev.format_all(tmp_path)
|
||||
# Should call ruff format and ruff check
|
||||
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
|
||||
|
||||
+143
-273
@@ -1,15 +1,13 @@
|
||||
"""Tests for cli.bumpversion module."""
|
||||
"""Tests for ops.bumpversion module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import pyflowx as px
|
||||
from pyflowx.cli import bumpversion
|
||||
from pyflowx.ops import bumpversion
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -24,299 +22,171 @@ def auto_use_tmp_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class TestBumpFileVersion:
|
||||
"""Test bump_file_version function."""
|
||||
|
||||
def test_bump_patch_version(self, tmp_path: Path) -> None:
|
||||
"""Should bump patch version correctly."""
|
||||
test_file = tmp_path / "pyproject.toml"
|
||||
test_file.write_text('version = "1.2.3"', encoding="utf-8")
|
||||
@pytest.mark.parametrize(
|
||||
("part", "expected"),
|
||||
[("patch", "1.2.4"), ("minor", "1.3.0"), ("major", "2.0.0")],
|
||||
)
|
||||
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"
|
||||
assert test_file.read_text(encoding="utf-8") == 'version = "1.2.4"'
|
||||
|
||||
def test_bump_minor_version(self, tmp_path: Path) -> None:
|
||||
"""Should bump minor 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, "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")
|
||||
def test_prerelease_and_build_metadata_stripped(self, tmp_path: Path) -> None:
|
||||
"""prerelease 和 build metadata 应被清除."""
|
||||
f = tmp_path / "pyproject.toml"
|
||||
f.write_text('version = "1.2.3-alpha.1+build.123"', encoding="utf-8")
|
||||
assert bumpversion.bump_file_version(f, "patch") == "1.2.4"
|
||||
content = f.read_text(encoding="utf-8")
|
||||
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
|
||||
|
||||
def test_no_version_found(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Should return None when no version pattern found."""
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.write_text("no version here", encoding="utf-8")
|
||||
def test_dependencies_not_modified(self, tmp_path: Path) -> None:
|
||||
"""只更新 project version, 不动 dependencies 中的版本号."""
|
||||
f = tmp_path / "pyproject.toml"
|
||||
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
|
||||
captured = capsys.readouterr()
|
||||
assert "未找到版本号模式" in captured.out
|
||||
|
||||
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()
|
||||
f2 = tmp_path / "test.txt"
|
||||
f2.write_text("no version here", encoding="utf-8")
|
||||
assert bumpversion.bump_file_version(f2, "patch") is None
|
||||
|
||||
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
|
||||
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:
|
||||
"""Should handle file write errors."""
|
||||
if os.geteuid() == 0:
|
||||
pytest.skip("测试在 root 权限下不适用")
|
||||
def test_write_failure_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""写入失败应抛 OSError."""
|
||||
f = tmp_path / "__init__.py"
|
||||
f.write_text('__version__ = "1.0.0"', encoding="utf-8")
|
||||
|
||||
# 在只读目录中创建文件(这个测试在某些系统上可能不适用)
|
||||
test_file = tmp_path / "readonly.toml"
|
||||
test_file.write_text('version = "1.0.0"', encoding="utf-8")
|
||||
# 设置为只读
|
||||
test_file.chmod(0o444)
|
||||
def raise_oserror(*_args: object, **_kwargs: object) -> None:
|
||||
raise OSError("write failed")
|
||||
|
||||
try:
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
bumpversion.bump_file_version(test_file, "patch")
|
||||
finally:
|
||||
# 恢复权限以便清理
|
||||
test_file.chmod(0o644)
|
||||
monkeypatch.setattr(Path, "write_text", raise_oserror)
|
||||
with pytest.raises(OSError, match="write failed"):
|
||||
bumpversion.bump_file_version(f, "patch")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Version pattern tests
|
||||
# bump_project_version (核心 bug 修复: 不同步文件统一同步)
|
||||
# ---------------------------------------------------------------------- #
|
||||
class TestVersionPattern:
|
||||
"""Test version pattern matching."""
|
||||
class TestBumpProjectVersion:
|
||||
"""Test bump_project_version function."""
|
||||
|
||||
def test_simple_version(self, tmp_path: Path) -> None:
|
||||
"""Should match simple version."""
|
||||
test_file = tmp_path / "__init__.py"
|
||||
test_file.write_text('__version__ = "1.0.0"', encoding="utf-8")
|
||||
@staticmethod
|
||||
def _mock_subprocess(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]:
|
||||
"""Mock subprocess.run, 返回调用记录列表."""
|
||||
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:
|
||||
"""Should handle versions with zeros correctly."""
|
||||
test_file = tmp_path / "__init__.py"
|
||||
test_file.write_text('__version__ = "0.0.0"', encoding="utf-8")
|
||||
def test_unsynced_files_synchronized(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""核心 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:
|
||||
"""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_project_version("patch")
|
||||
|
||||
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:
|
||||
"""Should not match version in URL or other contexts."""
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.write_text("https://example.com/v1.2.3/download", encoding="utf-8")
|
||||
tag_calls = [c for c in calls if c[:2] == ["git", "tag"]]
|
||||
assert len(tag_calls) == 1
|
||||
assert "v0.4.6" in tag_calls[0]
|
||||
|
||||
result = bumpversion.bump_file_version(test_file, "patch")
|
||||
|
||||
# 不应该匹配 URL 中的版本号
|
||||
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") == "这是一个测试文件"
|
||||
def test_no_files_returns_none(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""无版本号文件返回 None."""
|
||||
assert bumpversion.bump_project_version("patch") is None
|
||||
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"'
|
||||
|
||||
@@ -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
|
||||
+215
-185
@@ -125,19 +125,21 @@ class TestEmailDatabase:
|
||||
|
||||
# Insert test emails
|
||||
for i in range(5):
|
||||
db.insert_email({
|
||||
"file_path": f"/test/path{i}.eml",
|
||||
"file_hash": f"hash{i}",
|
||||
"subject": f"Subject {i}",
|
||||
"sender": f"sender{i}@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": f"2024-01-0{i + 1}T12:00:00",
|
||||
"body_text": f"Body {i}",
|
||||
"body_html": f"<p>Body {i}</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
})
|
||||
db.insert_email(
|
||||
{
|
||||
"file_path": f"/test/path{i}.eml",
|
||||
"file_hash": f"hash{i}",
|
||||
"subject": f"Subject {i}",
|
||||
"sender": f"sender{i}@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": f"2024-01-0{i + 1}T12:00:00",
|
||||
"body_text": f"Body {i}",
|
||||
"body_html": f"<p>Body {i}</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
}
|
||||
)
|
||||
|
||||
results = db.search_emails(limit=3)
|
||||
assert len(results) == 3
|
||||
@@ -148,33 +150,37 @@ class TestEmailDatabase:
|
||||
db_path = tmp_path / "test.db"
|
||||
db = emlmanager.EmailDatabase(db_path)
|
||||
|
||||
db.insert_email({
|
||||
"file_path": "/test/path1.eml",
|
||||
"file_hash": "hash1",
|
||||
"subject": "Important Meeting",
|
||||
"sender": "sender1@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": "2024-01-01T12:00:00",
|
||||
"body_text": "Meeting body",
|
||||
"body_html": "<p>Meeting body</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
})
|
||||
db.insert_email(
|
||||
{
|
||||
"file_path": "/test/path1.eml",
|
||||
"file_hash": "hash1",
|
||||
"subject": "Important Meeting",
|
||||
"sender": "sender1@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": "2024-01-01T12:00:00",
|
||||
"body_text": "Meeting body",
|
||||
"body_html": "<p>Meeting body</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
}
|
||||
)
|
||||
|
||||
db.insert_email({
|
||||
"file_path": "/test/path2.eml",
|
||||
"file_hash": "hash2",
|
||||
"subject": "Casual Chat",
|
||||
"sender": "sender2@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": "Tue, 2 Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": "2024-01-02T12:00:00",
|
||||
"body_text": "Chat body",
|
||||
"body_html": "<p>Chat body</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
})
|
||||
db.insert_email(
|
||||
{
|
||||
"file_path": "/test/path2.eml",
|
||||
"file_hash": "hash2",
|
||||
"subject": "Casual Chat",
|
||||
"sender": "sender2@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": "Tue, 2 Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": "2024-01-02T12:00:00",
|
||||
"body_text": "Chat body",
|
||||
"body_html": "<p>Chat body</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
}
|
||||
)
|
||||
|
||||
results = db.search_emails(keyword="Meeting", field="subject")
|
||||
assert len(results) == 1
|
||||
@@ -186,33 +192,37 @@ class TestEmailDatabase:
|
||||
db_path = tmp_path / "test.db"
|
||||
db = emlmanager.EmailDatabase(db_path)
|
||||
|
||||
db.insert_email({
|
||||
"file_path": "/test/path1.eml",
|
||||
"file_hash": "hash1",
|
||||
"subject": "Test",
|
||||
"sender": "alice@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": "2024-01-01T12:00:00",
|
||||
"body_text": "Body",
|
||||
"body_html": "<p>Body</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
})
|
||||
db.insert_email(
|
||||
{
|
||||
"file_path": "/test/path1.eml",
|
||||
"file_hash": "hash1",
|
||||
"subject": "Test",
|
||||
"sender": "alice@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": "2024-01-01T12:00:00",
|
||||
"body_text": "Body",
|
||||
"body_html": "<p>Body</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
}
|
||||
)
|
||||
|
||||
db.insert_email({
|
||||
"file_path": "/test/path2.eml",
|
||||
"file_hash": "hash2",
|
||||
"subject": "Test",
|
||||
"sender": "bob@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": "Tue, 2 Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": "2024-01-02T12:00:00",
|
||||
"body_text": "Body",
|
||||
"body_html": "<p>Body</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
})
|
||||
db.insert_email(
|
||||
{
|
||||
"file_path": "/test/path2.eml",
|
||||
"file_hash": "hash2",
|
||||
"subject": "Test",
|
||||
"sender": "bob@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": "Tue, 2 Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": "2024-01-02T12:00:00",
|
||||
"body_text": "Body",
|
||||
"body_html": "<p>Body</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
}
|
||||
)
|
||||
|
||||
results = db.search_emails(keyword="alice", field="sender")
|
||||
assert len(results) == 1
|
||||
@@ -224,19 +234,21 @@ class TestEmailDatabase:
|
||||
db_path = tmp_path / "test.db"
|
||||
db = emlmanager.EmailDatabase(db_path)
|
||||
|
||||
db.insert_email({
|
||||
"file_path": "/test/path1.eml",
|
||||
"file_hash": "hash1",
|
||||
"subject": "Project Update",
|
||||
"sender": "manager@example.com",
|
||||
"recipients": "team@example.com",
|
||||
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": "2024-01-01T12:00:00",
|
||||
"body_text": "Please review the quarterly report",
|
||||
"body_html": "<p>Please review the quarterly report</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
})
|
||||
db.insert_email(
|
||||
{
|
||||
"file_path": "/test/path1.eml",
|
||||
"file_hash": "hash1",
|
||||
"subject": "Project Update",
|
||||
"sender": "manager@example.com",
|
||||
"recipients": "team@example.com",
|
||||
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": "2024-01-01T12:00:00",
|
||||
"body_text": "Please review the quarterly report",
|
||||
"body_html": "<p>Please review the quarterly report</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
}
|
||||
)
|
||||
|
||||
# Search for keyword in subject
|
||||
results = db.search_emails(keyword="Project", field="all")
|
||||
@@ -253,47 +265,53 @@ class TestEmailDatabase:
|
||||
db = emlmanager.EmailDatabase(db_path)
|
||||
|
||||
# Insert emails with same subject (different prefixes)
|
||||
db.insert_email({
|
||||
"file_path": "/test/path1.eml",
|
||||
"file_hash": "hash1",
|
||||
"subject": "Meeting Tomorrow",
|
||||
"sender": "sender1@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": "2024-01-01T12:00:00",
|
||||
"body_text": "Body 1",
|
||||
"body_html": "<p>Body 1</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
})
|
||||
db.insert_email(
|
||||
{
|
||||
"file_path": "/test/path1.eml",
|
||||
"file_hash": "hash1",
|
||||
"subject": "Meeting Tomorrow",
|
||||
"sender": "sender1@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": "2024-01-01T12:00:00",
|
||||
"body_text": "Body 1",
|
||||
"body_html": "<p>Body 1</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
}
|
||||
)
|
||||
|
||||
db.insert_email({
|
||||
"file_path": "/test/path2.eml",
|
||||
"file_hash": "hash2",
|
||||
"subject": "Re: Meeting Tomorrow",
|
||||
"sender": "sender2@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": "Tue, 2 Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": "2024-01-02T12:00:00",
|
||||
"body_text": "Body 2",
|
||||
"body_html": "<p>Body 2</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
})
|
||||
db.insert_email(
|
||||
{
|
||||
"file_path": "/test/path2.eml",
|
||||
"file_hash": "hash2",
|
||||
"subject": "Re: Meeting Tomorrow",
|
||||
"sender": "sender2@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": "Tue, 2 Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": "2024-01-02T12:00:00",
|
||||
"body_text": "Body 2",
|
||||
"body_html": "<p>Body 2</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
}
|
||||
)
|
||||
|
||||
db.insert_email({
|
||||
"file_path": "/test/path3.eml",
|
||||
"file_hash": "hash3",
|
||||
"subject": "Different Topic",
|
||||
"sender": "sender3@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": "Wed, 3 Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": "2024-01-03T12:00:00",
|
||||
"body_text": "Body 3",
|
||||
"body_html": "<p>Body 3</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
})
|
||||
db.insert_email(
|
||||
{
|
||||
"file_path": "/test/path3.eml",
|
||||
"file_hash": "hash3",
|
||||
"subject": "Different Topic",
|
||||
"sender": "sender3@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": "Wed, 3 Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": "2024-01-03T12:00:00",
|
||||
"body_text": "Body 3",
|
||||
"body_html": "<p>Body 3</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
}
|
||||
)
|
||||
|
||||
grouped = db.get_grouped_emails()
|
||||
# Should have 2 groups: "Meeting Tomorrow" and "Different Topic"
|
||||
@@ -322,19 +340,21 @@ class TestEmailDatabase:
|
||||
assert db.get_email_count() == 0
|
||||
|
||||
for i in range(3):
|
||||
db.insert_email({
|
||||
"file_path": f"/test/path{i}.eml",
|
||||
"file_hash": f"hash{i}",
|
||||
"subject": f"Subject {i}",
|
||||
"sender": f"sender{i}@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": f"2024-01-0{i + 1}T12:00:00",
|
||||
"body_text": f"Body {i}",
|
||||
"body_html": f"<p>Body {i}</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
})
|
||||
db.insert_email(
|
||||
{
|
||||
"file_path": f"/test/path{i}.eml",
|
||||
"file_hash": f"hash{i}",
|
||||
"subject": f"Subject {i}",
|
||||
"sender": f"sender{i}@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": f"2024-01-0{i + 1}T12:00:00",
|
||||
"body_text": f"Body {i}",
|
||||
"body_html": f"<p>Body {i}</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
}
|
||||
)
|
||||
|
||||
assert db.get_email_count() == 3
|
||||
db.close()
|
||||
@@ -346,19 +366,21 @@ class TestEmailDatabase:
|
||||
|
||||
# Insert some emails
|
||||
for i in range(3):
|
||||
db.insert_email({
|
||||
"file_path": f"/test/path{i}.eml",
|
||||
"file_hash": f"hash{i}",
|
||||
"subject": f"Subject {i}",
|
||||
"sender": f"sender{i}@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": f"2024-01-0{i + 1}T12:00:00",
|
||||
"body_text": f"Body {i}",
|
||||
"body_html": f"<p>Body {i}</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
})
|
||||
db.insert_email(
|
||||
{
|
||||
"file_path": f"/test/path{i}.eml",
|
||||
"file_hash": f"hash{i}",
|
||||
"subject": f"Subject {i}",
|
||||
"sender": f"sender{i}@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": f"2024-01-0{i + 1}T12:00:00",
|
||||
"body_text": f"Body {i}",
|
||||
"body_html": f"<p>Body {i}</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
}
|
||||
)
|
||||
|
||||
assert db.get_email_count() == 3
|
||||
|
||||
@@ -687,19 +709,21 @@ class TestEmlManagerHandler:
|
||||
|
||||
# Insert some emails
|
||||
for i in range(3):
|
||||
db.insert_email({
|
||||
"file_path": f"/test/path{i}.eml",
|
||||
"file_hash": f"hash{i}",
|
||||
"subject": f"Subject {i}",
|
||||
"sender": f"sender{i}@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": f"2024-01-0{i + 1}T12:00:00",
|
||||
"body_text": f"Body {i}",
|
||||
"body_html": f"<p>Body {i}</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
})
|
||||
db.insert_email(
|
||||
{
|
||||
"file_path": f"/test/path{i}.eml",
|
||||
"file_hash": f"hash{i}",
|
||||
"subject": f"Subject {i}",
|
||||
"sender": f"sender{i}@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": f"2024-01-0{i + 1}T12:00:00",
|
||||
"body_text": f"Body {i}",
|
||||
"body_html": f"<p>Body {i}</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
}
|
||||
)
|
||||
|
||||
# Create a mock handler instance without calling __init__
|
||||
handler = Mock(spec=emlmanager.EmlManagerHandler)
|
||||
@@ -721,19 +745,21 @@ class TestEmlManagerHandler:
|
||||
db = emlmanager.EmailDatabase(db_path)
|
||||
|
||||
# Insert test email
|
||||
db.insert_email({
|
||||
"file_path": "/test/path.eml",
|
||||
"file_hash": "hash",
|
||||
"subject": "Test Subject",
|
||||
"sender": "sender@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": "2024-01-01T12:00:00",
|
||||
"body_text": "Test body",
|
||||
"body_html": "<p>Test body</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
})
|
||||
db.insert_email(
|
||||
{
|
||||
"file_path": "/test/path.eml",
|
||||
"file_hash": "hash",
|
||||
"subject": "Test Subject",
|
||||
"sender": "sender@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": "2024-01-01T12:00:00",
|
||||
"body_text": "Test body",
|
||||
"body_html": "<p>Test body</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
}
|
||||
)
|
||||
|
||||
# Create a mock handler instance without calling __init__
|
||||
handler = Mock(spec=emlmanager.EmlManagerHandler)
|
||||
@@ -756,19 +782,21 @@ class TestEmlManagerHandler:
|
||||
db = emlmanager.EmailDatabase(db_path)
|
||||
|
||||
# Insert test email
|
||||
db.insert_email({
|
||||
"file_path": "/test/path.eml",
|
||||
"file_hash": "hash",
|
||||
"subject": "Test Subject",
|
||||
"sender": "sender@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": "2024-01-01T12:00:00",
|
||||
"body_text": "Test body",
|
||||
"body_html": "<p>Test body</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
})
|
||||
db.insert_email(
|
||||
{
|
||||
"file_path": "/test/path.eml",
|
||||
"file_hash": "hash",
|
||||
"subject": "Test Subject",
|
||||
"sender": "sender@example.com",
|
||||
"recipients": "recipient@example.com",
|
||||
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
||||
"date_parsed": "2024-01-01T12:00:00",
|
||||
"body_text": "Test body",
|
||||
"body_html": "<p>Test body</p>",
|
||||
"has_attachments": 0,
|
||||
"file_size": 1024,
|
||||
}
|
||||
)
|
||||
|
||||
assert db.get_email_count() == 1
|
||||
|
||||
@@ -856,9 +884,11 @@ Date: Mon, {i + 1} Jan 2024 12:00:00 +0000
|
||||
Body {i}
|
||||
""")
|
||||
|
||||
with patch("sys.argv", ["emlmanager", "--dir", str(tmp_path), "--port", "8080"]), patch.object(
|
||||
emlmanager, "ThreadingHTTPServer"
|
||||
) as mock_server, patch("threading.Thread"):
|
||||
with (
|
||||
patch("sys.argv", ["emlmanager", "--dir", str(tmp_path), "--port", "8080"]),
|
||||
patch.object(emlmanager, "ThreadingHTTPServer") as mock_server,
|
||||
patch("threading.Thread"),
|
||||
):
|
||||
# Don't actually start the server
|
||||
mock_server_instance = Mock()
|
||||
mock_server.return_value = mock_server_instance
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
"""Tests for ops.envdev / ops.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.dockercmd import docker_login_tencent
|
||||
from pyflowx.ops.envdev import (
|
||||
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 envdev as envdev_module
|
||||
|
||||
fake_sccache = tmp_path / ".cargo" / "sccache"
|
||||
monkeypatch.setattr(envdev_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]
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Tests for ops.envdev / sglang / msdownload 模块 (@px.tool 注册验证)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pyflowx as px
|
||||
from pyflowx.ops import envdev, msdownload, sglang # noqa: F401 -- 触发 @px.tool 注册
|
||||
from pyflowx.tools import _TOOL_REGISTRY, get_tool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# envdev 注册验证
|
||||
# ---------------------------------------------------------------------- #
|
||||
class TestEnvdevRegistration:
|
||||
"""``envdev`` 模块 ``@px.tool`` 注册验证."""
|
||||
|
||||
def test_envdev_registered(self) -> None:
|
||||
"""envdev 应在 _TOOL_REGISTRY 中注册."""
|
||||
assert "envdev" in _TOOL_REGISTRY
|
||||
|
||||
def test_visible_subcommands(self) -> None:
|
||||
"""可见子命令应包含核心镜像源配置命令."""
|
||||
subs = px.list_subcommands("envdev")
|
||||
assert "setup-python" in subs
|
||||
assert "setup-conda" in subs
|
||||
assert "setup-rust" in subs
|
||||
assert "setup-linux-mirror" in subs
|
||||
|
||||
def test_install_subcommands_visible(self) -> None:
|
||||
"""安装类子命令应可见."""
|
||||
subs = px.list_subcommands("envdev")
|
||||
assert "download-rustup" in subs
|
||||
assert "install-rust" in subs
|
||||
assert "install-qt-libs" in subs
|
||||
assert "install-fonts" in subs
|
||||
assert "install-docker" in subs
|
||||
|
||||
def test_setup_python_is_fn(self) -> None:
|
||||
"""setup-python 应为 fn 任务 (非 cmd)."""
|
||||
spec = get_tool("envdev", "setup-python")
|
||||
assert spec.func is not None
|
||||
assert spec.cmd is None
|
||||
|
||||
def test_setup_conda_is_fn(self) -> None:
|
||||
"""setup-conda 应为 fn 任务."""
|
||||
spec = get_tool("envdev", "setup-conda")
|
||||
assert spec.func is not None
|
||||
|
||||
def test_setup_rust_is_fn(self) -> None:
|
||||
"""setup-rust 应为 fn 任务."""
|
||||
spec = get_tool("envdev", "setup-rust")
|
||||
assert spec.func is not None
|
||||
|
||||
def test_setup_linux_mirror_is_fn(self) -> None:
|
||||
"""setup-linux-mirror 应为 fn 任务."""
|
||||
spec = get_tool("envdev", "setup-linux-mirror")
|
||||
assert spec.func is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# sglang 注册验证
|
||||
# ---------------------------------------------------------------------- #
|
||||
class TestSglangRegistration:
|
||||
"""``sglang`` 模块 ``@px.tool`` 注册验证."""
|
||||
|
||||
def test_sglang_registered(self) -> None:
|
||||
"""sglang 应在 _TOOL_REGISTRY 中注册."""
|
||||
assert "sglang" in _TOOL_REGISTRY
|
||||
|
||||
def test_visible_subcommands(self) -> None:
|
||||
"""可见子命令: install, run."""
|
||||
subs = px.list_subcommands("sglang")
|
||||
assert "install" in subs
|
||||
assert "run" in subs
|
||||
|
||||
def test_run_needs_install(self) -> None:
|
||||
"""run 应依赖 install."""
|
||||
spec = get_tool("sglang", "run")
|
||||
assert "install" in spec.needs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# msdownload 注册验证
|
||||
# ---------------------------------------------------------------------- #
|
||||
class TestMsdownloadRegistration:
|
||||
"""``msdownload`` 模块 ``@px.tool`` 注册验证."""
|
||||
|
||||
def test_msdownload_registered(self) -> None:
|
||||
"""msdownload 应在 _TOOL_REGISTRY 中注册."""
|
||||
assert "msdownload" in _TOOL_REGISTRY
|
||||
|
||||
def test_msdownload_default_spec(self) -> None:
|
||||
"""msdownload 默认 spec 应可获取且为 fn 任务."""
|
||||
spec = get_tool("msdownload")
|
||||
assert spec.func is not None
|
||||
assert spec.cmd is None
|
||||
|
||||
def test_msdownload_run_callable(self) -> None:
|
||||
"""msdownload_run 应为可调用函数."""
|
||||
assert callable(msdownload.msdownload_run)
|
||||
+10
-43
@@ -3,10 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pyflowx as px
|
||||
from pyflowx.cli import filedate
|
||||
from pyflowx.ops import filedate as files
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
@@ -20,7 +18,7 @@ class TestGetFileTimestamp:
|
||||
test_file = tmp_path / "test.txt"
|
||||
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 timestamp.isdigit()
|
||||
|
||||
@@ -36,7 +34,7 @@ class TestRemoveDatePrefix:
|
||||
test_file = tmp_path / "20240101_test.txt"
|
||||
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"
|
||||
|
||||
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.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
|
||||
|
||||
|
||||
@@ -59,7 +57,7 @@ class TestAddDatePrefix:
|
||||
test_file = tmp_path / "test.txt"
|
||||
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 "_test.txt" in new_path.name
|
||||
|
||||
@@ -75,7 +73,7 @@ class TestProcessFileDate:
|
||||
test_file = tmp_path / "test.txt"
|
||||
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
|
||||
|
||||
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.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
|
||||
|
||||
|
||||
@@ -95,42 +93,11 @@ class TestProcessFilesDate:
|
||||
|
||||
def test_process_files_date_batch(self, tmp_path: Path) -> None:
|
||||
"""Should process multiple files."""
|
||||
files = []
|
||||
file_list = []
|
||||
for i in range(3):
|
||||
test_file = tmp_path / f"test{i}.txt"
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# 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
@@ -3,10 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pyflowx as px
|
||||
from pyflowx.cli import filelevel
|
||||
from pyflowx.ops import filelevel as files
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
@@ -18,19 +16,19 @@ class TestRemoveMarks:
|
||||
def test_remove_marks_single_mark(self) -> None:
|
||||
"""Should remove single mark."""
|
||||
stem = "filename(PUB)"
|
||||
result = filelevel.remove_marks(stem, ["PUB"])
|
||||
result = files.remove_marks(stem, ["PUB"])
|
||||
assert result == "filename"
|
||||
|
||||
def test_remove_marks_multiple_marks(self) -> None:
|
||||
"""Should remove multiple marks."""
|
||||
stem = "filename(PUB)(NOR)"
|
||||
result = filelevel.remove_marks(stem, ["PUB", "NOR"])
|
||||
result = files.remove_marks(stem, ["PUB", "NOR"])
|
||||
assert result == "filename"
|
||||
|
||||
def test_remove_marks_no_marks(self) -> None:
|
||||
"""Should not change stem without marks."""
|
||||
stem = "filename"
|
||||
result = filelevel.remove_marks(stem, ["PUB"])
|
||||
result = files.remove_marks(stem, ["PUB"])
|
||||
assert result == "filename"
|
||||
|
||||
|
||||
@@ -45,7 +43,7 @@ class TestProcessFileLevel:
|
||||
test_file = tmp_path / "test.txt"
|
||||
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
|
||||
|
||||
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.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
|
||||
|
||||
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.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
|
||||
|
||||
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.write_text("test content")
|
||||
|
||||
filelevel.process_file_level(test_file, level=5)
|
||||
files.process_file_level(test_file, level=5)
|
||||
# Should print error message
|
||||
|
||||
def test_process_file_level_nonexistent_file(self, tmp_path: Path) -> None:
|
||||
"""Should handle nonexistent file."""
|
||||
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
|
||||
|
||||
|
||||
@@ -88,46 +86,11 @@ class TestProcessFilesLevel:
|
||||
|
||||
def test_process_files_level_batch(self, tmp_path: Path) -> None:
|
||||
"""Should process multiple files."""
|
||||
files = []
|
||||
file_list = []
|
||||
for i in range(3):
|
||||
test_file = tmp_path / f"test{i}.txt"
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# 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
|
||||
|
||||
@@ -5,8 +5,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pyflowx as px
|
||||
from pyflowx.cli import folderback
|
||||
from pyflowx.ops import folderback as files
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
@@ -22,7 +21,7 @@ class TestRemoveDump:
|
||||
dst = tmp_path / "backup"
|
||||
dst.mkdir()
|
||||
|
||||
folderback.remove_dump(src, dst, 5)
|
||||
files.remove_dump(src, dst, 5)
|
||||
# Should not raise error
|
||||
|
||||
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.write_bytes(b"ZIP content")
|
||||
|
||||
folderback.remove_dump(src, dst, 5)
|
||||
files.remove_dump(src, dst, 5)
|
||||
# All files should remain
|
||||
assert len(list(dst.glob("*.zip"))) == 3
|
||||
|
||||
@@ -53,7 +52,7 @@ class TestRemoveDump:
|
||||
zip_file = dst / f"source_20240101_12000{i}.zip"
|
||||
zip_file.write_bytes(b"ZIP content")
|
||||
|
||||
folderback.remove_dump(src, dst, 5)
|
||||
files.remove_dump(src, dst, 5)
|
||||
# Should have only 5 files
|
||||
assert len(list(dst.glob("*.zip"))) == 5
|
||||
|
||||
@@ -73,7 +72,7 @@ class TestZipTarget:
|
||||
dst.mkdir()
|
||||
|
||||
with patch("time.strftime", return_value="_20240101_120000"):
|
||||
folderback.zip_target(src, dst, 5)
|
||||
files.zip_target(src, dst, 5)
|
||||
|
||||
# Should create zip file
|
||||
zip_files = list(dst.glob("*.zip"))
|
||||
@@ -91,7 +90,7 @@ class TestZipTarget:
|
||||
dst.mkdir()
|
||||
|
||||
with patch("time.strftime", return_value="_20240101_120000"):
|
||||
folderback.zip_target(src, dst, 5)
|
||||
files.zip_target(src, dst, 5)
|
||||
|
||||
# Should create zip file
|
||||
zip_files = list(dst.glob("*.zip"))
|
||||
@@ -111,8 +110,8 @@ class TestBackupFolder:
|
||||
(source_dir / "test.txt").write_text("test content")
|
||||
backup_dir = tmp_path / "backup"
|
||||
|
||||
with patch.object(folderback, "zip_target") as mock_zip:
|
||||
folderback.backup_folder(str(source_dir), str(backup_dir), 5)
|
||||
with patch.object(files, "zip_target") as mock_zip:
|
||||
files.backup_folder(str(source_dir), str(backup_dir), 5)
|
||||
assert mock_zip.called
|
||||
|
||||
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")
|
||||
backup_dir = tmp_path / "backup"
|
||||
|
||||
with patch.object(folderback, "zip_target") as mock_zip:
|
||||
folderback.backup_folder(str(source_dir), str(backup_dir), 10)
|
||||
with patch.object(files, "zip_target") as mock_zip:
|
||||
files.backup_folder(str(source_dir), str(backup_dir), 10)
|
||||
assert mock_zip.called
|
||||
|
||||
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.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
|
||||
|
||||
def test_backup_folder_creates_dst(self, tmp_path: Path) -> None:
|
||||
@@ -142,32 +141,7 @@ class TestBackupFolder:
|
||||
(source_dir / "test.txt").write_text("test content")
|
||||
backup_dir = tmp_path / "backup"
|
||||
|
||||
with patch.object(folderback, "zip_target") as mock_zip:
|
||||
folderback.backup_folder(str(source_dir), str(backup_dir), 5)
|
||||
with patch.object(files, "zip_target") as mock_zip:
|
||||
files.backup_folder(str(source_dir), str(backup_dir), 5)
|
||||
assert backup_dir.exists()
|
||||
assert mock_zip.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# TaskSpec definitions
|
||||
# ---------------------------------------------------------------------- #
|
||||
class TestTaskSpecDefinitions:
|
||||
"""Test that all TaskSpec definitions are valid."""
|
||||
|
||||
def test_folderback_default_spec(self) -> None:
|
||||
"""folderback_default spec should be properly defined."""
|
||||
assert folderback.folderback_default.name == "folderback_default"
|
||||
assert folderback.folderback_default.fn is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# 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
|
||||
|
||||
@@ -5,8 +5,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pyflowx as px
|
||||
from pyflowx.cli import folderzip
|
||||
from pyflowx.ops import folderzip as files
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
@@ -22,7 +21,7 @@ class TestArchiveFolder:
|
||||
(folder / "test.txt").write_text("test content")
|
||||
|
||||
with patch("shutil.make_archive") as mock_archive:
|
||||
folderzip.archive_folder(folder)
|
||||
files.archive_folder(folder)
|
||||
assert mock_archive.called
|
||||
|
||||
|
||||
@@ -39,37 +38,12 @@ class TestZipFolders:
|
||||
(tmp_path / "folder2").mkdir()
|
||||
(tmp_path / ".git").mkdir() # Should be ignored
|
||||
|
||||
with patch.object(folderzip, "archive_folder") as mock_archive:
|
||||
folderzip.zip_folders(str(tmp_path))
|
||||
with patch.object(files, "archive_folder") as mock_archive:
|
||||
files.zip_folders(str(tmp_path))
|
||||
# Should archive folder1 and folder2, but not .git
|
||||
assert mock_archive.call_count == 2
|
||||
|
||||
def test_zip_folders_nonexistent_cwd(self, tmp_path: Path) -> None:
|
||||
"""Should handle nonexistent cwd."""
|
||||
folderzip.zip_folders(str(tmp_path / "nonexistent"))
|
||||
files.zip_folders(str(tmp_path / "nonexistent"))
|
||||
# Should print error message and return
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# TaskSpec definitions
|
||||
# ---------------------------------------------------------------------- #
|
||||
class TestTaskSpecDefinitions:
|
||||
"""Test that all TaskSpec definitions are valid."""
|
||||
|
||||
def test_folderzip_default_spec(self) -> None:
|
||||
"""folderzip_default spec should be properly defined."""
|
||||
assert folderzip.folderzip_default.name == "folderzip_default"
|
||||
assert folderzip.folderzip_default.fn is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# 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
@@ -8,7 +8,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
import pyflowx as px
|
||||
from pyflowx.cli import gittool
|
||||
from pyflowx.ops import gittool as dev
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
@@ -20,7 +20,7 @@ class TestNotHasGitRepo:
|
||||
def test_not_has_git_repo_true(self, tmp_path: Path) -> None:
|
||||
"""Should return True when no .git directory."""
|
||||
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
|
||||
|
||||
def test_not_has_git_repo_false(self, tmp_path: Path) -> None:
|
||||
@@ -29,7 +29,7 @@ class TestNotHasGitRepo:
|
||||
git_dir.mkdir()
|
||||
|
||||
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
|
||||
|
||||
def test_not_has_git_repo_cwd_not_exists(self, tmp_path: Path) -> None:
|
||||
@@ -37,7 +37,7 @@ class TestNotHasGitRepo:
|
||||
nonexistent = tmp_path / "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
|
||||
|
||||
|
||||
@@ -47,19 +47,25 @@ class TestNotHasGitRepo:
|
||||
class TestHasFiles:
|
||||
"""Test has_files function."""
|
||||
|
||||
def test_has_files_true(self, tmp_path: Path) -> None:
|
||||
"""Should return True when files exist."""
|
||||
(tmp_path / "test.txt").write_text("test")
|
||||
def test_has_files_true(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Should return True when there are uncommitted changes."""
|
||||
|
||||
with patch.object(Path, "cwd", return_value=tmp_path):
|
||||
result = gittool.has_files()
|
||||
assert result is True
|
||||
class _FakeResult:
|
||||
stdout = " M test.txt\n"
|
||||
|
||||
def test_has_files_false(self, tmp_path: Path) -> None:
|
||||
"""Should return False when no files."""
|
||||
with patch.object(Path, "cwd", return_value=tmp_path):
|
||||
result = gittool.has_files()
|
||||
assert result is False
|
||||
monkeypatch.setattr("subprocess.run", lambda *_, **__: _FakeResult())
|
||||
result = dev.has_files()
|
||||
assert result is True
|
||||
|
||||
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()
|
||||
|
||||
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
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
def test_init_sub_dirs_no_subdirectories(self, tmp_path: Path) -> None:
|
||||
"""Should handle no subdirectories."""
|
||||
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
|
||||
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
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Tests for ops.gittool 模块 (@px.tool 注册验证)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import pyflowx as px
|
||||
import pyflowx.ops.gittool
|
||||
from pyflowx.ops import gittool
|
||||
from pyflowx.tools import _TOOL_REGISTRY, get_tool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# @px.tool 注册验证
|
||||
# ---------------------------------------------------------------------- #
|
||||
class TestGittoolRegistration:
|
||||
"""``gittool`` 模块 ``@px.tool`` 注册验证."""
|
||||
|
||||
def test_gittool_registered(self) -> None:
|
||||
"""gittool 应在 _TOOL_REGISTRY 中注册."""
|
||||
assert "gittool" in _TOOL_REGISTRY
|
||||
|
||||
def test_visible_subcommands(self) -> None:
|
||||
"""可见子命令: a, c, i, isub, p, pl."""
|
||||
subs = px.list_subcommands("gittool")
|
||||
assert "a" in subs
|
||||
assert "c" in subs
|
||||
assert "i" in subs
|
||||
assert "isub" in subs
|
||||
assert "p" in subs
|
||||
assert "pl" in subs
|
||||
|
||||
def test_clean_is_hidden(self) -> None:
|
||||
"""clean 应为 hidden (不在可见子命令中)."""
|
||||
subs = px.list_subcommands("gittool")
|
||||
assert "clean" not in subs
|
||||
|
||||
def test_clean_hidden_included_with_flag(self) -> None:
|
||||
"""include_hidden=True 时 clean 应出现."""
|
||||
subs = px.list_subcommands("gittool", include_hidden=True)
|
||||
assert "clean" in subs
|
||||
|
||||
def test_c_needs_clean(self) -> None:
|
||||
"""c 应依赖 clean."""
|
||||
spec = get_tool("gittool", "c")
|
||||
assert "clean" in spec.needs
|
||||
|
||||
def test_clean_has_cmd(self) -> None:
|
||||
"""clean 应有 cmd."""
|
||||
spec = get_tool("gittool", "clean")
|
||||
assert spec.cmd is not None
|
||||
assert "git" in spec.cmd
|
||||
assert "clean" in spec.cmd
|
||||
assert "-xfd" in spec.cmd
|
||||
|
||||
def test_clean_cmd_includes_excludes(self) -> None:
|
||||
"""clean cmd 应包含排除项."""
|
||||
spec = get_tool("gittool", "clean")
|
||||
assert spec.cmd is not None
|
||||
assert "-e" in spec.cmd
|
||||
assert ".venv" in spec.cmd
|
||||
|
||||
def test_c_has_cmd(self) -> None:
|
||||
"""c 应有 cmd."""
|
||||
spec = get_tool("gittool", "c")
|
||||
assert spec.cmd == ("git", "status", "--porcelain")
|
||||
|
||||
def test_p_has_cmd(self) -> None:
|
||||
"""p 应有 cmd."""
|
||||
spec = get_tool("gittool", "p")
|
||||
assert spec.cmd == ("git", "push")
|
||||
|
||||
def test_pl_has_cmd(self) -> None:
|
||||
"""pl 应有 cmd."""
|
||||
spec = get_tool("gittool", "pl")
|
||||
assert spec.cmd == ("git", "pull")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# 辅助函数验证 (not_has_git_repo / has_files)
|
||||
# ---------------------------------------------------------------------- #
|
||||
class TestGittoolHelpers:
|
||||
"""``gittool`` 模块辅助函数验证."""
|
||||
|
||||
def test_not_has_git_repo_true(self, tmp_path: Path) -> None:
|
||||
"""无 .git 目录时 not_has_git_repo 应返回 True."""
|
||||
with patch.object(Path, "cwd", return_value=tmp_path):
|
||||
assert gittool.not_has_git_repo() is True
|
||||
|
||||
def test_not_has_git_repo_false(self, tmp_path: Path) -> None:
|
||||
"""有 .git 目录时 not_has_git_repo 应返回 False."""
|
||||
(tmp_path / ".git").mkdir()
|
||||
with patch.object(Path, "cwd", return_value=tmp_path):
|
||||
assert gittool.not_has_git_repo() is False
|
||||
|
||||
def test_has_files_false_when_no_changes(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""无未提交更改时 has_files 应返回 False."""
|
||||
|
||||
class _FakeResult:
|
||||
stdout = ""
|
||||
|
||||
monkeypatch.setattr("subprocess.run", lambda *_, **__: _FakeResult())
|
||||
assert gittool.has_files() is False
|
||||
|
||||
def test_has_files_true_when_changes(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""有未提交更改时 has_files 应返回 True."""
|
||||
|
||||
class _FakeResult:
|
||||
stdout = " M test.txt\n"
|
||||
|
||||
monkeypatch.setattr("subprocess.run", lambda *_, **__: _FakeResult())
|
||||
assert gittool.has_files() is True
|
||||
|
||||
def test_has_files_false_on_subprocess_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""subprocess.SubprocessError 时 has_files 应返回 False."""
|
||||
|
||||
def _raise(*args: object, **kwargs: object) -> None:
|
||||
raise subprocess.SubprocessError("fail")
|
||||
|
||||
monkeypatch.setattr("subprocess.run", _raise)
|
||||
assert gittool.has_files() is False
|
||||
|
||||
def test_has_files_false_on_oserror(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""OSError 时 has_files 应返回 False."""
|
||||
|
||||
def _raise(*args: object, **kwargs: object) -> None:
|
||||
raise OSError("fail")
|
||||
|
||||
monkeypatch.setattr("subprocess.run", _raise)
|
||||
assert gittool.has_files() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# git_add_commit / git_init_add_commit 函数体验证
|
||||
# ---------------------------------------------------------------------- #
|
||||
class TestGittoolCommit:
|
||||
"""``gittool`` 模块提交函数验证."""
|
||||
|
||||
def test_git_add_commit_no_changes(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""无更改时 git_add_commit 应打印提示且不执行 git 命令."""
|
||||
monkeypatch.setattr(gittool, "has_files", lambda: False)
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def _capture(*args: object, **kwargs: object) -> None:
|
||||
calls.append(args[0]) # type: ignore[arg-type]
|
||||
|
||||
monkeypatch.setattr("subprocess.run", _capture)
|
||||
gittool.git_add_commit()
|
||||
assert calls == []
|
||||
assert "没有文件需要提交" in capsys.readouterr().out
|
||||
|
||||
def test_git_add_commit_with_changes(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""有更改时 git_add_commit 应执行 git add + git commit."""
|
||||
monkeypatch.setattr(gittool, "has_files", lambda: True)
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def _capture(*args: object, **kwargs: object) -> None:
|
||||
calls.append(args[0]) # type: ignore[arg-type]
|
||||
|
||||
monkeypatch.setattr("subprocess.run", _capture)
|
||||
gittool.git_add_commit(message="custom msg")
|
||||
assert calls == [["git", "add", "."], ["git", "commit", "-m", "custom msg"]]
|
||||
|
||||
def test_git_init_add_commit_init_and_commit(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""无仓库且有更改时 git_init_add_commit 应 git init + add + commit."""
|
||||
monkeypatch.setattr(gittool, "not_has_git_repo", lambda: True)
|
||||
monkeypatch.setattr(gittool, "has_files", lambda: True)
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def _capture(*args: object, **kwargs: object) -> None:
|
||||
calls.append(args[0]) # type: ignore[arg-type]
|
||||
|
||||
monkeypatch.setattr("subprocess.run", _capture)
|
||||
gittool.git_init_add_commit()
|
||||
assert calls == [["git", "init"], ["git", "add", "."], ["git", "commit", "-m", "init commit"]]
|
||||
|
||||
def test_git_init_add_commit_init_no_changes(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""无仓库且无更改时 git_init_add_commit 应 git init + 打印提示."""
|
||||
monkeypatch.setattr(gittool, "not_has_git_repo", lambda: True)
|
||||
monkeypatch.setattr(gittool, "has_files", lambda: False)
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def _capture(*args: object, **kwargs: object) -> None:
|
||||
calls.append(args[0]) # type: ignore[arg-type]
|
||||
|
||||
monkeypatch.setattr("subprocess.run", _capture)
|
||||
gittool.git_init_add_commit()
|
||||
assert calls == [["git", "init"]]
|
||||
assert "没有文件需要提交" in capsys.readouterr().out
|
||||
|
||||
def test_git_init_add_commit_no_init_with_changes(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""有仓库且有更改时 git_init_add_commit 应 add + commit (不 init)."""
|
||||
monkeypatch.setattr(gittool, "not_has_git_repo", lambda: False)
|
||||
monkeypatch.setattr(gittool, "has_files", lambda: True)
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def _capture(*args: object, **kwargs: object) -> None:
|
||||
calls.append(args[0]) # type: ignore[arg-type]
|
||||
|
||||
monkeypatch.setattr("subprocess.run", _capture)
|
||||
gittool.git_init_add_commit()
|
||||
assert calls == [["git", "add", "."], ["git", "commit", "-m", "init commit"]]
|
||||
|
||||
def test_git_init_add_commit_no_init_no_changes(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""有仓库且无更改时 git_init_add_commit 应仅打印提示 (不 init, 不 add/commit)."""
|
||||
monkeypatch.setattr(gittool, "not_has_git_repo", lambda: False)
|
||||
monkeypatch.setattr(gittool, "has_files", lambda: False)
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def _capture(*args: object, **kwargs: object) -> None:
|
||||
calls.append(args[0]) # type: ignore[arg-type]
|
||||
|
||||
monkeypatch.setattr("subprocess.run", _capture)
|
||||
gittool.git_init_add_commit()
|
||||
assert calls == []
|
||||
assert "没有文件需要提交" in capsys.readouterr().out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# init_sub_dirs 函数验证
|
||||
# ---------------------------------------------------------------------- #
|
||||
class TestGittoolInitSubDirs:
|
||||
"""``init_sub_dirs`` 函数验证."""
|
||||
|
||||
def test_init_sub_dirs_runs_graph_for_each_subdir(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""init_sub_dirs 应为每个子目录构建并运行图 (文件被跳过)."""
|
||||
(tmp_path / "subdir1").mkdir()
|
||||
(tmp_path / "subdir2").mkdir()
|
||||
(tmp_path / "file.txt").write_text("test")
|
||||
monkeypatch.setattr(Path, "cwd", lambda: tmp_path)
|
||||
runs: list[object] = []
|
||||
monkeypatch.setattr(gittool.px, "run", runs.append)
|
||||
gittool.init_sub_dirs()
|
||||
assert len(runs) == 2
|
||||
|
||||
def test_init_sub_dirs_no_subdirs(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""无子目录时 init_sub_dirs 不应运行任何图."""
|
||||
(tmp_path / "file.txt").write_text("test")
|
||||
monkeypatch.setattr(Path, "cwd", lambda: tmp_path)
|
||||
runs: list[object] = []
|
||||
monkeypatch.setattr(gittool.px, "run", runs.append)
|
||||
gittool.init_sub_dirs()
|
||||
assert runs == []
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Tests for ops.msdownload / ops.sglang 模块 (ModelScope 下载/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.msdownload import msdownload_run
|
||||
from pyflowx.ops.sglang import install_sglang, 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
@@ -5,9 +5,8 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pyflowx as px
|
||||
from pyflowx.cli import lscalc
|
||||
from pyflowx.conditions import Constants
|
||||
from pyflowx.ops import lscalc as system
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
@@ -19,7 +18,7 @@ class TestGetLsDynaCommand:
|
||||
def test_get_ls_dyna_command_windows(self) -> None:
|
||||
"""Should get LS-DYNA command for Windows."""
|
||||
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 "i=input.k" in cmd
|
||||
assert "ncpu=4" in cmd
|
||||
@@ -27,7 +26,7 @@ class TestGetLsDynaCommand:
|
||||
def test_get_ls_dyna_command_linux(self) -> None:
|
||||
"""Should get LS-DYNA command for Linux."""
|
||||
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 "i=input.k" in cmd
|
||||
assert "ncpu=8" in cmd
|
||||
@@ -46,14 +45,14 @@ class TestRunLsDyna:
|
||||
|
||||
with patch("subprocess.run") as mock_run:
|
||||
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
|
||||
|
||||
def test_run_ls_dyna_file_not_found(self, tmp_path: Path) -> None:
|
||||
"""Should handle nonexistent input file."""
|
||||
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
|
||||
|
||||
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")
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -79,14 +78,14 @@ class TestRunLsDynaMpi:
|
||||
|
||||
with patch("subprocess.run") as mock_run:
|
||||
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
|
||||
|
||||
def test_run_ls_dyna_mpi_file_not_found(self, tmp_path: Path) -> None:
|
||||
"""Should handle nonexistent input file."""
|
||||
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
|
||||
|
||||
|
||||
@@ -100,58 +99,12 @@ class TestCheckLsDynaStatus:
|
||||
"""Should check LS-DYNA status on Windows."""
|
||||
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)
|
||||
lscalc.check_ls_dyna_status()
|
||||
system.check_ls_dyna_status()
|
||||
assert mock_run.called
|
||||
|
||||
def test_check_ls_dyna_status_linux(self) -> None:
|
||||
"""Should check LS-DYNA status on Linux."""
|
||||
with patch.object(Constants, "IS_WINDOWS", False), patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="1234", returncode=0)
|
||||
lscalc.check_ls_dyna_status()
|
||||
system.check_ls_dyna_status()
|
||||
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
Reference in New Issue
Block a user