diff --git a/python/unbrowser/__init__.py b/python/unbrowser/__init__.py index 2793ec9..4e5c456 100644 --- a/python/unbrowser/__init__.py +++ b/python/unbrowser/__init__.py @@ -60,13 +60,13 @@ def find_binary() -> str: 1. ``UNBROWSER_BIN`` env var (overrides everything; right escape hatch for testing a one-off build or vendored copy). - 2. Bundled binary inside this package (the wheel ships one for your - platform — this is what end users hit). + 2. Newest by mtime among: the bundled binary inside this package and + local dev builds (``target/release`` / ``target/debug`` relative to + a source checkout). End users with a wheel install only have the + bundled binary; developers with a fresh ``cargo build`` get that + instead of a months-old bundled one. 3. ``unbrowser`` on ``$PATH`` (covers ``cargo install`` / ``brew install`` users who didn't install the wheel). - 4. The local debug build at ``target/debug/unbrowser`` relative to the - repo root (developer convenience — only fires when running from a - checkout without an installed wheel). Raises UnbrowserError with a helpful message if none of the above resolve. """ @@ -74,17 +74,24 @@ def find_binary() -> str: if env: return _checked_binary(Path(env), "UNBROWSER_BIN") - bundled = Path(__file__).parent / "_bin" / _binary_name() - if bundled.is_file(): - return _checked_binary(bundled, "bundled binary") - # Dev fallback before PATH: source checkouts commonly have the Python # package importable without an installed wheel, while PATH may contain the # pip-generated `unbrowser` console wrapper. Prefer the real local binary. # (python/unbrowser/__init__.py -> python/unbrowser -> python -> repo root). - dev = Path(__file__).resolve().parents[2] / "target" / "debug" / "unbrowser" - if dev.is_file(): - return _checked_binary(dev, "target/debug/unbrowser") + name = _binary_name() + candidates: list[tuple[float, str, Path]] = [] + for source, path in ( + ("bundled binary", Path(__file__).parent / "_bin" / name), + ("target/release/" + name, Path(__file__).resolve().parents[2] / "target" / "release" / name), + ("target/debug/" + name, Path(__file__).resolve().parents[2] / "target" / "debug" / name), + ): + try: + candidates.append((path.stat().st_mtime, source, path)) + except OSError: + continue # not present; single stat avoids is_file/stat TOCTOU + if candidates: + _, source, path = max(candidates, key=lambda c: c[0]) + return _checked_binary(path, source) on_path = shutil.which("unbrowser") if on_path: @@ -94,7 +101,7 @@ def find_binary() -> str: raise UnbrowserError( "Could not locate the unbrowser binary. Tried: $UNBROWSER_BIN, " - "package-bundled binary, target/debug/unbrowser, $PATH. " + "package-bundled binary, target/release|debug/unbrowser, $PATH. " "Install via `pip install pyunbrowser` (PyPI distribution; ships the binary), " "`cargo install unbrowser`, or `brew install unbrowser`." ) diff --git a/tests/test_mcp_minimal.py b/tests/test_mcp_minimal.py index ff7885d..916f548 100644 --- a/tests/test_mcp_minimal.py +++ b/tests/test_mcp_minimal.py @@ -1,6 +1,7 @@ """Protocol test: minimal vs full tools/list + help drift (PR #48).""" import json +import os import subprocess import sys from pathlib import Path @@ -235,3 +236,48 @@ def test_apply_coherence_filters_contradictions(): before = json.loads(json.dumps(c)) _apply_coherence(c) assert c == before + + +def test_find_binary_prefers_freshest_local_build(tmp_path): + # Regression: editable installs silently resolved to a months-old bundled + # binary even when target/release was freshly built. find_binary must pick + # the newest of bundled/target-release/target-debug by mtime. + sys.path.insert(0, str(REPO / "python")) + import unbrowser + + name = unbrowser._binary_name() # unbrowser.exe on Windows + + def fake_bin(path): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"#!/bin/sh\nexit 0\n") + if os.name != "nt": + path.chmod(0o755) + return path + + # find_binary derives: bundled =