How-to › Model, configure and generate

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.

Audience
API producer
Level
beginner
Topic
Generate code and projects
Languages
Python
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
CookiecutterA layout you regenerate often, and the largest pool of existing templates to start fromNo record of which template made a project, so a template change reaches existing projects by handProjects you expect to update from the template later
CopierTemplates that keep changing after the projects existAn answers file committed in every project, and a template that is a git repository with version tagsA one-off scaffold that nothing will ever re-apply
uv initThe standard layout is enough and nothing team-specific has to come alongNo variables, hooks or extra files, so the client class, the test scaffold and the CI file are copied from a reference project by handThe 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.

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

  1. The templates in context 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'. ↩︎ Back to text

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

  3. 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.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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.