Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
50 changes: 30 additions & 20 deletions tests/code_executors/container/test_container_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,21 @@
ContainerClient,
ContainerConfig,
)
from trpc_agent_sdk.code_executors.container._container_cli import \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

模块级副作用调用 _import_docker 污染全局状态

import 期执行 _import_docker() 会把真实 docker 符号写入 _container_cli 全局并置 _docker_imported=True,使测试行为依赖运行环境而非自洽的 mock,且 _docker_imported 置位后不可恢复。建议改在 fixture 中用 monkeypatch 设置相关符号或显式管理 _docker_imported 状态,避免 import 期执行有状态副作用。

_import_docker # noqa: F401 (used in fixture below)
from trpc_agent_sdk.utils import CommandExecResult


@pytest.fixture(autouse=True, scope="module")
def _ensure_docker_imported():
"""Ensure docker SDK symbols exist at module level for @patch compatibility.

Without this, @patch("..._container_cli._docker_mod") fails because docker
is a None placeholder until _import_docker() runs.
"""
_import_docker()


# ---------------------------------------------------------------------------
# ContainerConfig
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -107,7 +119,7 @@ def _make_mock_container(exec_exit_code=0):
class TestContainerClientInitDockerClient:

@patch("trpc_agent_sdk.code_executors.container._container_cli.atexit")
@patch("trpc_agent_sdk.code_executors.container._container_cli.docker")
@patch("trpc_agent_sdk.code_executors.container._container_cli._docker_mod")
def test_from_env_when_no_base_url(self, mock_docker, mock_atexit):
mock_client = _make_mock_docker_client()
mock_docker.from_env.return_value = mock_client
Expand All @@ -123,7 +135,7 @@ def test_from_env_when_no_base_url(self, mock_docker, mock_atexit):
assert cc.client is mock_client

@patch("trpc_agent_sdk.code_executors.container._container_cli.atexit")
@patch("trpc_agent_sdk.code_executors.container._container_cli.docker")
@patch("trpc_agent_sdk.code_executors.container._container_cli._docker_mod")
def test_custom_base_url(self, mock_docker, mock_atexit):
mock_client = _make_mock_docker_client()
mock_docker.DockerClient.return_value = mock_client
Expand All @@ -138,39 +150,39 @@ def test_custom_base_url(self, mock_docker, mock_atexit):
assert cc.base_url == "tcp://remote:2375"

@patch("trpc_agent_sdk.code_executors.container._container_cli.atexit")
@patch("trpc_agent_sdk.code_executors.container._container_cli.docker")
@patch("trpc_agent_sdk.code_executors.container._container_cli._docker_mod")
def test_docker_exception_connection_error(self, mock_docker, mock_atexit):
exc_cls = type("DockerException", (Exception,), {})
exc_cls = type("DockerException", (Exception, ), {})
mock_docker.errors.DockerException = exc_cls
mock_docker.from_env.side_effect = exc_cls("Connection refused")

with pytest.raises(RuntimeError, match="Failed to connect to Docker daemon"):
ContainerClient(config=ContainerConfig())

@patch("trpc_agent_sdk.code_executors.container._container_cli.atexit")
@patch("trpc_agent_sdk.code_executors.container._container_cli.docker")
@patch("trpc_agent_sdk.code_executors.container._container_cli._docker_mod")
def test_docker_exception_socket_error(self, mock_docker, mock_atexit):
exc_cls = type("DockerException", (Exception,), {})
exc_cls = type("DockerException", (Exception, ), {})
mock_docker.errors.DockerException = exc_cls
mock_docker.from_env.side_effect = exc_cls("No such file or directory")

with pytest.raises(RuntimeError, match="Failed to connect to Docker daemon"):
ContainerClient(config=ContainerConfig())

@patch("trpc_agent_sdk.code_executors.container._container_cli.atexit")
@patch("trpc_agent_sdk.code_executors.container._container_cli.docker")
@patch("trpc_agent_sdk.code_executors.container._container_cli._docker_mod")
def test_docker_exception_generic(self, mock_docker, mock_atexit):
exc_cls = type("DockerException", (Exception,), {})
exc_cls = type("DockerException", (Exception, ), {})
mock_docker.errors.DockerException = exc_cls
mock_docker.from_env.side_effect = exc_cls("some other error")

with pytest.raises(RuntimeError, match="Failed to connect to Docker daemon: some other error"):
ContainerClient(config=ContainerConfig())

@patch("trpc_agent_sdk.code_executors.container._container_cli.atexit")
@patch("trpc_agent_sdk.code_executors.container._container_cli.docker")
@patch("trpc_agent_sdk.code_executors.container._container_cli._docker_mod")
def test_unexpected_exception(self, mock_docker, mock_atexit):
mock_docker.errors.DockerException = type("DockerException", (Exception,), {})
mock_docker.errors.DockerException = type("DockerException", (Exception, ), {})
mock_docker.from_env.side_effect = OSError("unexpected")

with pytest.raises(RuntimeError, match="Unexpected error initializing Docker client"):
Expand All @@ -185,7 +197,7 @@ def test_unexpected_exception(self, mock_docker, mock_atexit):
class TestContainerClientInitContainer:

@patch("trpc_agent_sdk.code_executors.container._container_cli.atexit")
@patch("trpc_agent_sdk.code_executors.container._container_cli.docker")
@patch("trpc_agent_sdk.code_executors.container._container_cli._docker_mod")
def test_container_starts_and_verifies_python(self, mock_docker, mock_atexit):
mock_client = _make_mock_docker_client()
mock_docker.from_env.return_value = mock_client
Expand All @@ -210,7 +222,7 @@ def test_container_starts_and_verifies_python(self, mock_docker, mock_atexit):
assert cc.container is mock_container

@patch("trpc_agent_sdk.code_executors.container._container_cli.atexit")
@patch("trpc_agent_sdk.code_executors.container._container_cli.docker")
@patch("trpc_agent_sdk.code_executors.container._container_cli._docker_mod")
def test_container_with_bind_mounts(self, mock_docker, mock_atexit):
mock_client = _make_mock_docker_client()
mock_docker.from_env.return_value = mock_client
Expand All @@ -235,7 +247,7 @@ def test_container_with_bind_mounts(self, mock_docker, mock_atexit):
)

@patch("trpc_agent_sdk.code_executors.container._container_cli.atexit")
@patch("trpc_agent_sdk.code_executors.container._container_cli.docker")
@patch("trpc_agent_sdk.code_executors.container._container_cli._docker_mod")
def test_python3_not_installed_raises(self, mock_docker, mock_atexit):
mock_client = _make_mock_docker_client()
mock_docker.from_env.return_value = mock_client
Expand All @@ -248,7 +260,7 @@ def test_python3_not_installed_raises(self, mock_docker, mock_atexit):
ContainerClient(config=ContainerConfig())

@patch("trpc_agent_sdk.code_executors.container._container_cli.atexit")
@patch("trpc_agent_sdk.code_executors.container._container_cli.docker")
@patch("trpc_agent_sdk.code_executors.container._container_cli._docker_mod")
def test_init_container_client_not_initialized(self, mock_docker, mock_atexit):
mock_client = _make_mock_docker_client()
mock_docker.from_env.return_value = mock_client
Expand All @@ -274,7 +286,7 @@ def test_init_container_client_not_initialized(self, mock_docker, mock_atexit):
class TestContainerClientBuildDockerImage:

@patch("trpc_agent_sdk.code_executors.container._container_cli.atexit")
@patch("trpc_agent_sdk.code_executors.container._container_cli.docker")
@patch("trpc_agent_sdk.code_executors.container._container_cli._docker_mod")
def test_build_image_success(self, mock_docker, mock_atexit, tmp_path):
mock_client = _make_mock_docker_client()
mock_docker.from_env.return_value = mock_client
Expand All @@ -287,19 +299,17 @@ def test_build_image_success(self, mock_docker, mock_atexit, tmp_path):
cfg = ContainerConfig(docker_path=docker_dir, image="custom:latest")
cc = ContainerClient(config=cfg)

mock_client.images.build.assert_called_once_with(
path=os.path.abspath(docker_dir), tag="custom:latest", rm=True)
mock_client.images.build.assert_called_once_with(path=os.path.abspath(docker_dir), tag="custom:latest", rm=True)

@patch("trpc_agent_sdk.code_executors.container._container_cli.atexit")
@patch("trpc_agent_sdk.code_executors.container._container_cli.docker")
@patch("trpc_agent_sdk.code_executors.container._container_cli._docker_mod")
def test_build_image_path_not_exists(self, mock_docker, mock_atexit):
mock_client = _make_mock_docker_client()
mock_docker.from_env.return_value = mock_client
mock_docker.errors.DockerException = Exception

with pytest.raises(FileNotFoundError, match="Invalid Docker path"):
ContainerClient(config=ContainerConfig(
docker_path="/nonexistent/docker/path", image="custom:latest"))
ContainerClient(config=ContainerConfig(docker_path="/nonexistent/docker/path", image="custom:latest"))

def test_build_image_no_docker_path(self):
cc = ContainerClient.__new__(ContainerClient)
Expand Down
27 changes: 20 additions & 7 deletions tests/code_executors/test_utils_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
# tRPC-Agent-Python is licensed under Apache-2.0.

import os
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch

import pytest

from trpc_agent_sdk.code_executors.utils import collect_files_with_glob
from trpc_agent_sdk.code_executors.utils import copy_dir
from trpc_agent_sdk.code_executors.utils import copy_path
Expand Down Expand Up @@ -326,18 +329,28 @@ def test_detect_content_type_text_utf8(self):

assert "text" in mime_type.lower() or mime_type == "application/octet-stream"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_detect_content_type_with_magic 因局部 import 导致 patch 失效

@patch('..._files.magic', create=True) 替换的是模块属性,但新实现把 import magic 移到函数内部,只查 sys.modules,mock 永不会被调用,断言必然失败。应改为 patch sys.modules['magic'] 或在函数内通过模块属性引用 magic。

@patch('trpc_agent_sdk.code_executors.utils._files.HAS_MAGIC', True)
@patch('trpc_agent_sdk.code_executors.utils._files.magic', create=True)
def test_detect_content_type_with_magic(self, mock_magic):
@pytest.mark.skipif(sys.platform == 'win32', reason='python-magic crashes on Windows without libmagic DLL')
def test_detect_content_type_with_magic(self):
"""Test detecting content type using magic library."""
from unittest.mock import MagicMock

filename = Path("test.unknown")
data = b"some data"
mock_magic = MagicMock()
mock_magic.from_buffer.return_value = "application/custom"

mime_type = detect_content_type(filename, data)

assert mime_type == "application/custom"
mock_magic.from_buffer.assert_called_once_with(data, mime=True)
# Inject mock into the module's cached magic slot
import trpc_agent_sdk.code_executors.utils._files as _f
orig = (_f._magic_module, _f._magic_checked, _f.HAS_MAGIC)
_f._magic_module = mock_magic
_f._magic_checked = True
_f.HAS_MAGIC = True
try:
mime_type = detect_content_type(filename, data)
assert mime_type == "application/custom"
mock_magic.from_buffer.assert_called_once_with(data, mime=True)
finally:
_f._magic_module, _f._magic_checked, _f.HAS_MAGIC = orig


class TestGetRelPath:
Expand Down
83 changes: 71 additions & 12 deletions trpc_agent_sdk/code_executors/container/_container_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,70 @@
import atexit
import os
import socket as pysocket
import threading
from dataclasses import dataclass
from typing import Optional
from typing import TYPE_CHECKING, Optional

# Note: Docker SDK imports (docker, docker.models.containers, docker.utils.socket)
# are deferred to runtime via _import_docker() to avoid hanging/crashing on
# systems without Docker installed (e.g. Windows without Docker Desktop).
# See: https://github.com/trpc-group/trpc-agent-python/issues/230
#
# TYPE_CHECKING imports provide type info for static analysis without triggering
# a runtime import. _import_docker() populates runtime symbols on first use.
if TYPE_CHECKING:
import docker
from docker.models.containers import Container

import docker
from docker.models.containers import Container
from docker.utils.socket import consume_socket_output
from docker.utils.socket import demux_adaptor
from docker.utils.socket import frames_iter
from trpc_agent_sdk.log import logger
from trpc_agent_sdk.utils import CommandExecResult

# Runtime cache
# Not exposed as module-level public names to avoid shadowing the
# TYPE_CHECKING type aliases above.
_docker_mod = None
_docker_consume_socket_output = None
_docker_demux_adaptor = None
_docker_frames_iter = None

_docker_imported = False
_docker_lock = threading.Lock()


def _import_docker():
"""Lazily import the Docker SDK only when actually needed.

Importing ``docker`` at module top-level causes the Python Docker SDK
to probe for a Docker daemon on Windows (named pipe) and other platforms.
When Docker is not installed/running this probe hangs or segfaults,
making the entire ``trpc_agent_sdk`` package unusable. By deferring the
import to call-time we ensure that users who never touch
``ContainerCodeExecutor`` are unaffected.

If the docker package is not installed, ``_docker_mod`` stays ``None`` and
callers should check via the ``_docker_mod is None`` guard.
"""
global _docker_imported, _docker_mod
global _docker_consume_socket_output, _docker_demux_adaptor, _docker_frames_iter
if _docker_imported:
return
with _docker_lock:
if _docker_imported:
return
try:
import docker as _d
from docker.utils.socket import consume_socket_output as _cso
from docker.utils.socket import demux_adaptor as _da
from docker.utils.socket import frames_iter as _fi
_docker_mod = _d
_docker_consume_socket_output = _cso
_docker_demux_adaptor = _da
_docker_frames_iter = _fi
except Exception:
logger.debug("Docker SDK import failed; _docker_mod stays None", exc_info=True)
_docker_imported = True


DEFAULT_IMAGE_TAG = 'python:3-slim'


Expand Down Expand Up @@ -88,24 +141,30 @@ def _init_docker_client(self):
- Remote Docker via DOCKER_HOST environment variable
- Custom base_url if provided
"""
# Lazily import the Docker SDK on first real use.
_import_docker()
# Guard: if docker is still None after import attempt (e.g. SDK not installed),
# fail with a clear error rather than AttributeError later.
if _docker_mod is None:
raise RuntimeError("Docker SDK is not available. Install it with: pip install docker")
# Try to initialize Docker client
# Let docker SDK handle connection detection (it supports various methods)
try:
if self.base_url:
# Use custom base_url if provided
self._client = docker.DockerClient(base_url=self.base_url)
self._client = _docker_mod.DockerClient(base_url=self.base_url)
else:
# Use docker.from_env() which automatically detects:
# - DOCKER_HOST environment variable
# - Standard socket paths
# - Docker Desktop configurations
self._client = docker.from_env()
self._client = _docker_mod.from_env()

# Test connection by pinging Docker daemon
# This will fail if Docker is not running or not accessible
self._client.ping()
logger.info("Docker client initialized successfully")
except docker.errors.DockerException as ex:
except _docker_mod.errors.DockerException as ex:
# Extract more specific error information
error_str = str(ex)

Expand Down Expand Up @@ -254,9 +313,9 @@ def _exec_run_with_stdin(
if callable(close_write):
close_write()

frames = frames_iter(sock, tty=False)
demux_frames = (demux_adaptor(*frame) for frame in frames)
output = consume_socket_output(demux_frames, demux=True)
frames = _docker_frames_iter(sock, tty=False)
demux_frames = (_docker_demux_adaptor(*frame) for frame in frames)
output = _docker_consume_socket_output(demux_frames, demux=True)
stdout = output[0].decode("utf-8") if output and output[0] else ""
stderr = output[1].decode("utf-8") if output and output[1] else ""
finally:
Expand Down
49 changes: 41 additions & 8 deletions trpc_agent_sdk/code_executors/utils/_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,29 @@
import mimetypes
import os
import shutil
import sys
from pathlib import Path

from trpc_agent_sdk.log import logger
from typing import Optional

try:
import magic
HAS_MAGIC = True
except ImportError:
HAS_MAGIC = False
# NOTE: python-magic is NOT imported at module top level.
# On Windows (and other platforms without libmagic installed), the `magic`
# package searches for the native libmagic DLL at import time, which can hang
# or crash the interpreter — making the entire trpc_agent_sdk package unusable.
# Instead, we lazily import it inside detect_content_type() on first use.
# See: https://github.com/trpc-group/trpc-agent-python/issues/230
import threading as _threading

_magic_module = None # cached after first successful import on non-win32
_magic_checked = False
_magic_lock = _threading.Lock()

# Backward-compatible public alias. Remains False until first
# detect_content_type() call triggers lazy import on non-win32.
# Note: on win32 this is always False (python-magic requires libmagic DLL
# which causes access violations at import time).
HAS_MAGIC = False


def path_join(base: str, path: str) -> str:
Expand Down Expand Up @@ -228,9 +243,27 @@ def detect_content_type(filename: Path, data: bytes) -> str:
if mime_type:
return mime_type

# filename guess failed, use magic to guess
if HAS_MAGIC:
return magic.from_buffer(data, mime=True)
# filename guess failed, try python-magic (lazily imported).
# On Windows, python-magic requires a native libmagic DLL that, when
# missing, causes an access violation at import time (not catchable by
# try/except). We skip it entirely on win32 and fall through to the
# simple content-based detection below.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HAS_MAGIC 被硬编码为 False,magic 检测路径成为死代码

if HAS_MAGIC and sys.platform != 'win32' 中 HAS_MAGIC 恒为 False,函数内 import magic 永不执行,即便 Linux/Mac 正确安装 libmagic 也不生效,所有无法由文件名推断的文件回落到字节检测。建议去掉门控,直接在非 win32 平台尝试 import 并缓存结果。

global _magic_module, _magic_checked, HAS_MAGIC

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

magic 懒加载失败缓存仅捕获 ImportError,无法覆盖其它导入失败

_magic_checked = Trueexcept ImportError 分支将失败永久缓存。若 import magic 抛出 OSError 等非 ImportError 异常(如 libmagic 缺失),异常会向上冒泡导致 detect_content_type 直接崩溃,而非回退到字节签名检测。建议改为 except Exception,与下方 from_buffer 的兜底策略一致。

if sys.platform != 'win32' and not _magic_checked:
with _magic_lock:
if not _magic_checked:
try:
import magic as _m
_magic_module = _m
HAS_MAGIC = True
except Exception:
logger.debug("python-magic import failed; falling back to byte-signature detection", exc_info=True)
_magic_checked = True # cache result (success or failure)
if _magic_module is not None:
try:
return _magic_module.from_buffer(data, mime=True)
except Exception:
logger.debug("magic.from_buffer failed; falling back to byte-signature detection", exc_info=True)

# magic guess failed, use simple content-based detection
if data.startswith(b'\x89PNG\r\n\x1a\n'):
Expand Down
Loading