Merge pull request #3095 from dnephin/refactor_command_dispatch

Refactor command dispatch and fix api version mismatch error
This commit is contained in:
Aanand Prasad 2016-03-14 16:18:29 +00:00
commit e5cd869c61
8 changed files with 311 additions and 260 deletions

View File

@ -1,52 +1,25 @@
from __future__ import absolute_import from __future__ import absolute_import
from __future__ import unicode_literals from __future__ import unicode_literals
import contextlib
import logging import logging
import os import os
import re import re
import six import six
from requests.exceptions import ConnectionError
from requests.exceptions import SSLError
from . import errors
from . import verbose_proxy from . import verbose_proxy
from .. import config from .. import config
from ..const import API_VERSIONS from ..const import API_VERSIONS
from ..project import Project from ..project import Project
from .docker_client import docker_client from .docker_client import docker_client
from .utils import call_silently
from .utils import get_version_info from .utils import get_version_info
from .utils import is_mac
from .utils import is_ubuntu
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@contextlib.contextmanager def project_from_options(project_dir, options):
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):
return get_project( return get_project(
base_dir, project_dir,
get_config_path_from_options(options), get_config_path_from_options(options),
project_name=options.get('--project-name'), project_name=options.get('--project-name'),
verbose=options.get('--verbose'), verbose=options.get('--verbose'),
@ -76,8 +49,8 @@ def get_client(verbose=False, version=None):
return client return client
def get_project(base_dir, config_path=None, project_name=None, verbose=False): def get_project(project_dir, config_path=None, project_name=None, verbose=False):
config_details = config.find(base_dir, config_path) config_details = config.find(project_dir, config_path)
project_name = get_project_name(config_details.working_dir, project_name) project_name = get_project_name(config_details.working_dir, project_name)
config_data = config.load(config_details) config_data = config.load(config_details)

View File

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

View File

@ -1,10 +1,27 @@
from __future__ import absolute_import from __future__ import absolute_import
from __future__ import unicode_literals from __future__ import unicode_literals
import contextlib
import logging
from textwrap import dedent 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): class UserError(Exception):
def __init__(self, msg): def __init__(self, msg):
self.msg = dedent(msg).strip() self.msg = dedent(msg).strip()
@ -14,44 +31,90 @@ class UserError(Exception):
__str__ = __unicode__ __str__ = __unicode__
class DockerNotFoundMac(UserError): class ConnectionError(Exception):
def __init__(self): pass
super(DockerNotFoundMac, self).__init__("""
@contextlib.contextmanager
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: 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): docker_not_found_ubuntu = """
def __init__(self):
super(DockerNotFoundUbuntu, self).__init__("""
Couldn't connect to Docker daemon. You might need to install Docker: 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): docker_not_found_generic = """
def __init__(self):
super(DockerNotFoundGeneric, self).__init__("""
Couldn't connect to Docker daemon. You might need to install Docker: 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): conn_error_docker_machine = """
def __init__(self):
super(ConnectionErrorDockerMachine, self).__init__("""
Couldn't connect to Docker daemon - you might need to run `docker-machine start default`. Couldn't connect to Docker daemon - you might need to run `docker-machine start default`.
""") """
class ConnectionErrorGeneric(UserError): conn_error_generic = """
def __init__(self, url): Couldn't connect to Docker daemon at {url} - is it running?
super(ConnectionErrorGeneric, self).__init__("""
Couldn't connect to Docker daemon at %s - is it running?
If it's at a non-standard location, specify the URL with the DOCKER_HOST environment variable. If it's at a non-standard location, specify the URL with the DOCKER_HOST environment variable.
""" % url) """

View File

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

View File

@ -4,28 +4,12 @@ from __future__ import unicode_literals
import os import os
import pytest 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.cli.command import get_config_path_from_options
from compose.const import IS_WINDOWS_PLATFORM from compose.const import IS_WINDOWS_PLATFORM
from tests import mock 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): class TestGetConfigPathFromOptions(object):
def test_path_from_options(self): def test_path_from_options(self):

View File

@ -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
@pytest.yield_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)

View File

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

View File

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