From 9a96e5d052824537294cfcf3ca412a69f8d02b35 Mon Sep 17 00:00:00 2001 From: gooker_young Date: Sun, 5 Jul 2026 19:34:44 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20bump=5Fproject=5Fve?= =?UTF-8?q?rsion=20=E7=89=88=E6=9C=AC=E5=8F=B7=E4=B8=8D=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E8=B7=B3=E5=8F=B7=20bug=20=E5=B9=B6=E6=8A=BD=E7=A6=BB=E5=88=B0?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原实现对每个文件独立 +1, 文件版本号不同步时跳号; 改为先读取所有文件取 max 作为基准再统一写入. 同时修复 git add . 违规 (改按文件名) 与 check=False 吞错误. bumpversion 从 ops/dev.py 抽离到 ops/bumpversion.py, 测试简化为 16 个核心场景. --- src/pyflowx/ops/__init__.py | 7 +- src/pyflowx/ops/bumpversion.py | 233 ++++++++++++++++++++++ src/pyflowx/ops/dev.py | 191 +----------------- tests/cli/test_bumpversion.py | 350 +++++++++++++-------------------- tests/test_registry.py | 12 +- 5 files changed, 380 insertions(+), 413 deletions(-) create mode 100644 src/pyflowx/ops/bumpversion.py diff --git a/src/pyflowx/ops/__init__.py b/src/pyflowx/ops/__init__.py index d8b2663..38637fc 100644 --- a/src/pyflowx/ops/__init__.py +++ b/src/pyflowx/ops/__init__.py @@ -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"] diff --git a/src/pyflowx/ops/bumpversion.py b/src/pyflowx/ops/bumpversion.py new file mode 100644 index 0000000..9047385 --- /dev/null +++ b/src/pyflowx/ops/bumpversion.py @@ -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"(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)" + r"(?:-(?P(?: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[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?" + r'["\']', + re.MULTILINE, +) + +_INIT_VERSION_PATTERN = re.compile( + r'(?:^|\n)\s*__version__\s*=\s*["\']' + r"(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)" + r"(?:-(?P(?: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[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 diff --git a/src/pyflowx/ops/dev.py b/src/pyflowx/ops/dev.py index d106efe..587b3a9 100644 --- a/src/pyflowx/ops/dev.py +++ b/src/pyflowx/ops/dev.py @@ -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"(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)" - r"(?:-(?P(?: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[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?" - r'["\']', - re.MULTILINE, -) - -_INIT_VERSION_PATTERN = re.compile( - r'(?:^|\n)\s*__version__\s*=\s*["\']' - r"(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)" - r"(?:-(?P(?: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[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 私有辅助函数 # ============================================================================ diff --git a/tests/cli/test_bumpversion.py b/tests/cli/test_bumpversion.py index b2e0f0f..5f53887 100644 --- a/tests/cli/test_bumpversion.py +++ b/tests/cli/test_bumpversion.py @@ -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"' diff --git a/tests/test_registry.py b/tests/test_registry.py index 6374524..1bd7e3a 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -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