Просмотр исходного кода

Merge pull request #3095 from dnephin/refactor_command_dispatch

Refactor command dispatch and fix api version mismatch error
Aanand Prasad 10 лет назад
Родитель
Сommit
e5cd869c61

+ 4 - 31
compose/cli/command.py

@@ -1,52 +1,25 @@
 from __future__ import absolute_import
 from __future__ import unicode_literals
 
-import contextlib
 import logging
 import os
 import re
 
 import six
-from requests.exceptions import ConnectionError
-from requests.exceptions import SSLError
 
-from . import errors
 from . import verbose_proxy
 from .. import config
 from ..const import API_VERSIONS
 from ..project import Project
 from .docker_client import docker_client
-from .utils import call_silently
 from .utils import get_version_info
-from .utils import is_mac
-from .utils import is_ubuntu
 
 log = logging.getLogger(__name__)
 
 
[email protected]
-def friendly_error_message():
-    try:
-        yield
-    except SSLError as e:
-        raise errors.UserError('SSL error: %s' % e)
-    except ConnectionError:
-        if call_silently(['which', 'docker']) != 0:
-            if is_mac():
-                raise errors.DockerNotFoundMac()
-            elif is_ubuntu():
-                raise errors.DockerNotFoundUbuntu()
-            else:
-                raise errors.DockerNotFoundGeneric()
-        elif call_silently(['which', 'docker-machine']) == 0:
-            raise errors.ConnectionErrorDockerMachine()
-        else:
-            raise errors.ConnectionErrorGeneric(get_client().base_url)
-
-
-def project_from_options(base_dir, options):
+def project_from_options(project_dir, options):
     return get_project(
-        base_dir,
+        project_dir,
         get_config_path_from_options(options),
         project_name=options.get('--project-name'),
         verbose=options.get('--verbose'),
@@ -76,8 +49,8 @@ def get_client(verbose=False, version=None):
     return client
 
 
-def get_project(base_dir, config_path=None, project_name=None, verbose=False):
-    config_details = config.find(base_dir, config_path)
+def get_project(project_dir, config_path=None, project_name=None, verbose=False):
+    config_details = config.find(project_dir, config_path)
     project_name = get_project_name(config_details.working_dir, project_name)
     config_data = config.load(config_details)
 

+ 18 - 21
compose/cli/docopt_command.py

@@ -1,7 +1,6 @@
 from __future__ import absolute_import
 from __future__ import unicode_literals
 
-import sys
 from inspect import getdoc
 
 from docopt import docopt
@@ -15,24 +14,21 @@ def docopt_full_help(docstring, *args, **kwargs):
         raise SystemExit(docstring)
 
 
-class DocoptCommand(object):
-    def docopt_options(self):
-        return {'options_first': True}
+class DocoptDispatcher(object):
 
-    def sys_dispatch(self):
-        self.dispatch(sys.argv[1:])
-
-    def dispatch(self, argv):
-        self.perform_command(*self.parse(argv))
+    def __init__(self, command_class, options):
+        self.command_class = command_class
+        self.options = options
 
     def parse(self, argv):
-        options = docopt_full_help(getdoc(self), argv, **self.docopt_options())
+        command_help = getdoc(self.command_class)
+        options = docopt_full_help(command_help, argv, **self.options)
         command = options['COMMAND']
 
         if command is None:
-            raise SystemExit(getdoc(self))
+            raise SystemExit(command_help)
 
-        handler = self.get_handler(command)
+        handler = get_handler(self.command_class, command)
         docstring = getdoc(handler)
 
         if docstring is None:
@@ -41,17 +37,18 @@ class DocoptCommand(object):
         command_options = docopt_full_help(docstring, options['ARGS'], options_first=True)
         return options, handler, command_options
 
-    def get_handler(self, command):
-        command = command.replace('-', '_')
-        # we certainly want to have "exec" command, since that's what docker client has
-        # but in python exec is a keyword
-        if command == "exec":
-            command = "exec_command"
 
-        if not hasattr(self, command):
-            raise NoSuchCommand(command, self)
+def get_handler(command_class, command):
+    command = command.replace('-', '_')
+    # we certainly want to have "exec" command, since that's what docker client has
+    # but in python exec is a keyword
+    if command == "exec":
+        command = "exec_command"
+
+    if not hasattr(command_class, command):
+        raise NoSuchCommand(command, command_class)
 
-        return getattr(self, command)
+    return getattr(command_class, command)
 
 
 class NoSuchCommand(Exception):

+ 92 - 29
compose/cli/errors.py

@@ -1,10 +1,27 @@
 from __future__ import absolute_import
 from __future__ import unicode_literals
 
+import contextlib
+import logging
 from textwrap import dedent
 
+from docker.errors import APIError
+from requests.exceptions import ConnectionError as RequestsConnectionError
+from requests.exceptions import ReadTimeout
+from requests.exceptions import SSLError
+
+from ..const import API_VERSION_TO_ENGINE_VERSION
+from ..const import HTTP_TIMEOUT
+from .utils import call_silently
+from .utils import is_mac
+from .utils import is_ubuntu
+
+
+log = logging.getLogger(__name__)
+
 
 class UserError(Exception):
+
     def __init__(self, msg):
         self.msg = dedent(msg).strip()
 
@@ -14,44 +31,90 @@ class UserError(Exception):
     __str__ = __unicode__
 
 
-class DockerNotFoundMac(UserError):
-    def __init__(self):
-        super(DockerNotFoundMac, self).__init__("""
-        Couldn't connect to Docker daemon. You might need to install docker-osx:
+class ConnectionError(Exception):
+    pass
+
+
[email protected]
+def handle_connection_errors(client):
+    try:
+        yield
+    except SSLError as e:
+        log.error('SSL error: %s' % e)
+        raise ConnectionError()
+    except RequestsConnectionError:
+        if call_silently(['which', 'docker']) != 0:
+            if is_mac():
+                exit_with_error(docker_not_found_mac)
+            if is_ubuntu():
+                exit_with_error(docker_not_found_ubuntu)
+            exit_with_error(docker_not_found_generic)
+        if call_silently(['which', 'docker-machine']) == 0:
+            exit_with_error(conn_error_docker_machine)
+        exit_with_error(conn_error_generic.format(url=client.base_url))
+    except APIError as e:
+        log_api_error(e, client.api_version)
+        raise ConnectionError()
+    except ReadTimeout as e:
+        log.error(
+            "An HTTP request took too long to complete. Retry with --verbose to "
+            "obtain debug information.\n"
+            "If you encounter this issue regularly because of slow network "
+            "conditions, consider setting COMPOSE_HTTP_TIMEOUT to a higher "
+            "value (current value: %s)." % HTTP_TIMEOUT)
+        raise ConnectionError()
+
+
+def log_api_error(e, client_version):
+    if 'client is newer than server' not in e.explanation:
+        log.error(e.explanation)
+        return
+
+    version = API_VERSION_TO_ENGINE_VERSION.get(client_version)
+    if not version:
+        # They've set a custom API version
+        log.error(e.explanation)
+        return
+
+    log.error(
+        "The Docker Engine version is less than the minimum required by "
+        "Compose. Your current project requires a Docker Engine of "
+        "version {version} or greater.".format(version=version))
+
+
+def exit_with_error(msg):
+    log.error(dedent(msg).strip())
+    raise ConnectionError()
+
+
+docker_not_found_mac = """
+    Couldn't connect to Docker daemon. You might need to install docker-osx:
 
-        https://github.com/noplay/docker-osx
-        """)
+    https://github.com/noplay/docker-osx
+"""
 
 
-class DockerNotFoundUbuntu(UserError):
-    def __init__(self):
-        super(DockerNotFoundUbuntu, self).__init__("""
-        Couldn't connect to Docker daemon. You might need to install Docker:
+docker_not_found_ubuntu = """
+    Couldn't connect to Docker daemon. You might need to install Docker:
 
-        https://docs.docker.com/engine/installation/ubuntulinux/
-        """)
+    https://docs.docker.com/engine/installation/ubuntulinux/
+"""
 
 
-class DockerNotFoundGeneric(UserError):
-    def __init__(self):
-        super(DockerNotFoundGeneric, self).__init__("""
-        Couldn't connect to Docker daemon. You might need to install Docker:
+docker_not_found_generic = """
+    Couldn't connect to Docker daemon. You might need to install Docker:
 
-        https://docs.docker.com/engine/installation/
-        """)
+    https://docs.docker.com/engine/installation/
+"""
 
 
-class ConnectionErrorDockerMachine(UserError):
-    def __init__(self):
-        super(ConnectionErrorDockerMachine, self).__init__("""
-        Couldn't connect to Docker daemon - you might need to run `docker-machine start default`.
-        """)
+conn_error_docker_machine = """
+    Couldn't connect to Docker daemon - you might need to run `docker-machine start default`.
+"""
 
 
-class ConnectionErrorGeneric(UserError):
-    def __init__(self, url):
-        super(ConnectionErrorGeneric, self).__init__("""
-        Couldn't connect to Docker daemon at %s - is it running?
+conn_error_generic = """
+    Couldn't connect to Docker daemon at {url} - is it running?
 
-        If it's at a non-standard location, specify the URL with the DOCKER_HOST environment variable.
-        """ % url)
+    If it's at a non-standard location, specify the URL with the DOCKER_HOST environment variable.
+"""

+ 96 - 111
compose/cli/main.py

@@ -3,6 +3,7 @@ from __future__ import print_function
 from __future__ import unicode_literals
 
 import contextlib
+import functools
 import json
 import logging
 import re
@@ -10,18 +11,14 @@ import sys
 from inspect import getdoc
 from operator import attrgetter
 
-from docker.errors import APIError
-from requests.exceptions import ReadTimeout
-
+from . import errors
 from . import signals
 from .. import __version__
 from ..config import config
 from ..config import ConfigurationError
 from ..config import parse_environment
 from ..config.serialize import serialize_config
-from ..const import API_VERSION_TO_ENGINE_VERSION
 from ..const import DEFAULT_TIMEOUT
-from ..const import HTTP_TIMEOUT
 from ..const import IS_WINDOWS_PLATFORM
 from ..progress_stream import StreamOutputError
 from ..project import NoSuchService
@@ -30,10 +27,10 @@ from ..service import BuildError
 from ..service import ConvergenceStrategy
 from ..service import ImageType
 from ..service import NeedsBuildError
-from .command import friendly_error_message
 from .command import get_config_path_from_options
 from .command import project_from_options
-from .docopt_command import DocoptCommand
+from .docopt_command import DocoptDispatcher
+from .docopt_command import get_handler
 from .docopt_command import NoSuchCommand
 from .errors import UserError
 from .formatter import ConsoleWarningFormatter
@@ -51,23 +48,16 @@ console_handler = logging.StreamHandler(sys.stderr)
 
 
 def main():
-    setup_logging()
+    command = dispatch()
+
     try:
-        command = TopLevelCommand()
-        command.sys_dispatch()
+        command()
     except (KeyboardInterrupt, signals.ShutdownException):
         log.error("Aborting.")
         sys.exit(1)
     except (UserError, NoSuchService, ConfigurationError) as e:
         log.error(e.msg)
         sys.exit(1)
-    except NoSuchCommand as e:
-        commands = "\n".join(parse_doc_section("commands:", getdoc(e.supercommand)))
-        log.error("No such command: %s\n\n%s", e.command, commands)
-        sys.exit(1)
-    except APIError as e:
-        log_api_error(e)
-        sys.exit(1)
     except BuildError as e:
         log.error("Service '%s' failed to build: %s" % (e.service.name, e.reason))
         sys.exit(1)
@@ -77,31 +67,42 @@ def main():
     except NeedsBuildError as e:
         log.error("Service '%s' needs to be built, but --no-build was passed." % e.service.name)
         sys.exit(1)
-    except ReadTimeout as e:
-        log.error(
-            "An HTTP request took too long to complete. Retry with --verbose to "
-            "obtain debug information.\n"
-            "If you encounter this issue regularly because of slow network "
-            "conditions, consider setting COMPOSE_HTTP_TIMEOUT to a higher "
-            "value (current value: %s)." % HTTP_TIMEOUT
-        )
+    except errors.ConnectionError:
         sys.exit(1)
 
 
-def log_api_error(e):
-    if 'client is newer than server' in e.explanation:
-        # we need JSON formatted errors. In the meantime...
-        # TODO: fix this by refactoring project dispatch
-        # http://github.com/docker/compose/pull/2832#commitcomment-15923800
-        client_version = e.explanation.split('client API version: ')[1].split(',')[0]
-        log.error(
-            "The engine version is lesser than the minimum required by "
-            "compose. Your current project requires a Docker Engine of "
-            "version {version} or superior.".format(
-                version=API_VERSION_TO_ENGINE_VERSION[client_version]
-            ))
-    else:
-        log.error(e.explanation)
+def dispatch():
+    setup_logging()
+    dispatcher = DocoptDispatcher(
+        TopLevelCommand,
+        {'options_first': True, 'version': get_version_info('compose')})
+
+    try:
+        options, handler, command_options = dispatcher.parse(sys.argv[1:])
+    except NoSuchCommand as e:
+        commands = "\n".join(parse_doc_section("commands:", getdoc(e.supercommand)))
+        log.error("No such command: %s\n\n%s", e.command, commands)
+        sys.exit(1)
+
+    setup_console_handler(console_handler, options.get('--verbose'))
+    return functools.partial(perform_command, options, handler, command_options)
+
+
+def perform_command(options, handler, command_options):
+    if options['COMMAND'] in ('help', 'version'):
+        # Skip looking up the compose file.
+        handler(command_options)
+        return
+
+    if options['COMMAND'] == 'config':
+        command = TopLevelCommand(None)
+        handler(command, options, command_options)
+        return
+
+    project = project_from_options('.', options)
+    command = TopLevelCommand(project)
+    with errors.handle_connection_errors(project.client):
+        handler(command, command_options)
 
 
 def setup_logging():
@@ -134,7 +135,7 @@ def parse_doc_section(name, source):
     return [s.strip() for s in pattern.findall(source)]
 
 
-class TopLevelCommand(DocoptCommand):
+class TopLevelCommand(object):
     """Define and run multi-container applications with Docker.
 
     Usage:
@@ -171,30 +172,12 @@ class TopLevelCommand(DocoptCommand):
       up                 Create and start containers
       version            Show the Docker-Compose version information
     """
-    base_dir = '.'
 
-    def docopt_options(self):
-        options = super(TopLevelCommand, self).docopt_options()
-        options['version'] = get_version_info('compose')
-        return options
+    def __init__(self, project, project_dir='.'):
+        self.project = project
+        self.project_dir = '.'
 
-    def perform_command(self, options, handler, command_options):
-        setup_console_handler(console_handler, options.get('--verbose'))
-
-        if options['COMMAND'] in ('help', 'version'):
-            # Skip looking up the compose file.
-            handler(None, command_options)
-            return
-
-        if options['COMMAND'] == 'config':
-            handler(options, command_options)
-            return
-
-        project = project_from_options(self.base_dir, options)
-        with friendly_error_message():
-            handler(project, command_options)
-
-    def build(self, project, options):
+    def build(self, options):
         """
         Build or rebuild services.
 
@@ -209,7 +192,7 @@ class TopLevelCommand(DocoptCommand):
             --no-cache  Do not use cache when building the image.
             --pull      Always attempt to pull a newer version of the image.
         """
-        project.build(
+        self.project.build(
             service_names=options['SERVICE'],
             no_cache=bool(options.get('--no-cache', False)),
             pull=bool(options.get('--pull', False)),
@@ -228,7 +211,7 @@ class TopLevelCommand(DocoptCommand):
 
         """
         config_path = get_config_path_from_options(config_options)
-        compose_config = config.load(config.find(self.base_dir, config_path))
+        compose_config = config.load(config.find(self.project_dir, config_path))
 
         if options['--quiet']:
             return
@@ -239,7 +222,7 @@ class TopLevelCommand(DocoptCommand):
 
         print(serialize_config(compose_config))
 
-    def create(self, project, options):
+    def create(self, options):
         """
         Creates containers for a service.
 
@@ -255,13 +238,13 @@ class TopLevelCommand(DocoptCommand):
         """
         service_names = options['SERVICE']
 
-        project.create(
+        self.project.create(
             service_names=service_names,
             strategy=convergence_strategy_from_opts(options),
             do_build=build_action_from_opts(options),
         )
 
-    def down(self, project, options):
+    def down(self, options):
         """
         Stop containers and remove containers, networks, volumes, and images
         created by `up`. Only containers and networks are removed by default.
@@ -275,9 +258,9 @@ class TopLevelCommand(DocoptCommand):
             -v, --volumes   Remove data volumes
         """
         image_type = image_type_from_opt('--rmi', options['--rmi'])
-        project.down(image_type, options['--volumes'])
+        self.project.down(image_type, options['--volumes'])
 
-    def events(self, project, options):
+    def events(self, options):
         """
         Receive real time events from containers.
 
@@ -296,12 +279,12 @@ class TopLevelCommand(DocoptCommand):
             event['time'] = event['time'].isoformat()
             return json.dumps(event)
 
-        for event in project.events():
+        for event in self.project.events():
             formatter = json_format_event if options['--json'] else format_event
             print(formatter(event))
             sys.stdout.flush()
 
-    def exec_command(self, project, options):
+    def exec_command(self, options):
         """
         Execute a command in a running container
 
@@ -317,7 +300,7 @@ class TopLevelCommand(DocoptCommand):
                               instances of a service [default: 1]
         """
         index = int(options.get('--index'))
-        service = project.get_service(options['SERVICE'])
+        service = self.project.get_service(options['SERVICE'])
         try:
             container = service.get_container(number=index)
         except ValueError as e:
@@ -341,27 +324,28 @@ class TopLevelCommand(DocoptCommand):
         signals.set_signal_handler_to_shutdown()
         try:
             operation = ExecOperation(
-                    project.client,
+                    self.project.client,
                     exec_id,
                     interactive=tty,
             )
-            pty = PseudoTerminal(project.client, operation)
+            pty = PseudoTerminal(self.project.client, operation)
             pty.start()
         except signals.ShutdownException:
             log.info("received shutdown exception: closing")
-        exit_code = project.client.exec_inspect(exec_id).get("ExitCode")
+        exit_code = self.project.client.exec_inspect(exec_id).get("ExitCode")
         sys.exit(exit_code)
 
-    def help(self, project, options):
+    @classmethod
+    def help(cls, options):
         """
         Get help on a command.
 
         Usage: help COMMAND
         """
-        handler = self.get_handler(options['COMMAND'])
+        handler = get_handler(cls, options['COMMAND'])
         raise SystemExit(getdoc(handler))
 
-    def kill(self, project, options):
+    def kill(self, options):
         """
         Force stop service containers.
 
@@ -373,9 +357,9 @@ class TopLevelCommand(DocoptCommand):
         """
         signal = options.get('-s', 'SIGKILL')
 
-        project.kill(service_names=options['SERVICE'], signal=signal)
+        self.project.kill(service_names=options['SERVICE'], signal=signal)
 
-    def logs(self, project, options):
+    def logs(self, options):
         """
         View output from containers.
 
@@ -388,7 +372,7 @@ class TopLevelCommand(DocoptCommand):
             --tail="all"        Number of lines to show from the end of the logs
                                 for each container.
         """
-        containers = project.containers(service_names=options['SERVICE'], stopped=True)
+        containers = self.project.containers(service_names=options['SERVICE'], stopped=True)
 
         monochrome = options['--no-color']
         tail = options['--tail']
@@ -405,16 +389,16 @@ class TopLevelCommand(DocoptCommand):
         print("Attaching to", list_containers(containers))
         LogPrinter(containers, monochrome=monochrome, log_args=log_args).run()
 
-    def pause(self, project, options):
+    def pause(self, options):
         """
         Pause services.
 
         Usage: pause [SERVICE...]
         """
-        containers = project.pause(service_names=options['SERVICE'])
+        containers = self.project.pause(service_names=options['SERVICE'])
         exit_if(not containers, 'No containers to pause', 1)
 
-    def port(self, project, options):
+    def port(self, options):
         """
         Print the public port for a port binding.
 
@@ -426,7 +410,7 @@ class TopLevelCommand(DocoptCommand):
                               instances of a service [default: 1]
         """
         index = int(options.get('--index'))
-        service = project.get_service(options['SERVICE'])
+        service = self.project.get_service(options['SERVICE'])
         try:
             container = service.get_container(number=index)
         except ValueError as e:
@@ -435,7 +419,7 @@ class TopLevelCommand(DocoptCommand):
             options['PRIVATE_PORT'],
             protocol=options.get('--protocol') or 'tcp') or '')
 
-    def ps(self, project, options):
+    def ps(self, options):
         """
         List containers.
 
@@ -445,8 +429,8 @@ class TopLevelCommand(DocoptCommand):
             -q    Only display IDs
         """
         containers = sorted(
-            project.containers(service_names=options['SERVICE'], stopped=True) +
-            project.containers(service_names=options['SERVICE'], one_off=True),
+            self.project.containers(service_names=options['SERVICE'], stopped=True) +
+            self.project.containers(service_names=options['SERVICE'], one_off=True),
             key=attrgetter('name'))
 
         if options['-q']:
@@ -472,7 +456,7 @@ class TopLevelCommand(DocoptCommand):
                 ])
             print(Formatter().table(headers, rows))
 
-    def pull(self, project, options):
+    def pull(self, options):
         """
         Pulls images for services.
 
@@ -481,12 +465,12 @@ class TopLevelCommand(DocoptCommand):
         Options:
             --ignore-pull-failures  Pull what it can and ignores images with pull failures.
         """
-        project.pull(
+        self.project.pull(
             service_names=options['SERVICE'],
             ignore_pull_failures=options.get('--ignore-pull-failures')
         )
 
-    def rm(self, project, options):
+    def rm(self, options):
         """
         Remove stopped service containers.
 
@@ -501,21 +485,21 @@ class TopLevelCommand(DocoptCommand):
             -f, --force   Don't ask to confirm removal
             -v            Remove volumes associated with containers
         """
-        all_containers = project.containers(service_names=options['SERVICE'], stopped=True)
+        all_containers = self.project.containers(service_names=options['SERVICE'], stopped=True)
         stopped_containers = [c for c in all_containers if not c.is_running]
 
         if len(stopped_containers) > 0:
             print("Going to remove", list_containers(stopped_containers))
             if options.get('--force') \
                     or yesno("Are you sure? [yN] ", default=False):
-                project.remove_stopped(
+                self.project.remove_stopped(
                     service_names=options['SERVICE'],
                     v=options.get('-v', False)
                 )
         else:
             print("No stopped containers")
 
-    def run(self, project, options):
+    def run(self, options):
         """
         Run a one-off command on a service.
 
@@ -544,7 +528,7 @@ class TopLevelCommand(DocoptCommand):
             -T                    Disable pseudo-tty allocation. By default `docker-compose run`
                                   allocates a TTY.
         """
-        service = project.get_service(options['SERVICE'])
+        service = self.project.get_service(options['SERVICE'])
         detach = options['-d']
 
         if IS_WINDOWS_PLATFORM and not detach:
@@ -592,9 +576,9 @@ class TopLevelCommand(DocoptCommand):
         if options['--name']:
             container_options['name'] = options['--name']
 
-        run_one_off_container(container_options, project, service, options)
+        run_one_off_container(container_options, self.project, service, options)
 
-    def scale(self, project, options):
+    def scale(self, options):
         """
         Set number of containers to run for a service.
 
@@ -620,18 +604,18 @@ class TopLevelCommand(DocoptCommand):
             except ValueError:
                 raise UserError('Number of containers for service "%s" is not a '
                                 'number' % service_name)
-            project.get_service(service_name).scale(num, timeout=timeout)
+            self.project.get_service(service_name).scale(num, timeout=timeout)
 
-    def start(self, project, options):
+    def start(self, options):
         """
         Start existing containers.
 
         Usage: start [SERVICE...]
         """
-        containers = project.start(service_names=options['SERVICE'])
+        containers = self.project.start(service_names=options['SERVICE'])
         exit_if(not containers, 'No containers to start', 1)
 
-    def stop(self, project, options):
+    def stop(self, options):
         """
         Stop running containers without removing them.
 
@@ -644,9 +628,9 @@ class TopLevelCommand(DocoptCommand):
                                      (default: 10)
         """
         timeout = int(options.get('--timeout') or DEFAULT_TIMEOUT)
-        project.stop(service_names=options['SERVICE'], timeout=timeout)
+        self.project.stop(service_names=options['SERVICE'], timeout=timeout)
 
-    def restart(self, project, options):
+    def restart(self, options):
         """
         Restart running containers.
 
@@ -657,19 +641,19 @@ class TopLevelCommand(DocoptCommand):
                                      (default: 10)
         """
         timeout = int(options.get('--timeout') or DEFAULT_TIMEOUT)
-        containers = project.restart(service_names=options['SERVICE'], timeout=timeout)
+        containers = self.project.restart(service_names=options['SERVICE'], timeout=timeout)
         exit_if(not containers, 'No containers to restart', 1)
 
-    def unpause(self, project, options):
+    def unpause(self, options):
         """
         Unpause services.
 
         Usage: unpause [SERVICE...]
         """
-        containers = project.unpause(service_names=options['SERVICE'])
+        containers = self.project.unpause(service_names=options['SERVICE'])
         exit_if(not containers, 'No containers to unpause', 1)
 
-    def up(self, project, options):
+    def up(self, options):
         """
         Builds, (re)creates, starts, and attaches to containers for a service.
 
@@ -719,8 +703,8 @@ class TopLevelCommand(DocoptCommand):
         if detached and cascade_stop:
             raise UserError("--abort-on-container-exit and -d cannot be combined.")
 
-        with up_shutdown_context(project, service_names, timeout, detached):
-            to_attach = project.up(
+        with up_shutdown_context(self.project, service_names, timeout, detached):
+            to_attach = self.project.up(
                 service_names=service_names,
                 start_deps=start_deps,
                 strategy=convergence_strategy_from_opts(options),
@@ -737,9 +721,10 @@ class TopLevelCommand(DocoptCommand):
 
             if cascade_stop:
                 print("Aborting on container exit...")
-                project.stop(service_names=service_names, timeout=timeout)
+                self.project.stop(service_names=service_names, timeout=timeout)
 
-    def version(self, project, options):
+    @classmethod
+    def version(cls, options):
         """
         Show version informations
 

+ 0 - 16
tests/unit/cli/command_test.py

@@ -4,28 +4,12 @@ from __future__ import unicode_literals
 import os
 
 import pytest
-from requests.exceptions import ConnectionError
 
-from compose.cli import errors
-from compose.cli.command import friendly_error_message
 from compose.cli.command import get_config_path_from_options
 from compose.const import IS_WINDOWS_PLATFORM
 from tests import mock
 
 
-class TestFriendlyErrorMessage(object):
-
-    def test_dispatch_generic_connection_error(self):
-        with pytest.raises(errors.ConnectionErrorGeneric):
-            with mock.patch(
-                'compose.cli.command.call_silently',
-                autospec=True,
-                side_effect=[0, 1]
-            ):
-                with friendly_error_message():
-                    raise ConnectionError()
-
-
 class TestGetConfigPathFromOptions(object):
 
     def test_path_from_options(self):

+ 51 - 0
tests/unit/cli/errors_test.py

@@ -0,0 +1,51 @@
+from __future__ import absolute_import
+from __future__ import unicode_literals
+
+import pytest
+from docker.errors import APIError
+from requests.exceptions import ConnectionError
+
+from compose.cli import errors
+from compose.cli.errors import handle_connection_errors
+from tests import mock
+
+
[email protected]_fixture
+def mock_logging():
+    with mock.patch('compose.cli.errors.log', autospec=True) as mock_log:
+        yield mock_log
+
+
+def patch_call_silently(side_effect):
+    return mock.patch(
+        'compose.cli.errors.call_silently',
+        autospec=True,
+        side_effect=side_effect)
+
+
+class TestHandleConnectionErrors(object):
+
+    def test_generic_connection_error(self, mock_logging):
+        with pytest.raises(errors.ConnectionError):
+            with patch_call_silently([0, 1]):
+                with handle_connection_errors(mock.Mock()):
+                    raise ConnectionError()
+
+        _, args, _ = mock_logging.error.mock_calls[0]
+        assert "Couldn't connect to Docker daemon at" in args[0]
+
+    def test_api_error_version_mismatch(self, mock_logging):
+        with pytest.raises(errors.ConnectionError):
+            with handle_connection_errors(mock.Mock(api_version='1.22')):
+                raise APIError(None, None, "client is newer than server")
+
+        _, args, _ = mock_logging.error.mock_calls[0]
+        assert "Docker Engine of version 1.10.0 or greater" in args[0]
+
+    def test_api_error_version_other(self, mock_logging):
+        msg = "Something broke!"
+        with pytest.raises(errors.ConnectionError):
+            with handle_connection_errors(mock.Mock(api_version='1.22')):
+                raise APIError(None, None, msg)
+
+        mock_logging.error.assert_called_once_with(msg)

+ 35 - 32
tests/unit/cli/main_test.py

@@ -3,6 +3,8 @@ from __future__ import unicode_literals
 
 import logging
 
+import pytest
+
 from compose import container
 from compose.cli.errors import UserError
 from compose.cli.formatter import ConsoleWarningFormatter
@@ -11,7 +13,6 @@ from compose.cli.main import convergence_strategy_from_opts
 from compose.cli.main import setup_console_handler
 from compose.service import ConvergenceStrategy
 from tests import mock
-from tests import unittest
 
 
 def mock_container(service, number):
@@ -22,7 +23,14 @@ def mock_container(service, number):
         name_without_project='{0}_{1}'.format(service, number))
 
 
-class CLIMainTestCase(unittest.TestCase):
[email protected]
+def logging_handler():
+    stream = mock.Mock()
+    stream.isatty.return_value = True
+    return logging.StreamHandler(stream=stream)
+
+
+class TestCLIMainTestCase(object):
 
     def test_build_log_printer(self):
         containers = [
@@ -34,7 +42,7 @@ class CLIMainTestCase(unittest.TestCase):
         ]
         service_names = ['web', 'db']
         log_printer = build_log_printer(containers, service_names, True, False, {'follow': True})
-        self.assertEqual(log_printer.containers, containers[:3])
+        assert log_printer.containers == containers[:3]
 
     def test_build_log_printer_all_services(self):
         containers = [
@@ -44,58 +52,53 @@ class CLIMainTestCase(unittest.TestCase):
         ]
         service_names = []
         log_printer = build_log_printer(containers, service_names, True, False, {'follow': True})
-        self.assertEqual(log_printer.containers, containers)
-
+        assert log_printer.containers == containers
 
-class SetupConsoleHandlerTestCase(unittest.TestCase):
 
-    def setUp(self):
-        self.stream = mock.Mock()
-        self.stream.isatty.return_value = True
-        self.handler = logging.StreamHandler(stream=self.stream)
+class TestSetupConsoleHandlerTestCase(object):
 
-    def test_with_tty_verbose(self):
-        setup_console_handler(self.handler, True)
-        assert type(self.handler.formatter) == ConsoleWarningFormatter
-        assert '%(name)s' in self.handler.formatter._fmt
-        assert '%(funcName)s' in self.handler.formatter._fmt
+    def test_with_tty_verbose(self, logging_handler):
+        setup_console_handler(logging_handler, True)
+        assert type(logging_handler.formatter) == ConsoleWarningFormatter
+        assert '%(name)s' in logging_handler.formatter._fmt
+        assert '%(funcName)s' in logging_handler.formatter._fmt
 
-    def test_with_tty_not_verbose(self):
-        setup_console_handler(self.handler, False)
-        assert type(self.handler.formatter) == ConsoleWarningFormatter
-        assert '%(name)s' not in self.handler.formatter._fmt
-        assert '%(funcName)s' not in self.handler.formatter._fmt
+    def test_with_tty_not_verbose(self, logging_handler):
+        setup_console_handler(logging_handler, False)
+        assert type(logging_handler.formatter) == ConsoleWarningFormatter
+        assert '%(name)s' not in logging_handler.formatter._fmt
+        assert '%(funcName)s' not in logging_handler.formatter._fmt
 
-    def test_with_not_a_tty(self):
-        self.stream.isatty.return_value = False
-        setup_console_handler(self.handler, False)
-        assert type(self.handler.formatter) == logging.Formatter
+    def test_with_not_a_tty(self, logging_handler):
+        logging_handler.stream.isatty.return_value = False
+        setup_console_handler(logging_handler, False)
+        assert type(logging_handler.formatter) == logging.Formatter
 
 
-class ConvergeStrategyFromOptsTestCase(unittest.TestCase):
+class TestConvergeStrategyFromOptsTestCase(object):
 
     def test_invalid_opts(self):
         options = {'--force-recreate': True, '--no-recreate': True}
-        with self.assertRaises(UserError):
+        with pytest.raises(UserError):
             convergence_strategy_from_opts(options)
 
     def test_always(self):
         options = {'--force-recreate': True, '--no-recreate': False}
-        self.assertEqual(
-            convergence_strategy_from_opts(options),
+        assert (
+            convergence_strategy_from_opts(options) ==
             ConvergenceStrategy.always
         )
 
     def test_never(self):
         options = {'--force-recreate': False, '--no-recreate': True}
-        self.assertEqual(
-            convergence_strategy_from_opts(options),
+        assert (
+            convergence_strategy_from_opts(options) ==
             ConvergenceStrategy.never
         )
 
     def test_changed(self):
         options = {'--force-recreate': False, '--no-recreate': False}
-        self.assertEqual(
-            convergence_strategy_from_opts(options),
+        assert (
+            convergence_strategy_from_opts(options) ==
             ConvergenceStrategy.changed
         )

+ 13 - 18
tests/unit/cli_test.py

@@ -64,26 +64,20 @@ class CLITestCase(unittest.TestCase):
         self.assertTrue(project.client)
         self.assertTrue(project.services)
 
-    def test_help(self):
-        command = TopLevelCommand()
-        with self.assertRaises(SystemExit):
-            command.dispatch(['-h'])
-
     def test_command_help(self):
-        with self.assertRaises(SystemExit) as ctx:
-            TopLevelCommand().dispatch(['help', 'up'])
+        with pytest.raises(SystemExit) as exc:
+            TopLevelCommand.help({'COMMAND': 'up'})
 
-        self.assertIn('Usage: up', str(ctx.exception))
+        assert 'Usage: up' in exc.exconly()
 
     def test_command_help_nonexistent(self):
-        with self.assertRaises(NoSuchCommand):
-            TopLevelCommand().dispatch(['help', 'nonexistent'])
+        with pytest.raises(NoSuchCommand):
+            TopLevelCommand.help({'COMMAND': 'nonexistent'})
 
     @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason="requires dockerpty")
     @mock.patch('compose.cli.main.RunOperation', autospec=True)
     @mock.patch('compose.cli.main.PseudoTerminal', autospec=True)
     def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_operation):
-        command = TopLevelCommand()
         mock_client = mock.create_autospec(docker.Client)
         project = Project.from_config(
             name='composetest',
@@ -92,9 +86,10 @@ class CLITestCase(unittest.TestCase):
                 'service': {'image': 'busybox'}
             }),
         )
+        command = TopLevelCommand(project)
 
         with pytest.raises(SystemExit):
-            command.run(project, {
+            command.run({
                 'SERVICE': 'service',
                 'COMMAND': None,
                 '-e': [],
@@ -126,8 +121,8 @@ class CLITestCase(unittest.TestCase):
             }),
         )
 
-        command = TopLevelCommand()
-        command.run(project, {
+        command = TopLevelCommand(project)
+        command.run({
             'SERVICE': 'service',
             'COMMAND': None,
             '-e': [],
@@ -147,8 +142,8 @@ class CLITestCase(unittest.TestCase):
             'always'
         )
 
-        command = TopLevelCommand()
-        command.run(project, {
+        command = TopLevelCommand(project)
+        command.run({
             'SERVICE': 'service',
             'COMMAND': None,
             '-e': [],
@@ -168,7 +163,6 @@ class CLITestCase(unittest.TestCase):
         )
 
     def test_command_manula_and_service_ports_together(self):
-        command = TopLevelCommand()
         project = Project.from_config(
             name='composetest',
             client=None,
@@ -176,9 +170,10 @@ class CLITestCase(unittest.TestCase):
                 'service': {'image': 'busybox'},
             }),
         )
+        command = TopLevelCommand(project)
 
         with self.assertRaises(UserError):
-            command.run(project, {
+            command.run({
                 'SERVICE': 'service',
                 'COMMAND': None,
                 '-e': [],