diff --git a/abxpkg/binprovider.py b/abxpkg/binprovider.py index 0aaec73..a895efa 100755 --- a/abxpkg/binprovider.py +++ b/abxpkg/binprovider.py @@ -296,6 +296,37 @@ def bin_dir(self) -> BinDirPath | None: def loaded_respath(self) -> HostBinPath | None: return self.loaded_abspath and self.loaded_abspath.resolve() + @log_method_call(include_result=True) + def docs_url(self, quiet: bool = True) -> str | None: + """Return a human-readable info/docs URL for this binary. + + If this binary has been loaded via a specific provider, that provider + is asked first. Otherwise (and as a fallback) each candidate provider + in ``binproviders`` is tried in order until one returns a non-None URL. + """ + if not self.name: + return None + + tried: set[int] = set() + candidates: list["BinProvider"] = [] + if self.loaded_binprovider is not None: + candidates.append(self.loaded_binprovider) + candidates.extend(self.binproviders) + + for provider in candidates: + if id(provider) in tried: + continue + tried.add(id(provider)) + try: + url = provider.get_docs_url(self.name, quiet=quiet) + except Exception: + if not quiet: + raise + url = None + if url: + return url + return None + # @validate_call @log_method_call(include_result=True) def exec( @@ -384,6 +415,7 @@ class BinProvider(BaseModel): "install": "self.default_install_handler", "update": "self.default_update_handler", "uninstall": "self.default_uninstall_handler", + "docs_url": "self.default_docs_url_handler", }, }, repr=False, @@ -848,6 +880,17 @@ def INSTALLER_BINARY(self, no_cache: bool = False) -> ShallowBinary: if raw_provider_names or not self.INSTALLER_BINPROVIDERS else list(self.INSTALLER_BINPROVIDERS) ) + # Drop a cross-provider candidate iff that candidate's *own* + # explicit INSTALLER_BINPROVIDERS list names ``self`` — that's a + # genuine mutual-bootstrap cycle (e.g. a hypothetical brew with + # INSTALLER_BINPROVIDERS=("cargo",) trying to bootstrap brew via + # cargo while cargo's own chain bootstraps cargo via brew). + # Candidates with INSTALLER_BINPROVIDERS=None fall back to the + # ambient PATH via env first and don't actually recurse, so we + # keep them — over-filtering here would break legitimate + # cross-provider bootstraps like ``cargo`` installed via ``brew`` + # (especially under ``ABXPKG_BINPROVIDERS=...`` configurations + # that exclude env). installer_provider_names = [ provider_name for provider_name in preferred_provider_names @@ -855,6 +898,8 @@ def INSTALLER_BINARY(self, no_cache: bool = False) -> ShallowBinary: and provider_name in selected_provider_names and provider_name in PROVIDER_CLASS_BY_NAME and provider_name != self.name + and self.name + not in (PROVIDER_CLASS_BY_NAME[provider_name].INSTALLER_BINPROVIDERS or ()) ] installer_providers: list[BinProvider] = [ env_provider @@ -1228,6 +1273,62 @@ def default_install_args_handler( # ... install command calculation logic here return [bin_name] + # @validate_call + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> "DocsUrlFuncReturnValue": # aka str | None + """Subclasses override this to return a human-readable info URL for the package. + + Providers that don't have a meaningful docs/info URL (env, bash, custom, + etc.) should leave this returning None so callers can fall back to the + next provider in the binproviders list. + """ + return None + + def _docs_url_package_name( + self, + bin_name: BinName, + *, + allow_leading_at: bool = False, + ) -> str | None: + """Pick a canonical package name from install_args for URL building. + + Strips version specifiers (``==1.0``, ``>=2,<3``), extras (``foo[all]``), + and trailing ``@version`` suffixes (npm-style). Falls back to ``bin_name`` + if install_args don't yield a usable candidate. + """ + try: + install_args = self.get_install_args(bin_name, quiet=True) + except Exception: + install_args = [str(bin_name)] + + candidates = list(install_args) or [str(bin_name)] + for arg in candidates: + if not arg or arg.startswith("-"): + continue + if "://" in arg or arg.startswith((".", "/", "~")): + continue + name = arg + if allow_leading_at and name.startswith("@"): + # npm scoped pkg: keep leading @, only trim version after '@' that + # appears after the scope/name boundary. + _, _, after_slash = name[1:].partition("/") + if "@" in after_slash: + pkg, _, _ = after_slash.partition("@") + name = "@" + name[1:].split("/", 1)[0] + "/" + pkg + else: + name = name.split("@", 1)[0] if "@" in name else name + name = name.split("[", 1)[0] + for sep in ("==", ">=", "<=", "!=", "~=", ">", "<", ";", " "): + if sep in name: + name = name.split(sep, 1)[0] + name = name.strip() + if name: + return name + return str(bin_name) or None + def default_packages_handler( self, bin_name: BinName, @@ -1955,6 +2056,27 @@ def get_packages( ) -> InstallArgs: return self.get_install_args(bin_name, quiet=quiet, no_cache=no_cache) + @log_method_call(include_result=True) + def get_docs_url( + self, + bin_name: BinName, + quiet: bool = True, + no_cache: bool = False, + ) -> str | None: + try: + url = cast( + DocsUrlFuncReturnValue, + self._call_handler_for_action( + bin_name=bin_name, + handler_type="docs_url", + ), + ) + except Exception: + if not quiet: + raise + return None + return url or None + @log_method_call() def setup( self, @@ -2529,6 +2651,7 @@ class EnvProvider(BinProvider): "install": "self.install_noop", "update": "self.update_noop", "uninstall": "self.uninstall_noop", + "docs_url": "self.default_docs_url_handler", }, "python": { "abspath": "self.python_abspath_handler", @@ -2874,12 +2997,14 @@ def write_cached_binary( PackagesFuncReturnValue = InstallArgsFuncReturnValue InstallFuncReturnValue = str | None ActionFuncReturnValue = str | bool | None +DocsUrlFuncReturnValue = str | None ProviderFuncReturnValue = ( AbspathFuncReturnValue | VersionFuncReturnValue | InstallArgsFuncReturnValue | InstallFuncReturnValue | ActionFuncReturnValue + | DocsUrlFuncReturnValue ) @@ -2944,12 +3069,23 @@ def __call__( ) -> "ActionFuncReturnValue": ... +@runtime_checkable +class DocsUrlFuncWithArgs(Protocol): + def __call__( + _self, + binprovider: "BinProvider", + bin_name: BinName, + **context, + ) -> "DocsUrlFuncReturnValue": ... + + AbspathFuncWithNoArgs = Callable[[], AbspathFuncReturnValue] VersionFuncWithNoArgs = Callable[[], VersionFuncReturnValue] InstallArgsFuncWithNoArgs = Callable[[], InstallArgsFuncReturnValue] PackagesFuncWithNoArgs = InstallArgsFuncWithNoArgs InstallFuncWithNoArgs = Callable[[], InstallFuncReturnValue] ActionFuncWithNoArgs = Callable[[], ActionFuncReturnValue] +DocsUrlFuncWithNoArgs = Callable[[], DocsUrlFuncReturnValue] AbspathHandlerValue = ( SelfMethodName @@ -2979,6 +3115,12 @@ def __call__( ActionHandlerValue = ( SelfMethodName | ActionFuncWithNoArgs | ActionFuncWithArgs | ActionFuncReturnValue ) +DocsUrlHandlerValue = ( + SelfMethodName + | DocsUrlFuncWithNoArgs + | DocsUrlFuncWithArgs + | DocsUrlFuncReturnValue +) HandlerType = Literal[ "abspath", @@ -2988,6 +3130,7 @@ def __call__( "install", "update", "uninstall", + "docs_url", ] HandlerValue = ( AbspathHandlerValue @@ -2995,6 +3138,7 @@ def __call__( | InstallArgsHandlerValue | InstallHandlerValue | ActionHandlerValue + | DocsUrlHandlerValue ) HandlerReturnValue = ( AbspathFuncReturnValue @@ -3002,6 +3146,7 @@ def __call__( | InstallArgsFuncReturnValue | InstallFuncReturnValue | ActionFuncReturnValue + | DocsUrlFuncReturnValue ) @@ -3023,6 +3168,7 @@ class HandlerDict(TypedDict, total=False): install: InstallHandlerValue update: ActionHandlerValue uninstall: ActionHandlerValue + docs_url: DocsUrlHandlerValue # Binary.overrides map BinProviderName:ProviderFieldOrHandlerPatch diff --git a/abxpkg/binprovider_ansible.py b/abxpkg/binprovider_ansible.py index 19e4f2d..5e4642d 100755 --- a/abxpkg/binprovider_ansible.py +++ b/abxpkg/binprovider_ansible.py @@ -357,6 +357,43 @@ def get_ansible_module_extra_kwargs(self) -> dict[str, Any]: """Return provider-specific kwargs to splice into the ansible module block.""" return {} + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._docs_url_package_name(bin_name) + if not package: + return None + # ansible.builtin.package routes to whatever package manager the host + # actually has, so the docs URL has to follow. Only emit a URL for + # hosts we recognize; anything else returns None so the caller can + # fall back to the next provider. + if OPERATING_SYSTEM == "darwin": + return f"https://formulae.brew.sh/formula/{package}" + distro_id, codename = "", "" + try: + with open("/etc/os-release", encoding="utf-8") as fh: + for raw in fh: + line = raw.strip() + if not line or "=" not in line or line.startswith("#"): + continue + key, _, value = line.partition("=") + value = value.strip().strip('"').strip("'") + if key == "ID": + distro_id = value.lower() + elif ( + key in ("VERSION_CODENAME", "UBUNTU_CODENAME") and not codename + ): + codename = value.lower() + except OSError: + return None + if distro_id == "ubuntu": + return f"https://packages.ubuntu.com/{codename or 'noble'}/{package}" + if distro_id == "debian": + return f"https://packages.debian.org/{codename or 'stable'}/{package}" + return None + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_apt.py b/abxpkg/binprovider_apt.py index be27f27..2d73f68 100755 --- a/abxpkg/binprovider_apt.py +++ b/abxpkg/binprovider_apt.py @@ -65,6 +65,17 @@ def setup_PATH(self, no_cache: bool = False) -> None: if not dpkg_abspath or not apt_abspath: self.PATH = "" else: + # Seed self.PATH with apt-get's bin_dir before calling + # self.exec(dpkg -L bash). self.exec's build_exec_env + # re-enters self.setup_PATH; without a non-empty PATH, + # the ``not self.PATH`` guard at the top of this method + # would fire on every recursive entry and infinitely + # loop. The bin_dir is correct as a baseline value — + # the dpkg-discovered runtime bin dirs get prepended + # onto it just below. + self.PATH = TypeAdapter(PATHStr).validate_python( + str(apt_abspath.parent), + ) PATH = self.PATH dpkg_install_dirs = ( self.exec( @@ -85,6 +96,45 @@ def setup_PATH(self, no_cache: bool = False) -> None: self.PATH = TypeAdapter(PATHStr).validate_python(PATH) super().setup_PATH(no_cache=no_cache) + @staticmethod + def _detect_distro_codename() -> tuple[str, str]: + """Return (distro_id, codename) parsed from /etc/os-release with apt fallbacks.""" + os_release: dict[str, str] = {} + try: + with open("/etc/os-release", encoding="utf-8") as fh: + for raw in fh: + line = raw.strip() + if not line or "=" not in line or line.startswith("#"): + continue + key, _, value = line.partition("=") + os_release[key] = value.strip().strip('"').strip("'") + except OSError: + pass + distro_id = (os_release.get("ID") or "").lower() + codename = ( + os_release.get("VERSION_CODENAME") + or os_release.get("UBUNTU_CODENAME") + or "" + ).lower() + if distro_id in ("ubuntu", "debian"): + return distro_id, codename or ( + "noble" if distro_id == "ubuntu" else "stable" + ) + # apt is debian-derived; fall back to ubuntu LTS for derivatives. + return "ubuntu", codename or "noble" + + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._docs_url_package_name(bin_name) + if not package: + return None + distro, codename = self._detect_distro_codename() + host = "packages.debian.org" if distro == "debian" else "packages.ubuntu.com" + return f"https://{host}/{codename}/{package}" + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_bash.py b/abxpkg/binprovider_bash.py index 422fe09..ad94c52 100755 --- a/abxpkg/binprovider_bash.py +++ b/abxpkg/binprovider_bash.py @@ -58,6 +58,7 @@ class BashProvider(EnvProvider): "install": "self.default_install_handler", "update": "self.default_update_handler", "uninstall": "self.default_uninstall_handler", + "docs_url": "self.default_docs_url_handler", }, } diff --git a/abxpkg/binprovider_brew.py b/abxpkg/binprovider_brew.py index 89f6900..013fefe 100755 --- a/abxpkg/binprovider_brew.py +++ b/abxpkg/binprovider_brew.py @@ -2,6 +2,7 @@ __package__ = "abxpkg" import os +import subprocess import sys import time import platform @@ -268,6 +269,16 @@ def add_bin_dir(path: Path) -> None: self.PATH = TypeAdapter(PATHStr).validate_python(bin_dirs) super().setup_PATH(no_cache=no_cache) + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._docs_url_package_name(bin_name) + if not package: + return None + return f"https://formulae.brew.sh/formula/{package}" + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, @@ -340,8 +351,65 @@ def default_install_handler( return proc_output if proc.returncode != 0: self._raise_proc_error("install", install_args, proc) + + # If brew said "already installed" but the resulting binary doesn't + # actually run (e.g. an upstream uninstall removed a shared library + # the formula was linked against — happens on stale CI runners + # where ``brew install rust`` is a no-op even though + # ``cargo --version`` exits 127 with "libllhttp.so not found"), + # automatically ``brew reinstall`` to relink against the current + # set of dependencies. Only run the reinstall when both signals + # match so a healthy already-installed package is just a fast path. + if ( + "already installed" in proc_output.lower() + and not self._installed_binary_executes(bin_name) + ): + for package in dict.fromkeys( + arg + for arg in install_args + if isinstance(arg, str) and not arg.startswith("-") + ): + reinstall_proc = self.exec( + bin_name=installer_bin, + cmd=["reinstall", package], + timeout=timeout, + ) + if reinstall_proc.returncode != 0: + self._raise_proc_error( + "install", + install_args, + reinstall_proc, + ) return proc_output + def _installed_binary_executes(self, bin_name: str) -> bool: + """Return True iff `` --version`` exits cleanly. + + Used to detect the case where ``brew install`` reports the package + is already installed but the on-disk binary is broken (e.g. its + dynamic-link dependencies got purged by a separate uninstall). + Looks up the binary in brew's PATH first, falling back to a bare + ``shutil.which`` so it works during the initial install before + ``setup_PATH`` populates ``self.PATH``. + """ + from shutil import which as shutil_which + + candidate = bin_abspath(bin_name, PATH=self.PATH) or shutil_which(bin_name) + if not candidate: + # No binary on disk to test — defer the call (the post-install + # load() will still validate the install via the version probe). + return True + try: + proc = subprocess.run( + [str(candidate), "--version"], + capture_output=True, + text=True, + timeout=self.version_timeout, + ) + except (OSError, subprocess.SubprocessError): + return False + return proc.returncode == 0 + @remap_kwargs({"packages": "install_args"}) def default_update_handler( self, diff --git a/abxpkg/binprovider_bun.py b/abxpkg/binprovider_bun.py index 61adb84..893ae9b 100755 --- a/abxpkg/binprovider_bun.py +++ b/abxpkg/binprovider_bun.py @@ -143,6 +143,16 @@ def default_install_args_handler( or [str(bin_name)], ) + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._docs_url_package_name(bin_name, allow_leading_at=True) + if not package: + return None + return f"https://www.npmjs.com/package/{package}" + @computed_field @property def is_valid(self) -> bool: diff --git a/abxpkg/binprovider_cargo.py b/abxpkg/binprovider_cargo.py index 1b3a5b1..bd53393 100755 --- a/abxpkg/binprovider_cargo.py +++ b/abxpkg/binprovider_cargo.py @@ -2,6 +2,7 @@ __package__ = "abxpkg" import os +import subprocess from pathlib import Path @@ -93,6 +94,7 @@ def INSTALLER_BINARY(self, no_cache: bool = False): if ( cached_version is not None and cached_version >= MIN_CARGO_INSTALLER_VERSION + and self._cargo_executes(cached_installer.loaded_abspath) ): return cached_installer @@ -107,9 +109,14 @@ def INSTALLER_BINARY(self, no_cache: bool = False): if ( loaded_version is not None and loaded_version >= MIN_CARGO_INSTALLER_VERSION + and self._cargo_executes(loaded.loaded_abspath) ): self._INSTALLER_BINARY = loaded return loaded + # Discovered binary doesn't actually run (e.g. linuxbrew's cargo + # missing libllhttp.so on a stale CI image). Drop it so the + # install path below kicks in instead of returning a broken bin. + loaded = None raw_provider_names = os.environ.get("ABXPKG_BINPROVIDERS") selected_provider_names = ( @@ -137,17 +144,52 @@ def INSTALLER_BINARY(self, no_cache: bool = False): if not installer_providers: installer_providers = [env_provider] - upgraded = Binary( - name=self.INSTALLER_BIN, - min_version=MIN_CARGO_INSTALLER_VERSION, - binproviders=installer_providers, - ).install(no_cache=no_cache) - if upgraded and upgraded.loaded_abspath: + try: + upgraded = Binary( + name=self.INSTALLER_BIN, + min_version=MIN_CARGO_INSTALLER_VERSION, + binproviders=installer_providers, + ).install(no_cache=no_cache) + except Exception: + upgraded = None + if ( + upgraded + and upgraded.loaded_abspath + and self._cargo_executes(upgraded.loaded_abspath) + ): self._INSTALLER_BINARY = upgraded return upgraded - assert loaded is not None - return loaded + from .exceptions import BinProviderUnavailableError + + raise BinProviderUnavailableError( + self.__class__.__name__, + self.INSTALLER_BIN, + ) + + def _cargo_executes(self, abspath) -> bool: + """Return True iff `` --version`` exits cleanly with parseable output. + + Guards against partially broken cargo installs where the binary exists + on PATH but won't actually run (e.g. brew's cargo dynamically linked + to a libllhttp that's been removed). The base BinProvider load() does + a version probe, but providers can persist cache entries that bypass + it; this is a final, no-cache executable check. + """ + if abspath is None: + return False + try: + proc = subprocess.run( + [str(abspath), "--version"], + capture_output=True, + text=True, + timeout=self.version_timeout, + ) + except (OSError, subprocess.SubprocessError): + return False + if proc.returncode != 0: + return False + return bool(SemVer.parse(proc.stdout.strip() or proc.stderr.strip())) @log_method_call() def setup( @@ -213,6 +255,16 @@ def _cargo_package_specs( package_specs.append(arg) return package_specs or [bin_name] + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._docs_url_package_name(bin_name) + if not package: + return None + return f"https://crates.io/crates/{package}" + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_chromewebstore.py b/abxpkg/binprovider_chromewebstore.py index dcd2cce..c7f331a 100755 --- a/abxpkg/binprovider_chromewebstore.py +++ b/abxpkg/binprovider_chromewebstore.py @@ -64,6 +64,7 @@ class ChromeWebstoreProvider(BinProvider): "install": "self.chromewebstore_install_handler", "update": "self.chromewebstore_install_handler", "uninstall": "self.chromewebstore_uninstall_handler", + "docs_url": "self.default_docs_url_handler", }, } @@ -119,6 +120,43 @@ def chromewebstore_install_args_handler( """Default to `` --name=`` install args for extensions.""" return [bin_name, f"--name={bin_name}"] + @staticmethod + def _docs_url_name_slug(name: str) -> str: + """Slugify an extension name into the URL-safe form used by the Web Store.""" + cleaned = "".join(ch.lower() if ch.isalnum() else "-" for ch in name.strip()) + # collapse runs of hyphens and trim leading/trailing ones + while "--" in cleaned: + cleaned = cleaned.replace("--", "-") + return cleaned.strip("-") + + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + # Prefer the cached webstore_id/extension_name if we have them (set + # after install); otherwise fall back to install_args, which is the + # configured ``[, --name=]`` pair. + cached = self._cached_extension(str(bin_name)) + try: + install_args = list(self.get_install_args(bin_name, quiet=True)) + except Exception: + install_args = [] + webstore_id = str( + cached.get("webstore_id") or (install_args[0] if install_args else ""), + ).strip() + if not webstore_id: + return None + extension_name = str( + cached.get("name") + or cached.get("extension_name") + or self._extension_name(str(bin_name), install_args), + ) + slug = self._docs_url_name_slug(extension_name) + if slug and slug != webstore_id: + return f"https://chromewebstore.google.com/detail/{slug}/{webstore_id}" + return f"https://chromewebstore.google.com/detail/{webstore_id}" + def _cached_extension(self, bin_name: str) -> dict[str, Any]: """Load the persisted extension metadata JSON for a cached extension, if any.""" bin_dir = self.bin_dir diff --git a/abxpkg/binprovider_deno.py b/abxpkg/binprovider_deno.py index 268c841..030e8b8 100755 --- a/abxpkg/binprovider_deno.py +++ b/abxpkg/binprovider_deno.py @@ -110,6 +110,43 @@ def default_install_args_handler( or [str(bin_name)], ) + @staticmethod + def _strip_registry_prefix_pkg(spec: str) -> str: + """Strip ``@version`` from an npm/jsr spec, preserving ``@scope/name``.""" + if spec.startswith("@") and "/" in spec: + scope, _, after = spec[1:].partition("/") + return "@" + scope + "/" + after.split("@", 1)[0] + return spec.split("@", 1)[0] + + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + try: + install_args = self.get_install_args(str(bin_name), quiet=True) + except Exception: + install_args = [str(bin_name)] + # Prefer explicit registry-prefixed specs first, then any URL, and + # only fall back to deno.land/x if no specific target was given. + for arg in install_args or [str(bin_name)]: + if not arg or arg.startswith("-"): + continue + if arg.startswith("npm:"): + pkg = self._strip_registry_prefix_pkg(arg[4:]) + if pkg: + return f"https://www.npmjs.com/package/{pkg}" + elif arg.startswith("jsr:"): + pkg = self._strip_registry_prefix_pkg(arg[4:]) + if pkg: + return f"https://jsr.io/{pkg}" + elif "://" in arg: + return arg + fallback = self._docs_url_package_name(bin_name) + if fallback: + return f"https://deno.land/x/{fallback}" + return None + @computed_field @property def is_valid(self) -> bool: diff --git a/abxpkg/binprovider_docker.py b/abxpkg/binprovider_docker.py index 6e5a972..b07db48 100755 --- a/abxpkg/binprovider_docker.py +++ b/abxpkg/binprovider_docker.py @@ -92,6 +92,34 @@ def setup( def default_install_args_handler(self, bin_name: BinName, **context) -> InstallArgs: return [f"{bin_name}:latest"] + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + try: + install_args = self.get_install_args(str(bin_name), quiet=True) + except Exception: + install_args = [f"{bin_name}:latest"] + ref = next( + (str(arg) for arg in install_args if arg and not arg.startswith("-")), + f"{bin_name}:latest", + ) + # strip digest and tag + image = ref.split("@", 1)[0] + if ":" in image.rsplit("/", 1)[-1]: + image = image.rsplit(":", 1)[0] + # Docker Hub conventions: + # "redis" -> Official image at /_/redis + # "user/repo" -> Community image at /r/user/repo + # "ghcr.io/user/x" -> Not Docker Hub; link to the registry's web UI + if "/" not in image: + return f"https://hub.docker.com/_/{image}" + first, _, rest = image.partition("/") + if "." in first or ":" in first: # custom registry + return f"https://{first}/{rest}" + return f"https://hub.docker.com/r/{image}" + @remap_kwargs({"packages": "install_args"}) def _main_image_ref( self, diff --git a/abxpkg/binprovider_gem.py b/abxpkg/binprovider_gem.py index d0e3f96..aebecaa 100755 --- a/abxpkg/binprovider_gem.py +++ b/abxpkg/binprovider_gem.py @@ -202,6 +202,16 @@ def _patch_generated_wrappers(self) -> None: wrapper_path.write_text(wrapper_text, encoding="utf-8") + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._docs_url_package_name(bin_name) + if not package: + return None + return f"https://rubygems.org/gems/{package}" + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_goget.py b/abxpkg/binprovider_goget.py index 5e0f118..1df9eb1 100755 --- a/abxpkg/binprovider_goget.py +++ b/abxpkg/binprovider_goget.py @@ -55,6 +55,7 @@ class GoGetProvider(BinProvider): "install": "self.default_install_handler", "update": "self.default_update_handler", "uninstall": "self.default_uninstall_handler", + "docs_url": "self.default_docs_url_handler", }, "go": { "version": ["go", "version"], @@ -194,6 +195,29 @@ def default_install_args_handler(self, bin_name: BinName, **context) -> InstallA ) return [f"{bin_name}@latest"] + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + try: + install_args = self.get_install_args(str(bin_name), quiet=True) + except Exception: + return None + for arg in install_args or []: + if not arg or arg.startswith("-"): + continue + if arg.startswith(("./", "../", "/")): + continue # local path target — pkg.go.dev can't resolve it + module_path = str(arg).split("@", 1)[0] + # Go module paths must look like a domain followed by a path, + # e.g. ``example.com/foo`` — both a ``/`` and a ``.`` in the host. + host = module_path.split("/", 1)[0] + if "/" not in module_path or "." not in host: + continue + return f"https://pkg.go.dev/{module_path}" + return None + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_nix.py b/abxpkg/binprovider_nix.py index d6a15c6..ba6f24c 100755 --- a/abxpkg/binprovider_nix.py +++ b/abxpkg/binprovider_nix.py @@ -199,6 +199,19 @@ def _profile_element_name( def default_install_args_handler(self, bin_name: BinName, **context) -> InstallArgs: return [bin_name] + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._docs_url_package_name(bin_name) + if not package: + return None + # Nix flake refs like "nixpkgs#foo" -> use the attr after # + if "#" in package: + package = package.split("#", 1)[-1].split("^", 1)[0] + return f"https://search.nixos.org/packages?show={package}&query={package}" + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_npm.py b/abxpkg/binprovider_npm.py index 4c7ae34..0841068 100755 --- a/abxpkg/binprovider_npm.py +++ b/abxpkg/binprovider_npm.py @@ -169,6 +169,16 @@ def default_install_args_handler( or [str(bin_name)], ) + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._docs_url_package_name(bin_name, allow_leading_at=True) + if not package: + return None + return f"https://www.npmjs.com/package/{package}" + @computed_field @property def is_valid(self) -> bool: diff --git a/abxpkg/binprovider_pip.py b/abxpkg/binprovider_pip.py index f71d853..2817c07 100755 --- a/abxpkg/binprovider_pip.py +++ b/abxpkg/binprovider_pip.py @@ -653,6 +653,16 @@ def get_cache_info( cache_info["fingerprint_paths"].append(metadata_files[0]) return cache_info + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._package_name_for_bin(bin_name) or str(bin_name) + if not package: + return None + return f"https://pypi.org/project/{package}" + def default_version_handler( self, bin_name: BinName, diff --git a/abxpkg/binprovider_pnpm.py b/abxpkg/binprovider_pnpm.py index 87f1e15..53ad850 100755 --- a/abxpkg/binprovider_pnpm.py +++ b/abxpkg/binprovider_pnpm.py @@ -139,6 +139,16 @@ def default_install_args_handler( or [str(bin_name)], ) + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._docs_url_package_name(bin_name, allow_leading_at=True) + if not package: + return None + return f"https://www.npmjs.com/package/{package}" + @computed_field @property def is_valid(self) -> bool: diff --git a/abxpkg/binprovider_pyinfra.py b/abxpkg/binprovider_pyinfra.py index ee29940..739ea65 100755 --- a/abxpkg/binprovider_pyinfra.py +++ b/abxpkg/binprovider_pyinfra.py @@ -279,6 +279,42 @@ def INSTALLER_BINARY(self, no_cache: bool = False): ) return loaded + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._docs_url_package_name(bin_name) + if not package: + return None + # pyinfra auto-routes to brew on macOS and apt on Debian/Ubuntu; only + # emit URLs for hosts we recognize and return None for everything + # else so callers can fall back to the next provider. + if OPERATING_SYSTEM == "darwin": + return f"https://formulae.brew.sh/formula/{package}" + distro_id, codename = "", "" + try: + with open("/etc/os-release", encoding="utf-8") as fh: + for raw in fh: + line = raw.strip() + if not line or "=" not in line or line.startswith("#"): + continue + key, _, value = line.partition("=") + value = value.strip().strip('"').strip("'") + if key == "ID": + distro_id = value.lower() + elif ( + key in ("VERSION_CODENAME", "UBUNTU_CODENAME") and not codename + ): + codename = value.lower() + except OSError: + return None + if distro_id == "ubuntu": + return f"https://packages.ubuntu.com/{codename or 'noble'}/{package}" + if distro_id == "debian": + return f"https://packages.debian.org/{codename or 'stable'}/{package}" + return None + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_uv.py b/abxpkg/binprovider_uv.py index 1d30031..c157508 100755 --- a/abxpkg/binprovider_uv.py +++ b/abxpkg/binprovider_uv.py @@ -605,6 +605,16 @@ def default_abspath_handler( return TypeAdapter(HostBinPath).validate_python(candidate) return None + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._package_name_for_bin(str(bin_name), **context) or str(bin_name) + if not package: + return None + return f"https://pypi.org/project/{package}" + def default_version_handler( self, bin_name: BinName, diff --git a/abxpkg/binprovider_yarn.py b/abxpkg/binprovider_yarn.py index 972c670..02cf73e 100755 --- a/abxpkg/binprovider_yarn.py +++ b/abxpkg/binprovider_yarn.py @@ -160,6 +160,16 @@ def default_install_args_handler( or [str(bin_name)], ) + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._docs_url_package_name(bin_name, allow_leading_at=True) + if not package: + return None + return f"https://www.npmjs.com/package/{package}" + @computed_field @property def is_valid(self) -> bool: diff --git a/tests/test_binprovider.py b/tests/test_binprovider.py index 45bbf88..04da7e0 100644 --- a/tests/test_binprovider.py +++ b/tests/test_binprovider.py @@ -306,3 +306,32 @@ def test_exec_timeout_is_enforced_for_real_commands(self): timeout=2, quiet=True, ) + + def test_docs_url_returns_provider_specific_link_for_unloaded_binary(self): + from abxpkg import Binary, ShallowBinary + + assert ( + PipProvider().get_docs_url("pip_search") + == "https://pypi.org/project/pip_search" + ) + assert UvProvider().get_docs_url("black") == "https://pypi.org/project/black" + assert ( + NpmProvider().get_docs_url("@puppeteer/browsers") + == "https://www.npmjs.com/package/@puppeteer/browsers" + ) + assert EnvProvider().get_docs_url("python") is None + + # Binary that's not yet loaded falls back across binproviders. + binary = Binary(name="pip_search", binproviders=[EnvProvider(), PipProvider()]) + assert binary.docs_url() == "https://pypi.org/project/pip_search" + + # Loaded ShallowBinary uses the provider it was loaded from. + loaded = ShallowBinary.model_validate( + { + "name": "pip_search", + "binprovider": PipProvider(), + "version": SemVer.parse("3.2.1"), + "abspath": Path(sys.executable), + }, + ) + assert loaded.docs_url() == "https://pypi.org/project/pip_search"