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/flags.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Flags:
|
|
7
|
+
"""
|
|
8
|
+
Flags that control how to search for variable values in AppConfig.
|
|
9
|
+
These flags can be set globally in the AppConfig object or locally when calling resolve_vars() or
|
|
10
|
+
per config variables.
|
|
11
|
+
|
|
12
|
+
Not None flag values override other values with the following precedence:
|
|
13
|
+
ConfigVariableDef > resolve_vars() parameters > AppConfig global flags.
|
|
14
|
+
Which means that a not None value in a ConfVarDef directive will overrride a global flag value defined in AppConfig. And also that a None value in a ConfVarDef directive
|
|
15
|
+
will not override the value set in resolve_vars() parameters or an AppConfig global flags.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
no_env_search (bool|None): don't search values in the environment.
|
|
19
|
+
no_value_search (bool|None): don't search values in the values dict.
|
|
20
|
+
no_conffile_search (bool|None): don't search values in the configuration file.
|
|
21
|
+
no_search (bool|None): don't search values in any location.
|
|
22
|
+
click_key_conversion (bool|None): use the variable name converted to lower case and with dashes converted to underscores as
|
|
23
|
+
the key to search in values.
|
|
24
|
+
allow_override (bool|None): allow overriding variable in the configuration when calling resolve_vars.
|
|
25
|
+
mandatory (bool|None): raise exception when no value is found for a variable. Default is True.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
_no_env_search: bool | None
|
|
29
|
+
_no_value_search: bool | None
|
|
30
|
+
_no_conffile_search: bool | None
|
|
31
|
+
_no_search: bool | None
|
|
32
|
+
_click_key_conversion: bool | None
|
|
33
|
+
_allow_override: bool | None
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
no_env_search: Optional[bool] = None,
|
|
39
|
+
no_value_search: Optional[bool] = None,
|
|
40
|
+
no_conffile_search: Optional[bool] = None,
|
|
41
|
+
no_search: Optional[bool] = None,
|
|
42
|
+
click_key_conversion: Optional[bool] = None,
|
|
43
|
+
allow_override: Optional[bool] = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
self._no_env_search = no_env_search
|
|
46
|
+
self._no_value_search = no_value_search
|
|
47
|
+
self._no_conffile_search = no_conffile_search
|
|
48
|
+
self._no_search = no_search
|
|
49
|
+
if self._no_search:
|
|
50
|
+
self._no_env_search = True
|
|
51
|
+
self._no_value_search = True
|
|
52
|
+
self._no_conffile_search = True
|
|
53
|
+
self._click_key_conversion = click_key_conversion
|
|
54
|
+
self._allow_override = allow_override
|
|
55
|
+
|
|
56
|
+
def merge(self, *, inplace: bool = False, other: Flags) -> Flags | None:
|
|
57
|
+
"""
|
|
58
|
+
Merge the current flags with the given flags.
|
|
59
|
+
If inplace is True then modify the current object, otherwise return a new Flags object.
|
|
60
|
+
|
|
61
|
+
Flags given as parameters have precedence over the current object values if they are not None.
|
|
62
|
+
"""
|
|
63
|
+
cur_no_search: Optional[bool]
|
|
64
|
+
cur_no_env_search: Optional[bool]
|
|
65
|
+
cur_no_value_search: Optional[bool]
|
|
66
|
+
cur_no_conffile_search: Optional[bool]
|
|
67
|
+
cur_click_key_conversion: Optional[bool]
|
|
68
|
+
cur_allow_override: Optional[bool]
|
|
69
|
+
|
|
70
|
+
cur_no_search = (
|
|
71
|
+
other._no_search if other._no_search is not None else self._no_search
|
|
72
|
+
)
|
|
73
|
+
if cur_no_search:
|
|
74
|
+
cur_no_env_search = True
|
|
75
|
+
cur_no_value_search = True
|
|
76
|
+
cur_no_conffile_search = True
|
|
77
|
+
else:
|
|
78
|
+
cur_no_env_search = (
|
|
79
|
+
other._no_env_search
|
|
80
|
+
if other._no_env_search is not None
|
|
81
|
+
else self._no_env_search
|
|
82
|
+
)
|
|
83
|
+
cur_no_value_search = (
|
|
84
|
+
other._no_value_search
|
|
85
|
+
if other._no_value_search is not None
|
|
86
|
+
else self._no_value_search
|
|
87
|
+
)
|
|
88
|
+
cur_no_conffile_search = (
|
|
89
|
+
other._no_conffile_search
|
|
90
|
+
if other._no_conffile_search is not None
|
|
91
|
+
else self._no_conffile_search
|
|
92
|
+
)
|
|
93
|
+
cur_click_key_conversion = (
|
|
94
|
+
other._click_key_conversion
|
|
95
|
+
if other._click_key_conversion is not None
|
|
96
|
+
else self._click_key_conversion
|
|
97
|
+
)
|
|
98
|
+
cur_allow_override = (
|
|
99
|
+
other._allow_override
|
|
100
|
+
if other._allow_override is not None
|
|
101
|
+
else self._allow_override
|
|
102
|
+
)
|
|
103
|
+
if not inplace:
|
|
104
|
+
return Flags(
|
|
105
|
+
no_env_search=cur_no_env_search,
|
|
106
|
+
no_value_search=cur_no_value_search,
|
|
107
|
+
no_conffile_search=cur_no_conffile_search,
|
|
108
|
+
no_search=cur_no_search,
|
|
109
|
+
click_key_conversion=cur_click_key_conversion,
|
|
110
|
+
allow_override=cur_allow_override,
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
self._no_env_search = cur_no_env_search
|
|
114
|
+
self._no_value_search = cur_no_value_search
|
|
115
|
+
self._no_conffile_search = cur_no_conffile_search
|
|
116
|
+
self._no_search = cur_no_search
|
|
117
|
+
self._click_key_conversion = cur_click_key_conversion
|
|
118
|
+
self._allow_override = cur_allow_override
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def no_env_search(self) -> bool:
|
|
123
|
+
return False if self._no_env_search is None else self._no_env_search
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def no_value_search(self) -> bool:
|
|
127
|
+
return False if self._no_value_search is None else self._no_value_search
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def no_conffile_search(self) -> bool:
|
|
131
|
+
return False if self._no_conffile_search is None else self._no_conffile_search
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def no_search(self) -> bool:
|
|
135
|
+
return False if self._no_search is None else self._no_search
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def click_key_conversion(self) -> bool:
|
|
139
|
+
return False if self._click_key_conversion is None else self._click_key_conversion
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def allow_override(self) -> bool:
|
|
143
|
+
return False if self._allow_override is None else self._allow_override
|
the1conf/solver.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any, Callable, Iterable, Mapping, Optional, Sequence, cast
|
|
6
|
+
from .config_var import ConfigVarDef, Flags
|
|
7
|
+
from .attr_dict import AttrDict
|
|
8
|
+
from .utils import AppConfigException, Undefined, _undef, is_sequence, PathType
|
|
9
|
+
from .var_substitution import render_template, resolve_candidate_list
|
|
10
|
+
from pydantic import TypeAdapter, ValidationError
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Solver:
|
|
15
|
+
"""Class responsible for solving configuration variables values
|
|
16
|
+
according to the defined search order and applying transformations and validations."""
|
|
17
|
+
|
|
18
|
+
_logger = logging.getLogger(__name__)
|
|
19
|
+
_local_flags: Flags
|
|
20
|
+
_values_map: Mapping[str, Any]
|
|
21
|
+
""" The values provided directly to the AppConfig instance."""
|
|
22
|
+
_conffile_values_map: Optional[Mapping[str, Any]] = None
|
|
23
|
+
""" The values found in the configuration file."""
|
|
24
|
+
_eval_runner: Callable[[Callable[[str, Any, Any], Any], str, Any], Any]
|
|
25
|
+
""" The function that will run the evaluation of callables for default values and transformations."""
|
|
26
|
+
_var_solved: AttrDict
|
|
27
|
+
""" currently solved config var. Used to handle dependencies between variables and variable substitution """
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
flags: Flags,
|
|
33
|
+
values_map: Mapping[str, Any],
|
|
34
|
+
eval_runner: Callable[[Callable[[str, Any, Any], Any], str, Any], Any],
|
|
35
|
+
var_solved: AttrDict,
|
|
36
|
+
conffile_values_map: Optional[Mapping[str, Any]] = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._local_flags = flags
|
|
39
|
+
self._values_map = values_map
|
|
40
|
+
self._conffile_values_map = conffile_values_map
|
|
41
|
+
self._eval_runner = eval_runner
|
|
42
|
+
self._var_solved = var_solved
|
|
43
|
+
|
|
44
|
+
def set_conffile_values_map(
|
|
45
|
+
self, conffile_values_map: Optional[Mapping[str, Any]]
|
|
46
|
+
) -> None:
|
|
47
|
+
"""
|
|
48
|
+
Set or update the configuration file values map.
|
|
49
|
+
Allow to use the same solver with ou without configuration file.
|
|
50
|
+
"""
|
|
51
|
+
self._conffile_values_map = conffile_values_map
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def _find_var_in_dict(
|
|
55
|
+
cls, where: Mapping[Any, Any], var_name: str
|
|
56
|
+
) -> Any | Undefined:
|
|
57
|
+
"""
|
|
58
|
+
Search for var_name in a dictionary.
|
|
59
|
+
var_name contains dict keys separated by a dot (.) ie key1.key2.key3
|
|
60
|
+
The left side key key1 is search in the where directory, if it is found
|
|
61
|
+
and it is a dictionary then key2 is search in it and
|
|
62
|
+
all key are searched in sequence.
|
|
63
|
+
Returns _undef if not found, allowing to distinguish between None (explicit null) and missing.
|
|
64
|
+
"""
|
|
65
|
+
cur_dic: Mapping[Any, Any] = where
|
|
66
|
+
for key in var_name.split("."):
|
|
67
|
+
# logger = logging.getLogger(f"{cls.__module__}.{cls.__name__}")
|
|
68
|
+
# logger.debug(f"searching for key: {key} in dict : {cur_val}")
|
|
69
|
+
if key in cur_dic:
|
|
70
|
+
cur_dic = cur_dic[key]
|
|
71
|
+
# logger.debug(f"Found key: {key} = {cur_val}")
|
|
72
|
+
else:
|
|
73
|
+
return _undef
|
|
74
|
+
|
|
75
|
+
return cur_dic
|
|
76
|
+
|
|
77
|
+
def resolve_confvar(
|
|
78
|
+
self, *, var_to_solve: ConfigVarDef, scopes: Optional[str | Iterable[str]]
|
|
79
|
+
) -> Any:
|
|
80
|
+
"""
|
|
81
|
+
Assign a value to the given ConfigVarDef according to the search order:
|
|
82
|
+
1. values mapping
|
|
83
|
+
2. environment variables
|
|
84
|
+
3. configuration file
|
|
85
|
+
4. default value
|
|
86
|
+
Applies transformations and validations as specified in the ConfigVarDef.
|
|
87
|
+
If its a Path variable, process relative paths, create directories if needed.
|
|
88
|
+
"""
|
|
89
|
+
val_found: Path | str | None | list[Any] | Undefined = _undef
|
|
90
|
+
found = False
|
|
91
|
+
|
|
92
|
+
cur_flags = self._local_flags.merge(inplace=False, other=var_to_solve.flags)
|
|
93
|
+
assert cur_flags is not None
|
|
94
|
+
|
|
95
|
+
# if we are resolving the variables in some specific scopes (ie parameter scope has value)
|
|
96
|
+
# we check if the variable belongs to one of these scopes and if not we skip it, in this
|
|
97
|
+
# case the variable is said to be "out of scope" and has no existence.
|
|
98
|
+
if scopes is None and var_to_solve.scopes is not None:
|
|
99
|
+
return _undef
|
|
100
|
+
# if we are resolving the variable in some specific scopes (ie parameter scope has value) and the variable
|
|
101
|
+
# has also some scopes defined we check if there is at least one scope in common
|
|
102
|
+
if scopes is not None and var_to_solve.scopes is not None:
|
|
103
|
+
test_ctx = []
|
|
104
|
+
# put True in test_ctx for each scope that matches, False otherwise
|
|
105
|
+
if is_sequence(scopes):
|
|
106
|
+
test_ctx = [c in var_to_solve.scopes for c in scopes]
|
|
107
|
+
else:
|
|
108
|
+
test_ctx = [scopes in var_to_solve.scopes]
|
|
109
|
+
|
|
110
|
+
# test if there is any True in test_ctx which means that at least one scope matches
|
|
111
|
+
if not any(test_ctx):
|
|
112
|
+
return _undef
|
|
113
|
+
# if the variable has no scope and we are resolving in some specific scopes this means that
|
|
114
|
+
# the variable is common to all scopes so we process them but only once except if the flag
|
|
115
|
+
# allow_override is set to True
|
|
116
|
+
if var_to_solve.scopes is None and scopes is not None:
|
|
117
|
+
if not cur_flags.allow_override and var_to_solve.name in self._var_solved:
|
|
118
|
+
return _undef
|
|
119
|
+
|
|
120
|
+
# Searching variable value in the values mapping
|
|
121
|
+
if (
|
|
122
|
+
self._values_map is not None
|
|
123
|
+
and not cur_flags.no_value_search
|
|
124
|
+
and var_to_solve.value_key is not None
|
|
125
|
+
):
|
|
126
|
+
ctx = self._var_solved.to_dict()
|
|
127
|
+
# First Render value_key if needed
|
|
128
|
+
|
|
129
|
+
def value_checks(k: str) -> Any:
|
|
130
|
+
"""check if the key k exists in the values_map and return its value or _undef"""
|
|
131
|
+
if cur_flags.click_key_conversion:
|
|
132
|
+
k = k.lower().replace("-", "_")
|
|
133
|
+
return _undef if k not in self._values_map else self._values_map[k]
|
|
134
|
+
|
|
135
|
+
value_checked = resolve_candidate_list(
|
|
136
|
+
candidates=var_to_solve.value_key,
|
|
137
|
+
context=ctx,
|
|
138
|
+
check_exists=lambda k: value_checks(k),
|
|
139
|
+
)
|
|
140
|
+
# value_checked can be None meaning that the key exists in values_map with a None value which is the value to use
|
|
141
|
+
if value_checked != _undef:
|
|
142
|
+
# We found a key that exists in values_map and the checker has returned its value that can be None.
|
|
143
|
+
val_found = value_checked
|
|
144
|
+
found = True
|
|
145
|
+
self._logger.debug(
|
|
146
|
+
f"{var_to_solve.name} -> Found in Values = {val_found}"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Searching variable value in the environment variables
|
|
150
|
+
if (
|
|
151
|
+
not found
|
|
152
|
+
and not cur_flags.no_env_search
|
|
153
|
+
and var_to_solve.env_name is not None
|
|
154
|
+
):
|
|
155
|
+
ctx = self._var_solved.to_dict()
|
|
156
|
+
|
|
157
|
+
def check_env(k: str) -> Any:
|
|
158
|
+
"""check if the key k exists in the environment variables and return its value or _undef"""
|
|
159
|
+
res = os.getenv(k)
|
|
160
|
+
return _undef if res is None else res
|
|
161
|
+
|
|
162
|
+
value_checked = resolve_candidate_list(
|
|
163
|
+
candidates=var_to_solve.env_name, context=ctx, check_exists=check_env
|
|
164
|
+
)
|
|
165
|
+
# in env None means not found (there is no _undef in this case and the value can't be None)
|
|
166
|
+
if value_checked != _undef:
|
|
167
|
+
val_found = value_checked
|
|
168
|
+
found = True
|
|
169
|
+
self._logger.debug(f"{var_to_solve.name} -> Found in Env = {val_found}")
|
|
170
|
+
|
|
171
|
+
# Searching variable value in the configuration file
|
|
172
|
+
if (
|
|
173
|
+
not found
|
|
174
|
+
and not cur_flags.no_conffile_search
|
|
175
|
+
and self._conffile_values_map is not None
|
|
176
|
+
and var_to_solve.file_key is not None
|
|
177
|
+
):
|
|
178
|
+
ctx = self._var_solved.to_dict()
|
|
179
|
+
|
|
180
|
+
def check_file(k: str) -> Any:
|
|
181
|
+
"""check if the key k exists in configuration file and return its value or _undef"""
|
|
182
|
+
if self._conffile_values_map is None:
|
|
183
|
+
return False
|
|
184
|
+
return self._find_var_in_dict(self._conffile_values_map, k)
|
|
185
|
+
|
|
186
|
+
value_checked = resolve_candidate_list(
|
|
187
|
+
candidates=var_to_solve.file_key, context=ctx, check_exists=check_file
|
|
188
|
+
)
|
|
189
|
+
# None is a valid value from config file, _undef means not found
|
|
190
|
+
if value_checked != _undef:
|
|
191
|
+
val_found = value_checked
|
|
192
|
+
found = True
|
|
193
|
+
self._logger.debug(
|
|
194
|
+
f"{var_to_solve.name} -> Found in Configuration File = {val_found}"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Setting variable value to the default value if defined.
|
|
198
|
+
if (
|
|
199
|
+
var_to_solve.name not in self._var_solved
|
|
200
|
+
and not found
|
|
201
|
+
and var_to_solve.default is not _undef
|
|
202
|
+
):
|
|
203
|
+
if var_to_solve.default is not None and callable(var_to_solve.default):
|
|
204
|
+
val_found = self._eval_runner(
|
|
205
|
+
var_to_solve.default, var_to_solve.name, None
|
|
206
|
+
)
|
|
207
|
+
else:
|
|
208
|
+
val_found = render_template(
|
|
209
|
+
var_to_solve.default, self._var_solved.to_dict()
|
|
210
|
+
)
|
|
211
|
+
found = True
|
|
212
|
+
self._logger.debug(
|
|
213
|
+
f"{var_to_solve.name} -> Found in Default Value = {val_found}"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Raise exception if no value was found and variable is mandatory and if we have not
|
|
217
|
+
# already a value for this variable (in case of override with scopes for example)
|
|
218
|
+
if (
|
|
219
|
+
not found
|
|
220
|
+
and var_to_solve.mandatory
|
|
221
|
+
and var_to_solve.name not in self._var_solved
|
|
222
|
+
):
|
|
223
|
+
raise AppConfigException(
|
|
224
|
+
f"No value for var {var_to_solve.name} in scope {scopes}"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
if found:
|
|
228
|
+
if isinstance(val_found, Undefined):
|
|
229
|
+
raise AssertionError(
|
|
230
|
+
"Internal error: val_found is _undef while found=True"
|
|
231
|
+
)
|
|
232
|
+
# spliting lists
|
|
233
|
+
if (
|
|
234
|
+
var_to_solve.split_to_list
|
|
235
|
+
and val_found is not None
|
|
236
|
+
and isinstance(val_found, str)
|
|
237
|
+
):
|
|
238
|
+
sep: str = (
|
|
239
|
+
","
|
|
240
|
+
if isinstance(var_to_solve.split_to_list, bool)
|
|
241
|
+
else var_to_solve.split_to_list
|
|
242
|
+
)
|
|
243
|
+
val_found = val_found.split(sep)
|
|
244
|
+
|
|
245
|
+
# Transform the variable value if specified.
|
|
246
|
+
if var_to_solve.transform is not None:
|
|
247
|
+
if isinstance(var_to_solve.transform, str):
|
|
248
|
+
ctx = self._var_solved.to_dict()
|
|
249
|
+
ctx["value"] = val_found
|
|
250
|
+
val_transfo = render_template(var_to_solve.transform, ctx)
|
|
251
|
+
else:
|
|
252
|
+
val_transfo = self._eval_runner(
|
|
253
|
+
var_to_solve.transform,
|
|
254
|
+
var_to_solve.name,
|
|
255
|
+
val_found,
|
|
256
|
+
)
|
|
257
|
+
self._logger.debug(
|
|
258
|
+
f"{var_to_solve.name} -> Value Transformed: {val_found} => {val_transfo}"
|
|
259
|
+
)
|
|
260
|
+
val_found = val_transfo
|
|
261
|
+
|
|
262
|
+
# Validate and cast the variable value using pydantic TypeAdapter
|
|
263
|
+
if var_to_solve._type_info is not None and val_found is not None:
|
|
264
|
+
try:
|
|
265
|
+
ta = TypeAdapter(var_to_solve._type_info)
|
|
266
|
+
val_found = ta.validate_python(val_found)
|
|
267
|
+
except ValidationError as e:
|
|
268
|
+
raise AppConfigException(
|
|
269
|
+
f"Validation failed for var {var_to_solve.name}: {e}"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Make special treatment for paths
|
|
273
|
+
if (
|
|
274
|
+
not var_to_solve.no_dir_processing
|
|
275
|
+
and var_to_solve._type_info == Path
|
|
276
|
+
and val_found is not None
|
|
277
|
+
):
|
|
278
|
+
new_val: list[Path] | Path | None = None
|
|
279
|
+
if is_sequence(val_found):
|
|
280
|
+
# we need to consider the case where the variable is a list of Path
|
|
281
|
+
new_val = []
|
|
282
|
+
for cval in cast(Iterable, val_found):
|
|
283
|
+
res = self._process_paths(
|
|
284
|
+
value=cval, var=var_to_solve, var_solved=self._var_solved
|
|
285
|
+
)
|
|
286
|
+
new_val.append(res)
|
|
287
|
+
else:
|
|
288
|
+
res = self._process_paths(
|
|
289
|
+
value=val_found, var=var_to_solve, var_solved=self._var_solved
|
|
290
|
+
)
|
|
291
|
+
new_val = res
|
|
292
|
+
val_found = new_val
|
|
293
|
+
|
|
294
|
+
elif var_to_solve.name not in self._var_solved:
|
|
295
|
+
# in case of overiding and when on subsenquent call no value were found we don't want to erase a value
|
|
296
|
+
# found on a previous run. This can happen when some global flags are overriden or for variable without
|
|
297
|
+
# context.
|
|
298
|
+
val_found = None
|
|
299
|
+
|
|
300
|
+
return val_found
|
|
301
|
+
|
|
302
|
+
def _process_paths(
|
|
303
|
+
self, *, value: Any, var: ConfigVarDef, var_solved: AttrDict
|
|
304
|
+
) -> Path:
|
|
305
|
+
"""
|
|
306
|
+
Run the path processing for a single Path variable.
|
|
307
|
+
"""
|
|
308
|
+
var_value: Path
|
|
309
|
+
if isinstance(value, str):
|
|
310
|
+
var_value = Path(value)
|
|
311
|
+
elif isinstance(value, Path):
|
|
312
|
+
var_value = value
|
|
313
|
+
else:
|
|
314
|
+
raise Exception("not a path")
|
|
315
|
+
|
|
316
|
+
res: Path = var_value
|
|
317
|
+
# First process the can_be_relative_to case.
|
|
318
|
+
if var.can_be_relative_to is not None:
|
|
319
|
+
|
|
320
|
+
if var_value.is_absolute():
|
|
321
|
+
res = var_value
|
|
322
|
+
else:
|
|
323
|
+
# resolving the root directory from which to be relative to.
|
|
324
|
+
can_be_relative = var.can_be_relative_to
|
|
325
|
+
root_dir: Path
|
|
326
|
+
if (
|
|
327
|
+
isinstance(can_be_relative, str)
|
|
328
|
+
and can_be_relative in var_solved
|
|
329
|
+
and var_solved[can_be_relative] is not None
|
|
330
|
+
and var_solved[can_be_relative] != _undef
|
|
331
|
+
):
|
|
332
|
+
root_dir = Path(var_solved[can_be_relative])
|
|
333
|
+
else:
|
|
334
|
+
root_dir = Path(can_be_relative)
|
|
335
|
+
res = root_dir.joinpath(var_value)
|
|
336
|
+
|
|
337
|
+
# Expanding user home and resolving dots in path
|
|
338
|
+
res = res.expanduser().resolve()
|
|
339
|
+
|
|
340
|
+
# create directory if make_dirs is not None
|
|
341
|
+
if var.make_dirs:
|
|
342
|
+
if var.make_dirs == PathType.Dir:
|
|
343
|
+
res.mkdir(parents=True, exist_ok=True)
|
|
344
|
+
else:
|
|
345
|
+
res.parent.mkdir(parents=True, exist_ok=True)
|
|
346
|
+
return res
|
the1conf/utils.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
import pytest
|
|
5
|
+
except ImportError:
|
|
6
|
+
pytest = None
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
import inspect
|
|
10
|
+
import textwrap
|
|
11
|
+
from typing import TypeAlias, Any
|
|
12
|
+
from enum import Enum, IntFlag, auto
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AppConfigException(Exception):
|
|
16
|
+
def __init__(self, message: str) -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.message = message
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NameSpace:
|
|
22
|
+
"""Marker class for configuration namespaces.
|
|
23
|
+
Used to group configuration variables into nested namespaces within an AppConfig subclass.
|
|
24
|
+
See AppConfig documentation for more information.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PathType(Enum):
|
|
31
|
+
Dir = auto()
|
|
32
|
+
File = auto()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_attribute_docstrings(cls: type) -> dict[str, str]:
|
|
36
|
+
"""
|
|
37
|
+
Extracts docstrings for class attributes by parsing the source code.
|
|
38
|
+
This allows using docstrings to populate ConfigVarDef.help.
|
|
39
|
+
|
|
40
|
+
currently PEP257 is not yet implemented in python standard library so we need to parse the source code ourselves.
|
|
41
|
+
This can slow down the class creation but this is done at class creation time only.
|
|
42
|
+
"""
|
|
43
|
+
docstrings = {}
|
|
44
|
+
try:
|
|
45
|
+
source = inspect.getsource(cls)
|
|
46
|
+
# Deduct to handle nested classes indentation
|
|
47
|
+
source = textwrap.dedent(source)
|
|
48
|
+
except (OSError, TypeError):
|
|
49
|
+
# Source code not available (e.g. dynamic class, REPL, compiled files)
|
|
50
|
+
return {}
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
tree = ast.parse(source)
|
|
54
|
+
except SyntaxError:
|
|
55
|
+
return {}
|
|
56
|
+
|
|
57
|
+
# We look for the ClassDef in the parsed source.
|
|
58
|
+
# Since inspect.getsource(cls) returns the class definition itself,
|
|
59
|
+
# the first node in body should be the ClassDef.
|
|
60
|
+
for node in tree.body:
|
|
61
|
+
if isinstance(node, ast.ClassDef):
|
|
62
|
+
for i, item in enumerate(node.body):
|
|
63
|
+
if isinstance(item, (ast.Assign, ast.AnnAssign)):
|
|
64
|
+
# Check if the next node is an expression containing a string (docstring)
|
|
65
|
+
if i + 1 < len(node.body):
|
|
66
|
+
next_node = node.body[i + 1]
|
|
67
|
+
if (
|
|
68
|
+
isinstance(next_node, ast.Expr)
|
|
69
|
+
and isinstance(next_node.value, ast.Constant)
|
|
70
|
+
and isinstance(next_node.value.value, str)
|
|
71
|
+
):
|
|
72
|
+
docstring = next_node.value.value.strip()
|
|
73
|
+
|
|
74
|
+
targets = []
|
|
75
|
+
if isinstance(item, ast.Assign):
|
|
76
|
+
targets = item.targets
|
|
77
|
+
elif isinstance(item, ast.AnnAssign):
|
|
78
|
+
targets = [item.target]
|
|
79
|
+
|
|
80
|
+
for target in targets:
|
|
81
|
+
if isinstance(target, ast.Name):
|
|
82
|
+
docstrings[target.id] = docstring
|
|
83
|
+
# We found the class def, no need to continue in top level
|
|
84
|
+
break
|
|
85
|
+
|
|
86
|
+
return docstrings
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_sequence(obj: Any) -> bool:
|
|
90
|
+
"""test if obj is a sequence (list, tuple, etc...) but not a string"""
|
|
91
|
+
try:
|
|
92
|
+
len(obj)
|
|
93
|
+
obj[0:0]
|
|
94
|
+
return not isinstance(obj, str)
|
|
95
|
+
except KeyError:
|
|
96
|
+
return False
|
|
97
|
+
except TypeError:
|
|
98
|
+
return False # TypeError: object is not iterable
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _UndefinedSentinel:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
Undefined: TypeAlias = _UndefinedSentinel
|
|
106
|
+
_undef: Undefined = _UndefinedSentinel()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if pytest:
|
|
110
|
+
pass
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import jinja2
|
|
4
|
+
from typing import Any, Callable, Mapping, Optional, Sequence, Union, cast
|
|
5
|
+
from .utils import is_sequence, _undef
|
|
6
|
+
|
|
7
|
+
RenderContext = Mapping[str, Any]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def render_template(value: Union[str, Any], context: RenderContext) -> Any:
|
|
11
|
+
"""
|
|
12
|
+
Render a string as a jinja2 template using the provided context.
|
|
13
|
+
If value is not a string, return it as is.
|
|
14
|
+
"""
|
|
15
|
+
if isinstance(value, str):
|
|
16
|
+
return jinja2.Template(value).render(context)
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_candidate_list(
|
|
21
|
+
*,
|
|
22
|
+
candidates: Optional[Union[str, Sequence[str]]],
|
|
23
|
+
context: RenderContext,
|
|
24
|
+
check_exists: Optional[Callable[[str], Any]] = None,
|
|
25
|
+
) -> Optional[Any]:
|
|
26
|
+
"""
|
|
27
|
+
Resolve a list of candidate strings (which can be Jinja2 templates) to find the first valid one.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
candidates: A single string or a list of strings (potentially templates).
|
|
31
|
+
context: The context used for rendering templates.
|
|
32
|
+
check_exists: An optional predicate to validate the rendered string.
|
|
33
|
+
returns _undef if not valid, or the value to return if valid.
|
|
34
|
+
Returns:
|
|
35
|
+
The first successfully resolved and validated candidate string or the first not None value return by check_exists, or None if no match is found.
|
|
36
|
+
"""
|
|
37
|
+
if candidates is None:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
candidates_list: list[str] = []
|
|
41
|
+
if isinstance(candidates, str):
|
|
42
|
+
candidates_list = [candidates]
|
|
43
|
+
elif is_sequence(candidates):
|
|
44
|
+
candidates_list = cast(list[str], candidates)
|
|
45
|
+
|
|
46
|
+
for raw_candidate in candidates_list:
|
|
47
|
+
rendered = render_template(raw_candidate, context)
|
|
48
|
+
if rendered is None:
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
if isinstance(rendered, str):
|
|
52
|
+
if check_exists is not None:
|
|
53
|
+
res = check_exists(rendered)
|
|
54
|
+
if res != _undef:
|
|
55
|
+
return res
|
|
56
|
+
else:
|
|
57
|
+
return rendered
|
|
58
|
+
|
|
59
|
+
return _undef
|