Post

copier-astral: My Opinionated Python Project Template

copier-astral: My Opinionated Python Project Template

Every time I started a new Python project, I had to do the same things again. Set up a virtual environment. Configure a linter. Write tests. Add a CI pipeline. Create a Makefile. Set up docs. The first time it was fine. By the tenth time, it felt tedious.

copier-astral is how I fixed that problem. It is a Copier template that creates a fully configured Python project in seconds, using the Astral toolchain.

Why Copier (and not Cookiecutter)

Cookiecutter is the classic tool for generating projects from templates. It works fine, but it has one big limitation: after you generate a project, you are on your own. If you later improve your template (for example, add a new CI step or switch to a different linter), projects you already created don’t get those changes.

Copier fixes this with update support. If you improve the template later, you can run copier update inside any project that was created from it. Copier will figure out what changed and apply those changes, while keeping your own modifications safe. Think of it like updating a dependency instead of copy-pasting code once and forgetting about it.

Copier also has better support for Jinja2 extensions, computed variables, and conditional file generation. I needed all of these.

Why the Astral Toolchain

When I started building this, the Python tooling world was changing fast. uv had become a much faster replacement for pip, venv, and pip-tools all at once. ruff was getting popular for linting and formatting, doing the job of flake8, black, and isort in a single tool. ty was Astral’s new type checker, an alternative to mypy and pyright.

What they all have in common is speed. These tools are written in Rust and run noticeably faster than the older Python-based tools. That speed adds up, especially when these tools run on every commit through pre-commit hooks. Speaking of which, the template uses prek instead of the traditional pre-commit framework. prek is also written in Rust and is much faster at installing and running hooks.

What Gets Generated

The template asks you a few questions, then creates a fully set up project:

1
2
3
4
5
6
7
8
9
10
11
12
13
? Project name: My Awesome Package
? Short description: Does amazing things
? Python package name: my_awesome_package
? Author name: [auto-detected from git]
? Author email: [auto-detected from git]
? GitHub username: [auto-detected from gh CLI]
? Minimum Python version: 3.12
? Include CLI (Typer)? Yes
? Include GitHub Actions CI/CD? Yes
? Include Docker? Yes
? Include MkDocs documentation? Yes
? Include pre-commit hooks? Yes
? Include Renovate? Yes

Every feature is optional. If you are building a library, you might skip the CLI and Docker. If you are writing a quick script, you might skip docs. The template only generates what you ask for.

The generated project includes:

ToolRole
uvPackage management, virtual environments, dependency resolution
ruffLinting and formatting
tyType checking
pytestTest runner
hatchMulti-version CI matrix
MkDocsDocumentation with Material theme and mkdocstrings
prekPre-commit hooks
git-cliffChangelog from conventional commits
TyperCLI framework
gitleaksSecret scanning
pysentry-rsDependency vulnerability scanning
semgrepStatic application security testing
RenovateAutomated dependency update PRs

How the Template Works

Jinja2 Extensions

Copier templates use Jinja2 under the hood. By default, you can only insert user-provided answers into files. I wanted more than that. I wanted the template to automatically fill in fields from the user’s environment, so they don’t have to type information that their machine already knows.

I wrote three custom extensions in extensions.py:

GitExtension has two filters. The git_config filter runs git config <key> when the template is generated, so things like author name and email get pulled from the user’s git config automatically. The github_username filter tries the GitHub CLI first (gh api user), then falls back to git config github.user. Together, most fields fill themselves in:

1
2
3
4
5
author_name:
  default: "{{ 'git config user.name' | git_config }}"

github_username:
  default: "{{ '' | github_username }}"

SlugifyExtension provides a slugify filter that turns a human-readable project name into a valid Python package name: all lowercase, ASCII characters only, with hyphens as separators. The package name is computed automatically from the project name:

1
2
project_slug:
  default: "{{ project_name | slugify | replace('-', '_') }}"

CurrentYearExtension makes the current year available as a variable, used in LICENSE files.

Conditional File Generation

Copier lets you put Jinja2 conditions in file names. For example, a file named {% if include_docker %}Dockerfile{% endif %}.jinja only gets created if include_docker is true. If the condition is false, the file simply does not appear in the output. This keeps things clean because there is no extra logic needed inside the files themselves.

The pyproject.toml Template

The most complex file in the template is pyproject.toml, because every optional tool adds its own config section. Conditional blocks handle this:

1
2
3
4
{% if include_cli %}
[project.scripts]
{{ project_slug }} = "{{ project_slug }}.cli:app"
{% endif %}

The same pattern is used for optional dev dependencies, tool configurations, and hatch environments.

GitHub Actions

The CI pipeline is also generated from the template. The base workflow runs linting, type checking, and tests across multiple Python versions. If you chose to include Codecov, PyPI publishing, or security scanning, those jobs are added too.

Using It

1
2
3
4
5
# Install copier with the required extensions
uv tool install copier --with copier-template-extensions

# Generate a new project
copier copy --trust gh:ritwiktiwari/copier-astral my-project

The --trust flag is required because the custom Jinja2 extensions run Python code (the subprocess calls for git config and the GitHub CLI). Copier warns about this by default as a safety measure.

To update an existing project when the template changes:

1
2
cd my-project
copier update --trust

Copier compares the template version your project was created from with the current version, and applies the differences. It works like a three-way merge.

What I Learned

Being opinionated is the point. A template that tries to support every possible preference ends up being good at none of them. copier-astral is built around how I actually work on Python projects. If you work differently, the template is still a better starting point than an empty directory.

The update workflow changes everything. With Cookiecutter, making your template better does not help projects you already created. With Copier, it does. That makes it worth investing time in the template as something you maintain long-term, not just a one-time script.

Custom extensions are worth the effort. Auto-detecting the author name, email, and GitHub username might sound like a small thing. But it removes the part of project setup where you are most likely to make a mistake: typing the same values you have typed a hundred times before.

The full source is at github.com/ritwiktiwari/copier-astral and the documentation is at ritwiktiwari.com/copier-astral. Pull requests welcome.

This post is licensed under CC BY 4.0 by the author.