90d6104e17
prd-number-check / require-numbered-prds (pull_request) Successful in 5s
test / image-input-builds (pull_request) Successful in 38s
test / unit (pull_request) Successful in 44s
tracker-policy-pr / check-pr (pull_request) Successful in 4s
lint / lint (push) Successful in 53s
test / integration-docker (pull_request) Successful in 58s
test / coverage (pull_request) Successful in 18s
The harness's second run hit the wall the first one predicted: a fresh account has no pipx, so install.sh fell to `pip install --user`, and every Python a Mac offers — Homebrew and python.org alike — is externally managed, so PEP 668 blocked it. That fallback was never a fallback on macOS; it was a dead end that printed instructions. Replace it with a venv at ~/.bot-bottle/venv (BOT_BOTTLE_VENV to move it), with the console script symlinked into ~/.local/bin. PEP 668 does not apply inside a venv, and venv is stdlib, so unlike pipx there is nothing to bootstrap first. pipx stays the preferred path when present, so anyone already managing their Python apps that way is unaffected — and the post-install PATH check now asks pipx for PIPX_BIN_DIR instead of assuming ~/.local/bin. Keeping the venv under ~/.bot-bottle rather than ~/.local/share means the whole footprint stays in one directory, which is what lets the throwaway-account teardown remain a complete reset. This removes the PEP 668 pre-flight and the sysconfig user-scheme lookup, both of which existed only to serve the --user path. Their tests go with them: * `detects_externally_managed_python` asserted the check that is now moot; replaced by one asserting pipx is still preferred when present. * `checks_pip_usable_before_fallback` pinned a pip probe that no longer runs; replaced by one asserting the venv's own pip does the install, since using the base interpreter's would install outside the venv. * `resolves_user_scripts_dir_not_hardcoded` and `macos_user_scheme_is_not_dot_local_bin` guarded the ~/Library/Python scripts-dir lookup. Nothing installs there now. The surviving "don't hardcode" concern is pipx's bin dir, which has its own test. Five tests are added for the new path: the venv fallback exists, no --user path survives, the venv is under the config dir, venv creation failure names python3-venv (Debian ships it separately), and the entry point is exposed outside the venv. Verified end to end in a sandbox HOME with a fresh-account PATH and no pipx: venv built, package installed, symlink created, `doctor` reached and green (python 3.14.5, macos-container ready), exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
144 lines
6.1 KiB
Python
144 lines
6.1 KiB
Python
"""Unit: install.sh bootstrapper contract.
|
|
|
|
The installer is a thin, sudo-free, idempotent bootstrapper. These are
|
|
static checks on the script text (no network / no real install) so CI can
|
|
run them anywhere: it must be executable, fail-fast, never call sudo,
|
|
create the config tree, install the package, and verify with `doctor`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
INSTALL_SH = REPO_ROOT / "install.sh"
|
|
|
|
|
|
def code_only(text: str) -> str:
|
|
"""Script text with string literals and comments removed.
|
|
|
|
Both are places the script *talks about* commands rather than running
|
|
them — remediation advice quite reasonably says "sudo apt install …" —
|
|
so assertions about what the script actually executes must not see them.
|
|
Strings are stripped before comments because a '#' inside a quoted string
|
|
is not a comment, and several literals here span multiple lines.
|
|
"""
|
|
without_strings = re.sub(r"\"(?:[^\"\\]|\\.)*\"|'[^']*'", "", text)
|
|
return "\n".join(
|
|
ln for ln in without_strings.splitlines()
|
|
if ln.strip() and not ln.lstrip().startswith("#")
|
|
)
|
|
|
|
|
|
class TestInstallScript(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.text = INSTALL_SH.read_text()
|
|
|
|
def test_exists_and_executable(self):
|
|
self.assertTrue(INSTALL_SH.is_file())
|
|
self.assertTrue(os.access(INSTALL_SH, os.X_OK), "install.sh must be executable")
|
|
|
|
def test_posix_shebang_and_failfast(self):
|
|
first = self.text.splitlines()[0]
|
|
self.assertEqual("#!/bin/sh", first)
|
|
self.assertIn("set -eu", self.text)
|
|
|
|
def test_never_uses_sudo(self):
|
|
# The installer must never *invoke* sudo. It may print it: the "no
|
|
# usable python" error suggests 'sudo apt install python3.12'.
|
|
self.assertNotIn("sudo", code_only(self.text))
|
|
|
|
def test_creates_config_tree(self):
|
|
self.assertIn(".bot-bottle/agents", self.text)
|
|
self.assertIn(".bot-bottle/bottles", self.text)
|
|
|
|
def test_installs_via_pipx_with_venv_fallback(self):
|
|
self.assertIn("pipx install", self.text)
|
|
self.assertIn("-m venv", self.text)
|
|
|
|
def test_no_pip_user_fallback(self):
|
|
# `pip install --user` is not a fallback, it's a dead end: PEP 668
|
|
# blocks it on Homebrew, python.org and Debian/Ubuntu interpreters,
|
|
# which is every Python a Mac realistically offers. A private venv is
|
|
# exempt from PEP 668 and needs no bootstrap, since venv is stdlib.
|
|
# code_only, because the comment explaining the absence says the words.
|
|
code = code_only(self.text)
|
|
self.assertNotIn("pip install --user", code)
|
|
self.assertNotIn("--break-system-packages", code)
|
|
|
|
def test_venv_lives_under_the_config_dir(self):
|
|
# Keeps the whole install footprint inside ~/.bot-bottle (plus the
|
|
# entry-point symlink), which is what makes deleting a throwaway
|
|
# account a complete reset in scripts/macos-install-test.sh.
|
|
self.assertIn(".bot-bottle/venv", self.text)
|
|
self.assertIn("BOT_BOTTLE_VENV", self.text)
|
|
|
|
def test_venv_failure_is_actionable(self):
|
|
# Debian/Ubuntu ship venv separately; failing there must say so rather
|
|
# than dumping ensurepip's error.
|
|
self.assertIn("python3-venv", self.text)
|
|
|
|
def test_entry_point_is_exposed_outside_the_venv(self):
|
|
# A venv's bin dir is never on PATH, so the console script has to be
|
|
# linked somewhere conventional or `bot-bottle` is unreachable.
|
|
self.assertIn(".local/bin", self.text)
|
|
self.assertIn("ln -sf", self.text)
|
|
|
|
def test_runs_doctor_after_install(self):
|
|
self.assertIn("doctor", self.text)
|
|
|
|
def test_install_spec_is_overridable(self):
|
|
# Tests / local installs point BOT_BOTTLE_INSTALL_SPEC at a checkout.
|
|
self.assertIn("BOT_BOTTLE_INSTALL_SPEC", self.text)
|
|
|
|
def test_requires_git_for_git_specs(self):
|
|
# A git+ / .git spec (the default) shells out to git; the script must
|
|
# gate on it rather than failing opaquely inside pipx/pip.
|
|
self.assertIn("command -v git", self.text)
|
|
self.assertIn("git+*|*.git", self.text)
|
|
|
|
def test_installs_into_the_venv_with_its_own_pip(self):
|
|
# The venv's pip, not the base interpreter's — the base one may not
|
|
# exist, and using it would install outside the venv.
|
|
self.assertIn("${VENV}/bin/python\" -m pip install", self.text)
|
|
|
|
def test_pipx_is_preferred_when_present(self):
|
|
# The venv is a fallback, not a takeover: someone who already manages
|
|
# their Python apps with pipx keeps doing so.
|
|
self.assertIn("command -v pipx", self.text)
|
|
|
|
def test_asks_pipx_where_its_bin_dir_is(self):
|
|
# PIPX_BIN_DIR is configurable, so the post-install "is it on PATH?"
|
|
# check must ask rather than assume ~/.local/bin.
|
|
self.assertIn("PIPX_BIN_DIR", self.text)
|
|
|
|
def test_searches_beyond_path_for_an_interpreter(self):
|
|
# `python3` on PATH is the *oldest* interpreter on a stock Mac: a fresh
|
|
# account's PATH is /etc/paths, so python3 is the 3.9.6 CLT stub while
|
|
# the usable build sits somewhere only a shell profile puts on PATH.
|
|
# Giving up at that point dead-ends every new macOS user.
|
|
for candidate in ("python3.11", "/opt/homebrew/bin", "Python.framework"):
|
|
self.assertIn(candidate, self.text)
|
|
|
|
def test_interpreter_is_overridable(self):
|
|
self.assertIn("BOT_BOTTLE_PYTHON", self.text)
|
|
|
|
def test_pipx_is_pinned_to_the_vetted_interpreter(self):
|
|
# Without --python, pipx builds the venv with whichever interpreter
|
|
# pipx itself was installed with, which need not be the one that
|
|
# passed the version check.
|
|
self.assertIn("pipx install --python", self.text)
|
|
|
|
def test_version_failure_is_actionable(self):
|
|
# The failure a new macOS user actually hits must say what to do about
|
|
# it, not just state the requirement.
|
|
self.assertIn("brew install python@", self.text)
|
|
self.assertIn("BOT_BOTTLE_PYTHON=/path/to/python3", self.text)
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|