Release version 2.1.0

This commit is contained in:
Kevin Veen-Birkenbach
2025-12-08 20:15:13 +01:00
parent b9b64fed7d
commit bc3ff5b67f
11 changed files with 1401 additions and 200 deletions

View File

@@ -8,33 +8,37 @@ from unittest.mock import patch
class TestIntegrationBranchCommands(unittest.TestCase):
"""
E2E-style tests for the 'pkgmgr branch' CLI wiring.
Integration tests for the `pkgmgr branch` CLI wiring.
We do NOT call real git; instead we patch pkgmgr.branch_commands.open_branch
and verify that the CLI invokes it with the correct parameters.
These tests execute the real entry point (main.py) and mock
the high-level `open_branch` helper to ensure that argument
parsing and dispatch behave as expected.
"""
def _run_pkgmgr(self, argv: list[str]) -> None:
def _run_pkgmgr(self, extra_args: list[str]) -> None:
"""
Helper to run 'pkgmgr' via its entry module with a given argv.
Run the main entry point with the given extra args, as if called via:
pkgmgr <extra_args...>
We explicitly set sys.argv and execute main.py as __main__ using runpy.
"""
original_argv = list(sys.argv)
try:
# argv typically looks like: ["pkgmgr", "branch", ...]
sys.argv = argv
# Run the CLI entry point
runpy.run_module("pkgmgr.cli", run_name="__main__")
# argv[0] is the program name; the rest are CLI arguments.
sys.argv = ["pkgmgr"] + list(extra_args)
runpy.run_module("main", run_name="__main__")
finally:
sys.argv = original_argv
@patch("pkgmgr.branch_commands.open_branch")
@patch("pkgmgr.cli_core.commands.branch.open_branch")
def test_branch_open_with_name_and_base(self, mock_open_branch) -> None:
"""
pkgmgr branch open feature/test --base develop
should invoke open_branch(name='feature/test', base_branch='develop', cwd='.')
`pkgmgr branch open feature/test --base develop` must forward
the name and base branch to open_branch() with cwd=".".
"""
self._run_pkgmgr(
["pkgmgr", "branch", "open", "feature/test", "--base", "develop"]
["branch", "open", "feature/test", "--base", "develop"]
)
mock_open_branch.assert_called_once()
@@ -43,14 +47,16 @@ class TestIntegrationBranchCommands(unittest.TestCase):
self.assertEqual(kwargs.get("base_branch"), "develop")
self.assertEqual(kwargs.get("cwd"), ".")
@patch("pkgmgr.branch_commands.open_branch")
def test_branch_open_without_name_uses_default_base(self, mock_open_branch) -> None:
@patch("pkgmgr.cli_core.commands.branch.open_branch")
def test_branch_open_without_name_uses_default_base(
self,
mock_open_branch,
) -> None:
"""
pkgmgr branch open
should invoke open_branch(name=None, base_branch='main', cwd='.')
(the branch name will be asked interactively inside open_branch).
`pkgmgr branch open` without a name must still call open_branch(),
passing name=None and the default base branch 'main'.
"""
self._run_pkgmgr(["pkgmgr", "branch", "open"])
self._run_pkgmgr(["branch", "open"])
mock_open_branch.assert_called_once()
_, kwargs = mock_open_branch.call_args

View File

@@ -1,63 +1,100 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Integration tests for the `pkgmgr release` command.
We deliberately only test a *negative* path here, to avoid mutating
the real repositories (bumping versions, editing changelogs) during
CI runs.
The test verifies that:
- Calling `pkgmgr release` with a non-existent repository identifier
results in a non-zero exit code and a helpful error.
"""
from __future__ import annotations
import os
import runpy
import sys
import unittest
PROJECT_ROOT = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..")
)
class TestIntegrationReleaseCommand(unittest.TestCase):
"""
E2E tests for `pkgmgr release`.
"""
def _run_release_expect_failure(self) -> None:
cmd_repr = "pkgmgr release patch does-not-exist-xyz"
def _run_pkgmgr(
self,
argv: list[str],
expect_success: bool,
) -> None:
"""
Run the main entry point with the given argv and assert on success/failure.
argv must include the program name as argv[0], e.g. "":
["", "release", "patch", "pkgmgr", "--preview"]
"""
cmd_repr = " ".join(argv[1:])
original_argv = list(sys.argv)
try:
sys.argv = [
"pkgmgr",
"release",
"patch",
"does-not-exist-xyz",
]
sys.argv = argv
try:
# Execute main.py as if called via `python main.py ...`
runpy.run_module("main", run_name="__main__")
except SystemExit as exc:
code = exc.code if isinstance(exc.code, int) else str(exc.code)
# Hier wirklich verifizieren:
assert code != 0, f"{cmd_repr!r} unexpectedly succeeded with exit code 0"
print("[TEST] pkgmgr release failed as expected")
print(f"[TEST] Command : {cmd_repr}")
print(f"[TEST] Exit code : {code}")
code = exc.code if isinstance(exc.code, int) else 1
if expect_success and code != 0:
print()
print(f"[TEST] Command : {cmd_repr}")
print(f"[TEST] Exit code : {code}")
raise AssertionError(
f"{cmd_repr!r} failed with exit code {code}. "
"Scroll up to inspect the output printed before failure."
) from exc
if not expect_success and code == 0:
print()
print(f"[TEST] Command : {cmd_repr}")
print(f"[TEST] Exit code : {code}")
raise AssertionError(
f"{cmd_repr!r} unexpectedly succeeded with exit code 0."
) from exc
else:
# Kein SystemExit -> auf jeden Fall falsch
raise AssertionError(
f"{cmd_repr!r} returned normally (expected non-zero exit)."
)
# No SystemExit: treat as success when expect_success is True,
# otherwise as a failure (we expected a non-zero exit).
if not expect_success:
raise AssertionError(
f"{cmd_repr!r} returned normally (expected non-zero exit)."
)
finally:
sys.argv = original_argv
def test_release_for_unknown_repo_fails_cleanly(self) -> None:
self._run_release_expect_failure()
"""
Releasing a non-existent repository identifier must fail
with a non-zero exit code, but without crashing the interpreter.
"""
argv = [
"",
"release",
"patch",
"does-not-exist-xyz",
]
self._run_pkgmgr(argv, expect_success=False)
def test_release_preview_for_pkgmgr_repository(self) -> None:
"""
Sanity-check the happy path for the CLI:
- Runs `pkgmgr release patch pkgmgr --preview`
- Must exit with code 0
- Uses the real configuration + repository selection
- Exercises the new --preview mode end-to-end.
"""
argv = [
"",
"release",
"patch",
"pkgmgr",
"--preview",
]
original_cwd = os.getcwd()
try:
os.chdir(PROJECT_ROOT)
self._run_pkgmgr(argv, expect_success=True)
finally:
os.chdir(original_cwd)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,510 @@
from __future__ import annotations
import os
import tempfile
import textwrap
import unittest
from unittest.mock import patch
from pkgmgr.versioning import SemVer
from pkgmgr.release import (
_determine_current_version,
_bump_semver,
update_pyproject_version,
update_flake_version,
update_pkgbuild_version,
update_spec_version,
update_changelog,
update_debian_changelog,
release,
)
class TestDetermineCurrentVersion(unittest.TestCase):
@patch("pkgmgr.release.get_tags", return_value=[])
def test_determine_current_version_no_tags_returns_zero(
self,
mock_get_tags,
) -> None:
ver = _determine_current_version()
self.assertIsInstance(ver, SemVer)
self.assertEqual((ver.major, ver.minor, ver.patch), (0, 0, 0))
mock_get_tags.assert_called_once()
@patch("pkgmgr.release.find_latest_version")
@patch("pkgmgr.release.get_tags")
def test_determine_current_version_uses_latest_semver_tag(
self,
mock_get_tags,
mock_find_latest_version,
) -> None:
mock_get_tags.return_value = ["v0.1.0", "v1.2.3"]
mock_find_latest_version.return_value = ("v1.2.3", SemVer(1, 2, 3))
ver = _determine_current_version()
self.assertEqual((ver.major, ver.minor, ver.patch), (1, 2, 3))
mock_get_tags.assert_called_once()
mock_find_latest_version.assert_called_once_with(["v0.1.0", "v1.2.3"])
class TestBumpSemVer(unittest.TestCase):
def test_bump_semver_major(self) -> None:
base = SemVer(1, 2, 3)
bumped = _bump_semver(base, "major")
self.assertEqual((bumped.major, bumped.minor, bumped.patch), (2, 0, 0))
def test_bump_semver_minor(self) -> None:
base = SemVer(1, 2, 3)
bumped = _bump_semver(base, "minor")
self.assertEqual((bumped.major, bumped.minor, bumped.patch), (1, 3, 0))
def test_bump_semver_patch(self) -> None:
base = SemVer(1, 2, 3)
bumped = _bump_semver(base, "patch")
self.assertEqual((bumped.major, bumped.minor, bumped.patch), (1, 2, 4))
def test_bump_semver_invalid_type_raises(self) -> None:
base = SemVer(1, 2, 3)
with self.assertRaises(ValueError):
_bump_semver(base, "invalid-type")
class TestUpdatePyprojectVersion(unittest.TestCase):
def test_update_pyproject_version_replaces_version_line(self) -> None:
original = textwrap.dedent(
"""
[project]
name = "example"
version = "0.1.0"
"""
).strip() + "\n"
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "pyproject.toml")
with open(path, "w", encoding="utf-8") as f:
f.write(original)
update_pyproject_version(path, "1.2.3", preview=False)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn('version = "1.2.3"', content)
self.assertNotIn('version = "0.1.0"', content)
def test_update_pyproject_version_preview_does_not_write(self) -> None:
original = textwrap.dedent(
"""
[project]
name = "example"
version = "0.1.0"
"""
).strip() + "\n"
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "pyproject.toml")
with open(path, "w", encoding="utf-8") as f:
f.write(original)
update_pyproject_version(path, "1.2.3", preview=True)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# Content must be unchanged in preview mode
self.assertEqual(content, original)
def test_update_pyproject_version_exits_when_no_version_line_found(self) -> None:
original = textwrap.dedent(
"""
[project]
name = "example"
"""
).strip() + "\n"
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "pyproject.toml")
with open(path, "w", encoding="utf-8") as f:
f.write(original)
with self.assertRaises(SystemExit) as cm:
update_pyproject_version(path, "1.2.3", preview=False)
self.assertNotEqual(cm.exception.code, 0)
class TestUpdateFlakeVersion(unittest.TestCase):
def test_update_flake_version_normal(self) -> None:
original = 'version = "0.1.0";\n'
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "flake.nix")
with open(path, "w", encoding="utf-8") as f:
f.write(original)
update_flake_version(path, "1.2.3", preview=False)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn('version = "1.2.3";', content)
self.assertNotIn('version = "0.1.0";', content)
def test_update_flake_version_preview(self) -> None:
original = 'version = "0.1.0";\n'
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "flake.nix")
with open(path, "w", encoding="utf-8") as f:
f.write(original)
update_flake_version(path, "1.2.3", preview=True)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertEqual(content, original)
class TestUpdatePkgbuildVersion(unittest.TestCase):
def test_update_pkgbuild_version_normal(self) -> None:
original = textwrap.dedent(
"""
pkgname=example
pkgver=0.1.0
pkgrel=5
"""
).strip() + "\n"
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "PKGBUILD")
with open(path, "w", encoding="utf-8") as f:
f.write(original)
update_pkgbuild_version(path, "1.2.3", preview=False)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("pkgver=1.2.3", content)
self.assertIn("pkgrel=1", content)
self.assertNotIn("pkgver=0.1.0", content)
def test_update_pkgbuild_version_preview(self) -> None:
original = textwrap.dedent(
"""
pkgname=example
pkgver=0.1.0
pkgrel=5
"""
).strip() + "\n"
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "PKGBUILD")
with open(path, "w", encoding="utf-8") as f:
f.write(original)
update_pkgbuild_version(path, "1.2.3", preview=True)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertEqual(content, original)
class TestUpdateSpecVersion(unittest.TestCase):
def test_update_spec_version_normal(self) -> None:
original = textwrap.dedent(
"""
Name: package-manager
Version: 0.1.0
Release: 5%{?dist}
"""
).strip() + "\n"
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "package-manager.spec")
with open(path, "w", encoding="utf-8") as f:
f.write(original)
update_spec_version(path, "1.2.3", preview=False)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("Version: 1.2.3", content)
self.assertIn("Release: 1%{?dist}", content)
self.assertNotIn("Version: 0.1.0", content)
self.assertNotIn("Release: 5%{?dist}", content)
def test_update_spec_version_preview(self) -> None:
original = textwrap.dedent(
"""
Name: package-manager
Version: 0.1.0
Release: 5%{?dist}
"""
).strip() + "\n"
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "package-manager.spec")
with open(path, "w", encoding="utf-8") as f:
f.write(original)
update_spec_version(path, "1.2.3", preview=True)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertEqual(content, original)
class TestUpdateChangelog(unittest.TestCase):
def test_update_changelog_creates_file_if_missing(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md")
self.assertFalse(os.path.exists(path))
update_changelog(path, "1.2.3", message="First release", preview=False)
self.assertTrue(os.path.exists(path))
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("## [1.2.3]", content)
self.assertIn("First release", content)
def test_update_changelog_prepends_entry_to_existing_content(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md")
with open(path, "w", encoding="utf-8") as f:
f.write("## [0.1.0] - 2024-01-01\n\n* Initial content\n")
update_changelog(path, "1.0.0", message=None, preview=False)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# New entry must be on top
self.assertTrue(content.startswith("## [1.0.0]"))
self.assertIn("## [0.1.0] - 2024-01-01", content)
def test_update_changelog_preview_does_not_write(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md")
original = "## [0.1.0] - 2024-01-01\n\n* Initial content\n"
with open(path, "w", encoding="utf-8") as f:
f.write(original)
update_changelog(path, "1.0.0", message="Preview only", preview=True)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertEqual(content, original)
class TestUpdateDebianChangelog(unittest.TestCase):
def test_update_debian_changelog_creates_new_stanza(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "changelog")
# existing content
with open(path, "w", encoding="utf-8") as f:
f.write("existing content\n")
with patch.dict(
os.environ,
{
"DEBFULLNAME": "Test Maintainer",
"DEBEMAIL": "test@example.com",
},
clear=False,
):
update_debian_changelog(
debian_changelog_path=path,
package_name="package-manager",
new_version="1.2.3",
message="Test debian entry",
preview=False,
)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("package-manager (1.2.3-1) unstable; urgency=medium", content)
self.assertIn(" * Test debian entry", content)
self.assertIn("Test Maintainer <test@example.com>", content)
self.assertIn("existing content", content)
def test_update_debian_changelog_preview_does_not_write(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "changelog")
original = "existing content\n"
with open(path, "w", encoding="utf-8") as f:
f.write(original)
with patch.dict(
os.environ,
{
"DEBFULLNAME": "Test Maintainer",
"DEBEMAIL": "test@example.com",
},
clear=False,
):
update_debian_changelog(
debian_changelog_path=path,
package_name="package-manager",
new_version="1.2.3",
message="Test debian entry",
preview=True,
)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertEqual(content, original)
class TestReleaseOrchestration(unittest.TestCase):
@patch("pkgmgr.release._run_git_command")
@patch("pkgmgr.release.update_debian_changelog")
@patch("pkgmgr.release.update_spec_version")
@patch("pkgmgr.release.update_pkgbuild_version")
@patch("pkgmgr.release.update_flake_version")
@patch("pkgmgr.release.get_current_branch", return_value="develop")
@patch("pkgmgr.release.update_changelog")
@patch("pkgmgr.release.update_pyproject_version")
@patch("pkgmgr.release._bump_semver")
@patch("pkgmgr.release._determine_current_version")
def test_release_happy_path_uses_helpers_and_git(
self,
mock_determine_current_version,
mock_bump_semver,
mock_update_pyproject,
mock_update_changelog,
mock_get_current_branch,
mock_update_flake,
mock_update_pkgbuild,
mock_update_spec,
mock_update_debian_changelog,
mock_run_git_command,
) -> None:
mock_determine_current_version.return_value = SemVer(1, 2, 3)
mock_bump_semver.return_value = SemVer(1, 2, 4)
release(
pyproject_path="pyproject.toml",
changelog_path="CHANGELOG.md",
release_type="patch",
message="Test release",
preview=False,
)
# Current version + bump
mock_determine_current_version.assert_called_once()
mock_bump_semver.assert_called_once()
args, kwargs = mock_bump_semver.call_args
self.assertEqual(args[0], SemVer(1, 2, 3))
self.assertEqual(args[1], "patch")
self.assertEqual(kwargs, {})
# pyproject update
mock_update_pyproject.assert_called_once()
args, kwargs = mock_update_pyproject.call_args
self.assertEqual(args[0], "pyproject.toml")
self.assertEqual(args[1], "1.2.4")
self.assertEqual(kwargs.get("preview"), False)
# changelog update
mock_update_changelog.assert_called_once()
args, kwargs = mock_update_changelog.call_args
self.assertEqual(args[0], "CHANGELOG.md")
self.assertEqual(args[1], "1.2.4")
self.assertEqual(kwargs.get("message"), "Test release")
self.assertEqual(kwargs.get("preview"), False)
# repo root is derived from pyproject path; we don't care about
# exact paths here, only that helpers are called with preview=False.
mock_update_flake.assert_called_once()
self.assertEqual(mock_update_flake.call_args[1].get("preview"), False)
mock_update_pkgbuild.assert_called_once()
self.assertEqual(mock_update_pkgbuild.call_args[1].get("preview"), False)
mock_update_spec.assert_called_once()
self.assertEqual(mock_update_spec.call_args[1].get("preview"), False)
mock_update_debian_changelog.assert_called_once()
self.assertEqual(
mock_update_debian_changelog.call_args[1].get("preview"),
False,
)
# Git operations
mock_get_current_branch.assert_called_once()
self.assertEqual(mock_get_current_branch.return_value, "develop")
git_calls = [c.args[0] for c in mock_run_git_command.call_args_list]
self.assertIn('git commit -am "Release version 1.2.4"', git_calls)
self.assertIn('git tag -a v1.2.4 -m "Test release"', git_calls)
self.assertIn("git push origin develop", git_calls)
self.assertIn("git push origin --tags", git_calls)
@patch("pkgmgr.release._run_git_command")
@patch("pkgmgr.release.update_debian_changelog")
@patch("pkgmgr.release.update_spec_version")
@patch("pkgmgr.release.update_pkgbuild_version")
@patch("pkgmgr.release.update_flake_version")
@patch("pkgmgr.release.get_current_branch", return_value="develop")
@patch("pkgmgr.release.update_changelog")
@patch("pkgmgr.release.update_pyproject_version")
@patch("pkgmgr.release._bump_semver")
@patch("pkgmgr.release._determine_current_version")
def test_release_preview_mode_skips_git_and_uses_preview_flag(
self,
mock_determine_current_version,
mock_bump_semver,
mock_update_pyproject,
mock_update_changelog,
mock_get_current_branch,
mock_update_flake,
mock_update_pkgbuild,
mock_update_spec,
mock_update_debian_changelog,
mock_run_git_command,
) -> None:
mock_determine_current_version.return_value = SemVer(1, 2, 3)
mock_bump_semver.return_value = SemVer(1, 2, 4)
release(
pyproject_path="pyproject.toml",
changelog_path="CHANGELOG.md",
release_type="patch",
message="Preview release",
preview=True,
)
# All update helpers must be called with preview=True
mock_update_pyproject.assert_called_once()
self.assertTrue(mock_update_pyproject.call_args[1].get("preview"))
mock_update_changelog.assert_called_once()
self.assertTrue(mock_update_changelog.call_args[1].get("preview"))
mock_update_flake.assert_called_once()
self.assertTrue(mock_update_flake.call_args[1].get("preview"))
mock_update_pkgbuild.assert_called_once()
self.assertTrue(mock_update_pkgbuild.call_args[1].get("preview"))
mock_update_spec.assert_called_once()
self.assertTrue(mock_update_spec.call_args[1].get("preview"))
mock_update_debian_changelog.assert_called_once()
self.assertTrue(mock_update_debian_changelog.call_args[1].get("preview"))
# In preview mode no git commands must be executed
mock_run_git_command.assert_not_called()
if __name__ == "__main__":
unittest.main()