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/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