Refactor command dispatch to improve unit testing and support better error messages.

Signed-off-by: Daniel Nephin <dnephin@docker.com>
This commit is contained in:
Daniel Nephin 2016-02-29 14:35:23 -08:00
parent 9e242cdc75
commit 53bea8a720
3 changed files with 75 additions and 66 deletions

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

@ -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
@ -33,7 +34,8 @@ from ..service import NeedsBuildError
from .command import friendly_error_message 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
@ -52,19 +54,16 @@ console_handler = logging.StreamHandler(sys.stderr)
def main(): def main():
setup_logging() 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: except APIError as e:
log_api_error(e) log_api_error(e)
sys.exit(1) sys.exit(1)
@ -88,6 +87,40 @@ def main():
sys.exit(1) sys.exit(1)
def dispatch():
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 friendly_error_message():
# TODO: use self.project
handler(command, project, command_options)
def log_api_error(e): def log_api_error(e):
if 'client is newer than server' in e.explanation: if 'client is newer than server' in e.explanation:
# we need JSON formatted errors. In the meantime... # we need JSON formatted errors. In the meantime...
@ -134,7 +167,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:
@ -173,26 +206,8 @@ class TopLevelCommand(DocoptCommand):
""" """
base_dir = '.' base_dir = '.'
def docopt_options(self): def __init__(self, project):
options = super(TopLevelCommand, self).docopt_options() self.project = project
options['version'] = get_version_info('compose')
return options
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, project, options):
""" """
@ -352,13 +367,14 @@ class TopLevelCommand(DocoptCommand):
exit_code = project.client.exec_inspect(exec_id).get("ExitCode") exit_code = 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, project, options):
@ -739,7 +755,8 @@ class TopLevelCommand(DocoptCommand):
print("Aborting on container exit...") print("Aborting on container exit...")
project.stop(service_names=service_names, timeout=timeout) 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

@ -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,6 +86,7 @@ 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(project, {
@ -126,7 +121,7 @@ class CLITestCase(unittest.TestCase):
}), }),
) )
command = TopLevelCommand() command = TopLevelCommand(project)
command.run(project, { command.run(project, {
'SERVICE': 'service', 'SERVICE': 'service',
'COMMAND': None, 'COMMAND': None,
@ -147,7 +142,7 @@ class CLITestCase(unittest.TestCase):
'always' 'always'
) )
command = TopLevelCommand() command = TopLevelCommand(project)
command.run(project, { command.run(project, {
'SERVICE': 'service', 'SERVICE': 'service',
'COMMAND': None, 'COMMAND': None,
@ -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,6 +170,7 @@ 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(project, {