Manage Django settings with Pydantic - Part 2

Ignition

In the previous part, we manage to use pydantic BaseSettings object to store common django settings.

In this article, we use all the power of pydantic to handle common use-cases.

Using environment variables

In practice, we are likely to deploy our django app through a container. In this context, we do not have direct access to the settings.py file.

A solution is to mount a volume with a local settings file like:

docker run -it -v ./settings.py:/path/to/app/settings.py my-app-image:0.1

Actually it has two drawbacks:

  • all the settings are exposed so we must trust the user not to do anything wrong

  • run and deploy phases are not well separated. Let us assume that the dev and the devops are two different persons: the latter has to know python/django and understand the dev's logic... it does not sound good! Generally, we must avoid changing the code of a deployed application because it breaks its integrity (two users are likely not to have the same code while having the same app image) and its stability (think of a crash during a hot reload).

So, we commonly use environment variables (along with hacks) like in the following example.

# settings.py

DEBUG: bool = os.environ.get("APP_DEBUG", "0").lower() in ["1", "true", "yes"]

and we can pass it to the container

docker run -it -e "APP_DEBUG=1" my-app-image:0.1

Ok but we have a pydantic object, so we do need all these hacks anymore as everything is handled by the library. Let us improve the settings.py file to use environment variables.

# settings.py
from typing import Any, List
from pydantic import BaseSettings, Field

class DjangoSettings(BaseSettings):
    """Manage all the project settings"""
    DEBUG: bool = Field(True, env="DEBUG") # declare as a Field
    TIME_ZONE: str = Field("Europe/Paris", env="TIME_ZONE")
    ALLOWED_HOSTS: List[str] = []

    class Config: # extra class to configure the parent object
        env_prefix = "APP_"
        case_sensitive = True

_settings = DjangoSettings() 
_settings_dict = _settings.dict()

def __dir__() -> List[str]:
    """The list of available options are retrieved from 
    the dict view of our DjangoSettings object.
    """
    return list(_settings_dict.keys())

def __getattr__(name: str) -> Any:
    """Turn the module access into a DjangoSettings access"""
    return _settings_dict[name]

Both DEBUG and TIME_ZONE has been turned into a pydantic Field object that is able to retrieve value from environment variable (env=... option). In this example, we can change both settings with APP_DEBUG and APP_TIME_ZONE respectively (notice the env_prefix attribute). In addition, it will respect case sensitivity.

You can look at the pydantic documentation to see all the possibilities.

Nested settings

Currently, DjangoSettings can manage "flat" attributes. Indeed, nothing prevents us from using dict attributes. Here is an example with the DATABASES settings.

# settings.py
from typing import Any, Dict, List
from pydantic import BaseSettings, Field

class DjangoSettings(BaseSettings):
    """Manage all the project settings"""
    DEBUG: bool = Field(True, env="DEBUG")
    TIME_ZONE: str = Field("Europe/Paris", env="TIME_ZONE")
    ALLOWED_HOSTS: List[str] = []
    DATABASES: Dict[str, List[str]] = {  # nested example
      "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "db.sqlite3"}
    }  

    class Config:
        env_prefix = "APP_"
        case_sensitive = True

The burning issue is that we cannot use environment variables to set "deep" settings (i.e. settings inside a structure like set, list or dict).

To use the same logic as DjangoSettings, we can create other BaseSettings object to manage database settings.

# settings.py
from typing import Any, Dict, List
from pydantic import BaseSettings, Field

class SQLiteSettings(BaseSettings):
    """Manage sqlite settings only"""
    ENGINE: str = "django.db.backends.sqlite3"
    NAME: str = Field("db.sqlite3", env="NAME")

    class Config:
        env_prefix = "APP_DATABASE_SQLITE_"
        case_sensitive = True

class DatabaseSettings(BaseSettings):
    """Manage databases settings"""
    default: SQLiteSettings = SQLiteSettings()

class DjangoSettings(BaseSettings):
    """Manage all the project settings"""
    DEBUG: bool = Field(True, env="DEBUG")
    TIME_ZONE: str = Field("Europe/Paris", env="TIME_ZONE")
    ALLOWED_HOSTS: List[str] = []
    DATABASES: DatabaseSettings = DatabaseSettings()

    class Config:
        env_prefix = "APP_"
        case_sensitive = True

We finally have split the DATABASES setting into nested BaseSettings objects. Beautiful! But the very nice feature is that everything remains transparent for the developer, i.e. he/she does not have to modify the codebase.

from django.conf import settings

if settings.DEBUG:
    print(settings.DATABASES["default"]["NAME"])

Split settings

When the app grows, we are likely to have dozens of settings. Putting everything in this single settings.py file makes it hard to maintain. The simple idea is just to split the settings into several files, according to their topic.

This is a rather old issue, commonly solved by turning the settings into a subpackage, but here instead of writing several import * from ..., we will use the DjangoSettings object. Here is the new layout:

app/
  settings/
    __init__.py
    database.py
    settings.py

Instead of a single settings.py file, we define the settings package. In the settings/settings.py file, we put the base object that will be responsible to import other settings:

# settings/settings.py
from typing import Any, List
from pydantic import BaseSettings, Field

from .database import DatabaseSettings

class DjangoSettings(BaseSettings):
    """Manage all the project settings"""
    DEBUG: bool = Field(True, env="DEBUG")
    TIME_ZONE: str = Field("Europe/Paris", env="TIME_ZONE")
    ALLOWED_HOSTS: List[str] = []
    DATABASES: DatabaseSettings = DatabaseSettings()

    class Config:
        env_prefix = "APP_"
        case_sensitive = True

The settings/database.py gathers... hum, well the database settings :)

# settings/database.py
from pydantic import BaseSettings, Field

class SQLiteSettings(BaseSettings):
    """Manage sqlite settings only"""
    ENGINE: str = "django.db.backends.sqlite3"
    NAME: str = Field("db.sqlite3", env="NAME")

    class Config:
        env_prefix = "APP_DATABASE_SQLITE_"
        case_sensitive = True

class DatabaseSettings(BaseSettings):
    """Manage databases settings"""
    default: SQLiteSettings = SQLiteSettings()

Finally, the settings/__init__.py manages only the access to the DjangoSettings (no more import * or ugly local importlib calls).

# settings/__init__.py
from typing import Any, List

from .settings import DjangoSettings

_settings = DjangoSettings() 
_settings_dict = _settings.dict()

def __dir__() -> List[str]:
    """The list of available options are retrieved from 
    the dict view of our DjangoSettings object.
    """
    return list(_settings_dict.keys())

def __getattr__(name: str) -> Any:
    """Turn the module access into a DjangoSettings access"""
    return _settings_dict[name]

Looks great, doesn't it?

Finally, we can use all the power of pydantic to manage django app settings. Moreover, it definitely cleans our codebase (removing for example the os.environ.get and the import *).

In the next part, we will dig into more advanced topics.