5933fdc789
Give bot-bottle a real distribution path so new users can install
without cloning the repo:
- pyproject.toml: full project metadata, a `bot-bottle` console-script
entry point (bot_bottle.cli:main), and package-data for the runtime
assets (Dockerfiles, egress entrypoint, netpool defaults, macos init).
Still zero runtime pip dependencies.
- install.sh: POSIX, sudo-free, idempotent bootstrapper — checks Python
>= 3.11, creates ~/.bot-bottle/{agents,bottles,contrib}, installs via
pipx (pip --user fallback), then runs `bot-bottle doctor`.
- `bot-bottle doctor`: new store-free subcommand reporting Python
version, backend availability (reuses is_backend_available rather than
hardcoding Docker), and config-dir presence. Exits non-zero when a hard
prerequisite is unmet.
- PRD prd-new-install-script and unit tests for doctor, the packaging
contract, and the install script.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
78 lines
3.1 KiB
Python
78 lines
3.1 KiB
Python
"""Unit: `bot-bottle doctor` host prerequisite checks (ADR 0004).
|
|
|
|
`doctor` is a store-free diagnostic — it must run on a fresh install
|
|
before any DB migration, and its exit code gates only the two hard
|
|
prerequisites (Python and an available backend). The config-dir check is
|
|
advisory and never affects the exit code.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import tempfile
|
|
import unittest
|
|
from contextlib import redirect_stdout
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from bot_bottle.cli.commands import doctor
|
|
|
|
|
|
def _run(argv: list[str] | None = None) -> tuple[int, str]:
|
|
buf = io.StringIO()
|
|
with redirect_stdout(buf):
|
|
code = doctor.cmd_doctor(argv or [])
|
|
return code, buf.getvalue()
|
|
|
|
|
|
class TestDoctor(unittest.TestCase):
|
|
def test_passes_when_python_and_backend_ok(self):
|
|
with patch.object(doctor, "known_backend_names", return_value=("docker",)), \
|
|
patch.object(doctor, "is_backend_available", return_value=True):
|
|
code, out = _run()
|
|
self.assertEqual(0, code)
|
|
self.assertIn("ok: python", out)
|
|
self.assertIn("ok: backend: available: docker", out)
|
|
|
|
def test_fails_when_no_backend_available(self):
|
|
with patch.object(doctor, "known_backend_names", return_value=("docker", "firecracker")), \
|
|
patch.object(doctor, "is_backend_available", return_value=False):
|
|
code, out = _run()
|
|
self.assertEqual(1, code)
|
|
self.assertIn("fail: backend", out)
|
|
|
|
def test_fails_when_python_too_old(self):
|
|
# Force the version gate to fail without touching the interpreter.
|
|
with patch.object(doctor, "MIN_PYTHON", (99, 0)), \
|
|
patch.object(doctor, "known_backend_names", return_value=("docker",)), \
|
|
patch.object(doctor, "is_backend_available", return_value=True):
|
|
code, out = _run()
|
|
self.assertEqual(1, code)
|
|
self.assertIn("fail: python", out)
|
|
|
|
def test_missing_config_dir_is_advisory_not_fatal(self):
|
|
# A missing ~/.bot-bottle warns but must not fail. Point home at a
|
|
# fresh empty dir so the shared suite HOME (which other tests may
|
|
# populate) can't turn this into an "ok: config".
|
|
with tempfile.TemporaryDirectory() as tmp, \
|
|
patch.object(doctor.Path, "home", return_value=Path(tmp)), \
|
|
patch.object(doctor, "known_backend_names", return_value=("docker",)), \
|
|
patch.object(doctor, "is_backend_available", return_value=True):
|
|
code, out = _run()
|
|
self.assertEqual(0, code)
|
|
self.assertIn("warn: config", out)
|
|
|
|
def test_present_config_dir_reports_ok(self):
|
|
with tempfile.TemporaryDirectory() as tmp, \
|
|
patch.object(doctor.Path, "home", return_value=Path(tmp)), \
|
|
patch.object(doctor, "known_backend_names", return_value=("docker",)), \
|
|
patch.object(doctor, "is_backend_available", return_value=True):
|
|
(Path(tmp) / ".bot-bottle").mkdir()
|
|
code, out = _run()
|
|
self.assertEqual(0, code)
|
|
self.assertIn("ok: config", out)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|