Source code for engibench.utils.container

"""Abstraction over container runtimes."""

from __future__ import annotations

import os
import subprocess
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from collections.abc import Sequence


[docs] def pull(image: str) -> None: """Pull an image using the selected runtime. Args: image: Container image to pull. """ if RUNTIME is None: msg = "No container runtime found" raise FileNotFoundError(msg) RUNTIME.pull(image)
[docs] def run( command: list[str], image: str, mounts: Sequence[tuple[str, str]] = (), env: dict[str, str] | None = None, name: str | None = None, ) -> None: """Run a command in a container using the selected runtime. Args: command: Command (as a list of strings) to run inside the container. image: Container image to use. mounts: Pairs of host folder and destination folder inside the container. env: Mapping of environment variable names and values to set inside the container. name: Optional name for the container (not supported by all runtimes). """ if RUNTIME is None: msg = "No container runtime found. Please ensure Docker, Podman, or Singularity is installed and running." raise FileNotFoundError(msg) try: result = RUNTIME.run(command, image, mounts, env, name) result.check_returncode() except subprocess.CalledProcessError as e: msg = f"Container command failed with exit code {e.returncode}:\nCommand: {' '.join(command)}\nOutput: {e.output.decode() if e.output else 'No output'}" raise RuntimeError(msg) from e
def runtime() -> type[ContainerRuntime] | None: """Determine the container runtime to use according to the environment variable `CONTAINER_RUNTIME`. If not set, check for availability. Returns: Class object of the first available container runtime or the container runtime selected by the `CONTAINER_RUNTIME` environment variable if set. """ runtimes_by_name = {rt.name: rt for rt in RUNTIMES} rt_name = os.environ.get("CONTAINER_RUNTIME") rt = runtimes_by_name.get(rt_name) if rt_name is not None else None if rt is not None: return rt for rt in RUNTIMES: if rt.is_available(): return rt return None
[docs] class ContainerRuntime: """Abstraction over container runtimes.""" name: str executable: str
[docs] @classmethod def is_available(cls) -> bool: """Check if the container runtime is installed and executable. Returns: `True` if the container runtime appears to be installed on the system and if required daemons are running, `false` otherwise. """ try: return ( subprocess.run( [cls.executable, "--help"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False ).returncode == 0 ) except FileNotFoundError: return False
[docs] @classmethod def pull(cls, image: str) -> None: """Pull an image. Args: image: Container image to pull. """ raise NotImplementedError("Must be implemented by a subclass")
[docs] @classmethod def run( cls, command: list[str], image: str, mounts: Sequence[tuple[str, str]] = (), env: dict[str, str] | None = None, name: str | None = None, ) -> subprocess.CompletedProcess: """Run a command in a container. Args: command: Command (as a list of strings) to run inside the container. image: Container image to use. mounts: Pairs of host folder and destination folder inside the container. env: Mapping of environment variable names and values to set inside the container. name: Optional name for the container (not supported by all runtimes). """ raise NotImplementedError("Must be implemented by a subclass")
[docs] class Docker(ContainerRuntime): """Docker 🐋 runtime.""" name = "docker" executable = "docker" @classmethod def is_available(cls) -> bool: """Check if the container runtime is installed and executable. Returns: `True` if the container runtime appears to be installed on the system and if required daemons are running, `false` otherwise. """ try: return ( subprocess.run( [cls.executable, "info"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False ).returncode == 0 ) except FileNotFoundError: return False @classmethod def pull(cls, image: str) -> None: """Pull an image. Args: image: Container image to pull. """ subprocess.run([cls.executable, "pull", image], check=True) @classmethod def run( cls, command: list[str], image: str, mounts: Sequence[tuple[str, str]] = (), env: dict[str, str] | None = None, name: str | None = None, ) -> subprocess.CompletedProcess: """Run a command in a container. Args: command: Command (as a list of strings) to run inside the container. image: Container image to use. mounts: Pairs of host folder and destination folder inside the container. env: Mapping of environment variable names and values to set inside the container. name: Optional name for the container (not supported by all runtimes). """ name_args = [] if name is None else ["--name", name] mount_args = (["--mount", f"type=bind,src={src},target={target}"] for src, target in mounts) env_args = (["--env", f"{var}={value}"] for var, value in (env or {}).items()) return subprocess.run( [ cls.executable, "run", "--rm", *name_args, *(arg for args in mount_args for arg in args), *(arg for args in env_args for arg in args), image, *command, ], check=False, )
[docs] class Podman(Docker): """Podman 🦭 runtime.""" name = "podman" executable = "podman" @classmethod def is_available(cls) -> bool: """Check if the container runtime is installed and executable. Returns: `True` if the container runtime appears to be installed on the system and if required daemons are running, `false` otherwise. """ # `podman info` seems to take some more time than `docker info`. # Just use `podman --help` here. try: return ( subprocess.run( [cls.executable, "--help"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False ).returncode == 0 ) except FileNotFoundError: return False
[docs] class Singularity(ContainerRuntime): """Singularity / Apptainer.""" name = "singularity" executable = "singularity" @classmethod def pull(cls, image: str) -> None: """Pull an image. Args: image: Container image to pull. """ if "://" not in image: image = "docker://" + image subprocess.run([cls.executable, "pull", image], check=True) @classmethod def run( cls, command: list[str], image: str, mounts: Sequence[tuple[str, str]] = (), env: dict[str, str] | None = None, _name: str | None = None, ) -> subprocess.CompletedProcess: """Run a command in a container. Args: command: Command (as a list of strings) to run inside the container. image: Container image to use. mounts: Pairs of host folder and destination folder inside the container. env: Mapping of environment variable names and values to set inside the container. name: Optional name for the container (not supported by all runtimes). """ mount_args = (["--mount", f"type=bind,src={src},target={target}"] for src, target in mounts) env_args = (["--env", f"{var}={value}"] for var, value in (env or {}).items()) if "://" not in image: image = "docker://" + image return subprocess.run( [ cls.executable, "run", "--compat", *(arg for args in mount_args for arg in args), *(arg for args in env_args for arg in args), image, *command, ], check=False, )
RUNTIMES = [ rt for rt in globals().values() if isinstance(rt, type) and issubclass(rt, ContainerRuntime) and rt is not ContainerRuntime ] RUNTIME = runtime()