From 25416ed3ccff565b6fd6847b2222447c73dc8af3 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:04:30 +0200 Subject: [PATCH 1/3] feat(python-sdk): add logger option for request/debug logging Add a `logger` option (standard library `logging.Logger`) to `Sandbox.create`/`connect` and `AsyncSandbox.create`/`connect`, wired into the API client, envd client, volume content client, and the RPC (ConnectRPC) path. Mirrors the JS SDK: `logger` is a construction-time option (not a per-request ApiParams field), and logging is emitted only when a logger is supplied. Co-Authored-By: Claude Opus 4.8 --- .changeset/python-connection-logger.md | 33 +++++ packages/python-sdk/e2b/api/__init__.py | 67 ++++++--- .../e2b/api/client_async/__init__.py | 13 -- .../e2b/api/client_sync/__init__.py | 13 -- packages/python-sdk/e2b/connection_config.py | 3 + .../e2b/sandbox_async/commands/command.py | 1 + .../e2b/sandbox_async/commands/pty.py | 1 + .../sandbox_async/filesystem/filesystem.py | 1 + packages/python-sdk/e2b/sandbox_async/main.py | 18 +++ .../e2b/sandbox_async/sandbox_api.py | 7 +- .../e2b/sandbox_sync/commands/command.py | 1 + .../e2b/sandbox_sync/commands/pty.py | 1 + .../e2b/sandbox_sync/filesystem/filesystem.py | 1 + packages/python-sdk/e2b/sandbox_sync/main.py | 18 +++ .../e2b/sandbox_sync/sandbox_api.py | 7 +- .../e2b/volume/client_async/__init__.py | 17 +-- .../e2b/volume/client_sync/__init__.py | 17 +-- .../e2b/volume/connection_config.py | 8 + .../python-sdk/e2b/volume/volume_async.py | 1 + packages/python-sdk/e2b/volume/volume_sync.py | 1 + packages/python-sdk/e2b_connect/client.py | 29 ++++ .../python-sdk/tests/test_logging_option.py | 140 ++++++++++++++++++ 22 files changed, 325 insertions(+), 73 deletions(-) create mode 100644 .changeset/python-connection-logger.md create mode 100644 packages/python-sdk/tests/test_logging_option.py diff --git a/.changeset/python-connection-logger.md b/.changeset/python-connection-logger.md new file mode 100644 index 0000000000..263d753d9a --- /dev/null +++ b/.changeset/python-connection-logger.md @@ -0,0 +1,33 @@ +--- +"@e2b/python-sdk": patch +--- + +feat(python-sdk): add a `logger` option for request/debug logging + +You can now pass a standard library `logging.Logger` to `Sandbox.create` / +`AsyncSandbox.create` (and `connect`) to route that sandbox's request/response +logs to your own logger. Matching the JavaScript SDK, `logger` is a +construction-time option — it configures the sandbox and is **not** a +per-request parameter on control-plane methods like `kill`/`list`/`get_info`. +The stdlib `logging.Logger` is used directly as the adapter instead of a custom +interface. + +The logger is wired into the API client, the envd client, and the RPC +(ConnectRPC) path. Mirroring the JS SDK: requests log at `INFO`, successful API +and unary RPC responses at `INFO`, streamed RPC messages at `DEBUG`, and failed +API responses (status >= 400) at `ERROR`. When no logger is supplied, the SDK +emits no request/response logging at all (also matching the JS SDK). + +Volume content operations continue to accept `logger` per call via +`VolumeApiParams`, matching the JS Volume API. + +```python +import logging +from e2b import Sandbox + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger("my-app.e2b") + +sbx = Sandbox.create(logger=logger) +sbx.commands.run("echo hello") # request/response logged via `logger` +``` diff --git a/packages/python-sdk/e2b/api/__init__.py b/packages/python-sdk/e2b/api/__init__.py index 07830bc127..ef9108bf3c 100644 --- a/packages/python-sdk/e2b/api/__init__.py +++ b/packages/python-sdk/e2b/api/__init__.py @@ -18,7 +18,45 @@ SandboxException, ) -logger = logging.getLogger(__name__) + +def make_logging_event_hooks(log: Optional[logging.Logger]) -> dict: + """Build synchronous httpx ``event_hooks`` that log requests and responses + to the given ``logging.Logger``. Requests log at ``INFO``, successful + responses at ``INFO`` and responses with status >= 400 at ``ERROR``. + + Returns no hooks when ``log`` is ``None`` so that nothing is logged unless a + logger was explicitly supplied.""" + if log is None: + return {} + + def on_request(request) -> None: + log.info(f"Request {request.method} {request.url}") + + def on_response(response: Response) -> None: + if response.status_code >= 400: + log.error(f"Response {response.status_code}") + else: + log.info(f"Response {response.status_code}") + + return {"request": [on_request], "response": [on_response]} + + +def make_async_logging_event_hooks(log: Optional[logging.Logger]) -> dict: + """Asynchronous counterpart of :func:`make_logging_event_hooks`.""" + if log is None: + return {} + + async def on_request(request) -> None: + log.info(f"Request {request.method} {request.url}") + + async def on_response(response: Response) -> None: + if response.status_code >= 400: + log.error(f"Response {response.status_code}") + else: + log.info(f"Response {response.status_code}") + + return {"request": [on_request], "response": [on_response]} + limits = Limits( max_keepalive_connections=int(os.getenv("E2B_MAX_KEEPALIVE_CONNECTIONS", "20")), @@ -139,6 +177,8 @@ def __init__( auth_header_name = "X-API-KEY" if require_api_key else "Authorization" prefix = "" if require_api_key else "Bearer" + self._logger = config.logger + headers = { **default_headers, **(config.headers or {}), @@ -153,10 +193,7 @@ def __init__( kwargs.pop("prefix", None) httpx_args = { - "event_hooks": { - "request": [self._log_request], - "response": [self._log_response], - }, + "event_hooks": self._logging_event_hooks(), "transport": transport, } if transport is None: @@ -173,23 +210,11 @@ def __init__( **kwargs, ) - def _log_request(self, request): - logger.info(f"Request {request.method} {request.url}") - - def _log_response(self, response: Response): - if response.status_code >= 400: - logger.error(f"Response {response.status_code}") - else: - logger.info(f"Response {response.status_code}") + def _logging_event_hooks(self) -> dict: + return make_logging_event_hooks(self._logger) # We need to override the logging hooks for the async usage class AsyncApiClient(ApiClient): - async def _log_request(self, request): - logger.info(f"Request {request.method} {request.url}") - - async def _log_response(self, response: Response): - if response.status_code >= 400: - logger.error(f"Response {response.status_code}") - else: - logger.info(f"Response {response.status_code}") + def _logging_event_hooks(self) -> dict: + return make_async_logging_event_hooks(self._logger) diff --git a/packages/python-sdk/e2b/api/client_async/__init__.py b/packages/python-sdk/e2b/api/client_async/__init__.py index 85300f5902..44ce9d97de 100644 --- a/packages/python-sdk/e2b/api/client_async/__init__.py +++ b/packages/python-sdk/e2b/api/client_async/__init__.py @@ -1,5 +1,4 @@ import asyncio -import logging from typing import Dict, Tuple import httpx @@ -7,8 +6,6 @@ from e2b.api import AsyncApiClient, limits from e2b.connection_config import ConnectionConfig -logger = logging.getLogger(__name__) - def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient: return AsyncApiClient( @@ -21,16 +18,6 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient: class AsyncTransportWithLogger(httpx.AsyncHTTPTransport): _instances: Dict[Tuple[int, bool], "AsyncTransportWithLogger"] = {} - async def handle_async_request(self, request): - url = f"{request.url.scheme}://{request.url.host}{request.url.path}" - logger.info(f"Request: {request.method} {url}") - response = await super().handle_async_request(request) - - # data = connect.GzipCompressor.decompress(response.read()).decode() - logger.info(f"Response: {response.status_code} {url}") - - return response - @property def pool(self): return self._pool diff --git a/packages/python-sdk/e2b/api/client_sync/__init__.py b/packages/python-sdk/e2b/api/client_sync/__init__.py index e44c43707c..56de069d86 100644 --- a/packages/python-sdk/e2b/api/client_sync/__init__.py +++ b/packages/python-sdk/e2b/api/client_sync/__init__.py @@ -1,14 +1,11 @@ from typing import Dict import httpx -import logging import threading from e2b.api import ApiClient, limits from e2b.connection_config import ConnectionConfig -logger = logging.getLogger(__name__) - def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient: return ApiClient( @@ -21,16 +18,6 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient: class TransportWithLogger(httpx.HTTPTransport): _thread_local = threading.local() - def handle_request(self, request): - url = f"{request.url.scheme}://{request.url.host}{request.url.path}" - logger.info(f"Request: {request.method} {url}") - response = super().handle_request(request) - - # data = connect.GzipCompressor.decompress(response.read()).decode() - logger.info(f"Response: {response.status_code} {url}") - - return response - @property def pool(self): return self._pool diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index e24cedad27..a8f3c95234 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -1,3 +1,4 @@ +import logging import os from typing import Optional, Dict, TypedDict @@ -93,7 +94,9 @@ def __init__( api_headers: Optional[Dict[str, str]] = None, extra_sandbox_headers: Optional[Dict[str, str]] = None, proxy: Optional[ProxyTypes] = None, + logger: Optional[logging.Logger] = None, ): + self.logger = logger self.domain = domain or ConnectionConfig._domain() self.debug = debug or ConnectionConfig._debug() self.api_key = api_key or ConnectionConfig._api_key() diff --git a/packages/python-sdk/e2b/sandbox_async/commands/command.py b/packages/python-sdk/e2b/sandbox_async/commands/command.py index 4476558b27..eacc046440 100644 --- a/packages/python-sdk/e2b/sandbox_async/commands/command.py +++ b/packages/python-sdk/e2b/sandbox_async/commands/command.py @@ -40,6 +40,7 @@ def __init__( async_pool=pool, json=True, headers=connection_config.sandbox_headers, + logger=connection_config.logger, ) async def list( diff --git a/packages/python-sdk/e2b/sandbox_async/commands/pty.py b/packages/python-sdk/e2b/sandbox_async/commands/pty.py index 3585b13246..df66df86e7 100644 --- a/packages/python-sdk/e2b/sandbox_async/commands/pty.py +++ b/packages/python-sdk/e2b/sandbox_async/commands/pty.py @@ -42,6 +42,7 @@ def __init__( async_pool=pool, json=True, headers=connection_config.sandbox_headers, + logger=connection_config.logger, ) async def kill( diff --git a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py index 309f4f6169..9011d765df 100644 --- a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py @@ -84,6 +84,7 @@ def __init__( async_pool=pool, json=True, headers=connection_config.sandbox_headers, + logger=connection_config.logger, ) @overload diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 1c8ce0786a..6102b19392 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -9,6 +9,7 @@ from packaging.version import Version from typing_extensions import Self, Unpack +from e2b.api import make_async_logging_event_hooks from e2b.api.client.types import Unset from e2b.api.client_async import get_envd_transport as get_transport from e2b.connection_config import ApiParams, ConnectionConfig @@ -109,6 +110,7 @@ def __init__( ), transport=self._transport, headers=self.connection_config.sandbox_headers, + event_hooks=make_async_logging_event_hooks(self.connection_config.logger), ) self._filesystem = Filesystem( self.envd_api_url, @@ -180,6 +182,7 @@ async def create( network: Optional[SandboxNetworkOpts] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[SandboxAsyncVolumeMount] = None, + logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> Self: """ @@ -197,6 +200,7 @@ async def create( :param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.all_traffic``, ``ctx.rules``) and returning a list of strings. Per-host transform rules are nested under ``network.rules``. :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` (default) or ``"pause"``; ``auto_resume``: ``False`` (default) or ``True`` (only when ``on_timeout="pause"``). Example: ``{"on_timeout": "pause", "auto_resume": True}`` :param volume_mounts: Dictionary mapping mount paths to AsyncVolume instances or volume names + :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: A Sandbox instance for the new sandbox @@ -228,6 +232,7 @@ async def create( network=network, lifecycle=lifecycle, volume_mounts=transformed_mounts, + logger=logger, **opts, ) @@ -249,6 +254,7 @@ async def create( async def connect( self, timeout: Optional[int] = None, + logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> Self: """ @@ -259,6 +265,7 @@ async def connect( :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: A running sandbox instance @example @@ -277,6 +284,7 @@ async def connect( async def connect( sandbox_id: str, timeout: Optional[int] = None, + logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> "AsyncSandbox": """ @@ -288,6 +296,7 @@ async def connect( :param sandbox_id: Sandbox ID :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: A running sandbox instance @example @@ -305,6 +314,7 @@ async def connect( async def connect( self, timeout: Optional[int] = None, + logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> Self: """ @@ -315,6 +325,7 @@ async def connect( :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: A running sandbox instance @example @@ -329,6 +340,7 @@ async def connect( await SandboxApi._cls_connect( sandbox_id=self.sandbox_id, timeout=timeout, + logger=logger if logger is not None else self.connection_config.logger, **self.connection_config.get_api_params(**opts), ) @@ -836,11 +848,13 @@ async def _cls_connect_sandbox( cls, sandbox_id: str, timeout: Optional[int] = None, + logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> Self: sandbox = await SandboxApi._cls_connect( sandbox_id=sandbox_id, timeout=timeout, + logger=logger, **opts, ) @@ -854,6 +868,7 @@ async def _cls_connect_sandbox( connection_config = ConnectionConfig( extra_sandbox_headers=sandbox_headers, + logger=logger, **opts, ) @@ -879,6 +894,7 @@ async def _create( network: Optional[SandboxNetworkOpts] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[list] = None, + logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> Self: extra_sandbox_headers = {} @@ -902,6 +918,7 @@ async def _create( network=network, lifecycle=lifecycle, volume_mounts=volume_mounts, + logger=logger, **opts, ) @@ -921,6 +938,7 @@ async def _create( connection_config = ConnectionConfig( extra_sandbox_headers=extra_sandbox_headers, + logger=logger, **opts, ) diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index ac1740bfcb..5356edf64f 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -1,4 +1,5 @@ import datetime +import logging from typing import Any, Dict, List, Optional, cast from packaging.version import Version @@ -198,9 +199,10 @@ async def _create_sandbox( network: Optional[SandboxNetworkOpts] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[List[SandboxVolumeMountAPI]] = None, + logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> SandboxCreateResponse: - config = ConnectionConfig(**opts) + config = ConnectionConfig(logger=logger, **opts) on_timeout = lifecycle.get("on_timeout", "kill") if lifecycle else "kill" auto_resume = lifecycle.get("auto_resume", False) if lifecycle else False @@ -412,12 +414,13 @@ async def _cls_connect( cls, sandbox_id: str, timeout: Optional[int] = None, + logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> Sandbox: timeout = timeout or SandboxBase.default_sandbox_timeout # Sandbox is not running, resume it - config = ConnectionConfig(**opts) + config = ConnectionConfig(logger=logger, **opts) api_client = get_api_client( config, diff --git a/packages/python-sdk/e2b/sandbox_sync/commands/command.py b/packages/python-sdk/e2b/sandbox_sync/commands/command.py index e23c1809ec..a4757626e5 100644 --- a/packages/python-sdk/e2b/sandbox_sync/commands/command.py +++ b/packages/python-sdk/e2b/sandbox_sync/commands/command.py @@ -39,6 +39,7 @@ def __init__( pool=pool, json=True, headers=connection_config.sandbox_headers, + logger=connection_config.logger, ) def list( diff --git a/packages/python-sdk/e2b/sandbox_sync/commands/pty.py b/packages/python-sdk/e2b/sandbox_sync/commands/pty.py index fd936ef404..b3e51360fe 100644 --- a/packages/python-sdk/e2b/sandbox_sync/commands/pty.py +++ b/packages/python-sdk/e2b/sandbox_sync/commands/pty.py @@ -38,6 +38,7 @@ def __init__( pool=pool, json=True, headers=connection_config.sandbox_headers, + logger=connection_config.logger, ) def kill( diff --git a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py index b145200e41..ebc42d13ac 100644 --- a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py @@ -82,6 +82,7 @@ def __init__( pool=pool, json=True, headers=connection_config.sandbox_headers, + logger=connection_config.logger, ) @overload diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index e21abe1129..14943988c1 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -9,6 +9,7 @@ from packaging.version import Version from typing_extensions import Self, Unpack +from e2b.api import make_logging_event_hooks from e2b.api.client.types import Unset from e2b.api.client_sync import get_envd_transport as get_transport from e2b.connection_config import ApiParams, ConnectionConfig @@ -107,6 +108,7 @@ def __init__(self, **opts: Unpack[SandboxOpts]): base_url=self.envd_api_url, transport=self._transport, headers=self.connection_config.sandbox_headers, + event_hooks=make_logging_event_hooks(self.connection_config.logger), ) self._filesystem = Filesystem( self.envd_api_url, @@ -178,6 +180,7 @@ def create( network: Optional[SandboxNetworkOpts] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[SandboxVolumeMount] = None, + logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> Self: """ @@ -195,6 +198,7 @@ def create( :param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.all_traffic``, ``ctx.rules``) and returning a list of strings. Per-host transform rules are nested under ``network.rules``. :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` (default) or ``"pause"``; ``auto_resume``: ``False`` (default) or ``True`` (only when ``on_timeout="pause"``). Example: ``{"on_timeout": "pause", "auto_resume": True}`` :param volume_mounts: Dictionary mapping mount paths to Volume instances or volume names + :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: A Sandbox instance for the new sandbox @@ -226,6 +230,7 @@ def create( network=network, lifecycle=lifecycle, volume_mounts=transformed_mounts, + logger=logger, **opts, ) @@ -247,6 +252,7 @@ def create( def connect( self, timeout: Optional[int] = None, + logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> Self: """ @@ -257,6 +263,7 @@ def connect( :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: A running sandbox instance @example @@ -276,6 +283,7 @@ def connect( def connect( sandbox_id: str, timeout: Optional[int] = None, + logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> "Sandbox": """ @@ -287,6 +295,7 @@ def connect( :param sandbox_id: Sandbox ID :param timeout: Timeout for the sandbox in **seconds**. For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: A running sandbox instance @example @@ -304,6 +313,7 @@ def connect( def connect( self, timeout: Optional[int] = None, + logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> Self: """ @@ -314,6 +324,7 @@ def connect( :param timeout: Timeout for the sandbox in **seconds**. For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: A running sandbox instance @example @@ -328,6 +339,7 @@ def connect( SandboxApi._cls_connect( sandbox_id=self.sandbox_id, timeout=timeout, + logger=logger if logger is not None else self.connection_config.logger, **self.connection_config.get_api_params(**opts), ) @@ -832,11 +844,13 @@ def _cls_connect_sandbox( cls, sandbox_id: str, timeout: Optional[int] = None, + logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> Self: sandbox = SandboxApi._cls_connect( sandbox_id=sandbox_id, timeout=timeout, + logger=logger, **opts, ) @@ -850,6 +864,7 @@ def _cls_connect_sandbox( connection_config = ConnectionConfig( extra_sandbox_headers=sandbox_headers, + logger=logger, **opts, ) @@ -875,6 +890,7 @@ def _create( network: Optional[SandboxNetworkOpts] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[list] = None, + logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> Self: extra_sandbox_headers = {} @@ -898,6 +914,7 @@ def _create( network=network, lifecycle=lifecycle, volume_mounts=volume_mounts, + logger=logger, **opts, ) @@ -917,6 +934,7 @@ def _create( connection_config = ConnectionConfig( extra_sandbox_headers=extra_sandbox_headers, + logger=logger, **opts, ) diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index 45c5b5561d..390e1e441b 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -1,4 +1,5 @@ import datetime +import logging from typing import Any, Dict, List, Optional, cast from packaging.version import Version @@ -197,9 +198,10 @@ def _create_sandbox( network: Optional[SandboxNetworkOpts] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[List[SandboxVolumeMountAPI]] = None, + logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> SandboxCreateResponse: - config = ConnectionConfig(**opts) + config = ConnectionConfig(logger=logger, **opts) on_timeout = lifecycle.get("on_timeout", "kill") if lifecycle else "kill" auto_resume = lifecycle.get("auto_resume", False) if lifecycle else False @@ -317,11 +319,12 @@ def _cls_connect( cls, sandbox_id: str, timeout: Optional[int] = None, + logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> Sandbox: timeout = timeout or SandboxBase.default_sandbox_timeout - config = ConnectionConfig(**opts) + config = ConnectionConfig(logger=logger, **opts) api_client = get_api_client( config, diff --git a/packages/python-sdk/e2b/volume/client_async/__init__.py b/packages/python-sdk/e2b/volume/client_async/__init__.py index f1e44a8a33..96b0c05227 100644 --- a/packages/python-sdk/e2b/volume/client_async/__init__.py +++ b/packages/python-sdk/e2b/volume/client_async/__init__.py @@ -1,17 +1,15 @@ -import logging import os from typing import Optional import httpx from httpx import Limits +from e2b.api import make_async_logging_event_hooks from e2b.api.metadata import default_headers from e2b.exceptions import AuthenticationException from e2b.volume.client.client import AuthenticatedClient as AsyncVolumeApiClient from e2b.volume.connection_config import VolumeConnectionConfig -logger = logging.getLogger(__name__) - limits = Limits( max_keepalive_connections=int(os.getenv("E2B_MAX_KEEPALIVE_CONNECTIONS", "20")), max_connections=int(os.getenv("E2B_MAX_CONNECTIONS", "2000")), @@ -37,7 +35,11 @@ def get_api_client(config: VolumeConnectionConfig, **kwargs) -> AsyncVolumeApiCl auth_header_name="Authorization", prefix="Bearer", headers=headers, - httpx_args={"proxy": config.proxy, "transport": get_transport(config)}, + httpx_args={ + "proxy": config.proxy, + "transport": get_transport(config), + "event_hooks": make_async_logging_event_hooks(config.logger), + }, **kwargs, ) @@ -45,13 +47,6 @@ def get_api_client(config: VolumeConnectionConfig, **kwargs) -> AsyncVolumeApiCl class AsyncTransportWithLogger(httpx.AsyncHTTPTransport): singleton: Optional["AsyncTransportWithLogger"] = None - async def handle_async_request(self, request): - url = f"{request.url.scheme}://{request.url.host}{request.url.path}" - logger.info(f"Request: {request.method} {url}") - response = await super().handle_async_request(request) - logger.info(f"Response: {response.status_code} {url}") - return response - @property def pool(self): return self._pool diff --git a/packages/python-sdk/e2b/volume/client_sync/__init__.py b/packages/python-sdk/e2b/volume/client_sync/__init__.py index 5afdcfef2d..4471ec2e6e 100644 --- a/packages/python-sdk/e2b/volume/client_sync/__init__.py +++ b/packages/python-sdk/e2b/volume/client_sync/__init__.py @@ -1,17 +1,15 @@ -import logging import os from typing import Optional import httpx from httpx import Limits +from e2b.api import make_logging_event_hooks from e2b.api.metadata import default_headers from e2b.exceptions import AuthenticationException from e2b.volume.client.client import AuthenticatedClient as VolumeApiClient from e2b.volume.connection_config import VolumeConnectionConfig -logger = logging.getLogger(__name__) - limits = Limits( max_keepalive_connections=int(os.getenv("E2B_MAX_KEEPALIVE_CONNECTIONS", "20")), max_connections=int(os.getenv("E2B_MAX_CONNECTIONS", "2000")), @@ -37,7 +35,11 @@ def get_api_client(config: VolumeConnectionConfig, **kwargs) -> VolumeApiClient: auth_header_name="Authorization", prefix="Bearer", headers=headers, - httpx_args={"proxy": config.proxy, "transport": get_transport(config)}, + httpx_args={ + "proxy": config.proxy, + "transport": get_transport(config), + "event_hooks": make_logging_event_hooks(config.logger), + }, **kwargs, ) @@ -45,13 +47,6 @@ def get_api_client(config: VolumeConnectionConfig, **kwargs) -> VolumeApiClient: class TransportWithLogger(httpx.HTTPTransport): singleton: Optional["TransportWithLogger"] = None - def handle_request(self, request): - url = f"{request.url.scheme}://{request.url.host}{request.url.path}" - logger.info(f"Request: {request.method} {url}") - response = super().handle_request(request) - logger.info(f"Response: {response.status_code} {url}") - return response - @property def pool(self): return self._pool diff --git a/packages/python-sdk/e2b/volume/connection_config.py b/packages/python-sdk/e2b/volume/connection_config.py index f5a0b4aec3..c5e3491eba 100644 --- a/packages/python-sdk/e2b/volume/connection_config.py +++ b/packages/python-sdk/e2b/volume/connection_config.py @@ -1,3 +1,4 @@ +import logging import os from typing import Dict, Optional, TypedDict @@ -37,6 +38,9 @@ class VolumeApiParams(TypedDict, total=False): proxy: Optional[ProxyTypes] """Proxy to use for the request.""" + logger: Optional[logging.Logger] + """Logger used for request and response logging. Accepts a standard library `logging.Logger`.""" + class VolumeConnectionConfig: """ @@ -82,7 +86,9 @@ def __init__( request_timeout: Optional[float] = None, headers: Optional[Dict[str, str]] = None, proxy: Optional[ProxyTypes] = None, + logger: Optional[logging.Logger] = None, ): + self.logger = logger self.domain = domain or self._domain() self.debug = debug if debug is not None else self._debug() @@ -119,6 +125,7 @@ def get_api_params( token = opts.get("token") api_url = opts.get("api_url") proxy = opts.get("proxy") + logger = opts.get("logger") req_headers = self.headers.copy() if headers is not None: @@ -133,5 +140,6 @@ def get_api_params( request_timeout=self.get_request_timeout(request_timeout), headers=req_headers, proxy=proxy if proxy is not None else self.proxy, + logger=logger if logger is not None else self.logger, ) ) diff --git a/packages/python-sdk/e2b/volume/volume_async.py b/packages/python-sdk/e2b/volume/volume_async.py index d7533d2916..98fc7be99f 100644 --- a/packages/python-sdk/e2b/volume/volume_async.py +++ b/packages/python-sdk/e2b/volume/volume_async.py @@ -88,6 +88,7 @@ def _get_volume_config( request_timeout=opts.get("request_timeout"), headers=opts.get("headers"), proxy=opts.get("proxy"), + logger=opts.get("logger"), ) @classmethod diff --git a/packages/python-sdk/e2b/volume/volume_sync.py b/packages/python-sdk/e2b/volume/volume_sync.py index b56ff31ba5..cdc410c246 100644 --- a/packages/python-sdk/e2b/volume/volume_sync.py +++ b/packages/python-sdk/e2b/volume/volume_sync.py @@ -88,6 +88,7 @@ def _get_volume_config( request_timeout=opts.get("request_timeout"), headers=opts.get("headers"), proxy=opts.get("proxy"), + logger=opts.get("logger"), ) @classmethod diff --git a/packages/python-sdk/e2b_connect/client.py b/packages/python-sdk/e2b_connect/client.py index b41fedb03c..b18015d5a9 100644 --- a/packages/python-sdk/e2b_connect/client.py +++ b/packages/python-sdk/e2b_connect/client.py @@ -1,6 +1,7 @@ import gzip import inspect import json +import logging import struct import typing @@ -188,6 +189,7 @@ def __init__( compressor=None, json: Optional[bool] = False, headers: Optional[Dict[str, str]] = None, + logger: Optional[logging.Logger] = None, ): if headers is None: headers = {} @@ -200,6 +202,23 @@ def __init__( self._compressor = compressor self._headers = headers self._connection_retries = 3 + self._logger = logger + + def _log_request(self) -> None: + if self._logger is not None: + self._logger.info(f"Request: POST {self.url}") + + def _log_response(self, status: int) -> None: + if self._logger is None: + return + if status >= 400: + self._logger.error(f"Response: {status} {self.url}") + else: + self._logger.info(f"Response: {status} {self.url}") + + def _log_stream_message(self) -> None: + if self._logger is not None: + self._logger.debug(f"Response stream: {self.url}") def _prepare_unary_request( self, @@ -250,6 +269,8 @@ def _process_unary_response( self, http_resp: Response, ): + self._log_response(http_resp.status) + if http_resp.status != 200: raise error_for_response(http_resp) @@ -281,6 +302,7 @@ async def acall_unary( **opts, ) + self._log_request() res = await self.async_pool.request(**req_data) return self._process_unary_response(res) @@ -302,6 +324,7 @@ def call_unary( **opts, ) + self._log_request() res = self.pool.request(**req_data) return self._process_unary_response(res) @@ -386,13 +409,16 @@ async def acall_server_stream( response_type=self._response_type, ) + self._log_request() async with self.async_pool.stream(**req_data) as http_resp: if http_resp.status != 200: + self._log_response(http_resp.status) await http_resp.aread() raise error_for_response(http_resp) async for chunk in http_resp.aiter_stream(): for parsed in parser.parse(chunk): + self._log_stream_message() yield parsed @_retry(RemoteProtocolError, 3) @@ -420,13 +446,16 @@ def call_server_stream( response_type=self._response_type, ) + self._log_request() with self.pool.stream(**req_data) as http_resp: if http_resp.status != 200: + self._log_response(http_resp.status) http_resp.read() raise error_for_response(http_resp) for chunk in http_resp.iter_stream(): for parsed in parser.parse(chunk): + self._log_stream_message() yield parsed def call_client_stream(self, req, **opts): diff --git a/packages/python-sdk/tests/test_logging_option.py b/packages/python-sdk/tests/test_logging_option.py new file mode 100644 index 0000000000..c564dfcb11 --- /dev/null +++ b/packages/python-sdk/tests/test_logging_option.py @@ -0,0 +1,140 @@ +import inspect +import logging + +import e2b_connect as connect +from e2b import AsyncSandbox, ConnectionConfig, Sandbox +from e2b.api import ( + ApiClient, + make_async_logging_event_hooks, + make_logging_event_hooks, +) +from e2b.connection_config import ApiParams +from e2b.volume.connection_config import VolumeConnectionConfig + + +def test_connection_config_stores_logger(): + custom = logging.getLogger("test.custom") + config = ConnectionConfig(api_key="e2b_" + "0" * 40, logger=custom) + assert config.logger is custom + + +def test_connection_config_logger_defaults_to_none(): + config = ConnectionConfig(api_key="e2b_" + "0" * 40) + assert config.logger is None + + +def test_logger_is_not_a_per_request_api_param(): + # Matching the JS SDK, `logger` is a construction-time option (Sandbox.create + # / connect), not part of the per-request ApiParams used by control-plane + # methods like kill/list/get_info. It must therefore not round-trip through + # `get_api_params`. + assert "logger" not in ApiParams.__annotations__ + + custom = logging.getLogger("test.no-round-trip") + config = ConnectionConfig(api_key="e2b_" + "0" * 40, logger=custom) + assert "logger" not in config.get_api_params() + + +def test_logger_is_accepted_on_create_and_connect(): + for cls in (Sandbox, AsyncSandbox): + assert "logger" in inspect.signature(cls.create).parameters + assert "logger" in inspect.signature(cls.connect).parameters + + +def test_volume_connection_config_stores_and_round_trips_logger(): + custom = logging.getLogger("test.volume") + config = VolumeConnectionConfig(token="token", logger=custom) + assert config.logger is custom + assert config.get_api_params()["logger"] is custom + + +def test_api_client_uses_config_logger(): + custom = logging.getLogger("test.api-client") + config = ConnectionConfig(api_key="e2b_" + "0" * 40, logger=custom) + client = ApiClient(config) + try: + assert client._logger is custom + finally: + client.get_httpx_client().close() + + +def test_api_client_without_logger_emits_no_hooks(): + # With no logger supplied, nothing should be logged (matching the JS SDK, + # which only attaches its logging middleware when a logger is given). + config = ConnectionConfig(api_key="e2b_" + "0" * 40) + client = ApiClient(config) + try: + assert client._logger is None + assert client.get_httpx_client().event_hooks == { + "request": [], + "response": [], + } + finally: + client.get_httpx_client().close() + + +def test_rpc_client_without_logger_does_not_log(caplog): + client = connect.Client(url="https://example.com", response_type=object) + assert client._logger is None + # The guarded helpers must be safe no-ops when no logger was supplied. + with caplog.at_level(logging.DEBUG): + client._log_request() + client._log_response(200) + client._log_response(500) + client._log_stream_message() + assert caplog.records == [] + + +def test_rpc_client_uses_provided_logger(caplog): + custom = logging.getLogger("test.rpc") + client = connect.Client( + url="https://example.com", response_type=object, logger=custom + ) + assert client._logger is custom + + with caplog.at_level(logging.DEBUG, logger="test.rpc"): + client._log_request() + client._log_response(200) + client._log_response(500) + client._log_stream_message() + + levels = [(r.levelno, r.getMessage()) for r in caplog.records] + assert (logging.INFO, "Request: POST https://example.com") in levels + assert (logging.INFO, "Response: 200 https://example.com") in levels + assert (logging.ERROR, "Response: 500 https://example.com") in levels + assert (logging.DEBUG, "Response stream: https://example.com") in levels + + +def test_logging_event_hooks_without_logger_are_empty(): + assert make_logging_event_hooks(None) == {} + assert make_async_logging_event_hooks(None) == {} + + +def test_sync_logging_event_hooks_emit_records(caplog): + log = logging.getLogger("test.hooks.sync") + hooks = make_logging_event_hooks(log) + + class _Req: + method = "GET" + url = "https://example.com/foo" + + class _Resp: + def __init__(self, status_code): + self.status_code = status_code + + with caplog.at_level(logging.DEBUG, logger="test.hooks.sync"): + hooks["request"][0](_Req()) + hooks["response"][0](_Resp(200)) + hooks["response"][0](_Resp(500)) + + levels = [(r.levelno, r.getMessage()) for r in caplog.records] + assert (logging.INFO, "Request GET https://example.com/foo") in levels + assert (logging.INFO, "Response 200") in levels + assert (logging.ERROR, "Response 500") in levels + + +def test_make_async_logging_event_hooks_shape(): + hooks = make_async_logging_event_hooks(logging.getLogger("test.hooks.async")) + assert set(hooks) == {"request", "response"} + assert len(hooks["request"]) == 1 + assert len(hooks["response"]) == 1 From 15d80506451af332f645f1ae92d124f725cfefa9 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:14:20 +0200 Subject: [PATCH 2/3] fix(python-sdk): propagate sandbox logger to later operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `get_api_params` now carries the sandbox's stored logger so the throwaway ConnectionConfig that instance control-plane methods (kill, pause, set_timeout, get_info, connect) rebuild keeps logging with the construction-time logger. Also drop the misleading `logger` arg from instance `connect()` — the returned instance is unchanged, so it could never adopt a new logger; `logger` remains on `create` and the static `connect(sandbox_id, ...)` construction paths. Co-Authored-By: Claude Opus 4.8 --- .changeset/python-connection-logger.md | 14 +++++----- packages/python-sdk/e2b/connection_config.py | 10 ++++++- packages/python-sdk/e2b/sandbox_async/main.py | 5 ---- packages/python-sdk/e2b/sandbox_sync/main.py | 5 ---- .../python-sdk/tests/test_logging_option.py | 27 ++++++++++++++----- 5 files changed, 37 insertions(+), 24 deletions(-) diff --git a/.changeset/python-connection-logger.md b/.changeset/python-connection-logger.md index 263d753d9a..50d0353c22 100644 --- a/.changeset/python-connection-logger.md +++ b/.changeset/python-connection-logger.md @@ -5,12 +5,14 @@ feat(python-sdk): add a `logger` option for request/debug logging You can now pass a standard library `logging.Logger` to `Sandbox.create` / -`AsyncSandbox.create` (and `connect`) to route that sandbox's request/response -logs to your own logger. Matching the JavaScript SDK, `logger` is a -construction-time option — it configures the sandbox and is **not** a -per-request parameter on control-plane methods like `kill`/`list`/`get_info`. -The stdlib `logging.Logger` is used directly as the adapter instead of a custom -interface. +`AsyncSandbox.create` (and the static `Sandbox.connect(sandbox_id, ...)`) to +route that sandbox's request/response logs to your own logger. The logger is +stored on the sandbox and propagates to all of its later operations — +including control-plane calls such as `kill`, `pause`, `set_timeout`, and +`get_info`. Matching the JavaScript SDK, `logger` is a construction-time option +and is **not** a per-request parameter that those methods accept from the +caller. The stdlib `logging.Logger` is used directly as the adapter instead of +a custom interface. The logger is wired into the API client, the envd client, and the RPC (ConnectRPC) path. Mirroring the JS SDK: requests log at `INFO`, successful API diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index a8f3c95234..24c05acba5 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -205,7 +205,7 @@ def get_api_params( if api_headers is not None: req_headers.update(api_headers) - return dict( + params = dict( ApiParams( api_key=api_key if api_key is not None else self.api_key, api_url=api_url if api_url is not None else self.api_url, @@ -216,6 +216,14 @@ def get_api_params( proxy=proxy if proxy is not None else self.proxy, ) ) + # `logger` is a construction-time option rather than a per-request + # ApiParams field, but it must propagate to the throwaway + # ConnectionConfig that instance control-plane methods (kill, pause, + # set_timeout, get_info, connect, ...) rebuild from these params, so + # those requests keep logging with the logger the sandbox was created + # or connected with. + params["logger"] = self.logger + return params @property def sandbox_headers(self): diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 6102b19392..24044f5644 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -254,7 +254,6 @@ async def create( async def connect( self, timeout: Optional[int] = None, - logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> Self: """ @@ -265,7 +264,6 @@ async def connect( :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. - :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: A running sandbox instance @example @@ -314,7 +312,6 @@ async def connect( async def connect( self, timeout: Optional[int] = None, - logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> Self: """ @@ -325,7 +322,6 @@ async def connect( :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. - :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: A running sandbox instance @example @@ -340,7 +336,6 @@ async def connect( await SandboxApi._cls_connect( sandbox_id=self.sandbox_id, timeout=timeout, - logger=logger if logger is not None else self.connection_config.logger, **self.connection_config.get_api_params(**opts), ) diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 14943988c1..e77b2f2af8 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -252,7 +252,6 @@ def create( def connect( self, timeout: Optional[int] = None, - logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> Self: """ @@ -263,7 +262,6 @@ def connect( :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. - :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: A running sandbox instance @example @@ -313,7 +311,6 @@ def connect( def connect( self, timeout: Optional[int] = None, - logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> Self: """ @@ -324,7 +321,6 @@ def connect( :param timeout: Timeout for the sandbox in **seconds**. For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. - :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: A running sandbox instance @example @@ -339,7 +335,6 @@ def connect( SandboxApi._cls_connect( sandbox_id=self.sandbox_id, timeout=timeout, - logger=logger if logger is not None else self.connection_config.logger, **self.connection_config.get_api_params(**opts), ) diff --git a/packages/python-sdk/tests/test_logging_option.py b/packages/python-sdk/tests/test_logging_option.py index c564dfcb11..dcc39d0ca2 100644 --- a/packages/python-sdk/tests/test_logging_option.py +++ b/packages/python-sdk/tests/test_logging_option.py @@ -23,22 +23,35 @@ def test_connection_config_logger_defaults_to_none(): assert config.logger is None -def test_logger_is_not_a_per_request_api_param(): +def test_logger_is_not_a_public_per_request_api_param(): # Matching the JS SDK, `logger` is a construction-time option (Sandbox.create - # / connect), not part of the per-request ApiParams used by control-plane - # methods like kill/list/get_info. It must therefore not round-trip through - # `get_api_params`. + # / connect), not a public per-request ApiParams field that control-plane + # methods like kill/list/get_info accept from the caller. assert "logger" not in ApiParams.__annotations__ - custom = logging.getLogger("test.no-round-trip") + +def test_get_api_params_propagates_stored_logger(): + # Instance control-plane methods (kill, pause, set_timeout, get_info, + # connect) rebuild a throwaway ConnectionConfig from these params, so the + # logger the sandbox was created/connected with must survive the round-trip. + custom = logging.getLogger("test.propagate") config = ConnectionConfig(api_key="e2b_" + "0" * 40, logger=custom) - assert "logger" not in config.get_api_params() + assert config.get_api_params()["logger"] is custom + assert ConnectionConfig(**config.get_api_params()).logger is custom + + no_logger = ConnectionConfig(api_key="e2b_" + "0" * 40) + assert no_logger.get_api_params()["logger"] is None def test_logger_is_accepted_on_create_and_connect(): for cls in (Sandbox, AsyncSandbox): assert "logger" in inspect.signature(cls.create).parameters - assert "logger" in inspect.signature(cls.connect).parameters + # `logger` is a construction option, so it is accepted by the static + # `Sandbox.connect(sandbox_id, ...)` form (which builds a fresh instance) + # but not by instance `sandbox.connect()`, where the already-built clients + # cannot adopt a new logger. + assert "logger" not in inspect.signature(Sandbox.connect).parameters + assert "logger" not in inspect.signature(AsyncSandbox.connect).parameters def test_volume_connection_config_stores_and_round_trips_logger(): From 3b0d16cb6c5b6b8d9957943e85c04ba6eb086bf6 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:19:32 +0200 Subject: [PATCH 3/3] refactor(python-sdk): type get_api_params logger via ApiParamsWithLogger Replace the off-schema dict mutation with an internal `ApiParamsWithLogger` TypedDict (ApiParams + logger), so the construction-time logger is propagated through a typed structure rather than an untyped key assignment. Co-Authored-By: Claude Opus 4.8 --- packages/python-sdk/e2b/connection_config.py | 31 +++++++++++++------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index 24c05acba5..94b285a495 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -50,6 +50,18 @@ class ApiParams(TypedDict, total=False): """URL to connect to sandbox, defaults to `E2B_SANDBOX_URL` environment variable.""" +class ApiParamsWithLogger(ApiParams, total=False): + """:class:`ApiParams` plus the construction-time ``logger``. + + Internal type returned by :meth:`ConnectionConfig.get_api_params` so that the + logger a sandbox was created/connected with keeps propagating to the + throwaway ``ConnectionConfig`` that instance control-plane methods rebuild. + Unlike :class:`ApiParams`, ``logger`` is not a public per-request option. + """ + + logger: Optional[logging.Logger] + + class ConnectionConfig: """ Configuration for the connection to the API. @@ -205,8 +217,14 @@ def get_api_params( if api_headers is not None: req_headers.update(api_headers) - params = dict( - ApiParams( + # `logger` is a construction-time option rather than a per-request + # ApiParams field, but it must propagate to the throwaway + # ConnectionConfig that instance control-plane methods (kill, pause, + # set_timeout, get_info, connect, ...) rebuild from these params, so + # those requests keep logging with the logger the sandbox was created + # or connected with. + return dict( + ApiParamsWithLogger( api_key=api_key if api_key is not None else self.api_key, api_url=api_url if api_url is not None else self.api_url, domain=domain if domain is not None else self.domain, @@ -214,16 +232,9 @@ def get_api_params( request_timeout=self.get_request_timeout(request_timeout), headers=req_headers, proxy=proxy if proxy is not None else self.proxy, + logger=self.logger, ) ) - # `logger` is a construction-time option rather than a per-request - # ApiParams field, but it must propagate to the throwaway - # ConnectionConfig that instance control-plane methods (kill, pause, - # set_timeout, get_info, connect, ...) rebuild from these params, so - # those requests keep logging with the logger the sandbox was created - # or connected with. - params["logger"] = self.logger - return params @property def sandbox_headers(self):