the1conf 1.0.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 +4 -0
- the1conf/app_config.py +643 -0
- the1conf/attr_dict.py +155 -0
- the1conf/click_option.py +35 -0
- the1conf/config_var.py +626 -0
- the1conf/py.typed +0 -0
- the1conf-1.0.0.dist-info/METADATA +516 -0
- the1conf-1.0.0.dist-info/RECORD +10 -0
- the1conf-1.0.0.dist-info/WHEEL +4 -0
- the1conf-1.0.0.dist-info/licenses/LICENSE +21 -0
the1conf/__init__.py
ADDED
the1conf/app_config.py
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import importlib
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import copy
|
|
8
|
+
import os
|
|
9
|
+
import ast
|
|
10
|
+
import inspect
|
|
11
|
+
import textwrap
|
|
12
|
+
from collections.abc import Iterable, Mapping, MutableMapping, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from enum import Enum, auto
|
|
15
|
+
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
|
+
)
|
|
31
|
+
|
|
32
|
+
import yaml
|
|
33
|
+
|
|
34
|
+
from .attr_dict import AttrDict, is_sequence
|
|
35
|
+
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
|
+
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class AppConfig(AttrDict):
|
|
106
|
+
""" "
|
|
107
|
+
A class to manage application configuration. It can be passed from callable to callable.
|
|
108
|
+
It can search for variable values in command line arguments, environment variables, a yaml or json configuration file
|
|
109
|
+
or a default value or a computed value.
|
|
110
|
+
|
|
111
|
+
Configuration variable are defined with a list of ConfigVarDef object which defines how to look for the variable values and/or compute them.
|
|
112
|
+
|
|
113
|
+
The variable values are searched in these locations in the following order:
|
|
114
|
+
- a dict of values usually passed from the command line arguments
|
|
115
|
+
- environment variables
|
|
116
|
+
- a configuration file in yaml or json format
|
|
117
|
+
- a default value or a computed value
|
|
118
|
+
The first match found is used as the value for the variable.
|
|
119
|
+
|
|
120
|
+
When found the variable can be casted to a specific type and/or transformed with a callable.
|
|
121
|
+
|
|
122
|
+
The default value can also be computed with a callable that can use already defined variables in this AppConfig object.
|
|
123
|
+
One can combine a default value and a transformation callable to compute complex default values that can combine several already defined variables,
|
|
124
|
+
knowing that variables are resolved in the order they are declared in the AppConfig subclass.
|
|
125
|
+
|
|
126
|
+
See method 'resolve_vars()' and class 'ConfigVarDef' for more information.
|
|
127
|
+
|
|
128
|
+
Variable can also be set directly like in an object attribute or in a Dict key:
|
|
129
|
+
|
|
130
|
+
ctx = AppConfig()
|
|
131
|
+
ctx["var"] = 2
|
|
132
|
+
print(ct.var) # print 2
|
|
133
|
+
|
|
134
|
+
Accessing the variables can also be done like an object attribute or a Dict key:
|
|
135
|
+
ctx = AppConfig()
|
|
136
|
+
ctx.var = 2
|
|
137
|
+
print(ctx["var"]) # print 2
|
|
138
|
+
|
|
139
|
+
This can be useful to mix known variable name and variable dynamically defined in a file or passed from the command line.
|
|
140
|
+
def foo(ctx: AppConfig,attname:Any) -> Any:
|
|
141
|
+
return ctx[attname]
|
|
142
|
+
|
|
143
|
+
Variable can contain variable recursively, in this case they can be access with a dotted notation or with a dict like way:
|
|
144
|
+
ctx = AppConfig()
|
|
145
|
+
ctx["var1.var2"] = 2
|
|
146
|
+
ctx["var1.var3"] = 3
|
|
147
|
+
print(f"var1 = {ctx.var1}") # print a dict {"var2":2,"var3":3}
|
|
148
|
+
|
|
149
|
+
print(ctx.var1.var2) # print 2
|
|
150
|
+
|
|
151
|
+
Declarative Configuration:
|
|
152
|
+
--------------------------
|
|
153
|
+
Configuration is best defined declaratively by subclassing AppConfig:
|
|
154
|
+
|
|
155
|
+
class MyConfig(AppConfig):
|
|
156
|
+
host: str = configvar(Default="localhost")
|
|
157
|
+
port: int = configvar(Default=8080)
|
|
158
|
+
|
|
159
|
+
Nested Namespaces:
|
|
160
|
+
------------------
|
|
161
|
+
Variables can be grouped into namespaces using nested classes inheriting from NameSpace.
|
|
162
|
+
This creates a structured configuration:
|
|
163
|
+
|
|
164
|
+
class MyConfig(AppConfig):
|
|
165
|
+
# Root level variable
|
|
166
|
+
debug: bool = configvar(Default=False)
|
|
167
|
+
|
|
168
|
+
# Nested namespace 'db'
|
|
169
|
+
class db(NameSpace):
|
|
170
|
+
host: str = configvar(Default="db.local")
|
|
171
|
+
port: int = configvar(Default=5432)
|
|
172
|
+
|
|
173
|
+
# Nested namespace 'api'
|
|
174
|
+
class api(NameSpace):
|
|
175
|
+
key: str = configvar(EnvName="API_KEY")
|
|
176
|
+
|
|
177
|
+
inst = MyConfig()
|
|
178
|
+
inst.resolve_vars()
|
|
179
|
+
print(inst.db.host) # Access nested variables
|
|
180
|
+
|
|
181
|
+
Decentralized Configuration (Mixins):
|
|
182
|
+
-------------------------------------
|
|
183
|
+
Configuration can be split across multiple classes (Mixins) and combined using inheritance.
|
|
184
|
+
This allows different modules to define their own configuration requirements.
|
|
185
|
+
|
|
186
|
+
class DatabaseConfig(AppConfig):
|
|
187
|
+
class db(NameSpace):
|
|
188
|
+
host: str = configvar(Default="localhost")
|
|
189
|
+
|
|
190
|
+
class ApiConfig(AppConfig):
|
|
191
|
+
class api(NameSpace):
|
|
192
|
+
timeout: int = configvar(Default=30)
|
|
193
|
+
|
|
194
|
+
# Combine into the final application config
|
|
195
|
+
class App(DatabaseConfig, ApiConfig):
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
If multiple mixins define the same namespace (e.g. `class db(NameSpace)`), their variables are merged.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
#: The logger for this class
|
|
202
|
+
_logger = logging.getLogger(__name__)
|
|
203
|
+
_solver_logger = logging.getLogger(f"{__name__}.solver")
|
|
204
|
+
_global_flags: Flags
|
|
205
|
+
_config_var_defs: list[ConfigVarDef]
|
|
206
|
+
|
|
207
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
208
|
+
"""
|
|
209
|
+
Call when a subclass inherits from AppConfig.
|
|
210
|
+
|
|
211
|
+
Create the _config_var_defs attribute that contains the list of ConfigVarDef
|
|
212
|
+
defined in this subclass as well as the ones defined in parent classes.
|
|
213
|
+
"""
|
|
214
|
+
super().__init_subclass__(**kwargs)
|
|
215
|
+
|
|
216
|
+
# First try to look if there is already a _config_var_defs attribute in one of the parent classes.
|
|
217
|
+
collected: list[ConfigVarDef] = []
|
|
218
|
+
seen_attr_names: set[str] = set()
|
|
219
|
+
for base in reversed(cls.__mro__[1:]):
|
|
220
|
+
# from the deepest parent class to the nearest parent class: search if there
|
|
221
|
+
# is a _config_var_defs attribute in the inspected class.
|
|
222
|
+
base_defs = getattr(base, "_config_var_defs", None)
|
|
223
|
+
if not base_defs:
|
|
224
|
+
continue
|
|
225
|
+
for var_def in base_defs:
|
|
226
|
+
# base_defs is the _config_var_defs list. Loops on each of its elements and store them in the collected list
|
|
227
|
+
# For each one read its name. If the variable name is already in the collected list, it means that it has been
|
|
228
|
+
# defined in a parent class of the current inspected base class. So we remove the old definition and add the new one.
|
|
229
|
+
attr_name = getattr(var_def, "_name", None)
|
|
230
|
+
if attr_name:
|
|
231
|
+
if attr_name in seen_attr_names:
|
|
232
|
+
collected = [
|
|
233
|
+
v for v in collected if getattr(v, "_name", None) != attr_name
|
|
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
|
|
281
|
+
|
|
282
|
+
def __init__(
|
|
283
|
+
self,
|
|
284
|
+
*,
|
|
285
|
+
no_env_search: Optional[bool] = None,
|
|
286
|
+
no_key_search: Optional[bool] = None,
|
|
287
|
+
no_conffile_search: Optional[bool] = None,
|
|
288
|
+
no_search: Optional[bool] = None,
|
|
289
|
+
conffile_path: Optional[Path] = None,
|
|
290
|
+
allow_override: Optional[bool] = None,
|
|
291
|
+
click_key_conversion: Optional[bool] = None,
|
|
292
|
+
):
|
|
293
|
+
"""Init the config.
|
|
294
|
+
|
|
295
|
+
Arguments are called 'global flags', they specify the behavior of the variable resolution globaly. They can be overiden
|
|
296
|
+
when the resolve method is call. (this method can be call several time and thus on each call some flags can be changed) or
|
|
297
|
+
per variable when defining the ConfigVarDef object.
|
|
298
|
+
|
|
299
|
+
Global Flags:
|
|
300
|
+
no_env_search (bool, optional): don't search values in the environment. Defaults to False.
|
|
301
|
+
no_key_search (bool, optional): don't search values in the value dictionary. Defaults to False.
|
|
302
|
+
no_conffile_search (bool, optional): don't search values in the configuration file. Defaults to False.
|
|
303
|
+
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
|
+
allow_override (bool, optional): allow overriding variable in the configuration when calling resolve_vars.
|
|
306
|
+
it happens either when resolve_vars() is call several times or the variable was set before its call.
|
|
307
|
+
click_key_conversion (bool, optional): use the variable name converted to lower case and with dashes converted to underscores as
|
|
308
|
+
the key to search in values. Defaults to False.
|
|
309
|
+
"""
|
|
310
|
+
self._global_flags = Flags(
|
|
311
|
+
no_env_search=no_env_search,
|
|
312
|
+
no_value_search=no_key_search,
|
|
313
|
+
no_conffile_search=no_conffile_search,
|
|
314
|
+
no_search=no_search,
|
|
315
|
+
click_key_conversion=click_key_conversion,
|
|
316
|
+
allow_override=allow_override,
|
|
317
|
+
)
|
|
318
|
+
self.conffile_path = conffile_path
|
|
319
|
+
# Configuration variable definitions used by resolve_vars().
|
|
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")
|
|
326
|
+
|
|
327
|
+
def clone(self) -> AppConfig:
|
|
328
|
+
"""cloning the AppConfig object"""
|
|
329
|
+
clone = AppConfig()
|
|
330
|
+
clone.__dict__ = self.__dict__.copy()
|
|
331
|
+
return clone
|
|
332
|
+
|
|
333
|
+
@classmethod
|
|
334
|
+
def _find_var_in_dict(cls, where: Mapping[Any, Any], var_name: str) -> Any | Undefined:
|
|
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
|
|
352
|
+
|
|
353
|
+
return cur_dic
|
|
354
|
+
|
|
355
|
+
def _run_eval(
|
|
356
|
+
self,
|
|
357
|
+
callable: Callable[[str, Any, Any], Any],
|
|
358
|
+
var_val: Any = None,
|
|
359
|
+
var_name: str = "",
|
|
360
|
+
) -> Any:
|
|
361
|
+
"""
|
|
362
|
+
Call a Callable found in a Eval Form.
|
|
363
|
+
"""
|
|
364
|
+
return callable(var_name, self, var_val)
|
|
365
|
+
|
|
366
|
+
def _set_value(self, name: str, value: Any) -> None:
|
|
367
|
+
"""Set a value.
|
|
368
|
+
|
|
369
|
+
Depending on AttrDict implementation to handle dotted keys and not trigger recursion loops
|
|
370
|
+
if called from a descriptor.
|
|
371
|
+
"""
|
|
372
|
+
self[name] = value
|
|
373
|
+
|
|
374
|
+
def resolve_vars(
|
|
375
|
+
self,
|
|
376
|
+
*,
|
|
377
|
+
contexts: Optional[str | Iterable[str]] = None,
|
|
378
|
+
values: Mapping[str, Any] = {},
|
|
379
|
+
no_exception: Optional[bool] = None,
|
|
380
|
+
no_env_search: Optional[bool] = None,
|
|
381
|
+
no_value_search: Optional[bool] = None,
|
|
382
|
+
no_conffile_search: Optional[bool] = None,
|
|
383
|
+
no_search: Optional[bool] = None,
|
|
384
|
+
conffile_path: Optional[Path] = None,
|
|
385
|
+
allow_override: Optional[bool] = None,
|
|
386
|
+
click_key_conversion: Optional[bool] = None,
|
|
387
|
+
) -> Any:
|
|
388
|
+
"""
|
|
389
|
+
Resolve the configuration variables defined in this AppConfig object.
|
|
390
|
+
The variable values are searched in these locations in the following order:
|
|
391
|
+
- a dict of values usually passed from the command line arguments
|
|
392
|
+
- environment variables
|
|
393
|
+
- a configuration file in yaml or json format
|
|
394
|
+
- a default value or a computed value
|
|
395
|
+
The first match found is used as the value for the variable.
|
|
396
|
+
When found the variable can be casted to a specific type and/or transformed with a callable.
|
|
397
|
+
The default value can also be computed with a callable that can use already defined variables in this AppConfig object.
|
|
398
|
+
One can combine a default value and a transformation callable to compute complex default values that can combine several already defined variables,
|
|
399
|
+
knowing that variables are resolved in the order they are declared in the AppConfig subclass.
|
|
400
|
+
"""
|
|
401
|
+
|
|
402
|
+
self._logger.info(
|
|
403
|
+
f"Starting variable resolution, conf file = {str(conffile_path)} "
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# Compute flags that can be override locally.
|
|
407
|
+
resolve_flags = Flags(
|
|
408
|
+
no_env_search=no_env_search,
|
|
409
|
+
no_value_search=no_value_search,
|
|
410
|
+
no_conffile_search=no_conffile_search,
|
|
411
|
+
no_search=no_search,
|
|
412
|
+
click_key_conversion=click_key_conversion,
|
|
413
|
+
allow_override=allow_override,
|
|
414
|
+
no_exception=no_exception,
|
|
415
|
+
)
|
|
416
|
+
local_flags = self._global_flags.merge(inplace=False, other=resolve_flags)
|
|
417
|
+
assert local_flags is not None
|
|
418
|
+
|
|
419
|
+
# Read configuration file if requiered
|
|
420
|
+
conf_file_vars = None
|
|
421
|
+
cur_conffile_path = conffile_path if conffile_path else self.conffile_path
|
|
422
|
+
if not local_flags.no_conffile_search and cur_conffile_path is not None:
|
|
423
|
+
if cur_conffile_path.is_file():
|
|
424
|
+
with cur_conffile_path.open("r") as f:
|
|
425
|
+
if cur_conffile_path.suffix.lower() == ".json":
|
|
426
|
+
self._logger.debug(f"Parsing json file {cur_conffile_path}")
|
|
427
|
+
conf_file_vars = json.load(f)
|
|
428
|
+
else:
|
|
429
|
+
self._logger.debug(
|
|
430
|
+
f"Parsing file {cur_conffile_path} with suffix {cur_conffile_path.suffix.lower()}"
|
|
431
|
+
)
|
|
432
|
+
conf_file_vars = yaml.safe_load(f)
|
|
433
|
+
else:
|
|
434
|
+
self._logger.info(f"configuration file {cur_conffile_path} not found.")
|
|
435
|
+
|
|
436
|
+
# Read the var definitions one by one in their definition order
|
|
437
|
+
for var in self._config_var_defs:
|
|
438
|
+
# compute the current flags for this variable by merging local flags with variable flags
|
|
439
|
+
cur_flags = local_flags.merge(inplace=False, other=var.flags)
|
|
440
|
+
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
|
+
|
|
445
|
+
# if we are resolving the variables in some specific contexts (ie parameter context has value)
|
|
446
|
+
# we check if the variable belongs to one of these contexts and if not we skip it.
|
|
447
|
+
if contexts is None and var.Contexts is not None:
|
|
448
|
+
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
|
+
|
|
457
|
+
# test if there is any True in test_ctx which means that at least one context matches
|
|
458
|
+
if not any(test_ctx):
|
|
459
|
+
continue
|
|
460
|
+
|
|
461
|
+
if var.Contexts is None and contexts is not None:
|
|
462
|
+
# variable without context are common to all contexts so we process them but only once.
|
|
463
|
+
if var.Name in self.__dict__:
|
|
464
|
+
continue
|
|
465
|
+
|
|
466
|
+
# if the variable is already defined and override is not allowed then
|
|
467
|
+
# we skip it (First valid context wins).
|
|
468
|
+
if not cur_flags.allow_override and var.Name in self.__dict__:
|
|
469
|
+
continue
|
|
470
|
+
|
|
471
|
+
val_found: str | None | list[Any] | Undefined = _undef
|
|
472
|
+
found = False
|
|
473
|
+
|
|
474
|
+
# Searching variable value in the values mapping
|
|
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
|
+
)
|
|
527
|
+
|
|
528
|
+
# Raise exception if no value was found and no_exception is True
|
|
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
|
+
)
|
|
533
|
+
|
|
534
|
+
if found:
|
|
535
|
+
if isinstance(val_found, _UndefinedSentinel):
|
|
536
|
+
raise AssertionError(
|
|
537
|
+
"Internal error: val_found is _undef while found=True"
|
|
538
|
+
)
|
|
539
|
+
# spliting lists
|
|
540
|
+
if (
|
|
541
|
+
var.SplitToList
|
|
542
|
+
and val_found is not None
|
|
543
|
+
and isinstance(val_found, str)
|
|
544
|
+
):
|
|
545
|
+
sep: str = (
|
|
546
|
+
"," if isinstance(var.SplitToList, bool) else var.SplitToList
|
|
547
|
+
)
|
|
548
|
+
val_found = val_found.split(sep)
|
|
549
|
+
|
|
550
|
+
# Transform the variable value if specified.
|
|
551
|
+
if var.Transform is not None:
|
|
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:
|
|
598
|
+
"""
|
|
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
|
+
|
|
627
|
+
# Expanding user home and resolving dots in path
|
|
628
|
+
res = res.expanduser().resolve()
|
|
629
|
+
|
|
630
|
+
# create directory if MakeDir is not None
|
|
631
|
+
if var.MakeDirs:
|
|
632
|
+
if var.MakeDirs == PathType.Dir:
|
|
633
|
+
res.mkdir(parents=True, exist_ok=True)
|
|
634
|
+
else:
|
|
635
|
+
res.parent.mkdir(parents=True, exist_ok=True)
|
|
636
|
+
return res
|
|
637
|
+
|
|
638
|
+
def __repr__(self) -> str:
|
|
639
|
+
mess = ["App Context:"]
|
|
640
|
+
for cur_var, cur_val in self.__dict__.items():
|
|
641
|
+
mess.append(f"\t{cur_var}:\t{cur_val}")
|
|
642
|
+
|
|
643
|
+
return "\n".join(mess)
|