Every client project starts as a copy of the last one with eleven things renamed. Somebody misses the package directory, the import in the test still names the previous service, and the CI file points at the wrong repository. The third copy carries a bug fixed in the second, because nobody knows which copy is the reference.
What you get
You will end up with a Cookiecutter template that produces a Python API client project from a handful of answers and commits the result. Its hook leaves one known state when it fails. This is for you if you start a client per API by copying the last one.
Short answer
Put the layout under a directory named {{cookiecutter.project_slug}} and declare its
variables in cookiecutter.json, deriving the slug and package name from the project name.
Add hooks/post_gen_project.py to initialize git and install the project. Make every hook
step check its own precondition, because a failing hook deletes the directory only when
Cookiecutter created it. Bake the template with --no-input in a test.
You will need
Python 3.11 or later, git, and Cookiecutter
2.7 or later. Verified 2026-09-25 against Cookiecutter 2.7.1. The sample directory declares
the install in its package.json: npm ci runs a postinstall script that runs
python3 -m pip install --target vendor cookiecutter==2.7.1, so every command below carries
PYTHONPATH=vendor. Install it any other way and drop the prefix.
PYTHONPATH=vendor python3 -c "import cookiecutter; print(cookiecutter.__version__)"
2.7.1
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Cookiecutter | A layout you regenerate often, and the largest pool of existing templates to start from | No record of which template made a project, so a template change reaches existing projects by hand | Projects you expect to update from the template later |
| Copier | Templates that keep changing after the projects exist | An answers file committed in every project, and a template that is a git repository with version tags | A one-off scaffold that nothing will ever re-apply |
| uv init | The standard layout is enough and nothing team-specific has to come along | No variables, hooks or extra files, so the client class, the test scaffold and the CI file are copied from a reference project by hand | The layout carries files that must arrive already renamed |
Cookiecutter renders a template and forgets it. Copier renders the same kind of template and
writes the answers into the project, which is what lets copier update re-apply a changed
template later. The price is a file in every project and a template that must be tagged.
uv init needs no template at all, and gives you nothing a template would have carried.
Declare the answers and derive the names
cookiecutter.json holds one entry per question. The
values are Jinja templates,1
so the slug and the package name come from the project name and nobody types them.
{
"project_name": "Weather API Client",
"project_slug": "{{ cookiecutter.project_name|lower|replace(' ', '-') }}",
"package_name": "{{ cookiecutter.project_slug|replace('-', '_') }}",
"base_url": "https://api.example.com",
"python_version": ["3.11", "3.12", "3.13"],
"author_name": "Platform Team",
"author_email": "platform@example.com",
"install_command": "python3 -m pip install --quiet --editable .[dev]"
}
python_version is a
choice variable:
a list, with the first entry as the default. install_command is an answer rather than a
constant so that a CI job, or this page, can replace it on the command line without editing
the template.
A derived name is only as good as the project name it came from. Acme API v2.0 derives
acme_api_v2.0, which no import statement can name. hooks/pre_gen_project.py runs before any
file is rendered, checks that the package name is a Python identifier, and exits 1 with the
reason. Cookiecutter then removes the directory it had just created.
Lay out the project under a directory named by an answer
The directory {{cookiecutter.project_slug}} holds the project as it should look after
generation, with the answers where the names go. Its pyproject.toml follows the
packaging guide.
[project]
name = "{{ cookiecutter.project_slug }}"
version = "0.1.0"
description = "Client for {{ cookiecutter.base_url }}"
readme = "README.md"
requires-python = ">={{ cookiecutter.python_version }}"
authors = [{ name = {{ cookiecutter.author_name|tojson }}, email = {{ cookiecutter.author_email|tojson }} }]
dependencies = []
[project.optional-dependencies]
dev = ["pytest>=8"]
The client itself, under src/{{cookiecutter.package_name}}/client.py, uses
urllib.request from the standard library, so the generated project runs with nothing
installed and the dev extra is what the install step is for.
Write a hook that can run twice
Hooks are scripts under hooks/, and the
hooks documentation says
post_gen_project runs in the root of the generated project after generation, with template
variables rendered into the script first. That is why the author’s name can sit in a Python
list literal. It goes through the tojson filter, here and in pyproject.toml, so a quote in
a name or in the install command cannot end the string early and break the script.
def init_git():
if (PROJECT / ".git").is_dir():
print("git: repository exists, skipping init")
else:
git("init", "--quiet", "--initial-branch=main")
print("git: initialized")
git("add", "--all")
if git("status", "--porcelain", capture=True).stdout.strip():
git("commit", "--quiet", "--message", "Scaffold from cookiecutter-api-client")
print("git: committed the scaffold")
else:
print("git: nothing to commit")
def install():
if INSTALL in ("", "none"):
print("install: skipped")
return
print(f"install: {INSTALL}")
subprocess.run(shlex.split(INSTALL), cwd=PROJECT, check=True)
print("install: done")
Every step asks before it acts. git init is skipped when .git exists, and the commit is
skipped when git status reports nothing, so the hook can run over a directory it half built
last time. The commit passes the author from the answers as -c user.name and
-c user.email. A CI runner has no git identity, and the hook would otherwise stop at the
first commit with an identity error.
Bake it without prompts
--no-input takes every default, and a key=value argument
overrides one answer.
The sample runs offline, so it swaps the installer for the shell’s true.
rm -rf out && PYTHONPATH=vendor python3 -m cookiecutter cookiecutter-api-client --no-input -o out install_command=true
git: initialized
git: committed the scaffold
install: true
install: done
Cookiecutter prints nothing of its own on success; the four lines are the hook’s. The
-o out puts the project under out/, and the tree is the template with the names filled in.
find out -type f -not -path '*/.git/*' | sort
out/weather-api-client/.gitignore
out/weather-api-client/README.md
out/weather-api-client/pyproject.toml
out/weather-api-client/src/weather_api_client/__init__.py
out/weather-api-client/src/weather_api_client/client.py
out/weather-api-client/tests/__init__.py
out/weather-api-client/tests/test_client.py
Check it worked
The test bakes the template into a temporary directory through the Python API, which takes
the same no_input and extra_context the command line does, and reads back what the hook
left. Four of its nine cases cover the edges. A failing run with -f keeps the project, and a
quote in an answer survives. A package name Python cannot import is refused, and the generated
project’s own test runs with nothing installed.
@case
def a_kept_directory_is_finished_by_the_next_run():
with tempfile.TemporaryDirectory() as tmp:
with captured():
try:
cookiecutter(TEMPLATE, no_input=True, output_dir=tmp, keep_project_on_failure=True,
extra_context={"install_command": "false"})
except FailedHookException:
pass
project = Path(tmp) / "weather-api-client"
assert (project / ".git").is_dir(), "the failed run was kept, git included"
with captured() as lines:
cookiecutter(TEMPLATE, no_input=True, output_dir=tmp, overwrite_if_exists=True,
extra_context={"install_command": "true"})
assert lines[-1] == "install: done", lines
assert git(project, "rev-list", "--count", "HEAD") == "1"
PYTHONPATH=vendor python3 test_template.py
ok renders_the_layout_and_derives_the_names
ok commits_the_scaffold_as_the_named_author
ok a_second_run_over_the_same_directory_changes_nothing
ok a_failing_install_removes_the_directory_by_default
ok a_kept_directory_is_finished_by_the_next_run
ok a_failing_run_over_an_existing_project_leaves_it_in_place
ok quotes_in_an_answer_reach_git_and_pyproject_intact
ok a_package_name_python_cannot_import_is_refused
ok the_generated_tests_run_with_nothing_installed
9 cases, 0 failures
captured redirects file descriptors 1 and 2 into a temporary file around each bake, because
the hook runs as a child process and writes past sys.stdout. The lines it collects are what
the assertions read.
When it goes wrong
The install step fails after git has already committed. With install_command=false
standing in for a package index that is down, the hook exits 1. Cookiecutter stops, and
prints a traceback after the line shown here.
rm -rf out; PYTHONPATH=vendor python3 -m cookiecutter cookiecutter-api-client --no-input -o out install_command=false
git: initialized
git: committed the scaffold
install: false
hook failed: false exited 1
ERROR: Stopping generation because post_gen_project hook script didn't exit successfully
test -e out/weather-api-client && echo "kept" || echo "out/weather-api-client was removed"
out/weather-api-client was removed
The documentation says a failing hook halts generation and cleans the directory,2 and the
run above shows the whole project going with it, first commit included. Two things change
that. The
--keep-project-on-failure flag
keeps the directory. And Cookiecutter removes only a directory it created, so a failing hook
run with -f over an existing project leaves the project in whatever state the hook reached.
rm -rf out; PYTHONPATH=vendor python3 -m cookiecutter cookiecutter-api-client --no-input -o out --keep-project-on-failure install_command=false
git: initialized
git: committed the scaffold
install: false
hook failed: false exited 1
ERROR: Stopping generation because post_gen_project hook script didn't exit successfully
The directory is still there, with the first commit inside it.
ls -A out/weather-api-client
.git
.gitignore
README.md
pyproject.toml
src
tests
A half-built directory is only a problem if the next run cannot finish it. Run the same
template again over the kept directory, with -f to overwrite the rendered files, and the
hook picks up where it stopped.
PYTHONPATH=vendor python3 -m cookiecutter cookiecutter-api-client --no-input -o out -f install_command=true
git: repository exists, skipping init
git: nothing to commit
install: true
install: done
A hook that ran git init and git commit unconditionally would fail here on the commit,
because there is nothing to commit, and the directory would stay half built for good. The
precondition on every step is what turns a failed run into a resumable one.
The hook fails on Author identity unknown. The commit ran without -c user.name and
-c user.email on a machine with no global git identity, which describes every CI runner.
Pass the identity from the answers, as the hook above does.
The hook fails at render time with a Jinja error. A literal {{ in the script, in a string
or a comment, was read as a template expression, because hooks are rendered before they run.
Wrap it in {% raw %} and {% endraw %}, as the Jinja
template documentation describes.
When not to do this
Do not use Cookiecutter for a template you intend to re-apply. It keeps no record of which
template and which answers produced a project, so a change to the template reaches forty
existing projects by forty hand merges. Copier writes .copier-answers.yml into the project
for that reason, and copier update reads it.3
Do not run -f over a project with uncommitted changes. It overwrites every rendered file,
and the hook’s git add --all then commits whatever it finds.
Do not put a machine-specific path, a token or a registry password in cookiecutter.json.
The file is the template’s public interface, and every answer in it lands in the
replay file Cookiecutter
writes under the user’s home directory.
Do not maintain a template for a layout that uv init already produces. It creates the
pyproject.toml, the README.md, the .python-version and the src package with no
template to keep in step, and a template earns its keep only when the layout carries files of
your own.
Related how-tos
Last verified
Verified 2026-09-25 against Cookiecutter 2.7.1, installed by the sample’s package.json
postinstall script into vendor/. Every output block is what the command preceding it
printed. The default install_command was not run: the captured runs replace it with true
and false, because the sample runs with no network.
Footnotes
-
The templates in context page says the values of
cookiecutter.jsonare Jinja templates, and that the keys are not, with the parenthesis and its punctuation the page’s own. It adds that each answer joins the context as soon as it is given, so a value can be derived from the ones before it. Order in that file is therefore the order of the prompts and the order of derivation, and a slug that reads a name declared below it stops the run withUnable to render variable 'slug'. ↩︎ Back to text -
The hooks documentation covers failure in one sentence: if a hook exits with a nonzero status, generation halts and the generated directory is cleaned. The command line reference covers the exception in one line. The flag’s whole description reads “Do not delete project folder on failure.” Between the two lines sits the half-built directory this page is about. ↩︎ Back to text
-
Copier’s updating page explains the mechanism in a diagram. Copier regenerates a fresh project from the template version it was made with, and diffs that against the real project to capture your edits. It then updates the project from the new version and re-applies your edits. It also says, in a box headed Important, never to update
.copier-answers.ymlby hand, since the file records what produced the project and the diff is only as good as that record. The one file that makes the update possible is the one file you may not touch. ↩︎ Back to text