Environment driven config

Published:

Some years ago, I adapted the work of a coworker and released it as django-classy-settings, a way to define Django settings using a class, allowing a clean and easy way to derive local / production settings from a common base, and opening the way to allowing composition, too.

Recently I was faced with a similar problem at my current job, though not using Django this time.

Another big difference is my current job is using Python3.6 (and eager to move to 3.7 ASAP), opening other opportunities.

Setting the scene

They already had a config module that pulled values from the environment [for most of them], and pushed them through helper functions for casting types [like bools, ints, etc]

This would look something like:

USE_MAGIC = parse_bool(os.getenv('USE_MAGIC', False))

DATABASE_NAME = os.getenv('DATABASE_NAME', 'production-database-1')
DATABASE_PORT = int(os.getenv('DATABASE_PORT', 5432))

However, there were 2 projects sharing a common base, the base having its required settings, and the two others needing their own additional settings.

Anyone who's been at this game long enough knows what happened next: they diverged. The helper functions weren't shared, the settings value defaults weren't in sync, etc.

The "obvious" solution

So, the first idea was, simply, to move the shared settings into the base project, along with the helper functions.

This would work, and avoid most of the problems, but still leaves writing and reading the config to be somewhat laborious.

The game plan

So when it comes down to it, what we have is a list of names, types, and defaults.

We could write this as a list of tuples describing each, but there's a more Pythonic approach: type annotations.

Imagine if we could rewrite the above example as:

class Config:
    USE_MAGIC: bool = False
    DATABASE_NAME = 'testing-database-1'
    DATABASE_PORT: int = 5432

Wouldn't that be easy, and clear?

Enter confucius

So after a say of playing about [and some very welcome discussion and help from the people on Freenode IRC #python ] I wrote and published confucius.

The above code becomes:

from confucius import BaseConfig

class Config(BaseConfig):
    USE_MAGIC: bool = False
    DATABASE_NAME = 'testing-database-1'
    DATABASE_PORT: int = 5432

Accessing any of these attributes of the class will cause a lookup in os.getenv, falling back to the default. The resulting value is then passed to the "type".

Then the rest of my code can access the Config singleton ( gasp did he say singleton ? In Python?? )

from config import Config

db = connection(Config.DATABASE_NAME, port=Config.DATABASE_PORT)

Derived values

I'm not yet 100% happy with this step, but it's still clean. You can have values dependent on others:

class Config(BaseConfig):
    USE_MAGIC: bool = False

    def WIBBLE(self) -> int:
        return 1234 if self.USE_MAGIC else 4321

Not that to access Config.WIBBLE you treat it like an attribute, no need to call it.

And that's it?

Well, no, there's more!

A system that's not extensible is no fun. So, you can register your own type parsers!

import json

class Config(BaseConfig):
    __types__ = {
        json: lambda v: json.loads(v) if isinstance(v, str) else v
    }

    DATABASES: json = {'default': {'NAME': 'dummy', 'ENGINE': 'sqlite'}}

The __types__ dict will be merged with those of all parent classes [in MRO order] so you don't have to worry about redefining anything.

But wait, there's one more thing!

As a concession to how django-classy-settings worked, the class also provides an as_dict method to get all the attributes and values at once.

You can then use this in your django settings module:

import os

class Settings(BaseConfig):
    ....

globals().update(Settings.as_dict())