Zwei Config Dateien, Werte bevorzugen

Wenn du dir nicht sicher bist, in welchem der anderen Foren du die Frage stellen sollst, dann bist du hier im Forum für allgemeine Fragen sicher richtig.
Antworten
anonym12

Hallo,

ich möchte für mein Programm eine Config Datei im TOML Format erstellen.
Genauer gesagt zwei Dateien: Eine Default Config und eine User Config (welche die Default Werte überschreibt).
Also wenn ein Wert in der User Datei vorhanden ist, dann soll dieser genommen werden, ansonsten wird der Wert aus der Default Datei geholt.

Die TOML Datei wird beim einlesen ein Dict. Heißt ich kann die Werte theoretisch ganz easy erreichen.
Um das ganze Konstrukt an verschiedenen Stellen einfach zu integrieren würde ich dafür gerne eine Klasse schreiben.

Zum lesen eines Wertes aus der Datei soll die Methode .get() dienen.
Zum schreiben .set()

.get() hat bereits super funktioniert. Dann habe ich try excepts eingebaut und es ging nicht mehr.
Dann habe ich es mit zwei weiteren unterschiedlichen Anläufen probiert. Hat auch nicht funktioniert.
Habs mir quasi zerschossen. :D (Ja ich sollte git intensiver nutzen...)

.set() habe ich noch gar nicht hinbekommen. Aber ist jetzt mal außen vor, da bin ich noch nicht tiefer eingetaucht.
Wichtig wäre mir, dass .get() wieder ordentlich funktioniert.

Es wäre super hilfreich wenn mir hier jemand einen Ansatz aufzeigen könnte. Meine Denkansätze sind kreuz und quer.

Aktuell sieht die Klasse so aus:

Code: Alles auswählen

import os, sys, toml, logging


class Config():
    '''
    Ermöglicht einfachen Zugang zur Konfigurationsdatei.
    '''

    def __init__(self):
        FILENAME = 'config.toml'

        # Benutzer Config
        self.USER_DIR = os.path.join(os.environ['APPDATA'], 'MyAppName')
        self.USER_FILE = os.path.normpath(os.path.join(self.USER_DIR, FILENAME))
        # log.debug(f'Config().__init__ USER_FILE = {self.USER_FILE}')

        # Default Config
        self.DEFAULT_DIR = os.path.join(os.path.dirname(__file__), '..\\')
        self.DEFAULT_FILE = os.path.normpath(os.path.join(self.DEFAULT_DIR, FILENAME))
        # log.debug(f'Config().__init__ DEFAULT_FILE = {self.DEFAULT_FILE}')

    def _create_dir(self):
        '''
        Erstellt den Config Ordner falls dieser nicht existiert.
        '''

        if not os.path.isdir(self.USER_DIR):
            os.mkdir(self.USER_DIR)
            log.debug(f'Config Ordner {self.USER_DIR} wurde erstellt.')

    def _write_file(self, cfg):
        '''
        Schreibt eine Datei.
        Falls diese bereits vorhanden ist wird sie überschrieben.

        cfg = Config als Dict
        '''
        with open(self.USER_FILE, 'w') as file:
            toml.dump(cfg, file)

    def _nested_get(self, dic, keys):
        '''
        Gibt einen Wert aus einem verschachtelten Dict zurück.

        dic = Dict
        keys = ['key'] oder ['key', 'subkey']
        '''

        for key in keys:
            dic = dic[key]
        return dic

    def _load_file(self, file):
        cfg = toml.load(file)
        log.debug(f'Config().get() cfg = {file}')
        log.debug(f'Config().get() cfg = {str(cfg)}')

    def get(self, keys):
        '''
        Holt einen Wert aus der Konfigurationsdatei.
        Werte aus Benutzer Datei überschreiben Werte aus Default Datei.

        keys = ['key'] oder ['key', 'subkey']
        '''

        # Wenn User Config File existiert
        if os.path.isfile(self.USER_FILE) is True:

            # User Config laden
            cfg = self._load_file(self.USER_FILE)

            # Wert aus User Config holen
            try:
                return self._nested_get(cfg, keys)

            # Wert nicht in User Config vorhanden
            except KeyError:
                log.warning(f'Config().get() Wert für {keys} nicht in User Config vorhanden.')

                # Wenn Default Config File existiert
                if os.path.isfile(self.DEFAULT_FILE) is True:

                    # Default Config laden
                    cfg = self._load_file(self.DEFAULT_FILE)

                    # Wert aus Default Config holen
                    try:
                        return self._nested_get(cfg, keys)

                    # Wert nicht in Default Config vorhanden
                    except KeyError:
                        log.critical(f'Config().get() Wert für {keys} nicht in Default Config vorhanden.')
                        sys.exit(1)

                # Default Config nicht gefunden
                else:
                    log.critical('Config().get() Default Config File existiert nicht.')
                    sys.exit(1)

        # Wenn Default Config File existiert
        elif os.path.isfile(self.DEFAULT_FILE) is True:
            log.warning('Config().get() User Config File existiert nicht.')

            # Default Config laden
            cfg = self._load_file(self.DEFAULT_FILE)

            # Wert aus Default Config holen
            try:
                return self._nested_get(cfg, keys)

            # Wert nicht in Default Config vorhanden
            except KeyError:
                log.critical(f'Config().get() Wert für {keys} nicht in Default Config vorhanden.')
                sys.exit(1)

        else:
            log.critical('Config().get() Weder User noch Default Config File existiert.')
            sys.exit(1)

    def _nested_set(self, dic, keys, value):
        '''
        Schreibt einen Wert in ein verschachteltes Dict.
        Nicht vorhandene Keys werden erstellt.

        dic = Dict
        keys = ['key'] oder ['key', 'subkey']
        value = ?

        Siehe https://stackoverflow.com/a/37704379
        '''
        for key in keys[:-1]:
            dic = dic.setdefault(key, {})
        dic[keys[-1]] = value

    def set(self, keys, value):
        '''
        Schreibt einen Wert in die Konfigurationsdatei.

        keys = ['key'] oder ['key', 'subkey']
        value = ?
        '''

        # Versuche vorhandene User Config zu bearbeiten
        try:
            cfg = toml.load(self.USER_FILE)
            # debug print
            print('cfg: ' + str(cfg))
            cfg = self._nested_set(cfg, keys, value)
            self._write_file(cfg)

        # Neue User Config erstellen
        except Exception:
            # Config Ordner erstellen, falls er nicht existiert.
            self._create_dir()
            cfg = self._nested_set(cfg, keys, value)
            self._write_file(cfg)
            

if __name__ == '__main__':
    conf = Config()
    one = conf.get(['key', 'subkey', 'one'])
    print('ONE:    ' + str(one))
Und das ist der aktuelle Fehler:

Code: Alles auswählen

Traceback (most recent call last):
  File "C:\Users\MyName\Projekte\MyAppName\...\helpers.py", line 287, in <module>
    one = conf.get(['key', 'subkey', 'one'])
  File "C:\Users\MyName\Projekte\MyAppName\...\helpers.py", line 197, in get
    return self._nested_get(cfg, keys)
  File "C:\Users\MyName\Projekte\MyAppName\...\helpers.py", line 138, in _nested_get
    dic = dic[key]
TypeError: 'NoneType' object is not subscriptable
Falls notwendig, hier noch schnell die zwei Test Config Dateien:

Code: Alles auswählen

# User
[key]
    file = 'user'
    [key.subkey]
        two = 'rewrite by user'

# Default
[key]
    file = 'default'
    [key.subkey]
        one = 1
        two = 2
        three = 3

PS: Das ist so ziemlich die erste größere Klasse die ich schreibe und versuche halbwegs sinnvoll zu strukturieren.
Wäre super wenn da vielleicht auch nochmal jemand ne Rückmeldung geben könnte, ob das so in etwa in Ordnung ist, oder was man besser machen könnte.

Vielen lieben Dank im voraus! :)
nezzcarth
User
Beiträge: 1764
Registriert: Samstag 16. April 2011, 12:47

Hilft dir vielleicht collections.ChainMap weiter?

Beispiel:

Code: Alles auswählen

In [1]: from collections import ChainMap

In [2]: default = {'a': 1, 'b': 2}

In [3]: custom = {'a': 10, 'c': 3}

In [4]: config = ChainMap(custom, default)

In [5]: config['a']
Out[5]: 10

In [6]: config['b']
Out[6]: 2

In [7]: config['c']
Out[7]: 3
anonym12

Ja das sieht so aus als würde es die Sache um einiges einfacher machen. :D
Vielen Dank für den Tipp.

Ich werde es aber erst morgen ausprobieren und gebe dann eine Rückmeldung.
anonym12

Leider kann ich meinen vorherigen Beitrag nicht editieren.
Ich habe jetzt nochmal mit klarem Kopf von ganz vorne angefangen:

Code: Alles auswählen

import os
import sys
import toml
from collections import ChainMap
from helpers import Logger

log = Logger('config_test')
log.info('### NEW RUN ' + 50 * '#')

###############################################################################


class Config():
    def __init__(self):
        filename = 'config.toml'

        # Benutzer Datei
        user_dir = os.path.join(os.environ['APPDATA'], 'MyAppName')
        self.user_file = os.path.normpath(os.path.join(user_dir, filename))

        # Standard Datei
        default_dir = os.path.join(os.path.dirname(__file__), '..\\')
        self.default_file = os.path.normpath(os.path.join(default_dir, filename))

    def _load_cfg(self):

        try:  # Lade Default Config
            default_cfg = toml.load(self.default_file)
        except FileNotFoundError:
            log.critical(f'Default Config nicht gefunden: {self.default_file}')
            sys.exit(1)
        else:
            log.debug(f'Default Config geladen: {self.default_file}')
            log.debug(f'Default Config = {default_cfg}')

        try:  # Lade User Config
            user_cfg = toml.load(self.user_file)
        except FileNotFoundError:
            user_cfg = {}
            log.warning(f'User Config nicht gefunden: {self.user_file}')
        else:
            log.debug(f'User Config geladen: {self.user_file}')
            log.debug(f'User Config: {user_cfg}')

        # Merge
        return ChainMap(user_cfg, default_cfg)

    def __getitem__(self, item):
        return self._load_cfg()[item]


###############################################################################

if __name__ == '__main__':
    cfg = Config()

    print(cfg['key']['file'])
Funktioniert super!
anonym12

Öhh...

Ich wollte jetzt noch ein try except in __getitem__() einbauen:

Code: Alles auswählen

    def __getitem__(self, item):
        try:
            value = self._load()[item]
        except KeyError:
            log.error(f'Wert für {item} nicht gefunden.')
            sys.exit(1)
        else:
            log.debug(f'Wert {value} für {item} geholt.')
            return value
Aber das funktioniert nicht. Ich bekomm trotzdem den KeyError angezeigt.
Funktioniert das nicht innerhalb der dunder method? Oder was mache ich falsch? :shock:
Benutzeravatar
__blackjack__
User
Beiträge: 14055
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

@August: Bei jedem Zugriff die beiden Dateien zu laden ist ziemlicher Overkill. Und die Methode *sollte* eine Ausnahme auslösen statt einfach das gesamte Programm zu beenden. `sys.exit()` gehört *dort* ganz sicher nicht hin.

Die Umgebungsvariable APPDATA sieht sehr Windows-spezifisch aus. Und das "\\" gehört rt nicht in einen Pfadteil. Erstens ist das nicht notwendig und zweitens auch nicht portabel. In neuem Code würde ich auch `os.path` nicht mehr verwenden wenn man etwas mit `pathlib` erledigen kann.
“Vir, intelligence has nothing to do with politics!” — Londo Mollari
anonym12

Wenn die Config Datei nicht gefunden wird, dann kann der API Key nicht gelesen werden und das Programm kann seine Aufgabe gar nicht weiter erfüllen. Deshalb erschien es mir sinnvoll dort einfach das Programm zu beenden.
edit: Aber vermutlich sollte ich dass dann an anderen Stellen im Programm einbauen und nicht direkt in der Funktion von dieser Klasse. Denn

Ja es ist Windows spezifisch. Und da das Programm ausschließlich auf Windows genutzt werden soll (und kann) muss es nicht portabel sein. (Ist ein Excel Plugin)

Warum gehört "\\" nicht in einen Pfad? Den Backslash muss ich doch escapen. Und dass Windows Backslashes anstatt normalen nutzt - dafür kann ich auch nix. :D
Die Alternative wäre einen raw String r"C:\Pfad\..." zu nutzen. Oder worauf wolltest du hinaus?

Dass Pathlib generell zu bevorzugen ist habe ich schon mitbekommen. Aber Danke nochmal für den Hinweis an dieser Stelle.
Ich habe os verwendet, weil pathlib keinen Zugang zu Umgebungsvariablen bietet (soweit ich das gesehen hab). Deshalb müsste ich dafür sowieso os.environ nutzen.
Und ich dachte mir es wäre dann schlauer gleich alles damit zu machen anstatt beides zu importieren obwohl es mehr oder weniger die gleichen Funktionen bietet.
Hab immer das Mindset "weniger importieren ist besser". Ob das nun stimmt oder nicht - keine Ahnung. :D

Dass es unnötig ist die Dateien bei jedem Zugriff zu lesen - da gebe ich dir vollkommen Recht.
Besser wäre wahrscheinlich sie nur einmal beim Programmstart zu lesen, oder?
Benutzeravatar
__blackjack__
User
Beiträge: 14055
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

@August: Ich wollte im Grunde auf zwei Dingen hinaus: Einmal das "\" wieder nur Windows ist (obwohl auch unter Windows an der Stelle "/" gehen würde) und zum zweiten, dass das da nicht hingehört. Es macht keinen Sinn. Mach das weg und es ändert sich sich genau gar nichts am Verhalten des Programms.

Wobei auch das ".." davor nicht portabel ist. Dafür gibt es die Konstante ``os.pardir``. Beziehungsweise bei `pathlib.Path` das `parent`-Attribut.

Weniger importieren ist nicht automatisch besser. Wenn man dafür Sachen selber programmiert die es bereits gibt, oder eine schlechter lesbare API verwendet, was ist dadurch gewonnen? Zumal wir hier auch noch von der Standardbibliothek reden, die sowieso da ist.
“Vir, intelligence has nothing to do with politics!” — Londo Mollari
Antworten