Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1ee930d
Add docs_url handler to BinProvider/Binary
claude May 1, 2026
ae8bf1b
Fix precheck: ruff format and pyright/ty type errors
claude May 1, 2026
cbbb28f
Address cubic review: docker/deno/goget docs_url edge cases
claude May 1, 2026
a575c43
Drop redundant BinProvider.docs_url alias
claude May 1, 2026
a910c5d
Add docs_url for chromewebstore, ansible, pyinfra
claude May 1, 2026
cf6baa7
Address cubic review: route ansible/pyinfra docs_url per OS
claude May 1, 2026
5669a5a
Inline OS-routing in ansible/pyinfra docs_url for cleaner separation
claude May 1, 2026
aec7b87
CargoProvider: validate cargo --version + rustup-init fallback
claude May 1, 2026
11425d1
Address cubic review: verify rustup-init SHA-256 before exec
claude May 1, 2026
78e7d77
Surface why CargoProvider falls back to rustup, catch raw mkdir errors
claude May 2, 2026
8517f10
Fix install-chain wedges (apt setup_PATH recursion, mutual-bootstrap …
claude May 2, 2026
8282956
Address cubic review: tighten installer-cycle filter to candidate's o…
claude May 2, 2026
aaca8a6
Surface why rustup-init returns no working cargo
claude May 2, 2026
d54066a
Log cargo --version output + rustup-init log on rustup-bootstrap failure
claude May 2, 2026
bbef3b1
Cargo rustup fallback: force-set default toolchain when rustup-init n…
claude May 2, 2026
ab9103d
Cargo rustup diagnostics: mirror to stderr to survive subprocess cont…
claude May 2, 2026
b2ef3f7
Cargo rustup fallback: use standard RUSTUP_HOME=~/.rustup layout
claude May 2, 2026
4a114a9
Cargo bootstrap: trust BinProvider chain, fix BrewProvider broken-ins…
claude May 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions abxpkg/binprovider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -848,13 +880,26 @@ 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
if provider_name
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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
)


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2979,6 +3115,12 @@ def __call__(
ActionHandlerValue = (
SelfMethodName | ActionFuncWithNoArgs | ActionFuncWithArgs | ActionFuncReturnValue
)
DocsUrlHandlerValue = (
SelfMethodName
| DocsUrlFuncWithNoArgs
| DocsUrlFuncWithArgs
| DocsUrlFuncReturnValue
)

HandlerType = Literal[
"abspath",
Expand All @@ -2988,20 +3130,23 @@ def __call__(
"install",
"update",
"uninstall",
"docs_url",
]
HandlerValue = (
AbspathHandlerValue
| VersionHandlerValue
| InstallArgsHandlerValue
| InstallHandlerValue
| ActionHandlerValue
| DocsUrlHandlerValue
)
HandlerReturnValue = (
AbspathFuncReturnValue
| VersionFuncReturnValue
| InstallArgsFuncReturnValue
| InstallFuncReturnValue
| ActionFuncReturnValue
| DocsUrlFuncReturnValue
)


Expand All @@ -3023,6 +3168,7 @@ class HandlerDict(TypedDict, total=False):
install: InstallHandlerValue
update: ActionHandlerValue
uninstall: ActionHandlerValue
docs_url: DocsUrlHandlerValue


# Binary.overrides map BinProviderName:ProviderFieldOrHandlerPatch
Expand Down
37 changes: 37 additions & 0 deletions abxpkg/binprovider_ansible.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
50 changes: 50 additions & 0 deletions abxpkg/binprovider_apt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions abxpkg/binprovider_bash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
}

Expand Down
Loading