Search code examples
pythonconfiginipython-dataclasses

Python dataclass attributes monkeypatching


I have problem with my class Config, that is works as proxy between user and ini file. It can load parameters from ini files and set them to its name equivalent in dataclass. I've realized, that it I want to get some attribute with dot like Config()._BASE_DIR, it returns str value, because ConfigParser can get values as a str. My idea is to create some method, which will patch all my attributes with property and property.setter to make possible to access dataclass attributes using dot, but wrap them with annotation classes, so, for example, Config()._minAR will return not 4.0 as string but as float.

Is my idea acceptable, or do I need to do it differently?

Config code parts:

import configparser
import pathlib
from dataclasses import dataclass
from itertools import zip_longest


@dataclass
class Config:
    _IGNORE_FIELDS = {'_IGNORE_FIELDS' ,'_CONF_PARSER'}

    _CONF_PARSER: configparser.ConfigParser = configparser.ConfigParser()

    _BASE_TABLE_FILE_SUFFIX: str = '.csv'

    _BASE_DIR: pathlib.Path = pathlib.Path().absolute()
    _CONF_PATH: pathlib.Path = _BASE_DIR / 'conf'
    _CONF_FILE_PATH: pathlib.Path = _CONF_PATH / 'settings.ini'
    _DATA_TABLE_PATH: pathlib.Path = _CONF_PATH / ('_data_table' + _BASE_TABLE_FILE_SUFFIX)

    _minAR: float = 4.0
    _maxAR: float = 5.0

    CATCH_TIME: int = 6

    def __init__(self) -> None:
        self.prepare()

    def check_synchronized(self) -> tuple[bool, str]:
        if not self.CONF_PARSER.has_section('settings'):
            return False, 'ini'

        parser_config = self.CONF_PARSER['settings'].items()
        python_config = {
            k: v
            for k, v in self.__dataclass_fields__.items()
             if k not in self._IGNORE_FIELDS
        }.items()

        for pair_1, pair_2 in zip_longest(python_config, parser_config, fillvalue=(None, None)):
            key_1, val_1 = pair_1
            if key_1 is None:
                return False, 'script'

            key_2, val_2 = pair_2
            if key_2 is None:
                return False, 'ini'

            if key_2 in self._IGNORE_FIELDS:
                continue
            if key_1.lower() != key_2.lower() or (default := str(val_1.default)) != val_2:
                mode = 'ini' if default != str(getattr(self, key_1)) else 'script'
                return False, mode
        return True, 'both'

    def updateFromIni(self):
        for key, value in self.CONF_PARSER['settings'].items():
            upper_key = key.upper()
            if str(getattr(self, upper_key)) == value:
                continue

            setattr(self, upper_key, value)

    def prepare(self):
        self._createConfDir()

        is_sync, mode = self.check_synchronized()

        if is_sync:
            return

        if mode == 'ini' or mode == 'both':
            self._writeAll()
        elif mode == 'script':
            self.updateFromIni()

    def _writeAll(self):
        if not self.CONF_PARSER.has_section('settings'):
            self.CONF_PARSER.add_section('settings')

        for key, field in self.__dataclass_fields__.items():
            if key in self._IGNORE_FIELDS:
                continue
            self.CONF_PARSER.set('settings', key, str(field.default))

        self._writeInFile()

    def _writeInFile(self):
        with open(self.CONF_FILE_PATH, 'w') as file:
            self.CONF_PARSER.write(file)

    def _createConfDir(self) -> None:
        if not self.CONF_PATH.exists():
            self.CONF_PATH.mkdir(parents=True, exist_ok=True)

    def setValue(self, field, value):
        if not hasattr(self, field) or field in self._IGNORE_FIELDS:
            return

        setattr(self, field, value)
        if not isinstance(value, str):
            value = str(value)
        self.CONF_PARSER.set('settings', field, value)
        self._writeInFile()

More context: I use dataclass with configParser to make my Config class able to do the following things:

  1. Sync attributes with ini file (if no ini file, create it from Config structure with default values; if Config not syncronized with ini file, load from ini, and write to ini, it ini-file has wrong structure, or some values are incorrect) to avoid the situation, when user accidentally delete ini file;
  2. Set and Get all existing values in config from any part of my program (it is PyQt6 application);
  3. Save it state from one session (application run) to another.

So, I had no idea, what other structure of config class I should have used, except for this. If you have better idea for synchronizable config, tell me.


Solution

  • I've discovered, that only one change, that I need to make my Config class make custom dot access to attributes, is to write custom magic method __getattribute__ in my class.

    result:

    import configparser
    import pathlib
    from dataclasses import dataclass
    from itertools import zip_longest
    from typing import Any
    
    
    ACCESS_FIELDS = {
        'BASE_TABLE_FILE_SUFFIX', 'BASE_DIR', 'CONF_PATH', 'CONF_FILE_PATH',
        'DATA_TABLE_PATH', 'minAR', 'CATCH_TIME'
    }
    
    
    class Config:
        # some code ...
    
        def __getattribute__(self, __name: str) -> Any:
            if __name == 'ACCESS_FIELDS':
                return ACCESS_FIELDS
    
            attr = super().__getattribute__(__name)
    
            if __name in ACCESS_FIELDS:
                _type = self.__annotations__[__name]
                return _type(attr)
            return attr
    
        # other code ...
    

    I created variable with accessed fields not in class body, because in other cases, if I get ACCESS_FIELDS by using Config.ACCESS_FIELDS or self.ACCESS_FIELDS, it will call __getattrubute__ method again and cause recursion error.

    Basically, I got all what I need by using this solution, but I still has problem with setValue method. I've discovered, that __setattr__ overriden method works not so good with __getattribute__ overriden method in my class (it cause recursion error too). Probably, I'll restructure my Config class, but not now.