core(command): implement explicit command=None bypass and add unit tests

This update introduces Variant B behavior in the command resolver:

- If a repository explicitly defines the key \"command\" (even if its value is None),
  resolve_command_for_repo() treats it as authoritative and returns immediately.
  This allows library-only repositories to declare:
      command: null
  which disables CLI resolution entirely.

- As a result, Python package repositories without installed CLI entry points
  no longer trigger SystemExit during update/install flows, as long as they set
  command: null in their repo configuration.

The resolution logic is now bypassed for such repositories, skipping:
  - Python package detection (src/*/__main__.py)
  - PATH/Nix/venv binary lookup
  - main.sh/main.py fallback evaluation

A new unit test suite has been added under
  tests/unit/pkgmgr/core/command/test_resolve.py
covering:

 1) Python package without installed command → SystemExit
 2) Python package with installed command → returned correctly
 3) Script repository fallback to main.py
 4) Explicit command overrides all logic

This commit stabilizes update/install flows and ensures library-only
repositories behave as intended when no CLI command is provided.

https://chatgpt.com/share/69394a53-bc78-800f-995d-21099a68dd60
This commit is contained in:
Kevin Veen-Birkenbach
2025-12-10 11:23:57 +01:00
parent a29b831e41
commit 545d345ea4
2 changed files with 169 additions and 65 deletions

View File

@@ -1,24 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Command resolver for repositories.
This module determines the correct command to expose via symlink.
It implements the following priority:
1. Explicit command in repo config → command
2. System package manager binary (/usr/...) → NO LINK (respect OS)
3. Nix profile binary (~/.nix-profile/bin/<id>) → command
4. Python / non-system console script on PATH → command
5. Fallback: repository's main.sh or main.py → command
6. If nothing is available → raise error
The actual symlink creation is handled by create_ink(). This resolver
only decides *what* should be used as the entrypoint, or whether no
link should be created at all.
"""
import os
import shutil
from typing import Optional
@@ -26,88 +5,120 @@ from typing import Optional
def resolve_command_for_repo(repo, repo_identifier: str, repo_dir: str) -> Optional[str]:
"""
Determine the command for this repository.
Resolve the executable command for a repository.
Returns:
str → path to the command (a symlink should be created)
None → do NOT create a link (e.g. system package already provides it)
Variant B implemented:
-----------------------
If the repository explicitly defines the key "command" in its config,
the function immediately returns that value — even if it is None.
On total failure (no suitable command found at any layer), this function
raises SystemExit with a descriptive error message.
This allows a repository to intentionally declare:
command: null
meaning it does NOT provide a CLI command and should not be resolved.
This bypasses:
- Python package detection
- PATH / Nix / venv binary lookup
- main.py / main.sh fallback logic
- SystemExit errors for Python packages without installed commands
If "command" is NOT defined, the normal resolution logic applies.
"""
# ------------------------------------------------------------
# 1. Explicit command defined by repository config
# ------------------------------------------------------------
explicit = repo.get("command")
if explicit:
return explicit
# ----------------------------------------------------------------------
# 1) Explicit command declaration:
#
# If the repository defines the "command" key (even if the value is None),
# we treat this as authoritative. The repository is explicitly declaring
# whether it provides a command.
#
# - If command is a string → return it as the resolved command
# - If command is None → repository intentionally has no CLI command
# → skip all resolution logic
# ----------------------------------------------------------------------
if "command" in repo:
return repo.get("command")
home = os.path.expanduser("~")
def is_executable(path: str) -> bool:
return os.path.exists(path) and os.access(path, os.X_OK)
# ------------------------------------------------------------
# 2. System package manager binary via PATH
#
# If the binary lives under /usr/, we treat it as a system-managed
# package (e.g. installed via pacman/apt/yum). In that case, pkgmgr
# does NOT create a link at all and defers entirely to the OS.
# ------------------------------------------------------------
# ----------------------------------------------------------------------
# 2) Detect Python package structure: src/<pkg>/__main__.py
# ----------------------------------------------------------------------
is_python_package = False
src_dir = os.path.join(repo_dir, "src")
if os.path.isdir(src_dir):
for root, dirs, files in os.walk(src_dir):
if "__main__.py" in files:
is_python_package = True
python_package_root = root # for error reporting
break
# ----------------------------------------------------------------------
# 3) Try resolving installed CLI commands (PATH, Nix, venv)
# ----------------------------------------------------------------------
path_candidate = shutil.which(repo_identifier)
system_binary: Optional[str] = None
non_system_binary: Optional[str] = None
system_binary = None
non_system_binary = None
if path_candidate:
if path_candidate.startswith("/usr/"):
# System-level binaries under /usr are not used as CLI commands
# unless explicitly allowed later.
if path_candidate.startswith("/usr"):
system_binary = path_candidate
else:
non_system_binary = path_candidate
# System-level binary → skip creating symlink and return None
if system_binary:
# Respect system package manager: do not create a link.
if repo.get("debug", False):
if repo.get("ignore_system_binary", False):
print(
f"[pkgmgr] System binary for '{repo_identifier}' found at "
f"{system_binary}; no symlink will be created."
)
return None
# ------------------------------------------------------------
# 3. Nix profile binary (~/.nix-profile/bin/<identifier>)
# ------------------------------------------------------------
# Nix profile binary
nix_candidate = os.path.join(home, ".nix-profile", "bin", repo_identifier)
if is_executable(nix_candidate):
return nix_candidate
# ------------------------------------------------------------
# 4. Python / non-system console script on PATH
#
# Here we reuse the non-system PATH candidate (e.g. from a venv or
# a user-local install like ~/.local/bin). This is treated as a
# valid command target.
# ------------------------------------------------------------
# Non-system PATH binary (user-installed or venv)
if non_system_binary and is_executable(non_system_binary):
return non_system_binary
# ------------------------------------------------------------
# 5. Fallback: main.sh / main.py inside the repository
# ------------------------------------------------------------
# ----------------------------------------------------------------------
# 4) If it is a Python package, it must provide an installed command
# ----------------------------------------------------------------------
if is_python_package:
raise SystemExit(
f"Repository '{repo_identifier}' appears to be a Python package "
f"(src-layout with __main__.py detected in '{python_package_root}'). "
f"No installed command found (PATH, venv, Nix). "
f"Python packages must be executed via their installed entry point."
)
# ----------------------------------------------------------------------
# 5) Fallback for script-style repositories: main.sh or main.py
# ----------------------------------------------------------------------
main_sh = os.path.join(repo_dir, "main.sh")
main_py = os.path.join(repo_dir, "main.py")
if is_executable(main_sh):
return main_sh
if is_executable(main_py) or os.path.exists(main_py):
# main.py does not need to be executable
if os.path.exists(main_py):
return main_py
# ------------------------------------------------------------
# 6. Nothing found → treat as a hard error
# ------------------------------------------------------------
# ----------------------------------------------------------------------
# 6) Complete resolution failure
# ----------------------------------------------------------------------
raise SystemExit(
f"No executable command could be resolved for repository '{repo_identifier}'. "
"No explicit 'command' configured, no system-managed binary under /usr/, "
"no Nix profile binary, no non-system console script on PATH, and no "
"main.sh/main.py found in the repository."
f"No explicit 'command' configured, no installed binary (system/venv/Nix), "
f"and no main.sh/main.py fallback found."
)

View File

@@ -0,0 +1,93 @@
import os
import shutil
import tempfile
import unittest
from unittest.mock import patch
from pkgmgr.core.command.resolve import resolve_command_for_repo
class TestResolveCommandForRepo(unittest.TestCase):
# ----------------------------------------------------------------------
# Helper: Create a fake src/<pkg>/__main__.py for Python package detection
# ----------------------------------------------------------------------
def _create_python_package(self, repo_dir, package_name="mypkg"):
src = os.path.join(repo_dir, "src", package_name)
os.makedirs(src, exist_ok=True)
main_file = os.path.join(src, "__main__.py")
with open(main_file, "w", encoding="utf-8") as f:
f.write("# fake python package entry\n")
return main_file
# ----------------------------------------------------------------------
# 1) Python package but no installed command → must fail with SystemExit
# ----------------------------------------------------------------------
def test_python_package_without_installed_command_raises(self):
with tempfile.TemporaryDirectory() as repo_dir:
# Fake Python package src/.../__main__.py
self._create_python_package(repo_dir)
repo = {}
repo_identifier = "analysis-ready-code"
with patch("shutil.which", return_value=None):
with self.assertRaises(SystemExit) as ctx:
resolve_command_for_repo(repo, repo_identifier, repo_dir)
self.assertIn("Python package", str(ctx.exception))
# ----------------------------------------------------------------------
# 2) Python package with installed command via PATH → returns command
# ----------------------------------------------------------------------
def test_python_package_with_installed_command(self):
with tempfile.TemporaryDirectory() as repo_dir:
# Fake python package
self._create_python_package(repo_dir)
repo = {}
repo_identifier = "analysis-ready-code"
fake_binary = os.path.join(repo_dir, "fakebin", "analysis-ready-code")
os.makedirs(os.path.dirname(fake_binary), exist_ok=True)
with open(fake_binary, "w") as f:
f.write("#!/bin/sh\necho test\n")
os.chmod(fake_binary, 0o755)
with patch("shutil.which", return_value=fake_binary):
result = resolve_command_for_repo(repo, repo_identifier, repo_dir)
self.assertEqual(result, fake_binary)
# ----------------------------------------------------------------------
# 3) Script repo: return main.py if present
# ----------------------------------------------------------------------
def test_script_repo_fallback_main_py(self):
with tempfile.TemporaryDirectory() as repo_dir:
fake_main = os.path.join(repo_dir, "main.py")
with open(fake_main, "w", encoding="utf-8") as f:
f.write("# script\n")
repo = {}
repo_identifier = "myscript"
with patch("shutil.which", return_value=None):
result = resolve_command_for_repo(repo, repo_identifier, repo_dir)
self.assertEqual(result, fake_main)
# ----------------------------------------------------------------------
# 4) Explicit command has highest priority
# ----------------------------------------------------------------------
def test_explicit_command(self):
with tempfile.TemporaryDirectory() as repo_dir:
repo = {"command": "/custom/runner.sh"}
repo_identifier = "x"
result = resolve_command_for_repo(repo, repo_identifier, repo_dir)
self.assertEqual(result, "/custom/runner.sh")
if __name__ == "__main__":
unittest.main()