Django Classy Settings

Stay classy, Django.

Credits

This work was originally inspired by the work of Jessie O’Connor.

Overview

Class-based settings make it easy for you to manage multiple settings profiles for your Django project, without ever copying values.

Interdependant values, values sourced from the env, even calculated values are no problem, since you have the full power of Python and class inheritance.

Contents

Examples

Below is a default Django settings.py that has been adjusted to show how you might use django-classy-settings:

settings.py
  1 """
  2 Django settings for dummy project.
  3
  4 Generated by 'django-admin startproject' using Django 3.2.15.
  5
  6 For more information on this file, see
  7 https://docs.djangoproject.com/en/3.2/topics/settings/
  8
  9 For the full list of settings and their values, see
 10 https://docs.djangoproject.com/en/3.2/ref/settings/
 11 """
 12
 13 from pathlib import Path
 14
 15 from cbs import BaseSettings, env
 16
 17 # Build paths inside the project like this: BASE_DIR / 'subdir'.
 18 BASE_DIR = Path(__file__).resolve().parent.parent
 19
 20
 21 # Quick-start development settings - unsuitable for production
 22 # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
 23
 24 ALLOWED_HOSTS = []
 25
 26 ROOT_URLCONF = 'dummy.urls'
 27
 28 TEMPLATES = [
 29     {
 30         'BACKEND': 'django.template.backends.django.DjangoTemplates',
 31         'DIRS': [],
 32         'APP_DIRS': True,
 33         'OPTIONS': {
 34             'context_processors': [
 35                 'django.template.context_processors.debug',
 36                 'django.template.context_processors.request',
 37                 'django.contrib.auth.context_processors.auth',
 38                 'django.contrib.messages.context_processors.messages',
 39             ],
 40         },
 41     },
 42 ]
 43
 44 WSGI_APPLICATION = 'dummy.wsgi.application'
 45
 46 # Password validation
 47 # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
 48
 49 AUTH_PASSWORD_VALIDATORS = [
 50     {
 51         'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
 52     },
 53     {
 54         'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
 55     },
 56     {
 57         'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
 58     },
 59     {
 60         'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
 61     },
 62 ]
 63
 64 # Internationalization
 65 # https://docs.djangoproject.com/en/3.2/topics/i18n/
 66
 67 LANGUAGE_CODE = 'en-us'
 68
 69 TIME_ZONE = 'UTC'
 70
 71 USE_I18N = True
 72
 73 USE_L10N = True
 74
 75 USE_TZ = True
 76
 77
 78 # Static files (CSS, JavaScript, Images)
 79 # https://docs.djangoproject.com/en/3.2/howto/static-files/
 80
 81 STATIC_URL = '/static/'
 82
 83 # Default primary key field type
 84 # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
 85
 86 DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
 87
 88
 89 class Settings(BaseSettings):
 90
 91     # Allow production to override the secret key, but fall-back to something consistent.
 92     SECRET_KEY = env('django-insecure-kcdhlitokdqc7s7p7)^jm)55%p@frm#l39nzko458#1!6cu*$$')
 93
 94     # DEBUG defaults to True, but can be overridden by env var `DJANGO_DEBUG`
 95     DEBUG = env.bool(True, prefix='DJANGO_')
 96
 97     # Simple cases that don't need `self` can even use a lambda
 98     MEDIA_ROOT = env(lambda self: BASE_DIR / 'media')
 99
100     # Methods will be transparently invoked by the __getattr__ implementation
101     def INSTALLED_APPS(self):
102         return list(filter(None, [
103             'django.contrib.admin',
104             'django.contrib.auth',
105             'django.contrib.contenttypes',
106             'django.contrib.sessions',
107             'django.contrib.messages',
108             'django.contrib.staticfiles',
109             # Conditionally include an app
110             'debug_toolbar' if self.DEBUG else None,
111         ]))
112
113     def MIDDLEWARE(self):
114         return list(filter(None, [
115             'django.middleware.security.SecurityMiddleware',
116             'django.contrib.sessions.middleware.SessionMiddleware',
117             'django.middleware.common.CommonMiddleware',
118             'django.middleware.csrf.CsrfViewMiddleware',
119             'django.contrib.auth.middleware.AuthenticationMiddleware',
120             'django.contrib.messages.middleware.MessageMiddleware',
121             'django.middleware.clickjacking.XFrameOptionsMiddleware',
122             # Conditionally include a middleware
123             'debug_toolbar.middleware.DebugToolbarMiddleware' if self.DEBUG else False,
124         ]))
125
126     # Parse the URL into a database config dict.
127     DEFAULT_DATABASE = env.dburl('sqlite:///db.sqlite')
128
129     def DATABASES(self)
130         return {
131             'default': self.DEFAULT_DATABASE,
132         }
133
134
135 class ProdSettings(Settings):
136
137     # Override
138     DEBUG = False
139
140     # Values that *must* be provided in the environment.
141     @env
142     def STATIC_ROOT(self):
143         raise ValueError('STATIC_ROOT not supplied!')
144
145 # The `use` method will find the right sub-class of ``BaseSettings`` to use
146 # Based on the value of the `DJANGO_MODE` env var.
147 __getattr__, __dir__ = Settings.use()

Now when you start Django, it will use all of your global settings, and any from Settings.

You can switch to using the ProdSettings by setting the DJANGO_MODE environment variable:

# Use default Settings
$ ./manage.py shell

# Use ProdSettings
$ DJANGO_MODE=prod ./manage.py shell

BaseSettings.use() picks the BaseSettings sub-class named {DJANGO_MODE.title()}Settings.

Note

Since the registry of subclasses is on BaseSettings, you can call .use() on any sub-class and it will behave the same.

Which settings to move?

Generally, only move settings which are either environment driven, or need per-mode control.

Because of the precedence rules of Python’s module level __getattr__ function, any settings declared outside a class can not be overridden by a class-based setting.

GLOBAL = "global"

class Settings(BaseSettings):
    GLOBAL = "local"  # This setting will never be used

The env property

To help with environment driven settings there is the env property decorator.

The simplest use case is with an immediate value:

class Settings(BaseSettings):

    FOO = env('default')

__getattr__, __dir__ = BaseSettings.use()

In this case, if the FOO environment variable is set, then settings.FOO will yield its value. Otherwise, it will be "default".

You can optionally override the environment variable name to look up by passing a key argument:

class Settings(BaseSettings):

    FOO = env('default', key='BAR')

Additionally, you can define a prefix for the environment variable:

class Settings(BaseSettings):

    FOO = env('default', prefix='MY_')  # looks up env var MY_FOO

If you need a type other than str, you can pass a cast callable, which will be passed the value.

class Settings(BaseSettings):

    FOO = env('default', cast=int)

Pre-defined cast types

For convenience, there are several built-in pre-defined cast types, accessible via the env decorator.

env.bool    # Treats ("y", "yes", "on", "t", "true", "1") as True, and ("n", "no", "off", "f", "false", "0") as False
env.int     # Use the int constructor
env.dburl   # Converts URLs to Django DATABASES entries.
env.list    # splits on ',', and strips each value
env.tuple   # as above, but yields a tuple

In all cases, if the default value passed is a string, it will be passed to the cast function.

As a decorator

Additionally, env will function as a decorator. This allows you to put some logic into deciding the default value.

class Settings(BaseSettings):
    DEBUG = True

    @env.int
    def FOO(self):
        return 1 if self.DEBUG else 2

Mandatory environment variable

Should you require a value to always be supplied by environment variable, have your method raise a ValueError

class Settings(BaseSettings):
    @env.int
    def REQUIRED(self):
        raise ValueError()

Avoiding repeated prefixes

To avoid having to specify a prefix on multiple related variables, env will yield a partial when no default is provided.

Let’s say, for instance, you want all of your environment variables prefixed with DJANGO_

# Common prefix for DJANGO related settings
denv = env['DJANGO_']

class Settings(BaseSettings):

    DEBUG = denv(True)  # Will look for DJANGO_DEBUG in env

Now setting DJANGO_DEBUG=f will disable debug mode.