DEV Community

qoqosz
qoqosz

Posted on

Config class aware of your Python application changes

Minimalistic approach

When my application requires a centralized place for a config, I usually start with a dedicated file holding a dict() object:

# file: config.py
config = dict()
Enter fullscreen mode Exit fullscreen mode

The usage across the application is really simple - import and use it:

# file: app.py
from config import config

config['db'] = 'uat01'
config.update(host='localhost', username='user', 
              password='secret pass')
Enter fullscreen mode Exit fullscreen mode

Such config has a nice property - it is a singleton without any implementation tricks.

However, I don't find it particularly handy to use dict syntax of accessing elements with []. Quick fix would be to either:

  • 1. Create aliases for __*attr__ methods:
# file: config.py
class Config(dict):
    __getattr__ = dict.get
    __setattr__ = dict.__setitem__
    __delattr__ = dict.__delitem__

config = Config()
Enter fullscreen mode Exit fullscreen mode
  • 2. Use SimpleNamespace from types however, it lacks update() equivalent method.

When things are changing

Let's stick to the first choice and have a look at the following scenario:

# file: app.py
from config import config
from types import Optional

def fibonacci(
    count: int,
    first: Optional[int] = None,
    second: Optional[int] = None
) -> int:
    a, b = first or config.first, second or config.second
    for _ in range(count):
        a, b = b, a + b
    return a

# file: main.py
from app import fibonacci
from config import config

if __name__ == '__main__':
    config.update(first=1, second=1)
    print(f'{fibonacci(6)=}')

    config.update(first=3, second=5)
    print(f'{fibonacci(6)=}')
Enter fullscreen mode Exit fullscreen mode

The result won't change between two calls to our fibonacci function.

fibonacci(6)=8
fibonacci(6)=8
Enter fullscreen mode Exit fullscreen mode

Make it aware of the changes

# file: config.py
import importlib
import inspect

class Config:
    _data = dict()
    _referrers = set()

    def __getattr__(self, name):
        caller = inspect.currentframe().f_back
        module_name = caller.f_globals['__name__']

        if '__main__' not in module_name:
            self._referrers.add(module_name)

        try:
            return self._data[name]
        except KeyError:
            raise AttributeError('no attr %s', name)

    def __setattr__(self, name, value):
        self._data[name] = value

    def _reload_modules(self):
        for module_name in self._referrers.copy():
            module = importlib.import_module(module_name)
            importlib.reload(module)

    def __repr__(self):
        return 'Config(%r)' % self._data

    def update(self, reload=True, **kwargs):
        for k, v in kwargs.items()
            self.__setattr__(k, v)

        if reload:
            self._reload_modules()

config = Config()

# set defaults
config.update(first=0, second=1)
Enter fullscreen mode Exit fullscreen mode

Now, running main.py yields the following result:

fibonacci(6)=8
fibonacci(6)=55
Enter fullscreen mode Exit fullscreen mode

What's going on?

Ok, the newly defined Config class is a bit lengthy and requires some explanation.

Let's go through the code:

import importlib
import inspect
Enter fullscreen mode Exit fullscreen mode

These two imports will come in handy in a minute, bare with me.

class Config:
    _data = dict()
    _referrers = set()
Enter fullscreen mode Exit fullscreen mode

The Config class encapsulates two elements: 1. _data, the actual key-value container and 2. _referrers, the set of other modules that import config and access its entries.

    def __getattr__(self, name):
        caller = inspect.currentframe().f_back
        module_name = caller.f_globals['__name__']

        if '__main__' not in module_name:
            self._referrers.add(module_name)
Enter fullscreen mode Exit fullscreen mode

Custom __getattr__ enables dot access syntax. Additionally, it is the moment in which I record modules that refer to config. When config is updated, it will allow me to let those modules know that something has changed and it is time to reload.

The rest is pretty straightforward. In:

    def _reload_modules(self):
        for module_name in self._referrers.copy():
            module = importlib.import_module(module_name)
            importlib.reload(module)
Enter fullscreen mode Exit fullscreen mode

I loop over related modules and reload them.

Finally, at the end of the file, I have to set default values for entries that are used in other parts of the code. The other option would be to load a config file and parse it.

Also, by design I decided that only call to update refresh referred modules. Setting them one by one with __setattr__ does not have this behavior.

What is your approach to config?

Top comments (0)