Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .github/workflows/startproject.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,16 @@ jobs:
libpq-dev

# Base Python tooling
pip install invoke poetry django
pip install invoke poetry copier

- name: Create test project
run: |
django-admin startproject \
--template template/ \
--extension py,Dockerfile,env,toml,yml \
test_project
cat <<EOF > answers.yml
project_name: Test Project
project_module: test_project
use_feature_toggles: true
EOF
copier copy . --data-file answers.yml -f test_project

- name: Install test project
run: |
Expand Down
42 changes: 23 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,35 +13,37 @@ This project is intended as:

## Usage

To create a new project from this template, you must have the most recent stable version
of Django installed in order to run the `startproject` command. The simplest way to do
this is with [pipx][pipx]:
To create a new project from this template, you can use [Copier][copier]. The simplest
way to run copier is with [pipx][pipx] or [uvx][uvx]:

```shell
pipx install django
pipx run copier copy path/to/django-template <project_folder>
```

This will ensure that the `django-admin` command is available in your shell. From there
you can create a new project with the following command:
or

```shell
django-admin startproject --template path/to/django-template/template/ --extension py,env,sh,toml,yml --exclude nothing <project_name>
uvx copier copy path/to/django-template <project_folder>
```

> [!NOTE]
> When initiating a Django project with a custom template, be aware that directories starting with
> a dot (e.g., `.github` for GitHub Actions workflows) are not included by default. A workaround
> from Django 4.0 onwards involves using the `--exclude` option with the `startproject` command.
> Oddly, specifying `--exclude` with a non-existent directory can allow these dot-prefixed
> directories to be copied. This trick can ensure that essential configurations like `.github` are
> included in your project setup.
You can pull the template directly from Github:

```shell
uvx copier copy gh:ackama/django-template <project_folder>
```

This will ask you a series of questions to help you configure your project. The answer
you provide to these questions will also be recorded in your new project in a file
called `.copier-answers.yml`.y

> [!WARNING]
> The name of your project _must_ be a valid Python package name - that means
> underscores (`_`) not hyphens (`-`) for name separators please.
Running the copier command will create a new project in the folder specified in
with `<project_folder>`.

Running the Django admin command will create a new project in the folder specified in
with `<project_name>`.
> [!NOTE]
> Copier has an `update` mode which allows you to change your answers to the setup
> questions at a later date. Your current project _must_ be under git control for this
> to work. It works pretty well but has not been tested here after lots of changes to
> the project have be made since the copy. So use caution.

## Contributing

Expand All @@ -51,4 +53,6 @@ channel
Feature branches are encouraged, and merging should on consensus from guild

<!-- Links -->
[copier]: https://copier.readthedocs.io/en/stable/
[pipx]: https://pypa.github.io/pipx/
[uvx]: https://docs.astral.sh/uv/guides/tools/
16 changes: 16 additions & 0 deletions copier.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
_subdirectory: template

project_name:
type: str
help: What is your project name?

project_module:
type: str
help: What is your Python module name?
default: "{{project_name | lower | replace(' ','_')}}"
validator: "{% if not project_module.isidentifier() %}Must be valid Python identifier{% endif %}"

use_feature_toggles:
type: bool
help: Do you want support for feature toggles?
default: true
70 changes: 70 additions & 0 deletions docs/decisions/0007-copier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# 0000 Architectural Decision Record Template

- Date: 2026-01-30
- Author(s): [Jonathan Moss][jmoss]
- Status: `Active`

## Decision

We will adopt [copier][copier] as a tool for creating projects from this template. This
allows us to start building variants more easily.

## Context

Up until now the `django-template` project has used the built-in `startproject` command
to create new projects from this template. This approach was initially chosen as it
represented the simplistic route to getting the template project up and running.
However, it is also extremely limited. The `startproject` command does not currently
provide any mechanisms for extending the range of questions asked - something we need to
be able to start adding _variants_, additional optional project features and
configuration. We would need an external tool to support adding variants to the project
after it was created. Building a maintaining such a tool is entirely possible - and
likely quite fun. But there are also several tools in the Python ecosystem already that
are targeted at this kind functionality. Namely [Cookiecutter][cookie] and more
recently [Copier][copier]

Cookiecutter has been the _defacto_ approach for a while and it works quite well. It
does have some limitations. For example it cannot create files in a loop if that is
needed. Otherwise it is quite capable of doing the job. I have noticed that templates
with _lots_ of options things can get quite complicated in the files with so many
if/else blocks.

Copier is at the surface quite similar to Cookiecutter. It uses a YAML file for config
rather than JSON. Both use jinja2 for templating. Copier however does have 1 key
advantage. It is designed to support smart updates. Creating a project from a template
is not just a one time deal. You can, at a later date run copier _again_ in update mode.
Doing so will ask all the current questions in the template, pre-filling with the
answers from the last time it was run. You can then _change_ your response to questions
or provide answers to new questions and Copier will attempt to apply the necessary
changes to the existing project in a non-destructive way. To the author this is the
killer feature and make one of the problems with template projects less of a concern -
that of keeping existing projects in sync. With the more traditional one and done
approach of Cookie Cutter, once the creation is done and changes in the base template
have to be manually applied to existing project. Or worse, they are applied in a single
existing project and never make it back into the template, which then slowly becomes
outdated and obsolete. One minor annoyance - which might also be a feature depending on
your point of view is that any file that should be processed as a template (i.e. it
contains jinja2 markup) must have a `.jinja` suffix added to it. This looks a little
weird in the template code base but it also makes it quite explicit.

There is a 3rd option, write out our tool designed to work with our own project
structures that either replaces or compliments `startproject`. This approach would be
fun but would likely require significant time investment to create and more to maintain.
It would also likely result in a tool with less features than the existing tools
available.

As such we are going with Copier for the time being.

## Implications

The outcome of choosing Copier is a template that initially works very similar to how
it does with `startproject` but with the option to expand functionality later on. It
also means that many of the template's files now need `.jinja` suffixes which means that
IDEs tend to treat them as text templates rather than python code. So a little more
care is needed over accepting changes to the template project to ensure it continues
to produce valid Django projects.

<!-- Links -->
[jmoss]: mailto:jonathan.moss@ackama.com
[copier]: https://copier.readthedocs.io/en/stable/
[cookie]: https://cookiecutter.readthedocs.io/en/stable/
4 changes: 2 additions & 2 deletions template/.github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
run: |
invoke format --check
invoke lint


type-checking:
name: Type Checking
Expand Down Expand Up @@ -239,4 +239,4 @@ jobs:
# Since we are running the app in docker we need to ensure it attaches to the same
# virtual network.
run: |
invoke run-image --network {% verbatim %}${{ job.container.network }}{% endverbatim %} --command check
invoke run-image --network ${{ job.container.network }}--command check
1 change: 0 additions & 1 deletion template/README.md

This file was deleted.

1 change: 1 addition & 0 deletions template/README.md.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# {{ project_name }}
2 changes: 1 addition & 1 deletion template/docs/index.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
# SITE NAME
# {{ project_name }}
2 changes: 1 addition & 1 deletion template/example.env → template/example.env.jinja
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
SECRET_KEY={{ secret_key }}
SECRET_KEY=change-me-in-production
ALLOWED_HOSTS=*
DEBUG=True

Expand Down
2 changes: 1 addition & 1 deletion template/mkdocs.yml → template/mkdocs.yml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,6 @@ plugins:
setup_commands:
- import os, sys
- sys.path.append("src")
- os.environ["DJANGO_SETTINGS_MODULE"] = "{{ project_name }}.main.settings"
- os.environ["DJANGO_SETTINGS_MODULE"] = "{{ project_module }}.main.settings"
- tags:
tags_file: tags.md
13 changes: 7 additions & 6 deletions template/pyproject.toml → template/pyproject.toml.jinja
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
[tool.poetry]
authors = ["Jonathan Moss <xirisr@gmail.com>"]
authors = ["{{author_name}} {{author_email}}"]
description = ""
name = "{{ project_name }}"
packages = [{include = "{{ project_name }}", from = "src"}]
name = "{{ project_module }}"
packages = [{include = "{{ project_module }}", from = "src"}]
readme = "README.md"
version = "0.1.0"

[tool.poetry.scripts]
manage = "{{ project_name }}.manage:main"
manage = "{{ project_module }}.manage:main"

[tool.poetry.dependencies]
django = "^5.1.9"
Expand All @@ -18,6 +18,7 @@ uvicorn = "^0.34.0"
uvloop = { version = "^0.21.0", markers = "sys_platform != 'win32'" }
sentry-sdk = "^2.20.0"
httptools = "^0.6.4"
{% if use_feature_toggles %}django-waffle = "^5.0.0"{% endif %}

[tool.poetry.group.dev.dependencies]
django-stubs = "^5.1.0"
Expand Down Expand Up @@ -50,10 +51,10 @@ ignore_missing_imports = true
module = "environ"

[tool.django-stubs]
django_settings_module = "{{ project_name }}.main.settings"
django_settings_module = "{{ project_module }}.main.settings"

[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "{{ project_name }}.main.settings"
DJANGO_SETTINGS_MODULE = "{{ project_module }}.main.settings"
addopts = "--rootdir src --spec"
norecursedirs = ".git .venv docs data"
spec_header_format = "{test_case} [{module_path}]:"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ serve () {
--no-use-colors \
--access-log \
--log-level="$UVICORN_LOG_LEVEL" \
{{ project_name }}.main.asgi:application
{{ project_module }}.main.asgi:application
}

if [ -z "$1" ] # nothing specified so we bootstrap the service itself
Expand Down
1 change: 0 additions & 1 deletion template/src/project_name/accounts/views.py

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import pytest
from django.contrib.auth import get_user_model

from {{ project_name }}.accounts import models
from {{ project_module }}.accounts import models


@pytest.mark.django_db
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import json

from {{ project_name }}.main import views
from {{ project_module }}.main import views
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just noticed this file name. should it be .jinja?



def test_releases_view_includes_revision_details(settings, rf):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@

class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "{{ project_name }}.accounts"
name = "{{ project_module }}.accounts"
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
ASGI config for {{ project_name }} project.
ASGI config for {{ project_module }} project.

It exposes the ASGI callable as a module-level variable named ``application``.

Expand All @@ -11,6 +11,6 @@

from django.core.asgi import get_asgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{{ project_name }}.main.settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{{ project_module }}.main.settings")

application = get_asgi_application()
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@
"django.contrib.messages",
"django.contrib.staticfiles",
# Third party
{% if use_feature_toggles %}"waffle",{% endif %}
# Local
"{{ project_name }}.accounts",
"{{ project_module }}.accounts",
]

MIDDLEWARE = [
Expand All @@ -46,9 +47,10 @@
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
{% if use_feature_toggles %}"waffle.middleware.WaffleMiddleware",{% endif %}
]

ROOT_URLCONF = "{{ project_name }}.main.urls"
ROOT_URLCONF = "{{ project_module }}.main.urls"

TEMPLATES = [
{
Expand All @@ -66,7 +68,7 @@
},
]

WSGI_APPLICATION = "{{ project_name }}.main.wsgi.application"
WSGI_APPLICATION = "{{ project_module }}.main.wsgi.application"


# Database
Expand Down Expand Up @@ -158,14 +160,22 @@
},
},
"loggers": {
"{{ project_name }}": {
"{{ project_module }}": {
"handlers": ["console"],
"level": LOG_LEVEL,
"propagate": False,
},
},
}

{% if use_feature_toggles %}
# Waffle settings
WAFFLE_FLAG_DEFAULT = False
WAFFLE_SWITCH_DEFAULT = False
WAFFLE_SAMPLE_DEFAULT = 0.0
WAFFLE_CREATE_MISSING_SWITCHES = True
WAFFLE_CREATE_MISSING_FLAGS = True
WAFFLE_CREATE_MISSING_SAMPLES = True
{% endif %}
# Sentry
sentry_sdk.init(
dsn=env("SENTRY_DSN", default=None),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
WSGI config for {{ project_name }} project.
WSGI config for {{ project_module }} project.

It exposes the WSGI callable as a module-level variable named ``application``.

Expand All @@ -11,6 +11,6 @@

from django.core.wsgi import get_wsgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{{ project_name }}.main.settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{{ project_module }}.main.settings")

application = get_wsgi_application()
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

def main() -> None:
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{{ project_name }}.main.settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{{ project_module }}.main.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
Expand Down
Loading