# -*- coding: utf-8 -*- """Docker container :class:`BackendBase` implementation. Wraps the ``aiodocker`` container APIs (``exec``, ``get_archive``, ``put_archive``) into the three backend primitives (``exec_shell``, ``read_file``, ``write_file``) so that builtin tools (Bash, Read, Write, Edit, Grep, Glob) can operate inside a Docker container transparently. All derived filesystem helpers (``file_exists``, ``is_dir``, ``list_dir``, ``stat_mtime``, ``delete_path``) are inherited from :class:`BackendBase`, which implements them via ``exec_shell``. """ from __future__ import annotations import asyncio import io import posixpath import tarfile from typing import Any from ...tool import BackendBase, ExecResult class DockerBackend(BackendBase): """Backend that delegates to a running Docker container. Only the three abstract primitives (``exec_shell``, ``read_file``, ``write_file``) are implemented here; the derived filesystem helpers are inherited from :class:`BackendBase`. Args: container (`Any`): An ``aiodocker`` container object (must already be started). workdir (`str`): Default working directory for ``exec_shell`` calls inside the container. """ def __init__(self, container: Any, workdir: str) -> None: """Initialize the Docker backend. Args: container (`Any`): A started ``aiodocker`` container object. workdir (`str`): Default working directory for ``exec_shell`` calls inside the container. """ self._container = container self._workdir = workdir # ── exec ─────────────────────────────────────────────────────── async def getcwd(self) -> str: """Return the container's default working directory. Overrides the base class default (which would shell out to ``pwd``) with the cached ``workdir`` supplied at construction, avoiding a per-call container ``exec`` round-trip. Returns: `str`: The container's default working directory. """ return self._workdir async def exec_shell( self, command: list[str], *, cwd: str | None = None, timeout: float | None = None, ) -> ExecResult: """Run a program directly inside the container. *command* is an argv list executed via the container ``exec`` API without an intervening shell. Callers needing shell features wrap their command line as ``["sh", "-c", line]``. Args: command (`list[str]`): Executable path/name followed by its arguments. cwd (`str | None`, optional): Working directory inside the container. When ``None`` the backend's default ``workdir`` is used. timeout (`float | None`, optional): Maximum number of seconds to wait before returning an ``exit_code`` of ``-1``. When ``None`` the call waits indefinitely. Returns: `ExecResult`: The captured exit code, stdout, and stderr. """ async def _run() -> ExecResult: exec_obj = await self._container.exec( cmd=command, workdir=cwd or self._workdir, ) stdout_parts: list[bytes] = [] stderr_parts: list[bytes] = [] async with exec_obj.start() as stream: while True: msg = await stream.read_out() if msg is None: break if msg.stream == 1: stdout_parts.append(msg.data) else: stderr_parts.append(msg.data) inspect = await exec_obj.inspect() code = inspect.get("ExitCode", -1) if code is None: code = -1 return ExecResult( exit_code=int(code), stdout=b"".join(stdout_parts), stderr=b"".join(stderr_parts), ) if timeout is None: return await _run() try: return await asyncio.wait_for(_run(), timeout=timeout) except asyncio.TimeoutError: return ExecResult( exit_code=-1, stdout=b"", stderr=b"timed out", ) # ── file I/O ─────────────────────────────────────────────────── async def read_file(self, path: str) -> bytes: """Fetch a file from the container via ``get_archive``. Args: path (`str`): Path to the file inside the container. Returns: `bytes`: The raw file contents. Raises: `FileNotFoundError`: If the path does not exist inside the container. """ from aiodocker import exceptions as aiodocker_exceptions try: tar = await self._container.get_archive(path) except aiodocker_exceptions.DockerError as exc: if exc.status == 404: raise FileNotFoundError( f"not found in container: {path}", ) from exc raise try: for member in tar.getmembers(): if member.isfile(): f = tar.extractfile(member) if f: return f.read() finally: tar.close() raise FileNotFoundError(f"not found in container: {path}") async def write_file(self, path: str, data: bytes) -> None: """Write raw bytes to a file inside the container. Creates the parent directory first since ``put_archive`` requires it to exist. Args: path (`str`): Destination path inside the container. data (`bytes`): The raw bytes to write. """ parent = posixpath.dirname(path) or "/" name = posixpath.basename(path) await self.exec_shell(["mkdir", "-p", parent]) buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w") as tf: info = tarfile.TarInfo(name=name) info.size = len(data) tf.addfile(info, io.BytesIO(data)) await self._container.put_archive(parent, buf.getvalue())