Repository: audreyr/cookiecutter
Pull request #565 (2015-10-24 23:39:53)

Specify user config file via cli or environment variable
Retrieve the user config file from a custom location via:

``$ cookiecutter --config /home/audreyr/my-custom-config.yaml cookiecutter-pypackage``

or

``$ export COOKIECUTTER_CONFIG=/home/audreyr/my-custom-config.yaml``

**``--config`` takes precedence over ``COOKIECUTTER_CONFIG`` over ``~/.cookiecutterrc``.**

Close #424, close #258 cc @jhermann @pfmoore 

codecov-io: ## [Current coverage][1] is `99.18%`
> Merging **#565** into **master** will not affect coverage as of [`b2fcfcf`][3]


```diff
@@            master    #565   diff @@
======================================
  Files           12      12       
  Stmts          489     493     +4
  Branches         0       0       
  Methods          0       0       
======================================
+ Hit            485     489     +4
  Partial          0       0       
  Missed           4       4       
```

> Review entire [Coverage Diff][4] as of [`b2fcfcf`][3]


-----
### [Uncovered Suggestions][2]

1. `+0.41%` via [...okiecutter/config.py#19...20](https://codecov.io/github/audreyr/cookiecutter/cookiecutter/config.py?ref=b2fcfcf12407c77f406be84b9ef3bd167f293db5#19) 
1. `+0.41%` via [...ookiecutter/utils.py#27...28](https://codecov.io/github/audreyr/cookiecutter/cookiecutter/utils.py?ref=b2fcfcf12407c77f406be84b9ef3bd167f293db5#27) 


[1]: https://codecov.io/github/audreyr/cookiecutter?ref=b2fcfcf12407c77f406be84b9ef3bd167f293db5
[2]: https://codecov.io/github/audreyr/cookiecutter/features/suggestions?ref=b2fcfcf12407c77f406be84b9ef3bd167f293db5
[3]: https://codecov.io/github/audreyr/cookiecutter/commit/b2fcfcf12407c77f406be84b9ef3bd167f293db5
[4]: https://codecov.io/github/audreyr/cookiecutter/compare/9d69f892a58a9cb7b9d19d16e454c510c4489c2a...b2fcfcf12407c77f406be84b9ef3bd167f293db5

> Powered by [Codecov](https://codecov.io). Updated on successful CI builds.
hackebrot: Review pls :bow:

Patch:
diff --git a/cookiecutter/cli.py b/cookiecutter/cli.py
index 4c25256..8825a8f 100755
--- a/cookiecutter/cli.py
+++ b/cookiecutter/cli.py
@@ -15,6 +15,7 @@ import logging
 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
index 00c8113..edfcec4 100755
--- a/cookiecutter/config.py
+++ b/cookiecutter/config.py
@@ -25,6 +25,8 @@ from .exceptions import InvalidConfiguration
 
 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
index eae455b..e6d858f 100755
--- a/cookiecutter/main.py
+++ b/cookiecutter/main.py
@@ -16,7 +16,7 @@ import logging
 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)
 
diff --git a/docs/advanced_usage.rst b/docs/advanced_usage.rst
index cee1b6a..5d762de 100644
--- a/docs/advanced_usage.rst
+++ b/docs/advanced_usage.rst
@@ -66,8 +66,23 @@ before generating the project, to be used as ``hooks/pre_gen_project.py``:
 User Config (0.7.0+)
 ----------------------
 
-If you use Cookiecutter a lot, you'll find it useful to have a
-`.cookiecutterrc` file in your home directory like this:
+If you use Cookiecutter a lot, you'll find it useful to have a user config
+file. By default Cookiecutter tries to retrieve settings from a `.cookiecutterrc`
+file in your home directory.
+
+From version 1.3.0 you can also specify a config file on the command line via ``--config-file``::
+
+    $ cookiecutter --config-file /home/audreyr/my-custom-config.yaml cookiecutter-pypackage
+
+Or you can set the ``COOKIECUTTER_CONFIG`` environment variable::
+
+    $ export COOKIECUTTER_CONFIG=/home/audreyr/my-custom-config.yaml
+
+If you wish to stick to the built-in config and not load any user config file at all,
+use the cli option ``--default-config`` instead. Preventing Cookiecutter from loading
+user settings is crucial for writing integration tests in an isolated environment.
+
+Example user config:
 
 .. code-block:: yaml
 


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 0bc49bb..383d7ea 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -5,7 +5,7 @@ from click.testing import CliRunner
 
 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
index f015fab..884797f 100644
--- 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