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
33 changes: 20 additions & 13 deletions python/unbrowser/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,31 +60,38 @@ 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.
"""
env = os.environ.get("UNBROWSER_BIN")
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:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor TOCTOU: the file existence is checked with is_file() and then re-read via stat() a few lines later. If a candidate is removed between the two calls this raises. Low risk in practice (these are build artifacts), but wrapping the stat in try/except or using a single stat would be more robust — optional.

_, source, path = max(candidates, key=lambda c: c[0])

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

max(candidates, key=lambda c: c[0]) is correct, but note it is silent about mtime ties and about a stale-but-present binary that is non-executable. The subsequent _checked_binary(path, source) re-validates executability, so a non-executable but newest candidate still errors out with a helpful message rather than falling back to an older usable build. Consider whether fallback-to-next-newest is desired; as-is it intentionally fails fast, which is the safer behavior.

return _checked_binary(path, source)

on_path = shutil.which("unbrowser")
if on_path:
Expand All @@ -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`."
)
Expand Down
46 changes: 46 additions & 0 deletions tests/test_mcp_minimal.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REPO is not defined anywhere in the visible diff. If it isn't a module-level global or fixture defined elsewhere in this file, this line raises NameError and the test won't run. Verify REPO exists or define it (e.g. from pathlib.Path(__file__).resolve().parents[2]).

# 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"))

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate import os inside the function: os was already imported at module scope (used for os.utime). The function-level import is redundant; drop it or move it to the module imports.

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 = <dir(__file__)>/_bin/<name>,
# dev root = resolve(__file__).parents[2]/target -> pkg lives under repo/
repo = tmp_path / "repo"
pkg = repo / "pkg" / "unbrowser"
pkg.mkdir(parents=True)
(pkg / "__init__.py").write_text("")
old_bundled = fake_bin(pkg / "_bin" / name)
os.utime(old_bundled, (1_000_000,) * 2)

real_file = unbrowser.__file__
real_path_pos = sys.path.index(str(REPO / "python"))
unbrowser.__file__ = str(pkg / "__init__.py")
try:
release = fake_bin(repo / "target" / "release" / name)
os.utime(release, (2_000_000,) * 2)
assert unbrowser.find_binary() == str(release), "fresh release must beat stale bundled"

release.unlink()
debug = fake_bin(repo / "target" / "debug" / name)
os.utime(debug, (500_000,) * 2) # older than bundled -> bundled wins

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test mutates the live unbrowser.__file__ and global sys.path, then restores them in finally. This is safe for a single test but can leak state if the assertion raises before real_file/real_path_pos are captured — they are currently captured before the try, so that's fine here. Consider wrapping the mutation in a unittest.mock.patch('unbrowser.__file__', ...) or a fixture to guarantee cleanup even if future edits reorder the setup.

assert unbrowser.find_binary() == str(old_bundled), "bundled must beat stale debug"

os.utime(debug, (3_000_000,) * 2)
assert unbrowser.find_binary() == str(debug), "fresh debug must win overall"
finally:
unbrowser.__file__ = real_file
sys.path.pop(real_path_pos)
Loading