Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion docker/__init__.py
Original file line number Diff line number Diff line change
@@ -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__
Expand Down
64 changes: 63 additions & 1 deletion docker/client.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:

Expand All @@ -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
Expand Down Expand Up @@ -220,3 +281,4 @@ def __getattr__(self, name):


from_env = DockerClient.from_env
from_context = DockerClient.from_context
27 changes: 27 additions & 0 deletions docker/context/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
93 changes: 91 additions & 2 deletions tests/unit/client_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
)
Loading