diff --git a/compose/config/config.py b/compose/config/config.py index ffd805ad8..be680503b 100644 --- a/compose/config/config.py +++ b/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) diff --git a/compose/config/errors.py b/compose/config/errors.py index 99129f3de..f94ac7acd 100644 --- a/compose/config/errors.py +++ b/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 diff --git a/compose/config/fields_schema_v2.json b/compose/config/fields_schema_v2.0.json similarity index 94% rename from compose/config/fields_schema_v2.json rename to compose/config/fields_schema_v2.0.json index c001df686..7703adcd0 100644 --- a/compose/config/fields_schema_v2.json +++ b/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 diff --git a/compose/config/service_schema_v2.json b/compose/config/service_schema_v2.0.json similarity index 99% rename from compose/config/service_schema_v2.json rename to compose/config/service_schema_v2.0.json index ca9bb6715..8dd4faf5d 100644 --- a/compose/config/service_schema_v2.json +++ b/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", diff --git a/compose/config/types.py b/compose/config/types.py index 2e648e5a9..9bda71806 100644 --- a/compose/config/types.py +++ b/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 diff --git a/compose/config/validation.py b/compose/config/validation.py index 059820209..6b2401352 100644 --- a/compose/config/validation.py +++ b/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, diff --git a/compose/const.py b/compose/const.py index 6ff108fbd..0e307835c 100644 --- a/compose/const.py +++ b/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', } diff --git a/compose/project.py b/compose/project.py index d2787ecfc..6411f7cc3 100644 --- a/compose/project.py +++ b/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: diff --git a/docker-compose.spec b/docker-compose.spec index f7f2059fd..b3d8db399 100644 --- a/docker-compose.spec +++ b/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' ), ( diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index b69ce8aa7..447b1e323 100644 --- a/tests/acceptance/cli_test.py +++ b/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': { diff --git a/tests/fixtures/extends/invalid-net-v2.yml b/tests/fixtures/extends/invalid-net-v2.yml index 0a04f4680..7ba714e89 100644 --- a/tests/fixtures/extends/invalid-net-v2.yml +++ b/tests/fixtures/extends/invalid-net-v2.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: myweb: build: '.' diff --git a/tests/fixtures/logging-composefile/docker-compose.yml b/tests/fixtures/logging-composefile/docker-compose.yml index 0a73030ad..466d13e5b 100644 --- a/tests/fixtures/logging-composefile/docker-compose.yml +++ b/tests/fixtures/logging-composefile/docker-compose.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: simple: image: busybox:latest diff --git a/tests/fixtures/net-container/v2-invalid.yml b/tests/fixtures/net-container/v2-invalid.yml index eac4b5f18..9b8462958 100644 --- a/tests/fixtures/net-container/v2-invalid.yml +++ b/tests/fixtures/net-container/v2-invalid.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: foo: diff --git a/tests/fixtures/networks/bridge.yml b/tests/fixtures/networks/bridge.yml index 950983722..9fa7db820 100644 --- a/tests/fixtures/networks/bridge.yml +++ b/tests/fixtures/networks/bridge.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: web: diff --git a/tests/fixtures/networks/default-network-config.yml b/tests/fixtures/networks/default-network-config.yml index 275fae98d..4bd0989b7 100644 --- a/tests/fixtures/networks/default-network-config.yml +++ b/tests/fixtures/networks/default-network-config.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: simple: image: busybox:latest diff --git a/tests/fixtures/networks/docker-compose.yml b/tests/fixtures/networks/docker-compose.yml index 5351c0f08..c11fa6821 100644 --- a/tests/fixtures/networks/docker-compose.yml +++ b/tests/fixtures/networks/docker-compose.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: web: diff --git a/tests/fixtures/networks/external-default.yml b/tests/fixtures/networks/external-default.yml index 7b0797e55..5c9426b84 100644 --- a/tests/fixtures/networks/external-default.yml +++ b/tests/fixtures/networks/external-default.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: simple: image: busybox:latest diff --git a/tests/fixtures/networks/external-networks.yml b/tests/fixtures/networks/external-networks.yml index 644e3dda9..db75b7806 100644 --- a/tests/fixtures/networks/external-networks.yml +++ b/tests/fixtures/networks/external-networks.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: web: diff --git a/tests/fixtures/networks/missing-network.yml b/tests/fixtures/networks/missing-network.yml index 666f7d34b..41012535d 100644 --- a/tests/fixtures/networks/missing-network.yml +++ b/tests/fixtures/networks/missing-network.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: web: diff --git a/tests/fixtures/networks/network-mode.yml b/tests/fixtures/networks/network-mode.yml index 7ab63df82..e4d070b44 100644 --- a/tests/fixtures/networks/network-mode.yml +++ b/tests/fixtures/networks/network-mode.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: bridge: diff --git a/tests/fixtures/no-services/docker-compose.yml b/tests/fixtures/no-services/docker-compose.yml index fa4987846..6e76ec0c5 100644 --- a/tests/fixtures/no-services/docker-compose.yml +++ b/tests/fixtures/no-services/docker-compose.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" networks: foo: {} diff --git a/tests/fixtures/sleeps-composefile/docker-compose.yml b/tests/fixtures/sleeps-composefile/docker-compose.yml index 1eff7b730..7c8d84f8d 100644 --- a/tests/fixtures/sleeps-composefile/docker-compose.yml +++ b/tests/fixtures/sleeps-composefile/docker-compose.yml @@ -1,5 +1,5 @@ -version: 2 +version: "2" services: simple: diff --git a/tests/fixtures/v2-full/docker-compose.yml b/tests/fixtures/v2-full/docker-compose.yml index 725296c99..a973dd0cf 100644 --- a/tests/fixtures/v2-full/docker-compose.yml +++ b/tests/fixtures/v2-full/docker-compose.yml @@ -1,5 +1,5 @@ -version: 2 +version: "2" volumes: data: diff --git a/tests/fixtures/v2-simple/docker-compose.yml b/tests/fixtures/v2-simple/docker-compose.yml index 12a9de72c..c99ae02fc 100644 --- a/tests/fixtures/v2-simple/docker-compose.yml +++ b/tests/fixtures/v2-simple/docker-compose.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: simple: image: busybox:latest diff --git a/tests/fixtures/v2-simple/links-invalid.yml b/tests/fixtures/v2-simple/links-invalid.yml index 422f9314e..481aa4045 100644 --- a/tests/fixtures/v2-simple/links-invalid.yml +++ b/tests/fixtures/v2-simple/links-invalid.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: simple: image: busybox:latest diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 0c8c9a6ac..180c9df1b 100644 --- a/tests/integration/project_test.py +++ b/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', diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 5870946db..8e2f25937 100644 --- a/tests/integration/testcases.py +++ b/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) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 5f8b097b9..af256f20c 100644 --- a/tests/unit/config/config_test.py +++ b/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() + + assert "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" - - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + 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() + + 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'] diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py index 50b7efcb5..c741a339f 100644 --- a/tests/unit/config/types_test.py +++ b/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)