Răsfoiți Sursa

Merge pull request #2783 from aanand/fix-validation-v2

Fix validation and version checking
Aanand Prasad 9 ani în urmă
părinte
comite
60a5b39f6f

+ 39 - 19
compose/config/config.py

@@ -14,10 +14,12 @@ import six
 import yaml
 from cached_property import cached_property
 
-from ..const import COMPOSEFILE_VERSIONS
+from ..const import COMPOSEFILE_V1 as V1
+from ..const import COMPOSEFILE_V2_0 as V2_0
 from .errors import CircularReference
 from .errors import ComposeFileNotFound
 from .errors import ConfigurationError
+from .errors import VERSION_EXPLANATION
 from .interpolation import interpolate_environment_variables
 from .sort_services import get_container_name_from_network_mode
 from .sort_services import get_service_name_from_network_mode
@@ -103,6 +105,7 @@ SUPPORTED_FILENAMES = [
 
 DEFAULT_OVERRIDE_FILENAME = 'docker-compose.override.yml'
 
+
 log = logging.getLogger(__name__)
 
 
@@ -129,27 +132,48 @@ class ConfigFile(namedtuple('_ConfigFile', 'filename config')):
 
     @cached_property
     def version(self):
-        if self.config is None:
-            return 1
-        version = self.config.get('version', 1)
+        if 'version' not in self.config:
+            return V1
+
+        version = self.config['version']
+
         if isinstance(version, dict):
-            log.warn("Unexpected type for field 'version', in file {} assuming "
-                     "version is the name of a service, and defaulting to "
-                     "Compose file version 1".format(self.filename))
-            return 1
+            log.warn('Unexpected type for "version" key in "{}". Assuming '
+                     '"version" is the name of a service, and defaulting to '
+                     'Compose file version 1.'.format(self.filename))
+            return V1
+
+        if not isinstance(version, six.string_types):
+            raise ConfigurationError(
+                'Version in "{}" is invalid - it should be a string.'
+                .format(self.filename))
+
+        if version == '1':
+            raise ConfigurationError(
+                'Version in "{}" is invalid. {}'
+                .format(self.filename, VERSION_EXPLANATION))
+
+        if version == '2':
+            version = V2_0
+
+        if version != V2_0:
+            raise ConfigurationError(
+                'Version in "{}" is unsupported. {}'
+                .format(self.filename, VERSION_EXPLANATION))
+
         return version
 
     def get_service(self, name):
         return self.get_service_dicts()[name]
 
     def get_service_dicts(self):
-        return self.config if self.version == 1 else self.config.get('services', {})
+        return self.config if self.version == V1 else self.config.get('services', {})
 
     def get_volumes(self):
-        return {} if self.version == 1 else self.config.get('volumes', {})
+        return {} if self.version == V1 else self.config.get('volumes', {})
 
     def get_networks(self):
-        return {} if self.version == 1 else self.config.get('networks', {})
+        return {} if self.version == V1 else self.config.get('networks', {})
 
 
 class Config(namedtuple('_Config', 'version services volumes networks')):
@@ -211,10 +235,6 @@ def validate_config_version(config_files):
                     next_file.filename,
                     next_file.version))
 
-    if main_file.version not in COMPOSEFILE_VERSIONS:
-        raise ConfigurationError(
-            'Invalid Compose file version: {0}'.format(main_file.version))
-
 
 def get_default_config_files(base_dir):
     (candidates, path) = find_candidates_in_parent_dirs(SUPPORTED_FILENAMES, base_dir)
@@ -278,7 +298,7 @@ def load(config_details):
         main_file,
         [file.get_service_dicts() for file in config_details.config_files])
 
-    if main_file.version >= 2:
+    if main_file.version != V1:
         for service_dict in service_dicts:
             match_named_volumes(service_dict, volumes)
 
@@ -363,7 +383,7 @@ def process_config_file(config_file, service_name=None):
 
     interpolated_config = interpolate_environment_variables(service_dicts, 'service')
 
-    if config_file.version == 2:
+    if config_file.version == V2_0:
         processed_config = dict(config_file.config)
         processed_config['services'] = services = interpolated_config
         processed_config['volumes'] = interpolate_environment_variables(
@@ -371,7 +391,7 @@ def process_config_file(config_file, service_name=None):
         processed_config['networks'] = interpolate_environment_variables(
             config_file.get_networks(), 'network')
 
-    if config_file.version == 1:
+    if config_file.version == V1:
         processed_config = services = interpolated_config
 
     config_file = config_file._replace(config=processed_config)
@@ -655,7 +675,7 @@ def merge_service_dicts(base, override, version):
         if field in base or field in override:
             d[field] = override.get(field, base.get(field))
 
-    if version == 1:
+    if version == V1:
         legacy_v1_merge_image_or_build(d, base, override)
     else:
         merge_build(d, base, override)

+ 8 - 0
compose/config/errors.py

@@ -2,6 +2,14 @@ from __future__ import absolute_import
 from __future__ import unicode_literals
 
 
+VERSION_EXPLANATION = (
+    'Either specify a version of "2" (or "2.0") and place your service '
+    'definitions under the `services` key, or omit the `version` key and place '
+    'your service definitions at the root of the file to use version 1.\n'
+    'For more on the Compose file format versions, see '
+    'https://docs.docker.com/compose/compose-file/')
+
+
 class ConfigurationError(Exception):
     def __init__(self, msg):
         self.msg = msg

+ 3 - 3
compose/config/fields_schema_v2.json → compose/config/fields_schema_v2.0.json

@@ -1,18 +1,18 @@
 {
   "$schema": "http://json-schema.org/draft-04/schema#",
   "type": "object",
-  "id": "fields_schema_v2.json",
+  "id": "fields_schema_v2.0.json",
 
   "properties": {
     "version": {
-      "enum": [2]
+      "type": "string"
     },
     "services": {
       "id": "#/properties/services",
       "type": "object",
       "patternProperties": {
         "^[a-zA-Z0-9._-]+$": {
-          "$ref": "service_schema_v2.json#/definitions/service"
+          "$ref": "service_schema_v2.0.json#/definitions/service"
         }
       },
       "additionalProperties": false

+ 1 - 1
compose/config/service_schema_v2.json → compose/config/service_schema_v2.0.json

@@ -1,6 +1,6 @@
 {
   "$schema": "http://json-schema.org/draft-04/schema#",
-  "id": "service_schema_v2.json",
+  "id": "service_schema_v2.0.json",
 
   "type": "object",
 

+ 2 - 1
compose/config/types.py

@@ -7,6 +7,7 @@ from __future__ import unicode_literals
 import os
 from collections import namedtuple
 
+from compose.config.config import V1
 from compose.config.errors import ConfigurationError
 from compose.const import IS_WINDOWS_PLATFORM
 
@@ -16,7 +17,7 @@ class VolumeFromSpec(namedtuple('_VolumeFromSpec', 'source mode type')):
     # TODO: drop service_names arg when v1 is removed
     @classmethod
     def parse(cls, volume_from_config, service_names, version):
-        func = cls.parse_v1 if version == 1 else cls.parse_v2
+        func = cls.parse_v1 if version == V1 else cls.parse_v2
         return func(service_names, volume_from_config)
 
     @classmethod

+ 57 - 47
compose/config/validation.py

@@ -15,6 +15,7 @@ from jsonschema import RefResolver
 from jsonschema import ValidationError
 
 from .errors import ConfigurationError
+from .errors import VERSION_EXPLANATION
 from .sort_services import get_service_name_from_network_mode
 
 
@@ -174,8 +175,8 @@ def validate_depends_on(service_config, service_names):
                 "undefined.".format(s=service_config, dep=dependency))
 
 
-def get_unsupported_config_msg(service_name, error_key):
-    msg = "Unsupported config option for '{}' service: '{}'".format(service_name, error_key)
+def get_unsupported_config_msg(path, error_key):
+    msg = "Unsupported config option for {}: '{}'".format(path_string(path), error_key)
     if error_key in DOCKER_CONFIG_HINTS:
         msg += " (did you mean '{}'?)".format(DOCKER_CONFIG_HINTS[error_key])
     return msg
@@ -191,7 +192,7 @@ def is_service_dict_schema(schema_id):
     return schema_id == 'fields_schema_v1.json' or schema_id == '#/properties/services'
 
 
-def handle_error_for_schema_with_id(error, service_name):
+def handle_error_for_schema_with_id(error, path):
     schema_id = error.schema['id']
 
     if is_service_dict_schema(schema_id) and error.validator == 'additionalProperties':
@@ -215,62 +216,67 @@ def handle_error_for_schema_with_id(error, service_name):
         # TODO: only applies to v1
         if 'image' in error.instance and context:
             return (
-                "Service '{}' has both an image and build path specified. "
+                "{} has both an image and build path specified. "
                 "A service can either be built to image or use an existing "
-                "image, not both.".format(service_name))
+                "image, not both.".format(path_string(path)))
         if 'image' not in error.instance and not context:
             return (
-                "Service '{}' has neither an image nor a build path "
-                "specified. At least one must be provided.".format(service_name))
+                "{} has neither an image nor a build path specified. "
+                "At least one must be provided.".format(path_string(path)))
         # TODO: only applies to v1
         if 'image' in error.instance and dockerfile:
             return (
-                "Service '{}' has both an image and alternate Dockerfile. "
+                "{} has both an image and alternate Dockerfile. "
                 "A service can either be built to image or use an existing "
-                "image, not both.".format(service_name))
+                "image, not both.".format(path_string(path)))
 
-    if schema_id == '#/definitions/service':
-        if error.validator == 'additionalProperties':
+    if error.validator == 'additionalProperties':
+        if schema_id == '#/definitions/service':
             invalid_config_key = parse_key_from_error_msg(error)
-            return get_unsupported_config_msg(service_name, invalid_config_key)
+            return get_unsupported_config_msg(path, invalid_config_key)
 
+        if not error.path:
+            return '{}\n{}'.format(error.message, VERSION_EXPLANATION)
 
-def handle_generic_service_error(error, service_name):
-    config_key = " ".join("'%s'" % k for k in error.path)
+
+def handle_generic_service_error(error, path):
     msg_format = None
     error_msg = error.message
 
     if error.validator == 'oneOf':
-        msg_format = "Service '{}' configuration key {} {}"
-        error_msg = _parse_oneof_validator(error)
+        msg_format = "{path} {msg}"
+        config_key, error_msg = _parse_oneof_validator(error)
+        if config_key:
+            path.append(config_key)
 
     elif error.validator == 'type':
-        msg_format = ("Service '{}' configuration key {} contains an invalid "
-                      "type, it should be {}")
+        msg_format = "{path} contains an invalid type, it should be {msg}"
         error_msg = _parse_valid_types_from_validator(error.validator_value)
 
     # TODO: no test case for this branch, there are no config options
     # which exercise this branch
     elif error.validator == 'required':
-        msg_format = "Service '{}' configuration key '{}' is invalid, {}"
+        msg_format = "{path} is invalid, {msg}"
 
     elif error.validator == 'dependencies':
-        msg_format = "Service '{}' configuration key '{}' is invalid: {}"
         config_key = list(error.validator_value.keys())[0]
         required_keys = ",".join(error.validator_value[config_key])
+
+        msg_format = "{path} is invalid: {msg}"
+        path.append(config_key)
         error_msg = "when defining '{}' you must set '{}' as well".format(
             config_key,
             required_keys)
 
     elif error.cause:
         error_msg = six.text_type(error.cause)
-        msg_format = "Service '{}' configuration key {} is invalid: {}"
+        msg_format = "{path} is invalid: {msg}"
 
     elif error.path:
-        msg_format = "Service '{}' configuration key {} value {}"
+        msg_format = "{path} value {msg}"
 
     if msg_format:
-        return msg_format.format(service_name, config_key, error_msg)
+        return msg_format.format(path=path_string(path), msg=error_msg)
 
     return error.message
 
@@ -279,6 +285,10 @@ def parse_key_from_error_msg(error):
     return error.message.split("'")[1]
 
 
+def path_string(path):
+    return ".".join(c for c in path if isinstance(c, six.string_types))
+
+
 def _parse_valid_types_from_validator(validator):
     """A validator value can be either an array of valid types or a string of
     a valid type. Parse the valid types and prefix with the correct article.
@@ -304,52 +314,52 @@ def _parse_oneof_validator(error):
     for context in error.context:
 
         if context.validator == 'required':
-            return context.message
+            return (None, context.message)
 
         if context.validator == 'additionalProperties':
             invalid_config_key = parse_key_from_error_msg(context)
-            return "contains unsupported option: '{}'".format(invalid_config_key)
+            return (None, "contains unsupported option: '{}'".format(invalid_config_key))
 
         if context.path:
-            invalid_config_key = " ".join(
-                "'{}' ".format(fragment) for fragment in context.path
-                if isinstance(fragment, six.string_types)
+            return (
+                path_string(context.path),
+                "contains {}, which is an invalid type, it should be {}".format(
+                    json.dumps(context.instance),
+                    _parse_valid_types_from_validator(context.validator_value)),
             )
-            return "{}contains {}, which is an invalid type, it should be {}".format(
-                invalid_config_key,
-                # Always print the json repr of the invalid value
-                json.dumps(context.instance),
-                _parse_valid_types_from_validator(context.validator_value))
 
         if context.validator == 'uniqueItems':
-            return "contains non unique items, please remove duplicates from {}".format(
-                context.instance)
+            return (
+                None,
+                "contains non unique items, please remove duplicates from {}".format(
+                    context.instance),
+            )
 
         if context.validator == 'type':
             types.append(context.validator_value)
 
     valid_types = _parse_valid_types_from_validator(types)
-    return "contains an invalid type, it should be {}".format(valid_types)
+    return (None, "contains an invalid type, it should be {}".format(valid_types))
 
 
-def process_errors(errors, service_name=None):
+def process_errors(errors, path_prefix=None):
     """jsonschema gives us an error tree full of information to explain what has
     gone wrong. Process each error and pull out relevant information and re-write
     helpful error messages that are relevant.
     """
-    def format_error_message(error, service_name):
-        if not service_name and error.path:
-            # field_schema errors will have service name on the path
-            service_name = error.path.popleft()
+    path_prefix = path_prefix or []
+
+    def format_error_message(error):
+        path = path_prefix + list(error.path)
 
         if 'id' in error.schema:
-            error_msg = handle_error_for_schema_with_id(error, service_name)
+            error_msg = handle_error_for_schema_with_id(error, path)
             if error_msg:
                 return error_msg
 
-        return handle_generic_service_error(error, service_name)
+        return handle_generic_service_error(error, path)
 
-    return '\n'.join(format_error_message(error, service_name) for error in errors)
+    return '\n'.join(format_error_message(error) for error in errors)
 
 
 def validate_against_fields_schema(config_file):
@@ -366,14 +376,14 @@ def validate_against_service_schema(config, service_name, version):
         config,
         "service_schema_v{0}.json".format(version),
         format_checker=["ports"],
-        service_name=service_name)
+        path_prefix=[service_name])
 
 
 def _validate_against_schema(
         config,
         schema_filename,
         format_checker=(),
-        service_name=None,
+        path_prefix=None,
         filename=None):
     config_source_dir = os.path.dirname(os.path.abspath(__file__))
 
@@ -399,7 +409,7 @@ def _validate_against_schema(
     if not errors:
         return
 
-    error_msg = process_errors(errors, service_name)
+    error_msg = process_errors(errors, path_prefix=path_prefix)
     file_msg = " in file '{}'".format(filename) if filename else ''
     raise ConfigurationError("Validation failed{}, reason(s):\n{}".format(
         file_msg,

+ 5 - 3
compose/const.py

@@ -14,9 +14,11 @@ LABEL_PROJECT = 'com.docker.compose.project'
 LABEL_SERVICE = 'com.docker.compose.service'
 LABEL_VERSION = 'com.docker.compose.version'
 LABEL_CONFIG_HASH = 'com.docker.compose.config-hash'
-COMPOSEFILE_VERSIONS = (1, 2)
+
+COMPOSEFILE_V1 = '1'
+COMPOSEFILE_V2_0 = '2.0'
 
 API_VERSIONS = {
-    1: '1.21',
-    2: '1.22',
+    COMPOSEFILE_V1: '1.21',
+    COMPOSEFILE_V2_0: '1.22',
 }

+ 3 - 2
compose/project.py

@@ -10,6 +10,7 @@ from docker.errors import NotFound
 
 from . import parallel
 from .config import ConfigurationError
+from .config.config import V1
 from .config.sort_services import get_container_name_from_network_mode
 from .config.sort_services import get_service_name_from_network_mode
 from .const import DEFAULT_TIMEOUT
@@ -56,7 +57,7 @@ class Project(object):
         """
         Construct a Project from a config.Config object.
         """
-        use_networking = (config_data.version and config_data.version >= 2)
+        use_networking = (config_data.version and config_data.version != V1)
         project = cls(name, [], client, use_networking=use_networking)
 
         network_config = config_data.networks or {}
@@ -94,7 +95,7 @@ class Project(object):
             network_mode = project.get_network_mode(service_dict, networks)
             volumes_from = get_volumes_from(project, service_dict)
 
-            if config_data.version == 2:
+            if config_data.version != V1:
                 service_volumes = service_dict.get('volumes', [])
                 for volume_spec in service_volumes:
                     if volume_spec.is_named_volume:

+ 4 - 4
docker-compose.spec

@@ -23,8 +23,8 @@ exe = EXE(pyz,
                 'DATA'
             ),
             (
-                'compose/config/fields_schema_v2.json',
-                'compose/config/fields_schema_v2.json',
+                'compose/config/fields_schema_v2.0.json',
+                'compose/config/fields_schema_v2.0.json',
                 'DATA'
             ),
             (
@@ -33,8 +33,8 @@ exe = EXE(pyz,
                 'DATA'
             ),
             (
-                'compose/config/service_schema_v2.json',
-                'compose/config/service_schema_v2.json',
+                'compose/config/service_schema_v2.0.json',
+                'compose/config/service_schema_v2.0.json',
                 'DATA'
             ),
             (

+ 1 - 1
tests/acceptance/cli_test.py

@@ -177,7 +177,7 @@ class CLITestCase(DockerClientTestCase):
 
         output = yaml.load(result.stdout)
         expected = {
-            'version': 2,
+            'version': '2.0',
             'volumes': {'data': {'driver': 'local'}},
             'networks': {'front': {}},
             'services': {

+ 1 - 1
tests/fixtures/extends/invalid-net-v2.yml

@@ -1,4 +1,4 @@
-version: 2
+version: "2"
 services:
   myweb:
     build: '.'

+ 1 - 1
tests/fixtures/logging-composefile/docker-compose.yml

@@ -1,4 +1,4 @@
-version: 2
+version: "2"
 services:
   simple:
     image: busybox:latest

+ 1 - 1
tests/fixtures/net-container/v2-invalid.yml

@@ -1,4 +1,4 @@
-version: 2
+version: "2"
 
 services:
   foo:

+ 1 - 1
tests/fixtures/networks/bridge.yml

@@ -1,4 +1,4 @@
-version: 2
+version: "2"
 
 services:
   web:

+ 1 - 1
tests/fixtures/networks/default-network-config.yml

@@ -1,4 +1,4 @@
-version: 2
+version: "2"
 services:
   simple:
     image: busybox:latest

+ 1 - 1
tests/fixtures/networks/docker-compose.yml

@@ -1,4 +1,4 @@
-version: 2
+version: "2"
 
 services:
   web:

+ 1 - 1
tests/fixtures/networks/external-default.yml

@@ -1,4 +1,4 @@
-version: 2
+version: "2"
 services:
   simple:
     image: busybox:latest

+ 1 - 1
tests/fixtures/networks/external-networks.yml

@@ -1,4 +1,4 @@
-version: 2
+version: "2"
 
 services:
   web:

+ 1 - 1
tests/fixtures/networks/missing-network.yml

@@ -1,4 +1,4 @@
-version: 2
+version: "2"
 
 services:
   web:

+ 1 - 1
tests/fixtures/networks/network-mode.yml

@@ -1,4 +1,4 @@
-version: 2
+version: "2"
 
 services:
   bridge:

+ 1 - 1
tests/fixtures/no-services/docker-compose.yml

@@ -1,4 +1,4 @@
-version: 2
+version: "2"
 
 networks:
   foo: {}

+ 1 - 1
tests/fixtures/sleeps-composefile/docker-compose.yml

@@ -1,5 +1,5 @@
 
-version: 2
+version: "2"
 
 services:
   simple:

+ 1 - 1
tests/fixtures/v2-full/docker-compose.yml

@@ -1,5 +1,5 @@
 
-version: 2
+version: "2"
 
 volumes:
   data:

+ 1 - 1
tests/fixtures/v2-simple/docker-compose.yml

@@ -1,4 +1,4 @@
-version: 2
+version: "2"
 services:
   simple:
     image: busybox:latest

+ 1 - 1
tests/fixtures/v2-simple/links-invalid.yml

@@ -1,4 +1,4 @@
-version: 2
+version: "2"
 services:
   simple:
     image: busybox:latest

+ 15 - 14
tests/integration/project_test.py

@@ -10,6 +10,7 @@ from docker.errors import NotFound
 from .testcases import DockerClientTestCase
 from compose.config import config
 from compose.config import ConfigurationError
+from compose.config.config import V2_0
 from compose.config.types import VolumeFromSpec
 from compose.config.types import VolumeSpec
 from compose.const import LABEL_PROJECT
@@ -112,7 +113,7 @@ class ProjectTest(DockerClientTestCase):
             name='composetest',
             client=self.client,
             config_data=build_service_dicts({
-                'version': 2,
+                'version': V2_0,
                 'services': {
                     'net': {
                         'image': 'busybox:latest',
@@ -139,7 +140,7 @@ class ProjectTest(DockerClientTestCase):
             return Project.from_config(
                 name='composetest',
                 config_data=build_service_dicts({
-                    'version': 2,
+                    'version': V2_0,
                     'services': {
                         'web': {
                             'image': 'busybox:latest',
@@ -559,7 +560,7 @@ class ProjectTest(DockerClientTestCase):
     @v2_only()
     def test_project_up_networks(self):
         config_data = config.Config(
-            version=2,
+            version=V2_0,
             services=[{
                 'name': 'web',
                 'image': 'busybox:latest',
@@ -592,7 +593,7 @@ class ProjectTest(DockerClientTestCase):
     @v2_only()
     def test_up_with_ipam_config(self):
         config_data = config.Config(
-            version=2,
+            version=V2_0,
             services=[],
             volumes={},
             networks={
@@ -651,7 +652,7 @@ class ProjectTest(DockerClientTestCase):
         vol_name = '{0:x}'.format(random.getrandbits(32))
         full_vol_name = 'composetest_{0}'.format(vol_name)
         config_data = config.Config(
-            version=2,
+            version=V2_0,
             services=[{
                 'name': 'web',
                 'image': 'busybox:latest',
@@ -677,7 +678,7 @@ class ProjectTest(DockerClientTestCase):
         base_file = config.ConfigFile(
             'base.yml',
             {
-                'version': 2,
+                'version': V2_0,
                 'services': {
                     'simple': {'image': 'busybox:latest', 'command': 'top'},
                     'another': {
@@ -696,7 +697,7 @@ class ProjectTest(DockerClientTestCase):
         override_file = config.ConfigFile(
             'override.yml',
             {
-                'version': 2,
+                'version': V2_0,
                 'services': {
                     'another': {
                         'logging': {
@@ -729,7 +730,7 @@ class ProjectTest(DockerClientTestCase):
         vol_name = '{0:x}'.format(random.getrandbits(32))
         full_vol_name = 'composetest_{0}'.format(vol_name)
         config_data = config.Config(
-            version=2,
+            version=V2_0,
             services=[{
                 'name': 'web',
                 'image': 'busybox:latest',
@@ -754,7 +755,7 @@ class ProjectTest(DockerClientTestCase):
         vol_name = '{0:x}'.format(random.getrandbits(32))
         full_vol_name = 'composetest_{0}'.format(vol_name)
         config_data = config.Config(
-            version=2,
+            version=V2_0,
             services=[{
                 'name': 'web',
                 'image': 'busybox:latest',
@@ -779,7 +780,7 @@ class ProjectTest(DockerClientTestCase):
         vol_name = '{0:x}'.format(random.getrandbits(32))
 
         config_data = config.Config(
-            version=2,
+            version=V2_0,
             services=[{
                 'name': 'web',
                 'image': 'busybox:latest',
@@ -802,7 +803,7 @@ class ProjectTest(DockerClientTestCase):
         full_vol_name = 'composetest_{0}'.format(vol_name)
 
         config_data = config.Config(
-            version=2,
+            version=V2_0,
             services=[{
                 'name': 'web',
                 'image': 'busybox:latest',
@@ -841,7 +842,7 @@ class ProjectTest(DockerClientTestCase):
         full_vol_name = 'composetest_{0}'.format(vol_name)
         self.client.create_volume(vol_name)
         config_data = config.Config(
-            version=2,
+            version=V2_0,
             services=[{
                 'name': 'web',
                 'image': 'busybox:latest',
@@ -866,7 +867,7 @@ class ProjectTest(DockerClientTestCase):
         vol_name = '{0:x}'.format(random.getrandbits(32))
 
         config_data = config.Config(
-            version=2,
+            version=V2_0,
             services=[{
                 'name': 'web',
                 'image': 'busybox:latest',
@@ -895,7 +896,7 @@ class ProjectTest(DockerClientTestCase):
         base_file = config.ConfigFile(
             'base.yml',
             {
-                'version': 2,
+                'version': V2_0,
                 'services': {
                     'simple': {
                         'image': 'busybox:latest',

+ 4 - 2
tests/integration/testcases.py

@@ -10,6 +10,8 @@ from pytest import skip
 from .. import unittest
 from compose.cli.docker_client import docker_client
 from compose.config.config import resolve_environment
+from compose.config.config import V1
+from compose.config.config import V2_0
 from compose.const import API_VERSIONS
 from compose.const import LABEL_PROJECT
 from compose.progress_stream import stream_output
@@ -54,9 +56,9 @@ class DockerClientTestCase(unittest.TestCase):
     @classmethod
     def setUpClass(cls):
         if engine_version_too_low_for_v2():
-            version = API_VERSIONS[1]
+            version = API_VERSIONS[V1]
         else:
-            version = API_VERSIONS[2]
+            version = API_VERSIONS[V2_0]
 
         cls.client = docker_client(version)
 

+ 249 - 132
tests/unit/config/config_test.py

@@ -14,14 +14,16 @@ import pytest
 from compose.config import config
 from compose.config.config import resolve_build_args
 from compose.config.config import resolve_environment
+from compose.config.config import V1
+from compose.config.config import V2_0
 from compose.config.errors import ConfigurationError
+from compose.config.errors import VERSION_EXPLANATION
 from compose.config.types import VolumeSpec
 from compose.const import IS_WINDOWS_PLATFORM
 from tests import mock
 from tests import unittest
 
-DEFAULT_VERSION = V2 = 2
-V1 = 1
+DEFAULT_VERSION = V2_0
 
 
 def make_service_dict(name, service_dict, working_dir, filename=None):
@@ -78,7 +80,7 @@ class ConfigTest(unittest.TestCase):
     def test_load_v2(self):
         config_data = config.load(
             build_config_details({
-                'version': 2,
+                'version': '2',
                 'services': {
                     'foo': {'image': 'busybox'},
                     'bar': {'image': 'busybox', 'environment': ['FOO=1']},
@@ -143,9 +145,78 @@ class ConfigTest(unittest.TestCase):
             }
         })
 
+    def test_valid_versions(self):
+        for version in ['2', '2.0']:
+            cfg = config.load(build_config_details({'version': version}))
+            assert cfg.version == V2_0
+
+    def test_v1_file_version(self):
+        cfg = config.load(build_config_details({'web': {'image': 'busybox'}}))
+        assert cfg.version == V1
+        assert list(s['name'] for s in cfg.services) == ['web']
+
+        cfg = config.load(build_config_details({'version': {'image': 'busybox'}}))
+        assert cfg.version == V1
+        assert list(s['name'] for s in cfg.services) == ['version']
+
+    def test_wrong_version_type(self):
+        for version in [None, 1, 2, 2.0]:
+            with pytest.raises(ConfigurationError) as excinfo:
+                config.load(
+                    build_config_details(
+                        {'version': version},
+                        filename='filename.yml',
+                    )
+                )
+
+            assert 'Version in "filename.yml" is invalid - it should be a string.' \
+                in excinfo.exconly()
+
+    def test_unsupported_version(self):
+        with pytest.raises(ConfigurationError) as excinfo:
+            config.load(
+                build_config_details(
+                    {'version': '2.1'},
+                    filename='filename.yml',
+                )
+            )
+
+        assert 'Version in "filename.yml" is unsupported' in excinfo.exconly()
+        assert VERSION_EXPLANATION in excinfo.exconly()
+
+    def test_version_1_is_invalid(self):
+        with pytest.raises(ConfigurationError) as excinfo:
+            config.load(
+                build_config_details(
+                    {
+                        'version': '1',
+                        'web': {'image': 'busybox'},
+                    },
+                    filename='filename.yml',
+                )
+            )
+
+        assert 'Version in "filename.yml" is invalid' in excinfo.exconly()
+        assert VERSION_EXPLANATION in excinfo.exconly()
+
+    def test_v1_file_with_version_is_invalid(self):
+        with pytest.raises(ConfigurationError) as excinfo:
+            config.load(
+                build_config_details(
+                    {
+                        'version': '2',
+                        'web': {'image': 'busybox'},
+                    },
+                    filename='filename.yml',
+                )
+            )
+
+        assert 'Additional properties are not allowed' in excinfo.exconly()
+        assert VERSION_EXPLANATION in excinfo.exconly()
+
     def test_named_volume_config_empty(self):
         config_details = build_config_details({
-            'version': 2,
+            'version': '2',
             'services': {
                 'simple': {'image': 'busybox'}
             },
@@ -161,13 +232,18 @@ class ConfigTest(unittest.TestCase):
         assert volumes['other'] == {}
 
     def test_load_service_with_name_version(self):
-        config_data = config.load(
-            build_config_details({
-                'version': {
-                    'image': 'busybox'
-                }
-            }, 'working_dir', 'filename.yml')
-        )
+        with mock.patch('compose.config.config.log') as mock_logging:
+            config_data = config.load(
+                build_config_details({
+                    'version': {
+                        'image': 'busybox'
+                    }
+                }, 'working_dir', 'filename.yml')
+            )
+
+        assert 'Unexpected type for "version" key in "filename.yml"' \
+            in mock_logging.warn.call_args[0][0]
+
         service_dicts = config_data.services
         self.assertEqual(
             service_sort(service_dicts),
@@ -179,27 +255,6 @@ class ConfigTest(unittest.TestCase):
             ])
         )
 
-    def test_load_invalid_version(self):
-        with self.assertRaises(ConfigurationError):
-            config.load(
-                build_config_details({
-                    'version': 18,
-                    'services': {
-                        'foo': {'image': 'busybox'}
-                    }
-                }, 'working_dir', 'filename.yml')
-            )
-
-        with self.assertRaises(ConfigurationError):
-            config.load(
-                build_config_details({
-                    'version': 'two point oh',
-                    'services': {
-                        'foo': {'image': 'busybox'}
-                    }
-                }, 'working_dir', 'filename.yml')
-            )
-
     def test_load_throws_error_when_not_dict(self):
         with self.assertRaises(ConfigurationError):
             config.load(
@@ -214,7 +269,7 @@ class ConfigTest(unittest.TestCase):
         with self.assertRaises(ConfigurationError):
             config.load(
                 build_config_details(
-                    {'version': 2, 'services': {'web': 'busybox:latest'}},
+                    {'version': '2', 'services': {'web': 'busybox:latest'}},
                     'working_dir',
                     'filename.yml'
                 )
@@ -224,7 +279,7 @@ class ConfigTest(unittest.TestCase):
         with self.assertRaises(ConfigurationError):
             config.load(
                 build_config_details({
-                    'version': 2,
+                    'version': '2',
                     'services': {'web': 'busybox:latest'},
                     'networks': {
                         'invalid': {'foo', 'bar'}
@@ -246,22 +301,38 @@ class ConfigTest(unittest.TestCase):
             with pytest.raises(ConfigurationError) as exc:
                 config.load(
                     build_config_details({
-                        'version': 2,
+                        'version': '2',
                         'services': {invalid_name: {'image': 'busybox'}}
                     }, 'working_dir', 'filename.yml')
                 )
             assert 'Invalid service name \'%s\'' % invalid_name in exc.exconly()
 
     def test_load_with_invalid_field_name(self):
-        config_details = build_config_details(
-            {'web': {'image': 'busybox', 'name': 'bogus'}},
-            'working_dir',
-            'filename.yml')
         with pytest.raises(ConfigurationError) as exc:
-            config.load(config_details)
-        error_msg = "Unsupported config option for 'web' service: 'name'"
-        assert error_msg in exc.exconly()
-        assert "Validation failed in file 'filename.yml'" in exc.exconly()
+            config.load(build_config_details(
+                {
+                    'version': '2',
+                    'services': {
+                        'web': {'image': 'busybox', 'name': 'bogus'},
+                    }
+                },
+                'working_dir',
+                'filename.yml',
+            ))
+
+        assert "Unsupported config option for services.web: 'name'" in exc.exconly()
+
+    def test_load_with_invalid_field_name_v1(self):
+        with pytest.raises(ConfigurationError) as exc:
+            config.load(build_config_details(
+                {
+                    'web': {'image': 'busybox', 'name': 'bogus'},
+                },
+                'working_dir',
+                'filename.yml',
+            ))
+
+        assert "Unsupported config option for web: 'name'" in exc.exconly()
 
     def test_load_invalid_service_definition(self):
         config_details = build_config_details(
@@ -274,9 +345,7 @@ class ConfigTest(unittest.TestCase):
         assert error_msg in exc.exconly()
 
     def test_config_integer_service_name_raise_validation_error(self):
-        expected_error_msg = ("In file 'filename.yml' service name: 1 needs to "
-                              "be a string, eg '1'")
-        with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
+        with pytest.raises(ConfigurationError) as excinfo:
             config.load(
                 build_config_details(
                     {1: {'image': 'busybox'}},
@@ -285,15 +354,15 @@ class ConfigTest(unittest.TestCase):
                 )
             )
 
-    def test_config_integer_service_name_raise_validation_error_v2(self):
-        expected_error_msg = ("In file 'filename.yml' service name: 1 needs to "
-                              "be a string, eg '1'")
+        assert "In file 'filename.yml' service name: 1 needs to be a string, eg '1'" \
+            in excinfo.exconly()
 
-        with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
+    def test_config_integer_service_name_raise_validation_error_v2(self):
+        with pytest.raises(ConfigurationError) as excinfo:
             config.load(
                 build_config_details(
                     {
-                        'version': 2,
+                        'version': '2',
                         'services': {1: {'image': 'busybox'}}
                     },
                     'working_dir',
@@ -301,6 +370,9 @@ class ConfigTest(unittest.TestCase):
                 )
             )
 
+        assert "In file 'filename.yml' service name: 1 needs to be a string, eg '1'" \
+            in excinfo.exconly()
+
     def test_load_with_multiple_files_v1(self):
         base_file = config.ConfigFile(
             'base.yaml',
@@ -353,7 +425,7 @@ class ConfigTest(unittest.TestCase):
     def test_load_with_multiple_files_and_empty_override_v2(self):
         base_file = config.ConfigFile(
             'base.yml',
-            {'version': 2, 'services': {'web': {'image': 'example/web'}}})
+            {'version': '2', 'services': {'web': {'image': 'example/web'}}})
         override_file = config.ConfigFile('override.yml', None)
         details = config.ConfigDetails('.', [base_file, override_file])
 
@@ -377,7 +449,7 @@ class ConfigTest(unittest.TestCase):
         base_file = config.ConfigFile('base.yml', None)
         override_file = config.ConfigFile(
             'override.tml',
-            {'version': 2, 'services': {'web': {'image': 'example/web'}}}
+            {'version': '2', 'services': {'web': {'image': 'example/web'}}}
         )
         details = config.ConfigDetails('.', [base_file, override_file])
         with pytest.raises(ConfigurationError) as exc:
@@ -477,7 +549,7 @@ class ConfigTest(unittest.TestCase):
             config.load(
                 build_config_details(
                     {
-                        'version': 2,
+                        'version': '2',
                         'services': {
                             'web': {
                                 'build': '.',
@@ -492,7 +564,7 @@ class ConfigTest(unittest.TestCase):
 
         service = config.load(
             build_config_details({
-                'version': 2,
+                'version': '2',
                 'services': {
                     'web': {
                         'build': '.'
@@ -505,7 +577,7 @@ class ConfigTest(unittest.TestCase):
         service = config.load(
             build_config_details(
                 {
-                    'version': 2,
+                    'version': '2',
                     'services': {
                         'web': {
                             'build': {
@@ -526,7 +598,7 @@ class ConfigTest(unittest.TestCase):
         base_file = config.ConfigFile(
             'base.yaml',
             {
-                'version': 2,
+                'version': '2',
                 'services': {
                     'web': {
                         'image': 'example/web',
@@ -539,7 +611,7 @@ class ConfigTest(unittest.TestCase):
         override_file = config.ConfigFile(
             'override.yaml',
             {
-                'version': 2,
+                'version': '2',
                 'services': {
                     'web': {
                         'build': '/',
@@ -568,7 +640,7 @@ class ConfigTest(unittest.TestCase):
         base_file = config.ConfigFile(
             'base.yaml',
             {
-                'version': 2,
+                'version': '2',
                 'services': {
                     'web': {
                         'image': 'busybox:latest',
@@ -584,7 +656,7 @@ class ConfigTest(unittest.TestCase):
         base_file = config.ConfigFile(
             'base.yaml',
             {
-                'version': 2,
+                'version': '2',
                 'services': {
                     'web': {
                         'image': 'busybox:latest',
@@ -624,8 +696,7 @@ class ConfigTest(unittest.TestCase):
             assert services[0]['name'] == valid_name
 
     def test_config_hint(self):
-        expected_error_msg = "(did you mean 'privileged'?)"
-        with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
+        with pytest.raises(ConfigurationError) as excinfo:
             config.load(
                 build_config_details(
                     {
@@ -636,6 +707,8 @@ class ConfigTest(unittest.TestCase):
                 )
             )
 
+        assert "(did you mean 'privileged'?)" in excinfo.exconly()
+
     def test_load_errors_on_uppercase_with_no_image(self):
         with pytest.raises(ConfigurationError) as exc:
             config.load(build_config_details({
@@ -643,9 +716,41 @@ class ConfigTest(unittest.TestCase):
             }, 'tests/fixtures/build-ctx'))
             assert "Service 'Foo' contains uppercase characters" in exc.exconly()
 
-    def test_invalid_config_build_and_image_specified(self):
-        expected_error_msg = "Service 'foo' has both an image and build path specified."
-        with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
+    def test_invalid_config_v1(self):
+        with pytest.raises(ConfigurationError) as excinfo:
+            config.load(
+                build_config_details(
+                    {
+                        'foo': {'image': 1},
+                    },
+                    'tests/fixtures/extends',
+                    'filename.yml'
+                )
+            )
+
+        assert "foo.image contains an invalid type, it should be a string" \
+            in excinfo.exconly()
+
+    def test_invalid_config_v2(self):
+        with pytest.raises(ConfigurationError) as excinfo:
+            config.load(
+                build_config_details(
+                    {
+                        'version': '2',
+                        'services': {
+                            'foo': {'image': 1},
+                        },
+                    },
+                    'tests/fixtures/extends',
+                    'filename.yml'
+                )
+            )
+
+        assert "services.foo.image contains an invalid type, it should be a string" \
+            in excinfo.exconly()
+
+    def test_invalid_config_build_and_image_specified_v1(self):
+        with pytest.raises(ConfigurationError) as excinfo:
             config.load(
                 build_config_details(
                     {
@@ -656,9 +761,10 @@ class ConfigTest(unittest.TestCase):
                 )
             )
 
+        assert "foo has both an image and build path specified." in excinfo.exconly()
+
     def test_invalid_config_type_should_be_an_array(self):
-        expected_error_msg = "Service 'foo' configuration key 'links' contains an invalid type, it should be an array"
-        with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
+        with pytest.raises(ConfigurationError) as excinfo:
             config.load(
                 build_config_details(
                     {
@@ -669,10 +775,11 @@ class ConfigTest(unittest.TestCase):
                 )
             )
 
+        assert "foo.links contains an invalid type, it should be an array" \
+            in excinfo.exconly()
+
     def test_invalid_config_not_a_dictionary(self):
-        expected_error_msg = ("Top level object in 'filename.yml' needs to be "
-                              "an object.")
-        with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
+        with pytest.raises(ConfigurationError) as excinfo:
             config.load(
                 build_config_details(
                     ['foo', 'lol'],
@@ -681,9 +788,11 @@ class ConfigTest(unittest.TestCase):
                 )
             )
 
+        assert "Top level object in 'filename.yml' needs to be an object" \
+            in excinfo.exconly()
+
     def test_invalid_config_not_unique_items(self):
-        expected_error_msg = "has non-unique elements"
-        with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
+        with pytest.raises(ConfigurationError) as excinfo:
             config.load(
                 build_config_details(
                     {
@@ -694,10 +803,10 @@ class ConfigTest(unittest.TestCase):
                 )
             )
 
+        assert "has non-unique elements" in excinfo.exconly()
+
     def test_invalid_list_of_strings_format(self):
-        expected_error_msg = "Service 'web' configuration key 'command' contains 1"
-        expected_error_msg += ", which is an invalid type, it should be a string"
-        with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
+        with pytest.raises(ConfigurationError) as excinfo:
             config.load(
                 build_config_details(
                     {
@@ -708,7 +817,10 @@ class ConfigTest(unittest.TestCase):
                 )
             )
 
-    def test_load_config_dockerfile_without_build_raises_error(self):
+        assert "web.command contains 1, which is an invalid type, it should be a string" \
+            in excinfo.exconly()
+
+    def test_load_config_dockerfile_without_build_raises_error_v1(self):
         with pytest.raises(ConfigurationError) as exc:
             config.load(build_config_details({
                 'web': {
@@ -716,12 +828,11 @@ class ConfigTest(unittest.TestCase):
                     'dockerfile': 'Dockerfile.alt'
                 }
             }))
-        assert "Service 'web' has both an image and alternate Dockerfile." in exc.exconly()
 
-    def test_config_extra_hosts_string_raises_validation_error(self):
-        expected_error_msg = "Service 'web' configuration key 'extra_hosts' contains an invalid type"
+        assert "web has both an image and alternate Dockerfile." in exc.exconly()
 
-        with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
+    def test_config_extra_hosts_string_raises_validation_error(self):
+        with pytest.raises(ConfigurationError) as excinfo:
             config.load(
                 build_config_details(
                     {'web': {
@@ -733,12 +844,11 @@ class ConfigTest(unittest.TestCase):
                 )
             )
 
-    def test_config_extra_hosts_list_of_dicts_validation_error(self):
-        expected_error_msg = (
-            "key 'extra_hosts' contains {\"somehost\": \"162.242.195.82\"}, "
-            "which is an invalid type, it should be a string")
+        assert "web.extra_hosts contains an invalid type" \
+            in excinfo.exconly()
 
-        with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
+    def test_config_extra_hosts_list_of_dicts_validation_error(self):
+        with pytest.raises(ConfigurationError) as excinfo:
             config.load(
                 build_config_details(
                     {'web': {
@@ -753,10 +863,11 @@ class ConfigTest(unittest.TestCase):
                 )
             )
 
-    def test_config_ulimits_invalid_keys_validation_error(self):
-        expected = ("Service 'web' configuration key 'ulimits' 'nofile' contains "
-                    "unsupported option: 'not_soft_or_hard'")
+        assert "web.extra_hosts contains {\"somehost\": \"162.242.195.82\"}, " \
+               "which is an invalid type, it should be a string" \
+            in excinfo.exconly()
 
+    def test_config_ulimits_invalid_keys_validation_error(self):
         with pytest.raises(ConfigurationError) as exc:
             config.load(build_config_details(
                 {
@@ -773,10 +884,11 @@ class ConfigTest(unittest.TestCase):
                 },
                 'working_dir',
                 'filename.yml'))
-        assert expected in exc.exconly()
 
-    def test_config_ulimits_required_keys_validation_error(self):
+        assert "web.ulimits.nofile contains unsupported option: 'not_soft_or_hard'" \
+            in exc.exconly()
 
+    def test_config_ulimits_required_keys_validation_error(self):
         with pytest.raises(ConfigurationError) as exc:
             config.load(build_config_details(
                 {
@@ -787,7 +899,7 @@ class ConfigTest(unittest.TestCase):
                 },
                 'working_dir',
                 'filename.yml'))
-        assert "Service 'web' configuration key 'ulimits' 'nofile'" in exc.exconly()
+        assert "web.ulimits.nofile" in exc.exconly()
         assert "'hard' is a required property" in exc.exconly()
 
     def test_config_ulimits_soft_greater_than_hard_error(self):
@@ -888,7 +1000,7 @@ class ConfigTest(unittest.TestCase):
                     'extra_hosts': "www.example.com: 192.168.0.17",
                 }
             }))
-        assert "'extra_hosts' contains an invalid type" in exc.exconly()
+        assert "web.extra_hosts contains an invalid type" in exc.exconly()
 
     def test_validate_extra_hosts_invalid_list(self):
         with pytest.raises(ConfigurationError) as exc:
@@ -959,7 +1071,7 @@ class ConfigTest(unittest.TestCase):
 
     def test_external_volume_config(self):
         config_details = build_config_details({
-            'version': 2,
+            'version': '2',
             'services': {
                 'bogus': {'image': 'busybox'}
             },
@@ -977,7 +1089,7 @@ class ConfigTest(unittest.TestCase):
 
     def test_external_volume_invalid_config(self):
         config_details = build_config_details({
-            'version': 2,
+            'version': '2',
             'services': {
                 'bogus': {'image': 'busybox'}
             },
@@ -990,7 +1102,7 @@ class ConfigTest(unittest.TestCase):
 
     def test_depends_on_orders_services(self):
         config_details = build_config_details({
-            'version': 2,
+            'version': '2',
             'services': {
                 'one': {'image': 'busybox', 'depends_on': ['three', 'two']},
                 'two': {'image': 'busybox', 'depends_on': ['three']},
@@ -1005,7 +1117,7 @@ class ConfigTest(unittest.TestCase):
 
     def test_depends_on_unknown_service_errors(self):
         config_details = build_config_details({
-            'version': 2,
+            'version': '2',
             'services': {
                 'one': {'image': 'busybox', 'depends_on': ['three']},
             },
@@ -1018,7 +1130,7 @@ class ConfigTest(unittest.TestCase):
 class NetworkModeTest(unittest.TestCase):
     def test_network_mode_standard(self):
         config_data = config.load(build_config_details({
-            'version': 2,
+            'version': '2',
             'services': {
                 'web': {
                     'image': 'busybox',
@@ -1044,7 +1156,7 @@ class NetworkModeTest(unittest.TestCase):
 
     def test_network_mode_container(self):
         config_data = config.load(build_config_details({
-            'version': 2,
+            'version': '2',
             'services': {
                 'web': {
                     'image': 'busybox',
@@ -1069,7 +1181,7 @@ class NetworkModeTest(unittest.TestCase):
 
     def test_network_mode_service(self):
         config_data = config.load(build_config_details({
-            'version': 2,
+            'version': '2',
             'services': {
                 'web': {
                     'image': 'busybox',
@@ -1103,7 +1215,7 @@ class NetworkModeTest(unittest.TestCase):
     def test_network_mode_service_nonexistent(self):
         with pytest.raises(ConfigurationError) as excinfo:
             config.load(build_config_details({
-                'version': 2,
+                'version': '2',
                 'services': {
                     'web': {
                         'image': 'busybox',
@@ -1118,7 +1230,7 @@ class NetworkModeTest(unittest.TestCase):
     def test_network_mode_plus_networks_is_invalid(self):
         with pytest.raises(ConfigurationError) as excinfo:
             config.load(build_config_details({
-                'version': 2,
+                'version': '2',
                 'services': {
                     'web': {
                         'image': 'busybox',
@@ -1574,11 +1686,7 @@ class MemoryOptionsTest(unittest.TestCase):
         When you set a 'memswap_limit' it is invalid config unless you also set
         a mem_limit
         """
-        expected_error_msg = (
-            "Service 'foo' configuration key 'memswap_limit' is invalid: when "
-            "defining 'memswap_limit' you must set 'mem_limit' as well"
-        )
-        with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
+        with pytest.raises(ConfigurationError) as excinfo:
             config.load(
                 build_config_details(
                     {
@@ -1589,6 +1697,10 @@ class MemoryOptionsTest(unittest.TestCase):
                 )
             )
 
+        assert "foo.memswap_limit is invalid: when defining " \
+               "'memswap_limit' you must set 'mem_limit' as well" \
+            in excinfo.exconly()
+
     def test_validation_with_correct_memswap_values(self):
         service_dict = config.load(
             build_config_details(
@@ -1851,7 +1963,7 @@ class ExtendsTest(unittest.TestCase):
         self.assertEqual(path, expected)
 
     def test_extends_validation_empty_dictionary(self):
-        with self.assertRaisesRegexp(ConfigurationError, 'service'):
+        with pytest.raises(ConfigurationError) as excinfo:
             config.load(
                 build_config_details(
                     {
@@ -1862,8 +1974,10 @@ class ExtendsTest(unittest.TestCase):
                 )
             )
 
+        assert 'service' in excinfo.exconly()
+
     def test_extends_validation_missing_service_key(self):
-        with self.assertRaisesRegexp(ConfigurationError, "'service' is a required property"):
+        with pytest.raises(ConfigurationError) as excinfo:
             config.load(
                 build_config_details(
                     {
@@ -1874,12 +1988,10 @@ class ExtendsTest(unittest.TestCase):
                 )
             )
 
+        assert "'service' is a required property" in excinfo.exconly()
+
     def test_extends_validation_invalid_key(self):
-        expected_error_msg = (
-            "Service 'web' configuration key 'extends' "
-            "contains unsupported option: 'rogue_key'"
-        )
-        with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
+        with pytest.raises(ConfigurationError) as excinfo:
             config.load(
                 build_config_details(
                     {
@@ -1897,12 +2009,11 @@ class ExtendsTest(unittest.TestCase):
                 )
             )
 
+        assert "web.extends contains unsupported option: 'rogue_key'" \
+            in excinfo.exconly()
+
     def test_extends_validation_sub_property_key(self):
-        expected_error_msg = (
-            "Service 'web' configuration key 'extends' 'file' contains 1, "
-            "which is an invalid type, it should be a string"
-        )
-        with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
+        with pytest.raises(ConfigurationError) as excinfo:
             config.load(
                 build_config_details(
                     {
@@ -1919,13 +2030,16 @@ class ExtendsTest(unittest.TestCase):
                 )
             )
 
+        assert "web.extends.file contains 1, which is an invalid type, it should be a string" \
+            in excinfo.exconly()
+
     def test_extends_validation_no_file_key_no_filename_set(self):
         dictionary = {'extends': {'service': 'web'}}
 
-        def load_config():
-            return make_service_dict('myweb', dictionary, working_dir='tests/fixtures/extends')
+        with pytest.raises(ConfigurationError) as excinfo:
+            make_service_dict('myweb', dictionary, working_dir='tests/fixtures/extends')
 
-        self.assertRaisesRegexp(ConfigurationError, 'file', load_config)
+        assert 'file' in excinfo.exconly()
 
     def test_extends_validation_valid_config(self):
         service = config.load(
@@ -1946,7 +2060,7 @@ class ExtendsTest(unittest.TestCase):
         with pytest.raises(ConfigurationError) as exc:
             load_from_filename('tests/fixtures/extends/service-with-invalid-schema.yml')
         assert (
-            "Service 'myweb' has neither an image nor a build path specified" in
+            "myweb has neither an image nor a build path specified" in
             exc.exconly()
         )
 
@@ -1979,16 +2093,17 @@ class ExtendsTest(unittest.TestCase):
         ]))
 
     def test_invalid_links_in_extended_service(self):
-        expected_error_msg = "services with 'links' cannot be extended"
-        with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
+        with pytest.raises(ConfigurationError) as excinfo:
             load_from_filename('tests/fixtures/extends/invalid-links.yml')
 
-    def test_invalid_volumes_from_in_extended_service(self):
-        expected_error_msg = "services with 'volumes_from' cannot be extended"
+        assert "services with 'links' cannot be extended" in excinfo.exconly()
 
-        with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
+    def test_invalid_volumes_from_in_extended_service(self):
+        with pytest.raises(ConfigurationError) as excinfo:
             load_from_filename('tests/fixtures/extends/invalid-volumes.yml')
 
+        assert "services with 'volumes_from' cannot be extended" in excinfo.exconly()
+
     def test_invalid_net_in_extended_service(self):
         with pytest.raises(ConfigurationError) as excinfo:
             load_from_filename('tests/fixtures/extends/invalid-net-v2.yml')
@@ -2044,10 +2159,12 @@ class ExtendsTest(unittest.TestCase):
         ])
 
     def test_load_throws_error_when_base_service_does_not_exist(self):
-        err_msg = r'''Cannot extend service 'foo' in .*: Service not found'''
-        with self.assertRaisesRegexp(ConfigurationError, err_msg):
+        with pytest.raises(ConfigurationError) as excinfo:
             load_from_filename('tests/fixtures/extends/nonexistent-service.yml')
 
+        assert "Cannot extend service 'foo'" in excinfo.exconly()
+        assert "Service not found" in excinfo.exconly()
+
     def test_partial_service_config_in_extends_is_still_valid(self):
         dicts = load_from_filename('tests/fixtures/extends/valid-common-config.yml')
         self.assertEqual(dicts[0]['environment'], {'FOO': '1'})
@@ -2140,7 +2257,7 @@ class ExtendsTest(unittest.TestCase):
         tmpdir = py.test.ensuretemp('test_extends_with_mixed_version')
         self.addCleanup(tmpdir.remove)
         tmpdir.join('docker-compose.yml').write("""
-            version: 2
+            version: "2"
             services:
               web:
                 extends:
@@ -2162,7 +2279,7 @@ class ExtendsTest(unittest.TestCase):
         tmpdir = py.test.ensuretemp('test_extends_with_defined_version')
         self.addCleanup(tmpdir.remove)
         tmpdir.join('docker-compose.yml').write("""
-            version: 2
+            version: "2"
             services:
               web:
                 extends:
@@ -2171,7 +2288,7 @@ class ExtendsTest(unittest.TestCase):
                 image: busybox
         """)
         tmpdir.join('base.yml').write("""
-            version: 2
+            version: "2"
             services:
                 base:
                   volumes: ['/foo']

+ 8 - 8
tests/unit/config/types_test.py

@@ -3,13 +3,13 @@ from __future__ import unicode_literals
 
 import pytest
 
+from compose.config.config import V1
+from compose.config.config import V2_0
 from compose.config.errors import ConfigurationError
 from compose.config.types import parse_extra_hosts
 from compose.config.types import VolumeFromSpec
 from compose.config.types import VolumeSpec
 from compose.const import IS_WINDOWS_PLATFORM
-from tests.unit.config.config_test import V1
-from tests.unit.config.config_test import V2
 
 
 def test_parse_extra_hosts_list():
@@ -91,26 +91,26 @@ class TestVolumesFromSpec(object):
             VolumeFromSpec.parse('unknown:format:ro', self.services, V1)
 
     def test_parse_v2_from_service(self):
-        volume_from = VolumeFromSpec.parse('servicea', self.services, V2)
+        volume_from = VolumeFromSpec.parse('servicea', self.services, V2_0)
         assert volume_from == VolumeFromSpec('servicea', 'rw', 'service')
 
     def test_parse_v2_from_service_with_mode(self):
-        volume_from = VolumeFromSpec.parse('servicea:ro', self.services, V2)
+        volume_from = VolumeFromSpec.parse('servicea:ro', self.services, V2_0)
         assert volume_from == VolumeFromSpec('servicea', 'ro', 'service')
 
     def test_parse_v2_from_container(self):
-        volume_from = VolumeFromSpec.parse('container:foo', self.services, V2)
+        volume_from = VolumeFromSpec.parse('container:foo', self.services, V2_0)
         assert volume_from == VolumeFromSpec('foo', 'rw', 'container')
 
     def test_parse_v2_from_container_with_mode(self):
-        volume_from = VolumeFromSpec.parse('container:foo:ro', self.services, V2)
+        volume_from = VolumeFromSpec.parse('container:foo:ro', self.services, V2_0)
         assert volume_from == VolumeFromSpec('foo', 'ro', 'container')
 
     def test_parse_v2_invalid_type(self):
         with pytest.raises(ConfigurationError) as exc:
-            VolumeFromSpec.parse('bogus:foo:ro', self.services, V2)
+            VolumeFromSpec.parse('bogus:foo:ro', self.services, V2_0)
         assert "Unknown volumes_from type 'bogus'" in exc.exconly()
 
     def test_parse_v2_invalid(self):
         with pytest.raises(ConfigurationError):
-            VolumeFromSpec.parse('unknown:format:ro', self.services, V2)
+            VolumeFromSpec.parse('unknown:format:ro', self.services, V2_0)