-
Notifications
You must be signed in to change notification settings - Fork 14
Expand file tree
/
Copy pathconfig.py
More file actions
302 lines (255 loc) · 10.9 KB
/
Copy pathconfig.py
File metadata and controls
302 lines (255 loc) · 10.9 KB
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
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
"""Read the bot configuration from the settings.yaml and the autoreplies.yaml files"""
import logging
import os
import re
from importlib import resources
from typing import Any, Iterable, Literal
import yaml
SettingsKeys = Literal["debug", "post", "test", "token", "bot_tag"]
SettingsDebugKeys = Literal[
"local_log",
"reset_on_load",
"log_file",
"log_error_file",
"db_file",
"backup_chat_id",
"backup_keep_pending",
"crypto_key",
"zip_backup",
]
SettingsPostKeys = Literal[
"community_group_id",
"channel_id",
"channel_tag",
"comments",
"admin_group_id",
"n_votes",
"remove_after_h",
"report",
"report_wait_mins",
"replace_anonymous_comments",
"delete_anonymous_comments",
"blacklist_messages",
"max_n_warns",
"warn_expiration_days",
"mute_default_duration_days",
"autoreplies_per_page",
"reject_after_autoreply",
]
SettingsKeysType = Literal[SettingsKeys, SettingsPostKeys, SettingsDebugKeys]
AutorepliesKeysType = Literal["autoreplies"]
logger = logging.getLogger(__name__)
class Config:
"""Configurations"""
DEFAULT_SETTINGS_PATH = str(resources.files("spotted") / "config" / "yaml" / "settings.yaml")
DEFAULT_AUTOREPLIES_PATH = str(resources.files("spotted") / "config" / "yaml" / "autoreplies.yaml")
__instance: "Config | None" = None
SETTINGS_PATH = "settings.yaml"
AUTOREPLIES_PATH = "autoreplies.yaml"
@classmethod
def reload(cls, force_reload: bool = False):
"""Reset the configuration.
The next time a setting parameter is required, the whole configuration will be reloaded.
If force_reload is True, the configuration will be reloaded immediately.
Args:
force_reload: if True, the configuration will be reloaded immediately
"""
cls.__instance = None
if force_reload:
cls.__get_instance()
@staticmethod
def __get(config: dict, *keys: str, default: Any = None) -> Any:
"""Get the value of the specified key in the configuration.
If the key is a tuple, it will return the value of the nested key.
If the key is not present, it will return the default value.
Args:
config: configuration dict to search
key: key to search
default: default value to return if the key is not present
Returns:
value of the key or default value
"""
for k in keys:
if isinstance(config, Iterable) and k in config:
config = config[k]
else:
return default
return config
@classmethod
def __get_instance(cls) -> "Config":
"""Singleton getter
Returns:
instance of the Config class
"""
if cls.__instance is None:
cls.__instance = cls()
return cls.__instance
@classmethod
def post_get(cls, key: SettingsPostKeys, default: Any = None) -> Any:
"""Get the value of the specified key in the configuration under the 'post' section.
If the key is not present, it will return the default value.
Args:
key: key to get
default: default value to return if the key is not present
Returns:
value of the key or default value
"""
return cls.settings_get("post", key, default=default)
@classmethod
def debug_get(cls, key: SettingsDebugKeys, default: Any = None) -> Any:
"""Get the value of the specified key in the configuration under the 'debug' section.
If the key is not present, it will return the default value.
Args:
key: key to get
default: default value to return if the key is not present
Returns:
value of the key or default value
"""
return cls.settings_get("debug", key, default=default)
@classmethod
def settings_get(cls, *keys: SettingsKeysType, default: Any = None) -> Any:
"""Get the value of the specified key in the configuration.
If the key is a tuple, it will return the value of the nested key.
If the key is not present, it will return the default value.
Args:
key: key to get
default: default value to return if the key is not present
Returns:
value of the key or default value
"""
instance = cls.__get_instance()
return cls.__get(instance.settings, *keys, default=default)
@classmethod
def autoreplies_get(cls, *keys: AutorepliesKeysType, default: Any = None) -> dict:
"""Get the value of the specified key in the autoreplies configuration dictionary.
If the key is a tuple, it will return the value of the nested key.
If the key is not present, it will return the default value.
Args:
key: key to get
default: default value to return if the key is not present
Returns:
value of the key or default value
"""
instance = cls.__get_instance()
return cls.__get(instance.autoreplies, *keys, default=default)
@classmethod
def override_settings(cls, config: dict):
"""Overrides the settings with the configuration provided in the config dict.
Args:
config: configuration dict used to override the current settings
"""
instance = cls.__get_instance()
cls.__merge_settings(instance.settings, config)
def __init__(self):
if type(self).__instance is not None:
raise RuntimeError("This class is a singleton!")
# First, load the default configuration provided from the package
self.settings = self.__load_configuration(self.DEFAULT_SETTINGS_PATH)
self.autoreplies = self.__load_configuration(self.DEFAULT_AUTOREPLIES_PATH)
# Then update the current configuration by loading the one provided by the user, if present
# At least the settings file must exists, since we need to know the token
self.__merge_settings(self.settings, self.__load_configuration(self.SETTINGS_PATH))
self.__merge_settings(self.autoreplies, self.__load_configuration(self.AUTOREPLIES_PATH))
# Read the environment configuration, if present, and override the settings
self.__read_env_settings()
# Validate the types of the settings
self.__validate_types_settings()
self.__log_errors_loaded_config()
@classmethod
def __merge_settings(cls, base: dict, update: dict) -> dict:
"""Merges two configuration dictionaries.
Args:
base: dict to merge. It will be modified
update: dict to merge with
Returns:
merged dictionaries
"""
for key, value in update.items():
if isinstance(value, dict):
base[key] = cls.__merge_settings(base.get(key, {}), value)
else:
base[key] = value
return base
@classmethod
def __load_configuration(cls, path: str) -> dict:
"""Loads the configuration from the .yaml file specified in the path and stores it as a dict.
If load_default is True, it will first look for any file with the same name and the .default extension.
Then the values will be overwritten by the specified file, if present.
If force_load is True, the program will crash if the specified file is not present
Args:
path: path of the configuration .yaml file
Returns:
configuration dictionary
"""
conf: dict[str, Any] = {}
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as conf_file:
conf = cls.__merge_settings(conf, yaml.load(conf_file, Loader=yaml.SafeLoader))
return conf
def __read_env_settings(self):
"""Reads the environment variables and stores the values in the config dict.
Any key already present will be overwritten
"""
new_vars: dict[str, str] = {}
self.settings["post"] = self.settings.get("post", {})
self.settings["debug"] = self.settings.get("debug", {})
env_path = os.path.join(os.path.dirname(__file__), "..", "..", ".env")
if os.path.exists(env_path):
env_re = re.compile(r"""^([^\s=]+)=(?:[\s"']*)(.+?)(?:[\s"']*)$""")
with open(env_path, "r", encoding="utf-8") as env:
for line in env:
match = env_re.match(line)
if match is not None:
new_vars[match.group(1).lower()] = match.group(2)
for key in os.environ:
env_val = os.getenv(key)
if env_val is not None:
new_vars[key.lower()] = env_val
for key, value in new_vars.items():
if key.startswith("post_"):
self.settings["post"][key[5:]] = value
elif key.startswith("debug_"):
self.settings["debug"][key[6:]] = value
else:
self.settings[key] = value
def __validate_types_settings(self):
"""Validates the settings values in the 'post' section, casting them when necessary"""
type_path = f"{self.DEFAULT_SETTINGS_PATH}.types"
if not os.path.exists(type_path):
return
with open(type_path, "r", encoding="utf-8") as conf_file:
types = yaml.load(conf_file, Loader=yaml.SafeLoader)
self.__apply_type_validation(types, self.settings)
def __apply_type_validation(self, types: dict, conf: dict):
for key in types:
if isinstance(types[key], dict):
self.__apply_type_validation(types[key], conf[key])
else:
if key in conf:
if types[key] == "bool":
if isinstance(conf[key], str):
conf[key] = conf[key].lower() not in ("false", "0", "no", "")
else:
conf[key] = bool(conf[key])
elif types[key] == "int":
conf[key] = int(conf[key])
elif types[key] == "float":
conf[key] = float(conf[key])
elif types[key] == "list":
if isinstance(conf[key], str):
conf[key] = conf[key].split(",")
else:
conf[key] = str(conf[key])
def __log_errors_loaded_config(self):
"""Evaluate the loaded configuration and log
any anomaly or possible unintended configuration
"""
logger.debug("Loaded settings")
if self.settings.get("debug", {}).get("local_log", False):
logger.debug("Local log enabled")
if self.settings.get("debug", {}).get("reset_on_load", False):
logger.warning("Reset on load enabled")
if self.settings.get("token", "") == "":
logger.error("Missing bot token")
def __repr__(self):
return f"Config({self.settings!r}, {self.autoreplies!r})"