Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/)
and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]
### Changed
- Container-engine detection is now lazy: `run_support.config.engine()` probes PATH on first use instead of at import time, so importing the runner modules no longer requires Docker or Podman. `config.ENGINE` still resolves but is deprecated.
- `runner/batch.py` now uses the shared `config.engine()` instead of its own copy of the PATH probe.

## [0.10.0] - 2026-08-30
### Added
Expand Down
25 changes: 7 additions & 18 deletions src/clawbench/runner/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import json
import os
import re
import shutil
import signal
import sys
import time
Expand All @@ -17,24 +16,14 @@

import yaml

from clawbench.runner.run_support.config import engine
from clawbench.utils.paths import ASSET_ROOT, WORKSPACE_ROOT, ensure_workspace_templates


def detect_engine() -> str:
env = os.environ.get("CONTAINER_ENGINE", "").strip().lower()
if env:
if env not in ("docker", "podman"):
print(f"ERROR: CONTAINER_ENGINE must be 'docker' or 'podman', got '{env}'")
sys.exit(1)
if not shutil.which(env):
print(f"ERROR: CONTAINER_ENGINE={env} but '{env}' not found on PATH")
sys.exit(1)
return env
for cmd in ("docker", "podman"):
if shutil.which(cmd):
return cmd
print("ERROR: Neither 'docker' nor 'podman' found on PATH")
sys.exit(1)
# Re-exported under its historical name. The body used to be a third copy of
# the same PATH probe; it is config.engine() now that importing config no
# longer costs a container-engine probe.
detect_engine = engine


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -636,10 +625,10 @@ async def async_main(args: argparse.Namespace) -> int:
# Build image once — reuse run.py's spinner/progress helper so first-time
# builds show a clear banner and live step counter instead of a wall of
# apt/npm output.
engine = detect_engine()
resolved_engine = detect_engine()
# Ensure child run.py processes (and the imported helper below) use the
# same engine as we just detected.
os.environ["CONTAINER_ENGINE"] = engine
os.environ["CONTAINER_ENGINE"] = resolved_engine
from clawbench.runner import run as _run_mod

_run_mod.docker_build(args.harness)
Expand Down
5 changes: 2 additions & 3 deletions src/clawbench/runner/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@
from clawbench.runner.run_support.config import (
BASE_IMAGE,
DEFAULT_HARNESS,
ENGINE,
HARNESSES,
IMAGE,
WORKSPACE_ROOT,
ModelConfigError,
engine,
harness_image,
load_model_config,
load_runtime_env,
Expand Down Expand Up @@ -70,7 +70,6 @@
__all__ = [
"BASE_IMAGE",
"DEFAULT_HARNESS",
"ENGINE",
"HARNESSES",
"IMAGE",
"docker_build",
Expand Down Expand Up @@ -510,7 +509,7 @@ def handle_sigterm(sig, frame):
def handle_sigint(sig, frame):
print("\nCtrl+C received, stopping container gracefully...")
subprocess.run(
[ENGINE, "stop", "-t", "20", container], capture_output=True
[engine(), "stop", "-t", "20", container], capture_output=True
)

signal.signal(signal.SIGINT, handle_sigint)
Expand Down
45 changes: 36 additions & 9 deletions src/clawbench/runner/run_support/config.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""Configuration and path helpers for single ClawBench runs."""

import functools
import os
import shutil
import sys
import warnings
from pathlib import Path

import yaml
Expand All @@ -24,7 +26,6 @@
"ASSET_ROOT",
"BASE_IMAGE",
"DEFAULT_HARNESS",
"ENGINE",
"HARNESS_REGISTRY",
"HARNESS_REGISTRY_YAML",
"HARNESSES",
Expand All @@ -33,6 +34,7 @@
"WORKSPACE_ROOT",
"HarnessRegistry",
"ModelConfigError",
"engine",
"harness_image",
"load_dotenv",
"load_harness_registry",
Expand Down Expand Up @@ -74,14 +76,21 @@ def harness_image(harness: str) -> str:
IMAGE = harness_image(DEFAULT_HARNESS)


def _detect_engine() -> str:
# Help output is host-only and should work on machines that have not
# installed Docker/Podman yet. Actual run paths still call this without
# help flags and fail fast if no engine is available.
if any(arg in {"-h", "--help"} for arg in sys.argv[1:]):
env = os.environ.get("CONTAINER_ENGINE", "").strip().lower()
return env if env in ("docker", "podman") else "docker"
@functools.lru_cache(maxsize=1)
def engine() -> str:
"""Return the container engine to shell out to, probing PATH on first call.

Resolution is deliberately lazy: importing this module must not require a
container runtime. Plenty of code that imports it never starts a container
(rescore, the Harbor adapter, batch's job planning, `--help` output), and
an import-time probe that ends in sys.exit gives those callers no way to
degrade. The probe happens here, on the first call that actually needs an
engine, where the failure is attributable to the operation that needs it.

Cached because every container command re-asks, and shutil.which walks
PATH each time. Call engine.cache_clear() if CONTAINER_ENGINE changes
within a process (batch does, after it resolves the engine for children).
"""
env = os.environ.get("CONTAINER_ENGINE", "").strip().lower()
if env:
if env not in ("docker", "podman"):
Expand All @@ -98,7 +107,25 @@ def _detect_engine() -> str:
sys.exit(1)


ENGINE = _detect_engine()
def __getattr__(name: str) -> str:
"""Deprecated back-compat shim for the old module-level ENGINE constant.

Kept for one release for out-of-tree callers; in-tree code calls engine().
Because this only fires on attribute access, `from ... import config` stays
free of the probe, which is the point of the change.
"""
if name == "ENGINE":
warnings.warn(
"config.ENGINE is deprecated; call config.engine() instead. "
"The constant probed for a container engine at import time, which "
"made importing this module fail on hosts without one.",
DeprecationWarning,
stacklevel=2,
)
return engine()
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


MODELS_YAML = WORKSPACE_ROOT / "models" / "models.yaml"


Expand Down
44 changes: 26 additions & 18 deletions src/clawbench/runner/run_support/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@
from clawbench.runner.run_support.config import (
BASE_IMAGE,
DEFAULT_HARNESS,
ENGINE,
HARNESSES,
HARNESS_REGISTRY,
IMAGE,
engine,
harness_image,
)
from clawbench.runner.run_support.usage import (
Expand Down Expand Up @@ -66,7 +66,7 @@ def run(cmd: list[str], **kwargs): # type: ignore[no-untyped-def]
def image_exists(ref: str = IMAGE) -> bool:
return (
subprocess.run(
[ENGINE, "image", "inspect", ref],
[engine(), "image", "inspect", ref],
capture_output=True,
).returncode
== 0
Expand All @@ -76,7 +76,7 @@ def image_exists(ref: str = IMAGE) -> bool:
def image_id(ref: str) -> str | None:
try:
r = subprocess.run(
[ENGINE, "image", "inspect", ref, "--format", "{{.Id}}"],
[engine(), "image", "inspect", ref, "--format", "{{.Id}}"],
capture_output=True,
text=True,
timeout=10,
Expand All @@ -92,7 +92,7 @@ def image_id(ref: str) -> str | None:
def container_engine_version() -> str | None:
try:
r = subprocess.run(
[ENGINE, "--version"],
[engine(), "--version"],
capture_output=True,
text=True,
timeout=10,
Expand Down Expand Up @@ -182,7 +182,15 @@ def _looks_like_stale_cache(output_lines: list[str]) -> bool:

def _build_one(dockerfile: Path, tag: str) -> None:
"""Run one container build with a stale-cache retry."""
cmd = [ENGINE, "build", "-f", str(dockerfile), "-t", tag, str(DOCKER_CONTEXT_ROOT)]
cmd = [
engine(),
"build",
"-f",
str(dockerfile),
"-t",
tag,
str(DOCKER_CONTEXT_ROOT),
]
rc, last_line, output_lines = _run_build(cmd)

if rc != 0 and _looks_like_stale_cache(output_lines):
Expand All @@ -197,7 +205,7 @@ def _build_one(dockerfile: Path, tag: str) -> None:
)
console.print()
cmd_nc = [
ENGINE,
engine(),
"build",
"--no-cache",
"-f",
Expand Down Expand Up @@ -246,7 +254,7 @@ def fix_data_ownership(data_dir: Path) -> None:
"""Fix root-owned copied data on Linux + rootful Docker."""
if sys.platform != "linux":
return
if ENGINE != "docker":
if engine() != "docker":
return
if not data_dir.exists():
return
Expand All @@ -266,7 +274,7 @@ def fix_data_ownership(data_dir: Path) -> None:
print(f" Fixing ownership of {data_dir} (rootful Docker -> host UID)")
subprocess.run(
[
ENGINE,
engine(),
"run",
"--rm",
"-v",
Expand All @@ -284,15 +292,15 @@ def fix_data_ownership(data_dir: Path) -> None:

def _network_flags() -> list[str]:
"""Force slirp4netns on podman to avoid host-network port collisions."""
if ENGINE == "podman":
if engine() == "podman":
return ["--network=slirp4netns"]
return []


def _proxy_env_flags() -> list[str]:
"""Forward host proxy env vars into the container."""
host_gw = (
"host.containers.internal" if ENGINE == "podman" else "host.docker.internal"
"host.containers.internal" if engine() == "podman" else "host.docker.internal"
)
flags: list[str] = []
has_proxy = False
Expand Down Expand Up @@ -331,7 +339,7 @@ def docker_run_human(
recording_mode: str = "x11",
) -> None:
cmd = [
ENGINE,
engine(),
"run",
"-d",
"--name",
Expand Down Expand Up @@ -376,7 +384,7 @@ def docker_run(
recording_mode: str = "x11",
) -> None:
env_flags = [
ENGINE,
engine(),
"run",
"-d",
"--name",
Expand Down Expand Up @@ -497,7 +505,7 @@ def _container_usage_summary(
try:
r = subprocess.run(
[
ENGINE,
engine(),
"exec",
name,
"sh",
Expand Down Expand Up @@ -527,7 +535,7 @@ def docker_wait(
"""Block until the container exits, showing a live status line."""
start = time.time()
proc = subprocess.Popen(
[ENGINE, "wait", name], stdout=subprocess.PIPE, stderr=subprocess.PIPE
[engine(), "wait", name], stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
last_actions = 0
usage_summary: dict | None = None
Expand All @@ -542,7 +550,7 @@ def docker_wait(
mins, secs = divmod(elapsed, 60)
try:
r = subprocess.run(
[ENGINE, "exec", name, "wc", "-l", "/data/actions.jsonl"],
[engine(), "exec", name, "wc", "-l", "/data/actions.jsonl"],
capture_output=True,
text=True,
timeout=30,
Expand Down Expand Up @@ -586,13 +594,13 @@ def docker_wait(


def docker_copy(name: str, output_dir: Path) -> None:
run([ENGINE, "cp", f"{name}:/data", str(output_dir / "data")])
run([engine(), "cp", f"{name}:/data", str(output_dir / "data")])
(output_dir / "data" / ".stop-requested").unlink(missing_ok=True)


def docker_logs(name: str) -> None:
subprocess.run([ENGINE, "logs", "--tail", "40", name])
subprocess.run([engine(), "logs", "--tail", "40", name])


def docker_rm(name: str) -> None:
subprocess.run([ENGINE, "rm", "-f", name], capture_output=True)
subprocess.run([engine(), "rm", "-f", name], capture_output=True)
4 changes: 2 additions & 2 deletions src/clawbench/runner/run_support/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
from clawbench.runner.run_support.config import (
ASSET_ROOT,
BASE_IMAGE,
ENGINE,
WORKSPACE_ROOT,
engine,
harness_image,
)
from clawbench.runner.run_support.docker import container_engine_version, image_id
Expand Down Expand Up @@ -118,7 +118,7 @@ def _sanitized_model_config(model_cfg: dict | None) -> dict[str, Any] | None:
def _runtime_meta(harness: str) -> dict[str, Any]:
harness_ref = None if harness == "human" else harness_image(harness)
return {
"container_engine": ENGINE,
"container_engine": engine(),
"container_engine_source": (
"env" if os.environ.get("CONTAINER_ENGINE") else "auto"
),
Expand Down
7 changes: 5 additions & 2 deletions tests/test_cli_entrypoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ def test_module_help_does_not_require_container_runtime(module: str) -> None:
"""Help output should work even when docker/podman are unavailable.

The subprocess monkeypatches ``shutil.which`` before running the module so
import-time container-engine probes see a host with no container runtime.
This keeps the test fully Python-based and cross-platform.
any container-engine probe sees a host with no container runtime. This
keeps the test fully Python-based and cross-platform. ``--help`` used to
pass only because ``_detect_engine`` sniffed ``sys.argv`` for a help flag
and returned a guess; it now passes because argparse exits before anything
asks for an engine.
"""

code = textwrap.dedent(
Expand Down
Loading