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 os
import shutil import shutil
from typing import Optional 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]: 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: Variant B implemented:
str → path to the command (a symlink should be created) -----------------------
None → do NOT create a link (e.g. system package already provides it) 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 This allows a repository to intentionally declare:
raises SystemExit with a descriptive error message. 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 # ----------------------------------------------------------------------
# ------------------------------------------------------------ # 1) Explicit command declaration:
explicit = repo.get("command") #
if explicit: # If the repository defines the "command" key (even if the value is None),
return explicit # 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("~") home = os.path.expanduser("~")
def is_executable(path: str) -> bool: def is_executable(path: str) -> bool:
return os.path.exists(path) and os.access(path, os.X_OK) return os.path.exists(path) and os.access(path, os.X_OK)
# ------------------------------------------------------------ # ----------------------------------------------------------------------
# 2. System package manager binary via PATH # 2) Detect Python package structure: src/<pkg>/__main__.py
# # ----------------------------------------------------------------------
# If the binary lives under /usr/, we treat it as a system-managed is_python_package = False
# package (e.g. installed via pacman/apt/yum). In that case, pkgmgr src_dir = os.path.join(repo_dir, "src")
# does NOT create a link at all and defers entirely to the OS.
# ------------------------------------------------------------ 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) path_candidate = shutil.which(repo_identifier)
system_binary: Optional[str] = None system_binary = None
non_system_binary: Optional[str] = None non_system_binary = None
if path_candidate: 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 system_binary = path_candidate
else: else:
non_system_binary = path_candidate non_system_binary = path_candidate
# System-level binary → skip creating symlink and return None
if system_binary: if system_binary:
# Respect system package manager: do not create a link. if repo.get("ignore_system_binary", False):
if repo.get("debug", False):
print( print(
f"[pkgmgr] System binary for '{repo_identifier}' found at " f"[pkgmgr] System binary for '{repo_identifier}' found at "
f"{system_binary}; no symlink will be created." f"{system_binary}; no symlink will be created."
) )
return None return None
# ------------------------------------------------------------ # Nix profile binary
# 3. Nix profile binary (~/.nix-profile/bin/<identifier>)
# ------------------------------------------------------------
nix_candidate = os.path.join(home, ".nix-profile", "bin", repo_identifier) nix_candidate = os.path.join(home, ".nix-profile", "bin", repo_identifier)
if is_executable(nix_candidate): if is_executable(nix_candidate):
return nix_candidate return nix_candidate
# ------------------------------------------------------------ # Non-system PATH binary (user-installed or venv)
# 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.
# ------------------------------------------------------------
if non_system_binary and is_executable(non_system_binary): if non_system_binary and is_executable(non_system_binary):
return 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_sh = os.path.join(repo_dir, "main.sh")
main_py = os.path.join(repo_dir, "main.py") main_py = os.path.join(repo_dir, "main.py")
if is_executable(main_sh): if is_executable(main_sh):
return 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 return main_py
# ------------------------------------------------------------ # ----------------------------------------------------------------------
# 6. Nothing found → treat as a hard error # 6) Complete resolution failure
# ------------------------------------------------------------ # ----------------------------------------------------------------------
raise SystemExit( raise SystemExit(
f"No executable command could be resolved for repository '{repo_identifier}'. " f"No executable command could be resolved for repository '{repo_identifier}'. "
"No explicit 'command' configured, no system-managed binary under /usr/, " f"No explicit 'command' configured, no installed binary (system/venv/Nix), "
"no Nix profile binary, no non-system console script on PATH, and no " f"and no main.sh/main.py fallback found."
"main.sh/main.py found in the repository."
) )

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()