Repository: cookiecutter/cookiecutter

Issue:
Cookiecutter throws semicryptic FileNotFoundError if git isn't installed
```
[thomas@chansey ~]♣ cookiecutter https://github.com/audreyr/cookiecutter-pypackage.git  
Traceback (most recent call last):
  File "/home/thomas/.local/bin/cookiecutter", line 9, in <module>
    load_entry_point('cookiecutter==0.7.0', 'console_scripts', 'cookiecutter')()
  File "/home/thomas/.local/lib/python3.3/site-packages/cookiecutter/main.py", line 115, in main
    cookiecutter(args.input_dir, args.checkout, args.no_input)
  File "/home/thomas/.local/lib/python3.3/site-packages/cookiecutter/main.py", line 47, in cookiecutter
    clone_to_dir=config_dict['cookiecutters_dir']
  File "/home/thomas/.local/lib/python3.3/site-packages/cookiecutter/vcs.py", line 80, in clone
    subprocess.check_call([repo_type, 'clone', repo_url], cwd=clone_to_dir)
  File "/usr/lib/python3.3/subprocess.py", line 542, in check_call
    retcode = call(*popenargs, **kwargs)
  File "/usr/lib/python3.3/subprocess.py", line 523, in call
    with Popen(*popenargs, **kwargs) as p:
  File "/usr/lib/python3.3/subprocess.py", line 824, in __init__
    restore_signals, start_new_session)
  File "/usr/lib/python3.3/subprocess.py", line 1448, in _execute_child
    raise child_exception_type(errno_num, err_msg)
FileNotFoundError: [Errno 2] No such file or directory: 'git'
```

is it possible to check for the presence of the `git` executable and handle its absence more gracefully?



Discussion:

Resolving patch:
diff --git a/cookiecutter/compat.py b/cookiecutter/compat.py
--- a/cookiecutter/compat.py
+++ b/cookiecutter/compat.py
@@ -1,3 +1,4 @@
+import os
 import sys
 
 PY3 = sys.version_info[0] == 3
@@ -24,6 +25,7 @@ def read_response(prompt=''):
         """
         # The Python 3 input function does exactly what we want
         return input(prompt)
+
 else:  # pragma: no cover
     from __builtin__ import raw_input
     input = raw_input
@@ -67,4 +69,79 @@ def read_response(prompt=''):
         enc = sys.stdin.encoding or sys.getdefaultencoding()
         return raw_input(prompt).decode(enc)
 
-_hush_pyflakes = (patch, StringIO, json, OrderedDict, unittest)
+
+if PY3:  # Forced testing
+
+    from shutil import which
+
+else:  # Forced testing
+
+    def is_exe(program):
+        """
+        Returns whether or not a file is an executable.
+        """
+        return os.path.isfile(program) and os.access(program, os.X_OK)
+
+    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
+
+_hush_pyflakes = (patch, StringIO, json, OrderedDict, unittest, which)
diff --git a/cookiecutter/exceptions.py b/cookiecutter/exceptions.py
--- a/cookiecutter/exceptions.py
+++ b/cookiecutter/exceptions.py
@@ -56,3 +56,9 @@ class UnknownRepoType(CookiecutterException):
     """
     Raised if a repo's type cannot be determined.
     """
+
+
+class VCSNotInstalled(CookiecutterException):
+    """
+    Raised if the version control system (git or hg) is not installed.
+    """
diff --git a/cookiecutter/vcs.py b/cookiecutter/vcs.py
--- a/cookiecutter/vcs.py
+++ b/cookiecutter/vcs.py
@@ -14,7 +14,8 @@
 import subprocess
 import sys
 
-from .exceptions import UnknownRepoType
+from .compat import which
+from .exceptions import UnknownRepoType, VCSNotInstalled
 from .prompt import query_yes_no
 from .utils import make_sure_path_exists, rmtree
 
@@ -60,7 +61,16 @@ def identify_repo(repo_url):
         raise UnknownRepoType
 
 
-def clone(repo_url, checkout=None, clone_to_dir='.', no_input=False):
+def is_vcs_installed(repo_type):
+    """
+    Check if the version control system for a repo type is installed.
+
+    :param repo_type:
+    """
+    return bool(which(repo_type))
+
+
+def clone(repo_url, checkout=None, clone_to_dir=".", no_input=False):
     """
     Clone a repo to the current directory.
 
@@ -75,8 +85,14 @@ def clone(repo_url, checkout=None, clone_to_dir='.', no_input=False):
     clone_to_dir = os.path.expanduser(clone_to_dir)
     make_sure_path_exists(clone_to_dir)
 
+    # identify the repo_type
     repo_type = identify_repo(repo_url)
 
+    # check that the appropriate VCS for the repo_type is installed
+    if not is_vcs_installed(repo_type):
+        msg = "'{0}' is not installed.".format(repo_type)
+        raise VCSNotInstalled(msg)
+
     tail = os.path.split(repo_url)[1]
     if repo_type == 'git':
         repo_dir = os.path.normpath(os.path.join(clone_to_dir,


Test patch:
diff --git a/tests/test_compat.py b/tests/test_compat.py
new file mode 100644
--- /dev/null
+++ b/tests/test_compat.py
@@ -0,0 +1,27 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+test_compat
+------------
+
+Tests for `cookiecutter.compat` module.
+"""
+
+from cookiecutter.compat import unittest, which
+
+
+class TestWhich(unittest.TestCase):
+
+    def test_existing_command(self):
+        self.assertTrue(
+            which('cookiecutter')
+        )
+
+    def test_non_existing_command(self):
+        self.assertFalse(
+            which('stringthatisntashellcommand')
+        )
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/test_vcs.py b/tests/test_vcs.py
--- a/tests/test_vcs.py
+++ b/tests/test_vcs.py
@@ -14,7 +14,7 @@
 import subprocess
 
 from cookiecutter.compat import patch, unittest
-from cookiecutter import utils, vcs
+from cookiecutter import exceptions, utils, vcs
 
 try:
     no_network = os.environ[u'DISABLE_NETWORK_TESTS']
@@ -45,6 +45,14 @@ def test_identify_hg_mercurial(self):
         repo_url = "https://audreyr@bitbucket.org/audreyr/cookiecutter-bitbucket"
         self.assertEqual(vcs.identify_repo(repo_url), "hg")
 
+    def test_unknown_repo_type(self):
+        repo_url = "http://norepotypespecified.com"
+        self.assertRaises(
+            exceptions.UnknownRepoType,
+            vcs.identify_repo,
+            repo_url
+        )
+
 
 @unittest.skipIf(condition=no_network, reason='Needs a network connection to GitHub/Bitbucket.')
 class TestVCS(unittest.TestCase):
@@ -104,6 +112,14 @@ def test_hg_clone(self):
         if os.path.isdir('cookiecutter-trytonmodule'):
             utils.rmtree('cookiecutter-trytonmodule')
 
+    @patch('cookiecutter.vcs.identify_repo', lambda x: u'stringthatisntashellcommand')
+    def test_vcs_not_installed(self):
+        self.assertRaises(
+            exceptions.VCSNotInstalled,
+            vcs.clone,
+            "http://norepotypespecified.com"
+        )
+
 
 @unittest.skipIf(condition=no_network, reason='Needs a network connection to GitHub/Bitbucket.')
 class TestVCSPrompt(unittest.TestCase):
@@ -163,5 +179,18 @@ def tearDown(self):
             utils.rmtree('cookiecutter-trytonmodule')
 
 
+class TestIsVCSInstalled(unittest.TestCase):
+
+    def test_existing_repo_type(self):
+        self.assertTrue(
+            vcs.is_vcs_installed("git"),
+        )
+
+    def test_non_existing_repo_type(self):
+        self.assertFalse(
+            vcs.is_vcs_installed("stringthatisntashellcommand")
+        )
+
+
 if __name__ == '__main__':
     unittest.main()