Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58ee84ded6 | |||
| 9a96e5d052 |
+1
-1
@@ -22,7 +22,7 @@ license = { text = "MIT" }
|
||||
name = "pyflowx"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
version = "0.4.6"
|
||||
version = "0.4.7"
|
||||
|
||||
[project.scripts]
|
||||
dockercmd = "pyflowx.cli.dev.dockercmd:main"
|
||||
|
||||
@@ -102,7 +102,7 @@ from .task import (
|
||||
)
|
||||
from .yaml_loader import YamlLoadError, build_cli_parser, load_yaml, parse_yaml_string, run_cli, run_yaml
|
||||
|
||||
__version__ = "0.4.6"
|
||||
__version__ = "0.4.7"
|
||||
|
||||
__all__ = [
|
||||
"IS_LINUX",
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
子模块
|
||||
------
|
||||
- :mod:`files` —— 文件日期/等级/备份/压缩相关函数
|
||||
- :mod:`dev` —— 开发工具 (ruff/版本号/pip/git) 相关函数
|
||||
- :mod:`dev` —— 开发工具 (ruff/pip/git) 相关函数
|
||||
- :mod:`bumpversion` —— 版本号管理相关函数
|
||||
- :mod:`media` —— PDF/截图相关函数
|
||||
- :mod:`system` —— LS-DYNA/SSH/打包相关函数
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import dev, files, media, system
|
||||
from . import bumpversion, dev, files, media, system
|
||||
|
||||
__all__ = ["dev", "files", "media", "system"]
|
||||
__all__ = ["bumpversion", "dev", "files", "media", "system"]
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""版本号管理模块.
|
||||
|
||||
提供单文件版本号更新 (``bump_file_version``) 与项目级批量版本号同步
|
||||
(``bump_project_version``) 能力. 所有公共函数通过 ``@px.register_fn`` 注册,
|
||||
供 YAML 任务编排引用.
|
||||
|
||||
设计要点
|
||||
--------
|
||||
``bump_project_version`` 采用 "先读取基准、再统一写入" 的两阶段策略:
|
||||
先扫描所有 ``__init__.py`` / ``pyproject.toml`` 文件, 读取各自的版本号,
|
||||
取最大值作为基准版本计算新版本号, 然后把新版本号统一写入所有文件,
|
||||
避免文件间版本号不同步导致的跳号问题.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import pyflowx as px
|
||||
|
||||
__all__ = [
|
||||
"BumpVersionType",
|
||||
"bump_file_version",
|
||||
"bump_project_version",
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# 配置
|
||||
# ============================================================================
|
||||
|
||||
BumpVersionType = Literal["patch", "minor", "major"]
|
||||
|
||||
_PYPROJECT_VERSION_PATTERN = re.compile(
|
||||
r'(?:^|\n)\s*version\s*=\s*["\']'
|
||||
r"(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
|
||||
r"(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?"
|
||||
r"(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?"
|
||||
r'["\']',
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
_INIT_VERSION_PATTERN = re.compile(
|
||||
r'(?:^|\n)\s*__version__\s*=\s*["\']'
|
||||
r"(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
|
||||
r"(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?"
|
||||
r"(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?"
|
||||
r'["\']',
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
_IGNORE_DIRS = frozenset({".venv", "venv", ".git", "__pycache__", ".tox", "node_modules", "build", "dist", ".eggs"})
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 私有辅助函数
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _get_pattern_for_file(file_name: str) -> re.Pattern[str] | None:
|
||||
"""根据文件类型获取对应的正则表达式."""
|
||||
if file_name == "pyproject.toml":
|
||||
return _PYPROJECT_VERSION_PATTERN
|
||||
if file_name == "__init__.py":
|
||||
return _INIT_VERSION_PATTERN
|
||||
return None
|
||||
|
||||
|
||||
def _calculate_new_version(major: int, minor: int, patch: int, part: BumpVersionType) -> str:
|
||||
"""计算新版本号."""
|
||||
if part == "major":
|
||||
return f"{major + 1}.0.0"
|
||||
if part == "minor":
|
||||
return f"{major}.{minor + 1}.0"
|
||||
return f"{major}.{minor}.{patch + 1}"
|
||||
|
||||
|
||||
def _build_replacement_string(original_match: str, new_version: str, file_name: str) -> str:
|
||||
"""构建替换字符串, 保留原始格式."""
|
||||
quote_char = '"' if '"' in original_match else "'"
|
||||
key = "__version__" if file_name == "__init__.py" else "version"
|
||||
prefix_match = re.match(rf"(\s*{key}\s*=\s*)[\"']", original_match)
|
||||
prefix = prefix_match.group(1) if prefix_match else f"{key} = "
|
||||
return f"{prefix}{quote_char}{new_version}{quote_char}"
|
||||
|
||||
|
||||
def _read_version_tuple(file_path: Path) -> tuple[int, int, int] | None:
|
||||
"""从文件中读取版本号, 返回 (major, minor, patch) 元组; 未找到返回 None.
|
||||
|
||||
读取失败时抛出 ``OSError`` / ``UnicodeDecodeError`` 由调用方处理.
|
||||
"""
|
||||
pattern = _get_pattern_for_file(file_path.name)
|
||||
if pattern is None:
|
||||
return None
|
||||
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
match = pattern.search(content)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
return int(match.group("major")), int(match.group("minor")), int(match.group("patch"))
|
||||
|
||||
|
||||
def _write_version_to_file(file_path: Path, new_version: str) -> bool:
|
||||
"""把新版本号写入指定文件; 成功返回 True, 未匹配到版本号返回 False."""
|
||||
pattern = _get_pattern_for_file(file_path.name)
|
||||
if pattern is None: # pragma: no cover - 调用方已保证 pattern 不为 None
|
||||
return False
|
||||
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
match = pattern.search(content)
|
||||
if not match: # pragma: no cover - 调用方已通过 _read_version_tuple 验证
|
||||
return False
|
||||
|
||||
replacement = _build_replacement_string(match.group(0), new_version, file_path.name)
|
||||
content = content.replace(match.group(0), replacement)
|
||||
|
||||
try:
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
except OSError as e:
|
||||
print(f"更新文件 {file_path} 版本号时出错: {e}")
|
||||
raise
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 公共函数
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@px.register_fn
|
||||
def bump_file_version(file_path: Path, part: BumpVersionType = "patch") -> str | None:
|
||||
"""更新单个文件中的版本号.
|
||||
|
||||
读取文件当前版本号, 按 ``part`` 指定的部分递增, 写回文件.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
file_path : Path
|
||||
要更新的文件路径 (``pyproject.toml`` 或 ``__init__.py``)
|
||||
part : BumpVersionType
|
||||
版本部分: patch, minor, major
|
||||
|
||||
Returns
|
||||
-------
|
||||
str | None
|
||||
更新后的新版本号; 文件中未找到版本号或读取失败时返回 None
|
||||
"""
|
||||
version_tuple = _read_version_tuple(file_path)
|
||||
if version_tuple is None:
|
||||
print(f"文件 {file_path} 中未找到版本号模式")
|
||||
return None
|
||||
|
||||
major, minor, patch = version_tuple
|
||||
new_version = _calculate_new_version(major, minor, patch, part)
|
||||
|
||||
if not _write_version_to_file(file_path, new_version): # pragma: no cover - _read_version_tuple 已验证
|
||||
return None
|
||||
|
||||
return new_version
|
||||
|
||||
|
||||
@px.register_fn
|
||||
def bump_project_version(part: BumpVersionType = "patch", no_tag: bool = False) -> str | None:
|
||||
"""批量同步项目所有版本号文件并提交.
|
||||
|
||||
扫描当前目录下所有 ``__init__.py`` 和 ``pyproject.toml`` 文件
|
||||
(排除虚拟环境和缓存目录), 先读取每个文件的当前版本号取最大值作为基准,
|
||||
计算新版本号后统一写入所有文件, 最后执行 git add (按文件名) + commit + tag.
|
||||
|
||||
采用 "先读取基准、再统一写入" 的两阶段策略, 即使某些文件版本号不同步,
|
||||
也能在一次 bump 后重新对齐, 避免跳号.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
part : BumpVersionType
|
||||
版本部分: patch, minor, major
|
||||
no_tag : bool
|
||||
提交后不创建 git tag
|
||||
|
||||
Returns
|
||||
-------
|
||||
str | None
|
||||
更新后的新版本号; 未找到版本号文件时返回 None
|
||||
"""
|
||||
all_files: set[Path] = set()
|
||||
for pattern in ("__init__.py", "pyproject.toml"):
|
||||
for file in Path.cwd().rglob(pattern):
|
||||
if not any(ignore_dir in file.parts for ignore_dir in _IGNORE_DIRS):
|
||||
all_files.add(file)
|
||||
|
||||
if not all_files:
|
||||
print("未找到包含版本号的文件")
|
||||
return None
|
||||
|
||||
print(f"找到 {len(all_files)} 个文件需要更新版本号")
|
||||
cwd = Path.cwd()
|
||||
for file in sorted(all_files):
|
||||
print(f" - {file.relative_to(cwd)}")
|
||||
|
||||
# 阶段 1: 读取所有文件版本号, 取最大值作为基准
|
||||
versions: list[tuple[int, int, int]] = []
|
||||
for file in sorted(all_files):
|
||||
v = _read_version_tuple(file)
|
||||
if v is not None:
|
||||
versions.append(v)
|
||||
|
||||
if not versions:
|
||||
print("未能从任何文件读取版本号")
|
||||
return None
|
||||
|
||||
major, minor, patch = max(versions)
|
||||
new_version = _calculate_new_version(major, minor, patch, part)
|
||||
print(f"基准版本: {major}.{minor}.{patch} -> 新版本: {new_version}")
|
||||
|
||||
# 阶段 2: 统一写入新版本号到所有文件
|
||||
for file in sorted(all_files):
|
||||
_write_version_to_file(file, new_version)
|
||||
|
||||
# 阶段 3: git add (按文件名) + commit + tag
|
||||
relative_files = [str(file.relative_to(cwd)) for file in sorted(all_files)]
|
||||
subprocess.run(["git", "add", *relative_files], check=True)
|
||||
subprocess.run(["git", "commit", "-m", f"bump version to {new_version}"], check=True)
|
||||
|
||||
if not no_tag:
|
||||
tag_name = f"v{new_version}"
|
||||
subprocess.run(["git", "tag", "-a", tag_name, "-m", f"Release {tag_name}"], check=True)
|
||||
print(f"已创建标签: {tag_name}")
|
||||
|
||||
return new_version
|
||||
+3
-188
@@ -1,18 +1,16 @@
|
||||
"""开发工具类函数模块.
|
||||
|
||||
聚合自动格式化 (autofmt)、版本号管理 (bumpversion)、pip 包管理 (piptool)、
|
||||
git 工具 (gittool) 的可复用函数. 所有公共函数通过 ``@px.register_fn`` 注册,
|
||||
供 YAML 任务编排引用.
|
||||
聚合自动格式化 (autofmt)、pip 包管理 (piptool)、git 工具 (gittool) 的可复用函数.
|
||||
版本号管理已抽离到 :mod:`pyflowx.ops.bumpversion`. 所有公共函数通过
|
||||
``@px.register_fn`` 注册, 供 YAML 任务编排引用.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import fnmatch
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import pyflowx as px
|
||||
|
||||
@@ -21,11 +19,8 @@ __all__ = [
|
||||
"PACKAGE_DIR",
|
||||
"REQUIREMENTS_FILE",
|
||||
"_PROTECTED_PACKAGES",
|
||||
"BumpVersionType",
|
||||
"add_docstring",
|
||||
"auto_add_docstrings",
|
||||
"bump_file_version",
|
||||
"bump_project_version",
|
||||
"format_all",
|
||||
"format_with_ruff",
|
||||
"generate_module_docstring",
|
||||
@@ -62,30 +57,6 @@ IGNORE_PATTERNS = [
|
||||
".mypy_cache",
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# bumpversion 配置
|
||||
# ============================================================================
|
||||
|
||||
BumpVersionType = Literal["patch", "minor", "major"]
|
||||
|
||||
_PYPROJECT_VERSION_PATTERN = re.compile(
|
||||
r'(?:^|\n)\s*version\s*=\s*["\']'
|
||||
r"(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
|
||||
r"(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?"
|
||||
r"(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?"
|
||||
r'["\']',
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
_INIT_VERSION_PATTERN = re.compile(
|
||||
r'(?:^|\n)\s*__version__\s*=\s*["\']'
|
||||
r"(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
|
||||
r"(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?"
|
||||
r"(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?"
|
||||
r'["\']',
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# piptool 配置
|
||||
# ============================================================================
|
||||
@@ -289,162 +260,6 @@ def format_all(root_dir: Path) -> None:
|
||||
print(f"格式化完成: {root_dir}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# bumpversion 私有辅助函数
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _get_pattern_for_file(file_name: str) -> re.Pattern[str] | None:
|
||||
"""根据文件类型获取对应的正则表达式."""
|
||||
if file_name == "pyproject.toml":
|
||||
return _PYPROJECT_VERSION_PATTERN
|
||||
if file_name == "__init__.py":
|
||||
return _INIT_VERSION_PATTERN
|
||||
return None
|
||||
|
||||
|
||||
def _calculate_new_version(major: int, minor: int, patch: int, part: BumpVersionType) -> str:
|
||||
"""计算新版本号."""
|
||||
if part == "major":
|
||||
return f"{major + 1}.0.0"
|
||||
if part == "minor":
|
||||
return f"{major}.{minor + 1}.0"
|
||||
return f"{major}.{minor}.{patch + 1}"
|
||||
|
||||
|
||||
def _build_replacement_string(original_match: str, new_version: str, file_name: str) -> str:
|
||||
"""构建替换字符串, 保留原始格式."""
|
||||
quote_char = '"' if '"' in original_match else "'"
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# bumpversion 函数
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@px.register_fn
|
||||
def bump_file_version(file_path: Path, part: BumpVersionType = "patch") -> str | None:
|
||||
"""更新文件中的版本号.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
file_path : Path
|
||||
要更新的文件路径
|
||||
part : BumpVersionType
|
||||
版本部分: patch, minor, major
|
||||
|
||||
Returns
|
||||
-------
|
||||
str | None
|
||||
更新后的新版本号, 如果文件中未找到版本号则返回 None
|
||||
"""
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
print(f"读取文件 {file_path} 时出错: {e}")
|
||||
raise
|
||||
|
||||
pattern = _get_pattern_for_file(file_path.name)
|
||||
|
||||
if pattern:
|
||||
match = pattern.search(content)
|
||||
else:
|
||||
match = _PYPROJECT_VERSION_PATTERN.search(content) or _INIT_VERSION_PATTERN.search(content)
|
||||
|
||||
if not match:
|
||||
print(f"文件 {file_path} 中未找到版本号模式")
|
||||
return None
|
||||
|
||||
major = int(match.group("major"))
|
||||
minor = int(match.group("minor"))
|
||||
patch = int(match.group("patch"))
|
||||
|
||||
new_version = _calculate_new_version(major, minor, patch, part)
|
||||
|
||||
original_match = match.group(0)
|
||||
replacement = _build_replacement_string(original_match, new_version, file_path.name)
|
||||
|
||||
content = content.replace(original_match, replacement)
|
||||
|
||||
try:
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
except Exception as e:
|
||||
print(f"更新文件 {file_path} 版本号时出错: {e}")
|
||||
raise
|
||||
|
||||
return new_version
|
||||
|
||||
|
||||
@px.register_fn
|
||||
def bump_project_version(part: BumpVersionType = "patch", no_tag: bool = False) -> str | None:
|
||||
"""批量更新项目所有文件的版本号并提交.
|
||||
|
||||
搜索当前目录下所有 ``__init__.py`` 和 ``pyproject.toml`` 文件
|
||||
(排除虚拟环境和缓存目录), 更新版本号, 然后执行 git add + commit + tag.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
part : BumpVersionType
|
||||
版本部分: patch, minor, major
|
||||
no_tag : bool
|
||||
提交后不创建 git tag
|
||||
|
||||
Returns
|
||||
-------
|
||||
str | None
|
||||
更新后的新版本号, 如果未找到版本号文件则返回 None
|
||||
"""
|
||||
ignore_dirs = {".venv", "venv", ".git", "__pycache__", ".tox", "node_modules", "build", "dist", ".eggs"}
|
||||
all_files: set[Path] = set()
|
||||
|
||||
for pattern in ["__init__.py", "pyproject.toml"]:
|
||||
for file in Path.cwd().rglob(pattern):
|
||||
if not any(ignore_dir in file.parts for ignore_dir in ignore_dirs):
|
||||
all_files.add(file)
|
||||
|
||||
if not all_files:
|
||||
print("未找到包含版本号的文件")
|
||||
return None
|
||||
|
||||
print(f"找到 {len(all_files)} 个文件需要更新版本号")
|
||||
for file in sorted(all_files):
|
||||
print(f" - {file.relative_to(Path.cwd())}")
|
||||
|
||||
new_version: str | None = None
|
||||
for file in sorted(all_files):
|
||||
version = bump_file_version(file, part)
|
||||
if version is not None and new_version is None:
|
||||
new_version = version
|
||||
|
||||
if not new_version:
|
||||
print("未能获取新版本号")
|
||||
return None
|
||||
|
||||
print(f"版本号已更新为: {new_version}")
|
||||
|
||||
subprocess.run(["git", "add", "."], check=False)
|
||||
subprocess.run(["git", "commit", "-m", f"bump version to {new_version}"], check=False)
|
||||
|
||||
if not no_tag:
|
||||
tag_name = f"v{new_version}"
|
||||
subprocess.run(["git", "tag", "-a", tag_name, "-m", f"Release {tag_name}"], check=False)
|
||||
print(f"已创建标签: {tag_name}")
|
||||
|
||||
return new_version
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# piptool 私有辅助函数
|
||||
# ============================================================================
|
||||
|
||||
+134
-216
@@ -1,13 +1,13 @@
|
||||
"""Tests for cli.bumpversion module."""
|
||||
"""Tests for ops.bumpversion module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from pyflowx.ops import dev
|
||||
from pyflowx.ops import bumpversion
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -22,251 +22,169 @@ def auto_use_tmp_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class TestBumpFileVersion:
|
||||
"""Test bump_file_version function."""
|
||||
|
||||
def test_bump_patch_version(self, tmp_path: Path) -> None:
|
||||
"""Should bump patch version correctly."""
|
||||
test_file = tmp_path / "pyproject.toml"
|
||||
test_file.write_text('version = "1.2.3"', encoding="utf-8")
|
||||
@pytest.mark.parametrize(
|
||||
("part", "expected"),
|
||||
[("patch", "1.2.4"), ("minor", "1.3.0"), ("major", "2.0.0")],
|
||||
)
|
||||
def test_bump_pyproject(self, tmp_path: Path, part: str, expected: str) -> None:
|
||||
"""pyproject.toml 三种 part 递增."""
|
||||
f = tmp_path / "pyproject.toml"
|
||||
f.write_text('version = "1.2.3"', encoding="utf-8")
|
||||
assert bumpversion.bump_file_version(f, part) == expected # type: ignore[arg-type]
|
||||
assert f'version = "{expected}"' in f.read_text(encoding="utf-8")
|
||||
|
||||
result = dev.bump_file_version(test_file, "patch")
|
||||
@pytest.mark.parametrize(
|
||||
("part", "expected"),
|
||||
[("patch", "1.2.4"), ("minor", "1.3.0"), ("major", "2.0.0")],
|
||||
)
|
||||
def test_bump_init_py(self, tmp_path: Path, part: str, expected: str) -> None:
|
||||
"""__init__.py 三种 part 递增."""
|
||||
f = tmp_path / "__init__.py"
|
||||
f.write_text('__version__ = "1.2.3"', encoding="utf-8")
|
||||
assert bumpversion.bump_file_version(f, part) == expected # type: ignore[arg-type]
|
||||
assert f'__version__ = "{expected}"' in f.read_text(encoding="utf-8")
|
||||
|
||||
assert result == "1.2.4"
|
||||
assert test_file.read_text(encoding="utf-8") == 'version = "1.2.4"'
|
||||
|
||||
def test_bump_minor_version(self, tmp_path: Path) -> None:
|
||||
"""Should bump minor version correctly."""
|
||||
test_file = tmp_path / "pyproject.toml"
|
||||
test_file.write_text('version = "1.2.3"', encoding="utf-8")
|
||||
|
||||
result = dev.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 = dev.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 = dev.bump_file_version(test_file, "patch")
|
||||
|
||||
assert result == "1.2.4"
|
||||
# 预发布版本应该被清除
|
||||
content = test_file.read_text(encoding="utf-8")
|
||||
def test_prerelease_and_build_metadata_stripped(self, tmp_path: Path) -> None:
|
||||
"""prerelease 和 build metadata 应被清除."""
|
||||
f = tmp_path / "pyproject.toml"
|
||||
f.write_text('version = "1.2.3-alpha.1+build.123"', encoding="utf-8")
|
||||
assert bumpversion.bump_file_version(f, "patch") == "1.2.4"
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert "alpha" not in content
|
||||
|
||||
def test_version_pattern_with_build_metadata(self, tmp_path: Path) -> None:
|
||||
"""Should handle version with build metadata."""
|
||||
test_file = tmp_path / "pyproject.toml"
|
||||
test_file.write_text('version = "1.2.3+build.123"', encoding="utf-8")
|
||||
|
||||
result = dev.bump_file_version(test_file, "patch")
|
||||
|
||||
assert result == "1.2.4"
|
||||
# 构建元数据应该被清除
|
||||
content = test_file.read_text(encoding="utf-8")
|
||||
assert "build" not in content
|
||||
|
||||
def test_no_version_found(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Should return None when no version pattern found."""
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.write_text("no version here", encoding="utf-8")
|
||||
def test_dependencies_not_modified(self, tmp_path: Path) -> None:
|
||||
"""只更新 project version, 不动 dependencies 中的版本号."""
|
||||
f = tmp_path / "pyproject.toml"
|
||||
f.write_text(
|
||||
'[project]\nversion = "1.0.0"\ndependencies = ["lib >= 2.0.0", "other >= 3.0.0"]\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert bumpversion.bump_file_version(f, "patch") == "1.0.1"
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert 'version = "1.0.1"' in content
|
||||
assert "lib >= 2.0.0" in content
|
||||
assert "other >= 3.0.0" in content
|
||||
|
||||
result = dev.bump_file_version(test_file, "patch")
|
||||
def test_no_version_pattern_returns_none(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""未匹配到版本号模式返回 None (支持类型但无版本 / 不支持的文件类型)."""
|
||||
f1 = tmp_path / "__init__.py"
|
||||
f1.write_text("# no version here", encoding="utf-8")
|
||||
assert bumpversion.bump_file_version(f1, "patch") is None
|
||||
assert "未找到版本号模式" in capsys.readouterr().out
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
assert "未找到版本号模式" in captured.out
|
||||
|
||||
def test_utf8_encoding(self, tmp_path: Path) -> None:
|
||||
"""Should handle UTF-8 encoded files correctly."""
|
||||
test_file = tmp_path / "__init__.py"
|
||||
test_file.write_text('__version__ = "1.2.3"', encoding="utf-8")
|
||||
|
||||
result = dev.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 = dev.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 = dev.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 = dev.bump_file_version(test_file, "patch")
|
||||
|
||||
assert result == "1.0.1"
|
||||
updated = test_file.read_text(encoding="utf-8")
|
||||
assert 'version = "1.0.1"' in updated
|
||||
# 确保 dependencies 中的版本没有被更新
|
||||
assert "lib >= 2.0.0" in updated
|
||||
assert "other >= 3.0.0" in updated
|
||||
|
||||
def test_file_read_error(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Should handle file read errors."""
|
||||
# 创建一个目录而不是文件
|
||||
test_file = tmp_path / "test_dir"
|
||||
test_file.mkdir()
|
||||
f2 = tmp_path / "test.txt"
|
||||
f2.write_text("no version here", encoding="utf-8")
|
||||
assert bumpversion.bump_file_version(f2, "patch") is None
|
||||
|
||||
def test_read_directory_raises(self, tmp_path: Path) -> None:
|
||||
"""读取目录 (名为 __init__.py) 应抛异常."""
|
||||
f = tmp_path / "__init__.py"
|
||||
f.mkdir()
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
dev.bump_file_version(test_file, "patch")
|
||||
bumpversion.bump_file_version(f, "patch")
|
||||
|
||||
def test_file_write_error(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Should handle file write errors."""
|
||||
if os.geteuid() == 0:
|
||||
pytest.skip("测试在 root 权限下不适用")
|
||||
def test_write_failure_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""写入失败应抛 OSError."""
|
||||
f = tmp_path / "__init__.py"
|
||||
f.write_text('__version__ = "1.0.0"', encoding="utf-8")
|
||||
|
||||
# 在只读目录中创建文件(这个测试在某些系统上可能不适用)
|
||||
test_file = tmp_path / "readonly.toml"
|
||||
test_file.write_text('version = "1.0.0"', encoding="utf-8")
|
||||
# 设置为只读
|
||||
test_file.chmod(0o444)
|
||||
def raise_oserror(*_args: object, **_kwargs: object) -> None:
|
||||
raise OSError("write failed")
|
||||
|
||||
try:
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
dev.bump_file_version(test_file, "patch")
|
||||
finally:
|
||||
# 恢复权限以便清理
|
||||
test_file.chmod(0o644)
|
||||
monkeypatch.setattr(Path, "write_text", raise_oserror)
|
||||
with pytest.raises(OSError, match="write failed"):
|
||||
bumpversion.bump_file_version(f, "patch")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Version pattern tests
|
||||
# bump_project_version (核心 bug 修复: 不同步文件统一同步)
|
||||
# ---------------------------------------------------------------------- #
|
||||
class TestVersionPattern:
|
||||
"""Test version pattern matching."""
|
||||
class TestBumpProjectVersion:
|
||||
"""Test bump_project_version function."""
|
||||
|
||||
def test_simple_version(self, tmp_path: Path) -> None:
|
||||
"""Should match simple version."""
|
||||
test_file = tmp_path / "__init__.py"
|
||||
test_file.write_text('__version__ = "1.0.0"', encoding="utf-8")
|
||||
@staticmethod
|
||||
def _mock_subprocess(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]:
|
||||
"""Mock subprocess.run, 返回调用记录列表."""
|
||||
calls: list[list[str]] = []
|
||||
|
||||
result = dev.bump_file_version(test_file, "patch")
|
||||
def fake_run(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[bytes]:
|
||||
calls.append(cmd)
|
||||
return subprocess.CompletedProcess(cmd, 0, b"", b"")
|
||||
|
||||
assert result == "1.0.1"
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
return calls
|
||||
|
||||
def test_version_with_zeros(self, tmp_path: Path) -> None:
|
||||
"""Should handle versions with zeros correctly."""
|
||||
test_file = tmp_path / "__init__.py"
|
||||
test_file.write_text('__version__ = "0.0.0"', encoding="utf-8")
|
||||
def test_unsynced_files_synchronized(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""核心 bug 修复: 不同步的文件应统一同步到同一新版本号.
|
||||
|
||||
result = dev.bump_file_version(test_file, "patch")
|
||||
场景: __init__.py = 0.4.5, pyproject.toml = 0.3.5 (历史不同步)
|
||||
期望: bump patch 后两者都变为 0.4.6 (取最大值 0.4.5 作为基准 +1)
|
||||
"""
|
||||
init_file = tmp_path / "src" / "pkg" / "__init__.py"
|
||||
init_file.parent.mkdir(parents=True)
|
||||
init_file.write_text('__version__ = "0.4.5"', encoding="utf-8")
|
||||
pyproj = tmp_path / "pyproject.toml"
|
||||
pyproj.write_text('version = "0.3.5"', encoding="utf-8")
|
||||
|
||||
assert result == "0.0.1"
|
||||
calls = self._mock_subprocess(monkeypatch)
|
||||
|
||||
def test_large_version_numbers(self, tmp_path: Path) -> None:
|
||||
"""Should handle large version numbers."""
|
||||
test_file = tmp_path / "__init__.py"
|
||||
test_file.write_text('__version__ = "10.20.30"', encoding="utf-8")
|
||||
result = bumpversion.bump_project_version("patch")
|
||||
|
||||
result = dev.bump_file_version(test_file, "minor")
|
||||
assert result == "0.4.6"
|
||||
assert '__version__ = "0.4.6"' in init_file.read_text(encoding="utf-8")
|
||||
assert 'version = "0.4.6"' in pyproj.read_text(encoding="utf-8")
|
||||
out = capsys.readouterr().out
|
||||
assert "基准版本: 0.4.5" in out
|
||||
assert "新版本: 0.4.6" in out
|
||||
|
||||
assert result == "10.21.0"
|
||||
add_calls = [c for c in calls if c[:2] == ["git", "add"]]
|
||||
assert len(add_calls) == 1
|
||||
assert "src/pkg/__init__.py" in add_calls[0]
|
||||
assert "pyproject.toml" in add_calls[0]
|
||||
assert "." not in add_calls[0][2:]
|
||||
|
||||
def test_version_in_url(self, tmp_path: Path) -> None:
|
||||
"""Should not match version in URL or other contexts."""
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.write_text("https://example.com/v1.2.3/download", encoding="utf-8")
|
||||
tag_calls = [c for c in calls if c[:2] == ["git", "tag"]]
|
||||
assert len(tag_calls) == 1
|
||||
assert "v0.4.6" in tag_calls[0]
|
||||
|
||||
result = dev.bump_file_version(test_file, "patch")
|
||||
def test_no_files_returns_none(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""无版本号文件返回 None."""
|
||||
assert bumpversion.bump_project_version("patch") is None
|
||||
assert "未找到包含版本号的文件" in capsys.readouterr().out
|
||||
|
||||
# 不应该匹配 URL 中的版本号
|
||||
assert result is None
|
||||
def test_files_without_version_returns_none(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""有文件但所有文件都无版本号返回 None."""
|
||||
f = tmp_path / "__init__.py"
|
||||
f.write_text("# no version here", encoding="utf-8")
|
||||
self._mock_subprocess(monkeypatch)
|
||||
|
||||
assert bumpversion.bump_project_version("patch") is None
|
||||
assert "未能从任何文件读取版本号" in capsys.readouterr().out
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Edge cases
|
||||
# ---------------------------------------------------------------------- #
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and error handling."""
|
||||
def test_no_tag_skips_tag_creation(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""no_tag=True 跳过 tag 创建."""
|
||||
pyproj = tmp_path / "pyproject.toml"
|
||||
pyproj.write_text('version = "1.0.0"', encoding="utf-8")
|
||||
|
||||
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")
|
||||
calls = self._mock_subprocess(monkeypatch)
|
||||
|
||||
result = dev.bump_file_version(test_file, "patch")
|
||||
assert bumpversion.bump_project_version("patch", no_tag=True) == "1.0.1"
|
||||
assert not any(c[:2] == ["git", "tag"] for c in calls)
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
assert "未找到版本号模式" in captured.out
|
||||
def test_ignored_dirs_excluded(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
""".venv 等忽略目录中的版本号文件不被处理."""
|
||||
venv_init = tmp_path / ".venv" / "lib" / "pkg" / "__init__.py"
|
||||
venv_init.parent.mkdir(parents=True)
|
||||
venv_init.write_text('__version__ = "0.1.0"', encoding="utf-8")
|
||||
pyproj = tmp_path / "pyproject.toml"
|
||||
pyproj.write_text('version = "1.0.0"', encoding="utf-8")
|
||||
|
||||
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")
|
||||
self._mock_subprocess(monkeypatch)
|
||||
|
||||
result = dev.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 = dev.bump_file_version(test_file, "patch")
|
||||
assert result1 == "1.0.1"
|
||||
|
||||
# 第二次 bump
|
||||
result2 = dev.bump_file_version(test_file, "minor")
|
||||
assert result2 == "1.1.0"
|
||||
|
||||
# 第三次 bump
|
||||
result3 = dev.bump_file_version(test_file, "major")
|
||||
assert result3 == "2.0.0"
|
||||
|
||||
# 验证最终结果
|
||||
assert test_file.read_text(encoding="utf-8") == '__version__ = "2.0.0"'
|
||||
assert bumpversion.bump_project_version("patch") == "1.0.1"
|
||||
assert venv_init.read_text(encoding="utf-8") == '__version__ = "0.1.0"'
|
||||
|
||||
@@ -227,9 +227,9 @@ class TestOpsModules:
|
||||
"""导入 ``ops`` 后所有函数应已注册."""
|
||||
import inspect
|
||||
|
||||
from pyflowx.ops import dev, files, media, system
|
||||
from pyflowx.ops import bumpversion, dev, files, media, system
|
||||
|
||||
for module in (files, dev, media, system):
|
||||
for module in (files, dev, bumpversion, media, system):
|
||||
for name in module.__all__:
|
||||
obj = getattr(module, name)
|
||||
if not inspect.isfunction(obj):
|
||||
@@ -237,17 +237,17 @@ class TestOpsModules:
|
||||
assert px.has_fn(name), f"{module.__name__}.{name} 未注册"
|
||||
|
||||
def test_total_function_count(self) -> None:
|
||||
"""注册函数总数 = 18+15+18+12 = 63."""
|
||||
from pyflowx.ops import dev, files, media, system # noqa: F401
|
||||
"""注册函数总数 = 16+2+15+18+12 = 63."""
|
||||
from pyflowx.ops import bumpversion, dev, files, media, system # noqa: F401
|
||||
|
||||
all_names = px.FnRegistry.names()
|
||||
assert len(all_names) == 63
|
||||
|
||||
def test_specific_functions_callable(self) -> None:
|
||||
"""关键注册函数可调用."""
|
||||
from pyflowx.ops import dev, files, media, system
|
||||
from pyflowx.ops import bumpversion, files, media, system
|
||||
|
||||
assert px.get_fn("get_file_timestamp") is files.get_file_timestamp
|
||||
assert px.get_fn("bump_file_version") is dev.bump_file_version
|
||||
assert px.get_fn("bump_file_version") is bumpversion.bump_file_version
|
||||
assert px.get_fn("pdf_merge") is media.pdf_merge
|
||||
assert px.get_fn("ssh_copy_id") is system.ssh_copy_id
|
||||
|
||||
Reference in New Issue
Block a user