# How to scaffold a Python API client project with Cookiecutter

> Turn a Python client layout into a Cookiecutter template with derived names, a hook that survives a second run, and a test that bakes it into a temporary directory.

Source: https://voxgig.com/howto/scaffold-a-python-project-with-cookiecutter

- Audience: api-producer
- Level: beginner
- Languages: python
- Verified: 2026-09-25
- Published: 2026-09-25

## 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](https://cookiecutter.readthedocs.io/en/stable/)
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.

```bash
PYTHONPATH=vendor python3 -c "import cookiecutter; print(cookiecutter.__version__)"
```

```text output
2.7.1
```

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Cookiecutter](https://cookiecutter.readthedocs.io/en/stable/) | 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](https://copier.readthedocs.io/en/stable/updating/) | 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](https://docs.astral.sh/uv/concepts/projects/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](https://cookiecutter.readthedocs.io/en/stable/advanced/templates_in_context.html),[^1]
so the slug and the package name come from the project name and nobody types them.

```json title="cookiecutter-api-client/cookiecutter.json"
{
  "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](https://cookiecutter.readthedocs.io/en/stable/advanced/choice_variables.html):
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](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/).

```toml title="cookiecutter-api-client/{{cookiecutter.project_slug}}/pyproject.toml"
[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](https://cookiecutter.readthedocs.io/en/stable/advanced/hooks.html) 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.

```python title="cookiecutter-api-client/hooks/post_gen_project.py"
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](https://cookiecutter.readthedocs.io/en/stable/advanced/injecting_context.html).
The sample runs offline, so it swaps the installer for the shell's `true`.

```bash
rm -rf out && PYTHONPATH=vendor python3 -m cookiecutter cookiecutter-api-client --no-input -o out install_command=true
```

```text output
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.

```bash
find out -type f -not -path '*/.git/*' | sort
```

```text output
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.

```python title="test_template.py"
@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"
```

```bash
PYTHONPATH=vendor python3 test_template.py
```

```text output
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.

```bash
rm -rf out; PYTHONPATH=vendor python3 -m cookiecutter cookiecutter-api-client --no-input -o out install_command=false
```

```text output
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
```

```bash
test -e out/weather-api-client && echo "kept" || echo "out/weather-api-client was removed"
```

```text output
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](https://cookiecutter.readthedocs.io/en/stable/cli_options.html)
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.

```bash
rm -rf out; PYTHONPATH=vendor python3 -m cookiecutter cookiecutter-api-client --no-input -o out --keep-project-on-failure install_command=false
```

```text output
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.

```bash
ls -A out/weather-api-client
```

```text output
.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.

```bash
PYTHONPATH=vendor python3 -m cookiecutter cookiecutter-api-client --no-input -o out -f install_command=true
```

```text output
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](https://jinja.palletsprojects.com/en/stable/templates/) 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](https://cookiecutter.readthedocs.io/en/stable/advanced/replay.html) 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

- [Decide between generating and hand-writing a client](/howto/generate-or-hand-write-a-small-api-client)

- [Wrap an SDK transport in a middleware chain](/howto/wrap-an-sdk-transport-with-middleware)

## 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.

[^1]: The [templates in context](https://cookiecutter.readthedocs.io/en/stable/advanced/templates_in_context.html)
page says the values of `cookiecutter.json` are 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 with `Unable to render variable 'slug'`.

[^2]: The [hooks documentation](https://cookiecutter.readthedocs.io/en/stable/advanced/hooks.html)
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](https://cookiecutter.readthedocs.io/en/stable/cli_options.html)
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.

[^3]: Copier's [updating page](https://copier.readthedocs.io/en/stable/updating/) 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.yml` by 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.