Pārlūkot izejas kodu

Merge pull request #3057 from shin-/2636-env-file

Add support for a default environment file
Daniel Nephin 9 gadi atpakaļ
vecāks
revīzija
aa50023507

+ 1 - 1
.pre-commit-config.yaml

@@ -10,7 +10,7 @@
     - id: end-of-file-fixer
     - id: flake8
     - id: name-tests-test
-      exclude: 'tests/(helpers\.py|integration/testcases\.py)'
+      exclude: 'tests/(integration/testcases\.py|helpers\.py)'
     - id: requirements-txt-fixer
     - id: trailing-whitespace
 - repo: git://github.com/asottile/reorder_python_imports

+ 24 - 12
compose/cli/command.py

@@ -9,6 +9,7 @@ import six
 
 from . import verbose_proxy
 from .. import config
+from ..config.environment import Environment
 from ..const import API_VERSIONS
 from ..project import Project
 from .docker_client import docker_client
@@ -19,29 +20,34 @@ log = logging.getLogger(__name__)
 
 
 def project_from_options(project_dir, options):
+    environment = Environment.from_env_file(project_dir)
     return get_project(
         project_dir,
-        get_config_path_from_options(options),
+        get_config_path_from_options(project_dir, options, environment),
         project_name=options.get('--project-name'),
         verbose=options.get('--verbose'),
         host=options.get('--host'),
         tls_config=tls_config_from_options(options),
+        environment=environment
     )
 
 
-def get_config_path_from_options(options):
+def get_config_path_from_options(base_dir, options, environment):
     file_option = options.get('--file')
     if file_option:
         return file_option
 
-    config_files = os.environ.get('COMPOSE_FILE')
+    config_files = environment.get('COMPOSE_FILE')
     if config_files:
         return config_files.split(os.pathsep)
     return None
 
 
-def get_client(verbose=False, version=None, tls_config=None, host=None):
-    client = docker_client(version=version, tls_config=tls_config, host=host)
+def get_client(environment, verbose=False, version=None, tls_config=None, host=None):
+    client = docker_client(
+        version=version, tls_config=tls_config, host=host,
+        environment=environment
+    )
     if verbose:
         version_info = six.iteritems(client.version())
         log.info(get_version_info('full'))
@@ -53,27 +59,33 @@ def get_client(verbose=False, version=None, tls_config=None, host=None):
 
 
 def get_project(project_dir, config_path=None, project_name=None, verbose=False,
-                host=None, tls_config=None):
-    config_details = config.find(project_dir, config_path)
-    project_name = get_project_name(config_details.working_dir, project_name)
+                host=None, tls_config=None, environment=None):
+    if not environment:
+        environment = Environment.from_env_file(project_dir)
+    config_details = config.find(project_dir, config_path, environment)
+    project_name = get_project_name(
+        config_details.working_dir, project_name, environment
+    )
     config_data = config.load(config_details)
 
-    api_version = os.environ.get(
+    api_version = environment.get(
         'COMPOSE_API_VERSION',
         API_VERSIONS[config_data.version])
     client = get_client(
         verbose=verbose, version=api_version, tls_config=tls_config,
-        host=host
+        host=host, environment=environment
     )
 
     return Project.from_config(project_name, config_data, client)
 
 
-def get_project_name(working_dir, project_name=None):
+def get_project_name(working_dir, project_name=None, environment=None):
     def normalize_name(name):
         return re.sub(r'[^a-z0-9]', '', name.lower())
 
-    project_name = project_name or os.environ.get('COMPOSE_PROJECT_NAME')
+    if not environment:
+        environment = Environment.from_env_file(working_dir)
+    project_name = project_name or environment.get('COMPOSE_PROJECT_NAME')
     if project_name:
         return normalize_name(project_name)
 

+ 8 - 5
compose/cli/docker_client.py

@@ -2,7 +2,6 @@ from __future__ import absolute_import
 from __future__ import unicode_literals
 
 import logging
-import os
 
 from docker import Client
 from docker.errors import TLSParameterError
@@ -42,17 +41,17 @@ def tls_config_from_options(options):
         return None
 
 
-def docker_client(version=None, tls_config=None, host=None):
+def docker_client(environment, version=None, tls_config=None, host=None):
     """
     Returns a docker-py client configured using environment variables
     according to the same logic as the official Docker client.
     """
-    if 'DOCKER_CLIENT_TIMEOUT' in os.environ:
+    if 'DOCKER_CLIENT_TIMEOUT' in environment:
         log.warn("The DOCKER_CLIENT_TIMEOUT environment variable is deprecated.  "
                  "Please use COMPOSE_HTTP_TIMEOUT instead.")
 
     try:
-        kwargs = kwargs_from_env(assert_hostname=False)
+        kwargs = kwargs_from_env(assert_hostname=False, environment=environment)
     except TLSParameterError:
         raise UserError(
             "TLS configuration is invalid - make sure your DOCKER_TLS_VERIFY "
@@ -67,6 +66,10 @@ def docker_client(version=None, tls_config=None, host=None):
     if version:
         kwargs['version'] = version
 
-    kwargs['timeout'] = HTTP_TIMEOUT
+    timeout = environment.get('COMPOSE_HTTP_TIMEOUT')
+    if timeout:
+        kwargs['timeout'] = int(timeout)
+    else:
+        kwargs['timeout'] = HTTP_TIMEOUT
 
     return Client(**kwargs)

+ 8 - 2
compose/cli/main.py

@@ -17,6 +17,7 @@ from .. import __version__
 from ..config import config
 from ..config import ConfigurationError
 from ..config import parse_environment
+from ..config.environment import Environment
 from ..config.serialize import serialize_config
 from ..const import DEFAULT_TIMEOUT
 from ..const import IS_WINDOWS_PLATFORM
@@ -222,8 +223,13 @@ class TopLevelCommand(object):
             --services      Print the service names, one per line.
 
         """
-        config_path = get_config_path_from_options(config_options)
-        compose_config = config.load(config.find(self.project_dir, config_path))
+        environment = Environment.from_env_file(self.project_dir)
+        config_path = get_config_path_from_options(
+            self.project_dir, config_options, environment
+        )
+        compose_config = config.load(
+            config.find(self.project_dir, config_path, environment)
+        )
 
         if options['--quiet']:
             return

+ 1 - 0
compose/config/__init__.py

@@ -2,6 +2,7 @@
 from __future__ import absolute_import
 from __future__ import unicode_literals
 
+from . import environment
 from .config import ConfigurationError
 from .config import DOCKER_CONFIG_KEYS
 from .config import find

+ 62 - 60
compose/config/config.py

@@ -1,7 +1,6 @@
 from __future__ import absolute_import
 from __future__ import unicode_literals
 
-import codecs
 import functools
 import logging
 import operator
@@ -17,6 +16,9 @@ from cached_property import cached_property
 from ..const import COMPOSEFILE_V1 as V1
 from ..const import COMPOSEFILE_V2_0 as V2_0
 from ..utils import build_string_dict
+from .environment import env_vars_from_file
+from .environment import Environment
+from .environment import split_env
 from .errors import CircularReference
 from .errors import ComposeFileNotFound
 from .errors import ConfigurationError
@@ -113,13 +115,21 @@ DEFAULT_OVERRIDE_FILENAME = 'docker-compose.override.yml'
 log = logging.getLogger(__name__)
 
 
-class ConfigDetails(namedtuple('_ConfigDetails', 'working_dir config_files')):
+class ConfigDetails(namedtuple('_ConfigDetails', 'working_dir config_files environment')):
     """
     :param working_dir: the directory to use for relative paths in the config
     :type  working_dir: string
     :param config_files: list of configuration files to load
     :type  config_files: list of :class:`ConfigFile`
+    :param environment: computed environment values for this project
+    :type  environment: :class:`environment.Environment`
      """
+    def __new__(cls, working_dir, config_files, environment=None):
+        if environment is None:
+            environment = Environment.from_env_file(working_dir)
+        return super(ConfigDetails, cls).__new__(
+            cls, working_dir, config_files, environment
+        )
 
 
 class ConfigFile(namedtuple('_ConfigFile', 'filename config')):
@@ -207,11 +217,13 @@ class ServiceConfig(namedtuple('_ServiceConfig', 'working_dir filename name conf
             config)
 
 
-def find(base_dir, filenames):
+def find(base_dir, filenames, environment):
     if filenames == ['-']:
         return ConfigDetails(
             os.getcwd(),
-            [ConfigFile(None, yaml.safe_load(sys.stdin))])
+            [ConfigFile(None, yaml.safe_load(sys.stdin))],
+            environment
+        )
 
     if filenames:
         filenames = [os.path.join(base_dir, f) for f in filenames]
@@ -221,7 +233,9 @@ def find(base_dir, filenames):
     log.debug("Using configuration files: {}".format(",".join(filenames)))
     return ConfigDetails(
         os.path.dirname(filenames[0]),
-        [ConfigFile.from_filename(f) for f in filenames])
+        [ConfigFile.from_filename(f) for f in filenames],
+        environment
+    )
 
 
 def validate_config_version(config_files):
@@ -289,7 +303,7 @@ def load(config_details):
     validate_config_version(config_details.config_files)
 
     processed_files = [
-        process_config_file(config_file)
+        process_config_file(config_file, config_details.environment)
         for config_file in config_details.config_files
     ]
     config_details = config_details._replace(config_files=processed_files)
@@ -301,10 +315,7 @@ def load(config_details):
     networks = load_mapping(
         config_details.config_files, 'get_networks', 'Network'
     )
-    service_dicts = load_services(
-        config_details.working_dir,
-        main_file,
-        [file.get_service_dicts() for file in config_details.config_files])
+    service_dicts = load_services(config_details, main_file)
 
     if main_file.version != V1:
         for service_dict in service_dicts:
@@ -348,14 +359,16 @@ def load_mapping(config_files, get_func, entity_type):
     return mapping
 
 
-def load_services(working_dir, config_file, service_configs):
+def load_services(config_details, config_file):
     def build_service(service_name, service_dict, service_names):
         service_config = ServiceConfig.with_abs_paths(
-            working_dir,
+            config_details.working_dir,
             config_file.filename,
             service_name,
             service_dict)
-        resolver = ServiceExtendsResolver(service_config, config_file)
+        resolver = ServiceExtendsResolver(
+            service_config, config_file, environment=config_details.environment
+        )
         service_dict = process_service(resolver.run())
 
         service_config = service_config._replace(config=service_dict)
@@ -363,7 +376,8 @@ def load_services(working_dir, config_file, service_configs):
         service_dict = finalize_service(
             service_config,
             service_names,
-            config_file.version)
+            config_file.version,
+            config_details.environment)
         return service_dict
 
     def build_services(service_config):
@@ -383,6 +397,10 @@ def load_services(working_dir, config_file, service_configs):
             for name in all_service_names
         }
 
+    service_configs = [
+        file.get_service_dicts() for file in config_details.config_files
+    ]
+
     service_config = service_configs[0]
     for next_config in service_configs[1:]:
         service_config = merge_services(service_config, next_config)
@@ -390,16 +408,17 @@ def load_services(working_dir, config_file, service_configs):
     return build_services(service_config)
 
 
-def interpolate_config_section(filename, config, section):
+def interpolate_config_section(filename, config, section, environment):
     validate_config_section(filename, config, section)
-    return interpolate_environment_variables(config, section)
+    return interpolate_environment_variables(config, section, environment)
 
 
-def process_config_file(config_file, service_name=None):
+def process_config_file(config_file, environment, service_name=None):
     services = interpolate_config_section(
         config_file.filename,
         config_file.get_service_dicts(),
-        'service')
+        'service',
+        environment,)
 
     if config_file.version == V2_0:
         processed_config = dict(config_file.config)
@@ -407,11 +426,13 @@ def process_config_file(config_file, service_name=None):
         processed_config['volumes'] = interpolate_config_section(
             config_file.filename,
             config_file.get_volumes(),
-            'volume')
+            'volume',
+            environment,)
         processed_config['networks'] = interpolate_config_section(
             config_file.filename,
             config_file.get_networks(),
-            'network')
+            'network',
+            environment,)
 
     if config_file.version == V1:
         processed_config = services
@@ -428,11 +449,12 @@ def process_config_file(config_file, service_name=None):
 
 
 class ServiceExtendsResolver(object):
-    def __init__(self, service_config, config_file, already_seen=None):
+    def __init__(self, service_config, config_file, environment, already_seen=None):
         self.service_config = service_config
         self.working_dir = service_config.working_dir
         self.already_seen = already_seen or []
         self.config_file = config_file
+        self.environment = environment
 
     @property
     def signature(self):
@@ -462,8 +484,8 @@ class ServiceExtendsResolver(object):
         extends_file = ConfigFile.from_filename(config_path)
         validate_config_version([self.config_file, extends_file])
         extended_file = process_config_file(
-            extends_file,
-            service_name=service_name)
+            extends_file, self.environment, service_name=service_name
+        )
         service_config = extended_file.get_service(service_name)
 
         return config_path, service_config, service_name
@@ -476,7 +498,9 @@ class ServiceExtendsResolver(object):
                 service_name,
                 service_dict),
             self.config_file,
-            already_seen=self.already_seen + [self.signature])
+            already_seen=self.already_seen + [self.signature],
+            environment=self.environment
+        )
 
         service_config = resolver.run()
         other_service_dict = process_service(service_config)
@@ -505,7 +529,7 @@ class ServiceExtendsResolver(object):
         return filename
 
 
-def resolve_environment(service_dict):
+def resolve_environment(service_dict, environment=None):
     """Unpack any environment variables from an env_file, if set.
     Interpolate environment values if set.
     """
@@ -514,12 +538,12 @@ def resolve_environment(service_dict):
         env.update(env_vars_from_file(env_file))
 
     env.update(parse_environment(service_dict.get('environment')))
-    return dict(resolve_env_var(k, v) for k, v in six.iteritems(env))
+    return dict(resolve_env_var(k, v, environment) for k, v in six.iteritems(env))
 
 
-def resolve_build_args(build):
+def resolve_build_args(build, environment):
     args = parse_build_arguments(build.get('args'))
-    return dict(resolve_env_var(k, v) for k, v in six.iteritems(args))
+    return dict(resolve_env_var(k, v, environment) for k, v in six.iteritems(args))
 
 
 def validate_extended_service_dict(service_dict, filename, service):
@@ -598,11 +622,11 @@ def process_service(service_config):
     return service_dict
 
 
-def finalize_service(service_config, service_names, version):
+def finalize_service(service_config, service_names, version, environment):
     service_dict = dict(service_config.config)
 
     if 'environment' in service_dict or 'env_file' in service_dict:
-        service_dict['environment'] = resolve_environment(service_dict)
+        service_dict['environment'] = resolve_environment(service_dict, environment)
         service_dict.pop('env_file', None)
 
     if 'volumes_from' in service_dict:
@@ -629,7 +653,7 @@ def finalize_service(service_config, service_names, version):
     if 'restart' in service_dict:
         service_dict['restart'] = parse_restart_spec(service_dict['restart'])
 
-    normalize_build(service_dict, service_config.working_dir)
+    normalize_build(service_dict, service_config.working_dir, environment)
 
     service_dict['name'] = service_config.name
     return normalize_v1_service_format(service_dict)
@@ -777,15 +801,6 @@ def merge_environment(base, override):
     return env
 
 
-def split_env(env):
-    if isinstance(env, six.binary_type):
-        env = env.decode('utf-8', 'replace')
-    if '=' in env:
-        return env.split('=', 1)
-    else:
-        return env, None
-
-
 def split_label(label):
     if '=' in label:
         return label.split('=', 1)
@@ -823,30 +838,15 @@ def parse_ulimits(ulimits):
         return dict(ulimits)
 
 
-def resolve_env_var(key, val):
+def resolve_env_var(key, val, environment):
     if val is not None:
         return key, val
-    elif key in os.environ:
-        return key, os.environ[key]
+    elif environment and key in environment:
+        return key, environment[key]
     else:
         return key, None
 
 
-def env_vars_from_file(filename):
-    """
-    Read in a line delimited file of environment variables.
-    """
-    if not os.path.exists(filename):
-        raise ConfigurationError("Couldn't find env file: %s" % filename)
-    env = {}
-    for line in codecs.open(filename, 'r', 'utf-8'):
-        line = line.strip()
-        if line and not line.startswith('#'):
-            k, v = split_env(line)
-            env[k] = v
-    return env
-
-
 def resolve_volume_paths(working_dir, service_dict):
     return [
         resolve_volume_path(working_dir, volume)
@@ -866,7 +866,7 @@ def resolve_volume_path(working_dir, volume):
         return container_path
 
 
-def normalize_build(service_dict, working_dir):
+def normalize_build(service_dict, working_dir, environment):
 
     if 'build' in service_dict:
         build = {}
@@ -876,7 +876,9 @@ def normalize_build(service_dict, working_dir):
         else:
             build.update(service_dict['build'])
             if 'args' in build:
-                build['args'] = build_string_dict(resolve_build_args(build))
+                build['args'] = build_string_dict(
+                    resolve_build_args(build, environment)
+                )
 
         service_dict['build'] = build
 

+ 93 - 0
compose/config/environment.py

@@ -0,0 +1,93 @@
+from __future__ import absolute_import
+from __future__ import unicode_literals
+
+import codecs
+import logging
+import os
+
+import six
+
+from ..const import IS_WINDOWS_PLATFORM
+from .errors import ConfigurationError
+
+log = logging.getLogger(__name__)
+
+
+def split_env(env):
+    if isinstance(env, six.binary_type):
+        env = env.decode('utf-8', 'replace')
+    if '=' in env:
+        return env.split('=', 1)
+    else:
+        return env, None
+
+
+def env_vars_from_file(filename):
+    """
+    Read in a line delimited file of environment variables.
+    """
+    if not os.path.exists(filename):
+        raise ConfigurationError("Couldn't find env file: %s" % filename)
+    env = {}
+    for line in codecs.open(filename, 'r', 'utf-8'):
+        line = line.strip()
+        if line and not line.startswith('#'):
+            k, v = split_env(line)
+            env[k] = v
+    return env
+
+
+class Environment(dict):
+    def __init__(self, *args, **kwargs):
+        super(Environment, self).__init__(*args, **kwargs)
+        self.missing_keys = []
+
+    @classmethod
+    def from_env_file(cls, base_dir):
+        def _initialize():
+            result = cls()
+            if base_dir is None:
+                return result
+            env_file_path = os.path.join(base_dir, '.env')
+            try:
+                return cls(env_vars_from_file(env_file_path))
+            except ConfigurationError:
+                pass
+            return result
+        instance = _initialize()
+        instance.update(os.environ)
+        return instance
+
+    def __getitem__(self, key):
+        try:
+            return super(Environment, self).__getitem__(key)
+        except KeyError:
+            if IS_WINDOWS_PLATFORM:
+                try:
+                    return super(Environment, self).__getitem__(key.upper())
+                except KeyError:
+                    pass
+            if key not in self.missing_keys:
+                log.warn(
+                    "The {} variable is not set. Defaulting to a blank string."
+                    .format(key)
+                )
+                self.missing_keys.append(key)
+
+            return ""
+
+    def __contains__(self, key):
+        result = super(Environment, self).__contains__(key)
+        if IS_WINDOWS_PLATFORM:
+            return (
+                result or super(Environment, self).__contains__(key.upper())
+            )
+        return result
+
+    def get(self, key, *args, **kwargs):
+        if IS_WINDOWS_PLATFORM:
+            return super(Environment, self).get(
+                key,
+                super(Environment, self).get(key.upper(), *args, **kwargs)
+            )
+        return super(Environment, self).get(key, *args, **kwargs)

+ 2 - 23
compose/config/interpolation.py

@@ -2,7 +2,6 @@ from __future__ import absolute_import
 from __future__ import unicode_literals
 
 import logging
-import os
 from string import Template
 
 import six
@@ -11,12 +10,11 @@ from .errors import ConfigurationError
 log = logging.getLogger(__name__)
 
 
-def interpolate_environment_variables(config, section):
-    mapping = BlankDefaultDict(os.environ)
+def interpolate_environment_variables(config, section, environment):
 
     def process_item(name, config_dict):
         return dict(
-            (key, interpolate_value(name, key, val, section, mapping))
+            (key, interpolate_value(name, key, val, section, environment))
             for key, val in (config_dict or {}).items()
         )
 
@@ -60,25 +58,6 @@ def interpolate(string, mapping):
         raise InvalidInterpolation(string)
 
 
-class BlankDefaultDict(dict):
-    def __init__(self, *args, **kwargs):
-        super(BlankDefaultDict, self).__init__(*args, **kwargs)
-        self.missing_keys = []
-
-    def __getitem__(self, key):
-        try:
-            return super(BlankDefaultDict, self).__getitem__(key)
-        except KeyError:
-            if key not in self.missing_keys:
-                log.warn(
-                    "The {} variable is not set. Defaulting to a blank string."
-                    .format(key)
-                )
-                self.missing_keys.append(key)
-
-            return ""
-
-
 class InvalidInterpolation(Exception):
     def __init__(self, string):
         self.string = string

+ 1 - 1
compose/const.py

@@ -5,7 +5,7 @@ import os
 import sys
 
 DEFAULT_TIMEOUT = 10
-HTTP_TIMEOUT = int(os.environ.get('COMPOSE_HTTP_TIMEOUT', os.environ.get('DOCKER_CLIENT_TIMEOUT', 60)))
+HTTP_TIMEOUT = int(os.environ.get('DOCKER_CLIENT_TIMEOUT', 60))
 IMAGE_EVENTS = ['delete', 'import', 'pull', 'push', 'tag', 'untag']
 IS_WINDOWS_PLATFORM = (sys.platform == "win32")
 LABEL_CONTAINER_NUMBER = 'com.docker.compose.container-number'

+ 43 - 0
docs/env-file.md

@@ -0,0 +1,43 @@
+<!--[metadata]>
++++
+title = "Environment file"
+description = "Declaring default environment variables in file"
+keywords = ["fig, composition, compose, docker, orchestration, environment, env file"]
+[menu.main]
+parent = "workw_compose"
+weight=10
++++
+<![end-metadata]-->
+
+
+# Environment file
+
+Compose supports declaring default environment variables in an environment
+file named `.env` and placed in the same folder as your
+[compose file](compose-file.md).
+
+Compose expects each line in an env file to be in `VAR=VAL` format. Lines
+beginning with `#` (i.e. comments) are ignored, as are blank lines.
+
+> Note: Values present in the environment at runtime will always override
+> those defined inside the `.env` file. Similarly, values passed via
+> command-line arguments take precedence as well.
+
+Those environment variables will be used for
+[variable substitution](compose-file.md#variable-substitution) in your Compose
+file, but can also be used to define the following
+[CLI variables](reference/envvars.md):
+
+- `COMPOSE_API_VERSION`
+- `COMPOSE_FILE`
+- `COMPOSE_HTTP_TIMEOUT`
+- `COMPOSE_PROJECT_NAME`
+- `DOCKER_CERT_PATH`
+- `DOCKER_HOST`
+- `DOCKER_TLS_VERIFY`
+
+## More Compose documentation
+
+- [User guide](index.md)
+- [Command line reference](./reference/index.md)
+- [Compose file reference](compose-file.md)

+ 1 - 0
docs/index.md

@@ -23,6 +23,7 @@ Compose is a tool for defining and running multi-container Docker applications.
 - [Frequently asked questions](faq.md)
 - [Command line reference](./reference/index.md)
 - [Compose file reference](compose-file.md)
+- [Environment file](env-file.md)
 
 To see a detailed list of changes for past and current releases of Docker
 Compose, please refer to the

+ 4 - 0
docs/reference/envvars.md

@@ -17,6 +17,9 @@ Several environment variables are available for you to configure the Docker Comp
 Variables starting with `DOCKER_` are the same as those used to configure the
 Docker command-line client. If you're using `docker-machine`, then the `eval "$(docker-machine env my-docker-vm)"` command should set them to their correct values. (In this example, `my-docker-vm` is the name of a machine you created.)
 
+> Note: Some of these variables can also be provided using an
+> [environment file](../env-file.md)
+
 ## COMPOSE\_PROJECT\_NAME
 
 Sets the project name. This value is prepended along with the service name to the container container on start up. For example, if you project name is `myapp` and it includes two services `db` and `web` then compose starts containers named  `myapp_db_1` and `myapp_web_1` respectively.
@@ -81,3 +84,4 @@ it failed. Defaults to 60 seconds.
 - [User guide](../index.md)
 - [Installing Compose](../install.md)
 - [Compose file reference](../compose-file.md)
+- [Environment file](../env-file.md)

+ 4 - 0
tests/fixtures/default-env-file/.env

@@ -0,0 +1,4 @@
+IMAGE=alpine:latest
+COMMAND=true
+PORT1=5643
+PORT2=9999

+ 6 - 0
tests/fixtures/default-env-file/docker-compose.yml

@@ -0,0 +1,6 @@
+web:
+    image: ${IMAGE}
+    command: ${COMMAND}
+    ports:
+        - $PORT1
+        - $PORT2

+ 2 - 1
tests/helpers.py

@@ -13,4 +13,5 @@ def build_config(contents, **kwargs):
 def build_config_details(contents, working_dir='working_dir', filename='filename.yml'):
     return ConfigDetails(
         working_dir,
-        [ConfigFile(filename, contents)])
+        [ConfigFile(filename, contents)],
+    )

+ 5 - 2
tests/integration/testcases.py

@@ -12,6 +12,7 @@ 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.config.environment import Environment
 from compose.const import API_VERSIONS
 from compose.const import LABEL_PROJECT
 from compose.progress_stream import stream_output
@@ -60,7 +61,7 @@ class DockerClientTestCase(unittest.TestCase):
         else:
             version = API_VERSIONS[V2_0]
 
-        cls.client = docker_client(version)
+        cls.client = docker_client(Environment(), version)
 
     def tearDown(self):
         for c in self.client.containers(
@@ -89,7 +90,9 @@ class DockerClientTestCase(unittest.TestCase):
         if 'command' not in kwargs:
             kwargs['command'] = ["top"]
 
-        kwargs['environment'] = resolve_environment(kwargs)
+        kwargs['environment'] = resolve_environment(
+            kwargs, Environment.from_env_file(None)
+        )
         labels = dict(kwargs.setdefault('labels', {}))
         labels['com.docker.compose.test-name'] = self.id()
 

+ 15 - 5
tests/unit/cli/command_test.py

@@ -6,6 +6,7 @@ import os
 import pytest
 
 from compose.cli.command import get_config_path_from_options
+from compose.config.environment import Environment
 from compose.const import IS_WINDOWS_PLATFORM
 from tests import mock
 
@@ -15,24 +16,33 @@ class TestGetConfigPathFromOptions(object):
     def test_path_from_options(self):
         paths = ['one.yml', 'two.yml']
         opts = {'--file': paths}
-        assert get_config_path_from_options(opts) == paths
+        environment = Environment.from_env_file('.')
+        assert get_config_path_from_options('.', opts, environment) == paths
 
     def test_single_path_from_env(self):
         with mock.patch.dict(os.environ):
             os.environ['COMPOSE_FILE'] = 'one.yml'
-            assert get_config_path_from_options({}) == ['one.yml']
+            environment = Environment.from_env_file('.')
+            assert get_config_path_from_options('.', {}, environment) == ['one.yml']
 
     @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix separator')
     def test_multiple_path_from_env(self):
         with mock.patch.dict(os.environ):
             os.environ['COMPOSE_FILE'] = 'one.yml:two.yml'
-            assert get_config_path_from_options({}) == ['one.yml', 'two.yml']
+            environment = Environment.from_env_file('.')
+            assert get_config_path_from_options(
+                '.', {}, environment
+            ) == ['one.yml', 'two.yml']
 
     @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='windows separator')
     def test_multiple_path_from_env_windows(self):
         with mock.patch.dict(os.environ):
             os.environ['COMPOSE_FILE'] = 'one.yml;two.yml'
-            assert get_config_path_from_options({}) == ['one.yml', 'two.yml']
+            environment = Environment.from_env_file('.')
+            assert get_config_path_from_options(
+                '.', {}, environment
+            ) == ['one.yml', 'two.yml']
 
     def test_no_path(self):
-        assert not get_config_path_from_options({})
+        environment = Environment.from_env_file('.')
+        assert not get_config_path_from_options('.', {}, environment)

+ 2 - 2
tests/unit/cli/docker_client_test.py

@@ -17,12 +17,12 @@ class DockerClientTestCase(unittest.TestCase):
     def test_docker_client_no_home(self):
         with mock.patch.dict(os.environ):
             del os.environ['HOME']
-            docker_client()
+            docker_client(os.environ)
 
     def test_docker_client_with_custom_timeout(self):
         timeout = 300
         with mock.patch('compose.cli.docker_client.HTTP_TIMEOUT', 300):
-            client = docker_client()
+            client = docker_client(os.environ)
             self.assertEqual(client.timeout, int(timeout))
 
 

+ 21 - 3
tests/unit/cli_test.py

@@ -3,6 +3,8 @@ from __future__ import absolute_import
 from __future__ import unicode_literals
 
 import os
+import shutil
+import tempfile
 
 import docker
 import py
@@ -43,11 +45,11 @@ class CLITestCase(unittest.TestCase):
         project_name = get_project_name(None, project_name=name)
         self.assertEquals('explicitprojectname', project_name)
 
+    @mock.patch.dict(os.environ)
     def test_project_name_from_environment_new_var(self):
         name = 'namefromenv'
-        with mock.patch.dict(os.environ):
-            os.environ['COMPOSE_PROJECT_NAME'] = name
-            project_name = get_project_name(None)
+        os.environ['COMPOSE_PROJECT_NAME'] = name
+        project_name = get_project_name(None)
         self.assertEquals(project_name, name)
 
     def test_project_name_with_empty_environment_var(self):
@@ -57,6 +59,22 @@ class CLITestCase(unittest.TestCase):
             project_name = get_project_name(base_dir)
         self.assertEquals('simplecomposefile', project_name)
 
+    @mock.patch.dict(os.environ)
+    def test_project_name_with_environment_file(self):
+        base_dir = tempfile.mkdtemp()
+        try:
+            name = 'namefromenvfile'
+            with open(os.path.join(base_dir, '.env'), 'w') as f:
+                f.write('COMPOSE_PROJECT_NAME={}'.format(name))
+            project_name = get_project_name(base_dir)
+            assert project_name == name
+
+            # Environment has priority over .env file
+            os.environ['COMPOSE_PROJECT_NAME'] = 'namefromenv'
+            assert get_project_name(base_dir) == os.environ['COMPOSE_PROJECT_NAME']
+        finally:
+            shutil.rmtree(base_dir)
+
     def test_get_project(self):
         base_dir = 'tests/fixtures/longer-filename-composefile'
         project = get_project(base_dir)

+ 43 - 13
tests/unit/config/config_test.py

@@ -17,6 +17,7 @@ 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.environment import Environment
 from compose.config.errors import ConfigurationError
 from compose.config.errors import VERSION_EXPLANATION
 from compose.config.types import VolumeSpec
@@ -36,7 +37,9 @@ def make_service_dict(name, service_dict, working_dir, filename=None):
             filename=filename,
             name=name,
             config=service_dict),
-        config.ConfigFile(filename=filename, config={}))
+        config.ConfigFile(filename=filename, config={}),
+        environment=Environment.from_env_file(working_dir)
+    )
     return config.process_service(resolver.run())
 
 
@@ -1581,8 +1584,25 @@ class PortsTest(unittest.TestCase):
 
 
 class InterpolationTest(unittest.TestCase):
+    @mock.patch.dict(os.environ)
+    def test_config_file_with_environment_file(self):
+        project_dir = 'tests/fixtures/default-env-file'
+        service_dicts = config.load(
+            config.find(
+                project_dir, None, Environment.from_env_file(project_dir)
+            )
+        ).services
+
+        self.assertEqual(service_dicts[0], {
+            'name': 'web',
+            'image': 'alpine:latest',
+            'ports': ['5643', '9999'],
+            'command': 'true'
+        })
+
     @mock.patch.dict(os.environ)
     def test_config_file_with_environment_variable(self):
+        project_dir = 'tests/fixtures/environment-interpolation'
         os.environ.update(
             IMAGE="busybox",
             HOST_PORT="80",
@@ -1590,7 +1610,9 @@ class InterpolationTest(unittest.TestCase):
         )
 
         service_dicts = config.load(
-            config.find('tests/fixtures/environment-interpolation', None),
+            config.find(
+                project_dir, None, Environment.from_env_file(project_dir)
+            )
         ).services
 
         self.assertEqual(service_dicts, [
@@ -1620,7 +1642,7 @@ class InterpolationTest(unittest.TestCase):
             None,
         )
 
-        with mock.patch('compose.config.interpolation.log') as log:
+        with mock.patch('compose.config.environment.log') as log:
             config.load(config_details)
 
             self.assertEqual(2, log.warn.call_count)
@@ -2041,7 +2063,9 @@ class EnvTest(unittest.TestCase):
             },
         }
         self.assertEqual(
-            resolve_environment(service_dict),
+            resolve_environment(
+                service_dict, Environment.from_env_file(None)
+            ),
             {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': None},
         )
 
@@ -2078,7 +2102,10 @@ class EnvTest(unittest.TestCase):
         os.environ['FILE_DEF_EMPTY'] = 'E2'
         os.environ['ENV_DEF'] = 'E3'
         self.assertEqual(
-            resolve_environment({'env_file': ['tests/fixtures/env/resolve.env']}),
+            resolve_environment(
+                {'env_file': ['tests/fixtures/env/resolve.env']},
+                Environment.from_env_file(None)
+            ),
             {
                 'FILE_DEF': u'bär',
                 'FILE_DEF_EMPTY': '',
@@ -2101,7 +2128,7 @@ class EnvTest(unittest.TestCase):
             }
         }
         self.assertEqual(
-            resolve_build_args(build),
+            resolve_build_args(build, Environment.from_env_file(build['context'])),
             {'arg1': 'value1', 'empty_arg': '', 'env_arg': 'value2', 'no_env': None},
         )
 
@@ -2133,7 +2160,9 @@ class EnvTest(unittest.TestCase):
 
 
 def load_from_filename(filename):
-    return config.load(config.find('.', [filename])).services
+    return config.load(
+        config.find('.', [filename], Environment.from_env_file('.'))
+    ).services
 
 
 class ExtendsTest(unittest.TestCase):
@@ -2465,6 +2494,7 @@ class ExtendsTest(unittest.TestCase):
             },
         ]))
 
+    @mock.patch.dict(os.environ)
     def test_extends_with_environment_and_env_files(self):
         tmpdir = py.test.ensuretemp('test_extends_with_environment')
         self.addCleanup(tmpdir.remove)
@@ -2520,12 +2550,12 @@ class ExtendsTest(unittest.TestCase):
                 },
             },
         ]
-        with mock.patch.dict(os.environ):
-            os.environ['SECRET'] = 'secret'
-            os.environ['THING'] = 'thing'
-            os.environ['COMMON_ENV_FILE'] = 'secret'
-            os.environ['TOP_ENV_FILE'] = 'secret'
-            config = load_from_filename(str(tmpdir.join('docker-compose.yml')))
+
+        os.environ['SECRET'] = 'secret'
+        os.environ['THING'] = 'thing'
+        os.environ['COMMON_ENV_FILE'] = 'secret'
+        os.environ['TOP_ENV_FILE'] = 'secret'
+        config = load_from_filename(str(tmpdir.join('docker-compose.yml')))
 
         assert config == expected
 

+ 9 - 4
tests/unit/config/interpolation_test.py

@@ -6,6 +6,7 @@ import os
 import mock
 import pytest
 
+from compose.config.environment import Environment
 from compose.config.interpolation import interpolate_environment_variables
 
 
@@ -19,7 +20,7 @@ def mock_env():
 
 def test_interpolate_environment_variables_in_services(mock_env):
     services = {
-        'servivea': {
+        'servicea': {
             'image': 'example:${USER}',
             'volumes': ['$FOO:/target'],
             'logging': {
@@ -31,7 +32,7 @@ def test_interpolate_environment_variables_in_services(mock_env):
         }
     }
     expected = {
-        'servivea': {
+        'servicea': {
             'image': 'example:jenny',
             'volumes': ['bar:/target'],
             'logging': {
@@ -42,7 +43,9 @@ def test_interpolate_environment_variables_in_services(mock_env):
             }
         }
     }
-    assert interpolate_environment_variables(services, 'service') == expected
+    assert interpolate_environment_variables(
+        services, 'service', Environment.from_env_file(None)
+    ) == expected
 
 
 def test_interpolate_environment_variables_in_volumes(mock_env):
@@ -66,4 +69,6 @@ def test_interpolate_environment_variables_in_volumes(mock_env):
         },
         'other': {},
     }
-    assert interpolate_environment_variables(volumes, 'volume') == expected
+    assert interpolate_environment_variables(
+        volumes, 'volume', Environment.from_env_file(None)
+    ) == expected

+ 1 - 1
tests/unit/interpolation_test.py

@@ -3,7 +3,7 @@ from __future__ import unicode_literals
 
 import unittest
 
-from compose.config.interpolation import BlankDefaultDict as bddict
+from compose.config.environment import Environment as bddict
 from compose.config.interpolation import interpolate
 from compose.config.interpolation import InvalidInterpolation