diff options
| author | Joffrey F <f.joffrey@gmail.com> | 2018-11-28 12:11:38 -0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2018-11-28 12:11:38 -0800 |
| commit | d74bfa69ab1b44e0a745efe15e4b771d10c9cd54 (patch) | |
| tree | 100a9a7232379cc6eb3f5c48c6e1935c3ce0dc8f | |
| parent | 7cc0a1b6eb157e4a740099f66b98d9cbb40e71ad (diff) | |
| parent | 24ed2f356bd522f413a5e01765f63f2da3975f31 (diff) | |
| download | docker-py-d74bfa69ab1b44e0a745efe15e4b771d10c9cd54.tar.gz | |
Merge pull request #2187 from docker/3.6.0-release3.6.0
3.6.0 release
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( @@ -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: |
