summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docker/api/build.py9
-rw-r--r--docker/api/client.py19
-rw-r--r--docker/api/container.py46
-rw-r--r--docker/api/image.py5
-rw-r--r--docker/api/service.py3
-rw-r--r--docker/auth.py2
-rw-r--r--docker/constants.py1
-rw-r--r--docker/models/containers.py39
-rw-r--r--docker/models/images.py39
-rw-r--r--docker/transport/__init__.py5
-rw-r--r--docker/transport/npipesocket.py4
-rw-r--r--docker/transport/sshconn.py107
-rw-r--r--docker/types/containers.py56
-rw-r--r--docker/types/daemon.py12
-rw-r--r--docker/utils/utils.py137
-rw-r--r--docker/version.py2
-rw-r--r--docs/api.rst2
-rw-r--r--docs/change-log.md25
-rw-r--r--requirements.txt3
-rw-r--r--scripts/versions.py41
-rw-r--r--setup.py3
-rw-r--r--test-requirements.txt2
-rw-r--r--tests/helpers.py4
-rw-r--r--tests/integration/api_build_test.py5
-rw-r--r--tests/integration/api_container_test.py4
-rw-r--r--tests/integration/api_network_test.py2
-rw-r--r--tests/integration/api_plugin_test.py16
-rw-r--r--tests/integration/api_swarm_test.py3
-rw-r--r--tests/integration/base.py73
-rw-r--r--tests/integration/models_containers_test.py3
-rw-r--r--tests/integration/models_images_test.py27
-rw-r--r--tests/unit/api_build_test.py64
-rw-r--r--tests/unit/models_containers_test.py4
-rw-r--r--tests/unit/models_images_test.py22
-rw-r--r--tests/unit/utils_config_test.py24
-rw-r--r--tests/unit/utils_test.py16
36 files changed, 643 insertions, 186 deletions
diff --git a/docker/api/build.py b/docker/api/build.py
index 0486dce..3a67ff8 100644
--- a/docker/api/build.py
+++ b/docker/api/build.py
@@ -339,7 +339,14 @@ def process_dockerfile(dockerfile, path):
abs_dockerfile = dockerfile
if not os.path.isabs(dockerfile):
abs_dockerfile = os.path.join(path, dockerfile)
-
+ if constants.IS_WINDOWS_PLATFORM and path.startswith(
+ constants.WINDOWS_LONGPATH_PREFIX):
+ abs_dockerfile = '{}{}'.format(
+ constants.WINDOWS_LONGPATH_PREFIX,
+ os.path.normpath(
+ abs_dockerfile[len(constants.WINDOWS_LONGPATH_PREFIX):]
+ )
+ )
if (os.path.splitdrive(path)[0] != os.path.splitdrive(abs_dockerfile)[0] or
os.path.relpath(abs_dockerfile, path).startswith('..')):
# Dockerfile not in context - read data to insert into tar later
diff --git a/docker/api/client.py b/docker/api/client.py
index 91da1c8..197846d 100644
--- a/docker/api/client.py
+++ b/docker/api/client.py
@@ -39,6 +39,11 @@ try:
except ImportError:
pass
+try:
+ from ..transport import SSHAdapter
+except ImportError:
+ pass
+
class APIClient(
requests.Session,
@@ -141,6 +146,18 @@ class APIClient(
)
self.mount('http+docker://', self._custom_adapter)
self.base_url = 'http+docker://localnpipe'
+ elif base_url.startswith('ssh://'):
+ try:
+ self._custom_adapter = SSHAdapter(
+ base_url, timeout, pool_connections=num_pools
+ )
+ except NameError:
+ raise DockerException(
+ 'Install paramiko package to enable ssh:// support'
+ )
+ self.mount('http+docker://ssh', self._custom_adapter)
+ self._unmount('http://', 'https://')
+ self.base_url = 'http+docker://ssh'
else:
# Use SSLAdapter for the ability to specify SSL version
if isinstance(tls, TLSConfig):
@@ -279,6 +296,8 @@ class APIClient(
self._raise_for_status(response)
if self.base_url == "http+docker://localnpipe":
sock = response.raw._fp.fp.raw.sock
+ elif self.base_url.startswith('http+docker://ssh'):
+ sock = response.raw._fp.fp.channel
elif six.PY3:
sock = response.raw._fp.fp.raw
if self.base_url.startswith("https://"):
diff --git a/docker/api/container.py b/docker/api/container.py
index c59a6d0..fce73af 100644
--- a/docker/api/container.py
+++ b/docker/api/container.py
@@ -473,16 +473,12 @@ class ContainerApiMixin(object):
signals and reaps processes
init_path (str): Path to the docker-init binary
ipc_mode (str): Set the IPC mode for the container.
- isolation (str): Isolation technology to use. Default: `None`.
- links (dict or list of tuples): Either a dictionary mapping name
- to alias or as a list of ``(name, alias)`` tuples.
- log_config (dict): Logging configuration, as a dictionary with
- keys:
-
- - ``type`` The logging driver name.
- - ``config`` A dictionary of configuration for the logging
- driver.
-
+ isolation (str): Isolation technology to use. Default: ``None``.
+ links (dict): Mapping of links using the
+ ``{'container': 'alias'}`` format. The alias is optional.
+ Containers declared in this dict will be linked to the new
+ container using the provided alias. Default: ``None``.
+ log_config (LogConfig): Logging configuration
lxc_conf (dict): LXC config.
mem_limit (float or str): Memory limit. Accepts float values
(which represent the memory limit of the created container in
@@ -543,7 +539,7 @@ class ContainerApiMixin(object):
}
ulimits (:py:class:`list`): Ulimits to set inside the container,
- as a list of dicts.
+ as a list of :py:class:`docker.types.Ulimit` instances.
userns_mode (str): Sets the user namespace mode for the container
when user namespace remapping option is enabled. Supported
values are: ``host``
@@ -611,9 +607,10 @@ class ContainerApiMixin(object):
aliases (:py:class:`list`): A list of aliases for this endpoint.
Names in that list can be used within the network to reach the
container. Defaults to ``None``.
- links (:py:class:`list`): A list of links for this endpoint.
- Containers declared in this list will be linked to this
- container. Defaults to ``None``.
+ links (dict): Mapping of links for this endpoint using the
+ ``{'container': 'alias'}`` format. The alias is optional.
+ Containers declared in this dict will be linked to this
+ container using the provided alias. Defaults to ``None``.
ipv4_address (str): The IP address of this container on the
network, using the IPv4 protocol. Defaults to ``None``.
ipv6_address (str): The IP address of this container on the
@@ -628,7 +625,7 @@ class ContainerApiMixin(object):
>>> endpoint_config = client.create_endpoint_config(
aliases=['web', 'app'],
- links=['app_db'],
+ links={'app_db': 'db', 'another': None},
ipv4_address='132.65.0.123'
)
@@ -697,6 +694,18 @@ class ContainerApiMixin(object):
Raises:
:py:class:`docker.errors.APIError`
If the server returns an error.
+
+ Example:
+
+ >>> c = docker.APIClient()
+ >>> f = open('./sh_bin.tar', 'wb')
+ >>> bits, stat = c.get_archive(container, '/bin/sh')
+ >>> print(stat)
+ {'name': 'sh', 'size': 1075464, 'mode': 493,
+ 'mtime': '2018-10-01T15:37:48-07:00', 'linkTarget': ''}
+ >>> for chunk in bits:
+ ... f.write(chunk)
+ >>> f.close()
"""
params = {
'path': path
@@ -1074,7 +1083,8 @@ class ContainerApiMixin(object):
Args:
container (str): The container to stream statistics from
decode (bool): If set to true, stream will be decoded into dicts
- on the fly. False by default.
+ on the fly. Only applicable if ``stream`` is True.
+ False by default.
stream (bool): If set to false, only the current stats will be
returned instead of a stream. True by default.
@@ -1088,6 +1098,10 @@ class ContainerApiMixin(object):
return self._stream_helper(self._get(url, stream=True),
decode=decode)
else:
+ if decode:
+ raise errors.InvalidArgument(
+ "decode is only available in conjuction with stream=True"
+ )
return self._result(self._get(url, params={'stream': False}),
json=True)
diff --git a/docker/api/image.py b/docker/api/image.py
index 5f05d88..5a6537e 100644
--- a/docker/api/image.py
+++ b/docker/api/image.py
@@ -32,7 +32,7 @@ class ImageApiMixin(object):
Example:
>>> image = cli.get_image("busybox:latest")
- >>> f = open('/tmp/busybox-latest.tar', 'w')
+ >>> f = open('/tmp/busybox-latest.tar', 'wb')
>>> for chunk in image:
>>> f.write(chunk)
>>> f.close()
@@ -334,7 +334,8 @@ class ImageApiMixin(object):
Args:
repository (str): The repository to pull
tag (str): The tag to pull
- stream (bool): Stream the output as a generator
+ stream (bool): Stream the output as a generator. Make sure to
+ consume the generator, otherwise pull might get cancelled.
auth_config (dict): Override the credentials that
:py:meth:`~docker.api.daemon.DaemonApiMixin.login` has set for
this request. ``auth_config`` should contain the ``username``
diff --git a/docker/api/service.py b/docker/api/service.py
index 8b956b6..08e2591 100644
--- a/docker/api/service.py
+++ b/docker/api/service.py
@@ -197,7 +197,8 @@ class ServiceApiMixin(object):
into the service inspect output.
Returns:
- ``True`` if successful.
+ (dict): A dictionary of the server-side representation of the
+ service, including all relevant properties.
Raises:
:py:class:`docker.errors.APIError`
diff --git a/docker/auth.py b/docker/auth.py
index 9635f93..17158f4 100644
--- a/docker/auth.py
+++ b/docker/auth.py
@@ -267,7 +267,7 @@ def load_config(config_path=None, config_dict=None):
return res
log.debug(
- "Couldn't find auth-related section ; attempting to interpret"
+ "Couldn't find auth-related section ; attempting to interpret "
"as auth-only file"
)
return {'auths': parse_auth(config_dict)}
diff --git a/docker/constants.py b/docker/constants.py
index 7565a76..1ab11ec 100644
--- a/docker/constants.py
+++ b/docker/constants.py
@@ -14,6 +14,7 @@ INSECURE_REGISTRY_DEPRECATION_WARNING = \
'is deprecated and non-functional. Please remove it.'
IS_WINDOWS_PLATFORM = (sys.platform == 'win32')
+WINDOWS_LONGPATH_PREFIX = '\\\\?\\'
DEFAULT_USER_AGENT = "docker-sdk-python/{0}".format(version)
DEFAULT_NUM_POOLS = 25
diff --git a/docker/models/containers.py b/docker/models/containers.py
index f60ba6e..9d6f2cc 100644
--- a/docker/models/containers.py
+++ b/docker/models/containers.py
@@ -15,7 +15,12 @@ from .resource import Collection, Model
class Container(Model):
-
+ """ Local representation of a container object. Detailed configuration may
+ be accessed through the :py:attr:`attrs` attribute. Note that local
+ attributes are cached; users may call :py:meth:`reload` to
+ query the Docker daemon for the current properties, causing
+ :py:attr:`attrs` to be refreshed.
+ """
@property
def name(self):
"""
@@ -228,6 +233,17 @@ class Container(Model):
Raises:
:py:class:`docker.errors.APIError`
If the server returns an error.
+
+ Example:
+
+ >>> f = open('./sh_bin.tar', 'wb')
+ >>> bits, stat = container.get_archive('/bin/sh')
+ >>> print(stat)
+ {'name': 'sh', 'size': 1075464, 'mode': 493,
+ 'mtime': '2018-10-01T15:37:48-07:00', 'linkTarget': ''}
+ >>> for chunk in bits:
+ ... f.write(chunk)
+ >>> f.close()
"""
return self.client.api.get_archive(self.id, path, chunk_size)
@@ -380,7 +396,8 @@ class Container(Model):
Args:
decode (bool): If set to true, stream will be decoded into dicts
- on the fly. False by default.
+ on the fly. Only applicable if ``stream`` is True.
+ False by default.
stream (bool): If set to false, only the current stats will be
returned instead of a stream. True by default.
@@ -574,15 +591,11 @@ class ContainerCollection(Collection):
``{"label1": "value1", "label2": "value2"}``) or a list of
names of labels to set with empty values (e.g.
``["label1", "label2"]``)
- links (dict or list of tuples): Either a dictionary mapping name
- to alias or as a list of ``(name, alias)`` tuples.
- log_config (dict): Logging configuration, as a dictionary with
- keys:
-
- - ``type`` The logging driver name.
- - ``config`` A dictionary of configuration for the logging
- driver.
-
+ links (dict): Mapping of links using the
+ ``{'container': 'alias'}`` format. The alias is optional.
+ Containers declared in this dict will be linked to the new
+ container using the provided alias. Default: ``None``.
+ log_config (LogConfig): Logging configuration.
mac_address (str): MAC address to assign to the container.
mem_limit (int or str): Memory limit. Accepts float values
(which represent the memory limit of the created container in
@@ -691,8 +704,8 @@ class ContainerCollection(Collection):
}
tty (bool): Allocate a pseudo-TTY.
- ulimits (:py:class:`list`): Ulimits to set inside the container, as
- a list of dicts.
+ ulimits (:py:class:`list`): Ulimits to set inside the container,
+ as a list of :py:class:`docker.types.Ulimit` instances.
user (str or int): Username or UID to run commands as inside the
container.
userns_mode (str): Sets the user namespace mode for the container
diff --git a/docker/models/images.py b/docker/models/images.py
index 7d9ab70..30e86f1 100644
--- a/docker/models/images.py
+++ b/docker/models/images.py
@@ -1,5 +1,6 @@
import itertools
import re
+import warnings
import six
@@ -59,7 +60,7 @@ class Image(Model):
"""
return self.client.api.history(self.id)
- def save(self, chunk_size=DEFAULT_DATA_CHUNK_SIZE):
+ def save(self, chunk_size=DEFAULT_DATA_CHUNK_SIZE, named=False):
"""
Get a tarball of an image. Similar to the ``docker save`` command.
@@ -67,6 +68,12 @@ class Image(Model):
chunk_size (int): The generator will return up to that much data
per iteration, but may return less. If ``None``, data will be
streamed as it is received. Default: 2 MB
+ named (str or bool): If ``False`` (default), the tarball will not
+ retain repository and tag information for this image. If set
+ to ``True``, the first tag in the :py:attr:`~tags` list will
+ be used to identify the image. Alternatively, any element of
+ the :py:attr:`~tags` list can be used as an argument to use
+ that specific tag as the saved identifier.
Returns:
(generator): A stream of raw archive data.
@@ -78,12 +85,22 @@ class Image(Model):
Example:
>>> image = cli.get_image("busybox:latest")
- >>> f = open('/tmp/busybox-latest.tar', 'w')
+ >>> f = open('/tmp/busybox-latest.tar', 'wb')
>>> for chunk in image:
>>> f.write(chunk)
>>> f.close()
"""
- return self.client.api.get_image(self.id, chunk_size)
+ img = self.id
+ if named:
+ img = self.tags[0] if self.tags else img
+ if isinstance(named, six.string_types):
+ if named not in self.tags:
+ raise InvalidArgument(
+ "{} is not a valid tag for this image".format(named)
+ )
+ img = named
+
+ return self.client.api.get_image(img, chunk_size)
def tag(self, repository, tag=None, **kwargs):
"""
@@ -409,7 +426,21 @@ class ImageCollection(Collection):
if not tag:
repository, tag = parse_repository_tag(repository)
- self.client.api.pull(repository, tag=tag, **kwargs)
+ if 'stream' in kwargs:
+ warnings.warn(
+ '`stream` is not a valid parameter for this method'
+ ' and will be overridden'
+ )
+ del kwargs['stream']
+
+ pull_log = self.client.api.pull(
+ repository, tag=tag, stream=True, **kwargs
+ )
+ for _ in pull_log:
+ # We don't do anything with the logs, but we need
+ # to keep the connection alive and wait for the image
+ # to be pulled.
+ pass
if tag:
return self.get('{0}{2}{1}'.format(
repository, tag, '@' if tag.startswith('sha256:') else ':'
diff --git a/docker/transport/__init__.py b/docker/transport/__init__.py
index abbee18..d2cf2a7 100644
--- a/docker/transport/__init__.py
+++ b/docker/transport/__init__.py
@@ -6,3 +6,8 @@ try:
from .npipesocket import NpipeSocket
except ImportError:
pass
+
+try:
+ from .sshconn import SSHAdapter
+except ImportError:
+ pass
diff --git a/docker/transport/npipesocket.py b/docker/transport/npipesocket.py
index c04b39d..ef02031 100644
--- a/docker/transport/npipesocket.py
+++ b/docker/transport/npipesocket.py
@@ -87,10 +87,6 @@ class NpipeSocket(object):
def dup(self):
return NpipeSocket(self._handle)
- @check_closed
- def fileno(self):
- return int(self._handle)
-
def getpeername(self):
return self._address
diff --git a/docker/transport/sshconn.py b/docker/transport/sshconn.py
new file mode 100644
index 0000000..0f6bb51
--- /dev/null
+++ b/docker/transport/sshconn.py
@@ -0,0 +1,107 @@
+import paramiko
+import requests.adapters
+import six
+
+from .. import constants
+
+if six.PY3:
+ import http.client as httplib
+else:
+ import httplib
+
+try:
+ import requests.packages.urllib3 as urllib3
+except ImportError:
+ import urllib3
+
+RecentlyUsedContainer = urllib3._collections.RecentlyUsedContainer
+
+
+class SSHConnection(httplib.HTTPConnection, object):
+ def __init__(self, ssh_transport, timeout=60):
+ super(SSHConnection, self).__init__(
+ 'localhost', timeout=timeout
+ )
+ self.ssh_transport = ssh_transport
+ self.timeout = timeout
+
+ def connect(self):
+ sock = self.ssh_transport.open_session()
+ sock.settimeout(self.timeout)
+ sock.exec_command('docker system dial-stdio')
+ self.sock = sock
+
+
+class SSHConnectionPool(urllib3.connectionpool.HTTPConnectionPool):
+ scheme = 'ssh'
+
+ def __init__(self, ssh_client, timeout=60, maxsize=10):
+ super(SSHConnectionPool, self).__init__(
+ 'localhost', timeout=timeout, maxsize=maxsize
+ )
+ self.ssh_transport = ssh_client.get_transport()
+ self.timeout = timeout
+
+ def _new_conn(self):
+ return SSHConnection(self.ssh_transport, self.timeout)
+
+ # When re-using connections, urllib3 calls fileno() on our
+ # SSH channel instance, quickly overloading our fd limit. To avoid this,
+ # we override _get_conn
+ def _get_conn(self, timeout):
+ conn = None
+ try:
+ conn = self.pool.get(block=self.block, timeout=timeout)
+
+ except AttributeError: # self.pool is None
+ raise urllib3.exceptions.ClosedPoolError(self, "Pool is closed.")
+
+ except six.moves.queue.Empty:
+ if self.block:
+ raise urllib3.exceptions.EmptyPoolError(
+ self,
+ "Pool reached maximum size and no more "
+ "connections are allowed."
+ )
+ pass # Oh well, we'll create a new connection then
+
+ return conn or self._new_conn()
+
+
+class SSHAdapter(requests.adapters.HTTPAdapter):
+
+ __attrs__ = requests.adapters.HTTPAdapter.__attrs__ + [
+ 'pools', 'timeout', 'ssh_client',
+ ]
+
+ def __init__(self, base_url, timeout=60,
+ pool_connections=constants.DEFAULT_NUM_POOLS):
+ self.ssh_client = paramiko.SSHClient()
+ self.ssh_client.load_system_host_keys()
+
+ parsed = six.moves.urllib_parse.urlparse(base_url)
+ self.ssh_client.connect(
+ parsed.hostname, parsed.port, parsed.username,
+ )
+ self.timeout = timeout
+ self.pools = RecentlyUsedContainer(
+ pool_connections, dispose_func=lambda p: p.close()
+ )
+ super(SSHAdapter, self).__init__()
+
+ def get_connection(self, url, proxies=None):
+ with self.pools.lock:
+ pool = self.pools.get(url)
+ if pool:
+ return pool
+
+ pool = SSHConnectionPool(
+ self.ssh_client, self.timeout
+ )
+ self.pools[url] = pool
+
+ return pool
+
+ def close(self):
+ self.pools.clear()
+ self.ssh_client.close()
diff --git a/docker/types/containers.py b/docker/types/containers.py
index 9dfea8c..d040c0f 100644
--- a/docker/types/containers.py
+++ b/docker/types/containers.py
@@ -23,6 +23,36 @@ class LogConfigTypesEnum(object):
class LogConfig(DictType):
+ """
+ Configure logging for a container, when provided as an argument to
+ :py:meth:`~docker.api.container.ContainerApiMixin.create_host_config`.
+ You may refer to the
+ `official logging driver documentation <https://docs.docker.com/config/containers/logging/configure/>`_
+ for more information.
+
+ Args:
+ type (str): Indicate which log driver to use. A set of valid drivers
+ is provided as part of the :py:attr:`LogConfig.types`
+ enum. Other values may be accepted depending on the engine version
+ and available logging plugins.
+ config (dict): A driver-dependent configuration dictionary. Please
+ refer to the driver's documentation for a list of valid config
+ keys.
+
+ Example:
+
+ >>> from docker.types import LogConfig
+ >>> lc = LogConfig(type=LogConfig.types.JSON, config={
+ ... 'max-size': '1g',
+ ... 'labels': 'production_status,geo'
+ ... })
+ >>> hc = client.create_host_config(log_config=lc)
+ >>> container = client.create_container('busybox', 'true',
+ ... host_config=hc)
+ >>> client.inspect_container(container)['HostConfig']['LogConfig']
+ {'Type': 'json-file', 'Config': {'labels': 'production_status,geo', 'max-size': '1g'}}
+
+ """ # flake8: noqa
types = LogConfigTypesEnum
def __init__(self, **kwargs):
@@ -50,14 +80,40 @@ class LogConfig(DictType):
return self['Config']
def set_config_value(self, key, value):
+ """ Set a the value for ``key`` to ``value`` inside the ``config``
+ dict.
+ """
self.config[key] = value
def unset_config(self, key):
+ """ Remove the ``key`` property from the ``config`` dict. """
if key in self.config:
del self.config[key]
class Ulimit(DictType):
+ """
+ Create a ulimit declaration to be used with
+ :py:meth:`~docker.api.container.ContainerApiMixin.create_host_config`.
+
+ Args:
+
+ name (str): Which ulimit will this apply to. A list of valid names can
+ be found `here <http://tinyurl.me/ZWRkM2Ztwlykf>`_.
+ soft (int): The soft limit for this ulimit. Optional.
+ hard (int): The hard limit for this ulimit. Optional.
+
+ Example:
+
+ >>> nproc_limit = docker.types.Ulimit(name='nproc', soft=1024)
+ >>> hc = client.create_host_config(ulimits=[nproc_limit])
+ >>> container = client.create_container(
+ 'busybox', 'true', host_config=hc
+ )
+ >>> client.inspect_container(container)['HostConfig']['Ulimits']
+ [{'Name': 'nproc', 'Hard': 0, 'Soft': 1024}]
+
+ """
def __init__(self, **kwargs):
name = kwargs.get('name', kwargs.get('Name'))
soft = kwargs.get('soft', kwargs.get('Soft'))
diff --git a/docker/types/daemon.py b/docker/types/daemon.py
index ee8624e..700f9a9 100644
--- a/docker/types/daemon.py
+++ b/docker/types/daemon.py
@@ -5,6 +5,8 @@ try:
except ImportError:
import urllib3
+from ..errors import DockerException
+
class CancellableStream(object):
"""
@@ -55,9 +57,17 @@ class CancellableStream(object):
elif hasattr(sock_raw, '_sock'):
sock = sock_raw._sock
+ elif hasattr(sock_fp, 'channel'):
+ # We're working with a paramiko (SSH) channel, which doesn't
+ # support cancelable streams with the current implementation
+ raise DockerException(
+ 'Cancellable streams not supported for the SSH protocol'
+ )
else:
sock = sock_fp._sock
- if isinstance(sock, urllib3.contrib.pyopenssl.WrappedSocket):
+
+ if hasattr(urllib3.contrib, 'pyopenssl') and isinstance(
+ sock, urllib3.contrib.pyopenssl.WrappedSocket):
sock = sock.socket
sock.shutdown(socket.SHUT_RDWR)
diff --git a/docker/utils/utils.py b/docker/utils/utils.py
index fe3b9a5..61e307a 100644
--- a/docker/utils/utils.py
+++ b/docker/utils/utils.py
@@ -1,10 +1,11 @@
import base64
+import json
import os
import os.path
-import json
import shlex
-from distutils.version import StrictVersion
+import string
from datetime import datetime
+from distutils.version import StrictVersion
import six
@@ -13,11 +14,12 @@ from .. import tls
if six.PY2:
from urllib import splitnport
+ from urlparse import urlparse
else:
- from urllib.parse import splitnport
+ from urllib.parse import splitnport, urlparse
DEFAULT_HTTP_HOST = "127.0.0.1"
-DEFAULT_UNIX_SOCKET = "http+unix://var/run/docker.sock"
+DEFAULT_UNIX_SOCKET = "http+unix:///var/run/docker.sock"
DEFAULT_NPIPE = 'npipe:////./pipe/docker_engine'
BYTE_UNITS = {
@@ -212,75 +214,93 @@ def parse_repository_tag(repo_name):
return repo_name, None
-# Based on utils.go:ParseHost http://tinyurl.com/nkahcfh
-# fd:// protocol unsupported (for obvious reasons)
-# Added support for http and https
-# Protocol translation: tcp -> http, unix -> http+unix
def parse_host(addr, is_win32=False, tls=False):
- proto = "http+unix"
- port = None
path = ''
+ port = None
+ host = None
+ # Sensible defaults
if not addr and is_win32:
- addr = DEFAULT_NPIPE
-
+ return DEFAULT_NPIPE
if not addr or addr.strip() == 'unix://':
return DEFAULT_UNIX_SOCKET
addr = addr.strip()
- if addr.startswith('http://'):
- addr = addr.replace('http://', 'tcp://')
- if addr.startswith('http+unix://'):
- addr = addr.replace('http+unix://', 'unix://')
- if addr == 'tcp://':
+ parsed_url = urlparse(addr)
+ proto = parsed_url.scheme
+ if not proto or any([x not in string.ascii_letters + '+' for x in proto]):
+ # https://bugs.python.org/issue754016
+ parsed_url = urlparse('//' + addr, 'tcp')
+ proto = 'tcp'
+
+ if proto == 'fd':
+ raise errors.DockerException('fd protocol is not implemented')
+
+ # These protos are valid aliases for our library but not for the
+ # official spec
+ if proto == 'http' or proto == 'https':
+ tls = proto == 'https'
+ proto = 'tcp'
+ elif proto == 'http+unix':
+ proto = 'unix'
+
+ if proto not in ('tcp', 'unix', 'npipe', 'ssh'):
raise errors.DockerException(
- "Invalid bind address format: {0}".format(addr)
+ "Invalid bind address protocol: {}".format(addr)
)
- elif addr.startswith('unix://'):
- addr = addr[7:]
- elif addr.startswith('tcp://'):
- proto = 'http{0}'.format('s' if tls else '')
- addr = addr[6:]
- elif addr.startswith('https://'):
- proto = "https"
- addr = addr[8:]
- elif addr.startswith('npipe://'):
- proto = 'npipe'
- addr = addr[8:]
- elif addr.startswith('fd://'):
- raise errors.DockerException("fd protocol is not implemented")
- else:
- if "://" in addr:
- raise errors.DockerException(
- "Invalid bind address protocol: {0}".format(addr)
- )
- proto = "https" if tls else "http"
- if proto in ("http", "https"):
- address_parts = addr.split('/', 1)
- host = address_parts[0]
- if len(address_parts) == 2:
- path = '/' + address_parts[1]
- host, port = splitnport(host)
+ if proto == 'tcp' and not parsed_url.netloc:
+ # "tcp://" is exceptionally disallowed by convention;
+ # omitting a hostname for other protocols is fine
+ raise errors.DockerException(
+ 'Invalid bind address format: {}'.format(addr)
+ )
- if port is None:
- raise errors.DockerException(
- "Invalid port: {0}".format(addr)
- )
+ if any([
+ parsed_url.params, parsed_url.query, parsed_url.fragment,
+ parsed_url.password
+ ]):
+ raise errors.DockerException(
+ 'Invalid bind address format: {}'.format(addr)
+ )
+
+ if parsed_url.path and proto == 'ssh':
+ raise errors.DockerException(
+ 'Invalid bind address format: no path allowed for this protocol:'
+ ' {}'.format(addr)
+ )
+ else:
+ path = parsed_url.path
+ if proto == 'unix' and parsed_url.hostname is not None:
+ # For legacy reasons, we consider unix://path
+ # to be valid and equivalent to unix:///path
+ path = '/'.join((parsed_url.hostname, path))
+
+ if proto in ('tcp', 'ssh'):
+ # parsed_url.hostname strips brackets from IPv6 addresses,
+ # which can be problematic hence our use of splitnport() instead.
+ host, port = splitnport(parsed_url.netloc)
+ if port is None or port < 0:
+ if proto != 'ssh':
+ raise errors.DockerException(
+ 'Invalid bind address format: port is required:'
+ ' {}'.format(addr)
+ )
+ port = 22
if not host:
host = DEFAULT_HTTP_HOST
- else:
- host = addr
- if proto in ("http", "https") and port == -1:
- raise errors.DockerException(
- "Bind address needs a port: {0}".format(addr))
+ # Rewrite schemes to fit library internals (requests adapters)
+ if proto == 'tcp':
+ proto = 'http{}'.format('s' if tls else '')
+ elif proto == 'unix':
+ proto = 'http+unix'
- if proto == "http+unix" or proto == 'npipe':
- return "{0}://{1}".format(proto, host).rstrip('/')
- return "{0}://{1}:{2}{3}".format(proto, host, port, path).rstrip('/')
+ if proto in ('http+unix', 'npipe'):
+ return "{}://{}".format(proto, path).rstrip('/')
+ return '{0}://{1}:{2}{3}'.format(proto, host, port, path).rstrip('/')
def parse_devices(devices):
@@ -366,7 +386,10 @@ def convert_filters(filters):
v = 'true' if v else 'false'
if not isinstance(v, list):
v = [v, ]
- result[k] = v
+ result[k] = [
+ str(item) if not isinstance(item, six.string_types) else item
+ for item in v
+ ]
return json.dumps(result)
@@ -421,7 +444,7 @@ def normalize_links(links):
if isinstance(links, dict):
links = six.iteritems(links)
- return ['{0}:{1}'.format(k, v) for k, v in sorted(links)]
+ return ['{0}:{1}'.format(k, v) if v else k for k, v in sorted(links)]
def parse_env_file(env_file):
diff --git a/docker/version.py b/docker/version.py
index ef6b491..0b27a26 100644
--- a/docker/version.py
+++ b/docker/version.py
@@ -1,2 +1,2 @@
-version = "3.5.1"
+version = "3.6.0"
version_info = tuple([int(d) for d in version.split("-")[0].split(".")])
diff --git a/docs/api.rst b/docs/api.rst
index 6931245..1682128 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -140,6 +140,7 @@ Configuration types
.. autoclass:: Healthcheck
.. autoclass:: IPAMConfig
.. autoclass:: IPAMPool
+.. autoclass:: LogConfig
.. autoclass:: Mount
.. autoclass:: Placement
.. autoclass:: Privileges
@@ -151,4 +152,5 @@ Configuration types
.. autoclass:: SwarmExternalCA
.. autoclass:: SwarmSpec(*args, **kwargs)
.. autoclass:: TaskTemplate
+.. autoclass:: Ulimit
.. autoclass:: UpdateConfig
diff --git a/docs/change-log.md b/docs/change-log.md
index 750afb9..873db8c 100644
--- a/docs/change-log.md
+++ b/docs/change-log.md
@@ -1,6 +1,31 @@
Change log
==========
+3.6.0
+-----
+
+[List of PRs / issues for this release](https://github.com/docker/docker-py/milestone=55?closed=1)
+
+### Features
+
+* Added support for connecting to the Docker Engine over SSH. Additional
+ dependencies for this feature can be installed with
+ `pip install "docker[ssh]"`
+* Added support for the `named` parameter in `Image.save`, which may be
+ used to ensure the resulting tarball retains the image's name on save.
+
+### Bugfixes
+
+* Fixed a bug where builds on Windows with a context path using the `\\?\`
+ prefix would fail with some relative Dockerfile paths.
+* Fixed an issue where pulls made with the `DockerClient` would fail when
+ setting the `stream` parameter to `True`.
+
+### Miscellaneous
+
+* The minimum requirement for the `requests` dependency has been bumped
+ to 2.20.0
+
3.5.1
-----
diff --git a/requirements.txt b/requirements.txt
index c46a021..d13e9d6 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -9,12 +9,13 @@ enum34==1.1.6
idna==2.5
ipaddress==1.0.18
packaging==16.8
+paramiko==2.4.2
pycparser==2.17
pyOpenSSL==18.0.0
pyparsing==2.2.0
pypiwin32==219; sys_platform == 'win32' and python_version < '3.6'
pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6'
-requests==2.14.2
+requests==2.20.0
six==1.10.0
websocket-client==0.40.0
urllib3==1.21.1; python_version == '3.3' \ No newline at end of file
diff --git a/scripts/versions.py b/scripts/versions.py
index 77aaf4f..7ad1d56 100644
--- a/scripts/versions.py
+++ b/scripts/versions.py
@@ -11,23 +11,24 @@ categories = [
'test'
]
+STAGES = ['tp', 'beta', 'rc']
-class Version(namedtuple('_Version', 'major minor patch rc edition')):
+
+class Version(namedtuple('_Version', 'major minor patch stage edition')):
@classmethod
def parse(cls, version):
edition = None
version = version.lstrip('v')
- version, _, rc = version.partition('-')
- if rc:
- if 'rc' not in rc:
- edition = rc
- rc = None
- elif '-' in rc:
- edition, rc = rc.split('-')
-
+ version, _, stage = version.partition('-')
+ if stage:
+ if not any(marker in stage for marker in STAGES):
+ edition = stage
+ stage = None
+ elif '-' in stage:
+ edition, stage = stage.split('-')
major, minor, patch = version.split('.', 3)
- return cls(major, minor, patch, rc, edition)
+ return cls(major, minor, patch, stage, edition)
@property
def major_minor(self):
@@ -38,14 +39,22 @@ class Version(namedtuple('_Version', 'major minor patch rc edition')):
"""Return a representation that allows this object to be sorted
correctly with the default comparator.
"""
- # rc releases should appear before official releases
- rc = (0, self.rc) if self.rc else (1, )
- return (int(self.major), int(self.minor), int(self.patch)) + rc
+ # non-GA releases should appear before GA releases
+ # Order: tp -> beta -> rc -> GA
+ if self.stage:
+ for st in STAGES:
+ if st in self.stage:
+ stage = (STAGES.index(st), self.stage)
+ break
+ else:
+ stage = (len(STAGES),)
+
+ return (int(self.major), int(self.minor), int(self.patch)) + stage
def __str__(self):
- rc = '-{}'.format(self.rc) if self.rc else ''
+ stage = '-{}'.format(self.stage) if self.stage else ''
edition = '-{}'.format(self.edition) if self.edition else ''
- return '.'.join(map(str, self[:3])) + edition + rc
+ return '.'.join(map(str, self[:3])) + edition + stage
def main():
@@ -57,7 +66,7 @@ def main():
Version.parse(
v.strip('"').lstrip('docker-').rstrip('.tgz').rstrip('-x86_64')
) for v in re.findall(
- r'"docker-[0-9]+\.[0-9]+\.[0-9]+-.*tgz"', content
+ r'"docker-[0-9]+\.[0-9]+\.[0-9]+-?.*tgz"', content
)
]
sorted_versions = sorted(
diff --git a/setup.py b/setup.py
index 390783d..3ad572b 100644
--- a/setup.py
+++ b/setup.py
@@ -42,6 +42,9 @@ extras_require = {
# 'requests[security] >= 2.5.2, != 2.11.0, != 2.12.2'
'tls': ['pyOpenSSL>=17.5.0', 'cryptography>=1.3.4', 'idna>=2.0.0'],
+ # Only required when connecting using the ssh:// protocol
+ 'ssh': ['paramiko>=2.4.2'],
+
}
version = None
diff --git a/test-requirements.txt b/test-requirements.txt
index 9ad59cc..07e1a90 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -4,4 +4,4 @@ mock==1.0.1
pytest==2.9.1; python_version == '3.3'
pytest==3.6.3; python_version > '3.3'
pytest-cov==2.1.0
-pytest-timeout==1.2.1
+pytest-timeout==1.3.3
diff --git a/tests/helpers.py b/tests/helpers.py
index b36d6d7..f912bd8 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -10,6 +10,7 @@ import six
import socket
import docker
+import paramiko
import pytest
@@ -121,6 +122,9 @@ def assert_cat_socket_detached_with_keys(sock, inputs):
if getattr(sock, 'family', -9) == getattr(socket, 'AF_UNIX', -1):
with pytest.raises(socket.error):
sock.sendall(b'make sure the socket is closed\n')
+ elif isinstance(sock, paramiko.Channel):
+ with pytest.raises(OSError):
+ sock.sendall(b'make sure the socket is closed\n')
else:
sock.sendall(b"make sure the socket is closed\n")
data = sock.recv(128)
diff --git a/tests/integration/api_build_test.py b/tests/integration/api_build_test.py
index baaf33e..bad411b 100644
--- a/tests/integration/api_build_test.py
+++ b/tests/integration/api_build_test.py
@@ -540,6 +540,11 @@ class BuildTest(BaseAPIIntegrationTest):
) == sorted(lsdata)
@requires_api_version('1.31')
+ @pytest.mark.xfail(
+ True,
+ reason='Currently fails on 18.09: '
+ 'https://github.com/moby/moby/issues/37920'
+ )
def test_prune_builds(self):
prune_result = self.client.prune_builds()
assert 'SpaceReclaimed' in prune_result
diff --git a/tests/integration/api_container_test.py b/tests/integration/api_container_test.py
index 6ce846b..02f3603 100644
--- a/tests/integration/api_container_test.py
+++ b/tests/integration/api_container_test.py
@@ -883,6 +883,8 @@ Line2'''
assert logs == (snippet + '\n').encode(encoding='ascii')
@pytest.mark.timeout(5)
+ @pytest.mark.skipif(os.environ.get('DOCKER_HOST', '').startswith('ssh://'),
+ reason='No cancellable streams over SSH')
def test_logs_streaming_and_follow_and_cancel(self):
snippet = 'Flowering Nights (Sakuya Iyazoi)'
container = self.client.create_container(
@@ -1255,6 +1257,8 @@ class AttachContainerTest(BaseAPIIntegrationTest):
assert output == 'hello\n'.encode(encoding='ascii')
@pytest.mark.timeout(5)
+ @pytest.mark.skipif(os.environ.get('DOCKER_HOST', '').startswith('ssh://'),
+ reason='No cancellable streams over SSH')
def test_attach_stream_and_cancel(self):
container = self.client.create_container(
BUSYBOX, 'sh -c "echo hello && sleep 60"',
diff --git a/tests/integration/api_network_test.py b/tests/integration/api_network_test.py
index b6726d0..db37cbd 100644
--- a/tests/integration/api_network_test.py
+++ b/tests/integration/api_network_test.py
@@ -8,8 +8,8 @@ from .base import BaseAPIIntegrationTest, BUSYBOX
class TestNetworks(BaseAPIIntegrationTest):
def tearDown(self):
- super(TestNetworks, self).tearDown()
self.client.leave_swarm(force=True)
+ super(TestNetworks, self).tearDown()
def create_network(self, *args, **kwargs):
net_name = random_name()
diff --git a/tests/integration/api_plugin_test.py b/tests/integration/api_plugin_test.py
index 1150b09..38f9d12 100644
--- a/tests/integration/api_plugin_test.py
+++ b/tests/integration/api_plugin_test.py
@@ -3,7 +3,7 @@ import os
import docker
import pytest
-from .base import BaseAPIIntegrationTest, TEST_API_VERSION
+from .base import BaseAPIIntegrationTest
from ..helpers import requires_api_version
SSHFS = 'vieux/sshfs:latest'
@@ -13,27 +13,27 @@ SSHFS = 'vieux/sshfs:latest'
class PluginTest(BaseAPIIntegrationTest):
@classmethod
def teardown_class(cls):
- c = docker.APIClient(
- version=TEST_API_VERSION, timeout=60,
- **docker.utils.kwargs_from_env()
- )
+ client = cls.get_client_instance()
try:
- c.remove_plugin(SSHFS, force=True)
+ client.remove_plugin(SSHFS, force=True)
except docker.errors.APIError:
pass
def teardown_method(self, method):
+ client = self.get_client_instance()
try:
- self.client.disable_plugin(SSHFS)
+ client.disable_plugin(SSHFS)
except docker.errors.APIError:
pass
for p in self.tmp_plugins:
try:
- self.client.remove_plugin(p, force=True)
+ client.remove_plugin(p, force=True)
except docker.errors.APIError:
pass
+ client.close()
+
def ensure_plugin_installed(self, plugin_name):
try:
return self.client.inspect_plugin(plugin_name)
diff --git a/tests/integration/api_swarm_test.py b/tests/integration/api_swarm_test.py
index dbf3786..b58dabc 100644
--- a/tests/integration/api_swarm_test.py
+++ b/tests/integration/api_swarm_test.py
@@ -13,14 +13,13 @@ class SwarmTest(BaseAPIIntegrationTest):
self._unlock_key = None
def tearDown(self):
- super(SwarmTest, self).tearDown()
try:
if self._unlock_key:
self.client.unlock_swarm(self._unlock_key)
except docker.errors.APIError:
pass
-
force_leave_swarm(self.client)
+ super(SwarmTest, self).tearDown()
@requires_api_version('1.24')
def test_init_swarm_simple(self):
diff --git a/tests/integration/base.py b/tests/integration/base.py
index 56c23ed..262769d 100644
--- a/tests/integration/base.py
+++ b/tests/integration/base.py
@@ -29,41 +29,44 @@ class BaseIntegrationTest(unittest.TestCase):
def tearDown(self):
client = docker.from_env(version=TEST_API_VERSION)
- for img in self.tmp_imgs:
- try:
- client.api.remove_image(img)
- except docker.errors.APIError:
- pass
- for container in self.tmp_containers:
- try:
- client.api.remove_container(container, force=True, v=True)
- except docker.errors.APIError:
- pass
- for network in self.tmp_networks:
- try:
- client.api.remove_network(network)
- except docker.errors.APIError:
- pass
- for volume in self.tmp_volumes:
- try:
- client.api.remove_volume(volume)
- except docker.errors.APIError:
- pass
-
- for secret in self.tmp_secrets:
- try:
- client.api.remove_secret(secret)
- except docker.errors.APIError:
- pass
-
- for config in self.tmp_configs:
- try:
- client.api.remove_config(config)
- except docker.errors.APIError:
- pass
-
- for folder in self.tmp_folders:
- shutil.rmtree(folder)
+ try:
+ for img in self.tmp_imgs:
+ try:
+ client.api.remove_image(img)
+ except docker.errors.APIError:
+ pass
+ for container in self.tmp_containers:
+ try:
+ client.api.remove_container(container, force=True, v=True)
+ except docker.errors.APIError:
+ pass
+ for network in self.tmp_networks:
+ try:
+ client.api.remove_network(network)
+ except docker.errors.APIError:
+ pass
+ for volume in self.tmp_volumes:
+ try:
+ client.api.remove_volume(volume)
+ except docker.errors.APIError:
+ pass
+
+ for secret in self.tmp_secrets:
+ try:
+ client.api.remove_secret(secret)
+ except docker.errors.APIError:
+ pass
+
+ for config in self.tmp_configs:
+ try:
+ client.api.remove_config(config)
+ except docker.errors.APIError:
+ pass
+
+ for folder in self.tmp_folders:
+ shutil.rmtree(folder)
+ finally:
+ client.close()
class BaseAPIIntegrationTest(BaseIntegrationTest):
diff --git a/tests/integration/models_containers_test.py b/tests/integration/models_containers_test.py
index ab41ea5..b48f6fb 100644
--- a/tests/integration/models_containers_test.py
+++ b/tests/integration/models_containers_test.py
@@ -1,3 +1,4 @@
+import os
import tempfile
import threading
@@ -146,6 +147,8 @@ class ContainerCollectionTest(BaseIntegrationTest):
assert logs[1] == b'world\n'
@pytest.mark.timeout(5)
+ @pytest.mark.skipif(os.environ.get('DOCKER_HOST', '').startswith('ssh://'),
+ reason='No cancellable streams over SSH')
def test_run_with_streamed_logs_and_cancel(self):
client = docker.from_env(version=TEST_API_VERSION)
out = client.containers.run(
diff --git a/tests/integration/models_images_test.py b/tests/integration/models_images_test.py
index ae735ba..31fab10 100644
--- a/tests/integration/models_images_test.py
+++ b/tests/integration/models_images_test.py
@@ -5,6 +5,7 @@ import docker
import pytest
from .base import BaseIntegrationTest, BUSYBOX, TEST_API_VERSION
+from ..helpers import random_name
class ImageCollectionTest(BaseIntegrationTest):
@@ -108,6 +109,32 @@ class ImageCollectionTest(BaseIntegrationTest):
assert len(result) == 1
assert result[0].id == image.id
+ def test_save_and_load_repo_name(self):
+ client = docker.from_env(version=TEST_API_VERSION)
+ image = client.images.get(BUSYBOX)
+ additional_tag = random_name()
+ image.tag(additional_tag)
+ self.tmp_imgs.append(additional_tag)
+ image.reload()
+ with tempfile.TemporaryFile() as f:
+ stream = image.save(named='{}:latest'.format(additional_tag))
+ for chunk in stream:
+ f.write(chunk)
+
+ f.seek(0)
+ client.images.remove(additional_tag, force=True)
+ result = client.images.load(f.read())
+
+ assert len(result) == 1
+ assert result[0].id == image.id
+ assert '{}:latest'.format(additional_tag) in result[0].tags
+
+ def test_save_name_error(self):
+ client = docker.from_env(version=TEST_API_VERSION)
+ image = client.images.get(BUSYBOX)
+ with pytest.raises(docker.errors.InvalidArgument):
+ image.save(named='sakuya/izayoi')
+
class ImageTest(BaseIntegrationTest):
diff --git a/tests/unit/api_build_test.py b/tests/unit/api_build_test.py
index a7f34fd..59470ca 100644
--- a/tests/unit/api_build_test.py
+++ b/tests/unit/api_build_test.py
@@ -1,12 +1,16 @@
import gzip
import io
+import shutil
import docker
from docker import auth
+from docker.api.build import process_dockerfile
-from .api_test import BaseAPIClientTest, fake_request, url_prefix
import pytest
+from ..helpers import make_tree
+from .api_test import BaseAPIClientTest, fake_request, url_prefix
+
class BuildTest(BaseAPIClientTest):
def test_build_container(self):
@@ -161,3 +165,61 @@ class BuildTest(BaseAPIClientTest):
self.client._set_auth_headers(headers)
assert headers == expected_headers
+
+ @pytest.mark.skipif(
+ not docker.constants.IS_WINDOWS_PLATFORM,
+ reason='Windows-specific syntax')
+ def test_process_dockerfile_win_longpath_prefix(self):
+ dirs = [
+ 'foo', 'foo/bar', 'baz',
+ ]
+
+ files = [
+ 'Dockerfile', 'foo/Dockerfile.foo', 'foo/bar/Dockerfile.bar',
+ 'baz/Dockerfile.baz',
+ ]
+
+ base = make_tree(dirs, files)
+ self.addCleanup(shutil.rmtree, base)
+
+ def pre(path):
+ return docker.constants.WINDOWS_LONGPATH_PREFIX + path
+
+ assert process_dockerfile(None, pre(base)) == (None, None)
+ assert process_dockerfile('Dockerfile', pre(base)) == (
+ 'Dockerfile', None
+ )
+ assert process_dockerfile('foo/Dockerfile.foo', pre(base)) == (
+ 'foo/Dockerfile.foo', None
+ )
+ assert process_dockerfile(
+ '../Dockerfile', pre(base + '\\foo')
+ )[1] is not None
+ assert process_dockerfile(
+ '../baz/Dockerfile.baz', pre(base + '/baz')
+ ) == ('../baz/Dockerfile.baz', None)
+
+ def test_process_dockerfile(self):
+ dirs = [
+ 'foo', 'foo/bar', 'baz',
+ ]
+
+ files = [
+ 'Dockerfile', 'foo/Dockerfile.foo', 'foo/bar/Dockerfile.bar',
+ 'baz/Dockerfile.baz',
+ ]
+
+ base = make_tree(dirs, files)
+ self.addCleanup(shutil.rmtree, base)
+
+ assert process_dockerfile(None, base) == (None, None)
+ assert process_dockerfile('Dockerfile', base) == ('Dockerfile', None)
+ assert process_dockerfile('foo/Dockerfile.foo', base) == (
+ 'foo/Dockerfile.foo', None
+ )
+ assert process_dockerfile(
+ '../Dockerfile', base + '/foo'
+ )[1] is not None
+ assert process_dockerfile('../baz/Dockerfile.baz', base + '/baz') == (
+ '../baz/Dockerfile.baz', None
+ )
diff --git a/tests/unit/models_containers_test.py b/tests/unit/models_containers_test.py
index 22dd241..39e409e 100644
--- a/tests/unit/models_containers_test.py
+++ b/tests/unit/models_containers_test.py
@@ -232,7 +232,9 @@ class ContainerCollectionTest(unittest.TestCase):
container = client.containers.run('alpine', 'sleep 300', detach=True)
assert container.id == FAKE_CONTAINER_ID
- client.api.pull.assert_called_with('alpine', platform=None, tag=None)
+ client.api.pull.assert_called_with(
+ 'alpine', platform=None, tag=None, stream=True
+ )
def test_run_with_error(self):
client = make_fake_client()
diff --git a/tests/unit/models_images_test.py b/tests/unit/models_images_test.py
index 6783279..fd894ab 100644
--- a/tests/unit/models_images_test.py
+++ b/tests/unit/models_images_test.py
@@ -1,6 +1,8 @@
+import unittest
+import warnings
+
from docker.constants import DEFAULT_DATA_CHUNK_SIZE
from docker.models.images import Image
-import unittest
from .fake_api import FAKE_IMAGE_ID
from .fake_api_client import make_fake_client
@@ -43,7 +45,9 @@ class ImageCollectionTest(unittest.TestCase):
def test_pull(self):
client = make_fake_client()
image = client.images.pull('test_image:latest')
- client.api.pull.assert_called_with('test_image', tag='latest')
+ client.api.pull.assert_called_with(
+ 'test_image', tag='latest', stream=True
+ )
client.api.inspect_image.assert_called_with('test_image:latest')
assert isinstance(image, Image)
assert image.id == FAKE_IMAGE_ID
@@ -51,7 +55,9 @@ class ImageCollectionTest(unittest.TestCase):
def test_pull_multiple(self):
client = make_fake_client()
images = client.images.pull('test_image')
- client.api.pull.assert_called_with('test_image', tag=None)
+ client.api.pull.assert_called_with(
+ 'test_image', tag=None, stream=True
+ )
client.api.images.assert_called_with(
all=False, name='test_image', filters=None
)
@@ -61,6 +67,16 @@ class ImageCollectionTest(unittest.TestCase):
assert isinstance(image, Image)
assert image.id == FAKE_IMAGE_ID
+ def test_pull_with_stream_param(self):
+ client = make_fake_client()
+ with warnings.catch_warnings(record=True) as w:
+ client.images.pull('test_image', stream=True)
+
+ assert len(w) == 1
+ assert str(w[0].message).startswith(
+ '`stream` is not a valid parameter'
+ )
+
def test_push(self):
client = make_fake_client()
client.images.push('foobar', insecure_registry=True)
diff --git a/tests/unit/utils_config_test.py b/tests/unit/utils_config_test.py
index 50ba383..b0934f9 100644
--- a/tests/unit/utils_config_test.py
+++ b/tests/unit/utils_config_test.py
@@ -4,8 +4,8 @@ import shutil
import tempfile
import json
-from py.test import ensuretemp
-from pytest import mark
+from pytest import mark, fixture
+
from docker.utils import config
try:
@@ -15,25 +15,25 @@ except ImportError:
class FindConfigFileTest(unittest.TestCase):
- def tmpdir(self, name):
- tmpdir = ensuretemp(name)
- self.addCleanup(tmpdir.remove)
- return tmpdir
+
+ @fixture(autouse=True)
+ def tmpdir(self, tmpdir):
+ self.mkdir = tmpdir.mkdir
def test_find_config_fallback(self):
- tmpdir = self.tmpdir('test_find_config_fallback')
+ tmpdir = self.mkdir('test_find_config_fallback')
with mock.patch.dict(os.environ, {'HOME': str(tmpdir)}):
assert config.find_config_file() is None
def test_find_config_from_explicit_path(self):
- tmpdir = self.tmpdir('test_find_config_from_explicit_path')
+ tmpdir = self.mkdir('test_find_config_from_explicit_path')
config_path = tmpdir.ensure('my-config-file.json')
assert config.find_config_file(str(config_path)) == str(config_path)
def test_find_config_from_environment(self):
- tmpdir = self.tmpdir('test_find_config_from_environment')
+ tmpdir = self.mkdir('test_find_config_from_environment')
config_path = tmpdir.ensure('config.json')
with mock.patch.dict(os.environ, {'DOCKER_CONFIG': str(tmpdir)}):
@@ -41,7 +41,7 @@ class FindConfigFileTest(unittest.TestCase):
@mark.skipif("sys.platform == 'win32'")
def test_find_config_from_home_posix(self):
- tmpdir = self.tmpdir('test_find_config_from_home_posix')
+ tmpdir = self.mkdir('test_find_config_from_home_posix')
config_path = tmpdir.ensure('.docker', 'config.json')
with mock.patch.dict(os.environ, {'HOME': str(tmpdir)}):
@@ -49,7 +49,7 @@ class FindConfigFileTest(unittest.TestCase):
@mark.skipif("sys.platform == 'win32'")
def test_find_config_from_home_legacy_name(self):
- tmpdir = self.tmpdir('test_find_config_from_home_legacy_name')
+ tmpdir = self.mkdir('test_find_config_from_home_legacy_name')
config_path = tmpdir.ensure('.dockercfg')
with mock.patch.dict(os.environ, {'HOME': str(tmpdir)}):
@@ -57,7 +57,7 @@ class FindConfigFileTest(unittest.TestCase):
@mark.skipif("sys.platform != 'win32'")
def test_find_config_from_home_windows(self):
- tmpdir = self.tmpdir('test_find_config_from_home_windows')
+ tmpdir = self.mkdir('test_find_config_from_home_windows')
config_path = tmpdir.ensure('.docker', 'config.json')
with mock.patch.dict(os.environ, {'USERPROFILE': str(tmpdir)}):
diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py
index 8880cfe..a4e9c9c 100644
--- a/tests/unit/utils_test.py
+++ b/tests/unit/utils_test.py
@@ -272,6 +272,11 @@ class ParseHostTest(unittest.TestCase):
'tcp://',
'udp://127.0.0.1',
'udp://127.0.0.1:2375',
+ 'ssh://:22/path',
+ 'tcp://netloc:3333/path?q=1',
+ 'unix:///sock/path#fragment',
+ 'https://netloc:3333/path;params',
+ 'ssh://:clearpassword@host:22',
]
valid_hosts = {
@@ -281,7 +286,7 @@ class ParseHostTest(unittest.TestCase):
'http://:7777': 'http://127.0.0.1:7777',
'https://kokia.jp:2375': 'https://kokia.jp:2375',
'unix:///var/run/docker.sock': 'http+unix:///var/run/docker.sock',
- 'unix://': 'http+unix://var/run/docker.sock',
+ 'unix://': 'http+unix:///var/run/docker.sock',
'12.234.45.127:2375/docker/engine': (
'http://12.234.45.127:2375/docker/engine'
),
@@ -294,6 +299,9 @@ class ParseHostTest(unittest.TestCase):
'[fd12::82d1]:2375/docker/engine': (
'http://[fd12::82d1]:2375/docker/engine'
),
+ 'ssh://': 'ssh://127.0.0.1:22',
+ 'ssh://user@localhost:22': 'ssh://user@localhost:22',
+ 'ssh://user@remote': 'ssh://user@remote:22',
}
for host in invalid_hosts:
@@ -304,7 +312,7 @@ class ParseHostTest(unittest.TestCase):
assert parse_host(host, None) == expected
def test_parse_host_empty_value(self):
- unix_socket = 'http+unix://var/run/docker.sock'
+ unix_socket = 'http+unix:///var/run/docker.sock'
npipe = 'npipe:////./pipe/docker_engine'
for val in [None, '']:
@@ -449,8 +457,8 @@ class UtilsTest(unittest.TestCase):
tests = [
({'dangling': True}, '{"dangling": ["true"]}'),
({'dangling': "true"}, '{"dangling": ["true"]}'),
- ({'exited': 0}, '{"exited": [0]}'),
- ({'exited': [0, 1]}, '{"exited": [0, 1]}'),
+ ({'exited': 0}, '{"exited": ["0"]}'),
+ ({'exited': [0, 1]}, '{"exited": ["0", "1"]}'),
]
for filters, expected in tests: