Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "ohbin"
version = "0.1.0"
version = "0.1.1"
description = "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)."
readme = "README.md"
authors = [
Expand Down
49 changes: 49 additions & 0 deletions src/ohbin/_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import sys
from pathlib import Path
from typing import TYPE_CHECKING

from ohbin._engine import sha256_of_url
from ohbin._github import Asset, fetch_release
Expand All @@ -16,6 +17,13 @@
)
from ohbin._types import AssetEntry, ToolConfig

if TYPE_CHECKING:
from tomlkit.items import Item, Key, Table

# One element of a tomlkit container body: a real key + item, or (None, item)
# for standalone whitespace/comment lines.
BodyEntry = tuple[Key | None, Item]

# Asset filenames that are never the binary we want.
_DENY_SUFFIXES = (
".txt",
Expand Down Expand Up @@ -125,6 +133,40 @@ def resolve_tool(*, repo: str, version: str | None, binary: str | None) -> ToolC
)


def _deepest_last_table(table: Table) -> Table:
"""Descend through the last sub-table at each level to the innermost one.

Standalone comments/blank lines between the end of a tool's last sub-table and
the next section header are parsed by tomlkit into *that* innermost sub-table's
body — so this is where trailing trivia lives and must be re-attached.
"""
from tomlkit.items import Table

last_sub: Table | None = None
for _key, item in table.value.body:
if isinstance(item, Table):
last_sub = item
if last_sub is None:
return table
return _deepest_last_table(last_sub)


def _detach_trailing_trivia(table: Table) -> list[BodyEntry]:
"""Pop the trailing run of standalone whitespace/comments from a tool's last table.

Returns them in document order; an empty list when the table ends on a real key.
These belong to the *following* section (a comment block before the next header),
not the tool — overwriting the tool entry must not eat them.
"""
from tomlkit.items import Comment, Whitespace

body = _deepest_last_table(table).value.body
trailing: list[BodyEntry] = []
while body and body[-1][0] is None and isinstance(body[-1][1], (Whitespace, Comment)):
trailing.insert(0, body.pop())
return trailing


def write_tool(pyproject: Path, name: str, cfg: ToolConfig) -> None:
"""Write/overwrite `[tool.ohbin.tools.<name>]`, preserving the rest of the file."""
import tomlkit
Expand All @@ -147,6 +189,11 @@ def ensure(parent: TOMLDocument | Table, key: str, *, super_table: bool) -> Tabl
super_table=True,
)

# A comment block before the next section is parsed into the old entry's
# innermost body; capture it so the rebuilt entry doesn't drop it.
old_entry = tools.get(name)
trailing = _detach_trailing_trivia(old_entry) if isinstance(old_entry, Table) else []

entry = tomlkit.table()
entry["repo"] = cfg["repo"]
entry["version"] = cfg["version"]
Expand All @@ -161,6 +208,8 @@ def ensure(parent: TOMLDocument | Table, key: str, *, super_table: bool) -> Tabl
entry["assets"] = assets

tools[name] = entry
if trailing:
_deepest_last_table(entry).value.body.extend(trailing)
pyproject.write_text(tomlkit.dumps(doc))


Expand Down
100 changes: 100 additions & 0 deletions tests/test_add.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""`write_tool`: round-trip rewrites that must not clobber surrounding file content."""

from __future__ import annotations

from pathlib import Path

import pytest

from ohbin._add import write_tool
from ohbin._manifest import load_tool
from ohbin._types import AssetEntry, ToolConfig


def _cfg(version: str) -> ToolConfig:
return ToolConfig(
repo="owner/foo",
version=version,
binary="foo",
assets={
"linux-x86_64": AssetEntry(url=f"https://example/v{version}/foo-linux.tar.gz", sha256="aaaa"),
"darwin-arm64": AssetEntry(url=f"https://example/v{version}/foo-darwin.tar.gz", sha256="bbbb"),
},
)


def test_overwrite_preserves_following_comment_block(tmp_path: Path) -> None:
# The comment block + blank line before the NEXT section is parsed by tomlkit
# into the tool's innermost sub-table body. Overwriting the tool must not eat it.
pp = tmp_path / "pyproject.toml"
pp.write_text(
"[tool.ohbin.tools.foo]\n"
'repo = "owner/foo"\n'
'version = "0.7.0"\n'
'binary = "foo"\n'
"\n"
"[tool.ohbin.tools.foo.assets.darwin-arm64]\n"
'url = "https://example/v0.7.0/foo-darwin.tar.gz"\n'
'sha256 = "old"\n'
"\n"
"# Single source of truth for the harness path scope.\n"
"# This block belongs to [tool.other], not to foo.\n"
"[tool.other]\n"
'key = "value"\n'
)

write_tool(pp, "foo", _cfg("0.7.5"))
out = pp.read_text()

assert "# Single source of truth for the harness path scope." in out
assert "# This block belongs to [tool.other], not to foo." in out
# Blank-line separator between the tool block and the comment survives.
assert "\n\n# Single source of truth" in out
# The next section header is intact and still preceded by its comments.
assert out.index("# This block belongs") < out.index("[tool.other]")
# The version bump actually happened.
assert load_tool("foo", pyproject=pp)["version"] == "0.7.5"
assert "0.7.0" not in out


def test_add_new_tool_appends_without_touching_existing(tmp_path: Path) -> None:
pp = tmp_path / "pyproject.toml"
pp.write_text('[project]\nname = "demo"\n\n# trailing project comment\n')

write_tool(pp, "foo", _cfg("1.0.0"))
out = pp.read_text()

assert "# trailing project comment" in out
tool = load_tool("foo", pyproject=pp)
assert tool["version"] == "1.0.0"
assert set(tool["assets"]) == {"linux-x86_64", "darwin-arm64"}


def test_overwrite_at_eof_is_idempotent_on_layout(tmp_path: Path) -> None:
# No following section: a re-add must produce stable, parseable output.
pp = tmp_path / "pyproject.toml"
pp.write_text("")
write_tool(pp, "foo", _cfg("0.1.0"))
write_tool(pp, "foo", _cfg("0.2.0"))

tool = load_tool("foo", pyproject=pp)
assert tool["version"] == "0.2.0"
assert "0.1.0" not in pp.read_text()


def test_write_new_tool_into_empty_file(tmp_path: Path) -> None:
pp = tmp_path / "pyproject.toml"
pp.write_text("")
write_tool(pp, "foo", _cfg("3.1.4"))
assert load_tool("foo", pyproject=pp)["version"] == "3.1.4"


@pytest.mark.parametrize("trailing", ["", "\n", "\n\n"])
def test_overwrite_tolerates_varied_trailing_whitespace(tmp_path: Path, trailing: str) -> None:
pp = tmp_path / "pyproject.toml"
pp.write_text("")
write_tool(pp, "foo", _cfg("0.1.0"))
pp.write_text(pp.read_text().rstrip("\n") + trailing)

write_tool(pp, "foo", _cfg("0.2.0"))
assert load_tool("foo", pyproject=pp)["version"] == "0.2.0"
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading