the1conf 1.0.0__py3-none-any.whl → 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- the1conf/__init__.py +12 -2
- the1conf/app_config.py +276 -408
- the1conf/app_config_meta.py +202 -0
- the1conf/attr_dict.py +38 -25
- the1conf/auto_prolog.py +177 -0
- the1conf/click_option.py +62 -25
- the1conf/config_var.py +312 -396
- the1conf/flags.py +143 -0
- the1conf/solver.py +346 -0
- the1conf/utils.py +110 -0
- the1conf/var_substitution.py +59 -0
- the1conf-1.3.0.dist-info/METADATA +794 -0
- the1conf-1.3.0.dist-info/RECORD +16 -0
- the1conf-1.0.0.dist-info/METADATA +0 -516
- the1conf-1.0.0.dist-info/RECORD +0 -10
- {the1conf-1.0.0.dist-info → the1conf-1.3.0.dist-info}/WHEEL +0 -0
- {the1conf-1.0.0.dist-info → the1conf-1.3.0.dist-info}/licenses/LICENSE +0 -0
the1conf/app_config.py
CHANGED
|
@@ -1,108 +1,31 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
|
|
3
|
+
import copy
|
|
4
|
+
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
|
-
import copy
|
|
8
7
|
import os
|
|
9
|
-
|
|
10
|
-
import
|
|
11
|
-
import
|
|
12
|
-
from collections.abc import Iterable, Mapping, MutableMapping, Sequence
|
|
13
|
-
from dataclasses import dataclass
|
|
14
|
-
from enum import Enum, auto
|
|
8
|
+
|
|
9
|
+
from collections import OrderedDict
|
|
10
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
15
11
|
from pathlib import Path
|
|
16
|
-
from typing import
|
|
17
|
-
Annotated,
|
|
18
|
-
Any,
|
|
19
|
-
Callable,
|
|
20
|
-
cast,
|
|
21
|
-
Generic,
|
|
22
|
-
Optional,
|
|
23
|
-
TypeAlias,
|
|
24
|
-
TypeVar,
|
|
25
|
-
Union,
|
|
26
|
-
get_args,
|
|
27
|
-
get_origin,
|
|
28
|
-
get_type_hints,
|
|
29
|
-
dataclass_transform,
|
|
30
|
-
)
|
|
12
|
+
from typing import Any, Callable, Optional, Union, cast
|
|
31
13
|
|
|
14
|
+
from .auto_prolog import AutoProlog
|
|
15
|
+
import toml
|
|
32
16
|
import yaml
|
|
33
|
-
|
|
34
|
-
from .attr_dict import AttrDict, is_sequence
|
|
35
17
|
from pydantic import TypeAdapter, ValidationError
|
|
36
|
-
from .config_var import ConfigVarDef, Flags, PathType, Undefined, _UndefinedSentinel, _undef, configvar
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _get_attribute_docstrings(cls: type) -> dict[str, str]:
|
|
40
|
-
"""
|
|
41
|
-
Extracts docstrings for class attributes by parsing the source code.
|
|
42
|
-
This allows using docstrings to populate ConfigVarDef.Help.
|
|
43
|
-
"""
|
|
44
|
-
docstrings = {}
|
|
45
|
-
try:
|
|
46
|
-
source = inspect.getsource(cls)
|
|
47
|
-
# Deduct to handle nested classes indentation
|
|
48
|
-
source = textwrap.dedent(source)
|
|
49
|
-
except (OSError, TypeError):
|
|
50
|
-
# Source code not available (e.g. dynamic class, REPL, compiled files)
|
|
51
|
-
return {}
|
|
52
|
-
|
|
53
|
-
try:
|
|
54
|
-
tree = ast.parse(source)
|
|
55
|
-
except SyntaxError:
|
|
56
|
-
return {}
|
|
57
|
-
|
|
58
|
-
# We look for the ClassDef in the parsed source.
|
|
59
|
-
# Since inspect.getsource(cls) returns the class definition itself,
|
|
60
|
-
# the first node in body should be the ClassDef.
|
|
61
|
-
for node in tree.body:
|
|
62
|
-
if isinstance(node, ast.ClassDef):
|
|
63
|
-
for i, item in enumerate(node.body):
|
|
64
|
-
if isinstance(item, (ast.Assign, ast.AnnAssign)):
|
|
65
|
-
# Check if the next node is an expression containing a string (docstring)
|
|
66
|
-
if i + 1 < len(node.body):
|
|
67
|
-
next_node = node.body[i + 1]
|
|
68
|
-
if (
|
|
69
|
-
isinstance(next_node, ast.Expr)
|
|
70
|
-
and isinstance(next_node.value, ast.Constant)
|
|
71
|
-
and isinstance(next_node.value.value, str)
|
|
72
|
-
):
|
|
73
|
-
docstring = next_node.value.value.strip()
|
|
74
|
-
|
|
75
|
-
targets = []
|
|
76
|
-
if isinstance(item, ast.Assign):
|
|
77
|
-
targets = item.targets
|
|
78
|
-
elif isinstance(item, ast.AnnAssign):
|
|
79
|
-
targets = [item.target]
|
|
80
|
-
|
|
81
|
-
for target in targets:
|
|
82
|
-
if isinstance(target, ast.Name):
|
|
83
|
-
docstrings[target.id] = docstring
|
|
84
|
-
# We found the class def, no need to continue in top level
|
|
85
|
-
break
|
|
86
|
-
|
|
87
|
-
return docstrings
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
class AppConfigException(Exception):
|
|
91
|
-
def __init__(self, message: str) -> None:
|
|
92
|
-
super().__init__(message)
|
|
93
|
-
self.message = message
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
class NameSpace:
|
|
97
|
-
"""Marker class for configuration namespaces.
|
|
98
|
-
Used to group configuration variables into nested namespaces within an AppConfig subclass.
|
|
99
|
-
See AppConfig documentation for more information.
|
|
100
|
-
"""
|
|
101
18
|
|
|
102
|
-
|
|
19
|
+
from .attr_dict import AttrDict
|
|
20
|
+
from .utils import is_sequence, Undefined
|
|
21
|
+
from .config_var import ConfigVarDef, Flags
|
|
22
|
+
from .solver import Solver
|
|
23
|
+
from .var_substitution import resolve_candidate_list
|
|
24
|
+
from .utils import _undef
|
|
25
|
+
from .app_config_meta import AppConfigMeta, ConfigVarCollector
|
|
103
26
|
|
|
104
27
|
|
|
105
|
-
class AppConfig(AttrDict):
|
|
28
|
+
class AppConfig(AttrDict, AutoProlog, metaclass=AppConfigMeta):
|
|
106
29
|
""" "
|
|
107
30
|
A class to manage application configuration. It can be passed from callable to callable.
|
|
108
31
|
It can search for variable values in command line arguments, environment variables, a yaml or json configuration file
|
|
@@ -153,8 +76,8 @@ class AppConfig(AttrDict):
|
|
|
153
76
|
Configuration is best defined declaratively by subclassing AppConfig:
|
|
154
77
|
|
|
155
78
|
class MyConfig(AppConfig):
|
|
156
|
-
host: str = configvar(
|
|
157
|
-
port: int = configvar(
|
|
79
|
+
host: str = configvar(default="localhost")
|
|
80
|
+
port: int = configvar(default=8080)
|
|
158
81
|
|
|
159
82
|
Nested Namespaces:
|
|
160
83
|
------------------
|
|
@@ -163,16 +86,16 @@ class AppConfig(AttrDict):
|
|
|
163
86
|
|
|
164
87
|
class MyConfig(AppConfig):
|
|
165
88
|
# Root level variable
|
|
166
|
-
debug: bool = configvar(
|
|
89
|
+
debug: bool = configvar(default=False)
|
|
167
90
|
|
|
168
91
|
# Nested namespace 'db'
|
|
169
92
|
class db(NameSpace):
|
|
170
|
-
host: str = configvar(
|
|
171
|
-
port: int = configvar(
|
|
93
|
+
host: str = configvar(default="db.local")
|
|
94
|
+
port: int = configvar(default=5432)
|
|
172
95
|
|
|
173
96
|
# Nested namespace 'api'
|
|
174
97
|
class api(NameSpace):
|
|
175
|
-
key: str = configvar(
|
|
98
|
+
key: str = configvar(env_name="API_KEY")
|
|
176
99
|
|
|
177
100
|
inst = MyConfig()
|
|
178
101
|
inst.resolve_vars()
|
|
@@ -185,11 +108,11 @@ class AppConfig(AttrDict):
|
|
|
185
108
|
|
|
186
109
|
class DatabaseConfig(AppConfig):
|
|
187
110
|
class db(NameSpace):
|
|
188
|
-
host: str = configvar(
|
|
111
|
+
host: str = configvar(default="localhost")
|
|
189
112
|
|
|
190
113
|
class ApiConfig(AppConfig):
|
|
191
114
|
class api(NameSpace):
|
|
192
|
-
timeout: int = configvar(
|
|
115
|
+
timeout: int = configvar(default=30)
|
|
193
116
|
|
|
194
117
|
# Combine into the final application config
|
|
195
118
|
class App(DatabaseConfig, ApiConfig):
|
|
@@ -198,11 +121,16 @@ class AppConfig(AttrDict):
|
|
|
198
121
|
If multiple mixins define the same namespace (e.g. `class db(NameSpace)`), their variables are merged.
|
|
199
122
|
"""
|
|
200
123
|
|
|
201
|
-
#: The logger for this class
|
|
202
124
|
_logger = logging.getLogger(__name__)
|
|
203
|
-
_solver_logger = logging.getLogger(f"{__name__}.solver")
|
|
204
125
|
_global_flags: Flags
|
|
205
|
-
_config_var_defs:
|
|
126
|
+
_config_var_defs: OrderedDict[str, ConfigVarDef]
|
|
127
|
+
""" The list of ConfigVarDef defined in this AppConfig class and its parent classes as an ordered dictionary.
|
|
128
|
+
This attribute is created at class creation time by the __init_subclass__ method.
|
|
129
|
+
"""
|
|
130
|
+
_autoprolog_var_names: list[str]
|
|
131
|
+
""" The list of the names of ConfigVarDef defined in AutoProlog.
|
|
132
|
+
This attribute is created at class creation time by the __init_subclass__ method.
|
|
133
|
+
"""
|
|
206
134
|
|
|
207
135
|
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
208
136
|
"""
|
|
@@ -210,74 +138,38 @@ class AppConfig(AttrDict):
|
|
|
210
138
|
|
|
211
139
|
Create the _config_var_defs attribute that contains the list of ConfigVarDef
|
|
212
140
|
defined in this subclass as well as the ones defined in parent classes.
|
|
141
|
+
|
|
142
|
+
This method also handle namespaces defined with nested classes inheriting from NameSpace.
|
|
143
|
+
1. It first collects the _config_var_defs from parent classes to handle mixins, when a variable is found in a parent class with the same name as a variable in a child class,
|
|
144
|
+
the child class variable takes precedence, thus we remove the parent class variable from the collected list before adding the child class variable.
|
|
145
|
+
2. Then it inspects the class __dict__ to find ConfigVarDef defined in this class (and nested classes) and add them to the collected list.
|
|
146
|
+
Collecting ConfigVarDef in this class is done by the collect_vars() class method which is called recursively on nested NameSpace classes
|
|
147
|
+
and which is also search for docstrings to attach them as help to the ConfigVarDef if help is not already defined.
|
|
148
|
+
3. Finally it sets the _config_var_defs attribute on the class.
|
|
149
|
+
|
|
213
150
|
"""
|
|
151
|
+
# First call super to handle any parent class logic
|
|
214
152
|
super().__init_subclass__(**kwargs)
|
|
215
153
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
#
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
collected.append(var_def)
|
|
236
|
-
seen_attr_names.add(attr_name)
|
|
237
|
-
|
|
238
|
-
# Helper method for recursive collection
|
|
239
|
-
def collect_vars(owner: type, prefix: str = "") -> Iterable[ConfigVarDef]:
|
|
240
|
-
# extract docstrings from the class source code
|
|
241
|
-
docstrings = _get_attribute_docstrings(owner)
|
|
242
|
-
|
|
243
|
-
# Inspect the class __dict__ to find ConfigVarDef defined in this class
|
|
244
|
-
for attr_name, value in owner.__dict__.items():
|
|
245
|
-
if isinstance(value, ConfigVarDef):
|
|
246
|
-
# Clone to update name with prefix
|
|
247
|
-
# We use copy.copy because ConfigVarDef is mutable but generic.
|
|
248
|
-
new_var = copy.copy(value)
|
|
249
|
-
|
|
250
|
-
# If Help is missing, try to use the docstring
|
|
251
|
-
if not new_var.Help and attr_name in docstrings:
|
|
252
|
-
new_var._help = docstrings[attr_name]
|
|
253
|
-
|
|
254
|
-
if prefix:
|
|
255
|
-
# We need to access _name directly
|
|
256
|
-
original_name = getattr(value, "_name", attr_name)
|
|
257
|
-
new_var._name = f"{prefix}{original_name}"
|
|
258
|
-
|
|
259
|
-
yield new_var
|
|
260
|
-
|
|
261
|
-
elif (
|
|
262
|
-
isinstance(value, type)
|
|
263
|
-
and issubclass(value, NameSpace)
|
|
264
|
-
and value is not NameSpace
|
|
265
|
-
):
|
|
266
|
-
# Recurse
|
|
267
|
-
yield from collect_vars(value, prefix=f"{prefix}{attr_name}.")
|
|
268
|
-
|
|
269
|
-
# Now inspect the class __dict__ to find ConfigVarDef defined in this class (and nested classes) and add them to the collected list.
|
|
270
|
-
for value in collect_vars(cls):
|
|
271
|
-
attr_name = getattr(value, "_name", None)
|
|
272
|
-
if attr_name:
|
|
273
|
-
if attr_name in seen_attr_names:
|
|
274
|
-
collected = [
|
|
275
|
-
v for v in collected if getattr(v, "_name", None) != attr_name
|
|
276
|
-
]
|
|
277
|
-
collected.append(value)
|
|
278
|
-
seen_attr_names.add(attr_name)
|
|
279
|
-
|
|
280
|
-
cls._config_var_defs = collected
|
|
154
|
+
with ConfigVarCollector() as collector:
|
|
155
|
+
# try to look if there is already a _config_var_defs attribute in one of the parent classes.
|
|
156
|
+
|
|
157
|
+
for base in reversed(cls.__mro__[1:-1]):
|
|
158
|
+
# __mro__ contain:
|
|
159
|
+
# 1. The class of the object (class of cls here, which is this class)
|
|
160
|
+
# 2. The hierarchy of the parent classes in the MRO order which goes from left to right at each level and from the level
|
|
161
|
+
# closest to the object to one deeper in the tree.
|
|
162
|
+
# 3. The Object class which is the latest class in the inheritance tree.
|
|
163
|
+
# Here we skip the first element which is the class of the object itself (cls) and the last which is Object class and we traverse
|
|
164
|
+
# it in reverse order.
|
|
165
|
+
|
|
166
|
+
collector.collect_in_config_var_defs(base)
|
|
167
|
+
|
|
168
|
+
# Now inspect the class __dict__ to find ConfigVarDef defined in this class (and nested classes) and add them to the collected list.
|
|
169
|
+
# note that collect_config_vars is defined in the metaclass.
|
|
170
|
+
collector.collect_on_class(cls)
|
|
171
|
+
cls._config_var_defs = collector.get_collected_vars()
|
|
172
|
+
cls._autoprolog_var_names = collector.get_auto_prolog_var_names()
|
|
281
173
|
|
|
282
174
|
def __init__(
|
|
283
175
|
self,
|
|
@@ -286,7 +178,6 @@ class AppConfig(AttrDict):
|
|
|
286
178
|
no_key_search: Optional[bool] = None,
|
|
287
179
|
no_conffile_search: Optional[bool] = None,
|
|
288
180
|
no_search: Optional[bool] = None,
|
|
289
|
-
conffile_path: Optional[Path] = None,
|
|
290
181
|
allow_override: Optional[bool] = None,
|
|
291
182
|
click_key_conversion: Optional[bool] = None,
|
|
292
183
|
):
|
|
@@ -301,7 +192,6 @@ class AppConfig(AttrDict):
|
|
|
301
192
|
no_key_search (bool, optional): don't search values in the value dictionary. Defaults to False.
|
|
302
193
|
no_conffile_search (bool, optional): don't search values in the configuration file. Defaults to False.
|
|
303
194
|
no_search (bool, optional): don't search values in any location. Defaults to False.
|
|
304
|
-
conffile_path (Path, optional): the configuration file path. Defaults to None.
|
|
305
195
|
allow_override (bool, optional): allow overriding variable in the configuration when calling resolve_vars.
|
|
306
196
|
it happens either when resolve_vars() is call several times or the variable was set before its call.
|
|
307
197
|
click_key_conversion (bool, optional): use the variable name converted to lower case and with dashes converted to underscores as
|
|
@@ -315,14 +205,8 @@ class AppConfig(AttrDict):
|
|
|
315
205
|
click_key_conversion=click_key_conversion,
|
|
316
206
|
allow_override=allow_override,
|
|
317
207
|
)
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
# This is intentionally an instance attribute so callers can set/replace it
|
|
321
|
-
# dynamically (major-version breaking change: resolve_vars no longer accepts
|
|
322
|
-
# a var_specs parameter).
|
|
323
|
-
# Default to the declarative definitions collected on the class.
|
|
324
|
-
self._config_var_defs = list(getattr(self.__class__, "_config_var_defs", []))
|
|
325
|
-
self._logger.debug("init AppCtx")
|
|
208
|
+
|
|
209
|
+
self._logger.debug("init AppConfig")
|
|
326
210
|
|
|
327
211
|
def clone(self) -> AppConfig:
|
|
328
212
|
"""cloning the AppConfig object"""
|
|
@@ -330,61 +214,73 @@ class AppConfig(AttrDict):
|
|
|
330
214
|
clone.__dict__ = self.__dict__.copy()
|
|
331
215
|
return clone
|
|
332
216
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
"""
|
|
336
|
-
Search for var_name in a dictionary.
|
|
337
|
-
var_name contains dict keys separated by a dot (.) ie key1.key2.key3
|
|
338
|
-
The left side key key1 is search in the where directory, if it is found
|
|
339
|
-
and it is a dictionary then key2 is search in it and
|
|
340
|
-
all key are searched in sequence.
|
|
341
|
-
Returns _undef if not found, allowing to distinguish between None (explicit null) and missing.
|
|
342
|
-
"""
|
|
343
|
-
cur_dic: Mapping[Any, Any] = where
|
|
344
|
-
for key in var_name.split("."):
|
|
345
|
-
# logger = logging.getLogger(f"{cls.__module__}.{cls.__name__}")
|
|
346
|
-
# logger.debug(f"searching for key: {key} in dict : {cur_val}")
|
|
347
|
-
if key in cur_dic:
|
|
348
|
-
cur_dic = cur_dic[key]
|
|
349
|
-
# logger.debug(f"Found key: {key} = {cur_val}")
|
|
350
|
-
else:
|
|
351
|
-
return _undef
|
|
217
|
+
def _set_value(self, name: str, value: Any) -> None:
|
|
218
|
+
"""Set a value.
|
|
352
219
|
|
|
353
|
-
|
|
220
|
+
Use an AttrDict implementation to handle dotted keys and not trigger recursion loops
|
|
221
|
+
if called from a descriptor.
|
|
222
|
+
"""
|
|
223
|
+
self[name] = value
|
|
354
224
|
|
|
355
|
-
def
|
|
225
|
+
def _get_eval_runner(
|
|
356
226
|
self,
|
|
357
|
-
|
|
358
|
-
var_val: Any = None,
|
|
359
|
-
var_name: str = "",
|
|
360
|
-
) -> Any:
|
|
227
|
+
) -> Callable[[Callable[[str, Any, Any], Any], str, Any], Any]:
|
|
361
228
|
"""
|
|
362
229
|
Call a Callable found in a Eval Form.
|
|
363
230
|
"""
|
|
364
|
-
|
|
231
|
+
this = self
|
|
232
|
+
|
|
233
|
+
def runner(
|
|
234
|
+
callable: Callable[[str, Any, Any], Any], var_name: str, var_val: Any
|
|
235
|
+
) -> Any:
|
|
236
|
+
return callable(var_name, this, var_val)
|
|
237
|
+
|
|
238
|
+
return runner
|
|
239
|
+
|
|
240
|
+
def _resolve_conffile_path(
|
|
241
|
+
self, candidates: Optional[str | Path | Iterable[str | Path]]
|
|
242
|
+
) -> Path | None:
|
|
243
|
+
if candidates is None:
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
# Normalize candidates to list of str for resolve_candidate_list
|
|
247
|
+
candidates_str_list: list[str] = []
|
|
248
|
+
if isinstance(candidates, (str, Path)):
|
|
249
|
+
candidates_str_list = [str(candidates)]
|
|
250
|
+
elif is_sequence(candidates):
|
|
251
|
+
candidates_str_list = [str(c) for c in cast(Sequence[Any], candidates)]
|
|
252
|
+
|
|
253
|
+
# Define existence check
|
|
254
|
+
def file_exists(p: str) -> Any:
|
|
255
|
+
path_obj = Path(p)
|
|
256
|
+
if path_obj.is_file():
|
|
257
|
+
return path_obj
|
|
258
|
+
else:
|
|
259
|
+
return _undef
|
|
365
260
|
|
|
366
|
-
|
|
367
|
-
|
|
261
|
+
# Build context for Jinja with current values resolved
|
|
262
|
+
context = self.to_dict()
|
|
368
263
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
264
|
+
# Resolve using current self as context (for jinja variables)
|
|
265
|
+
resolved_path = resolve_candidate_list(
|
|
266
|
+
candidates=candidates_str_list, context=context, check_exists=file_exists
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
return resolved_path
|
|
373
270
|
|
|
374
271
|
def resolve_vars(
|
|
375
272
|
self,
|
|
376
273
|
*,
|
|
377
|
-
|
|
274
|
+
scopes: Optional[str | Iterable[str]] = None,
|
|
378
275
|
values: Mapping[str, Any] = {},
|
|
379
|
-
no_exception: Optional[bool] = None,
|
|
380
276
|
no_env_search: Optional[bool] = None,
|
|
381
277
|
no_value_search: Optional[bool] = None,
|
|
382
278
|
no_conffile_search: Optional[bool] = None,
|
|
383
279
|
no_search: Optional[bool] = None,
|
|
384
|
-
conffile_path: Optional[Path] = None,
|
|
280
|
+
conffile_path: Optional[Path | str | Sequence[str | Path]] = None,
|
|
385
281
|
allow_override: Optional[bool] = None,
|
|
386
282
|
click_key_conversion: Optional[bool] = None,
|
|
387
|
-
) ->
|
|
283
|
+
) -> None:
|
|
388
284
|
"""
|
|
389
285
|
Resolve the configuration variables defined in this AppConfig object.
|
|
390
286
|
The variable values are searched in these locations in the following order:
|
|
@@ -411,233 +307,205 @@ class AppConfig(AttrDict):
|
|
|
411
307
|
no_search=no_search,
|
|
412
308
|
click_key_conversion=click_key_conversion,
|
|
413
309
|
allow_override=allow_override,
|
|
414
|
-
no_exception=no_exception,
|
|
415
310
|
)
|
|
416
311
|
local_flags = self._global_flags.merge(inplace=False, other=resolve_flags)
|
|
417
312
|
assert local_flags is not None
|
|
418
313
|
|
|
314
|
+
# building the Solver who is in charge of resolving configuration variables
|
|
315
|
+
solver = Solver(
|
|
316
|
+
eval_runner=self._get_eval_runner(),
|
|
317
|
+
flags=local_flags,
|
|
318
|
+
values_map=values,
|
|
319
|
+
var_solved=self,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# Special case for auto_prolog variable: we need to try to solve them
|
|
323
|
+
# before any other variable and before resolving the configuration file path because
|
|
324
|
+
# it can depends on one of them, the one that can be ovveriden are resolved at each run
|
|
325
|
+
for var_name in self._autoprolog_var_names:
|
|
326
|
+
vardef = self._config_var_defs.get(var_name)
|
|
327
|
+
if vardef is None:
|
|
328
|
+
continue
|
|
329
|
+
if self.has_value(var_name) and not (
|
|
330
|
+
local_flags.allow_override or vardef.flags.allow_override
|
|
331
|
+
):
|
|
332
|
+
continue
|
|
333
|
+
# call solver to try to find a value for the configuration variable
|
|
334
|
+
value_found = solver.resolve_confvar(var_to_solve=vardef, scopes=scopes)
|
|
335
|
+
if value_found != _undef:
|
|
336
|
+
self._set_value(var_name, value_found)
|
|
337
|
+
|
|
419
338
|
# Read configuration file if requiered
|
|
420
339
|
conf_file_vars = None
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
340
|
+
if not local_flags.no_conffile_search and conffile_path is not None:
|
|
341
|
+
# Resolve conffile_path if it's dynamic
|
|
342
|
+
cur_conffile_path = self._resolve_conffile_path(conffile_path)
|
|
343
|
+
|
|
344
|
+
if cur_conffile_path is not None and cur_conffile_path.is_file():
|
|
424
345
|
with cur_conffile_path.open("r") as f:
|
|
425
346
|
if cur_conffile_path.suffix.lower() == ".json":
|
|
426
347
|
self._logger.debug(f"Parsing json file {cur_conffile_path}")
|
|
427
348
|
conf_file_vars = json.load(f)
|
|
349
|
+
elif cur_conffile_path.suffix.lower() == ".toml":
|
|
350
|
+
self._logger.debug(f"Parsing toml file {cur_conffile_path}")
|
|
351
|
+
conf_file_vars = toml.load(f)
|
|
428
352
|
else:
|
|
429
353
|
self._logger.debug(
|
|
430
354
|
f"Parsing file {cur_conffile_path} with suffix {cur_conffile_path.suffix.lower()}"
|
|
431
355
|
)
|
|
432
356
|
conf_file_vars = yaml.safe_load(f)
|
|
357
|
+
# updating the solver with the values found in the configuration file
|
|
358
|
+
solver.set_conffile_values_map(conf_file_vars)
|
|
433
359
|
else:
|
|
434
360
|
self._logger.info(f"configuration file {cur_conffile_path} not found.")
|
|
435
361
|
|
|
436
362
|
# Read the var definitions one by one in their definition order
|
|
437
|
-
for
|
|
363
|
+
for var_name, var_def in self._config_var_defs.items():
|
|
364
|
+
|
|
365
|
+
# Check if variable is manually set on the instance or resolved in a previous pass
|
|
366
|
+
has_value = self.has_value(var_name)
|
|
367
|
+
|
|
438
368
|
# compute the current flags for this variable by merging local flags with variable flags
|
|
439
|
-
cur_flags = local_flags.merge(inplace=False, other=
|
|
369
|
+
cur_flags = local_flags.merge(inplace=False, other=var_def.flags)
|
|
440
370
|
assert cur_flags is not None
|
|
441
|
-
# self.logger.debug(f"looking for a value for variable: {var.Name}")
|
|
442
|
-
|
|
443
|
-
self._solver_logger.debug(f"Searching for var name {var.Name}:\n{str(var)}")
|
|
444
371
|
|
|
445
|
-
# if
|
|
446
|
-
# we
|
|
447
|
-
if
|
|
372
|
+
# if the variable is already defined and override is not allowed then
|
|
373
|
+
# we skip it (First valid scope wins).
|
|
374
|
+
if not cur_flags.allow_override and has_value:
|
|
448
375
|
continue
|
|
449
|
-
if contexts is not None and var.Contexts is not None:
|
|
450
|
-
test_ctx = []
|
|
451
|
-
# put True in test_ctx for each context that matches, False otherwise
|
|
452
|
-
if is_sequence(contexts):
|
|
453
|
-
test_ctx = [c in var.Contexts for c in contexts]
|
|
454
|
-
else:
|
|
455
|
-
test_ctx = [contexts in var.Contexts]
|
|
456
376
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
377
|
+
# call solver to try to find a value for the configuration variable
|
|
378
|
+
value_found = solver.resolve_confvar(var_to_solve=var_def, scopes=scopes)
|
|
379
|
+
if value_found != _undef:
|
|
380
|
+
self._logger.info(
|
|
381
|
+
f"Variable '{var_name}' resolved to: {str(value_found)}"
|
|
382
|
+
)
|
|
383
|
+
# add the found value to the AppConfig object through the AttrDict implementation.
|
|
384
|
+
self._set_value(var_name, value_found)
|
|
385
|
+
else:
|
|
386
|
+
self._logger.debug(f"Variable '{var_def.name}' could not be resolved.")
|
|
465
387
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
388
|
+
def _serialize_var_defs(
|
|
389
|
+
self, vardef_to_serialize: Iterable[ConfigVarDef], for_file: bool = False
|
|
390
|
+
) -> dict[str, Any]:
|
|
391
|
+
"""
|
|
392
|
+
Serialize the given variable definitions to a dictionary.
|
|
393
|
+
We know that values came from strings and that they are either alone or in a list.
|
|
394
|
+
Thus we can just use str() to serialize them.
|
|
395
|
+
To detect list we need to look at the split_to_list attribute of the ConfigVarDef and call str() on
|
|
396
|
+
each element of the list, then join them with the separator used to create the list.
|
|
397
|
+
If for_file is True we serialize only variables that have no_conffile_search set to False and
|
|
398
|
+
used file_key as the key in the dict, otherwise the key is the variable name.
|
|
399
|
+
"""
|
|
400
|
+
# we are going to take advantage of the AttrDict to accept dotted keys in order to create nested dicts.
|
|
401
|
+
dumper = AttrDict()
|
|
402
|
+
for var in vardef_to_serialize:
|
|
403
|
+
if for_file and not var.flags.no_conffile_search:
|
|
404
|
+
key = var.file_key
|
|
405
|
+
elif not for_file:
|
|
406
|
+
key = var.name
|
|
407
|
+
else:
|
|
469
408
|
continue
|
|
470
409
|
|
|
471
|
-
|
|
472
|
-
|
|
410
|
+
sep = var.split_to_list if isinstance(var.split_to_list, str) else ","
|
|
411
|
+
if var.split_to_list:
|
|
412
|
+
val_list = [str(v) for v in self[var.name]]
|
|
413
|
+
val = sep.join(val_list)
|
|
414
|
+
else:
|
|
415
|
+
val = str(self[var.name])
|
|
473
416
|
|
|
474
|
-
|
|
475
|
-
if (
|
|
476
|
-
values is not None
|
|
477
|
-
and not cur_flags.no_value_search
|
|
478
|
-
and var.ValueKey is not None
|
|
479
|
-
):
|
|
480
|
-
# searching var key
|
|
481
|
-
if var.ValueKey in values and values[var.ValueKey] is not None:
|
|
482
|
-
val_found = values[var.ValueKey]
|
|
483
|
-
found = True
|
|
484
|
-
self._solver_logger.debug(
|
|
485
|
-
f"{var.Name} -> Found in Values = {val_found}"
|
|
486
|
-
)
|
|
487
|
-
if not found and cur_flags.click_key_conversion:
|
|
488
|
-
# try with click key conversion
|
|
489
|
-
click_key = var.ValueKey.lower().replace("-", "_")
|
|
490
|
-
if click_key in values and values[click_key] is not None:
|
|
491
|
-
val_found = values[click_key]
|
|
492
|
-
found = True
|
|
493
|
-
self._solver_logger.debug(
|
|
494
|
-
f"{var.Name} -> Found in Values with Click Key Conversion = {val_found}"
|
|
495
|
-
)
|
|
496
|
-
# Searching variable value in the environment variables
|
|
497
|
-
if not found and not cur_flags.no_env_search and var.EnvName is not None:
|
|
498
|
-
env_val = os.getenv(var.EnvName)
|
|
499
|
-
if env_val is not None:
|
|
500
|
-
val_found = env_val
|
|
501
|
-
found = True
|
|
502
|
-
self._solver_logger.debug(f"{var.Name} -> Found in Env = {val_found}")
|
|
503
|
-
# Searching variable value in the configuration file
|
|
504
|
-
if (
|
|
505
|
-
not found
|
|
506
|
-
and not cur_flags.no_conffile_search
|
|
507
|
-
and conf_file_vars is not None
|
|
508
|
-
and var.FileKey is not None
|
|
509
|
-
):
|
|
510
|
-
file_val = self._find_var_in_dict(conf_file_vars, var.FileKey)
|
|
511
|
-
if file_val is not _undef:
|
|
512
|
-
val_found = file_val
|
|
513
|
-
found = True
|
|
514
|
-
self._solver_logger.debug(
|
|
515
|
-
f"{var.Name} -> Found in Configuration File = {val_found}"
|
|
516
|
-
)
|
|
517
|
-
# Setting variable value to the default value if defined.
|
|
518
|
-
if not found and var.Default is not _undef:
|
|
519
|
-
if var.Default is not None and callable(var.Default):
|
|
520
|
-
val_found = self._run_eval(callable=var.Default, var_name=var.Name)
|
|
521
|
-
else:
|
|
522
|
-
val_found = var.Default
|
|
523
|
-
found = True
|
|
524
|
-
self._solver_logger.debug(
|
|
525
|
-
f"{var.Name} -> Found in Default Value = {val_found}"
|
|
526
|
-
)
|
|
417
|
+
dumper[key] = val
|
|
527
418
|
|
|
528
|
-
|
|
529
|
-
if not found and not cur_flags.no_exception:
|
|
530
|
-
raise AppConfigException(
|
|
531
|
-
f"No value for var {var.Name} in context {contexts}"
|
|
532
|
-
)
|
|
419
|
+
return cast(dict[str, Any], dumper.to_dict())
|
|
533
420
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
val_transfo = self._run_eval(
|
|
553
|
-
callable=var.Transform,
|
|
554
|
-
var_val=val_found,
|
|
555
|
-
var_name=var.Name,
|
|
556
|
-
)
|
|
557
|
-
self._solver_logger.debug(
|
|
558
|
-
f"{var.Name} -> Value Transformed: {val_found} => {val_transfo}"
|
|
559
|
-
)
|
|
560
|
-
val_found = val_transfo
|
|
561
|
-
|
|
562
|
-
# Validate and cast the variable value using pydantic TypeAdapter
|
|
563
|
-
if var.TypeInfo is not None and val_found is not None:
|
|
564
|
-
try:
|
|
565
|
-
ta = TypeAdapter(var.TypeInfo)
|
|
566
|
-
val_found = ta.validate_python(val_found)
|
|
567
|
-
except ValidationError as e:
|
|
568
|
-
raise AppConfigException(f"Validation failed for var {var.Name}: {e}")
|
|
569
|
-
|
|
570
|
-
self._set_value(var.Name, val_found)
|
|
571
|
-
elif var.Name not in self.__dict__:
|
|
572
|
-
# in case of overiding and when on subsenquent call no value were found we don't want to erase a value
|
|
573
|
-
# found on a previous run. This can happen when some global flags are overriden or for variable without
|
|
574
|
-
# context.
|
|
575
|
-
self._set_value(var.Name, None)
|
|
576
|
-
|
|
577
|
-
# Make special treatment for paths
|
|
578
|
-
if (
|
|
579
|
-
not var.NoDirProcessing
|
|
580
|
-
and var.TypeInfo == Path
|
|
581
|
-
and self[var.Name] != None
|
|
582
|
-
):
|
|
583
|
-
var_value = self[var.Name]
|
|
584
|
-
new_val: list[Path] | Path | None = None
|
|
585
|
-
try:
|
|
586
|
-
if iter(var_value):
|
|
587
|
-
# we need to consider the case where the variable is a list of Path
|
|
588
|
-
new_val = []
|
|
589
|
-
for val in var_value:
|
|
590
|
-
res = self._process_paths(value=val, var=var)
|
|
591
|
-
new_val.append(res)
|
|
592
|
-
except Exception:
|
|
593
|
-
res = self._process_paths(value=var_value, var=var)
|
|
594
|
-
new_val = res
|
|
595
|
-
self._set_value(var.Name, new_val)
|
|
596
|
-
|
|
597
|
-
def _process_paths(self, *, value: Any, var: ConfigVarDef) -> Path:
|
|
421
|
+
def store_conf_infile(
|
|
422
|
+
self,
|
|
423
|
+
file: Union[Path, str],
|
|
424
|
+
*,
|
|
425
|
+
namespaces: Sequence[str] = [],
|
|
426
|
+
scopes: Sequence[str] = [],
|
|
427
|
+
type: str = "yaml",
|
|
428
|
+
) -> None:
|
|
429
|
+
"""
|
|
430
|
+
Store the current configuration in a file by merging the variables in the file with the one in this Appconfig object.
|
|
431
|
+
The method writes the variables in a file.
|
|
432
|
+
The variables to write must be in one of the specified namespaces and have one of the specified scopes.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
file (Path | str ): The path to the file where to write the configuration.
|
|
436
|
+
namespaces (Sequence[str], optional): A list of namespaces dotted names to filter the variables to write. Defaults to [].
|
|
437
|
+
scopes (Sequence[str], optional): A list of scopes to filter the variables to write. Defaults to [].
|
|
438
|
+
type (str, optional): The type of the file to write. Can be "yaml", "json" or "toml". Defaults to "yaml".
|
|
598
439
|
"""
|
|
599
|
-
Run the path processing for a single Path variable.
|
|
600
|
-
|
|
601
|
-
"""
|
|
602
|
-
|
|
603
|
-
var_value: Path
|
|
604
|
-
if isinstance(value, str):
|
|
605
|
-
var_value = Path(value)
|
|
606
|
-
elif isinstance(value, Path):
|
|
607
|
-
var_value = value
|
|
608
|
-
else:
|
|
609
|
-
raise Exception("not a path")
|
|
610
|
-
|
|
611
|
-
res: Path = var_value
|
|
612
|
-
# First process the CanBeRelative case.
|
|
613
|
-
if var.CanBeRelativeTo is not None:
|
|
614
|
-
|
|
615
|
-
if var_value.is_absolute():
|
|
616
|
-
res = var_value
|
|
617
|
-
else:
|
|
618
|
-
# resolving the root directory from which to be relative to.
|
|
619
|
-
can_be_relative = var.CanBeRelativeTo
|
|
620
|
-
root_dir: Path
|
|
621
|
-
if isinstance(can_be_relative, str) and can_be_relative in self.__dict__:
|
|
622
|
-
root_dir = Path(self[can_be_relative])
|
|
623
|
-
else:
|
|
624
|
-
root_dir = Path(can_be_relative)
|
|
625
|
-
res = root_dir.joinpath(var_value)
|
|
626
440
|
|
|
627
|
-
|
|
628
|
-
|
|
441
|
+
def get_vars_to_dump() -> Iterable[ConfigVarDef]:
|
|
442
|
+
"""
|
|
443
|
+
loop over variable definitions to select the variables to dump. we use a separate function
|
|
444
|
+
in ordre to return a generator to avoid double list traversal
|
|
445
|
+
"""
|
|
446
|
+
for var in self._config_var_defs.values():
|
|
447
|
+
|
|
448
|
+
# check namespaces filter if any
|
|
449
|
+
if namespaces:
|
|
450
|
+
# if the variable is not in one of the requested namespaces skip it
|
|
451
|
+
if not any(var.name.startswith(prefix) for prefix in namespaces):
|
|
452
|
+
continue
|
|
453
|
+
|
|
454
|
+
# check scope filter if any
|
|
455
|
+
if scopes:
|
|
456
|
+
# if the variable is specific to some scopes check if one of them is in the requested scopes
|
|
457
|
+
if var.scopes is not None:
|
|
458
|
+
if not any(ctx in var.scopes for ctx in scopes):
|
|
459
|
+
continue
|
|
460
|
+
|
|
461
|
+
# check if the variable should be in the file
|
|
462
|
+
# redondant because file_key is None only if no_conffile_search is True, but useful for type checker that knwos that file_key is not None.
|
|
463
|
+
if var.flags.no_conffile_search or var.file_key is None:
|
|
464
|
+
continue
|
|
629
465
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
466
|
+
yield var
|
|
467
|
+
|
|
468
|
+
if type not in ["json", "yaml", "toml"]:
|
|
469
|
+
raise ValueError(
|
|
470
|
+
f"Unknown type {type}. Supported types are 'json', 'toml' and 'yaml'."
|
|
471
|
+
)
|
|
472
|
+
data: dict[str, Any] = {}
|
|
473
|
+
|
|
474
|
+
data = self._serialize_var_defs(get_vars_to_dump())
|
|
475
|
+
if isinstance(file, str):
|
|
476
|
+
file = Path(file)
|
|
477
|
+
|
|
478
|
+
# if possible we need to read the file in order to inject our data into it and
|
|
479
|
+
# not remove data in the file that we don't have in configuration.
|
|
480
|
+
if file.exists() and os.access(file, os.R_OK):
|
|
481
|
+
# file exists and is readable, we open it in read mode to load existing data
|
|
482
|
+
with file.open("r", encoding="utf-8") as f:
|
|
483
|
+
if type == "json":
|
|
484
|
+
existing_data = json.load(f)
|
|
485
|
+
elif type == "yaml":
|
|
486
|
+
existing_data = yaml.safe_load(f)
|
|
487
|
+
else:
|
|
488
|
+
existing_data = toml.load(f)
|
|
489
|
+
# merge existing data with new data
|
|
490
|
+
existing_data.update(data)
|
|
491
|
+
data = existing_data
|
|
492
|
+
if not file.parent.exists():
|
|
493
|
+
file.parent.mkdir(parents=True, exist_ok=True)
|
|
494
|
+
|
|
495
|
+
with file.open("w", encoding="utf-8") as f:
|
|
496
|
+
if type == "json":
|
|
497
|
+
json.dump(data, f, indent=4)
|
|
498
|
+
elif type == "yaml":
|
|
499
|
+
yaml.safe_dump(data, f)
|
|
634
500
|
else:
|
|
635
|
-
|
|
636
|
-
|
|
501
|
+
toml.dump(data, f)
|
|
502
|
+
|
|
503
|
+
def serialize(self) -> dict[str, Any]:
|
|
504
|
+
"""Serialize the AppConfig to a dictionary."""
|
|
505
|
+
return self._serialize_var_defs(self._config_var_defs.values())
|
|
637
506
|
|
|
638
507
|
def __repr__(self) -> str:
|
|
639
|
-
|
|
640
|
-
for cur_var, cur_val in self.__dict__.items():
|
|
641
|
-
mess.append(f"\t{cur_var}:\t{cur_val}")
|
|
508
|
+
return str(self.to_dict())
|
|
642
509
|
|
|
643
|
-
|
|
510
|
+
def __str__(self) -> str:
|
|
511
|
+
return json.dumps(self.serialize(), indent=2, sort_keys=True, default=str)
|