3 Patterns For Cookiecutter Templates
Intro
If you’ve heard of cookiecutter you can skip this part.
Cookiecutter is a command-line utility that creates projects from templates. There’s a list of templates maintained by the cookiecutter team and plenty of community awesome lists. It’s built with python and uses the jinja templating framework (found in python web frameworks like flask). You can use it to make a template for pretty much anything! All you need to get started is pip install cookiecutter
.
Hooks
Cookiecutter provides pre and post generate scripts. They are Python or Shell scripts that run before and/or after your project is generated.
They can be really useful. For example, if you want to get the absolute path to the generated project, you can use a post generate script to replace a specific piece of text with the absolute path. e.g.
# cookiecutter-$your-project/hooks/post_gen_project.py
abs_path = os.getcwd()
for root, dirs, files in os.walk(abs_path):
for filename in files:
with open(os.path.join(root, filename)) as f:
content = f.read()
content = content.replace('replace_me.base_dir', abs_path)
with open(os.path.join(root, filename), 'w') as f:
f.write(content)
Here’s an example in a cookiecutter I made. See github.com/cookiecutter/cookiecutter/issues..
Tests
There are a few ways to test cookiecutters.
Putting tests inside the template
This approach has the advantage that when someone generates a project using your template, they already have tests set up. e.g.
# {{cookiecutter.repo_name}}/{{cookiecutter.repo_name}}.py
__version__ = "0.1.0"
def {{cookiecutter.repo_name}}(version=False):
if version:
return __version__
else:
# do some cli stuff
# {{cookiecutter.repo_name}}/tests/test_{{cookiecutter.repo_name}}.py
import unittest
class Test{{cookiecutter.repo_name}}(unittest.TestCase):
def test_version(self):
assert {{cookiecutter.repo_name}}(version=True) == "0.1.0"
Here’s an example in a cookiecutter I made.
Putting tests outside the template
This approach is useful if it doesn’t make sense to include tests in the generated project, but you still want to test what is generated. Note: this doesn’t mean trying to test cookiecutter itself!
Normally Cookiecutter opens a prompt to get user input to be injected into your template. You can bypass this with the --no-input
argument. It also allows you to pass values required by cookiecutter.json
as arguments. e.g.
// cookiecutter-$your-project/cookiecutter.json
{
"project_name": "alphabet",
}
# this will generate a project named foo instead of alphabet
cookiecutter . --no-input project_name="foo"
I’ve used this approach when creating cookiecutters that contain scripts rather than full projects. To test the scripts I generate a project, import and run functions from the scripts, and test the output. e.g.
# cookiecutter-$your-project/{{cookiecutter.project_name|lower}}/script.sh
#!/bin/bash
{{cookiecutter.project_name|lower}}_repo_dir="{{cookiecutter.repo_dir}}"
goto_{{cookiecutter.project_name|lower}}_repo() {
cd "${{cookiecutter.project_name|lower}}_repo_dir" || return 1
}
# cookiecutter-$your-project/tests/test_helper.bash
setup() {
# we expect foo/script.sh to be generated
load "foo/script.sh"
}
# cookiecutter-$your-project/tests/script.bats
#!/usr/bin/env bats
load "test_helper"
# we expect a function named goto_foo_repo in foo/script.sh
@test "goto_foo_repo" {
goto_foo_repo
assert_equal "$foo_repo_dir" "$PWD"
}
Here’s an example in a cookiecutter I made.
CI
Now that you have tests set up, you can set up continuous integration! The important bit of here is
cookiecutter
. # create a project using the current directory as a template
--overwrite-if-exists # if the destination directory exists overwrite it
--no-input # don't prompt for user input.
# since there are no other args, use default values from cookiecutter.json
Here’s an example with github actions
# .github/workflows/ci.yml
name: ci
jobs:
build:
runs-on: macos-latest
strategy:
matrix:
python: [3.6, 3.7, 3.8]
steps:
- uses: actions/checkout@v1
- name: Set up Python ${{ matrix.python }}
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python }}
- name: Install Poetry
run: |
python -m pip install --upgrade pip
pip install poetry
- name: Install python packages
run: |
poetry install
# here's the important bit!
# generate a new project using the cookiecutter template
# use the default values in cookiecutter.json with --no-input
# if the directory already exists, overwrite it
- name: Generate package using cookiecutter
run: |
poetry run cookiecutter . --overwrite-if-exists --no-input
# now inside the generated project, install dependencies and run tests
- name: Install python packages (in cookiecutter dir)
working-directory: example_cli
run: |
poetry install
- name: Run tests (in cookiecutter dir)
working-directory: example_cli
run: |
poetry run task tests
Here’s an example in a cookiecutter I made.
Install from GitHub
Cookiecutter provides a really easy way to use templates hosted on github. All you need is cookiecutter gh:$username/$repo
Hopefully now you should be able to create a Cookiecutter template with hooks, tests, and CI, all easily installable from GitHub!