refactor(backend): make BottleBackend generic over its plan types
test / unit (push) Successful in 12s
test / integration (push) Successful in 12s

Parameterize BottleBackend over PlanT (bound to BottlePlan) and
CleanupT (bound to BottleCleanupPlan). DockerBottleBackend declares
itself BottleBackend[DockerBottlePlan, DockerBottleCleanupPlan], which
narrows every method's plan parameter to the concrete type and lets
the six `assert isinstance(plan, DockerBottlePlan)` lines on
launch/cleanup/provision_* go away.

The dict in get_bottle_backend keeps its unparameterized
BottleBackend element type so it can hold heterogeneous backend
specializations.
This commit is contained in:
2026-05-11 20:02:56 -04:00
parent 4fc0707760
commit cbafbbec5a
2 changed files with 28 additions and 32 deletions
+20 -11
View File
@@ -34,6 +34,7 @@ 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 Generic, TypeVar
from ..log import die from ..log import die
from ..manifest import Manifest from ..manifest import Manifest
@@ -113,23 +114,29 @@ class Bottle(ABC):
class BottleBackend(ABC): PlanT = TypeVar("PlanT", bound=BottlePlan)
CleanupT = TypeVar("CleanupT", bound=BottleCleanupPlan)
class BottleBackend(ABC, Generic[PlanT, CleanupT]):
"""Abstract base for selectable bottle backends. Concrete subclasses """Abstract base for selectable bottle backends. Concrete subclasses
(e.g. DockerBottleBackend) own their own prepare/launch impls. (e.g. DockerBottleBackend) own their own prepare/launch impls.
Symmetric with the BottlePlan → DockerBottlePlan hierarchy.""" Parameterized over the backend's concrete plan + cleanup-plan types
so subclass methods get the narrow type without isinstance
boilerplate."""
name: str name: str
@abstractmethod @abstractmethod
def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> BottlePlan: def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT:
"""Resolve names, validate host-side prerequisites, write """Resolve names, validate host-side prerequisites, write
scratch files. No remote/runtime resources created yet.""" scratch files. No remote/runtime resources created yet."""
@abstractmethod @abstractmethod
def launch(self, plan: BottlePlan) -> AbstractContextManager[Bottle]: def launch(self, plan: PlanT) -> AbstractContextManager[Bottle]:
"""Build/run the bottle and yield a handle; tear down on exit.""" """Build/run the bottle and yield a handle; tear down on exit."""
def provision(self, plan: BottlePlan, target: str) -> str | None: def provision(self, plan: PlanT, target: str) -> str | None:
"""Copy host-side files (prompt, skills, SSH keys, .git) into """Copy host-side files (prompt, skills, SSH keys, .git) into
the running bottle. Called from `launch` after the container/ the running bottle. Called from `launch` after the container/
machine is up. `target` identifies the running instance in machine is up. `target` identifies the running instance in
@@ -149,35 +156,35 @@ class BottleBackend(ABC):
return prompt_path return prompt_path
@abstractmethod @abstractmethod
def provision_prompt(self, plan: BottlePlan, target: str) -> str | None: def provision_prompt(self, plan: PlanT, target: str) -> str | None:
"""Copy the prompt file into the running bottle. Returns the """Copy the prompt file into the running bottle. Returns the
in-container path iff the agent has a non-empty prompt; in-container path iff the agent has a non-empty prompt;
callers use the return value to decide whether to add callers use the return value to decide whether to add
--append-system-prompt-file to claude's argv.""" --append-system-prompt-file to claude's argv."""
@abstractmethod @abstractmethod
def provision_skills(self, plan: BottlePlan, target: str) -> None: def provision_skills(self, plan: PlanT, target: str) -> None:
"""Copy the agent's named skills from the host into the """Copy the agent's named skills from the host into the
running bottle. No-op when the agent has no skills.""" running bottle. No-op when the agent has no skills."""
@abstractmethod @abstractmethod
def provision_ssh(self, plan: BottlePlan, target: str) -> None: def provision_ssh(self, plan: PlanT, target: str) -> None:
"""Set up SSH in the running bottle (config, agent, keys) """Set up SSH in the running bottle (config, agent, keys)
so the bottle can reach the manifest's declared SSH hosts. so the bottle can reach the manifest's declared SSH hosts.
No-op when the bottle has no SSH entries.""" No-op when the bottle has no SSH entries."""
@abstractmethod @abstractmethod
def provision_git(self, plan: BottlePlan, target: str) -> None: def provision_git(self, plan: PlanT, target: str) -> None:
"""Copy the host's cwd `.git` directory into the running """Copy the host's cwd `.git` directory into the running
bottle if the user requested --cwd. No-op otherwise.""" bottle if the user requested --cwd. No-op otherwise."""
@abstractmethod @abstractmethod
def prepare_cleanup(self) -> BottleCleanupPlan: def prepare_cleanup(self) -> CleanupT:
"""Enumerate orphaned resources from previous bottles. No side """Enumerate orphaned resources from previous bottles. No side
effects; safe to call before the y/N.""" effects; safe to call before the y/N."""
@abstractmethod @abstractmethod
def cleanup(self, plan: BottleCleanupPlan) -> None: def cleanup(self, plan: CleanupT) -> None:
"""Remove everything described by the cleanup plan.""" """Remove everything described by the cleanup plan."""
@abstractmethod @abstractmethod
@@ -192,6 +199,8 @@ class BottleBackend(ABC):
from .docker import DockerBottleBackend # noqa: E402 from .docker import DockerBottleBackend # noqa: E402
# The dict carries heterogeneous BottleBackend specializations; callers
# use it through the unparameterized BottleBackend interface.
_BACKENDS: dict[str, BottleBackend] = { _BACKENDS: dict[str, BottleBackend] = {
"docker": DockerBottleBackend(), "docker": DockerBottleBackend(),
} }
+8 -21
View File
@@ -23,7 +23,7 @@ from ...env import ResolvedEnv, resolve_env
from ...log import die, info from ...log import die, info
from ...manifest import SshEntry from ...manifest import SshEntry
from ...util import expand_tilde from ...util import expand_tilde
from .. import BottleBackend, BottleCleanupPlan, BottlePlan, BottleSpec from .. import BottleBackend, BottleSpec
from ..util import host_skill_dir from ..util import host_skill_dir
from . import network as network_mod from . import network as network_mod
from . import util as docker_mod from . import util as docker_mod
@@ -53,7 +53,7 @@ def _force_remove_container(name: str) -> None:
) )
class DockerBottleBackend(BottleBackend): class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
"""Docker backend implementation. Selected by CLAUDE_BOTTLE_BACKEND """Docker backend implementation. Selected by CLAUDE_BOTTLE_BACKEND
(default).""" (default)."""
@@ -164,13 +164,8 @@ class DockerBottleBackend(BottleBackend):
args_file.write_text("\n".join(args_lines) + ("\n" if args_lines else "")) args_file.write_text("\n".join(args_lines) + ("\n" if args_lines else ""))
@contextmanager @contextmanager
def launch(self, plan: BottlePlan) -> Iterator[DockerBottle]: def launch(self, plan: DockerBottlePlan) -> 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), (
f"DockerBottleBackend.launch expects DockerBottlePlan, "
f"got {type(plan).__name__}"
)
stack = ExitStack() stack = ExitStack()
def teardown() -> None: def teardown() -> None:
@@ -277,8 +272,7 @@ class DockerBottleBackend(BottleBackend):
docker_args[name_idx] = container docker_args[name_idx] = container
info(f"name conflict; retrying as {container}") info(f"name conflict; retrying as {container}")
def provision_prompt(self, plan: BottlePlan, target: str) -> str | None: def provision_prompt(self, plan: DockerBottlePlan, target: str) -> str | None:
assert isinstance(plan, DockerBottlePlan)
return _prompt.provision_prompt(plan, target) return _prompt.provision_prompt(plan, target)
def validate_skills(self, skills: list[str]) -> None: def validate_skills(self, skills: list[str]) -> None:
@@ -294,8 +288,7 @@ class DockerBottleBackend(BottleBackend):
f"Create it under ~/.claude/skills/, then re-run." f"Create it under ~/.claude/skills/, then re-run."
) )
def provision_skills(self, plan: BottlePlan, target: str) -> None: def provision_skills(self, plan: DockerBottlePlan, target: str) -> None:
assert isinstance(plan, DockerBottlePlan)
_skills.provision_skills(plan, target) _skills.provision_skills(plan, target)
def validate_ssh_entries(self, entries: Sequence[SshEntry]) -> None: def validate_ssh_entries(self, entries: Sequence[SshEntry]) -> None:
@@ -309,12 +302,10 @@ class DockerBottleBackend(BottleBackend):
if not os.path.isfile(key): if not os.path.isfile(key):
die(f"ssh key file not found for host '{entry.Host}': {key}") die(f"ssh key file not found for host '{entry.Host}': {key}")
def provision_ssh(self, plan: BottlePlan, target: str) -> None: def provision_ssh(self, plan: DockerBottlePlan, target: str) -> None:
assert isinstance(plan, DockerBottlePlan)
_ssh.provision_ssh(plan, target) _ssh.provision_ssh(plan, target)
def provision_git(self, plan: BottlePlan, target: str) -> None: def provision_git(self, plan: DockerBottlePlan, target: str) -> None:
assert isinstance(plan, DockerBottlePlan)
_git.provision_git(plan, target) _git.provision_git(plan, target)
# --- Cleanup --- # --- Cleanup ---
@@ -358,14 +349,10 @@ class DockerBottleBackend(BottleBackend):
return DockerBottleCleanupPlan(containers=containers, networks=networks) return DockerBottleCleanupPlan(containers=containers, networks=networks)
def cleanup(self, plan: BottleCleanupPlan) -> None: def cleanup(self, plan: DockerBottleCleanupPlan) -> None:
"""Remove the containers and networks listed in the plan. """Remove the containers and networks listed in the plan.
Containers first; networks would refuse to delete while Containers first; networks would refuse to delete while
containers are still attached.""" containers are still attached."""
assert isinstance(plan, DockerBottleCleanupPlan), (
f"DockerBottleBackend.cleanup expects DockerBottleCleanupPlan, "
f"got {type(plan).__name__}"
)
for name in plan.containers: for name in plan.containers:
info(f"removing container {name}") info(f"removing container {name}")
subprocess.run( subprocess.run(