Python settings revisited

So, earlier I mucked about with pydantic, and then some plain dataclasses for the sake of making a settings object. Why should you? Well.. it's darn snazzy! And further more it helps in that you can compartmentalize your settings as you app grows.

I outlined how you could use dotenv to load secrets into your environment and populate a settings object. This time I'm going to expand on the settings object, and I will show how to add the settings object to django.

💡
In a django application your environment is typically defined either in your kube.yaml file or docker_compose.yaml file.. The first is Podman/kubernetes and the second is podman/docker. And if you are not using containers.. you really should.

Below is a non-typical django settings structure. Typically, when you start a project and add an application the application will contain a settings.py file. This is fine, for the most part. However - I tend to build monoliths, this after years and years of micro services. I've learned my lesson! Monoliths it is (I do of course also use micro services, only they are not so micro any more). Monoliths tend to acquire a lot of applications. Mine does anyway, and therefore I also tend to structure my settings in the way shown below.

<project>/<app>/settings
├── apps
│   ├── bcentral_settings.py
│   ├── some_settings.py
│   ├── __init__.py
│   ├── sso_settings.py
|   |── logging_settings.py
│   └── other_settings.py
├── base.py
└── __init__.py

base.py is more or less the just the settings.py file. The __init__.py file looks like this:

$ cat __init__.py
from .base import *
from bcentral_settings.py import *
from .apps.some_settings import *
from .apps.sso_settings import *
from .apps.logging_settings import *
from .apps.other_settings import *

This means that everything(yeah...) defined in these files will be read into the settings namespace. For example, logging_settings.py contains a dictionary

LOGGING = {
    "version": 1, 
    ....
}

I just prefer to have the logging in a separate configuration file. There's no magic here.. other than the magic that django introduces by automagically configuring the logger when it encounters LOGGING.. still. It's not like it's Galtworth or something.

We are just going to look at bcentral_settings.py

Let's start with some code.

"""A settings class."""
import os
from dataclasses import dataclass, field
from django.core.exceptions import ValidationError

__all__ = ["BCENTRAL"]

PREFIX = "BCENTRAL_"  # the environment variables are prefixed

@dataclass
class BCentralSettings:
    CLIENT_ID: str = field(default="")
    CLIENT_SECRET: str = field(default="")
    TENANT_ID: str = field(default="")
    API_URL: str = field(default="")
    AUTH_ENDPOINT: str = "https://login.microsoftonline.com//oauth2/v2.0/token"
    COMPANY_UUID: str = field(default="")
    SCOPE: list[str] = field(default_factory=lambda: [
        "https://api.businesscentral.dynamics.com/.default"
        ])

So far pretty normal. We are going to add a __post_init__ magic method to the class. In this __post_init__ we will build the mechanics for populating the class with environment variables, and we will add validation mechanics. The validation is no different from what you'd normally do in django (or any other framework / program). https://docs.djangoproject.com/en/5.0/ref/validators/

    def __post_init__(self):
        for name, _field in self.__dataclass_fields__.items():
            if match := os.getenv(f"{PREFIX}{name}"):
                setattr(self, name, match)
            if method := getattr(self, f"validate_{name}", None):
                setattr(self, name, method(getattr(self, name), field=_field))

This code might warrant some explanation. In this implementation of __post_init__ we are looping over all the fields in the dataclass and can do whatever we want with them. First, we try to match an environment variable to a name in the dataclass. I tend to keep one flat file of environment variables and distinguish between them I use a prefix that tie into the settings object. In this case PREFIX = "BCENTRAL_"

The second if statement looks for a metod named validate_NAME in the class.

    def validate_CLIENT_ID(self, value: str, field=field) -> str:
        if not value:
            raise ValidationError(f"{field.name} is required")
        return value

And so on and so forth. You could also make sure value is a valid UUID, has fins and lives in the Gobi desert.. if that's your thing.

After all this, make an instance of your object

BCENTRAL = BCentralSettings()

Do this at the end of bcentral_settings.py. As you might have noticed __all__ = ["BCENTRAL"] is used. This ensures that settings is not polluted with variables from our file.

💡
Note: all affects the from <module> import * behavior only. Members that are not mentioned in all are still accessible from outside the module and can be imported with from <module> import <name>.

Access your settings:

$ python <project>/manage.py shell
In [1]: from django.conf import settings
In [2]: settings.BCENTRAL.CLIENT_ID
'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
In [3]: settings.PREFIX
.
.
AttributeError: 'Settings' object has no attribute 'PREFIX'

That's about it. Easy configuration management for, in this instance, django.

Until next time..