From e22a96e511b54200ce497472a8faea59d372d557 Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 10 May 2026 22:56:47 -0400 Subject: [PATCH] refactor(bottles): BottlePlatform becomes ABC; DockerBottlePlatform in docker.py Mirror the BottlePlan -> DockerBottlePlan hierarchy at the platform layer. BottlePlatform is now an abstract base with abstract `prepare` and `launch` methods; DockerBottlePlatform lives alongside the rest of the Docker code in bottles/docker.py and supplies the concrete impls. The registry in bottles/__init__.py now holds an instance of each concrete platform class. Future per-platform state (region, api token, cleanup primitives) has a natural home on the subclass rather than being stitched onto a dataclass struct. No behavior change. Tests pass; dry-run output unchanged. --- claude_bottle/bottles/__init__.py | 38 +++++++++++++++++-------------- claude_bottle/bottles/docker.py | 25 ++++++++++++++++++-- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/claude_bottle/bottles/__init__.py b/claude_bottle/bottles/__init__.py index d629516..0c7c575 100644 --- a/claude_bottle/bottles/__init__.py +++ b/claude_bottle/bottles/__init__.py @@ -24,7 +24,7 @@ from abc import ABC, abstractmethod from contextlib import AbstractContextManager from dataclasses import dataclass from pathlib import Path -from typing import Callable, Protocol +from typing import Protocol from ..log import die from ..manifest import Manifest @@ -58,12 +58,6 @@ class BottlePlan(ABC): """Render the y/N preflight summary to stderr.""" -# Import concrete platform factories AFTER the base types are defined, -# so each platform module can pull BottleSpec / BottlePlan via -# `from . import ...` without hitting a partially-initialized module. -from .docker import launch_docker_bottle, prepare_docker_bottle # noqa: E402 - - class Bottle(Protocol): """Handle to a running bottle. Yielded by a platform's launch step. @@ -79,21 +73,31 @@ class Bottle(Protocol): def close(self) -> None: ... -@dataclass(frozen=True) -class BottlePlatform: - """Bundles a platform's two-phase factory under one selectable name.""" +class BottlePlatform(ABC): + """Abstract base for selectable bottle platforms. Concrete subclasses + (e.g. DockerBottlePlatform) own their own prepare/launch impls. + Symmetric with the BottlePlan → DockerBottlePlan hierarchy.""" name: str - prepare: Callable[..., BottlePlan] - launch: Callable[..., AbstractContextManager[Bottle]] + + @abstractmethod + def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> BottlePlan: + """Resolve names, validate host-side prerequisites, write + scratch files. No remote/runtime resources created yet.""" + + @abstractmethod + def launch(self, plan: BottlePlan) -> AbstractContextManager[Bottle]: + """Build/run the bottle and yield a handle; tear down on exit.""" + + +# Import concrete platform classes AFTER the base types are defined, so +# each platform module can pull BottleSpec / BottlePlan / BottlePlatform +# via `from . import ...` without hitting a partially-initialized module. +from .docker import DockerBottlePlatform # noqa: E402 _PLATFORMS: dict[str, BottlePlatform] = { - "docker": BottlePlatform( - name="docker", - prepare=prepare_docker_bottle, - launch=launch_docker_bottle, - ), + "docker": DockerBottlePlatform(), } diff --git a/claude_bottle/bottles/docker.py b/claude_bottle/bottles/docker.py index dffc8c4..c41a56b 100644 --- a/claude_bottle/bottles/docker.py +++ b/claude_bottle/bottles/docker.py @@ -22,7 +22,7 @@ from __future__ import annotations import os import subprocess import sys -from contextlib import contextmanager +from contextlib import AbstractContextManager, contextmanager from dataclasses import dataclass from pathlib import Path from typing import Iterator @@ -34,7 +34,7 @@ from .. import skills as skills_mod from .. import ssh as ssh_mod from ..env_resolve import env_resolve from ..log import die, info -from . import BottlePlan, BottleSpec +from . import BottlePlan, BottlePlatform, BottleSpec # --- Runtime detection ----------------------------------------------------- @@ -434,3 +434,24 @@ def _provision_container(plan: DockerBottlePlan, container: str) -> str | None: ) return in_container_prompt_path if agent.prompt else None + + +# --- Platform -------------------------------------------------------------- + + +class DockerBottlePlatform(BottlePlatform): + """Docker platform implementation. Selected by CLAUDE_BOTTLE_PLATFORM + (default). The methods delegate to the module-level prepare/launch + functions so the platform class itself stays a thin dispatch layer.""" + + name = "docker" + + def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> BottlePlan: + return prepare_docker_bottle(spec, stage_dir=stage_dir) + + def launch(self, plan: BottlePlan) -> AbstractContextManager[_DockerBottle]: + assert isinstance(plan, DockerBottlePlan), ( + f"DockerBottlePlatform.launch expects DockerBottlePlan, " + f"got {type(plan).__name__}" + ) + return launch_docker_bottle(plan)