diff --git a/AUTHORS.md b/AUTHORS.md index d991d54fa..13b835c30 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -170,3 +170,4 @@ * Tom Forbes ([@orf](https://github.com/orf)) * Xie Yanbo ([@xyb](https://github.com/xyb)) * Maxim Ivanov ([@ivanovmg](https://github.com/ivanovmg)) +* Peter Bull ([@pjbull](https://github.com/pjbull)) diff --git a/cookiecutter/generate.py b/cookiecutter/generate.py index 7dbd9867b..146402887 100644 --- a/cookiecutter/generate.py +++ b/cookiecutter/generate.py @@ -199,7 +199,7 @@ def generate_file(project_dir, infile, context, env, skip_if_file_exists=False): def render_and_create_dir( - dirname, context, output_dir, environment, overwrite_if_exists=False + dirname, context, output_dir, environment, overwrite_if_exists=False, symlink=None ): """Render name of a directory, create the directory, return its path.""" name_tmpl = environment.from_string(dirname) @@ -218,9 +218,24 @@ def render_and_create_dir( logger.debug( 'Output directory %s already exists, overwriting it', dir_to_create ) + + # must be removed for symlink to be created successfully + if symlink is not None: + shutil.rmtree(dir_to_create) + else: msg = 'Error: "{}" directory already exists'.format(dir_to_create) raise OutputDirExistsException(msg) + + if symlink is not None: + link_tmpl = environment.from_string(symlink) + rendered_link = link_tmpl.render(**context) + + logger.debug( + 'Creating symlink from {} to {}'.format(dir_to_create, rendered_link) + ) + + os.symlink(rendered_link, dir_to_create) else: make_sure_path_exists(dir_to_create) @@ -323,9 +338,15 @@ def generate_files( # unrendered directories, since they will just be copied. copy_dirs = [] render_dirs = [] + symlinks = dict() for d in dirs: d_ = os.path.normpath(os.path.join(root, d)) + + if os.path.islink(d_): + logger.debug('Processing symlink at {}...'.format(d)) + symlinks[d] = os.readlink(d_) + # We check the full path, because that's how it can be # specified in the ``_copy_without_render`` setting, but # we store just the dir name @@ -339,7 +360,9 @@ def generate_files( outdir = os.path.normpath(os.path.join(project_dir, indir)) outdir = env.from_string(outdir).render(**context) logger.debug('Copying dir %s to %s without rendering', indir, outdir) - shutil.copytree(indir, outdir) + shutil.copytree( + indir, outdir, symlinks=True, + ) # We mutate ``dirs``, because we only want to go through these dirs # recursively @@ -348,7 +371,12 @@ def generate_files( unrendered_dir = os.path.join(project_dir, root, d) try: render_and_create_dir( - unrendered_dir, context, output_dir, env, overwrite_if_exists + unrendered_dir, + context, + output_dir, + env, + overwrite_if_exists, + symlink=symlinks.get(d, None), ) except UndefinedError as err: if delete_project_on_failure: diff --git a/docs/advanced/index.rst b/docs/advanced/index.rst index 90e7f2933..57569eb33 100644 --- a/docs/advanced/index.rst +++ b/docs/advanced/index.rst @@ -23,3 +23,4 @@ Various advanced topics regarding cookiecutter usage. template_extensions directories new_line_characters + symlinks diff --git a/docs/advanced/symlinks.rst b/docs/advanced/symlinks.rst new file mode 100644 index 000000000..8cc2ab265 --- /dev/null +++ b/docs/advanced/symlinks.rst @@ -0,0 +1,53 @@ +.. _symlinks: + +Symlinks +---------------------- + +Symlinks are virtual files or folders that simply point to another location on the +file system. For example, you may add a symlink to your project template ``system_logs`` +that points to ``/var/log`` on the system where the template is rendered. Symlinks can +be thought of as a shortcut to a specific file or folder. + +Symlinks will work on most platforms with cookiecutter. However, if you expect your +template to be used on Windows systems, see the additional information below. + + +Using symlinks +~~~~~~~~~~~~~~~~~~~~~~~ + +`Symbolic links`_ are commonly used in posix systems. Cookiecutter supports symlinks +in templates both as rendered and unrendered content. That is, the symlink itself +both be named with a variable or point to a destination with a variable in it. + +On posix systems (see below for Windows systems) you can simply create a symbolic link +as normal in the template directory:: + + ln -s existing_file_path new_symlink_path + +As stated above, either ``existing_file_path`` or ``new_symlink_path`` can contain +variables that will be templated in braces ``{{ cookiecutter.variable }}``. These +will be replaced when the template is rendered. + + +Symlinks on Windows +~~~~~~~~~~~~~~~~~~~~~~~ + +Symlinks in the posix sense are a relatively new addition to Windows operating +systems, and support for these was introduced into Python in Python 3.2. If you +want to use symlinks in a template on a Windows system, you may need to take some +additional steps. On windows the ``mklink`` command will create links:: + + > mklink new_symlink_path existing_file_path + +First, if your template is cloned using ``git``, you may need to tell ``git`` to respect +symlinks when cloning the project. This can be done at the commandline:: + + $ git config --global core.symlinks true + +Second, it may be the case that a user, if not an administrator, needs to be granted +special permissions in order to create symlinks. More details on permissions for +symlinks and how those can be managed are available `from Microsoft`_. + + +.. _`Symbolic links`: https://en.wikipedia.org/wiki/Symbolic_link +.. _`from Microsoft`: https://blogs.windows.com/buildingapps/2016/12/02/symlinks-windows-10/#TXpueSdQMpMz2YWf.97 diff --git a/tests/test-generate-symlinks/cookiecutter.json b/tests/test-generate-symlinks/cookiecutter.json new file mode 100644 index 000000000..d8f906797 --- /dev/null +++ b/tests/test-generate-symlinks/cookiecutter.json @@ -0,0 +1,9 @@ +{ + "name": "test_symlinks", + "link_dir": "rendered_dir", + "sym_to_nontemp": "rendered_sym_to_original", + "sym_to_temp": "rendered_sym_to_rendered_dir", + "_copy_without_render": [ + "copy_no_render" + ] +} diff --git a/tests/test-generate-symlinks/{{ cookiecutter.name }}/copy_no_render/original/afile.txt b/tests/test-generate-symlinks/{{ cookiecutter.name }}/copy_no_render/original/afile.txt new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test-generate-symlinks/{{ cookiecutter.name }}/copy_no_render/symlink b/tests/test-generate-symlinks/{{ cookiecutter.name }}/copy_no_render/symlink new file mode 120000 index 000000000..94f3610c0 --- /dev/null +++ b/tests/test-generate-symlinks/{{ cookiecutter.name }}/copy_no_render/symlink @@ -0,0 +1 @@ +original \ No newline at end of file diff --git a/tests/test-generate-symlinks/{{ cookiecutter.name }}/copy_no_render/symlink_to_rendered b/tests/test-generate-symlinks/{{ cookiecutter.name }}/copy_no_render/symlink_to_rendered new file mode 120000 index 000000000..383ed85cb --- /dev/null +++ b/tests/test-generate-symlinks/{{ cookiecutter.name }}/copy_no_render/symlink_to_rendered @@ -0,0 +1 @@ +{{ cookiecutter.link_dir }} \ No newline at end of file diff --git a/tests/test-generate-symlinks/{{ cookiecutter.name }}/copy_no_render/{{ cookiecutter.link_dir }}/.gitkeep b/tests/test-generate-symlinks/{{ cookiecutter.name }}/copy_no_render/{{ cookiecutter.link_dir }}/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test-generate-symlinks/{{ cookiecutter.name }}/copy_no_render/{{ cookiecutter.sym_to_nontemp }} b/tests/test-generate-symlinks/{{ cookiecutter.name }}/copy_no_render/{{ cookiecutter.sym_to_nontemp }} new file mode 120000 index 000000000..1a154a6e2 --- /dev/null +++ b/tests/test-generate-symlinks/{{ cookiecutter.name }}/copy_no_render/{{ cookiecutter.sym_to_nontemp }} @@ -0,0 +1 @@ +original/ \ No newline at end of file diff --git a/tests/test-generate-symlinks/{{ cookiecutter.name }}/copy_no_render/{{ cookiecutter.sym_to_temp }} b/tests/test-generate-symlinks/{{ cookiecutter.name }}/copy_no_render/{{ cookiecutter.sym_to_temp }} new file mode 120000 index 000000000..1996a98d6 --- /dev/null +++ b/tests/test-generate-symlinks/{{ cookiecutter.name }}/copy_no_render/{{ cookiecutter.sym_to_temp }} @@ -0,0 +1 @@ +{{ cookiecutter.link_dir }}/ \ No newline at end of file diff --git a/tests/test-generate-symlinks/{{ cookiecutter.name }}/original/afile.txt b/tests/test-generate-symlinks/{{ cookiecutter.name }}/original/afile.txt new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test-generate-symlinks/{{ cookiecutter.name }}/symlink b/tests/test-generate-symlinks/{{ cookiecutter.name }}/symlink new file mode 120000 index 000000000..94f3610c0 --- /dev/null +++ b/tests/test-generate-symlinks/{{ cookiecutter.name }}/symlink @@ -0,0 +1 @@ +original \ No newline at end of file diff --git a/tests/test-generate-symlinks/{{ cookiecutter.name }}/symlink_to_rendered b/tests/test-generate-symlinks/{{ cookiecutter.name }}/symlink_to_rendered new file mode 120000 index 000000000..383ed85cb --- /dev/null +++ b/tests/test-generate-symlinks/{{ cookiecutter.name }}/symlink_to_rendered @@ -0,0 +1 @@ +{{ cookiecutter.link_dir }} \ No newline at end of file diff --git a/tests/test-generate-symlinks/{{ cookiecutter.name }}/{{ cookiecutter.link_dir }}/.gitkeep b/tests/test-generate-symlinks/{{ cookiecutter.name }}/{{ cookiecutter.link_dir }}/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test-generate-symlinks/{{ cookiecutter.name }}/{{ cookiecutter.sym_to_nontemp }} b/tests/test-generate-symlinks/{{ cookiecutter.name }}/{{ cookiecutter.sym_to_nontemp }} new file mode 120000 index 000000000..1a154a6e2 --- /dev/null +++ b/tests/test-generate-symlinks/{{ cookiecutter.name }}/{{ cookiecutter.sym_to_nontemp }} @@ -0,0 +1 @@ +original/ \ No newline at end of file diff --git a/tests/test-generate-symlinks/{{ cookiecutter.name }}/{{ cookiecutter.sym_to_temp }} b/tests/test-generate-symlinks/{{ cookiecutter.name }}/{{ cookiecutter.sym_to_temp }} new file mode 120000 index 000000000..1996a98d6 --- /dev/null +++ b/tests/test-generate-symlinks/{{ cookiecutter.name }}/{{ cookiecutter.sym_to_temp }} @@ -0,0 +1 @@ +{{ cookiecutter.link_dir }}/ \ No newline at end of file diff --git a/tests/test_generate_symlinks.py b/tests/test_generate_symlinks.py new file mode 100644 index 000000000..0a0012b16 --- /dev/null +++ b/tests/test_generate_symlinks.py @@ -0,0 +1,132 @@ +"""Tests for `generate.py` that have symlinks in the templates.""" + +from __future__ import unicode_literals +import os +import shutil + +import pytest + +from cookiecutter import generate +from cookiecutter import utils + + +TEST_OUTPUT_DIR = 'test_symlinks' + + +@pytest.fixture(scope='function') +def remove_test_dir(request): + """Remove the folder that is created by the test.""" + + def fin_remove_test_dir(): + if os.path.exists(TEST_OUTPUT_DIR): + utils.rmtree(TEST_OUTPUT_DIR) + + request.addfinalizer(fin_remove_test_dir) + + +@pytest.mark.usefixtures('clean_system', 'remove_test_dir') +def test_symlinks(): + """ + Verify generating projects with symlinks. + + Includes both rendered and non-rendered symlink paths. + """ + generate.generate_files( + context={ + 'cookiecutter': { + 'name': TEST_OUTPUT_DIR, + "link_dir": "rendered_dir", + "sym_to_nontemp": "rendered_sym_to_original", + "sym_to_temp": "rendered_sym_to_rendered_dir", + "_copy_without_render": ["copy_no_render"], + } + }, + repo_dir='tests/test-generate-symlinks', + ) + + dir_contents = os.listdir(TEST_OUTPUT_DIR) + + assert 'copy_no_render' in dir_contents + assert 'original' in dir_contents + assert 'rendered_dir' in dir_contents + assert 'rendered_sym_to_original' in dir_contents + assert 'rendered_sym_to_rendered_dir' in dir_contents + assert 'symlink' in dir_contents + + # Test links that have been rendered and copied + def _test_symlink(root, link, points_to): + assert os.path.islink(os.path.join(root, link)) + + actual_points_to = os.readlink(os.path.join(root, link)) + + if actual_points_to.endswith(os.sep): + actual_points_to = actual_points_to[:-1] + + assert actual_points_to == points_to + + # normal symlink, not rendered target + _test_symlink(TEST_OUTPUT_DIR, 'symlink', 'original') + + # normal symlink, rendered target + _test_symlink(TEST_OUTPUT_DIR, 'symlink_to_rendered', 'rendered_dir') + + # rendered symlink, not rendered target + _test_symlink(TEST_OUTPUT_DIR, 'rendered_sym_to_original', 'original') + + # rendered symlink, rendered target + _test_symlink(TEST_OUTPUT_DIR, 'rendered_sym_to_rendered_dir', 'rendered_dir') + + # Test links that have not been rendered + non_rendered_dir = os.path.join(TEST_OUTPUT_DIR, 'copy_no_render') + non_rendered_dir_contents = os.listdir(non_rendered_dir) + + assert 'original' in non_rendered_dir_contents + assert 'symlink' in non_rendered_dir_contents + assert 'symlink_to_rendered' in non_rendered_dir_contents + assert '{{ cookiecutter.link_dir }}' in non_rendered_dir_contents + assert '{{ cookiecutter.sym_to_nontemp }}' in non_rendered_dir_contents + assert '{{ cookiecutter.sym_to_temp }}' in non_rendered_dir_contents + + # normal symlink, not rendered target + _test_symlink(non_rendered_dir, 'symlink', 'original') + + # normal symlink, rendered target + _test_symlink( + non_rendered_dir, 'symlink_to_rendered', '{{ cookiecutter.link_dir }}' + ) + + # rendered symlink, not rendered target + _test_symlink(non_rendered_dir, '{{ cookiecutter.sym_to_nontemp }}', 'original') + + # rendered symlink, rendered target + _test_symlink( + non_rendered_dir, + '{{ cookiecutter.sym_to_temp }}', + '{{ cookiecutter.link_dir }}', + ) + + # test overwriting + symlinks + utils.rmtree(TEST_OUTPUT_DIR) # remove output + os.makedirs(os.path.join(TEST_OUTPUT_DIR, "symlink")) + shutil.copy( + os.path.join("tests", "test-generate-symlinks", "cookiecutter.json"), + os.path.join( + TEST_OUTPUT_DIR, "symlink", "afile.txt" + ), # copy to where output will exist + ) + generate.generate_files( + context={ + 'cookiecutter': { + 'name': TEST_OUTPUT_DIR, + "link_dir": "rendered_dir", + "sym_to_nontemp": "rendered_sym_to_original", + "sym_to_temp": "rendered_sym_to_rendered_dir", + "_copy_without_render": ["copy_no_render"], + } + }, + repo_dir=os.path.join('tests', 'test-generate-symlinks'), + overwrite_if_exists=True, # overwrite the symlink + ) + + # normal symlink, not rendered target + _test_symlink(non_rendered_dir, 'symlink', 'original')