ohbin 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ohbin/__init__.py +95 -0
- ohbin/__main__.py +10 -0
- ohbin/_add.py +254 -0
- ohbin/_crypto.py +95 -0
- ohbin/_engine.py +256 -0
- ohbin/_errors.py +12 -0
- ohbin/_gist.py +234 -0
- ohbin/_github.py +125 -0
- ohbin/_manifest.py +107 -0
- ohbin/_platform.py +83 -0
- ohbin/_retry.py +56 -0
- ohbin/_types.py +40 -0
- ohbin/cli.py +175 -0
- ohbin-0.2.2.dist-info/METADATA +161 -0
- ohbin-0.2.2.dist-info/RECORD +18 -0
- ohbin-0.2.2.dist-info/WHEEL +4 -0
- ohbin-0.2.2.dist-info/entry_points.txt +3 -0
- ohbin-0.2.2.dist-info/licenses/LICENSE +21 -0
ohbin/_manifest.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Locate and parse the `[tool.ohbin.tools.*]` manifest from a project's pyproject."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ohbin._errors import OhbinError
|
|
11
|
+
from ohbin._types import AssetEntry, ToolConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ManifestError(OhbinError):
|
|
15
|
+
"""Raised when the manifest is missing or a tool entry is malformed."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _read(path: Path) -> dict[str, Any]:
|
|
19
|
+
with path.open("rb") as fh:
|
|
20
|
+
return tomllib.load(fh)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _has_ohbin(path: Path) -> bool:
|
|
24
|
+
data = _read(path)
|
|
25
|
+
tool = data.get("tool")
|
|
26
|
+
return isinstance(tool, dict) and "ohbin" in tool
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def find_pyproject(start: Path | None = None) -> Path:
|
|
30
|
+
"""Find the nearest pyproject.toml carrying `[tool.ohbin]`, walking up from CWD.
|
|
31
|
+
|
|
32
|
+
`OHBIN_PYPROJECT` overrides discovery (handy for CI / in-process callers
|
|
33
|
+
that run from an unrelated CWD).
|
|
34
|
+
"""
|
|
35
|
+
override = os.environ.get("OHBIN_PYPROJECT")
|
|
36
|
+
if override:
|
|
37
|
+
p = Path(override)
|
|
38
|
+
if not p.is_file():
|
|
39
|
+
msg = f"OHBIN_PYPROJECT points to a missing file: {p}"
|
|
40
|
+
raise ManifestError(msg)
|
|
41
|
+
return p
|
|
42
|
+
|
|
43
|
+
current = (start or Path.cwd()).resolve()
|
|
44
|
+
for directory in (current, *current.parents):
|
|
45
|
+
candidate = directory / "pyproject.toml"
|
|
46
|
+
if candidate.is_file() and _has_ohbin(candidate):
|
|
47
|
+
return candidate
|
|
48
|
+
|
|
49
|
+
msg = "no pyproject.toml with [tool.ohbin] found (set OHBIN_PYPROJECT to override)"
|
|
50
|
+
raise ManifestError(msg)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_asset(tool: str, key: str, raw: object) -> AssetEntry:
|
|
54
|
+
if not isinstance(raw, dict) or "url" not in raw or "sha256" not in raw:
|
|
55
|
+
msg = f"tool {tool!r}: asset {key!r} must define both 'url' and 'sha256'"
|
|
56
|
+
raise ManifestError(msg)
|
|
57
|
+
entry = AssetEntry(url=str(raw["url"]), sha256=str(raw["sha256"]))
|
|
58
|
+
if "binary_sha256" in raw:
|
|
59
|
+
entry["binary_sha256"] = str(raw["binary_sha256"])
|
|
60
|
+
return entry
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_tool(name: str, raw: object) -> ToolConfig:
|
|
64
|
+
if not isinstance(raw, dict):
|
|
65
|
+
msg = f"tool {name!r} must be a table"
|
|
66
|
+
raise ManifestError(msg)
|
|
67
|
+
for required in ("repo", "version"):
|
|
68
|
+
if required not in raw:
|
|
69
|
+
msg = f"tool {name!r} is missing required key {required!r}"
|
|
70
|
+
raise ManifestError(msg)
|
|
71
|
+
raw_assets = raw.get("assets", {})
|
|
72
|
+
if not isinstance(raw_assets, dict):
|
|
73
|
+
msg = f"tool {name!r}: 'assets' must be a table"
|
|
74
|
+
raise ManifestError(msg)
|
|
75
|
+
assets = {str(k): _parse_asset(name, str(k), v) for k, v in raw_assets.items()}
|
|
76
|
+
cfg = ToolConfig(
|
|
77
|
+
repo=str(raw["repo"]),
|
|
78
|
+
version=str(raw["version"]),
|
|
79
|
+
binary=str(raw.get("binary", name)),
|
|
80
|
+
assets=assets,
|
|
81
|
+
)
|
|
82
|
+
if raw.get("encrypted"):
|
|
83
|
+
cfg["encrypted"] = True
|
|
84
|
+
if "password" in raw:
|
|
85
|
+
cfg["password"] = str(raw["password"])
|
|
86
|
+
if raw.get("password_committed_ok"):
|
|
87
|
+
cfg["password_committed_ok"] = True
|
|
88
|
+
return cfg
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_tools(pyproject: Path | None = None) -> dict[str, ToolConfig]:
|
|
92
|
+
path = pyproject or find_pyproject()
|
|
93
|
+
data = _read(path)
|
|
94
|
+
raw_tools = data.get("tool", {}).get("ohbin", {}).get("tools", {})
|
|
95
|
+
if not isinstance(raw_tools, dict):
|
|
96
|
+
msg = "[tool.ohbin.tools] must be a table"
|
|
97
|
+
raise ManifestError(msg)
|
|
98
|
+
return {str(name): _parse_tool(str(name), raw) for name, raw in raw_tools.items()}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_tool(name: str, pyproject: Path | None = None) -> ToolConfig:
|
|
102
|
+
tools = load_tools(pyproject)
|
|
103
|
+
if name not in tools:
|
|
104
|
+
declared = ", ".join(sorted(tools)) or "none"
|
|
105
|
+
msg = f"tool {name!r} not declared in [tool.ohbin.tools] (declared: {declared})"
|
|
106
|
+
raise ManifestError(msg)
|
|
107
|
+
return tools[name]
|
ohbin/_platform.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Current-platform detection + the OS/arch token aliases used to match assets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
from typing import NamedTuple
|
|
7
|
+
|
|
8
|
+
from ohbin._errors import OhbinError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UnsupportedPlatformError(OhbinError):
|
|
12
|
+
"""Raised when a tool declares no asset for the current OS/arch."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Platform(NamedTuple):
|
|
16
|
+
os: str # "linux" | "darwin" | "windows"
|
|
17
|
+
arch: str # "x86_64" | "aarch64" | "arm64"
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def key(self) -> str:
|
|
21
|
+
return f"{self.os}-{self.arch}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _normalized_arch(machine: str, os_name: str) -> str:
|
|
25
|
+
m = machine.lower()
|
|
26
|
+
if m in {"x86_64", "amd64", "x64"}:
|
|
27
|
+
return "x86_64"
|
|
28
|
+
if m in {"aarch64", "arm64"}:
|
|
29
|
+
# Apple Silicon reports "arm64"; Linux reports "aarch64". Keep each OS
|
|
30
|
+
# family's canonical spelling so the platform key matches what `add`
|
|
31
|
+
# wrote (which uses the same TARGET_PLATFORMS below).
|
|
32
|
+
return "arm64" if os_name == "darwin" else "aarch64"
|
|
33
|
+
return m
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def current_platform() -> Platform:
|
|
37
|
+
os_name = platform.system().lower()
|
|
38
|
+
return Platform(os=os_name, arch=_normalized_arch(platform.machine(), os_name))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# The platforms `add` tries to resolve assets for, with their canonical keys.
|
|
42
|
+
TARGET_PLATFORMS: tuple[Platform, ...] = (
|
|
43
|
+
Platform("linux", "x86_64"),
|
|
44
|
+
Platform("linux", "aarch64"),
|
|
45
|
+
Platform("darwin", "x86_64"),
|
|
46
|
+
Platform("darwin", "arm64"),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Token aliases for fuzzy-matching release asset filenames during `add`.
|
|
50
|
+
_OS_ALIASES: dict[str, tuple[str, ...]] = {
|
|
51
|
+
"linux": ("linux",),
|
|
52
|
+
"darwin": ("darwin", "macos", "apple", "osx"),
|
|
53
|
+
"windows": ("windows", "win"),
|
|
54
|
+
}
|
|
55
|
+
_ARCH_ALIASES: dict[str, tuple[str, ...]] = {
|
|
56
|
+
"x86_64": ("x86_64", "amd64", "x64"),
|
|
57
|
+
"aarch64": ("aarch64", "arm64"),
|
|
58
|
+
"arm64": ("arm64", "aarch64"),
|
|
59
|
+
}
|
|
60
|
+
# Every arch token we know — used to detect "universal" assets (an OS match
|
|
61
|
+
# carrying no arch token at all, e.g. a `*_darwin_universal.tar.gz`).
|
|
62
|
+
ALL_ARCH_TOKENS: tuple[str, ...] = (
|
|
63
|
+
"x86_64",
|
|
64
|
+
"amd64",
|
|
65
|
+
"x64",
|
|
66
|
+
"aarch64",
|
|
67
|
+
"arm64",
|
|
68
|
+
"i386",
|
|
69
|
+
"i686",
|
|
70
|
+
"armv7",
|
|
71
|
+
"armv6",
|
|
72
|
+
"s390x",
|
|
73
|
+
"ppc64le",
|
|
74
|
+
"riscv64",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def os_tokens(p: Platform) -> tuple[str, ...]:
|
|
79
|
+
return _OS_ALIASES[p.os]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def arch_tokens(p: Platform) -> tuple[str, ...]:
|
|
83
|
+
return _ARCH_ALIASES[p.arch]
|
ohbin/_retry.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Tiny retry-with-backoff for transient network failures.
|
|
2
|
+
|
|
3
|
+
Shared by `_github` (release resolution) and `_engine` (asset download). Both
|
|
4
|
+
classify failures into *permanent* (404, bad checksum — surfaced immediately)
|
|
5
|
+
and *transient* (network blip, timeout, rate-limit, 5xx — retried here).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import TypeVar
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
ATTEMPTS = 4
|
|
18
|
+
BASE_DELAY = 0.5 # exponential backoff base → sleeps of 0.5s, 1s, 2s between tries
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def retryable_http_status(code: int) -> bool:
|
|
22
|
+
"""True for HTTP statuses worth retrying (rate-limit / request-timeout / 5xx)."""
|
|
23
|
+
return code in (403, 408, 425, 429) or code >= 500
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _warn_stderr(message: str) -> None:
|
|
27
|
+
print(message, file=sys.stderr)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def retry(
|
|
31
|
+
fn: Callable[[], T],
|
|
32
|
+
*,
|
|
33
|
+
op: str,
|
|
34
|
+
retry_on: tuple[type[BaseException], ...],
|
|
35
|
+
attempts: int = ATTEMPTS,
|
|
36
|
+
base_delay: float = BASE_DELAY,
|
|
37
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
38
|
+
warn: Callable[[str], None] | None = _warn_stderr,
|
|
39
|
+
) -> T:
|
|
40
|
+
"""Call `fn`, retrying with exponential backoff on `retry_on` exceptions.
|
|
41
|
+
|
|
42
|
+
Re-raises the last exception after `attempts` tries. `op` labels the action
|
|
43
|
+
in the retry notice. `sleep`/`warn` are injectable for tests (pass
|
|
44
|
+
`warn=None` to silence).
|
|
45
|
+
"""
|
|
46
|
+
for attempt in range(1, attempts + 1):
|
|
47
|
+
try:
|
|
48
|
+
return fn()
|
|
49
|
+
except retry_on as exc:
|
|
50
|
+
if attempt == attempts:
|
|
51
|
+
raise
|
|
52
|
+
delay = base_delay * 2 ** (attempt - 1)
|
|
53
|
+
if warn is not None:
|
|
54
|
+
warn(f"ohbin: {op} failed (attempt {attempt}/{attempts}): {exc}; retrying in {delay:.1f}s")
|
|
55
|
+
sleep(delay)
|
|
56
|
+
raise RuntimeError(f"retry({op}): exhausted without result") # pragma: no cover
|
ohbin/_types.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Typed shapes for the manifest boundary (`[tool.ohbin.tools.*]`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import NotRequired, TypedDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AssetEntry(TypedDict):
|
|
9
|
+
"""A single resolved release asset for one platform: where to get it + its hash.
|
|
10
|
+
|
|
11
|
+
`sha256` is the hash of the *downloaded* file (verified before any decryption,
|
|
12
|
+
so a tampered gist is caught early). For encrypted tools the downloaded file is
|
|
13
|
+
the ciphertext; `binary_sha256` then pins the *decrypted* executable, which also
|
|
14
|
+
doubles as a wrong-password / corruption check.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
url: str
|
|
18
|
+
sha256: str
|
|
19
|
+
binary_sha256: NotRequired[str]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ToolConfig(TypedDict):
|
|
23
|
+
"""One tool as declared under `[tool.ohbin.tools.<name>]`.
|
|
24
|
+
|
|
25
|
+
`binary` is the executable's filename *inside* the archive (defaults to the
|
|
26
|
+
tool's command name). `assets` is keyed by `"<os>-<arch>"` platform keys.
|
|
27
|
+
|
|
28
|
+
Encrypted tools (`encrypted = true`) ship a gzip+AES-CBC+base64 blob per
|
|
29
|
+
platform; ohbin decrypts on first use with a password from `--password` or the
|
|
30
|
+
`password` field. `password_committed_ok` silences the warning emitted when the
|
|
31
|
+
password is read from the (typically committed) manifest.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
repo: str
|
|
35
|
+
version: str
|
|
36
|
+
binary: str
|
|
37
|
+
assets: dict[str, AssetEntry]
|
|
38
|
+
encrypted: NotRequired[bool]
|
|
39
|
+
password: NotRequired[str]
|
|
40
|
+
password_committed_ok: NotRequired[bool]
|
ohbin/cli.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Command-line interface: `ohbin run | add | which | list | publish-gist | add-gist`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ohbin import ensure
|
|
11
|
+
from ohbin._add import add_tool, local_pyproject
|
|
12
|
+
from ohbin._errors import OhbinError
|
|
13
|
+
from ohbin._gist import add_gist_tool, publish_gist
|
|
14
|
+
from ohbin._manifest import find_pyproject, load_tools
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolved_pyproject(explicit: Path | None, *, write: bool) -> Path:
|
|
18
|
+
"""Resolve the manifest a command will use and announce it (to stderr).
|
|
19
|
+
|
|
20
|
+
Reading discovers upward; writing stays CWD-local. Either way we print the
|
|
21
|
+
fully-resolved realpath so it's never a mystery which pyproject ohbin touched.
|
|
22
|
+
"""
|
|
23
|
+
path = local_pyproject(explicit) if write else (explicit or find_pyproject())
|
|
24
|
+
real = path.resolve()
|
|
25
|
+
print(f"[ohbin] resolved pyproject as {real}", file=sys.stderr)
|
|
26
|
+
return real
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _cmd_run(ns: argparse.Namespace) -> int:
|
|
30
|
+
rest: list[str] = list(ns.args)
|
|
31
|
+
if rest and rest[0] == "--": # tolerate `ohbin run tool -- <args>`
|
|
32
|
+
rest = rest[1:]
|
|
33
|
+
pyproject = _resolved_pyproject(ns.pyproject_file, write=False)
|
|
34
|
+
binary = ensure(ns.tool, pyproject=pyproject, password=ns.password)
|
|
35
|
+
# execv replaces this process so signals + exit codes forward transparently.
|
|
36
|
+
os.execv(str(binary), [ns.tool, *rest])
|
|
37
|
+
return 0 # unreachable
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _cmd_add(ns: argparse.Namespace) -> int:
|
|
41
|
+
pyproject = _resolved_pyproject(ns.pyproject_file, write=True)
|
|
42
|
+
print(f"resolving {ns.repo}{f'@{ns.version}' if ns.version else ' (latest)'} ...")
|
|
43
|
+
name, target = add_tool(
|
|
44
|
+
repo=ns.repo,
|
|
45
|
+
version=ns.version,
|
|
46
|
+
name=ns.name,
|
|
47
|
+
binary=ns.binary,
|
|
48
|
+
pyproject=pyproject,
|
|
49
|
+
)
|
|
50
|
+
print(f"\nwrote [tool.ohbin.tools.{name}] to {target}")
|
|
51
|
+
print(f"run it with: uv run ohbin run {name} -- <args>")
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _cmd_publish_gist(ns: argparse.Namespace) -> int:
|
|
56
|
+
name = ns.name or Path(ns.path).name
|
|
57
|
+
url = publish_gist(
|
|
58
|
+
name=name,
|
|
59
|
+
binary=ns.binary or name,
|
|
60
|
+
binary_path=Path(ns.path),
|
|
61
|
+
password=ns.password,
|
|
62
|
+
platform_key=ns.platform,
|
|
63
|
+
gist_ref=ns.gist,
|
|
64
|
+
description=ns.desc or f"ohbin: encrypted {name}",
|
|
65
|
+
)
|
|
66
|
+
print(f"published {name} ({ns.platform or 'current platform'}) to {url}")
|
|
67
|
+
print(f"add it with: uv run ohbin add-gist {url}")
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _cmd_add_gist(ns: argparse.Namespace) -> int:
|
|
72
|
+
pyproject = _resolved_pyproject(ns.pyproject_file, write=True)
|
|
73
|
+
name, target = add_gist_tool(
|
|
74
|
+
gist_ref=ns.gist,
|
|
75
|
+
name=ns.name,
|
|
76
|
+
password=ns.password,
|
|
77
|
+
pyproject=pyproject,
|
|
78
|
+
)
|
|
79
|
+
print(f"wrote [tool.ohbin.tools.{name}] (encrypted) to {target}")
|
|
80
|
+
print(f"run it with: uv run ohbin run --password <pw> {name} -- <args>")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _cmd_which(ns: argparse.Namespace) -> int:
|
|
85
|
+
pyproject = _resolved_pyproject(ns.pyproject_file, write=False)
|
|
86
|
+
print(ensure(ns.tool, pyproject=pyproject, password=ns.password))
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _cmd_list(ns: argparse.Namespace) -> int:
|
|
91
|
+
pyproject = _resolved_pyproject(ns.pyproject_file, write=False)
|
|
92
|
+
tools = load_tools(pyproject)
|
|
93
|
+
if not tools:
|
|
94
|
+
print("no tools declared in [tool.ohbin.tools]")
|
|
95
|
+
return 0
|
|
96
|
+
width = max(len(n) for n in tools)
|
|
97
|
+
for name, cfg in sorted(tools.items()):
|
|
98
|
+
platforms = ", ".join(sorted(cfg["assets"]))
|
|
99
|
+
print(f"{name:{width}} {cfg['repo']}@{cfg['version']} [{platforms}]")
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
104
|
+
parser = argparse.ArgumentParser(prog="ohbin", description=__doc__)
|
|
105
|
+
|
|
106
|
+
# Shared across every subcommand: which manifest to read/write. Default None
|
|
107
|
+
# keeps walk-up discovery (find_pyproject) + the OHBIN_PYPROJECT override; an
|
|
108
|
+
# explicit path wins over both.
|
|
109
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
110
|
+
common.add_argument(
|
|
111
|
+
"--pyproject-file",
|
|
112
|
+
dest="pyproject_file",
|
|
113
|
+
default=None,
|
|
114
|
+
type=Path,
|
|
115
|
+
metavar="PATH",
|
|
116
|
+
help="manifest to read/write (default: nearest pyproject.toml with [tool.ohbin], or $OHBIN_PYPROJECT)",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
120
|
+
|
|
121
|
+
p_run = sub.add_parser("run", parents=[common], help="download (if needed) and exec a declared tool")
|
|
122
|
+
p_run.add_argument("tool")
|
|
123
|
+
p_run.add_argument("--password", default=None, help="decrypt password for an encrypted tool")
|
|
124
|
+
p_run.add_argument("args", nargs=argparse.REMAINDER, help="args forwarded to the tool")
|
|
125
|
+
p_run.set_defaults(func=_cmd_run)
|
|
126
|
+
|
|
127
|
+
p_add = sub.add_parser("add", parents=[common], help="resolve a GitHub release and write it into pyproject")
|
|
128
|
+
p_add.add_argument("repo", help="GitHub repo as owner/name")
|
|
129
|
+
p_add.add_argument("--version", default=None, help="release version/tag (default: latest)")
|
|
130
|
+
p_add.add_argument("--name", default=None, help="command name (default: repo name)")
|
|
131
|
+
p_add.add_argument("--binary", default=None, help="binary name in archive (default: cmd name)")
|
|
132
|
+
p_add.set_defaults(func=_cmd_add)
|
|
133
|
+
|
|
134
|
+
p_pub = sub.add_parser("publish-gist", help="encrypt a binary and upload it to a secret gist for one platform")
|
|
135
|
+
p_pub.add_argument("path", help="path to the binary to encrypt + publish")
|
|
136
|
+
p_pub.add_argument("--password", required=True, help="encryption password (keep it out of the gist)")
|
|
137
|
+
p_pub.add_argument("--name", default=None, help="command name (default: binary filename)")
|
|
138
|
+
p_pub.add_argument("--binary", default=None, help="executable name to run (default: command name)")
|
|
139
|
+
p_pub.add_argument("--platform", default=None, help="platform key, e.g. linux-x86_64 (default: current)")
|
|
140
|
+
p_pub.add_argument("--gist", default=None, help="existing gist id/url to add this platform to")
|
|
141
|
+
p_pub.add_argument("--desc", default=None, help="gist description")
|
|
142
|
+
p_pub.set_defaults(func=_cmd_publish_gist)
|
|
143
|
+
|
|
144
|
+
p_addg = sub.add_parser("add-gist", parents=[common], help="resolve a published gist and write it into pyproject")
|
|
145
|
+
p_addg.add_argument("gist", help="gist id or url produced by publish-gist")
|
|
146
|
+
p_addg.add_argument("--name", default=None, help="command name (default: from the gist index)")
|
|
147
|
+
p_addg.add_argument(
|
|
148
|
+
"--password",
|
|
149
|
+
default=None,
|
|
150
|
+
help="store this decrypt password in pyproject (omit to pass --password at run time)",
|
|
151
|
+
)
|
|
152
|
+
p_addg.set_defaults(func=_cmd_add_gist)
|
|
153
|
+
|
|
154
|
+
p_which = sub.add_parser("which", parents=[common], help="print the cached binary path (downloads if needed)")
|
|
155
|
+
p_which.add_argument("tool")
|
|
156
|
+
p_which.add_argument("--password", default=None, help="decrypt password for an encrypted tool")
|
|
157
|
+
p_which.set_defaults(func=_cmd_which)
|
|
158
|
+
|
|
159
|
+
p_list = sub.add_parser("list", parents=[common], help="list declared tools")
|
|
160
|
+
p_list.set_defaults(func=_cmd_list)
|
|
161
|
+
|
|
162
|
+
return parser
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def main(argv: list[str] | None = None) -> int:
|
|
166
|
+
ns = _build_parser().parse_args(argv)
|
|
167
|
+
try:
|
|
168
|
+
return ns.func(ns)
|
|
169
|
+
except OhbinError as exc: # expected, user-facing failures — no stack trace
|
|
170
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
171
|
+
return 1
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
if __name__ == "__main__":
|
|
175
|
+
sys.exit(main())
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ohbin
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Declarative GitHub-release binaries for uv projects — declare a tool in pyproject, `ohbin run <tool>` downloads, SHA256-verifies, caches, and execs it. POSIX only (uses flock).
|
|
5
|
+
Author: prostomarkeloff
|
|
6
|
+
Author-email: prostomarkeloff <prostomarkeloff@ohreally.me>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: tomlkit>=0.13
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# ohbin
|
|
14
|
+
|
|
15
|
+
[](https://www.python.org/downloads/)
|
|
16
|
+
[](https://opensource.org/licenses/MIT)
|
|
17
|
+
|
|
18
|
+
ohbin runs the binaries your project needs but can't `pip install`. You know the ones:
|
|
19
|
+
`ripgrep` ([or… can you?](https://pypi.org/project/ripgrep/)), [`find-dup-defs`](https://github.com/prostomarkeloff/find-dup-defs), [`oasdiff`](https://github.com/oasdiff/oasdiff), some linter
|
|
20
|
+
written in Rust that only ships as a GitHub release. uv
|
|
21
|
+
installs Python packages, and those aren't Python packages, so normally you're stuck either
|
|
22
|
+
telling everyone to install them by hand and watching the versions drift, or writing a little
|
|
23
|
+
download-and-verify wrapper package and copying it into every repo.
|
|
24
|
+
|
|
25
|
+
With ohbin you just write the tool down in your `pyproject.toml`. The first time you run it,
|
|
26
|
+
ohbin downloads it, checks it against a SHA256 you pinned, caches it, and runs it. One
|
|
27
|
+
dev-dependency, as many tools as you want.
|
|
28
|
+
|
|
29
|
+
It's a small thing on purpose, built for people who already live in uv. Your binaries get
|
|
30
|
+
pinned right next to your Python deps, in the same file, and run through the same flow.
|
|
31
|
+
|
|
32
|
+
What it gives you:
|
|
33
|
+
|
|
34
|
+
* binaries pinned to a version, pulled from GitHub releases
|
|
35
|
+
* a SHA256 per platform, checked before anything gets unpacked
|
|
36
|
+
* one dev-dependency, however many tools you declare
|
|
37
|
+
* a per-host cache that's safe to hit from parallel CI
|
|
38
|
+
* mostly stdlib (it shells out to `gh` and `openssl` only for the private-gist part)
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
It's a dev dependency, so with uv:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
uv add --dev git+https://github.com/prostomarkeloff/ohbin.git
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## How to?
|
|
49
|
+
|
|
50
|
+
Say you want ripgrep. Point ohbin at the repo:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
uv run ohbin add BurntSushi/ripgrep --version 14.1.1 --name rg --binary rg
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
This goes and looks at the release, finds the right asset for each platform, pins the SHA256s,
|
|
57
|
+
and writes a little table into your `pyproject.toml`. Your comments and formatting stay where
|
|
58
|
+
they are:
|
|
59
|
+
|
|
60
|
+
```toml
|
|
61
|
+
[tool.ohbin.tools.rg]
|
|
62
|
+
repo = "BurntSushi/ripgrep"
|
|
63
|
+
version = "14.1.1"
|
|
64
|
+
binary = "rg"
|
|
65
|
+
# add writes one [..assets.<os>-<arch>] table per platform under here, checksums and all
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`--name` is what you'll type when it's different from the repo name (ripgrep becomes rg), and
|
|
69
|
+
`--binary` is the actual executable inside the archive. If add guesses an asset wrong, don't
|
|
70
|
+
fight it, the table is the source of truth, just fix the line.
|
|
71
|
+
|
|
72
|
+
Then run it:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
uv run ohbin run rg -- --files # first time: downloads, checks, caches, runs
|
|
76
|
+
uv run ohbin run rg -- TODO src/ # after that it just runs
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
ohbin hands the process straight over with execv, so the tool itself gets stdin, stdout,
|
|
80
|
+
signals and the exit code, exactly like you'd run it yourself. In a Makefile I usually hide
|
|
81
|
+
the prefix behind a variable:
|
|
82
|
+
|
|
83
|
+
```make
|
|
84
|
+
RG := uv run ohbin run rg --
|
|
85
|
+
search:; $(RG) TODO src/
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`ohbin which fd` prints the cached path (and downloads it first if it has to), and `ohbin list`
|
|
89
|
+
shows what you've declared.
|
|
90
|
+
|
|
91
|
+
### Private binaries
|
|
92
|
+
|
|
93
|
+
Sometimes the binary isn't on a public release page. Maybe you built it yourself and you don't
|
|
94
|
+
want it in a repo at all. ohbin can ship it through a secret gist instead, encrypted with a
|
|
95
|
+
password:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
uv run ohbin publish-gist ./dist/mytool --password "$PW"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
That gzips it, encrypts it, and drops one gist file per platform plus a small index. Run it
|
|
102
|
+
from each platform's own machine, passing `--gist <id>` to add to the same gist. After that
|
|
103
|
+
it's just another tool:
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
uv run ohbin add-gist https://gist.github.com/you/ab12… --name mytool
|
|
107
|
+
uv run ohbin run --password "$PW" mytool -- --help
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Why a gist and not a private repo? Because a gist isn't tied to a repo, and that's the whole
|
|
111
|
+
point. You don't commit the binary anywhere, you don't hand out repo access and tokens to
|
|
112
|
+
everyone who needs it, you just give them a link and a password. The link is unlisted and the
|
|
113
|
+
bytes are AES-256-CBC, so a leaked link on its own is nothing without the password. The
|
|
114
|
+
password goes to openssl over a file descriptor, never on the command line. To take access
|
|
115
|
+
away, delete the gist or change the password.
|
|
116
|
+
|
|
117
|
+
### From Python
|
|
118
|
+
|
|
119
|
+
If you want the path instead of running the thing:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from ohbin import ensure
|
|
123
|
+
|
|
124
|
+
path = ensure("rg") # a Path, downloaded and checked the first time
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
It finds your `pyproject.toml` by walking up from wherever you are. Set `OHBIN_PYPROJECT` if
|
|
128
|
+
you need to point it at a specific file, like in CI.
|
|
129
|
+
|
|
130
|
+
## How it works
|
|
131
|
+
|
|
132
|
+
Nothing clever. On `ohbin run rg`, it reads the rg table, works out your os and arch, and looks
|
|
133
|
+
for `~/.cache/ohbin/rg/14.1.1/rg`. If it's there, it runs it. If not, it downloads under a lock
|
|
134
|
+
(so two parallel runs don't race), checks the SHA256 before unpacking anything, extracts, and
|
|
135
|
+
runs. The version is in the cache path, so bumping it is just a fresh download that doesn't step
|
|
136
|
+
on the old one. Downloads retry with backoff, and a real 404 doesn't get mistaken for a flaky
|
|
137
|
+
network.
|
|
138
|
+
|
|
139
|
+
## Limitations
|
|
140
|
+
|
|
141
|
+
POSIX only for now, the locking uses `fcntl` so it won't even import on Windows. add
|
|
142
|
+
auto-resolves four platforms (linux and macOS, x86_64 and arm64); anything else (windows, musl,
|
|
143
|
+
riscv) you add to the table by hand and the engine runs it fine. Asset matching is just looking
|
|
144
|
+
at the os/arch words in the filename and preferring `.tar.gz`, so a weird naming scheme might
|
|
145
|
+
cost you a one-line fix.
|
|
146
|
+
|
|
147
|
+
## Development
|
|
148
|
+
|
|
149
|
+
```sh
|
|
150
|
+
git clone https://github.com/prostomarkeloff/ohbin
|
|
151
|
+
cd ohbin && uv sync
|
|
152
|
+
|
|
153
|
+
make lint-heavy # ruff format + check + pyright
|
|
154
|
+
make test-full # the network-free test suite
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT, see [LICENSE](LICENSE).
|
|
160
|
+
|
|
161
|
+
Made with 📦 by [prostomarkeloff](https://github.com/prostomarkeloff) and contributors.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
ohbin/__init__.py,sha256=1e031f7476d21bfd7919cacf8f275ef0de6986b8bed9fe4bef09c27ba2ca94f4,3104
|
|
2
|
+
ohbin/__main__.py,sha256=915476282041b2503b2deeecc4cf70e5b8d5444cb340536c4f84b38511168ba4,156
|
|
3
|
+
ohbin/_add.py,sha256=76ee03705b8028e555ae9cdf1bbeca698a03da0ef8aca1bee1c1e385bc5e8016,8677
|
|
4
|
+
ohbin/_crypto.py,sha256=8ff07d75607e85ebed4491fb249e437f4738a15a6a0a3bff62397af078a1bb49,3829
|
|
5
|
+
ohbin/_engine.py,sha256=6f938571253f2b029d3ea46fdae17a7f75a26b32cd6808ea2a4da4e7ad15cf99,8849
|
|
6
|
+
ohbin/_errors.py,sha256=bf801a5ed91fc44a8bf34f53174d7d96e50c0f4d46f869d8e2ba402bf387c464,470
|
|
7
|
+
ohbin/_gist.py,sha256=72309401b5699262dc5912e797b36b4fbc72f05644fd130243f83fa9fc6f7002,7893
|
|
8
|
+
ohbin/_github.py,sha256=a08b702d70f725c2a14be74a26ee21c589a75eec377942207b28110cfffe6415,4388
|
|
9
|
+
ohbin/_manifest.py,sha256=67b19720d7b4868e2baf737aba2bb96e53733f99f10878861600cfac6bac8722,3697
|
|
10
|
+
ohbin/_platform.py,sha256=b7e6977af9e5bf9bfa564ce10ca9603040b18b6aecb88955e0b2b0b59c0c7016,2322
|
|
11
|
+
ohbin/_retry.py,sha256=aa420f728d2efa33e8f5b5fa47c94961f03a297c38a0cc5fa4357f8f012d87d1,1829
|
|
12
|
+
ohbin/_types.py,sha256=74129064d74de26d9e34e995dfcfde4c73595ee60cdee7a47e3b931e822b5e8c,1404
|
|
13
|
+
ohbin/cli.py,sha256=78df9de58b14af23d4d69c8c9c8fbf134f4fe86e9ac0808f7d3e739693169401,7177
|
|
14
|
+
ohbin-0.2.2.dist-info/licenses/LICENSE,sha256=901a5290e212a2d5df3eecf82c9476c0193d7b11c481c0bb06c647ec96fbdccf,1072
|
|
15
|
+
ohbin-0.2.2.dist-info/WHEEL,sha256=3b15e9d349224e1d7f45954c2fa08db3621d4900601519e7501bb24397da80f8,78
|
|
16
|
+
ohbin-0.2.2.dist-info/entry_points.txt,sha256=45ce01a96e87f712abce59fb3c26a2c32180403e90ab00e8946cb262bca17b35,42
|
|
17
|
+
ohbin-0.2.2.dist-info/METADATA,sha256=bde4b686c7df117c52d718a071853fd27f54c5c16843f36ee899ed05917c73b8,6203
|
|
18
|
+
ohbin-0.2.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 prostomarkeloff
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|