Table of contents
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 not 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.