iru-setup-python-django
Scaffold a new Django 6.1 project with uv (`uv init --bare` + `uv add`, committed `uv.lock`, `.python-version`) and `django-admin startproject config .`, or bring an existing Django project up to this
SKILL.md.claude/skills/iru-setup-python-django/SKILL.md
GitHubSetup Python Django
Scaffold a Django project with uv and startproject, then layer on this catalog's standard toolchain and a small,
real, tested project skeleton: split settings fed by the environment, a custom user model before the first
migration, health probes, one sample app with a service layer and an API, fast and integration test tiers, strict
typing, a production container image, Sphinx API docs, and Sonar configuration. Every template in reference/ was
rendered into throwaway projects and run against the real tooling (see Verification notes); the gotchas called
out inline are observed behavior.
The generated code is the first exemplar of the conventions iru-django-code-one-task enforces (apps under
apps/ with AppConfig.name = "apps.<x>", views/API → services.py → models, @transaction.atomic services that
turn IntegrityError into domain errors, explicit serializer/schema fields, factories over fixtures, migrations
generated by makemigrations), and every command in Step 13 is the one iru-setup-python-github-workflows and the
iru-python-test/-coverage/-code-quality/-docstrings gates run.
Reference files — read only what this run needs
| Read | When |
|---|---|
reference/project-templates.md |
Always. pyproject.toml header + tool config, config/ (outside settings/), manage.py, gunicorn.conf.py, apps.core, apps.accounts. |
reference/settings-templates.md |
Always. config/settings/{__init__,base,local,test,production}.py. |
reference/sample-app-and-tests.md |
Always for a new project; for an existing one only when the user wants the sample. The catalog app, tests/ (conftest, factories, fast tier, integration tier). |
reference/api-drf.md / reference/api-ninja.md |
Only the one matching api (nothing for api: none). |
reference/docs-docker-and-sonar.md |
Always (Sphinx sources, Dockerfile, .dockerignore, compose.yaml, .env.example); its Sonar section only when sonar ≠ none. |
reference/ui-tests.md |
Always. .ui-quality.yml and the browser UI tier (tests/ui/: helper, fixtures, the AdminLoginScreen test). |
Template conventions
The templates are shared by every api/database combination:
- A line ending in
# [api: drf](orninja/none) or# [database: postgresql](orsqlite) is kept only for that choice, with the tag removed; otherwise the line is dropped. - A line that is only such a tag (e.g.
# [api: drf]) opens a block kept only for that choice;# [end]closes it. Drop both marker lines. - Whole files are per-choice where the heading says so:
apps/catalog/api/(package) +tests/catalog/test_api_drf.pyonly fordrf;config/api.py,apps/catalog/api.py(module),apps/catalog/schemas.py,tests/catalog/test_api_ninja.pyonly forninja;tests/integration/only fordatabase: postgresql. - Placeholders:
<name>(distribution name),<package>(snake_case identifier — default PostgreSQL database/user name),<description>,<license>,<developer-name>,<developer-email>,<organization-url>,<python-version>,<uv-version>, and the Sonar keys. Nothing else in angle brackets is a placeholder.
After rendering, no # [ tag and no <placeholder> may remain (grep -rn '# \[\(api\|database\|end\)' . must be
empty outside .venv).
Step 0 — Resolve inputs
Invoked stand-alone (/iru-setup-python-django) or by iru-setup-python-repository, which passes already-resolved
answers through args as key: value lines, one per line, e.g.:
name: acme-books
package: acme_books
description: Book catalog service.
license: BSD-3-Clause
developer-name: Jane Doe
developer-email: jane@example.com
organization-url: https://github.com/acme
python-version: 3.13
database: postgresql
api: drf
open-source: yes
visibility: public
ci: yes
distribution: internal
sonar: cloud
sonar-organization: acme-github
sonar-project-key: acme_acme-books
sonar-host-url: https://sonarcloud.io
accessibility: enforce
mode: new
on-existing: ask
Parse any such lines now; every key found is resolved — never ask it again. If args is absent or not in this
format, treat everything as unset.
Recognized keys:
- Identity:
name(the[project].name, lowercase kebab-case; also the image and compose project name),package(snake_case identifier; Django code lives inconfig/andapps/, so it only names the default PostgreSQL database/user — default:namewith-→_),description,license(SPDX id — map "MIT License" →MIT, "BSD 3-Clause" →BSD-3-Clause, "Apache License 2.0" →Apache-2.0; "No license" → nolicensekey plus thePrivate :: Do Not Uploadclassifier),developer-name,developer-email,organization-url. - Shared pipeline vocabulary:
python-version(CPython minor the project runs on, default3.13),open-source(yes/no),visibility(public/private/internal),ci(yes/no— only consulted by the Sonar question),distribution(none/internal/store— only reported; theDockerfileis written either way and the workflows decide whether to push it),sonar(cloud/self-hosted/none) plus, only whencloud/self-hosted,sonar-organization/sonar-project-key/sonar-host-url,accessibility(enforce/report/off/none, defaultenforce— the automated accessibility level of the browser UI tests, written to.ui-quality.yml; asked only in a stand-alone run, Step 2),mode(new/existing),on-existing(ask/keep/update/replace, defaultask, for scripted runs). - This skill only:
database(postgresql/sqlite, defaultpostgresql;noneis invalid — Django's auth, sessions and admin need a database: warn and ask),api(drf/ninja/none, defaultdrf).
Ignore any other key (ci-tests, pages, publish, integration-branch, stable-branch, orm, … may arrive from
the orchestrator). mode: existing marks an update run but never means "update silently": Step 1 still warns and
asks per pre-existing artifact unless on-existing says otherwise.
Accessibility level none and assistive-technology templates. accessibility takes enforce, report, off or
none (UI contract §8; the stand-alone question gains a fourth option, None — opt out entirely, e.g. a mandated
non-compliant design). With none: write accessibility: none to .ui-quality.yml, do not install or configure the
accessibility test tooling (axe-core in the browser UI tests), and omit the accessibility-specific attributes from the generated templates.
With enforce, report or off: include the assistive-technology attributes from the contract's §8 platform
subsections in every generated template — semantic template landmarks, labelled form controls, alt on images, relative font sizes, prefers-reduced-motion guards — and, for enforce/report only, the automated checks. In
mode: existing, a prior .ui-quality.yml follows this skill's existing Update / Keep / Replace prompt and its
accessibility value is never silently flipped (Update sets it only when args supplied it).
Step 1 — Survey the repository
- Detect a Django project: a root
manage.pyanddjangoamongpyproject.toml[project].dependencies(or[tool.poetry.dependencies],requirements*.txt,Pipfile). Read the Django version fromuv.lock/poetry.lockoruv run python -m django --version. Ifmodewasn't supplied, derive it: project present ⇒existing, absent ⇒new. - A Python project that is not Django (a FastAPI service, a library): stop — this skill only scaffolds/updates
Django projects; suggest
iru-setup-python-repositoryto pick the right flavor. Never runstartprojectover another project's manifest. - Existing project shape: settings as a package (
config/settings/) or a singlesettings.py(and which modulemanage.py/wsgi.pydefault to), the project package name (configor another), app layout (apps/<x>, root-level apps, a cookiecutter project-slug package),AUTH_USER_MODEL(and whether migrations already exist — see Step 7), the API framework already installed (rest_framework/ninjainINSTALLED_APPSfixesapi; tell the user when that disagrees withargs), the test runner (pytest-django ormanage.py test), the package manager (uv.lock/poetry.lock/Pipfile.lock/requirements*.txt), and the database engine (psycopgordjango.db.backends.postgresql⇒postgresql). - Survey every artifact this skill owns:
pyproject.toml+uv.lock+.python-version, the tool sections ([tool.ruff],[tool.mypy]+[tool.django-stubs],[tool.pytest.ini_options],[tool.coverage.*]),manage.py,config/settings/(orsettings.py),config/env.py,config/urls.py,config/asgi.py/wsgi.py,config/api.py(Ninja),apps/core/,apps/accounts/, the sampleapps/catalog/,tests/(conftest.py,factories.py),gunicorn.conf.py,docs/api/conf.py+docs/api/*.rst,Dockerfile,.dockerignore,compose.yaml/docker-compose.yml,.env.example,sonar-project.properties,.ui-quality.yml, and the UI tier (tests/ui/ui_screenshots.py,tests/ui/conftest.py, theuimarker, the Playwright dev dependencies,LocaleMiddlewarein the settings). - Announce the mode before asking anything else:
- Nothing owned exists: "Scaffolding the Django project from scratch."
- Anything owned exists (or
mode: existing): "Updating an existing setup — found: <list with paths> (Django <version>, Python <requires-python>, settings <shape>, API <drf/ninja/none>)." Warn that each will be handled individually, then resolve one action per artifact:on-existingiskeep/update/replace: apply it to every artifact without asking (still print the warning). Exception:replacenever re-scaffolds an existing project, its settings, or its apps — treat those asupdateand say so.- Otherwise: ask with
AskUserQuestion, up to 4 artifacts per call (one question each), naming concrete contradictions with this run's choices in the question (e.g. "pyproject.tomlhas nointegrationmarker — the CI integration job selects it", "settings.pyhard-codesSECRET_KEY", "[tool.coverage.xml]writescoverage.xmlat the root, notbuild/coverage/coverage.xml", "asonar-project.propertiesexists but you chosesonar: none", "Dockerfilerunsmigratein itsCMD", ".ui-quality.ymlsaysaccessibility: offbut you choseenforce", "tests/ui/has nouimarker registered"):- Update (Recommended) — merge: add what's missing and correct what this skill owns (missing dev
dependencies and tool sections, the
integrationanduimarkers, coverage paths, a settings split around the existing module, health views,.dockerignoreentries,.ui-quality.ymlkeys — a differentaccessibilityis set to this run's value, a narroweddirections/themeslist is kept), keeping everything else. Show the diff; customizations survive, but a customized part this skill owns may be rewritten. - Keep as is — leave it untouched and skip the steps that only serve it. It may stay out of date or disagree with this run's choices — Step 14 lists each mismatch.
- Replace — regenerate from the template; customizations are lost (recoverable only from git history).
Not offered for
manage.py, the settings,config/urls.py, or an app: they are real code once the project exists — Update or Keep only. For the samplecatalogapp recommend Keep as is. An existingtests/ui/ui_screenshots.pyis the project's own helper (the coding skills extend it): Update merges in missing functions only, never Replace by default. - Stop (first batch only) — end without changing anything; report that and end here.
- Update (Recommended) — merge: add what's missing and correct what this skill owns (missing dev
dependencies and tool sections, the
- Never add the sample
catalogapp to an existing project unless the user asks — it would be dead code. Ask once: "Add thecatalogsample app as a reference?" (default No). The health probes andconfig/env.pyare offered (they're infrastructure, not sample code). - Ask about
sonar-project.propertiesonly after Step 2 resolvessonar: withsonar: none, Update/Replace mean removing it and Keep leaves it as a reported mismatch.
- Placement (only when Step 4 will scaffold): the repository root.
uv init --bareonly writespyproject.toml, anddjango-admin startproject config .refuses to overwrite existing files but tolerates other ones (README.md,LICENSE,docs/,.git,.github/are fine). If the root holds another project'spyproject.toml/manage.py/config/package, stop and ask whether to scaffold into a subdirectory named<name>.
Step 2 — Collect identity and options
For every key Step 0 resolved, use it. In mode: existing, read identity from pyproject.toml [project] (name,
description, license, authors, urls) and confirm it in one line instead of asking. Otherwise ask (plain
conversation for free text, AskUserQuestion for bounded choices):
- name (default: the repository directory's basename, lowercased, kebab-case), package (derived; confirm
only; must be a valid identifier and not
django,test,config,apps), description, developer name/email, organization URL (default:https://github.com/<owner>fromgit remote get-url origin). - license — BSD-3-Clause (Recommended) "Django's own license" / MIT / Apache-2.0 / No license
(proprietary). Remind in Step 14 to add a matching
LICENSEfile if missing (iru-check-licensebackfills headers). - python-version — default
3.13("the version the irurueta docs' examples, CI and Docker use"). Django 6.1 supports 3.12–3.14; never accept a value outside that range.requires-pythonstays>=3.12(the floor);python-versionis what.python-version, theDockerfilebase image andsonar.python.versionuse. - database — PostgreSQL (Recommended) "psycopg 3; fast tests on SQLite in memory, an
integrationtier on a real PostgreSQL via testcontainers (needs Docker),compose.yamlwithpostgres:18" / SQLite "file database in every environment; no integration tier, no database container". - api — Django REST Framework (Recommended) "DRF 3.18 + drf-spectacular OpenAPI schema under
/api/v1/; the irurueta docs' primary choice" / Django Ninja "typed, Pydantic-based endpoints with a built-in OpenAPI schema; async-capable, served on ASGI" / None "no API layer (admin, services and models only)". - sonar — "Configure SonarCloud / SonarQube for this project?". Resolve
visibilityonly now if missing (gh repo view --json visibility -q .visibility, lowercased; else ask Public/Private —internalcounts as private). Ifopen-source: yesbut private/internal, flag the contradiction and ask which is right. Recommend SonarCloud (cloud) only for a public open-source repository; otherwise say SonarCloud is free only for public open-source projects and recommend None, offering self-hosted second. Withci: no, say no CI workflow would send reports and recommend None. Forcloudasksonar-organization(suggest<owner>-github) andsonar-project-key(suggest<owner>_<repo>), hosthttps://sonarcloud.io; forself-hostedask the host URL, the organization only if the server uses them, and the project key. - accessibility (stand-alone runs only; an orchestrator always passes it) — "Support accessibility and assistive technologies?" Enforce (Recommended) "axe violations fail the UI tests" / Report only "listed in the test
output and the gate summary, never failing" / Off "not run; the accessibility coding defaults still apply".
Written to
.ui-quality.yml.
Step 3 — Toolchain and versions
- uv is required:
command -v uvanduv --version. Without it, stop and tell the user to install it (https://docs.astral.sh/uv/getting-started/installation/) — neverpip installinto a system Python. An existing project managed by Poetry/Pipenv/pip keeps its manager: run the equivalent commands (poetry add --group dev …,poetry run …) and say in Step 14 that the CI workflows expect uv (uv.lock), so a migration to uv is recommended. uv python install <python-version>ifuv python find <python-version>fails.- Docker (
docker info) is needed only for the integration tier and the image build — its absence makes those checks unverified, never skipped silently. - Versions are resolved live by
uv add(latest release within each range below); record whatuv.lockended up with. Ranges:django>=6.1,<6.2(pin the feature series),djangorestframework>=3.18,<3.19,drf-spectacular>=0.30,<0.31,django-ninja>=1.7,<2,pytest-playwright>=0.10,axe-playwright-python>=0.1.8; everything else takesuv add's default>=<latest>floor.<uv-version>for theDockerfileisuv self version --short(or the latestastral-sh/uvrelease) — pin an exact tag, neverlatest.
Step 4 — Scaffold (new projects only)
Skip for an existing project. From the repository root:
uv init --bare --python <python-version> --name <name> --description "<description>"
uv python pin <python-version>
uv add "django>=6.1,<6.2" "django-stubs-ext>=6.1" whitenoise gunicorn
uv add "psycopg[binary]" # database: postgresql
uv add "djangorestframework>=3.18,<3.19" "drf-spectacular>=0.30,<0.31" # api: drf
uv add "django-ninja>=1.7,<2" uvicorn-worker # api: ninja
uv add --group dev pytest pytest-django pytest-cov factory-boy ruff mypy "django-stubs[compatible-mypy]"
uv add --group dev "pytest-playwright>=0.10" "axe-playwright-python>=0.1.8" # browser UI tier (every choice)
uv add --group dev "testcontainers[postgres]" # database: postgresql
uv add --group dev djangorestframework-stubs # api: drf
uv add --group docs sphinx sphinx-autodoc-typehints
uv run django-admin startproject config .
mkdir -p apps
for app in core accounts catalog; do mkdir -p "apps/$app" && uv run django-admin startapp "$app" "apps/$app"; done
uv init --barewrites onlypyproject.toml(noREADME.md,main.py, or.python-version—uv python pinwrites the latter, which CI'ssetup-uv/setup-pythoncan read).uv addcreates.venv/anduv.lock.django-stubs-extis a runtime dependency:base.pycallsdjango_stubs_ext.monkeypatch()so the generic classes mypy needs (admin.ModelAdmin[Book],UserAdmin[User],QuerySet[Book]) can be subscripted at run time — without it, importingadmin.pyfails withTypeError: type 'ModelAdmin' is not subscriptable(verified).- Then delete what the templates replace or don't use:
config/settings.py(→ the settings package),apps/*/tests.py,apps/*/views.py(core's is rewritten), andapps/core/migrations/(core holds only abstract models). Skipcatalogwhen the sample isn't wanted (existing projects). - Ninja note:
apps/catalog/api.pyis a module; DRF uses anapps/catalog/api/package — never create both.
Step 5 — pyproject.toml
From reference/project-templates.md:
- Rewrite the
[project]header (keep thedependencieslistuv addwrote):version = "0.1.0.dev0"(the PEP 440 development version; releaseX.Y.Z),requires-python = ">=3.12",license(SPDX),authors,[project.urls] Homepage. Addreadme = "README.md"only ifREADME.mdexists —uv_buildfails the build when the file is missing (verified);iru-setup-readmeruns later in the orchestrated chain. [build-system]uv_buildwith[tool.uv.build-backend] module-name = ["apps", "config"],module-root = "": the project is installed into.venv, which is what letsconfig/__init__.pyread the version withimportlib.metadata.version("<name>")(a virtual, un-built project has no metadata), anduv buildproduces the sdist/wheel the release workflow attaches.- Append the tool sections verbatim (
apitags resolved): Ruff (line length 88 — Ruff/Black's default, the width the formatter is tuned for; the docs' PEP 8 79 would wrap Django's long dotted settings paths constantly),selectincl.DJ,ignore = ["D106", "RUF012"](Django/DRF configuration class attributes andMetaclasses), Google pydocstyle, per-file-ignores fortests/**(S101,D) and**/migrations/**, migrations excluded fromruff format(they are generated;makemigrationsoutput never has to be reformatted); mypystrictwith the django-stubs plugin (+mypy_drf_plugin.mainfor DRF,pydantic.mypyfor Ninja), migrations/build//docs/excluded, factory_boy overrides;[tool.django-stubs] django_settings_module = "config.settings.test"; pytest (DJANGO_SETTINGS_MODULE = "config.settings.test",addopts = "-ra --strict-markers --strict-config", theintegrationanduimarkers,filterwarnings = ["error::DeprecationWarning"]), a mypy override ignoringaxe_playwright_python's missing type information; coverage (branch,source = ["apps", "config"], omit migrations/settings/asgi.py/wsgi.py/manage.py,fail_under = 80— the catalog's gate; the irurueta docs use 90, offer it if the user wants the stricter floor — and XML atbuild/coverage/coverage.xml). - Existing project (Update): merge section by section; never drop a rule or override a stricter value the project
already has (e.g.
fail_under = 90stays); add theintegrationanduimarkers and the coverage XML path; report each change.
Run uv lock && uv sync afterwards.
Step 6 — Settings and entry points
From reference/settings-templates.md and reference/project-templates.md: config/settings/{__init__,base,local, test,production}.py, config/env.py, config/__init__.py (__version__ from the distribution metadata),
config/urls.py, config/asgi.py/wsgi.py (default config.settings.production), manage.py (default
config.settings.local), gunicorn.conf.py.
- Environment contract (production;
config/env.pyraisesImproperlyConfiguredwhen a required one is missing): requiredDJANGO_SECRET_KEY,DJANGO_ALLOWED_HOSTS(comma-separated), and for PostgreSQLPOSTGRES_HOST,POSTGRES_PASSWORD; optionalPOSTGRES_PORT(5432),POSTGRES_DB/POSTGRES_USER(<package>),DJANGO_SECRET_KEY_FALLBACKS,DJANGO_CSRF_TRUSTED_ORIGINS,DJANGO_HTTPS(default on: SSL redirect, secure cookies, HSTS 1 year + subdomains + preload —check --deploy --fail-level WARNINGpasses only with all of them;0only for plain-HTTP smoke runs),DJANGO_SECURE_PROXY_SSL_HEADER(only behind a proxy that strips the header),DJANGO_SECURE_HSTS_*,DJANGO_CONN_MAX_AGE,DJANGO_EMAIL_*(SMTPMAILERS— Django 6.1'smail.E001deploy check rejects the console backend),DJANGO_SQLITE_PATH(SQLite),DJANGO_ADMIN_URL,DJANGO_LOG_LEVEL,WEB_CONCURRENCY/GUNICORN_*.local.pyuses PostgreSQL whenPOSTGRES_HOSTis set (e.g.uv run --env-file .env python manage.py …against the compose database), elsedb.sqlite3, and a random key unlessDJANGO_SECRET_KEYis set.test.pyis fully self-contained (SQLite:memory:, MD5 hasher,InMemoryStorage, dummy cache, locmem mailer,WHITENOISE_AUTOREFRESH = True— without it every test client request warns "No directory at: staticfiles/", verified). LocaleMiddlewaresits afterSessionMiddlewareinbase.py: adjango_languagecookie (orAccept-Language) activates a language and the admin's<html dir>followsLANGUAGE_BIDI, which is how the UI tier renders right-to-left for real. Without a request preference the language staysLANGUAGE_CODE, so API responses and the fast tests are unchanged. Existing project (Update): add it only if it isn't there and the project has no other locale mechanism (i18n_patterns, a custom middleware); otherwise keep the project's and make the helper'sapply_direction_and_themeselect the language that way (ani18n_patternsprefix wins over the cookie).- No
STATICFILES_DIRSpointing at a directory that doesn't exist:staticfiles.W004is a warning and failscheck --deploy --fail-level WARNING. Addstatic/+ the setting together when the project gets assets. - Server:
api: drf/none→ WSGIgthread(DRF is sync-only; no async code ⇒ stay on WSGI);api: ninja→config.asgi:applicationwithuvicorn_worker.UvicornWorker(the maintained worker package, not the deprecateduvicorn.workers). The Gunicorn log line "ASGI 'lifespan' protocol appears unsupported" is expected. - Existing project with a single
settings.py(Update): don't split it silently — propose the split as a diff (base.py= the current module minus secrets/hosts; the three environment modules from the templates), and repointmanage.py,wsgi.py,asgi.py, pytest and django-stubs at the new modules. If the user keeps the single module, only add env-drivenSECRET_KEY/DEBUG/ALLOWED_HOSTSand report the rest as mismatches.
Step 7 — Apps, custom user, first migrations
Write apps/__init__.py, apps/core/ and apps/accounts/ from reference/project-templates.md, and (new projects,
or when asked) apps/catalog/ from reference/sample-app-and-tests.md plus the API files from reference/api-drf.md
or reference/api-ninja.md. Every apps.py sets name = "apps.<x>" and label = "<x>" (without the name edit,
startapp's name = "<x>" raises ImproperlyConfigured: Cannot import '<x>').
Then generate the first migrations — after AUTH_USER_MODEL = "accounts.User" is in base.py, so the custom
user is in accounts/0001_initial:
uv run python manage.py makemigrations accounts catalog
Never hand-write or edit a generated migration. Existing project that already has migrations with the default
auth.User: don't swap AUTH_USER_MODEL (that is InconsistentMigrationHistory territory and needs a manual data
migration) — keep its user model, skip apps.accounts, and report it; the irurueta docs' alternative is a profile
model with a OneToOneField.
Step 8 — Tests
From reference/sample-app-and-tests.md (+ the API test file): tests/__init__.py and one package per area
(__init__.py in every test directory — test module basenames repeat across them), tests/conftest.py,
tests/factories.py, the fast tests, and — database: postgresql — tests/integration/.
- Fast tier (
-m "not integration"): SQLite in memory viaconfig.settings.test; no service, no Docker. This is the tier the 80% coverage gate measures (verified 100%). - Integration tier (
-m integration,database: postgresqlonly): when any selected test carries theintegrationmarker, the session-scopeddjango_db_modify_db_settingsoverride intests/conftest.pystartspostgres:18-alpinewith testcontainers (from testcontainers.community.postgres import PostgresContainer— the oldtestcontainers.postgrespath raises aDeprecationWarning, whichfilterwarnings = errorturns into a failure, verified with testcontainers 4.15), mutatessettings.DATABASES["default"]in place, then closes and deletesconnections["default"]— without that last step the already-created SQLite connection is reused and the "integration" tests silently run on SQLite (verified). pytest-django then creates the test database on the container and applies every migration, so the tier also proves the migrations run on PostgreSQL. A plainuv run pytest(both tiers in one session) runs everything on PostgreSQL. database: sqlite: notests/integration/, no testcontainers;pytest -m integrationthen collects nothing and exits with code 5 — callers must treat that as "no integration tier", not a failure.- UI tier (
-m ui, everydatabase/api) — fromreference/ui-tests.md:.ui-quality.ymlat the root (accessibilityfrom Step 0/2,directions: [ltr, rtl],themes: [light, dark]— the contract's exact shape),tests/ui/__init__.py, the shared helpertests/ui/ui_screenshots.py(ui_quality(),screenshots_dir(),language_for(),apply_direction_and_theme(),capture_ui_state(),check_accessibility()— the same APIiru-django-code-one-taskdocuments, so later UI tests import it unchanged),tests/ui/conftest.py(DJANGO_ALLOW_ASYNC_UNSAFE— Playwright's sync API runs an event loop in the test thread, which would make pytest-django's database setup raiseSynchronousOnlyOperation; thedirection×themeparametrization from.ui-quality.yml; a fixed 1280×800 viewport,en-US, UTC, reduced motion), andtests/ui/test_admin_login.py(pytest-djangolive_server+ pytest-playwrightpage;AdminLoginScreendefaultanderrorstates, each captured to.ui-screenshots/screen/AdminLoginScreen/<state>--<direction>--<theme>.pngand checked with axe-core — WCAG 2.x A/AA tags — at the configured level; every scan is appended to.ui-screenshots/a11y-report.jsonl). The conftest deselectsuitests unless the-mexpression namesui, souv run pytest,-m "not integration"(the fast tier and its coverage gate) and-m integrationnever need a browser and the 80% floor keeps meaning "fast tests only". The UI tier starts a live server and a browser: the coding/test skills run it behind.integration-test.lock. Never weaken the axe check to get green — underenforce, fix the template/markup; a rule can only be narrowed with a documented reason at the call site. - Typed tests: use
UserFactory.create()/.build()(factory_boy 3.3's generics give the model type), notUserFactory()(mypy sees the factory class).
Step 9 — Sphinx API docs
Write docs/api/conf.py, index.rst and the module pages from reference/docs-docker-and-sonar.md (the catalog
page lists the API modules of the chosen api). conf.py puts the repository root on sys.path, sets
DJANGO_SETTINGS_MODULE=config.settings.test and calls django.setup(), so autodoc imports models and views with no
environment or database. Keep index.rst's title static ("API reference"): an <name>-length title underline breaks
-W builds (Title underline too short, verified). Never create or edit Antora files (docs/antora.yml,
docs/antora-playbook.yml, docs/modules/) — iru-setup-antora owns them; the workflows copy the Sphinx output
under the Antora site's api/ path. When a module is added later, add an automodule line to the matching page.
Step 10 — Container image, compose, .env.example
From reference/docs-docker-and-sonar.md (written for every distribution; internal/store only decide whether
the release workflow pushes the image to GHCR):
Dockerfile: multi-stagepython:<python-version>-slim-trixie(same base in both stages), uv copied fromghcr.io/astral-sh/uv:<uv-version>,UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_NO_DEV=1 UV_PYTHON_DOWNLOADS=0,uv sync --locked --no-install-project(cached layer) thenuv sync --locked,collectstaticat build time with throw-away values (never real secrets), non-root UID 10001 with root-owned code,DJANGO_SETTINGS_MODULEproduction, a stdliburllibHEALTHCHECKon/healthz(no curl in slim), exec-formCMD ["gunicorn"](readsgunicorn.conf.py). Migrations never run in the image's command..dockerignore: excludes.venv,.env*(keeps.env.example), caches,build/,dist/,staticfiles/,db.sqlite3, the UI tier's.ui-screenshots/andtest-results/, anddocs/(so Antora'sdocs/node_modules/never enters the build context).compose.yaml:db(postgres:18, volume at/var/lib/postgresql— PostgreSQL 18's path, not/data;pg_isreadyhealth check; published on127.0.0.1:5432forlocal.py), one-shotmigrate(manage.py migrate --noinput,restart: "no", waits for a healthydb), andapp(waits formigrateto complete successfully; port 8000).DJANGO_SECRET_KEYandPOSTGRES_PASSWORDcome from.env(${VAR:?}makes compose fail loudly when they're missing);DJANGO_HTTPS: "0"so the plain-HTTP stack works. SQLite variant: nodb;migrateandappshare asqlite-datavolume at/data(the image creates/dataowned by UID 10001)..env.example: placeholders only..envstays untracked (iru-setup-python-gitignoreignores it) and never enters an image.
Step 11 — sonar-project.properties
Only when sonar is cloud/self-hosted, from reference/docs-docker-and-sonar.md: sources apps,config, tests
tests, migrations excluded, settings/asgi.py/wsgi.py excluded from coverage,
sonar.python.version=<python-version>, sonar.python.coverage.reportPaths=build/coverage/coverage.xml,
sonar.python.xunit.reportPath=build/test-results/pytest*.xml, sonar.python.ruff.reportPaths=build/reports/ruff.json
(the workflows write that report with ruff check . --exit-zero --output-format json --output-file build/reports/ruff.json). Omit sonar.organization for a self-hosted server without organizations. With
sonar: none, write nothing (and handle an existing file per Step 1).
Step 12 — Format
uv run ruff check --fix . && uv run ruff format . — the templates are already clean; this catches anything adapted
for an existing project. In an existing project, run uv run ruff format --check . first and, if it would touch
files this skill didn't write, ask before reformatting (suggest a separate "apply Ruff format" commit).
Step 13 — Verify with the gate skills' commands
Run, in order, and fix anything this skill wrote until green — never "fix" pre-existing project code to make a gate pass; report it instead:
uv sync --locked
uv run ruff check .
uv run ruff format --check .
uv run mypy .
uv run python manage.py check
uv run python manage.py makemigrations --check --dry-run
DJANGO_SETTINGS_MODULE=config.settings.production \
DJANGO_SECRET_KEY="$(uv run python -c 'import secrets; print(secrets.token_urlsafe(64))')" \
DJANGO_ALLOWED_HOSTS=ci.example.com POSTGRES_HOST=localhost POSTGRES_PASSWORD=dummy \
uv run python manage.py check --deploy --fail-level WARNING
mkdir -p build/api-docs
uv run python manage.py spectacular --validate --fail-on-warn --file build/api-docs/openapi.yaml # api: drf
uv run python manage.py export_openapi_schema --api config.api.api --output build/api-docs/openapi.json # api: ninja
uv run pytest -m "not integration" --cov --cov-report=xml --cov-report=term-missing --junitxml=build/test-results/pytest.xml
uv run pytest -m integration --junitxml=build/test-results/pytest-integration.xml # database: postgresql, Docker
uv run playwright install chromium # once per machine (CI: --with-deps)
uv run pytest -m ui --junitxml=build/test-results/pytest-ui.xml # browser UI tier
uv run --group docs sphinx-build -W --keep-going -b html docs/api build/api-docs
uv build -o build/dist
docker build -t <name>:verify . # Docker
check --deployneeds no database connection — the dummyPOSTGRES_*values only satisfy the fail-fast settings (the SQLite variant needs only the first three variables). The secret must be ≥ 50 characters and must not start withdjango-insecure-(security.W009).spectacular --fileandexport_openapi_schema --outputdon't create directories — hence themkdir -p(verified:FileNotFoundErrorwithout it).- Coverage: pytest-cov reads
fail_under = 80and the XML path frompyproject.toml; the run fails below the floor. - UI tier: expect
.ui-screenshots/screen/AdminLoginScreen/{default,error}--{ltr,rtl}--{light,dark}.png(8 files, fewer when.ui-quality.ymlnarrows the matrix) and ana11y-report.jsonlwith no violations underenforce. No browser (download blocked, unsupported host) → unverified (no browser), never passed. - Integration tier with no reachable Docker daemon: report it unverified (Docker unavailable), never passed. Same for the image build.
- With Docker, also smoke-test the stack: write a throwaway
.env(randomDJANGO_SECRET_KEY, anyPOSTGRES_PASSWORD),docker compose up -d --build --wait, confirmcurl -s localhost:8000/healthz→{"status": "ok", …},/readyz→ 200, the API list endpoint → 200, a/static/admin/...file → 200 (WhiteNoise),docker compose exec app id→ uid 10001; thendocker compose down -vand delete the throwaway.envunless the user wants to keep it. If port 8000 is taken, say so rather than editingcompose.yaml.
Remove build/ and db.sqlite3 artifacts created by the checks only if they weren't there before (both are ignored
anyway).
Step 14 — Report
Start with the mode announced in Step 1 (scaffolded from scratch or updated an existing setup) and the location. Then:
- Per owned artifact, the action actually taken — created / updated / replaced / kept as is / removed — and, for every kept one, each mismatch with this run's choices.
- The resolved choices:
python-version(+requires-pythonfloor),database,api, server (WSGI gthread or ASGI Uvicorn worker), license,open-source,distribution(image built either way),sonar(with thevisibilityit was keyed on and where that came from; ifci: noand Sonar was still configured, say no CI step sends reports). - Versions from
uv.lock(Django, DRF/drf-spectacular or Ninja, psycopg, pytest, pytest-django, factory_boy, testcontainers, pytest-playwright, Playwright, axe-playwright-python, Ruff, mypy, django-stubs, Sphinx, Gunicorn, WhiteNoise) and theuvimage tag pinned in theDockerfile. .ui-quality.ymlwith its action (created / updated / replaced / kept as is) and theaccessibilitylevel applied (and where it came from:args, the user, or the default).- Every Step 13 command with its result: tests passed per tier (incl. the UI tier: captures written, axe violations by
rule id), coverage %, mypy/ruff results, the Django checks, the
schema export, Sphinx,
uv build, the image build and smoke test — and anything unverified and why.
Finish with a Warn explicitly block:
- Review before committing —
pyproject.tomlidentity/license/authors,SPECTACULAR_SETTINGS/NinjaAPItitle, the Sonar keys. - Commit
uv.lock,.python-version, the migrations; never commit.envordb.sqlite3. - Production environment — the required variables above; HSTS with
includeSubDomains+preloadis on by default (lowerDJANGO_SECURE_HSTS_SECONDSwhile rolling out HTTPS); setDJANGO_SECURE_PROXY_SSL_HEADER=1only behind a proxy that stripsX-Forwarded-Proto; probes and the imageHEALTHCHECKsendHost: 127.0.0.1— keep it (or the probe host) inDJANGO_ALLOWED_HOSTS. - Migrations run once per release (the compose
migrateservice, a Kubernetes Job, a release step) — never in every replica's start command. - Integration tests need Docker (testcontainers pulls
postgres:18-alpine). - UI tests need a browser:
uv run playwright install chromiumonce (--with-depson a bare Linux runner);.ui-screenshots/is regenerated output (gitignored byiru-setup-python-gitignore),.ui-quality.ymlis committed. sonar: cloud/self-hosted: aSONAR_TOKENsecret and a matching project must exist before CI analysis succeeds.- No
.gitignore, workflows,CHANGELOG.md,README.mdor Antora files were written —iru-setup-python-gitignore,iru-setup-python-github-workflows,iru-setup-changelog,iru-setup-readmeandiru-setup-antoracover them (theiru-setup-python-repositoryorchestrator runs them next). If noLICENSEexists, suggestiru-check-license.
Verification notes
Exercised on 2026-10-14 in $TMPDIR/iru-verify/python/setup-django/ (outside the repository) by rendering every
reference/ template with the conventions above into fresh uv init --bare + startproject projects — the
templates were extracted back out of the reference/*.md files and compared byte-for-byte with the verified sources
first. Toolchain: uv 0.12.8, CPython 3.13.15, Docker 29.8.2; resolved Django 6.1.2, DRF 3.18.3, drf-spectacular
0.30.0, django-ninja 1.7.1 (Pydantic 2.14.1), psycopg 3.3.6, WhiteNoise 6.12.0, Gunicorn 26.2.0, uvicorn-worker
0.4.0 (Uvicorn 0.54.0), pytest 9.1.1, pytest-django 4.14.0, pytest-cov 7.1.0, factory_boy 3.3.3, testcontainers
4.15.0, Ruff 0.17.0, mypy 2.4.0, django-stubs 6.1.2, djangorestframework-stubs 3.18.1, Sphinx 9.1.0,
sphinx-autodoc-typehints 3.13.10, uv_build 0.12.
| Variant | uv sync / ruff / format / mypy strict / check / makemigrations --check / check --deploy | Schema | Fast tier + coverage | Integration | Sphinx -W / uv build | Docker |
|---|---|---|---|---|---|---|
api: drf, database: postgresql |
all pass | spectacular --validate --fail-on-warn passes |
39 passed, 100% (gate 80%) | 3 passed on postgres:18-alpine (connection.vendor == "postgresql" asserted) |
pass / sdist + wheel | built; compose db healthy → migrate exited 0 → app healthy, /healthz + /readyz + /api/v1/books/ + WhiteNoise static 200, uid 10001 |
api: ninja, database: postgresql |
all pass | export_openapi_schema passes |
39 passed, 100% | 3 passed | pass / pass | built; compose stack healthy, Gunicorn reports uvicorn_worker.UvicornWorker, /api/v1/books/ + /api/v1/docs 200 |
api: none, database: sqlite |
all pass | n/a | 31 passed, 100% | none (exit 5, by design) | pass / pass | built; compose (migrate + app on the /data volume) healthy, /readyz 200 |
Also run: uv run pytest (both tiers in one session) — 42 passed, all on PostgreSQL. Not verified: api: drf/ninja
with database: sqlite beyond what the shared templates cover (the tagged lines are the same ones exercised above),
python-version 3.12/3.14, the update path against a real pre-existing project (Steps 1/5/6 merge rules are
instructions, not templates), Poetry/pip-managed projects, and anything on a Linux CI runner (the workflows skill
covers that).
UI tier, exercised on 2026-10-16 in $TMPDIR/iru-verify/python/iru-setup-python-django/ on the rendered api: drf,
database: postgresql project plus reference/ui-tests.md and the LocaleMiddleware/marker/mypy-override changes
(pytest-playwright 0.10.0, Playwright 1.64.0 with its bundled Chromium, axe-playwright-python 0.1.8 bundling axe-core
4.12.1): ruff check/format, mypy strict (48 files), manage.py check, makemigrations --check, check --deploy --fail-level WARNING all pass; pytest -m "not integration" 39 passed / 11 deselected (3 integration + 8 UI), 100%
coverage — unchanged; pytest -m ui 8 passed, writing the 8 AdminLoginScreen PNGs (the rtl captures show the
Arabic admin with dir="rtl", the dark ones Django's dark palette) and zero axe violations on the admin login
page in both states, both directions and both themes, so enforce needs no rule exclusion; an injected page with an
unlabelled button and an image without alt failed with button-name, document-title, image-alt. Observed:
Accept-Language set through page.set_extra_http_headers is overridden by the context locale in Chromium (the RTL
captures stayed dir="ltr") — hence the language cookie. Not verified: the UI tier on a Linux runner and on the
ninja/sqlite shapes (the files are identical; the admin is the same), and the plain uv run pytest integration
run on this machine (testcontainers' PostgreSQL container exited during start-up — a local Docker issue unrelated to
the UI tier, which that run correctly deselected).
Sources
- irurueta docs:
web/django/getting-started.adoc(uv route,startproject config .),web/django/project-structure-and-apps.adoc(apps/, settings split,core, layering, pyproject),web/django/settings-and-configuration.adoc(env-driven settings,STORAGES,MAILERS,check --deploy),web/django/custom-user-model-and-backends.adoc,web/django/best-practices.adoc,web/django/testing.adoc,web/django/pytest-factories-and-browser-tests.adoc(pytest-django, factory_boy, coverage),web/django/django-rest-framework.adoc,web/django/django-ninja.adoc,web/django/async-support.adoc(Uvicorn worker),web/django/deployment.adoc(Gunicorn config, WhiteNoise, health probes, migrations once per release),web/django/docker-and-kubernetes.adoc(multi-stage uv Dockerfile,.dockerignore, compose withpostgres:18+ one-shotmigrate),web/django/migrations.adoc,web/django/security.adoc,web/django/static-files-and-front-end-assets.adoc,web/django/system-checks.adoc,web/django/logging-and-error-reporting.adoc;programming-languages/python/docstrings.adoc(Sphinx + napoleon);backend/flask/docker-and-kubernetes.adoc(cross-framework container rules) - This catalog:
iru-django-code-one-task/reference/*(the conventions the skeleton exemplifies),iru-setup-python-github-workflows(commands, report paths),iru-setup-php-laravel,iru-setup-nestjs-app(structure) - https://docs.djangoproject.com/en/6.1/, https://docs.astral.sh/uv/guides/integration/docker/, https://pytest-django.readthedocs.io/, https://testcontainers-python.readthedocs.io/, https://github.com/typeddjango/django-stubs, https://drf-spectacular.readthedocs.io/, https://django-ninja.dev/, https://whitenoise.readthedocs.io/, https://docs.gunicorn.org/en/stable/settings.html