Skip to content

Commit 7f757db

Browse files
docs: rewrite README — before/after hook, real terminal output, sharper voice
1 parent a4ee567 commit 7f757db

1 file changed

Lines changed: 118 additions & 73 deletions

File tree

README.md

Lines changed: 118 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
# ohbin
44

5-
**Declare it once. Fetch it on demand. Pin it forever.**
5+
**Declare binaries, not wrapper packages.**
66

77
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
88
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -11,71 +11,97 @@
1111

1212
</div>
1313

14-
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.
1515

16-
```bash
17-
uv add --dev git+https://github.com/prostomarkeloff/ohbin.git
18-
```
16+
`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.
1917

2018
```bash
21-
uv run ohbin add BurntSushi/ripgrep --version 14.1.1 --name rg --binary rg
22-
uv run ohbin run rg -- --version
23-
# ^ first run: download + SHA256-verify + cache, then exec
24-
# subsequent runs: straight to exec
19+
uv add --dev git+https://github.com/prostomarkeloff/ohbin.git
2520
```
2621

2722
---
2823

29-
## Why
30-
31-
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
3425

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 |
40-
| Checksums | hand-pinned | auto-pinned by `add` |
41-
| Integrity check | re-implemented per wrapper | shared, SHA256 |
26+
**❌ The hand-rolled wrapper — a whole package, per tool, copied into every repo**
4227

43-
---
44-
45-
## Add a tool
28+
```python
29+
# a download-and-verify wrapper · ~180 lines · written again for the next tool
30+
_PLATFORM_ASSETS = {
31+
("linux", "x86_64"): _Asset("ripgrep-14.1.1-x86_64-unknown-linux-musl.tar.gz", "4cf9f2741e6c…"),
32+
("darwin", "arm64"): _Asset("ripgrep-14.1.1-aarch64-apple-darwin.tar.gz", "24ad767777…"),
33+
# ...two more, each SHA hand-copied from the release page
34+
}
35+
36+
def ensure_binary() -> Path:
37+
asset = _resolve_asset() # platform.machine() guesswork
38+
with _flock(cache / ".lock"): # concurrency, if you bother
39+
_download(url, archive) # urllib + redirects (+ retries, if you bother)
40+
_verify_checksum(archive, asset.sha256) # hashlib
41+
_extract(archive, binary) # tarfile, atomic rename
42+
return binary
43+
# + a wheel shim, [project.scripts], and a [tool.uv.sources] entry — in every repo
44+
```
4645

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**
4847

4948
```bash
5049
uv run ohbin add BurntSushi/ripgrep --version 14.1.1 --name rg --binary rg
51-
uv run ohbin add sharkdp/fd --version 10.2.0
5250
```
5351

5452
```toml
5553
[tool.ohbin.tools.rg]
5654
repo = "BurntSushi/ripgrep"
5755
version = "14.1.1"
58-
binary = "rg" # executable name inside the archive
56+
binary = "rg"
57+
# + 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.
73+
74+
---
75+
76+
## `ohbin add` does the boring part
5977

60-
[tool.ohbin.tools.rg.assets.darwin-arm64]
61-
url = "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-aarch64-apple-darwin.tar.gz"
62-
sha256 = "..."
63-
# ... 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`:
79+
80+
```console
81+
$ uv run ohbin add BurntSushi/ripgrep --version 14.1.1 --name rg --binary rg
82+
resolving BurntSushi/ripgrep@14.1.1 ...
83+
+ linux-x86_64 ripgrep-14.1.1-x86_64-unknown-linux-musl.tar.gz (downloaded+hashed)
84+
+ linux-aarch64 ripgrep-14.1.1-aarch64-unknown-linux-gnu.tar.gz (downloaded+hashed)
85+
+ darwin-x86_64 ripgrep-14.1.1-x86_64-apple-darwin.tar.gz (downloaded+hashed)
86+
+ darwin-arm64 ripgrep-14.1.1-aarch64-apple-darwin.tar.gz (downloaded+hashed)
87+
88+
wrote [tool.ohbin.tools.rg] to pyproject.toml
6489
```
6590

66-
`--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).
6792

6893
---
6994

70-
## Run a tool
95+
## `ohbin run` does the rest
7196

7297
```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
76102
```
77103

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:
79105

80106
```make
81107
RG := uv run ohbin run rg --
@@ -84,67 +110,86 @@ search:; $(RG) TODO src/
84110

85111
---
86112

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
142+
+ linux-x86_64 ripgrep-14.1.1-x86_64-unknown-linux-musl.tar.gz (downloaded+hashed)
143+
144+
```
88145

89-
When build tooling needs the binary's *path* not to exec it call the Python API. It reads the same manifest and returns the cached path:
146+
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:
90153

91154
```python
92155
from ohbin import ensure
93156

94157
path = ensure("rg") # -> pathlib.Path, downloaded + verified on first use
95158
```
96159

97-
Manifest discovery walks up from CWD to the nearest `pyproject.toml` carrying `[tool.ohbin]`. Set `OHBIN_PYPROJECT` to point at a specific file for CI, 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).
98161

99162
---
100163

101-
## How it works
164+
## Hand-rolled wrapper vs `ohbin`
102165

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
111-
112-
113-
cached? ~/.cache/ohbin/rg/14.1.1/rg
114-
├── yes ───────────────────────────────────┐
115-
└── no → flock → download → SHA256 verify │ ← _engine
116-
→ extract (tar / zip / raw) → chmod │
117-
▼ ▼
118-
os.execv(binary, ["rg", "--version"]) ◄───────┘
119-
```
120-
121-
- **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 |
173+
| Integrity check | re-implemented per wrapper | shared, SHA256 |
125174

126175
---
127176

128177
## Limitations
129178

130-
- **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.
133182

134183
---
135184

136185
## Development
137186

138187
```bash
139188
git clone https://github.com/prostomarkeloff/ohbin
140-
cd ohbin
141-
uv sync
142-
143-
# ruff format + ruff check --fix + pyright
144-
make lint-heavy
189+
cd ohbin && uv sync
145190

146-
# unit suite (network-free: platform / matching / manifest / engine logic)
147-
make test-full
191+
make lint-heavy # ruff format + ruff check --fix + pyright
192+
make test-full # 44 network-free tests (platform / matching / manifest / engine / retry)
148193
```
149194

150195
CI runs the lint once, then an `os: [ubuntu, macos, windows] × python: [3.11, 3.12, 3.13, 3.14]` matrix.

0 commit comments

Comments
 (0)