refactor(bottles): BottlePlatform becomes ABC; DockerBottlePlatform in docker.py
test / run tests/run_tests.py (pull_request) Successful in 18s

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.
This commit is contained in:
2026-05-10 22:56:47 -04:00
parent 2827d9b899
commit e22a96e511
2 changed files with 44 additions and 19 deletions
+21 -17
View File
@@ -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(),
}
+23 -2
View File
@@ -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)