~bumpversion

This commit is contained in:
2026-06-25 23:21:42 +08:00
parent 0d6a78f320
commit f10f8d09a6
4 changed files with 351 additions and 147 deletions
+1 -1
View File
@@ -84,7 +84,7 @@ from .runner import CliExitCode, CliRunner
from .storage import JSONBackend, MemoryBackend, StateBackend from .storage import JSONBackend, MemoryBackend, StateBackend
from .task import TaskCmd, TaskEvent, TaskResult, TaskSpec, TaskStatus from .task import TaskCmd, TaskEvent, TaskResult, TaskSpec, TaskStatus
__version__ = "0.1.13" __version__ = "0.1.14"
__all__ = [ __all__ = [
"IS_LINUX", "IS_LINUX",
+128 -72
View File
@@ -5,97 +5,153 @@
from __future__ import annotations from __future__ import annotations
import subprocess import argparse
import re
from pathlib import Path
from typing import Literal, get_args
import pyflowx as px import pyflowx as px
# ============================================================================ BumpVersionType = Literal["patch", "minor", "major"]
# 辅助函数
# ============================================================================
def bump_version(part: str = "patch", tag: bool = False, commit: bool = False) -> None: _VERSION_PATTERN = re.compile(
"""递增版本号. 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-]+)*))?",
)
def bump_file_version(file_path: Path, part: BumpVersionType = "patch") -> str | None:
"""更新文件中的版本号.
Parameters Parameters
---------- ----------
part : str file_path : Path
要更新的文件路径
part : BumpVersionType
版本部分: patch, minor, major 版本部分: patch, minor, major
tag : bool
是否创建 Git 标签 Returns
commit : bool -------
是否提交更改 str | None
更新后的新版本号,如果文件中未找到版本号则返回 None
""" """
try: try:
subprocess.run(["bumpversion", part], check=True) content = file_path.read_text(encoding="utf-8")
if commit: except Exception as e:
subprocess.run(["git", "add", "."], check=True) print(f"读取文件 {file_path} 时出错: {e}")
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")
raise raise
match = _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"))
# 计算新版本号
if part == "major":
new_major = major + 1
new_version_str = f"{new_major}.0.0"
elif part == "minor":
new_minor = minor + 1
new_version_str = f"{major}.{new_minor}.0"
else: # patch
new_patch = patch + 1
new_version_str = f"{major}.{minor}.{new_patch}"
content = content.replace(match.group(0), new_version_str)
def bump_version_alpha(part: str = "patch") -> None:
"""递增版本号并添加 alpha 预发布标识."""
try: try:
subprocess.run(["bumpversion", part, "--new-version", f"{part}-alpha"], check=True) file_path.write_text(content, encoding="utf-8")
except FileNotFoundError: except Exception as e:
print("未找到 bumpversion 工具,请先安装: pip install bumpversion") print(f"更新文件 {file_path} 版本号时出错: {e}")
raise raise
return new_version_str
# ============================================================================
# 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
# ============================================================================
def main() -> None: def main() -> None:
"""版本号管理工具主函数.""" """版本号管理工具主函数."""
runner = px.CliRunner( parser = argparse.ArgumentParser(description="BumpVersion - 版本号自动管理工具")
strategy="thread", parser.add_argument(
description="BumpVersion - 版本号自动管理工具", "part",
graphs={ type=str,
# 递增补丁号 (1.0.0 -> 1.0.1) nargs="?",
"p": px.Graph.from_specs([bump_patch]), default="patch",
# 递增次版本号 (1.0.0 -> 1.1.0) choices=get_args(BumpVersionType),
"m": px.Graph.from_specs([bump_minor]), help=f"版本部分: {get_args(BumpVersionType)}",
# 递增主版本号 (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]),
},
) )
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}")
# 提交修改
graph = px.Graph.from_specs([
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"]
),
])
px.run(graph, strategy="sequential")
# 创建 git tag
if not args.no_tag:
tag_name = f"v{new_version}"
graph = px.Graph.from_specs([
px.TaskSpec("git_tag", cmd=["git", "tag", "-a", tag_name, "-m", f"Release {tag_name}"]),
])
px.run(graph, strategy="sequential")
print(f"已创建标签: {tag_name}")
+221 -73
View File
@@ -2,105 +2,253 @@
from __future__ import annotations from __future__ import annotations
from unittest.mock import MagicMock, patch from pathlib import Path
import pytest import pytest
import pyflowx as px
from pyflowx.cli import bumpversion from pyflowx.cli import bumpversion
# ---------------------------------------------------------------------- # # ---------------------------------------------------------------------- #
# bump_version # bump_file_version
# ---------------------------------------------------------------------- # # ---------------------------------------------------------------------- #
class TestBumpVersion: class TestBumpFileVersion:
"""Test bump_version function.""" """Test bump_file_version function."""
def test_bump_version_patch(self) -> None: def test_bump_patch_version(self, tmp_path: Path) -> None:
"""Should bump patch version.""" """Should bump patch version correctly."""
with patch("subprocess.run") as mock_run: test_file = tmp_path / "test.txt"
mock_run.return_value = MagicMock(returncode=0) test_file.write_text("version = 1.2.3", encoding="utf-8")
bumpversion.bump_version("patch")
assert mock_run.called
def test_bump_version_minor(self) -> None: result = bumpversion.bump_file_version(test_file, "patch")
"""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: assert result == "1.2.4"
"""Should bump major version.""" assert test_file.read_text(encoding="utf-8") == "version = 1.2.4"
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: def test_bump_minor_version(self, tmp_path: Path) -> None:
"""Should bump version with tag.""" """Should bump minor version correctly."""
with patch("subprocess.run") as mock_run: test_file = tmp_path / "test.txt"
mock_run.return_value = MagicMock(returncode=0, stdout="v1.0.0") test_file.write_text("version = 1.2.3", encoding="utf-8")
bumpversion.bump_version("patch", tag=True)
assert mock_run.called
def test_bump_version_with_commit(self) -> None: result = bumpversion.bump_file_version(test_file, "minor")
"""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: assert result == "1.3.0"
"""Should handle FileNotFoundError.""" assert test_file.read_text(encoding="utf-8") == "version = 1.3.0"
with patch("subprocess.run", side_effect=FileNotFoundError), pytest.raises(FileNotFoundError):
bumpversion.bump_version("patch") def test_bump_major_version(self, tmp_path: Path) -> None:
"""Should bump major version correctly."""
test_file = tmp_path / "test.txt"
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 / "test.txt"
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 / "test.txt"
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) -> 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 / "test.txt"
test_file.write_text("版本 = 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") == "版本 = 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 first version occurrence."""
test_file = tmp_path / "test.txt"
test_file.write_text("version1 = 1.0.0\nversion2 = 2.0.0", encoding="utf-8")
result = bumpversion.bump_file_version(test_file, "patch")
assert result == "1.0.1"
content = test_file.read_text(encoding="utf-8")
assert "version1 = 1.0.1" in content
assert "version2 = 2.0.0" in content
def test_file_read_error(self, tmp_path: Path, capsys) -> 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) -> None:
"""Should handle file write errors."""
# 在只读目录中创建文件(这个测试在某些系统上可能不适用)
test_file = tmp_path / "readonly.txt"
test_file.write_text("version = 1.0.0", encoding="utf-8")
# 设置为只读
test_file.chmod(0o444)
try:
with pytest.raises(Exception):
bumpversion.bump_file_version(test_file, "patch")
finally:
# 恢复权限以便清理
test_file.chmod(0o644)
# ---------------------------------------------------------------------- # # ---------------------------------------------------------------------- #
# bump_version_alpha # Version pattern tests
# ---------------------------------------------------------------------- # # ---------------------------------------------------------------------- #
class TestBumpVersionAlpha: class TestVersionPattern:
"""Test bump_version_alpha function.""" """Test version pattern matching."""
def test_bump_version_alpha_patch(self) -> None: def test_simple_version(self, tmp_path: Path) -> None:
"""Should bump alpha patch version.""" """Should match simple version."""
with patch("subprocess.run") as mock_run: test_file = tmp_path / "test.txt"
mock_run.return_value = MagicMock(returncode=0) test_file.write_text("1.0.0", encoding="utf-8")
bumpversion.bump_version_alpha("patch")
assert mock_run.called result = bumpversion.bump_file_version(test_file, "patch")
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 / "test.txt"
test_file.write_text("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 / "test.txt"
test_file.write_text("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 only match first version in complex text."""
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")
assert result == "1.2.4"
# ---------------------------------------------------------------------- # # ---------------------------------------------------------------------- #
# TaskSpec definitions # Edge cases
# ---------------------------------------------------------------------- # # ---------------------------------------------------------------------- #
class TestTaskSpecDefinitions: class TestEdgeCases:
"""Test that all TaskSpec definitions are valid.""" """Test edge cases and error handling."""
def test_bump_patch_spec(self) -> None: def test_empty_file(self, tmp_path: Path, capsys) -> None:
"""bump_patch spec should be properly defined.""" """Should handle empty file."""
assert bumpversion.bump_patch.name == "bump_patch" test_file = tmp_path / "empty.txt"
assert bumpversion.bump_patch.fn is not None test_file.write_text("", encoding="utf-8")
def test_bump_minor_spec(self) -> None: result = bumpversion.bump_file_version(test_file, "patch")
"""bump_minor spec should be properly defined."""
assert bumpversion.bump_minor.name == "bump_minor"
assert bumpversion.bump_minor.fn is not None
def test_bump_major_spec(self) -> None: assert result is None
"""bump_major spec should be properly defined.""" captured = capsys.readouterr()
assert bumpversion.bump_major.name == "bump_major" assert "未找到版本号模式" in captured.out
assert bumpversion.bump_major.fn is not None
def test_file_with_special_chars(self, tmp_path: Path) -> None:
"""Should handle file with special characters."""
test_file = tmp_path / "test.txt"
content = "# 中文注释\nversion = 1.0.0\n# 特殊字符: @#$%"
test_file.write_text(content, encoding="utf-8")
# ---------------------------------------------------------------------- # result = bumpversion.bump_file_version(test_file, "patch")
# main function
# ---------------------------------------------------------------------- #
class TestMain:
"""Test main function."""
def test_main_calls_run_cli(self) -> None: assert result == "1.0.1"
"""main() should create a CliRunner and call run_cli().""" updated = test_file.read_text(encoding="utf-8")
with patch.object(px.CliRunner, "run_cli") as mock_run_cli: assert "# 中文注释" in updated
bumpversion.main() assert "# 特殊字符: @#$%" in updated
assert mock_run_cli.called
def test_consecutive_bumps(self, tmp_path: Path) -> None:
"""Should handle consecutive version bumps correctly."""
test_file = tmp_path / "test.txt"
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"
Generated
+1 -1
View File
@@ -2184,7 +2184,7 @@ wheels = [
[[package]] [[package]]
name = "pyflowx" name = "pyflowx"
version = "0.1.12" version = "0.1.13"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "graphlib-backport", marker = "python_full_version < '3.9'" }, { name = "graphlib-backport", marker = "python_full_version < '3.9'" },