Support reading config from stdin.

Signed-off-by: Daniel Nephin <dnephin@gmail.com>
This commit is contained in:
Daniel Nephin 2015-05-26 22:09:26 -04:00
parent cd2cdb25e3
commit ae96fc0071
10 changed files with 197 additions and 147 deletions

View File

@ -10,7 +10,7 @@ from .. import config
from ..project import Project from ..project import Project
from ..service import ConfigError from ..service import ConfigError
from .docopt_command import DocoptCommand from .docopt_command import DocoptCommand
from .utils import call_silently, is_mac, is_ubuntu, find_candidates_in_parent_dirs from .utils import call_silently, is_mac, is_ubuntu
from .docker_client import docker_client from .docker_client import docker_client
from . import verbose_proxy from . import verbose_proxy
from . import errors from . import errors
@ -18,13 +18,6 @@ from .. import __version__
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
SUPPORTED_FILENAMES = [
'docker-compose.yml',
'docker-compose.yaml',
'fig.yml',
'fig.yaml',
]
class Command(DocoptCommand): class Command(DocoptCommand):
base_dir = '.' base_dir = '.'
@ -59,7 +52,7 @@ class Command(DocoptCommand):
explicit_config_path = options.get('--file') or os.environ.get('COMPOSE_FILE') or os.environ.get('FIG_FILE') explicit_config_path = options.get('--file') or os.environ.get('COMPOSE_FILE') or os.environ.get('FIG_FILE')
project = self.get_project( project = self.get_project(
self.get_config_path(explicit_config_path), explicit_config_path,
project_name=options.get('--project-name'), project_name=options.get('--project-name'),
verbose=options.get('--verbose')) verbose=options.get('--verbose'))
@ -76,16 +69,18 @@ class Command(DocoptCommand):
return verbose_proxy.VerboseProxy('docker', client) return verbose_proxy.VerboseProxy('docker', client)
return client return client
def get_project(self, config_path, project_name=None, verbose=False): def get_project(self, config_path=None, project_name=None, verbose=False):
config_details = config.find(self.base_dir, config_path)
try: try:
return Project.from_dicts( return Project.from_dicts(
self.get_project_name(config_path, project_name), self.get_project_name(config_details.working_dir, project_name),
config.load(config_path), config.load(config_details),
self.get_client(verbose=verbose)) self.get_client(verbose=verbose))
except ConfigError as e: except ConfigError as e:
raise errors.UserError(six.text_type(e)) raise errors.UserError(six.text_type(e))
def get_project_name(self, config_path, project_name=None): def get_project_name(self, working_dir, project_name=None):
def normalize_name(name): def normalize_name(name):
return re.sub(r'[^a-z0-9]', '', name.lower()) return re.sub(r'[^a-z0-9]', '', name.lower())
@ -93,38 +88,15 @@ class Command(DocoptCommand):
log.warn('The FIG_PROJECT_NAME environment variable is deprecated.') log.warn('The FIG_PROJECT_NAME environment variable is deprecated.')
log.warn('Please use COMPOSE_PROJECT_NAME instead.') log.warn('Please use COMPOSE_PROJECT_NAME instead.')
project_name = project_name or os.environ.get('COMPOSE_PROJECT_NAME') or os.environ.get('FIG_PROJECT_NAME') project_name = (
project_name or
os.environ.get('COMPOSE_PROJECT_NAME') or
os.environ.get('FIG_PROJECT_NAME'))
if project_name is not None: if project_name is not None:
return normalize_name(project_name) return normalize_name(project_name)
project = os.path.basename(os.path.dirname(os.path.abspath(config_path))) project = os.path.basename(os.path.abspath(working_dir))
if project: if project:
return normalize_name(project) return normalize_name(project)
return 'default' return 'default'
def get_config_path(self, file_path=None):
if file_path:
return os.path.join(self.base_dir, file_path)
(candidates, path) = find_candidates_in_parent_dirs(SUPPORTED_FILENAMES, self.base_dir)
if len(candidates) == 0:
raise errors.ComposeFileNotFound(SUPPORTED_FILENAMES)
winner = candidates[0]
if len(candidates) > 1:
log.warning("Found multiple config files with supported names: %s", ", ".join(candidates))
log.warning("Using %s\n", winner)
if winner == 'docker-compose.yaml':
log.warning("Please be aware that .yml is the expected extension "
"in most cases, and using .yaml can cause compatibility "
"issues in future.\n")
if winner.startswith("fig."):
log.warning("%s is deprecated and will not be supported in future. "
"Please rename your config file to docker-compose.yml\n" % winner)
return os.path.join(path, winner)

View File

@ -53,12 +53,3 @@ class ConnectionErrorGeneric(UserError):
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) """ % url)
class ComposeFileNotFound(UserError):
def __init__(self, supported_filenames):
super(ComposeFileNotFound, self).__init__("""
Can't find a suitable configuration file in this directory or any parent. Are you in the right directory?
Supported filenames: %s
""" % ", ".join(supported_filenames))

View File

@ -1,7 +1,13 @@
import logging
import os import os
import sys
import yaml import yaml
from collections import namedtuple
import six import six
from compose.cli.utils import find_candidates_in_parent_dirs
DOCKER_CONFIG_KEYS = [ DOCKER_CONFIG_KEYS = [
'cap_add', 'cap_add',
@ -64,12 +70,57 @@ DOCKER_CONFIG_HINTS = {
} }
def load(filename): SUPPORTED_FILENAMES = [
working_dir = os.path.dirname(filename) 'docker-compose.yml',
return from_dictionary(load_yaml(filename), working_dir=working_dir, filename=filename) 'docker-compose.yaml',
'fig.yml',
'fig.yaml',
]
def from_dictionary(dictionary, working_dir=None, filename=None): log = logging.getLogger(__name__)
ConfigDetails = namedtuple('ConfigDetails', 'config working_dir filename')
def find(base_dir, filename):
if filename == '-':
return ConfigDetails(yaml.safe_load(sys.stdin), os.getcwd(), None)
if filename:
filename = os.path.join(base_dir, filename)
else:
filename = get_config_path(base_dir)
return ConfigDetails(load_yaml(filename), os.path.dirname(filename), filename)
def get_config_path(base_dir):
(candidates, path) = find_candidates_in_parent_dirs(SUPPORTED_FILENAMES, base_dir)
if len(candidates) == 0:
raise ComposeFileNotFound(SUPPORTED_FILENAMES)
winner = candidates[0]
if len(candidates) > 1:
log.warn("Found multiple config files with supported names: %s", ", ".join(candidates))
log.warn("Using %s\n", winner)
if winner == 'docker-compose.yaml':
log.warn("Please be aware that .yml is the expected extension "
"in most cases, and using .yaml can cause compatibility "
"issues in future.\n")
if winner.startswith("fig."):
log.warn("%s is deprecated and will not be supported in future. "
"Please rename your config file to docker-compose.yml\n" % winner)
return os.path.join(path, winner)
def load(config_details):
dictionary, working_dir, filename = config_details
service_dicts = [] service_dicts = []
for service_name, service_dict in list(dictionary.items()): for service_name, service_dict in list(dictionary.items()):
@ -488,3 +539,12 @@ class CircularReference(ConfigurationError):
for (filename, service_name) in self.trail for (filename, service_name) in self.trail
] ]
return "Circular reference:\n {}".format("\n extends ".join(lines)) return "Circular reference:\n {}".format("\n extends ".join(lines))
class ComposeFileNotFound(ConfigurationError):
def __init__(self, supported_filenames):
super(ComposeFileNotFound, self).__init__("""
Can't find a suitable configuration file in this directory or any parent. Are you in the right directory?
Supported filenames: %s
""" % ", ".join(supported_filenames))

View File

@ -153,6 +153,9 @@ By default, if there are existing containers for a service, `docker-compose up`
for `docker-compose.yml` in the current working directory, and then each parent for `docker-compose.yml` in the current working directory, and then each parent
directory successively, until found. directory successively, until found.
Use a `-` as the filename to read configuration from stdin. When stdin is used
all paths in the configuration will be relative to the current working
directory.
### -p, --project-name NAME ### -p, --project-name NAME

View File

@ -36,7 +36,7 @@ class CLITestCase(DockerClientTestCase):
if hasattr(self, '_project'): if hasattr(self, '_project'):
return self._project return self._project
return self.command.get_project(self.command.get_config_path()) return self.command.get_project()
def test_help(self): def test_help(self):
old_base_dir = self.command.base_dir old_base_dir = self.command.base_dir

View File

@ -7,6 +7,10 @@ from compose.container import Container
from .testcases import DockerClientTestCase from .testcases import DockerClientTestCase
def build_service_dicts(service_config):
return config.load(config.ConfigDetails(service_config, 'working_dir', None))
class ProjectTest(DockerClientTestCase): class ProjectTest(DockerClientTestCase):
def test_containers(self): def test_containers(self):
@ -32,7 +36,7 @@ class ProjectTest(DockerClientTestCase):
['composetest_web_1']) ['composetest_web_1'])
def test_volumes_from_service(self): def test_volumes_from_service(self):
service_dicts = config.from_dictionary({ service_dicts = build_service_dicts({
'data': { 'data': {
'image': 'busybox:latest', 'image': 'busybox:latest',
'volumes': ['/var/data'], 'volumes': ['/var/data'],
@ -41,7 +45,7 @@ class ProjectTest(DockerClientTestCase):
'image': 'busybox:latest', 'image': 'busybox:latest',
'volumes_from': ['data'], 'volumes_from': ['data'],
}, },
}, working_dir='.') })
project = Project.from_dicts( project = Project.from_dicts(
name='composetest', name='composetest',
service_dicts=service_dicts, service_dicts=service_dicts,
@ -61,7 +65,7 @@ class ProjectTest(DockerClientTestCase):
) )
project = Project.from_dicts( project = Project.from_dicts(
name='composetest', name='composetest',
service_dicts=config.from_dictionary({ service_dicts=build_service_dicts({
'db': { 'db': {
'image': 'busybox:latest', 'image': 'busybox:latest',
'volumes_from': ['composetest_data_container'], 'volumes_from': ['composetest_data_container'],
@ -75,7 +79,7 @@ class ProjectTest(DockerClientTestCase):
def test_net_from_service(self): def test_net_from_service(self):
project = Project.from_dicts( project = Project.from_dicts(
name='composetest', name='composetest',
service_dicts=config.from_dictionary({ service_dicts=build_service_dicts({
'net': { 'net': {
'image': 'busybox:latest', 'image': 'busybox:latest',
'command': ["top"] 'command': ["top"]
@ -107,7 +111,7 @@ class ProjectTest(DockerClientTestCase):
project = Project.from_dicts( project = Project.from_dicts(
name='composetest', name='composetest',
service_dicts=config.from_dictionary({ service_dicts=build_service_dicts({
'web': { 'web': {
'image': 'busybox:latest', 'image': 'busybox:latest',
'net': 'container:composetest_net_container' 'net': 'container:composetest_net_container'
@ -274,7 +278,7 @@ class ProjectTest(DockerClientTestCase):
def test_project_up_starts_depends(self): def test_project_up_starts_depends(self):
project = Project.from_dicts( project = Project.from_dicts(
name='composetest', name='composetest',
service_dicts=config.from_dictionary({ service_dicts=build_service_dicts({
'console': { 'console': {
'image': 'busybox:latest', 'image': 'busybox:latest',
'command': ["top"], 'command': ["top"],
@ -309,7 +313,7 @@ class ProjectTest(DockerClientTestCase):
def test_project_up_with_no_deps(self): def test_project_up_with_no_deps(self):
project = Project.from_dicts( project = Project.from_dicts(
name='composetest', name='composetest',
service_dicts=config.from_dictionary({ service_dicts=build_service_dicts({
'console': { 'console': {
'image': 'busybox:latest', 'image': 'busybox:latest',
'command': ["top"], 'command': ["top"],

View File

@ -23,7 +23,7 @@ class ProjectTestCase(DockerClientTestCase):
return Project.from_dicts( return Project.from_dicts(
name='composetest', name='composetest',
client=self.client, client=self.client,
service_dicts=config.from_dictionary(cfg), service_dicts=config.load(config.ConfigDetails(cfg, 'working_dir', None))
) )

View File

@ -2,17 +2,14 @@ from __future__ import unicode_literals
from __future__ import absolute_import from __future__ import absolute_import
import logging import logging
import os import os
import tempfile
import shutil
from .. import unittest from .. import unittest
import docker import docker
import mock import mock
from compose.cli import main from compose.cli import main
from compose.cli.main import TopLevelCommand
from compose.cli.docopt_command import NoSuchCommand from compose.cli.docopt_command import NoSuchCommand
from compose.cli.errors import ComposeFileNotFound from compose.cli.main import TopLevelCommand
from compose.service import Service from compose.service import Service
@ -23,7 +20,7 @@ class CLITestCase(unittest.TestCase):
try: try:
os.chdir('tests/fixtures/simple-composefile') os.chdir('tests/fixtures/simple-composefile')
command = TopLevelCommand() command = TopLevelCommand()
project_name = command.get_project_name(command.get_config_path()) project_name = command.get_project_name('.')
self.assertEquals('simplecomposefile', project_name) self.assertEquals('simplecomposefile', project_name)
finally: finally:
os.chdir(cwd) os.chdir(cwd)
@ -31,13 +28,13 @@ class CLITestCase(unittest.TestCase):
def test_project_name_with_explicit_base_dir(self): def test_project_name_with_explicit_base_dir(self):
command = TopLevelCommand() command = TopLevelCommand()
command.base_dir = 'tests/fixtures/simple-composefile' command.base_dir = 'tests/fixtures/simple-composefile'
project_name = command.get_project_name(command.get_config_path()) project_name = command.get_project_name(command.base_dir)
self.assertEquals('simplecomposefile', project_name) self.assertEquals('simplecomposefile', project_name)
def test_project_name_with_explicit_uppercase_base_dir(self): def test_project_name_with_explicit_uppercase_base_dir(self):
command = TopLevelCommand() command = TopLevelCommand()
command.base_dir = 'tests/fixtures/UpperCaseDir' command.base_dir = 'tests/fixtures/UpperCaseDir'
project_name = command.get_project_name(command.get_config_path()) project_name = command.get_project_name(command.base_dir)
self.assertEquals('uppercasedir', project_name) self.assertEquals('uppercasedir', project_name)
def test_project_name_with_explicit_project_name(self): def test_project_name_with_explicit_project_name(self):
@ -62,37 +59,10 @@ class CLITestCase(unittest.TestCase):
project_name = command.get_project_name(None) project_name = command.get_project_name(None)
self.assertEquals(project_name, name) self.assertEquals(project_name, name)
def test_filename_check(self):
files = [
'docker-compose.yml',
'docker-compose.yaml',
'fig.yml',
'fig.yaml',
]
"""Test with files placed in the basedir"""
self.assertEqual('docker-compose.yml', get_config_filename_for_files(files[0:]))
self.assertEqual('docker-compose.yaml', get_config_filename_for_files(files[1:]))
self.assertEqual('fig.yml', get_config_filename_for_files(files[2:]))
self.assertEqual('fig.yaml', get_config_filename_for_files(files[3:]))
self.assertRaises(ComposeFileNotFound, lambda: get_config_filename_for_files([]))
"""Test with files placed in the subdir"""
def get_config_filename_for_files_in_subdir(files):
return get_config_filename_for_files(files, subdir=True)
self.assertEqual('docker-compose.yml', get_config_filename_for_files_in_subdir(files[0:]))
self.assertEqual('docker-compose.yaml', get_config_filename_for_files_in_subdir(files[1:]))
self.assertEqual('fig.yml', get_config_filename_for_files_in_subdir(files[2:]))
self.assertEqual('fig.yaml', get_config_filename_for_files_in_subdir(files[3:]))
self.assertRaises(ComposeFileNotFound, lambda: get_config_filename_for_files_in_subdir([]))
def test_get_project(self): def test_get_project(self):
command = TopLevelCommand() command = TopLevelCommand()
command.base_dir = 'tests/fixtures/longer-filename-composefile' command.base_dir = 'tests/fixtures/longer-filename-composefile'
project = command.get_project(command.get_config_path()) project = command.get_project()
self.assertEqual(project.name, 'longerfilenamecomposefile') self.assertEqual(project.name, 'longerfilenamecomposefile')
self.assertTrue(project.client) self.assertTrue(project.client)
self.assertTrue(project.services) self.assertTrue(project.services)
@ -201,23 +171,3 @@ class CLITestCase(unittest.TestCase):
}) })
_, _, call_kwargs = mock_client.create_container.mock_calls[0] _, _, call_kwargs = mock_client.create_container.mock_calls[0]
self.assertFalse('RestartPolicy' in call_kwargs['host_config']) self.assertFalse('RestartPolicy' in call_kwargs['host_config'])
def get_config_filename_for_files(filenames, subdir=None):
project_dir = tempfile.mkdtemp()
try:
make_files(project_dir, filenames)
command = TopLevelCommand()
if subdir:
command.base_dir = tempfile.mkdtemp(dir=project_dir)
else:
command.base_dir = project_dir
return os.path.basename(command.get_config_path())
finally:
shutil.rmtree(project_dir)
def make_files(dirname, filenames):
for fname in filenames:
with open(os.path.join(dirname, fname), 'w') as f:
f.write('')

View File

@ -1,16 +1,24 @@
import os
import mock import mock
import os
import shutil
import tempfile
from .. import unittest from .. import unittest
from compose import config from compose import config
class ConfigTest(unittest.TestCase): class ConfigTest(unittest.TestCase):
def test_from_dictionary(self): def test_load(self):
service_dicts = config.from_dictionary({ service_dicts = config.load(
'foo': {'image': 'busybox'}, config.ConfigDetails(
'bar': {'environment': ['FOO=1']}, {
}) 'foo': {'image': 'busybox'},
'bar': {'environment': ['FOO=1']},
},
'working_dir',
'filename.yml'
)
)
self.assertEqual( self.assertEqual(
sorted(service_dicts, key=lambda d: d['name']), sorted(service_dicts, key=lambda d: d['name']),
@ -26,11 +34,15 @@ class ConfigTest(unittest.TestCase):
]) ])
) )
def test_from_dictionary_throws_error_when_not_dict(self): def test_load_throws_error_when_not_dict(self):
with self.assertRaises(config.ConfigurationError): with self.assertRaises(config.ConfigurationError):
config.from_dictionary({ config.load(
'web': 'busybox:latest', config.ConfigDetails(
}) {'web': 'busybox:latest'},
'working_dir',
'filename.yml'
)
)
def test_config_validation(self): def test_config_validation(self):
self.assertRaises( self.assertRaises(
@ -335,9 +347,13 @@ class EnvTest(unittest.TestCase):
) )
def load_from_filename(filename):
return config.load(config.find('.', filename))
class ExtendsTest(unittest.TestCase): class ExtendsTest(unittest.TestCase):
def test_extends(self): def test_extends(self):
service_dicts = config.load('tests/fixtures/extends/docker-compose.yml') service_dicts = load_from_filename('tests/fixtures/extends/docker-compose.yml')
service_dicts = sorted( service_dicts = sorted(
service_dicts, service_dicts,
@ -364,7 +380,7 @@ class ExtendsTest(unittest.TestCase):
]) ])
def test_nested(self): def test_nested(self):
service_dicts = config.load('tests/fixtures/extends/nested.yml') service_dicts = load_from_filename('tests/fixtures/extends/nested.yml')
self.assertEqual(service_dicts, [ self.assertEqual(service_dicts, [
{ {
@ -380,7 +396,7 @@ class ExtendsTest(unittest.TestCase):
def test_circular(self): def test_circular(self):
try: try:
config.load('tests/fixtures/extends/circle-1.yml') load_from_filename('tests/fixtures/extends/circle-1.yml')
raise Exception("Expected config.CircularReference to be raised") raise Exception("Expected config.CircularReference to be raised")
except config.CircularReference as e: except config.CircularReference as e:
self.assertEqual( self.assertEqual(
@ -445,7 +461,7 @@ class ExtendsTest(unittest.TestCase):
print load_config() print load_config()
def test_volume_path(self): def test_volume_path(self):
dicts = config.load('tests/fixtures/volume-path/docker-compose.yml') dicts = load_from_filename('tests/fixtures/volume-path/docker-compose.yml')
paths = [ paths = [
'%s:/foo' % os.path.abspath('tests/fixtures/volume-path/common/foo'), '%s:/foo' % os.path.abspath('tests/fixtures/volume-path/common/foo'),
@ -455,7 +471,7 @@ class ExtendsTest(unittest.TestCase):
self.assertEqual(set(dicts[0]['volumes']), set(paths)) self.assertEqual(set(dicts[0]['volumes']), set(paths))
def test_parent_build_path_dne(self): def test_parent_build_path_dne(self):
child = config.load('tests/fixtures/extends/nonexistent-path-child.yml') child = load_from_filename('tests/fixtures/extends/nonexistent-path-child.yml')
self.assertEqual(child, [ self.assertEqual(child, [
{ {
@ -475,14 +491,16 @@ class BuildPathTest(unittest.TestCase):
self.abs_context_path = os.path.join(os.getcwd(), 'tests/fixtures/build-ctx') self.abs_context_path = os.path.join(os.getcwd(), 'tests/fixtures/build-ctx')
def test_nonexistent_path(self): def test_nonexistent_path(self):
options = {'build': 'nonexistent.path'} with self.assertRaises(config.ConfigurationError):
self.assertRaises( config.load(
config.ConfigurationError, config.ConfigDetails(
lambda: config.from_dictionary({ {
'foo': options, 'foo': {'build': 'nonexistent.path'},
'working_dir': 'tests/fixtures/build-path' },
}) 'working_dir',
) 'filename.yml'
)
)
def test_relative_path(self): def test_relative_path(self):
relative_build_path = '../build-ctx/' relative_build_path = '../build-ctx/'
@ -502,5 +520,56 @@ class BuildPathTest(unittest.TestCase):
self.assertEquals(service_dict['build'], self.abs_context_path) self.assertEquals(service_dict['build'], self.abs_context_path)
def test_from_file(self): def test_from_file(self):
service_dict = config.load('tests/fixtures/build-path/docker-compose.yml') service_dict = load_from_filename('tests/fixtures/build-path/docker-compose.yml')
self.assertEquals(service_dict, [{'name': 'foo', 'build': self.abs_context_path}]) self.assertEquals(service_dict, [{'name': 'foo', 'build': self.abs_context_path}])
class GetConfigPathTestCase(unittest.TestCase):
files = [
'docker-compose.yml',
'docker-compose.yaml',
'fig.yml',
'fig.yaml',
]
def test_get_config_path_default_file_in_basedir(self):
files = self.files
self.assertEqual('docker-compose.yml', get_config_filename_for_files(files[0:]))
self.assertEqual('docker-compose.yaml', get_config_filename_for_files(files[1:]))
self.assertEqual('fig.yml', get_config_filename_for_files(files[2:]))
self.assertEqual('fig.yaml', get_config_filename_for_files(files[3:]))
with self.assertRaises(config.ComposeFileNotFound):
get_config_filename_for_files([])
def test_get_config_path_default_file_in_parent_dir(self):
"""Test with files placed in the subdir"""
files = self.files
def get_config_in_subdir(files):
return get_config_filename_for_files(files, subdir=True)
self.assertEqual('docker-compose.yml', get_config_in_subdir(files[0:]))
self.assertEqual('docker-compose.yaml', get_config_in_subdir(files[1:]))
self.assertEqual('fig.yml', get_config_in_subdir(files[2:]))
self.assertEqual('fig.yaml', get_config_in_subdir(files[3:]))
with self.assertRaises(config.ComposeFileNotFound):
get_config_in_subdir([])
def get_config_filename_for_files(filenames, subdir=None):
def make_files(dirname, filenames):
for fname in filenames:
with open(os.path.join(dirname, fname), 'w') as f:
f.write('')
project_dir = tempfile.mkdtemp()
try:
make_files(project_dir, filenames)
if subdir:
base_dir = tempfile.mkdtemp(dir=project_dir)
else:
base_dir = project_dir
return os.path.basename(config.get_config_path(base_dir))
finally:
shutil.rmtree(project_dir)

View File

@ -3,7 +3,6 @@ from .. import unittest
from compose.service import Service from compose.service import Service
from compose.project import Project from compose.project import Project
from compose.container import Container from compose.container import Container
from compose import config
import mock import mock
import docker import docker
@ -51,14 +50,16 @@ class ProjectTest(unittest.TestCase):
self.assertEqual(project.services[2].name, 'web') self.assertEqual(project.services[2].name, 'web')
def test_from_config(self): def test_from_config(self):
dicts = config.from_dictionary({ dicts = [
'web': { {
'name': 'web',
'image': 'busybox:latest', 'image': 'busybox:latest',
}, },
'db': { {
'name': 'db',
'image': 'busybox:latest', 'image': 'busybox:latest',
}, },
}) ]
project = Project.from_dicts('composetest', dicts, None) project = Project.from_dicts('composetest', dicts, None)
self.assertEqual(len(project.services), 2) self.assertEqual(len(project.services), 2)
self.assertEqual(project.get_service('web').name, 'web') self.assertEqual(project.get_service('web').name, 'web')