From 2aed423b85c1eb4063c344136a6b8e6b61618d44 Mon Sep 17 00:00:00 2001 From: Emmanuel Briney Date: Tue, 12 May 2026 18:10:54 +0200 Subject: [PATCH 1/2] context: add kwargs_from_context helper Resolves a Docker CLI context (honouring DOCKER_CONTEXT, then currentContext in ~/.docker/config.json, then the built-in default context) and returns base_url / tls kwargs ready to be passed to APIClient. Used by the client to talk to Docker Desktop out of the box. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Emmanuel Briney --- docker/context/api.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docker/context/api.py b/docker/context/api.py index 9ac4ff470a..c86f1c74d1 100644 --- a/docker/context/api.py +++ b/docker/context/api.py @@ -132,6 +132,33 @@ def get_current_context(cls): """ return cls.get_context() + @classmethod + def kwargs_from_context(cls, name=None, environment=None): + """Build ``base_url`` / ``tls`` kwargs from a Docker CLI context. + + Mirrors the Docker CLI: if ``name`` is not given, honours the + ``DOCKER_CONTEXT`` env var, then the ``currentContext`` field in + ``~/.docker/config.json``, defaulting to the built-in ``default`` + context (local socket / named pipe). On a host with Docker Desktop + this resolves to the ``desktop-linux`` (or equivalent) context, so + client construction targets Docker Desktop out of the box. + """ + if environment is None: + environment = os.environ + if name is None: + name = environment.get("DOCKER_CONTEXT") + ctx = cls.get_context(name) + if ctx is None: + return {} + host = ctx.Host + if not host: + return {} + params = {"base_url": host} + tls_cfg = ctx.TLSConfig + if tls_cfg is not None: + params["tls"] = tls_cfg + return params + @classmethod def set_current_context(cls, name="default"): ctx = cls.get_context(name) From 37e59452d0ecc4e5480a5cba5eb01f8bfcb1645e Mon Sep 17 00:00:00 2001 From: Emmanuel Briney Date: Tue, 12 May 2026 18:11:04 +0200 Subject: [PATCH 2/2] client: use current Docker CLI context by default DockerClient.from_env() now falls back to the current Docker CLI context when DOCKER_HOST is unset, so docker.from_env() targets Docker Desktop (or any other configured context) out of the box. DOCKER_HOST still wins when set, matching the CLI's precedence. Callers can opt out with use_context=False. Also adds DockerClient.from_context(name=None) (exposed as docker.from_context) for explicit context selection. Existing pool-size unit tests are pinned with use_context=False so they stay deterministic regardless of the developer's current context. New tests cover: current-context fallback, DOCKER_HOST precedence, opt-out, named-context selection, unknown-context safety, and default-context resolution. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Emmanuel Briney --- docker/__init__.py | 2 +- docker/client.py | 64 ++++++++++++++++++++++++++- tests/unit/client_test.py | 93 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 155 insertions(+), 4 deletions(-) diff --git a/docker/__init__.py b/docker/__init__.py index fb7a5e921a..4e06ac2ac6 100644 --- a/docker/__init__.py +++ b/docker/__init__.py @@ -1,5 +1,5 @@ from .api import APIClient -from .client import DockerClient, from_env +from .client import DockerClient, from_context, from_env from .context import Context, ContextAPI from .tls import TLSConfig from .version import __version__ diff --git a/docker/client.py b/docker/client.py index 9012d24c9c..821706387c 100644 --- a/docker/client.py +++ b/docker/client.py @@ -1,5 +1,8 @@ +import os + from .api.client import APIClient from .constants import DEFAULT_MAX_POOL_SIZE, DEFAULT_TIMEOUT_SECONDS +from .context import ContextAPI from .models.configs import ConfigCollection from .models.containers import ContainerCollection from .models.images import ImageCollection @@ -78,6 +81,10 @@ def from_env(cls, **kwargs): use_ssh_client (bool): If set to `True`, an ssh connection is made via shelling out to the ssh client. Ensure the ssh client is installed and configured on the host. + use_context (bool): If ``True`` (the default), fall back to the + current Docker CLI context (``~/.docker/config.json`` / + ``DOCKER_CONTEXT``) when ``DOCKER_HOST`` is not set. This + allows the client to talk to Docker Desktop out of the box. Example: @@ -91,12 +98,66 @@ def from_env(cls, **kwargs): max_pool_size = kwargs.pop('max_pool_size', DEFAULT_MAX_POOL_SIZE) version = kwargs.pop('version', None) use_ssh_client = kwargs.pop('use_ssh_client', False) + use_context = kwargs.pop('use_context', True) + environment = kwargs.get('environment') or os.environ + + params = kwargs_from_env(**kwargs) + if use_context and 'base_url' not in params: + for k, v in ContextAPI.kwargs_from_context( + environment=environment).items(): + params.setdefault(k, v) + + return cls( + timeout=timeout, + max_pool_size=max_pool_size, + version=version, + use_ssh_client=use_ssh_client, + **params, + ) + + @classmethod + def from_context(cls, name=None, **kwargs): + """ + Return a client configured from a Docker CLI context. + + With no ``name``, resolves the current context the same way the + Docker CLI does: ``DOCKER_CONTEXT`` env var, then the + ``currentContext`` field in ``~/.docker/config.json``, falling back + to the built-in ``default`` context. On a machine with Docker + Desktop installed this typically resolves to ``desktop-linux``. + + Args: + name (str): Name of the context to load. ``None`` (the default) + means "use the current context". + version (str): The version of the API to use. Set to ``auto`` to + automatically detect the server's version. + timeout (int): Default timeout for API calls, in seconds. + max_pool_size (int): The maximum number of connections to save in + the pool. + use_ssh_client (bool): If ``True``, shell out to the ssh client + for ssh:// contexts. + + Example: + + >>> import docker + >>> client = docker.DockerClient.from_context() + >>> # or, pick a specific context: + >>> client = docker.DockerClient.from_context('desktop-linux') + """ + timeout = kwargs.pop('timeout', DEFAULT_TIMEOUT_SECONDS) + max_pool_size = kwargs.pop('max_pool_size', DEFAULT_MAX_POOL_SIZE) + version = kwargs.pop('version', None) + use_ssh_client = kwargs.pop('use_ssh_client', False) + + params = ContextAPI.kwargs_from_context(name=name) + params.update(kwargs) + return cls( timeout=timeout, max_pool_size=max_pool_size, version=version, use_ssh_client=use_ssh_client, - **kwargs_from_env(**kwargs) + **params, ) # Resources @@ -220,3 +281,4 @@ def __getattr__(self, name): from_env = DockerClient.from_env +from_context = DockerClient.from_context diff --git a/tests/unit/client_test.py b/tests/unit/client_test.py index 5ba712d240..e299e5906b 100644 --- a/tests/unit/client_test.py +++ b/tests/unit/client_test.py @@ -9,7 +9,9 @@ from docker.constants import ( DEFAULT_DOCKER_API_VERSION, DEFAULT_MAX_POOL_SIZE, + DEFAULT_NPIPE, DEFAULT_TIMEOUT_SECONDS, + DEFAULT_UNIX_SOCKET, IS_WINDOWS_PLATFORM, ) from docker.utils import kwargs_from_env @@ -193,7 +195,9 @@ def test_from_env_without_timeout_uses_default(self): ) @mock.patch("docker.transport.unixconn.UnixHTTPConnectionPool") def test_default_pool_size_from_env_unix(self, mock_obj): - client = docker.from_env(version=DEFAULT_DOCKER_API_VERSION) + client = docker.from_env( + version=DEFAULT_DOCKER_API_VERSION, use_context=False, + ) mock_obj.return_value.urlopen.return_value.status = 200 client.ping() @@ -227,7 +231,8 @@ def test_default_pool_size_from_env_win(self, mock_obj): def test_pool_size_from_env_unix(self, mock_obj): client = docker.from_env( version=DEFAULT_DOCKER_API_VERSION, - max_pool_size=POOL_SIZE + max_pool_size=POOL_SIZE, + use_context=False, ) mock_obj.return_value.urlopen.return_value.status = 200 client.ping() @@ -256,3 +261,87 @@ def test_pool_size_from_env_win(self, mock_obj): 60, maxsize=POOL_SIZE ) + + +class FromContextTest(unittest.TestCase): + """from_env / from_context honour Docker CLI contexts so the SDK + talks to Docker Desktop (or any other current context) by default.""" + + def setUp(self): + self.os_environ = os.environ.copy() + # Make sure DOCKER_HOST does not short-circuit the context path + os.environ.pop('DOCKER_HOST', None) + os.environ.pop('DOCKER_CONTEXT', None) + + def tearDown(self): + os.environ.clear() + os.environ.update(self.os_environ) + + @mock.patch('docker.client.ContextAPI.kwargs_from_context') + def test_from_env_uses_current_context_when_no_docker_host( + self, mock_ctx): + mock_ctx.return_value = { + 'base_url': 'unix:///Users/me/.docker/run/docker.sock', + } + client = docker.from_env(version=DEFAULT_DOCKER_API_VERSION) + assert mock_ctx.called + # Unix socket base_urls are rewritten internally; confirm the + # underlying APIClient picked up the context-supplied socket. + assert ( + client.api._custom_adapter.socket_path + == '/Users/me/.docker/run/docker.sock' + ) + + @mock.patch('docker.client.ContextAPI.kwargs_from_context') + def test_from_env_docker_host_overrides_context(self, mock_ctx): + mock_ctx.return_value = { + 'base_url': 'unix:///Users/me/.docker/run/docker.sock', + } + os.environ['DOCKER_HOST'] = 'tcp://192.168.59.103:2375' + client = docker.from_env(version=DEFAULT_DOCKER_API_VERSION) + assert client.api.base_url == 'http://192.168.59.103:2375' + # When DOCKER_HOST is set we don't need the context fallback. + assert not mock_ctx.called + + @mock.patch('docker.client.ContextAPI.kwargs_from_context') + def test_from_env_use_context_false_skips_context(self, mock_ctx): + client = docker.from_env( + version=DEFAULT_DOCKER_API_VERSION, use_context=False, + ) + assert not mock_ctx.called + # Falls back to APIClient's own platform default. + assert client.api.base_url in ( + 'http+docker://localhost', 'http+docker://localnpipe', + ) + + @mock.patch('docker.client.ContextAPI.kwargs_from_context') + def test_from_context_uses_named_context(self, mock_ctx): + mock_ctx.return_value = { + 'base_url': 'unix:///Users/me/.docker/run/docker.sock', + } + client = docker.from_context( + 'desktop-linux', version=DEFAULT_DOCKER_API_VERSION, + ) + mock_ctx.assert_called_once_with(name='desktop-linux') + assert ( + client.api._custom_adapter.socket_path + == '/Users/me/.docker/run/docker.sock' + ) + + def test_kwargs_from_context_honours_docker_context_env(self): + from docker.context import ContextAPI + # No context with this name exists; helper should return {} rather + # than raise, leaving APIClient to use its own default. + params = ContextAPI.kwargs_from_context( + environment={'DOCKER_CONTEXT': 'does-not-exist'}, + ) + assert params == {} + + def test_kwargs_from_context_default(self): + from docker.context import ContextAPI + params = ContextAPI.kwargs_from_context(name='default') + # The default context always resolves to the local socket / pipe. + assert 'base_url' in params + assert params['base_url'] in ( + DEFAULT_UNIX_SOCKET[len('http+'):], DEFAULT_NPIPE, + )