Building a project template
You start new projects the same way every time. Your personal dev stack. Your team’s standard service skeleton. Your agency’s client project baseline. Same layout, same tooling, same CI workflow — you copy the last one, search-and-replace the project name, and push. Three days later you notice pyproject.toml still says name = 'old-service' because the old name appeared in a comment you didn’t touch. Or the README still references the old author email. You’ve shipped the wrong metadata again.
Write that pattern down once as a diecut template. Test it locally, push it to GitHub, use it from anywhere.
Set up the template directory
Section titled “Set up the template directory”Create a directory for your template. If you plan to store multiple templates in one repo, put this in a subdirectory.
templates/ python-pkg/ diecut.toml template/Everything under template/ becomes your generated project. Files ending in .die are rendered through the Tera template engine and have the suffix stripped. Everything else is copied as-is.
Write the config
Section titled “Write the config”Create templates/python-pkg/diecut.toml:
[template]name = "python-pkg"
[variables.project_name]type = "string"prompt = "Project name"default = "my-package"validation = '^[a-z][a-z0-9-]*$'validation_message = "Must start with a letter. Only lowercase letters, numbers, hyphens."
[variables.project_slug]type = "string"computed = "{{ project_name | replace(from='-', to='_') }}"
[variables.author]type = "string"prompt = "Author name"
[variables.description]type = "string"prompt = "Short description"default = ""
[variables.license]type = "select"prompt = "License"choices = ["MIT", "Apache-2.0", "GPL-3.0"]default = "MIT"project_slug is computed — it’s derived from project_name with hyphens replaced by underscores. Python package names use underscores; project directory names conventionally use hyphens.
Without this, you’d have to ask for both separately and trust the user types them consistently. If they enter project_name = my-lib but project_slug = my_lib_utils by mistake, the import in test_package.py (from my_lib_utils import ...) won’t match the directory diecut creates (src/my_lib/). The computed variable eliminates that class of mismatch: one value is entered, the other is always derived from it.
Add the template files
Section titled “Add the template files”pyproject.toml
Section titled “pyproject.toml”Create templates/python-pkg/template/pyproject.toml.die:
[build-system]requires = ["hatchling"]build-backend = "hatchling.build"
[project]name = "{{ project_name }}"version = "0.1.0"description = "{{ description }}"authors = [{ name = "{{ author }}" }]license = { text = "{{ license }}" }requires-python = ">=3.11"dependencies = []
[project.optional-dependencies]dev = ["pytest", "ruff"]
[tool.ruff]line-length = 100Source package
Section titled “Source package”Create templates/python-pkg/template/src/{{ project_slug }}/__init__.py.die:
"""{{ description }}"""
__version__ = "0.1.0"The directory name {{ project_slug }} is also rendered by diecut — path components can contain Tera expressions.
README
Section titled “README”Create templates/python-pkg/template/README.md.die:
# {{ project_name }}
{{ description }}
## Installation
```bashpip install {{ project_name }}```
## License
{{ license }}Create templates/python-pkg/template/tests/test_package.py.die:
from {{ project_slug }} import __version__
def test_version(): assert __version__ == "0.1.0"Your template directory now looks like this:
templates/python-pkg/ diecut.toml template/ pyproject.toml.die README.md.die src/ {{ project_slug }}/ __init__.py.die tests/ test_package.py.dieTest it locally
Section titled “Test it locally”Before pushing anything, preview the output with --dry-run --verbose:
diecut new ./templates/python-pkg -o my-lib --dry-run --verbosediecut prompts you normally, then prints each file it would write without touching the filesystem:
Project name [my-package]: my-libAuthor name: Jane DoeShort description: A small utility library.License [MIT]: 1. MIT 2. Apache-2.0 3. GPL-3.0
[dry-run] would write: my-lib/pyproject.toml[dry-run] would write: my-lib/README.md[dry-run] would write: my-lib/src/my_lib/__init__.py[dry-run] would write: my-lib/tests/test_package.pyThe thing to verify is that my_lib appears, not my-lib. If you’d written computed = '{{ project_name }}' without the replace filter, the dry-run would show src/my-lib/__init__.py — a directory name Python can’t import from. Catching that here costs nothing; catching it after pip install -e . fails costs you a confused ten minutes.
Once you’re satisfied, generate for real:
diecut new ./templates/python-pkg -o my-libThe output directory:
my-lib/ pyproject.toml README.md src/ my_lib/ __init__.py tests/ test_package.py .diecut-answers.toml.diecut-answers.toml records the variable values used. If a teammate asks which license you picked, or you want to scaffold a closely related second package with the same author and description, the answers are already there — no digging through pyproject.toml to reconstruct what you typed.
Push to GitHub and use from anywhere
Section titled “Push to GitHub and use from anywhere”Commit the template directory to a GitHub repo. The structure can be a dedicated templates repo or a subdirectory inside an existing one:
git add templates/python-pkggit commit -m "add python-pkg template"git pushNow use it from any machine:
diecut new gh:yourname/templates/python-pkg -o my-libdiecut fetches the repo, reads the template from the python-pkg subdirectory, and prompts as usual. Skip prompts entirely with --defaults, or override specific values inline:
diecut new gh:yourname/templates/python-pkg -o my-lib \ -d project_name=my-lib \ -d author="Jane Doe" \ --defaultsThe difference
Section titled “The difference”Without a template, starting a new project means copying the last one and replacing every trace of the old name — in comments, in pyproject.toml, in the README. You catch the ones your editor flags, and you miss the ones it won’t. The dry-run output earlier in this article shows exactly that distinction: my-lib and my_lib are two different strings, in two different contexts, and you have to find them both.
With a template, the prompts replace the search-and-replace session. You type the project name once; the template renders it everywhere it belongs — as a directory name, as a package identifier, as a heading in the README.
The template is also a specification: it records what a correct new project looks like — the build backend, the linter config, the test runner. When that spec changes — say you switch from setuptools to hatchling — you update the template once. Every project created after that gets the new baseline automatically.
To learn more about what you can do in a template, see Creating Templates. For all CLI options, see the Commands reference.