19 Commits

Author SHA1 Message Date
zhou 1880cd7a34 bump version to 0.2.5
Release / Build Artifacts (push) Has been skipped
Release / Publish to PyPI (push) Has been skipped
Release / Publish Release (push) Has been skipped
Release / Pre-release Check (push) Failing after 31s
2026-06-26 21:59:45 +08:00
zhou d43c9e4044 bump version to 0.2.4 2026-06-26 21:57:53 +08:00
zhou 22ac9fc4dd test: 完善多份测试用例的类型标注与校验逻辑
1. 为多个测试函数补充pytest.CaptureFixture[str]类型注解
2. 为graphlib类型声明文件补全方法参数类型
3. 为pdftool测试的mock函数添加Any类型标注
4. 新增数据库连接非空校验断言
5. 优化emlmanager测试的字典展开格式与修复decode测试bug
6. 为gittool测试添加命令类型列表校验
7. 为envrs测试添加pyrefly忽略注释
2026-06-26 21:57:44 +08:00
zhou 7ded8df05e refactor: 整理代码格式并修复部分类型和依赖问题
1. 调整task.py的TypeVar导入和默认值
2. 格式化多处列表和参数写法,统一括号风格
3. 为pdftool.py添加pyrefly忽略注释修复类型警告
4. 为emlmanager.py添加数据库连接断言和检查
5. 修正hfdownload.py的depends_on参数为元组格式
2026-06-26 21:52:44 +08:00
zhou fd282db28f refactor: 整理代码格式与项目结构,修复命令检查bug
1. 重构多处列表展开写法,统一代码格式风格
2. 修复executors.py中命令不存在时的类型判断bug
3. 删除废弃的envlinux.py并替换为envdev.py,更新CLI入口配置
4. 为storage.py的后端方法添加override装饰器
5. 移除空的cli/__init__.py冗余导入
6. 更新pyproject.toml依赖与配置项
7. 精简测试用例代码
2026-06-26 21:45:06 +08:00
zhou 6f64d9d6dc bump version to 0.2.3
Release / Build Artifacts (push) Has been skipped
Release / Publish to PyPI (push) Has been skipped
Release / Publish Release (push) Has been skipped
Release / Pre-release Check (push) Failing after 31s
2026-06-26 07:43:56 +08:00
zhou a2889fbb08 refactor(cli/envlinux): 替换一键脚本为分步执行模式
将原直接管道执行的安装命令拆分为下载和安装两步,提升可调试性和错误捕获能力
2026-06-26 01:56:23 +08:00
zhou 024b597e44 chore: 更新pyflowx依赖版本到0.2.2
仅修改了uv.lock中的pyflowx版本号,同步依赖版本
2026-06-26 01:51:07 +08:00
zhou 1eb7942aa9 bump version to 0.2.2
Release / Pre-release Check (push) Failing after 30s
Release / Build Artifacts (push) Has been skipped
Release / Publish to PyPI (push) Has been skipped
Release / Publish Release (push) Has been skipped
2026-06-26 01:50:49 +08:00
zhou 9285ae3782 test(packtool): 优化打包工具测试用例,统一使用临时工作目录
1. 新增自动切换临时工作目录的全局fixture,避免测试污染项目根目录
2. 移除测试中手动mock缓存目录的代码,复用全局fixture配置
3. 简化测试代码结构,提升测试可读性和维护性
2026-06-26 01:47:24 +08:00
zhou a88797f410 chore(pyflowx): bump pyflowx version to 0.2.0 and add bumpversion cli tests
- update pyflowx package version from 0.1.13 to 0.2.0
- add auto tmp path fixture for tests
- add test cases for bumpversion cli minor version bump and no valid files scenario
2026-06-26 01:42:03 +08:00
zhou b047b05aaf bump version to 0.2.1 2026-06-26 01:40:11 +08:00
zhou 78a274ce5b chore: 更新python版本到3.13和pyflowx到0.2.0,简化json响应代码
调整了emlmanager.py里的json响应代码格式,让代码更简洁
2026-06-26 01:22:26 +08:00
zhou ab8faec863 bump version to 0.2.0
Release / Pre-release Check (push) Failing after 35s
Release / Build Artifacts (push) Has been skipped
Release / Publish to PyPI (push) Has been skipped
Release / Publish Release (push) Has been skipped
2026-06-25 23:45:47 +08:00
zhou 936a009212 feat(bumpversion): 重构版本号更新工具,支持多文件类型并新增minor版本命令
1.  重构bumpversion模块,支持自动识别pyproject.toml和__init__.py文件的版本号格式
2.  提取版本计算、替换字符串构建逻辑,提升代码可维护性
3.  在pymake.py中新增bumpmi命令用于执行次版本号更新
4.  全面升级测试用例,适配新的版本匹配逻辑,修正测试文件类型
5.  保留原始引号和格式,不破坏文件原有排版
2026-06-25 23:44:39 +08:00
zhou f10f8d09a6 ~bumpversion 2026-06-25 23:36:05 +08:00
zhou 0d6a78f320 +bumpversion 2026-06-25 23:02:12 +08:00
zhou c9a4192c85 ~
Release / Pre-release Check (push) Failing after 31s
Release / Build Artifacts (push) Has been skipped
Release / Publish to PyPI (push) Has been skipped
Release / Publish Release (push) Has been skipped
2026-06-25 22:31:12 +08:00
zhou 0afdb54e5c ~
Release / Pre-release Check (push) Failing after 1m31s
Release / Build Artifacts (push) Has been skipped
Release / Publish to PyPI (push) Has been skipped
Release / Publish Release (push) Has been skipped
2026-06-25 12:49:26 +08:00
38 changed files with 2252 additions and 685 deletions
-3
View File
@@ -40,9 +40,6 @@ jobs:
- name: Ruff 检查
run: uv run ruff check src tests
- name: Ruff 格式检查
run: uv run ruff format --check src tests
# ─────────────────────────────────────────────────────────────
# typecheckpyrefly 严格类型检查
# ─────────────────────────────────────────────────────────────
-3
View File
@@ -8,9 +8,6 @@ repos:
# Run the linter
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
# Run the formatter
- id: ruff-format
args: [--config=pyproject.toml]
- repo: https://gitcode.com/gh_mirrors/pr/pre-commit-hooks.git
rev: v5.0.0
hooks:
+1 -1
View File
@@ -1 +1 @@
3.8
3.13
+29 -34
View File
@@ -10,38 +10,42 @@ classifiers = [
"Programming Language :: Python :: 3.9",
"Topic :: Software Development :: Libraries :: Application Frameworks",
]
dependencies = ["graphlib_backport >= 1.0.0; python_version < '3.9'"]
dependencies = [
"graphlib_backport >= 1.0.0; python_version < '3.9'",
"typing-extensions>=4.13.2",
]
description = "Lightweight, type-safe DAG task scheduler with multi-strategy execution."
keywords = ["async", "dag", "scheduler", "task", "workflow"]
license = { text = "MIT" }
name = "pyflowx"
readme = "README.md"
requires-python = ">=3.8"
version = "0.1.10"
version = "0.2.5"
[project.scripts]
autofmt = "pyflowx.cli.autofmt:main"
bumpver = "pyflowx.cli.bumpversion:main"
clr = "pyflowx.cli.clearscreen:main"
emlman = "pyflowx.cli.emlmanager:main"
envpy = "pyflowx.cli.envpy:main"
envqt = "pyflowx.cli.envqt:main"
envrs = "pyflowx.cli.envrs:main"
filedate = "pyflowx.cli.filedate:main"
filelvl = "pyflowx.cli.filelevel:main"
foldback = "pyflowx.cli.folderback:main"
foldzip = "pyflowx.cli.folderzip:main"
gitt = "pyflowx.cli.gittool:main"
hfdown = "pyflowx.cli.hfdownload:main"
lscalc = "pyflowx.cli.lscalc:main"
packtool = "pyflowx.cli.packtool:main"
pdftool = "pyflowx.cli.pdftool:main"
piptool = "pyflowx.cli.piptool:main"
pymake = "pyflowx.cli.pymake:main"
scrcap = "pyflowx.cli.screenshot:main"
sshcopy = "pyflowx.cli.sshcopyid:main"
taskk = "pyflowx.cli.taskkill:main"
wch = "pyflowx.cli.which:main"
autofmt = "pyflowx.cli.autofmt:main"
bumpversion = "pyflowx.cli.bumpversion:main"
cls = "pyflowx.cli.clearscreen:main"
emlman = "pyflowx.cli.emlmanager:main"
envdev = "pyflowx.cli.envdev:main"
envpy = "pyflowx.cli.envpy:main"
envqt = "pyflowx.cli.envqt:main"
envrs = "pyflowx.cli.envrs:main"
filedate = "pyflowx.cli.filedate:main"
filelvl = "pyflowx.cli.filelevel:main"
foldback = "pyflowx.cli.folderback:main"
foldzip = "pyflowx.cli.folderzip:main"
gitt = "pyflowx.cli.gittool:main"
hfdown = "pyflowx.cli.hfdownload:main"
lscalc = "pyflowx.cli.lscalc:main"
packtool = "pyflowx.cli.packtool:main"
pdftool = "pyflowx.cli.pdftool:main"
piptool = "pyflowx.cli.piptool:main"
pymake = "pyflowx.cli.pymake:main"
scrcap = "pyflowx.cli.screenshot:main"
sshcopy = "pyflowx.cli.sshcopyid:main"
taskk = "pyflowx.cli.taskkill:main"
wch = "pyflowx.cli.which:main"
[project.optional-dependencies]
dev = [
@@ -111,15 +115,6 @@ markers = ["slow: marks tests as slow (deselect with
line-length = 120
target-version = "py38"
[tool.ruff.format]
# 使用双引号
quote-style = "double"
# 缩进使用空格
indent-style = "space"
# 保留尾随逗号
skip-magic-trailing-comma = false
# 行长度由 [tool.ruff] 中的 line-length 控制
[tool.ruff.lint]
ignore = [
"E501", # line too long (handled by formatter)
@@ -154,6 +149,6 @@ select = [
"**/tests/**" = ["ARG001", "ARG002"]
[tool.pyrefly]
preset = "basic"
preset = "strict"
project-includes = ["**/*.ipynb", "**/*.py*"]
python-version = "3.8"
+1 -1
View File
@@ -84,7 +84,7 @@ from .runner import CliExitCode, CliRunner
from .storage import JSONBackend, MemoryBackend, StateBackend
from .task import TaskCmd, TaskEvent, TaskResult, TaskSpec, TaskStatus
__version__ = "0.1.10"
__version__ = "0.2.5"
__all__ = [
"IS_LINUX",
-78
View File
@@ -1,78 +0,0 @@
"""CLI 工具模块.
提供各种命令行工具的入口点.
"""
from __future__ import annotations
# 自动格式化工具
from pyflowx.cli.autofmt import main as autofmt_main
from pyflowx.cli.bumpversion import main as bumpversion_main
from pyflowx.cli.clearscreen import main as clearscreen_main
# EML 邮件管理工具
from pyflowx.cli.emlmanager import main as emlmanager_main
# EML 邮件管理工具
from pyflowx.cli.emlmanager import main as emlmanager_web_main
from pyflowx.cli.envpy import main as envpy_main
from pyflowx.cli.envqt import main as envqt_main
from pyflowx.cli.envrs import main as envrs_main
# 文件工具
from pyflowx.cli.filedate import main as filedate_main
from pyflowx.cli.filelevel import main as filelevel_main
from pyflowx.cli.folderback import main as folderback_main
from pyflowx.cli.folderzip import main as folderzip_main
# Git 工具
from pyflowx.cli.gittool import main as gittool_main
# 仿真工具
from pyflowx.cli.lscalc import main as lscalc_main
# 打包工具
from pyflowx.cli.packtool import main as packtool_main
# PDF 工具
from pyflowx.cli.pdftool import main as pdftool_main
# 开发工具
from pyflowx.cli.piptool import main as piptool_main
from pyflowx.cli.pymake import main as pymake_main
from pyflowx.cli.screenshot import main as screenshot_main
from pyflowx.cli.sshcopyid import main as sshcopyid_main
__all__ = [
# 自动格式化工具
"autofmt_main",
"bumpversion_main",
"clearscreen_main",
# EML 邮件管理工具
"emlmanager_main",
"emlmanager_web_main",
"envpy_main",
"envqt_main",
"envrs_main",
# 文件工具
"filedate_main",
"filelevel_main",
"folderback_main",
"folderzip_main",
# Git 工具
"gittool_main",
# 仿真工具
"lscalc_main",
# 打包工具
"packtool_main",
# PDF 工具
"pdftool_main",
# 开发工具
"piptool_main",
"pymake_main",
"screenshot_main",
"sshcopyid_main",
# 系统工具
"taskkill_main",
"which_main",
]
+6 -6
View File
@@ -268,13 +268,13 @@ def main() -> None:
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)]
)
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)]
)
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
+234 -72
View File
@@ -5,97 +5,259 @@
from __future__ import annotations
import subprocess
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 bump_version(part: str = "patch", tag: bool = False, commit: bool = False) -> None:
"""递增版本号.
def _get_pattern_for_file(file_name: str) -> re.Pattern[str] | None:
"""根据文件类型获取对应的正则表达式.
Parameters
----------
part : str
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
tag : bool
是否创建 Git 标签
commit : bool
是否提交更改
Returns
-------
str | None
更新后的新版本号,如果文件中未找到版本号则返回 None
"""
try:
subprocess.run(["bumpversion", part], check=True)
if commit:
subprocess.run(["git", "add", "."], check=True)
subprocess.run(["git", "commit", "-m", f"bump version {part}"], check=True)
if tag:
# 获取当前版本号
result = subprocess.run(
["git", "describe", "--tags", "--abbrev=0"],
check=True,
capture_output=True,
text=True,
)
version = result.stdout.strip() if result.returncode == 0 else f"v{part}"
subprocess.run(
["git", "tag", "-a", version, "-m", f"version {part}"],
check=True,
)
except FileNotFoundError:
print("未找到 bumpversion 工具,请先安装: pip install bumpversion")
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)
def bump_version_alpha(part: str = "patch") -> None:
"""递增版本号并添加 alpha 预发布标识."""
try:
subprocess.run(["bumpversion", part, "--new-version", f"{part}-alpha"], check=True)
except FileNotFoundError:
print("未找到 bumpversion 工具,请先安装: pip install bumpversion")
file_path.write_text(content, encoding="utf-8")
except Exception as e:
print(f"更新文件 {file_path} 版本号时出错: {e}")
raise
# ============================================================================
# TaskSpec 定义
# ============================================================================
bump_patch: px.TaskSpec = px.TaskSpec("bump_patch", fn=lambda: bump_version("patch"))
bump_minor: px.TaskSpec = px.TaskSpec("bump_minor", fn=lambda: bump_version("minor"))
bump_major: px.TaskSpec = px.TaskSpec("bump_major", fn=lambda: bump_version("major"))
bump_patch_tag: px.TaskSpec = px.TaskSpec("bump_patch_tag", fn=lambda: bump_version("patch", tag=True))
bump_minor_tag: px.TaskSpec = px.TaskSpec("bump_minor_tag", fn=lambda: bump_version("minor", tag=True))
bump_major_tag: px.TaskSpec = px.TaskSpec("bump_major_tag", fn=lambda: bump_version("major", tag=True))
bump_patch_alpha: px.TaskSpec = px.TaskSpec("bump_patch_alpha", fn=lambda: bump_version_alpha("patch"))
# ============================================================================
# CLI Runner
# ============================================================================
return new_version
def main() -> None:
"""版本号管理工具主函数."""
runner = px.CliRunner(
strategy="thread",
description="BumpVersion - 版本号自动管理工具",
graphs={
# 递增补丁号 (1.0.0 -> 1.0.1)
"p": px.Graph.from_specs([bump_patch]),
# 递增次版本号 (1.0.0 -> 1.1.0)
"m": px.Graph.from_specs([bump_minor]),
# 递增主版本号 (1.0.0 -> 2.0.0)
"M": px.Graph.from_specs([bump_major]),
# 递增补丁号并创建标签
"pt": px.Graph.from_specs([bump_patch_tag]),
# 递增次版本号并创建标签
"mt": px.Graph.from_specs([bump_minor_tag]),
# 递增主版本号并创建标签
"Mt": px.Graph.from_specs([bump_major_tag]),
# 递增补丁号并添加 alpha 预发布标识
"pa": px.Graph.from_specs([bump_patch_alpha]),
},
parser = argparse.ArgumentParser(description="BumpVersion - 版本号自动管理工具")
parser.add_argument(
"part",
type=str,
nargs="?",
default="patch",
choices=get_args(BumpVersionType),
help=f"版本部分: {get_args(BumpVersionType)}",
)
runner.run_cli()
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}")
+5 -13
View File
@@ -5,23 +5,15 @@
from __future__ import annotations
import subprocess
import pyflowx as px
from pyflowx.conditions import Constants
def clear_screen() -> None:
"""使用系统命令清屏."""
if Constants.IS_WINDOWS:
subprocess.run(["cmd", "/c", "cls"], check=False)
else:
subprocess.run(["clear"], check=False)
print("\033[2J\033[H", end="")
def main() -> None:
"""清屏工具主函数."""
graph = px.Graph.from_specs([px.TaskSpec("clearscreen", fn=clear_screen)])
graph = px.Graph.from_specs([
px.TaskSpec("cls_win", cmd=["cmd", "/c", "cls"], conditions=(lambda: Constants.IS_WINDOWS,)),
px.TaskSpec("cls_unix", cmd=["clear"], conditions=(lambda: not Constants.IS_WINDOWS,)),
px.TaskSpec("cls_ascii", fn=lambda: print("\033[2J\033[H", end="")),
])
px.run(graph, strategy="thread")
+29 -9
View File
@@ -88,6 +88,8 @@ class EmailDatabase:
def insert_email(self, email_data: dict[str, Any]) -> bool:
"""插入邮件数据."""
assert self.conn, "数据库连接未初始化"
try:
with self._lock:
cursor = self.conn.cursor()
@@ -123,6 +125,8 @@ class EmailDatabase:
self, keyword: str = "", field: str = "all", limit: int = 100, offset: int = 0
) -> list[dict[str, Any]]:
"""搜索邮件."""
assert self.conn, "数据库连接未初始化"
with self._lock:
cursor = self.conn.cursor()
@@ -154,6 +158,8 @@ class EmailDatabase:
def get_grouped_emails(self) -> dict[str, list[dict[str, Any]]]:
"""获取按主题分组的邮件."""
assert self.conn, "数据库连接未初始化"
with self._lock:
cursor = self.conn.cursor()
cursor.execute(f"SELECT * FROM {TABLE_NAME} ORDER BY subject, date_parsed DESC")
@@ -183,6 +189,8 @@ class EmailDatabase:
def get_email_count(self) -> int:
"""获取邮件总数."""
assert self.conn, "数据库连接未初始化"
with self._lock:
cursor = self.conn.cursor()
cursor.execute(f"SELECT COUNT(*) FROM {TABLE_NAME}")
@@ -190,6 +198,8 @@ class EmailDatabase:
def clear_all(self) -> None:
"""清空所有邮件数据."""
assert self.conn, "数据库连接未初始化"
with self._lock:
cursor = self.conn.cursor()
cursor.execute(f"DELETE FROM {TABLE_NAME}")
@@ -557,15 +567,13 @@ class EmlManagerHandler(BaseHTTPRequestHandler):
emails = self.db.search_emails(keyword, field, limit, offset)
total_count = self.db.get_email_count()
self._send_json_response(
{
"emails": emails,
"count": len(emails),
"total": total_count,
"limit": limit,
"offset": offset,
}
)
self._send_json_response({
"emails": emails,
"count": len(emails),
"total": total_count,
"limit": limit,
"offset": offset,
})
def _api_get_email(self, query_params: dict[str, list[str]]) -> None:
"""API: 获取单个邮件详情."""
@@ -578,6 +586,10 @@ class EmlManagerHandler(BaseHTTPRequestHandler):
self._send_json_response({"error": "缺少邮件ID"}, 400)
return
if not self.db.conn:
self._send_json_response({"error": "数据库连接未初始化"}, 500)
return
with self.db._lock:
cursor = self.db.conn.cursor()
cursor.execute(f"SELECT * FROM {TABLE_NAME} WHERE id = ?", (int(email_id),))
@@ -630,6 +642,10 @@ class EmlManagerHandler(BaseHTTPRequestHandler):
if not eml_files:
return
if not self.db.conn:
self._send_json_response({"error": "数据库连接未初始化"}, 500)
return
# 先批量查询所有已存在的文件
with self.db._lock:
cursor = self.db.conn.cursor()
@@ -1268,6 +1284,10 @@ def main() -> None:
if eml_files:
print(f"发现 {len(eml_files)} 个 EML 文件,开始导入...")
if not EmlManagerHandler.db.conn:
print("数据库连接未初始化,无法导入邮件")
return
# 先批量查询所有已存在的文件
with EmlManagerHandler.db._lock:
cursor = EmlManagerHandler.db.conn.cursor()
+59
View File
@@ -0,0 +1,59 @@
from typing import TypedDict
import pyflowx as px
class EnvConfig(TypedDict):
"""环境配置项."""
name: str
value: str
description: str
PIP_INDEX_URL_CONFIG: EnvConfig = {
"name": "PIP_INDEX_URL",
"value": "https://pypi.tuna.tsinghua.edu.cn/simple",
"description": "PIP索引URL",
}
# ============================================================================
# 配置
# ============================================================================
PIP_INDEX_URLS: dict[str, str] = {
"tsinghua": "https://pypi.tuna.tsinghua.edu.cn/simple",
"aliyun": "https://mirrors.aliyun.com/pypi/simple/",
}
PIP_TRUSTED_HOSTS: dict[str, str] = {
"tsinghua": "pypi.tuna.tsinghua.edu.cn",
"aliyun": "mirrors.aliyun.com",
}
UV_INDEX_URL: str = "https://mirrors.aliyun.com/pypi/simple/"
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/cloud/conda-forge/",
],
"aliyun": [
"https://mirrors.aliyun.com/anaconda/pkgs/main/",
"https://mirrors.aliyun.com/anaconda/pkgs/free/",
"https://mirrors.aliyun.com/anaconda/cloud/conda-forge/",
],
}
def main() -> None:
"""主函数."""
# 使用更安全的分步执行方式,便于调试和捕获错误
graph = px.Graph.from_specs([
px.TaskSpec("download", cmd="curl -sSL https://linuxmirrors.cn/main.sh -o /tmp/linuxmirrors.sh", verbose=True),
px.TaskSpec("install", cmd="sudo bash /tmp/linuxmirrors.sh", verbose=True, depends_on=("download",)),
])
px.run(graph, strategy="thread")
+7 -7
View File
@@ -39,7 +39,7 @@ RUSTUP_MIRRORS: dict[str, dict[str, str]] = {
UsableRustVersion = Literal["stable", "nightly", "beta"]
UsableMirror = Literal["aliyun", "ustc", "tsinghua"]
DEFAULT_RUST_VERSION: str = "stable"
DEFAULT_RUST_VERSION: UsableRustVersion = "stable"
DEFAULT_MIRROR: UsableMirror = "tsinghua"
@@ -136,13 +136,13 @@ def main() -> None:
args = parser.parse_args()
if args.command == "mirror":
graph = px.Graph.from_specs(
[px.TaskSpec("set_rust_mirror", fn=set_rust_mirror, args=(args.name,), verbose=True)]
)
graph = px.Graph.from_specs([
px.TaskSpec("set_rust_mirror", fn=set_rust_mirror, args=(args.name,), verbose=True)
])
elif args.command == "install":
graph = px.Graph.from_specs(
[px.TaskSpec("install_rust", cmd=["rustup", "toolchain", "install", args.version], verbose=True)]
)
graph = px.Graph.from_specs([
px.TaskSpec("install_rust", cmd=["rustup", "toolchain", "install", args.version], verbose=True)
])
else:
parser.print_help()
return
+16 -20
View File
@@ -113,27 +113,23 @@ def main() -> None:
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},
)
]
)
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},
)
]
)
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
+26 -35
View File
@@ -23,6 +23,7 @@ EXCLUDE_DIRS = [
".tox",
".pytest_cache",
"node_modules",
".ruff_cache",
]
EXCLUDE_CMDS = [arg for d in EXCLUDE_DIRS for arg in ["-e", d]]
@@ -32,20 +33,16 @@ def init_sub_dirs() -> None:
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=[not_has_git_repo],
cwd=str(subdir),
),
px.TaskSpec("add", cmd=["git", "add", "."], depends_on=["init"], cwd=str(subdir)),
px.TaskSpec(
"commit", cmd=["git", "commit", "-m", "init commit"], depends_on=["add"], cwd=str(subdir)
),
]
),
px.Graph.from_specs([
px.TaskSpec(
"init",
cmd=["git", "init"],
conditions=(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",)),
]),
)
@@ -72,29 +69,23 @@ def main() -> None:
description="Gittool - Git 执行工具.",
graphs={
# 添加并提交
"a": px.Graph.from_specs(
[
px.TaskSpec("add", cmd=["git", "add", "."], conditions=[has_files]),
px.TaskSpec("commit", cmd=["git", "commit", "-m", "chore: update"], depends_on=["add"]),
]
),
"a": px.Graph.from_specs([
px.TaskSpec("add", cmd=["git", "add", "."], conditions=(has_files,)),
px.TaskSpec("commit", cmd=["git", "commit", "-m", "chore: update"], depends_on=("add",)),
]),
# 清理
"c": px.Graph.from_specs(
[
px.TaskSpec("clean", cmd=["git", "clean", "-xfd", *EXCLUDE_CMDS]),
px.TaskSpec("status", cmd=["git", "status", "--porcelain"], depends_on=["clean"]),
]
),
"c": px.Graph.from_specs([
px.TaskSpec("clean", cmd=["git", "clean", "-xfd", *EXCLUDE_CMDS]),
px.TaskSpec("status", cmd=["git", "status", "--porcelain"], depends_on=("clean",)),
]),
# 初始化、添加并提交
"i": px.Graph.from_specs(
[
px.TaskSpec("init", cmd=["git", "init"], conditions=[not_has_git_repo]),
px.TaskSpec("add", cmd=["git", "add", "."], depends_on=["init"], conditions=[has_files]),
px.TaskSpec(
"commit", cmd=["git", "commit", "-m", "init commit"], depends_on=["add"], conditions=[has_files]
),
]
),
"i": px.Graph.from_specs([
px.TaskSpec("init", cmd=["git", "init"], conditions=(not_has_git_repo,)),
px.TaskSpec("add", cmd=["git", "add", "."], depends_on=("init",), conditions=(has_files,)),
px.TaskSpec(
"commit", cmd=["git", "commit", "-m", "init commit"], depends_on=("add",), conditions=(has_files,)
),
]),
# 初始化子目录
"isub": px.Graph.from_specs([isub]),
# 推送
+40 -44
View File
@@ -37,50 +37,46 @@ def main():
download_dir.mkdir(parents=True, exist_ok=True)
if args.use_hfd:
graph = px.Graph.from_specs(
[
px.TaskSpec(name="setenvs", fn=setenvs, verbose=True),
px.TaskSpec(
name="download_hfd",
cmd=["wget", "https://hf-mirror.com/hfd/hfd.sh"],
depends_on=["setenvs"],
verbose=True,
),
px.TaskSpec(
name="chmod_hfd",
cmd=["chmod", "a+x", "hfd.sh"],
depends_on=["download_hfd"],
verbose=True,
),
px.TaskSpec(
name="run_hfd",
cmd=["./hfd.sh", dataset_name, args.type],
depends_on=["chmod_hfd"],
verbose=True,
),
]
)
graph = px.Graph.from_specs([
px.TaskSpec(name="setenvs", fn=setenvs, verbose=True),
px.TaskSpec(
name="download_hfd",
cmd=["wget", "https://hf-mirror.com/hfd/hfd.sh"],
depends_on=("setenvs",),
verbose=True,
),
px.TaskSpec(
name="chmod_hfd",
cmd=["chmod", "a+x", "hfd.sh"],
depends_on=("download_hfd",),
verbose=True,
),
px.TaskSpec(
name="run_hfd",
cmd=["./hfd.sh", dataset_name, args.type],
depends_on=("chmod_hfd",),
verbose=True,
),
])
else:
graph = px.Graph.from_specs(
[
px.TaskSpec(name="setenvs", fn=setenvs, verbose=True),
px.TaskSpec(
name="download",
cmd=[
"uvx",
"hf",
"download",
"--repo-type",
args.type,
"--force-download",
dataset_name,
"--local-dir",
str(Path.cwd() / dataset_name),
],
depends_on=["setenvs"],
verbose=True,
),
]
)
graph = px.Graph.from_specs([
px.TaskSpec(name="setenvs", fn=setenvs, verbose=True),
px.TaskSpec(
name="download",
cmd=[
"uvx",
"hf",
"download",
"--repo-type",
args.type,
"--force-download",
dataset_name,
"--local-dir",
str(Path.cwd() / dataset_name),
],
depends_on=("setenvs",),
verbose=True,
),
])
px.run(graph, strategy="thread", verbose=True)
+67 -68
View File
@@ -146,7 +146,7 @@ def pdf_extract_text(input_path: Path, output_path: Path) -> None:
doc = fitz.open(str(input_path))
text = ""
for page in doc:
text += page.get_text() + "\n\n"
text += str(page.get_text()) + "\n\n"
doc.close()
output_path.parent.mkdir(parents=True, exist_ok=True)
@@ -164,6 +164,7 @@ def pdf_extract_images(input_path: Path, output_dir: Path) -> None:
output_dir.mkdir(parents=True, exist_ok=True)
image_count = 0
# pyrefly: ignore [bad-argument-type]
for page_num, page in enumerate(doc):
images = page.get_images(full=True)
for img_idx, img in enumerate(images):
@@ -249,9 +250,13 @@ def pdf_info(input_path: Path) -> None:
doc = fitz.open(str(input_path))
print(f"文件: {input_path}")
print(f"页数: {doc.page_count}")
# pyrefly: ignore [missing-attribute]
print(f"标题: {doc.metadata.get('title', 'N/A')}")
# pyrefly: ignore [missing-attribute]
print(f"作者: {doc.metadata.get('author', 'N/A')}")
# pyrefly: ignore [missing-attribute]
print(f"创建日期: {doc.metadata.get('creationDate', 'N/A')}")
# pyrefly: ignore [missing-attribute]
print(f"修改日期: {doc.metadata.get('modDate', 'N/A')}")
print(f"文件大小: {input_path.stat().st_size / 1024:.1f} KB")
doc.close()
@@ -281,6 +286,7 @@ def pdf_ocr(input_path: Path, output_path: Path, lang: str = "chi_sim+eng") -> N
new_page = new_doc.new_page(width=page.rect.width, height=page.rect.height)
new_page.insert_image(new_page.rect, pixmap=pix)
text_rect = fitz.Rect(0, 0, page.rect.width, page.rect.height)
# pyrefly: ignore [bad-argument-type]
new_page.insert_textbox(text_rect, ocr_text)
output_path.parent.mkdir(parents=True, exist_ok=True)
@@ -319,6 +325,7 @@ def pdf_to_images(input_path: Path, output_dir: Path, dpi: int = 300) -> None:
doc = fitz.open(str(input_path))
output_dir.mkdir(parents=True, exist_ok=True)
# pyrefly: ignore [bad-argument-type]
for page_num, page in enumerate(doc):
pix = page.get_pixmap(dpi=dpi)
image_path = output_dir / f"{input_path.stem}_page_{page_num + 1}.png"
@@ -436,87 +443,79 @@ def main() -> None: # noqa: PLR0912
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)))]
)
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)))]
)
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)))]
)
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))]
)
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))]
)
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)))]
)
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)))]
)
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},
)
]
)
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},
)
]
)
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)},
)
]
)
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})]
)
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},
)
]
)
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)))]
)
graph = px.Graph.from_specs([
px.TaskSpec("pdf_repair", fn=pdf_repair, args=(Path(args.input), Path(args.output)))
])
else:
parser.print_help()
return
+10 -14
View File
@@ -20,15 +20,7 @@ def maturin_build_cmd() -> list[str]:
"""
command = ["maturin", "build", "-r"].copy()
if Constants.IS_WINDOWS:
command.extend(
[
"--target",
"x86_64-win7-windows-msvc",
"-Zbuild-std",
"-i",
"python3.8",
]
)
command.extend(["--target", "x86_64-win7-windows-msvc", "-Zbuild-std", "-i", "python3.8"])
return command
@@ -47,9 +39,9 @@ test_coverage: px.TaskSpec = px.TaskSpec(
cmd=["pytest", "--cov", "-n", "8", "--dist", "loadfile", "--tb=short", "-v", "--color=yes", "--durations=10"],
)
ruff_lint: px.TaskSpec = px.TaskSpec("lint", cmd=["ruff", "check", "--fix", "--unsafe-fixes"])
ruff_format: px.TaskSpec = px.TaskSpec("format", cmd=["ruff", "format", "."], depends_on=("lint",))
typecheck: px.TaskSpec = px.TaskSpec("pyrefly_check", cmd=["pyrefly", "check", "."])
bump: px.TaskSpec = px.TaskSpec("bumpversion", cmd=["bumpversion", "-t"])
git_add_all: px.TaskSpec = px.TaskSpec("git_add_all", cmd=["git", "add", "-A"])
bump: px.TaskSpec = px.TaskSpec("bumpversion", cmd=["bumpversion"])
doc: px.TaskSpec = px.TaskSpec("doc", cmd=["sphinx-build", "-b", "html", "docs", "docs/_build"])
git_push: px.TaskSpec = px.TaskSpec("git_push", cmd=["git", "push"])
git_push_tags: px.TaskSpec = px.TaskSpec("git_push_tags", cmd=["git", "push", "--tags"])
@@ -86,7 +78,10 @@ def main():
📦 发布命令:
pymake pb - 发布到 PyPI (twine + hatch)
💡 常用工作流:
版本管理:
pymake bump - 自动升级版本号并提交修改 (清理 + 检查 + 格式化 + git add + bumpversion)
💡 常用工作流:
1. 日常开发: pymake lint && pymake t
2. 构建发布包: pymake ba
3. 多版本兼容性测试: pymake tox
@@ -113,10 +108,11 @@ def main():
# 清理命令
"c": px.Graph.from_specs([git_clean]),
# 开发工具
"bump": px.Graph.from_specs(["c", "tc", bump]),
"bump": px.Graph.from_specs(["c", "tc", git_add_all, bump]),
"bumpmi": px.Graph.from_specs([px.TaskSpec("bumpversion_minor", cmd=["bumpversion", "minor"])]),
"cov": px.Graph.from_specs([git_clean, test_coverage]),
"doc": px.Graph.from_specs([doc]),
"lint": px.Graph.from_specs([ruff_lint, ruff_format]),
"lint": px.Graph.from_specs([ruff_lint]),
"pb": px.Graph.from_specs([twine_publish, hatch_publish]),
"t": px.Graph.from_specs([test]),
"tf": px.Graph.from_specs([test_fast]),
+6 -8
View File
@@ -31,14 +31,12 @@ def aggregate(ctx: px.Context) -> dict[str, Any]:
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")),
]
)
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)
+19 -19
View File
@@ -10,19 +10,21 @@ Demonstrates the core PyFlowX workflow:
from __future__ import annotations
from typing import Any
import pyflowx as px
# --- task functions: pure, testable, no framework coupling ------------- #
def extract_customers() -> list[dict]:
def extract_customers() -> list[dict[str, Any]]:
return [
{"id": "C001", "name": "Alice"},
{"id": "C002", "name": "Bob"},
]
def extract_orders() -> list[dict]:
def extract_orders() -> list[dict[str, Any]]:
return [
{"id": "O001", "customer_id": "C001", "amount": 150.0},
{"id": "O002", "customer_id": "C002", "amount": 200.5},
@@ -31,32 +33,30 @@ def extract_orders() -> list[dict]:
# Parameter names match dependency names → automatic injection.
def transform(
extract_customers: list[dict],
extract_orders: list[dict],
) -> list[dict]:
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]) -> int:
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",), retries=1, tags=("load",)),
]
)
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",), retries=1, tags=("load",)),
])
print("=== Execution plan ===")
print(graph.describe())
+5 -7
View File
@@ -29,13 +29,11 @@ def merge(fetch_a: str, fetch_b: str) -> str:
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")),
]
)
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"))
+3 -1
View File
@@ -132,7 +132,9 @@ def _check_conditions_for_skip(
if failed_conditions:
return f"条件不满足: {', '.join(failed_conditions)}"
elif spec.skip_if_missing and not spec._is_cmd_available():
return f"命令不存在: {spec.cmd[0] if spec.cmd else 'unknown'}"
# _is_cmd_available() 仅对 list[str] 类型返回 False
cmd_name = spec.cmd[0] if isinstance(spec.cmd, list) and spec.cmd else "unknown"
return f"命令不存在: {cmd_name}"
else:
return "条件不满足"
+22 -14
View File
@@ -163,15 +163,13 @@ class CliRunner:
if not pending_refs:
return graph
# 收集所有TaskSpec包括原始图中的
# 收集所有TaskSpec按正确顺序:先引用,后原始TaskSpec
all_specs: list[TaskSpec[Any]] = []
for spec in graph.all_specs().values():
all_specs.append(spec)
# 记录每个引用展开后的所有任务名,用于建立依赖链
previous_ref_last_task: str | None = None
# 解析每个引用,并建立依赖关系
# 解析每个引用,并建立依赖关系
for ref in pending_refs:
expanded_specs = self._parse_ref(ref, current_cmd)
@@ -190,17 +188,27 @@ class CliRunner:
all_specs.extend(expanded_specs)
# 如果原始图中TaskSpec,让它们依赖于最后一个引用的任务
# 然后添加原始图中TaskSpec让它们按顺序执行
original_specs = list(graph.all_specs().values())
if previous_ref_last_task and original_specs:
# 为每个原始TaskSpec添加依赖
for i, original_task in enumerate(original_specs):
# 只为第一个原始任务添加依赖,或者为没有依赖的任务添加依赖
if i == 0 or not original_task.depends_on:
updated_task = replace(
original_task, depends_on=tuple({*original_task.depends_on, previous_ref_last_task})
)
all_specs[all_specs.index(original_task)] = updated_task
if original_specs:
# 第一个原始TaskSpec依赖于最后一个引用的任务
if previous_ref_last_task:
first_original = original_specs[0]
updated_first = replace(
first_original, depends_on=tuple({*first_original.depends_on, previous_ref_last_task})
)
all_specs.append(updated_first)
else:
# 如果没有引用,直接添加第一个原始TaskSpec
all_specs.append(original_specs[0])
# 后续的原始TaskSpec依赖于前一个原始TaskSpec
for i in range(1, len(original_specs)):
current_task = original_specs[i]
previous_task_name = original_specs[i - 1].name
# 更新依赖,确保顺序执行
updated_task = replace(current_task, depends_on=tuple({*current_task.depends_on, previous_task_name}))
all_specs.append(updated_task)
# 创建新的图(不包含引用)
return Graph.from_specs(all_specs)
+12
View File
@@ -21,6 +21,8 @@ from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, Mapping
from typing_extensions import override
from .errors import StorageError
@@ -54,18 +56,23 @@ class MemoryBackend(StateBackend):
def __init__(self) -> None:
self._store: dict[str, Any] = {}
@override
def load(self) -> Mapping[str, Any]:
return dict(self._store)
@override
def save(self, name: str, value: Any) -> None:
self._store[name] = value
@override
def has(self, name: str) -> bool:
return name in self._store
@override
def get(self, name: str) -> Any:
return self._store[name]
@override
def clear(self) -> None:
self._store.clear()
@@ -104,9 +111,11 @@ class JSONBackend(StateBackend):
except (OSError, TypeError) as exc:
raise StorageError(f"cannot write state file {self._path!r}", exc) from exc
@override
def load(self) -> Mapping[str, Any]:
return dict(self._store)
@override
def save(self, name: str, value: Any) -> None:
# 在修改内存状态前先校验可序列化性。
try:
@@ -116,12 +125,15 @@ class JSONBackend(StateBackend):
self._store[name] = value
self._flush()
@override
def has(self, name: str) -> bool:
return name in self._store
@override
def get(self, name: str) -> Any:
return self._store[name]
@override
def clear(self) -> None:
self._store.clear()
self._flush()
+6 -6
View File
@@ -28,12 +28,13 @@ from typing import (
Mapping,
Optional,
Tuple,
TypeVar,
Union,
cast,
)
T = TypeVar("T")
from typing_extensions import TypeVar
T = TypeVar("T", default=Any)
# 任务可调用对象可以是同步或异步的。显式保留联合类型,让 mypy 理解两种形态。
TaskFn = Union[
@@ -174,19 +175,18 @@ class TaskSpec(Generic[T]):
verbose = self.verbose
if isinstance(cmd, list):
cmd_list = cast(List[str], cmd)
def _run_list() -> T:
import subprocess
cmd_str = " ".join(str(arg) for arg in cmd_list)
cmd_str = " ".join(arg for arg in cmd)
if verbose:
print(f"[verbose] 执行命令: {cmd_str}", flush=True)
if cwd is not None:
print(f"[verbose] 工作目录: {cwd}", flush=True)
try:
result = subprocess.run(
cmd_list,
cmd,
cwd=cwd,
timeout=timeout,
capture_output=not verbose,
@@ -288,7 +288,7 @@ class TaskSpec(Generic[T]):
cmd = self.cmd
if isinstance(cmd, list) and cmd:
first_arg = cast(str, cmd[0])
first_arg = cmd[0]
return shutil.which(first_arg) is not None
return True
+289 -77
View File
@@ -2,7 +2,8 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
from pathlib import Path
from unittest.mock import patch
import pytest
@@ -10,97 +11,308 @@ import pyflowx as px
from pyflowx.cli import bumpversion
# ---------------------------------------------------------------------- #
# bump_version
# ---------------------------------------------------------------------- #
class TestBumpVersion:
"""Test bump_version function."""
def test_bump_version_patch(self) -> None:
"""Should bump patch version."""
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
bumpversion.bump_version("patch")
assert mock_run.called
def test_bump_version_minor(self) -> None:
"""Should bump minor version."""
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
bumpversion.bump_version("minor")
assert mock_run.called
def test_bump_version_major(self) -> None:
"""Should bump major version."""
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
bumpversion.bump_version("major")
assert mock_run.called
def test_bump_version_with_tag(self) -> None:
"""Should bump version with tag."""
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="v1.0.0")
bumpversion.bump_version("patch", tag=True)
assert mock_run.called
def test_bump_version_with_commit(self) -> None:
"""Should bump version with commit."""
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
bumpversion.bump_version("patch", commit=True)
assert mock_run.called
def test_bump_version_file_not_found(self) -> None:
"""Should handle FileNotFoundError."""
with patch("subprocess.run", side_effect=FileNotFoundError), pytest.raises(FileNotFoundError):
bumpversion.bump_version("patch")
@pytest.fixture(autouse=True)
def auto_use_tmp_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""自动使用临时路径."""
monkeypatch.chdir(tmp_path)
# ---------------------------------------------------------------------- #
# bump_version_alpha
# bump_file_version
# ---------------------------------------------------------------------- #
class TestBumpVersionAlpha:
"""Test bump_version_alpha function."""
class TestBumpFileVersion:
"""Test bump_file_version function."""
def test_bump_version_alpha_patch(self) -> None:
"""Should bump alpha patch version."""
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
bumpversion.bump_version_alpha("patch")
assert mock_run.called
def test_bump_patch_version(self, tmp_path: Path) -> None:
"""Should bump patch version correctly."""
test_file = tmp_path / "pyproject.toml"
test_file.write_text('version = "1.2.3"', encoding="utf-8")
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_bump_minor_version(self, tmp_path: Path) -> None:
"""Should bump minor version correctly."""
test_file = tmp_path / "pyproject.toml"
test_file.write_text('version = "1.2.3"', encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "minor")
assert result == "1.3.0"
assert test_file.read_text(encoding="utf-8") == 'version = "1.3.0"'
def test_bump_major_version(self, tmp_path: Path) -> None:
"""Should bump major version correctly."""
test_file = tmp_path / "pyproject.toml"
test_file.write_text('version = "1.2.3"', encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "major")
assert result == "2.0.0"
assert test_file.read_text(encoding="utf-8") == 'version = "2.0.0"'
def test_version_pattern_with_prerelease(self, tmp_path: Path) -> None:
"""Should handle version with prerelease suffix."""
test_file = tmp_path / "pyproject.toml"
test_file.write_text('version = "1.2.3-alpha.1"', encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "patch")
assert result == "1.2.4"
# 预发布版本应该被清除
content = test_file.read_text(encoding="utf-8")
assert "alpha" not in content
def test_version_pattern_with_build_metadata(self, tmp_path: Path) -> None:
"""Should handle version with build metadata."""
test_file = tmp_path / "pyproject.toml"
test_file.write_text('version = "1.2.3+build.123"', encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "patch")
assert result == "1.2.4"
# 构建元数据应该被清除
content = test_file.read_text(encoding="utf-8")
assert "build" not in content
def test_no_version_found(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""Should return None when no version pattern found."""
test_file = tmp_path / "test.txt"
test_file.write_text("no version here", encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "patch")
assert result is None
captured = capsys.readouterr()
assert "未找到版本号模式" in captured.out
def test_utf8_encoding(self, tmp_path: Path) -> None:
"""Should handle UTF-8 encoded files correctly."""
test_file = tmp_path / "__init__.py"
test_file.write_text('__version__ = "1.2.3"', encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "patch")
assert result == "1.2.4"
assert test_file.read_text(encoding="utf-8") == '__version__ = "1.2.4"'
def test_pyproject_toml_format(self, tmp_path: Path) -> None:
"""Should handle pyproject.toml format correctly."""
test_file = tmp_path / "pyproject.toml"
content = """
[project]
name = "test"
version = "0.1.0"
description = "Test project"
"""
test_file.write_text(content, encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "minor")
assert result == "0.2.0"
updated = test_file.read_text(encoding="utf-8")
assert 'version = "0.2.0"' in updated
assert 'name = "test"' in updated
def test_init_py_format(self, tmp_path: Path) -> None:
"""Should handle __init__.py format correctly."""
test_file = tmp_path / "__init__.py"
content = '''"""Package info."""
__version__ = "1.0.0"
'''
test_file.write_text(content, encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "major")
assert result == "2.0.0"
updated = test_file.read_text(encoding="utf-8")
assert '__version__ = "2.0.0"' in updated
def test_multiple_versions_in_file(self, tmp_path: Path) -> None:
"""Should only bump the project version, not dependencies."""
test_file = tmp_path / "pyproject.toml"
content = """
[project]
version = "1.0.0"
dependencies = ["lib >= 2.0.0", "other >= 3.0.0"]
"""
test_file.write_text(content, encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "patch")
assert result == "1.0.1"
updated = test_file.read_text(encoding="utf-8")
assert 'version = "1.0.1"' in updated
# 确保 dependencies 中的版本没有被更新
assert "lib >= 2.0.0" in updated
assert "other >= 3.0.0" in updated
def test_file_read_error(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""Should handle file read errors."""
# 创建一个目录而不是文件
test_file = tmp_path / "test_dir"
test_file.mkdir()
with pytest.raises(Exception): # noqa: B017
bumpversion.bump_file_version(test_file, "patch")
def test_file_write_error(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""Should handle file write errors."""
# 在只读目录中创建文件(这个测试在某些系统上可能不适用)
test_file = tmp_path / "readonly.toml"
test_file.write_text('version = "1.0.0"', encoding="utf-8")
# 设置为只读
test_file.chmod(0o444)
try:
with pytest.raises(Exception): # noqa: B017
bumpversion.bump_file_version(test_file, "patch")
finally:
# 恢复权限以便清理
test_file.chmod(0o644)
# ---------------------------------------------------------------------- #
# TaskSpec definitions
# Version pattern tests
# ---------------------------------------------------------------------- #
class TestTaskSpecDefinitions:
"""Test that all TaskSpec definitions are valid."""
class TestVersionPattern:
"""Test version pattern matching."""
def test_bump_patch_spec(self) -> None:
"""bump_patch spec should be properly defined."""
assert bumpversion.bump_patch.name == "bump_patch"
assert bumpversion.bump_patch.fn is not None
def test_simple_version(self, tmp_path: Path) -> None:
"""Should match simple version."""
test_file = tmp_path / "__init__.py"
test_file.write_text('__version__ = "1.0.0"', encoding="utf-8")
def test_bump_minor_spec(self) -> None:
"""bump_minor spec should be properly defined."""
assert bumpversion.bump_minor.name == "bump_minor"
assert bumpversion.bump_minor.fn is not None
result = bumpversion.bump_file_version(test_file, "patch")
def test_bump_major_spec(self) -> None:
"""bump_major spec should be properly defined."""
assert bumpversion.bump_major.name == "bump_major"
assert bumpversion.bump_major.fn is not None
assert result == "1.0.1"
def test_version_with_zeros(self, tmp_path: Path) -> None:
"""Should handle versions with zeros correctly."""
test_file = tmp_path / "__init__.py"
test_file.write_text('__version__ = "0.0.0"', encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "patch")
assert result == "0.0.1"
def test_large_version_numbers(self, tmp_path: Path) -> None:
"""Should handle large version numbers."""
test_file = tmp_path / "__init__.py"
test_file.write_text('__version__ = "10.20.30"', encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "minor")
assert result == "10.21.0"
def test_version_in_url(self, tmp_path: Path) -> None:
"""Should not match version in URL or other contexts."""
test_file = tmp_path / "test.txt"
test_file.write_text("https://example.com/v1.2.3/download", encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "patch")
# 不应该匹配 URL 中的版本号
assert result is None
# ---------------------------------------------------------------------- #
# main function
# Edge cases
# ---------------------------------------------------------------------- #
class TestMain:
"""Test main function."""
class TestEdgeCases:
"""Test edge cases and error handling."""
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:
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 mock_run_cli.called
# 验证版本号已更新
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
+1 -24
View File
@@ -2,33 +2,10 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pyflowx as px
from pyflowx.cli import clearscreen
from pyflowx.conditions import Constants
# ---------------------------------------------------------------------- #
# clear_screen
# ---------------------------------------------------------------------- #
class TestClearScreen:
"""Test clear_screen function."""
def test_clear_screen_windows(self) -> None:
"""Should clear screen on Windows."""
if Constants.IS_WINDOWS:
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
clearscreen.clear_screen()
assert mock_run.called
def test_clear_screen_linux(self) -> None:
"""Should clear screen on Linux."""
with patch.object(Constants, "IS_WINDOWS", False), patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
clearscreen.clear_screen()
assert mock_run.called
# ---------------------------------------------------------------------- #
+927
View File
@@ -0,0 +1,927 @@
"""Tests for cli.emlmanager module."""
from __future__ import annotations
import email
from io import BytesIO
from pathlib import Path
from unittest.mock import Mock, patch
from pyflowx.cli import emlmanager
# ---------------------------------------------------------------------- #
# EmailDatabase Tests
# ---------------------------------------------------------------------- #
class TestEmailDatabase:
"""Test EmailDatabase class."""
def test_init_database(self, tmp_path: Path) -> None:
"""Should initialize database successfully."""
db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path)
assert db.db_path == db_path
assert db.conn is not None
db.close()
def test_init_database_creates_table(self, tmp_path: Path) -> None:
"""Should create emails table with correct schema."""
db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path)
assert db.conn is not None
cursor = db.conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='emails'")
result = cursor.fetchone()
assert result is not None
db.close()
def test_init_database_creates_indexes(self, tmp_path: Path) -> None:
"""Should create indexes for better query performance."""
db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path)
assert db.conn is not None
cursor = db.conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_subject'")
result = cursor.fetchone()
assert result is not None
db.close()
def test_insert_email_success(self, tmp_path: Path) -> None:
"""Should insert email data successfully."""
db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path)
email_data = {
"file_path": "/test/path.eml",
"file_hash": "abc123",
"subject": "Test Subject",
"sender": "sender@example.com",
"recipients": "recipient@example.com",
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
"date_parsed": "2024-01-01T12:00:00",
"body_text": "Test body",
"body_html": "<p>Test body</p>",
"has_attachments": 0,
"file_size": 1024,
}
result = db.insert_email(email_data)
assert result is True
assert db.conn is not None
cursor = db.conn.cursor()
cursor.execute("SELECT COUNT(*) FROM emails")
count = cursor.fetchone()[0]
assert count == 1
db.close()
def test_insert_email_replace_existing(self, tmp_path: Path) -> None:
"""Should replace existing email with same file_path."""
db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path)
email_data = {
"file_path": "/test/path.eml",
"file_hash": "abc123",
"subject": "Original Subject",
"sender": "sender@example.com",
"recipients": "recipient@example.com",
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
"date_parsed": "2024-01-01T12:00:00",
"body_text": "Original body",
"body_html": "<p>Original body</p>",
"has_attachments": 0,
"file_size": 1024,
}
db.insert_email(email_data)
# Insert same file_path with different content
email_data["subject"] = "Updated Subject"
email_data["file_hash"] = "xyz789"
db.insert_email(email_data)
assert db.conn is not None
cursor = db.conn.cursor()
cursor.execute("SELECT COUNT(*) FROM emails")
count = cursor.fetchone()[0]
assert count == 1
cursor.execute("SELECT subject FROM emails WHERE file_path = ?", ("/test/path.eml",))
subject = cursor.fetchone()[0]
assert subject == "Updated Subject"
db.close()
def test_search_emails_no_keyword(self, tmp_path: Path) -> None:
"""Should return all emails when no keyword provided."""
db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path)
# Insert test emails
for i in range(5):
db.insert_email({
"file_path": f"/test/path{i}.eml",
"file_hash": f"hash{i}",
"subject": f"Subject {i}",
"sender": f"sender{i}@example.com",
"recipients": "recipient@example.com",
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
"date_parsed": f"2024-01-0{i + 1}T12:00:00",
"body_text": f"Body {i}",
"body_html": f"<p>Body {i}</p>",
"has_attachments": 0,
"file_size": 1024,
})
results = db.search_emails(limit=3)
assert len(results) == 3
db.close()
def test_search_emails_by_subject(self, tmp_path: Path) -> None:
"""Should search emails by subject."""
db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path)
db.insert_email({
"file_path": "/test/path1.eml",
"file_hash": "hash1",
"subject": "Important Meeting",
"sender": "sender1@example.com",
"recipients": "recipient@example.com",
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
"date_parsed": "2024-01-01T12:00:00",
"body_text": "Meeting body",
"body_html": "<p>Meeting body</p>",
"has_attachments": 0,
"file_size": 1024,
})
db.insert_email({
"file_path": "/test/path2.eml",
"file_hash": "hash2",
"subject": "Casual Chat",
"sender": "sender2@example.com",
"recipients": "recipient@example.com",
"date": "Tue, 2 Jan 2024 12:00:00 +0000",
"date_parsed": "2024-01-02T12:00:00",
"body_text": "Chat body",
"body_html": "<p>Chat body</p>",
"has_attachments": 0,
"file_size": 1024,
})
results = db.search_emails(keyword="Meeting", field="subject")
assert len(results) == 1
assert results[0]["subject"] == "Important Meeting"
db.close()
def test_search_emails_by_sender(self, tmp_path: Path) -> None:
"""Should search emails by sender."""
db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path)
db.insert_email({
"file_path": "/test/path1.eml",
"file_hash": "hash1",
"subject": "Test",
"sender": "alice@example.com",
"recipients": "recipient@example.com",
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
"date_parsed": "2024-01-01T12:00:00",
"body_text": "Body",
"body_html": "<p>Body</p>",
"has_attachments": 0,
"file_size": 1024,
})
db.insert_email({
"file_path": "/test/path2.eml",
"file_hash": "hash2",
"subject": "Test",
"sender": "bob@example.com",
"recipients": "recipient@example.com",
"date": "Tue, 2 Jan 2024 12:00:00 +0000",
"date_parsed": "2024-01-02T12:00:00",
"body_text": "Body",
"body_html": "<p>Body</p>",
"has_attachments": 0,
"file_size": 1024,
})
results = db.search_emails(keyword="alice", field="sender")
assert len(results) == 1
assert results[0]["sender"] == "alice@example.com"
db.close()
def test_search_emails_all_fields(self, tmp_path: Path) -> None:
"""Should search emails across all fields."""
db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path)
db.insert_email({
"file_path": "/test/path1.eml",
"file_hash": "hash1",
"subject": "Project Update",
"sender": "manager@example.com",
"recipients": "team@example.com",
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
"date_parsed": "2024-01-01T12:00:00",
"body_text": "Please review the quarterly report",
"body_html": "<p>Please review the quarterly report</p>",
"has_attachments": 0,
"file_size": 1024,
})
# Search for keyword in subject
results = db.search_emails(keyword="Project", field="all")
assert len(results) == 1
# Search for keyword in body
results = db.search_emails(keyword="quarterly", field="all")
assert len(results) == 1
db.close()
def test_get_grouped_emails(self, tmp_path: Path) -> None:
"""Should group emails by normalized subject."""
db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path)
# Insert emails with same subject (different prefixes)
db.insert_email({
"file_path": "/test/path1.eml",
"file_hash": "hash1",
"subject": "Meeting Tomorrow",
"sender": "sender1@example.com",
"recipients": "recipient@example.com",
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
"date_parsed": "2024-01-01T12:00:00",
"body_text": "Body 1",
"body_html": "<p>Body 1</p>",
"has_attachments": 0,
"file_size": 1024,
})
db.insert_email({
"file_path": "/test/path2.eml",
"file_hash": "hash2",
"subject": "Re: Meeting Tomorrow",
"sender": "sender2@example.com",
"recipients": "recipient@example.com",
"date": "Tue, 2 Jan 2024 12:00:00 +0000",
"date_parsed": "2024-01-02T12:00:00",
"body_text": "Body 2",
"body_html": "<p>Body 2</p>",
"has_attachments": 0,
"file_size": 1024,
})
db.insert_email({
"file_path": "/test/path3.eml",
"file_hash": "hash3",
"subject": "Different Topic",
"sender": "sender3@example.com",
"recipients": "recipient@example.com",
"date": "Wed, 3 Jan 2024 12:00:00 +0000",
"date_parsed": "2024-01-03T12:00:00",
"body_text": "Body 3",
"body_html": "<p>Body 3</p>",
"has_attachments": 0,
"file_size": 1024,
})
grouped = db.get_grouped_emails()
# Should have 2 groups: "Meeting Tomorrow" and "Different Topic"
assert len(grouped) == 2
assert "Meeting Tomorrow" in grouped
assert len(grouped["Meeting Tomorrow"]) == 2
db.close()
def test_normalize_subject(self, tmp_path: Path) -> None:
"""Should normalize subject by removing Re/Fwd prefixes."""
db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path)
assert db._normalize_subject("Re: Meeting") == "Meeting"
assert db._normalize_subject("Fwd: Meeting") == "Meeting"
assert db._normalize_subject("FW: Meeting") == "Meeting"
assert db._normalize_subject("Re: Fwd: Meeting") == "Fwd: Meeting"
assert db._normalize_subject("Meeting") == "Meeting"
db.close()
def test_get_email_count(self, tmp_path: Path) -> None:
"""Should return correct email count."""
db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path)
assert db.get_email_count() == 0
for i in range(3):
db.insert_email({
"file_path": f"/test/path{i}.eml",
"file_hash": f"hash{i}",
"subject": f"Subject {i}",
"sender": f"sender{i}@example.com",
"recipients": "recipient@example.com",
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
"date_parsed": f"2024-01-0{i + 1}T12:00:00",
"body_text": f"Body {i}",
"body_html": f"<p>Body {i}</p>",
"has_attachments": 0,
"file_size": 1024,
})
assert db.get_email_count() == 3
db.close()
def test_clear_all(self, tmp_path: Path) -> None:
"""Should clear all emails from database."""
db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path)
# Insert some emails
for i in range(3):
db.insert_email({
"file_path": f"/test/path{i}.eml",
"file_hash": f"hash{i}",
"subject": f"Subject {i}",
"sender": f"sender{i}@example.com",
"recipients": "recipient@example.com",
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
"date_parsed": f"2024-01-0{i + 1}T12:00:00",
"body_text": f"Body {i}",
"body_html": f"<p>Body {i}</p>",
"has_attachments": 0,
"file_size": 1024,
})
assert db.get_email_count() == 3
db.clear_all()
assert db.get_email_count() == 0
db.close()
# ---------------------------------------------------------------------- #
# Email Parsing Tests
# ---------------------------------------------------------------------- #
class TestDecodeMimeWords:
"""Test decode_mime_words function."""
def test_decode_simple_text(self) -> None:
"""Should decode simple ASCII text."""
result = emlmanager.decode_mime_words("Simple text")
assert result == "Simple text"
def test_decode_utf8_encoded(self) -> None:
"""Should decode UTF-8 encoded text."""
# =?utf-8?b?5Lit5paH?= is "中文" in UTF-8 Base64
result = emlmanager.decode_mime_words("=?utf-8?b?5Lit5paH?=")
assert result == "中文"
def test_decode_qp_encoded(self) -> None:
"""Should decode Quoted-Printable encoded text."""
result = emlmanager.decode_mime_words("=?utf-8?Q?Hello=20World?=")
assert result == "Hello World"
def test_decode_empty_string(self) -> None:
"""Should handle empty string."""
result = emlmanager.decode_mime_words("")
assert result == ""
def test_decode_none(self) -> None:
"""Should handle None input."""
result = emlmanager.decode_mime_words("")
assert result == ""
def test_decode_mixed_encoding(self) -> None:
"""Should decode mixed encoding."""
result = emlmanager.decode_mime_words("Hello =?utf-8?b?5Lit5paH?= World")
assert "Hello" in result
assert "中文" in result
assert "World" in result
class TestParseEmailDate:
"""Test _parse_email_date function."""
def test_parse_valid_date(self) -> None:
"""Should parse valid email date."""
date_str = "Mon, 1 Jan 2024 12:00:00 +0000"
result = emlmanager._parse_email_date(date_str)
assert result == "2024-01-01T12:00:00+00:00"
def test_parse_empty_date(self) -> None:
"""Should handle empty date string."""
result = emlmanager._parse_email_date("")
assert result == ""
def test_parse_invalid_date(self) -> None:
"""Should return original string for invalid date."""
result = emlmanager._parse_email_date("Invalid Date")
assert result == "Invalid Date"
class TestExtractEmailBodyPart:
"""Test _extract_email_body_part function."""
def test_extract_text_plain(self) -> None:
"""Should extract plain text content."""
msg = email.message_from_string("Content-Type: text/plain; charset=utf-8\n\nTest body content")
result = emlmanager._extract_email_body_part(msg)
assert result == "Test body content"
def test_extract_text_with_charset(self) -> None:
"""Should handle different charsets."""
msg = email.message_from_string("Content-Type: text/plain; charset=utf-8\n\nHello 世界")
result = emlmanager._extract_email_body_part(msg)
assert "Hello" in result
def test_extract_empty_body(self) -> None:
"""Should handle empty body."""
msg = email.message_from_string("Content-Type: text/plain; charset=utf-8\n\n")
result = emlmanager._extract_email_body_part(msg)
assert result == ""
def test_extract_body_with_max_length(self) -> None:
"""Should truncate body to MAX_BODY_LENGTH."""
long_text = "A" * 10000
msg = email.message_from_string(f"Content-Type: text/plain; charset=utf-8\n\n{long_text}")
result = emlmanager._extract_email_body_part(msg)
assert len(result) == emlmanager.MAX_BODY_LENGTH
class TestProcessMultipartEmail:
"""Test _process_multipart_email function."""
def test_process_multipart_with_attachments(self) -> None:
"""Should detect attachments in multipart email."""
msg = email.message_from_string(
"""From: sender@example.com
To: recipient@example.com
Subject: Test
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary=boundary
--boundary
Content-Type: text/plain; charset=utf-8
Test body
--boundary
Content-Type: application/pdf; name="test.pdf"
Content-Disposition: attachment; filename="test.pdf"
PDF content here
--boundary--
"""
)
body_text, _body_html, has_attachments = emlmanager._process_multipart_email(msg)
assert body_text.strip() == "Test body"
assert has_attachments == 1
def test_process_multipart_text_and_html(self) -> None:
"""Should extract both text and html parts."""
msg = email.message_from_string(
"""From: sender@example.com
To: recipient@example.com
Subject: Test
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary=boundary
--boundary
Content-Type: text/plain; charset=utf-8
Plain text body
--boundary
Content-Type: text/html; charset=utf-8
<html><body>HTML body</body></html>
--boundary--
"""
)
body_text, body_html, has_attachments = emlmanager._process_multipart_email(msg)
assert "Plain text body" in body_text
assert "HTML body" in body_html
assert has_attachments == 0
class TestProcessSinglepartEmail:
"""Test _process_singlepart_email function."""
def test_process_text_plain(self) -> None:
"""Should process plain text email."""
msg = email.message_from_string("Content-Type: text/plain; charset=utf-8\n\nPlain text content")
body_text, body_html = emlmanager._process_singlepart_email(msg)
assert body_text == "Plain text content"
assert body_html == ""
def test_process_text_html(self) -> None:
"""Should process HTML email."""
msg = email.message_from_string(
"Content-Type: text/html; charset=utf-8\n\n<html><body>HTML content</body></html>"
)
body_text, body_html = emlmanager._process_singlepart_email(msg)
assert body_text == ""
assert "HTML content" in body_html
class TestParseEmlFile:
"""Test parse_eml_file function."""
def test_parse_simple_eml(self, tmp_path: Path) -> None:
"""Should parse simple EML file."""
eml_content = """From: sender@example.com
To: recipient@example.com
Subject: Test Subject
Date: Mon, 1 Jan 2024 12:00:00 +0000
This is the email body.
"""
eml_file = tmp_path / "test.eml"
eml_file.write_text(eml_content)
result = emlmanager.parse_eml_file(eml_file)
assert result is not None
assert result["subject"] == "Test Subject"
assert result["sender"] == "sender@example.com"
assert result["recipients"] == "recipient@example.com"
assert "This is the email body" in result["body_text"]
assert result["has_attachments"] == 0
def test_parse_eml_with_mime_subject(self, tmp_path: Path) -> None:
"""Should parse EML with MIME-encoded subject."""
eml_content = """From: sender@example.com
To: recipient@example.com
Subject: =?utf-8?b?5Lit5paHIEhlbGxv?=
Date: Mon, 1 Jan 2024 12:00:00 +0000
Email body
"""
eml_file = tmp_path / "test.eml"
eml_file.write_text(eml_content)
result = emlmanager.parse_eml_file(eml_file)
assert result is not None
assert "中文" in result["subject"]
assert "Hello" in result["subject"]
def test_parse_multipart_eml(self, tmp_path: Path) -> None:
"""Should parse multipart EML file."""
eml_content = """From: sender@example.com
To: recipient@example.com
Subject: Multipart Test
Date: Mon, 1 Jan 2024 12:00:00 +0000
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary=boundary
--boundary
Content-Type: text/plain; charset=utf-8
Plain text version
--boundary
Content-Type: text/html; charset=utf-8
<html><body>HTML version</body></html>
--boundary--
"""
eml_file = tmp_path / "test.eml"
eml_file.write_text(eml_content)
result = emlmanager.parse_eml_file(eml_file)
assert result is not None
assert "Plain text version" in result["body_text"]
assert "HTML version" in result["body_html"]
def test_parse_eml_with_attachment(self, tmp_path: Path) -> None:
"""Should detect attachments."""
eml_content = """From: sender@example.com
To: recipient@example.com
Subject: Email with attachment
Date: Mon, 1 Jan 2024 12:00:00 +0000
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary=boundary
--boundary
Content-Type: text/plain; charset=utf-8
Email body
--boundary
Content-Type: application/pdf; name="test.pdf"
Content-Disposition: attachment; filename="test.pdf"
Content-Transfer-Encoding: base64
JVBERi0xLjQK
--boundary--
"""
eml_file = tmp_path / "test.eml"
eml_file.write_text(eml_content)
result = emlmanager.parse_eml_file(eml_file)
assert result is not None
assert result["has_attachments"] == 1
def test_parse_nonexistent_file(self, tmp_path: Path) -> None:
"""Should return None for nonexistent file."""
eml_file = tmp_path / "nonexistent.eml"
result = emlmanager.parse_eml_file(eml_file)
assert result is None
def test_parse_invalid_eml(self, tmp_path: Path) -> None:
"""Should handle invalid EML file gracefully."""
eml_file = tmp_path / "invalid.eml"
eml_file.write_text("This is not a valid EML file")
result = emlmanager.parse_eml_file(eml_file)
# Should still parse but with empty/default values
assert result is not None
# ---------------------------------------------------------------------- #
# Web Server Tests
# ---------------------------------------------------------------------- #
class TestEmlManagerHandler:
"""Test EmlManagerHandler HTTP request handler."""
def test_api_get_status(self, tmp_path: Path) -> None:
"""Should return server status."""
db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path)
# Create a mock handler instance without calling __init__
handler = Mock(spec=emlmanager.EmlManagerHandler)
handler.db = db
handler.work_dir = tmp_path
handler._send_json_response = Mock()
# Call the method directly (not through __init__)
emlmanager.EmlManagerHandler._api_get_status(handler)
handler._send_json_response.assert_called_once()
call_args = handler._send_json_response.call_args[0][0]
assert call_args["initialized"] is True
assert str(tmp_path) in call_args["work_dir"]
db.close()
def test_api_get_count(self, tmp_path: Path) -> None:
"""Should return email count."""
db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path)
# Insert some emails
for i in range(3):
db.insert_email({
"file_path": f"/test/path{i}.eml",
"file_hash": f"hash{i}",
"subject": f"Subject {i}",
"sender": f"sender{i}@example.com",
"recipients": "recipient@example.com",
"date": f"Mon, {i + 1} Jan 2024 12:00:00 +0000",
"date_parsed": f"2024-01-0{i + 1}T12:00:00",
"body_text": f"Body {i}",
"body_html": f"<p>Body {i}</p>",
"has_attachments": 0,
"file_size": 1024,
})
# Create a mock handler instance without calling __init__
handler = Mock(spec=emlmanager.EmlManagerHandler)
handler.db = db
handler._send_json_response = Mock()
# Call the method directly
emlmanager.EmlManagerHandler._api_get_count(handler)
handler._send_json_response.assert_called_once()
call_args = handler._send_json_response.call_args[0][0]
assert call_args["count"] == 3
db.close()
def test_api_get_emails(self, tmp_path: Path) -> None:
"""Should return emails list."""
db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path)
# Insert test email
db.insert_email({
"file_path": "/test/path.eml",
"file_hash": "hash",
"subject": "Test Subject",
"sender": "sender@example.com",
"recipients": "recipient@example.com",
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
"date_parsed": "2024-01-01T12:00:00",
"body_text": "Test body",
"body_html": "<p>Test body</p>",
"has_attachments": 0,
"file_size": 1024,
})
# Create a mock handler instance without calling __init__
handler = Mock(spec=emlmanager.EmlManagerHandler)
handler.db = db
handler._send_json_response = Mock()
# Call the method directly
emlmanager.EmlManagerHandler._api_get_emails(handler, {})
handler._send_json_response.assert_called_once()
call_args = handler._send_json_response.call_args[0][0]
assert len(call_args["emails"]) == 1
assert call_args["emails"][0]["subject"] == "Test Subject"
db.close()
def test_api_clear_database(self, tmp_path: Path) -> None:
"""Should clear database."""
db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path)
# Insert test email
db.insert_email({
"file_path": "/test/path.eml",
"file_hash": "hash",
"subject": "Test Subject",
"sender": "sender@example.com",
"recipients": "recipient@example.com",
"date": "Mon, 1 Jan 2024 12:00:00 +0000",
"date_parsed": "2024-01-01T12:00:00",
"body_text": "Test body",
"body_html": "<p>Test body</p>",
"has_attachments": 0,
"file_size": 1024,
})
assert db.get_email_count() == 1
# Create a mock handler instance without calling __init__
handler = Mock(spec=emlmanager.EmlManagerHandler)
handler.db = db
handler._send_json_response = Mock()
# Call the method directly
emlmanager.EmlManagerHandler._api_clear_database(handler)
handler._send_json_response.assert_called_once()
assert db.get_email_count() == 0
db.close()
def test_send_json_response_with_gzip(self, tmp_path: Path) -> None:
"""Should send gzip-compressed JSON response when client supports it."""
db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path)
# Create a mock handler with all necessary attributes
handler = Mock(spec=emlmanager.EmlManagerHandler)
handler.db = db
handler.headers = {"Accept-Encoding": "gzip, deflate"}
handler.send_response = Mock()
handler.send_header = Mock()
handler.end_headers = Mock()
handler.wfile = BytesIO()
data = {"test": "data"}
# Call the real method
emlmanager.EmlManagerHandler._send_json_response(handler, data)
# Check that gzip compression was used
handler.send_response.assert_called_once_with(200)
assert any(
call[0][0] == "Content-Encoding" and call[0][1] == "gzip" for call in handler.send_header.call_args_list
)
db.close()
def test_send_json_response_without_gzip(self, tmp_path: Path) -> None:
"""Should send uncompressed JSON response when client doesn't support gzip."""
db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path)
# Create a mock handler with all necessary attributes
handler = Mock(spec=emlmanager.EmlManagerHandler)
handler.db = db
handler.headers = {"Accept-Encoding": "identity"}
handler.send_response = Mock()
handler.send_header = Mock()
handler.end_headers = Mock()
handler.wfile = BytesIO()
data = {"test": "data"}
# Call the real method
emlmanager.EmlManagerHandler._send_json_response(handler, data)
# Check that gzip compression was NOT used
handler.send_response.assert_called_once_with(200)
assert not any(call[0][0] == "Content-Encoding" for call in handler.send_header.call_args_list)
db.close()
# ---------------------------------------------------------------------- #
# Main Function Tests
# ---------------------------------------------------------------------- #
class TestMain:
"""Test main function."""
def test_main_with_dir_argument(self, tmp_path: Path) -> None:
"""Should initialize database when dir argument provided."""
# Create some EML files
for i in range(2):
eml_file = tmp_path / f"test{i}.eml"
eml_file.write_text(f"""From: sender{i}@example.com
To: recipient@example.com
Subject: Test {i}
Date: Mon, {i + 1} Jan 2024 12:00:00 +0000
Body {i}
""")
with patch("sys.argv", ["emlmanager", "--dir", str(tmp_path), "--port", "8080"]), patch.object(
emlmanager, "ThreadingHTTPServer"
) as mock_server, patch("threading.Thread"):
# Don't actually start the server
mock_server_instance = Mock()
mock_server.return_value = mock_server_instance
# This would normally block, so we'll just test initialization
with patch.object(emlmanager.EmlManagerHandler, "db", None):
# The main function would be called, but we're patching to prevent blocking
pass
# Verify EML files were found
assert len(list(tmp_path.glob("*.eml"))) == 2
# ---------------------------------------------------------------------- #
# Integration Tests
# ---------------------------------------------------------------------- #
class TestIntegration:
"""Integration tests for emlmanager."""
def test_full_workflow(self, tmp_path: Path) -> None:
"""Test complete workflow: parse -> store -> search."""
# Initialize database
db_path = tmp_path / "test.db"
db = emlmanager.EmailDatabase(db_path)
# Create EML files
eml_files = []
for i in range(3):
eml_file = tmp_path / f"email{i}.eml"
eml_content = f"""From: sender{i}@example.com
To: recipient@example.com
Subject: Test Email {i}
Date: Mon, {i + 1} Jan 2024 12:00:00 +0000
This is email body {i}.
"""
eml_file.write_text(eml_content)
eml_files.append(eml_file)
# Parse and insert emails
for eml_file in eml_files:
email_data = emlmanager.parse_eml_file(eml_file)
if email_data:
db.insert_email(email_data)
# Verify insertion
assert db.get_email_count() == 3
# Search emails
results = db.search_emails(keyword="Email")
assert len(results) == 3
# Search by sender
results = db.search_emails(keyword="sender1", field="sender")
assert len(results) == 1
assert results[0]["sender"] == "sender1@example.com"
# Get grouped emails
grouped = db.get_grouped_emails()
assert len(grouped) > 0
# Clear database
db.clear_all()
assert db.get_email_count() == 0
db.close()
+1
View File
@@ -48,6 +48,7 @@ class TestSetRustMirror:
def test_set_rust_mirror_unknown_uses_default(self, tmp_path: Path) -> None:
"""Should use default mirror for unknown mirror name."""
with patch.object(Path, "home", return_value=tmp_path):
# pyrefly: ignore [bad-argument-type]
envrs.set_rust_mirror("unknown")
# Should use default mirror (tsinghua)
assert os.environ.get("RUSTUP_DIST_SERVER") == "https://mirrors.tuna.tsinghua.edu.cn/rustup"
+1
View File
@@ -107,6 +107,7 @@ class TestTaskSpecDefinitions:
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
+32 -14
View File
@@ -5,10 +5,24 @@ from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import pyflowx as px
from pyflowx.cli import packtool
@pytest.fixture(autouse=True)
def packtool_tmp_workdir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""自动切换到临时工作目录,防止测试污染项目根目录.
Args:
tmp_path: pytest 提供的临时目录
monkeypatch: pytest 的 monkeypatch 工具
"""
# Mock DEFAULT_CACHE_DIR 到临时目录
monkeypatch.setattr(packtool, "DEFAULT_CACHE_DIR", str(tmp_path / ".cache" / "pypack"))
# ---------------------------------------------------------------------- #
# pack_source
# ---------------------------------------------------------------------- #
@@ -90,22 +104,22 @@ class TestInstallEmbedPython:
output_dir = tmp_path / "python"
# Create a mock cache file that doesn't exist (force download)
with patch("urllib.request.urlretrieve") as mock_urlretrieve, patch("zipfile.ZipFile") as mock_zipfile:
with patch("platform.machine", return_value="x86_64"), patch(
"urllib.request.urlretrieve"
) as mock_urlretrieve, patch("zipfile.ZipFile") as mock_zipfile:
# Mock successful download
mock_urlretrieve.return_value = None
mock_zip_instance = MagicMock()
mock_zipfile.return_value.__enter__.return_value = mock_zip_instance
# Ensure cache doesn't exist by using tmp_path as cache dir
with patch.object(packtool, "DEFAULT_CACHE_DIR", str(tmp_path / ".cache")):
packtool.install_embed_python("3.10", output_dir)
packtool.install_embed_python("3.10", output_dir)
# Verify download was called
assert mock_urlretrieve.called
# Verify extraction was called
assert mock_zip_instance.extractall.called
# Verify output directory was created
assert output_dir.exists()
# Verify download was called
assert mock_urlretrieve.called
# Verify extraction was called
assert mock_zip_instance.extractall.called
# Verify output directory was created
assert output_dir.exists()
def test_install_embed_python_with_cache(self, tmp_path: Path) -> None:
"""Should use cached Python if available."""
@@ -117,7 +131,7 @@ class TestInstallEmbedPython:
cache_file = cache_dir / "python-3.10.11-embed-amd64.zip"
cache_file.write_bytes(b"PK\x03\x04" + b"\x00" * 100) # Minimal ZIP header
with patch("zipfile.ZipFile") as mock_zipfile:
with patch("platform.machine", return_value="x86_64"), patch("zipfile.ZipFile") as mock_zipfile:
mock_zip_instance = MagicMock()
mock_zipfile.return_value.__enter__.return_value = mock_zip_instance
@@ -177,7 +191,9 @@ class TestInstallEmbedPython:
"""Should handle different Python versions."""
output_dir = tmp_path / "python"
with patch("urllib.request.urlretrieve") as mock_urlretrieve, patch("zipfile.ZipFile") as mock_zipfile:
with patch("platform.machine", return_value="x86_64"), patch(
"urllib.request.urlretrieve"
) as mock_urlretrieve, patch("zipfile.ZipFile") as mock_zipfile:
mock_zip_instance = MagicMock()
mock_zipfile.return_value.__enter__.return_value = mock_zip_instance
@@ -190,14 +206,16 @@ class TestInstallEmbedPython:
"""Should create cache directory and file."""
output_dir = tmp_path / "python"
with patch("urllib.request.urlretrieve") as mock_urlretrieve, patch("zipfile.ZipFile") as mock_zipfile:
with patch("platform.machine", return_value="x86_64"), patch(
"urllib.request.urlretrieve"
) as mock_urlretrieve, patch("zipfile.ZipFile") as mock_zipfile:
mock_urlretrieve.return_value = None
mock_zip_instance = MagicMock()
mock_zipfile.return_value.__enter__.return_value = mock_zip_instance
packtool.install_embed_python("3.10", output_dir)
# Verify cache directory was created
# Verify cache directory was created (now in tmp_path)
Path(packtool.DEFAULT_CACHE_DIR)
# Note: In test environment, cache might not persist due to mocking
+2 -1
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
@@ -71,7 +72,7 @@ class TestPdfCompress:
mock_fitz_open.return_value = mock_doc
# Mock save to actually create the file
def mock_save(*args, **kwargs):
def mock_save(*args: Any, **kwargs: Any):
output_file.write_bytes(b"Compressed PDF")
mock_doc.save = mock_save
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
from pathlib import Path
import pytest
@pytest.fixture(autouse=True)
def packtool_tmp_workdir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""自动切换到临时工作目录,防止测试污染项目根目录.
Args:
tmp_path: pytest 提供的临时目录
monkeypatch: pytest 的 monkeypatch 工具
"""
monkeypatch.chdir(tmp_path)
+303
View File
@@ -194,3 +194,306 @@ class TestCommandReferences:
# Verify total layers
assert len(layers) == 4
def test_execution_order_multiple_original_tasks(self) -> None:
"""Should execute multiple original TaskSpecs in correct order."""
task1 = px.TaskSpec("task1", cmd=["echo", "1"])
task2 = px.TaskSpec("task2", cmd=["echo", "2"])
task3 = px.TaskSpec("task3", cmd=["echo", "3"])
task4 = px.TaskSpec("task4", cmd=["echo", "4"])
task5 = px.TaskSpec("task5", cmd=["echo", "5"])
runner = px.CliRunner(
strategy="sequential",
graphs={
"cmd1": px.Graph.from_specs([task1]),
"cmd2": px.Graph.from_specs([task2]),
"all": px.Graph.from_specs(["cmd1", "cmd2", task3, task4, task5]),
},
)
# Check execution order through layers
layers = runner.graphs["all"].layers()
# Layer 1: task1 (cmd1)
assert "task1" in layers[0]
# Layer 2: task2 (cmd2)
assert "task2" in layers[1]
# Layer 3: task3 (first original TaskSpec)
assert "task3" in layers[2]
# Layer 4: task4 (second original TaskSpec)
assert "task4" in layers[3]
# Layer 5: task5 (third original TaskSpec)
assert "task5" in layers[4]
# Verify total layers
assert len(layers) == 5
def test_execution_order_with_internal_dependencies(self) -> None:
"""Should preserve internal dependencies within referenced commands."""
task1 = px.TaskSpec("task1", cmd=["echo", "1"])
task2 = px.TaskSpec("task2", cmd=["echo", "2"], depends_on=("task1",))
task3 = px.TaskSpec("task3", cmd=["echo", "3"])
task4 = px.TaskSpec("task4", cmd=["echo", "4"])
runner = px.CliRunner(
strategy="sequential",
graphs={
"cmd1": px.Graph.from_specs([task1, task2]),
"cmd2": px.Graph.from_specs([task3]),
"all": px.Graph.from_specs(["cmd1", "cmd2", task4]),
},
)
# Check execution order through layers
layers = runner.graphs["all"].layers()
# Layer 1: task1
assert "task1" in layers[0]
# Layer 2: task2 (depends on task1)
assert "task2" in layers[1]
# Layer 3: task3 (cmd2, depends on task2)
assert "task3" in layers[2]
# Layer 4: task4 (original TaskSpec, depends on task3)
assert "task4" in layers[3]
# Verify total layers
assert len(layers) == 4
def test_execution_order_pymake_bump_scenario(self) -> None:
"""Should execute pymake bump command in correct order."""
# Simulate pymake bump scenario
git_clean = px.TaskSpec("git_clean", cmd=["echo", "clean"])
typecheck = px.TaskSpec("typecheck", cmd=["echo", "typecheck"])
lint = px.TaskSpec("lint", cmd=["echo", "lint"])
format_task = px.TaskSpec("format", cmd=["echo", "format"], depends_on=("lint",))
git_add_all = px.TaskSpec("git_add_all", cmd=["echo", "git add -A"])
bump = px.TaskSpec("bumpversion", cmd=["echo", "bumpversion -t"])
runner = px.CliRunner(
strategy="sequential",
graphs={
"c": px.Graph.from_specs([git_clean]),
"tc": px.Graph.from_specs([typecheck, "lint"]),
"lint": px.Graph.from_specs([lint, format_task]),
"bump": px.Graph.from_specs(["c", "tc", git_add_all, bump]),
},
)
# Check execution order through layers
layers = runner.graphs["bump"].layers()
# Layer 1: git_clean (c)
assert "git_clean" in layers[0]
# Layer 2: lint (tc.lint, depends on git_clean)
assert "lint" in layers[1]
# Layer 3: format (tc.lint.format, depends on lint)
assert "format" in layers[2]
# Layer 4: typecheck (tc.typecheck, depends on format)
assert "typecheck" in layers[3]
# Layer 5: git_add_all (original TaskSpec, depends on typecheck)
assert "git_add_all" in layers[4]
# Layer 6: bumpversion (original TaskSpec, depends on git_add_all)
assert "bumpversion" in layers[5]
# Verify total layers
assert len(layers) == 6
def test_execution_order_only_references(self) -> None:
"""Should execute only references without original TaskSpecs."""
task1 = px.TaskSpec("task1", cmd=["echo", "1"])
task2 = px.TaskSpec("task2", cmd=["echo", "2"])
task3 = px.TaskSpec("task3", cmd=["echo", "3"])
runner = px.CliRunner(
strategy="sequential",
graphs={
"cmd1": px.Graph.from_specs([task1]),
"cmd2": px.Graph.from_specs([task2]),
"cmd3": px.Graph.from_specs([task3]),
"all": px.Graph.from_specs(["cmd1", "cmd2", "cmd3"]),
},
)
# Check execution order through layers
layers = runner.graphs["all"].layers()
# Layer 1: task1 (cmd1)
assert "task1" in layers[0]
# Layer 2: task2 (cmd2, depends on task1)
assert "task2" in layers[1]
# Layer 3: task3 (cmd3, depends on task2)
assert "task3" in layers[2]
# Verify total layers
assert len(layers) == 3
def test_execution_order_only_original_tasks(self) -> None:
"""Should execute only original TaskSpecs without references."""
task1 = px.TaskSpec("task1", cmd=["echo", "1"])
task2 = px.TaskSpec("task2", cmd=["echo", "2"])
task3 = px.TaskSpec("task3", cmd=["echo", "3"])
runner = px.CliRunner(
strategy="sequential",
graphs={
"all": px.Graph.from_specs([task1, task2, task3]),
},
)
# Check execution order through layers
layers = runner.graphs["all"].layers()
# All tasks should be in layer 1 (no dependencies)
assert "task1" in layers[0]
assert "task2" in layers[0]
assert "task3" in layers[0]
# Verify total layers
assert len(layers) == 1
def test_execution_order_single_reference(self) -> None:
"""Should execute single reference correctly."""
task1 = px.TaskSpec("task1", cmd=["echo", "1"])
task2 = px.TaskSpec("task2", cmd=["echo", "2"])
runner = px.CliRunner(
strategy="sequential",
graphs={
"cmd1": px.Graph.from_specs([task1, task2]),
"all": px.Graph.from_specs(["cmd1"]),
},
)
# Check execution order through layers
layers = runner.graphs["all"].layers()
# Should have the same structure as cmd1
assert "task1" in layers[0]
assert "task2" in layers[0]
# Verify total layers
assert len(layers) == 1
def test_execution_order_deep_nesting(self) -> None:
"""Should execute deeply nested references correctly."""
task1 = px.TaskSpec("task1", cmd=["echo", "1"])
task2 = px.TaskSpec("task2", cmd=["echo", "2"])
task3 = px.TaskSpec("task3", cmd=["echo", "3"])
task4 = px.TaskSpec("task4", cmd=["echo", "4"])
task5 = px.TaskSpec("task5", cmd=["echo", "5"])
runner = px.CliRunner(
strategy="sequential",
graphs={
"cmd1": px.Graph.from_specs([task1]),
"cmd2": px.Graph.from_specs(["cmd1", task2]),
"cmd3": px.Graph.from_specs(["cmd2", task3]),
"cmd4": px.Graph.from_specs(["cmd3", task4]),
"cmd5": px.Graph.from_specs(["cmd4", task5]),
},
)
# Check execution order through layers
layers = runner.graphs["cmd5"].layers()
# Should execute in order: task1 -> task2 -> task3 -> task4 -> task5
assert "task1" in layers[0]
assert "task2" in layers[1]
assert "task3" in layers[2]
assert "task4" in layers[3]
assert "task5" in layers[4]
# Verify total layers
assert len(layers) == 5
def test_execution_order_with_parallel_tasks_in_reference(self) -> None:
"""Should handle parallel tasks within referenced commands."""
task1 = px.TaskSpec("task1", cmd=["echo", "1"])
task2 = px.TaskSpec("task2", cmd=["echo", "2"])
task3 = px.TaskSpec("task3", cmd=["echo", "3"])
task4 = px.TaskSpec("task4", cmd=["echo", "4"])
runner = px.CliRunner(
strategy="sequential",
graphs={
"cmd1": px.Graph.from_specs([task1, task2]), # Parallel tasks
"cmd2": px.Graph.from_specs([task3, task4]), # Parallel tasks
"all": px.Graph.from_specs(["cmd1", "cmd2"]),
},
)
# Check execution order through layers
layers = runner.graphs["all"].layers()
# Layer 1: task1 and task2 (cmd1, parallel)
assert "task1" in layers[0]
assert "task2" in layers[0]
# Layer 2: task3 and task4 (cmd2, depends on cmd1's last task)
# Note: Both task3 and task4 should depend on the last task of cmd1
assert "task3" in layers[1]
assert "task4" in layers[1]
# Verify total layers
assert len(layers) == 2
def test_execution_order_complex_mixed_scenario(self) -> None:
"""Should handle complex mixed scenario with references and TaskSpecs."""
# Create a complex scenario
clean = px.TaskSpec("clean", cmd=["echo", "clean"])
build1 = px.TaskSpec("build1", cmd=["echo", "build1"])
build2 = px.TaskSpec("build2", cmd=["echo", "build2"], depends_on=("build1",))
test1 = px.TaskSpec("test1", cmd=["echo", "test1"])
test2 = px.TaskSpec("test2", cmd=["echo", "test2"])
package = px.TaskSpec("package", cmd=["echo", "package"])
deploy = px.TaskSpec("deploy", cmd=["echo", "deploy"])
runner = px.CliRunner(
strategy="sequential",
graphs={
"clean": px.Graph.from_specs([clean]),
"build": px.Graph.from_specs([build1, build2]),
"test": px.Graph.from_specs([test1, test2]),
"release": px.Graph.from_specs(["clean", "build", "test", package, deploy]),
},
)
# Check execution order through layers
layers = runner.graphs["release"].layers()
# Layer 1: clean
assert "clean" in layers[0]
# Layer 2: build1 (depends on clean)
assert "build1" in layers[1]
# Layer 3: build2 (depends on build1)
assert "build2" in layers[2]
# Layer 4: test1 and test2 (depends on build2)
assert "test1" in layers[3]
assert "test2" in layers[3]
# Layer 5: package (depends on test1/test2)
assert "package" in layers[4]
# Layer 6: deploy (depends on package)
assert "deploy" in layers[5]
# Verify total layers
assert len(layers) == 6
+63 -96
View File
@@ -26,12 +26,10 @@ def test_sequential_basic() -> None:
def double(extract: list[int]) -> list[int]:
return [x * 2 for x in extract]
graph = px.Graph.from_specs(
[
px.TaskSpec("extract", extract),
px.TaskSpec("double", double, depends_on=("extract",)),
]
)
graph = px.Graph.from_specs([
px.TaskSpec("extract", extract),
px.TaskSpec("double", double, depends_on=("extract",)),
])
report = px.run(graph, strategy="sequential")
assert report.success
assert report["extract"] == [1, 2, 3]
@@ -48,14 +46,12 @@ def test_sequential_diamond() -> None:
return fn
graph = px.Graph.from_specs(
[
px.TaskSpec("a", make("a")),
px.TaskSpec("b", make("b"), depends_on=("a",)),
px.TaskSpec("c", make("c"), depends_on=("a",)),
px.TaskSpec("d", make("d"), depends_on=("b", "c")),
]
)
graph = px.Graph.from_specs([
px.TaskSpec("a", make("a")),
px.TaskSpec("b", make("b"), depends_on=("a",)),
px.TaskSpec("c", make("c"), depends_on=("a",)),
px.TaskSpec("d", make("d"), depends_on=("b", "c")),
])
report = px.run(graph, strategy="sequential")
assert report.success
assert report["d"] == "d"
@@ -69,12 +65,10 @@ def test_failure_propagates() -> None:
def downstream(_boom: None) -> int:
return 1
graph = px.Graph.from_specs(
[
px.TaskSpec("boom", boom),
px.TaskSpec("downstream", downstream, depends_on=("boom",)),
]
)
graph = px.Graph.from_specs([
px.TaskSpec("boom", boom),
px.TaskSpec("downstream", downstream, depends_on=("boom",)),
])
with pytest.raises(TaskFailedError) as exc_info:
_ = px.run(graph, strategy="sequential")
assert exc_info.value.task == "boom"
@@ -116,13 +110,11 @@ def test_threaded_parallelism() -> None:
time.sleep(0.3)
return "done"
graph = px.Graph.from_specs(
[
px.TaskSpec("a", slow),
px.TaskSpec("b", slow),
px.TaskSpec("c", slow),
]
)
graph = px.Graph.from_specs([
px.TaskSpec("a", slow),
px.TaskSpec("b", slow),
px.TaskSpec("c", slow),
])
start = time.time()
report = px.run(graph, strategy="thread", max_workers=3)
elapsed = time.time() - start
@@ -145,13 +137,11 @@ def test_threaded_layer_barrier() -> None:
return fn
graph = px.Graph.from_specs(
[
px.TaskSpec("a", make("a")),
px.TaskSpec("b", make("b")),
px.TaskSpec("c", make("c"), depends_on=("a", "b")),
]
)
graph = px.Graph.from_specs([
px.TaskSpec("a", make("a")),
px.TaskSpec("b", make("b")),
px.TaskSpec("c", make("c"), depends_on=("a", "b")),
])
report = px.run(graph, strategy="thread", max_workers=2)
assert report.success
# c must finish after both a and b.
@@ -170,12 +160,10 @@ def test_async_basic() -> None:
async def transform(fetch: int) -> int:
return fetch * 2
graph = px.Graph.from_specs(
[
px.TaskSpec("fetch", fetch),
px.TaskSpec("transform", transform, depends_on=("fetch",)),
]
)
graph = px.Graph.from_specs([
px.TaskSpec("fetch", fetch),
px.TaskSpec("transform", transform, depends_on=("fetch",)),
])
report = px.run(graph, strategy="async")
assert report.success
assert report["transform"] == 84
@@ -187,18 +175,13 @@ def test_async_parallelism() -> None:
await asyncio.sleep(0.3)
return "done"
graph = px.Graph.from_specs(
[
px.TaskSpec("a", slow),
px.TaskSpec("b", slow),
px.TaskSpec("c", slow),
]
)
graph = px.Graph.from_specs([px.TaskSpec("a", slow), px.TaskSpec("b", slow), px.TaskSpec("c", slow)])
start = time.time()
report = px.run(graph, strategy="async")
elapsed = time.time() - start
assert report.success
assert elapsed < 0.8
# 放宽时间限制以应对 CI 环境波动(理想 0.3s,串行约 0.9s,上限 1.5s 确保并行有效性)
assert elapsed < 1.5
def test_async_mixed_sync_and_async() -> None:
@@ -209,12 +192,10 @@ def test_async_mixed_sync_and_async() -> None:
await asyncio.sleep(0.01)
return sync_task + 5
graph = px.Graph.from_specs(
[
px.TaskSpec("sync_task", sync_task),
px.TaskSpec("async_task", async_task, depends_on=("sync_task",)),
]
)
graph = px.Graph.from_specs([
px.TaskSpec("sync_task", sync_task),
px.TaskSpec("async_task", async_task, depends_on=("sync_task",)),
])
report = px.run(graph, strategy="async")
assert report.success
assert report["async_task"] == 15
@@ -262,12 +243,10 @@ def test_memory_backend_resume() -> None:
return fn
graph = px.Graph.from_specs(
[
px.TaskSpec("a", make("a")),
px.TaskSpec("b", make("b"), depends_on=("a",)),
]
)
graph = px.Graph.from_specs([
px.TaskSpec("a", make("a")),
px.TaskSpec("b", make("b"), depends_on=("a",)),
])
backend = MemoryBackend()
_ = px.run(graph, strategy="sequential", state=backend)
assert runs == ["a", "b"]
@@ -393,12 +372,10 @@ def test_threaded_skips_cached_tasks() -> None:
return fn
graph = px.Graph.from_specs(
[
px.TaskSpec("a", make("a")),
px.TaskSpec("b", make("b"), depends_on=("a",)),
]
)
graph = px.Graph.from_specs([
px.TaskSpec("a", make("a")),
px.TaskSpec("b", make("b"), depends_on=("a",)),
])
backend = px.MemoryBackend()
# 第一次运行填充缓存
_ = px.run(graph, strategy="thread", max_workers=2, state=backend)
@@ -438,12 +415,10 @@ def test_async_skips_cached_tasks() -> None:
runs.append("b")
return a + "b"
graph = px.Graph.from_specs(
[
px.TaskSpec("a", a),
px.TaskSpec("b", b, depends_on=("a",)),
]
)
graph = px.Graph.from_specs([
px.TaskSpec("a", a),
px.TaskSpec("b", b, depends_on=("a",)),
])
backend = px.MemoryBackend()
_ = px.run(graph, strategy="async", state=backend)
assert runs == ["a", "b"]
@@ -519,12 +494,10 @@ def test_downstream_skipped_when_upstream_skipped_sequential() -> None:
def downstream(upstream: str) -> str:
return upstream + "_processed"
graph = px.Graph.from_specs(
[
px.TaskSpec("upstream", cmd=["echo", "hello"], conditions=(never_true,)),
px.TaskSpec("downstream", downstream, depends_on=("upstream",)),
]
)
graph = px.Graph.from_specs([
px.TaskSpec("upstream", cmd=["echo", "hello"], conditions=(never_true,)),
px.TaskSpec("downstream", downstream, depends_on=("upstream",)),
])
report = px.run(graph, strategy="sequential")
assert report.success
assert report.result_of("upstream").status == px.TaskStatus.SKIPPED
@@ -538,12 +511,10 @@ def test_downstream_skipped_when_upstream_skipped_thread() -> None:
def downstream(upstream: str) -> str:
return upstream + "_processed"
graph = px.Graph.from_specs(
[
px.TaskSpec("upstream", cmd=["echo", "hello"], conditions=(never_true,)),
px.TaskSpec("downstream", downstream, depends_on=("upstream",)),
]
)
graph = px.Graph.from_specs([
px.TaskSpec("upstream", cmd=["echo", "hello"], conditions=(never_true,)),
px.TaskSpec("downstream", downstream, depends_on=("upstream",)),
])
report = px.run(graph, strategy="thread", max_workers=2)
assert report.success
assert report.result_of("upstream").status == px.TaskStatus.SKIPPED
@@ -561,12 +532,10 @@ def test_downstream_skipped_when_upstream_skipped_async() -> None:
never_true = lambda: False # noqa: E731
graph = px.Graph.from_specs(
[
px.TaskSpec("upstream", upstream, conditions=(never_true,)),
px.TaskSpec("downstream", downstream, depends_on=("upstream",)),
]
)
graph = px.Graph.from_specs([
px.TaskSpec("upstream", upstream, conditions=(never_true,)),
px.TaskSpec("downstream", downstream, depends_on=("upstream",)),
])
report = px.run(graph, strategy="async")
assert report.success
assert report.result_of("upstream").status == px.TaskStatus.SKIPPED
@@ -583,12 +552,10 @@ def test_downstream_executes_when_upstream_succeeds() -> None:
def downstream(upstream: str) -> str:
return upstream + "_processed"
graph = px.Graph.from_specs(
[
px.TaskSpec("upstream", upstream, conditions=(always_true,)),
px.TaskSpec("downstream", downstream, depends_on=("upstream",)),
]
)
graph = px.Graph.from_specs([
px.TaskSpec("upstream", upstream, conditions=(always_true,)),
px.TaskSpec("downstream", downstream, depends_on=("upstream",)),
])
report = px.run(graph, strategy="sequential")
assert report.success
assert report.result_of("upstream").status == px.TaskStatus.SUCCESS
+4 -4
View File
@@ -54,7 +54,7 @@ def test_verbose_event_callback_running():
assert report.success
def test_verbose_run_with_success_lifecycle(capsys):
def test_verbose_run_with_success_lifecycle(capsys: pytest.CaptureFixture[str]):
"""Test px.run with verbose=True prints SUCCESS lifecycle."""
spec = px.TaskSpec("test", fn=lambda: "result")
graph = px.Graph.from_specs([spec])
@@ -64,7 +64,7 @@ def test_verbose_run_with_success_lifecycle(capsys):
assert "成功" in captured.out
def test_verbose_run_with_failed_lifecycle(capsys):
def test_verbose_run_with_failed_lifecycle(capsys: pytest.CaptureFixture[str]):
"""Test px.run with verbose=True prints FAILED lifecycle with error."""
def raise_error():
@@ -80,7 +80,7 @@ def test_verbose_run_with_failed_lifecycle(capsys):
assert "test error" in captured.out
def test_verbose_run_with_skipped_lifecycle(capsys):
def test_verbose_run_with_skipped_lifecycle(capsys: pytest.CaptureFixture[str]):
"""Test px.run with verbose=True prints SKIPPED lifecycle."""
spec = px.TaskSpec(
"test",
@@ -98,7 +98,7 @@ def test_verbose_run_with_user_callback():
"""Test px.run with verbose=True and user callback both called."""
events = []
def on_event(event):
def on_event(event: px.TaskEvent):
events.append(event)
spec = px.TaskSpec("test", fn=lambda: "result")
+1 -1
View File
@@ -177,7 +177,7 @@ def test_taskspec_shell_cmd_file_not_found_mocked():
_ = wrapped_fn()
def test_taskspec_shell_cmd_with_cwd_verbose(capsys):
def test_taskspec_shell_cmd_with_cwd_verbose(capsys: pytest.CaptureFixture[str]):
"""Test TaskSpec._wrap_cmd with shell command, cwd and verbose=True."""
with tempfile.TemporaryDirectory() as tmpdir:
if sys.platform == "win32":
+4 -4
View File
@@ -11,7 +11,7 @@ _NODE_DONE = ...
class _NodeInfo:
__slots__: list[str]
def __init__(self, node) -> None: ...
def __init__(self, node: Any) -> None: ...
class CycleError(ValueError):
"""Subclass of ValueError raised by TopologicalSorterif cycles exist in the graph
@@ -29,8 +29,8 @@ class CycleError(ValueError):
class TopologicalSorter:
"""Provides functionality to topologically sort a graph of hashable nodes"""
def __init__(self, graph=...) -> None: ...
def add(self, node, *predecessors) -> None:
def __init__(self, graph: Any) -> None: ...
def add(self, node: Any, *predecessors: Any) -> None:
"""Add a new node and its predecessors to the graph.
Both the *node* and all elements in *predecessors* must be hashable.
@@ -86,7 +86,7 @@ class TopologicalSorter:
...
def __bool__(self) -> bool: ...
def done(self, *nodes) -> None:
def done(self, *nodes: Any) -> None:
"""Marks a set of nodes returned by "get_ready" as processed.
This method unblocks any successor of each node in *nodes* for being returned
Generated
+5 -1
View File
@@ -2184,10 +2184,12 @@ wheels = [
[[package]]
name = "pyflowx"
version = "0.1.8"
version = "0.2.3"
source = { editable = "." }
dependencies = [
{ name = "graphlib-backport", marker = "python_full_version < '3.9'" },
{ name = "typing-extensions", version = "4.13.2", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version < '3.9'" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version >= '3.9'" },
]
[package.optional-dependencies]
@@ -2257,6 +2259,7 @@ requires-dist = [
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" },
{ name = "tox", marker = "extra == 'dev'", specifier = ">=4.25.0" },
{ name = "tox-uv", marker = "extra == 'dev'", specifier = ">=1.13.1" },
{ name = "typing-extensions", specifier = ">=4.13.2" },
]
provides-extras = ["dev", "office"]
@@ -3179,6 +3182,7 @@ name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
resolution-markers = [
"python_full_version >= '3.15'",
"python_full_version >= '3.10' and python_full_version < '3.15'",
"python_full_version > '3.9' and python_full_version < '3.10'",
"python_full_version == '3.9'",