Repository: cookiecutter/cookiecutter

Issue:
Added --rc-file option to load specific or no user config file
Motivation (use-cases):
- isolate unit tests of cookiecutter templates from user home
- Selection of different config sets in a CI server or similar

Get the config file location from $COOKIECUTTER_CONFIG, if set
Apart from being useful for users, this will allow us to test more easily, without having to save/restore the user's config as much as we currently do.



Discussion:
See also #258 

[![Coverage Status](https://coveralls.io/builds/1049677/badge)](https://coveralls.io/builds/1049677)

Coverage increased (+0.63%) when pulling **baa1be710b6a94d10dd56fdbf5c0dd41885170f6 on pfmoore:env_config** into **173fc7190953b1c446627086fc977d1f89addcb5 on audreyr:master**.

[![Coverage Status](https://coveralls.io/builds/1049859/badge)](https://coveralls.io/builds/1049859)

Coverage increased (+0.63%) when pulling **799d02125d448907b8b3ea94714804862dfdd40c on pfmoore:env_config** into **173fc7190953b1c446627086fc977d1f89addcb5 on audreyr:master**.

[![Coverage Status](https://coveralls.io/builds/1049859/badge)](https://coveralls.io/builds/1049859)

Coverage increased (+0.63%) when pulling **799d02125d448907b8b3ea94714804862dfdd40c on pfmoore:env_config** into **173fc7190953b1c446627086fc977d1f89addcb5 on audreyr:master**.

:+1:

[![Coverage Status](https://coveralls.io/builds/1049948/badge)](https://coveralls.io/builds/1049948)

Coverage remained the same when pulling **0a32f32a9d08e643e1eecee835ff2909d8ceb6c3 on pfmoore:env_config** into **a944be327e0523e434be012d70c6ce9b45fe331a on audreyr:master**.

The core change looks great but somehow a bunch of changes of double quotes to single quotes stowed away here. They make the PR longer than it needs to be and are likely the reason it won't merge cleanly now. 

If you strip out the extra stuff, I'd be :+1

I could do this on top of my PR, it is a one line change and 1or 2 tests.


Resolving patch:
diff --git a/cookiecutter/cli.py b/cookiecutter/cli.py
--- a/cookiecutter/cli.py
+++ b/cookiecutter/cli.py
@@ -15,6 +15,7 @@
 import click
 
 from cookiecutter import __version__
+from cookiecutter.config import USER_CONFIG_PATH
 from cookiecutter.main import cookiecutter
 from cookiecutter.exceptions import (
     OutputDirExistsException, InvalidModeException, FailedHookException
@@ -59,8 +60,16 @@ def version_msg():
     u'-o', u'--output-dir', default='.', type=click.Path(),
     help=u'Where to output the generated project dir into'
 )
+@click.option(
+    u'--config-file', type=click.Path(), default=USER_CONFIG_PATH,
+    help=u'User configuration file'
+)
+@click.option(
+    u'--default-config', is_flag=True,
+    help=u'Do not load a config file. Use the defaults instead'
+)
 def main(template, no_input, checkout, verbose, replay, overwrite_if_exists,
-         output_dir):
+         output_dir, config_file, default_config):
     """Create a project from a Cookiecutter project template (TEMPLATE)."""
     if verbose:
         logging.basicConfig(
@@ -75,18 +84,20 @@ def main(template, no_input, checkout, verbose, replay, overwrite_if_exists,
         )
 
     try:
-
         # If you _need_ to support a local template in a directory
         # called 'help', use a qualified path to the directory.
         if template == u'help':
             click.echo(click.get_current_context().get_help())
             sys.exit(0)
 
+        user_config = None if default_config else config_file
+
         cookiecutter(
             template, checkout, no_input,
             replay=replay,
             overwrite_if_exists=overwrite_if_exists,
-            output_dir=output_dir
+            output_dir=output_dir,
+            config_file=user_config
         )
     except (OutputDirExistsException,
             InvalidModeException, FailedHookException) as e:
diff --git a/cookiecutter/config.py b/cookiecutter/config.py
--- a/cookiecutter/config.py
+++ b/cookiecutter/config.py
@@ -25,6 +25,8 @@
 
 logger = logging.getLogger(__name__)
 
+USER_CONFIG_PATH = os.path.expanduser('~/.cookiecutterrc')
+
 DEFAULT_CONFIG = {
     'cookiecutters_dir': os.path.expanduser('~/.cookiecutters/'),
     'replay_dir': os.path.expanduser('~/.cookiecutter_replay/'),
@@ -57,15 +59,30 @@ def get_config(config_path):
     return config_dict
 
 
-def get_user_config():
-    """
-    Retrieve config from the user's ~/.cookiecutterrc, if it exists.
-    Otherwise, return None.
+def get_user_config(config_file=USER_CONFIG_PATH):
+    """Retrieve the config from a file or return the defaults if None is
+    passed. If an environment variable `COOKIECUTTER_CONFIG` is set up, try
+    to load its value. Otherwise fall back to a default file or config.
     """
-
-    # TODO: test on windows...
-    USER_CONFIG_PATH = os.path.expanduser('~/.cookiecutterrc')
-
-    if os.path.exists(USER_CONFIG_PATH):
-        return get_config(USER_CONFIG_PATH)
-    return copy.copy(DEFAULT_CONFIG)
+    # Do NOT load a config. Return defaults instead.
+    if config_file is None:
+        return copy.copy(DEFAULT_CONFIG)
+
+    # Load the given config file
+    if config_file and config_file is not USER_CONFIG_PATH:
+        return get_config(config_file)
+
+    try:
+        # Does the user set up a config environment variable?
+        env_config_file = os.environ['COOKIECUTTER_CONFIG']
+    except KeyError:
+        # Load an optional user config if it exists
+        # otherwise return the defaults
+        if os.path.exists(USER_CONFIG_PATH):
+            return get_config(USER_CONFIG_PATH)
+        else:
+            return copy.copy(DEFAULT_CONFIG)
+    else:
+        # There is a config environment variable. Try to load it.
+        # Do not check for existence, so invalid file paths raise an error.
+        return get_config(env_config_file)
diff --git a/cookiecutter/main.py b/cookiecutter/main.py
--- a/cookiecutter/main.py
+++ b/cookiecutter/main.py
@@ -16,7 +16,7 @@
 import os
 import re
 
-from .config import get_user_config
+from .config import get_user_config, USER_CONFIG_PATH
 from .exceptions import InvalidModeException
 from .prompt import prompt_for_config
 from .generate import generate_context, generate_files
@@ -71,7 +71,8 @@ def expand_abbreviations(template, config_dict):
 
 def cookiecutter(
         template, checkout=None, no_input=False, extra_context=None,
-        replay=False, overwrite_if_exists=False, output_dir='.'):
+        replay=False, overwrite_if_exists=False, output_dir='.',
+        config_file=USER_CONFIG_PATH):
     """
     API equivalent to using Cookiecutter at the command line.
 
@@ -84,6 +85,7 @@ def cookiecutter(
     :param: overwrite_if_exists: Overwrite the contents of output directory
         if it exists
     :param output_dir: Where to output the generated project dir into.
+    :param config_file: User configuration file path.
     """
     if replay and ((no_input is not False) or (extra_context is not None)):
         err_msg = (
@@ -94,7 +96,7 @@ def cookiecutter(
 
     # Get user config from ~/.cookiecutterrc or equivalent
     # If no config file, sensible defaults from config.DEFAULT_CONFIG are used
-    config_dict = get_user_config()
+    config_dict = get_user_config(config_file=config_file)
 
     template = expand_abbreviations(template, config_dict)
 


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -5,7 +5,7 @@
 
 from cookiecutter.cli import main
 from cookiecutter.main import cookiecutter
-from cookiecutter import utils
+from cookiecutter import utils, config
 
 runner = CliRunner()
 
@@ -80,7 +80,8 @@ def test_cli_replay(mocker):
         False,
         replay=True,
         overwrite_if_exists=False,
-        output_dir='.'
+        output_dir='.',
+        config_file=config.USER_CONFIG_PATH
     )
 
 
@@ -114,7 +115,8 @@ def test_cli_exit_on_noinput_and_replay(mocker):
         True,
         replay=True,
         overwrite_if_exists=False,
-        output_dir='.'
+        output_dir='.',
+        config_file=config.USER_CONFIG_PATH
     )
 
 
@@ -147,7 +149,8 @@ def test_run_cookiecutter_on_overwrite_if_exists_and_replay(
         False,
         replay=True,
         overwrite_if_exists=True,
-        output_dir='.'
+        output_dir='.',
+        config_file=config.USER_CONFIG_PATH
     )
 
 
@@ -201,7 +204,8 @@ def test_cli_output_dir(mocker, output_dir_flag, output_dir):
         False,
         replay=False,
         overwrite_if_exists=False,
-        output_dir=output_dir
+        output_dir=output_dir,
+        config_file=config.USER_CONFIG_PATH
     )
 
 
@@ -214,3 +218,80 @@ def test_cli_help(help_cli_flag):
     result = runner.invoke(main, [help_cli_flag])
     assert result.exit_code == 0
     assert result.output.startswith('Usage')
+
+
+@pytest.fixture
+def user_config_path(tmpdir):
+    return str(tmpdir.join('tests/config.yaml'))
+
+
+def test_user_config(mocker, user_config_path):
+    mock_cookiecutter = mocker.patch(
+        'cookiecutter.cli.cookiecutter'
+    )
+
+    template_path = 'tests/fake-repo-pre/'
+    result = runner.invoke(main, [
+        template_path,
+        '--config-file',
+        user_config_path
+    ])
+
+    assert result.exit_code == 0
+    mock_cookiecutter.assert_called_once_with(
+        template_path,
+        None,
+        False,
+        replay=False,
+        overwrite_if_exists=False,
+        output_dir='.',
+        config_file=user_config_path
+    )
+
+
+def test_default_user_config_overwrite(mocker, user_config_path):
+    mock_cookiecutter = mocker.patch(
+        'cookiecutter.cli.cookiecutter'
+    )
+
+    template_path = 'tests/fake-repo-pre/'
+    result = runner.invoke(main, [
+        template_path,
+        '--config-file',
+        user_config_path,
+        '--default-config'
+    ])
+
+    assert result.exit_code == 0
+    mock_cookiecutter.assert_called_once_with(
+        template_path,
+        None,
+        False,
+        replay=False,
+        overwrite_if_exists=False,
+        output_dir='.',
+        config_file=None
+    )
+
+
+def test_default_user_config(mocker):
+    mock_cookiecutter = mocker.patch(
+        'cookiecutter.cli.cookiecutter'
+    )
+
+    template_path = 'tests/fake-repo-pre/'
+    result = runner.invoke(main, [
+        template_path,
+        '--default-config'
+    ])
+
+    assert result.exit_code == 0
+    mock_cookiecutter.assert_called_once_with(
+        template_path,
+        None,
+        False,
+        replay=False,
+        overwrite_if_exists=False,
+        output_dir='.',
+        config_file=None
+    )
diff --git a/tests/test_get_user_config.py b/tests/test_get_user_config.py
--- a/tests/test_get_user_config.py
+++ b/tests/test_get_user_config.py
@@ -93,3 +93,56 @@ def test_get_user_config_nonexistent():
     Get config from a nonexistent ~/.cookiecutterrc file
     """
     assert config.get_user_config() == config.DEFAULT_CONFIG
+
+
+@pytest.fixture
+def custom_config():
+    return {
+        'cookiecutters_dir': '/foo/bar/some-path-to-templates',
+        'replay_dir': '/foo/bar/some-path-to-replay-files',
+        'default_context': {
+            'full_name': 'Cookiemonster',
+            'github_username': 'hackebrot'
+        },
+        'abbreviations': {
+            'cookiedozer': 'https://github.com/hackebrot/cookiedozer.git',
+        }
+    }
+
+
+@pytest.fixture
+def custom_config_path(tmpdir, custom_config):
+    user_config_file = tmpdir.join('user_config')
+
+    user_config_file.write(config.yaml.dump(custom_config))
+    return str(user_config_file)
+
+
+def test_specify_config_path(mocker, custom_config_path, custom_config):
+    spy_get_config = mocker.spy(config, 'get_config')
+
+    user_config = config.get_user_config(custom_config_path)
+    spy_get_config.assert_called_once_with(custom_config_path)
+
+    assert user_config == custom_config
+
+
+def test_default_config_path(user_config_path):
+    assert config.USER_CONFIG_PATH == user_config_path
+
+
+def test_default_config_from_env_variable(
+        monkeypatch, custom_config_path, custom_config):
+    monkeypatch.setenv('COOKIECUTTER_CONFIG', custom_config_path)
+
+    user_config = config.get_user_config()
+    assert user_config == custom_config
+
+
+def test_force_default_config(mocker):
+    spy_get_config = mocker.spy(config, 'get_config')
+
+    user_config = config.get_user_config(None)
+
+    assert user_config == config.DEFAULT_CONFIG
+    assert not spy_get_config.called