refactor(bottles): Bottle becomes an ABC; DockerBottle inherits
test / run tests/run_tests.py (pull_request) Successful in 14s

Bottle was the only Protocol in an otherwise-ABC family
(BottlePlan, BottleCleanupPlan, BottlePlatform are all ABCs).
Convert to an ABC with abstract exec_claude / cp_in / close,
matching the rest of the hierarchy.

Rename _DockerBottle -> DockerBottle: the underscore was a
default-Python-private instinct that doesn't match the sibling
plan classes (DockerBottlePlan, DockerBottleCleanupPlan), all of
which are equally "only constructed by the platform" and yet
public-by-name.

Re-export DockerBottle from claude_bottle.bottles.docker.
This commit is contained in:
2026-05-10 23:32:33 -04:00
parent d28f0e6d9b
commit aaed390953
4 changed files with 18 additions and 10 deletions
+6 -2
View File
@@ -31,7 +31,6 @@ from abc import ABC, abstractmethod
from contextlib import AbstractContextManager from contextlib import AbstractContextManager
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Protocol
from ..log import die from ..log import die
from ..manifest import Manifest from ..manifest import Manifest
@@ -82,7 +81,7 @@ class BottleCleanupPlan(ABC):
short-circuit before showing the y/N.""" short-circuit before showing the y/N."""
class Bottle(Protocol): class Bottle(ABC):
"""Handle to a running bottle. Yielded by a platform's launch step. """Handle to a running bottle. Yielded by a platform's launch step.
`exec_claude` runs `claude` inside the bottle and blocks until the `exec_claude` runs `claude` inside the bottle and blocks until the
@@ -92,8 +91,13 @@ class Bottle(Protocol):
name: str name: str
@abstractmethod
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: ... def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: ...
@abstractmethod
def cp_in(self, host_path: str, container_path: str) -> None: ... def cp_in(self, host_path: str, container_path: str) -> None: ...
@abstractmethod
def close(self) -> None: ... def close(self) -> None: ...
+4 -2
View File
@@ -5,21 +5,23 @@ The bulk of the implementation lives in sibling modules:
- util: thin Docker subprocess wrappers - util: thin Docker subprocess wrappers
- bottle_plan: DockerBottlePlan - bottle_plan: DockerBottlePlan
- bottle_cleanup_plan: DockerBottleCleanupPlan - bottle_cleanup_plan: DockerBottleCleanupPlan
- bottle: _DockerBottle handle - bottle: DockerBottle handle
- platform: DockerBottlePlatform - platform: DockerBottlePlatform
This file only re-exports the platform class so This file only re-exports the public names so
`from claude_bottle.bottles.docker import DockerBottlePlatform` keeps `from claude_bottle.bottles.docker import DockerBottlePlatform` keeps
working. working.
""" """
from __future__ import annotations from __future__ import annotations
from .bottle import DockerBottle
from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_cleanup_plan import DockerBottleCleanupPlan
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
from .platform import DockerBottlePlatform from .platform import DockerBottlePlatform
__all__ = [ __all__ = [
"DockerBottle",
"DockerBottleCleanupPlan", "DockerBottleCleanupPlan",
"DockerBottlePlan", "DockerBottlePlan",
"DockerBottlePlatform", "DockerBottlePlatform",
+4 -2
View File
@@ -1,4 +1,4 @@
"""_DockerBottle — concrete Bottle handle yielded by """DockerBottle — concrete Bottle handle yielded by
DockerBottlePlatform.launch. DockerBottlePlatform.launch.
Holds the container name plus the in-container prompt path so Holds the container name plus the in-container prompt path so
@@ -10,8 +10,10 @@ from __future__ import annotations
import subprocess import subprocess
from .. import Bottle
class _DockerBottle:
class DockerBottle(Bottle):
"""Concrete Bottle for Docker.""" """Concrete Bottle for Docker."""
def __init__(self, container: str, teardown, prompt_path_in_container: str | None): def __init__(self, container: str, teardown, prompt_path_in_container: str | None):
+4 -4
View File
@@ -2,7 +2,7 @@
Methods: Methods:
.prepare(spec, stage_dir=...) -> DockerBottlePlan .prepare(spec, stage_dir=...) -> DockerBottlePlan
.launch(plan) -> ContextManager[_DockerBottle] .launch(plan) -> ContextManager[DockerBottle]
.prepare_cleanup() -> DockerBottleCleanupPlan .prepare_cleanup() -> DockerBottleCleanupPlan
.cleanup(plan) -> None .cleanup(plan) -> None
.list_active() -> None .list_active() -> None
@@ -25,7 +25,7 @@ from ...env_resolve import env_resolve
from ...log import die, info from ...log import die, info
from .. import BottleCleanupPlan, BottlePlan, BottlePlatform, BottleSpec from .. import BottleCleanupPlan, BottlePlan, BottlePlatform, BottleSpec
from . import util as docker_mod from . import util as docker_mod
from .bottle import _DockerBottle from .bottle import DockerBottle
from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_cleanup_plan import DockerBottleCleanupPlan
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
@@ -128,7 +128,7 @@ class DockerBottlePlatform(BottlePlatform):
) )
@contextmanager @contextmanager
def launch(self, plan: BottlePlan) -> Iterator[_DockerBottle]: def launch(self, plan: BottlePlan) -> Iterator[DockerBottle]:
"""Build, launch, and provision a Docker bottle. Teardown on exit.""" """Build, launch, and provision a Docker bottle. Teardown on exit."""
assert isinstance(plan, DockerBottlePlan), ( assert isinstance(plan, DockerBottlePlan), (
f"DockerBottlePlatform.launch expects DockerBottlePlan, " f"DockerBottlePlatform.launch expects DockerBottlePlan, "
@@ -187,7 +187,7 @@ class DockerBottlePlatform(BottlePlatform):
prompt_path = self._provision_container(plan, container) prompt_path = self._provision_container(plan, container)
bottle = _DockerBottle(container, teardown, prompt_path) bottle = DockerBottle(container, teardown, prompt_path)
yield bottle yield bottle
finally: finally:
teardown() teardown()