Compare commits

..

3 Commits

Author SHA1 Message Date
Kevin Veen-Birkenbach
22efe0b32e Release version 0.7.13 2025-12-10 10:27:27 +01:00
Kevin Veen-Birkenbach
d23a0a94d5 Fix tools path resolution and add tests
- Use _resolve_repository_path() for explore, terminal and code commands
  so tools no longer rely on a 'directory' key in the repository dict.
- Fall back to repositories_base_dir/repositories_dir via get_repo_dir()
  when no explicit path-like key is present.
- Make VS Code workspace creation more robust (safe default for
  directories.workspaces and UTF-8 when writing JSON).
- Add unit tests for handle_tools_command (explore, terminal, code) under
  tests/unit/pkgmgr/cli/commands/test_tools.py.
- Add E2E/integration-style tests for the tools subcommands' --help
  output under tests/e2e/test_tools_help.py, treating SystemExit(0) as
  success.

This change fixes the KeyError: 'directory' when running 'pkgmgr code'
and verifies the behavior via unit and integration tests.

https://chatgpt.com/share/69393ca1-b554-800f-9967-abf8c4e3fea3
2025-12-10 10:25:29 +01:00
Kevin Veen-Birkenbach
e42b79c9d8 Add E2E tests for 'clone --all' and 'update --all' using HTTPS mode
This commit introduces two new end-to-end integration tests:

  • tests/e2e/test_clone_all.py
      Runs: pkgmgr clone --all --clone-mode https --no-verification
      Verifies that full HTTPS cloning of all configured repositories
      works inside the test container environment.

  • tests/e2e/test_update_all.py
      Runs: pkgmgr update --all --clone-mode https --no-verification
      Ensures that updating all repositories with HTTPS mode completes
      successfully without raising exceptions.

Both tests:
  - Provide extended diagnostics on SystemExit
  - Reuse nix-profile cleanup helpers for consistent test environments
  - Validate that `pkgmgr --help` works after execution

These tests complement the existing shallow-install integration test
and improve overall reliability of HTTPS clone/update workflows.
2025-12-09 23:47:43 +01:00
11 changed files with 508 additions and 34 deletions

View File

@@ -1,3 +1,8 @@
## [0.7.13] - 2025-12-10
* Automated release.
## [0.7.12] - 2025-12-09
* Fixed self refering alias during setup

View File

@@ -1,7 +1,7 @@
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
pkgname=package-manager
pkgver=0.7.12
pkgver=0.7.13
pkgrel=1
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
arch=('any')

6
debian/changelog vendored
View File

@@ -1,3 +1,9 @@
package-manager (0.7.13-1) unstable; urgency=medium
* Automated release.
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 10 Dec 2025 10:27:24 +0100
package-manager (0.7.12-1) unstable; urgency=medium
* Fixed self refering alias during setup

View File

@@ -31,7 +31,7 @@
rec {
pkgmgr = pyPkgs.buildPythonApplication {
pname = "package-manager";
version = "0.7.12";
version = "0.7.13";
# Use the git repo as source
src = ./.;

View File

@@ -1,5 +1,5 @@
Name: package-manager
Version: 0.7.12
Version: 0.7.13
Release: 1%{?dist}
Summary: Wrapper that runs Kevin's package-manager via Nix flake
@@ -77,6 +77,9 @@ echo ">>> package-manager removed. Nix itself was not removed."
/usr/lib/package-manager/
%changelog
* Wed Dec 10 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.13-1
- Automated release.
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.12-1
- Fixed self refering alias during setup

View File

@@ -5,49 +5,76 @@ import os
from typing import Any, Dict, List
from pkgmgr.cli.context import CLIContext
from pkgmgr.core.command.run import run_command
from pkgmgr.core.repository.identifier import get_repo_identifier
from pkgmgr .cli .context import CLIContext
from pkgmgr .core .command .run import run_command
from pkgmgr .core .repository .identifier import get_repo_identifier
from pkgmgr .core .repository .dir import get_repo_dir
Repository = Dict[str, Any]
def _resolve_repository_path(repository: Repository, ctx: CLIContext) -> str:
"""
Resolve the filesystem path for a repository.
Priority:
1. Use explicit keys if present (directory / path / workspace / workspace_dir).
2. Fallback to get_repo_dir(...) using the repositories base directory
from the CLI context.
"""
# 1) Explicit path-like keys on the repository object
for key in ("directory", "path", "workspace", "workspace_dir"):
value = repository.get(key)
if value:
return value
# 2) Fallback: compute from base dir + repository metadata
base_dir = (
getattr(ctx, "repositories_base_dir", None)
or getattr(ctx, "repositories_dir", None)
)
if not base_dir:
raise RuntimeError(
"Cannot resolve repositories base directory from context; "
"expected ctx.repositories_base_dir or ctx.repositories_dir."
)
return get_repo_dir(base_dir, repository)
def handle_tools_command(
args,
ctx: CLIContext,
selected: List[Repository],
) -> None:
"""
Handle integration commands:
- explore (file manager)
- terminal (GNOME Terminal)
- code (VS Code workspace)
"""
# --------------------------------------------------------
# explore
# --------------------------------------------------------
# ------------------------------------------------------------------
# nautilus "explore" command
# ------------------------------------------------------------------
if args.command == "explore":
for repository in selected:
repo_path = _resolve_repository_path(repository, ctx)
run_command(
f"nautilus {repository['directory']} & disown"
f'nautilus "{repo_path}" & disown'
)
return
# --------------------------------------------------------
# terminal
# --------------------------------------------------------
# ------------------------------------------------------------------
# GNOME terminal command
# ------------------------------------------------------------------
if args.command == "terminal":
for repository in selected:
repo_path = _resolve_repository_path(repository, ctx)
run_command(
f'gnome-terminal --tab --working-directory="{repository["directory"]}"'
f'gnome-terminal --tab --working-directory="{repo_path}"'
)
return
# --------------------------------------------------------
# code
# --------------------------------------------------------
# ------------------------------------------------------------------
# VS Code workspace command
# ------------------------------------------------------------------
if args.command == "code":
if not selected:
print("No repositories selected.")
@@ -60,20 +87,25 @@ def handle_tools_command(
sorted_identifiers = sorted(identifiers)
workspace_name = "_".join(sorted_identifiers) + ".code-workspace"
directories_cfg = ctx.config_merged.get("directories") or {}
workspaces_dir = os.path.expanduser(
ctx.config_merged.get("directories").get("workspaces")
directories_cfg.get("workspaces", "~/Workspaces")
)
os.makedirs(workspaces_dir, exist_ok=True)
workspace_file = os.path.join(workspaces_dir, workspace_name)
folders = [{"path": repository["directory"]} for repository in selected]
folders = [
{"path": _resolve_repository_path(repository, ctx)}
for repository in selected
]
workspace_data = {
"folders": folders,
"settings": {},
}
if not os.path.exists(workspace_file):
with open(workspace_file, "w") as f:
with open(workspace_file, "w", encoding="utf-8") as f:
json.dump(workspace_data, f, indent=4)
print(f"Created workspace file: {workspace_file}")
else:

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "package-manager"
version = "0.7.12"
version = "0.7.13"
description = "Kevin's package-manager tool (pkgmgr)"
readme = "README.md"
requires-python = ">=3.11"

View File

@@ -0,0 +1,93 @@
"""
Integration test: clone all configured repositories using
--clone-mode https and --no-verification.
This test is intended to be run inside the Docker container where:
- network access is available,
- the config/config.yaml is present,
- and it is safe to perform real git operations.
It passes if the command completes without raising an exception.
"""
import runpy
import sys
import unittest
from test_install_pkgmgr_shallow import (
nix_profile_list_debug,
remove_pkgmgr_from_nix_profile,
pkgmgr_help_debug,
)
class TestIntegrationCloneAllHttps(unittest.TestCase):
def _run_pkgmgr_clone_all_https(self) -> None:
"""
Helper that runs the CLI command via main.py and provides
extra diagnostics if the command exits with a non-zero code.
"""
cmd_repr = "pkgmgr clone --all --clone-mode https --no-verification"
original_argv = sys.argv
try:
sys.argv = [
"pkgmgr",
"clone",
"--all",
"--clone-mode",
"https",
"--no-verification",
]
try:
# Execute main.py as if it was called from CLI.
# This will run the full clone pipeline inside the container.
runpy.run_module("main", run_name="__main__")
except SystemExit as exc:
# Convert SystemExit into a more helpful assertion with debug output.
exit_code = exc.code if isinstance(exc.code, int) else str(exc.code)
print("\n[TEST] pkgmgr clone --all failed with SystemExit")
print(f"[TEST] Command : {cmd_repr}")
print(f"[TEST] Exit code: {exit_code}")
# Additional Nix profile debug on failure (may still be useful
# if the clone step interacts with Nix-based tooling).
nix_profile_list_debug("ON FAILURE (AFTER SystemExit)")
raise AssertionError(
f"{cmd_repr!r} failed with exit code {exit_code}. "
"Scroll up to see the full pkgmgr/make output inside the container."
) from exc
finally:
sys.argv = original_argv
def test_clone_all_repositories_https(self) -> None:
"""
Run: pkgmgr clone --all --clone-mode https --no-verification
This will perform real git clone operations inside the container.
The test succeeds if no exception is raised and `pkgmgr --help`
works in a fresh interactive bash session afterwards.
"""
# Debug before cleanup (reusing the same helpers as the install test).
nix_profile_list_debug("BEFORE CLEANUP")
# Cleanup: aggressively try to drop any pkgmgr/profile entries
# (harmless for a pure clone test but keeps environments comparable).
remove_pkgmgr_from_nix_profile()
# Debug after cleanup
nix_profile_list_debug("AFTER CLEANUP")
# Run the actual clone with extended diagnostics
self._run_pkgmgr_clone_all_https()
# After successful clone: show `pkgmgr --help`
# via interactive bash (same helper as in the install test).
pkgmgr_help_debug()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,74 @@
"""
E2E/Integration tests for the tool-related subcommands' --help output.
We assert that calling:
- pkgmgr explore --help
- pkgmgr terminal --help
- pkgmgr code --help
completes successfully. For --help, argparse exits with SystemExit(0),
which we treat as success and suppress in the helper.
"""
from __future__ import annotations
import os
import runpy
import sys
import unittest
from typing import List
# Resolve project root (the repo where main.py lives, e.g. /src)
PROJECT_ROOT = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..")
)
MAIN_PATH = os.path.join(PROJECT_ROOT, "main.py")
def _run_main(argv: List[str]) -> None:
"""
Helper to run main.py with the given argv.
This mimics a "pkgmgr ..." invocation in the E2E container.
For --help invocations, argparse will call sys.exit(0), which raises
SystemExit(0). We treat this as success and only re-raise non-zero
exit codes.
"""
old_argv = sys.argv
try:
sys.argv = ["pkgmgr"] + argv
try:
runpy.run_path(MAIN_PATH, run_name="__main__")
except SystemExit as exc: # argparse uses this for --help
# SystemExit.code can be int, str or None; for our purposes:
code = exc.code
if code not in (0, None):
# Non-zero exit code -> real error.
raise
# For 0/None: treat as success and swallow the exception.
finally:
sys.argv = old_argv
class TestToolsHelp(unittest.TestCase):
"""
E2E/Integration tests for tool commands' --help screens.
"""
def test_explore_help(self) -> None:
"""Ensure `pkgmgr explore --help` runs successfully."""
_run_main(["explore", "--help"])
def test_terminal_help(self) -> None:
"""Ensure `pkgmgr terminal --help` runs successfully."""
_run_main(["terminal", "--help"])
def test_code_help(self) -> None:
"""Ensure `pkgmgr code --help` runs successfully."""
_run_main(["code", "--help"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,93 @@
"""
Integration test: update all configured repositories using
--clone-mode https and --no-verification.
This test is intended to be run inside the Docker container where:
- network access is available,
- the config/config.yaml is present,
- and it is safe to perform real git operations.
It passes if the command completes without raising an exception.
"""
import runpy
import sys
import unittest
from test_install_pkgmgr_shallow import (
nix_profile_list_debug,
remove_pkgmgr_from_nix_profile,
pkgmgr_help_debug,
)
class TestIntegrationUpdateAllHttps(unittest.TestCase):
def _run_pkgmgr_update_all_https(self) -> None:
"""
Helper that runs the CLI command via main.py and provides
extra diagnostics if the command exits with a non-zero code.
"""
cmd_repr = "pkgmgr update --all --clone-mode https --no-verification"
original_argv = sys.argv
try:
sys.argv = [
"pkgmgr",
"update",
"--all",
"--clone-mode",
"https",
"--no-verification",
]
try:
# Execute main.py as if it was called from CLI.
# This will run the full update pipeline inside the container.
runpy.run_module("main", run_name="__main__")
except SystemExit as exc:
# Convert SystemExit into a more helpful assertion with debug output.
exit_code = exc.code if isinstance(exc.code, int) else str(exc.code)
print("\n[TEST] pkgmgr update --all failed with SystemExit")
print(f"[TEST] Command : {cmd_repr}")
print(f"[TEST] Exit code: {exit_code}")
# Additional Nix profile debug on failure (useful if any update
# step interacts with Nix-based tooling).
nix_profile_list_debug("ON FAILURE (AFTER SystemExit)")
raise AssertionError(
f"{cmd_repr!r} failed with exit code {exit_code}. "
"Scroll up to see the full pkgmgr/make output inside the container."
) from exc
finally:
sys.argv = original_argv
def test_update_all_repositories_https(self) -> None:
"""
Run: pkgmgr update --all --clone-mode https --no-verification
This will perform real git update operations inside the container.
The test succeeds if no exception is raised and `pkgmgr --help`
works in a fresh interactive bash session afterwards.
"""
# Debug before cleanup
nix_profile_list_debug("BEFORE CLEANUP")
# Cleanup: aggressively try to drop any pkgmgr/profile entries
# (keeps the environment comparable to other integration tests).
remove_pkgmgr_from_nix_profile()
# Debug after cleanup
nix_profile_list_debug("AFTER CLEANUP")
# Run the actual update with extended diagnostics
self._run_pkgmgr_update_all_https()
# After successful update: show `pkgmgr --help`
# via interactive bash (same helper as in the other integration tests).
pkgmgr_help_debug()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,168 @@
from __future__ import annotations
import json
import os
import tempfile
import unittest
from types import SimpleNamespace
from typing import Any, Dict, List
from pkgmgr.cli.commands.tools import handle_tools_command
Repository = Dict[str, Any]
class _Args:
"""
Simple helper object to mimic argparse.Namespace for handle_tools_command.
"""
def __init__(self, command: str) -> None:
self.command = command
class TestHandleToolsCommand(unittest.TestCase):
"""
Unit tests for pkgmgr.cli.commands.tools.handle_tools_command.
We focus on:
- Correct path resolution for repositories that have a 'directory' key.
- Correct shell commands for 'explore' and 'terminal'.
- Proper workspace creation and invocation of 'code' for the 'code' command.
"""
def setUp(self) -> None:
# Two fake repositories with explicit 'directory' entries so that
# _resolve_repository_path() does not need to call get_repo_dir().
self.repos: List[Repository] = [
{"alias": "repo1", "directory": "/tmp/repo1"},
{"alias": "repo2", "directory": "/tmp/repo2"},
]
# Minimal CLI context; only attributes used in tools.py are provided.
self.ctx = SimpleNamespace(
config_merged={"directories": {"workspaces": "~/Workspaces"}},
all_repositories=self.repos,
repositories_base_dir="/base/dir",
)
# ------------------------------------------------------------------ #
# Helper
# ------------------------------------------------------------------ #
def _patch_run_command(self):
"""
Convenience context manager for patching run_command in tools module.
"""
from unittest.mock import patch
return patch("pkgmgr.cli.commands.tools.run_command")
# ------------------------------------------------------------------ #
# Tests for 'explore'
# ------------------------------------------------------------------ #
def test_explore_uses_directory_paths(self) -> None:
"""
The 'explore' command should call Nautilus with the resolved
repository paths and use '& disown' as in the implementation.
"""
from unittest.mock import call
args = _Args(command="explore")
with self._patch_run_command() as mock_run_command:
handle_tools_command(args, self.ctx, self.repos)
expected_calls = [
call('nautilus "/tmp/repo1" & disown'),
call('nautilus "/tmp/repo2" & disown'),
]
self.assertEqual(mock_run_command.call_args_list, expected_calls)
# ------------------------------------------------------------------ #
# Tests for 'terminal'
# ------------------------------------------------------------------ #
def test_terminal_uses_directory_paths(self) -> None:
"""
The 'terminal' command should open a GNOME Terminal tab with the
repository as its working directory.
"""
from unittest.mock import call
args = _Args(command="terminal")
with self._patch_run_command() as mock_run_command:
handle_tools_command(args, self.ctx, self.repos)
expected_calls = [
call('gnome-terminal --tab --working-directory="/tmp/repo1"'),
call('gnome-terminal --tab --working-directory="/tmp/repo2"'),
]
self.assertEqual(mock_run_command.call_args_list, expected_calls)
# ------------------------------------------------------------------ #
# Tests for 'code'
# ------------------------------------------------------------------ #
def test_code_creates_workspace_and_calls_code(self) -> None:
"""
The 'code' command should:
- Build a workspace file name from sorted repository identifiers.
- Resolve the repository paths into VS Code 'folders'.
- Create the workspace file if it does not exist.
- Call 'code "<workspace_file>"' via run_command.
"""
from unittest.mock import patch
args = _Args(command="code")
with tempfile.TemporaryDirectory() as tmpdir:
# Patch expanduser so that the configured '~/Workspaces'
# resolves into our temporary directory.
with patch(
"pkgmgr.cli.commands.tools.os.path.expanduser"
) as mock_expanduser:
mock_expanduser.return_value = tmpdir
# Patch get_repo_identifier so the resulting workspace file
# name is deterministic and easy to assert.
with patch(
"pkgmgr.cli.commands.tools.get_repo_identifier"
) as mock_get_identifier:
mock_get_identifier.side_effect = ["repo-b", "repo-a"]
with self._patch_run_command() as mock_run_command:
handle_tools_command(args, self.ctx, self.repos)
# The identifiers are ['repo-b', 'repo-a'], which are
# sorted to ['repo-a', 'repo-b'] and joined with '_'.
expected_workspace_name = "repo-a_repo-b.code-workspace"
expected_workspace_file = os.path.join(
tmpdir, expected_workspace_name
)
# Workspace file should have been created.
self.assertTrue(
os.path.exists(expected_workspace_file),
"Workspace file was not created.",
)
# The content of the workspace must be valid JSON with
# the expected folder paths.
with open(expected_workspace_file, "r", encoding="utf-8") as f:
data = json.load(f)
self.assertIn("folders", data)
folder_paths = {f["path"] for f in data["folders"]}
self.assertEqual(
folder_paths,
{"/tmp/repo1", "/tmp/repo2"},
)
# And VS Code must have been invoked with that workspace.
mock_run_command.assert_called_once_with(
f'code "{expected_workspace_file}"'
)