-
Notifications
You must be signed in to change notification settings - Fork 1
fix(packaging): find_binary prefers freshest local build over stale bundled binary #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9cca2b3
46c0b54
daf4b46
f1cf865
67ca796
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: | ||
| _, source, path = max(candidates, key=lambda c: c[0]) | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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`." | ||
| ) | ||
|
|
||
| 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 | ||
|
|
@@ -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 | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| # 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")) | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Duplicate |
||
| 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 | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The test mutates the live |
||
| 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) | ||
There was a problem hiding this comment.
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 viastat()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.