Repository: audreyr/cookiecutter
Pull request #495 (2015-08-10 12:08:08)

Add a flag to overwrite existing output directory
Fix issue #493 and allow the user to alter the default behaviour
behaviour of not overwriting the contents of the output directory
if it already exists. Fix issue #475 by removing the conflicting
use of 'fail_if_exists' arguments and refactoring it.

lgp171188: cc @hackebrot @schacki @ionelmc 
codecov-io: ## [Current coverage][1] is `96.74%`
> Merging **#495** into **master** will increase coverage by **+0.02%** as of [`4fec055`][3]


```diff
@@            master    #495   diff @@
======================================
  Files           12      12       
  Stmts          457     460     +3
  Branches         0       0       
  Methods          0       0       
======================================
+ Hit            442     445     +3
  Partial          0       0       
  Missed          15      15       
```

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


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

1. `+0.65%` via [...okiecutter/compat.py#70...72](https://codecov.io/github/audreyr/cookiecutter/cookiecutter/compat.py?ref=4fec055b7148bc6ccc254a65be0778fd2a48bb4a#70) 
1. `+0.65%` via [...okiecutter/compat.py#61...63](https://codecov.io/github/audreyr/cookiecutter/cookiecutter/compat.py?ref=4fec055b7148bc6ccc254a65be0778fd2a48bb4a#61) 
1. `+0.65%` via [...okiecutter/compat.py#49...51](https://codecov.io/github/audreyr/cookiecutter/cookiecutter/compat.py?ref=4fec055b7148bc6ccc254a65be0778fd2a48bb4a#49) 
1. *[See 4 more...][2]*

[1]: https://codecov.io/github/audreyr/cookiecutter?ref=4fec055b7148bc6ccc254a65be0778fd2a48bb4a
[2]: https://codecov.io/github/audreyr/cookiecutter/features/suggestions?ref=4fec055b7148bc6ccc254a65be0778fd2a48bb4a
[3]: https://codecov.io/github/audreyr/cookiecutter/commit/4fec055b7148bc6ccc254a65be0778fd2a48bb4a
[4]: https://codecov.io/github/audreyr/cookiecutter/compare/75590ed66ca7ad14df62c4c43f53dd35253f606c...4fec055b7148bc6ccc254a65be0778fd2a48bb4a

> Powered by [Codecov](https://codecov.io). Updated on successful CI builds.
ionelmc: Anyone still working on this?
hackebrot: Nope, I am not. I'm still in favor of using kwargs :smirk:
pydanny: :-1:  on breaking backwards compatibility. 

FWIW cookiecutter is used in a lot of places and packages, including as an imported package. If we accept this PR as it stands the issues board will be filled with help requests.
lgp171188: @pydanny I will fix the backward compatibility issues pointed out by @hackebrot. I was just waiting for all the review comments which would require a change so that I could do all of them in one shot. Will make the suggested changes and get back.

Patch:
diff --git a/.travis.yml b/.travis.yml
index e315d1b..315799d 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -17,6 +17,5 @@ install:
 
 after_success:
     # Report coverage results to codecov.io
-    # and export tox environment variables
     - pip install codecov
-    - codecov -e TOX_ENV
+    - codecov
diff --git a/AUTHORS.rst b/AUTHORS.rst
index 294590c..62519fc 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -72,9 +72,7 @@ Contributors
 * Guruprasad (`@lgp171188`_)
 * Charles-Axel Dein (`@charlax`_)
 * Diego Garcia (`@drgarcia1986`_)
-* maiksensi (`@maiksensi_`)
 
-.. _`@maiksensi_`: https://github.com/maiksensi
 .. _`@svisser`: https://github.com/svisser
 .. _`@LucianU`: https://github.com/LucianU
 .. _`@osantana`: https://github.com/osantana
@@ -119,7 +117,7 @@ Contributors
 .. _`@emonty`: https://github.com/emonty
 .. _`@schacki`: https://github.com/schacki
 .. _`@ryanolson`: https://github.com/ryanolson
-.. _`@treyhunner`: https://github.com/treyhunner
+.. _`@treyhunner`: https://github.com/treyhunner 
 .. _`@freakboy3742`: https://github.com/freakboy3742
 .. _`@mishbahr`: https://github.com/mishbahr
 .. _`@robinandeer`: https://github.com/robinandeer
diff --git a/HISTORY.rst b/HISTORY.rst
index 3fa3db3..710ede3 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -28,9 +28,6 @@ Other Changes:
 * Removed deprecated and fully extraneous `compat.is_exe()` function, thanks to `@hackebrot`_ (#485).
 * Disabled sudo in .travis, thanks to `@hackebrot`_ (#482).
 * Switched to shields.io for problematic badges, thanks to `@pydanny`_ (#491).
-* Added whichcraft and removed ``compat.which()``, thanks to `@pydanny`_ (#511).
-* Changed to export tox environment variables to codecov, thanks to `@maiksensi`_ (#508).
-* Moved to using click version command, thanks to `@hackebrot`_ (#489).
 * Added more cookiecutter templates to the mix:
 
   * `cookiecutter-beamer`_ by `@luismartingil`_ (#307)
@@ -42,7 +39,6 @@ Other Changes:
   * `cookiecutter-sublime-text-3-plugin`_ by `@kkujawinski`_ (#500)
   * `cookiecutter-muffin`_ by `@drgarcia1986`_ (#494)
 
-.. _`@maiksensi`: https://github.com/maiksensi
 .. _`copy without render`: http://cookiecutter.readthedocs.org/en/latest/advanced_usage.html#copy-without-render
 .. _`@osantana`: https://github.com/osantana
 .. _`@LucianU`: https://github.com/LucianU
diff --git a/cookiecutter/cli.py b/cookiecutter/cli.py
index b03abd8..de80d1b 100755
--- a/cookiecutter/cli.py
+++ b/cookiecutter/cli.py
@@ -25,15 +25,18 @@ from cookiecutter.exceptions import (
 logger = logging.getLogger(__name__)
 
 
-def version_msg():
-    python_version = sys.version[:3]
-    location = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-    message = 'Cookiecutter %(version)s from {} (Python {})'
-    return message.format(location, python_version)
+def print_version(context, param, value):
+    if not value or context.resilient_parsing:
+        return
+    click.echo('Cookiecutter %s from %s (Python %s)' % (
+        __version__,
+        os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+        sys.version[:3]
+    ))
+    context.exit()
 
 
 @click.command()
-@click.version_option(__version__, '-V', '--version', message=version_msg())
 @click.argument('template')
 @click.option(
     '--no-input', is_flag=True,
@@ -44,6 +47,11 @@ def version_msg():
     '-c', '--checkout',
     help='branch, tag or commit to checkout after git clone',
 )
+@click.option(
+    '-V', '--version',
+    is_flag=True, help='Show version information and exit.',
+    callback=print_version, expose_value=False, is_eager=True,
+)
 @click.option(
     '-v', '--verbose',
     is_flag=True, help='Print debug information', default=False
@@ -53,7 +61,11 @@ def version_msg():
     help='Do not prompt for parameters and only use information entered '
          'previously',
 )
-def main(template, no_input, checkout, verbose, replay):
+@click.option(
+    '-f', '--overwrite-if-exists', is_flag=True,
+    help='Overwrite the contents of the output directory if it already exists'
+)
+def main(template, no_input, checkout, verbose, replay, overwrite_if_exists):
     """Create a project from a Cookiecutter project template (TEMPLATE)."""
     if verbose:
         logging.basicConfig(
@@ -68,7 +80,8 @@ def main(template, no_input, checkout, verbose, replay):
         )
 
     try:
-        cookiecutter(template, checkout, no_input, replay=replay)
+        cookiecutter(template, checkout, no_input, replay=replay,
+                     overwrite_if_exists=overwrite_if_exists)
     except (OutputDirExistsException, InvalidModeException) as e:
         click.echo(e)
         sys.exit(1)
diff --git a/cookiecutter/compat.py b/cookiecutter/compat.py
index 2665dae..68c9473 100644
--- a/cookiecutter/compat.py
+++ b/cookiecutter/compat.py
@@ -1,3 +1,4 @@
+import os
 import sys
 
 PY3 = sys.version_info[0] == 3
@@ -18,9 +19,78 @@ else:  # pragma: no cover
     from cStringIO import StringIO
 
 
+if PY3:  # Forced testing
+
+    from shutil import which
+
+else:  # Forced testing
+
+    def which(cmd, mode=os.F_OK | os.X_OK, path=None):
+        """Given a command, mode, and a PATH string, return the path which
+        conforms to the given mode on the PATH, or None if there is no such
+        file.
+        `mode` defaults to os.F_OK | os.X_OK. `path` defaults to the result
+        of os.environ.get("PATH"), or can be overridden with a custom search
+        path.
+
+        Note: This function was backported from the Python 3 source code.
+        """
+        # Check that a given file can be accessed with the correct mode.
+        # Additionally check that `file` is not a directory, as on Windows
+        # directories pass the os.access check.
+        def _access_check(fn, mode):
+            return (os.path.exists(fn) and os.access(fn, mode) and
+                    not os.path.isdir(fn))
+
+        # If we're given a path with a directory part, look it up directly
+        # rather than referring to PATH directories. This includes checking
+        # relative to the current directory, e.g. ./script
+        if os.path.dirname(cmd):
+            if _access_check(cmd, mode):
+                return cmd
+            return None
+
+        if path is None:
+            path = os.environ.get("PATH", os.defpath)
+        if not path:
+            return None
+        path = path.split(os.pathsep)
+
+        if sys.platform == "win32":
+            # The current directory takes precedence on Windows.
+            if os.curdir not in path:
+                path.insert(0, os.curdir)
+
+            # PATHEXT is necessary to check on Windows.
+            pathext = os.environ.get("PATHEXT", "").split(os.pathsep)
+            # See if the given file matches any of the expected path
+            # extensions. This will allow us to short circuit when given
+            # "python.exe". If it does match, only test that one, otherwise we
+            # have to try others.
+            if any(cmd.lower().endswith(ext.lower()) for ext in pathext):
+                files = [cmd]
+            else:
+                files = [cmd + ext for ext in pathext]
+        else:
+            # On other platforms you don't have things like PATHEXT to tell you
+            # what file suffixes are executable, so just pass on cmd as-is.
+            files = [cmd]
+
+        seen = set()
+        for dir in path:
+            normdir = os.path.normcase(dir)
+            if normdir not in seen:
+                seen.add(normdir)
+                for thefile in files:
+                    name = os.path.join(dir, thefile)
+                    if _access_check(name, mode):
+                        return name
+        return None
+
+
 def is_string(obj):
     """Determine if an object is a string."""
     return isinstance(obj, str if PY3 else basestring)
 
 
-_hush_pyflakes = (StringIO)
+_hush_pyflakes = (StringIO, which)
diff --git a/cookiecutter/generate.py b/cookiecutter/generate.py
index 8bc63a3..2707ad0 100755
--- a/cookiecutter/generate.py
+++ b/cookiecutter/generate.py
@@ -152,7 +152,8 @@ def generate_file(project_dir, infile, context, env):
     shutil.copymode(infile, outfile)
 
 
-def render_and_create_dir(dirname, context, output_dir, fail_if_exists=False):
+def render_and_create_dir(dirname, context, output_dir,
+                          overwrite_if_exists=False):
     """
     Renders the name of a directory, creates the directory, and
     returns its path.
@@ -168,8 +169,14 @@ def render_and_create_dir(dirname, context, output_dir, fail_if_exists=False):
         os.path.join(output_dir, rendered_dirname)
     )
 
-    if fail_if_exists:
-        if os.path.exists(dir_to_create):
+    output_dir_exists = os.path.exists(dir_to_create)
+
+    if overwrite_if_exists:
+        if output_dir_exists:
+            logging.debug('Output directory {} already exists,'
+                          'overwriting it'.format(dir_to_create))
+    else:
+        if output_dir_exists:
             msg = 'Error: "{}" directory already exists'.format(dir_to_create)
             raise OutputDirExistsException(msg)
 
@@ -187,13 +194,16 @@ def ensure_dir_is_templated(dirname):
         raise NonTemplatedInputDirException
 
 
-def generate_files(repo_dir, context=None, output_dir='.'):
+def generate_files(repo_dir, context=None, output_dir='.',
+                   overwrite_if_exists=False):
     """
     Renders the templates and saves them to files.
 
     :param repo_dir: Project template input directory.
     :param context: Dict for populating the template's variables.
     :param output_dir: Where to output the generated project dir into.
+    :param overwrite_if_exists: Overwrite teh contents of the output directory
+        if it exists
     """
 
     template_dir = find_template(repo_dir)
@@ -205,7 +215,7 @@ def generate_files(repo_dir, context=None, output_dir='.'):
     project_dir = render_and_create_dir(unrendered_dir,
                                         context,
                                         output_dir,
-                                        fail_if_exists=True)
+                                        overwrite_if_exists)
 
     # We want the Jinja path and the OS paths to match. Consequently, we'll:
     #   + CD to the template folder
@@ -256,7 +266,8 @@ def generate_files(repo_dir, context=None, output_dir='.'):
             dirs[:] = render_dirs
             for d in dirs:
                 unrendered_dir = os.path.join(project_dir, root, d)
-                render_and_create_dir(unrendered_dir, context, output_dir)
+                render_and_create_dir(unrendered_dir, context, output_dir,
+                                      overwrite_if_exists)
 
             for f in files:
                 infile = os.path.normpath(os.path.join(root, f))
diff --git a/cookiecutter/main.py b/cookiecutter/main.py
index 415aa6e..14b1e65 100755
--- a/cookiecutter/main.py
+++ b/cookiecutter/main.py
@@ -70,8 +70,8 @@ def expand_abbreviations(template, config_dict):
 
 
 def cookiecutter(
-        template,
-        checkout=None, no_input=False, extra_context=None, replay=False):
+        template, checkout=None, no_input=False, extra_context=None,
+        replay=False, overwrite_if_exists=False):
     """
     API equivalent to using Cookiecutter at the command line.
 
@@ -81,6 +81,8 @@ def cookiecutter(
     :param no_input: Prompt the user at command line for manual configuration?
     :param extra_context: A dictionary of context that overrides default
         and user configuration.
+    :param: overwrite_if_exists: Overwrite the contents of output directory
+        if it exists
     """
     if replay and ((no_input is not False) or (extra_context is not None)):
         err_msg = (
@@ -130,5 +132,6 @@ def cookiecutter(
     # Create project from local context and project template.
     generate_files(
         repo_dir=repo_dir,
-        context=context
+        context=context,
+        overwrite_if_exists=overwrite_if_exists
     )
diff --git a/cookiecutter/vcs.py b/cookiecutter/vcs.py
index 269fee3..f97461c 100755
--- a/cookiecutter/vcs.py
+++ b/cookiecutter/vcs.py
@@ -14,8 +14,7 @@ import os
 import subprocess
 import sys
 
-from whichcraft import which
-
+from .compat import which
 from .exceptions import UnknownRepoType, VCSNotInstalled
 from .prompt import read_user_yes_no
 from .utils import make_sure_path_exists, rmtree
diff --git a/setup.py b/setup.py
index 5e5a5de..b6b7fd5 100755
--- a/setup.py
+++ b/setup.py
@@ -30,8 +30,7 @@ requirements = [
     'binaryornot>=0.2.0',
     'jinja2>=2.7',
     'PyYAML>=3.10',
-    'click<5.0',
-    'whichcraft>=0.1.1'
+    'click<5.0'
 ]
 
 long_description = readme + '\n\n' + history


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 38da901..59bf3f4 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -30,13 +30,8 @@ def make_fake_project_dir(request):
         os.makedirs('fake-project')
 
 
-@pytest.fixture(params=['-V', '--version'])
-def version_cli_flag(request):
-    return request.param
-
-
-def test_cli_version(version_cli_flag):
-    result = runner.invoke(main, [version_cli_flag])
+def test_cli_version():
+    result = runner.invoke(main, ['-V'])
     assert result.exit_code == 0
     assert result.output.startswith('Cookiecutter')
 
@@ -81,7 +76,8 @@ def test_cli_replay(mocker):
         template_path,
         None,
         False,
-        replay=True
+        replay=True,
+        overwrite_if_exists=False
     )
 
 
@@ -106,11 +102,56 @@ def test_cli_exit_on_noinput_and_replay(mocker):
         "You can not use both replay and no_input or extra_context "
         "at the same time."
     )
+
     assert expected_error_msg in result.output
 
     mock_cookiecutter.assert_called_once_with(
         template_path,
         None,
         True,
-        replay=True
+        replay=True,
+        overwrite_if_exists=False
+    )
+
+@pytest.mark.usefixtures('remove_fake_project_dir')
+def test_cli_exit_on_overwrite_if_exists_and_replay(mocker):
+    mock_cookiecutter = mocker.patch(
+        'cookiecutter.cli.cookiecutter',
+        side_effect=cookiecutter
     )
+
+    template_path = 'tests/fake-repo-pre/'
+    result = runner.invoke(main, [
+        template_path,
+        '--replay',
+        '-v',
+        '-f',
+    ])
+
+    assert result.exit_code == 0
+
+    mock_cookiecutter.assert_called_once_with(
+        template_path,
+        None,
+        False,
+        replay=True,
+        overwrite_if_exists=True
+    )
+
+
+@pytest.mark.usefixtures('remove_fake_project_dir')
+def test_cli_overwrite_if_exists_when_output_dir_does_not_exist():
+    if os.path.isdir('fake-project'):
+        utils.rmtree('fake-project')
+
+    result = runner.invoke(main, ['tests/fake-repo-pre/', '--no-input', '-f'])
+
+    assert result.exit_code == 0
+    assert os.path.isdir('fake-project')
+
+
+@pytest.mark.usefixtures('make_fake_project_dir', 'remove_fake_project_dir')
+def test_cli_overwrite_if_exists_when_output_dir_exists():
+    result = runner.invoke(main, ['tests/fake-repo-pre/', '--no-input', '-f'])
+    assert result.exit_code == 0
+    assert os.path.isdir('fake-project')
diff --git a/tests/test_compat.py b/tests/test_compat.py
new file mode 100644
index 0000000..4655f71
--- /dev/null
+++ b/tests/test_compat.py
@@ -0,0 +1,25 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+test_compat
+------------
+
+Tests for `cookiecutter.compat` module.
+"""
+
+import os
+
+from cookiecutter.compat import which
+
+
+def test_existing_command():
+    cmd = which('date')
+    assert cmd
+    assert os.path.exists(cmd)
+    assert os.access(cmd, os.F_OK | os.X_OK)
+    assert not os.path.isdir(cmd)
+
+
+def test_non_existing_command():
+    assert which('stringthatisntashellcommand') is None