Bonjour

parfois, nos scripts ont besoin d'être configurables et existe plusieurs techniques pour passer ces paramètres, mais existe aussi plusieurs niveaux où nous pouvons passer ces paramètres.


Pour simplifier l'exemple, je vais partir d'un script qui a en configuration une couleur pour mettre en évidences certains textes.

Le cas probable où nous pouvons trouver le plus de cas d'utilisation est sans doute un script open-source:
1) Le développeur défini une couleur par défaut
2) Le packageur va définir une autre couleur qui correspond à sa distribution
3) Chaque utilisateur du même système peut re-définir sa propre couleur (durablement ou ponctuellement)


Résolution du problème :

1)
Nous en avons l'habitude, nous écrivons dans le code source la configuration.

2)
Il faut donc sauvegarder dans le système cette configuration. Si il n'existe pas, alors nous le créons avec les réglages du codeur. Le plus habituel est le fichier de type .ini car il est relativement simple à modifier.
Un fichier .json n'est pas une bonne idée parce qu'un utilisateur non avancé a de fortes chances de casser ce format lorsqu'il va éditer ce fichier.
Ce fichier est stocké dans le système avec l'application pour windows et sous linux normalement dans /etc/.
Existe plusieurs bibliothèques pour lire ce type de fichiers avec python. Note: Le code exemple va utiliser la nouvelle librairie intégrée dans python 3.11.

3)
Pour changer durablement au niveau utilisateur, il faut en conséquence avoir ce même fichier dans son espace utilisateur (fichier optionnel).

Et si un utilisateur désire un changement temporaire ? Alors, il faut qu'il puisse le passer directement à notre application.
Existe plusieurs façons, mais puisque nous sommes gentils, nous allons en donner la possibilité à notre utilisateur.
1) il peut passer des paramètres au script (--theme.color=rouge, syntaxe exemple) si c'est un changement ponctuel
2) il peut utiliser des variables d'environnement
3) pour passer beaucoup de paramètres au script, il peut éventuellement injecter un fichier de configuration dans stdin


Ce cahier des charges correspond à notre demande, une application hautement configurable et à tous niveaux. Et au final, l'utilisateur gagne et a plusieurs choix en fonction de ces attentes.





Chaque technique individuellement est très simple à mettre en œuvre, il faut en fait simplement les combiner.

Dans la classe Contexte()
Code : Sélectionner tout - Visualiser dans une fenêtre à part
self.items = self.items | self.user_load() | self.env_load() | self.stdin_load() | self.params_load()
Cette ligne va déterminer quelle technique va écraser l'autre. Pour environnement, stdin et paramètres, c'est au codeur de donner une préférence, d'en supprimer ou même d'en ajouter.


Note :
Ce script n'est aucunement une bibliothèque, mais uniquement une compilation de techniques, un chainage "simple" pour toutes les proposer à l'utilisateur final.
Uniquement pour cette démo, Le fichier .ini "système" n'est pas dans le système


Résultat du script :
Code : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 
python main.py --theme.color OR --config.debug=5 
Configurations, détail:
 Système:  /home/Data/Patrick/workspace/python/configuration/override.conf/config système test/OverrideConf/config.ini 
    {'theme': {'color': 'red'}, 'config': {'debug': 1, 'lang': 'fr'}}
 
 Utilisateur:  /home/patrick/.config/OverrideConf.ini 
    {'theme': {'color': 'blue'}}
 
 StdIn:
        # `./main.py --config.debug=1 < test.ini`
    {}
 
 Passage de paramètres:
        # `./main.py --theme.color green ou ./main.py --theme.color=green`
    {'theme': {'color': 'OR'}, 'config': {'debug': '5'}}
 
########################
 
Merge les 4: paramètre du script écrase stdIn, stdIn écrase utilisateur et utilisateur écrase système :
{'theme': {'color': 'OR'}, 'config': {'debug': '5'}}
 
Pour notre thème, nous allons utiliser la couleur: OR pour mettre en évidence certains textes
```
Nous pouvons facilement voir, si debug est supérieur à 0 :
Que le packageur (ou dev si le fichier n'existait pas) avait choisi la couleur rouge et debug niveau 1.
L'utilisateur "patrick" a par défaut : couleur bleu.
Mais que pour cette fois, il a la couleur Or et un niveau "debug" égal à 5.

Code :
Code : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
#!/usr/bin/env python
 
import argparse
from pathlib import Path
import sys
import tomllib  # uniquement python 3.11
 
 
PACKAGE = "OverrideConf"    # nom de l'applications
 
 
class Contexte:
    """ on charge une configuration """
 
    SYSTEM_PATH = Path("/etc")      # valeur classique sous linux
    SYSTEM_PATH = Path(__file__).parent / "config système test" # uniquement pour la démo
 
    def __init__(self, /, auto_create=False):
        self.auto_create = auto_create    # éventuellemnt créer une arborescence
        self.items = self.system_load()
        self.items = self.items | self.user_load() | self.env_load() | self.stdin_load() | self.params_load()
 
    @property
    def file_system(self) -> Path:
        return self.SYSTEM_PATH / PACKAGE / "config.ini"
 
    @property
    def file_user(self) -> Path:
        return Path.home() / ".config" / f"{PACKAGE}.ini"
 
    def system_load(self) -> dict:
        """ configuration système dans fichier """
        config_file = self.file_system
        data = None
        if not config_file.exists():
            # CAS REEL : on n'a sens doute pas les droits pour créer le fichier
            config_file.parent.mkdir(parents=True, exist_ok=True)
            config_file.write_text(self._default_datas())
 
        with open(config_file, "rb") as f_conf:
            data = tomllib.load(f_conf)
        return data
 
    def user_load(self) -> dict:
        """ configuration utilisateur dans fichier """
        config_file = self.file_user
        data = {}
        if not config_file.exists():
            config_file.parent.mkdir(parents=True, exist_ok=True)
            config_file.write_text('[theme]\ncolor="blue"')
        with open(config_file, "rb") as f_conf:
            data = tomllib.load(f_conf)
        return data
 
    def params_load(self) -> dict:
        """ quelques variables passées au script """
        # on ne prend que si la variable existe dans fichier .ini
        # A réécrire si on désire plus de 2 niveaux...
        data = {}
        for key, item in self.items.items():
            for subitem in item.keys():
                key_param = f"--{key}.{subitem}"
                for i, param in enumerate(sys.argv):
                    if param.startswith(key_param):
                        if "=" in param:
                            # format: --truc=1
                            data.setdefault(key, {})
                            data[key][subitem] = param.split("=", maxsplit=2)[-1].strip()
                        else:
                            # format: --truc 1
                            try:
                                value = sys.argv[i+1].strip()
                                if not value.startswith("-"):
                                    data.setdefault(key, {})
                                    data[key][subitem] = value
                            except IndexError:
                                pass
 
        return data
 
    def stdin_load(self) -> dict:
        """ un fichier.ini passé en flux d'entrée au script """
        if not sys.stdin.isatty():
            file_data = sys.stdin.read()
            print("->", file_data)
            return tomllib.loads(file_data)
        return {}
 
    def env_load(self) -> dict:
        """ lecture des variables d'environnement"""
        # pratiquement même code que params_load() et même plus simple
        return {}
 
    @staticmethod
    def _default_datas() -> str:
        """ A titre de démo uniquement, il est plus logique d'avoir un dictionnaire par defaut"""
        return '[theme]\ncolor = "red"\n\n[config]\ndebug=1\nlang="fr"\n'
 
    def __call__(self, args: str):
        """ facon `simple` d'accéder aux valeurs """
        args = args.split(".")
        item = self.items
        for arg in args:
            try:
                item = item.get(arg)
            except AttributeError:
                break
        if isinstance(item, str):
            if item.isdigit():
                return int(item)
        return item
 
 
if __name__ == "__main__":
 
    configuration = Contexte(True)
    if configuration("config.debug"):
        # pour la démo
        print("Configurations, détail:")
 
        print(" Système: ", configuration.file_system, "\n   ", configuration.system_load())
        print()
 
        print(" Utilisateur: ", configuration.file_user, "\n   ", configuration.user_load())
        print()
 
        print(" StdIn:")
        print("\t# `./main.py --config.debug=1 < test.ini`")
        print("   ", configuration.stdin_load())
        print()
 
        print(" Passage de paramètres:")
        print("\t# `./main.py --theme.color green ou ./main.py --theme.color=green`")
        print("   ", configuration.params_load())
        print()
 
        print("#"*24)
        print()
        print("Merge les 4: paramètre du script écrase stdIn, stdIn écrase utilisateur et utilisateur écrase système :")
        print(configuration.items)
        print("\n")
 
 
    # nous pouvons aussi avoir des commandes...
    # pour distinction, ici, elles débutent par un seul tiret
    parser = argparse.ArgumentParser(prog=PACKAGE, description='app python hautement configurable')
    parser.add_argument('-a', '-add', type=int)
    parser.add_argument('-r', '-run', action='store_false')
    parser.add_argument('-s', '-supp', action='store_false')
    # eventuellement ajouter --theme.color pour uniquement la documentation et ne pas le traiter ici avec argparse puisque fait précédemment
    args, unknown = parser.parse_known_args()
    if args : print("Commandes à exécuter:", args)
 
    print()
    print()
    print("Pour notre thème, nous allons utiliser la couleur:", configuration("theme.color"), "pour mettre en évidence certains textes")