refactor: BottleImages dataclass, prelaunch_checks, build_or_load_images
Addresses review comments 3098, 3099, 3100 on PR #336: - Add BottleImages(agent, sidecar) dataclass to backend/__init__.py. Docker/macOS backends use str image refs; smolmachines uses Path artifacts. Replaces the singular `image` variable from the canonical pattern in comment 3100. - Replace _image_stale_checks/skip_stale with public prelaunch_checks(). CLI now calls backend.prelaunch_checks(plan) before backend.launch(plan); if StaleImageError is raised and the operator confirms, launch proceeds without re-checking. Removes the while-True/skip_stale retry loop. - Add abstract _build_or_load_images(plan) -> BottleImages to BottleBackend. launch() calls it then passes images to _launch_impl. Each backend implements both methods. - Fix comment 3098 (macos-container): _build_images is removed. build_or_load_images() has separate fresh/cached code paths — the cached path never calls a build helper. - Update _start_bundle (smolmachines) to accept sidecar_artifact: Path directly. Sidecar artifact resolution moves to _sidecar_from_path(), called by build_or_load_images alongside _agent_from_path().
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
"""Unit: _launch_bottle StaleImageError handling.
|
||||
|
||||
Exercises the while-True / try-except loop around backend.launch:
|
||||
Exercises prelaunch_checks / backend.launch flow:
|
||||
- headless mode → die on stale
|
||||
- interactive mode, user declines → stop without launching
|
||||
- interactive mode, user confirms → retry with skip_stale=True
|
||||
- interactive mode, user confirms → skip stale check and launch once
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -28,14 +28,6 @@ def _fake_plan() -> Any:
|
||||
))
|
||||
|
||||
|
||||
def _stale_cm() -> MagicMock:
|
||||
"""Return a context-manager mock whose __enter__ raises StaleImageError."""
|
||||
cm = MagicMock()
|
||||
cm.__enter__ = MagicMock(side_effect=StaleImageError("image is 5 day(s) old"))
|
||||
cm.__exit__ = MagicMock(return_value=False)
|
||||
return cm
|
||||
|
||||
|
||||
def _ok_cm(bottle: Any) -> MagicMock:
|
||||
"""Return a context-manager mock that yields `bottle`."""
|
||||
cm = MagicMock()
|
||||
@@ -78,23 +70,25 @@ class TestLaunchBottleStaleHandling(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_headless_stale_calls_die(self) -> None:
|
||||
"""In headless mode (assume_yes=True), a StaleImageError must call die()."""
|
||||
"""In headless mode (assume_yes=True), a StaleImageError from prelaunch_checks must call die()."""
|
||||
import bot_bottle.cli.start as start_mod
|
||||
|
||||
backend_mock = MagicMock()
|
||||
backend_mock.launch.return_value = _stale_cm()
|
||||
backend_mock.prelaunch_checks.side_effect = StaleImageError("image is 5 day(s) old")
|
||||
|
||||
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
|
||||
patch.object(start_mod, "die", side_effect=Die()):
|
||||
with self.assertRaises(Die):
|
||||
self._run_launch(assume_yes=True)
|
||||
|
||||
def test_interactive_user_declines_stops_loop(self) -> None:
|
||||
"""Interactive user answering 'n' → loop exits without a second launch."""
|
||||
backend_mock.launch.assert_not_called()
|
||||
|
||||
def test_interactive_user_declines_stops_before_launch(self) -> None:
|
||||
"""Interactive user answering 'n' → launch is never called."""
|
||||
import bot_bottle.cli.start as start_mod
|
||||
|
||||
backend_mock = MagicMock()
|
||||
backend_mock.launch.return_value = _stale_cm()
|
||||
backend_mock.prelaunch_checks.side_effect = StaleImageError("image is 5 day(s) old")
|
||||
|
||||
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
|
||||
patch.object(start_mod, "read_tty_line", return_value="n"), \
|
||||
@@ -102,23 +96,18 @@ class TestLaunchBottleStaleHandling(unittest.TestCase):
|
||||
rc = self._run_launch(assume_yes=False)
|
||||
|
||||
self.assertEqual(0, rc)
|
||||
# launch was called once (stale), then the user declined → no second call.
|
||||
backend_mock.launch.assert_called_once()
|
||||
backend_mock.launch.assert_not_called()
|
||||
|
||||
def test_interactive_user_confirms_retries_with_skip_stale(self) -> None:
|
||||
"""Interactive user answering 'y' → second launch with skip_stale=True."""
|
||||
def test_interactive_user_confirms_launches_once(self) -> None:
|
||||
"""Interactive user answering 'y' → prelaunch stale error is bypassed; launch called once."""
|
||||
import bot_bottle.cli.start as start_mod
|
||||
|
||||
bottle_mock = MagicMock()
|
||||
bottle_mock.name = "dev-abc"
|
||||
|
||||
def launch_side_effect(plan: Any, *, skip_stale: bool = False) -> Any:
|
||||
if not skip_stale:
|
||||
return _stale_cm()
|
||||
return _ok_cm(bottle_mock)
|
||||
|
||||
backend_mock = MagicMock()
|
||||
backend_mock.launch.side_effect = launch_side_effect
|
||||
backend_mock.prelaunch_checks.side_effect = StaleImageError("image is 5 day(s) old")
|
||||
backend_mock.launch.return_value = _ok_cm(bottle_mock)
|
||||
|
||||
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
|
||||
patch.object(start_mod, "read_tty_line", return_value="y"), \
|
||||
@@ -128,11 +117,8 @@ class TestLaunchBottleStaleHandling(unittest.TestCase):
|
||||
rc = self._run_launch(assume_yes=False)
|
||||
|
||||
self.assertEqual(0, rc)
|
||||
# First call: skip_stale=False → stale. Second call: skip_stale=True → ok.
|
||||
self.assertEqual(2, backend_mock.launch.call_count)
|
||||
calls = backend_mock.launch.call_args_list
|
||||
self.assertFalse(calls[0].kwargs.get("skip_stale", False))
|
||||
self.assertTrue(calls[1].kwargs.get("skip_stale", False))
|
||||
backend_mock.prelaunch_checks.assert_called_once()
|
||||
backend_mock.launch.assert_called_once()
|
||||
|
||||
def test_interactive_yes_uppercase_also_accepted(self) -> None:
|
||||
"""'Y' or 'YES' should also be accepted as confirmation."""
|
||||
@@ -141,13 +127,9 @@ class TestLaunchBottleStaleHandling(unittest.TestCase):
|
||||
bottle_mock = MagicMock()
|
||||
bottle_mock.name = "dev-abc"
|
||||
|
||||
def launch_side_effect(plan: Any, *, skip_stale: bool = False) -> Any:
|
||||
if not skip_stale:
|
||||
return _stale_cm()
|
||||
return _ok_cm(bottle_mock)
|
||||
|
||||
backend_mock = MagicMock()
|
||||
backend_mock.launch.side_effect = launch_side_effect
|
||||
backend_mock.prelaunch_checks.side_effect = StaleImageError("image is 5 day(s) old")
|
||||
backend_mock.launch.return_value = _ok_cm(bottle_mock)
|
||||
|
||||
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
|
||||
patch.object(start_mod, "read_tty_line", return_value="YES"), \
|
||||
@@ -157,7 +139,7 @@ class TestLaunchBottleStaleHandling(unittest.TestCase):
|
||||
rc = self._run_launch(assume_yes=False)
|
||||
|
||||
self.assertEqual(0, rc)
|
||||
self.assertEqual(2, backend_mock.launch.call_count)
|
||||
backend_mock.launch.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user