Python Copier
A library for rendering project templates with Jinja2, supporting prompts, hooks, and template updates.
Python Copier
A library for rendering project templates with Jinja2, supporting prompts, hooks, and template updates.
Overview
Copier is a powerful project templating tool that generates projects from templates stored in git repositories or local directories. It supports interactive prompts, pre/post-generation hooks, and crucially, the ability to update existing projects when templates change. This makes it ideal for maintaining consistency across multiple projects and organisations.
flowchart LR
A[Template Repository] --> B[Copier]
C[User Answers] --> B
B --> D[Generated Project]
D --> E[Update with New Template Version]
E --> B
Project Templating Basics
Key Concepts
Copier templates use Jinja2 syntax with special markers to distinguish template code from generated content. Templates can be stored in git repositories (recommended) or local directories.
Template Structure
my-template/
├── copier.yml # Configuration and prompts
├── {{ project_name }}/ # Templated directory name
│ ├── __init__.py
│ └── main.py.jinja # .jinja extension for templated files
├── README.md.jinja
├── pyproject.toml.jinja
└── .copier-answers.yml.jinja # Tracks answers in generated project
Basic copier.yml
# Template metadata
_min_copier_version: "9.0.0"
_subdirectory: template # Optional: template files in subdirectory
# Questions/variables
project_name:
type: str
help: What is your project name?
author_name:
type: str
help: Who is the author?
default: "Unknown"
Running Copier
# Generate from git repository
copier copy gh:user/template-repo ./my-project
# Generate from local template
copier copy ./my-template ./my-project
# Non-interactive with defaults
copier copy --defaults gh:user/template ./my-project
# Use specific template version/tag
copier copy --vcs-ref v1.2.0 gh:user/template ./my-project
Jinja2 Templating
{# In pyproject.toml.jinja #}
[project]
name = "{{ project_name }}"
version = "{{ version | default('0.1.0') }}"
authors = [
{ name = "{{ author_name }}", email = "{{ author_email }}" }
]
{% if include_cli %}
[project.scripts]
{{ project_name }} = "{{ project_name }}.cli:main"
{% endif %}
Variable Definitions and Prompts
Prompt Types
# String input
project_name:
type: str
help: Project name (lowercase, hyphens allowed)
validator: "{% if not project_name | regex_search('^[a-z][a-z0-9-]*$') %}Invalid format{% endif %}"
# Integer input
port:
type: int
default: 8000
help: Default port number
# Float input
version:
type: float
default: 1.0
# Boolean (yes/no)
include_tests:
type: bool
default: true
help: Include test configuration?
# Single choice
python_version:
type: str
choices:
- "3.10"
- "3.11"
- "3.12"
default: "3.11"
# Multiple choice
features:
type: str
multiselect: true
choices:
cli: Command-line interface
api: REST API
database: Database support
default:
- api
Advanced Prompt Features
# Conditional prompts (only shown if condition met)
database_url:
type: str
when: "{{ 'database' in features }}"
default: "postgresql://localhost/{{ project_name }}"
# Computed values (not prompted)
package_name:
type: str
default: "{{ project_name | replace('-', '_') }}"
when: false # Never prompt, always compute
# Secret input (hidden)
api_key:
type: str
secret: true
help: Enter your API key
# JSON type for complex data
extra_config:
type: json
default: {}
help: Additional configuration (JSON format)
# YAML type
metadata:
type: yaml
default:
tags: []
category: general
Validation
project_name:
type: str
validator: |
{% if project_name | length < 3 %}
Name must be at least 3 characters
{% elif project_name | length > 50 %}
Name must be less than 50 characters
{% elif not project_name | regex_search('^[a-z][a-z0-9_-]*$') %}
Name must start with letter, contain only lowercase, numbers, hyphens, underscores
{% endif %}
email:
type: str
validator: "{% if '@' not in email %}Invalid email address{% endif %}"
Hooks and Pre/Post Processing
Hooks are Python scripts that run before or after template generation.
flowchart TD
A[Start Copy] --> B[Pre-copy Tasks]
B --> C[Prompt for Answers]
C --> D[pre_copy.py Hook]
D --> E[Render Templates]
E --> F[post_copy.py Hook]
F --> G[Complete]
H[Start Update] --> I[pre_update.py Hook]
I --> J[Apply Changes]
J --> K[post_update.py Hook]
K --> L[Complete]
Hook Structure
my-template/
├── copier.yml
├── extensions/ # Custom Jinja2 extensions
│ └── my_extension.py
├── tasks/ # Alternative hook location
│ ├── pre_copy.py
│ └── post_copy.py
└── template/
└── ...
Configuring Hooks in copier.yml
_tasks:
# Run after generation
- "python scripts/post_generate.py"
- "{{ _copier_python }} -m pip install -e ."
# Or use dedicated hook files
_jinja_extensions:
- copier_templates_extensions.TemplateExtensionLoader
- jinja2_time.TimeExtension
Pre-copy Hook Example
#!/usr/bin/env python3
"""pre_copy.py - Runs before template rendering."""
import os
import sys
from pathlib import Path
def main():
# Access answers via environment variables
project_name = os.environ.get("PROJECT_NAME", "")
# Validate external dependencies
try:
import git
except ImportError:
print("Error: GitPython required. Install with: pip install gitpython")
sys.exit(1)
# Check if destination already exists
dest = Path(os.environ.get("_copier_conf_dst_path", "."))
if (dest / ".git").exists():
print("Warning: Destination already contains a git repository")
print(f"Pre-copy checks passed for {project_name}")
if __name__ == "__main__":
main()
Post-copy Hook Example
#!/usr/bin/env python3
"""post_copy.py - Runs after template rendering."""
import os
import subprocess
from pathlib import Path
def main():
project_name = os.environ.get("PROJECT_NAME", "project")
include_git = os.environ.get("INCLUDE_GIT", "true").lower() == "true"
project_dir = Path.cwd()
# Initialise git repository
if include_git and not (project_dir / ".git").exists():
subprocess.run(["git", "init"], check=True)
subprocess.run(["git", "add", "."], check=True)
subprocess.run(
["git", "commit", "-m", "Initial commit from template"],
check=True
)
print("Git repository initialised")
# Create virtual environment
if not (project_dir / ".venv").exists():
subprocess.run(["python", "-m", "venv", ".venv"], check=True)
print("Virtual environment created")
# Install dependencies
pip_path = project_dir / ".venv" / "bin" / "pip"
if (project_dir / "requirements.txt").exists():
subprocess.run([str(pip_path), "install", "-r", "requirements.txt"])
print(f"\nProject {project_name} created successfully!")
print(f"Next steps:")
print(f" cd {project_name}")
print(f" source .venv/bin/activate")
print(f" pip install -e .[dev]")
if __name__ == "__main__":
main()
Migration Hooks (for Updates)
#!/usr/bin/env python3
"""pre_update.py - Prepare for template update."""
import json
import os
from pathlib import Path
def main():
# Backup custom configurations
project_dir = Path.cwd()
custom_config = project_dir / "custom_config.json"
if custom_config.exists():
backup = project_dir / ".copier_backup" / "custom_config.json"
backup.parent.mkdir(exist_ok=True)
backup.write_text(custom_config.read_text())
print("Backed up custom configuration")
if __name__ == "__main__":
main()
Updating Templates
One of Copier's most powerful features is updating existing projects when templates change.
How Updates Work
sequenceDiagram
participant User
participant Copier
participant Template
participant Project
User->>Copier: copier update
Copier->>Project: Read .copier-answers.yml
Copier->>Template: Fetch latest version
Copier->>Copier: Calculate diff
Copier->>User: Show conflicts (if any)
User->>Copier: Resolve conflicts
Copier->>Project: Apply changes
Copier->>Project: Update .copier-answers.yml
Update Commands
# Update to latest template version
copier update
# Update to specific version
copier update --vcs-ref v2.0.0
# Preview changes without applying
copier update --pretend
# Skip prompts, use existing answers
copier update --defaults
# Force update even with conflicts
copier update --conflict rej # Create .rej files for conflicts
# Trust template hooks
copier update --trust
The .copier-answers.yml File
# .copier-answers.yml.jinja (in template)
_commit: {{ _copier_conf.vcs_ref_hash }}
_src_path: {{ _copier_conf.src_path }}
project_name: {{ project_name }}
author_name: {{ author_name }}
python_version: {{ python_version }}
features: {{ features | tojson }}
Generated in project:
# .copier-answers.yml (in generated project)
_commit: abc123def456
_src_path: gh:user/template-repo
project_name: my-project
author_name: Jane Smith
python_version: "3.11"
features: ["api", "database"]
Handling Conflicts
# copier.yml - Configure conflict handling
_conflict: inline # Options: inline, rej
# Inline shows conflicts in file:
# <<<<<<< YOUR CHANGES
# custom code here
# =======
# template code here
# >>>>>>> TEMPLATE
Migrations Between Versions
# copier.yml
_migrations:
- version: "2.0.0"
before:
- "python migrations/v2_pre.py"
after:
- "python migrations/v2_post.py"
Common Use Cases for Project Scaffolding
Python Package Template
# copier.yml for Python package
_min_copier_version: "9.0.0"
project_name:
type: str
help: Package name
validator: "{% if not project_name | regex_search('^[a-z][a-z0-9_-]*$') %}Invalid name{% endif %}"
description:
type: str
help: Short description
author_name:
type: str
author_email:
type: str
validator: "{% if '@' not in author_email %}Invalid email{% endif %}"
license:
type: str
choices:
- MIT
- Apache-2.0
- GPL-3.0
- BSD-3-Clause
default: MIT
python_version:
type: str
choices: ["3.9", "3.10", "3.11", "3.12"]
default: "3.11"
use_src_layout:
type: bool
default: true
help: Use src/ layout?
include_cli:
type: bool
default: false
include_docker:
type: bool
default: false
ci_provider:
type: str
choices:
none: No CI
github: GitHub Actions
gitlab: GitLab CI
default: github
FastAPI Service Template
# copier.yml for FastAPI service
service_name:
type: str
help: Service name (lowercase, hyphens)
database:
type: str
choices:
none: No database
postgresql: PostgreSQL
mysql: MySQL
sqlite: SQLite (development only)
default: postgresql
orm:
type: str
when: "{{ database != 'none' }}"
choices:
- sqlalchemy
- tortoise
default: sqlalchemy
include_auth:
type: bool
default: true
help: Include JWT authentication?
include_redis:
type: bool
default: false
help: Include Redis for caching?
container_registry:
type: str
default: "ghcr.io/{{ github_org }}"
Microservice Template
# copier.yml for microservice
service_name:
type: str
team_name:
type: str
help: Owning team name
service_type:
type: str
choices:
api: REST API Service
worker: Background Worker
gateway: API Gateway
default: api
ports:
type: json
default:
http: 8080
metrics: 9090
health_endpoint:
type: str
default: /health
kubernetes_namespace:
type: str
default: "{{ team_name }}-{{ service_name }}"
Integration with CI/CD Pipelines
GitHub Actions Workflow
# .github/workflows/template-test.yml.jinja
name: Test Template
on:
push:
branches: [main]
pull_request:
jobs:
test-generation:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: {{ "${{ matrix.python-version }}" }}
- name: Install Copier
run: pip install copier
- name: Generate test project
run: |
copier copy --defaults \
--data project_name=test-project \
--data author_name="CI Test" \
. ../test-output
- name: Validate generated project
working-directory: ../test-output
run: |
python -m py_compile **/*.py
{% if include_tests %}
pip install -e .[dev]
pytest
{% endif %}
Template Testing Script
#!/usr/bin/env python3
"""test_template.py - Test template generation."""
import subprocess
import tempfile
from pathlib import Path
TEST_CASES = [
{
"name": "minimal",
"data": {
"project_name": "test-minimal",
"author_name": "Test User",
}
},
{
"name": "full-features",
"data": {
"project_name": "test-full",
"author_name": "Test User",
"include_cli": True,
"include_docker": True,
"features": ["api", "database", "cli"]
}
}
]
def test_template():
template_dir = Path(__file__).parent.parent
for case in TEST_CASES:
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = Path(tmpdir) / case["name"]
# Build copier command
cmd = ["copier", "copy", "--defaults", "--trust"]
for key, value in case["data"].items():
if isinstance(value, bool):
value = str(value).lower()
elif isinstance(value, list):
value = ",".join(value)
cmd.extend(["--data", f"{key}={value}"])
cmd.extend([str(template_dir), str(output_dir)])
# Generate project
result = subprocess.run(cmd, capture_output=True, text=True)
assert result.returncode == 0, f"Failed: {result.stderr}"
# Validate structure
assert (output_dir / "pyproject.toml").exists()
assert (output_dir / ".copier-answers.yml").exists()
print(f"Test case '{case['name']}' passed")
if __name__ == "__main__":
test_template()
GitLab CI Template Testing
# .gitlab-ci.yml
stages:
- test
- release
test-template:
stage: test
image: python:3.11
script:
- pip install copier pytest
- python tests/test_template.py
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
release-template:
stage: release
script:
- git tag "v${CI_COMMIT_SHORT_SHA}"
- git push origin "v${CI_COMMIT_SHORT_SHA}"
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
Best Practices for Template Management
Template Organisation
graph TB
subgraph "Repository Structure"
A[template-repo/]
A --> B[copier.yml]
A --> C[template/]
A --> D[extensions/]
A --> E[tests/]
A --> F[docs/]
C --> G[project files]
D --> H[custom Jinja filters]
E --> I[template tests]
end
Version Control Strategy
# copier.yml
_min_copier_version: "9.0.0"
# Use semantic versioning for templates
# Tag releases: v1.0.0, v1.1.0, v2.0.0
# Document breaking changes
_message_after_copy: |
Project generated successfully!
{% if _copier_conf.vcs_ref %}
Template version: {{ _copier_conf.vcs_ref }}
{% endif %}
Run 'copier update' to get template updates.
Sensible Defaults
# Provide good defaults that work for most cases
project_name:
type: str
default: "{{ _copier_conf.dst_path | basename }}"
author_email:
type: str
default: "{{ author_name | lower | replace(' ', '.') }}@example.com"
# Use computed values to reduce prompts
package_name:
type: str
default: "{{ project_name | replace('-', '_') }}"
when: false
Template Documentation
# copier.yml - Add help text to all prompts
project_name:
type: str
help: |
Project name used in pyproject.toml and imports.
Must be lowercase with hyphens or underscores.
Examples: my-project, data_pipeline
# Comprehensive after-copy message
_message_after_copy: |
## Next Steps
1. Create virtual environment:
python -m venv .venv && source .venv/bin/activate
2. Install dependencies:
pip install -e .[dev]
3. Run tests:
pytest
## Updating
To update when template changes:
copier update
Conditional File Generation
# copier.yml
_exclude:
- "*.pyc"
- "__pycache__"
- ".git"
# Conditionally exclude files
- "{% if not include_docker %}Dockerfile{% endif %}"
- "{% if not include_docker %}docker-compose.yml{% endif %}"
- "{% if ci_provider != 'github' %}.github{% endif %}"
- "{% if ci_provider != 'gitlab' %}.gitlab-ci.yml{% endif %}"
Modular Templates and Composition
Template Inheritance
Copier does not have a built-in _inherit directive. The supported approach is template composition: apply multiple independent templates in sequence, or use _subdirectory to share a base layout. True inheritance (a child template extending a parent) is a requested feature, not yet implemented.
# base-template/copier.yml — standalone base template
_min_copier_version: "9.0.0"
project_name:
type: str
author_name:
type: str
To add child-specific questions, apply a second template on top (see Multi-template Composition below).
Multi-template Composition
#!/usr/bin/env python3
"""compose_templates.py - Apply multiple templates."""
import subprocess
from pathlib import Path
def compose_project(
output_dir: Path,
templates: list[dict],
common_data: dict
):
"""Apply multiple templates to create a composed project."""
for i, template in enumerate(templates):
# Merge common data with template-specific data
data = {**common_data, **template.get("data", {})}
cmd = ["copier", "copy", "--trust"]
# First template creates, subsequent ones overwrite
if i > 0:
cmd.append("--overwrite")
for key, value in data.items():
cmd.extend(["--data", f"{key}={value}"])
cmd.extend([template["src"], str(output_dir)])
subprocess.run(cmd, check=True)
# Example usage
compose_project(
output_dir=Path("./my-service"),
templates=[
{"src": "gh:org/python-base", "data": {}},
{"src": "gh:org/fastapi-addon", "data": {"port": 8080}},
{"src": "gh:org/kubernetes-addon", "data": {"replicas": 3}},
],
common_data={
"project_name": "my-service",
"author_name": "Team Name"
}
)
Shared Extensions
# extensions/custom_filters.py
"""Custom Jinja2 filters for templates."""
import re
from jinja2.ext import Extension
class CustomFilters(Extension):
def __init__(self, environment):
super().__init__(environment)
environment.filters["to_pascal"] = self.to_pascal_case
environment.filters["to_snake"] = self.to_snake_case
environment.filters["pluralise"] = self.pluralise
@staticmethod
def to_pascal_case(value: str) -> str:
"""Convert to PascalCase."""
return "".join(word.title() for word in re.split(r"[-_\s]+", value))
@staticmethod
def to_snake_case(value: str) -> str:
"""Convert to snake_case."""
s1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", value)
return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
@staticmethod
def pluralise(value: str) -> str:
"""Simple English pluralisation."""
if value.endswith("y"):
return value[:-1] + "ies"
elif value.endswith(("s", "x", "z", "ch", "sh")):
return value + "es"
return value + "s"
# copier.yml - Use custom extensions
_jinja_extensions:
- extensions.custom_filters.CustomFilters
# Usage in templates
# {{ project_name | to_pascal }} -> MyProject
# {{ "user" | pluralise }} -> users
Quick Reference
Commands
| Command | Description |
|---|---|
copier copy <src> <dst> |
Generate project from template |
copier update |
Update project to latest template |
copier update --pretend |
Preview update changes |
copier recopy |
Regenerate project (discards changes) |
copier copy --trust |
Trust template hooks |
copier copy --defaults |
Use defaults, skip prompts |
copier copy --data key=value |
Pass answer via command line |
copier copy --vcs-ref v1.0 |
Use specific template version |
Variable Types
| Type | Description | Example |
|---|---|---|
str |
Text input | name: {type: str} |
int |
Integer | port: {type: int, default: 8080} |
float |
Decimal | version: {type: float} |
bool |
Yes/No | include_tests: {type: bool} |
json |
JSON data | config: {type: json} |
yaml |
YAML data | metadata: {type: yaml} |
Special Variables
| Variable | Description |
|---|---|
_copier_conf.src_path |
Template source path |
_copier_conf.dst_path |
Destination path |
_copier_conf.vcs_ref |
Template version/tag |
_copier_conf.vcs_ref_hash |
Template commit hash |
_copier_python |
Path to Python running Copier |
copier.yml Settings
| Setting | Description |
|---|---|
_min_copier_version |
Minimum Copier version |
_subdirectory |
Template files subdirectory |
_exclude |
Patterns to exclude |
_skip_if_exists |
Don't overwrite these files |
_tasks |
Post-generation commands |
_jinja_extensions |
Custom Jinja2 extensions |
_migrations |
Version migration scripts |
_message_after_copy |
Message shown after generation |
Common Issues and Solutions
| Issue | Cause | Solution |
|---|---|---|
Template not found |
Invalid git URL or path | Verify URL format: gh:user/repo or full URL |
Permission denied on hooks |
Hook scripts not executable | Add chmod +x in hook or use python script.py |
Jinja2 syntax error |
Template syntax issue | Check for unescaped { in non-template files |
Answers file not found |
Running update in wrong directory | Ensure .copier-answers.yml exists in project |
Conflict during update |
Local changes conflict with template | Use --conflict rej or manually resolve |
Variable undefined |
Missing or misspelled variable | Check copier.yml for variable definition |
Hook import error |
Missing dependency in hook | Install dependencies or add to hook requirements |
Version constraint failed |
Copier version too old | Upgrade: uv tool upgrade copier |
Debugging Templates
# Verbose output
copier --verbose copy ./template ./output
# Check answers file
cat .copier-answers.yml
# Test template rendering
copier copy --pretend --defaults ./template ./test-output
# Validate copier.yml
python -c "import yaml; yaml.safe_load(open('copier.yml'))"
Escaping Jinja2 in Generated Files
{# When generating files that themselves use Jinja2 #}
{% raw %}
{{ variable }} {# This will appear literally in output #}
{% endraw %}
{# Or use different delimiters in copier.yml #}
{# _envops:
block_start_string: "{%"
block_end_string: "%}"
variable_start_string: "[["
variable_end_string: "]]"
#}
Fixing Update Conflicts
# Option 1: Keep your changes (reject template changes)
copier update --conflict rej
# Then manually apply .rej files
# Option 2: Preview and decide
copier update --pretend
# Review changes, then apply selectively
# Option 3: Recopy and reapply changes
copier recopy
git diff HEAD # See what changed
# Manually restore your customisations