Config class aware of your Python application changes
Tags: python, programming
Published: 2022-09-27 20:08:16

## Minimalistic approach
When my application requires a centralized place for a config, I usually start with a dedicated file holding a `dict()` object:
```python
# file: config.py
config = dict()
```
The usage across the application is really simple - import and use it:
```python
# file: app.py
from config import config

config['db'] = 'uat01'
config.update(host='localhost', username='user', 
              password='secret pass')
```
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:
```python
# file: config.py
class Config(dict):
    __getattr__ = dict.get
    __setattr__ = dict.__setitem__
    __delattr__ = dict.__delitem__

config = Config()
```

- 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:

```python
# 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)=}')
```
The result won't change between two calls to our `fibonacci` function.
```
fibonacci(6)=8
fibonacci(6)=8
```

## Make it aware of the changes
```python
# 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)
```
Now, running `main.py` yields the following result:
```
fibonacci(6)=8
fibonacci(6)=55
```

## What's going on?
Ok, the newly defined `Config` class is a bit lengthy and requires some explanation.

Let's go through the code:

```python
import importlib
import inspect
```
These two imports will come in handy in a minute, bare with me.

```python
class Config:
    _data = dict()
    _referrers = set()
```
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.

```python
    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)
```
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:
```python
    def _reload_modules(self):
        for module_name in self._referrers.copy():
            module = importlib.import_module(module_name)
            importlib.reload(module)
```
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?