You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Any GitHub-release binary — linters, diff tools, formatters — declared in your `pyproject.toml` and fetched lazily on first use. `ohbin` downloads it, SHA256-verifies against a pinned hash, caches it per host, and execs it. One dev-dependency, any number of tools, zero per-tool wrapper packages to copy between repos.
14
+
Your project needs `ripgrep`, or `oasdiff`, or some Rust linter that ships only as a GitHub release. Python can't install it. So you either tell every developer "go install it yourself" — and watch versions drift and CI break — or you hand-write a download-and-verify wrapper package, and copy it into every repo, for every tool.
`ohbin` deletes that. Declare the tool in `pyproject.toml`; it's fetched on first use, SHA256-checked against a pinned hash, cached per host, and exec'd. One dev-dependency. Any number of tools.
uv can't install an arbitrary GitHub-release binary. `uv run <name>` resolves to a *Python* console-script entry point — static wheel metadata, fixed at build time. There is no hook for "read a config table and expose `<name>`".
32
-
33
-
So the usual workaround is a hand-rolled "download-on-first-use" wrapper package, one per tool, copied into every repo that needs it. That copy-paste is what `ohbin` deletes: the per-tool detail — repo, version, per-platform asset + checksum — moves into a `[tool.ohbin.tools.*]` table, and a single generic engine reads it.
24
+
## Before & After
34
25
35
-
|| hand-rolled wrapper per tool |`ohbin`|
36
-
|---|---|---|
37
-
| Packages to maintain | one per tool | one, total |
38
-
| New tool | a new wrapper package | one `ohbin add`|
39
-
| New repo | copy the wrapper files | one dev-dependency |
# + a wheel shim, [project.scripts], and a [tool.uv.sources] entry — in every repo
44
+
```
46
45
47
-
`ohbin add` hits the GitHub release, matches one asset per platform (linux/darwin × x86_64/arm64) by filename heuristics, pins each asset's SHA256 (from the GitHub API `digest`, else by downloading + hashing), and writes the result into your `pyproject.toml` — comments and formatting preserved (via `tomlkit`):
46
+
**✅ ohbin — one dev-dependency, one table per tool**
# + one [..assets.<os>-<arch>] table per platform — written by `add`, checksums and all
58
+
```
59
+
60
+
```bash
61
+
uv run ohbin run rg -- TODO src/
62
+
```
63
+
64
+
One is **a package you maintain**. The other is **a table you declare**.
65
+
66
+
---
67
+
68
+
## Why a wrapper at all?
69
+
70
+
uv can't install an arbitrary GitHub-release binary — and that's not an oversight. `uv run <name>` resolves to a Python console-script entry point, which is *static wheel metadata* baked at build time. There is no hook that reads a config table and conjures a command. So *something* has to bridge "a binary on a release page" to "a command in your venv."
71
+
72
+
The honest choices are **(a)** a wrapper package per tool — the duplication above — or **(b)** one generic engine that reads a manifest. `ohbin` is (b): the per-tool detail (repo, version, per-platform asset + checksum) lives in `[tool.ohbin.tools.*]`, and a single mostly-stdlib engine does download / verify / cache / exec for all of them.
# ... one [..assets.<os>-<arch>] table per platform
78
+
Point it at a repo. It resolves the release, matches one asset per platform, pins each SHA256 (from the GitHub API `digest`, else by downloading and hashing), and writes it into your pyproject — comments and formatting intact, via `tomlkit`:
`--name` sets the command when it should differ from the repo name; `--binary` sets the executable's name inside the archive when it differs from the command (ripgrep's repo is `ripgrep`, its binary is `rg`). Review what it matched — for an unusual naming scheme, fix an entry by hand. `add` uses the `gh` CLI when present (auth, rate limits), else the public REST API (`GH_TOKEN` / `GITHUB_TOKEN` honored).
91
+
`--name` sets the command when it differs from the repo (`ripgrep` → `rg`); `--binary` sets the executable's name inside the archive. Odd naming scheme? The manifest is the source of truth — `add` just fills it; fix an entry by hand. Uses the `gh` CLI when present (auth, rate limits), else the public REST API (`GH_TOKEN` / `GITHUB_TOKEN` honored).
67
92
68
93
---
69
94
70
-
## Run a tool
95
+
## `ohbin run` does the rest
71
96
72
97
```bash
73
-
uv run ohbin run rg -- --version # download-on-first-use, then exec
74
-
uv run ohbin which fd # print the cached path (downloads if needed)
75
-
uv run ohbin list # show declared tools + resolved platforms
98
+
uv run ohbin run rg -- --files # first run: download → verify → cache → exec
99
+
uv run ohbin run rg -- TODO src/ # next runs: straight to exec
100
+
uv run ohbin which fd # print the cached path (downloads if needed)
101
+
uv run ohbin list # declared tools + resolved platforms
76
102
```
77
103
78
-
`run`execs the binary in place — signals and exit codes forward transparently, so it is drop-in for CI and Make. The `ohbin run` prefix disappears behind a variable:
104
+
`run`replaces the process with `execv`, so the tool owns stdin/stdout, signals, and the exit code — drop-in for CI and Make, where the prefix disappears behind a variable:
79
105
80
106
```make
81
107
RG := uv run ohbin run rg --
@@ -84,67 +110,86 @@ search:; $(RG) TODO src/
84
110
85
111
---
86
112
87
-
## In-process use
113
+
## How it works
114
+
115
+
```
116
+
ohbin run rg -- --version
117
+
│
118
+
├─ read [tool.ohbin.tools.rg]_manifest (walks up to your pyproject)
119
+
├─ pick the asset for this os/arch _platform (→ darwin-arm64)
120
+
│
121
+
├─ cached? ~/.cache/ohbin/rg/14.1.1/rg
122
+
│ ├─ yes ───────────────────────────────┐
123
+
│ └─ no → flock → download → SHA256 ✓ │ _engine
124
+
│ → extract (tar/zip/raw) → +x │
125
+
▼ ▼
126
+
os.execv(binary, ["rg", "--version"]) ◄──────┘
127
+
```
128
+
129
+
- **Cache** — `$XDG_CACHE_HOME/ohbin/<tool>/<version>/<binary>` (`~/.cache/…` default). The version is in the path, so a bump is a clean new download that never collides with the old one.
130
+
- **Concurrency** — the first caller downloads under a `flock`; the rest wait and reuse. Safe under xdist / parallel CI.
131
+
- **Integrity** — SHA256-checked *before* extraction. A mismatch aborts; nothing partial lands in the cache.
132
+
133
+
---
134
+
135
+
## It survives the network
136
+
137
+
Release assets live behind CDNs that hiccup; `gh` rate-limits; DNS blips mid-clone. Every release lookup and every download retries with exponential backoff — and a real **404 is never mistaken for a transient failure** (the bug that makes naive wrappers cry "release not found" on a dropped packet):
138
+
139
+
```console
140
+
$ uv run ohbin add BurntSushi/ripgrep --version 14.1.1
141
+
ohbin: download failed (attempt 1/4): … Connection reset by peer; retrying in 0.5s
That is a real line from a live run — a reset connection, recovered, no fuss.
147
+
148
+
---
149
+
150
+
## In-process
151
+
152
+
Need the binary's *path*, not to exec it? Same manifest, one call:
90
153
91
154
```python
92
155
from ohbin import ensure
93
156
94
157
path = ensure("rg") # -> pathlib.Path, downloaded + verified on first use
95
158
```
96
159
97
-
Manifest discovery walks up from CWD to the nearest `pyproject.toml` carrying `[tool.ohbin]`. Set`OHBIN_PYPROJECT` to point at a specific file — forCI, or callers running from an unrelated directory.
160
+
Discovery walks up from CWD to the nearest `pyproject.toml` carrying `[tool.ohbin]`; set`OHBIN_PYPROJECT` to point at a specific file (CI, or callers running from an unrelated directory).
98
161
99
162
---
100
163
101
-
## How it works
164
+
## Hand-rolled wrapper vs `ohbin`
102
165
103
-
```
104
-
ohbin run rg -- --version
105
-
│
106
-
▼
107
-
read [tool.ohbin.tools.rg] from pyproject ← _manifest
108
-
│
109
-
▼
110
-
pick the asset for this os/arch (darwin-arm64) ← _platform
-**Cache** — `$XDG_CACHE_HOME/ohbin/<tool>/<version>/<binary>` (`~/.cache/…` default). The version is in the path, so a bump is a fresh download that never collides with the old one.
122
-
-**Concurrency** — the first invocation downloads under a `flock`; the rest wait and reuse. Safe under xdist / parallel CI.
123
-
-**Integrity** — every download is SHA256-checked against the pinned hash *before* extraction. A mismatch aborts; nothing lands in the cache.
124
-
-**Exec** — `os.execv` replaces the process, so the tool owns stdin/stdout, signals, and the exit code.
166
+
|| wrapper package per tool |`ohbin`|
167
+
|---|---|---|
168
+
| Packages to maintain | one per tool | one, total |
169
+
| New tool | write a new package |`ohbin add`|
170
+
| New repo | copy the files | one dev-dependency |
171
+
| Checksums | hand-pinned from the release page | auto-pinned by `add`|
172
+
| Network resilience | re-implemented (or skipped) | retry + backoff, built in |
-**POSIX only.**Uses `fcntl.flock` for the install lock; the engine imports `fcntl` at the top, so Windows fails on import. (The CI matrix keeps a Windows placeholder for when someone ports the lock.)
131
-
-**Heuristic matching.**`add` matches assets by OS/arch tokens in the filename and prefers `.tar.gz`. An unusual scheme may need a one-line manual fix to the written entry — by design, the manifest is the source of truth and `add` is just the convenience that fills it.
132
-
-**Four platforms.**linux/darwin × x86_64/arm64. Others (windows, musl variants, riscv) are not auto-resolved by `add`, though you can add their entries by hand.
179
+
-**POSIX only.**The install lock is `fcntl.flock`; the engine imports `fcntl` at the top, so Windows fails on import.
180
+
-**Four platforms.**linux/darwin × x86_64/arm64 are what `add` auto-resolves. Others (windows, musl, riscv) you add by hand — the engine runs them fine.
181
+
-**Heuristic matching.**`add` matches assets by OS/arch tokens in the filename and prefers `.tar.gz`. The manifest is the source of truth; an unusual scheme is a one-line fix.
0 commit comments