Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 1f274fe828 | |||
| 85793ff9d5 | |||
| 37ac4b8025 | |||
| 0edeadb846 | |||
| f63db6c71a | |||
| 4d397606e6 | |||
| f24388b151 | |||
| 535b7cba31 | |||
| 3f68bed3fd | |||
| 2e2ca812a1 | |||
| 8de565d0cb | |||
| 5480c48e67 | |||
| c6653d5117 | |||
| d194a991a0 | |||
| 4446658170 | |||
| 1d26f9d3e7 | |||
| d9644ca5d1 | |||
| d3c2d53449 | |||
| 9cfcfb38e4 | |||
| 69db241611 | |||
| 66e6295a24 | |||
| aebb4fce68 | |||
| 7784c8ff86 | |||
| 77918a5568 | |||
| 7e4c615dc7 | |||
| ac5082523e | |||
| 0df6f7c8ac | |||
| 4b66176ce6 | |||
| cf6b6fd059 |
@@ -0,0 +1,46 @@
|
|||||||
|
# 版本控制
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.github
|
||||||
|
|
||||||
|
# Python 缓存与构建产物
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.egg-info
|
||||||
|
*.egg
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.eggs
|
||||||
|
|
||||||
|
# 测试与覆盖率
|
||||||
|
.pytest_cache
|
||||||
|
.coverage
|
||||||
|
htmlcov
|
||||||
|
.tox
|
||||||
|
coverage.xml
|
||||||
|
|
||||||
|
# 虚拟环境
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
env
|
||||||
|
|
||||||
|
# 工具缓存
|
||||||
|
.uv-cache
|
||||||
|
.ruff_cache
|
||||||
|
.pyrefly_cache
|
||||||
|
.mypy_cache
|
||||||
|
|
||||||
|
# IDE 与编辑器
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# 文档(按需保留)
|
||||||
|
docs
|
||||||
|
|
||||||
|
# 系统文件
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
+18
-34
@@ -9,42 +9,26 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-and-typecheck:
|
ci:
|
||||||
name: Lint & Typecheck
|
name: Lint, Typecheck & Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: pyflowx-ci:latest
|
||||||
|
env:
|
||||||
|
UV_LINK_MODE: copy
|
||||||
|
# ---- 国内源 ----
|
||||||
|
PIP_INDEX_URL: https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
PIP_TRUSTED_HOST: pypi.tuna.tsinghua.edu.cn
|
||||||
|
UV_INDEX_URL: https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
UV_TRUSTED_HOST: pypi.tuna.tsinghua.edu.cn
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: http://gitea:3000/zhou/checkout.git@main
|
||||||
|
|
||||||
- uses: astral-sh/setup-uv@v5
|
- name: Sync dependencies
|
||||||
with:
|
run: uv sync --frozen
|
||||||
enable-cache: true
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
- name: Ruff check
|
||||||
with:
|
run: ruff check src tests
|
||||||
python-version: '3.13'
|
|
||||||
|
|
||||||
- run: uv sync
|
- name: Tox test (py38, py313)
|
||||||
- run: uv run ruff check src tests
|
run: uvx tox run -e py38,py313
|
||||||
- run: uv run pyrefly check .
|
|
||||||
|
|
||||||
test:
|
|
||||||
name: Test (${{ matrix.os }})
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: astral-sh/setup-uv@v5
|
|
||||||
with:
|
|
||||||
enable-cache: true
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: |
|
|
||||||
3.8
|
|
||||||
3.13
|
|
||||||
|
|
||||||
- run: uvx tox run -e py38,py313
|
|
||||||
|
|||||||
@@ -6,56 +6,53 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
id-token: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
version: ${{ steps.version.outputs.version }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: astral-sh/setup-uv@v5
|
|
||||||
with:
|
|
||||||
enable-cache: true
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.13'
|
|
||||||
|
|
||||||
- run: uv build
|
|
||||||
|
|
||||||
- id: version
|
|
||||||
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: dist
|
|
||||||
path: dist/
|
|
||||||
|
|
||||||
publish-pypi:
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment: pypi
|
|
||||||
steps:
|
|
||||||
- uses: actions/download-artifact@v8
|
|
||||||
with:
|
|
||||||
name: dist
|
|
||||||
path: dist
|
|
||||||
|
|
||||||
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: [build, publish-pypi]
|
name: Build, Publish & Release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: pyflowx-ci:latest
|
||||||
|
env:
|
||||||
|
UV_LINK_MODE: copy
|
||||||
|
# ---- 国内源 ----
|
||||||
|
PIP_INDEX_URL: https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
PIP_TRUSTED_HOST: pypi.tuna.tsinghua.edu.cn
|
||||||
|
UV_INDEX_URL: https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
UV_TRUSTED_HOST: pypi.tuna.tsinghua.edu.cn
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@v8
|
- uses: http://gitea:3000/zhou/checkout.git@v4
|
||||||
with:
|
|
||||||
name: dist
|
|
||||||
path: dist
|
|
||||||
|
|
||||||
- uses: softprops/action-gh-release@v2
|
- name: Build distributions
|
||||||
with:
|
run: uv build
|
||||||
files: dist/*
|
|
||||||
generate_release_notes: true
|
- name: Publish to pypi
|
||||||
|
run: uv publish --token '${{ secrets.PYPI_TOKEN }}'
|
||||||
|
|
||||||
|
- name: Create Gitea Release & Upload Assets
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG_NAME: ${{ github.ref_name }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
GITEA_URL: http://172.17.0.1:3000
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
# 1. 创建 Release
|
||||||
|
RELEASE_ID=$(curl -sS -X POST "$GITEA_URL/api/v1/repos/$REPO/releases" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"$TAG_NAME\",\"name\":\"Release $TAG_NAME\",\"body\":\"Automated release from CI\",\"draft\":false,\"prerelease\":false}" \
|
||||||
|
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||||
|
|
||||||
|
echo "Created release id=$RELEASE_ID"
|
||||||
|
|
||||||
|
# 2. 上传 dist/ 下所有文件作为附件
|
||||||
|
for f in dist/*; do
|
||||||
|
echo "Uploading $f ..."
|
||||||
|
curl -sS -X POST "$GITEA_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets?name=$(basename $f)" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,3 +11,7 @@ wheels/
|
|||||||
.coverage
|
.coverage
|
||||||
.idea
|
.idea
|
||||||
*_profile.html
|
*_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,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。
|
||||||
|
- 不新增中文文档翻译(文档用中文撰写,与项目既有风格一致)。
|
||||||
@@ -150,7 +150,7 @@ uvx --from pyflowx pymake cov
|
|||||||
|
|
||||||
## Git 与提交
|
## Git 与提交
|
||||||
|
|
||||||
- **不自动提交/push**:除非用户明确要求。
|
- **自动提交**:任务完成后自动 `git add`(按文件名)+ `git commit` + `git push`(仅当分支已跟踪远程时执行 push;新分支跳过 push 并在总结中说明)。
|
||||||
- **不修改 git config**。
|
- **不修改 git config**。
|
||||||
- **不运行破坏性命令**(`push --force`/`reset --hard`/`clean -f`)除非用户明确要求。
|
- **不运行破坏性命令**(`push --force`/`reset --hard`/`clean -f`)除非用户明确要求。
|
||||||
- **staging**:按文件名添加,不用 `git add -A`/`git add .`,避免误加敏感文件。
|
- **staging**:按文件名添加,不用 `git add -A`/`git add .`,避免误加敏感文件。
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# 自驱动开发规则
|
||||||
|
|
||||||
|
本规则定义一种"目标驱动、闭环执行"的工作模式:仅在任务开始时与用户确认一次目标与边界,后续由 Agent 自主完成"计划 → 编码 → 测试 → 文档 → 验证"的迭代循环,直到用户目标达成。
|
||||||
|
|
||||||
|
## 核心原则
|
||||||
|
|
||||||
|
- **目标导向**:始终以用户最终目标为准绳,所有阶段产出都应服务于该目标。
|
||||||
|
- **闭环执行**:每个子任务必须走完"计划 → 实现 → 测试 → 文档 → 验证"五步;禁止跳步留半成品。
|
||||||
|
- **自主决策**:初始确认之后,实现路径、API 形态、重构范围、文件命名、测试组织、错误修复策略等由 Agent 自行决断,不再逐项请示。**可逆操作(编辑文件、运行测试、修复 lint、调整实现)直接执行,不询问**;只有不可逆/高风险操作才暂停。
|
||||||
|
- **透明沟通**:每个阶段开始前用一句话说明意图;关键节点(完成、阻塞、转向)给简短更新;不复述内部思考,**不在收尾时停下询问"是否继续"或"是否提交"**——直接输出总结并结束。
|
||||||
|
- **安全边界**:仅在高风险、不可逆操作或真正阻塞时才暂停找用户。
|
||||||
|
|
||||||
|
## 初始确认(一次性,仅在最开始)
|
||||||
|
|
||||||
|
任务启动时,用 `AskUserQuestion` 一次性确认以下信息(已由项目规范覆盖的不必重复确认):
|
||||||
|
|
||||||
|
1. **目标与范围**:要解决什么问题?交付物是什么?显式列出不在范围内的内容。
|
||||||
|
2. **验收标准**:怎样算"完成"?可观测的判定条件(功能、性能、覆盖率阈值)。
|
||||||
|
3. **特殊约束**:除 `python-standards.md` 之外的约束(兼容性、依赖限制、API 兼容策略等)。
|
||||||
|
4. **测试要求**:覆盖率门槛(项目默认 ≥95%,branch);是否需要新增 `slow` 标记。
|
||||||
|
|
||||||
|
**git commit/push 不在确认范围内**:任务完成后自动 commit + push(仅当分支已跟踪远程时执行 push;新分支跳过 push 并在总结中说明),遵循 `.trae/rules/git-commit-message.md` 风格。仅 force-push、reset --hard、clean -f、修改 git config 等真正破坏性操作才需暂停确认。
|
||||||
|
|
||||||
|
确认后,将目标与验收标准固化进 `TaskCreate` 任务列表,后续不再就同一信息反复询问。
|
||||||
|
|
||||||
|
## 迭代循环
|
||||||
|
|
||||||
|
下列五个阶段构成一个完整闭环。未达验收标准时,回到「计划」开启下一轮;达标准时,进入「收尾」。
|
||||||
|
|
||||||
|
### 1. 计划(Plan)
|
||||||
|
|
||||||
|
- 用 Explore/Glob/Grep 研究相关代码与既有模式,避免凭空设计。
|
||||||
|
- 用 `TaskCreate` 把目标拆为可独立验证的子任务;每完成一项立即 `TaskUpdate` 为 completed。
|
||||||
|
- 优先复用现有抽象;不为本轮假想需求设计接口。
|
||||||
|
- 不过早抽象:三处相似才考虑提取,否则就地写。
|
||||||
|
|
||||||
|
### 2. 实现(Code)
|
||||||
|
|
||||||
|
- 严格遵守 `.trae/rules/python-standards.md` 与既有代码风格。
|
||||||
|
- 优先 Edit 现有文件;新增文件需有明确职责边界。
|
||||||
|
- 不引入运行时依赖(项目零依赖原则);确需引入须在计划阶段说明。
|
||||||
|
- 公共 API 必须有完整类型注解与中文 docstring。
|
||||||
|
- 不写未被要求的功能、不为未来场景预留扩展点。
|
||||||
|
|
||||||
|
### 3. 测试(Test)
|
||||||
|
|
||||||
|
- 新增/修改的公共 API 必须配套测试;优先通过公共接口测试,故障注入可访问私有属性并在 docstring 注明。
|
||||||
|
- Mock 优先级:`monkeypatch` > 内联 stub > `unittest.mock` > `pytest-mock`;禁用 `@patch` 装饰器。
|
||||||
|
- 必跑校验(每次修改后):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx --from pyflowx pymake tc
|
||||||
|
uvx --from pyflowx pymake cov
|
||||||
|
```
|
||||||
|
|
||||||
|
- 测试失败时定位根因再修复,不通过放宽断言或 `# pragma: no cover` 绕过。
|
||||||
|
- 覆盖率不得低于上一次的值(项目门槛 95%,branch)。
|
||||||
|
|
||||||
|
### 4. 文档(Docs)
|
||||||
|
|
||||||
|
- 同步更新 docstring、README、模块结构说明。
|
||||||
|
- 行为变更须同步更新 `.agents/skills/pyflowx-development/SKILL.md` 中的对应章节。
|
||||||
|
- 跨会话有价值的设计决策、约束、陷阱,追加到 memory(`project_memory.md` 或对应 `topics.md`)。
|
||||||
|
- 不主动新建 `*.md` 文档;除非用户明确要求。
|
||||||
|
|
||||||
|
### 5. 验证(Verify)
|
||||||
|
|
||||||
|
- 逐条对照初始确认的「验收标准」核验;未满足则回到「计划」继续下一轮。
|
||||||
|
- 全套门禁通过:ruff、pyrefly、pytest、coverage。
|
||||||
|
- 给出本轮变更清单(改了哪些文件、为什么)。
|
||||||
|
|
||||||
|
## 暂停条件(仅在以下情况中断自驱动找用户)
|
||||||
|
|
||||||
|
1. **歧义无法自决**:需求存在多种合理解读且无既有约定可循。
|
||||||
|
2. **高风险/不可逆操作**:删除非临时文件、`git push --force`、`reset --hard`、删表、修改 CI 配置、修改 git config、卸载依赖等。**普通 `git commit`/`push` 不属于此类**(任务完成后自动执行)。
|
||||||
|
3. **不可恢复的失败**:根因不在本仓库、需外部环境/权限配合、或经两轮尝试仍无法定位。
|
||||||
|
4. **超出初始确认范围**:用户目标在执行中发现需要显著扩大范围或改变方向。
|
||||||
|
5. **用户主动询问**:用户在对话中提出新问题或要求澄清。
|
||||||
|
|
||||||
|
**注意**:"目标已达成"**不是**暂停条件——验收标准全部满足后直接进入收尾并结束任务,不询问"是否扩展范围"或"是否提交"。
|
||||||
|
|
||||||
|
非以上情况,一律继续自驱动,不要为"求确认"而暂停。
|
||||||
|
|
||||||
|
## 决策判据:该问还是自决
|
||||||
|
|
||||||
|
遇到不确定时,按以下顺序判断:
|
||||||
|
|
||||||
|
1. **是否不可逆/高风险?** 是 → 暂停确认(如删除文件、`push --force`、修改 CI 配置、卸载依赖)。否 → 继续。
|
||||||
|
2. **是否在初始确认范围内?** 是 → 按确认执行,不询问。否 → 视为"超出初始确认范围",暂停。
|
||||||
|
3. **是否有既有约定可循?** 是 → 按约定执行(参考 `python-standards.md`、`project_memory.md`)。否 → 视为"歧义无法自决",暂停。
|
||||||
|
4. **是否可逆?** 是 → 直接执行,即使结果可能不完美(可在后续迭代修正)。否 → 暂停。
|
||||||
|
|
||||||
|
**可直接自决(不询问)的典型情况**:
|
||||||
|
|
||||||
|
- 测试失败、覆盖率不达标、lint/类型检查报错 → 定位根因并修复。
|
||||||
|
- 代码风格选择(命名、模块划分、参数顺序)→ 自决。
|
||||||
|
- 文件编辑、运行测试、运行校验命令 → 直接执行。
|
||||||
|
- 任务完成后输出收尾总结 → 直接输出,不询问下一步。
|
||||||
|
- 显式指定 `name` 参数以保持测试兼容性 → 自决。
|
||||||
|
- 重命名局部变量以避免遮蔽 → 自决。
|
||||||
|
|
||||||
|
**必须暂停询问的典型情况**:
|
||||||
|
|
||||||
|
- 删除非临时文件、重命名公共模块/包。
|
||||||
|
- `git push --force`、`reset --hard`、`clean -f`、修改 git config(普通 commit/push 自动执行,无需询问)。
|
||||||
|
- 引入新的运行时依赖(违反项目零依赖原则)。
|
||||||
|
- 修改 CI 配置、pre-commit 钩子、pyproject.toml 的工具链配置。
|
||||||
|
- 卸载或降级既有依赖。
|
||||||
|
|
||||||
|
## 沟通风格
|
||||||
|
|
||||||
|
- 阶段切换时一句话说明即可;不要把内部推理写给用户看。
|
||||||
|
- 完成子任务后用一两句总结改了什么、下一步做什么。
|
||||||
|
- 遇到阻塞时直接说明:卡在哪、试了什么、需要用户做什么。
|
||||||
|
- **不在收尾时询问"是否需要提交"或"是否扩展范围"**——直接输出总结并结束。用户后续若有新需求,由用户主动提出。
|
||||||
|
- 不使用 emoji,除非用户明确要求。
|
||||||
|
|
||||||
|
## 工具使用
|
||||||
|
|
||||||
|
- 独立操作尽量并行调用(多个 Read/Grep/Glob 一批发出)。
|
||||||
|
- 用 `TaskCreate`/`TaskUpdate` 维护进度,不批量推迟标记。
|
||||||
|
- 长命令用后台运行(`run_in_background`),完成会自动通知。
|
||||||
|
- 文件操作一律用专用工具:Read/Edit/Write/Glob/Grep,不用 `cat`/`sed`/`grep`/`find`。
|
||||||
|
|
||||||
|
## 收尾
|
||||||
|
|
||||||
|
- 验收标准全部满足后,**直接输出最终总结并结束任务**:交付物、关键决策、遗留事项。
|
||||||
|
- **自动提交**:收尾时自动 `git add`(按文件名)+ `git commit`(遵循 `.trae/rules/git-commit-message.md` 风格)+ `git push`(仅当分支已跟踪远程时执行;新分支跳过 push 并在总结中说明);**不询问**"是否需要提交"或"是否扩展范围"。
|
||||||
|
- 若验收标准未全部满足,回到「计划」继续下一轮,不停下询问。
|
||||||
|
- 将本次会话的关键产出与决策更新到 memory,便于后续会话续接。
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
# 使用国内镜像源拉取基础镜像
|
||||||
|
# 备选镜像源前缀:docker.1ms.run / dockerpull.com / docker.xuanyuan.me
|
||||||
|
FROM docker.m.daocloud.io/python:3.13-slim
|
||||||
|
|
||||||
|
# 国内镜像源(清华)
|
||||||
|
ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
ENV PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
|
||||||
|
ENV UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
ENV UV_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
|
||||||
|
|
||||||
|
# 环境变量:非交互 + 路径配置
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
|
LANG=C.UTF-8 \
|
||||||
|
LC_ALL=C.UTF-8 \
|
||||||
|
UV_LINK_MODE=copy \
|
||||||
|
UV_CACHE_DIR=/uv-cache \
|
||||||
|
UV_PYTHON_INSTALL_DIR=/uv-python \
|
||||||
|
UV_PROJECT_ENVIRONMENT=/opt/venv \
|
||||||
|
PATH="/opt/venv/bin:${PATH}"
|
||||||
|
|
||||||
|
# 配置 apt 国内镜像(阿里云)并安装系统依赖
|
||||||
|
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
jq \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 配置 pip 国内镜像(阿里云)
|
||||||
|
RUN mkdir -p /etc/pip \
|
||||||
|
&& printf '[global]\nindex-url = https://mirrors.aliyun.com/pypi/simple/\ntrusted-host = mirrors.aliyun.com\n' \
|
||||||
|
> /etc/pip/pip.conf \
|
||||||
|
&& mkdir -p /root/.config/pip \
|
||||||
|
&& ln -sf /etc/pip/pip.conf /root/.config/pip/pip.conf
|
||||||
|
|
||||||
|
# 安装 uv 并预装 Python 3.8 / 3.13
|
||||||
|
RUN pip install --no-cache-dir uv -i https://mirrors.aliyun.com/pypi/simple/ \
|
||||||
|
&& uv python install 3.8 3.13
|
||||||
|
|
||||||
|
# 安装 Node.js 20.x(actions/checkout 需要)
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
||||||
|
apt-get install -y nodejs && \
|
||||||
|
node --version
|
||||||
|
|
||||||
|
# 预装项目 dev 依赖(仅复制依赖描述文件,利用 Docker 层缓存)
|
||||||
|
WORKDIR /workspace
|
||||||
|
COPY pyproject.toml tox.ini README.md ./
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
# 同步依赖到 /opt/venv(CI 时直接复用)
|
||||||
|
RUN uv sync --frozen --no-install-project 2>/dev/null || uv sync --no-install-project
|
||||||
|
|
||||||
|
# 预装 tox 环境(py38 + py313)
|
||||||
|
RUN uvx tox run -e py38,py313 --notest 2>/dev/null || true
|
||||||
|
|
||||||
|
# 持久化 uv 缓存目录(CI 可挂载到宿主机加速)
|
||||||
|
VOLUME ["/uv-cache"]
|
||||||
|
|
||||||
|
# 默认入口
|
||||||
|
CMD ["/bin/bash"]
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
[](https://github.com/gookeryoung/pyflowx/actions/workflows/ci.yml)
|
[](https://github.com/gookeryoung/pyflowx/actions/workflows/ci.yml)
|
||||||
[](https://pypi.org/project/pyflowx/)
|
[](https://pypi.org/project/pyflowx/)
|
||||||
[](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)
|
||||||
[](https://github.com/gookeryoung/pyflowx/blob/main/LICENSE)
|
[](https://github.com/gookeryoung/pyflowx/blob/main/LICENSE)
|
||||||
|
|
||||||
@@ -31,7 +32,8 @@ PyFlowX 把"任务依赖"这件事做到极致简单:**参数名就是依赖
|
|||||||
- **图级默认值** —— `GraphDefaults` 统一配置 retry/timeout/concurrency 等
|
- **图级默认值** —— `GraphDefaults` 统一配置 retry/timeout/concurrency 等
|
||||||
- **CLI 运行器** —— `CliRunner` 把多个图映射为命令行子命令,替代 Makefile
|
- **CLI 运行器** —— `CliRunner` 把多个图映射为命令行子命令,替代 Makefile
|
||||||
- **可观测** —— `on_event` 回调(RUNNING/SUCCESS/FAILED/SKIPPED)、`dry_run` 预览、`verbose` 生命周期日志、Mermaid 可视化
|
- **可观测** —— `on_event` 回调(RUNNING/SUCCESS/FAILED/SKIPPED)、`dry_run` 预览、`verbose` 生命周期日志、Mermaid 可视化
|
||||||
- **零运行时依赖** —— 仅依赖标准库(3.8 需 `graphlib_backport`)
|
- **YAML 任务编排** —— GitHub Actions 风格的声明式任务图,支持 `jobs`/`needs`/`strategy.matrix`/`if` 等 CI/CD 概念,从 YAML 文件直接加载执行
|
||||||
|
- **最小依赖** —— 仅依赖标准库 + PyYAML(3.8 需 `graphlib_backport`、`typing-extensions`)
|
||||||
- **97% 测试覆盖** —— 分支覆盖率 >= 95%
|
- **97% 测试覆盖** —— 分支覆盖率 >= 95%
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
@@ -300,31 +302,123 @@ runner.run_cli() # 解析 sys.argv 并执行
|
|||||||
命令行用法:
|
命令行用法:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python build.py clean # 执行 clean 图
|
pf pymake clean # 执行 clean 图
|
||||||
python build.py build --strategy thread # 覆盖执行策略
|
pf pymake build --strategy thread # 覆盖执行策略
|
||||||
python build.py test --dry-run # 仅打印执行计划
|
pf pymake test --dry-run # 仅打印执行计划
|
||||||
python build.py --list # 列出所有命令
|
pf pymake --list # 列出所有命令
|
||||||
python build.py --quiet # 静默模式
|
pf pymake --quiet # 静默模式
|
||||||
```
|
```
|
||||||
|
|
||||||
`verbose=True`(默认)时打印任务生命周期(开始/成功/失败/跳过)与命令输出;`--quiet` 关闭。
|
`verbose=True`(默认)时打印任务生命周期(开始/成功/失败/跳过)与命令输出;`--quiet` 关闭。
|
||||||
|
|
||||||
## 示例
|
## YAML 任务编排
|
||||||
|
|
||||||
仓库 `examples/` 目录包含完整示例:
|
PyFlowX 支持 GitHub Actions 风格的声明式 YAML 任务编排,从 YAML 文件直接加载任务图。
|
||||||
|
|
||||||
- [`etl_pipeline.py`](examples/etl_pipeline.py) —— ETL 流水线(sequential)
|
### 编程式 API
|
||||||
- [`parallel_run.py`](examples/parallel_run.py) —— 并行执行对比(thread vs sequential)
|
|
||||||
- [`async_aggregation.py`](examples/async_aggregation.py) —— 异步聚合 + Context 注入
|
|
||||||
|
|
||||||
运行:
|
```python
|
||||||
|
import pyflowx as px
|
||||||
|
|
||||||
|
# 从 YAML 文件加载任务图
|
||||||
|
graph = px.Graph.from_yaml("pipeline.yaml")
|
||||||
|
report = px.run(graph, strategy="thread")
|
||||||
|
|
||||||
|
# 或用函数式 API
|
||||||
|
graph = px.load_yaml("pipeline.yaml")
|
||||||
|
graph = px.parse_yaml_string("""
|
||||||
|
jobs:
|
||||||
|
hello:
|
||||||
|
cmd: ["echo", "hello"]
|
||||||
|
""")
|
||||||
|
```
|
||||||
|
|
||||||
|
### CLI 入口
|
||||||
|
|
||||||
|
通过 `pf` 统一入口调用(详见 [pf 工具](#cli-工具) 章节):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python examples/etl_pipeline.py
|
# 执行 YAML 任务图
|
||||||
python examples/parallel_run.py
|
pf yamlrun pipeline.yaml
|
||||||
python examples/async_aggregation.py
|
|
||||||
|
# 指定执行策略
|
||||||
|
pf yamlrun pipeline.yaml --strategy thread
|
||||||
|
|
||||||
|
# 仅打印任务分层,不执行
|
||||||
|
pf yamlrun pipeline.yaml --dry-run
|
||||||
|
|
||||||
|
# 列出所有任务名
|
||||||
|
pf yamlrun pipeline.yaml --list
|
||||||
|
|
||||||
|
# 静默模式
|
||||||
|
pf yamlrun pipeline.yaml --quiet
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### YAML 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
|
```python
|
||||||
@@ -441,12 +535,14 @@ uv run pytest --cov=pyflowx --cov-fail-under=95
|
|||||||
uv run mypy
|
uv run mypy
|
||||||
|
|
||||||
# 代码风格
|
# 代码风格
|
||||||
uv run ruff check src tests examples
|
uv run ruff check src tests
|
||||||
uv run ruff format --check src tests examples
|
uv run ruff format --check src tests
|
||||||
```
|
```
|
||||||
|
|
||||||
## 模块结构
|
## 模块结构
|
||||||
|
|
||||||
|
### 核心
|
||||||
|
|
||||||
| 模块 | 职责 |
|
| 模块 | 职责 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `task.py` | 纯数据结构:`TaskSpec`、`RetryPolicy`、`TaskHooks`、`TaskStatus` |
|
| `task.py` | 纯数据结构:`TaskSpec`、`RetryPolicy`、`TaskHooks`、`TaskStatus` |
|
||||||
@@ -455,11 +551,25 @@ uv run ruff format --check src tests examples
|
|||||||
| `context.py` | 上下文注入:参数名→依赖解析 |
|
| `context.py` | 上下文注入:参数名→依赖解析 |
|
||||||
| `command.py` | 命令执行:`run_command`(list/shell/Callable) |
|
| `command.py` | 命令执行:`run_command`(list/shell/Callable) |
|
||||||
| `conditions.py` | 条件执行:内置条件与组合器 |
|
| `conditions.py` | 条件执行:内置条件与组合器 |
|
||||||
| `executors.py` | 执行器与 `run` 入口:四种策略共享模块级辅助 |
|
| `executors.py` | 执行器与 `run` 入口:四种策略共享模块级辅助;verbose 统一应用到 spec |
|
||||||
| `storage.py` | 状态后端:`MemoryBackend` / `JSONBackend`(batch flush) |
|
| `storage.py` | 状态后端:`MemoryBackend` / `JSONBackend`(batch flush) |
|
||||||
| `runner.py` | CLI 运行器:`CliRunner` |
|
| `runner.py` | CLI 运行器:`CliRunner` |
|
||||||
| `report.py` | 运行结果:`RunReport` / `TaskResult` |
|
| `report.py` | 运行结果:`RunReport` / `TaskResult` |
|
||||||
|
| `yaml_loader.py` | YAML 任务编排:GitHub Actions 风格 schema 解析(`load_yaml` / `parse_yaml_string` / `run_cli`) |
|
||||||
|
| `registry.py` | 函数注册中心:`register_fn` / `get_fn` / `has_fn`(YAML 的 `fn:` 引用) |
|
||||||
|
| `profiling.py` | 性能分析:`Profiler` 任务耗时统计 |
|
||||||
| `errors.py` | 错误家族:`PyFlowXError` 子类 |
|
| `errors.py` | 错误家族:`PyFlowXError` 子类 |
|
||||||
|
| `ops/` | 工具函数(dev/files/llm/media/system),被 YAML 的 `fn:` 引用 |
|
||||||
|
|
||||||
|
### CLI 工具
|
||||||
|
|
||||||
|
| 模块 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `cli/pf.py` | 统一入口:`pf <tool> [command]`,自动发现 `configs/*.yaml` 并路由 |
|
||||||
|
| `configs/` | YAML 工具配置(clr/taskkill/which/msdownload/sglang/dockercmd/envdev 等) |
|
||||||
|
| `cli/yamlrun.py` | YAML pipeline 执行器,`pf yamlrun pipeline.yaml` 调用 |
|
||||||
|
| `cli/profiler.py` | 性能分析 CLI |
|
||||||
|
| `cli/emlmanager.py` | 邮件管理 CLI |
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
|
|||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
API 参考
|
||||||
|
========
|
||||||
|
|
||||||
|
任务描述
|
||||||
|
--------
|
||||||
|
|
||||||
|
.. autoclass:: pyflowx.TaskSpec
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
:exclude-members: args, kwargs
|
||||||
|
|
||||||
|
.. autoclass:: pyflowx.RetryPolicy
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
.. autoclass:: pyflowx.TaskHooks
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
.. autoclass:: pyflowx.TaskStatus
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
图构建
|
||||||
|
------
|
||||||
|
|
||||||
|
.. autoclass:: pyflowx.Graph
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:exclude-members: from_specs, from_yaml
|
||||||
|
|
||||||
|
.. autoclass:: pyflowx.GraphDefaults
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
.. autofunction:: pyflowx.compose
|
||||||
|
.. autofunction:: pyflowx.task_template
|
||||||
|
|
||||||
|
执行
|
||||||
|
----
|
||||||
|
|
||||||
|
.. autofunction:: pyflowx.run
|
||||||
|
|
||||||
|
.. autoclass:: pyflowx.RunReport
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
.. autoclass:: pyflowx.TaskResult
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
YAML 编排
|
||||||
|
---------
|
||||||
|
|
||||||
|
.. autofunction:: pyflowx.load_yaml
|
||||||
|
.. autofunction:: pyflowx.parse_yaml_string
|
||||||
|
.. autofunction:: pyflowx.run_yaml
|
||||||
|
.. autofunction:: pyflowx.run_cli
|
||||||
|
.. autofunction:: pyflowx.build_cli_parser
|
||||||
|
|
||||||
|
函数注册
|
||||||
|
--------
|
||||||
|
|
||||||
|
.. autofunction:: pyflowx.register_fn
|
||||||
|
.. autofunction:: pyflowx.get_fn
|
||||||
|
.. autofunction:: pyflowx.has_fn
|
||||||
|
|
||||||
|
命令执行
|
||||||
|
--------
|
||||||
|
|
||||||
|
.. autofunction:: pyflowx.run_command
|
||||||
|
|
||||||
|
CLI 运行器
|
||||||
|
----------
|
||||||
|
|
||||||
|
.. autoclass:: pyflowx.CliRunner
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
状态后端
|
||||||
|
--------
|
||||||
|
|
||||||
|
.. autoclass:: pyflowx.StateBackend
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
.. autoclass:: pyflowx.MemoryBackend
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
.. autoclass:: pyflowx.JSONBackend
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
错误家族
|
||||||
|
--------
|
||||||
|
|
||||||
|
.. autoexception:: pyflowx.PyFlowXError
|
||||||
|
.. autoexception:: pyflowx.DuplicateTaskError
|
||||||
|
.. autoexception:: pyflowx.MissingDependencyError
|
||||||
|
.. autoexception:: pyflowx.CycleError
|
||||||
|
.. autoexception:: pyflowx.TaskFailedError
|
||||||
|
.. autoexception:: pyflowx.TaskTimeoutError
|
||||||
|
.. autoexception:: pyflowx.InjectionError
|
||||||
|
.. autoexception:: pyflowx.StorageError
|
||||||
@@ -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 入口
|
||||||
+14
-32
@@ -13,7 +13,8 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"graphlib_backport >= 1.0.0; python_version < '3.9'",
|
"graphlib_backport >= 1.0.0; python_version < '3.9'",
|
||||||
"typing-extensions>=4.13.2; python_version < '3.10'",
|
"pyyaml>=6.0.1",
|
||||||
|
"typing-extensions>=4.13.2; python_version < '3.13'",
|
||||||
]
|
]
|
||||||
description = "Lightweight, type-safe DAG task scheduler with multi-strategy execution."
|
description = "Lightweight, type-safe DAG task scheduler with multi-strategy execution."
|
||||||
keywords = ["async", "dag", "scheduler", "task", "workflow"]
|
keywords = ["async", "dag", "scheduler", "task", "workflow"]
|
||||||
@@ -21,34 +22,13 @@ license = { text = "MIT" }
|
|||||||
name = "pyflowx"
|
name = "pyflowx"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
version = "0.3.0"
|
version = "0.4.7"
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
autofmt = "pyflowx.cli.autofmt:main"
|
emlman = "pyflowx.cli.emlmanager:main"
|
||||||
bumpversion = "pyflowx.cli.bumpversion:main"
|
pf = "pyflowx.cli.pf:main"
|
||||||
emlman = "pyflowx.cli.emlmanager:main"
|
pxp = "pyflowx.cli.profiler:main"
|
||||||
filedate = "pyflowx.cli.filedate:main"
|
yamlrun = "pyflowx.cli.yamlrun:main"
|
||||||
filelvl = "pyflowx.cli.filelevel:main"
|
|
||||||
foldback = "pyflowx.cli.folderback:main"
|
|
||||||
foldzip = "pyflowx.cli.folderzip:main"
|
|
||||||
gitt = "pyflowx.cli.gittool:main"
|
|
||||||
lscalc = "pyflowx.cli.lscalc:main"
|
|
||||||
msdown = "pyflowx.cli.llm.msdownload:main"
|
|
||||||
packtool = "pyflowx.cli.packtool:main"
|
|
||||||
pdftool = "pyflowx.cli.pdftool:main"
|
|
||||||
piptool = "pyflowx.cli.piptool:main"
|
|
||||||
pymake = "pyflowx.cli.pymake:main"
|
|
||||||
pxp = "pyflowx.cli.profiler:main"
|
|
||||||
reseticon = "pyflowx.cli.reseticoncache:main"
|
|
||||||
scrcap = "pyflowx.cli.screenshot:main"
|
|
||||||
sglang = "pyflowx.cli.llm.sglang:main"
|
|
||||||
sshcopy = "pyflowx.cli.sshcopyid:main"
|
|
||||||
# dev
|
|
||||||
envdev = "pyflowx.cli.dev.envdev:main"
|
|
||||||
# system
|
|
||||||
clr = "pyflowx.cli.system.clearscreen:main"
|
|
||||||
taskk = "pyflowx.cli.system.taskkill:main"
|
|
||||||
wch = "pyflowx.cli.system.which:main"
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -65,10 +45,9 @@ dev = [
|
|||||||
"ruff>=0.8.0",
|
"ruff>=0.8.0",
|
||||||
"tox-uv>=1.13.1",
|
"tox-uv>=1.13.1",
|
||||||
"tox>=4.25.0",
|
"tox>=4.25.0",
|
||||||
|
"types-PyYAML>=6.0.12",
|
||||||
]
|
]
|
||||||
llm = [
|
docs = ["myst-parser>=3.0", "sphinx-rtd-theme>=2.0", "sphinx>=7.0"]
|
||||||
"sglang[all]==0.5.10rc0; python_version >= '3.10' and sys_platform == 'linux'",
|
|
||||||
]
|
|
||||||
office = [
|
office = [
|
||||||
"pillow>=10.4.0",
|
"pillow>=10.4.0",
|
||||||
"pymupdf>=1.24.11",
|
"pymupdf>=1.24.11",
|
||||||
@@ -80,6 +59,9 @@ office = [
|
|||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
required-version = ">=0.5.0"
|
||||||
|
|
||||||
[[tool.uv.index]]
|
[[tool.uv.index]]
|
||||||
default = true
|
default = true
|
||||||
url = "https://mirrors.aliyun.com/pypi/simple/"
|
url = "https://mirrors.aliyun.com/pypi/simple/"
|
||||||
@@ -94,12 +76,12 @@ packages = ["src/pyflowx"]
|
|||||||
pyflowx = { workspace = true }
|
pyflowx = { workspace = true }
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["pyflowx[dev,office,llm]"]
|
dev = ["pyflowx[dev,docs,office]"]
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
branch = true
|
branch = true
|
||||||
concurrency = ["thread"]
|
concurrency = ["thread"]
|
||||||
omit = ["src/pyflowx/cli/*", "src/pyflowx/examples/*", "tests/*"]
|
omit = ["src/pyflowx/cli/*", "tests/*"]
|
||||||
source = ["pyflowx"]
|
source = ["pyflowx"]
|
||||||
|
|
||||||
[tool.coverage.report]
|
[tool.coverage.report]
|
||||||
|
|||||||
+13
-1
@@ -83,6 +83,7 @@ from .errors import (
|
|||||||
from .executors import Strategy, run
|
from .executors import Strategy, run
|
||||||
from .graph import Graph, GraphDefaults
|
from .graph import Graph, GraphDefaults
|
||||||
from .profiling import ProfileReport, TaskProfile
|
from .profiling import ProfileReport, TaskProfile
|
||||||
|
from .registry import FnRegistry, get_fn, has_fn, register_fn
|
||||||
from .report import RunReport
|
from .report import RunReport
|
||||||
from .runner import CliExitCode, CliRunner
|
from .runner import CliExitCode, CliRunner
|
||||||
from .storage import JSONBackend, MemoryBackend, StateBackend
|
from .storage import JSONBackend, MemoryBackend, StateBackend
|
||||||
@@ -99,8 +100,9 @@ from .task import (
|
|||||||
task,
|
task,
|
||||||
task_template,
|
task_template,
|
||||||
)
|
)
|
||||||
|
from .yaml_loader import YamlLoadError, build_cli_parser, load_yaml, parse_yaml_string, run_cli, run_yaml
|
||||||
|
|
||||||
__version__ = "0.4.0"
|
__version__ = "0.4.7"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"IS_LINUX",
|
"IS_LINUX",
|
||||||
@@ -116,6 +118,7 @@ __all__ = [
|
|||||||
"Context",
|
"Context",
|
||||||
"CycleError",
|
"CycleError",
|
||||||
"DuplicateTaskError",
|
"DuplicateTaskError",
|
||||||
|
"FnRegistry",
|
||||||
"Graph",
|
"Graph",
|
||||||
"GraphComposer",
|
"GraphComposer",
|
||||||
"GraphDefaults",
|
"GraphDefaults",
|
||||||
@@ -139,12 +142,21 @@ __all__ = [
|
|||||||
"TaskSpec",
|
"TaskSpec",
|
||||||
"TaskStatus",
|
"TaskStatus",
|
||||||
"TaskTimeoutError",
|
"TaskTimeoutError",
|
||||||
|
"YamlLoadError",
|
||||||
"build_call_args",
|
"build_call_args",
|
||||||
|
"build_cli_parser",
|
||||||
"cmd",
|
"cmd",
|
||||||
"compose",
|
"compose",
|
||||||
"describe_injection",
|
"describe_injection",
|
||||||
|
"get_fn",
|
||||||
|
"has_fn",
|
||||||
|
"load_yaml",
|
||||||
|
"parse_yaml_string",
|
||||||
|
"register_fn",
|
||||||
"run",
|
"run",
|
||||||
|
"run_cli",
|
||||||
"run_command",
|
"run_command",
|
||||||
|
"run_yaml",
|
||||||
"task",
|
"task",
|
||||||
"task_template",
|
"task_template",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,282 +0,0 @@
|
|||||||
"""自动格式化工具模块.
|
|
||||||
|
|
||||||
提供 Python 代码自动格式化的常用功能封装,
|
|
||||||
支持 docstring 自动生成、pyproject.toml 配置同步等功能.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import ast
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pyflowx as px
|
|
||||||
|
|
||||||
try:
|
|
||||||
import tomllib # noqa: F401
|
|
||||||
|
|
||||||
HAS_TOMLLIB = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_TOMLLIB = False
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 配置
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
IGNORE_PATTERNS = [
|
|
||||||
"__pycache__",
|
|
||||||
"*.pyc",
|
|
||||||
"*.pyo",
|
|
||||||
".git",
|
|
||||||
".venv",
|
|
||||||
".idea",
|
|
||||||
".vscode",
|
|
||||||
"*.egg-info",
|
|
||||||
"dist",
|
|
||||||
"build",
|
|
||||||
".pytest_cache",
|
|
||||||
".tox",
|
|
||||||
".mypy_cache",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 辅助函数
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def format_with_ruff(target: Path, fix: bool = True) -> None:
|
|
||||||
"""使用 ruff 格式化代码.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
target : Path
|
|
||||||
目标路径
|
|
||||||
fix : bool
|
|
||||||
是否自动修复
|
|
||||||
"""
|
|
||||||
cmd = ["ruff", "format", str(target)]
|
|
||||||
if fix:
|
|
||||||
cmd.append("--fix")
|
|
||||||
|
|
||||||
subprocess.run(cmd, check=True)
|
|
||||||
print(f"ruff format 完成: {target}")
|
|
||||||
|
|
||||||
|
|
||||||
def lint_with_ruff(target: Path, fix: bool = True) -> None:
|
|
||||||
"""使用 ruff 检查代码.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
target : Path
|
|
||||||
目标路径
|
|
||||||
fix : bool
|
|
||||||
是否自动修复
|
|
||||||
"""
|
|
||||||
cmd = ["ruff", "check", str(target)]
|
|
||||||
if fix:
|
|
||||||
cmd.extend(["--fix", "--unsafe-fixes"])
|
|
||||||
|
|
||||||
subprocess.run(cmd, check=True)
|
|
||||||
print(f"ruff check 完成: {target}")
|
|
||||||
|
|
||||||
|
|
||||||
def add_docstring(file_path: Path, docstring: str) -> bool:
|
|
||||||
"""为文件添加 docstring.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
file_path : Path
|
|
||||||
文件路径
|
|
||||||
docstring : str
|
|
||||||
docstring 内容
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
bool
|
|
||||||
是否成功添加
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
content = file_path.read_text(encoding="utf-8")
|
|
||||||
tree = ast.parse(content)
|
|
||||||
|
|
||||||
# 检查是否已有 docstring
|
|
||||||
first_node = tree.body[0] if tree.body else None
|
|
||||||
if first_node and isinstance(first_node, ast.Expr) and isinstance(first_node.value, ast.Constant):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 添加 docstring
|
|
||||||
lines = content.splitlines()
|
|
||||||
doc_lines = docstring.splitlines()
|
|
||||||
doc_lines.append("")
|
|
||||||
new_content = "\n".join(doc_lines + lines)
|
|
||||||
|
|
||||||
file_path.write_text(new_content, encoding="utf-8")
|
|
||||||
print(f"添加 docstring: {file_path}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except (OSError, UnicodeDecodeError, SyntaxError) as e:
|
|
||||||
print(f"处理失败: {file_path} - {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def generate_module_docstring(file_path: Path) -> str:
|
|
||||||
"""生成模块 docstring.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
file_path : Path
|
|
||||||
文件路径
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
str
|
|
||||||
生成的 docstring
|
|
||||||
"""
|
|
||||||
stem = file_path.stem
|
|
||||||
parent = file_path.parent.name
|
|
||||||
|
|
||||||
# 关键词匹配
|
|
||||||
keywords = {
|
|
||||||
"cli": f"Command-line interface for {parent}",
|
|
||||||
"gui": f"Graphical user interface for {parent}",
|
|
||||||
"core": f"Core functionality for {parent}",
|
|
||||||
"util": f"Utility functions for {parent}",
|
|
||||||
"model": f"Data models for {parent}",
|
|
||||||
"test": f"Tests for {parent}",
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, desc in keywords.items():
|
|
||||||
if key in stem.lower():
|
|
||||||
return f'"""{desc}."""'
|
|
||||||
|
|
||||||
return f'"""{stem.replace("_", " ").title()} module."""'
|
|
||||||
|
|
||||||
|
|
||||||
def auto_add_docstrings(root_dir: Path) -> int:
|
|
||||||
"""自动为所有 Python 文件添加 docstring.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
root_dir : Path
|
|
||||||
根目录
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
int
|
|
||||||
添加的 docstring 数量
|
|
||||||
"""
|
|
||||||
count = 0
|
|
||||||
for py_file in root_dir.rglob("*.py"):
|
|
||||||
# 跳过忽略的文件
|
|
||||||
if any(pattern in str(py_file) for pattern in IGNORE_PATTERNS):
|
|
||||||
continue
|
|
||||||
|
|
||||||
docstring = generate_module_docstring(py_file)
|
|
||||||
if add_docstring(py_file, docstring):
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
print(f"共添加 {count} 个 docstring")
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
def sync_pyproject_config(root_dir: Path) -> None:
|
|
||||||
"""同步 pyproject.toml 配置到子项目.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
root_dir : Path
|
|
||||||
根目录
|
|
||||||
"""
|
|
||||||
main_toml = root_dir / "pyproject.toml"
|
|
||||||
if not main_toml.exists():
|
|
||||||
print(f"主项目配置文件不存在: {main_toml}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 查找所有子项目的 pyproject.toml
|
|
||||||
sub_tomls = [p for p in root_dir.rglob("pyproject.toml") if p != main_toml and ".venv" not in str(p)]
|
|
||||||
|
|
||||||
if not sub_tomls:
|
|
||||||
print("没有找到子项目的 pyproject.toml")
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"找到 {len(sub_tomls)} 个子项目配置文件")
|
|
||||||
|
|
||||||
# 对每个子项目调用 ruff format
|
|
||||||
for sub_toml in sub_tomls:
|
|
||||||
subprocess.run(["ruff", "format", str(sub_toml)], check=False)
|
|
||||||
|
|
||||||
print("配置同步完成")
|
|
||||||
|
|
||||||
|
|
||||||
def format_all(root_dir: Path) -> None:
|
|
||||||
"""格式化所有 Python 文件.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
root_dir : Path
|
|
||||||
根目录
|
|
||||||
"""
|
|
||||||
# 使用 ruff format
|
|
||||||
subprocess.run(["ruff", "format", str(root_dir)], check=True)
|
|
||||||
|
|
||||||
# 使用 ruff check
|
|
||||||
subprocess.run(["ruff", "check", "--fix", "--unsafe-fixes", str(root_dir)], check=True)
|
|
||||||
|
|
||||||
print(f"格式化完成: {root_dir}")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# CLI Runner
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
"""自动格式化工具主函数."""
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="AutoFmt - 自动格式化工具",
|
|
||||||
usage="autofmt <command> [options]",
|
|
||||||
)
|
|
||||||
subparsers = parser.add_subparsers(dest="command", help="可用命令")
|
|
||||||
|
|
||||||
# ruff format 命令
|
|
||||||
format_parser = subparsers.add_parser("fmt", help="使用 ruff 格式化代码")
|
|
||||||
format_parser.add_argument("--target", type=str, default=".", help="目标路径")
|
|
||||||
|
|
||||||
# ruff check 命令
|
|
||||||
lint_parser = subparsers.add_parser("lint", help="使用 ruff 检查代码")
|
|
||||||
lint_parser.add_argument("--target", type=str, default=".", help="目标路径")
|
|
||||||
lint_parser.add_argument("--fix", action="store_true", help="自动修复")
|
|
||||||
|
|
||||||
# 自动添加 docstring 命令
|
|
||||||
doc_parser = subparsers.add_parser("doc", help="自动添加 docstring")
|
|
||||||
doc_parser.add_argument("--root-dir", type=str, default=".", help="根目录")
|
|
||||||
|
|
||||||
# 同步配置命令
|
|
||||||
sync_parser = subparsers.add_parser("sync", help="同步 pyproject.toml 配置")
|
|
||||||
sync_parser.add_argument("--root-dir", type=str, default=".", help="根目录")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.command == "fmt":
|
|
||||||
graph = px.Graph.from_specs([px.TaskSpec("ruff_format", cmd=["ruff", "format", args.target], verbose=True)])
|
|
||||||
elif args.command == "lint":
|
|
||||||
cmd = ["ruff", "check", args.target]
|
|
||||||
if args.fix:
|
|
||||||
cmd.extend(["--fix", "--unsafe-fixes"])
|
|
||||||
graph = px.Graph.from_specs([px.TaskSpec("ruff_check", cmd=cmd, verbose=True)])
|
|
||||||
elif args.command == "doc":
|
|
||||||
graph = px.Graph.from_specs([
|
|
||||||
px.TaskSpec("auto_docstring", fn=auto_add_docstrings, args=(Path(args.root_dir),), verbose=True)
|
|
||||||
])
|
|
||||||
elif args.command == "sync":
|
|
||||||
graph = px.Graph.from_specs([
|
|
||||||
px.TaskSpec("sync_config", fn=sync_pyproject_config, args=(Path(args.root_dir),), verbose=True)
|
|
||||||
])
|
|
||||||
else:
|
|
||||||
parser.print_help()
|
|
||||||
return
|
|
||||||
|
|
||||||
px.run(graph, strategy="thread")
|
|
||||||
@@ -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,331 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Literal, get_args
|
|
||||||
|
|
||||||
import pyflowx as px
|
|
||||||
from pyflowx.conditions import BuiltinConditions
|
|
||||||
from pyflowx.tasks.system import setenv_group, write_file
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Mirror 配置
|
|
||||||
# ============================================================================
|
|
||||||
DOWNLOAD_MIRROR_SCRIPT: str = "curl -sSL https://linuxmirrors.cn/main.sh -o /tmp/linuxmirrors.sh"
|
|
||||||
INSTALL_MIRROR_SCRIPT: str = "sudo bash /tmp/linuxmirrors.sh"
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Python 配置
|
|
||||||
# ============================================================================
|
|
||||||
PyMirrorType = Literal["tsinghua", "aliyun", "huaweicloud", "ustc", "zju"]
|
|
||||||
|
|
||||||
PIP_INDEX_URLS: dict[PyMirrorType, str] = {
|
|
||||||
"tsinghua": "https://pypi.tuna.tsinghua.edu.cn/simple",
|
|
||||||
"aliyun": "https://mirrors.aliyun.com/pypi/simple/",
|
|
||||||
"huaweicloud": "https://mirrors.huaweicloud.com/repository/pypi/simple/",
|
|
||||||
"ustc": "https://pypi.mirrors.ustc.edu.cn/simple/",
|
|
||||||
"zju": "https://mirrors.zju.edu.cn/pypi/simple/",
|
|
||||||
}
|
|
||||||
|
|
||||||
PIP_TRUSTED_HOSTS: dict[PyMirrorType, str] = {
|
|
||||||
"tsinghua": "pypi.tuna.tsinghua.edu.cn",
|
|
||||||
"aliyun": "mirrors.aliyun.com",
|
|
||||||
"huaweicloud": "mirrors.huaweicloud.com",
|
|
||||||
"ustc": "pypi.mirrors.ustc.edu.cn",
|
|
||||||
"zju": "mirrors.zju.edu.cn",
|
|
||||||
}
|
|
||||||
PIP_CONFIG_PATH = Path.home() / ".pip" / "pip.conf" if BuiltinConditions.IS_LINUX() else Path.home() / "pip" / "pip.ini"
|
|
||||||
|
|
||||||
UV_INDEX_URLS = PIP_INDEX_URLS
|
|
||||||
UV_PYTHON_INSTALL_MIRROR: str = "https://registry.npmmirror.com/-/binary/python-build-standalone"
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Conda 配置
|
|
||||||
# ============================================================================
|
|
||||||
CondaMirrorType = Literal["tsinghua", "ustc", "bsfu", "aliyun"]
|
|
||||||
|
|
||||||
CONDA_MIRROR_URLS: dict[CondaMirrorType, list[str]] = {
|
|
||||||
"tsinghua": [
|
|
||||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/",
|
|
||||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/",
|
|
||||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/r/",
|
|
||||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/msys2/",
|
|
||||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/pro/",
|
|
||||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/",
|
|
||||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/bioconda/",
|
|
||||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/menpo/",
|
|
||||||
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/pytorch/",
|
|
||||||
],
|
|
||||||
"ustc": [
|
|
||||||
"https://mirrors.ustc.edu.cn/anaconda/pkgs/main/",
|
|
||||||
"https://mirrors.ustc.edu.cn/anaconda/pkgs/free/",
|
|
||||||
"https://mirrors.ustc.edu.cn/anaconda/pkgs/r/",
|
|
||||||
"https://mirrors.ustc.edu.cn/anaconda/pkgs/msys2/",
|
|
||||||
"https://mirrors.ustc.edu.cn/anaconda/pkgs/pro/",
|
|
||||||
"https://mirrors.ustc.edu.cn/anaconda/pkgs/dev/",
|
|
||||||
"https://mirrors.ustc.edu.cn/anaconda/cloud/conda-forge/",
|
|
||||||
"https://mirrors.ustc.edu.cn/anaconda/cloud/bioconda/",
|
|
||||||
"https://mirrors.ustc.edu.cn/anaconda/cloud/menpo/",
|
|
||||||
"https://mirrors.ustc.edu.cn/anaconda/cloud/pytorch/",
|
|
||||||
],
|
|
||||||
"bsfu": [
|
|
||||||
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/main/",
|
|
||||||
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/free/",
|
|
||||||
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/r/",
|
|
||||||
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/msys2/",
|
|
||||||
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/pro/",
|
|
||||||
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/dev/",
|
|
||||||
"https://mirrors.bsfu.edu.cn/anaconda/cloud/conda-forge/",
|
|
||||||
"https://mirrors.bsfu.edu.cn/anaconda/cloud/bioconda/",
|
|
||||||
"https://mirrors.bsfu.edu.cn/anaconda/cloud/menpo/",
|
|
||||||
"https://mirrors.bsfu.edu.cn/anaconda/cloud/pytorch/",
|
|
||||||
],
|
|
||||||
"aliyun": [
|
|
||||||
"https://mirrors.aliyun.com/anaconda/pkgs/main/",
|
|
||||||
"https://mirrors.aliyun.com/anaconda/pkgs/free/",
|
|
||||||
"https://mirrors.aliyun.com/anaconda/pkgs/r/",
|
|
||||||
"https://mirrors.aliyun.com/anaconda/pkgs/msys2/",
|
|
||||||
"https://mirrors.aliyun.com/anaconda/pkgs/pro/",
|
|
||||||
"https://mirrors.aliyun.com/anaconda/pkgs/dev/",
|
|
||||||
"https://mirrors.aliyun.com/anaconda/cloud/conda-forge/",
|
|
||||||
"https://mirrors.aliyun.com/anaconda/cloud/bioconda/",
|
|
||||||
"https://mirrors.aliyun.com/anaconda/cloud/menpo/",
|
|
||||||
"https://mirrors.aliyun.com/anaconda/cloud/pytorch/",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
CONDA_CONFIG_PATH = Path.home() / ".condarc"
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Qt 配置
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
QT_LIBS: list[str] = [
|
|
||||||
"build-essential",
|
|
||||||
"libgl1",
|
|
||||||
"libegl1",
|
|
||||||
"libglib2.0-0",
|
|
||||||
"libfontconfig1",
|
|
||||||
"libfreetype6",
|
|
||||||
"libxkbcommon0",
|
|
||||||
"libdbus-1-3",
|
|
||||||
"libxcb-xinerama0",
|
|
||||||
"libxcb-icccm4",
|
|
||||||
"libxcb-image0",
|
|
||||||
"libxcb-keysyms1",
|
|
||||||
"libxcb-randr0",
|
|
||||||
"libxcb-render-util0",
|
|
||||||
"libxcb-shape0",
|
|
||||||
"libxcb-xfixes0",
|
|
||||||
"libxcb-cursor0",
|
|
||||||
]
|
|
||||||
|
|
||||||
CHINESE_FONTS: list[str] = [
|
|
||||||
"fonts-noto-cjk",
|
|
||||||
"fonts-wqy-microhei",
|
|
||||||
"fonts-wqy-zenhei",
|
|
||||||
"fonts-noto-color-emoji",
|
|
||||||
]
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Rust 配置
|
|
||||||
# ============================================================================
|
|
||||||
RustMirrorType = Literal["tsinghua", "ustc", "aliyun"]
|
|
||||||
RustVersionType = Literal["stable", "nightly", "beta"]
|
|
||||||
DEFAULT_RUST_VERSION: RustVersionType = "stable"
|
|
||||||
DEFAULT_MIRROR: RustMirrorType = "tsinghua"
|
|
||||||
|
|
||||||
RUSTUP_MIRRORS: dict[RustMirrorType, dict[str, str]] = {
|
|
||||||
"tsinghua": {
|
|
||||||
"RUSTUP_DIST_SERVER": "https://mirrors.tuna.tsinghua.edu.cn/rustup",
|
|
||||||
"RUSTUP_UPDATE_ROOT": "https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup",
|
|
||||||
"TOML_REGISTRY": "https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/",
|
|
||||||
},
|
|
||||||
"aliyun": {
|
|
||||||
"RUSTUP_DIST_SERVER": "https://mirrors.aliyun.com/rustup",
|
|
||||||
"RUSTUP_UPDATE_ROOT": "https://mirrors.aliyun.com/rustup/rustup",
|
|
||||||
"TOML_REGISTRY": "https://mirrors.aliyun.com/crates.io-index/",
|
|
||||||
},
|
|
||||||
"ustc": {
|
|
||||||
"RUSTUP_DIST_SERVER": "https://mirrors.ustc.edu.cn/rust-static",
|
|
||||||
"RUSTUP_UPDATE_ROOT": "https://mirrors.ustc.edu.cn/rust-static/rustup",
|
|
||||||
"TOML_REGISTRY": "https://mirrors.ustc.edu.cn/crates.io-index/",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
RUSTUP_DOWNLOAD_URL_LINUX = "https://mirrors.aliyun.com/repo/rust/rustup-init.sh"
|
|
||||||
RUSTUP_DOWNLOAD_URL_WINDOWS = "https://static.rust-lang.org/rustup/dist/x86_64-pc-windows-msvc/rustup-init.exe"
|
|
||||||
RUST_CONFIG_PATH = Path.home() / ".cargo" / "config.toml"
|
|
||||||
RUST_SCCACHE_DIR: Path = Path.home() / ".cargo" / "sccache"
|
|
||||||
RUST_SCCACHE_CACHE_SIZE: str = "20G"
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
"""主函数."""
|
|
||||||
parser = argparse.ArgumentParser(description="环境开发工具")
|
|
||||||
parser.add_argument(
|
|
||||||
"--python-mirror",
|
|
||||||
nargs="?",
|
|
||||||
type=str,
|
|
||||||
default="tsinghua",
|
|
||||||
choices=get_args(PyMirrorType),
|
|
||||||
help="Python 镜像源",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--conda-mirror",
|
|
||||||
nargs="?",
|
|
||||||
type=str,
|
|
||||||
default="tsinghua",
|
|
||||||
choices=get_args(CondaMirrorType),
|
|
||||||
help="Conda 镜镜像源",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--rust-mirror",
|
|
||||||
nargs="?",
|
|
||||||
type=str,
|
|
||||||
default=DEFAULT_MIRROR,
|
|
||||||
choices=get_args(RustMirrorType),
|
|
||||||
help="Rust 镜像源",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--rust-version",
|
|
||||||
nargs="?",
|
|
||||||
type=str,
|
|
||||||
default=DEFAULT_RUST_VERSION,
|
|
||||||
choices=get_args(RustVersionType),
|
|
||||||
help=f"Rust 版本, 推荐: {get_args(RustVersionType)}",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
python_mirror = args.python_mirror
|
|
||||||
conda_mirror_urls = CONDA_MIRROR_URLS[args.conda_mirror]
|
|
||||||
rust_mirror = args.rust_mirror
|
|
||||||
rust_version = args.rust_version
|
|
||||||
|
|
||||||
# 确保配置文件目录存在
|
|
||||||
PIP_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
CONDA_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
RUST_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
RUST_SCCACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# 使用 conditions 自动控制任务执行
|
|
||||||
graph = px.Graph.from_specs([
|
|
||||||
# 系统镜像配置(仅 Linux 且未配置国内镜像)
|
|
||||||
px.TaskSpec(
|
|
||||||
"download_mirror",
|
|
||||||
cmd=DOWNLOAD_MIRROR_SCRIPT,
|
|
||||||
conditions=(
|
|
||||||
BuiltinConditions.IS_LINUX(),
|
|
||||||
BuiltinConditions.NOT(
|
|
||||||
BuiltinConditions.OR(
|
|
||||||
*[
|
|
||||||
BuiltinConditions.FILE_CONTENT_EXISTS(f, m)
|
|
||||||
for f in [
|
|
||||||
"/etc/apt/sources.list",
|
|
||||||
"/etc/apt/sources.list.d/ubuntu.sources",
|
|
||||||
]
|
|
||||||
for m in get_args(PyMirrorType)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
),
|
|
||||||
),
|
|
||||||
verbose=True,
|
|
||||||
),
|
|
||||||
px.TaskSpec(
|
|
||||||
"install_mirror",
|
|
||||||
cmd=INSTALL_MIRROR_SCRIPT,
|
|
||||||
depends_on=("download_mirror",),
|
|
||||||
verbose=True,
|
|
||||||
),
|
|
||||||
# 安装 Qt 依赖(仅 Linux)
|
|
||||||
px.TaskSpec(
|
|
||||||
"install_qt_libs",
|
|
||||||
cmd=["sudo", "apt", "install", "-y", *QT_LIBS],
|
|
||||||
conditions=(BuiltinConditions.IS_LINUX(),),
|
|
||||||
depends_on=("install_mirror",),
|
|
||||||
allow_upstream_skip=True,
|
|
||||||
verbose=True,
|
|
||||||
),
|
|
||||||
# 安装中文字体(仅 Linux)
|
|
||||||
px.TaskSpec(
|
|
||||||
"install_fonts",
|
|
||||||
cmd=["sudo", "apt", "install", "-y", *CHINESE_FONTS],
|
|
||||||
conditions=(BuiltinConditions.IS_LINUX(),),
|
|
||||||
depends_on=("install_mirror",),
|
|
||||||
allow_upstream_skip=True,
|
|
||||||
verbose=True,
|
|
||||||
),
|
|
||||||
# 设置 Python 环境变量
|
|
||||||
*setenv_group({
|
|
||||||
"PIP_INDEX_URL": PIP_INDEX_URLS[python_mirror],
|
|
||||||
"PIP_TRUSTED_HOSTS": PIP_TRUSTED_HOSTS[python_mirror],
|
|
||||||
"UV_INDEX_URL": UV_INDEX_URLS[python_mirror],
|
|
||||||
"UV_PYTHON_INSTALL_MIRROR": UV_PYTHON_INSTALL_MIRROR,
|
|
||||||
"UV_HTTP_TIMEOUT": "600",
|
|
||||||
"UV_LINK_MODE": "copy",
|
|
||||||
}),
|
|
||||||
# 写入 Python 配置(仅当未配置)
|
|
||||||
write_file(
|
|
||||||
str(PIP_CONFIG_PATH),
|
|
||||||
f"[global]\nindex-url = {PIP_INDEX_URLS[python_mirror]}\ntrusted-host = {PIP_TRUSTED_HOSTS[python_mirror]}",
|
|
||||||
),
|
|
||||||
# 写入 Conda 配置(仅当未配置)
|
|
||||||
write_file(
|
|
||||||
str(CONDA_CONFIG_PATH),
|
|
||||||
"show_channel_urls: true\nchannels:\n - " + "\n - ".join(conda_mirror_urls) + "\n - defaults",
|
|
||||||
),
|
|
||||||
# 设置 Rust 镜像源
|
|
||||||
*setenv_group({
|
|
||||||
"RUSTUP_DIST_SERVER": RUSTUP_MIRRORS[rust_mirror]["RUSTUP_DIST_SERVER"],
|
|
||||||
"RUSTUP_UPDATE_ROOT": RUSTUP_MIRRORS[rust_mirror]["RUSTUP_UPDATE_ROOT"],
|
|
||||||
"RUST_SCCACHE_DIR": str(RUST_SCCACHE_DIR),
|
|
||||||
"RUST_SCCACHE_CACHE_SIZE": RUST_SCCACHE_CACHE_SIZE,
|
|
||||||
}),
|
|
||||||
# 写入 Rust 配置(仅当未配置)
|
|
||||||
write_file(
|
|
||||||
str(RUST_CONFIG_PATH),
|
|
||||||
f"""
|
|
||||||
[source.crates-io]
|
|
||||||
replace-with = '{rust_mirror}'
|
|
||||||
|
|
||||||
[source.{rust_mirror}]
|
|
||||||
registry = "sparse+{RUSTUP_MIRRORS[rust_mirror]["TOML_REGISTRY"]}"
|
|
||||||
|
|
||||||
[registries.{rust_mirror}]
|
|
||||||
index = "sparse+{RUSTUP_MIRRORS[rust_mirror]["TOML_REGISTRY"]}"
|
|
||||||
""",
|
|
||||||
),
|
|
||||||
# 下载 Rustup 安装脚本
|
|
||||||
px.TaskSpec(
|
|
||||||
"download_rustup",
|
|
||||||
cmd=["curl", "-fsSL", RUSTUP_DOWNLOAD_URL_LINUX, "-o", "rustup-init.sh"],
|
|
||||||
conditions=(BuiltinConditions.IS_LINUX(), BuiltinConditions.NOT(BuiltinConditions.HAS_INSTALLED("rustup"))),
|
|
||||||
verbose=True,
|
|
||||||
),
|
|
||||||
px.TaskSpec(
|
|
||||||
"download_rustup_win",
|
|
||||||
cmd=[
|
|
||||||
"powershell",
|
|
||||||
"-Command",
|
|
||||||
"Invoke-WebRequest",
|
|
||||||
"-Uri",
|
|
||||||
RUSTUP_DOWNLOAD_URL_WINDOWS,
|
|
||||||
"-OutFile",
|
|
||||||
"rustup-init.exe",
|
|
||||||
],
|
|
||||||
conditions=(
|
|
||||||
BuiltinConditions.IS_WINDOWS(),
|
|
||||||
BuiltinConditions.NOT(BuiltinConditions.HAS_INSTALLED("rustup")),
|
|
||||||
),
|
|
||||||
verbose=True,
|
|
||||||
),
|
|
||||||
# 安装 Rust 工具链
|
|
||||||
px.TaskSpec(
|
|
||||||
"install_rust",
|
|
||||||
cmd=["rustup", "toolchain", "install", rust_version],
|
|
||||||
conditions=(BuiltinConditions.HAS_INSTALLED("rustup"),),
|
|
||||||
depends_on=("setenv_rustup_dist_server",),
|
|
||||||
allow_upstream_skip=True,
|
|
||||||
verbose=True,
|
|
||||||
),
|
|
||||||
])
|
|
||||||
px.run(graph, strategy="thread", verbose=True)
|
|
||||||
@@ -567,13 +567,15 @@ class EmlManagerHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
emails = self.db.search_emails(keyword, field, limit, offset)
|
emails = self.db.search_emails(keyword, field, limit, offset)
|
||||||
total_count = self.db.get_email_count()
|
total_count = self.db.get_email_count()
|
||||||
self._send_json_response({
|
self._send_json_response(
|
||||||
"emails": emails,
|
{
|
||||||
"count": len(emails),
|
"emails": emails,
|
||||||
"total": total_count,
|
"count": len(emails),
|
||||||
"limit": limit,
|
"total": total_count,
|
||||||
"offset": offset,
|
"limit": limit,
|
||||||
})
|
"offset": offset,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def _api_get_email(self, query_params: dict[str, list[str]]) -> None:
|
def _api_get_email(self, query_params: dict[str, list[str]]) -> None:
|
||||||
"""API: 获取单个邮件详情."""
|
"""API: 获取单个邮件详情."""
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
"""文件日期处理工具.
|
|
||||||
|
|
||||||
自动检测文件名的日期前缀,
|
|
||||||
并根据文件的实际创建或修改时间重命名文件.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pyflowx as px
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 配置
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
DATE_PATTERN = re.compile(r"(20|19)\d{2}[-_#.~]?((0[1-9])|(1[012]))[-_#.~]?((0[1-9])|([12]\d)|(3[01]))[-_#.~]?")
|
|
||||||
SEP = "_"
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 辅助函数
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def get_file_timestamp(filepath: Path) -> str:
|
|
||||||
"""获取文件时间戳."""
|
|
||||||
modified_time = filepath.stat().st_mtime
|
|
||||||
created_time = filepath.stat().st_ctime
|
|
||||||
return time.strftime("%Y%m%d", time.localtime(max((modified_time, created_time))))
|
|
||||||
|
|
||||||
|
|
||||||
def remove_date_prefix(filepath: Path) -> Path:
|
|
||||||
"""移除文件日期前缀."""
|
|
||||||
stem = filepath.stem
|
|
||||||
new_stem = DATE_PATTERN.sub("", stem)
|
|
||||||
if new_stem != stem:
|
|
||||||
new_path = filepath.with_name(new_stem + filepath.suffix)
|
|
||||||
filepath.rename(new_path)
|
|
||||||
return new_path
|
|
||||||
return filepath
|
|
||||||
|
|
||||||
|
|
||||||
def add_date_prefix(filepath: Path) -> Path:
|
|
||||||
"""添加文件日期前缀."""
|
|
||||||
timestamp = get_file_timestamp(filepath)
|
|
||||||
stem = filepath.stem
|
|
||||||
new_stem = f"{timestamp}{SEP}{stem}"
|
|
||||||
new_path = filepath.with_name(new_stem + filepath.suffix)
|
|
||||||
if new_path != filepath:
|
|
||||||
filepath.rename(new_path)
|
|
||||||
return new_path
|
|
||||||
return filepath
|
|
||||||
|
|
||||||
|
|
||||||
def process_file_date(filepath: Path, clear: bool = False) -> None:
|
|
||||||
"""处理单个文件的日期前缀.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
filepath : Path
|
|
||||||
文件路径
|
|
||||||
clear : bool
|
|
||||||
是否清除日期前缀
|
|
||||||
"""
|
|
||||||
if clear:
|
|
||||||
remove_date_prefix(filepath)
|
|
||||||
else:
|
|
||||||
# 先移除旧日期前缀,再添加新日期前缀
|
|
||||||
new_path = remove_date_prefix(filepath)
|
|
||||||
add_date_prefix(new_path)
|
|
||||||
|
|
||||||
|
|
||||||
def process_files_date(targets: list[Path], clear: bool = False) -> None:
|
|
||||||
"""批量处理文件日期前缀.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
targets : list[Path]
|
|
||||||
文件路径列表
|
|
||||||
clear : bool
|
|
||||||
是否清除日期前缀
|
|
||||||
"""
|
|
||||||
for target in targets:
|
|
||||||
if target.exists() and not target.name.startswith("."):
|
|
||||||
process_file_date(target, clear)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# CLI Runner
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
"""文件日期处理工具主函数."""
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="FileDate - 文件日期处理工具",
|
|
||||||
usage="filedate <command> [options]",
|
|
||||||
)
|
|
||||||
subparsers = parser.add_subparsers(dest="command", help="可用命令")
|
|
||||||
|
|
||||||
# 添加日期前缀命令
|
|
||||||
add_parser = subparsers.add_parser("add", help="添加日期前缀")
|
|
||||||
add_parser.add_argument("files", nargs="+", help="文件路径")
|
|
||||||
|
|
||||||
# 清除日期前缀命令
|
|
||||||
clear_parser = subparsers.add_parser("clear", help="清除日期前缀")
|
|
||||||
clear_parser.add_argument("files", nargs="+", help="文件路径")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.command == "add":
|
|
||||||
graph = px.Graph.from_specs([
|
|
||||||
px.TaskSpec(
|
|
||||||
"process_files_date",
|
|
||||||
fn=process_files_date,
|
|
||||||
args=([Path(f) for f in args.files],),
|
|
||||||
kwargs={"clear": False},
|
|
||||||
)
|
|
||||||
])
|
|
||||||
elif args.command == "clear":
|
|
||||||
graph = px.Graph.from_specs([
|
|
||||||
px.TaskSpec(
|
|
||||||
"process_files_date",
|
|
||||||
fn=process_files_date,
|
|
||||||
args=([Path(f) for f in args.files],),
|
|
||||||
kwargs={"clear": True},
|
|
||||||
)
|
|
||||||
])
|
|
||||||
else:
|
|
||||||
parser.print_help()
|
|
||||||
return
|
|
||||||
|
|
||||||
px.run(graph, strategy="thread")
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
"""文件等级重命名工具.
|
|
||||||
|
|
||||||
根据文件等级配置自动重命名文件,
|
|
||||||
支持多种等级标识和括号格式.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pyflowx as px
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 配置
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
LEVELS: dict[str, str] = {
|
|
||||||
"0": "",
|
|
||||||
"1": "PUB,NOR",
|
|
||||||
"2": "INT",
|
|
||||||
"3": "CON",
|
|
||||||
"4": "CLA",
|
|
||||||
}
|
|
||||||
|
|
||||||
BRACKETS: tuple[str, str] = (" ([_(【-", " )]_)】")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 辅助函数
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def remove_marks(stem: str, marks: list[str]) -> str:
|
|
||||||
"""从文件名主干中移除所有标记."""
|
|
||||||
left_brackets, right_brackets = BRACKETS
|
|
||||||
for mark in marks:
|
|
||||||
pos = 0
|
|
||||||
while True:
|
|
||||||
pos = stem.find(mark, pos)
|
|
||||||
if pos == -1:
|
|
||||||
break
|
|
||||||
b, e = pos - 1, pos + len(mark)
|
|
||||||
if b >= 0 and e < len(stem) and stem[b] in left_brackets and stem[e] in right_brackets:
|
|
||||||
stem = stem[:b] + stem[e + 1 :]
|
|
||||||
else:
|
|
||||||
pos = e
|
|
||||||
return stem
|
|
||||||
|
|
||||||
|
|
||||||
def process_file_level(filepath: Path, level: int = 0) -> None:
|
|
||||||
"""处理单个文件的等级标记.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
filepath : Path
|
|
||||||
文件路径
|
|
||||||
level : int
|
|
||||||
文件等级 (0-4), 0 用于清除等级
|
|
||||||
"""
|
|
||||||
if not (0 <= level < len(LEVELS)):
|
|
||||||
print(f"无效的等级 {level}, 必须在 0 和 {len(LEVELS) - 1} 之间")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not filepath.exists():
|
|
||||||
print(f"文件不存在: {filepath}")
|
|
||||||
return
|
|
||||||
|
|
||||||
filestem = filepath.stem
|
|
||||||
original_stem = filestem
|
|
||||||
|
|
||||||
# 移除所有等级标记
|
|
||||||
for level_names in LEVELS.values():
|
|
||||||
if level_names:
|
|
||||||
filestem = remove_marks(filestem, level_names.split(","))
|
|
||||||
|
|
||||||
# 移除数字标记
|
|
||||||
for digit in map(str, range(1, 10)):
|
|
||||||
filestem = remove_marks(filestem, [digit])
|
|
||||||
|
|
||||||
# 添加等级标记
|
|
||||||
if level > 0:
|
|
||||||
levelstr = LEVELS.get(str(level), "").split(",")[0]
|
|
||||||
if levelstr:
|
|
||||||
filestem = f"{filestem}({levelstr})"
|
|
||||||
|
|
||||||
# 重命名文件
|
|
||||||
if filestem != original_stem:
|
|
||||||
new_path = filepath.with_name(filestem + filepath.suffix)
|
|
||||||
filepath.rename(new_path)
|
|
||||||
print(f"重命名: {filepath} -> {new_path}")
|
|
||||||
|
|
||||||
|
|
||||||
def process_files_level(targets: list[Path], level: int = 0) -> None:
|
|
||||||
"""批量处理文件等级标记.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
targets : list[Path]
|
|
||||||
文件路径列表
|
|
||||||
level : int
|
|
||||||
文件等级 (0-4)
|
|
||||||
"""
|
|
||||||
for target in targets:
|
|
||||||
process_file_level(target, level)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# CLI Runner
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
"""文件等级重命名工具主函数."""
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="FileLevel - 文件等级重命名工具",
|
|
||||||
usage="filelevel <command> [options]",
|
|
||||||
)
|
|
||||||
subparsers = parser.add_subparsers(dest="command", help="可用命令")
|
|
||||||
|
|
||||||
# 设置等级命令
|
|
||||||
level_parser = subparsers.add_parser("set", help="设置文件等级")
|
|
||||||
level_parser.add_argument("files", nargs="+", help="文件路径")
|
|
||||||
level_parser.add_argument("--level", type=int, choices=[0, 1, 2, 3, 4], required=True, help="文件等级 (0-4)")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.command == "set":
|
|
||||||
graph = px.Graph.from_specs(
|
|
||||||
[
|
|
||||||
px.TaskSpec(
|
|
||||||
"process_files_level", fn=process_files_level, args=([Path(f) for f in args.files], args.level)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
parser.print_help()
|
|
||||||
return
|
|
||||||
|
|
||||||
px.run(graph, strategy="thread")
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
"""文件夹备份工具.
|
|
||||||
|
|
||||||
备份文件和文件夹为 zip 文件,
|
|
||||||
自动删除超过最大数量的旧备份文件.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
import zipfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pyflowx as px
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 辅助函数
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def remove_dump(src: Path, dst: Path, max_zip: int) -> None:
|
|
||||||
"""递归删除旧的备份 zip 文件."""
|
|
||||||
zip_paths = [filepath for filepath in dst.rglob("*.zip") if src.stem in str(filepath)]
|
|
||||||
zip_files = sorted(zip_paths, key=lambda fn: str(fn)[-19:-4])
|
|
||||||
if len(zip_files) > max_zip:
|
|
||||||
zip_files[0].unlink()
|
|
||||||
remove_dump(src, dst, max_zip)
|
|
||||||
|
|
||||||
|
|
||||||
def zip_target(src: Path, dst: Path, max_zip: int) -> None:
|
|
||||||
"""将单个文件或文件夹压缩为 zip 文件."""
|
|
||||||
files = [str(_) for _ in src.rglob("*")]
|
|
||||||
timestamp = time.strftime("_%Y%m%d_%H%M%S")
|
|
||||||
target_path = dst / (src.stem + timestamp + ".zip")
|
|
||||||
|
|
||||||
with zipfile.ZipFile(target_path, "w") as zip_file:
|
|
||||||
for file in files:
|
|
||||||
zip_file.write(file, arcname=file.replace(str(src.parent), ""))
|
|
||||||
|
|
||||||
remove_dump(src, dst, max_zip)
|
|
||||||
print(f"备份完成: {target_path}")
|
|
||||||
|
|
||||||
|
|
||||||
def backup_folder(src: str, dst: str, max_zip: int = 5) -> None:
|
|
||||||
"""备份文件夹.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
src : str
|
|
||||||
源文件夹路径
|
|
||||||
dst : str
|
|
||||||
目标文件夹路径
|
|
||||||
max_zip : int
|
|
||||||
最大备份数量
|
|
||||||
"""
|
|
||||||
src_path = Path(src)
|
|
||||||
dst_path = Path(dst)
|
|
||||||
|
|
||||||
if not src_path.exists():
|
|
||||||
print(f"源文件夹不存在: {src_path}")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not dst_path.exists():
|
|
||||||
dst_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
print(f"创建目标文件夹: {dst_path}")
|
|
||||||
|
|
||||||
zip_target(src_path, dst_path, max_zip)
|
|
||||||
|
|
||||||
|
|
||||||
@px.task
|
|
||||||
def folderback_default() -> None:
|
|
||||||
"""备份当前目录到 ./backup."""
|
|
||||||
backup_folder(".", "./backup", 5)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
"""文件夹备份工具主函数."""
|
|
||||||
runner = px.CliRunner(
|
|
||||||
strategy="thread",
|
|
||||||
description="FolderBack - 文件夹备份工具",
|
|
||||||
aliases={
|
|
||||||
# 备份当前目录到 ./backup
|
|
||||||
"b": folderback_default,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
runner.run_cli()
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
"""文件夹压缩工具.
|
|
||||||
|
|
||||||
压缩目录下的所有文件/文件夹为 zip 文件,
|
|
||||||
默认压缩当前目录下的所有子文件夹.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import shutil
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pyflowx as px
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 配置
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
IGNORE_DIRS: list[str] = [".git", ".idea", ".vscode", "__pycache__"]
|
|
||||||
IGNORE_FILES: list[str] = [".gitignore"]
|
|
||||||
IGNORE: list[str] = [*IGNORE_DIRS, *IGNORE_FILES]
|
|
||||||
IGNORE_EXT: list[str] = [".zip", ".rar", ".7z", ".tar", ".gz"]
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 辅助函数
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def archive_folder(folder: Path) -> None:
|
|
||||||
"""压缩单个文件夹."""
|
|
||||||
shutil.make_archive(
|
|
||||||
str(folder.with_name(folder.name)),
|
|
||||||
format="zip",
|
|
||||||
base_dir=folder,
|
|
||||||
)
|
|
||||||
print(f"压缩完成: {folder.name}.zip")
|
|
||||||
|
|
||||||
|
|
||||||
def zip_folders(cwd: str = ".") -> None:
|
|
||||||
"""压缩目录下的所有文件夹.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
cwd : str
|
|
||||||
工作目录
|
|
||||||
"""
|
|
||||||
cwd_path = Path(cwd)
|
|
||||||
if not cwd_path.exists():
|
|
||||||
print(f"目录不存在: {cwd_path}")
|
|
||||||
return
|
|
||||||
|
|
||||||
dirs: list[Path] = [
|
|
||||||
e for e in cwd_path.iterdir() if e.is_dir() and e.name not in IGNORE_DIRS and e.suffix not in IGNORE_EXT
|
|
||||||
]
|
|
||||||
|
|
||||||
for dir_path in dirs:
|
|
||||||
archive_folder(dir_path)
|
|
||||||
|
|
||||||
|
|
||||||
@px.task
|
|
||||||
def folderzip_default() -> None:
|
|
||||||
"""压缩当前目录下的所有文件夹."""
|
|
||||||
zip_folders(".")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
"""文件夹压缩工具主函数."""
|
|
||||||
runner = px.CliRunner(
|
|
||||||
strategy="thread",
|
|
||||||
description="FolderZip - 文件夹压缩工具",
|
|
||||||
aliases={
|
|
||||||
# 压缩当前目录下的所有文件夹
|
|
||||||
"z": folderzip_default,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
runner.run_cli()
|
|
||||||
@@ -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")
|
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""PyFlowX 统一 CLI 入口.
|
||||||
|
|
||||||
|
通过 ``pf <tool> [command] [options]`` 调用所有工具,
|
||||||
|
工具定义在 ``configs/`` 目录下的 YAML 文件中.
|
||||||
|
|
||||||
|
用法
|
||||||
|
----
|
||||||
|
pf # 列出所有可用工具
|
||||||
|
pf filedate # 查看 filedate 工具帮助
|
||||||
|
pf filedate add a.txt # 调用 filedate 的 add 子命令
|
||||||
|
pf pymake b # 调用 pymake 的 b 别名
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
import pyflowx as px
|
||||||
|
|
||||||
|
|
||||||
|
class PfApp:
|
||||||
|
"""pf 统一入口应用.
|
||||||
|
|
||||||
|
路由 ``pf <tool> [command]`` 到 YAML 配置工具或传统 Python 工具.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CONFIGS_DIR = Path(__file__).parent.parent / "configs"
|
||||||
|
|
||||||
|
# 工具名到 YAML 配置文件的映射 (支持短别名)
|
||||||
|
_TOOL_ALIASES: dict[str, str] = {
|
||||||
|
"autofmt": "autofmt",
|
||||||
|
"af": "autofmt",
|
||||||
|
"bump": "bumpversion",
|
||||||
|
"bumpversion": "bumpversion",
|
||||||
|
"bv": "bumpversion",
|
||||||
|
"clr": "clr",
|
||||||
|
"clearscreen": "clr",
|
||||||
|
"dockercmd": "dockercmd",
|
||||||
|
"docker": "dockercmd",
|
||||||
|
"envdev": "envdev",
|
||||||
|
"env": "envdev",
|
||||||
|
"filedate": "filedate",
|
||||||
|
"fd": "filedate",
|
||||||
|
"filelevel": "filelevel",
|
||||||
|
"fl": "filelevel",
|
||||||
|
"folderback": "folderback",
|
||||||
|
"foldback": "folderback",
|
||||||
|
"fb": "folderback",
|
||||||
|
"folderzip": "folderzip",
|
||||||
|
"foldzip": "folderzip",
|
||||||
|
"fz": "folderzip",
|
||||||
|
"git": "gittool",
|
||||||
|
"gitt": "gittool",
|
||||||
|
"gittool": "gittool",
|
||||||
|
"gt": "gittool",
|
||||||
|
"ls": "lscalc",
|
||||||
|
"lscalc": "lscalc",
|
||||||
|
"msdown": "msdownload",
|
||||||
|
"msdownload": "msdownload",
|
||||||
|
"msd": "msdownload",
|
||||||
|
"pack": "packtool",
|
||||||
|
"packtool": "packtool",
|
||||||
|
"pk": "packtool",
|
||||||
|
"pdf": "pdftool",
|
||||||
|
"pdftool": "pdftool",
|
||||||
|
"pt": "pdftool",
|
||||||
|
"pip": "piptool",
|
||||||
|
"pymake": "pymake",
|
||||||
|
"piptool": "piptool",
|
||||||
|
"pp": "piptool",
|
||||||
|
"reseticon": "reseticoncache",
|
||||||
|
"reseticoncache": "reseticoncache",
|
||||||
|
"ric": "reseticoncache",
|
||||||
|
"screenshot": "screenshot",
|
||||||
|
"scrcap": "screenshot",
|
||||||
|
"ss": "screenshot",
|
||||||
|
"sglang": "sglang",
|
||||||
|
"sg": "sglang",
|
||||||
|
"ssh": "sshcopyid",
|
||||||
|
"sshcopy": "sshcopyid",
|
||||||
|
"sshcopyid": "sshcopyid",
|
||||||
|
"sc": "sshcopyid",
|
||||||
|
"taskk": "taskkill",
|
||||||
|
"taskkill": "taskkill",
|
||||||
|
"tk": "taskkill",
|
||||||
|
"wch": "which",
|
||||||
|
"which": "which",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 传统工具: 有自己的 main() 函数 (无法 YAML 化的复杂逻辑)
|
||||||
|
_LEGACY_TOOLS: dict[str, str] = {
|
||||||
|
"emlman": "pyflowx.cli.emlmanager:main",
|
||||||
|
"profiler": "pyflowx.cli.profiler:main",
|
||||||
|
"pxp": "pyflowx.cli.profiler:main",
|
||||||
|
"yamlrun": "pyflowx.cli.yamlrun:main",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, argv: Sequence[str] | None = None) -> None:
|
||||||
|
self._argv = list(argv) if argv is not None else sys.argv[1:]
|
||||||
|
|
||||||
|
def run(self) -> int:
|
||||||
|
"""主入口, 返回退出码."""
|
||||||
|
if not self._argv:
|
||||||
|
self._list_tools()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
tool_name = self._argv[0]
|
||||||
|
rest_argv = self._argv[1:]
|
||||||
|
|
||||||
|
resolved = self._resolve_tool(tool_name)
|
||||||
|
if resolved is None:
|
||||||
|
print(f"错误: 未知工具 '{tool_name}'", file=sys.stderr)
|
||||||
|
print("运行 'pf' 查看可用工具列表", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
tool_type, target = resolved
|
||||||
|
if tool_type == "legacy":
|
||||||
|
return self._run_legacy(target, rest_argv)
|
||||||
|
return self._run_yaml(target, rest_argv)
|
||||||
|
|
||||||
|
def _list_tools(self) -> None:
|
||||||
|
"""列出所有可用工具."""
|
||||||
|
print("PyFlowX 工具列表:")
|
||||||
|
print()
|
||||||
|
print("YAML 配置工具:")
|
||||||
|
yaml_tools = sorted(set(self._TOOL_ALIASES.values()))
|
||||||
|
for tool in yaml_tools:
|
||||||
|
print(f" pf {tool:<15} - {self._tool_description(tool)}")
|
||||||
|
print()
|
||||||
|
print("传统工具:")
|
||||||
|
for tool in sorted(self._LEGACY_TOOLS):
|
||||||
|
print(f" pf {tool:<15}")
|
||||||
|
print()
|
||||||
|
print("示例:")
|
||||||
|
print(" pf filedate add a.txt")
|
||||||
|
print(" pf pymake b")
|
||||||
|
|
||||||
|
def _tool_description(self, tool_name: str) -> str:
|
||||||
|
"""获取工具描述 (从 YAML cli.description)."""
|
||||||
|
config_path = self._CONFIGS_DIR / f"{tool_name}.yaml"
|
||||||
|
if not config_path.exists():
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(data, dict) and isinstance(data.get("cli"), dict):
|
||||||
|
return str(data["cli"].get("description", ""))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _resolve_tool(self, name: str) -> tuple[str, str] | None:
|
||||||
|
"""解析工具名, 返回 (类型, 目标).
|
||||||
|
|
||||||
|
类型: "yaml" 或 "legacy"
|
||||||
|
目标: YAML 文件名 (不含 .yaml) 或 legacy 模块路径
|
||||||
|
"""
|
||||||
|
if name in self._TOOL_ALIASES:
|
||||||
|
return ("yaml", self._TOOL_ALIASES[name])
|
||||||
|
if name in self._LEGACY_TOOLS:
|
||||||
|
return ("legacy", self._LEGACY_TOOLS[name])
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _run_legacy(self, module_path: str, argv: list[str]) -> int:
|
||||||
|
"""运行传统工具的 main() 函数."""
|
||||||
|
module_name, func_name = module_path.split(":", 1)
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
func = getattr(module, func_name)
|
||||||
|
|
||||||
|
original_argv = sys.argv
|
||||||
|
sys.argv = [f"pf {module_name.split('.')[-1]}", *argv]
|
||||||
|
try:
|
||||||
|
func()
|
||||||
|
return 0
|
||||||
|
except SystemExit as e:
|
||||||
|
return int(e.code) if e.code is not None else 0
|
||||||
|
finally:
|
||||||
|
sys.argv = original_argv
|
||||||
|
|
||||||
|
def _run_yaml(self, target: str, argv: list[str]) -> int:
|
||||||
|
"""运行 YAML 配置工具."""
|
||||||
|
config_path = self._CONFIGS_DIR / f"{target}.yaml"
|
||||||
|
if not config_path.exists():
|
||||||
|
print(f"错误: 未找到配置文件 '{config_path}'", file=sys.stderr)
|
||||||
|
print("运行 'pf' 查看可用工具列表", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"运行配置文件 '{config_path}'")
|
||||||
|
return px.run_cli(config_path, argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""pf 统一入口主函数."""
|
||||||
|
sys.exit(PfApp().run())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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,163 +0,0 @@
|
|||||||
"""截图工具.
|
|
||||||
|
|
||||||
跨平台截图工具, 支持全屏截图和区域截图.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import subprocess
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pyflowx as px
|
|
||||||
from pyflowx.conditions import Constants
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 辅助函数
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def get_screenshot_path(filename: str | None = None) -> Path:
|
|
||||||
"""获取截图保存路径.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
filename : str | None
|
|
||||||
文件名, 如果为 None 则自动生成
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
截图保存路径
|
|
||||||
"""
|
|
||||||
if filename is None:
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
||||||
filename = f"screenshot_{timestamp}.png"
|
|
||||||
|
|
||||||
screenshots_dir = Path.home() / "Pictures" / "screenshots"
|
|
||||||
screenshots_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
return screenshots_dir / filename
|
|
||||||
|
|
||||||
|
|
||||||
def take_screenshot_full(filename: str | None = None) -> None:
|
|
||||||
"""全屏截图.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
filename : str | None
|
|
||||||
文件名
|
|
||||||
"""
|
|
||||||
output_path = get_screenshot_path(filename)
|
|
||||||
|
|
||||||
if Constants.IS_WINDOWS:
|
|
||||||
# Windows: 使用 PowerShell 截图
|
|
||||||
ps_script = f"""
|
|
||||||
Add-Type -AssemblyName System.Windows.Forms
|
|
||||||
Add-Type -AssemblyName System.Drawing
|
|
||||||
$screen = [System.Windows.Forms.Screen]::PrimaryScreen
|
|
||||||
$bounds = $screen.Bounds
|
|
||||||
$bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
|
|
||||||
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
|
||||||
$graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
|
|
||||||
$bitmap.Save('{output_path.as_posix()}')
|
|
||||||
$graphics.Dispose()
|
|
||||||
$bitmap.Dispose()
|
|
||||||
"""
|
|
||||||
subprocess.run(["powershell", "-Command", ps_script], check=True)
|
|
||||||
elif Constants.IS_MACOS:
|
|
||||||
# macOS: 使用 screencapture
|
|
||||||
subprocess.run(["screencapture", "-x", str(output_path)], check=True)
|
|
||||||
else:
|
|
||||||
# Linux: 使用 gnome-screenshot 或 scrot
|
|
||||||
try:
|
|
||||||
subprocess.run(["gnome-screenshot", "-f", str(output_path)], check=True)
|
|
||||||
except FileNotFoundError:
|
|
||||||
subprocess.run(["scrot", str(output_path)], check=True)
|
|
||||||
|
|
||||||
print(f"截图已保存: {output_path}")
|
|
||||||
|
|
||||||
|
|
||||||
def take_screenshot_area(filename: str | None = None) -> None:
|
|
||||||
"""区域截图.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
filename : str | None
|
|
||||||
文件名
|
|
||||||
"""
|
|
||||||
output_path = get_screenshot_path(filename)
|
|
||||||
|
|
||||||
if Constants.IS_WINDOWS:
|
|
||||||
# Windows: 使用 PowerShell 截图 (需要用户选择区域)
|
|
||||||
ps_script = f"""
|
|
||||||
Add-Type -AssemblyName System.Windows.Forms
|
|
||||||
Add-Type -AssemblyName System.Drawing
|
|
||||||
$form = New-Object System.Windows.Forms.Form
|
|
||||||
$form.WindowState = 'Maximized'
|
|
||||||
$form.FormBorderStyle = 'None'
|
|
||||||
$form.BackColor = [System.Drawing.Color]::FromArgb(1, 0, 0)
|
|
||||||
$form.Opacity = 0.5
|
|
||||||
$form.TopMost = $true
|
|
||||||
$form.Show()
|
|
||||||
Start-Sleep -Milliseconds 100
|
|
||||||
$screen = [System.Windows.Forms.Screen]::PrimaryScreen
|
|
||||||
$bounds = $screen.Bounds
|
|
||||||
$bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
|
|
||||||
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
|
||||||
$graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
|
|
||||||
$form.Close()
|
|
||||||
$bitmap.Save('{output_path.as_posix()}')
|
|
||||||
$graphics.Dispose()
|
|
||||||
$bitmap.Dispose()
|
|
||||||
"""
|
|
||||||
subprocess.run(["powershell", "-Command", ps_script], check=True)
|
|
||||||
elif Constants.IS_MACOS:
|
|
||||||
# macOS: 使用 screencapture 交互模式
|
|
||||||
subprocess.run(["screencapture", "-i", str(output_path)], check=True)
|
|
||||||
else:
|
|
||||||
# Linux: 使用 gnome-screenshot 交互模式
|
|
||||||
try:
|
|
||||||
subprocess.run(["gnome-screenshot", "-a", "-f", str(output_path)], check=True)
|
|
||||||
except FileNotFoundError:
|
|
||||||
subprocess.run(["scrot", "-s", str(output_path)], check=True)
|
|
||||||
|
|
||||||
print(f"截图已保存: {output_path}")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# CLI Runner
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
"""截图工具主函数."""
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Screenshot - 截图工具",
|
|
||||||
usage="screenshot <command> [options]",
|
|
||||||
)
|
|
||||||
subparsers = parser.add_subparsers(dest="command", help="可用命令")
|
|
||||||
|
|
||||||
# 全屏截图命令
|
|
||||||
full_parser = subparsers.add_parser("full", help="全屏截图")
|
|
||||||
full_parser.add_argument("--filename", type=str, help="文件名")
|
|
||||||
|
|
||||||
# 区域截图命令
|
|
||||||
area_parser = subparsers.add_parser("area", help="区域截图")
|
|
||||||
area_parser.add_argument("--filename", type=str, help="文件名")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.command == "full":
|
|
||||||
graph = px.Graph.from_specs(
|
|
||||||
[px.TaskSpec("screenshot_full", fn=take_screenshot_full, kwargs={"filename": args.filename})]
|
|
||||||
)
|
|
||||||
elif args.command == "area":
|
|
||||||
graph = px.Graph.from_specs(
|
|
||||||
[px.TaskSpec("screenshot_area", fn=take_screenshot_area, kwargs={"filename": args.filename})]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
parser.print_help()
|
|
||||||
return
|
|
||||||
|
|
||||||
px.run(graph, strategy="thread")
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
"""SSH 密钥部署工具.
|
|
||||||
|
|
||||||
类似 ssh-copy-id, 自动将 SSH 公钥部署到远程服务器,
|
|
||||||
支持密码认证和密钥认证两种方式.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pyflowx as px
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 辅助函数
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def ssh_copy_id(
|
|
||||||
hostname: str,
|
|
||||||
username: str,
|
|
||||||
password: str,
|
|
||||||
port: int = 22,
|
|
||||||
keypath: str = "~/.ssh/id_rsa.pub",
|
|
||||||
timeout: int = 30,
|
|
||||||
) -> None:
|
|
||||||
"""将 SSH 公钥部署到远程服务器.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
hostname : str
|
|
||||||
远程服务器主机名或 IP 地址
|
|
||||||
username : str
|
|
||||||
远程服务器用户名
|
|
||||||
password : str
|
|
||||||
远程服务器密码
|
|
||||||
port : int
|
|
||||||
SSH 端口, 默认 22
|
|
||||||
keypath : str
|
|
||||||
公钥文件路径, 默认 ~/.ssh/id_rsa.pub
|
|
||||||
timeout : int
|
|
||||||
SSH 操作超时秒数, 默认 30
|
|
||||||
"""
|
|
||||||
# 读取公钥
|
|
||||||
pub_key_path = Path(keypath).expanduser()
|
|
||||||
if not pub_key_path.exists():
|
|
||||||
print(f"公钥文件不存在: {pub_key_path}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
pub_key = pub_key_path.read_text().strip()
|
|
||||||
|
|
||||||
# 构建部署脚本
|
|
||||||
script = f"""mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
|
||||||
cd ~/.ssh && touch authorized_keys && chmod 600 authorized_keys
|
|
||||||
grep -qF '{pub_key.split()[1]}' authorized_keys 2>/dev/null || echo '{pub_key}' >> authorized_keys"""
|
|
||||||
|
|
||||||
# 使用 sshpass 执行
|
|
||||||
try:
|
|
||||||
subprocess.run(
|
|
||||||
[
|
|
||||||
"sshpass",
|
|
||||||
"-p",
|
|
||||||
password,
|
|
||||||
"ssh",
|
|
||||||
"-p",
|
|
||||||
str(port),
|
|
||||||
"-o",
|
|
||||||
"StrictHostKeyChecking=no",
|
|
||||||
"-o",
|
|
||||||
"UserKnownHostsFile=/dev/null",
|
|
||||||
"-o",
|
|
||||||
f"ConnectTimeout={timeout}",
|
|
||||||
f"{username}@{hostname}",
|
|
||||||
script,
|
|
||||||
],
|
|
||||||
check=True,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
print(f"SSH 密钥已部署到 {username}@{hostname}:{port}")
|
|
||||||
except FileNotFoundError:
|
|
||||||
print(f"未找到 sshpass 工具,请手动执行: ssh-copy-id -p {port} {username}@{hostname}")
|
|
||||||
sys.exit(1)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
print("SSH 连接超时")
|
|
||||||
sys.exit(1)
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"SSH 执行失败: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# CLI Runner
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
"""SSH 密钥部署工具主函数."""
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="SSHCopyID - SSH 密钥部署工具",
|
|
||||||
usage="sshcopyid <hostname> <username> <password> [--port PORT] [--keypath KEYPATH]",
|
|
||||||
)
|
|
||||||
parser.add_argument("hostname", type=str, help="远程服务器主机名或 IP 地址")
|
|
||||||
parser.add_argument("username", type=str, help="远程服务器用户名")
|
|
||||||
parser.add_argument("password", type=str, help="远程服务器密码")
|
|
||||||
parser.add_argument("--port", type=int, default=22, help="SSH 端口 (默认: 22)")
|
|
||||||
parser.add_argument("--keypath", type=str, default="~/.ssh/id_rsa.pub", help="公钥文件路径")
|
|
||||||
parser.add_argument("--timeout", type=int, default=30, help="SSH 操作超时秒数 (默认: 30)")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
graph = px.Graph.from_specs(
|
|
||||||
[
|
|
||||||
px.TaskSpec(
|
|
||||||
"ssh_deploy",
|
|
||||||
fn=ssh_copy_id,
|
|
||||||
args=(args.hostname, args.username, args.password),
|
|
||||||
kwargs={"port": args.port, "keypath": args.keypath, "timeout": args.timeout},
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
px.run(graph, strategy="thread")
|
|
||||||
@@ -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")
|
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""YAML 任务编排执行工具.
|
||||||
|
|
||||||
|
从 YAML 文件加载 GitHub Actions 风格的任务图并执行.
|
||||||
|
支持串并行编排、矩阵扇出、条件执行等 CI/CD 核心概念.
|
||||||
|
|
||||||
|
用法
|
||||||
|
----
|
||||||
|
yamlrun pipeline.yaml # 执行 YAML 任务图
|
||||||
|
yamlrun pipeline.yaml --strategy thread # 指定执行策略
|
||||||
|
yamlrun pipeline.yaml --dry-run # 仅打印任务分层, 不执行
|
||||||
|
yamlrun pipeline.yaml --list # 列出所有任务名
|
||||||
|
yamlrun pipeline.yaml --quiet # 静默模式
|
||||||
|
|
||||||
|
示例 YAML
|
||||||
|
----------
|
||||||
|
::
|
||||||
|
|
||||||
|
strategy: thread
|
||||||
|
jobs:
|
||||||
|
setup:
|
||||||
|
cmd: ["git", "clone", "https://github.com/foo/bar"]
|
||||||
|
build:
|
||||||
|
needs: [setup]
|
||||||
|
cmd: ["python", "-m", "build"]
|
||||||
|
test:
|
||||||
|
needs: [build]
|
||||||
|
cmd: ["pytest"]
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python: ["3.8", "3.9"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import pyflowx as px
|
||||||
|
from pyflowx.executors import Strategy
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""YAML 任务编排执行工具主函数."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="YamlRun - 从 YAML 文件加载并执行任务图",
|
||||||
|
usage="yamlrun <file.yaml> [--strategy STRATEGY] [--dry-run] [--list] [--quiet]",
|
||||||
|
)
|
||||||
|
parser.add_argument("file", type=str, help="YAML 任务图文件路径")
|
||||||
|
parser.add_argument(
|
||||||
|
"--strategy",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help="执行策略: sequential/thread/async/dependency (默认: YAML 中指定的策略或 dependency)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="仅打印任务分层, 不执行")
|
||||||
|
parser.add_argument("--list", action="store_true", help="列出所有任务名后退出")
|
||||||
|
parser.add_argument("--quiet", action="store_true", help="静默模式, 不打印详细输出")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
file_path = Path(args.file)
|
||||||
|
if not file_path.exists():
|
||||||
|
print(f"错误: 文件不存在: {file_path}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
graph = px.Graph.from_yaml(file_path)
|
||||||
|
except px.YamlLoadError as e:
|
||||||
|
print(f"错误: YAML 加载失败: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.list:
|
||||||
|
print("任务列表:")
|
||||||
|
for name in graph.names:
|
||||||
|
spec = graph.spec(name)
|
||||||
|
deps = ", ".join(spec.depends_on) if spec.depends_on else "(无依赖)"
|
||||||
|
print(f" - {name} (依赖: {deps})")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
layers = graph.layers()
|
||||||
|
print(f"任务分层 ({len(layers)} 层):")
|
||||||
|
for i, layer in enumerate(layers):
|
||||||
|
print(f" 层 {i + 1}: {layer}")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("\n[dry-run] 跳过执行")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
strategy = args.strategy or graph.defaults.strategy or "dependency"
|
||||||
|
print(f"\n执行策略: {strategy}")
|
||||||
|
print(f"任务总数: {len(graph.names)}")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
report = px.run(graph, strategy=cast(Strategy, strategy), verbose=not args.quiet)
|
||||||
|
|
||||||
|
print("-" * 40)
|
||||||
|
succeeded = report.succeeded_tasks()
|
||||||
|
failed = report.failed_tasks()
|
||||||
|
skipped = report.skipped_tasks()
|
||||||
|
print(f"完成: {len(succeeded)} 成功 / {len(failed)} 失败 / {len(skipped)} 跳过 (共 {len(graph.names)})")
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
print(f"失败任务: {failed}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -90,6 +90,8 @@ def run_command(spec: TaskSpec[Any]) -> Any: # noqa: PLR0912
|
|||||||
print(f"[verbose] 返回码: {result.returncode}", flush=True)
|
print(f"[verbose] 返回码: {result.returncode}", flush=True)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
|
if not verbose and result.stdout:
|
||||||
|
print(result.stdout, end="", flush=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
err_msg = f"{label}执行失败: `{cmd_str}`, 返回码: {result.returncode}"
|
err_msg = f"{label}执行失败: `{cmd_str}`, 返回码: {result.returncode}"
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# autofmt - 自动格式化工具
|
||||||
|
# 用法:
|
||||||
|
# pf autofmt fmt --target .
|
||||||
|
# pf autofmt lint --target .
|
||||||
|
# pf autofmt lint --target . --fix
|
||||||
|
# pf autofmt doc --root-dir .
|
||||||
|
# pf autofmt sync --root-dir .
|
||||||
|
strategy: thread
|
||||||
|
variables:
|
||||||
|
TARGET: "."
|
||||||
|
ROOT_DIR: "."
|
||||||
|
FIX: false
|
||||||
|
cli:
|
||||||
|
description: "AutoFmt - 自动格式化工具"
|
||||||
|
usage: "pf autofmt <command> [options]"
|
||||||
|
subcommands:
|
||||||
|
fmt:
|
||||||
|
help: "格式化代码"
|
||||||
|
options:
|
||||||
|
- name: TARGET
|
||||||
|
flag: "--target"
|
||||||
|
type: str
|
||||||
|
default: "."
|
||||||
|
help: "目标路径 (默认: .)"
|
||||||
|
lint:
|
||||||
|
help: "代码检查"
|
||||||
|
options:
|
||||||
|
- name: TARGET
|
||||||
|
flag: "--target"
|
||||||
|
type: str
|
||||||
|
default: "."
|
||||||
|
help: "目标路径 (默认: .)"
|
||||||
|
- name: FIX
|
||||||
|
flag: "--fix"
|
||||||
|
action: "store_true"
|
||||||
|
help: "自动修复问题"
|
||||||
|
doc:
|
||||||
|
help: "自动添加文档字符串"
|
||||||
|
options:
|
||||||
|
- name: ROOT_DIR
|
||||||
|
flag: "--root-dir"
|
||||||
|
type: str
|
||||||
|
default: "."
|
||||||
|
help: "根目录 (默认: .)"
|
||||||
|
sync:
|
||||||
|
help: "同步 pyproject 配置"
|
||||||
|
options:
|
||||||
|
- name: ROOT_DIR
|
||||||
|
flag: "--root-dir"
|
||||||
|
type: str
|
||||||
|
default: "."
|
||||||
|
help: "根目录 (默认: .)"
|
||||||
|
jobs:
|
||||||
|
fmt:
|
||||||
|
cmd: ["ruff", "format", "${TARGET}"]
|
||||||
|
lint:
|
||||||
|
cmd: ["ruff", "check", "${TARGET}"]
|
||||||
|
lint_fix:
|
||||||
|
cmd: ["ruff", "check", "--fix", "--unsafe-fixes", "${TARGET}"]
|
||||||
|
doc:
|
||||||
|
fn: auto_add_docstrings
|
||||||
|
args: ["${ROOT_DIR}"]
|
||||||
|
sync:
|
||||||
|
fn: sync_pyproject_config
|
||||||
|
args: ["${ROOT_DIR}"]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# bumpversion - 版本号自动管理工具
|
||||||
|
# 用法:
|
||||||
|
# pf bumpversion
|
||||||
|
# pf bumpversion minor --no-tag
|
||||||
|
strategy: sequential
|
||||||
|
variables:
|
||||||
|
PART: patch
|
||||||
|
NO_TAG: false
|
||||||
|
cli:
|
||||||
|
description: "BumpVersion - 版本号自动管理工具"
|
||||||
|
usage: "pf bumpversion [part] [options]"
|
||||||
|
positional:
|
||||||
|
- name: PART
|
||||||
|
type: str
|
||||||
|
default: patch
|
||||||
|
help: "版本部分: patch, minor, major"
|
||||||
|
options:
|
||||||
|
- name: NO_TAG
|
||||||
|
flag: "--no-tag"
|
||||||
|
action: "store_true"
|
||||||
|
help: "提交后不创建 git tag"
|
||||||
|
jobs:
|
||||||
|
bump:
|
||||||
|
fn: bump_project_version
|
||||||
|
args: ["${PART}"]
|
||||||
|
kwargs:
|
||||||
|
no_tag: ${NO_TAG}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# clr - 清屏工具
|
||||||
|
# 用法:
|
||||||
|
# pf clr
|
||||||
|
strategy: sequential
|
||||||
|
cli:
|
||||||
|
description: "清屏工具 (跨平台)"
|
||||||
|
usage: "pf clr"
|
||||||
|
jobs:
|
||||||
|
clear:
|
||||||
|
fn: clear_screen_run
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# dockercmd - Docker 镜像登录工具
|
||||||
|
# 用法:
|
||||||
|
# pf dockercmd login
|
||||||
|
# pf dockercmd login --username myuser
|
||||||
|
strategy: sequential
|
||||||
|
variables:
|
||||||
|
USERNAME: ""
|
||||||
|
cli:
|
||||||
|
description: "DockerCmd - Docker 镜像登录工具"
|
||||||
|
usage: "pf dockercmd <command> [options]"
|
||||||
|
subcommands:
|
||||||
|
login:
|
||||||
|
help: "登录腾讯云 Docker 镜像仓库"
|
||||||
|
options:
|
||||||
|
- name: USERNAME
|
||||||
|
flag: "--username"
|
||||||
|
type: str
|
||||||
|
default: ""
|
||||||
|
help: "Docker 用户名 (默认: 当前系统用户)"
|
||||||
|
jobs:
|
||||||
|
login:
|
||||||
|
fn: docker_login_tencent
|
||||||
|
kwargs:
|
||||||
|
username: ${USERNAME}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# envdev - 开发环境镜像源配置工具
|
||||||
|
# 用法:
|
||||||
|
# pf envdev
|
||||||
|
# pf envdev --python-mirror aliyun --conda-mirror ustc --rust-mirror ustc --rust-version nightly
|
||||||
|
# 说明
|
||||||
|
# 配置 Python / Conda / Rust 镜像源 (Linux 还会安装 Qt 库、中文字体、Docker).
|
||||||
|
# 所有镜像源参数互不影响, 可单独使用.
|
||||||
|
# Linux 专用操作 (系统镜像/Qt/字体/Docker) 在非 Linux 平台上由函数内部跳过.
|
||||||
|
strategy: thread
|
||||||
|
variables:
|
||||||
|
PYTHON_MIRROR: tsinghua
|
||||||
|
CONDA_MIRROR: tsinghua
|
||||||
|
RUST_MIRROR: tsinghua
|
||||||
|
RUST_VERSION: stable
|
||||||
|
cli:
|
||||||
|
description: "EnvDev - 开发环境镜像源配置工具"
|
||||||
|
usage: "pf envdev [options]"
|
||||||
|
options:
|
||||||
|
- name: PYTHON_MIRROR
|
||||||
|
flag: "--python-mirror"
|
||||||
|
type: str
|
||||||
|
default: tsinghua
|
||||||
|
help: "Python 镜像源: tsinghua/aliyun/huaweicloud/ustc/zju (默认: tsinghua)"
|
||||||
|
- name: CONDA_MIRROR
|
||||||
|
flag: "--conda-mirror"
|
||||||
|
type: str
|
||||||
|
default: tsinghua
|
||||||
|
help: "Conda 镜像源: tsinghua/ustc/bsfu/aliyun (默认: tsinghua)"
|
||||||
|
- name: RUST_MIRROR
|
||||||
|
flag: "--rust-mirror"
|
||||||
|
type: str
|
||||||
|
default: tsinghua
|
||||||
|
help: "Rust 镜像源: tsinghua/ustc/aliyun (默认: tsinghua)"
|
||||||
|
- name: RUST_VERSION
|
||||||
|
flag: "--rust-version"
|
||||||
|
type: str
|
||||||
|
default: stable
|
||||||
|
help: "Rust 版本: stable/nightly/beta (默认: stable)"
|
||||||
|
jobs:
|
||||||
|
# Linux 系统镜像配置 (函数内部判断平台与已配置状态, 非自动跳过)
|
||||||
|
setup_linux_mirror:
|
||||||
|
fn: setup_linux_system_mirror
|
||||||
|
# 安装 Qt 依赖 (仅 Linux, 函数内部判断)
|
||||||
|
install_qt_libs:
|
||||||
|
fn: install_linux_qt_libs
|
||||||
|
needs: [setup_linux_mirror]
|
||||||
|
allow-upstream-skip: true
|
||||||
|
# 安装中文字体 (仅 Linux, 函数内部判断)
|
||||||
|
install_fonts:
|
||||||
|
fn: install_linux_fonts
|
||||||
|
needs: [setup_linux_mirror]
|
||||||
|
allow-upstream-skip: true
|
||||||
|
# 安装 Docker (仅 Linux, 函数内部判断)
|
||||||
|
install_docker:
|
||||||
|
fn: install_linux_docker
|
||||||
|
needs: [setup_linux_mirror]
|
||||||
|
allow-upstream-skip: true
|
||||||
|
# 配置 Python 镜像源 (跨平台)
|
||||||
|
setup_python:
|
||||||
|
fn: setup_python_mirror
|
||||||
|
args: ["${PYTHON_MIRROR}"]
|
||||||
|
# 配置 Conda 镜像源 (跨平台)
|
||||||
|
setup_conda:
|
||||||
|
fn: setup_conda_mirror
|
||||||
|
args: ["${CONDA_MIRROR}"]
|
||||||
|
# 配置 Rust 镜像源 (跨平台)
|
||||||
|
setup_rust:
|
||||||
|
fn: setup_rust_mirror
|
||||||
|
args: ["${RUST_MIRROR}", "${RUST_VERSION}"]
|
||||||
|
# 下载 Rustup 安装脚本 (跨平台, 已安装时由函数内部跳过)
|
||||||
|
download_rustup:
|
||||||
|
fn: download_rustup_script
|
||||||
|
# 安装 Rust 工具链 (rustup 未安装时由函数内部跳过)
|
||||||
|
install_rust:
|
||||||
|
fn: install_rust_toolchain
|
||||||
|
args: ["${RUST_VERSION}"]
|
||||||
|
needs: [setup_rust, download_rustup]
|
||||||
|
allow-upstream-skip: true
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# filedate - 文件日期处理工具
|
||||||
|
# 用法:
|
||||||
|
# pf filedate add file1.txt file2.txt
|
||||||
|
# pf filedate clear file1.txt file2.txt
|
||||||
|
strategy: thread
|
||||||
|
variables:
|
||||||
|
FILES: []
|
||||||
|
cli:
|
||||||
|
description: "FileDate - 文件日期处理工具"
|
||||||
|
usage: "pf filedate <command> [files...]"
|
||||||
|
subcommands:
|
||||||
|
add:
|
||||||
|
help: "添加日期前缀"
|
||||||
|
positional:
|
||||||
|
- name: FILES
|
||||||
|
nargs: "+"
|
||||||
|
type: path
|
||||||
|
help: "文件路径"
|
||||||
|
clear:
|
||||||
|
help: "清除日期前缀"
|
||||||
|
positional:
|
||||||
|
- name: FILES
|
||||||
|
nargs: "+"
|
||||||
|
type: path
|
||||||
|
help: "文件路径"
|
||||||
|
jobs:
|
||||||
|
add:
|
||||||
|
fn: process_files_date
|
||||||
|
args: ["${FILES}"]
|
||||||
|
kwargs:
|
||||||
|
clear: false
|
||||||
|
clear:
|
||||||
|
fn: process_files_date
|
||||||
|
args: ["${FILES}"]
|
||||||
|
kwargs:
|
||||||
|
clear: true
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# filelevel - 文件等级重命名工具
|
||||||
|
# 用法:
|
||||||
|
# pf filelevel set file.txt --level 2
|
||||||
|
strategy: thread
|
||||||
|
variables:
|
||||||
|
FILES: []
|
||||||
|
LEVEL: 0
|
||||||
|
cli:
|
||||||
|
description: "FileLevel - 文件等级重命名工具"
|
||||||
|
usage: "pf filelevel <command> [files...] [options]"
|
||||||
|
subcommands:
|
||||||
|
set:
|
||||||
|
help: "设置文件等级"
|
||||||
|
positional:
|
||||||
|
- name: FILES
|
||||||
|
nargs: "+"
|
||||||
|
type: path
|
||||||
|
help: "文件路径"
|
||||||
|
options:
|
||||||
|
- name: LEVEL
|
||||||
|
flag: "--level"
|
||||||
|
type: int
|
||||||
|
required: true
|
||||||
|
help: "文件等级 (0-4)"
|
||||||
|
jobs:
|
||||||
|
set:
|
||||||
|
fn: process_files_level
|
||||||
|
args: ["${FILES}", "${LEVEL}"]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# folderback - 文件夹备份工具
|
||||||
|
# 用法:
|
||||||
|
# pf folderback
|
||||||
|
# pf folderback --src ./project --dst ./backup --max-zip 10
|
||||||
|
strategy: thread
|
||||||
|
variables:
|
||||||
|
SRC: "."
|
||||||
|
DST: "./backup"
|
||||||
|
MAX_ZIP: 5
|
||||||
|
cli:
|
||||||
|
description: "FolderBack - 文件夹备份工具"
|
||||||
|
usage: "pf folderback [options]"
|
||||||
|
options:
|
||||||
|
- name: SRC
|
||||||
|
flag: "--src"
|
||||||
|
type: str
|
||||||
|
default: "."
|
||||||
|
help: "源文件夹路径 (默认: 当前目录)"
|
||||||
|
- name: DST
|
||||||
|
flag: "--dst"
|
||||||
|
type: str
|
||||||
|
default: "./backup"
|
||||||
|
help: "目标文件夹路径 (默认: ./backup)"
|
||||||
|
- name: MAX_ZIP
|
||||||
|
flag: "--max-zip"
|
||||||
|
type: int
|
||||||
|
default: 5
|
||||||
|
help: "最大备份数量 (默认: 5)"
|
||||||
|
jobs:
|
||||||
|
backup:
|
||||||
|
fn: backup_folder
|
||||||
|
args: ["${SRC}", "${DST}"]
|
||||||
|
kwargs:
|
||||||
|
max_zip: ${MAX_ZIP}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# folderzip - 文件夹压缩工具
|
||||||
|
# 用法:
|
||||||
|
# pf folderzip
|
||||||
|
# pf folderzip --cwd ./project
|
||||||
|
strategy: thread
|
||||||
|
variables:
|
||||||
|
CWD: "."
|
||||||
|
cli:
|
||||||
|
description: "FolderZip - 文件夹压缩工具"
|
||||||
|
usage: "pf folderzip [options]"
|
||||||
|
options:
|
||||||
|
- name: CWD
|
||||||
|
flag: "--cwd"
|
||||||
|
type: str
|
||||||
|
required: false
|
||||||
|
default: "."
|
||||||
|
help: "工作目录 (默认: 当前目录)"
|
||||||
|
jobs:
|
||||||
|
zip:
|
||||||
|
fn: zip_folders
|
||||||
|
args: ["${CWD}"]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# gittool - Git 执行工具
|
||||||
|
# 用法:
|
||||||
|
# pf gittool a
|
||||||
|
# pf gittool c
|
||||||
|
# pf gittool i
|
||||||
|
# pf gittool isub
|
||||||
|
# pf gittool p
|
||||||
|
# pf gittool pl
|
||||||
|
strategy: thread
|
||||||
|
variables:
|
||||||
|
# git clean -e 参数列表 (展开为 cmd 数组元素)
|
||||||
|
CLEAN_EXCLUDES: ["-e", ".venv", "-e", ".tox", "-e", ".pytest_cache",
|
||||||
|
"-e", ".ruff_cache", "-e", "node_modules",
|
||||||
|
"-e", ".idea", "-e", ".vscode",
|
||||||
|
"-e", ".trae", "-e", ".qoder",
|
||||||
|
"-e", ".editorconfig", "-e", "idea.config",
|
||||||
|
"-e", "idea_modules.xml", "-e", "vcs.xml"]
|
||||||
|
cli:
|
||||||
|
description: "GitTool - Git 执行工具"
|
||||||
|
usage: "pf gittool <command>"
|
||||||
|
subcommands:
|
||||||
|
a:
|
||||||
|
help: "添加并提交"
|
||||||
|
c:
|
||||||
|
help: "清理并查看状态"
|
||||||
|
i:
|
||||||
|
help: "初始化并提交"
|
||||||
|
isub:
|
||||||
|
help: "初始化子目录"
|
||||||
|
p:
|
||||||
|
help: "推送"
|
||||||
|
pl:
|
||||||
|
help: "拉取"
|
||||||
|
jobs:
|
||||||
|
a:
|
||||||
|
fn: git_add_commit
|
||||||
|
args: ["chore: update"]
|
||||||
|
clean:
|
||||||
|
cmd: ["git", "clean", "-xfd", "${CLEAN_EXCLUDES}"]
|
||||||
|
c:
|
||||||
|
needs: [clean]
|
||||||
|
cmd: ["git", "status", "--porcelain"]
|
||||||
|
i:
|
||||||
|
fn: git_init_add_commit
|
||||||
|
args: ["init commit"]
|
||||||
|
isub:
|
||||||
|
fn: init_sub_dirs
|
||||||
|
p:
|
||||||
|
cmd: ["git", "push"]
|
||||||
|
pl:
|
||||||
|
cmd: ["git", "pull"]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# lscalc - LS-DYNA 计算工具
|
||||||
|
# 用法:
|
||||||
|
# pf lscalc run input.k --ncpu 4
|
||||||
|
# pf lscalc status
|
||||||
|
strategy: thread
|
||||||
|
variables:
|
||||||
|
INPUT_FILE: input.k
|
||||||
|
NCPU: 4
|
||||||
|
cli:
|
||||||
|
description: "LSCalc - LS-DYNA 计算工具"
|
||||||
|
usage: "pf lscalc <command> [options]"
|
||||||
|
subcommands:
|
||||||
|
run:
|
||||||
|
help: "运行 LS-DYNA 计算"
|
||||||
|
positional:
|
||||||
|
- name: INPUT_FILE
|
||||||
|
type: str
|
||||||
|
help: "输入文件路径"
|
||||||
|
options:
|
||||||
|
- name: NCPU
|
||||||
|
flag: "--ncpu"
|
||||||
|
type: int
|
||||||
|
default: 4
|
||||||
|
help: "CPU 核心数 (默认: 4)"
|
||||||
|
mpi:
|
||||||
|
help: "运行 LS-DYNA MPI 计算"
|
||||||
|
positional:
|
||||||
|
- name: INPUT_FILE
|
||||||
|
type: str
|
||||||
|
help: "输入文件路径"
|
||||||
|
options:
|
||||||
|
- name: NCPU
|
||||||
|
flag: "--ncpu"
|
||||||
|
type: int
|
||||||
|
default: 4
|
||||||
|
help: "CPU 核心数 (默认: 4)"
|
||||||
|
status:
|
||||||
|
help: "检查 LS-DYNA 进程状态"
|
||||||
|
jobs:
|
||||||
|
run:
|
||||||
|
fn: run_ls_dyna
|
||||||
|
args: ["${INPUT_FILE}"]
|
||||||
|
kwargs:
|
||||||
|
ncpu: ${NCPU}
|
||||||
|
mpi:
|
||||||
|
fn: run_ls_dyna_mpi
|
||||||
|
args: ["${INPUT_FILE}"]
|
||||||
|
kwargs:
|
||||||
|
ncpu: ${NCPU}
|
||||||
|
status:
|
||||||
|
fn: check_ls_dyna_status
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# msdownload - ModelScope 下载工具
|
||||||
|
# 用法:
|
||||||
|
# pf msdownload Qwen/Qwen2.5-Coder-32B-Instruct
|
||||||
|
# pf msdownload AI-ModelScope/MNIST --type dataset --dir ./data
|
||||||
|
strategy: thread
|
||||||
|
variables:
|
||||||
|
NAME: ""
|
||||||
|
TYPE: model
|
||||||
|
DIR: null
|
||||||
|
cli:
|
||||||
|
description: "MSDownload - ModelScope 模型/数据集下载工具"
|
||||||
|
usage: "pf msdownload <name> [--type TYPE] [--dir DIR]"
|
||||||
|
positional:
|
||||||
|
- name: NAME
|
||||||
|
type: str
|
||||||
|
help: "目标名称 (如: Qwen/Qwen2.5-Coder-32B-Instruct)"
|
||||||
|
options:
|
||||||
|
- name: TYPE
|
||||||
|
flag: "--type"
|
||||||
|
type: str
|
||||||
|
default: model
|
||||||
|
help: "目标类型: model / dataset / space (默认: model)"
|
||||||
|
- name: DIR
|
||||||
|
flag: "--dir"
|
||||||
|
type: str
|
||||||
|
default: null
|
||||||
|
help: "下载目录 (默认: ~/.models/<name>)"
|
||||||
|
jobs:
|
||||||
|
download:
|
||||||
|
fn: msdownload_run
|
||||||
|
args: ["${NAME}"]
|
||||||
|
kwargs:
|
||||||
|
target_type: ${TYPE}
|
||||||
|
download_dir: ${DIR}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# packtool - Python 打包工具
|
||||||
|
# 用法:
|
||||||
|
# pf packtool src --project-dir . --output-dir .pypack
|
||||||
|
# pf packtool deps requests numpy --lib-dir libs
|
||||||
|
# pf packtool wheel --project-dir . --output-dir dist
|
||||||
|
# pf packtool embed --version 3.10 --output-dir python
|
||||||
|
# pf packtool zip --source-dir . --output-file package.zip
|
||||||
|
# pf packtool clean
|
||||||
|
strategy: thread
|
||||||
|
variables:
|
||||||
|
PROJECT_DIR: "."
|
||||||
|
OUTPUT_DIR: ".pypack"
|
||||||
|
LIB_DIR: "libs"
|
||||||
|
DEPENDENCIES: []
|
||||||
|
VERSION: "3.10"
|
||||||
|
OUTPUT_FILE: "package.zip"
|
||||||
|
SOURCE_DIR: "."
|
||||||
|
cli:
|
||||||
|
description: "PackTool - Python 打包工具"
|
||||||
|
usage: "pf packtool <command> [options]"
|
||||||
|
subcommands:
|
||||||
|
src:
|
||||||
|
help: "打包源码"
|
||||||
|
options:
|
||||||
|
- name: PROJECT_DIR
|
||||||
|
flag: "--project-dir"
|
||||||
|
type: path
|
||||||
|
default: "."
|
||||||
|
help: "项目目录 (默认: .)"
|
||||||
|
- name: OUTPUT_DIR
|
||||||
|
flag: "--output-dir"
|
||||||
|
type: str
|
||||||
|
default: ".pypack"
|
||||||
|
help: "输出目录 (默认: .pypack)"
|
||||||
|
deps:
|
||||||
|
help: "打包依赖"
|
||||||
|
positional:
|
||||||
|
- name: DEPENDENCIES
|
||||||
|
nargs: "*"
|
||||||
|
type: str
|
||||||
|
help: "依赖包列表"
|
||||||
|
options:
|
||||||
|
- name: LIB_DIR
|
||||||
|
flag: "--lib-dir"
|
||||||
|
type: path
|
||||||
|
default: "libs"
|
||||||
|
help: "依赖库目录 (默认: libs)"
|
||||||
|
wheel:
|
||||||
|
help: "构建 wheel"
|
||||||
|
options:
|
||||||
|
- name: PROJECT_DIR
|
||||||
|
flag: "--project-dir"
|
||||||
|
type: path
|
||||||
|
default: "."
|
||||||
|
help: "项目目录 (默认: .)"
|
||||||
|
- name: OUTPUT_DIR
|
||||||
|
flag: "--output-dir"
|
||||||
|
type: path
|
||||||
|
default: "dist"
|
||||||
|
help: "输出目录 (默认: dist)"
|
||||||
|
embed:
|
||||||
|
help: "安装嵌入式 Python"
|
||||||
|
options:
|
||||||
|
- name: VERSION
|
||||||
|
flag: "--version"
|
||||||
|
type: str
|
||||||
|
default: "3.10"
|
||||||
|
help: "Python 版本 (默认: 3.10)"
|
||||||
|
- name: OUTPUT_DIR
|
||||||
|
flag: "--output-dir"
|
||||||
|
type: path
|
||||||
|
default: "python"
|
||||||
|
help: "输出目录 (默认: python)"
|
||||||
|
zip:
|
||||||
|
help: "创建 zip 包"
|
||||||
|
options:
|
||||||
|
- name: SOURCE_DIR
|
||||||
|
flag: "--source-dir"
|
||||||
|
type: path
|
||||||
|
default: "."
|
||||||
|
help: "源目录 (默认: .)"
|
||||||
|
- name: OUTPUT_FILE
|
||||||
|
flag: "--output-file"
|
||||||
|
type: path
|
||||||
|
default: "package.zip"
|
||||||
|
help: "输出文件 (默认: package.zip)"
|
||||||
|
clean:
|
||||||
|
help: "清理构建目录"
|
||||||
|
jobs:
|
||||||
|
src:
|
||||||
|
fn: pack_source
|
||||||
|
args: ["${PROJECT_DIR}", "${OUTPUT_DIR}"]
|
||||||
|
deps:
|
||||||
|
fn: pack_dependencies
|
||||||
|
args: ["${LIB_DIR}", "${DEPENDENCIES}"]
|
||||||
|
wheel:
|
||||||
|
fn: pack_wheel
|
||||||
|
args: ["${PROJECT_DIR}", "${OUTPUT_DIR}"]
|
||||||
|
embed:
|
||||||
|
fn: install_embed_python
|
||||||
|
args: ["${VERSION}", "${OUTPUT_DIR}"]
|
||||||
|
zip:
|
||||||
|
fn: create_zip_package
|
||||||
|
args: ["${SOURCE_DIR}", "${OUTPUT_FILE}"]
|
||||||
|
clean:
|
||||||
|
fn: clean_build_dir
|
||||||
|
args: ["${OUTPUT_DIR}"]
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
# pdftool - PDF 文件工具集
|
||||||
|
# 用法:
|
||||||
|
# pf pdftool m a.pdf b.pdf --output merged.pdf
|
||||||
|
# pf pdftool s input.pdf --output-dir split
|
||||||
|
# pf pdftool c input.pdf --output compressed.pdf
|
||||||
|
# pf pdftool e input.pdf --output encrypted.pdf --password 123456
|
||||||
|
# pf pdftool d input.pdf --output decrypted.pdf --password 123456
|
||||||
|
# pf pdftool xt input.pdf --output output.txt
|
||||||
|
# pf pdftool xi input.pdf --output-dir images
|
||||||
|
# pf pdftool w input.pdf --output watermarked.pdf --text CONFIDENTIAL
|
||||||
|
# pf pdftool r input.pdf --output rotated.pdf --rotation 90
|
||||||
|
# pf pdftool crop input.pdf --output cropped.pdf --left 10 --top 10 --right 10 --bottom 10
|
||||||
|
# pf pdftool i input.pdf
|
||||||
|
# pf pdftool ocr input.pdf --output ocr.pdf --lang chi_sim+eng
|
||||||
|
# pf pdftool img input.pdf --output-dir images --dpi 300
|
||||||
|
# pf pdftool repair input.pdf --output repaired.pdf
|
||||||
|
strategy: thread
|
||||||
|
variables:
|
||||||
|
INPUT: input.pdf
|
||||||
|
INPUTS: []
|
||||||
|
OUTPUT: output.pdf
|
||||||
|
OUTPUT_DIR: output
|
||||||
|
PASSWORD: ""
|
||||||
|
TEXT: CONFIDENTIAL
|
||||||
|
ROTATION: 90
|
||||||
|
MARGINS: [10, 10, 10, 10]
|
||||||
|
DPI: 300
|
||||||
|
LANG: chi_sim+eng
|
||||||
|
ORDER: []
|
||||||
|
LEFT: 10
|
||||||
|
TOP: 10
|
||||||
|
RIGHT: 10
|
||||||
|
BOTTOM: 10
|
||||||
|
cli:
|
||||||
|
description: "PdfTool - PDF 文件工具集"
|
||||||
|
usage: "pf pdftool <command> [options]"
|
||||||
|
subcommands:
|
||||||
|
m:
|
||||||
|
help: "合并 PDF"
|
||||||
|
positional:
|
||||||
|
- name: INPUTS
|
||||||
|
nargs: "+"
|
||||||
|
type: path
|
||||||
|
help: "输入 PDF 文件列表"
|
||||||
|
options:
|
||||||
|
- name: OUTPUT
|
||||||
|
flag: "--output"
|
||||||
|
type: path
|
||||||
|
default: "merged.pdf"
|
||||||
|
help: "输出文件 (默认: merged.pdf)"
|
||||||
|
s:
|
||||||
|
help: "拆分 PDF"
|
||||||
|
positional:
|
||||||
|
- name: INPUT
|
||||||
|
type: path
|
||||||
|
help: "输入 PDF 文件"
|
||||||
|
options:
|
||||||
|
- name: OUTPUT_DIR
|
||||||
|
flag: "--output-dir"
|
||||||
|
type: path
|
||||||
|
default: "split"
|
||||||
|
help: "输出目录 (默认: split)"
|
||||||
|
c:
|
||||||
|
help: "压缩 PDF"
|
||||||
|
positional:
|
||||||
|
- name: INPUT
|
||||||
|
type: path
|
||||||
|
help: "输入 PDF 文件"
|
||||||
|
options:
|
||||||
|
- name: OUTPUT
|
||||||
|
flag: "--output"
|
||||||
|
type: path
|
||||||
|
default: "compressed.pdf"
|
||||||
|
help: "输出文件 (默认: compressed.pdf)"
|
||||||
|
e:
|
||||||
|
help: "加密 PDF"
|
||||||
|
positional:
|
||||||
|
- name: INPUT
|
||||||
|
type: path
|
||||||
|
help: "输入 PDF 文件"
|
||||||
|
options:
|
||||||
|
- name: OUTPUT
|
||||||
|
flag: "--output"
|
||||||
|
type: path
|
||||||
|
default: "encrypted.pdf"
|
||||||
|
help: "输出文件 (默认: encrypted.pdf)"
|
||||||
|
- name: PASSWORD
|
||||||
|
flag: "--password"
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
help: "密码 (必填)"
|
||||||
|
d:
|
||||||
|
help: "解密 PDF"
|
||||||
|
positional:
|
||||||
|
- name: INPUT
|
||||||
|
type: path
|
||||||
|
help: "输入 PDF 文件"
|
||||||
|
options:
|
||||||
|
- name: OUTPUT
|
||||||
|
flag: "--output"
|
||||||
|
type: path
|
||||||
|
default: "decrypted.pdf"
|
||||||
|
help: "输出文件 (默认: decrypted.pdf)"
|
||||||
|
- name: PASSWORD
|
||||||
|
flag: "--password"
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
help: "密码 (必填)"
|
||||||
|
xt:
|
||||||
|
help: "提取文本"
|
||||||
|
positional:
|
||||||
|
- name: INPUT
|
||||||
|
type: path
|
||||||
|
help: "输入 PDF 文件"
|
||||||
|
options:
|
||||||
|
- name: OUTPUT
|
||||||
|
flag: "--output"
|
||||||
|
type: path
|
||||||
|
default: "output.txt"
|
||||||
|
help: "输出文件 (默认: output.txt)"
|
||||||
|
xi:
|
||||||
|
help: "提取图片"
|
||||||
|
positional:
|
||||||
|
- name: INPUT
|
||||||
|
type: path
|
||||||
|
help: "输入 PDF 文件"
|
||||||
|
options:
|
||||||
|
- name: OUTPUT_DIR
|
||||||
|
flag: "--output-dir"
|
||||||
|
type: path
|
||||||
|
default: "images"
|
||||||
|
help: "输出目录 (默认: images)"
|
||||||
|
w:
|
||||||
|
help: "添加水印"
|
||||||
|
positional:
|
||||||
|
- name: INPUT
|
||||||
|
type: path
|
||||||
|
help: "输入 PDF 文件"
|
||||||
|
options:
|
||||||
|
- name: OUTPUT
|
||||||
|
flag: "--output"
|
||||||
|
type: path
|
||||||
|
default: "watermarked.pdf"
|
||||||
|
help: "输出文件 (默认: watermarked.pdf)"
|
||||||
|
- name: TEXT
|
||||||
|
flag: "--text"
|
||||||
|
type: str
|
||||||
|
default: "CONFIDENTIAL"
|
||||||
|
help: "水印文字 (默认: CONFIDENTIAL)"
|
||||||
|
r:
|
||||||
|
help: "旋转 PDF"
|
||||||
|
positional:
|
||||||
|
- name: INPUT
|
||||||
|
type: path
|
||||||
|
help: "输入 PDF 文件"
|
||||||
|
options:
|
||||||
|
- name: OUTPUT
|
||||||
|
flag: "--output"
|
||||||
|
type: path
|
||||||
|
default: "rotated.pdf"
|
||||||
|
help: "输出文件 (默认: rotated.pdf)"
|
||||||
|
- name: ROTATION
|
||||||
|
flag: "--rotation"
|
||||||
|
type: int
|
||||||
|
default: 90
|
||||||
|
help: "旋转角度 (默认: 90)"
|
||||||
|
crop:
|
||||||
|
help: "裁剪 PDF"
|
||||||
|
positional:
|
||||||
|
- name: INPUT
|
||||||
|
type: path
|
||||||
|
help: "输入 PDF 文件"
|
||||||
|
options:
|
||||||
|
- name: OUTPUT
|
||||||
|
flag: "--output"
|
||||||
|
type: path
|
||||||
|
default: "cropped.pdf"
|
||||||
|
help: "输出文件 (默认: cropped.pdf)"
|
||||||
|
- name: LEFT
|
||||||
|
flag: "--left"
|
||||||
|
type: int
|
||||||
|
default: 10
|
||||||
|
help: "左边距 (默认: 10)"
|
||||||
|
- name: TOP
|
||||||
|
flag: "--top"
|
||||||
|
type: int
|
||||||
|
default: 10
|
||||||
|
help: "上边距 (默认: 10)"
|
||||||
|
- name: RIGHT
|
||||||
|
flag: "--right"
|
||||||
|
type: int
|
||||||
|
default: 10
|
||||||
|
help: "右边距 (默认: 10)"
|
||||||
|
- name: BOTTOM
|
||||||
|
flag: "--bottom"
|
||||||
|
type: int
|
||||||
|
default: 10
|
||||||
|
help: "下边距 (默认: 10)"
|
||||||
|
i:
|
||||||
|
help: "查看 PDF 信息"
|
||||||
|
positional:
|
||||||
|
- name: INPUT
|
||||||
|
type: path
|
||||||
|
help: "输入 PDF 文件"
|
||||||
|
ocr:
|
||||||
|
help: "PDF OCR 识别"
|
||||||
|
positional:
|
||||||
|
- name: INPUT
|
||||||
|
type: path
|
||||||
|
help: "输入 PDF 文件"
|
||||||
|
options:
|
||||||
|
- name: OUTPUT
|
||||||
|
flag: "--output"
|
||||||
|
type: path
|
||||||
|
default: "ocr.pdf"
|
||||||
|
help: "输出文件 (默认: ocr.pdf)"
|
||||||
|
- name: LANG
|
||||||
|
flag: "--lang"
|
||||||
|
type: str
|
||||||
|
default: "chi_sim+eng"
|
||||||
|
help: "识别语言 (默认: chi_sim+eng)"
|
||||||
|
img:
|
||||||
|
help: "PDF 转图片"
|
||||||
|
positional:
|
||||||
|
- name: INPUT
|
||||||
|
type: path
|
||||||
|
help: "输入 PDF 文件"
|
||||||
|
options:
|
||||||
|
- name: OUTPUT_DIR
|
||||||
|
flag: "--output-dir"
|
||||||
|
type: path
|
||||||
|
default: "images"
|
||||||
|
help: "输出目录 (默认: images)"
|
||||||
|
- name: DPI
|
||||||
|
flag: "--dpi"
|
||||||
|
type: int
|
||||||
|
default: 300
|
||||||
|
help: "DPI (默认: 300)"
|
||||||
|
repair:
|
||||||
|
help: "修复 PDF"
|
||||||
|
positional:
|
||||||
|
- name: INPUT
|
||||||
|
type: path
|
||||||
|
help: "输入 PDF 文件"
|
||||||
|
options:
|
||||||
|
- name: OUTPUT
|
||||||
|
flag: "--output"
|
||||||
|
type: path
|
||||||
|
default: "repaired.pdf"
|
||||||
|
help: "输出文件 (默认: repaired.pdf)"
|
||||||
|
jobs:
|
||||||
|
m:
|
||||||
|
fn: pdf_merge
|
||||||
|
args: ["${INPUTS}", "${OUTPUT}"]
|
||||||
|
s:
|
||||||
|
fn: pdf_split
|
||||||
|
args: ["${INPUT}", "${OUTPUT_DIR}"]
|
||||||
|
c:
|
||||||
|
fn: pdf_compress
|
||||||
|
args: ["${INPUT}", "${OUTPUT}"]
|
||||||
|
e:
|
||||||
|
fn: pdf_encrypt
|
||||||
|
args: ["${INPUT}", "${OUTPUT}", "${PASSWORD}"]
|
||||||
|
d:
|
||||||
|
fn: pdf_decrypt
|
||||||
|
args: ["${INPUT}", "${OUTPUT}", "${PASSWORD}"]
|
||||||
|
xt:
|
||||||
|
fn: pdf_extract_text
|
||||||
|
args: ["${INPUT}", "${OUTPUT}"]
|
||||||
|
xi:
|
||||||
|
fn: pdf_extract_images
|
||||||
|
args: ["${INPUT}", "${OUTPUT_DIR}"]
|
||||||
|
w:
|
||||||
|
fn: pdf_add_watermark
|
||||||
|
args: ["${INPUT}", "${OUTPUT}"]
|
||||||
|
kwargs:
|
||||||
|
text: "${TEXT}"
|
||||||
|
r:
|
||||||
|
fn: pdf_rotate
|
||||||
|
args: ["${INPUT}", "${OUTPUT}"]
|
||||||
|
kwargs:
|
||||||
|
rotation: ${ROTATION}
|
||||||
|
crop:
|
||||||
|
fn: pdf_crop
|
||||||
|
args: ["${INPUT}", "${OUTPUT}"]
|
||||||
|
kwargs:
|
||||||
|
margins: "${MARGINS}"
|
||||||
|
i:
|
||||||
|
fn: pdf_info
|
||||||
|
args: ["${INPUT}"]
|
||||||
|
ocr:
|
||||||
|
fn: pdf_ocr
|
||||||
|
args: ["${INPUT}", "${OUTPUT}"]
|
||||||
|
kwargs:
|
||||||
|
lang: "${LANG}"
|
||||||
|
img:
|
||||||
|
fn: pdf_to_images
|
||||||
|
args: ["${INPUT}", "${OUTPUT_DIR}"]
|
||||||
|
kwargs:
|
||||||
|
dpi: ${DPI}
|
||||||
|
repair:
|
||||||
|
fn: pdf_repair
|
||||||
|
args: ["${INPUT}", "${OUTPUT}"]
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# piptool - pip 包管理工具
|
||||||
|
# 用法:
|
||||||
|
# pf piptool i requests
|
||||||
|
# pf piptool u requests
|
||||||
|
# pf piptool r requests
|
||||||
|
# pf piptool d requests
|
||||||
|
# pf piptool up
|
||||||
|
# pf piptool f
|
||||||
|
strategy: thread
|
||||||
|
variables:
|
||||||
|
PACKAGES: []
|
||||||
|
OFFLINE: false
|
||||||
|
cli:
|
||||||
|
description: "PipTool - pip 包管理工具"
|
||||||
|
usage: "pf piptool <command> [packages...] [options]"
|
||||||
|
subcommands:
|
||||||
|
i:
|
||||||
|
help: "安装包"
|
||||||
|
positional:
|
||||||
|
- name: PACKAGES
|
||||||
|
nargs: "+"
|
||||||
|
type: str
|
||||||
|
help: "包名列表"
|
||||||
|
u:
|
||||||
|
help: "卸载包"
|
||||||
|
positional:
|
||||||
|
- name: PACKAGES
|
||||||
|
nargs: "+"
|
||||||
|
type: str
|
||||||
|
help: "包名列表"
|
||||||
|
r:
|
||||||
|
help: "重装包"
|
||||||
|
positional:
|
||||||
|
- name: PACKAGES
|
||||||
|
nargs: "+"
|
||||||
|
type: str
|
||||||
|
help: "包名列表"
|
||||||
|
options:
|
||||||
|
- name: OFFLINE
|
||||||
|
flag: "--offline"
|
||||||
|
action: "store_true"
|
||||||
|
help: "离线模式"
|
||||||
|
d:
|
||||||
|
help: "下载包"
|
||||||
|
positional:
|
||||||
|
- name: PACKAGES
|
||||||
|
nargs: "+"
|
||||||
|
type: str
|
||||||
|
help: "包名列表"
|
||||||
|
options:
|
||||||
|
- name: OFFLINE
|
||||||
|
flag: "--offline"
|
||||||
|
action: "store_true"
|
||||||
|
help: "离线模式"
|
||||||
|
up:
|
||||||
|
help: "升级 pip"
|
||||||
|
f:
|
||||||
|
help: "导出依赖"
|
||||||
|
jobs:
|
||||||
|
i:
|
||||||
|
cmd: ["pip", "install", "${PACKAGES}"]
|
||||||
|
u:
|
||||||
|
fn: pip_uninstall
|
||||||
|
args: ["${PACKAGES}"]
|
||||||
|
r:
|
||||||
|
fn: pip_reinstall
|
||||||
|
args: ["${PACKAGES}"]
|
||||||
|
kwargs:
|
||||||
|
offline: ${OFFLINE}
|
||||||
|
d:
|
||||||
|
fn: pip_download
|
||||||
|
args: ["${PACKAGES}"]
|
||||||
|
kwargs:
|
||||||
|
offline: ${OFFLINE}
|
||||||
|
up:
|
||||||
|
cmd: ["python", "-m", "pip", "install", "--upgrade", "pip"]
|
||||||
|
f:
|
||||||
|
fn: pip_freeze
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# pymake - 项目构建工具
|
||||||
|
# 用法
|
||||||
|
# pf pymake <command>
|
||||||
|
# 命令
|
||||||
|
# b: 构建 Python 主包 (uv build)
|
||||||
|
# ba: 构建所有包 (Python + Rust)
|
||||||
|
# bc: 构建 Rust 核心模块 (maturin build)
|
||||||
|
# bump: 升级版本号 (清理 + 检查 + add + bumpversion)
|
||||||
|
# bumpmi: 升级次版本号 (bumpversion minor)
|
||||||
|
# c: 清理构建产物 (调用 gitt c)
|
||||||
|
# cov: 测试并生成覆盖率
|
||||||
|
# doc: 构建 Sphinx 文档
|
||||||
|
# lint: 代码格式化与检查 (ruff)
|
||||||
|
# p: 推送代码 (清理 + push + push tags)
|
||||||
|
# pb: 发布到 PyPI (twine + hatch)
|
||||||
|
# sync: 同步依赖 (uv sync)
|
||||||
|
# t: 运行测试
|
||||||
|
# tc: 类型检查 (pyrefly + ruff)
|
||||||
|
# tf: 快速测试 (无 slow)
|
||||||
|
# tox: 多版本测试 (tox)
|
||||||
|
strategy: thread
|
||||||
|
variables:
|
||||||
|
CWD: "."
|
||||||
|
cli:
|
||||||
|
description: "PyMake - 项目构建工具"
|
||||||
|
usage: "pf pymake <command>"
|
||||||
|
options:
|
||||||
|
- name: CWD
|
||||||
|
flag: "--cwd"
|
||||||
|
type: path
|
||||||
|
required: false
|
||||||
|
default: "."
|
||||||
|
help: "工作目录 (默认: 当前目录)"
|
||||||
|
subcommands:
|
||||||
|
b: {help: "构建 Python 主包 (uv build)"}
|
||||||
|
ba: {help: "构建所有包 (Python + Rust)"}
|
||||||
|
bc: {help: "构建 Rust 核心模块 (maturin build)"}
|
||||||
|
bump: {help: "升级版本号 (清理 + 检查 + add + bumpversion)"}
|
||||||
|
bumpmi: {help: "升级次版本号 (bumpversion minor)"}
|
||||||
|
c: {help: "清理构建产物 (调用 gitt c)"}
|
||||||
|
cov: {help: "测试并生成覆盖率"}
|
||||||
|
doc: {help: "构建 Sphinx 文档"}
|
||||||
|
lint: {help: "代码格式化与检查 (ruff)"}
|
||||||
|
p: {help: "推送代码 (清理 + push + push tags)"}
|
||||||
|
pb: {help: "发布到 PyPI (twine + hatch)"}
|
||||||
|
sync: {help: "同步依赖 (uv sync)"}
|
||||||
|
t: {help: "运行测试"}
|
||||||
|
tc: {help: "类型检查 (pyrefly + ruff)"}
|
||||||
|
tf: {help: "快速测试 (无 slow)"}
|
||||||
|
tox: {help: "多版本测试 (tox)"}
|
||||||
|
jobs:
|
||||||
|
# 单任务别名
|
||||||
|
b:
|
||||||
|
cmd: ["uv", "build"]
|
||||||
|
cwd: ${CWD}
|
||||||
|
bc:
|
||||||
|
cmd: ["maturin", "build", "-r"]
|
||||||
|
cwd: ${CWD}
|
||||||
|
sync:
|
||||||
|
cmd: ["uv", "sync"]
|
||||||
|
cwd: ${CWD}
|
||||||
|
c:
|
||||||
|
cmd: ["pf", "gitt", "c"]
|
||||||
|
cwd: ${CWD}
|
||||||
|
t:
|
||||||
|
cmd: ["pytest", "-m", "not slow", "-n", "8", "--dist", "loadfile", "--color=yes", "--durations=10"]
|
||||||
|
cwd: ${CWD}
|
||||||
|
tf:
|
||||||
|
cmd: ["pytest", "-m", "not slow", "--dist", "loadfile", "--color=yes", "--durations=10"]
|
||||||
|
cwd: ${CWD}
|
||||||
|
bumpversion:
|
||||||
|
cmd: ["pf", "bumpversion", "patch"]
|
||||||
|
needs: [git_add_all]
|
||||||
|
cwd: ${CWD}
|
||||||
|
bumpmi:
|
||||||
|
cmd: ["pf", "bumpversion", "minor"]
|
||||||
|
cwd: ${CWD}
|
||||||
|
doc:
|
||||||
|
cmd: ["sphinx-build", "-b", "html", "docs", "docs/_build"]
|
||||||
|
cwd: ${CWD}
|
||||||
|
lint:
|
||||||
|
cmd: ["ruff", "check", "--fix", "--unsafe-fixes"]
|
||||||
|
cwd: ${CWD}
|
||||||
|
tox:
|
||||||
|
cmd: ["tox", "-p", "auto"]
|
||||||
|
cwd: ${CWD}
|
||||||
|
|
||||||
|
# 内部 job (不暴露为 subcommand)
|
||||||
|
test_coverage:
|
||||||
|
cmd: ["pytest", "--cov", "-n", "8", "--dist", "loadfile", "--tb=short", "-v", "--color=yes", "--durations=10"]
|
||||||
|
needs: [c]
|
||||||
|
cwd: ${CWD}
|
||||||
|
pyrefly_check:
|
||||||
|
cmd: ["pyrefly", "check", "."]
|
||||||
|
cwd: ${CWD}
|
||||||
|
git_add_all:
|
||||||
|
cmd: ["git", "add", "-A"]
|
||||||
|
needs: [tc]
|
||||||
|
cwd: ${CWD}
|
||||||
|
git_push:
|
||||||
|
cmd: ["git", "push"]
|
||||||
|
cwd: ${CWD}
|
||||||
|
git_push_tags:
|
||||||
|
cmd: ["git", "push", "--tags"]
|
||||||
|
cwd: ${CWD}
|
||||||
|
twine_publish:
|
||||||
|
cmd: ["twine", "upload", "--disable-progress-bar"]
|
||||||
|
cwd: ${CWD}
|
||||||
|
publish_python:
|
||||||
|
cmd: ["hatch", "publish"]
|
||||||
|
cwd: ${CWD}
|
||||||
|
|
||||||
|
# 聚合 job (方向 B: 有 needs 无 cmd/fn)
|
||||||
|
ba:
|
||||||
|
needs: [b, bc]
|
||||||
|
bump:
|
||||||
|
needs: [bumpversion]
|
||||||
|
cov:
|
||||||
|
needs: [test_coverage]
|
||||||
|
tc:
|
||||||
|
needs: [c, pyrefly_check, lint]
|
||||||
|
p:
|
||||||
|
needs: [c, git_push, git_push_tags]
|
||||||
|
pb:
|
||||||
|
needs: [twine_publish, publish_python]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# reseticoncache - 重置 Windows 图标缓存
|
||||||
|
# 用法
|
||||||
|
# pf reseticon
|
||||||
|
# 说明
|
||||||
|
# 杀掉 explorer → 删除 IconCache.db → 删除 iconcache* → 重启 explorer
|
||||||
|
# 仅在 Windows 上有效, 非 Windows 平台打印提示并跳过
|
||||||
|
strategy: sequential
|
||||||
|
cli:
|
||||||
|
description: "重置 Windows 图标缓存"
|
||||||
|
usage: "pf reseticon"
|
||||||
|
jobs:
|
||||||
|
reset:
|
||||||
|
fn: reset_icon_cache_run
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# screenshot - 截图工具
|
||||||
|
# 用法:
|
||||||
|
# pf screenshot full
|
||||||
|
# pf screenshot area --filename custom.png
|
||||||
|
strategy: thread
|
||||||
|
variables:
|
||||||
|
FILENAME: null
|
||||||
|
cli:
|
||||||
|
description: "Screenshot - 截图工具"
|
||||||
|
usage: "pf screenshot <command> [options]"
|
||||||
|
subcommands:
|
||||||
|
full:
|
||||||
|
help: "全屏截图"
|
||||||
|
options:
|
||||||
|
- name: FILENAME
|
||||||
|
flag: "--filename"
|
||||||
|
type: str
|
||||||
|
help: "文件名"
|
||||||
|
area:
|
||||||
|
help: "区域截图"
|
||||||
|
options:
|
||||||
|
- name: FILENAME
|
||||||
|
flag: "--filename"
|
||||||
|
type: str
|
||||||
|
help: "文件名"
|
||||||
|
jobs:
|
||||||
|
full:
|
||||||
|
fn: take_screenshot_full
|
||||||
|
kwargs:
|
||||||
|
filename: "${FILENAME}"
|
||||||
|
area:
|
||||||
|
fn: take_screenshot_area
|
||||||
|
kwargs:
|
||||||
|
filename: "${FILENAME}"
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# sglang - SGLang 本地模型服务
|
||||||
|
# 用法:
|
||||||
|
# pf sglang
|
||||||
|
# pf sglang --model ~/.models/Qwen2.5-Coder-32B-Instruct-AWQ
|
||||||
|
# pf sglang --port 9000 --mem 0.8
|
||||||
|
strategy: sequential
|
||||||
|
variables:
|
||||||
|
MODEL: "~/.models/Qwen2.5-Coder-32B-Instruct-AWQ"
|
||||||
|
PORT: 8000
|
||||||
|
CTX_LEN: 32768
|
||||||
|
MEM: 0.75
|
||||||
|
HOST: "0.0.0.0"
|
||||||
|
LOG_LEVEL: "info"
|
||||||
|
cli:
|
||||||
|
description: "SGLang - 本地模型服务启动工具"
|
||||||
|
usage: "pf sglang [options]"
|
||||||
|
options:
|
||||||
|
- name: MODEL
|
||||||
|
flag: "--model"
|
||||||
|
type: str
|
||||||
|
default: "~/.models/Qwen2.5-Coder-32B-Instruct-AWQ"
|
||||||
|
help: "模型路径"
|
||||||
|
- name: PORT
|
||||||
|
flag: "--port"
|
||||||
|
type: int
|
||||||
|
default: 8000
|
||||||
|
help: "服务端口 (默认: 8000)"
|
||||||
|
- name: CTX_LEN
|
||||||
|
flag: "--ctx-len"
|
||||||
|
type: int
|
||||||
|
default: 32768
|
||||||
|
help: "最大上下文长度 (默认: 32768)"
|
||||||
|
- name: MEM
|
||||||
|
flag: "--mem"
|
||||||
|
type: float
|
||||||
|
default: 0.75
|
||||||
|
help: "显存占比 0-1 (默认: 0.75)"
|
||||||
|
- name: HOST
|
||||||
|
flag: "--host"
|
||||||
|
type: str
|
||||||
|
default: "0.0.0.0"
|
||||||
|
help: "主机地址 (默认: 0.0.0.0)"
|
||||||
|
- name: LOG_LEVEL
|
||||||
|
flag: "--log-level"
|
||||||
|
type: str
|
||||||
|
default: "info"
|
||||||
|
help: "日志级别 (默认: info)"
|
||||||
|
jobs:
|
||||||
|
install:
|
||||||
|
fn: install_sglang
|
||||||
|
run:
|
||||||
|
fn: run_sglang
|
||||||
|
needs: [install]
|
||||||
|
kwargs:
|
||||||
|
model: ${MODEL}
|
||||||
|
port: ${PORT}
|
||||||
|
ctx_len: ${CTX_LEN}
|
||||||
|
mem_fraction: ${MEM}
|
||||||
|
host: ${HOST}
|
||||||
|
log_level: ${LOG_LEVEL}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# sshcopyid - SSH 密钥部署工具
|
||||||
|
# 用法:
|
||||||
|
# pf sshcopyid hostname username password
|
||||||
|
# pf sshcopyid server user pass --port 2222
|
||||||
|
strategy: thread
|
||||||
|
variables:
|
||||||
|
HOSTNAME: ""
|
||||||
|
USERNAME: ""
|
||||||
|
PASSWORD: ""
|
||||||
|
PORT: 22
|
||||||
|
KEYPATH: "~/.ssh/id_rsa.pub"
|
||||||
|
TIMEOUT: 30
|
||||||
|
cli:
|
||||||
|
description: "SSHCopyID - SSH 密钥部署工具"
|
||||||
|
usage: "pf sshcopyid <hostname> <username> <password> [options]"
|
||||||
|
positional:
|
||||||
|
- name: HOSTNAME
|
||||||
|
type: str
|
||||||
|
help: "远程服务器主机名或 IP 地址"
|
||||||
|
- name: USERNAME
|
||||||
|
type: str
|
||||||
|
help: "远程服务器用户名"
|
||||||
|
- name: PASSWORD
|
||||||
|
type: str
|
||||||
|
help: "远程服务器密码"
|
||||||
|
options:
|
||||||
|
- name: PORT
|
||||||
|
flag: "--port"
|
||||||
|
type: int
|
||||||
|
default: 22
|
||||||
|
help: "SSH 端口 (默认: 22)"
|
||||||
|
- name: KEYPATH
|
||||||
|
flag: "--keypath"
|
||||||
|
type: str
|
||||||
|
default: "~/.ssh/id_rsa.pub"
|
||||||
|
help: "公钥文件路径"
|
||||||
|
- name: TIMEOUT
|
||||||
|
flag: "--timeout"
|
||||||
|
type: int
|
||||||
|
default: 30
|
||||||
|
help: "SSH 操作超时秒数 (默认: 30)"
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
fn: ssh_copy_id
|
||||||
|
args: ["${HOSTNAME}", "${USERNAME}", "${PASSWORD}"]
|
||||||
|
kwargs:
|
||||||
|
port: ${PORT}
|
||||||
|
keypath: "${KEYPATH}"
|
||||||
|
timeout: ${TIMEOUT}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# taskkill - 进程终止工具
|
||||||
|
# 用法:
|
||||||
|
# pf taskkill chrome.exe python node
|
||||||
|
strategy: thread
|
||||||
|
variables:
|
||||||
|
PROCESS_NAMES: []
|
||||||
|
cli:
|
||||||
|
description: "TaskKill - 进程终止工具 (跨平台)"
|
||||||
|
usage: "pf taskkill <process_name> [process_name ...]"
|
||||||
|
positional:
|
||||||
|
- name: PROCESS_NAMES
|
||||||
|
nargs: "+"
|
||||||
|
type: str
|
||||||
|
help: "进程名称 (如: chrome.exe python node)"
|
||||||
|
jobs:
|
||||||
|
kill:
|
||||||
|
fn: taskkill_run
|
||||||
|
args: ["${PROCESS_NAMES}"]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# which - 命令查找工具
|
||||||
|
# 用法:
|
||||||
|
# pf which python ls ps gcc
|
||||||
|
strategy: thread
|
||||||
|
variables:
|
||||||
|
COMMANDS: []
|
||||||
|
cli:
|
||||||
|
description: "Which - 命令查找工具 (跨平台)"
|
||||||
|
usage: "pf which <command> [command ...]"
|
||||||
|
positional:
|
||||||
|
- name: COMMANDS
|
||||||
|
nargs: "+"
|
||||||
|
type: str
|
||||||
|
help: "要查找的命令名称, 如: python ls ps gcc"
|
||||||
|
jobs:
|
||||||
|
find:
|
||||||
|
fn: which_run
|
||||||
|
args: ["${COMMANDS}"]
|
||||||
@@ -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()
|
|
||||||
@@ -792,6 +792,13 @@ def run(
|
|||||||
_print_dry_run(graph, layers)
|
_print_dry_run(graph, layers)
|
||||||
return RunReport(success=True)
|
return RunReport(success=True)
|
||||||
|
|
||||||
|
# verbose 模式下, 把所有 spec 的 verbose 标记设为 True,
|
||||||
|
# 使 execute_command 打印执行命令与返回码 (任务生命周期由 callback 打印)
|
||||||
|
if verbose:
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
graph = Graph.from_specs([replace(s, verbose=True) if not s.verbose else s for s in graph.all_specs().values()])
|
||||||
|
|
||||||
# 入口统一校验一次:所有策略共用,避免 layers() / dependency 路径
|
# 入口统一校验一次:所有策略共用,避免 layers() / dependency 路径
|
||||||
# 各自重复调用 validate()。
|
# 各自重复调用 validate()。
|
||||||
graph.validate()
|
graph.validate()
|
||||||
|
|||||||
+33
-1
@@ -20,6 +20,7 @@ __all__ = [
|
|||||||
import inspect
|
import inspect
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, field, replace
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Iterable, Mapping, Sequence
|
from typing import Any, Callable, Iterable, Mapping, Sequence
|
||||||
|
|
||||||
from .errors import CycleError, DuplicateTaskError, MissingDependencyError
|
from .errors import CycleError, DuplicateTaskError, MissingDependencyError
|
||||||
@@ -219,7 +220,6 @@ class Graph:
|
|||||||
"""
|
"""
|
||||||
graph = cls(defaults=defaults or GraphDefaults(), namespace=namespace)
|
graph = cls(defaults=defaults or GraphDefaults(), namespace=namespace)
|
||||||
pending_refs: list[str] = []
|
pending_refs: list[str] = []
|
||||||
|
|
||||||
for spec in specs:
|
for spec in specs:
|
||||||
if isinstance(spec, str):
|
if isinstance(spec, str):
|
||||||
pending_refs.append(spec)
|
pending_refs.append(spec)
|
||||||
@@ -235,6 +235,38 @@ class Graph:
|
|||||||
graph.validate()
|
graph.validate()
|
||||||
return graph
|
return graph
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_yaml(
|
||||||
|
cls,
|
||||||
|
path: str | Path,
|
||||||
|
variables: Mapping[str, Any] | None = None,
|
||||||
|
) -> Graph:
|
||||||
|
"""从 YAML 文件构建任务图。
|
||||||
|
|
||||||
|
参考 GitHub Actions 风格 schema, 支持 jobs/needs/strategy.matrix/if
|
||||||
|
等 CI/CD 概念。详见 :mod:`pyflowx.yaml_loader`。
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
path : str | Path
|
||||||
|
YAML 文件路径
|
||||||
|
variables : Mapping[str, Any] | None
|
||||||
|
运行时变量, 用于替换 ``${VAR}`` 占位符
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Graph
|
||||||
|
构建好的任务图
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
YamlLoadError
|
||||||
|
文件不存在、YAML 格式错误、schema 校验失败、循环依赖等
|
||||||
|
"""
|
||||||
|
from .yaml_loader import load_yaml
|
||||||
|
|
||||||
|
return load_yaml(path, variables=variables)
|
||||||
|
|
||||||
def add_subgraph(self, sub: Graph, *, namespace: str | None = None) -> Graph:
|
def add_subgraph(self, sub: Graph, *, namespace: str | None = None) -> Graph:
|
||||||
"""将子图合并到当前图,任务名加命名空间前缀避免冲突。
|
"""将子图合并到当前图,任务名加命名空间前缀避免冲突。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""工具函数模块.
|
||||||
|
|
||||||
|
按类别组织 CLI 工具中可复用的函数, 每个子模块使用 ``@px.register_fn`` 注册函数,
|
||||||
|
供 YAML 任务编排通过 ``fn`` 字段引用.
|
||||||
|
|
||||||
|
子模块
|
||||||
|
------
|
||||||
|
- :mod:`files` —— 文件日期/等级/备份/压缩相关函数
|
||||||
|
- :mod:`dev` —— 开发工具 (ruff/pip/git/envdev/dockercmd) 相关函数
|
||||||
|
- :mod:`bumpversion` —— 版本号管理相关函数
|
||||||
|
- :mod:`media` —— PDF/截图相关函数
|
||||||
|
- :mod:`system` —— LS-DYNA/SSH/打包/清屏/进程终止相关函数
|
||||||
|
- :mod:`llm` —— ModelScope 下载/SGLang 服务相关函数
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from . import bumpversion, dev, files, llm, media, system
|
||||||
|
|
||||||
|
__all__ = ["bumpversion", "dev", "files", "llm", "media", "system"]
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"""版本号管理模块.
|
||||||
|
|
||||||
|
提供单文件版本号更新 (``bump_file_version``) 与项目级批量版本号同步
|
||||||
|
(``bump_project_version``) 能力. 所有公共函数通过 ``@px.register_fn`` 注册,
|
||||||
|
供 YAML 任务编排引用.
|
||||||
|
|
||||||
|
设计要点
|
||||||
|
--------
|
||||||
|
``bump_project_version`` 采用 "先读取基准、再统一写入" 的两阶段策略:
|
||||||
|
先扫描所有 ``__init__.py`` / ``pyproject.toml`` 文件, 读取各自的版本号,
|
||||||
|
取最大值作为基准版本计算新版本号, 然后把新版本号统一写入所有文件,
|
||||||
|
避免文件间版本号不同步导致的跳号问题.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import pyflowx as px
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BumpVersionType",
|
||||||
|
"bump_file_version",
|
||||||
|
"bump_project_version",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 配置
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
BumpVersionType = Literal["patch", "minor", "major"]
|
||||||
|
|
||||||
|
_PYPROJECT_VERSION_PATTERN = re.compile(
|
||||||
|
r'(?:^|\n)\s*version\s*=\s*["\']'
|
||||||
|
r"(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
|
||||||
|
r"(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?"
|
||||||
|
r"(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?"
|
||||||
|
r'["\']',
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
_INIT_VERSION_PATTERN = re.compile(
|
||||||
|
r'(?:^|\n)\s*__version__\s*=\s*["\']'
|
||||||
|
r"(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
|
||||||
|
r"(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?"
|
||||||
|
r"(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?"
|
||||||
|
r'["\']',
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
_IGNORE_DIRS = frozenset({".venv", "venv", ".git", "__pycache__", ".tox", "node_modules", "build", "dist", ".eggs"})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 私有辅助函数
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _get_pattern_for_file(file_name: str) -> re.Pattern[str] | None:
|
||||||
|
"""根据文件类型获取对应的正则表达式."""
|
||||||
|
if file_name == "pyproject.toml":
|
||||||
|
return _PYPROJECT_VERSION_PATTERN
|
||||||
|
if file_name == "__init__.py":
|
||||||
|
return _INIT_VERSION_PATTERN
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_new_version(major: int, minor: int, patch: int, part: BumpVersionType) -> str:
|
||||||
|
"""计算新版本号."""
|
||||||
|
if part == "major":
|
||||||
|
return f"{major + 1}.0.0"
|
||||||
|
if part == "minor":
|
||||||
|
return f"{major}.{minor + 1}.0"
|
||||||
|
return f"{major}.{minor}.{patch + 1}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_replacement_string(original_match: str, new_version: str, file_name: str) -> str:
|
||||||
|
"""构建替换字符串, 保留原始格式."""
|
||||||
|
quote_char = '"' if '"' in original_match else "'"
|
||||||
|
key = "__version__" if file_name == "__init__.py" else "version"
|
||||||
|
prefix_match = re.match(rf"(\s*{key}\s*=\s*)[\"']", original_match)
|
||||||
|
prefix = prefix_match.group(1) if prefix_match else f"{key} = "
|
||||||
|
return f"{prefix}{quote_char}{new_version}{quote_char}"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_version_tuple(file_path: Path) -> tuple[int, int, int] | None:
|
||||||
|
"""从文件中读取版本号, 返回 (major, minor, patch) 元组; 未找到返回 None.
|
||||||
|
|
||||||
|
读取失败时抛出 ``OSError`` / ``UnicodeDecodeError`` 由调用方处理.
|
||||||
|
"""
|
||||||
|
pattern = _get_pattern_for_file(file_path.name)
|
||||||
|
if pattern is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
content = file_path.read_text(encoding="utf-8")
|
||||||
|
match = pattern.search(content)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return int(match.group("major")), int(match.group("minor")), int(match.group("patch"))
|
||||||
|
|
||||||
|
|
||||||
|
def _write_version_to_file(file_path: Path, new_version: str) -> bool:
|
||||||
|
"""把新版本号写入指定文件; 成功返回 True, 未匹配到版本号返回 False."""
|
||||||
|
pattern = _get_pattern_for_file(file_path.name)
|
||||||
|
if pattern is None: # pragma: no cover - 调用方已保证 pattern 不为 None
|
||||||
|
return False
|
||||||
|
|
||||||
|
content = file_path.read_text(encoding="utf-8")
|
||||||
|
match = pattern.search(content)
|
||||||
|
if not match: # pragma: no cover - 调用方已通过 _read_version_tuple 验证
|
||||||
|
return False
|
||||||
|
|
||||||
|
replacement = _build_replacement_string(match.group(0), new_version, file_path.name)
|
||||||
|
content = content.replace(match.group(0), replacement)
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_path.write_text(content, encoding="utf-8")
|
||||||
|
except OSError as e:
|
||||||
|
print(f"更新文件 {file_path} 版本号时出错: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 公共函数
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def bump_file_version(file_path: Path, part: BumpVersionType = "patch") -> str | None:
|
||||||
|
"""更新单个文件中的版本号.
|
||||||
|
|
||||||
|
读取文件当前版本号, 按 ``part`` 指定的部分递增, 写回文件.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
file_path : Path
|
||||||
|
要更新的文件路径 (``pyproject.toml`` 或 ``__init__.py``)
|
||||||
|
part : BumpVersionType
|
||||||
|
版本部分: patch, minor, major
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
str | None
|
||||||
|
更新后的新版本号; 文件中未找到版本号或读取失败时返回 None
|
||||||
|
"""
|
||||||
|
version_tuple = _read_version_tuple(file_path)
|
||||||
|
if version_tuple is None:
|
||||||
|
print(f"文件 {file_path} 中未找到版本号模式")
|
||||||
|
return None
|
||||||
|
|
||||||
|
major, minor, patch = version_tuple
|
||||||
|
new_version = _calculate_new_version(major, minor, patch, part)
|
||||||
|
|
||||||
|
if not _write_version_to_file(file_path, new_version): # pragma: no cover - _read_version_tuple 已验证
|
||||||
|
return None
|
||||||
|
|
||||||
|
return new_version
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def bump_project_version(part: BumpVersionType = "patch", no_tag: bool = False) -> str | None:
|
||||||
|
"""批量同步项目所有版本号文件并提交.
|
||||||
|
|
||||||
|
扫描当前目录下所有 ``__init__.py`` 和 ``pyproject.toml`` 文件
|
||||||
|
(排除虚拟环境和缓存目录), 先读取每个文件的当前版本号取最大值作为基准,
|
||||||
|
计算新版本号后统一写入所有文件, 最后执行 git add (按文件名) + commit + tag.
|
||||||
|
|
||||||
|
采用 "先读取基准、再统一写入" 的两阶段策略, 即使某些文件版本号不同步,
|
||||||
|
也能在一次 bump 后重新对齐, 避免跳号.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
part : BumpVersionType
|
||||||
|
版本部分: patch, minor, major
|
||||||
|
no_tag : bool
|
||||||
|
提交后不创建 git tag
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
str | None
|
||||||
|
更新后的新版本号; 未找到版本号文件时返回 None
|
||||||
|
"""
|
||||||
|
all_files: set[Path] = set()
|
||||||
|
for pattern in ("__init__.py", "pyproject.toml"):
|
||||||
|
for file in Path.cwd().rglob(pattern):
|
||||||
|
if not any(ignore_dir in file.parts for ignore_dir in _IGNORE_DIRS):
|
||||||
|
all_files.add(file)
|
||||||
|
|
||||||
|
if not all_files:
|
||||||
|
print("未找到包含版本号的文件")
|
||||||
|
return None
|
||||||
|
|
||||||
|
print(f"找到 {len(all_files)} 个文件需要更新版本号")
|
||||||
|
cwd = Path.cwd()
|
||||||
|
for file in sorted(all_files):
|
||||||
|
print(f" - {file.relative_to(cwd)}")
|
||||||
|
|
||||||
|
# 阶段 1: 读取所有文件版本号, 取最大值作为基准
|
||||||
|
versions: list[tuple[int, int, int]] = []
|
||||||
|
for file in sorted(all_files):
|
||||||
|
v = _read_version_tuple(file)
|
||||||
|
if v is not None:
|
||||||
|
versions.append(v)
|
||||||
|
|
||||||
|
if not versions:
|
||||||
|
print("未能从任何文件读取版本号")
|
||||||
|
return None
|
||||||
|
|
||||||
|
major, minor, patch = max(versions)
|
||||||
|
new_version = _calculate_new_version(major, minor, patch, part)
|
||||||
|
print(f"基准版本: {major}.{minor}.{patch} -> 新版本: {new_version}")
|
||||||
|
|
||||||
|
# 阶段 2: 统一写入新版本号到所有文件
|
||||||
|
for file in sorted(all_files):
|
||||||
|
_write_version_to_file(file, new_version)
|
||||||
|
|
||||||
|
# 阶段 3: git add (按文件名) + commit + tag
|
||||||
|
relative_files = [str(file.relative_to(cwd)) for file in sorted(all_files)]
|
||||||
|
subprocess.run(["git", "add", *relative_files], check=True)
|
||||||
|
subprocess.run(["git", "commit", "-m", f"bump version to {new_version}"], check=True)
|
||||||
|
|
||||||
|
if not no_tag:
|
||||||
|
tag_name = f"v{new_version}"
|
||||||
|
subprocess.run(["git", "tag", "-a", tag_name, "-m", f"Release {tag_name}"], check=True)
|
||||||
|
print(f"已创建标签: {tag_name}")
|
||||||
|
|
||||||
|
return new_version
|
||||||
@@ -0,0 +1,823 @@
|
|||||||
|
"""开发工具类函数模块.
|
||||||
|
|
||||||
|
聚合自动格式化 (autofmt)、pip 包管理 (piptool)、git 工具 (gittool)、
|
||||||
|
开发环境配置 (envdev)、docker 镜像登录 (dockercmd) 的可复用函数.
|
||||||
|
版本号管理已抽离到 :mod:`pyflowx.ops.bumpversion`. 所有公共函数通过
|
||||||
|
``@px.register_fn`` 注册, 供 YAML 任务编排引用.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import fnmatch
|
||||||
|
import getpass
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import pyflowx as px
|
||||||
|
from pyflowx.conditions import Constants
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"IGNORE_PATTERNS",
|
||||||
|
"PACKAGE_DIR",
|
||||||
|
"REQUIREMENTS_FILE",
|
||||||
|
"_PROTECTED_PACKAGES",
|
||||||
|
"add_docstring",
|
||||||
|
"auto_add_docstrings",
|
||||||
|
"docker_login_tencent",
|
||||||
|
"download_rustup_script",
|
||||||
|
"format_all",
|
||||||
|
"format_with_ruff",
|
||||||
|
"generate_module_docstring",
|
||||||
|
"git_add_commit",
|
||||||
|
"git_init_add_commit",
|
||||||
|
"has_files",
|
||||||
|
"init_sub_dirs",
|
||||||
|
"install_linux_docker",
|
||||||
|
"install_linux_fonts",
|
||||||
|
"install_linux_qt_libs",
|
||||||
|
"install_rust_toolchain",
|
||||||
|
"lint_with_ruff",
|
||||||
|
"not_has_git_repo",
|
||||||
|
"pip_download",
|
||||||
|
"pip_freeze",
|
||||||
|
"pip_reinstall",
|
||||||
|
"pip_uninstall",
|
||||||
|
"setup_conda_mirror",
|
||||||
|
"setup_linux_system_mirror",
|
||||||
|
"setup_python_mirror",
|
||||||
|
"setup_rust_mirror",
|
||||||
|
"sync_pyproject_config",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# autofmt 配置
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
IGNORE_PATTERNS = [
|
||||||
|
"__pycache__",
|
||||||
|
"*.pyc",
|
||||||
|
"*.pyo",
|
||||||
|
".git",
|
||||||
|
".venv",
|
||||||
|
".idea",
|
||||||
|
".vscode",
|
||||||
|
"*.egg-info",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
".pytest_cache",
|
||||||
|
".tox",
|
||||||
|
".mypy_cache",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# piptool 配置
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
PACKAGE_DIR = "packages"
|
||||||
|
REQUIREMENTS_FILE = "requirements.txt"
|
||||||
|
|
||||||
|
_PROTECTED_PACKAGES: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"pyflowx",
|
||||||
|
"bitool",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# autofmt 私有辅助函数
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# autofmt 函数
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def format_with_ruff(target: Path, fix: bool = True) -> None:
|
||||||
|
"""使用 ruff 格式化代码.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
target : Path
|
||||||
|
目标路径
|
||||||
|
fix : bool
|
||||||
|
是否自动修复
|
||||||
|
"""
|
||||||
|
cmd = ["ruff", "format", str(target)]
|
||||||
|
if fix:
|
||||||
|
cmd.append("--fix")
|
||||||
|
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
print(f"ruff format 完成: {target}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def lint_with_ruff(target: Path, fix: bool = True) -> None:
|
||||||
|
"""使用 ruff 检查代码.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
target : Path
|
||||||
|
目标路径
|
||||||
|
fix : bool
|
||||||
|
是否自动修复
|
||||||
|
"""
|
||||||
|
cmd = ["ruff", "check", str(target)]
|
||||||
|
if fix:
|
||||||
|
cmd.extend(["--fix", "--unsafe-fixes"])
|
||||||
|
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
print(f"ruff check 完成: {target}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def add_docstring(file_path: Path, docstring: str) -> bool:
|
||||||
|
"""为文件添加 docstring.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
file_path : Path
|
||||||
|
文件路径
|
||||||
|
docstring : str
|
||||||
|
docstring 内容
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
bool
|
||||||
|
是否成功添加
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content = file_path.read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(content)
|
||||||
|
|
||||||
|
first_node = tree.body[0] if tree.body else None
|
||||||
|
if first_node and isinstance(first_node, ast.Expr) and isinstance(first_node.value, ast.Constant):
|
||||||
|
return False
|
||||||
|
|
||||||
|
lines = content.splitlines()
|
||||||
|
doc_lines = docstring.splitlines()
|
||||||
|
doc_lines.append("")
|
||||||
|
new_content = "\n".join(doc_lines + lines)
|
||||||
|
|
||||||
|
file_path.write_text(new_content, encoding="utf-8")
|
||||||
|
print(f"添加 docstring: {file_path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except (OSError, UnicodeDecodeError, SyntaxError) as e:
|
||||||
|
print(f"处理失败: {file_path} - {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def generate_module_docstring(file_path: Path) -> str:
|
||||||
|
"""生成模块 docstring.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
file_path : Path
|
||||||
|
文件路径
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
str
|
||||||
|
生成的 docstring
|
||||||
|
"""
|
||||||
|
stem = file_path.stem
|
||||||
|
parent = file_path.parent.name
|
||||||
|
|
||||||
|
keywords = {
|
||||||
|
"cli": f"Command-line interface for {parent}",
|
||||||
|
"gui": f"Graphical user interface for {parent}",
|
||||||
|
"core": f"Core functionality for {parent}",
|
||||||
|
"util": f"Utility functions for {parent}",
|
||||||
|
"model": f"Data models for {parent}",
|
||||||
|
"test": f"Tests for {parent}",
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, desc in keywords.items():
|
||||||
|
if key in stem.lower():
|
||||||
|
return f'"""{desc}."""'
|
||||||
|
|
||||||
|
return f'"""{stem.replace("_", " ").title()} module."""'
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def auto_add_docstrings(root_dir: Path) -> int:
|
||||||
|
"""自动为所有 Python 文件添加 docstring.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
root_dir : Path
|
||||||
|
根目录
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
int
|
||||||
|
添加的 docstring 数量
|
||||||
|
"""
|
||||||
|
count = 0
|
||||||
|
for py_file in root_dir.rglob("*.py"):
|
||||||
|
if any(pattern in str(py_file) for pattern in IGNORE_PATTERNS):
|
||||||
|
continue
|
||||||
|
|
||||||
|
docstring = generate_module_docstring(py_file)
|
||||||
|
if add_docstring(py_file, docstring):
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
print(f"共添加 {count} 个 docstring")
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def sync_pyproject_config(root_dir: Path) -> None:
|
||||||
|
"""同步 pyproject.toml 配置到子项目.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
root_dir : Path
|
||||||
|
根目录
|
||||||
|
"""
|
||||||
|
main_toml = root_dir / "pyproject.toml"
|
||||||
|
if not main_toml.exists():
|
||||||
|
print(f"主项目配置文件不存在: {main_toml}")
|
||||||
|
return
|
||||||
|
|
||||||
|
sub_tomls = [p for p in root_dir.rglob("pyproject.toml") if p != main_toml and ".venv" not in str(p)]
|
||||||
|
|
||||||
|
if not sub_tomls:
|
||||||
|
print("没有找到子项目的 pyproject.toml")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"找到 {len(sub_tomls)} 个子项目配置文件")
|
||||||
|
|
||||||
|
for sub_toml in sub_tomls:
|
||||||
|
subprocess.run(["ruff", "format", str(sub_toml)], check=False)
|
||||||
|
|
||||||
|
print("配置同步完成")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def format_all(root_dir: Path) -> None:
|
||||||
|
"""格式化所有 Python 文件.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
root_dir : Path
|
||||||
|
根目录
|
||||||
|
"""
|
||||||
|
subprocess.run(["ruff", "format", str(root_dir)], check=True)
|
||||||
|
subprocess.run(["ruff", "check", "--fix", "--unsafe-fixes", str(root_dir)], check=True)
|
||||||
|
print(f"格式化完成: {root_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# piptool 私有辅助函数
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _get_installed_packages() -> list[str]:
|
||||||
|
"""获取当前环境中所有已安装的包名."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["pip", "list", "--format=freeze"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
packages: list[str] = []
|
||||||
|
for line in result.stdout.strip().split("\n"):
|
||||||
|
if line and "==" in line:
|
||||||
|
pkg_name = line.split("==")[0].strip()
|
||||||
|
packages.append(pkg_name)
|
||||||
|
except (subprocess.SubprocessError, OSError):
|
||||||
|
return []
|
||||||
|
return packages
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_wildcard_packages(pattern: str) -> list[str]:
|
||||||
|
"""展开通配符模式为实际的包名列表."""
|
||||||
|
if not any(char in pattern for char in ["*", "?", "[", "]"]):
|
||||||
|
return [pattern]
|
||||||
|
|
||||||
|
installed_packages = _get_installed_packages()
|
||||||
|
matched = [pkg for pkg in installed_packages if fnmatch.fnmatchcase(pkg.lower(), pattern.lower())]
|
||||||
|
return matched
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_protected_packages(packages: list[str]) -> list[str]:
|
||||||
|
"""过滤掉受保护的包名."""
|
||||||
|
safe = [p for p in packages if p.lower() not in {p.lower() for p in _PROTECTED_PACKAGES}]
|
||||||
|
filtered = [p for p in packages if p.lower() in {p.lower() for p in _PROTECTED_PACKAGES}]
|
||||||
|
if filtered:
|
||||||
|
print(f"跳过受保护的包: {', '.join(filtered)}")
|
||||||
|
return safe
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# piptool 函数
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def pip_uninstall(pkg_names: list[str]) -> None:
|
||||||
|
"""卸载包."""
|
||||||
|
packages_to_uninstall: list[str] = []
|
||||||
|
for pattern in pkg_names:
|
||||||
|
packages_to_uninstall.extend(_expand_wildcard_packages(pattern))
|
||||||
|
|
||||||
|
packages_to_uninstall = _filter_protected_packages(packages_to_uninstall)
|
||||||
|
|
||||||
|
if not packages_to_uninstall:
|
||||||
|
return
|
||||||
|
|
||||||
|
subprocess.run(["pip", "uninstall", "-y", *packages_to_uninstall], check=True)
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def pip_reinstall(pkg_names: list[str], offline: bool = False) -> None:
|
||||||
|
"""重新安装包."""
|
||||||
|
safe_ps = _filter_protected_packages(pkg_names)
|
||||||
|
if not safe_ps:
|
||||||
|
print("所有指定的包均为受保护包, 跳过重装")
|
||||||
|
return
|
||||||
|
|
||||||
|
subprocess.run(["pip", "uninstall", "-y", *safe_ps], check=True)
|
||||||
|
|
||||||
|
options = ["--no-index", "--find-links", "."] if offline else []
|
||||||
|
subprocess.run(["pip", "install", *options, *safe_ps], check=True)
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def pip_download(pkg_names: list[str], offline: bool = False) -> None:
|
||||||
|
"""下载包."""
|
||||||
|
options = ["--no-index", "--find-links", "."] if offline else []
|
||||||
|
subprocess.run(
|
||||||
|
["pip", "download", *pkg_names, *options, "-d", PACKAGE_DIR],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def pip_freeze() -> None:
|
||||||
|
"""冻结依赖."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["pip", "freeze", "--exclude-editable"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
Path(REQUIREMENTS_FILE).write_text(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# gittool 函数
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def init_sub_dirs() -> None:
|
||||||
|
"""初始化子目录的 Git 仓库."""
|
||||||
|
sub_dirs = [subdir for subdir in Path.cwd().iterdir() if subdir.is_dir()]
|
||||||
|
for subdir in sub_dirs:
|
||||||
|
px.run(
|
||||||
|
px.Graph().chain(
|
||||||
|
px.cmd(["git", "init"], conditions=(lambda _: not_has_git_repo(),), cwd=subdir),
|
||||||
|
px.cmd(["git", "add", "."]),
|
||||||
|
px.cmd(["git", "commit", "-m", "init commit"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def not_has_git_repo() -> bool:
|
||||||
|
"""检查当前目录没有 Git 仓库."""
|
||||||
|
return not Path.cwd().exists() or not (Path.cwd() / ".git").is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def has_files() -> bool:
|
||||||
|
"""检查当前 Git 仓库是否有未提交的更改."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "status", "--porcelain"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
return bool(result.stdout.strip())
|
||||||
|
except (subprocess.SubprocessError, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def git_add_commit(message: str = "chore: update") -> None:
|
||||||
|
"""执行 git add + git commit (仅当有未提交更改时).
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
message : str
|
||||||
|
提交信息
|
||||||
|
"""
|
||||||
|
if not has_files():
|
||||||
|
print("没有文件需要提交")
|
||||||
|
return
|
||||||
|
subprocess.run(["git", "add", "."], check=True)
|
||||||
|
subprocess.run(["git", "commit", "-m", message], check=True)
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def git_init_add_commit(message: str = "init commit") -> None:
|
||||||
|
"""执行 git init (若需) + git add + git commit (若有更改).
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
message : str
|
||||||
|
提交信息
|
||||||
|
"""
|
||||||
|
if not_has_git_repo():
|
||||||
|
subprocess.run(["git", "init"], check=True)
|
||||||
|
if has_files():
|
||||||
|
subprocess.run(["git", "add", "."], check=True)
|
||||||
|
subprocess.run(["git", "commit", "-m", message], check=True)
|
||||||
|
else:
|
||||||
|
print("没有文件需要提交")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# envdev 配置 (Python / Conda / Rust 镜像源)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
PyMirrorType = Literal["tsinghua", "aliyun", "huaweicloud", "ustc", "zju"]
|
||||||
|
CondaMirrorType = Literal["tsinghua", "ustc", "bsfu", "aliyun"]
|
||||||
|
RustMirrorType = Literal["tsinghua", "ustc", "aliyun"]
|
||||||
|
|
||||||
|
_PIP_INDEX_URLS: dict[str, str] = {
|
||||||
|
"tsinghua": "https://pypi.tuna.tsinghua.edu.cn/simple",
|
||||||
|
"aliyun": "https://mirrors.aliyun.com/pypi/simple/",
|
||||||
|
"huaweicloud": "https://mirrors.huaweicloud.com/repository/pypi/simple/",
|
||||||
|
"ustc": "https://pypi.mirrors.ustc.edu.cn/simple/",
|
||||||
|
"zju": "https://mirrors.zju.edu.cn/pypi/simple/",
|
||||||
|
}
|
||||||
|
|
||||||
|
_PIP_TRUSTED_HOSTS: dict[str, str] = {
|
||||||
|
"tsinghua": "pypi.tuna.tsinghua.edu.cn",
|
||||||
|
"aliyun": "mirrors.aliyun.com",
|
||||||
|
"huaweicloud": "mirrors.huaweicloud.com",
|
||||||
|
"ustc": "pypi.mirrors.ustc.edu.cn",
|
||||||
|
"zju": "mirrors.zju.edu.cn",
|
||||||
|
}
|
||||||
|
|
||||||
|
_UV_PYTHON_INSTALL_MIRROR: str = "https://registry.npmmirror.com/-/binary/python-build-standalone"
|
||||||
|
|
||||||
|
_CONDA_MIRROR_URLS: dict[str, list[str]] = {
|
||||||
|
"tsinghua": [
|
||||||
|
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/",
|
||||||
|
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/",
|
||||||
|
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/r/",
|
||||||
|
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/msys2/",
|
||||||
|
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/pro/",
|
||||||
|
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/",
|
||||||
|
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/bioconda/",
|
||||||
|
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/menpo/",
|
||||||
|
"https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/pytorch/",
|
||||||
|
],
|
||||||
|
"ustc": [
|
||||||
|
"https://mirrors.ustc.edu.cn/anaconda/pkgs/main/",
|
||||||
|
"https://mirrors.ustc.edu.cn/anaconda/pkgs/free/",
|
||||||
|
"https://mirrors.ustc.edu.cn/anaconda/pkgs/r/",
|
||||||
|
"https://mirrors.ustc.edu.cn/anaconda/pkgs/msys2/",
|
||||||
|
"https://mirrors.ustc.edu.cn/anaconda/pkgs/pro/",
|
||||||
|
"https://mirrors.ustc.edu.cn/anaconda/pkgs/dev/",
|
||||||
|
"https://mirrors.ustc.edu.cn/anaconda/cloud/conda-forge/",
|
||||||
|
"https://mirrors.ustc.edu.cn/anaconda/cloud/bioconda/",
|
||||||
|
"https://mirrors.ustc.edu.cn/anaconda/cloud/menpo/",
|
||||||
|
"https://mirrors.ustc.edu.cn/anaconda/cloud/pytorch/",
|
||||||
|
],
|
||||||
|
"bsfu": [
|
||||||
|
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/main/",
|
||||||
|
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/free/",
|
||||||
|
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/r/",
|
||||||
|
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/msys2/",
|
||||||
|
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/pro/",
|
||||||
|
"https://mirrors.bsfu.edu.cn/anaconda/pkgs/dev/",
|
||||||
|
"https://mirrors.bsfu.edu.cn/anaconda/cloud/conda-forge/",
|
||||||
|
"https://mirrors.bsfu.edu.cn/anaconda/cloud/bioconda/",
|
||||||
|
"https://mirrors.bsfu.edu.cn/anaconda/cloud/menpo/",
|
||||||
|
"https://mirrors.bsfu.edu.cn/anaconda/cloud/pytorch/",
|
||||||
|
],
|
||||||
|
"aliyun": [
|
||||||
|
"https://mirrors.aliyun.com/anaconda/pkgs/main/",
|
||||||
|
"https://mirrors.aliyun.com/anaconda/pkgs/free/",
|
||||||
|
"https://mirrors.aliyun.com/anaconda/pkgs/r/",
|
||||||
|
"https://mirrors.aliyun.com/anaconda/pkgs/msys2/",
|
||||||
|
"https://mirrors.aliyun.com/anaconda/pkgs/pro/",
|
||||||
|
"https://mirrors.aliyun.com/anaconda/pkgs/dev/",
|
||||||
|
"https://mirrors.aliyun.com/anaconda/cloud/conda-forge/",
|
||||||
|
"https://mirrors.aliyun.com/anaconda/cloud/bioconda/",
|
||||||
|
"https://mirrors.aliyun.com/anaconda/cloud/menpo/",
|
||||||
|
"https://mirrors.aliyun.com/anaconda/cloud/pytorch/",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
_RUSTUP_MIRRORS: dict[str, dict[str, str]] = {
|
||||||
|
"tsinghua": {
|
||||||
|
"RUSTUP_DIST_SERVER": "https://mirrors.tuna.tsinghua.edu.cn/rustup",
|
||||||
|
"RUSTUP_UPDATE_ROOT": "https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup",
|
||||||
|
"TOML_REGISTRY": "https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/",
|
||||||
|
},
|
||||||
|
"aliyun": {
|
||||||
|
"RUSTUP_DIST_SERVER": "https://mirrors.aliyun.com/rustup",
|
||||||
|
"RUSTUP_UPDATE_ROOT": "https://mirrors.aliyun.com/rustup/rustup",
|
||||||
|
"TOML_REGISTRY": "https://mirrors.aliyun.com/crates.io-index/",
|
||||||
|
},
|
||||||
|
"ustc": {
|
||||||
|
"RUSTUP_DIST_SERVER": "https://mirrors.ustc.edu.cn/rust-static",
|
||||||
|
"RUSTUP_UPDATE_ROOT": "https://mirrors.ustc.edu.cn/rust-static/rustup",
|
||||||
|
"TOML_REGISTRY": "https://mirrors.ustc.edu.cn/crates.io-index/",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_RUST_SCCACHE_DIR: Path = Path.home() / ".cargo" / "sccache"
|
||||||
|
_RUST_SCCACHE_CACHE_SIZE: str = "20G"
|
||||||
|
|
||||||
|
|
||||||
|
def _pip_config_path() -> Path:
|
||||||
|
"""返回当前平台的 pip 配置文件路径."""
|
||||||
|
if Constants.IS_LINUX:
|
||||||
|
return Path.home() / ".pip" / "pip.conf"
|
||||||
|
return Path.home() / "pip" / "pip.ini"
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def setup_python_mirror(mirror: str) -> None:
|
||||||
|
"""配置 Python 镜像源 (设置环境变量 + 写入 pip 配置文件).
|
||||||
|
|
||||||
|
设置 ``PIP_INDEX_URL`` / ``PIP_TRUSTED_HOSTS`` / ``UV_INDEX_URL`` /
|
||||||
|
``UV_PYTHON_INSTALL_MIRROR`` 等环境变量, 并写入 pip 配置文件.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
mirror : str
|
||||||
|
镜像源名称, 见 :data:`_PIP_INDEX_URLS`
|
||||||
|
"""
|
||||||
|
if mirror not in _PIP_INDEX_URLS:
|
||||||
|
print(f"未知 Python 镜像源: {mirror}")
|
||||||
|
return
|
||||||
|
|
||||||
|
index_url = _PIP_INDEX_URLS[mirror]
|
||||||
|
trusted_host = _PIP_TRUSTED_HOSTS[mirror]
|
||||||
|
|
||||||
|
os.environ["PIP_INDEX_URL"] = index_url
|
||||||
|
os.environ["PIP_TRUSTED_HOSTS"] = trusted_host
|
||||||
|
os.environ["UV_INDEX_URL"] = index_url
|
||||||
|
os.environ["UV_PYTHON_INSTALL_MIRROR"] = _UV_PYTHON_INSTALL_MIRROR
|
||||||
|
os.environ["UV_HTTP_TIMEOUT"] = "600"
|
||||||
|
os.environ["UV_LINK_MODE"] = "copy"
|
||||||
|
|
||||||
|
config_path = _pip_config_path()
|
||||||
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
content = f"[global]\nindex-url = {index_url}\ntrusted-host = {trusted_host}\n"
|
||||||
|
config_path.write_text(content, encoding="utf-8")
|
||||||
|
print(f"Python 镜像源已配置: {mirror} -> {config_path}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def setup_conda_mirror(mirror: str) -> None:
|
||||||
|
"""配置 Conda 镜像源 (写入 ~/.condarc).
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
mirror : str
|
||||||
|
镜像源名称, 见 :data:`_CONDA_MIRROR_URLS`
|
||||||
|
"""
|
||||||
|
if mirror not in _CONDA_MIRROR_URLS:
|
||||||
|
print(f"未知 Conda 镜像源: {mirror}")
|
||||||
|
return
|
||||||
|
|
||||||
|
urls = _CONDA_MIRROR_URLS[mirror]
|
||||||
|
config_path = Path.home() / ".condarc"
|
||||||
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
content = "show_channel_urls: true\nchannels:\n - " + "\n - ".join(urls) + "\n - defaults\n"
|
||||||
|
config_path.write_text(content, encoding="utf-8")
|
||||||
|
print(f"Conda 镜像源已配置: {mirror} -> {config_path}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def setup_rust_mirror(mirror: str, version: str = "stable") -> None:
|
||||||
|
"""配置 Rust 镜像源 (设置环境变量 + 写入 cargo config + 创建 sccache 目录).
|
||||||
|
|
||||||
|
设置 ``RUSTUP_DIST_SERVER`` / ``RUSTUP_UPDATE_ROOT`` / ``RUST_SCCACHE_DIR``
|
||||||
|
等环境变量, 写入 ``~/.cargo/config.toml``, 并创建 sccache 缓存目录.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
mirror : str
|
||||||
|
镜像源名称, 见 :data:`_RUSTUP_MIRRORS`
|
||||||
|
version : str
|
||||||
|
Rust 版本 (未使用, 保留以与原 envdev 参数对齐)
|
||||||
|
"""
|
||||||
|
del version # 兼容旧参数, 实际安装由独立 job 处理
|
||||||
|
|
||||||
|
if mirror not in _RUSTUP_MIRRORS:
|
||||||
|
print(f"未知 Rust 镜像源: {mirror}")
|
||||||
|
return
|
||||||
|
|
||||||
|
mirrors = _RUSTUP_MIRRORS[mirror]
|
||||||
|
os.environ["RUSTUP_DIST_SERVER"] = mirrors["RUSTUP_DIST_SERVER"]
|
||||||
|
os.environ["RUSTUP_UPDATE_ROOT"] = mirrors["RUSTUP_UPDATE_ROOT"]
|
||||||
|
os.environ["RUST_SCCACHE_DIR"] = str(_RUST_SCCACHE_DIR)
|
||||||
|
os.environ["RUST_SCCACHE_CACHE_SIZE"] = _RUST_SCCACHE_CACHE_SIZE
|
||||||
|
|
||||||
|
_RUST_SCCACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
config_path = Path.home() / ".cargo" / "config.toml"
|
||||||
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
registry = mirrors["TOML_REGISTRY"]
|
||||||
|
content = (
|
||||||
|
f"\n[source.crates-io]\nreplace-with = '{mirror}'\n\n"
|
||||||
|
f'[source.{mirror}]\nregistry = "sparse+{registry}"\n\n'
|
||||||
|
f'[registries.{mirror}]\nindex = "sparse+{registry}"\n'
|
||||||
|
)
|
||||||
|
config_path.write_text(content, encoding="utf-8")
|
||||||
|
print(f"Rust 镜像源已配置: {mirror} -> {config_path}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# dockercmd 函数
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
_DOCKER_MIRROR_TENCENT: str = "ccr.ccs.tencentyun.com"
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def docker_login_tencent(username: str = "") -> None:
|
||||||
|
"""登录腾讯云 Docker 镜像仓库.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
username : str
|
||||||
|
Docker 用户名 (为空时由 docker 交互式提示输入)
|
||||||
|
"""
|
||||||
|
user = username or getpass.getuser()
|
||||||
|
subprocess.run(["docker", "login", "--username", user, _DOCKER_MIRROR_TENCENT], check=False)
|
||||||
|
print(f"已尝试登录腾讯云镜像仓库 (用户: {user})")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# envdev Linux 专用函数
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
_QT_LIBS: list[str] = [
|
||||||
|
"build-essential",
|
||||||
|
"libgl1",
|
||||||
|
"libegl1",
|
||||||
|
"libglib2.0-0",
|
||||||
|
"libfontconfig1",
|
||||||
|
"libfreetype6",
|
||||||
|
"libxkbcommon0",
|
||||||
|
"libdbus-1-3",
|
||||||
|
"libxcb-xinerama0",
|
||||||
|
"libxcb-icccm4",
|
||||||
|
"libxcb-image0",
|
||||||
|
"libxcb-keysyms1",
|
||||||
|
"libxcb-randr0",
|
||||||
|
"libxcb-render-util0",
|
||||||
|
"libxcb-shape0",
|
||||||
|
"libxcb-xfixes0",
|
||||||
|
"libxcb-cursor0",
|
||||||
|
]
|
||||||
|
|
||||||
|
_CHINESE_FONTS: list[str] = [
|
||||||
|
"fonts-noto-cjk",
|
||||||
|
"fonts-wqy-microhei",
|
||||||
|
"fonts-wqy-zenhei",
|
||||||
|
"fonts-noto-color-emoji",
|
||||||
|
]
|
||||||
|
|
||||||
|
_DOWNLOAD_MIRROR_SCRIPT: str = "curl -sSL https://linuxmirrors.cn/main.sh -o /tmp/linuxmirrors.sh"
|
||||||
|
_INSTALL_MIRROR_SCRIPT: str = "sudo bash /tmp/linuxmirrors.sh"
|
||||||
|
|
||||||
|
_RUSTUP_DOWNLOAD_URL_LINUX: str = "https://mirrors.aliyun.com/repo/rust/rustup-init.sh"
|
||||||
|
_RUSTUP_DOWNLOAD_URL_WINDOWS: str = "https://static.rust-lang.org/rustup/dist/x86_64-pc-windows-msvc/rustup-init.exe"
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def setup_linux_system_mirror() -> None:
|
||||||
|
"""下载并安装 Linux 系统镜像源 (仅 Linux, 已配置国内镜像时跳过).
|
||||||
|
|
||||||
|
检查 ``/etc/apt/sources.list`` 与 ``/etc/apt/sources.list.d/ubuntu.sources``
|
||||||
|
是否已配置国内镜像, 已配置则跳过; 未配置则下载并执行 linuxmirrors 脚本.
|
||||||
|
"""
|
||||||
|
if not Constants.IS_LINUX:
|
||||||
|
print("setup_linux_system_mirror: 仅在 Linux 上执行")
|
||||||
|
return
|
||||||
|
|
||||||
|
apt_files = ["/etc/apt/sources.list", "/etc/apt/sources.list.d/ubuntu.sources"]
|
||||||
|
mirror_keys = list(_PIP_INDEX_URLS.keys())
|
||||||
|
already_configured = False
|
||||||
|
for apt_file in apt_files:
|
||||||
|
try:
|
||||||
|
content = Path(apt_file).read_text(encoding="utf-8")
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
continue
|
||||||
|
if any(mirror in content for mirror in mirror_keys):
|
||||||
|
already_configured = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if already_configured:
|
||||||
|
print("已配置国内镜像源, 跳过系统镜像配置")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("下载 linuxmirrors 脚本...")
|
||||||
|
subprocess.run(_DOWNLOAD_MIRROR_SCRIPT, shell=True, check=False)
|
||||||
|
print("安装 linuxmirrors...")
|
||||||
|
subprocess.run(_INSTALL_MIRROR_SCRIPT, shell=True, check=False)
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def install_linux_qt_libs() -> None:
|
||||||
|
"""安装 Qt 依赖库 (仅 Linux)."""
|
||||||
|
if not Constants.IS_LINUX:
|
||||||
|
print("install_linux_qt_libs: 仅在 Linux 上执行")
|
||||||
|
return
|
||||||
|
|
||||||
|
subprocess.run(["sudo", "apt", "install", "-y", *_QT_LIBS], check=False)
|
||||||
|
print("Qt 依赖库安装完成")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def install_linux_fonts() -> None:
|
||||||
|
"""安装中文字体 (仅 Linux)."""
|
||||||
|
if not Constants.IS_LINUX:
|
||||||
|
print("install_linux_fonts: 仅在 Linux 上执行")
|
||||||
|
return
|
||||||
|
|
||||||
|
subprocess.run(["sudo", "apt", "install", "-y", *_CHINESE_FONTS], check=False)
|
||||||
|
print("中文字体安装完成")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def install_linux_docker() -> None:
|
||||||
|
"""安装 Docker (仅 Linux)."""
|
||||||
|
if not Constants.IS_LINUX:
|
||||||
|
print("install_linux_docker: 仅在 Linux 上执行")
|
||||||
|
return
|
||||||
|
|
||||||
|
subprocess.run(["sudo", "apt", "install", "-y", "docker-compose-v2"], check=False)
|
||||||
|
subprocess.run(["sudo", "usermod", "-aG", "docker", getpass.getuser()], check=False)
|
||||||
|
print("Docker 安装完成 (需重新登录以生效 docker 用户组)")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def download_rustup_script() -> None:
|
||||||
|
"""下载 Rustup 安装脚本 (跨平台, 已安装 rustup 时跳过).
|
||||||
|
|
||||||
|
Linux 下载 ``rustup-init.sh``, Windows 下载 ``rustup-init.exe``.
|
||||||
|
"""
|
||||||
|
if shutil.which("rustup") is not None:
|
||||||
|
print("rustup 已安装, 跳过下载")
|
||||||
|
return
|
||||||
|
|
||||||
|
if Constants.IS_WINDOWS:
|
||||||
|
print("下载 rustup-init.exe...")
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"powershell",
|
||||||
|
"-Command",
|
||||||
|
"Invoke-WebRequest",
|
||||||
|
"-Uri",
|
||||||
|
_RUSTUP_DOWNLOAD_URL_WINDOWS,
|
||||||
|
"-OutFile",
|
||||||
|
"rustup-init.exe",
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("下载 rustup-init.sh...")
|
||||||
|
subprocess.run(
|
||||||
|
["curl", "-fsSL", _RUSTUP_DOWNLOAD_URL_LINUX, "-o", "rustup-init.sh"],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def install_rust_toolchain(version: str = "stable") -> None:
|
||||||
|
"""安装 Rust 工具链 (rustup 未安装时跳过).
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
version : str
|
||||||
|
Rust 版本: ``stable`` / ``nightly`` / ``beta`` (默认: ``stable``)
|
||||||
|
"""
|
||||||
|
if shutil.which("rustup") is None:
|
||||||
|
print("rustup 未安装, 跳过工具链安装")
|
||||||
|
return
|
||||||
|
|
||||||
|
subprocess.run(["rustup", "toolchain", "install", version], check=False)
|
||||||
|
print(f"Rust 工具链 {version} 安装完成")
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
"""文件类函数模块.
|
||||||
|
|
||||||
|
聚合文件日期处理、文件等级重命名、文件夹备份、文件夹压缩工具的可复用函数.
|
||||||
|
所有公共函数通过 ``@px.register_fn`` 注册, 供 YAML 任务编排引用.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pyflowx as px
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BRACKETS",
|
||||||
|
"DATE_PATTERN",
|
||||||
|
"IGNORE_DIRS",
|
||||||
|
"IGNORE_EXT",
|
||||||
|
"IGNORE_FILES",
|
||||||
|
"LEVELS",
|
||||||
|
"SEP",
|
||||||
|
"add_date_prefix",
|
||||||
|
"archive_folder",
|
||||||
|
"backup_folder",
|
||||||
|
"folderback_default",
|
||||||
|
"folderzip_default",
|
||||||
|
"get_file_timestamp",
|
||||||
|
"process_file_date",
|
||||||
|
"process_file_level",
|
||||||
|
"process_files_date",
|
||||||
|
"process_files_level",
|
||||||
|
"remove_date_prefix",
|
||||||
|
"remove_dump",
|
||||||
|
"remove_marks",
|
||||||
|
"zip_folders",
|
||||||
|
"zip_target",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# filedate 配置
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
DATE_PATTERN = re.compile(r"(20|19)\d{2}[-_#.~]?((0[1-9])|(1[012]))[-_#.~]?((0[1-9])|([12]\d)|(3[01]))[-_#.~]?")
|
||||||
|
SEP = "_"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# filelevel 配置
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
LEVELS: dict[str, str] = {
|
||||||
|
"0": "",
|
||||||
|
"1": "PUB,NOR",
|
||||||
|
"2": "INT",
|
||||||
|
"3": "CON",
|
||||||
|
"4": "CLA",
|
||||||
|
}
|
||||||
|
|
||||||
|
BRACKETS: tuple[str, str] = (" ([_(【-", " )]_)】")
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# folderzip 配置
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
IGNORE_DIRS: list[str] = [".git", ".idea", ".vscode", "__pycache__"]
|
||||||
|
IGNORE_FILES: list[str] = [".gitignore"]
|
||||||
|
IGNORE: list[str] = [*IGNORE_DIRS, *IGNORE_FILES]
|
||||||
|
IGNORE_EXT: list[str] = [".zip", ".rar", ".7z", ".tar", ".gz"]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# filedate 函数
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def get_file_timestamp(filepath: Path) -> str:
|
||||||
|
"""获取文件时间戳."""
|
||||||
|
modified_time = filepath.stat().st_mtime
|
||||||
|
created_time = filepath.stat().st_ctime
|
||||||
|
return time.strftime("%Y%m%d", time.localtime(max((modified_time, created_time))))
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def remove_date_prefix(filepath: Path) -> Path:
|
||||||
|
"""移除文件日期前缀."""
|
||||||
|
stem = filepath.stem
|
||||||
|
new_stem = DATE_PATTERN.sub("", stem)
|
||||||
|
if new_stem != stem:
|
||||||
|
new_path = filepath.with_name(new_stem + filepath.suffix)
|
||||||
|
filepath.rename(new_path)
|
||||||
|
return new_path
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def add_date_prefix(filepath: Path) -> Path:
|
||||||
|
"""添加文件日期前缀."""
|
||||||
|
timestamp = get_file_timestamp(filepath)
|
||||||
|
stem = filepath.stem
|
||||||
|
new_stem = f"{timestamp}{SEP}{stem}"
|
||||||
|
new_path = filepath.with_name(new_stem + filepath.suffix)
|
||||||
|
if new_path != filepath:
|
||||||
|
filepath.rename(new_path)
|
||||||
|
return new_path
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def process_file_date(filepath: Path, clear: bool = False) -> None:
|
||||||
|
"""处理单个文件的日期前缀.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
filepath : Path
|
||||||
|
文件路径
|
||||||
|
clear : bool
|
||||||
|
是否清除日期前缀
|
||||||
|
"""
|
||||||
|
if clear:
|
||||||
|
remove_date_prefix(filepath)
|
||||||
|
else:
|
||||||
|
new_path = remove_date_prefix(filepath)
|
||||||
|
add_date_prefix(new_path)
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def process_files_date(targets: list[Path], clear: bool = False) -> None:
|
||||||
|
"""批量处理文件日期前缀.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
targets : list[Path]
|
||||||
|
文件路径列表
|
||||||
|
clear : bool
|
||||||
|
是否清除日期前缀
|
||||||
|
"""
|
||||||
|
for target in targets:
|
||||||
|
if target.exists() and not target.name.startswith("."):
|
||||||
|
process_file_date(target, clear)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# filelevel 函数
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def remove_marks(stem: str, marks: list[str]) -> str:
|
||||||
|
"""从文件名主干中移除所有标记."""
|
||||||
|
left_brackets, right_brackets = BRACKETS
|
||||||
|
for mark in marks:
|
||||||
|
pos = 0
|
||||||
|
while True:
|
||||||
|
pos = stem.find(mark, pos)
|
||||||
|
if pos == -1:
|
||||||
|
break
|
||||||
|
b, e = pos - 1, pos + len(mark)
|
||||||
|
if b >= 0 and e < len(stem) and stem[b] in left_brackets and stem[e] in right_brackets:
|
||||||
|
stem = stem[:b] + stem[e + 1 :]
|
||||||
|
else:
|
||||||
|
pos = e
|
||||||
|
return stem
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def process_file_level(filepath: Path, level: int = 0) -> None:
|
||||||
|
"""处理单个文件的等级标记.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
filepath : Path
|
||||||
|
文件路径
|
||||||
|
level : int
|
||||||
|
文件等级 (0-4), 0 用于清除等级
|
||||||
|
"""
|
||||||
|
if not (0 <= level < len(LEVELS)):
|
||||||
|
print(f"无效的等级 {level}, 必须在 0 和 {len(LEVELS) - 1} 之间")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not filepath.exists():
|
||||||
|
print(f"文件不存在: {filepath}")
|
||||||
|
return
|
||||||
|
|
||||||
|
filestem = filepath.stem
|
||||||
|
original_stem = filestem
|
||||||
|
|
||||||
|
for level_names in LEVELS.values():
|
||||||
|
if level_names:
|
||||||
|
filestem = remove_marks(filestem, level_names.split(","))
|
||||||
|
|
||||||
|
for digit in map(str, range(1, 10)):
|
||||||
|
filestem = remove_marks(filestem, [digit])
|
||||||
|
|
||||||
|
if level > 0:
|
||||||
|
levelstr = LEVELS.get(str(level), "").split(",")[0]
|
||||||
|
if levelstr:
|
||||||
|
filestem = f"{filestem}({levelstr})"
|
||||||
|
|
||||||
|
if filestem != original_stem:
|
||||||
|
new_path = filepath.with_name(filestem + filepath.suffix)
|
||||||
|
filepath.rename(new_path)
|
||||||
|
print(f"重命名: {filepath} -> {new_path}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def process_files_level(targets: list[Path], level: int = 0) -> None:
|
||||||
|
"""批量处理文件等级标记.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
targets : list[Path]
|
||||||
|
文件路径列表
|
||||||
|
level : int
|
||||||
|
文件等级 (0-4)
|
||||||
|
"""
|
||||||
|
for target in targets:
|
||||||
|
process_file_level(target, level)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# folderback 函数
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def remove_dump(src: Path, dst: Path, max_zip: int) -> None:
|
||||||
|
"""递归删除旧的备份 zip 文件."""
|
||||||
|
zip_paths = [filepath for filepath in dst.rglob("*.zip") if src.stem in str(filepath)]
|
||||||
|
zip_files = sorted(zip_paths, key=lambda fn: str(fn)[-19:-4])
|
||||||
|
if len(zip_files) > max_zip:
|
||||||
|
zip_files[0].unlink()
|
||||||
|
remove_dump(src, dst, max_zip)
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def zip_target(src: Path, dst: Path, max_zip: int) -> None:
|
||||||
|
"""将单个文件或文件夹压缩为 zip 文件."""
|
||||||
|
files = [str(_) for _ in src.rglob("*")]
|
||||||
|
timestamp = time.strftime("_%Y%m%d_%H%M%S")
|
||||||
|
target_path = dst / (src.stem + timestamp + ".zip")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(target_path, "w") as zip_file:
|
||||||
|
for file in files:
|
||||||
|
zip_file.write(file, arcname=file.replace(str(src.parent), ""))
|
||||||
|
|
||||||
|
remove_dump(src, dst, max_zip)
|
||||||
|
print(f"备份完成: {target_path}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def backup_folder(src: str, dst: str, max_zip: int = 5) -> None:
|
||||||
|
"""备份文件夹.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
src : str
|
||||||
|
源文件夹路径
|
||||||
|
dst : str
|
||||||
|
目标文件夹路径
|
||||||
|
max_zip : int
|
||||||
|
最大备份数量
|
||||||
|
"""
|
||||||
|
src_path = Path(src)
|
||||||
|
dst_path = Path(dst)
|
||||||
|
|
||||||
|
if not src_path.exists():
|
||||||
|
print(f"源文件夹不存在: {src_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not dst_path.exists():
|
||||||
|
dst_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
print(f"创建目标文件夹: {dst_path}")
|
||||||
|
|
||||||
|
zip_target(src_path, dst_path, max_zip)
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn("folderback_default")
|
||||||
|
def folderback_default() -> None:
|
||||||
|
"""备份当前目录到 ./backup."""
|
||||||
|
backup_folder(".", "./backup", 5)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# folderzip 函数
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def archive_folder(folder: Path) -> None:
|
||||||
|
"""压缩单个文件夹."""
|
||||||
|
shutil.make_archive(
|
||||||
|
str(folder.with_name(folder.name)),
|
||||||
|
format="zip",
|
||||||
|
base_dir=folder,
|
||||||
|
)
|
||||||
|
print(f"压缩完成: {folder.name}.zip")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def zip_folders(cwd: str = ".") -> None:
|
||||||
|
"""压缩目录下的所有文件夹.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
cwd : str
|
||||||
|
工作目录
|
||||||
|
"""
|
||||||
|
cwd_path = Path(cwd)
|
||||||
|
if not cwd_path.exists():
|
||||||
|
print(f"目录不存在: {cwd_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
dirs: list[Path] = [
|
||||||
|
e for e in cwd_path.iterdir() if e.is_dir() and e.name not in IGNORE_DIRS and e.suffix not in IGNORE_EXT
|
||||||
|
]
|
||||||
|
|
||||||
|
for dir_path in dirs:
|
||||||
|
archive_folder(dir_path)
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn("folderzip_default")
|
||||||
|
def folderzip_default() -> None:
|
||||||
|
"""压缩当前目录下的所有文件夹."""
|
||||||
|
zip_folders(".")
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""LLM 工具类函数模块.
|
||||||
|
|
||||||
|
聚合 ModelScope 下载 (msdownload) 与 SGLang 本地模型服务 (sglang) 的可复用函数.
|
||||||
|
所有公共函数通过 ``@px.register_fn`` 注册, 供 YAML 任务编排引用.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pyflowx as px
|
||||||
|
from pyflowx.conditions import Constants
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"install_sglang",
|
||||||
|
"msdownload_run",
|
||||||
|
"run_sglang",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def msdownload_run(name: str, target_type: str = "model", download_dir: str | None = None) -> None:
|
||||||
|
"""从 ModelScope 下载模型/数据集/空间.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
name : str
|
||||||
|
目标名称 (如: ``Qwen/Qwen2.5-Coder-32B-Instruct``)
|
||||||
|
target_type : str
|
||||||
|
目标类型: ``model`` / ``dataset`` / ``space`` (默认: ``model``)
|
||||||
|
download_dir : str | None
|
||||||
|
下载目录; 为 None 时默认 ``~/.models/<name 最后一段>``
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
print("msdownload: name 不能为空")
|
||||||
|
return
|
||||||
|
|
||||||
|
if download_dir:
|
||||||
|
out_dir = Path(download_dir)
|
||||||
|
else:
|
||||||
|
out_dir = Path.home() / ".models" / name.rsplit("/", 1)[-1]
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
cmd = ["uvx", "modelscope", "download", f"--{target_type}", name, "--local_dir", str(out_dir)]
|
||||||
|
print(f"下载 {target_type}: {name} -> {out_dir}")
|
||||||
|
subprocess.run(cmd, check=False)
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def install_sglang() -> None:
|
||||||
|
"""安装 sglang (若未安装).
|
||||||
|
|
||||||
|
通过 ``shutil.which`` 检测 sglang 是否已安装, 未安装时执行 ``uv install sglang[all]``.
|
||||||
|
"""
|
||||||
|
if shutil.which("sglang") is not None:
|
||||||
|
print("sglang 已安装, 跳过安装步骤")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("正在安装 sglang[all]...")
|
||||||
|
subprocess.run(["uv", "install", "sglang[all]"], check=False)
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def run_sglang(
|
||||||
|
model: str = "~/.models/Qwen2.5-Coder-32B-Instruct-AWQ",
|
||||||
|
port: int = 8000,
|
||||||
|
ctx_len: int = 32768,
|
||||||
|
mem_fraction: float = 0.75,
|
||||||
|
host: str = "0.0.0.0",
|
||||||
|
log_level: str = "info",
|
||||||
|
) -> None:
|
||||||
|
"""启动 SGLang 本地模型服务.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
model : str
|
||||||
|
模型路径 (默认: ``~/.models/Qwen2.5-Coder-32B-Instruct-AWQ``)
|
||||||
|
port : int
|
||||||
|
服务端口 (默认: 8000)
|
||||||
|
ctx_len : int
|
||||||
|
最大上下文长度 (默认: 32768)
|
||||||
|
mem_fraction : float
|
||||||
|
显存占比 0-1 (默认: 0.75)
|
||||||
|
host : str
|
||||||
|
主机地址 (默认: 0.0.0.0)
|
||||||
|
log_level : str
|
||||||
|
日志级别 (默认: info)
|
||||||
|
"""
|
||||||
|
model_dir = Path(model).expanduser()
|
||||||
|
if not model_dir.exists():
|
||||||
|
print(f"模型目录不存在: {model_dir}")
|
||||||
|
return
|
||||||
|
|
||||||
|
python_bin = "python" if Constants.IS_WINDOWS else "python3"
|
||||||
|
cmd = [
|
||||||
|
python_bin,
|
||||||
|
"-m",
|
||||||
|
"sglang.launch_server",
|
||||||
|
"--model-path",
|
||||||
|
str(model_dir),
|
||||||
|
"--host",
|
||||||
|
host,
|
||||||
|
"--port",
|
||||||
|
str(port),
|
||||||
|
"--mem-fraction-static",
|
||||||
|
str(mem_fraction),
|
||||||
|
"--context-length",
|
||||||
|
str(ctx_len),
|
||||||
|
"--tool-call-parser",
|
||||||
|
"qwen",
|
||||||
|
"--log-level",
|
||||||
|
log_level,
|
||||||
|
]
|
||||||
|
print(f"启动 SGLang: {model_dir} (port={port}, ctx={ctx_len}, mem={mem_fraction})")
|
||||||
|
subprocess.run(cmd, check=False)
|
||||||
@@ -1,15 +1,41 @@
|
|||||||
"""PDF 工具模块.
|
"""媒体类函数模块.
|
||||||
|
|
||||||
提供 PDF 文件操作的常用功能封装,
|
聚合 PDF 工具 (pdftool) 和截图工具 (screenshot) 的可复用函数.
|
||||||
支持合并、拆分、压缩、加密、水印、OCR等功能.
|
所有公共函数通过 ``@px.register_fn`` 注册, 供 YAML 任务编排引用.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import subprocess
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pyflowx as px
|
import pyflowx as px
|
||||||
|
from pyflowx.conditions import Constants
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_PASSWORD",
|
||||||
|
"DEFAULT_QUALITY",
|
||||||
|
"PDF_SUFFIX",
|
||||||
|
"get_screenshot_path",
|
||||||
|
"pdf_add_watermark",
|
||||||
|
"pdf_compress",
|
||||||
|
"pdf_crop",
|
||||||
|
"pdf_decrypt",
|
||||||
|
"pdf_encrypt",
|
||||||
|
"pdf_extract_images",
|
||||||
|
"pdf_extract_text",
|
||||||
|
"pdf_info",
|
||||||
|
"pdf_merge",
|
||||||
|
"pdf_ocr",
|
||||||
|
"pdf_reorder",
|
||||||
|
"pdf_repair",
|
||||||
|
"pdf_rotate",
|
||||||
|
"pdf_split",
|
||||||
|
"pdf_to_images",
|
||||||
|
"take_screenshot_area",
|
||||||
|
"take_screenshot_full",
|
||||||
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import fitz # PyMuPDF
|
import fitz # PyMuPDF
|
||||||
@@ -36,14 +62,15 @@ DEFAULT_PASSWORD = ""
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# 辅助函数
|
# PDF 函数
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
def pdf_merge(input_paths: list[Path], output_path: Path) -> None:
|
def pdf_merge(input_paths: list[Path], output_path: Path) -> None:
|
||||||
"""合并多个 PDF 文件."""
|
"""合并多个 PDF 文件."""
|
||||||
if not HAS_PYPDF:
|
if not HAS_PYPDF:
|
||||||
print("未安装 pypdf 库,请安装: pip install pypdf")
|
print("未安装 pypdf 库, 请安装: pip install pypdf")
|
||||||
return
|
return
|
||||||
|
|
||||||
writer = pypdf.PdfWriter()
|
writer = pypdf.PdfWriter()
|
||||||
@@ -60,10 +87,11 @@ def pdf_merge(input_paths: list[Path], output_path: Path) -> None:
|
|||||||
print(f"合并完成: {output_path}")
|
print(f"合并完成: {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
def pdf_split(input_path: Path, output_dir: Path) -> None:
|
def pdf_split(input_path: Path, output_dir: Path) -> None:
|
||||||
"""拆分 PDF 文件为单页."""
|
"""拆分 PDF 文件为单页."""
|
||||||
if not HAS_PYPDF:
|
if not HAS_PYPDF:
|
||||||
print("未安装 pypdf 库,请安装: pip install pypdf")
|
print("未安装 pypdf 库, 请安装: pip install pypdf")
|
||||||
return
|
return
|
||||||
|
|
||||||
reader = pypdf.PdfReader(str(input_path))
|
reader = pypdf.PdfReader(str(input_path))
|
||||||
@@ -79,10 +107,11 @@ def pdf_split(input_path: Path, output_dir: Path) -> None:
|
|||||||
print(f"拆分完成: {output_dir}")
|
print(f"拆分完成: {output_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
def pdf_compress(input_path: Path, output_path: Path) -> None:
|
def pdf_compress(input_path: Path, output_path: Path) -> None:
|
||||||
"""压缩 PDF 文件."""
|
"""压缩 PDF 文件."""
|
||||||
if not HAS_PYMUPDF:
|
if not HAS_PYMUPDF:
|
||||||
print("未安装 PyMuPDF 库,请安装: pip install PyMuPDF")
|
print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
|
||||||
return
|
return
|
||||||
|
|
||||||
doc = fitz.open(str(input_path))
|
doc = fitz.open(str(input_path))
|
||||||
@@ -96,10 +125,11 @@ def pdf_compress(input_path: Path, output_path: Path) -> None:
|
|||||||
print(f"压缩完成: {output_path} (缩小 {ratio:.1f}%)")
|
print(f"压缩完成: {output_path} (缩小 {ratio:.1f}%)")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
def pdf_encrypt(input_path: Path, output_path: Path, password: str) -> None:
|
def pdf_encrypt(input_path: Path, output_path: Path, password: str) -> None:
|
||||||
"""加密 PDF 文件."""
|
"""加密 PDF 文件."""
|
||||||
if not HAS_PYPDF:
|
if not HAS_PYPDF:
|
||||||
print("未安装 pypdf 库,请安装: pip install pypdf")
|
print("未安装 pypdf 库, 请安装: pip install pypdf")
|
||||||
return
|
return
|
||||||
|
|
||||||
reader = pypdf.PdfReader(str(input_path))
|
reader = pypdf.PdfReader(str(input_path))
|
||||||
@@ -116,10 +146,11 @@ def pdf_encrypt(input_path: Path, output_path: Path, password: str) -> None:
|
|||||||
print(f"加密完成: {output_path}")
|
print(f"加密完成: {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
def pdf_decrypt(input_path: Path, output_path: Path, password: str) -> None:
|
def pdf_decrypt(input_path: Path, output_path: Path, password: str) -> None:
|
||||||
"""解密 PDF 文件."""
|
"""解密 PDF 文件."""
|
||||||
if not HAS_PYPDF:
|
if not HAS_PYPDF:
|
||||||
print("未安装 pypdf 库,请安装: pip install pypdf")
|
print("未安装 pypdf 库, 请安装: pip install pypdf")
|
||||||
return
|
return
|
||||||
|
|
||||||
reader = pypdf.PdfReader(str(input_path))
|
reader = pypdf.PdfReader(str(input_path))
|
||||||
@@ -137,10 +168,11 @@ def pdf_decrypt(input_path: Path, output_path: Path, password: str) -> None:
|
|||||||
print(f"解密完成: {output_path}")
|
print(f"解密完成: {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
def pdf_extract_text(input_path: Path, output_path: Path) -> None:
|
def pdf_extract_text(input_path: Path, output_path: Path) -> None:
|
||||||
"""提取 PDF 文本."""
|
"""提取 PDF 文本."""
|
||||||
if not HAS_PYMUPDF:
|
if not HAS_PYMUPDF:
|
||||||
print("未安装 PyMuPDF 库,请安装: pip install PyMuPDF")
|
print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
|
||||||
return
|
return
|
||||||
|
|
||||||
doc = fitz.open(str(input_path))
|
doc = fitz.open(str(input_path))
|
||||||
@@ -154,10 +186,11 @@ def pdf_extract_text(input_path: Path, output_path: Path) -> None:
|
|||||||
print(f"文本提取完成: {output_path}")
|
print(f"文本提取完成: {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
def pdf_extract_images(input_path: Path, output_dir: Path) -> None:
|
def pdf_extract_images(input_path: Path, output_dir: Path) -> None:
|
||||||
"""提取 PDF 图片."""
|
"""提取 PDF 图片."""
|
||||||
if not HAS_PYMUPDF:
|
if not HAS_PYMUPDF:
|
||||||
print("未安装 PyMuPDF 库,请安装: pip install PyMuPDF")
|
print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
|
||||||
return
|
return
|
||||||
|
|
||||||
doc = fitz.open(str(input_path))
|
doc = fitz.open(str(input_path))
|
||||||
@@ -180,10 +213,11 @@ def pdf_extract_images(input_path: Path, output_dir: Path) -> None:
|
|||||||
print(f"图片提取完成: {output_dir} (共 {image_count} 张)")
|
print(f"图片提取完成: {output_dir} (共 {image_count} 张)")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
def pdf_add_watermark(input_path: Path, output_path: Path, text: str = "CONFIDENTIAL") -> None:
|
def pdf_add_watermark(input_path: Path, output_path: Path, text: str = "CONFIDENTIAL") -> None:
|
||||||
"""添加 PDF 水印."""
|
"""添加 PDF 水印."""
|
||||||
if not HAS_PYMUPDF:
|
if not HAS_PYMUPDF:
|
||||||
print("未安装 PyMuPDF 库,请安装: pip install PyMuPDF")
|
print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
|
||||||
return
|
return
|
||||||
|
|
||||||
doc = fitz.open(str(input_path))
|
doc = fitz.open(str(input_path))
|
||||||
@@ -200,10 +234,11 @@ def pdf_add_watermark(input_path: Path, output_path: Path, text: str = "CONFIDEN
|
|||||||
print(f"水印添加完成: {output_path}")
|
print(f"水印添加完成: {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
def pdf_rotate(input_path: Path, output_path: Path, rotation: int = 90) -> None:
|
def pdf_rotate(input_path: Path, output_path: Path, rotation: int = 90) -> None:
|
||||||
"""旋转 PDF 页面."""
|
"""旋转 PDF 页面."""
|
||||||
if not HAS_PYMUPDF:
|
if not HAS_PYMUPDF:
|
||||||
print("未安装 PyMuPDF 库,请安装: pip install PyMuPDF")
|
print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
|
||||||
return
|
return
|
||||||
|
|
||||||
doc = fitz.open(str(input_path))
|
doc = fitz.open(str(input_path))
|
||||||
@@ -216,10 +251,11 @@ def pdf_rotate(input_path: Path, output_path: Path, rotation: int = 90) -> None:
|
|||||||
print(f"旋转完成: {output_path}")
|
print(f"旋转完成: {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
def pdf_crop(input_path: Path, output_path: Path, margins: tuple[int, int, int, int]) -> None:
|
def pdf_crop(input_path: Path, output_path: Path, margins: tuple[int, int, int, int]) -> None:
|
||||||
"""裁剪 PDF 页面."""
|
"""裁剪 PDF 页面."""
|
||||||
if not HAS_PYMUPDF:
|
if not HAS_PYMUPDF:
|
||||||
print("未安装 PyMuPDF 库,请安装: pip install PyMuPDF")
|
print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
|
||||||
return
|
return
|
||||||
|
|
||||||
doc = fitz.open(str(input_path))
|
doc = fitz.open(str(input_path))
|
||||||
@@ -241,10 +277,11 @@ def pdf_crop(input_path: Path, output_path: Path, margins: tuple[int, int, int,
|
|||||||
print(f"裁剪完成: {output_path}")
|
print(f"裁剪完成: {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
def pdf_info(input_path: Path) -> None:
|
def pdf_info(input_path: Path) -> None:
|
||||||
"""显示 PDF 信息."""
|
"""显示 PDF 信息."""
|
||||||
if not HAS_PYMUPDF:
|
if not HAS_PYMUPDF:
|
||||||
print("未安装 PyMuPDF 库,请安装: pip install PyMuPDF")
|
print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
|
||||||
return
|
return
|
||||||
|
|
||||||
doc = fitz.open(str(input_path))
|
doc = fitz.open(str(input_path))
|
||||||
@@ -262,17 +299,18 @@ def pdf_info(input_path: Path) -> None:
|
|||||||
doc.close()
|
doc.close()
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
def pdf_ocr(input_path: Path, output_path: Path, lang: str = "chi_sim+eng") -> None:
|
def pdf_ocr(input_path: Path, output_path: Path, lang: str = "chi_sim+eng") -> None:
|
||||||
"""PDF OCR 识别."""
|
"""PDF OCR 识别."""
|
||||||
try:
|
try:
|
||||||
import pytesseract
|
import pytesseract
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
except ImportError:
|
except ImportError:
|
||||||
print("未安装 OCR 相关库,请安装: pip install pytesseract pillow")
|
print("未安装 OCR 相关库, 请安装: pip install pytesseract pillow")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not HAS_PYMUPDF:
|
if not HAS_PYMUPDF:
|
||||||
print("未安装 PyMuPDF 库,请安装: pip install PyMuPDF")
|
print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
|
||||||
return
|
return
|
||||||
|
|
||||||
doc = fitz.open(str(input_path))
|
doc = fitz.open(str(input_path))
|
||||||
@@ -287,7 +325,7 @@ def pdf_ocr(input_path: Path, output_path: Path, lang: str = "chi_sim+eng") -> N
|
|||||||
new_page.insert_image(new_page.rect, pixmap=pix)
|
new_page.insert_image(new_page.rect, pixmap=pix)
|
||||||
text_rect = fitz.Rect(0, 0, page.rect.width, page.rect.height)
|
text_rect = fitz.Rect(0, 0, page.rect.width, page.rect.height)
|
||||||
# pyrefly: ignore [bad-argument-type]
|
# pyrefly: ignore [bad-argument-type]
|
||||||
new_page.insert_textbox(text_rect, ocr_text)
|
new_page.insert_textbox(text_rect, ocr_text, fontname="china-ss", fontsize=11)
|
||||||
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
new_doc.save(str(output_path))
|
new_doc.save(str(output_path))
|
||||||
@@ -296,10 +334,11 @@ def pdf_ocr(input_path: Path, output_path: Path, lang: str = "chi_sim+eng") -> N
|
|||||||
print(f"OCR 识别完成: {output_path}")
|
print(f"OCR 识别完成: {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
def pdf_reorder(input_path: Path, output_path: Path, order: list[int]) -> None:
|
def pdf_reorder(input_path: Path, output_path: Path, order: list[int]) -> None:
|
||||||
"""重排 PDF 页面顺序."""
|
"""重排 PDF 页面顺序."""
|
||||||
if not HAS_PYPDF:
|
if not HAS_PYPDF:
|
||||||
print("未安装 pypdf 库,请安装: pip install pypdf")
|
print("未安装 pypdf 库, 请安装: pip install pypdf")
|
||||||
return
|
return
|
||||||
|
|
||||||
reader = pypdf.PdfReader(str(input_path))
|
reader = pypdf.PdfReader(str(input_path))
|
||||||
@@ -316,10 +355,11 @@ def pdf_reorder(input_path: Path, output_path: Path, order: list[int]) -> None:
|
|||||||
print(f"重排完成: {output_path}")
|
print(f"重排完成: {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
def pdf_to_images(input_path: Path, output_dir: Path, dpi: int = 300) -> None:
|
def pdf_to_images(input_path: Path, output_dir: Path, dpi: int = 300) -> None:
|
||||||
"""PDF 转图片."""
|
"""PDF 转图片."""
|
||||||
if not HAS_PYMUPDF:
|
if not HAS_PYMUPDF:
|
||||||
print("未安装 PyMuPDF 库,请安装: pip install PyMuPDF")
|
print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
|
||||||
return
|
return
|
||||||
|
|
||||||
doc = fitz.open(str(input_path))
|
doc = fitz.open(str(input_path))
|
||||||
@@ -335,10 +375,11 @@ def pdf_to_images(input_path: Path, output_dir: Path, dpi: int = 300) -> None:
|
|||||||
print(f"转换完成: {output_dir}")
|
print(f"转换完成: {output_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
def pdf_repair(input_path: Path, output_path: Path) -> None:
|
def pdf_repair(input_path: Path, output_path: Path) -> None:
|
||||||
"""修复 PDF 文件."""
|
"""修复 PDF 文件."""
|
||||||
if not HAS_PYMUPDF:
|
if not HAS_PYMUPDF:
|
||||||
print("未安装 PyMuPDF 库,请安装: pip install PyMuPDF")
|
print("未安装 PyMuPDF 库, 请安装: pip install PyMuPDF")
|
||||||
return
|
return
|
||||||
|
|
||||||
doc = fitz.open(str(input_path))
|
doc = fitz.open(str(input_path))
|
||||||
@@ -349,175 +390,109 @@ def pdf_repair(input_path: Path, output_path: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# CLI Runner
|
# screenshot 函数
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
def main() -> None: # noqa: PLR0912
|
@px.register_fn
|
||||||
"""PDF 工具主函数."""
|
def get_screenshot_path(filename: str | None = None) -> Path:
|
||||||
parser = argparse.ArgumentParser(
|
"""获取截图保存路径.
|
||||||
description="PDFTool - PDF 文件工具集",
|
|
||||||
usage="pdftool <command> [options]",
|
|
||||||
)
|
|
||||||
subparsers = parser.add_subparsers(dest="command", help="可用命令")
|
|
||||||
|
|
||||||
# 合并 PDF 命令
|
Parameters
|
||||||
merge_parser = subparsers.add_parser("m", help="合并 PDF 文件")
|
----------
|
||||||
merge_parser.add_argument("inputs", nargs="+", help="输入 PDF 文件路径")
|
filename : str | None
|
||||||
merge_parser.add_argument("--output", type=str, default="merged.pdf", help="输出文件路径")
|
文件名, 如果为 None 则自动生成
|
||||||
|
|
||||||
# 拆分 PDF 命令
|
Returns
|
||||||
split_parser = subparsers.add_parser("s", help="拆分 PDF 文件为单页")
|
-------
|
||||||
split_parser.add_argument("input", help="输入 PDF 文件路径")
|
Path
|
||||||
split_parser.add_argument("--output-dir", type=str, default="split", help="输出目录")
|
截图保存路径
|
||||||
|
"""
|
||||||
|
if filename is None:
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"screenshot_{timestamp}.png"
|
||||||
|
|
||||||
# 压缩 PDF 命令
|
screenshots_dir = Path.home() / "Pictures" / "screenshots"
|
||||||
compress_parser = subparsers.add_parser("c", help="压缩 PDF 文件")
|
screenshots_dir.mkdir(parents=True, exist_ok=True)
|
||||||
compress_parser.add_argument("input", help="输入 PDF 文件路径")
|
return screenshots_dir / filename
|
||||||
compress_parser.add_argument("--output", type=str, default="compressed.pdf", help="输出文件路径")
|
|
||||||
|
|
||||||
# 加密 PDF 命令
|
|
||||||
encrypt_parser = subparsers.add_parser("e", help="加密 PDF 文件")
|
|
||||||
encrypt_parser.add_argument("input", help="输入 PDF 文件路径")
|
|
||||||
encrypt_parser.add_argument("--output", type=str, default="encrypted.pdf", help="输出文件路径")
|
|
||||||
encrypt_parser.add_argument("--password", type=str, required=True, help="密码")
|
|
||||||
|
|
||||||
# 解密 PDF 命令
|
@px.register_fn
|
||||||
decrypt_parser = subparsers.add_parser("d", help="解密 PDF 文件")
|
def take_screenshot_full(filename: str | None = None) -> None:
|
||||||
decrypt_parser.add_argument("input", help="输入 PDF 文件路径")
|
"""全屏截图.
|
||||||
decrypt_parser.add_argument("--output", type=str, default="decrypted.pdf", help="输出文件路径")
|
|
||||||
decrypt_parser.add_argument("--password", type=str, required=True, help="密码")
|
|
||||||
|
|
||||||
# 提取文本命令
|
Parameters
|
||||||
extract_text_parser = subparsers.add_parser("xt", help="提取 PDF 文本")
|
----------
|
||||||
extract_text_parser.add_argument("input", help="输入 PDF 文件路径")
|
filename : str | None
|
||||||
extract_text_parser.add_argument("--output", type=str, default="output.txt", help="输出文件路径")
|
文件名
|
||||||
|
"""
|
||||||
|
output_path = get_screenshot_path(filename)
|
||||||
|
|
||||||
# 提取图片命令
|
if Constants.IS_WINDOWS:
|
||||||
extract_images_parser = subparsers.add_parser("xi", help="提取 PDF 图片")
|
ps_script = f"""
|
||||||
extract_images_parser.add_argument("input", help="输入 PDF 文件路径")
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
extract_images_parser.add_argument("--output-dir", type=str, default="images", help="输出目录")
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
$screen = [System.Windows.Forms.Screen]::PrimaryScreen
|
||||||
# 添加水印命令
|
$bounds = $screen.Bounds
|
||||||
watermark_parser = subparsers.add_parser("w", help="添加 PDF 水印")
|
$bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
|
||||||
watermark_parser.add_argument("input", help="输入 PDF 文件路径")
|
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||||
watermark_parser.add_argument("--output", type=str, default="watermarked.pdf", help="输出文件路径")
|
$graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
|
||||||
watermark_parser.add_argument("--text", type=str, default="CONFIDENTIAL", help="水印文本")
|
$bitmap.Save('{output_path.as_posix()}')
|
||||||
|
$graphics.Dispose()
|
||||||
# 旋转 PDF 命令
|
$bitmap.Dispose()
|
||||||
rotate_parser = subparsers.add_parser("r", help="旋转 PDF 页面")
|
"""
|
||||||
rotate_parser.add_argument("input", help="输入 PDF 文件路径")
|
subprocess.run(["powershell", "-Command", ps_script], check=True)
|
||||||
rotate_parser.add_argument("--output", type=str, default="rotated.pdf", help="输出文件路径")
|
elif Constants.IS_MACOS:
|
||||||
rotate_parser.add_argument("--rotation", type=int, default=90, help="旋转角度 (90, 180, 270)")
|
subprocess.run(["screencapture", "-x", str(output_path)], check=True)
|
||||||
|
|
||||||
# 裁剪 PDF 命令
|
|
||||||
crop_parser = subparsers.add_parser("crop", help="裁剪 PDF 页面")
|
|
||||||
crop_parser.add_argument("input", help="输入 PDF 文件路径")
|
|
||||||
crop_parser.add_argument("--output", type=str, default="cropped.pdf", help="输出文件路径")
|
|
||||||
crop_parser.add_argument("--left", type=int, default=10, help="左边裁剪")
|
|
||||||
crop_parser.add_argument("--top", type=int, default=10, help="顶部裁剪")
|
|
||||||
crop_parser.add_argument("--right", type=int, default=10, help="右边裁剪")
|
|
||||||
crop_parser.add_argument("--bottom", type=int, default=10, help="底部裁剪")
|
|
||||||
|
|
||||||
# 显示信息命令
|
|
||||||
info_parser = subparsers.add_parser("i", help="显示 PDF 信息")
|
|
||||||
info_parser.add_argument("input", help="输入 PDF 文件路径")
|
|
||||||
|
|
||||||
# OCR 识别命令
|
|
||||||
ocr_parser = subparsers.add_parser("ocr", help="PDF OCR 识别")
|
|
||||||
ocr_parser.add_argument("input", help="输入 PDF 文件路径")
|
|
||||||
ocr_parser.add_argument("--output", type=str, default="ocr.pdf", help="输出文件路径")
|
|
||||||
ocr_parser.add_argument("--lang", type=str, default="chi_sim+eng", help="OCR 语言")
|
|
||||||
|
|
||||||
# 转换图片命令
|
|
||||||
to_images_parser = subparsers.add_parser("img", help="PDF 转图片")
|
|
||||||
to_images_parser.add_argument("input", help="输入 PDF 文件路径")
|
|
||||||
to_images_parser.add_argument("--output-dir", type=str, default="images", help="输出目录")
|
|
||||||
to_images_parser.add_argument("--dpi", type=int, default=300, help="图片 DPI")
|
|
||||||
|
|
||||||
# 修复 PDF 命令
|
|
||||||
repair_parser = subparsers.add_parser("repair", help="修复 PDF 文件")
|
|
||||||
repair_parser.add_argument("input", help="输入 PDF 文件路径")
|
|
||||||
repair_parser.add_argument("--output", type=str, default="repaired.pdf", help="输出文件路径")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.command == "m":
|
|
||||||
graph = px.Graph.from_specs([
|
|
||||||
px.TaskSpec("pdf_merge", fn=pdf_merge, args=([Path(p) for p in args.inputs], Path(args.output)))
|
|
||||||
])
|
|
||||||
elif args.command == "s":
|
|
||||||
graph = px.Graph.from_specs([
|
|
||||||
px.TaskSpec("pdf_split", fn=pdf_split, args=(Path(args.input), Path(args.output_dir)))
|
|
||||||
])
|
|
||||||
elif args.command == "c":
|
|
||||||
graph = px.Graph.from_specs([
|
|
||||||
px.TaskSpec("pdf_compress", fn=pdf_compress, args=(Path(args.input), Path(args.output)))
|
|
||||||
])
|
|
||||||
elif args.command == "e":
|
|
||||||
graph = px.Graph.from_specs([
|
|
||||||
px.TaskSpec("pdf_encrypt", fn=pdf_encrypt, args=(Path(args.input), Path(args.output), args.password))
|
|
||||||
])
|
|
||||||
elif args.command == "d":
|
|
||||||
graph = px.Graph.from_specs([
|
|
||||||
px.TaskSpec("pdf_decrypt", fn=pdf_decrypt, args=(Path(args.input), Path(args.output), args.password))
|
|
||||||
])
|
|
||||||
elif args.command == "xt":
|
|
||||||
graph = px.Graph.from_specs([
|
|
||||||
px.TaskSpec("pdf_extract_text", fn=pdf_extract_text, args=(Path(args.input), Path(args.output)))
|
|
||||||
])
|
|
||||||
elif args.command == "xi":
|
|
||||||
graph = px.Graph.from_specs([
|
|
||||||
px.TaskSpec("pdf_extract_images", fn=pdf_extract_images, args=(Path(args.input), Path(args.output_dir)))
|
|
||||||
])
|
|
||||||
elif args.command == "w":
|
|
||||||
graph = px.Graph.from_specs([
|
|
||||||
px.TaskSpec(
|
|
||||||
"pdf_watermark",
|
|
||||||
fn=pdf_add_watermark,
|
|
||||||
args=(Path(args.input), Path(args.output)),
|
|
||||||
kwargs={"text": args.text},
|
|
||||||
)
|
|
||||||
])
|
|
||||||
elif args.command == "r":
|
|
||||||
graph = px.Graph.from_specs([
|
|
||||||
px.TaskSpec(
|
|
||||||
"pdf_rotate",
|
|
||||||
fn=pdf_rotate,
|
|
||||||
args=(Path(args.input), Path(args.output)),
|
|
||||||
kwargs={"rotation": args.rotation},
|
|
||||||
)
|
|
||||||
])
|
|
||||||
elif args.command == "crop":
|
|
||||||
graph = px.Graph.from_specs([
|
|
||||||
px.TaskSpec(
|
|
||||||
"pdf_crop",
|
|
||||||
fn=pdf_crop,
|
|
||||||
args=(Path(args.input), Path(args.output)),
|
|
||||||
kwargs={"margins": (args.left, args.top, args.right, args.bottom)},
|
|
||||||
)
|
|
||||||
])
|
|
||||||
elif args.command == "i":
|
|
||||||
graph = px.Graph.from_specs([px.TaskSpec("pdf_info", fn=pdf_info, args=(Path(args.input),))])
|
|
||||||
elif args.command == "ocr":
|
|
||||||
graph = px.Graph.from_specs([
|
|
||||||
px.TaskSpec("pdf_ocr", fn=pdf_ocr, args=(Path(args.input), Path(args.output)), kwargs={"lang": args.lang})
|
|
||||||
])
|
|
||||||
elif args.command == "img":
|
|
||||||
graph = px.Graph.from_specs([
|
|
||||||
px.TaskSpec(
|
|
||||||
"pdf_to_images",
|
|
||||||
fn=pdf_to_images,
|
|
||||||
args=(Path(args.input), Path(args.output_dir)),
|
|
||||||
kwargs={"dpi": args.dpi},
|
|
||||||
)
|
|
||||||
])
|
|
||||||
elif args.command == "repair":
|
|
||||||
graph = px.Graph.from_specs([
|
|
||||||
px.TaskSpec("pdf_repair", fn=pdf_repair, args=(Path(args.input), Path(args.output)))
|
|
||||||
])
|
|
||||||
else:
|
else:
|
||||||
parser.print_help()
|
try:
|
||||||
return
|
subprocess.run(["gnome-screenshot", "-f", str(output_path)], check=True)
|
||||||
|
except FileNotFoundError:
|
||||||
|
subprocess.run(["scrot", str(output_path)], check=True)
|
||||||
|
|
||||||
px.run(graph, strategy="thread")
|
print(f"截图已保存: {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def take_screenshot_area(filename: str | None = None) -> None:
|
||||||
|
"""区域截图.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
filename : str | None
|
||||||
|
文件名
|
||||||
|
"""
|
||||||
|
output_path = get_screenshot_path(filename)
|
||||||
|
|
||||||
|
if Constants.IS_WINDOWS:
|
||||||
|
ps_script = f"""
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
$form = New-Object System.Windows.Forms.Form
|
||||||
|
$form.WindowState = 'Maximized'
|
||||||
|
$form.FormBorderStyle = 'None'
|
||||||
|
$form.BackColor = [System.Drawing.Color]::FromArgb(1, 0, 0)
|
||||||
|
$form.Opacity = 0.5
|
||||||
|
$form.TopMost = $true
|
||||||
|
$form.Show()
|
||||||
|
Start-Sleep -Milliseconds 100
|
||||||
|
$screen = [System.Windows.Forms.Screen]::PrimaryScreen
|
||||||
|
$bounds = $screen.Bounds
|
||||||
|
$bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
|
||||||
|
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||||
|
$graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
|
||||||
|
$form.Close()
|
||||||
|
$bitmap.Save('{output_path.as_posix()}')
|
||||||
|
$graphics.Dispose()
|
||||||
|
$bitmap.Dispose()
|
||||||
|
"""
|
||||||
|
subprocess.run(["powershell", "-Command", ps_script], check=True)
|
||||||
|
elif Constants.IS_MACOS:
|
||||||
|
subprocess.run(["screencapture", "-i", str(output_path)], check=True)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
subprocess.run(["gnome-screenshot", "-a", "-f", str(output_path)], check=True)
|
||||||
|
except FileNotFoundError:
|
||||||
|
subprocess.run(["scrot", "-s", str(output_path)], check=True)
|
||||||
|
|
||||||
|
print(f"截图已保存: {output_path}")
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
"""系统类函数模块.
|
||||||
|
|
||||||
|
聚合 LS-DYNA 计算 (lscalc)、SSH 密钥部署 (sshcopyid)、Python 打包 (packtool)、
|
||||||
|
重置图标缓存 (reset_icon_cache) 的可复用函数. 所有公共函数通过 ``@px.register_fn``
|
||||||
|
注册, 供 YAML 任务编排引用.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pyflowx as px
|
||||||
|
from pyflowx.conditions import Constants
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_BUILD_DIR",
|
||||||
|
"DEFAULT_CACHE_DIR",
|
||||||
|
"DEFAULT_DIST_DIR",
|
||||||
|
"DEFAULT_INPUT_FILE",
|
||||||
|
"DEFAULT_LIB_DIR",
|
||||||
|
"DEFAULT_NCPU",
|
||||||
|
"IGNORE_PATTERNS",
|
||||||
|
"LS_DYNA_COMMANDS",
|
||||||
|
"check_ls_dyna_status",
|
||||||
|
"clean_build_dir",
|
||||||
|
"clear_screen_run",
|
||||||
|
"create_zip_package",
|
||||||
|
"get_ls_dyna_command",
|
||||||
|
"install_embed_python",
|
||||||
|
"pack_dependencies",
|
||||||
|
"pack_source",
|
||||||
|
"pack_wheel",
|
||||||
|
"reset_icon_cache_run",
|
||||||
|
"run_ls_dyna",
|
||||||
|
"run_ls_dyna_mpi",
|
||||||
|
"ssh_copy_id",
|
||||||
|
"taskkill_run",
|
||||||
|
"which_run",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# lscalc 配置
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
LS_DYNA_COMMANDS: dict[str, list[str]] = {
|
||||||
|
"windows": ["ls-dyna_mpp", "i=input.k", "ncpu=4"],
|
||||||
|
"linux": ["ls-dyna_mpp", "i=input.k", "ncpu=8"],
|
||||||
|
"macos": ["ls-dyna_mpp", "i=input.k", "ncpu=4"],
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_INPUT_FILE: str = "input.k"
|
||||||
|
DEFAULT_NCPU: int = 4
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# packtool 配置
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
DEFAULT_BUILD_DIR = ".pypack"
|
||||||
|
DEFAULT_DIST_DIR = "dist"
|
||||||
|
DEFAULT_LIB_DIR = "libs"
|
||||||
|
DEFAULT_CACHE_DIR = ".cache/pypack"
|
||||||
|
|
||||||
|
IGNORE_PATTERNS = [
|
||||||
|
"__pycache__",
|
||||||
|
"*.pyc",
|
||||||
|
"*.pyo",
|
||||||
|
".git",
|
||||||
|
".venv",
|
||||||
|
".idea",
|
||||||
|
".vscode",
|
||||||
|
"*.egg-info",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
".pytest_cache",
|
||||||
|
".tox",
|
||||||
|
".mypy_cache",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# lscalc 函数
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def get_ls_dyna_command(input_file: str, ncpu: int) -> list[str]:
|
||||||
|
"""获取 LS-DYNA 命令.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
input_file : str
|
||||||
|
输入文件路径
|
||||||
|
ncpu : int
|
||||||
|
CPU 核心数
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[str]
|
||||||
|
LS-DYNA 命令列表
|
||||||
|
"""
|
||||||
|
if Constants.IS_WINDOWS or Constants.IS_MACOS:
|
||||||
|
return ["ls-dyna_mpp", f"i={input_file}", f"ncpu={ncpu}"]
|
||||||
|
else:
|
||||||
|
return ["ls-dyna_mpp", f"i={input_file}", f"ncpu={ncpu}"]
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def run_ls_dyna(input_file: str, ncpu: int = DEFAULT_NCPU) -> None:
|
||||||
|
"""运行 LS-DYNA 计算.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
input_file : str
|
||||||
|
输入文件路径
|
||||||
|
ncpu : int
|
||||||
|
CPU 核心数
|
||||||
|
"""
|
||||||
|
input_path = Path(input_file)
|
||||||
|
if not input_path.exists():
|
||||||
|
print(f"输入文件不存在: {input_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
cmd = get_ls_dyna_command(input_file, ncpu)
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
print(f"LS-DYNA 计算完成: {input_file}")
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("未找到 ls-dyna_mpp 命令")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"LS-DYNA 计算失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def run_ls_dyna_mpi(input_file: str, ncpu: int = DEFAULT_NCPU) -> None:
|
||||||
|
"""运行 LS-DYNA MPI 计算.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
input_file : str
|
||||||
|
输入文件路径
|
||||||
|
ncpu : int
|
||||||
|
CPU 核心数
|
||||||
|
"""
|
||||||
|
input_path = Path(input_file)
|
||||||
|
if not input_path.exists():
|
||||||
|
print(f"输入文件不存在: {input_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
cmd = ["mpirun", "-np", str(ncpu), "ls-dyna_mpp", f"i={input_file}"]
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
print(f"LS-DYNA MPI 计算完成: {input_file}")
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("未找到 mpirun 或 ls-dyna_mpp 命令")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"LS-DYNA MPI 计算失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def check_ls_dyna_status() -> None:
|
||||||
|
"""检查 LS-DYNA 进程状态."""
|
||||||
|
try:
|
||||||
|
if Constants.IS_WINDOWS:
|
||||||
|
result = subprocess.run(
|
||||||
|
["tasklist", "/fi", "imagename eq ls-dyna_mpp.exe"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
print(result.stdout)
|
||||||
|
else:
|
||||||
|
result = subprocess.run(
|
||||||
|
["pgrep", "-f", "ls-dyna"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.stdout.strip():
|
||||||
|
print(f"运行中的 LS-DYNA 进程 PID: {result.stdout.strip()}")
|
||||||
|
else:
|
||||||
|
print("没有运行中的 LS-DYNA 进程")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"检查进程状态失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# sshcopyid 函数
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def ssh_copy_id(
|
||||||
|
hostname: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
port: int = 22,
|
||||||
|
keypath: str = "~/.ssh/id_rsa.pub",
|
||||||
|
timeout: int = 30,
|
||||||
|
) -> None:
|
||||||
|
"""将 SSH 公钥部署到远程服务器.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
hostname : str
|
||||||
|
远程服务器主机名或 IP 地址
|
||||||
|
username : str
|
||||||
|
远程服务器用户名
|
||||||
|
password : str
|
||||||
|
远程服务器密码
|
||||||
|
port : int
|
||||||
|
SSH 端口, 默认 22
|
||||||
|
keypath : str
|
||||||
|
公钥文件路径, 默认 ~/.ssh/id_rsa.pub
|
||||||
|
timeout : int
|
||||||
|
SSH 操作超时秒数, 默认 30
|
||||||
|
"""
|
||||||
|
pub_key_path = Path(keypath).expanduser()
|
||||||
|
if not pub_key_path.exists():
|
||||||
|
print(f"公钥文件不存在: {pub_key_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
pub_key = pub_key_path.read_text().strip()
|
||||||
|
|
||||||
|
script = f"""mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||||
|
cd ~/.ssh && touch authorized_keys && chmod 600 authorized_keys
|
||||||
|
grep -qF '{pub_key.split()[1]}' authorized_keys 2>/dev/null || echo '{pub_key}' >> authorized_keys"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"sshpass",
|
||||||
|
"-p",
|
||||||
|
password,
|
||||||
|
"ssh",
|
||||||
|
"-p",
|
||||||
|
str(port),
|
||||||
|
"-o",
|
||||||
|
"StrictHostKeyChecking=no",
|
||||||
|
"-o",
|
||||||
|
"UserKnownHostsFile=/dev/null",
|
||||||
|
"-o",
|
||||||
|
f"ConnectTimeout={timeout}",
|
||||||
|
f"{username}@{hostname}",
|
||||||
|
script,
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
print(f"SSH 密钥已部署到 {username}@{hostname}:{port}")
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"未找到 sshpass 工具, 请手动执行: ssh-copy-id -p {port} {username}@{hostname}")
|
||||||
|
sys.exit(1)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print("SSH 连接超时")
|
||||||
|
sys.exit(1)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"SSH 执行失败: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# packtool 函数
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def pack_source(project_dir: Path, output_dir: Path) -> None:
|
||||||
|
"""打包项目源码.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
project_dir : Path
|
||||||
|
项目目录
|
||||||
|
output_dir : Path
|
||||||
|
输出目录
|
||||||
|
"""
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
pyproject_file = project_dir / "pyproject.toml"
|
||||||
|
project_name = project_dir.name
|
||||||
|
|
||||||
|
if pyproject_file.exists():
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
content = pyproject_file.read_text(encoding="utf-8")
|
||||||
|
data = tomllib.loads(content)
|
||||||
|
project_name = data.get("project", {}).get("name", project_name)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
source_dir = output_dir / "src" / project_name
|
||||||
|
source_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
src_subdir = project_dir / "src"
|
||||||
|
if src_subdir.exists():
|
||||||
|
shutil.copytree(
|
||||||
|
src_subdir,
|
||||||
|
source_dir / "src",
|
||||||
|
ignore=shutil.ignore_patterns(*IGNORE_PATTERNS),
|
||||||
|
dirs_exist_ok=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for item in project_dir.iterdir():
|
||||||
|
if item.name in IGNORE_PATTERNS or item.name.startswith("."):
|
||||||
|
continue
|
||||||
|
dst_item = source_dir / item.name
|
||||||
|
if item.is_dir():
|
||||||
|
shutil.copytree(
|
||||||
|
item,
|
||||||
|
dst_item,
|
||||||
|
ignore=shutil.ignore_patterns(*IGNORE_PATTERNS),
|
||||||
|
dirs_exist_ok=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
shutil.copy2(item, dst_item)
|
||||||
|
|
||||||
|
print(f"源码打包完成: {source_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def pack_dependencies(lib_dir: Path, dependencies: list[str]) -> None:
|
||||||
|
"""打包项目依赖.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
lib_dir : Path
|
||||||
|
依赖库目录
|
||||||
|
dependencies : list[str]
|
||||||
|
依赖列表
|
||||||
|
"""
|
||||||
|
lib_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if not dependencies:
|
||||||
|
print("没有依赖需要打包")
|
||||||
|
return
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"pip",
|
||||||
|
"install",
|
||||||
|
"--target",
|
||||||
|
str(lib_dir),
|
||||||
|
"--no-compile",
|
||||||
|
"--no-warn-script-location",
|
||||||
|
]
|
||||||
|
cmd.extend(dependencies)
|
||||||
|
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
print(f"依赖打包完成: {lib_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def pack_wheel(project_dir: Path, output_dir: Path) -> None:
|
||||||
|
"""打包项目为 wheel 文件.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
project_dir : Path
|
||||||
|
项目目录
|
||||||
|
output_dir : Path
|
||||||
|
输出目录
|
||||||
|
"""
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"pip",
|
||||||
|
"wheel",
|
||||||
|
"--no-deps",
|
||||||
|
"--wheel-dir",
|
||||||
|
str(output_dir),
|
||||||
|
str(project_dir),
|
||||||
|
]
|
||||||
|
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
print(f"Wheel 打包完成: {output_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def install_embed_python(version: str, output_dir: Path) -> None:
|
||||||
|
"""安装嵌入式 Python.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
version : str
|
||||||
|
Python 版本 (如: 3.10, 3.11)
|
||||||
|
output_dir : Path
|
||||||
|
输出目录
|
||||||
|
"""
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
arch = platform.machine().lower()
|
||||||
|
if arch in ["x86_64", "amd64"]:
|
||||||
|
arch = "amd64"
|
||||||
|
elif arch in ["arm64", "aarch64"]:
|
||||||
|
arch = "arm64"
|
||||||
|
|
||||||
|
version_map = {
|
||||||
|
"3.8": "3.8.10",
|
||||||
|
"3.9": "3.9.13",
|
||||||
|
"3.10": "3.10.11",
|
||||||
|
"3.11": "3.11.9",
|
||||||
|
"3.12": "3.12.4",
|
||||||
|
}
|
||||||
|
full_version = version_map.get(version, f"{version}.0")
|
||||||
|
|
||||||
|
url = f"https://www.python.org/ftp/python/{full_version}/python-{full_version}-embed-{arch}.zip"
|
||||||
|
|
||||||
|
cache_file = Path(DEFAULT_CACHE_DIR) / f"python-{full_version}-embed-{arch}.zip"
|
||||||
|
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if not cache_file.exists():
|
||||||
|
print(f"正在下载嵌入式 Python {full_version}...")
|
||||||
|
urllib.request.urlretrieve(url, cache_file)
|
||||||
|
print(f"下载完成: {cache_file}")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(cache_file, "r") as zf:
|
||||||
|
zf.extractall(output_dir)
|
||||||
|
|
||||||
|
print(f"嵌入式 Python 安装完成: {output_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def create_zip_package(source_dir: Path, output_file: Path) -> None:
|
||||||
|
"""创建 ZIP 打包文件.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
source_dir : Path
|
||||||
|
源目录
|
||||||
|
output_file : Path
|
||||||
|
输出文件
|
||||||
|
"""
|
||||||
|
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(output_file, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for file in source_dir.rglob("*"):
|
||||||
|
if file.is_file():
|
||||||
|
arcname = file.relative_to(source_dir)
|
||||||
|
zf.write(file, arcname)
|
||||||
|
|
||||||
|
print(f"ZIP 打包完成: {output_file}")
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def clean_build_dir(build_dir: Path) -> None:
|
||||||
|
"""清理构建目录.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
build_dir : Path
|
||||||
|
构建目录
|
||||||
|
"""
|
||||||
|
if build_dir.exists():
|
||||||
|
shutil.rmtree(build_dir)
|
||||||
|
print(f"清理完成: {build_dir}")
|
||||||
|
else:
|
||||||
|
print(f"目录不存在: {build_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# reseticoncache 函数
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def reset_icon_cache_run() -> None:
|
||||||
|
"""重置 Windows 图标缓存.
|
||||||
|
|
||||||
|
执行流程: 杀掉 explorer → 删除 IconCache.db → 删除 iconcache* → 重启 explorer.
|
||||||
|
仅在 Windows 上执行, 非 Windows 平台打印提示并跳过.
|
||||||
|
"""
|
||||||
|
if not Constants.IS_WINDOWS:
|
||||||
|
print("reset_icon_cache: 仅在 Windows 上支持")
|
||||||
|
return
|
||||||
|
|
||||||
|
local_app_data = os.environ.get("LOCALAPPDATA", "")
|
||||||
|
if not local_app_data:
|
||||||
|
print("reset_icon_cache: LOCALAPPDATA 环境变量未设置")
|
||||||
|
return
|
||||||
|
|
||||||
|
icon_cache_db = Path(local_app_data) / "IconCache.db"
|
||||||
|
explorer_cache_dir = Path(local_app_data) / "Microsoft" / "Windows" / "Explorer"
|
||||||
|
|
||||||
|
print("正在终止 explorer 进程...")
|
||||||
|
subprocess.run(["taskkill", "/f", "/im", "explorer.exe"], check=False)
|
||||||
|
|
||||||
|
if icon_cache_db.exists():
|
||||||
|
print(f"删除图标缓存: {icon_cache_db}")
|
||||||
|
subprocess.run(["cmd", "/c", "del", "/a", "/q", str(icon_cache_db)], check=False)
|
||||||
|
|
||||||
|
if explorer_cache_dir.exists():
|
||||||
|
print(f"清理 Explorer 缓存: {explorer_cache_dir}")
|
||||||
|
subprocess.run(
|
||||||
|
["cmd", "/c", "del", "/a", "/q", str(explorer_cache_dir / "iconcache*")],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("重启 explorer...")
|
||||||
|
subprocess.run(["cmd", "/c", "start", "explorer.exe"], check=False)
|
||||||
|
print("图标缓存已重置")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# clearscreen / taskkill / which 函数
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def clear_screen_run() -> None:
|
||||||
|
"""清屏 (跨平台).
|
||||||
|
|
||||||
|
Windows 调用 ``cls``, Linux/macOS 调用 ``clear``.
|
||||||
|
"""
|
||||||
|
cmd = ["cls"] if Constants.IS_WINDOWS else ["clear"]
|
||||||
|
subprocess.run(cmd, check=False)
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def taskkill_run(process_names: list[str]) -> None:
|
||||||
|
"""按名称终止进程 (跨平台).
|
||||||
|
|
||||||
|
Windows 使用 ``taskkill /f /im <name>*``,
|
||||||
|
Linux/macOS 使用 ``pkill -f <name>*``.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
process_names : list[str]
|
||||||
|
进程名称列表 (如: ``["chrome.exe", "python"]``)
|
||||||
|
"""
|
||||||
|
if Constants.IS_WINDOWS:
|
||||||
|
cmd_prefix: list[str] = ["taskkill", "/f", "/im"]
|
||||||
|
else:
|
||||||
|
cmd_prefix = ["pkill", "-f"]
|
||||||
|
|
||||||
|
for name in process_names:
|
||||||
|
print(f"终止进程: {name}")
|
||||||
|
subprocess.run([*cmd_prefix, f"{name}*"], check=False)
|
||||||
|
|
||||||
|
|
||||||
|
@px.register_fn
|
||||||
|
def which_run(commands: list[str]) -> None:
|
||||||
|
"""查找可执行命令路径 (跨平台).
|
||||||
|
|
||||||
|
Windows 使用 ``where``, Linux/macOS 使用 ``which``.
|
||||||
|
对每个命令打印 ``<cmd> -> <path>`` 或 ``<cmd> -> 未找到``.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
commands : list[str]
|
||||||
|
要查找的命令名称列表
|
||||||
|
"""
|
||||||
|
which_cmd = "where" if Constants.IS_WINDOWS else "which"
|
||||||
|
|
||||||
|
for cmd in commands:
|
||||||
|
result = subprocess.run([which_cmd, cmd], capture_output=True, text=True, check=False)
|
||||||
|
if result.returncode == 0:
|
||||||
|
# Windows 的 where 可能返回多行, 取第一个
|
||||||
|
path = result.stdout.strip().split("\n")[0].strip()
|
||||||
|
print(f"{cmd} -> {path}")
|
||||||
|
else:
|
||||||
|
print(f"{cmd} -> 未找到")
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""函数注册表.
|
||||||
|
|
||||||
|
提供全局函数注册机制, 供 YAML 任务编排通过 ``fn`` 字段引用 Python 函数.
|
||||||
|
|
||||||
|
使用方式
|
||||||
|
--------
|
||||||
|
import pyflowx as px
|
||||||
|
|
||||||
|
@px.register_fn("pack_source")
|
||||||
|
def pack_source(project_dir, output_dir):
|
||||||
|
...
|
||||||
|
|
||||||
|
# YAML 中引用:
|
||||||
|
# jobs:
|
||||||
|
# pack:
|
||||||
|
# fn: pack_source
|
||||||
|
# args: ["./project", "./dist"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from typing import Any, Callable, TypeVar, overload
|
||||||
|
|
||||||
|
if sys.version_info >= (3, 10):
|
||||||
|
from typing import ParamSpec
|
||||||
|
else:
|
||||||
|
from typing_extensions import ParamSpec # pragma: no cover
|
||||||
|
|
||||||
|
__all__ = ["FnRegistry", "get_fn", "has_fn", "register_fn"]
|
||||||
|
|
||||||
|
P = ParamSpec("P")
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
_REGISTRY: dict[str, Callable[..., Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def register_fn(name: Callable[P, T]) -> Callable[P, T]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def register_fn(name: str | None = None) -> Callable[[Callable[P, T]], Callable[P, T]]: ...
|
||||||
|
|
||||||
|
|
||||||
|
def register_fn(name: str | Callable[..., Any] | None = None) -> Callable[..., Any]:
|
||||||
|
"""装饰器:将函数注册到全局 registry.
|
||||||
|
|
||||||
|
支持两种用法::
|
||||||
|
|
||||||
|
@register_fn # 使用函数 __name__ 作为注册名
|
||||||
|
def my_func(): ...
|
||||||
|
|
||||||
|
@register_fn("custom") # 显式指定注册名
|
||||||
|
def my_func(): ...
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
name : str | Callable | None
|
||||||
|
注册名或被装饰函数; 为 None 时使用函数 ``__name__``
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Callable
|
||||||
|
装饰器函数或被装饰函数
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ValueError
|
||||||
|
名称已注册或无法推断函数名
|
||||||
|
"""
|
||||||
|
if callable(name):
|
||||||
|
fn = name
|
||||||
|
key = getattr(fn, "__name__", None)
|
||||||
|
if key is None:
|
||||||
|
raise ValueError("无法推断函数名, 请显式提供 name 参数")
|
||||||
|
if key in _REGISTRY:
|
||||||
|
raise ValueError(f"函数 {key!r} 已注册")
|
||||||
|
_REGISTRY[key] = fn
|
||||||
|
return fn
|
||||||
|
|
||||||
|
def decorator(fn: Callable[P, T]) -> Callable[P, T]:
|
||||||
|
key = name if name is not None else getattr(fn, "__name__", None)
|
||||||
|
if key is None:
|
||||||
|
raise ValueError("无法推断函数名, 请显式提供 name 参数")
|
||||||
|
if key in _REGISTRY:
|
||||||
|
raise ValueError(f"函数 {key!r} 已注册")
|
||||||
|
_REGISTRY[key] = fn
|
||||||
|
return fn
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def get_fn(name: str) -> Callable[..., Any]:
|
||||||
|
"""按名称获取已注册的函数.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
name : str
|
||||||
|
函数名
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Callable
|
||||||
|
已注册的函数
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
KeyError
|
||||||
|
函数未注册
|
||||||
|
"""
|
||||||
|
if name not in _REGISTRY:
|
||||||
|
raise KeyError(f"函数 {name!r} 未注册")
|
||||||
|
return _REGISTRY[name]
|
||||||
|
|
||||||
|
|
||||||
|
def has_fn(name: str) -> bool:
|
||||||
|
"""检查函数是否已注册.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
name : str
|
||||||
|
函数名
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
bool
|
||||||
|
是否已注册
|
||||||
|
"""
|
||||||
|
return name in _REGISTRY
|
||||||
|
|
||||||
|
|
||||||
|
class FnRegistry:
|
||||||
|
"""函数注册表的面向对象访问接口."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def register(name: str | None = None) -> Callable[[Callable[..., T]], Callable[..., T]]:
|
||||||
|
"""注册装饰器, 等价于 :func:`register_fn`."""
|
||||||
|
return register_fn(name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get(name: str) -> Callable[..., Any]:
|
||||||
|
"""获取已注册函数, 等价于 :func:`get_fn`."""
|
||||||
|
return get_fn(name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def has(name: str) -> bool:
|
||||||
|
"""检查是否已注册, 等价于 :func:`has_fn`."""
|
||||||
|
return has_fn(name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def clear() -> None:
|
||||||
|
"""清空注册表."""
|
||||||
|
_REGISTRY.clear()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def names() -> list[str]:
|
||||||
|
"""返回所有已注册函数名."""
|
||||||
|
return list(_REGISTRY.keys())
|
||||||
+2
-39
@@ -14,7 +14,7 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import enum
|
import enum
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Sequence, get_args
|
from typing import Any, Sequence, get_args
|
||||||
|
|
||||||
@@ -35,39 +35,6 @@ class CliExitCode(enum.IntEnum):
|
|||||||
INTERRUPTED = 130 # 与 POSIX 信号中断一致
|
INTERRUPTED = 130 # 与 POSIX 信号中断一致
|
||||||
|
|
||||||
|
|
||||||
def _apply_verbose_to_graph(graph: Graph, verbose: bool) -> Graph:
|
|
||||||
"""创建新图, 其中所有 TaskSpec 的 verbose 字段被设置为指定值.
|
|
||||||
|
|
||||||
使用 ``dataclasses.replace`` 在不可变的 TaskSpec 上创建带 verbose 标记的副本.
|
|
||||||
依赖关系、标签等元数据全部保留.
|
|
||||||
|
|
||||||
Note
|
|
||||||
-----
|
|
||||||
自 ``_wrap_cmd`` 不再闭包捕获 ``verbose`` 后,此函数不再是必需的——
|
|
||||||
直接翻转 ``spec.verbose`` 即可生效。保留是为了向后兼容现有调用与测试。
|
|
||||||
TaskSpec 仍是 frozen dataclass,故仍用 ``replace`` 创建副本。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
graph : Graph
|
|
||||||
原始图.
|
|
||||||
verbose : bool
|
|
||||||
要设置的 verbose 值.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Graph
|
|
||||||
所有 spec 的 verbose 字段已更新的新图.
|
|
||||||
"""
|
|
||||||
new_specs: list[TaskSpec[Any]] = []
|
|
||||||
for spec in graph.all_specs().values():
|
|
||||||
if spec.verbose == verbose:
|
|
||||||
new_specs.append(spec)
|
|
||||||
else:
|
|
||||||
new_specs.append(replace(spec, verbose=verbose))
|
|
||||||
return Graph.from_specs(new_specs)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CliRunner:
|
class CliRunner:
|
||||||
"""命令行运行器: 根据用户输入执行对应的任务流图.
|
"""命令行运行器: 根据用户输入执行对应的任务流图.
|
||||||
@@ -296,12 +263,8 @@ class CliRunner:
|
|||||||
# 确定是否 verbose: --quiet 覆盖默认值
|
# 确定是否 verbose: --quiet 覆盖默认值
|
||||||
verbose = self.verbose and not parsed.quiet
|
verbose = self.verbose and not parsed.quiet
|
||||||
|
|
||||||
# 对图应用 verbose 设置 (重建带 verbose 标记的 spec)
|
# 执行对应的图 (verbose 标记由 run() 统一应用到各 spec)
|
||||||
graph = self.graphs[parsed.command]
|
graph = self.graphs[parsed.command]
|
||||||
if verbose:
|
|
||||||
graph = _apply_verbose_to_graph(graph, verbose=True)
|
|
||||||
|
|
||||||
# 执行对应的图
|
|
||||||
try:
|
try:
|
||||||
report = run(
|
report = run(
|
||||||
graph,
|
graph,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+20
-103
@@ -5,8 +5,7 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pyflowx as px
|
from pyflowx.ops import dev
|
||||||
from pyflowx.cli import autofmt
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- #
|
# ---------------------------------------------------------------------- #
|
||||||
@@ -19,14 +18,14 @@ class TestFormatWithRuff:
|
|||||||
"""Should format with ruff."""
|
"""Should format with ruff."""
|
||||||
with patch("subprocess.run") as mock_run:
|
with patch("subprocess.run") as mock_run:
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
autofmt.format_with_ruff(tmp_path, fix=True)
|
dev.format_with_ruff(tmp_path, fix=True)
|
||||||
assert mock_run.called
|
assert mock_run.called
|
||||||
|
|
||||||
def test_format_with_ruff_no_fix(self, tmp_path: Path) -> None:
|
def test_format_with_ruff_no_fix(self, tmp_path: Path) -> None:
|
||||||
"""Should format with ruff without fix."""
|
"""Should format with ruff without fix."""
|
||||||
with patch("subprocess.run") as mock_run:
|
with patch("subprocess.run") as mock_run:
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
autofmt.format_with_ruff(tmp_path, fix=False)
|
dev.format_with_ruff(tmp_path, fix=False)
|
||||||
# Should not include --fix flag
|
# Should not include --fix flag
|
||||||
call_args = mock_run.call_args[0][0]
|
call_args = mock_run.call_args[0][0]
|
||||||
assert "--fix" not in call_args
|
assert "--fix" not in call_args
|
||||||
@@ -42,14 +41,14 @@ class TestLintWithRuff:
|
|||||||
"""Should lint with ruff."""
|
"""Should lint with ruff."""
|
||||||
with patch("subprocess.run") as mock_run:
|
with patch("subprocess.run") as mock_run:
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
autofmt.lint_with_ruff(tmp_path, fix=True)
|
dev.lint_with_ruff(tmp_path, fix=True)
|
||||||
assert mock_run.called
|
assert mock_run.called
|
||||||
|
|
||||||
def test_lint_with_ruff_no_fix(self, tmp_path: Path) -> None:
|
def test_lint_with_ruff_no_fix(self, tmp_path: Path) -> None:
|
||||||
"""Should lint with ruff without fix."""
|
"""Should lint with ruff without fix."""
|
||||||
with patch("subprocess.run") as mock_run:
|
with patch("subprocess.run") as mock_run:
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
autofmt.lint_with_ruff(tmp_path, fix=False)
|
dev.lint_with_ruff(tmp_path, fix=False)
|
||||||
# Should not include --fix flag
|
# Should not include --fix flag
|
||||||
call_args = mock_run.call_args[0][0]
|
call_args = mock_run.call_args[0][0]
|
||||||
assert "--fix" not in call_args
|
assert "--fix" not in call_args
|
||||||
@@ -66,7 +65,7 @@ class TestAddDocstring:
|
|||||||
py_file = tmp_path / "test.py"
|
py_file = tmp_path / "test.py"
|
||||||
py_file.write_text("def test():\n pass\n")
|
py_file.write_text("def test():\n pass\n")
|
||||||
|
|
||||||
result = autofmt.add_docstring(py_file, '"""Test module."""')
|
result = dev.add_docstring(py_file, '"""Test module."""')
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
def test_add_docstring_skips_files_with_docstring(self, tmp_path: Path) -> None:
|
def test_add_docstring_skips_files_with_docstring(self, tmp_path: Path) -> None:
|
||||||
@@ -74,7 +73,7 @@ class TestAddDocstring:
|
|||||||
py_file = tmp_path / "test.py"
|
py_file = tmp_path / "test.py"
|
||||||
py_file.write_text('"""Existing docstring."""\ndef test():\n pass\n')
|
py_file.write_text('"""Existing docstring."""\ndef test():\n pass\n')
|
||||||
|
|
||||||
result = autofmt.add_docstring(py_file, '"""New docstring."""')
|
result = dev.add_docstring(py_file, '"""New docstring."""')
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
def test_add_docstring_empty_file(self, tmp_path: Path) -> None:
|
def test_add_docstring_empty_file(self, tmp_path: Path) -> None:
|
||||||
@@ -82,7 +81,7 @@ class TestAddDocstring:
|
|||||||
py_file = tmp_path / "test.py"
|
py_file = tmp_path / "test.py"
|
||||||
py_file.write_text("")
|
py_file.write_text("")
|
||||||
|
|
||||||
result = autofmt.add_docstring(py_file, '"""Test module."""')
|
result = dev.add_docstring(py_file, '"""Test module."""')
|
||||||
# Should handle empty file
|
# Should handle empty file
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
@@ -98,7 +97,7 @@ class TestGenerateModuleDocstring:
|
|||||||
py_file = tmp_path / "test.py"
|
py_file = tmp_path / "test.py"
|
||||||
py_file.write_text("def test():\n pass\n")
|
py_file.write_text("def test():\n pass\n")
|
||||||
|
|
||||||
result = autofmt.generate_module_docstring(py_file)
|
result = dev.generate_module_docstring(py_file)
|
||||||
# Should contain "Tests for" since stem contains "test"
|
# Should contain "Tests for" since stem contains "test"
|
||||||
assert "Tests for" in result
|
assert "Tests for" in result
|
||||||
|
|
||||||
@@ -108,7 +107,7 @@ class TestGenerateModuleDocstring:
|
|||||||
py_file.parent.mkdir(parents=True)
|
py_file.parent.mkdir(parents=True)
|
||||||
py_file.write_text("def test():\n pass\n")
|
py_file.write_text("def test():\n pass\n")
|
||||||
|
|
||||||
result = autofmt.generate_module_docstring(py_file)
|
result = dev.generate_module_docstring(py_file)
|
||||||
assert "mypackage" in result
|
assert "mypackage" in result
|
||||||
|
|
||||||
def test_generate_module_docstring_cli(self, tmp_path: Path) -> None:
|
def test_generate_module_docstring_cli(self, tmp_path: Path) -> None:
|
||||||
@@ -116,7 +115,7 @@ class TestGenerateModuleDocstring:
|
|||||||
py_file = tmp_path / "cli.py"
|
py_file = tmp_path / "cli.py"
|
||||||
py_file.write_text("def test():\n pass\n")
|
py_file.write_text("def test():\n pass\n")
|
||||||
|
|
||||||
result = autofmt.generate_module_docstring(py_file)
|
result = dev.generate_module_docstring(py_file)
|
||||||
assert "Command-line interface" in result
|
assert "Command-line interface" in result
|
||||||
|
|
||||||
def test_generate_module_docstring_util(self, tmp_path: Path) -> None:
|
def test_generate_module_docstring_util(self, tmp_path: Path) -> None:
|
||||||
@@ -124,7 +123,7 @@ class TestGenerateModuleDocstring:
|
|||||||
py_file = tmp_path / "utils.py"
|
py_file = tmp_path / "utils.py"
|
||||||
py_file.write_text("def test():\n pass\n")
|
py_file.write_text("def test():\n pass\n")
|
||||||
|
|
||||||
result = autofmt.generate_module_docstring(py_file)
|
result = dev.generate_module_docstring(py_file)
|
||||||
assert "Utility functions" in result
|
assert "Utility functions" in result
|
||||||
|
|
||||||
|
|
||||||
@@ -139,8 +138,8 @@ class TestAutoAddDocstrings:
|
|||||||
py_file = tmp_path / "test.py"
|
py_file = tmp_path / "test.py"
|
||||||
py_file.write_text("def test():\n pass\n")
|
py_file.write_text("def test():\n pass\n")
|
||||||
|
|
||||||
with patch.object(autofmt, "add_docstring", return_value=True):
|
with patch.object(dev, "add_docstring", return_value=True):
|
||||||
count = autofmt.auto_add_docstrings(tmp_path)
|
count = dev.auto_add_docstrings(tmp_path)
|
||||||
assert count >= 0
|
assert count >= 0
|
||||||
|
|
||||||
def test_auto_add_docstrings_skips_ignored(self, tmp_path: Path) -> None:
|
def test_auto_add_docstrings_skips_ignored(self, tmp_path: Path) -> None:
|
||||||
@@ -149,7 +148,7 @@ class TestAutoAddDocstrings:
|
|||||||
py_file.parent.mkdir()
|
py_file.parent.mkdir()
|
||||||
py_file.write_text("def test():\n pass\n")
|
py_file.write_text("def test():\n pass\n")
|
||||||
|
|
||||||
count = autofmt.auto_add_docstrings(tmp_path)
|
count = dev.auto_add_docstrings(tmp_path)
|
||||||
# Should skip __pycache__
|
# Should skip __pycache__
|
||||||
assert count == 0
|
assert count == 0
|
||||||
|
|
||||||
@@ -158,7 +157,7 @@ class TestAutoAddDocstrings:
|
|||||||
txt_file = tmp_path / "test.txt"
|
txt_file = tmp_path / "test.txt"
|
||||||
txt_file.write_text("test content")
|
txt_file.write_text("test content")
|
||||||
|
|
||||||
count = autofmt.auto_add_docstrings(tmp_path)
|
count = dev.auto_add_docstrings(tmp_path)
|
||||||
assert count == 0
|
assert count == 0
|
||||||
|
|
||||||
|
|
||||||
@@ -179,7 +178,7 @@ class TestSyncPyprojectConfig:
|
|||||||
|
|
||||||
with patch("subprocess.run") as mock_run:
|
with patch("subprocess.run") as mock_run:
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
autofmt.sync_pyproject_config(tmp_path)
|
dev.sync_pyproject_config(tmp_path)
|
||||||
assert mock_run.called
|
assert mock_run.called
|
||||||
|
|
||||||
def test_sync_pyproject_config_updates_file(self, tmp_path: Path) -> None:
|
def test_sync_pyproject_config_updates_file(self, tmp_path: Path) -> None:
|
||||||
@@ -193,7 +192,7 @@ class TestSyncPyprojectConfig:
|
|||||||
|
|
||||||
with patch("subprocess.run") as mock_run:
|
with patch("subprocess.run") as mock_run:
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
autofmt.sync_pyproject_config(tmp_path)
|
dev.sync_pyproject_config(tmp_path)
|
||||||
assert mock_run.called
|
assert mock_run.called
|
||||||
|
|
||||||
|
|
||||||
@@ -207,95 +206,13 @@ class TestFormatAll:
|
|||||||
"""Should run ruff format."""
|
"""Should run ruff format."""
|
||||||
with patch("subprocess.run") as mock_run:
|
with patch("subprocess.run") as mock_run:
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
autofmt.format_all(tmp_path)
|
dev.format_all(tmp_path)
|
||||||
assert mock_run.called
|
assert mock_run.called
|
||||||
|
|
||||||
def test_format_all_runs_ruff_check(self, tmp_path: Path) -> None:
|
def test_format_all_runs_ruff_check(self, tmp_path: Path) -> None:
|
||||||
"""Should run ruff check."""
|
"""Should run ruff check."""
|
||||||
with patch("subprocess.run") as mock_run:
|
with patch("subprocess.run") as mock_run:
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
autofmt.format_all(tmp_path)
|
dev.format_all(tmp_path)
|
||||||
# Should call ruff format and ruff check
|
# Should call ruff format and ruff check
|
||||||
assert mock_run.call_count == 2
|
assert mock_run.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- #
|
|
||||||
# main function
|
|
||||||
# ---------------------------------------------------------------------- #
|
|
||||||
class TestMain:
|
|
||||||
"""Test main function."""
|
|
||||||
|
|
||||||
def test_main_fmt_default_target(self) -> None:
|
|
||||||
"""main() should handle fmt command with default target."""
|
|
||||||
with patch("sys.argv", ["autofmt", "fmt"]), patch.object(px, "run") as mock_run:
|
|
||||||
autofmt.main()
|
|
||||||
assert mock_run.called
|
|
||||||
|
|
||||||
def test_main_fmt_custom_target(self) -> None:
|
|
||||||
"""main() should handle fmt command with custom target."""
|
|
||||||
with patch("sys.argv", ["autofmt", "fmt", "--target", "src"]), patch.object(px, "run") as mock_run:
|
|
||||||
autofmt.main()
|
|
||||||
assert mock_run.called
|
|
||||||
|
|
||||||
def test_main_lint_default_target(self) -> None:
|
|
||||||
"""main() should handle lint command with default target."""
|
|
||||||
with patch("sys.argv", ["autofmt", "lint"]), patch.object(px, "run") as mock_run:
|
|
||||||
autofmt.main()
|
|
||||||
assert mock_run.called
|
|
||||||
|
|
||||||
def test_main_lint_with_fix(self) -> None:
|
|
||||||
"""main() should handle lint command with fix."""
|
|
||||||
with patch("sys.argv", ["autofmt", "lint", "--fix"]), patch.object(px, "run") as mock_run:
|
|
||||||
autofmt.main()
|
|
||||||
assert mock_run.called
|
|
||||||
|
|
||||||
def test_main_lint_custom_target(self) -> None:
|
|
||||||
"""main() should handle lint command with custom target."""
|
|
||||||
with patch("sys.argv", ["autofmt", "lint", "--target", "src"]), patch.object(px, "run") as mock_run:
|
|
||||||
autofmt.main()
|
|
||||||
assert mock_run.called
|
|
||||||
|
|
||||||
def test_main_doc_default_root(self) -> None:
|
|
||||||
"""main() should handle doc command with default root."""
|
|
||||||
with patch("sys.argv", ["autofmt", "doc"]), patch.object(px, "run") as mock_run:
|
|
||||||
autofmt.main()
|
|
||||||
assert mock_run.called
|
|
||||||
|
|
||||||
def test_main_doc_custom_root(self) -> None:
|
|
||||||
"""main() should handle doc command with custom root."""
|
|
||||||
with patch("sys.argv", ["autofmt", "doc", "--root-dir", "src"]), patch.object(px, "run") as mock_run:
|
|
||||||
autofmt.main()
|
|
||||||
assert mock_run.called
|
|
||||||
|
|
||||||
def test_main_sync_default_root(self) -> None:
|
|
||||||
"""main() should handle sync command with default root."""
|
|
||||||
with patch("sys.argv", ["autofmt", "sync"]), patch.object(px, "run") as mock_run:
|
|
||||||
autofmt.main()
|
|
||||||
assert mock_run.called
|
|
||||||
|
|
||||||
def test_main_sync_custom_root(self) -> None:
|
|
||||||
"""main() should handle sync command with custom root."""
|
|
||||||
with patch("sys.argv", ["autofmt", "sync", "--root-dir", "."]), patch.object(px, "run") as mock_run:
|
|
||||||
autofmt.main()
|
|
||||||
assert mock_run.called
|
|
||||||
|
|
||||||
def test_main_with_no_args_shows_help(self) -> None:
|
|
||||||
"""main() with no args should show help."""
|
|
||||||
with patch("sys.argv", ["autofmt"]), patch.object(autofmt, "main"):
|
|
||||||
# Just call main, it should show help and return
|
|
||||||
autofmt.main()
|
|
||||||
# main() should return without calling px.run
|
|
||||||
assert True
|
|
||||||
|
|
||||||
def test_main_creates_task_specs_with_verbose(self) -> None:
|
|
||||||
"""main() should create TaskSpecs with verbose=True."""
|
|
||||||
with patch("sys.argv", ["autofmt", "fmt"]), patch.object(px, "run") as mock_run:
|
|
||||||
autofmt.main()
|
|
||||||
assert mock_run.called
|
|
||||||
|
|
||||||
def test_main_uses_thread_strategy(self) -> None:
|
|
||||||
"""main() should use thread strategy."""
|
|
||||||
with patch("sys.argv", ["autofmt", "fmt"]), patch.object(px, "run") as mock_run:
|
|
||||||
autofmt.main()
|
|
||||||
# Check that strategy="thread" was used
|
|
||||||
assert mock_run.called
|
|
||||||
|
|||||||
+144
-270
@@ -1,14 +1,13 @@
|
|||||||
"""Tests for cli.bumpversion module."""
|
"""Tests for ops.bumpversion module."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import pyflowx as px
|
from pyflowx.ops import bumpversion
|
||||||
from pyflowx.cli import bumpversion
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -23,296 +22,171 @@ def auto_use_tmp_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
class TestBumpFileVersion:
|
class TestBumpFileVersion:
|
||||||
"""Test bump_file_version function."""
|
"""Test bump_file_version function."""
|
||||||
|
|
||||||
def test_bump_patch_version(self, tmp_path: Path) -> None:
|
@pytest.mark.parametrize(
|
||||||
"""Should bump patch version correctly."""
|
("part", "expected"),
|
||||||
test_file = tmp_path / "pyproject.toml"
|
[("patch", "1.2.4"), ("minor", "1.3.0"), ("major", "2.0.0")],
|
||||||
test_file.write_text('version = "1.2.3"', encoding="utf-8")
|
)
|
||||||
|
def test_bump_pyproject(self, tmp_path: Path, part: str, expected: str) -> None:
|
||||||
|
"""pyproject.toml 三种 part 递增."""
|
||||||
|
f = tmp_path / "pyproject.toml"
|
||||||
|
f.write_text('version = "1.2.3"', encoding="utf-8")
|
||||||
|
assert bumpversion.bump_file_version(f, part) == expected # type: ignore[arg-type]
|
||||||
|
assert f'version = "{expected}"' in f.read_text(encoding="utf-8")
|
||||||
|
|
||||||
result = bumpversion.bump_file_version(test_file, "patch")
|
@pytest.mark.parametrize(
|
||||||
|
("part", "expected"),
|
||||||
|
[("patch", "1.2.4"), ("minor", "1.3.0"), ("major", "2.0.0")],
|
||||||
|
)
|
||||||
|
def test_bump_init_py(self, tmp_path: Path, part: str, expected: str) -> None:
|
||||||
|
"""__init__.py 三种 part 递增."""
|
||||||
|
f = tmp_path / "__init__.py"
|
||||||
|
f.write_text('__version__ = "1.2.3"', encoding="utf-8")
|
||||||
|
assert bumpversion.bump_file_version(f, part) == expected # type: ignore[arg-type]
|
||||||
|
assert f'__version__ = "{expected}"' in f.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert result == "1.2.4"
|
def test_prerelease_and_build_metadata_stripped(self, tmp_path: Path) -> None:
|
||||||
assert test_file.read_text(encoding="utf-8") == 'version = "1.2.4"'
|
"""prerelease 和 build metadata 应被清除."""
|
||||||
|
f = tmp_path / "pyproject.toml"
|
||||||
def test_bump_minor_version(self, tmp_path: Path) -> None:
|
f.write_text('version = "1.2.3-alpha.1+build.123"', encoding="utf-8")
|
||||||
"""Should bump minor version correctly."""
|
assert bumpversion.bump_file_version(f, "patch") == "1.2.4"
|
||||||
test_file = tmp_path / "pyproject.toml"
|
content = f.read_text(encoding="utf-8")
|
||||||
test_file.write_text('version = "1.2.3"', encoding="utf-8")
|
|
||||||
|
|
||||||
result = bumpversion.bump_file_version(test_file, "minor")
|
|
||||||
|
|
||||||
assert result == "1.3.0"
|
|
||||||
assert test_file.read_text(encoding="utf-8") == 'version = "1.3.0"'
|
|
||||||
|
|
||||||
def test_bump_major_version(self, tmp_path: Path) -> None:
|
|
||||||
"""Should bump major version correctly."""
|
|
||||||
test_file = tmp_path / "pyproject.toml"
|
|
||||||
test_file.write_text('version = "1.2.3"', encoding="utf-8")
|
|
||||||
|
|
||||||
result = bumpversion.bump_file_version(test_file, "major")
|
|
||||||
|
|
||||||
assert result == "2.0.0"
|
|
||||||
assert test_file.read_text(encoding="utf-8") == 'version = "2.0.0"'
|
|
||||||
|
|
||||||
def test_version_pattern_with_prerelease(self, tmp_path: Path) -> None:
|
|
||||||
"""Should handle version with prerelease suffix."""
|
|
||||||
test_file = tmp_path / "pyproject.toml"
|
|
||||||
test_file.write_text('version = "1.2.3-alpha.1"', encoding="utf-8")
|
|
||||||
|
|
||||||
result = bumpversion.bump_file_version(test_file, "patch")
|
|
||||||
|
|
||||||
assert result == "1.2.4"
|
|
||||||
# 预发布版本应该被清除
|
|
||||||
content = test_file.read_text(encoding="utf-8")
|
|
||||||
assert "alpha" not in content
|
assert "alpha" not in content
|
||||||
|
|
||||||
def test_version_pattern_with_build_metadata(self, tmp_path: Path) -> None:
|
|
||||||
"""Should handle version with build metadata."""
|
|
||||||
test_file = tmp_path / "pyproject.toml"
|
|
||||||
test_file.write_text('version = "1.2.3+build.123"', encoding="utf-8")
|
|
||||||
|
|
||||||
result = bumpversion.bump_file_version(test_file, "patch")
|
|
||||||
|
|
||||||
assert result == "1.2.4"
|
|
||||||
# 构建元数据应该被清除
|
|
||||||
content = test_file.read_text(encoding="utf-8")
|
|
||||||
assert "build" not in content
|
assert "build" not in content
|
||||||
|
|
||||||
def test_no_version_found(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
def test_dependencies_not_modified(self, tmp_path: Path) -> None:
|
||||||
"""Should return None when no version pattern found."""
|
"""只更新 project version, 不动 dependencies 中的版本号."""
|
||||||
test_file = tmp_path / "test.txt"
|
f = tmp_path / "pyproject.toml"
|
||||||
test_file.write_text("no version here", encoding="utf-8")
|
f.write_text(
|
||||||
|
'[project]\nversion = "1.0.0"\ndependencies = ["lib >= 2.0.0", "other >= 3.0.0"]\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert bumpversion.bump_file_version(f, "patch") == "1.0.1"
|
||||||
|
content = f.read_text(encoding="utf-8")
|
||||||
|
assert 'version = "1.0.1"' in content
|
||||||
|
assert "lib >= 2.0.0" in content
|
||||||
|
assert "other >= 3.0.0" in content
|
||||||
|
|
||||||
result = bumpversion.bump_file_version(test_file, "patch")
|
def test_no_version_pattern_returns_none(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""未匹配到版本号模式返回 None (支持类型但无版本 / 不支持的文件类型)."""
|
||||||
|
f1 = tmp_path / "__init__.py"
|
||||||
|
f1.write_text("# no version here", encoding="utf-8")
|
||||||
|
assert bumpversion.bump_file_version(f1, "patch") is None
|
||||||
|
assert "未找到版本号模式" in capsys.readouterr().out
|
||||||
|
|
||||||
assert result is None
|
f2 = tmp_path / "test.txt"
|
||||||
captured = capsys.readouterr()
|
f2.write_text("no version here", encoding="utf-8")
|
||||||
assert "未找到版本号模式" in captured.out
|
assert bumpversion.bump_file_version(f2, "patch") is None
|
||||||
|
|
||||||
def test_utf8_encoding(self, tmp_path: Path) -> None:
|
|
||||||
"""Should handle UTF-8 encoded files correctly."""
|
|
||||||
test_file = tmp_path / "__init__.py"
|
|
||||||
test_file.write_text('__version__ = "1.2.3"', encoding="utf-8")
|
|
||||||
|
|
||||||
result = bumpversion.bump_file_version(test_file, "patch")
|
|
||||||
|
|
||||||
assert result == "1.2.4"
|
|
||||||
assert test_file.read_text(encoding="utf-8") == '__version__ = "1.2.4"'
|
|
||||||
|
|
||||||
def test_pyproject_toml_format(self, tmp_path: Path) -> None:
|
|
||||||
"""Should handle pyproject.toml format correctly."""
|
|
||||||
test_file = tmp_path / "pyproject.toml"
|
|
||||||
content = """
|
|
||||||
[project]
|
|
||||||
name = "test"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "Test project"
|
|
||||||
"""
|
|
||||||
test_file.write_text(content, encoding="utf-8")
|
|
||||||
|
|
||||||
result = bumpversion.bump_file_version(test_file, "minor")
|
|
||||||
|
|
||||||
assert result == "0.2.0"
|
|
||||||
updated = test_file.read_text(encoding="utf-8")
|
|
||||||
assert 'version = "0.2.0"' in updated
|
|
||||||
assert 'name = "test"' in updated
|
|
||||||
|
|
||||||
def test_init_py_format(self, tmp_path: Path) -> None:
|
|
||||||
"""Should handle __init__.py format correctly."""
|
|
||||||
test_file = tmp_path / "__init__.py"
|
|
||||||
content = '''"""Package info."""
|
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
|
||||||
'''
|
|
||||||
test_file.write_text(content, encoding="utf-8")
|
|
||||||
|
|
||||||
result = bumpversion.bump_file_version(test_file, "major")
|
|
||||||
|
|
||||||
assert result == "2.0.0"
|
|
||||||
updated = test_file.read_text(encoding="utf-8")
|
|
||||||
assert '__version__ = "2.0.0"' in updated
|
|
||||||
|
|
||||||
def test_multiple_versions_in_file(self, tmp_path: Path) -> None:
|
|
||||||
"""Should only bump the project version, not dependencies."""
|
|
||||||
test_file = tmp_path / "pyproject.toml"
|
|
||||||
content = """
|
|
||||||
[project]
|
|
||||||
version = "1.0.0"
|
|
||||||
dependencies = ["lib >= 2.0.0", "other >= 3.0.0"]
|
|
||||||
"""
|
|
||||||
test_file.write_text(content, encoding="utf-8")
|
|
||||||
|
|
||||||
result = bumpversion.bump_file_version(test_file, "patch")
|
|
||||||
|
|
||||||
assert result == "1.0.1"
|
|
||||||
updated = test_file.read_text(encoding="utf-8")
|
|
||||||
assert 'version = "1.0.1"' in updated
|
|
||||||
# 确保 dependencies 中的版本没有被更新
|
|
||||||
assert "lib >= 2.0.0" in updated
|
|
||||||
assert "other >= 3.0.0" in updated
|
|
||||||
|
|
||||||
def test_file_read_error(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
||||||
"""Should handle file read errors."""
|
|
||||||
# 创建一个目录而不是文件
|
|
||||||
test_file = tmp_path / "test_dir"
|
|
||||||
test_file.mkdir()
|
|
||||||
|
|
||||||
|
def test_read_directory_raises(self, tmp_path: Path) -> None:
|
||||||
|
"""读取目录 (名为 __init__.py) 应抛异常."""
|
||||||
|
f = tmp_path / "__init__.py"
|
||||||
|
f.mkdir()
|
||||||
with pytest.raises(Exception): # noqa: B017
|
with pytest.raises(Exception): # noqa: B017
|
||||||
bumpversion.bump_file_version(test_file, "patch")
|
bumpversion.bump_file_version(f, "patch")
|
||||||
|
|
||||||
def test_file_write_error(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
def test_write_failure_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""Should handle file write errors."""
|
"""写入失败应抛 OSError."""
|
||||||
# 在只读目录中创建文件(这个测试在某些系统上可能不适用)
|
f = tmp_path / "__init__.py"
|
||||||
test_file = tmp_path / "readonly.toml"
|
f.write_text('__version__ = "1.0.0"', encoding="utf-8")
|
||||||
test_file.write_text('version = "1.0.0"', encoding="utf-8")
|
|
||||||
# 设置为只读
|
|
||||||
test_file.chmod(0o444)
|
|
||||||
|
|
||||||
try:
|
def raise_oserror(*_args: object, **_kwargs: object) -> None:
|
||||||
with pytest.raises(Exception): # noqa: B017
|
raise OSError("write failed")
|
||||||
bumpversion.bump_file_version(test_file, "patch")
|
|
||||||
finally:
|
monkeypatch.setattr(Path, "write_text", raise_oserror)
|
||||||
# 恢复权限以便清理
|
with pytest.raises(OSError, match="write failed"):
|
||||||
test_file.chmod(0o644)
|
bumpversion.bump_file_version(f, "patch")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- #
|
# ---------------------------------------------------------------------- #
|
||||||
# Version pattern tests
|
# bump_project_version (核心 bug 修复: 不同步文件统一同步)
|
||||||
# ---------------------------------------------------------------------- #
|
# ---------------------------------------------------------------------- #
|
||||||
class TestVersionPattern:
|
class TestBumpProjectVersion:
|
||||||
"""Test version pattern matching."""
|
"""Test bump_project_version function."""
|
||||||
|
|
||||||
def test_simple_version(self, tmp_path: Path) -> None:
|
@staticmethod
|
||||||
"""Should match simple version."""
|
def _mock_subprocess(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]:
|
||||||
test_file = tmp_path / "__init__.py"
|
"""Mock subprocess.run, 返回调用记录列表."""
|
||||||
test_file.write_text('__version__ = "1.0.0"', encoding="utf-8")
|
calls: list[list[str]] = []
|
||||||
|
|
||||||
result = bumpversion.bump_file_version(test_file, "patch")
|
def fake_run(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
calls.append(cmd)
|
||||||
|
return subprocess.CompletedProcess(cmd, 0, b"", b"")
|
||||||
|
|
||||||
assert result == "1.0.1"
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||||
|
return calls
|
||||||
|
|
||||||
def test_version_with_zeros(self, tmp_path: Path) -> None:
|
def test_unsynced_files_synchronized(
|
||||||
"""Should handle versions with zeros correctly."""
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
test_file = tmp_path / "__init__.py"
|
) -> None:
|
||||||
test_file.write_text('__version__ = "0.0.0"', encoding="utf-8")
|
"""核心 bug 修复: 不同步的文件应统一同步到同一新版本号.
|
||||||
|
|
||||||
result = bumpversion.bump_file_version(test_file, "patch")
|
场景: __init__.py = 0.4.5, pyproject.toml = 0.3.5 (历史不同步)
|
||||||
|
期望: bump patch 后两者都变为 0.4.6 (取最大值 0.4.5 作为基准 +1)
|
||||||
|
"""
|
||||||
|
init_file = tmp_path / "src" / "pkg" / "__init__.py"
|
||||||
|
init_file.parent.mkdir(parents=True)
|
||||||
|
init_file.write_text('__version__ = "0.4.5"', encoding="utf-8")
|
||||||
|
pyproj = tmp_path / "pyproject.toml"
|
||||||
|
pyproj.write_text('version = "0.3.5"', encoding="utf-8")
|
||||||
|
|
||||||
assert result == "0.0.1"
|
calls = self._mock_subprocess(monkeypatch)
|
||||||
|
|
||||||
def test_large_version_numbers(self, tmp_path: Path) -> None:
|
result = bumpversion.bump_project_version("patch")
|
||||||
"""Should handle large version numbers."""
|
|
||||||
test_file = tmp_path / "__init__.py"
|
|
||||||
test_file.write_text('__version__ = "10.20.30"', encoding="utf-8")
|
|
||||||
|
|
||||||
result = bumpversion.bump_file_version(test_file, "minor")
|
assert result == "0.4.6"
|
||||||
|
assert '__version__ = "0.4.6"' in init_file.read_text(encoding="utf-8")
|
||||||
|
assert 'version = "0.4.6"' in pyproj.read_text(encoding="utf-8")
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "基准版本: 0.4.5" in out
|
||||||
|
assert "新版本: 0.4.6" in out
|
||||||
|
|
||||||
assert result == "10.21.0"
|
add_calls = [c for c in calls if c[:2] == ["git", "add"]]
|
||||||
|
assert len(add_calls) == 1
|
||||||
|
# 跨平台: Windows 上 Path 转换为反斜杠, 统一用正斜杠比较
|
||||||
|
init_path = str(init_file.relative_to(tmp_path)).replace("\\", "/")
|
||||||
|
assert init_path in [arg.replace("\\", "/") for arg in add_calls[0]]
|
||||||
|
assert "pyproject.toml" in add_calls[0]
|
||||||
|
assert "." not in add_calls[0][2:]
|
||||||
|
|
||||||
def test_version_in_url(self, tmp_path: Path) -> None:
|
tag_calls = [c for c in calls if c[:2] == ["git", "tag"]]
|
||||||
"""Should not match version in URL or other contexts."""
|
assert len(tag_calls) == 1
|
||||||
test_file = tmp_path / "test.txt"
|
assert "v0.4.6" in tag_calls[0]
|
||||||
test_file.write_text("https://example.com/v1.2.3/download", encoding="utf-8")
|
|
||||||
|
|
||||||
result = bumpversion.bump_file_version(test_file, "patch")
|
def test_no_files_returns_none(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""无版本号文件返回 None."""
|
||||||
# 不应该匹配 URL 中的版本号
|
assert bumpversion.bump_project_version("patch") is None
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- #
|
|
||||||
# Edge cases
|
|
||||||
# ---------------------------------------------------------------------- #
|
|
||||||
class TestEdgeCases:
|
|
||||||
"""Test edge cases and error handling."""
|
|
||||||
|
|
||||||
def test_empty_file(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
||||||
"""Should handle empty file."""
|
|
||||||
test_file = tmp_path / "empty.txt"
|
|
||||||
test_file.write_text("", encoding="utf-8")
|
|
||||||
|
|
||||||
result = bumpversion.bump_file_version(test_file, "patch")
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
assert "未找到版本号模式" in captured.out
|
|
||||||
|
|
||||||
def test_file_with_special_chars(self, tmp_path: Path) -> None:
|
|
||||||
"""Should handle file with special characters."""
|
|
||||||
test_file = tmp_path / "__init__.py"
|
|
||||||
content = '# 中文注释\n__version__ = "1.0.0"\n# 特殊字符: @#$%'
|
|
||||||
test_file.write_text(content, encoding="utf-8")
|
|
||||||
|
|
||||||
result = bumpversion.bump_file_version(test_file, "patch")
|
|
||||||
|
|
||||||
assert result == "1.0.1"
|
|
||||||
updated = test_file.read_text(encoding="utf-8")
|
|
||||||
assert "# 中文注释" in updated
|
|
||||||
assert "# 特殊字符: @#$%" in updated
|
|
||||||
|
|
||||||
def test_consecutive_bumps(self, tmp_path: Path) -> None:
|
|
||||||
"""Should handle consecutive version bumps correctly."""
|
|
||||||
test_file = tmp_path / "__init__.py"
|
|
||||||
test_file.write_text('__version__ = "1.0.0"', encoding="utf-8")
|
|
||||||
|
|
||||||
# 第一次 bump
|
|
||||||
result1 = bumpversion.bump_file_version(test_file, "patch")
|
|
||||||
assert result1 == "1.0.1"
|
|
||||||
|
|
||||||
# 第二次 bump
|
|
||||||
result2 = bumpversion.bump_file_version(test_file, "minor")
|
|
||||||
assert result2 == "1.1.0"
|
|
||||||
|
|
||||||
# 第三次 bump
|
|
||||||
result3 = bumpversion.bump_file_version(test_file, "major")
|
|
||||||
assert result3 == "2.0.0"
|
|
||||||
|
|
||||||
# 验证最终结果
|
|
||||||
assert test_file.read_text(encoding="utf-8") == '__version__ = "2.0.0"'
|
|
||||||
|
|
||||||
|
|
||||||
class TestBumpVersionCli:
|
|
||||||
"""Test bumpversion CLI."""
|
|
||||||
|
|
||||||
def test_minor(self, tmp_path: Path) -> None:
|
|
||||||
"""Should handle minor version bump."""
|
|
||||||
test_file = tmp_path / "__init__.py"
|
|
||||||
test_file.write_text('__version__ = "1.0.0"', encoding="utf-8")
|
|
||||||
|
|
||||||
# Mock px.run: 只真正执行第一次调用(版本更新),其余返回空 dict
|
|
||||||
with patch("sys.argv", ["bumpversion", "minor", "--no-tag"]), patch("pyflowx.run") as mock_run:
|
|
||||||
|
|
||||||
def run_side_effect(graph: px.Graph, strategy: str | None = None):
|
|
||||||
# 执行实际版本更新任务
|
|
||||||
results = {}
|
|
||||||
for spec in graph.specs.values():
|
|
||||||
if spec.fn is not None and spec.args:
|
|
||||||
results[spec.name] = spec.fn(*spec.args)
|
|
||||||
return results
|
|
||||||
|
|
||||||
mock_run.side_effect = run_side_effect
|
|
||||||
bumpversion.main()
|
|
||||||
|
|
||||||
# 验证版本号已更新
|
|
||||||
assert test_file.read_text(encoding="utf-8") == '__version__ = "1.1.0"'
|
|
||||||
|
|
||||||
def test_no_valid_files(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
||||||
"""Should handle no valid files."""
|
|
||||||
test_file = tmp_path / "test.txt"
|
|
||||||
test_file.write_text("这是一个测试文件", encoding="utf-8")
|
|
||||||
|
|
||||||
with patch("sys.argv", ["bumpversion", "minor", "--no-tag"]), patch("pyflowx.run") as mock_run:
|
|
||||||
|
|
||||||
def run_side_effect(graph: px.Graph, strategy: str | None = None):
|
|
||||||
# 执行实际版本更新任务
|
|
||||||
results = {}
|
|
||||||
for spec in graph.specs.values():
|
|
||||||
if spec.fn is not None and spec.args:
|
|
||||||
results[spec.name] = spec.fn(*spec.args)
|
|
||||||
return results
|
|
||||||
|
|
||||||
mock_run.side_effect = run_side_effect
|
|
||||||
bumpversion.main()
|
|
||||||
|
|
||||||
# 验证未更新任何文件
|
|
||||||
assert test_file.read_text(encoding="utf-8") == "这是一个测试文件"
|
|
||||||
assert "未找到包含版本号的文件" in capsys.readouterr().out
|
assert "未找到包含版本号的文件" in capsys.readouterr().out
|
||||||
|
|
||||||
|
def test_files_without_version_returns_none(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""有文件但所有文件都无版本号返回 None."""
|
||||||
|
f = tmp_path / "__init__.py"
|
||||||
|
f.write_text("# no version here", encoding="utf-8")
|
||||||
|
self._mock_subprocess(monkeypatch)
|
||||||
|
|
||||||
|
assert bumpversion.bump_project_version("patch") is None
|
||||||
|
assert "未能从任何文件读取版本号" in capsys.readouterr().out
|
||||||
|
|
||||||
|
def test_no_tag_skips_tag_creation(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""no_tag=True 跳过 tag 创建."""
|
||||||
|
pyproj = tmp_path / "pyproject.toml"
|
||||||
|
pyproj.write_text('version = "1.0.0"', encoding="utf-8")
|
||||||
|
|
||||||
|
calls = self._mock_subprocess(monkeypatch)
|
||||||
|
|
||||||
|
assert bumpversion.bump_project_version("patch", no_tag=True) == "1.0.1"
|
||||||
|
assert not any(c[:2] == ["git", "tag"] for c in calls)
|
||||||
|
|
||||||
|
def test_ignored_dirs_excluded(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
""".venv 等忽略目录中的版本号文件不被处理."""
|
||||||
|
venv_init = tmp_path / ".venv" / "lib" / "pkg" / "__init__.py"
|
||||||
|
venv_init.parent.mkdir(parents=True)
|
||||||
|
venv_init.write_text('__version__ = "0.1.0"', encoding="utf-8")
|
||||||
|
pyproj = tmp_path / "pyproject.toml"
|
||||||
|
pyproj.write_text('version = "1.0.0"', encoding="utf-8")
|
||||||
|
|
||||||
|
self._mock_subprocess(monkeypatch)
|
||||||
|
|
||||||
|
assert bumpversion.bump_project_version("patch") == "1.0.1"
|
||||||
|
assert venv_init.read_text(encoding="utf-8") == '__version__ = "0.1.0"'
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
"""Tests for cli.clearscreen module."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pyflowx as px
|
|
||||||
from pyflowx.cli.system import clearscreen
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- #
|
|
||||||
# main function
|
|
||||||
# ---------------------------------------------------------------------- #
|
|
||||||
class TestMain:
|
|
||||||
"""Test main function."""
|
|
||||||
|
|
||||||
def test_main_creates_graph_and_runs(self) -> None:
|
|
||||||
"""main() should create a Graph and run it."""
|
|
||||||
with patch.object(px, "run") as mock_run:
|
|
||||||
clearscreen.main()
|
|
||||||
assert mock_run.called
|
|
||||||
+210
-182
@@ -125,19 +125,21 @@ class TestEmailDatabase:
|
|||||||
|
|
||||||
# Insert test emails
|
# Insert test emails
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
db.insert_email({
|
db.insert_email(
|
||||||
"file_path": f"/test/path{i}.eml",
|
{
|
||||||
"file_hash": f"hash{i}",
|
"file_path": f"/test/path{i}.eml",
|
||||||
"subject": f"Subject {i}",
|
"file_hash": f"hash{i}",
|
||||||
"sender": f"sender{i}@example.com",
|
"subject": f"Subject {i}",
|
||||||
"recipients": "recipient@example.com",
|
"sender": f"sender{i}@example.com",
|
||||||
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
|
"recipients": "recipient@example.com",
|
||||||
"date_parsed": f"2024-01-0{i + 1}T12:00:00",
|
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
|
||||||
"body_text": f"Body {i}",
|
"date_parsed": f"2024-01-0{i + 1}T12:00:00",
|
||||||
"body_html": f"<p>Body {i}</p>",
|
"body_text": f"Body {i}",
|
||||||
"has_attachments": 0,
|
"body_html": f"<p>Body {i}</p>",
|
||||||
"file_size": 1024,
|
"has_attachments": 0,
|
||||||
})
|
"file_size": 1024,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
results = db.search_emails(limit=3)
|
results = db.search_emails(limit=3)
|
||||||
assert len(results) == 3
|
assert len(results) == 3
|
||||||
@@ -148,33 +150,37 @@ class TestEmailDatabase:
|
|||||||
db_path = tmp_path / "test.db"
|
db_path = tmp_path / "test.db"
|
||||||
db = emlmanager.EmailDatabase(db_path)
|
db = emlmanager.EmailDatabase(db_path)
|
||||||
|
|
||||||
db.insert_email({
|
db.insert_email(
|
||||||
"file_path": "/test/path1.eml",
|
{
|
||||||
"file_hash": "hash1",
|
"file_path": "/test/path1.eml",
|
||||||
"subject": "Important Meeting",
|
"file_hash": "hash1",
|
||||||
"sender": "sender1@example.com",
|
"subject": "Important Meeting",
|
||||||
"recipients": "recipient@example.com",
|
"sender": "sender1@example.com",
|
||||||
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
"recipients": "recipient@example.com",
|
||||||
"date_parsed": "2024-01-01T12:00:00",
|
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
||||||
"body_text": "Meeting body",
|
"date_parsed": "2024-01-01T12:00:00",
|
||||||
"body_html": "<p>Meeting body</p>",
|
"body_text": "Meeting body",
|
||||||
"has_attachments": 0,
|
"body_html": "<p>Meeting body</p>",
|
||||||
"file_size": 1024,
|
"has_attachments": 0,
|
||||||
})
|
"file_size": 1024,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
db.insert_email({
|
db.insert_email(
|
||||||
"file_path": "/test/path2.eml",
|
{
|
||||||
"file_hash": "hash2",
|
"file_path": "/test/path2.eml",
|
||||||
"subject": "Casual Chat",
|
"file_hash": "hash2",
|
||||||
"sender": "sender2@example.com",
|
"subject": "Casual Chat",
|
||||||
"recipients": "recipient@example.com",
|
"sender": "sender2@example.com",
|
||||||
"date": "Tue, 2 Jan 2024 12:00:00 +0000",
|
"recipients": "recipient@example.com",
|
||||||
"date_parsed": "2024-01-02T12:00:00",
|
"date": "Tue, 2 Jan 2024 12:00:00 +0000",
|
||||||
"body_text": "Chat body",
|
"date_parsed": "2024-01-02T12:00:00",
|
||||||
"body_html": "<p>Chat body</p>",
|
"body_text": "Chat body",
|
||||||
"has_attachments": 0,
|
"body_html": "<p>Chat body</p>",
|
||||||
"file_size": 1024,
|
"has_attachments": 0,
|
||||||
})
|
"file_size": 1024,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
results = db.search_emails(keyword="Meeting", field="subject")
|
results = db.search_emails(keyword="Meeting", field="subject")
|
||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
@@ -186,33 +192,37 @@ class TestEmailDatabase:
|
|||||||
db_path = tmp_path / "test.db"
|
db_path = tmp_path / "test.db"
|
||||||
db = emlmanager.EmailDatabase(db_path)
|
db = emlmanager.EmailDatabase(db_path)
|
||||||
|
|
||||||
db.insert_email({
|
db.insert_email(
|
||||||
"file_path": "/test/path1.eml",
|
{
|
||||||
"file_hash": "hash1",
|
"file_path": "/test/path1.eml",
|
||||||
"subject": "Test",
|
"file_hash": "hash1",
|
||||||
"sender": "alice@example.com",
|
"subject": "Test",
|
||||||
"recipients": "recipient@example.com",
|
"sender": "alice@example.com",
|
||||||
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
"recipients": "recipient@example.com",
|
||||||
"date_parsed": "2024-01-01T12:00:00",
|
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
||||||
"body_text": "Body",
|
"date_parsed": "2024-01-01T12:00:00",
|
||||||
"body_html": "<p>Body</p>",
|
"body_text": "Body",
|
||||||
"has_attachments": 0,
|
"body_html": "<p>Body</p>",
|
||||||
"file_size": 1024,
|
"has_attachments": 0,
|
||||||
})
|
"file_size": 1024,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
db.insert_email({
|
db.insert_email(
|
||||||
"file_path": "/test/path2.eml",
|
{
|
||||||
"file_hash": "hash2",
|
"file_path": "/test/path2.eml",
|
||||||
"subject": "Test",
|
"file_hash": "hash2",
|
||||||
"sender": "bob@example.com",
|
"subject": "Test",
|
||||||
"recipients": "recipient@example.com",
|
"sender": "bob@example.com",
|
||||||
"date": "Tue, 2 Jan 2024 12:00:00 +0000",
|
"recipients": "recipient@example.com",
|
||||||
"date_parsed": "2024-01-02T12:00:00",
|
"date": "Tue, 2 Jan 2024 12:00:00 +0000",
|
||||||
"body_text": "Body",
|
"date_parsed": "2024-01-02T12:00:00",
|
||||||
"body_html": "<p>Body</p>",
|
"body_text": "Body",
|
||||||
"has_attachments": 0,
|
"body_html": "<p>Body</p>",
|
||||||
"file_size": 1024,
|
"has_attachments": 0,
|
||||||
})
|
"file_size": 1024,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
results = db.search_emails(keyword="alice", field="sender")
|
results = db.search_emails(keyword="alice", field="sender")
|
||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
@@ -224,19 +234,21 @@ class TestEmailDatabase:
|
|||||||
db_path = tmp_path / "test.db"
|
db_path = tmp_path / "test.db"
|
||||||
db = emlmanager.EmailDatabase(db_path)
|
db = emlmanager.EmailDatabase(db_path)
|
||||||
|
|
||||||
db.insert_email({
|
db.insert_email(
|
||||||
"file_path": "/test/path1.eml",
|
{
|
||||||
"file_hash": "hash1",
|
"file_path": "/test/path1.eml",
|
||||||
"subject": "Project Update",
|
"file_hash": "hash1",
|
||||||
"sender": "manager@example.com",
|
"subject": "Project Update",
|
||||||
"recipients": "team@example.com",
|
"sender": "manager@example.com",
|
||||||
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
"recipients": "team@example.com",
|
||||||
"date_parsed": "2024-01-01T12:00:00",
|
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
||||||
"body_text": "Please review the quarterly report",
|
"date_parsed": "2024-01-01T12:00:00",
|
||||||
"body_html": "<p>Please review the quarterly report</p>",
|
"body_text": "Please review the quarterly report",
|
||||||
"has_attachments": 0,
|
"body_html": "<p>Please review the quarterly report</p>",
|
||||||
"file_size": 1024,
|
"has_attachments": 0,
|
||||||
})
|
"file_size": 1024,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Search for keyword in subject
|
# Search for keyword in subject
|
||||||
results = db.search_emails(keyword="Project", field="all")
|
results = db.search_emails(keyword="Project", field="all")
|
||||||
@@ -253,47 +265,53 @@ class TestEmailDatabase:
|
|||||||
db = emlmanager.EmailDatabase(db_path)
|
db = emlmanager.EmailDatabase(db_path)
|
||||||
|
|
||||||
# Insert emails with same subject (different prefixes)
|
# Insert emails with same subject (different prefixes)
|
||||||
db.insert_email({
|
db.insert_email(
|
||||||
"file_path": "/test/path1.eml",
|
{
|
||||||
"file_hash": "hash1",
|
"file_path": "/test/path1.eml",
|
||||||
"subject": "Meeting Tomorrow",
|
"file_hash": "hash1",
|
||||||
"sender": "sender1@example.com",
|
"subject": "Meeting Tomorrow",
|
||||||
"recipients": "recipient@example.com",
|
"sender": "sender1@example.com",
|
||||||
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
"recipients": "recipient@example.com",
|
||||||
"date_parsed": "2024-01-01T12:00:00",
|
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
||||||
"body_text": "Body 1",
|
"date_parsed": "2024-01-01T12:00:00",
|
||||||
"body_html": "<p>Body 1</p>",
|
"body_text": "Body 1",
|
||||||
"has_attachments": 0,
|
"body_html": "<p>Body 1</p>",
|
||||||
"file_size": 1024,
|
"has_attachments": 0,
|
||||||
})
|
"file_size": 1024,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
db.insert_email({
|
db.insert_email(
|
||||||
"file_path": "/test/path2.eml",
|
{
|
||||||
"file_hash": "hash2",
|
"file_path": "/test/path2.eml",
|
||||||
"subject": "Re: Meeting Tomorrow",
|
"file_hash": "hash2",
|
||||||
"sender": "sender2@example.com",
|
"subject": "Re: Meeting Tomorrow",
|
||||||
"recipients": "recipient@example.com",
|
"sender": "sender2@example.com",
|
||||||
"date": "Tue, 2 Jan 2024 12:00:00 +0000",
|
"recipients": "recipient@example.com",
|
||||||
"date_parsed": "2024-01-02T12:00:00",
|
"date": "Tue, 2 Jan 2024 12:00:00 +0000",
|
||||||
"body_text": "Body 2",
|
"date_parsed": "2024-01-02T12:00:00",
|
||||||
"body_html": "<p>Body 2</p>",
|
"body_text": "Body 2",
|
||||||
"has_attachments": 0,
|
"body_html": "<p>Body 2</p>",
|
||||||
"file_size": 1024,
|
"has_attachments": 0,
|
||||||
})
|
"file_size": 1024,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
db.insert_email({
|
db.insert_email(
|
||||||
"file_path": "/test/path3.eml",
|
{
|
||||||
"file_hash": "hash3",
|
"file_path": "/test/path3.eml",
|
||||||
"subject": "Different Topic",
|
"file_hash": "hash3",
|
||||||
"sender": "sender3@example.com",
|
"subject": "Different Topic",
|
||||||
"recipients": "recipient@example.com",
|
"sender": "sender3@example.com",
|
||||||
"date": "Wed, 3 Jan 2024 12:00:00 +0000",
|
"recipients": "recipient@example.com",
|
||||||
"date_parsed": "2024-01-03T12:00:00",
|
"date": "Wed, 3 Jan 2024 12:00:00 +0000",
|
||||||
"body_text": "Body 3",
|
"date_parsed": "2024-01-03T12:00:00",
|
||||||
"body_html": "<p>Body 3</p>",
|
"body_text": "Body 3",
|
||||||
"has_attachments": 0,
|
"body_html": "<p>Body 3</p>",
|
||||||
"file_size": 1024,
|
"has_attachments": 0,
|
||||||
})
|
"file_size": 1024,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
grouped = db.get_grouped_emails()
|
grouped = db.get_grouped_emails()
|
||||||
# Should have 2 groups: "Meeting Tomorrow" and "Different Topic"
|
# Should have 2 groups: "Meeting Tomorrow" and "Different Topic"
|
||||||
@@ -322,19 +340,21 @@ class TestEmailDatabase:
|
|||||||
assert db.get_email_count() == 0
|
assert db.get_email_count() == 0
|
||||||
|
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
db.insert_email({
|
db.insert_email(
|
||||||
"file_path": f"/test/path{i}.eml",
|
{
|
||||||
"file_hash": f"hash{i}",
|
"file_path": f"/test/path{i}.eml",
|
||||||
"subject": f"Subject {i}",
|
"file_hash": f"hash{i}",
|
||||||
"sender": f"sender{i}@example.com",
|
"subject": f"Subject {i}",
|
||||||
"recipients": "recipient@example.com",
|
"sender": f"sender{i}@example.com",
|
||||||
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
|
"recipients": "recipient@example.com",
|
||||||
"date_parsed": f"2024-01-0{i + 1}T12:00:00",
|
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
|
||||||
"body_text": f"Body {i}",
|
"date_parsed": f"2024-01-0{i + 1}T12:00:00",
|
||||||
"body_html": f"<p>Body {i}</p>",
|
"body_text": f"Body {i}",
|
||||||
"has_attachments": 0,
|
"body_html": f"<p>Body {i}</p>",
|
||||||
"file_size": 1024,
|
"has_attachments": 0,
|
||||||
})
|
"file_size": 1024,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
assert db.get_email_count() == 3
|
assert db.get_email_count() == 3
|
||||||
db.close()
|
db.close()
|
||||||
@@ -346,19 +366,21 @@ class TestEmailDatabase:
|
|||||||
|
|
||||||
# Insert some emails
|
# Insert some emails
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
db.insert_email({
|
db.insert_email(
|
||||||
"file_path": f"/test/path{i}.eml",
|
{
|
||||||
"file_hash": f"hash{i}",
|
"file_path": f"/test/path{i}.eml",
|
||||||
"subject": f"Subject {i}",
|
"file_hash": f"hash{i}",
|
||||||
"sender": f"sender{i}@example.com",
|
"subject": f"Subject {i}",
|
||||||
"recipients": "recipient@example.com",
|
"sender": f"sender{i}@example.com",
|
||||||
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
|
"recipients": "recipient@example.com",
|
||||||
"date_parsed": f"2024-01-0{i + 1}T12:00:00",
|
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
|
||||||
"body_text": f"Body {i}",
|
"date_parsed": f"2024-01-0{i + 1}T12:00:00",
|
||||||
"body_html": f"<p>Body {i}</p>",
|
"body_text": f"Body {i}",
|
||||||
"has_attachments": 0,
|
"body_html": f"<p>Body {i}</p>",
|
||||||
"file_size": 1024,
|
"has_attachments": 0,
|
||||||
})
|
"file_size": 1024,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
assert db.get_email_count() == 3
|
assert db.get_email_count() == 3
|
||||||
|
|
||||||
@@ -687,19 +709,21 @@ class TestEmlManagerHandler:
|
|||||||
|
|
||||||
# Insert some emails
|
# Insert some emails
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
db.insert_email({
|
db.insert_email(
|
||||||
"file_path": f"/test/path{i}.eml",
|
{
|
||||||
"file_hash": f"hash{i}",
|
"file_path": f"/test/path{i}.eml",
|
||||||
"subject": f"Subject {i}",
|
"file_hash": f"hash{i}",
|
||||||
"sender": f"sender{i}@example.com",
|
"subject": f"Subject {i}",
|
||||||
"recipients": "recipient@example.com",
|
"sender": f"sender{i}@example.com",
|
||||||
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
|
"recipients": "recipient@example.com",
|
||||||
"date_parsed": f"2024-01-0{i + 1}T12:00:00",
|
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
|
||||||
"body_text": f"Body {i}",
|
"date_parsed": f"2024-01-0{i + 1}T12:00:00",
|
||||||
"body_html": f"<p>Body {i}</p>",
|
"body_text": f"Body {i}",
|
||||||
"has_attachments": 0,
|
"body_html": f"<p>Body {i}</p>",
|
||||||
"file_size": 1024,
|
"has_attachments": 0,
|
||||||
})
|
"file_size": 1024,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Create a mock handler instance without calling __init__
|
# Create a mock handler instance without calling __init__
|
||||||
handler = Mock(spec=emlmanager.EmlManagerHandler)
|
handler = Mock(spec=emlmanager.EmlManagerHandler)
|
||||||
@@ -721,19 +745,21 @@ class TestEmlManagerHandler:
|
|||||||
db = emlmanager.EmailDatabase(db_path)
|
db = emlmanager.EmailDatabase(db_path)
|
||||||
|
|
||||||
# Insert test email
|
# Insert test email
|
||||||
db.insert_email({
|
db.insert_email(
|
||||||
"file_path": "/test/path.eml",
|
{
|
||||||
"file_hash": "hash",
|
"file_path": "/test/path.eml",
|
||||||
"subject": "Test Subject",
|
"file_hash": "hash",
|
||||||
"sender": "sender@example.com",
|
"subject": "Test Subject",
|
||||||
"recipients": "recipient@example.com",
|
"sender": "sender@example.com",
|
||||||
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
"recipients": "recipient@example.com",
|
||||||
"date_parsed": "2024-01-01T12:00:00",
|
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
||||||
"body_text": "Test body",
|
"date_parsed": "2024-01-01T12:00:00",
|
||||||
"body_html": "<p>Test body</p>",
|
"body_text": "Test body",
|
||||||
"has_attachments": 0,
|
"body_html": "<p>Test body</p>",
|
||||||
"file_size": 1024,
|
"has_attachments": 0,
|
||||||
})
|
"file_size": 1024,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Create a mock handler instance without calling __init__
|
# Create a mock handler instance without calling __init__
|
||||||
handler = Mock(spec=emlmanager.EmlManagerHandler)
|
handler = Mock(spec=emlmanager.EmlManagerHandler)
|
||||||
@@ -756,19 +782,21 @@ class TestEmlManagerHandler:
|
|||||||
db = emlmanager.EmailDatabase(db_path)
|
db = emlmanager.EmailDatabase(db_path)
|
||||||
|
|
||||||
# Insert test email
|
# Insert test email
|
||||||
db.insert_email({
|
db.insert_email(
|
||||||
"file_path": "/test/path.eml",
|
{
|
||||||
"file_hash": "hash",
|
"file_path": "/test/path.eml",
|
||||||
"subject": "Test Subject",
|
"file_hash": "hash",
|
||||||
"sender": "sender@example.com",
|
"subject": "Test Subject",
|
||||||
"recipients": "recipient@example.com",
|
"sender": "sender@example.com",
|
||||||
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
"recipients": "recipient@example.com",
|
||||||
"date_parsed": "2024-01-01T12:00:00",
|
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
|
||||||
"body_text": "Test body",
|
"date_parsed": "2024-01-01T12:00:00",
|
||||||
"body_html": "<p>Test body</p>",
|
"body_text": "Test body",
|
||||||
"has_attachments": 0,
|
"body_html": "<p>Test body</p>",
|
||||||
"file_size": 1024,
|
"has_attachments": 0,
|
||||||
})
|
"file_size": 1024,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
assert db.get_email_count() == 1
|
assert db.get_email_count() == 1
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
"""Tests for ops.dev 模块 envdev/dockercmd 函数 (镜像源配置/Docker 登录)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyflowx.conditions import Constants
|
||||||
|
from pyflowx.ops.dev import (
|
||||||
|
docker_login_tencent,
|
||||||
|
download_rustup_script,
|
||||||
|
install_linux_docker,
|
||||||
|
install_linux_fonts,
|
||||||
|
install_linux_qt_libs,
|
||||||
|
install_rust_toolchain,
|
||||||
|
setup_conda_mirror,
|
||||||
|
setup_linux_system_mirror,
|
||||||
|
setup_python_mirror,
|
||||||
|
setup_rust_mirror,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
# setup_python_mirror
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
class TestSetupPythonMirror:
|
||||||
|
"""``setup_python_mirror`` 函数测试."""
|
||||||
|
|
||||||
|
def test_unknown_mirror_skips(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""未知镜像源应打印提示并跳过."""
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda *_, **__: MagicMock())
|
||||||
|
setup_python_mirror("unknown_mirror")
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "未知 Python 镜像源" in captured.out
|
||||||
|
|
||||||
|
def test_known_mirror_writes_config(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""已知镜像源应写入配置文件."""
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||||
|
setup_python_mirror("tsinghua")
|
||||||
|
# Linux 平台默认配置路径
|
||||||
|
config_path = tmp_path / ".pip" / "pip.conf"
|
||||||
|
if not config_path.exists():
|
||||||
|
config_path = tmp_path / "pip" / "pip.ini"
|
||||||
|
assert config_path.exists()
|
||||||
|
content = config_path.read_text(encoding="utf-8")
|
||||||
|
assert "pypi.tuna.tsinghua.edu.cn" in content
|
||||||
|
|
||||||
|
def test_linux_uses_pip_conf(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""Linux 平台应写入 ~/.pip/pip.conf."""
|
||||||
|
monkeypatch.setattr(Constants, "IS_LINUX", True)
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||||
|
setup_python_mirror("tsinghua")
|
||||||
|
config_path = tmp_path / ".pip" / "pip.conf"
|
||||||
|
assert config_path.exists()
|
||||||
|
|
||||||
|
def test_non_linux_uses_pip_ini(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""非 Linux 平台应写入 ~/pip/pip.ini."""
|
||||||
|
monkeypatch.setattr(Constants, "IS_LINUX", False)
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||||
|
setup_python_mirror("tsinghua")
|
||||||
|
config_path = tmp_path / "pip" / "pip.ini"
|
||||||
|
assert config_path.exists()
|
||||||
|
|
||||||
|
def test_sets_env_vars(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""应设置 PIP_INDEX_URL 等环境变量."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||||
|
monkeypatch.setattr(os, "environ", {})
|
||||||
|
|
||||||
|
setup_python_mirror("aliyun")
|
||||||
|
|
||||||
|
assert "PIP_INDEX_URL" in os.environ
|
||||||
|
assert "mirrors.aliyun.com" in os.environ["PIP_INDEX_URL"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
# setup_conda_mirror
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
class TestSetupCondaMirror:
|
||||||
|
"""``setup_conda_mirror`` 函数测试."""
|
||||||
|
|
||||||
|
def test_unknown_mirror_skips(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""未知镜像源应跳过."""
|
||||||
|
setup_conda_mirror("unknown")
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "未知 Conda 镜像源" in captured.out
|
||||||
|
|
||||||
|
def test_known_mirror_writes_condarc(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""已知镜像源应写入 ~/.condarc."""
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||||
|
setup_conda_mirror("tsinghua")
|
||||||
|
condarc = tmp_path / ".condarc"
|
||||||
|
assert condarc.exists()
|
||||||
|
content = condarc.read_text(encoding="utf-8")
|
||||||
|
assert "tsinghua" in content
|
||||||
|
assert "channels:" in content
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
# setup_rust_mirror
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
class TestSetupRustMirror:
|
||||||
|
"""``setup_rust_mirror`` 函数测试."""
|
||||||
|
|
||||||
|
def test_unknown_mirror_skips(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""未知镜像源应跳过."""
|
||||||
|
setup_rust_mirror("unknown")
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "未知 Rust 镜像源" in captured.out
|
||||||
|
|
||||||
|
def test_known_mirror_writes_config(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""已知镜像源应写入 ~/.cargo/config.toml."""
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||||
|
setup_rust_mirror("ustc", "nightly")
|
||||||
|
config = tmp_path / ".cargo" / "config.toml"
|
||||||
|
assert config.exists()
|
||||||
|
content = config.read_text(encoding="utf-8")
|
||||||
|
assert "ustc" in content
|
||||||
|
|
||||||
|
def test_creates_sccache_dir(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""应创建 sccache 缓存目录."""
|
||||||
|
from pyflowx.ops import dev as dev_module
|
||||||
|
|
||||||
|
fake_sccache = tmp_path / ".cargo" / "sccache"
|
||||||
|
monkeypatch.setattr(dev_module, "_RUST_SCCACHE_DIR", fake_sccache)
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||||
|
setup_rust_mirror("tsinghua")
|
||||||
|
assert fake_sccache.exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
# docker_login_tencent
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
class TestDockerLoginTencent:
|
||||||
|
"""``docker_login_tencent`` 函数测试."""
|
||||||
|
|
||||||
|
def test_default_username_uses_getpass(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""未提供 username 时应使用 getpass.getuser."""
|
||||||
|
monkeypatch.setattr("getpass.getuser", lambda: "testuser")
|
||||||
|
ran_cmds: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
|
||||||
|
|
||||||
|
docker_login_tencent()
|
||||||
|
|
||||||
|
assert ran_cmds[0][0] == "docker"
|
||||||
|
assert ran_cmds[0][1] == "login"
|
||||||
|
assert "testuser" in ran_cmds[0]
|
||||||
|
assert "ccr.ccs.tencentyun.com" in ran_cmds[0]
|
||||||
|
|
||||||
|
def test_custom_username(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""提供 username 时应使用自定义用户名."""
|
||||||
|
ran_cmds: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
|
||||||
|
|
||||||
|
docker_login_tencent("myuser")
|
||||||
|
|
||||||
|
assert "myuser" in ran_cmds[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
# setup_linux_system_mirror
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
class TestSetupLinuxSystemMirror:
|
||||||
|
"""``setup_linux_system_mirror`` 函数测试."""
|
||||||
|
|
||||||
|
def test_non_linux_skips(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""非 Linux 平台应跳过."""
|
||||||
|
monkeypatch.setattr(Constants, "IS_LINUX", False)
|
||||||
|
called: list[str] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: called.append(cmd))
|
||||||
|
|
||||||
|
setup_linux_system_mirror()
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "仅在 Linux 上执行" in captured.out
|
||||||
|
assert called == []
|
||||||
|
|
||||||
|
def test_linux_already_configured_skips(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Linux 上已配置国内镜像时应跳过."""
|
||||||
|
monkeypatch.setattr(Constants, "IS_LINUX", True)
|
||||||
|
|
||||||
|
def fake_read_text(self: Path, encoding: str = "utf-8") -> str:
|
||||||
|
return "tsinghua mirror configured"
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "read_text", fake_read_text)
|
||||||
|
called: list[str] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: called.append(cmd))
|
||||||
|
|
||||||
|
setup_linux_system_mirror()
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "已配置" in captured.out
|
||||||
|
assert called == []
|
||||||
|
|
||||||
|
def test_linux_not_configured_runs_script(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Linux 上未配置镜像时应执行下载与安装脚本."""
|
||||||
|
monkeypatch.setattr(Constants, "IS_LINUX", True)
|
||||||
|
|
||||||
|
def fake_read_text(self: Path, encoding: str = "utf-8") -> str:
|
||||||
|
raise OSError("file not found")
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "read_text", fake_read_text)
|
||||||
|
ran_cmds: list[str] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
|
||||||
|
|
||||||
|
setup_linux_system_mirror()
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "下载" in captured.out
|
||||||
|
assert "安装" in captured.out
|
||||||
|
assert len(ran_cmds) == 2
|
||||||
|
|
||||||
|
def test_linux_content_without_mirror_runs_script(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Linux 上文件存在但不包含镜像关键词时应执行脚本."""
|
||||||
|
monkeypatch.setattr(Constants, "IS_LINUX", True)
|
||||||
|
|
||||||
|
def fake_read_text(self: Path, encoding: str = "utf-8") -> str:
|
||||||
|
return "deb http://archive.ubuntu.com/ubuntu/ jammy main"
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "read_text", fake_read_text)
|
||||||
|
ran_cmds: list[str] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
|
||||||
|
|
||||||
|
setup_linux_system_mirror()
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "下载" in captured.out
|
||||||
|
assert len(ran_cmds) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
# install_linux_qt_libs / install_linux_fonts / install_linux_docker
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
class TestLinuxInstallers:
|
||||||
|
"""Linux 专用安装函数测试."""
|
||||||
|
|
||||||
|
def test_qt_libs_non_linux_skips(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""非 Linux 上 install_linux_qt_libs 应跳过."""
|
||||||
|
monkeypatch.setattr(Constants, "IS_LINUX", False)
|
||||||
|
called: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: called.append(cmd))
|
||||||
|
install_linux_qt_libs()
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "仅在 Linux" in captured.out
|
||||||
|
assert called == []
|
||||||
|
|
||||||
|
def test_qt_libs_linux_runs_apt(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Linux 上应执行 apt install."""
|
||||||
|
monkeypatch.setattr(Constants, "IS_LINUX", True)
|
||||||
|
ran_cmds: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
|
||||||
|
install_linux_qt_libs()
|
||||||
|
assert ran_cmds[0][0] == "sudo"
|
||||||
|
assert "apt" in ran_cmds[0]
|
||||||
|
assert "install" in ran_cmds[0]
|
||||||
|
|
||||||
|
def test_fonts_non_linux_skips(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""非 Linux 上 install_linux_fonts 应跳过."""
|
||||||
|
monkeypatch.setattr(Constants, "IS_LINUX", False)
|
||||||
|
called: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: called.append(cmd))
|
||||||
|
install_linux_fonts()
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "仅在 Linux" in captured.out
|
||||||
|
assert called == []
|
||||||
|
|
||||||
|
def test_fonts_linux_runs_apt(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Linux 上应执行 apt install 字体包."""
|
||||||
|
monkeypatch.setattr(Constants, "IS_LINUX", True)
|
||||||
|
ran_cmds: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
|
||||||
|
install_linux_fonts()
|
||||||
|
assert "fonts-noto-cjk" in ran_cmds[0]
|
||||||
|
|
||||||
|
def test_docker_non_linux_skips(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""非 Linux 上 install_linux_docker 应跳过."""
|
||||||
|
monkeypatch.setattr(Constants, "IS_LINUX", False)
|
||||||
|
called: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: called.append(cmd))
|
||||||
|
install_linux_docker()
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "仅在 Linux" in captured.out
|
||||||
|
assert called == []
|
||||||
|
|
||||||
|
def test_docker_linux_runs_install_and_usermod(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Linux 上应执行 apt install docker-compose-v2 和 usermod."""
|
||||||
|
monkeypatch.setattr(Constants, "IS_LINUX", True)
|
||||||
|
monkeypatch.setattr("getpass.getuser", lambda: "testuser")
|
||||||
|
ran_cmds: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
|
||||||
|
|
||||||
|
install_linux_docker()
|
||||||
|
|
||||||
|
assert any("docker-compose-v2" in cmd for cmd in ran_cmds)
|
||||||
|
assert any("usermod" in cmd and "docker" in cmd for cmd in ran_cmds)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
# download_rustup_script
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
class TestDownloadRustupScript:
|
||||||
|
"""``download_rustup_script`` 函数测试."""
|
||||||
|
|
||||||
|
def test_rustup_installed_skips(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""rustup 已安装时应跳过."""
|
||||||
|
monkeypatch.setattr("shutil.which", lambda _cmd: "/usr/bin/rustup")
|
||||||
|
called: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: called.append(cmd))
|
||||||
|
|
||||||
|
download_rustup_script()
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "已安装" in captured.out
|
||||||
|
assert called == []
|
||||||
|
|
||||||
|
def test_windows_downloads_exe(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Windows 上应下载 rustup-init.exe."""
|
||||||
|
monkeypatch.setattr("shutil.which", lambda _cmd: None)
|
||||||
|
monkeypatch.setattr(Constants, "IS_WINDOWS", True)
|
||||||
|
ran_cmds: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
|
||||||
|
|
||||||
|
download_rustup_script()
|
||||||
|
|
||||||
|
assert "powershell" in ran_cmds[0]
|
||||||
|
assert "rustup-init.exe" in ran_cmds[0]
|
||||||
|
|
||||||
|
def test_non_windows_downloads_sh(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""非 Windows 上应下载 rustup-init.sh."""
|
||||||
|
monkeypatch.setattr("shutil.which", lambda _cmd: None)
|
||||||
|
monkeypatch.setattr(Constants, "IS_WINDOWS", False)
|
||||||
|
ran_cmds: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
|
||||||
|
|
||||||
|
download_rustup_script()
|
||||||
|
|
||||||
|
assert "curl" in ran_cmds[0]
|
||||||
|
assert "rustup-init.sh" in ran_cmds[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
# install_rust_toolchain
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
class TestInstallRustToolchain:
|
||||||
|
"""``install_rust_toolchain`` 函数测试."""
|
||||||
|
|
||||||
|
def test_rustup_not_installed_skips(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""rustup 未安装时应跳过."""
|
||||||
|
monkeypatch.setattr("shutil.which", lambda _cmd: None)
|
||||||
|
called: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: called.append(cmd))
|
||||||
|
|
||||||
|
install_rust_toolchain()
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "未安装" in captured.out
|
||||||
|
assert called == []
|
||||||
|
|
||||||
|
def test_rustup_installed_runs_toolchain_install(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""rustup 已安装时应执行 toolchain install."""
|
||||||
|
monkeypatch.setattr("shutil.which", lambda _cmd: "/usr/bin/rustup")
|
||||||
|
ran_cmds: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
|
||||||
|
|
||||||
|
install_rust_toolchain("nightly")
|
||||||
|
|
||||||
|
assert ran_cmds == [["rustup", "toolchain", "install", "nightly"]]
|
||||||
|
|
||||||
|
def test_default_version_stable(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""默认版本应为 stable."""
|
||||||
|
monkeypatch.setattr("shutil.which", lambda _cmd: "/usr/bin/rustup")
|
||||||
|
ran_cmds: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
|
||||||
|
|
||||||
|
install_rust_toolchain()
|
||||||
|
|
||||||
|
assert "stable" in ran_cmds[0]
|
||||||
+10
-43
@@ -3,10 +3,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pyflowx as px
|
from pyflowx.ops import files
|
||||||
from pyflowx.cli import filedate
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- #
|
# ---------------------------------------------------------------------- #
|
||||||
@@ -20,7 +18,7 @@ class TestGetFileTimestamp:
|
|||||||
test_file = tmp_path / "test.txt"
|
test_file = tmp_path / "test.txt"
|
||||||
test_file.write_text("test content")
|
test_file.write_text("test content")
|
||||||
|
|
||||||
timestamp = filedate.get_file_timestamp(test_file)
|
timestamp = files.get_file_timestamp(test_file)
|
||||||
assert len(timestamp) == 8 # YYYYMMDD format
|
assert len(timestamp) == 8 # YYYYMMDD format
|
||||||
assert timestamp.isdigit()
|
assert timestamp.isdigit()
|
||||||
|
|
||||||
@@ -36,7 +34,7 @@ class TestRemoveDatePrefix:
|
|||||||
test_file = tmp_path / "20240101_test.txt"
|
test_file = tmp_path / "20240101_test.txt"
|
||||||
test_file.write_text("test content")
|
test_file.write_text("test content")
|
||||||
|
|
||||||
new_path = filedate.remove_date_prefix(test_file)
|
new_path = files.remove_date_prefix(test_file)
|
||||||
assert new_path.name == "test.txt"
|
assert new_path.name == "test.txt"
|
||||||
|
|
||||||
def test_remove_date_prefix_without_date(self, tmp_path: Path) -> None:
|
def test_remove_date_prefix_without_date(self, tmp_path: Path) -> None:
|
||||||
@@ -44,7 +42,7 @@ class TestRemoveDatePrefix:
|
|||||||
test_file = tmp_path / "test.txt"
|
test_file = tmp_path / "test.txt"
|
||||||
test_file.write_text("test content")
|
test_file.write_text("test content")
|
||||||
|
|
||||||
new_path = filedate.remove_date_prefix(test_file)
|
new_path = files.remove_date_prefix(test_file)
|
||||||
assert new_path == test_file
|
assert new_path == test_file
|
||||||
|
|
||||||
|
|
||||||
@@ -59,7 +57,7 @@ class TestAddDatePrefix:
|
|||||||
test_file = tmp_path / "test.txt"
|
test_file = tmp_path / "test.txt"
|
||||||
test_file.write_text("test content")
|
test_file.write_text("test content")
|
||||||
|
|
||||||
new_path = filedate.add_date_prefix(test_file)
|
new_path = files.add_date_prefix(test_file)
|
||||||
assert new_path.name.startswith("20") # Starts with year
|
assert new_path.name.startswith("20") # Starts with year
|
||||||
assert "_test.txt" in new_path.name
|
assert "_test.txt" in new_path.name
|
||||||
|
|
||||||
@@ -75,7 +73,7 @@ class TestProcessFileDate:
|
|||||||
test_file = tmp_path / "test.txt"
|
test_file = tmp_path / "test.txt"
|
||||||
test_file.write_text("test content")
|
test_file.write_text("test content")
|
||||||
|
|
||||||
filedate.process_file_date(test_file, clear=False)
|
files.process_file_date(test_file, clear=False)
|
||||||
# File should be renamed with date prefix
|
# File should be renamed with date prefix
|
||||||
|
|
||||||
def test_process_file_date_clear(self, tmp_path: Path) -> None:
|
def test_process_file_date_clear(self, tmp_path: Path) -> None:
|
||||||
@@ -83,7 +81,7 @@ class TestProcessFileDate:
|
|||||||
test_file = tmp_path / "20240101_test.txt"
|
test_file = tmp_path / "20240101_test.txt"
|
||||||
test_file.write_text("test content")
|
test_file.write_text("test content")
|
||||||
|
|
||||||
filedate.process_file_date(test_file, clear=True)
|
files.process_file_date(test_file, clear=True)
|
||||||
# File should be renamed without date prefix
|
# File should be renamed without date prefix
|
||||||
|
|
||||||
|
|
||||||
@@ -95,42 +93,11 @@ class TestProcessFilesDate:
|
|||||||
|
|
||||||
def test_process_files_date_batch(self, tmp_path: Path) -> None:
|
def test_process_files_date_batch(self, tmp_path: Path) -> None:
|
||||||
"""Should process multiple files."""
|
"""Should process multiple files."""
|
||||||
files = []
|
file_list = []
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
test_file = tmp_path / f"test{i}.txt"
|
test_file = tmp_path / f"test{i}.txt"
|
||||||
test_file.write_text(f"content{i}")
|
test_file.write_text(f"content{i}")
|
||||||
files.append(test_file)
|
file_list.append(test_file)
|
||||||
|
|
||||||
filedate.process_files_date(files, clear=False)
|
files.process_files_date(file_list, clear=False)
|
||||||
# All files should be processed
|
# All files should be processed
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- #
|
|
||||||
# main function
|
|
||||||
# ---------------------------------------------------------------------- #
|
|
||||||
class TestMain:
|
|
||||||
"""Test main function."""
|
|
||||||
|
|
||||||
def test_main_add_command(self, tmp_path: Path) -> None:
|
|
||||||
"""main() should handle add command."""
|
|
||||||
test_file = tmp_path / "test.txt"
|
|
||||||
test_file.write_text("test content")
|
|
||||||
|
|
||||||
with patch("sys.argv", ["filedate", "add", str(test_file)]), patch.object(px, "run") as mock_run:
|
|
||||||
filedate.main()
|
|
||||||
assert mock_run.called
|
|
||||||
|
|
||||||
def test_main_clear_command(self, tmp_path: Path) -> None:
|
|
||||||
"""main() should handle clear command."""
|
|
||||||
test_file = tmp_path / "20240101_test.txt"
|
|
||||||
test_file.write_text("test content")
|
|
||||||
|
|
||||||
with patch("sys.argv", ["filedate", "clear", str(test_file)]), patch.object(px, "run") as mock_run:
|
|
||||||
filedate.main()
|
|
||||||
assert mock_run.called
|
|
||||||
|
|
||||||
def test_main_with_no_args_shows_help(self) -> None:
|
|
||||||
"""main() with no args should show help."""
|
|
||||||
with patch("sys.argv", ["filedate"]):
|
|
||||||
filedate.main()
|
|
||||||
# Should print help and return
|
|
||||||
|
|||||||
+12
-49
@@ -3,10 +3,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pyflowx as px
|
from pyflowx.ops import files
|
||||||
from pyflowx.cli import filelevel
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- #
|
# ---------------------------------------------------------------------- #
|
||||||
@@ -18,19 +16,19 @@ class TestRemoveMarks:
|
|||||||
def test_remove_marks_single_mark(self) -> None:
|
def test_remove_marks_single_mark(self) -> None:
|
||||||
"""Should remove single mark."""
|
"""Should remove single mark."""
|
||||||
stem = "filename(PUB)"
|
stem = "filename(PUB)"
|
||||||
result = filelevel.remove_marks(stem, ["PUB"])
|
result = files.remove_marks(stem, ["PUB"])
|
||||||
assert result == "filename"
|
assert result == "filename"
|
||||||
|
|
||||||
def test_remove_marks_multiple_marks(self) -> None:
|
def test_remove_marks_multiple_marks(self) -> None:
|
||||||
"""Should remove multiple marks."""
|
"""Should remove multiple marks."""
|
||||||
stem = "filename(PUB)(NOR)"
|
stem = "filename(PUB)(NOR)"
|
||||||
result = filelevel.remove_marks(stem, ["PUB", "NOR"])
|
result = files.remove_marks(stem, ["PUB", "NOR"])
|
||||||
assert result == "filename"
|
assert result == "filename"
|
||||||
|
|
||||||
def test_remove_marks_no_marks(self) -> None:
|
def test_remove_marks_no_marks(self) -> None:
|
||||||
"""Should not change stem without marks."""
|
"""Should not change stem without marks."""
|
||||||
stem = "filename"
|
stem = "filename"
|
||||||
result = filelevel.remove_marks(stem, ["PUB"])
|
result = files.remove_marks(stem, ["PUB"])
|
||||||
assert result == "filename"
|
assert result == "filename"
|
||||||
|
|
||||||
|
|
||||||
@@ -45,7 +43,7 @@ class TestProcessFileLevel:
|
|||||||
test_file = tmp_path / "test.txt"
|
test_file = tmp_path / "test.txt"
|
||||||
test_file.write_text("test content")
|
test_file.write_text("test content")
|
||||||
|
|
||||||
filelevel.process_file_level(test_file, level=1)
|
files.process_file_level(test_file, level=1)
|
||||||
# File should be renamed with PUB level
|
# File should be renamed with PUB level
|
||||||
|
|
||||||
def test_process_file_level_set_int(self, tmp_path: Path) -> None:
|
def test_process_file_level_set_int(self, tmp_path: Path) -> None:
|
||||||
@@ -53,7 +51,7 @@ class TestProcessFileLevel:
|
|||||||
test_file = tmp_path / "test.txt"
|
test_file = tmp_path / "test.txt"
|
||||||
test_file.write_text("test content")
|
test_file.write_text("test content")
|
||||||
|
|
||||||
filelevel.process_file_level(test_file, level=2)
|
files.process_file_level(test_file, level=2)
|
||||||
# File should be renamed with INT level
|
# File should be renamed with INT level
|
||||||
|
|
||||||
def test_process_file_level_clear(self, tmp_path: Path) -> None:
|
def test_process_file_level_clear(self, tmp_path: Path) -> None:
|
||||||
@@ -61,7 +59,7 @@ class TestProcessFileLevel:
|
|||||||
test_file = tmp_path / "test(PUB).txt"
|
test_file = tmp_path / "test(PUB).txt"
|
||||||
test_file.write_text("test content")
|
test_file.write_text("test content")
|
||||||
|
|
||||||
filelevel.process_file_level(test_file, level=0)
|
files.process_file_level(test_file, level=0)
|
||||||
# File should be renamed without level
|
# File should be renamed without level
|
||||||
|
|
||||||
def test_process_file_level_invalid_level(self, tmp_path: Path) -> None:
|
def test_process_file_level_invalid_level(self, tmp_path: Path) -> None:
|
||||||
@@ -69,14 +67,14 @@ class TestProcessFileLevel:
|
|||||||
test_file = tmp_path / "test.txt"
|
test_file = tmp_path / "test.txt"
|
||||||
test_file.write_text("test content")
|
test_file.write_text("test content")
|
||||||
|
|
||||||
filelevel.process_file_level(test_file, level=5)
|
files.process_file_level(test_file, level=5)
|
||||||
# Should print error message
|
# Should print error message
|
||||||
|
|
||||||
def test_process_file_level_nonexistent_file(self, tmp_path: Path) -> None:
|
def test_process_file_level_nonexistent_file(self, tmp_path: Path) -> None:
|
||||||
"""Should handle nonexistent file."""
|
"""Should handle nonexistent file."""
|
||||||
test_file = tmp_path / "nonexistent.txt"
|
test_file = tmp_path / "nonexistent.txt"
|
||||||
|
|
||||||
filelevel.process_file_level(test_file, level=1)
|
files.process_file_level(test_file, level=1)
|
||||||
# Should print error message
|
# Should print error message
|
||||||
|
|
||||||
|
|
||||||
@@ -88,46 +86,11 @@ class TestProcessFilesLevel:
|
|||||||
|
|
||||||
def test_process_files_level_batch(self, tmp_path: Path) -> None:
|
def test_process_files_level_batch(self, tmp_path: Path) -> None:
|
||||||
"""Should process multiple files."""
|
"""Should process multiple files."""
|
||||||
files = []
|
file_list = []
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
test_file = tmp_path / f"test{i}.txt"
|
test_file = tmp_path / f"test{i}.txt"
|
||||||
test_file.write_text(f"content{i}")
|
test_file.write_text(f"content{i}")
|
||||||
files.append(test_file)
|
file_list.append(test_file)
|
||||||
|
|
||||||
filelevel.process_files_level(files, level=1)
|
files.process_files_level(file_list, level=1)
|
||||||
# All files should be processed
|
# All files should be processed
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- #
|
|
||||||
# main function
|
|
||||||
# ---------------------------------------------------------------------- #
|
|
||||||
class TestMain:
|
|
||||||
"""Test main function."""
|
|
||||||
|
|
||||||
def test_main_set_command(self, tmp_path: Path) -> None:
|
|
||||||
"""main() should handle set command."""
|
|
||||||
test_file = tmp_path / "test.txt"
|
|
||||||
test_file.write_text("test content")
|
|
||||||
|
|
||||||
with patch("sys.argv", ["filelevel", "set", str(test_file), "--level", "1"]), patch.object(
|
|
||||||
px, "run"
|
|
||||||
) as mock_run:
|
|
||||||
filelevel.main()
|
|
||||||
assert mock_run.called
|
|
||||||
|
|
||||||
def test_main_set_command_level_2(self, tmp_path: Path) -> None:
|
|
||||||
"""main() should handle set command with level 2."""
|
|
||||||
test_file = tmp_path / "test.txt"
|
|
||||||
test_file.write_text("test content")
|
|
||||||
|
|
||||||
with patch("sys.argv", ["filelevel", "set", str(test_file), "--level", "2"]), patch.object(
|
|
||||||
px, "run"
|
|
||||||
) as mock_run:
|
|
||||||
filelevel.main()
|
|
||||||
assert mock_run.called
|
|
||||||
|
|
||||||
def test_main_with_no_args_shows_help(self) -> None:
|
|
||||||
"""main() with no args should show help."""
|
|
||||||
with patch("sys.argv", ["filelevel"]):
|
|
||||||
filelevel.main()
|
|
||||||
# Should print help and return
|
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pyflowx as px
|
from pyflowx.ops import files
|
||||||
from pyflowx.cli import folderback
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- #
|
# ---------------------------------------------------------------------- #
|
||||||
@@ -22,7 +21,7 @@ class TestRemoveDump:
|
|||||||
dst = tmp_path / "backup"
|
dst = tmp_path / "backup"
|
||||||
dst.mkdir()
|
dst.mkdir()
|
||||||
|
|
||||||
folderback.remove_dump(src, dst, 5)
|
files.remove_dump(src, dst, 5)
|
||||||
# Should not raise error
|
# Should not raise error
|
||||||
|
|
||||||
def test_remove_dump_within_limit(self, tmp_path: Path) -> None:
|
def test_remove_dump_within_limit(self, tmp_path: Path) -> None:
|
||||||
@@ -37,7 +36,7 @@ class TestRemoveDump:
|
|||||||
zip_file = dst / f"source_20240101_12000{i}.zip"
|
zip_file = dst / f"source_20240101_12000{i}.zip"
|
||||||
zip_file.write_bytes(b"ZIP content")
|
zip_file.write_bytes(b"ZIP content")
|
||||||
|
|
||||||
folderback.remove_dump(src, dst, 5)
|
files.remove_dump(src, dst, 5)
|
||||||
# All files should remain
|
# All files should remain
|
||||||
assert len(list(dst.glob("*.zip"))) == 3
|
assert len(list(dst.glob("*.zip"))) == 3
|
||||||
|
|
||||||
@@ -53,7 +52,7 @@ class TestRemoveDump:
|
|||||||
zip_file = dst / f"source_20240101_12000{i}.zip"
|
zip_file = dst / f"source_20240101_12000{i}.zip"
|
||||||
zip_file.write_bytes(b"ZIP content")
|
zip_file.write_bytes(b"ZIP content")
|
||||||
|
|
||||||
folderback.remove_dump(src, dst, 5)
|
files.remove_dump(src, dst, 5)
|
||||||
# Should have only 5 files
|
# Should have only 5 files
|
||||||
assert len(list(dst.glob("*.zip"))) == 5
|
assert len(list(dst.glob("*.zip"))) == 5
|
||||||
|
|
||||||
@@ -73,7 +72,7 @@ class TestZipTarget:
|
|||||||
dst.mkdir()
|
dst.mkdir()
|
||||||
|
|
||||||
with patch("time.strftime", return_value="_20240101_120000"):
|
with patch("time.strftime", return_value="_20240101_120000"):
|
||||||
folderback.zip_target(src, dst, 5)
|
files.zip_target(src, dst, 5)
|
||||||
|
|
||||||
# Should create zip file
|
# Should create zip file
|
||||||
zip_files = list(dst.glob("*.zip"))
|
zip_files = list(dst.glob("*.zip"))
|
||||||
@@ -91,7 +90,7 @@ class TestZipTarget:
|
|||||||
dst.mkdir()
|
dst.mkdir()
|
||||||
|
|
||||||
with patch("time.strftime", return_value="_20240101_120000"):
|
with patch("time.strftime", return_value="_20240101_120000"):
|
||||||
folderback.zip_target(src, dst, 5)
|
files.zip_target(src, dst, 5)
|
||||||
|
|
||||||
# Should create zip file
|
# Should create zip file
|
||||||
zip_files = list(dst.glob("*.zip"))
|
zip_files = list(dst.glob("*.zip"))
|
||||||
@@ -111,8 +110,8 @@ class TestBackupFolder:
|
|||||||
(source_dir / "test.txt").write_text("test content")
|
(source_dir / "test.txt").write_text("test content")
|
||||||
backup_dir = tmp_path / "backup"
|
backup_dir = tmp_path / "backup"
|
||||||
|
|
||||||
with patch.object(folderback, "zip_target") as mock_zip:
|
with patch.object(files, "zip_target") as mock_zip:
|
||||||
folderback.backup_folder(str(source_dir), str(backup_dir), 5)
|
files.backup_folder(str(source_dir), str(backup_dir), 5)
|
||||||
assert mock_zip.called
|
assert mock_zip.called
|
||||||
|
|
||||||
def test_backup_folder_with_max_backups(self, tmp_path: Path) -> None:
|
def test_backup_folder_with_max_backups(self, tmp_path: Path) -> None:
|
||||||
@@ -122,8 +121,8 @@ class TestBackupFolder:
|
|||||||
(source_dir / "test.txt").write_text("test content")
|
(source_dir / "test.txt").write_text("test content")
|
||||||
backup_dir = tmp_path / "backup"
|
backup_dir = tmp_path / "backup"
|
||||||
|
|
||||||
with patch.object(folderback, "zip_target") as mock_zip:
|
with patch.object(files, "zip_target") as mock_zip:
|
||||||
folderback.backup_folder(str(source_dir), str(backup_dir), 10)
|
files.backup_folder(str(source_dir), str(backup_dir), 10)
|
||||||
assert mock_zip.called
|
assert mock_zip.called
|
||||||
|
|
||||||
def test_backup_folder_source_not_exists(self, tmp_path: Path) -> None:
|
def test_backup_folder_source_not_exists(self, tmp_path: Path) -> None:
|
||||||
@@ -132,7 +131,7 @@ class TestBackupFolder:
|
|||||||
backup_dir = tmp_path / "backup"
|
backup_dir = tmp_path / "backup"
|
||||||
backup_dir.mkdir()
|
backup_dir.mkdir()
|
||||||
|
|
||||||
folderback.backup_folder(str(source_dir), str(backup_dir), 5)
|
files.backup_folder(str(source_dir), str(backup_dir), 5)
|
||||||
# Should print error message and return
|
# Should print error message and return
|
||||||
|
|
||||||
def test_backup_folder_creates_dst(self, tmp_path: Path) -> None:
|
def test_backup_folder_creates_dst(self, tmp_path: Path) -> None:
|
||||||
@@ -142,32 +141,19 @@ class TestBackupFolder:
|
|||||||
(source_dir / "test.txt").write_text("test content")
|
(source_dir / "test.txt").write_text("test content")
|
||||||
backup_dir = tmp_path / "backup"
|
backup_dir = tmp_path / "backup"
|
||||||
|
|
||||||
with patch.object(folderback, "zip_target") as mock_zip:
|
with patch.object(files, "zip_target") as mock_zip:
|
||||||
folderback.backup_folder(str(source_dir), str(backup_dir), 5)
|
files.backup_folder(str(source_dir), str(backup_dir), 5)
|
||||||
assert backup_dir.exists()
|
assert backup_dir.exists()
|
||||||
assert mock_zip.called
|
assert mock_zip.called
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- #
|
# ---------------------------------------------------------------------- #
|
||||||
# TaskSpec definitions
|
# 函数注册
|
||||||
# ---------------------------------------------------------------------- #
|
# ---------------------------------------------------------------------- #
|
||||||
class TestTaskSpecDefinitions:
|
class TestRegisteredFunctions:
|
||||||
"""Test that all TaskSpec definitions are valid."""
|
"""Test that folderback functions are registered."""
|
||||||
|
|
||||||
def test_folderback_default_spec(self) -> None:
|
def test_folderback_default_spec(self) -> None:
|
||||||
"""folderback_default spec should be properly defined."""
|
"""folderback_default should be a registered callable."""
|
||||||
assert folderback.folderback_default.name == "folderback_default"
|
# folderback_default 现在是通过 @px.register_fn 注册的普通函数, 不是 TaskSpec
|
||||||
assert folderback.folderback_default.fn is not None
|
assert callable(files.folderback_default)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- #
|
|
||||||
# main function
|
|
||||||
# ---------------------------------------------------------------------- #
|
|
||||||
class TestMain:
|
|
||||||
"""Test main function."""
|
|
||||||
|
|
||||||
def test_main_calls_run_cli(self) -> None:
|
|
||||||
"""main() should create a CliRunner and call run_cli()."""
|
|
||||||
with patch.object(px.CliRunner, "run_cli") as mock_run_cli:
|
|
||||||
folderback.main()
|
|
||||||
assert mock_run_cli.called
|
|
||||||
|
|||||||
+11
-25
@@ -5,8 +5,7 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pyflowx as px
|
from pyflowx.ops import files
|
||||||
from pyflowx.cli import folderzip
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- #
|
# ---------------------------------------------------------------------- #
|
||||||
@@ -22,7 +21,7 @@ class TestArchiveFolder:
|
|||||||
(folder / "test.txt").write_text("test content")
|
(folder / "test.txt").write_text("test content")
|
||||||
|
|
||||||
with patch("shutil.make_archive") as mock_archive:
|
with patch("shutil.make_archive") as mock_archive:
|
||||||
folderzip.archive_folder(folder)
|
files.archive_folder(folder)
|
||||||
assert mock_archive.called
|
assert mock_archive.called
|
||||||
|
|
||||||
|
|
||||||
@@ -39,37 +38,24 @@ class TestZipFolders:
|
|||||||
(tmp_path / "folder2").mkdir()
|
(tmp_path / "folder2").mkdir()
|
||||||
(tmp_path / ".git").mkdir() # Should be ignored
|
(tmp_path / ".git").mkdir() # Should be ignored
|
||||||
|
|
||||||
with patch.object(folderzip, "archive_folder") as mock_archive:
|
with patch.object(files, "archive_folder") as mock_archive:
|
||||||
folderzip.zip_folders(str(tmp_path))
|
files.zip_folders(str(tmp_path))
|
||||||
# Should archive folder1 and folder2, but not .git
|
# Should archive folder1 and folder2, but not .git
|
||||||
assert mock_archive.call_count == 2
|
assert mock_archive.call_count == 2
|
||||||
|
|
||||||
def test_zip_folders_nonexistent_cwd(self, tmp_path: Path) -> None:
|
def test_zip_folders_nonexistent_cwd(self, tmp_path: Path) -> None:
|
||||||
"""Should handle nonexistent cwd."""
|
"""Should handle nonexistent cwd."""
|
||||||
folderzip.zip_folders(str(tmp_path / "nonexistent"))
|
files.zip_folders(str(tmp_path / "nonexistent"))
|
||||||
# Should print error message and return
|
# Should print error message and return
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- #
|
# ---------------------------------------------------------------------- #
|
||||||
# TaskSpec definitions
|
# 函数注册
|
||||||
# ---------------------------------------------------------------------- #
|
# ---------------------------------------------------------------------- #
|
||||||
class TestTaskSpecDefinitions:
|
class TestRegisteredFunctions:
|
||||||
"""Test that all TaskSpec definitions are valid."""
|
"""Test that folderzip functions are registered."""
|
||||||
|
|
||||||
def test_folderzip_default_spec(self) -> None:
|
def test_folderzip_default_spec(self) -> None:
|
||||||
"""folderzip_default spec should be properly defined."""
|
"""folderzip_default should be a registered callable."""
|
||||||
assert folderzip.folderzip_default.name == "folderzip_default"
|
# folderzip_default 现在是通过 @px.register_fn 注册的普通函数, 不是 TaskSpec
|
||||||
assert folderzip.folderzip_default.fn is not None
|
assert callable(files.folderzip_default)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- #
|
|
||||||
# main function
|
|
||||||
# ---------------------------------------------------------------------- #
|
|
||||||
class TestMain:
|
|
||||||
"""Test main function."""
|
|
||||||
|
|
||||||
def test_main_calls_run_cli(self) -> None:
|
|
||||||
"""main() should create a CliRunner and call run_cli()."""
|
|
||||||
with patch.object(px.CliRunner, "run_cli") as mock_run_cli:
|
|
||||||
folderzip.main()
|
|
||||||
assert mock_run_cli.called
|
|
||||||
|
|||||||
+23
-66
@@ -8,7 +8,7 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import pyflowx as px
|
import pyflowx as px
|
||||||
from pyflowx.cli import gittool
|
from pyflowx.ops import dev
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- #
|
# ---------------------------------------------------------------------- #
|
||||||
@@ -20,7 +20,7 @@ class TestNotHasGitRepo:
|
|||||||
def test_not_has_git_repo_true(self, tmp_path: Path) -> None:
|
def test_not_has_git_repo_true(self, tmp_path: Path) -> None:
|
||||||
"""Should return True when no .git directory."""
|
"""Should return True when no .git directory."""
|
||||||
with patch.object(Path, "cwd", return_value=tmp_path):
|
with patch.object(Path, "cwd", return_value=tmp_path):
|
||||||
result = gittool.not_has_git_repo()
|
result = dev.not_has_git_repo()
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
def test_not_has_git_repo_false(self, tmp_path: Path) -> None:
|
def test_not_has_git_repo_false(self, tmp_path: Path) -> None:
|
||||||
@@ -29,7 +29,7 @@ class TestNotHasGitRepo:
|
|||||||
git_dir.mkdir()
|
git_dir.mkdir()
|
||||||
|
|
||||||
with patch.object(Path, "cwd", return_value=tmp_path):
|
with patch.object(Path, "cwd", return_value=tmp_path):
|
||||||
result = gittool.not_has_git_repo()
|
result = dev.not_has_git_repo()
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
def test_not_has_git_repo_cwd_not_exists(self, tmp_path: Path) -> None:
|
def test_not_has_git_repo_cwd_not_exists(self, tmp_path: Path) -> None:
|
||||||
@@ -37,7 +37,7 @@ class TestNotHasGitRepo:
|
|||||||
nonexistent = tmp_path / "nonexistent"
|
nonexistent = tmp_path / "nonexistent"
|
||||||
|
|
||||||
with patch.object(Path, "cwd", return_value=nonexistent):
|
with patch.object(Path, "cwd", return_value=nonexistent):
|
||||||
result = gittool.not_has_git_repo()
|
result = dev.not_has_git_repo()
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
@@ -47,19 +47,25 @@ class TestNotHasGitRepo:
|
|||||||
class TestHasFiles:
|
class TestHasFiles:
|
||||||
"""Test has_files function."""
|
"""Test has_files function."""
|
||||||
|
|
||||||
def test_has_files_true(self, tmp_path: Path) -> None:
|
def test_has_files_true(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""Should return True when files exist."""
|
"""Should return True when there are uncommitted changes."""
|
||||||
(tmp_path / "test.txt").write_text("test")
|
|
||||||
|
|
||||||
with patch.object(Path, "cwd", return_value=tmp_path):
|
class _FakeResult:
|
||||||
result = gittool.has_files()
|
stdout = " M test.txt\n"
|
||||||
assert result is True
|
|
||||||
|
|
||||||
def test_has_files_false(self, tmp_path: Path) -> None:
|
monkeypatch.setattr("subprocess.run", lambda *_, **__: _FakeResult())
|
||||||
"""Should return False when no files."""
|
result = dev.has_files()
|
||||||
with patch.object(Path, "cwd", return_value=tmp_path):
|
assert result is True
|
||||||
result = gittool.has_files()
|
|
||||||
assert result is False
|
def test_has_files_false(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Should return False when no uncommitted changes."""
|
||||||
|
|
||||||
|
class _FakeResult:
|
||||||
|
stdout = ""
|
||||||
|
|
||||||
|
monkeypatch.setattr("subprocess.run", lambda *_, **__: _FakeResult())
|
||||||
|
result = dev.has_files()
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- #
|
# ---------------------------------------------------------------------- #
|
||||||
@@ -76,62 +82,13 @@ class TestInitSubDirs:
|
|||||||
subdir2.mkdir()
|
subdir2.mkdir()
|
||||||
|
|
||||||
with patch.object(Path, "cwd", return_value=tmp_path), patch.object(px, "run") as mock_run:
|
with patch.object(Path, "cwd", return_value=tmp_path), patch.object(px, "run") as mock_run:
|
||||||
gittool.init_sub_dirs()
|
dev.init_sub_dirs()
|
||||||
# Should call px.run for each subdirectory
|
# Should call px.run for each subdirectory
|
||||||
assert mock_run.call_count == 2
|
assert mock_run.call_count == 2
|
||||||
|
|
||||||
def test_init_sub_dirs_no_subdirectories(self, tmp_path: Path) -> None:
|
def test_init_sub_dirs_no_subdirectories(self, tmp_path: Path) -> None:
|
||||||
"""Should handle no subdirectories."""
|
"""Should handle no subdirectories."""
|
||||||
with patch.object(Path, "cwd", return_value=tmp_path), patch.object(px, "run") as mock_run:
|
with patch.object(Path, "cwd", return_value=tmp_path), patch.object(px, "run") as mock_run:
|
||||||
gittool.init_sub_dirs()
|
dev.init_sub_dirs()
|
||||||
# Should not call px.run
|
# Should not call px.run
|
||||||
assert mock_run.call_count == 0
|
assert mock_run.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- #
|
|
||||||
# TaskSpec definitions
|
|
||||||
# ---------------------------------------------------------------------- #
|
|
||||||
class TestTaskSpecDefinitions:
|
|
||||||
"""Test that all TaskSpec definitions are valid."""
|
|
||||||
|
|
||||||
def test_push_spec(self) -> None:
|
|
||||||
"""push spec should be properly defined."""
|
|
||||||
assert gittool.push.name == "push"
|
|
||||||
assert gittool.push.cmd == ["git", "push"]
|
|
||||||
|
|
||||||
def test_pull_spec(self) -> None:
|
|
||||||
"""pull spec should be properly defined."""
|
|
||||||
assert gittool.pull.name == "pull"
|
|
||||||
assert gittool.pull.cmd == ["git", "pull"]
|
|
||||||
|
|
||||||
def test_kill_tgit_spec(self) -> None:
|
|
||||||
"""kill_tgit spec should be properly defined."""
|
|
||||||
assert gittool.kill_tgit.name == "task_kill"
|
|
||||||
assert isinstance(gittool.kill_tgit.cmd, list)
|
|
||||||
assert "taskkill" in gittool.kill_tgit.cmd
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- #
|
|
||||||
# main function
|
|
||||||
# ---------------------------------------------------------------------- #
|
|
||||||
class TestMain:
|
|
||||||
"""Test main function."""
|
|
||||||
|
|
||||||
def test_main_calls_run_cli(self) -> None:
|
|
||||||
"""main() should create a CliRunner and call run_cli()."""
|
|
||||||
with pytest.raises(SystemExit) as exc_info:
|
|
||||||
gittool.main()
|
|
||||||
# run_cli() calls sys.exit(), so we should get SystemExit
|
|
||||||
assert exc_info.value.code in (0, 1, 2)
|
|
||||||
|
|
||||||
def test_main_with_list_argument(self) -> None:
|
|
||||||
"""main() should handle --list argument."""
|
|
||||||
with patch("sys.argv", ["gittool", "--list"]), pytest.raises(SystemExit) as exc_info:
|
|
||||||
gittool.main()
|
|
||||||
assert exc_info.value.code == 0
|
|
||||||
|
|
||||||
def test_main_with_no_args_shows_help(self) -> None:
|
|
||||||
"""main() with no args should show help and exit."""
|
|
||||||
with patch("sys.argv", ["gittool"]), pytest.raises(SystemExit) as exc_info:
|
|
||||||
gittool.main()
|
|
||||||
assert exc_info.value.code == 1
|
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""Tests for ops.llm 模块 (msdownload/sglang)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyflowx.conditions import Constants
|
||||||
|
from pyflowx.ops.llm import install_sglang, msdownload_run, run_sglang
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
# msdownload_run
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
class TestMsdownloadRun:
|
||||||
|
"""``msdownload_run`` 函数测试."""
|
||||||
|
|
||||||
|
def test_empty_name_does_nothing(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""name 为空时应直接返回."""
|
||||||
|
called: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: called.append(cmd))
|
||||||
|
msdownload_run("")
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "name 不能为空" in captured.out
|
||||||
|
assert called == []
|
||||||
|
|
||||||
|
def test_default_download_dir(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""未提供 download_dir 时默认使用 ~/.models/<name 最后一段>."""
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||||
|
ran_cmds: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
|
||||||
|
|
||||||
|
msdownload_run("Qwen/Qwen2.5-Coder")
|
||||||
|
|
||||||
|
expected_dir = tmp_path / ".models" / "Qwen2.5-Coder"
|
||||||
|
assert expected_dir.exists()
|
||||||
|
assert ran_cmds[0] == [
|
||||||
|
"uvx",
|
||||||
|
"modelscope",
|
||||||
|
"download",
|
||||||
|
"--model",
|
||||||
|
"Qwen/Qwen2.5-Coder",
|
||||||
|
"--local_dir",
|
||||||
|
str(expected_dir),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_custom_download_dir(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""提供 download_dir 时应使用指定目录."""
|
||||||
|
custom_dir = tmp_path / "custom"
|
||||||
|
ran_cmds: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
|
||||||
|
|
||||||
|
msdownload_run("Qwen/Qwen2.5", "dataset", str(custom_dir))
|
||||||
|
|
||||||
|
assert custom_dir.exists()
|
||||||
|
assert ran_cmds[0][3] == "--dataset"
|
||||||
|
assert str(custom_dir) in ran_cmds[0]
|
||||||
|
|
||||||
|
def test_dataset_type(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""target_type=dataset 时应传递 --dataset."""
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||||
|
ran_cmds: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
|
||||||
|
|
||||||
|
msdownload_run("foo/bar", "dataset")
|
||||||
|
|
||||||
|
assert "--dataset" in ran_cmds[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
# install_sglang
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
class TestInstallSglang:
|
||||||
|
"""``install_sglang`` 函数测试."""
|
||||||
|
|
||||||
|
def test_already_installed_skips(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""sglang 已安装时应跳过."""
|
||||||
|
monkeypatch.setattr("shutil.which", lambda _cmd: "/usr/bin/sglang")
|
||||||
|
called: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: called.append(cmd))
|
||||||
|
|
||||||
|
install_sglang()
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "已安装" in captured.out
|
||||||
|
assert called == []
|
||||||
|
|
||||||
|
def test_not_installed_runs_uv_install(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""sglang 未安装时应执行 uv install sglang[all]."""
|
||||||
|
monkeypatch.setattr("shutil.which", lambda _cmd: None)
|
||||||
|
ran_cmds: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
|
||||||
|
|
||||||
|
install_sglang()
|
||||||
|
|
||||||
|
assert ran_cmds == [["uv", "install", "sglang[all]"]]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
# run_sglang
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
class TestRunSglang:
|
||||||
|
"""``run_sglang`` 函数测试."""
|
||||||
|
|
||||||
|
def test_model_dir_not_exist_skips(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""模型目录不存在时应跳过."""
|
||||||
|
monkeypatch.setattr(Path, "exists", lambda _self: False)
|
||||||
|
called: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: called.append(cmd))
|
||||||
|
|
||||||
|
run_sglang(model="/nonexistent/path")
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "模型目录不存在" in captured.out
|
||||||
|
assert called == []
|
||||||
|
|
||||||
|
def test_windows_uses_python(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""Windows 上应使用 python."""
|
||||||
|
monkeypatch.setattr(Constants, "IS_WINDOWS", True)
|
||||||
|
monkeypatch.setattr(Path, "expanduser", lambda _self: tmp_path)
|
||||||
|
monkeypatch.setattr(Path, "exists", lambda _self: True)
|
||||||
|
ran_cmds: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
|
||||||
|
|
||||||
|
run_sglang(model=str(tmp_path))
|
||||||
|
|
||||||
|
assert ran_cmds[0][0] == "python"
|
||||||
|
assert "-m" in ran_cmds[0]
|
||||||
|
assert "sglang.launch_server" in ran_cmds[0]
|
||||||
|
|
||||||
|
def test_non_windows_uses_python3(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""非 Windows 上应使用 python3."""
|
||||||
|
monkeypatch.setattr(Constants, "IS_WINDOWS", False)
|
||||||
|
monkeypatch.setattr(Path, "expanduser", lambda _self: tmp_path)
|
||||||
|
monkeypatch.setattr(Path, "exists", lambda _self: True)
|
||||||
|
ran_cmds: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
|
||||||
|
|
||||||
|
run_sglang(model=str(tmp_path), port=9000, ctx_len=4096, mem_fraction=0.5, host="127.0.0.1", log_level="debug")
|
||||||
|
|
||||||
|
cmd = ran_cmds[0]
|
||||||
|
assert cmd[0] == "python3"
|
||||||
|
assert "--port" in cmd
|
||||||
|
assert "9000" in cmd
|
||||||
|
assert "4096" in cmd
|
||||||
|
assert "0.5" in cmd
|
||||||
|
assert "127.0.0.1" in cmd
|
||||||
|
assert "debug" in cmd
|
||||||
|
|
||||||
|
def test_command_includes_qwen_parser(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""命令应包含 --tool-call-parser qwen."""
|
||||||
|
monkeypatch.setattr(Constants, "IS_WINDOWS", True)
|
||||||
|
monkeypatch.setattr(Path, "expanduser", lambda _self: tmp_path)
|
||||||
|
monkeypatch.setattr(Path, "exists", lambda _self: True)
|
||||||
|
ran_cmds: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **_: ran_cmds.append(cmd) or MagicMock())
|
||||||
|
|
||||||
|
run_sglang(model=str(tmp_path))
|
||||||
|
|
||||||
|
cmd = ran_cmds[0]
|
||||||
|
assert "--tool-call-parser" in cmd
|
||||||
|
qwen_idx = cmd.index("--tool-call-parser")
|
||||||
|
assert cmd[qwen_idx + 1] == "qwen"
|
||||||
+10
-57
@@ -5,9 +5,8 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pyflowx as px
|
|
||||||
from pyflowx.cli import lscalc
|
|
||||||
from pyflowx.conditions import Constants
|
from pyflowx.conditions import Constants
|
||||||
|
from pyflowx.ops import system
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- #
|
# ---------------------------------------------------------------------- #
|
||||||
@@ -19,7 +18,7 @@ class TestGetLsDynaCommand:
|
|||||||
def test_get_ls_dyna_command_windows(self) -> None:
|
def test_get_ls_dyna_command_windows(self) -> None:
|
||||||
"""Should get LS-DYNA command for Windows."""
|
"""Should get LS-DYNA command for Windows."""
|
||||||
with patch.object(Constants, "IS_WINDOWS", True), patch.object(Constants, "IS_MACOS", False):
|
with patch.object(Constants, "IS_WINDOWS", True), patch.object(Constants, "IS_MACOS", False):
|
||||||
cmd = lscalc.get_ls_dyna_command("input.k", 4)
|
cmd = system.get_ls_dyna_command("input.k", 4)
|
||||||
assert "ls-dyna_mpp" in cmd
|
assert "ls-dyna_mpp" in cmd
|
||||||
assert "i=input.k" in cmd
|
assert "i=input.k" in cmd
|
||||||
assert "ncpu=4" in cmd
|
assert "ncpu=4" in cmd
|
||||||
@@ -27,7 +26,7 @@ class TestGetLsDynaCommand:
|
|||||||
def test_get_ls_dyna_command_linux(self) -> None:
|
def test_get_ls_dyna_command_linux(self) -> None:
|
||||||
"""Should get LS-DYNA command for Linux."""
|
"""Should get LS-DYNA command for Linux."""
|
||||||
with patch.object(Constants, "IS_WINDOWS", False), patch.object(Constants, "IS_MACOS", False):
|
with patch.object(Constants, "IS_WINDOWS", False), patch.object(Constants, "IS_MACOS", False):
|
||||||
cmd = lscalc.get_ls_dyna_command("input.k", 8)
|
cmd = system.get_ls_dyna_command("input.k", 8)
|
||||||
assert "ls-dyna_mpp" in cmd
|
assert "ls-dyna_mpp" in cmd
|
||||||
assert "i=input.k" in cmd
|
assert "i=input.k" in cmd
|
||||||
assert "ncpu=8" in cmd
|
assert "ncpu=8" in cmd
|
||||||
@@ -46,14 +45,14 @@ class TestRunLsDyna:
|
|||||||
|
|
||||||
with patch("subprocess.run") as mock_run:
|
with patch("subprocess.run") as mock_run:
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
lscalc.run_ls_dyna(str(input_file), ncpu=4)
|
system.run_ls_dyna(str(input_file), ncpu=4)
|
||||||
assert mock_run.called
|
assert mock_run.called
|
||||||
|
|
||||||
def test_run_ls_dyna_file_not_found(self, tmp_path: Path) -> None:
|
def test_run_ls_dyna_file_not_found(self, tmp_path: Path) -> None:
|
||||||
"""Should handle nonexistent input file."""
|
"""Should handle nonexistent input file."""
|
||||||
input_file = tmp_path / "nonexistent.k"
|
input_file = tmp_path / "nonexistent.k"
|
||||||
|
|
||||||
lscalc.run_ls_dyna(str(input_file), ncpu=4)
|
system.run_ls_dyna(str(input_file), ncpu=4)
|
||||||
# Should print error message
|
# Should print error message
|
||||||
|
|
||||||
def test_run_ls_dyna_command_not_found(self, tmp_path: Path) -> None:
|
def test_run_ls_dyna_command_not_found(self, tmp_path: Path) -> None:
|
||||||
@@ -62,7 +61,7 @@ class TestRunLsDyna:
|
|||||||
input_file.write_text("LS-DYNA input")
|
input_file.write_text("LS-DYNA input")
|
||||||
|
|
||||||
with patch("subprocess.run", side_effect=FileNotFoundError):
|
with patch("subprocess.run", side_effect=FileNotFoundError):
|
||||||
lscalc.run_ls_dyna(str(input_file), ncpu=4)
|
system.run_ls_dyna(str(input_file), ncpu=4)
|
||||||
# Should print error message
|
# Should print error message
|
||||||
|
|
||||||
|
|
||||||
@@ -79,14 +78,14 @@ class TestRunLsDynaMpi:
|
|||||||
|
|
||||||
with patch("subprocess.run") as mock_run:
|
with patch("subprocess.run") as mock_run:
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
lscalc.run_ls_dyna_mpi(str(input_file), ncpu=8)
|
system.run_ls_dyna_mpi(str(input_file), ncpu=8)
|
||||||
assert mock_run.called
|
assert mock_run.called
|
||||||
|
|
||||||
def test_run_ls_dyna_mpi_file_not_found(self, tmp_path: Path) -> None:
|
def test_run_ls_dyna_mpi_file_not_found(self, tmp_path: Path) -> None:
|
||||||
"""Should handle nonexistent input file."""
|
"""Should handle nonexistent input file."""
|
||||||
input_file = tmp_path / "nonexistent.k"
|
input_file = tmp_path / "nonexistent.k"
|
||||||
|
|
||||||
lscalc.run_ls_dyna_mpi(str(input_file), ncpu=8)
|
system.run_ls_dyna_mpi(str(input_file), ncpu=8)
|
||||||
# Should print error message
|
# Should print error message
|
||||||
|
|
||||||
|
|
||||||
@@ -100,58 +99,12 @@ class TestCheckLsDynaStatus:
|
|||||||
"""Should check LS-DYNA status on Windows."""
|
"""Should check LS-DYNA status on Windows."""
|
||||||
with patch.object(Constants, "IS_WINDOWS", True), patch("subprocess.run") as mock_run:
|
with patch.object(Constants, "IS_WINDOWS", True), patch("subprocess.run") as mock_run:
|
||||||
mock_run.return_value = MagicMock(stdout="ls-dyna_mpp.exe", returncode=0)
|
mock_run.return_value = MagicMock(stdout="ls-dyna_mpp.exe", returncode=0)
|
||||||
lscalc.check_ls_dyna_status()
|
system.check_ls_dyna_status()
|
||||||
assert mock_run.called
|
assert mock_run.called
|
||||||
|
|
||||||
def test_check_ls_dyna_status_linux(self) -> None:
|
def test_check_ls_dyna_status_linux(self) -> None:
|
||||||
"""Should check LS-DYNA status on Linux."""
|
"""Should check LS-DYNA status on Linux."""
|
||||||
with patch.object(Constants, "IS_WINDOWS", False), patch("subprocess.run") as mock_run:
|
with patch.object(Constants, "IS_WINDOWS", False), patch("subprocess.run") as mock_run:
|
||||||
mock_run.return_value = MagicMock(stdout="1234", returncode=0)
|
mock_run.return_value = MagicMock(stdout="1234", returncode=0)
|
||||||
lscalc.check_ls_dyna_status()
|
system.check_ls_dyna_status()
|
||||||
assert mock_run.called
|
assert mock_run.called
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- #
|
|
||||||
# main function
|
|
||||||
# ---------------------------------------------------------------------- #
|
|
||||||
class TestMain:
|
|
||||||
"""Test main function."""
|
|
||||||
|
|
||||||
def test_main_run_command(self, tmp_path: Path) -> None:
|
|
||||||
"""main() should handle run command."""
|
|
||||||
input_file = tmp_path / "input.k"
|
|
||||||
input_file.write_text("LS-DYNA input")
|
|
||||||
|
|
||||||
with patch("sys.argv", ["lscalc", "run", str(input_file)]), patch.object(px, "run") as mock_run:
|
|
||||||
lscalc.main()
|
|
||||||
assert mock_run.called
|
|
||||||
|
|
||||||
def test_main_run_command_with_ncpu(self, tmp_path: Path) -> None:
|
|
||||||
"""main() should handle run command with ncpu."""
|
|
||||||
input_file = tmp_path / "input.k"
|
|
||||||
input_file.write_text("LS-DYNA input")
|
|
||||||
|
|
||||||
with patch("sys.argv", ["lscalc", "run", str(input_file), "--ncpu", "8"]), patch.object(px, "run") as mock_run:
|
|
||||||
lscalc.main()
|
|
||||||
assert mock_run.called
|
|
||||||
|
|
||||||
def test_main_mpi_command(self, tmp_path: Path) -> None:
|
|
||||||
"""main() should handle mpi command."""
|
|
||||||
input_file = tmp_path / "input.k"
|
|
||||||
input_file.write_text("LS-DYNA input")
|
|
||||||
|
|
||||||
with patch("sys.argv", ["lscalc", "mpi", str(input_file)]), patch.object(px, "run") as mock_run:
|
|
||||||
lscalc.main()
|
|
||||||
assert mock_run.called
|
|
||||||
|
|
||||||
def test_main_status_command(self) -> None:
|
|
||||||
"""main() should handle status command."""
|
|
||||||
with patch("sys.argv", ["lscalc", "status"]), patch.object(px, "run") as mock_run:
|
|
||||||
lscalc.main()
|
|
||||||
assert mock_run.called
|
|
||||||
|
|
||||||
def test_main_with_no_args_shows_help(self) -> None:
|
|
||||||
"""main() with no args should show help."""
|
|
||||||
with patch("sys.argv", ["lscalc"]):
|
|
||||||
lscalc.main()
|
|
||||||
# Should print help and return
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user