the1conf 1.0.0__py3-none-any.whl → 1.2.3__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/app_config.py CHANGED
@@ -1,108 +1,31 @@
1
1
  from __future__ import annotations
2
2
 
3
- import dataclasses
4
- import importlib
3
+ import copy
4
+
5
5
  import json
6
6
  import logging
7
- import copy
8
7
  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
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
- pass
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(Default="localhost")
157
- port: int = configvar(Default=8080)
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(Default=False)
89
+ debug: bool = configvar(default=False)
167
90
 
168
91
  # Nested namespace 'db'
169
92
  class db(NameSpace):
170
- host: str = configvar(Default="db.local")
171
- port: int = configvar(Default=5432)
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(EnvName="API_KEY")
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(Default="localhost")
111
+ host: str = configvar(default="localhost")
189
112
 
190
113
  class ApiConfig(AppConfig):
191
114
  class api(NameSpace):
192
- timeout: int = configvar(Default=30)
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: list[ConfigVarDef]
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
- # 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
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
- 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")
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
- @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
217
+ def _set_value(self, name: str, value: Any) -> None:
218
+ """Set a value.
352
219
 
353
- return cur_dic
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 _run_eval(
225
+ def _get_eval_runner(
356
226
  self,
357
- callable: Callable[[str, Any, Any], Any],
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
- return callable(var_name, self, var_val)
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
- def _set_value(self, name: str, value: Any) -> None:
367
- """Set a value.
261
+ # Build context for Jinja with current values resolved
262
+ context = self.to_dict()
368
263
 
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
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
- contexts: Optional[str | Iterable[str]] = None,
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
- ) -> Any:
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
- 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():
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 var in self._config_var_defs:
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=var.flags)
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 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:
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
- # 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
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
- # 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__:
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
- val_found: str | None | list[Any] | Undefined = _undef
472
- found = False
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
- # 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
- )
417
+ dumper[key] = val
527
418
 
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
- )
419
+ return cast(dict[str, Any], dumper.to_dict())
533
420
 
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:
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
- # Expanding user home and resolving dots in path
628
- res = res.expanduser().resolve()
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
- # 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)
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
- res.parent.mkdir(parents=True, exist_ok=True)
636
- return res
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
- mess = ["App Context:"]
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
- return "\n".join(mess)
510
+ def __str__(self) -> str:
511
+ return json.dumps(self.serialize(), indent=2, sort_keys=True, default=str)