From 47b6bead69783368b25aef1d75f12238ab049d19 Mon Sep 17 00:00:00 2001 From: codex Date: Sun, 26 Jul 2026 22:32:43 +0000 Subject: [PATCH] refactor(backend): type enumeration failures --- bot_bottle/backend/__init__.py | 3 +++ bot_bottle/backend/base.py | 4 ++++ bot_bottle/backend/docker/compose.py | 9 +++++--- bot_bottle/backend/docker/enumerate.py | 6 ++--- .../backend/macos_container/enumerate.py | 23 ++++++++++--------- tests/unit/test_compose.py | 3 ++- tests/unit/test_docker_enumerate_active.py | 3 ++- tests/unit/test_macos_container_cleanup.py | 11 ++++++++- 8 files changed, 42 insertions(+), 20 deletions(-) diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index 1baf774b..311a9812 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -30,6 +30,7 @@ if TYPE_CHECKING: BottleImages, BottlePlan, BottleSpec, + EnumerationError, ExecResult, ) from .selection import ( @@ -59,6 +60,7 @@ _LAZY_MODULES: dict[str, str] = { "BottleImages": "base", "BottleBackend": "base", "BackendStatus": "base", + "EnumerationError": "base", "get_bottle_backend": "selection", "known_backend_names": "selection", "has_backend": "selection", @@ -100,6 +102,7 @@ __all__ = [ "BottlePlan", "BottleSpec", "ExecResult", + "EnumerationError", "CommitCancelled", "Freezer", "get_freezer", diff --git a/bot_bottle/backend/base.py b/bot_bottle/backend/base.py index 65478387..73fa744d 100644 --- a/bot_bottle/backend/base.py +++ b/bot_bottle/backend/base.py @@ -42,6 +42,10 @@ class BackendStatus(enum.IntEnum): READY = 0 +class EnumerationError(RuntimeError): + """A backend could not produce an authoritative live-resource snapshot.""" + + @dataclass(frozen=True) class BottleSpec: """CLI-supplied intent. Backend-agnostic — each backend's prepare diff --git a/bot_bottle/backend/docker/compose.py b/bot_bottle/backend/docker/compose.py index b7ed2d39..ae302fa0 100644 --- a/bot_bottle/backend/docker/compose.py +++ b/bot_bottle/backend/docker/compose.py @@ -16,6 +16,7 @@ from pathlib import Path from typing import Any from ...log import die, warn +from ..base import EnumerationError # --- Lifecycle helpers (PRD 0018 chunk 3) ---------------------------------- @@ -75,12 +76,14 @@ def list_compose_projects( ) except FileNotFoundError as exc: if raise_on_error: - raise RuntimeError("docker compose ls failed: docker not found") from exc + raise EnumerationError( + "docker compose ls failed: docker not found" + ) from exc return [] if result.returncode != 0: message = f"docker compose ls failed: {result.stderr.strip()}" if raise_on_error: - raise RuntimeError(message) + raise EnumerationError(message) if warn_on_error: warn(message) return [] @@ -89,7 +92,7 @@ def list_compose_projects( except json.JSONDecodeError as e: message = f"docker compose ls returned malformed JSON: {e}" if raise_on_error: - raise RuntimeError(message) from e + raise EnumerationError(message) from e if warn_on_error: warn(message) return [] diff --git a/bot_bottle/backend/docker/enumerate.py b/bot_bottle/backend/docker/enumerate.py index f7c28431..81c4c835 100644 --- a/bot_bottle/backend/docker/enumerate.py +++ b/bot_bottle/backend/docker/enumerate.py @@ -12,7 +12,7 @@ from __future__ import annotations import subprocess -from .. import ActiveAgent +from .. import ActiveAgent, EnumerationError from ...bottle_state import read_metadata from .compose import compose_project_name, list_active_slugs @@ -75,7 +75,7 @@ def _query_services_by_project() -> dict[str, set[str]]: capture_output=True, text=True, check=False, ) except FileNotFoundError as exc: - raise RuntimeError("docker ps failed: docker not found") from exc + raise EnumerationError("docker ps failed: docker not found") from exc if r.returncode != 0: - raise RuntimeError(f"docker ps failed: {r.stderr.strip()}") + raise EnumerationError(f"docker ps failed: {r.stderr.strip()}") return _parse_services_by_project(r.stdout or "") diff --git a/bot_bottle/backend/macos_container/enumerate.py b/bot_bottle/backend/macos_container/enumerate.py index 18421edc..db4e2c50 100644 --- a/bot_bottle/backend/macos_container/enumerate.py +++ b/bot_bottle/backend/macos_container/enumerate.py @@ -5,7 +5,7 @@ from __future__ import annotations import subprocess from ...bottle_state import read_metadata -from .. import ActiveAgent +from .. import ActiveAgent, EnumerationError from .infra import INFRA_NAME, ORCHESTRATOR_NAME # The name every agent container carries: `bot-bottle-`. Exported @@ -20,17 +20,18 @@ CONTAINER_NAME_PREFIX = "bot-bottle-" _INFRA_NAMES = frozenset({INFRA_NAME, ORCHESTRATOR_NAME}) -class EnumerationError(RuntimeError): - """container list failed; the resulting live set is not authoritative.""" - - def enumerate_active() -> list[ActiveAgent]: - result = subprocess.run( - ["container", "list", "--quiet"], - capture_output=True, - text=True, - check=False, - ) + try: + result = subprocess.run( + ["container", "list", "--quiet"], + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError as exc: + raise EnumerationError( + "container list failed: container CLI not found" + ) from exc if result.returncode != 0: raise EnumerationError( f"container list failed: " diff --git a/tests/unit/test_compose.py b/tests/unit/test_compose.py index 2fe94b16..74e0633a 100644 --- a/tests/unit/test_compose.py +++ b/tests/unit/test_compose.py @@ -18,6 +18,7 @@ from bot_bottle.backend.docker.compose import ( list_compose_projects, slug_from_compose_project, ) +from bot_bottle.backend import EnumerationError class TestProjectNaming(unittest.TestCase): @@ -76,7 +77,7 @@ class TestComposeProjectListing(unittest.TestCase): args=["docker"], returncode=1, stdout="", stderr="no daemon", ), ): - with self.assertRaisesRegex(RuntimeError, "no daemon"): + with self.assertRaisesRegex(EnumerationError, "no daemon"): list_active_slugs( warn_on_error=False, raise_on_error=True, diff --git a/tests/unit/test_docker_enumerate_active.py b/tests/unit/test_docker_enumerate_active.py index 15cf5bd5..19f9afad 100644 --- a/tests/unit/test_docker_enumerate_active.py +++ b/tests/unit/test_docker_enumerate_active.py @@ -28,6 +28,7 @@ from unittest.mock import patch from tests.unit import use_bottle_root from bot_bottle import bottle_state from bot_bottle.backend.docker import enumerate as _enumerate +from bot_bottle.backend import EnumerationError class TestParseServicesByProject(unittest.TestCase): @@ -82,7 +83,7 @@ class TestQueryServicesByProject(unittest.TestCase): args=["docker"], returncode=1, stdout="", stderr="daemon down", ), ): - with self.assertRaisesRegex(RuntimeError, "daemon down"): + with self.assertRaisesRegex(EnumerationError, "daemon down"): _enumerate._query_services_by_project() diff --git a/tests/unit/test_macos_container_cleanup.py b/tests/unit/test_macos_container_cleanup.py index 0651b99f..fcc9886d 100644 --- a/tests/unit/test_macos_container_cleanup.py +++ b/tests/unit/test_macos_container_cleanup.py @@ -5,6 +5,7 @@ from __future__ import annotations import unittest from unittest.mock import patch +from bot_bottle.backend import EnumerationError from bot_bottle.backend.macos_container import cleanup, enumerate as enum_mod from bot_bottle.backend.macos_container.bottle_cleanup_plan import ( MacosContainerBottleCleanupPlan, @@ -69,10 +70,18 @@ class TestMacosContainerEnumerate(unittest.TestCase): self.assertEqual(["dev-abc"], [a.slug for a in agents]) def test_raises_when_the_cli_fails(self): - from bot_bottle.backend.macos_container.enumerate import EnumerationError with self.assertRaises(EnumerationError): self._enumerate("", returncode=1) + def test_raises_typed_error_when_cli_is_missing(self): + with ( + patch.object( + enum_mod.subprocess, "run", side_effect=FileNotFoundError, + ), + self.assertRaisesRegex(EnumerationError, "CLI not found"), + ): + enum_mod.enumerate_active() + if __name__ == "__main__": unittest.main()