Files
pkgmgr/tests/unit/pkgmgr/test_cli.py
Kevin Veen-Birkenbach d50891dfe5 Refactor: Restructure pkgmgr into actions/, core/, and cli/ (full module breakup)
This commit introduces a large-scale structural refactor of the pkgmgr
codebase. All functionality has been moved from the previous flat
top-level layout into three clearly separated namespaces:

  • pkgmgr.actions      – high-level operations invoked by the CLI
  • pkgmgr.core         – pure logic, helpers, repository utilities,
                          versioning, git helpers, config IO, and
                          command resolution
  • pkgmgr.cli          – parser, dispatch, context, and command
                          handlers

Key improvements:
  - Moved all “branch”, “release”, “changelog”, repo-management
    actions, installer pipelines, and proxy execution logic into
    pkgmgr.actions.<domain>.
  - Reworked installer structure under
        pkgmgr.actions.repository.install.installers
    including OS-package installers, Nix, Python, and Makefile.
  - Consolidated all low-level functionality under pkgmgr.core:
        • git helpers → core/git
        • config load/save → core/config
        • repository helpers → core/repository
        • versioning & semver → core/version
        • command helpers (alias, resolve, run, ink) → core/command
  - Replaced pkgmgr.cli_core with pkgmgr.cli and updated all imports.
  - Added minimal __init__.py files for clean package exposure.
  - Updated all E2E, integration, and unit tests with new module paths.
  - Fixed patch targets so mocks point to the new structure.
  - Ensured backward compatibility at the CLI boundary (pkgmgr entry point unchanged).

This refactor produces a cleaner, layered architecture:
  - `core` = logic
  - `actions` = orchestrated behaviour
  - `cli` = user interface

Reference: ChatGPT-assisted refactor discussion
https://chatgpt.com/share/6938221c-e24c-800f-8317-7732cedf39b9
2025-12-09 14:20:19 +01:00

248 lines
7.7 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Unit tests for the pkgmgr CLI (version command).
These tests focus on the 'version' subcommand and its interaction with:
- git tags (SemVer),
- pyproject.toml version,
- and the mismatch warning logic.
Important:
- Uses only the Python standard library unittest framework.
- Does not use pytest.
- Does not rely on a real git repository or real config files.
"""
from __future__ import annotations
import io
import os
import sys
import tempfile
import textwrap
import unittest
from contextlib import redirect_stdout
from typing import Any, Dict, List
from unittest import mock
from pkgmgr import cli
def _fake_config() -> Dict[str, Any]:
"""
Provide a minimal configuration dict sufficient for cli.main()
to start without touching real config files.
"""
return {
"directories": {
"repositories": "/tmp/pkgmgr-repos",
"binaries": "/tmp/pkgmgr-bin",
"workspaces": "/tmp/pkgmgr-workspaces",
},
# The actual list of repositories is not used directly by the tests,
# because we mock the selection logic. It must exist, though.
"repositories": [],
}
class TestCliVersion(unittest.TestCase):
"""
Tests for the 'pkgmgr version' command.
Each test:
- Runs in a temporary working directory.
- Uses a fake configuration via load_config().
- Uses the same selection logic as the new CLI:
* dispatch_command() calls _select_repo_for_current_directory()
when there is no explicit selection.
"""
def setUp(self) -> None:
# Create a temporary directory and switch into it
self._tmp_dir = tempfile.TemporaryDirectory()
self._old_cwd = os.getcwd()
os.chdir(self._tmp_dir.name)
# Define a fake repo pointing to our temp dir
self._fake_repo = {
"provider": "github.com",
"account": "test",
"repository": "pkgmgr-test",
"directory": self._tmp_dir.name,
}
# Patch load_config so cli.main() does not read real config files
self._patch_load_config = mock.patch(
"pkgmgr.cli.load_config", return_value=_fake_config()
)
self.mock_load_config = self._patch_load_config.start()
# Patch the "current directory" selection used by dispatch_command().
# This matches the new behaviour: without explicit identifiers,
# version uses _select_repo_for_current_directory(ctx).
self._patch_select_repo_for_current_directory = mock.patch(
"pkgmgr.cli.dispatch._select_repo_for_current_directory",
return_value=[self._fake_repo],
)
self.mock_select_repo_for_current_directory = (
self._patch_select_repo_for_current_directory.start()
)
# Keep a reference to the original sys.argv, so we can restore it
self._old_argv = list(sys.argv)
def tearDown(self) -> None:
# Restore sys.argv
sys.argv = self._old_argv
# Stop all patches
self._patch_select_repo_for_current_directory.stop()
self._patch_load_config.stop()
# Restore working directory
os.chdir(self._old_cwd)
# Cleanup temp directory
self._tmp_dir.cleanup()
# ------------------------------------------------------------
# Helpers
# ------------------------------------------------------------
def _write_pyproject(self, version: str) -> str:
"""
Write a minimal PEP 621-style pyproject.toml into the temp directory.
"""
content = textwrap.dedent(
f"""
[project]
name = "pkgmgr-test"
version = "{version}"
"""
).strip() + "\n"
path = os.path.join(self._tmp_dir.name, "pyproject.toml")
with open(path, "w", encoding="utf-8") as f:
f.write(content)
return path
def _run_cli_version_and_capture(
self,
extra_args: List[str] | None = None,
) -> str:
"""
Run 'pkgmgr version [extra_args]' via cli.main() and return captured stdout.
"""
if extra_args is None:
extra_args = []
sys.argv = ["pkgmgr", "version"] + list(extra_args)
buf = io.StringIO()
with redirect_stdout(buf):
try:
cli.main()
except SystemExit as exc:
# Re-raise as AssertionError to make failures easier to read
raise AssertionError(
f"'pkgmgr version' exited with code {exc.code}"
) from exc
return buf.getvalue()
# ------------------------------------------------------------
# Tests
# ------------------------------------------------------------
def test_version_matches_tag(self) -> None:
"""
If the latest SemVer tag matches the pyproject.toml version,
the CLI should:
- show both values
- NOT emit a mismatch warning.
"""
# Arrange: pyproject.toml with version 1.2.3
self._write_pyproject("1.2.3")
# Arrange: mock git tags used by handle_version
with mock.patch(
"pkgmgr.cli.commands.version.get_tags",
return_value=["v1.2.0", "v1.2.3", "v1.0.0"],
):
# Act
stdout = self._run_cli_version_and_capture()
# Basic header
self.assertIn("pkgmgr version info", stdout)
self.assertIn("Repository:", stdout)
# Git SemVer tag line
self.assertIn("Git (latest SemVer tag):", stdout)
self.assertIn("v1.2.3", stdout)
self.assertIn("(parsed: 1.2.3)", stdout)
# pyproject line
self.assertIn("pyproject.toml:", stdout)
self.assertIn("1.2.3", stdout)
# No warning expected if versions are equal
self.assertNotIn("[WARN]", stdout)
def test_version_mismatch_warns(self) -> None:
"""
If the latest SemVer tag differs from the pyproject.toml version,
the CLI should emit a mismatch warning.
"""
# Arrange: pyproject.toml says 1.2.4
self._write_pyproject("1.2.4")
# Arrange: mock git tags (latest is 1.2.3)
with mock.patch(
"pkgmgr.cli.commands.version.get_tags",
return_value=["v1.2.3"],
):
stdout = self._run_cli_version_and_capture()
# Git line
self.assertIn("Git (latest SemVer tag):", stdout)
self.assertIn("v1.2.3", stdout)
# pyproject line
self.assertIn("pyproject.toml:", stdout)
self.assertIn("1.2.4", stdout)
# Mismatch warning must be printed
self.assertIn("[WARN]", stdout)
self.assertIn("Version mismatch", stdout)
def test_version_no_tags(self) -> None:
"""
If no tags exist at all, the CLI should handle this gracefully,
show "<none found>" for tags and still display the pyproject version.
No mismatch warning should be emitted because there is no tag.
"""
# Arrange: pyproject.toml exists
self._write_pyproject("0.0.1")
# Arrange: no tags returned
with mock.patch(
"pkgmgr.cli.commands.version.get_tags",
return_value=[],
):
stdout = self._run_cli_version_and_capture()
# Indicates that no SemVer tag was found
self.assertIn("Git (latest SemVer tag): <none found>", stdout)
# pyproject version is still shown
self.assertIn("pyproject.toml:", stdout)
self.assertIn("0.0.1", stdout)
# No mismatch warning expected
self.assertNotIn("Version mismatch", stdout)
self.assertNotIn("[WARN]", stdout)
if __name__ == "__main__":
unittest.main()