diff --git a/pyproject.toml b/pyproject.toml index 32d8580..cacbaa3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 ` downloads, SHA256-verifies, caches, and execs it. POSIX only (uses flock)." readme = "README.md" authors = [ diff --git a/src/ohbin/_add.py b/src/ohbin/_add.py index 3b19202..914ed72 100644 --- a/src/ohbin/_add.py +++ b/src/ohbin/_add.py @@ -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 @@ -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", @@ -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.]`, preserving the rest of the file.""" import tomlkit @@ -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"] @@ -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)) diff --git a/tests/test_add.py b/tests/test_add.py new file mode 100644 index 0000000..5ab65ae --- /dev/null +++ b/tests/test_add.py @@ -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" diff --git a/uv.lock b/uv.lock index 52dc925..f2ed040 100644 --- a/uv.lock +++ b/uv.lock @@ -31,7 +31,7 @@ wheels = [ [[package]] name = "ohbin" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ { name = "tomlkit" },