Repository: cookiecutter/cookiecutter

Issue:
PEP257 docstrings for file "./tests/conftest.py"
Cover `./tests/conftest.py` file with docstrings and follow [PEP257](https://www.python.org/dev/peps/pep-0257/). We use [pydocstyle](https://pypi.org/project/pydocstyle/) for validation.

Current validation log:

```
./tests/conftest.py:3 at module level:
        D205: 1 blank line required between summary line and description (found 0)
./tests/conftest.py:3 at module level:
        D400: First line should end with a period (not 't')
./tests/conftest.py:25 in public function `backup_dir`:
        D103: Missing docstring in public function
./tests/conftest.py:38 in public function `restore_backup_dir`:
        D103: Missing docstring in public function
./tests/conftest.py:62 in public function `clean_system`:
        D202: No blank lines allowed after function docstring (found 1)
./tests/conftest.py:154 in public function `user_dir`:
        D400: First line should end with a period (not 'y')
./tests/conftest.py:160 in public function `user_config_data`:
        D205: 1 blank line required between summary line and description (found 0)
./tests/conftest.py:160 in public function `user_config_data`:
        D400: First line should end with a period (not 'e')
./tests/conftest.py:178 in public function `user_config_file`:
        D205: 1 blank line required between summary line and description (found 0)
./tests/conftest.py:178 in public function `user_config_file`:
        D400: First line should end with a period (not 'e')
./tests/conftest.py:193 in public function `disable_poyo_logging`:
        D103: Missing docstring in public function
```

Subtask for #742 

Discussion:

Patch:
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 0000000..5d2e6ba
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,2 @@
+# comment spam as user can always click the failed coverage check
+comment: false
diff --git a/cookiecutter/cli.py b/cookiecutter/cli.py
index 9034d95..a9b10e3 100644
--- a/cookiecutter/cli.py
+++ b/cookiecutter/cli.py
@@ -59,6 +59,11 @@ def validate_extra_context(ctx, param, value):
     u'-c', u'--checkout',
     help=u'branch, tag or commit to checkout after git clone',
 )
+@click.option(
+    u'--directory',
+    help=u'Directory within repo that holds cookiecutter.json file '
+         u'for advanced repositories with multi templates in it',
+)
 @click.option(
     '-v', '--verbose',
     is_flag=True, help='Print debug information', default=False
@@ -91,7 +96,7 @@ def validate_extra_context(ctx, param, value):
 def main(
         template, extra_context, no_input, checkout, verbose,
         replay, overwrite_if_exists, output_dir, config_file,
-        default_config, debug_file):
+        default_config, debug_file, directory):
     """Create a project from a Cookiecutter project template (TEMPLATE).
 
     Cookiecutter is free and open source software, developed and managed by
@@ -118,7 +123,8 @@ def main(
             output_dir=output_dir,
             config_file=config_file,
             default_config=default_config,
-            password=os.environ.get('COOKIECUTTER_REPO_PASSWORD')
+            password=os.environ.get('COOKIECUTTER_REPO_PASSWORD'),
+            directory=directory
         )
     except (OutputDirExistsException,
             InvalidModeException,
diff --git a/cookiecutter/environment.py b/cookiecutter/environment.py
index 308bce7..2f99ca0 100644
--- a/cookiecutter/environment.py
+++ b/cookiecutter/environment.py
@@ -27,6 +27,7 @@ class ExtensionLoaderMixin(object):
 
         default_extensions = [
             'cookiecutter.extensions.JsonifyExtension',
+            'cookiecutter.extensions.RandomStringExtension',
             'jinja2_time.TimeExtension',
         ]
         extensions = default_extensions + self._read_extensions(context)
diff --git a/cookiecutter/extensions.py b/cookiecutter/extensions.py
index bb10ff0..f9c02d9 100644
--- a/cookiecutter/extensions.py
+++ b/cookiecutter/extensions.py
@@ -3,6 +3,12 @@
 """Jinja2 extensions."""
 
 import json
+import string
+try:
+    # Python 3.6 and above
+    from secrets import choice
+except ImportError:
+    from random import choice
 
 from jinja2.ext import Extension
 
@@ -18,3 +24,18 @@ class JsonifyExtension(Extension):
             return json.dumps(obj, sort_keys=True, indent=4)
 
         environment.filters['jsonify'] = jsonify
+
+
+class RandomStringExtension(Extension):
+    """Jinja2 extension to create a random string."""
+
+    def __init__(self, environment):
+        super(RandomStringExtension, self).__init__(environment)
+
+        def random_ascii_string(length, punctuation=False):
+            if punctuation:
+                corpus = "".join((string.ascii_letters, string.punctuation))
+            else:
+                corpus = string.ascii_letters
+            return "".join(choice(corpus) for _ in range(length))
+        environment.globals.update(random_ascii_string=random_ascii_string)
diff --git a/cookiecutter/main.py b/cookiecutter/main.py
index b5c791f..85965be 100644
--- a/cookiecutter/main.py
+++ b/cookiecutter/main.py
@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
 def cookiecutter(
         template, checkout=None, no_input=False, extra_context=None,
         replay=False, overwrite_if_exists=False, output_dir='.',
-        config_file=None, default_config=False, password=None):
+        config_file=None, default_config=False, password=None, directory=None):
     """
     Run Cookiecutter just as if using it from the command line.
 
@@ -41,6 +41,7 @@ def cookiecutter(
     :param config_file: User configuration file path.
     :param default_config: Use default values rather than a config file.
     :param password: The password to use when extracting the repository.
+    :param directory: Relative path to a cookiecutter template in a repository.
     """
     if replay and ((no_input is not False) or (extra_context is not None)):
         err_msg = (
@@ -60,7 +61,8 @@ def cookiecutter(
         clone_to_dir=config_dict['cookiecutters_dir'],
         checkout=checkout,
         no_input=no_input,
-        password=password
+        password=password,
+        directory=directory
     )
 
     template_name = os.path.basename(os.path.abspath(repo_dir))
diff --git a/cookiecutter/repository.py b/cookiecutter/repository.py
index 08fbcef..14d8704 100644
--- a/cookiecutter/repository.py
+++ b/cookiecutter/repository.py
@@ -62,7 +62,7 @@ def repository_has_cookiecutter_json(repo_directory):
 
 
 def determine_repo_dir(template, abbreviations, clone_to_dir, checkout,
-                       no_input, password=None):
+                       no_input, password=None, directory=None):
     """
     Locate the repository directory from a template reference.
 
@@ -78,6 +78,7 @@ def determine_repo_dir(template, abbreviations, clone_to_dir, checkout,
     :param checkout: The branch, tag or commit ID to checkout after clone.
     :param no_input: Prompt the user at command line for manual configuration?
     :param password: The password to use when extracting the repository.
+    :param directory: Directory within repo where cookiecutter.json lives.
     :return: A tuple containing the cookiecutter template directory, and
         a boolean descriving whether that directory should be cleaned up
         after the template has been instantiated.
@@ -111,6 +112,10 @@ def determine_repo_dir(template, abbreviations, clone_to_dir, checkout,
         ]
         cleanup = False
 
+    if directory:
+        repository_candidates = [os.path.join(s, directory)
+                                 for s in repository_candidates]
+
     for repo_candidate in repository_candidates:
         if repository_has_cookiecutter_json(repo_candidate):
             return repo_candidate, cleanup
diff --git a/cookiecutter/vcs.py b/cookiecutter/vcs.py
index 6af7818..bbe63b1 100644
--- a/cookiecutter/vcs.py
+++ b/cookiecutter/vcs.py
@@ -7,7 +7,10 @@ import logging
 import os
 import subprocess
 
-from whichcraft import which
+try:
+    from shutil import which
+except ImportError:
+    from whichcraft import which
 
 from .exceptions import (
     RepositoryNotFound, RepositoryCloneFailed, UnknownRepoType, VCSNotInstalled
diff --git a/docs/advanced/directories.rst b/docs/advanced/directories.rst
new file mode 100644
index 0000000..cbec8e4
--- /dev/null
+++ b/docs/advanced/directories.rst
@@ -0,0 +1,23 @@
+.. _directories:
+
+Organizing cookiecutters in directories (1.7+)
+---------------------------------------------------
+
+*New in Cookiecutter 1.7*
+
+Cookiecutter introduces the ability to organize several templates in one
+repository or zip file, separating them by directories. This allows using
+symlinks for general files. Here's an example repository demonstrating
+this feature::
+
+    https://github.com/user/repo-name.git
+        ├── directory1-name/
+        |   ├── {{cookiecutter.project_slug}}/
+        |   └── cookiecutter.json
+        └── directory2-name/
+            ├── {{cookiecutter.project_slug}}/
+            └── cookiecutter.json
+
+To activate one of templates within a subdirectory, use the ``--directory`` option::
+
+    cookiecutter https://github.com/user/repo-name.git --directory="directory1-name"
diff --git a/docs/advanced/index.rst b/docs/advanced/index.rst
index 124e380..b5b311c 100644
--- a/docs/advanced/index.rst
+++ b/docs/advanced/index.rst
@@ -20,3 +20,4 @@ Various advanced topics regarding cookiecutter usage.
    choice_variables
    dict_variables
    template_extensions
+   directories
diff --git a/docs/advanced/template_extensions.rst b/docs/advanced/template_extensions.rst
index 4d133e6..e8e993a 100644
--- a/docs/advanced/template_extensions.rst
+++ b/docs/advanced/template_extensions.rst
@@ -27,6 +27,61 @@ Please note that Cookiecutter will **not** install any dependencies on its own!
 As a user you need to make sure you have all the extensions installed, before
 running Cookiecutter on a template that requires custom Jinja2 extensions.
 
+By default Cookiecutter includes the following extensions:
+
+- ``cookiecutter.extensions.JsonifyExtension``
+- ``cookiecutter.extensions.RandomStringExtension``
+- ``jinja2_time.TimeExtension``
+
+Jsonify extension
+~~~~~~~~~~~~~~~~~
+
+The ``cookiecutter.extensions.JsonifyExtension`` extension provides a ``jsonify`` filter in templates
+that converts a Python object to JSON:
+
+.. code-block:: jinja
+
+    {% {'a': True} | jsonify %}
+
+Would output:
+
+.. code-block:: json
+
+    {"a": true}
+
+Random string extension
+~~~~~~~~~~~~~~~~~~~~~~~
+
+*New in Cookiecutter 1.7*
+
+The ``cookiecutter.extensions.RandomStringExtension`` extension provides a ``random_ascii_string``
+method in templates that generates a random fixed-length string, optionally with punctuation.
+
+Generate a random n-size character string. Example for n=12:
+
+.. code-block:: jinja
+
+    {{ random_ascii_string(12) }}
+
+Outputs:
+
+.. code-block:: text
+
+    bIIUczoNvswh
+
+The second argument controls if punctuation and special characters
+``!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~`` should be present in the result:
+
+.. code-block:: jinja
+
+    {{ random_ascii_string(12, punctuation=True) }}
+
+Outputs:
+
+.. code-block:: text
+
+    fQupUkY}W!)!
+
 .. _`Jinja2 extensions`: http://jinja.pocoo.org/docs/latest/extensions/
 .. _`now`: https://github.com/hackebrot/jinja2-time#now-tag
 .. _`jinja2_time.TimeExtension`: https://github.com/hackebrot/jinja2-time
diff --git a/setup.py b/setup.py
index 1127bd3..cff34c0 100644
--- a/setup.py
+++ b/setup.py
@@ -27,7 +27,6 @@ requirements = [
     'binaryornot>=0.2.0',
     'jinja2>=2.7',
     'click>=7.0',
-    'whichcraft>=0.4.0',
     'poyo>=0.1.0',
     'jinja2-time>=0.1.0',
     'requests>=2.18.0',
@@ -61,6 +60,9 @@ setup(
     include_package_data=True,
     python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*',
     install_requires=requirements,
+    extras_require={
+        ':python_version<"3.3"': ['whichcraft>=0.4.0'],
+    },
     license='BSD',
     zip_safe=False,
     classifiers=[


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 8a1af55..8c86602 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,9 +1,9 @@
 # -*- coding: utf-8 -*-
 
 """
-conftest
---------
+conftest.
 
+--------
 Contains pytest fixtures which are globally available throughout the suite.
 """
 
@@ -23,6 +23,7 @@ replay_dir: "{replay_dir}"
 
 
 def backup_dir(original_dir, backup_dir):
+    """Method to generate backup directory based on original directory."""
     # If the default original_dir is pre-existing, move it to a temp location
     if not os.path.isdir(original_dir):
         return False
@@ -36,6 +37,7 @@ def backup_dir(original_dir, backup_dir):
 
 
 def restore_backup_dir(original_dir, backup_dir, original_dir_found):
+    """Method restores default contents."""
     # Carefully delete the created original_dir only in certain
     # conditions.
     original_dir_is_dir = os.path.isdir(original_dir)
@@ -92,7 +94,6 @@ def clean_system(request):
       `~/.cookiecutter_replay.backup/`
 
     """
-
     # If ~/.cookiecutterrc is pre-existing, move it to a temp location
     user_config_path = os.path.expanduser('~/.cookiecutterrc')
     user_config_path_backup = os.path.expanduser(
@@ -151,14 +152,16 @@ def clean_system(request):
 
 @pytest.fixture(scope='session')
 def user_dir(tmpdir_factory):
-    """Fixture that simulates the user's home directory"""
+    """Fixture that simulates the user's home directory."""
     return tmpdir_factory.mktemp('user_dir')
 
 
 @pytest.fixture(scope='session')
 def user_config_data(user_dir):
-    """Fixture that creates 2 Cookiecutter user config dirs in the user's home
-    directory:
+    """Fixture that creates 2 Cookiecutter user config dirs.
+
+     It will create it in the user's home directory.
+
     * `cookiecutters_dir`
     * `cookiecutter_replay`
 
@@ -175,8 +178,10 @@ def user_config_data(user_dir):
 
 @pytest.fixture(scope='session')
 def user_config_file(user_dir, user_config_data):
-    """Fixture that creates a config file called `config` in the user's home
-    directory, with YAML from `user_config_data`.
+    """Fixture that creates a config file called `config`.
+
+     It will create it in the user's home directory, with YAML from
+     `user_config_data`.
 
     :param user_dir: Simulated user's home directory
     :param user_config_data: Dict of config values
@@ -191,4 +196,5 @@ def user_config_file(user_dir, user_config_data):
 
 @pytest.fixture(autouse=True)
 def disable_poyo_logging():
+    """Fixture that disables poyo logging."""
     logging.getLogger('poyo').setLevel(logging.WARNING)
diff --git a/tests/fake-repo-dir/my-dir/cookiecutter.json b/tests/fake-repo-dir/my-dir/cookiecutter.json
new file mode 100644
index 0000000..e25d080
--- /dev/null
+++ b/tests/fake-repo-dir/my-dir/cookiecutter.json
@@ -0,0 +1,11 @@
+{
+	"full_name": "Audrey Roy",
+	"email": "audreyr@gmail.com",
+	"github_username": "audreyr",
+	"project_name": "Fake Project",
+	"repo_name": "fake-project",
+	"project_short_description": "This is a fake project.",
+	"release_date": "2013-07-28",
+	"year": "2013",
+	"version": "0.1"
+}
\ No newline at end of file
diff --git a/tests/fake-repo-dir/my-dir/{{cookiecutter.repo_name}}/README.rst b/tests/fake-repo-dir/my-dir/{{cookiecutter.repo_name}}/README.rst
new file mode 100644
index 0000000..0abd26e
--- /dev/null
+++ b/tests/fake-repo-dir/my-dir/{{cookiecutter.repo_name}}/README.rst
@@ -0,0 +1,7 @@
+============
+Fake Project
+============
+
+Project name: **{{ cookiecutter.project_name }}**
+
+Blah!!!!
diff --git a/tests/files/{{cookiecutter.random_string_file}}.txt b/tests/files/{{cookiecutter.random_string_file}}.txt
new file mode 100644
index 0000000..3034a3a
--- /dev/null
+++ b/tests/files/{{cookiecutter.random_string_file}}.txt
@@ -0,0 +1,1 @@
+{{ random_ascii_string(length, punctuation) }}
diff --git a/tests/repository/test_determine_repo_dir_finds_subdirectories.py b/tests/repository/test_determine_repo_dir_finds_subdirectories.py
new file mode 100644
index 0000000..f05020b
--- /dev/null
+++ b/tests/repository/test_determine_repo_dir_finds_subdirectories.py
@@ -0,0 +1,73 @@
+# -*- coding: utf-8 -*-
+import io
+import os
+import pytest
+
+from cookiecutter import repository, exceptions
+
+
+@pytest.fixture
+def template():
+    return 'cookiecutter-pytest-plugin'
+
+
+@pytest.fixture
+def cloned_cookiecutter_path(user_config_data, template):
+    cookiecutters_dir = user_config_data['cookiecutters_dir']
+
+    cloned_template_path = os.path.join(cookiecutters_dir, template)
+    if not os.path.exists(cloned_template_path):
+        os.mkdir(cloned_template_path)  # might exist from other tests.
+
+    subdir_template_path = os.path.join(cloned_template_path, 'my-dir')
+    if not os.path.exists(subdir_template_path):
+        os.mkdir(subdir_template_path)
+    io.open(os.path.join(subdir_template_path, 'cookiecutter.json'), 'w')
+
+    return subdir_template_path
+
+
+def test_should_find_existing_cookiecutter(
+        template, user_config_data, cloned_cookiecutter_path):
+
+    project_dir, cleanup = repository.determine_repo_dir(
+        template,
+        abbreviations={},
+        clone_to_dir=user_config_data['cookiecutters_dir'],
+        checkout=None,
+        no_input=True,
+        directory='my-dir',
+    )
+
+    assert cloned_cookiecutter_path == project_dir
+    assert not cleanup
+
+
+def test_local_repo_typo(template, user_config_data, cloned_cookiecutter_path):
+    """An unknown local repository should raise a `RepositoryNotFound`
+    exception.
+    """
+    with pytest.raises(exceptions.RepositoryNotFound) as err:
+        repository.determine_repo_dir(
+            template,
+            abbreviations={},
+            clone_to_dir=user_config_data['cookiecutters_dir'],
+            checkout=None,
+            no_input=True,
+            directory='wrong-dir',
+        )
+
+    wrong_full_cookiecutter_path = os.path.join(
+        os.path.dirname(cloned_cookiecutter_path),
+        'wrong-dir'
+    )
+    assert str(err.value) == (
+        'A valid repository for "{}" could not be found in the following '
+        'locations:\n{}'.format(
+            template,
+            '\n'.join([
+                os.path.join(template, 'wrong-dir'),
+                wrong_full_cookiecutter_path
+            ]),
+        )
+    )
diff --git a/tests/test_cli.py b/tests/test_cli.py
index ab5a3ae..7c5c8cb 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -97,7 +97,8 @@ def test_cli_replay(mocker, cli_runner):
         config_file=None,
         default_config=False,
         extra_context=None,
-        password=None
+        password=None,
+        directory=None,
     )
 
 
@@ -131,6 +132,7 @@ def test_cli_exit_on_noinput_and_replay(mocker, cli_runner):
         default_config=False,
         extra_context=None,
         password=None,
+        directory=None,
     )
 
 
@@ -163,6 +165,7 @@ def test_run_cookiecutter_on_overwrite_if_exists_and_replay(
         default_config=False,
         extra_context=None,
         password=None,
+        directory=None,
     )
 
 
@@ -223,6 +226,7 @@ def test_cli_output_dir(mocker, cli_runner, output_dir_flag, output_dir):
         default_config=False,
         extra_context=None,
         password=None,
+        directory=None,
     )
 
 
@@ -262,6 +266,7 @@ def test_user_config(mocker, cli_runner, user_config_path):
         default_config=False,
         extra_context=None,
         password=None,
+        directory=None,
     )
 
 
@@ -290,6 +295,7 @@ def test_default_user_config_overwrite(mocker, cli_runner, user_config_path):
         default_config=True,
         extra_context=None,
         password=None,
+        directory=None,
     )
 
 
@@ -313,6 +319,7 @@ def test_default_user_config(mocker, cli_runner):
         default_config=True,
         extra_context=None,
         password=None,
+        directory=None,
     )
 
 
@@ -442,3 +449,17 @@ def test_debug_file_verbose(cli_runner, debug_file):
     )
     assert context_log in debug_file.readlines(cr=False)
     assert context_log in result.output
+
+
+@pytest.mark.usefixtures('remove_fake_project_dir')
+def test_directory_repo(cli_runner):
+    result = cli_runner(
+        'tests/fake-repo-dir/',
+        '--no-input',
+        '-v',
+        '--directory=my-dir',
+    )
+    assert result.exit_code == 0
+    assert os.path.isdir('fake-project')
+    with open(os.path.join('fake-project', 'README.rst')) as f:
+        assert 'Project name: **Fake Project**' in f.read()
diff --git a/tests/test_generate_file.py b/tests/test_generate_file.py
index 9821cfd..a997475 100644
--- a/tests/test_generate_file.py
+++ b/tests/test_generate_file.py
@@ -82,6 +82,29 @@ def test_generate_file_jsonify_filter(env):
         assert json.loads(generated_text) == data
 
 
+@pytest.mark.usefixtures('remove_cheese_file')
+@pytest.mark.parametrize("length", (10, 40))
+@pytest.mark.parametrize("punctuation", (True, False))
+def test_generate_file_random_ascii_string(env, length, punctuation):
+    infile = 'tests/files/{{cookiecutter.random_string_file}}.txt'
+    data = {'random_string_file': 'cheese'}
+    context = {
+        "cookiecutter": data,
+        "length": length,
+        "punctuation": punctuation
+    }
+    generate.generate_file(
+        project_dir=".",
+        infile=infile,
+        context=context,
+        env=env
+    )
+    assert os.path.isfile('tests/files/cheese.txt')
+    with open('tests/files/cheese.txt', 'rt') as f:
+        generated_text = f.read()
+        assert len(generated_text) == length
+
+
 @pytest.mark.usefixtures('remove_cheese_file')
 def test_generate_file_with_true_conditional(env):
     infile = 'tests/files/{% if generate_file == \'y\' %}cheese.txt{% endif %}'