Skip to content

Commit 65b80cf

Browse files
committed
fix: bootstrap provider runtimes deterministically
1 parent bc772a1 commit 65b80cf

5 files changed

Lines changed: 92 additions & 64 deletions

File tree

README.md

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ cd "$ABXPKG_INSTALL_TEST"
3030
-->
3131
<!--pytest-codeblocks:cont-->
3232
```bash
33-
uv pip install abxpkg # uv tool install abxpkg
33+
pip install abxpkg # uv tool install abxpkg
3434
abxpkg --version
3535
```
3636

@@ -78,8 +78,8 @@ dependencies = [
7878
Binary(name='curl', binproviders=[env, apt, brew]),
7979
Binary(name='yt-dlp', binproviders=[env, pip, uv, apt, brew]),
8080
Binary(name='playwright', binproviders=[env, npm, pnpm]),
81-
Binary(name='chromium', binproviders=[playwright, puppeteer, apt]),
82-
Binary(name='postgres', binproviders=[docker, env, apt, brew]),
81+
Binary(name='chromium', binproviders=[env, playwright, puppeteer, apt]),
82+
Binary(name='postgres', binproviders=[env, docker, apt, brew]),
8383
]
8484
assert dependencies[0].binproviders == [env, apt, brew]
8585
assert dependencies[1].binproviders == [env, pip, uv, apt, brew]
@@ -116,7 +116,7 @@ cd "$ABXPKG_PIP_INSTALL_TEST"
116116
-->
117117
<!--pytest-codeblocks:cont-->
118118
```bash
119-
uv pip install abxpkg
119+
pip install abxpkg
120120
abxpkg --version
121121
```
122122

@@ -129,14 +129,15 @@ trap 'rm -rf "$ABXPKG_TOOL_INSTALL_TEST"' EXIT
129129
export ABXPKG_LIB_DIR="$ABXPKG_TOOL_INSTALL_TEST/lib"
130130
export UV_TOOL_DIR="$ABXPKG_TOOL_INSTALL_TEST/tools"
131131
export UV_TOOL_BIN_DIR="$ABXPKG_TOOL_INSTALL_TEST/bin"
132+
export PATH="$UV_TOOL_BIN_DIR:$PATH"
132133
export UV_PYTHON=3.12
133134
cd "$ABXPKG_TOOL_INSTALL_TEST"
134135
```
135136
-->
136137
<!--pytest-codeblocks:cont-->
137138
```bash
138139
uv tool install abxpkg
139-
"$UV_TOOL_BIN_DIR/abxpkg" --version
140+
abxpkg --version
140141
```
141142

142143
### CLI
@@ -148,6 +149,7 @@ Installing `abxpkg` also provides an `abxpkg` CLI entrypoint:
148149
ABXPKG_CLI_TEST="$(mktemp -d)"
149150
trap 'rm -rf "$ABXPKG_CLI_TEST"' EXIT
150151
export ABXPKG_LIB_DIR="$ABXPKG_CLI_TEST/lib"
152+
export ABXPKG_BINPROVIDERS="env,pip"
151153
abxpkg() { uv run --project "$PWD" abxpkg "$@"; }
152154
```
153155
-->
@@ -157,12 +159,12 @@ abxpkg --version
157159
abxpkg version
158160
abxpkg list
159161

160-
abxpkg --binproviders=env,pip install yt-dlp
161-
abxpkg --binproviders=env,pip load yt-dlp
162-
abxpkg --binproviders=env,pip env yt-dlp
163-
abxpkg --binproviders=env,pip activate yt-dlp
164-
abxpkg --binproviders=env,pip update yt-dlp
165-
abxpkg --binproviders=env,pip uninstall yt-dlp
162+
abxpkg install yt-dlp
163+
abxpkg load yt-dlp
164+
abxpkg env yt-dlp
165+
abxpkg activate yt-dlp
166+
abxpkg update yt-dlp
167+
abxpkg uninstall yt-dlp
166168
```
167169

168170
<!--pytest-codeblocks:cont-->
@@ -173,6 +175,8 @@ test ! -e "$ABXPKG_LIB_DIR/pip/bin/yt-dlp"
173175
```
174176
-->
175177

178+
Search package indexes:
179+
176180
<!-- pytest.mark.live_required -->
177181
```bash
178182
abxpkg search chromium # search all providers in parallel
@@ -267,10 +271,8 @@ env ABXPKG_BINPROVIDERS=env,uv,pip,apt,brew abxpkg install yt-dlp
267271
#### Customize where installed packages are located
268272

269273
```bash
270-
ABXPKG_DOCS_TMP="$(mktemp -d)"
271-
trap 'rm -rf "$ABXPKG_DOCS_TMP"' EXIT
272-
abxpkg --lib="$ABXPKG_DOCS_TMP/custom" --binproviders=env load python3
273-
env ABXPKG_LIB_DIR="$ABXPKG_DOCS_TMP/from-env" abxpkg --binproviders=env load python3
274+
abxpkg --lib=./vendor/abxpkg --binproviders=env load python3
275+
env ABXPKG_LIB_DIR=./vendor/abxpkg abxpkg --binproviders=env load python3
274276
```
275277

276278
#### Run in "dry mode" to see what commands will do before executing
@@ -301,8 +303,8 @@ It will automatically fetch, install, and make the packages available to your sc
301303
// /// script
302304
// dependencies = [
303305
// {name = "node", binproviders = ["env", "apt", "brew"], min_version = "22.0.0"},
304-
// {name = "playwright", binproviders = ["pnpm", "npm"], install_args = ["playwright@next"]},
305-
// {name = "chromium", binproviders = ["playwright", "puppeteer", "apt"], min_version = "131.0.0"},
306+
// {name = "playwright", binproviders = ["env", "pnpm", "npm"], install_args = ["playwright@next"]},
307+
// {name = "chromium", binproviders = ["env", "playwright", "puppeteer", "apt"], min_version = "131.0.0"},
306308
// ]
307309
// [tool.abxpkg]
308310
// ABXPKG_POSTINSTALL_SCRIPTS = true
@@ -460,7 +462,7 @@ class DockerBinary(Binary):
460462
overrides: dict[BinProviderName, HandlerDict] = {
461463
'env': {
462464
# prefer podman if installed, fall back to docker
463-
'abspath': shutil.which('podman') or shutil.which('docker') or shutil.which('docker-ce'),
465+
'abspath': lambda: shutil.which('podman') or shutil.which('docker') or shutil.which('docker-ce'),
464466
},
465467
'apt': {
466468
# vary the installed package name based on CPU architecture
@@ -866,7 +868,7 @@ PATH = DEFAULT_ENV_PATH # current PATH + current Python bin dir
866868
- Auto-switching: none.
867869
- Security: `min_release_age` and `postinstall_scripts` are unsupported here and are ignored with a warning if explicitly passed to `install()` / `update()`.
868870
- Overrides: `abspath` / `version` are the useful ones here. `python` has a built-in override to the current `sys.executable` and interpreter version.
869-
- Notes: resolved `abspath`s always point at the real underlying host binary, not the managed `env/bin/<name>` symlink. `install()` / `update()` return explanatory no-op messages, and `uninstall()` is a no-op.
871+
- Notes: with a managed env root, compatible host binaries are projected into `env/bin/<name>` and resolved through that stable symlink. `install()` / `update()` return explanatory no-op messages, and `uninstall()` is a no-op.
870872

871873
</details>
872874

@@ -1135,7 +1137,7 @@ bin_dir = None # defaults to <install_root>/bin
11351137
```
11361138

11371139
- Install root: set `install_root` for the Go install tree, and optionally `bin_dir` for the executable dir; otherwise installs land in `<install_root>/bin`.
1138-
- Auto-switching: none.
1140+
- Auto-switching: none. Resolves a compatible host `go` first, then installs Go through Apt or Homebrew if it is missing.
11391141
- `dry_run`: shared behavior.
11401142
- Security: `min_release_age` and `postinstall_scripts=False` are unsupported and are ignored with a warning if explicitly requested.
11411143
- Overrides: `install_args` is passed to `go install ...`; the default is `["<bin_name>@latest"]`.
@@ -1161,7 +1163,7 @@ install_root = Path(os.environ.get("ABXPKG_NIX_PROFILE", "~/.nix-profile")).expa
11611163
- Auto-switching: none.
11621164
- `dry_run`: shared behavior.
11631165
- Security: `min_release_age` and `postinstall_scripts=False` are unsupported and are ignored with a warning if explicitly requested.
1164-
- Overrides: `install_args` is passed to `nix profile install ...`; search results use the explicit official `nixpkgs-unstable` channel archive instead of the host's Nix registry.
1166+
- Overrides: `install_args` is passed to `nix profile install ...`; the default is `[bin_name]`. Search results use the explicit official `nixpkgs-unstable` channel archive instead of the host's Nix registry.
11651167
- Notes: update/uninstall operate on the resolved profile element name rather than reusing the full flake ref.
11661168

11671169
</details>

abxpkg/binprovider_goget.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pathlib import Path
77

88
from pydantic import Field, TypeAdapter, model_validator, computed_field
9-
from typing import Self
9+
from typing import ClassVar, Self
1010

1111
from .base_types import (
1212
BinProviderName,
@@ -36,6 +36,11 @@ class GoGetProvider(BinProvider):
3636
name: BinProviderName = "goget"
3737
_log_emoji = "🐹"
3838
INSTALLER_BIN: BinName = "go"
39+
INSTALLER_BINPROVIDERS: ClassVar[tuple[BinProviderName, ...] | None] = (
40+
"env",
41+
"apt",
42+
"brew",
43+
)
3944

4045
PATH: PATHStr = DEFAULT_ENV_PATH # Starts with ambient system PATH; setup_PATH() prepends the active GOBIN/bin_dir lazily.
4146

@@ -124,10 +129,7 @@ def INSTALLER_BINARY(self, no_cache: bool = False):
124129
self._INSTALLER_BINARY = loaded
125130
return loaded
126131

127-
env_provider = EnvProvider(
128-
install_root=None,
129-
bin_dir=None,
130-
).get_provider_with_overrides(
132+
env_provider = EnvProvider().get_provider_with_overrides(
131133
overrides={
132134
"*": {
133135
"version": ["go", "version"],

abxpkg/binprovider_pnpm.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,10 @@ def exec(
502502
# pnpm REQUIRES PNPM_HOME to exist for global installs to work.
503503
pnpm_home = Path(self.ENV["PNPM_HOME"])
504504
pnpm_home.mkdir(parents=True, exist_ok=True)
505+
if self.install_root is None:
506+
env = dict(os.environ if kwargs.get("env") is None else kwargs["env"])
507+
env["npm_config_global_bin_dir"] = str(pnpm_home)
508+
kwargs["env"] = env
505509
if env_flag_is_true("ABXPKG_NO_CACHE"):
506510
env = dict(os.environ if kwargs.get("env") is None else kwargs["env"])
507511
env["XDG_CACHE_HOME"] = str(abxpkg_ephemeral_cache_home_default())

tests/test_gogetprovider.py

Lines changed: 29 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
import tempfile
23
from pathlib import Path
34
import logging
@@ -9,20 +10,34 @@
910

1011

1112
class TestGoGetProvider:
12-
def test_installer_binary_uses_go_version_override(self, test_machine):
13-
test_machine.require_tool("go")
14-
15-
provider = GoGetProvider(postinstall_scripts=True, min_release_age=3)
16-
installer = provider.INSTALLER_BINARY(no_cache=True)
17-
18-
assert installer is not None
19-
assert installer.loaded_abspath is not None
20-
assert installer.loaded_version is not None
21-
assert installer.loaded_abspath.name == "go"
22-
loaded_version = installer.loaded_version
23-
expected_version = SemVer.parse("1.0.0")
24-
assert expected_version is not None
25-
assert loaded_version >= expected_version
13+
def test_installer_binary_uses_go_version_override(self):
14+
with tempfile.TemporaryDirectory() as temp_dir:
15+
lib_dir = (Path(temp_dir) / "lib").resolve()
16+
previous_lib_dir = os.environ.get("ABXPKG_LIB_DIR")
17+
os.environ["ABXPKG_LIB_DIR"] = str(lib_dir)
18+
try:
19+
bootstrap_provider = GoGetProvider(
20+
postinstall_scripts=True,
21+
min_release_age=3,
22+
)
23+
bootstrapped = bootstrap_provider.INSTALLER_BINARY(no_cache=True)
24+
assert bootstrapped.loaded_abspath is not None
25+
26+
# Re-resolve after bootstrap so an installer supplied by Apt or
27+
# Homebrew is discovered as a host binary and projected through
28+
# the stable managed env path before GoGet executes it.
29+
provider = GoGetProvider(postinstall_scripts=True, min_release_age=3)
30+
installer = provider.INSTALLER_BINARY(no_cache=True)
31+
finally:
32+
if previous_lib_dir is None:
33+
os.environ.pop("ABXPKG_LIB_DIR", None)
34+
else:
35+
os.environ["ABXPKG_LIB_DIR"] = previous_lib_dir
36+
37+
assert installer.loaded_abspath == lib_dir / "env" / "bin" / "go"
38+
assert installer.loaded_abspath.is_symlink()
39+
assert installer.loaded_version is not None
40+
assert installer.loaded_version >= SemVer("1.0.0")
2641

2742
def test_default_install_args_fail_closed_for_bare_binary_names(self):
2843
with tempfile.TemporaryDirectory() as temp_dir:
@@ -38,8 +53,6 @@ def test_default_install_args_fail_closed_for_bare_binary_names(self):
3853
provider.get_install_args("shfmt", quiet=False)
3954

4055
def test_module_path_name_installs_without_overrides(self, test_machine):
41-
test_machine.require_tool("go")
42-
4356
with tempfile.TemporaryDirectory() as temp_dir:
4457
module_path = "mvdan.cc/sh/v3/cmd/shfmt"
4558
provider = GoGetProvider.model_validate(
@@ -64,8 +77,6 @@ def test_install_root_and_bin_dir_aliases_install_into_the_requested_paths(
6477
self,
6578
test_machine,
6679
):
67-
test_machine.require_tool("go")
68-
6980
with tempfile.TemporaryDirectory() as temp_dir:
7081
install_root = Path(temp_dir) / "go-root"
7182
bin_dir = Path(temp_dir) / "custom-bin"
@@ -97,8 +108,6 @@ def test_install_root_without_explicit_bin_dir_takes_precedence_over_existing_PA
97108
self,
98109
test_machine,
99110
):
100-
test_machine.require_tool("go")
101-
102111
with tempfile.TemporaryDirectory() as temp_dir:
103112
temp_dir_path = Path(temp_dir)
104113
ambient_provider = GoGetProvider.model_validate(
@@ -152,8 +161,6 @@ def test_explicit_go_bin_dir_takes_precedence_over_existing_PATH_entries(
152161
self,
153162
test_machine,
154163
):
155-
test_machine.require_tool("go")
156-
157164
with tempfile.TemporaryDirectory() as temp_dir:
158165
temp_dir_path = Path(temp_dir)
159166
ambient_provider = GoGetProvider(
@@ -203,8 +210,6 @@ def test_explicit_go_bin_dir_takes_precedence_over_existing_PATH_entries(
203210
assert installed.loaded_version > ambient_installed.loaded_version
204211

205212
def test_provider_direct_methods_exercise_real_lifecycle(self, test_machine):
206-
test_machine.require_tool("go")
207-
208213
with tempfile.TemporaryDirectory() as temp_dir:
209214
provider = GoGetProvider(
210215
bin_dir=Path(temp_dir) / "go/bin",
@@ -224,8 +229,6 @@ def test_provider_direct_min_version_revalidates_old_install_and_upgrades(
224229
self,
225230
test_machine,
226231
):
227-
test_machine.require_tool("go")
228-
229232
with tempfile.TemporaryDirectory() as temp_dir:
230233
gobin = Path(temp_dir) / "go/bin"
231234
gopath = Path(temp_dir) / "go"
@@ -274,8 +277,6 @@ def test_unsupported_security_controls_warn_and_continue(
274277
test_machine,
275278
caplog,
276279
):
277-
test_machine.require_tool("go")
278-
279280
with tempfile.TemporaryDirectory() as temp_dir:
280281
with caplog.at_level(logging.WARNING, logger="abxpkg.binprovider"):
281282
installed = (
@@ -324,8 +325,6 @@ def test_unsupported_security_controls_warn_and_continue(
324325
assert "ignoring unsupported postinstall_scripts=False" in caplog.text
325326

326327
def test_binary_direct_methods_exercise_real_lifecycle(self, test_machine):
327-
test_machine.require_tool("go")
328-
329328
with tempfile.TemporaryDirectory() as temp_dir:
330329
binary = Binary(
331330
name="shfmt",
@@ -348,8 +347,6 @@ def test_binary_direct_methods_exercise_real_lifecycle(self, test_machine):
348347
test_machine.exercise_binary_lifecycle(binary)
349348

350349
def test_provider_dry_run_does_not_install_shfmt(self, test_machine):
351-
test_machine.require_tool("go")
352-
353350
with tempfile.TemporaryDirectory() as temp_dir:
354351
provider = GoGetProvider(
355352
bin_dir=Path(temp_dir) / "go/bin",
@@ -366,7 +363,6 @@ def test_provider_dry_run_does_not_install_shfmt(self, test_machine):
366363
test_machine.exercise_provider_dry_run(provider, bin_name="shfmt")
367364

368365
def test_search_finds_real_go_module_and_install_works(self, test_machine):
369-
test_machine.require_tool("go")
370366
with tempfile.TemporaryDirectory() as temp_dir:
371367
provider = GoGetProvider(
372368
install_root=Path(temp_dir) / "go",

0 commit comments

Comments
 (0)