cookiecutter / cookiecutter

A cross-platform command-line utility that creates projects from cookiecutters (project templates), e.g. Python package projects, C projects.
https://pypi.org/project/cookiecutter/
BSD 3-Clause "New" or "Revised" License
22.71k stars 2.01k forks source link

PR proposal: add option to strip extra file extension to improve IDE dev experience when mixing code and Jinja2 #1601

Open andersgs opened 3 years ago

andersgs commented 3 years ago

Description:

When working with any files that include Jinja2 templating, I miss out on the IDE completions and linting for Jinja2 and cause problems with the linting for the file. For example, if I have Jinja2 syntax in a file with an extension of .py, the linter gets very upset and development gets a bit distracting.

Proposal:

I would like to make a PR to add a --strip-ext option to the CLI. That way, any files that are Jinja2 templates (or any other templating engine) could be suffixed with a .j2 extension (that is what I use, people may use others) which would be stripped when generating the files.

Here goes an example:

Let's say I have a file called main.py which I need to add some options too using Jinja2. I would name it instead main.py.j2, thus taking advantages of all the IDE has to offer in terms of linting and code completion. When calling cookiecutter, I would add the --strip-ext .j2 option, which would copy over the file into the destination directory as main.py.

The same could be done for README.md.j2, etc... Any files without templates would just have their native extensions and be copies as is.

This has the bonus of making it explicit in the project which files have templating in them.

In practical terms, I think the best way to implement this would be to modify this part of the generate.py code from:

                if is_copy_only_path(infile, context):
                    outfile_tmpl = env.from_string(infile)
                    outfile_rendered = outfile_tmpl.render(**context)
                    outfile = os.path.join(project_dir, outfile_rendered)
                    logger.debug(
                        'Copying file %s to %s without rendering', infile, outfile
                    )
                    shutil.copyfile(infile, outfile)
                    shutil.copymode(infile, outfile)
                    continue

to:

                if is_copy_only_path(infile, context):
                    outfile_tmpl = env.from_string(infile)
                    outfile_rendered = outfile_tmpl.render(**context)
                    outfile = os.path.join(project_dir, outfile_rendered)
                    # add support for --strip-ext
                    # this assumes --strip-ext is None by default and there is a single extension
                    # if support for multiple extensions is required/desired, 
                    # allow for a CSV list of extensions to strip (e..g., --strip-ext .j2,jinja2)
                    # and substitute `==` for `in` in the ternary expression below
                    if strip_ext:
                        tmp_out, ext = os.path.splitext(outfile)
                        outfile = tmp_out if ext == strip_ext else outfile
                    # end 
                    logger.debug(
                        'Copying file %s to %s without rendering', infile, outfile
                    )
                    shutil.copyfile(infile, outfile)
                    shutil.copymode(infile, outfile)
                    continue

As default, --strip-ext would be None, thus this would be completely backwards compatible and only an option for those that would like to use it.

The edge case that is not handled by the above code is where one actually has a template file (e.g., my_template.j2) that needs to exist in the final project folder. The implementation above would mean the file would be named my_template in the final project folder. This would have to be resolved either by having the user name their file with a double extension: my_template.j2.j2 or by adding some logic above to check for two extensions. The former solution can be easily documented and avoids having to awkwardly try to figure out if the user has two extensions or not.

geoffreyvanwyk commented 1 year ago

I use the following post-generate hook:

# Create a "hooks" directory at the root of your Cookiecutter project, then add this file to it.
import os

for path, subdirs, files in os.walk("."):
    for name in files:
        if name.endswith(".j2"):
            os.rename(os.path.join(path, name), os.path.join(path, name.rstrip(".j2")))