Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for symlinks #934

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS.md
Expand Up @@ -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))
34 changes: 31 additions & 3 deletions cookiecutter/generate.py
Expand Up @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions docs/advanced/index.rst
Expand Up @@ -23,3 +23,4 @@ Various advanced topics regarding cookiecutter usage.
template_extensions
directories
new_line_characters
symlinks
53 changes: 53 additions & 0 deletions 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
9 changes: 9 additions & 0 deletions 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"
]
}
Empty file.
132 changes: 132 additions & 0 deletions 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')