Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Django

pip install "dynamic-config-py[django]"

Four lines in a project, and no more:

# settings.py
INSTALLED_APPS = [..., "dynamic_config_web.django"]
MIDDLEWARE = [
    "dynamic_config_web.django.middleware.DynamicConfigMiddleware",
    ...,
]
DYNAMIC_CONFIG = {"target": "myproject.config:database"}

# urls.py
urlpatterns = [path("", include("dynamic_config_web.django.urls")), ...]
# views.py
from dynamic_config_web.django import snapshot


def index(request):
    db = snapshot()                      # or request.dynamic_config["db"]
    return JsonResponse({"host": db.host, "pool": db.pool.max_size})

examples/05_django.py runs all of it, DRF half included.

Configuration does not go in settings

Django loads its settings module once and caches it for the life of the process. A value copied in at startup is frozen there — the file reloads, the engine installs a new model, and settings.DATABASE_HOST keeps answering with the old one.

DYNAMIC_CONFIG therefore holds a pointer, never a value:

DYNAMIC_CONFIG = {
    "target": "myproject.config:database",   # dotted path, resolved at ready()
    "watch": True,
    "debounce": 0.25,
    "poll_interval": None,                   # seconds, to poll instead
    "guard": "myproject.config:only_operators",
    "stale_after": 300.0,
    "metrics": True,
    "diagnostics_prefix": "_config",
}

The short form DYNAMIC_CONFIG = "myproject.config:database" means the same thing with every default. Both target and guard accept package.module:attribute and package.module.attribute; they are resolved in ready() rather than in settings.py, because importing project code from a settings module while Django is still starting is a well-known way to produce a circular import.

A project that would rather wire in Python calls dynamic_config_web.django.configure(config) from its own AppConfig.ready and leaves DYNAMIC_CONFIG unset.

Which processes watch, and which fail

AppConfig.ready() runs in every process Django starts, and they do not all want the same thing. The adapter asks two questions:

Should this process watch files?should_watch()

processwatches
gunicorn, uvicorn, mod_wsgi, granianyes, one watcher per worker
runserver — the autoreloader's parentno (RUN_MAIN is unset there)
runserver — the child that servesyes
runserver --noreloadyes, there is only one process
migrate, shell, collectstatic, configcheckno — it loads, but a watcher would only delay the exit

Should a broken document stop it?serving_process()

A serving process that cannot load its configuration does not start. That is the whole value of loading in ready(): the failure lands in the deploy, where a rollback is one command, instead of in a worker that has already taken traffic.

A management command is the exception. configcheck is the tool you reach for because configuration is broken, and a migrate that cannot run during an outage is a worse problem than the outage. Commands load if they can, log a warning if they cannot, and carry on.

The middleware

MIDDLEWARE = [
    "dynamic_config_web.django.middleware.DynamicConfigMiddleware",
    ...,
]

Put it first, or close to it. Everything below it — every other middleware, every view — then reads the same snapshot. Something listed above it reads outside the scope and raises, which is a clearer failure than reading a different generation.

It is sync- and async-capable in one class, so a project serving both under ASGI does not get wrapped in sync_to_async — a wrapper that would run the view in another thread and could leave the scope open on the wrong one.

It also attaches request.dynamic_config:

db = request.dynamic_config["db"]
db = request.dynamic_config.one()     # when there is only one

which is there for the code that has a request in hand. snapshot() is the same values without one.

manage.py configcheck

$ python manage.py configcheck
[db]
  host                         in /etc/myapp/config.toml
  port                         in APP_PORT

  would load
ok — Database would load

$ python manage.py configcheck --explain database.port
$ python manage.py configcheck --config db --strict

Exits non-zero when a configuration would not load, which is what makes it useful in a container's start script and in CI: the failure happens in a command whose output you read, rather than in a worker whose first request you do not.

--strict fails on unknown keys as well as on a refusal.

Django REST Framework

pip install "dynamic-config-py[drf]"
from dynamic_config_web.django.drf import urls as config_urls

urlpatterns = [path("internal/", include(config_urls())), ...]

The plain views already work in a DRF project — they are just views. The DRF set exists for the project that wants its diagnostics inside its own permission scheme rather than behind a shared token:

from dynamic_config_web.django.drf import ConfigCheckView

class OurCheckView(ConfigCheckView):
    permission_classes = [IsAdminUser]

ConfigDiagnosticsPermission — the default — defers to the installation's guard, so guard= covers both sets. Readiness and metrics stay open, because a probe and a scrape are not authenticated callers and neither renders a value.

A request these refuse gets 403, where the plain views answer 404 — DRF's convention, and what a project's own permission classes produce anyway. With no guard configured, neither set of diagnostics routes is built at all.

django-ninja

pip install "dynamic-config-py[ninja]"
from ninja import NinjaAPI
from dynamic_config_web.django.ninja import router

api = NinjaAPI()
api.add_router("/", router())

urlpatterns = [path("api/", api.urls), ...]

django-ninja is a Django application, so everything that is not routing is already in place: AppConfig.ready loads and watches, and the middleware opens the request scope. router() adds the operations, and an operation reads with snapshot() like any other view:

@api.get("/")
def index(request):
    db = snapshot()
    return {"host": db.host, "pool": db.pool.max_size}

Every operation router() registers carries auth=None except the two diagnostics ones. That matters in a project that sets an API-wide default:

api = NinjaAPI(auth=django_auth)

Without the explicit auth=None, /healthz would inherit that default and report the authentication backend's health rather than the process's.

A refused diagnostics request gets 401, which is django-ninja's convention. Across the three Django route sets, then: 404 from the plain views, 403 from DRF, 401 from Ninja — and in all three, no guard means no routes.

Async views

async def index(request):
    db = snapshot()
    ...

contextvars is what Django's async path propagates, so the scope reaches an async def view, a sync view running in a thread, and a sync_to_async call inside either.