diff --git a/tests/code_executors/container/test_container_cli.py b/tests/code_executors/container/test_container_cli.py index 19c30aff..9c41e38d 100644 --- a/tests/code_executors/container/test_container_cli.py +++ b/tests/code_executors/container/test_container_cli.py @@ -27,9 +27,21 @@ ContainerClient, ContainerConfig, ) +from trpc_agent_sdk.code_executors.container._container_cli 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 # --------------------------------------------------------------------------- @@ -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 @@ -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 @@ -138,9 +150,9 @@ 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") @@ -148,9 +160,9 @@ def test_docker_exception_connection_error(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_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") @@ -158,9 +170,9 @@ def test_docker_exception_socket_error(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_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") @@ -168,9 +180,9 @@ def test_docker_exception_generic(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_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"): @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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) diff --git a/tests/code_executors/test_utils_files.py b/tests/code_executors/test_utils_files.py index 69e1c011..441fa3e1 100644 --- a/tests/code_executors/test_utils_files.py +++ b/tests/code_executors/test_utils_files.py @@ -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 @@ -326,18 +329,28 @@ def test_detect_content_type_text_utf8(self): assert "text" in mime_type.lower() or mime_type == "application/octet-stream" - @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: diff --git a/trpc_agent_sdk/code_executors/container/_container_cli.py b/trpc_agent_sdk/code_executors/container/_container_cli.py index 39447ad6..5e96114d 100644 --- a/trpc_agent_sdk/code_executors/container/_container_cli.py +++ b/trpc_agent_sdk/code_executors/container/_container_cli.py @@ -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' @@ -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) @@ -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: diff --git a/trpc_agent_sdk/code_executors/utils/_files.py b/trpc_agent_sdk/code_executors/utils/_files.py index af79ab66..435f2a04 100644 --- a/trpc_agent_sdk/code_executors/utils/_files.py +++ b/trpc_agent_sdk/code_executors/utils/_files.py @@ -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: @@ -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. + global _magic_module, _magic_checked, HAS_MAGIC + 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'):