diff --git a/compose/cli/command.py b/compose/cli/command.py index 157e00161..59f6c4bc9 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -46,7 +46,7 @@ def friendly_error_message(): def project_from_options(base_dir, options): return get_project( base_dir, - get_config_path(options.get('--file')), + get_config_path_from_options(options), project_name=options.get('--project-name'), verbose=options.get('--verbose'), use_networking=options.get('--x-networking'), @@ -54,7 +54,8 @@ def project_from_options(base_dir, options): ) -def get_config_path(file_option): +def get_config_path_from_options(options): + file_option = options.get('--file') if file_option: return file_option diff --git a/compose/cli/main.py b/compose/cli/main.py index 62db5183d..f30ea3340 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -8,10 +8,12 @@ import sys from inspect import getdoc from operator import attrgetter +import yaml from docker.errors import APIError from requests.exceptions import ReadTimeout from .. import __version__ +from ..config import config from ..config import ConfigurationError from ..config import parse_environment from ..const import DEFAULT_TIMEOUT @@ -23,6 +25,7 @@ from ..service import BuildError from ..service import ConvergenceStrategy 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 NoSuchCommand @@ -126,6 +129,7 @@ class TopLevelCommand(DocoptCommand): Commands: build Build or rebuild services + config Validate and view the compose file help Get help on a command kill Kill containers logs View output from containers @@ -158,6 +162,10 @@ class TopLevelCommand(DocoptCommand): 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) @@ -183,6 +191,36 @@ class TopLevelCommand(DocoptCommand): pull=bool(options.get('--pull', False)), force_rm=bool(options.get('--force-rm', False))) + def config(self, config_options, options): + """ + Validate and view the compose file. + + Usage: config [options] + + Options: + -q, --quiet Only validate the configuration, don't print + anything. + --services Print the service names, one per line. + + """ + config_path = get_config_path_from_options(config_options) + compose_config = config.load(config.find(self.base_dir, config_path)) + + if options['--quiet']: + return + + if options['--services']: + print('\n'.join(service['name'] for service in compose_config)) + return + + compose_config = dict( + (service.pop('name'), service) for service in compose_config) + print(yaml.dump( + compose_config, + default_flow_style=False, + indent=2, + width=80)) + def help(self, project, options): """ Get help on a command. diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 99b78d081..0d26ea1f2 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -7,6 +7,7 @@ import subprocess import time from collections import namedtuple from operator import attrgetter +from textwrap import dedent from docker import errors @@ -90,10 +91,11 @@ class CLITestCase(DockerClientTestCase): self.base_dir = 'tests/fixtures/simple-composefile' def tearDown(self): - self.project.kill() - self.project.remove_stopped() - for container in self.project.containers(stopped=True, one_off=True): - container.remove(force=True) + if self.base_dir: + self.project.kill() + self.project.remove_stopped() + for container in self.project.containers(stopped=True, one_off=True): + container.remove(force=True) super(CLITestCase, self).tearDown() @property @@ -109,13 +111,39 @@ class CLITestCase(DockerClientTestCase): return wait_on_process(proc, returncode=returncode) def test_help(self): - old_base_dir = self.base_dir self.base_dir = 'tests/fixtures/no-composefile' result = self.dispatch(['help', 'up'], returncode=1) assert 'Usage: up [options] [SERVICE...]' in result.stderr - # self.project.kill() fails during teardown - # unless there is a composefile. - self.base_dir = old_base_dir + # Prevent tearDown from trying to create a project + self.base_dir = None + + def test_config_list_services(self): + result = self.dispatch(['config', '--services']) + assert set(result.stdout.rstrip().split('\n')) == {'simple', 'another'} + + def test_config_quiet_with_error(self): + self.base_dir = None + result = self.dispatch([ + '-f', 'tests/fixtures/invalid-composefile/invalid.yml', + 'config', '-q' + ], returncode=1) + assert "'notaservice' doesn't have any configuration" in result.stderr + + def test_config_quiet(self): + assert self.dispatch(['config', '-q']).stdout == '' + + def test_config_default(self): + result = self.dispatch(['config']) + assert dedent(""" + simple: + command: top + image: busybox:latest + """).lstrip() in result.stdout + assert dedent(""" + another: + command: top + image: busybox:latest + """).lstrip() in result.stdout def test_ps(self): self.project.get_service('simple').create_container() diff --git a/tests/fixtures/invalid-composefile/invalid.yml b/tests/fixtures/invalid-composefile/invalid.yml new file mode 100644 index 000000000..0e74be440 --- /dev/null +++ b/tests/fixtures/invalid-composefile/invalid.yml @@ -0,0 +1,5 @@ + +notaservice: oops + +web: + image: 'alpine:edge'