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:
@@ -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."
|
|
||||||
)
|
)
|
||||||
|
|||||||
93
tests/unit/pkgmgr/core/command/test_resolve.py
Normal file
93
tests/unit/pkgmgr/core/command/test_resolve.py
Normal 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()
|
||||||
Reference in New Issue
Block a user