"""Tests for ops.bumpversion module.""" from __future__ import annotations import subprocess from pathlib import Path import pytest from pyflowx.ops import bumpversion @pytest.fixture(autouse=True) def auto_use_tmp_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """自动使用临时路径.""" monkeypatch.chdir(tmp_path) # ---------------------------------------------------------------------- # # bump_file_version # ---------------------------------------------------------------------- # class TestBumpFileVersion: """Test bump_file_version function.""" @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") @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") 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 assert "build" not in content 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 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 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 bumpversion.bump_file_version(f, "patch") 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") def raise_oserror(*_args: object, **_kwargs: object) -> None: raise OSError("write failed") monkeypatch.setattr(Path, "write_text", raise_oserror) with pytest.raises(OSError, match="write failed"): bumpversion.bump_file_version(f, "patch") # ---------------------------------------------------------------------- # # bump_project_version (核心 bug 修复: 不同步文件统一同步) # ---------------------------------------------------------------------- # class TestBumpProjectVersion: """Test bump_project_version function.""" @staticmethod def _mock_subprocess(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]: """Mock subprocess.run, 返回调用记录列表.""" calls: list[list[str]] = [] def fake_run(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[bytes]: calls.append(cmd) return subprocess.CompletedProcess(cmd, 0, b"", b"") monkeypatch.setattr(subprocess, "run", fake_run) return calls def test_unsynced_files_synchronized( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """核心 bug 修复: 不同步的文件应统一同步到同一新版本号. 场景: __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") calls = self._mock_subprocess(monkeypatch) result = bumpversion.bump_project_version("patch") 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 add_calls = [c for c in calls if c[:2] == ["git", "add"]] assert len(add_calls) == 1 # 跨平台: Windows 上 Path 转换为反斜杠, 统一用正斜杠比较 init_path = str(init_file.relative_to(tmp_path)).replace("\\", "/") assert init_path in [arg.replace("\\", "/") for arg in add_calls[0]] assert "pyproject.toml" in add_calls[0] assert "." not in add_calls[0][2:] 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] 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 def test_files_without_version_returns_none( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """有文件但所有文件都无版本号返回 None.""" f = tmp_path / "__init__.py" f.write_text("# no version here", encoding="utf-8") self._mock_subprocess(monkeypatch) assert bumpversion.bump_project_version("patch") is None assert "未能从任何文件读取版本号" in capsys.readouterr().out def test_no_tag_skips_tag_creation(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """no_tag=True 跳过 tag 创建.""" pyproj = tmp_path / "pyproject.toml" pyproj.write_text('version = "1.0.0"', encoding="utf-8") calls = self._mock_subprocess(monkeypatch) assert bumpversion.bump_project_version("patch", no_tag=True) == "1.0.1" assert not any(c[:2] == ["git", "tag"] for c in calls) def test_ignored_dirs_excluded(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """.venv 等忽略目录中的版本号文件不被处理.""" venv_init = tmp_path / ".venv" / "lib" / "pkg" / "__init__.py" venv_init.parent.mkdir(parents=True) venv_init.write_text('__version__ = "0.1.0"', encoding="utf-8") pyproj = tmp_path / "pyproject.toml" pyproj.write_text('version = "1.0.0"', encoding="utf-8") self._mock_subprocess(monkeypatch) assert bumpversion.bump_project_version("patch") == "1.0.1" assert venv_init.read_text(encoding="utf-8") == '__version__ = "0.1.0"'