diff --git a/.github/workflows/startproject.yml b/.github/workflows/startproject.yml index 1fc6fea..ab74fab 100644 --- a/.github/workflows/startproject.yml +++ b/.github/workflows/startproject.yml @@ -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 < 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: | diff --git a/README.md b/README.md index ce57bec..78579b6 100644 --- a/README.md +++ b/README.md @@ -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 ``` -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 +uvx copier copy path/to/django-template ``` -> [!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 +``` + +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 ``. -Running the Django admin command will create a new project in the folder specified in -with ``. +> [!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 @@ -51,4 +53,6 @@ channel Feature branches are encouraged, and merging should on consensus from guild +[copier]: https://copier.readthedocs.io/en/stable/ [pipx]: https://pypa.github.io/pipx/ +[uvx]: https://docs.astral.sh/uv/guides/tools/ \ No newline at end of file diff --git a/copier.yml b/copier.yml new file mode 100644 index 0000000..2797d2c --- /dev/null +++ b/copier.yml @@ -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 diff --git a/docs/decisions/0007-copier.md b/docs/decisions/0007-copier.md new file mode 100644 index 0000000..7b5d3e7 --- /dev/null +++ b/docs/decisions/0007-copier.md @@ -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. + + +[jmoss]: mailto:jonathan.moss@ackama.com +[copier]: https://copier.readthedocs.io/en/stable/ +[cookie]: https://cookiecutter.readthedocs.io/en/stable/ diff --git a/template/.github/workflows/ci.yml b/template/.github/workflows/ci.yml index 6854d43..da7d8d4 100644 --- a/template/.github/workflows/ci.yml +++ b/template/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: run: | invoke format --check invoke lint - + type-checking: name: Type Checking @@ -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 diff --git a/template/README.md b/template/README.md deleted file mode 100644 index 8b1c9b8..0000000 --- a/template/README.md +++ /dev/null @@ -1 +0,0 @@ -# Template Readme diff --git a/template/README.md.jinja b/template/README.md.jinja new file mode 100644 index 0000000..27f56dc --- /dev/null +++ b/template/README.md.jinja @@ -0,0 +1 @@ +# {{ project_name }} diff --git a/template/docs/index.md b/template/docs/index.md index 00b36a5..e1ec0f9 100644 --- a/template/docs/index.md +++ b/template/docs/index.md @@ -1 +1 @@ -# SITE NAME +# {{ project_name }} \ No newline at end of file diff --git a/template/example.env b/template/example.env.jinja similarity index 83% rename from template/example.env rename to template/example.env.jinja index 5fa08c4..a74cda8 100644 --- a/template/example.env +++ b/template/example.env.jinja @@ -1,4 +1,4 @@ -SECRET_KEY={{ secret_key }} +SECRET_KEY=change-me-in-production ALLOWED_HOSTS=* DEBUG=True diff --git a/template/mkdocs.yml b/template/mkdocs.yml.jinja similarity index 97% rename from template/mkdocs.yml rename to template/mkdocs.yml.jinja index 31005cb..5cf8faa 100644 --- a/template/mkdocs.yml +++ b/template/mkdocs.yml.jinja @@ -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 diff --git a/template/pyproject.toml b/template/pyproject.toml.jinja similarity index 84% rename from template/pyproject.toml rename to template/pyproject.toml.jinja index fd11fe7..c061956 100644 --- a/template/pyproject.toml +++ b/template/pyproject.toml.jinja @@ -1,13 +1,13 @@ [tool.poetry] -authors = ["Jonathan Moss "] +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" @@ -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" @@ -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}]:" diff --git a/template/src/entrypoint.sh b/template/src/entrypoint.sh.jinja similarity index 94% rename from template/src/entrypoint.sh rename to template/src/entrypoint.sh.jinja index b23b35d..8d9833e 100644 --- a/template/src/entrypoint.sh +++ b/template/src/entrypoint.sh.jinja @@ -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 diff --git a/template/src/project_name/accounts/views.py b/template/src/project_name/accounts/views.py deleted file mode 100644 index 60f00ef..0000000 --- a/template/src/project_name/accounts/views.py +++ /dev/null @@ -1 +0,0 @@ -# Create your views here. diff --git a/template/src/tests/system/accounts/test_models.py b/template/src/tests/system/accounts/test_models.py.jinja similarity index 94% rename from template/src/tests/system/accounts/test_models.py rename to template/src/tests/system/accounts/test_models.py.jinja index 6bc4d06..efe3c70 100644 --- a/template/src/tests/system/accounts/test_models.py +++ b/template/src/tests/system/accounts/test_models.py.jinja @@ -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 diff --git a/template/src/tests/system/main/test_views.py b/template/src/tests/system/main/test_views.py.jinja similarity index 91% rename from template/src/tests/system/main/test_views.py rename to template/src/tests/system/main/test_views.py.jinja index 514cc1b..21a46a0 100644 --- a/template/src/tests/system/main/test_views.py +++ b/template/src/tests/system/main/test_views.py.jinja @@ -1,6 +1,6 @@ import json -from {{ project_name }}.main import views +from {{ project_module }}.main import views def test_releases_view_includes_revision_details(settings, rf): diff --git a/template/src/project_name/__init__.py b/template/src/{{ project_module }}/__init__.py similarity index 100% rename from template/src/project_name/__init__.py rename to template/src/{{ project_module }}/__init__.py diff --git a/template/src/project_name/accounts/__init__.py b/template/src/{{ project_module }}/accounts/__init__.py similarity index 100% rename from template/src/project_name/accounts/__init__.py rename to template/src/{{ project_module }}/accounts/__init__.py diff --git a/template/src/project_name/accounts/admin.py b/template/src/{{ project_module }}/accounts/admin.py similarity index 100% rename from template/src/project_name/accounts/admin.py rename to template/src/{{ project_module }}/accounts/admin.py diff --git a/template/src/project_name/accounts/apps.py b/template/src/{{ project_module }}/accounts/apps.py.jinja similarity index 74% rename from template/src/project_name/accounts/apps.py rename to template/src/{{ project_module }}/accounts/apps.py.jinja index 9236961..80a6d4f 100644 --- a/template/src/project_name/accounts/apps.py +++ b/template/src/{{ project_module }}/accounts/apps.py.jinja @@ -3,4 +3,4 @@ class AccountsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" - name = "{{ project_name }}.accounts" + name = "{{ project_module }}.accounts" diff --git a/template/src/project_name/accounts/migrations/0001_initial.py b/template/src/{{ project_module }}/accounts/migrations/0001_initial.py similarity index 100% rename from template/src/project_name/accounts/migrations/0001_initial.py rename to template/src/{{ project_module }}/accounts/migrations/0001_initial.py diff --git a/template/src/project_name/accounts/migrations/__init__.py b/template/src/{{ project_module }}/accounts/migrations/__init__.py similarity index 100% rename from template/src/project_name/accounts/migrations/__init__.py rename to template/src/{{ project_module }}/accounts/migrations/__init__.py diff --git a/template/src/project_name/accounts/models.py b/template/src/{{ project_module }}/accounts/models.py similarity index 100% rename from template/src/project_name/accounts/models.py rename to template/src/{{ project_module }}/accounts/models.py diff --git a/template/src/project_name/main/__init__.py b/template/src/{{ project_module }}/main/__init__.py similarity index 100% rename from template/src/project_name/main/__init__.py rename to template/src/{{ project_module }}/main/__init__.py diff --git a/template/src/project_name/main/asgi.py b/template/src/{{ project_module }}/main/asgi.py.jinja similarity index 68% rename from template/src/project_name/main/asgi.py rename to template/src/{{ project_module }}/main/asgi.py.jinja index a11c116..d2f1a83 100644 --- a/template/src/project_name/main/asgi.py +++ b/template/src/{{ project_module }}/main/asgi.py.jinja @@ -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``. @@ -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() diff --git a/template/src/project_name/main/settings.py b/template/src/{{ project_module }}/main/settings.py.jinja similarity index 89% rename from template/src/project_name/main/settings.py rename to template/src/{{ project_module }}/main/settings.py.jinja index e8e6d86..7cad174 100644 --- a/template/src/project_name/main/settings.py +++ b/template/src/{{ project_module }}/main/settings.py.jinja @@ -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 = [ @@ -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 = [ { @@ -66,7 +68,7 @@ }, ] -WSGI_APPLICATION = "{{ project_name }}.main.wsgi.application" +WSGI_APPLICATION = "{{ project_module }}.main.wsgi.application" # Database @@ -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), diff --git a/template/src/project_name/main/urls.py b/template/src/{{ project_module }}/main/urls.py similarity index 100% rename from template/src/project_name/main/urls.py rename to template/src/{{ project_module }}/main/urls.py diff --git a/template/src/project_name/main/views.py b/template/src/{{ project_module }}/main/views.py similarity index 100% rename from template/src/project_name/main/views.py rename to template/src/{{ project_module }}/main/views.py diff --git a/template/src/project_name/main/wsgi.py b/template/src/{{ project_module }}/main/wsgi.py.jinja similarity index 68% rename from template/src/project_name/main/wsgi.py rename to template/src/{{ project_module }}/main/wsgi.py.jinja index e98dea1..b4e1dd4 100644 --- a/template/src/project_name/main/wsgi.py +++ b/template/src/{{ project_module }}/main/wsgi.py.jinja @@ -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``. @@ -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() diff --git a/template/src/project_name/manage.py b/template/src/{{ project_module }}/manage.py.jinja similarity index 96% rename from template/src/project_name/manage.py rename to template/src/{{ project_module }}/manage.py.jinja index 2ed3d74..b6173b9 100755 --- a/template/src/project_name/manage.py +++ b/template/src/{{ project_module }}/manage.py.jinja @@ -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: diff --git a/template/src/project_name/templates/pages/humans.txt b/template/src/{{ project_module }}/templates/pages/humans.txt similarity index 100% rename from template/src/project_name/templates/pages/humans.txt rename to template/src/{{ project_module }}/templates/pages/humans.txt diff --git a/template/tasks.py b/template/tasks.py.jinja similarity index 97% rename from template/tasks.py rename to template/tasks.py.jinja index 95900b0..c377764 100644 --- a/template/tasks.py +++ b/template/tasks.py.jinja @@ -7,7 +7,7 @@ # CONSTANTS # ############# -PACKAGE = "{{ project_name }}" +PACKAGE = "{{ project_module }}" ################### # GETTING STARTED # @@ -25,7 +25,7 @@ def help(ctx): # noqa: A001 @invoke.task() def install(ctx, skip_install_playwright: bool = False): """ - Install system dependencies necessary for the {{ project_name }} project. + Install system dependencies necessary for the {{ project_module }} project. This task optionally skips the installation of Playwright dependencies, which is useful for CI pipelines where Playwright is not needed, @@ -207,7 +207,7 @@ def build_image(ctx, tag=None, pty=True): @invoke.task def build_docs(ctx): """ - Build the {{ project_name }} documentation + Build the {{ project_module }} documentation """ _title("Building documentation") ctx.run("poetry run mkdocs build --strict") @@ -259,7 +259,7 @@ def run_image( @invoke.task def run_docs(ctx): """ - Run the {{ project_name }} documentation locally + Run the {{ project_module }} documentation locally """ ctx.run("poetry run mkdocs serve") diff --git a/template/{{_copier_conf.answers_file}}.jinja b/template/{{_copier_conf.answers_file}}.jinja new file mode 100644 index 0000000..a96840d --- /dev/null +++ b/template/{{_copier_conf.answers_file}}.jinja @@ -0,0 +1,2 @@ +# Changes here will be overwritten by Copier +{{ _copier_answers|to_nice_yaml -}}