From cbafbbec5a345d995aab5b0190a6921f1c154917 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 20:02:56 -0400 Subject: [PATCH] refactor(backend): make BottleBackend generic over its plan types 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. --- claude_bottle/backend/__init__.py | 31 ++++++++++++++++--------- claude_bottle/backend/docker/backend.py | 29 +++++++---------------- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/claude_bottle/backend/__init__.py b/claude_bottle/backend/__init__.py index d44633b..fe4cd4b 100644 --- a/claude_bottle/backend/__init__.py +++ b/claude_bottle/backend/__init__.py @@ -34,6 +34,7 @@ from abc import ABC, abstractmethod from contextlib import AbstractContextManager from dataclasses import dataclass from pathlib import Path +from typing import Generic, TypeVar from ..log import die 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 (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 @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 scratch files. No remote/runtime resources created yet.""" @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.""" - 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 the running bottle. Called from `launch` after the container/ machine is up. `target` identifies the running instance in @@ -149,35 +156,35 @@ class BottleBackend(ABC): return prompt_path @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 in-container path iff the agent has a non-empty prompt; callers use the return value to decide whether to add --append-system-prompt-file to claude's argv.""" @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 running bottle. No-op when the agent has no skills.""" @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) so the bottle can reach the manifest's declared SSH hosts. No-op when the bottle has no SSH entries.""" @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 bottle if the user requested --cwd. No-op otherwise.""" @abstractmethod - def prepare_cleanup(self) -> BottleCleanupPlan: + def prepare_cleanup(self) -> CleanupT: """Enumerate orphaned resources from previous bottles. No side effects; safe to call before the y/N.""" @abstractmethod - def cleanup(self, plan: BottleCleanupPlan) -> None: + def cleanup(self, plan: CleanupT) -> None: """Remove everything described by the cleanup plan.""" @abstractmethod @@ -192,6 +199,8 @@ class BottleBackend(ABC): from .docker import DockerBottleBackend # noqa: E402 +# The dict carries heterogeneous BottleBackend specializations; callers +# use it through the unparameterized BottleBackend interface. _BACKENDS: dict[str, BottleBackend] = { "docker": DockerBottleBackend(), } diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index f64367d..0ad0c15 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -23,7 +23,7 @@ from ...env import ResolvedEnv, resolve_env from ...log import die, info from ...manifest import SshEntry from ...util import expand_tilde -from .. import BottleBackend, BottleCleanupPlan, BottlePlan, BottleSpec +from .. import BottleBackend, BottleSpec from ..util import host_skill_dir from . import network as network_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 (default).""" @@ -164,13 +164,8 @@ class DockerBottleBackend(BottleBackend): args_file.write_text("\n".join(args_lines) + ("\n" if args_lines else "")) @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.""" - assert isinstance(plan, DockerBottlePlan), ( - f"DockerBottleBackend.launch expects DockerBottlePlan, " - f"got {type(plan).__name__}" - ) - stack = ExitStack() def teardown() -> None: @@ -277,8 +272,7 @@ class DockerBottleBackend(BottleBackend): docker_args[name_idx] = container info(f"name conflict; retrying as {container}") - def provision_prompt(self, plan: BottlePlan, target: str) -> str | None: - assert isinstance(plan, DockerBottlePlan) + def provision_prompt(self, plan: DockerBottlePlan, target: str) -> str | None: return _prompt.provision_prompt(plan, target) def validate_skills(self, skills: list[str]) -> None: @@ -294,8 +288,7 @@ class DockerBottleBackend(BottleBackend): f"Create it under ~/.claude/skills/, then re-run." ) - def provision_skills(self, plan: BottlePlan, target: str) -> None: - assert isinstance(plan, DockerBottlePlan) + def provision_skills(self, plan: DockerBottlePlan, target: str) -> None: _skills.provision_skills(plan, target) def validate_ssh_entries(self, entries: Sequence[SshEntry]) -> None: @@ -309,12 +302,10 @@ class DockerBottleBackend(BottleBackend): if not os.path.isfile(key): die(f"ssh key file not found for host '{entry.Host}': {key}") - def provision_ssh(self, plan: BottlePlan, target: str) -> None: - assert isinstance(plan, DockerBottlePlan) + def provision_ssh(self, plan: DockerBottlePlan, target: str) -> None: _ssh.provision_ssh(plan, target) - def provision_git(self, plan: BottlePlan, target: str) -> None: - assert isinstance(plan, DockerBottlePlan) + def provision_git(self, plan: DockerBottlePlan, target: str) -> None: _git.provision_git(plan, target) # --- Cleanup --- @@ -358,14 +349,10 @@ class DockerBottleBackend(BottleBackend): 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. Containers first; networks would refuse to delete while containers are still attached.""" - assert isinstance(plan, DockerBottleCleanupPlan), ( - f"DockerBottleBackend.cleanup expects DockerBottleCleanupPlan, " - f"got {type(plan).__name__}" - ) for name in plan.containers: info(f"removing container {name}") subprocess.run(