diff --git a/pkgmgr/core/command/resolve.py b/pkgmgr/core/command/resolve.py index 37f5a7f..5075be8 100644 --- a/pkgmgr/core/command/resolve.py +++ b/pkgmgr/core/command/resolve.py @@ -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/) → 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//__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/) - # ------------------------------------------------------------ + # 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." ) diff --git a/tests/unit/pkgmgr/core/command/test_resolve.py b/tests/unit/pkgmgr/core/command/test_resolve.py new file mode 100644 index 0000000..e96b0cc --- /dev/null +++ b/tests/unit/pkgmgr/core/command/test_resolve.py @@ -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//__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()