psyplus 0.9.2__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.
- psyplus/__init__.py +2 -0
- psyplus/env_var_handler.py +245 -0
- psyplus/field_container.py +173 -0
- psyplus/psyplus.py +223 -0
- psyplus/utils.py +205 -0
- psyplus/yaml_pydantic_metadata_comment_injector.py +137 -0
- psyplus-0.9.2.dist-info/LICENSE +21 -0
- psyplus-0.9.2.dist-info/METADATA +241 -0
- psyplus-0.9.2.dist-info/RECORD +11 -0
- psyplus-0.9.2.dist-info/WHEEL +5 -0
- psyplus-0.9.2.dist-info/top_level.txt +1 -0
psyplus/__init__.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
from typing import Tuple, List, Dict, Any, get_args, get_origin
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from pydantic_settings import BaseSettings
|
|
4
|
+
from pydantic_core import PydanticUndefined
|
|
5
|
+
import os
|
|
6
|
+
from inspect import isclass
|
|
7
|
+
from psyplus.field_container import FieldInfoContainer
|
|
8
|
+
from psyplus.utils import (
|
|
9
|
+
clean_annotation,
|
|
10
|
+
get_dict_val_key_insensitive,
|
|
11
|
+
env_to_nested_dict,
|
|
12
|
+
)
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
######
|
|
17
|
+
# WIP - needs total rewrite
|
|
18
|
+
######
|
|
19
|
+
# This is gargabe atm.
|
|
20
|
+
# pydantic-settings 2.x does most the thing now i tried to achieve here.
|
|
21
|
+
# todo: only thing left is indexes env var e.g. `MY_LIST__0=1`,`MY_DICT__KEY1=2`, `MY_OBJ_LIST__0__OBJ0_ATTR=Hello`
|
|
22
|
+
#
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ListitemPlaceholder:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EnvVarHandler:
|
|
30
|
+
def __init__(self, settings: BaseSettings | BaseModel):
|
|
31
|
+
self.settings: BaseSettings | BaseModel = settings
|
|
32
|
+
self.settings_as_dict: BaseSettings | BaseModel = settings.model_dump()
|
|
33
|
+
self.env_var_delimiter, self.env_var_prefix = self._get_env_var_seps()
|
|
34
|
+
self.env_vars: Dict[str, str] = self._get_env_vars()
|
|
35
|
+
for key in self.env_vars.keys():
|
|
36
|
+
val_dic = self.get_value_dict_by_env_var_key(key)
|
|
37
|
+
if val_dic:
|
|
38
|
+
self._deep_merge(self.settings_as_dict, val_dic)
|
|
39
|
+
self.settings = settings.__class__.model_validate(self.settings_as_dict)
|
|
40
|
+
|
|
41
|
+
def get_value_dict_by_env_var_key(
|
|
42
|
+
self, env_var_key: str, default: Any = PydanticUndefined
|
|
43
|
+
) -> Dict | None:
|
|
44
|
+
try:
|
|
45
|
+
val = self.env_vars[env_var_key]
|
|
46
|
+
except KeyError:
|
|
47
|
+
prefix_hint = ""
|
|
48
|
+
if self.env_var_prefix and env_var_key.startswith(self.env_var_prefix):
|
|
49
|
+
prefix_hint = f" Did you exclude the prefix? If no, try it whithout the prefix `{self.env_var_prefix}`."
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"No environment var with key `{env_var_key}` found.{prefix_hint}"
|
|
52
|
+
)
|
|
53
|
+
env_var_splitted = self._split_env_var(env_var_key)
|
|
54
|
+
try:
|
|
55
|
+
result = self._get_value_dict_by_env_var_key(
|
|
56
|
+
env_var_key_splitted=env_var_splitted,
|
|
57
|
+
settings=self.settings,
|
|
58
|
+
value=val,
|
|
59
|
+
)
|
|
60
|
+
except KeyError:
|
|
61
|
+
return None
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
def _get_env_vars(self) -> Dict[str, str]:
|
|
65
|
+
"""Collect env vars. if applicable filter by prefix and remove prefix for internal use."""
|
|
66
|
+
result = {}
|
|
67
|
+
for key, val in os.environ.items():
|
|
68
|
+
if key.startswith(self.env_var_prefix):
|
|
69
|
+
non_prefixed_key = key.replace(self.env_var_prefix, "", 1)
|
|
70
|
+
result[non_prefixed_key] = val
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
def _get_env_var_seps(self) -> Tuple[str, str]:
|
|
74
|
+
env_var_delimiter: str = (
|
|
75
|
+
self.settings.model_config["env_nested_delimiter"]
|
|
76
|
+
if self.settings.model_config["env_nested_delimiter"]
|
|
77
|
+
else "__"
|
|
78
|
+
)
|
|
79
|
+
env_prefix: str = (
|
|
80
|
+
self.settings.model_config["env_prefix"]
|
|
81
|
+
if self.settings.model_config["env_prefix"]
|
|
82
|
+
else ""
|
|
83
|
+
)
|
|
84
|
+
return env_var_delimiter, env_prefix
|
|
85
|
+
|
|
86
|
+
def _get_value_dict_by_env_var_key(
|
|
87
|
+
self,
|
|
88
|
+
env_var_key_splitted: List[str],
|
|
89
|
+
settings: BaseSettings | BaseModel | Dict | List = None,
|
|
90
|
+
annotation: Any = None,
|
|
91
|
+
value: Any = None,
|
|
92
|
+
parent_keys: List[str] = None,
|
|
93
|
+
) -> Dict:
|
|
94
|
+
if len(env_var_key_splitted) == 0:
|
|
95
|
+
return value
|
|
96
|
+
|
|
97
|
+
env_var_fragment = env_var_key_splitted[0]
|
|
98
|
+
|
|
99
|
+
annotation = clean_annotation(annotation)
|
|
100
|
+
next_annotation = None
|
|
101
|
+
if annotation is None or (
|
|
102
|
+
isclass(annotation) and issubclass(annotation, (BaseSettings, BaseModel))
|
|
103
|
+
):
|
|
104
|
+
if annotation is None:
|
|
105
|
+
annotation = settings
|
|
106
|
+
try:
|
|
107
|
+
key = next(
|
|
108
|
+
k
|
|
109
|
+
for k in annotation.model_fields.keys()
|
|
110
|
+
if k.upper() == env_var_fragment.upper()
|
|
111
|
+
)
|
|
112
|
+
except StopIteration:
|
|
113
|
+
# we found no setting key that matches the env var key.
|
|
114
|
+
# this env var propably has nothing to do with the settings here
|
|
115
|
+
raise KeyError(
|
|
116
|
+
f"Attr/Key `{env_var_fragment}` not found in `{annotation.__class__}`"
|
|
117
|
+
)
|
|
118
|
+
next_annotation = annotation.model_fields[key].annotation
|
|
119
|
+
next_settings_instance = getattr(settings, key, None)
|
|
120
|
+
result = {}
|
|
121
|
+
result[key] = self._get_value_dict_by_env_var_key(
|
|
122
|
+
env_var_key_splitted=env_var_key_splitted[1:],
|
|
123
|
+
settings=next_settings_instance,
|
|
124
|
+
annotation=next_annotation,
|
|
125
|
+
value=value,
|
|
126
|
+
parent_keys=env_var_key_splitted,
|
|
127
|
+
)
|
|
128
|
+
elif get_origin(annotation) == dict:
|
|
129
|
+
next_annotation = get_args(annotation)[1]
|
|
130
|
+
|
|
131
|
+
next_settings_instance = get_dict_val_key_insensitive(
|
|
132
|
+
settings, env_var_fragment, None
|
|
133
|
+
)
|
|
134
|
+
result = {}
|
|
135
|
+
result[env_var_fragment.lower()] = self._get_value_dict_by_env_var_key(
|
|
136
|
+
env_var_key_splitted=env_var_key_splitted[1:],
|
|
137
|
+
settings=next_settings_instance,
|
|
138
|
+
annotation=next_annotation,
|
|
139
|
+
value=value,
|
|
140
|
+
parent_keys=env_var_key_splitted,
|
|
141
|
+
)
|
|
142
|
+
elif annotation == dict:
|
|
143
|
+
# omg, we are in the wildlands. any dict is allowed.
|
|
144
|
+
# we just make our best guess by creating a nested dict based on the path fragments
|
|
145
|
+
if not os.getenv("PSYPLUS_SUPRESS_MISSING_TYPE_WARNING", None) in [
|
|
146
|
+
"yes",
|
|
147
|
+
"true",
|
|
148
|
+
"1",
|
|
149
|
+
]:
|
|
150
|
+
log.warning(
|
|
151
|
+
f"Env var key `{self.env_var_delimiter.join(parent_keys)}` is mapped to a simple `dict` annotation (subkey: '{env_var_key_splitted[0]}', value: `{value}`) in the config model `{self.settings.__class__.__name__}`. This is not recommended. "
|
|
152
|
+
+ "Please use a `Dict[<type>]` annotation."
|
|
153
|
+
+ "PsYplus is now creating a nested dict based on the path fragments, which maybe is not what you expected."
|
|
154
|
+
+ "Set env var 'PSYPLUS_SUPRESS_MISSING_TYPE_WARNING=true' to supress this warning."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
result = env_to_nested_dict(env_var_key_splitted, value)
|
|
158
|
+
|
|
159
|
+
elif get_origin(annotation) == list:
|
|
160
|
+
next_annotation = get_args(annotation)[0]
|
|
161
|
+
# fill up list with placeholder to respect the env vars given index
|
|
162
|
+
result = [ListitemPlaceholder] * int(env_var_fragment)
|
|
163
|
+
try:
|
|
164
|
+
next_settings_instance = (
|
|
165
|
+
settings[int(env_var_fragment)] if settings is not None else None
|
|
166
|
+
)
|
|
167
|
+
except IndexError:
|
|
168
|
+
next_settings_instance = None
|
|
169
|
+
|
|
170
|
+
result.append(
|
|
171
|
+
self._get_value_dict_by_env_var_key(
|
|
172
|
+
env_var_key_splitted=env_var_key_splitted[1:],
|
|
173
|
+
settings=next_settings_instance,
|
|
174
|
+
annotation=next_annotation,
|
|
175
|
+
value=value,
|
|
176
|
+
parent_keys=env_var_key_splitted,
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
elif annotation == list:
|
|
180
|
+
# omg, any list is allowed.
|
|
181
|
+
# this is stupid. lets output a warning and just make a simple list the value
|
|
182
|
+
if not os.getenv("PSYPLUS_SUPRESS_MISSING_TYPE_WARNING", None) in [
|
|
183
|
+
"yes",
|
|
184
|
+
"true",
|
|
185
|
+
"1",
|
|
186
|
+
]:
|
|
187
|
+
log.warning(
|
|
188
|
+
f"Env var key `{self.env_var_delimiter.join(parent_keys)}` is mapped to a simple `list` annotation. This is not recommended. "
|
|
189
|
+
+ "Please use a `List[<type>]` annotation. "
|
|
190
|
+
+ "We are just creating a list with the value and ignoring following env path fragments, which is possibly not what you expected."
|
|
191
|
+
+ "Set env var 'PSYPLUS_SUPRESS_MISSING_TYPE_WARNING=true' to supress this warning."
|
|
192
|
+
)
|
|
193
|
+
result = [value]
|
|
194
|
+
|
|
195
|
+
return result
|
|
196
|
+
|
|
197
|
+
def _get_setting_dict_by_env_var(self, env_var_key: str) -> Dict:
|
|
198
|
+
self._get_value_dict_by_env_var_key(env_var_key)
|
|
199
|
+
|
|
200
|
+
def _split_env_var(self, env_var_key: str) -> List[str]:
|
|
201
|
+
return env_var_key.split(self.env_var_delimiter)
|
|
202
|
+
|
|
203
|
+
def _deep_merge(self, base: Dict | List, update: Dict | List):
|
|
204
|
+
"""Deep merge a complex nested dict/list object. Lists can have `ListitemPlaceholder` which will be recessive while merging
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
base (Dict | List): _description_
|
|
208
|
+
update (Dict | List): _description_
|
|
209
|
+
|
|
210
|
+
Raises:
|
|
211
|
+
ValueError: _description_
|
|
212
|
+
"""
|
|
213
|
+
if (isinstance(base, dict) or base in (None, PydanticUndefined)) and isinstance(
|
|
214
|
+
update, dict
|
|
215
|
+
):
|
|
216
|
+
if base in (None, PydanticUndefined):
|
|
217
|
+
base = {}
|
|
218
|
+
for key in set(base.keys()).union(update.keys()):
|
|
219
|
+
if key in base and key in update:
|
|
220
|
+
# we have both keys. we need to go deeper
|
|
221
|
+
base[key] = self._deep_merge(base[key], update[key])
|
|
222
|
+
elif key in update:
|
|
223
|
+
base[key] = update[key]
|
|
224
|
+
elif (
|
|
225
|
+
isinstance(base, list) or base in (None, PydanticUndefined)
|
|
226
|
+
) and isinstance(update, list):
|
|
227
|
+
if base in (None, PydanticUndefined):
|
|
228
|
+
base = []
|
|
229
|
+
length = max(len(base), len(update))
|
|
230
|
+
# Pad the base list, if its shorter
|
|
231
|
+
base.extend([ListitemPlaceholder] * (length - len(base)))
|
|
232
|
+
# iter through items and merge them
|
|
233
|
+
for index, item in enumerate(update):
|
|
234
|
+
if item != ListitemPlaceholder:
|
|
235
|
+
if isinstance(item, (dict, list)):
|
|
236
|
+
base[index] = self._deep_merge(base[index], update[index])
|
|
237
|
+
else:
|
|
238
|
+
base[index] = update[index]
|
|
239
|
+
elif update is None:
|
|
240
|
+
pass
|
|
241
|
+
else:
|
|
242
|
+
raise ValueError(
|
|
243
|
+
"Source and update obj are too divergent in structure to be merged."
|
|
244
|
+
)
|
|
245
|
+
return base
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from pydantic import fields, BaseModel
|
|
2
|
+
from pydantic_settings import BaseSettings
|
|
3
|
+
from typing import List, Any
|
|
4
|
+
from typing_extensions import Self
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pydantic_core import PydanticUndefined
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
from psyplus.utils import (
|
|
13
|
+
nested_pydantic_to_dict,
|
|
14
|
+
get_str_dict_as_table,
|
|
15
|
+
python_annotation_to_generic_readable,
|
|
16
|
+
has_literal,
|
|
17
|
+
get_literal_list,
|
|
18
|
+
indent_multilines,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
ENV_VAR_LISTINDEX_PLACEHOLDER: str = "<list-index>"
|
|
22
|
+
ENV_VAR_DICTKEY_PLACEHOLDER: str = "<dict-key>"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ListIndex:
|
|
27
|
+
index: int
|
|
28
|
+
|
|
29
|
+
def __str__(self):
|
|
30
|
+
return f"[{self.index}]"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class DictKey:
|
|
35
|
+
key: int
|
|
36
|
+
|
|
37
|
+
def __str__(self):
|
|
38
|
+
return f"['{self.key}']"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class FieldInfoContainer:
|
|
43
|
+
"""A wrapper class to store and simplify access to certain (meta-)informations in/of a `pydantic.fields.FieldInfo` instance.
|
|
44
|
+
Intended for internal use only"""
|
|
45
|
+
|
|
46
|
+
path: List[str | ListIndex | DictKey]
|
|
47
|
+
field_name: str
|
|
48
|
+
field_info: fields.FieldInfo | None = None
|
|
49
|
+
annotation: Any = None
|
|
50
|
+
|
|
51
|
+
def get_annotation(self) -> Any:
|
|
52
|
+
if self.field_info:
|
|
53
|
+
return self.field_info.annotation
|
|
54
|
+
return self.annotation
|
|
55
|
+
|
|
56
|
+
def get_env_var_scheme(
|
|
57
|
+
self, env_var_delimiter: str = "__", prefix: str = ""
|
|
58
|
+
) -> str:
|
|
59
|
+
result = []
|
|
60
|
+
for key in self.path:
|
|
61
|
+
if isinstance(key, str):
|
|
62
|
+
result.append(key.upper())
|
|
63
|
+
elif isinstance(key, ListIndex):
|
|
64
|
+
result.append(ENV_VAR_LISTINDEX_PLACEHOLDER)
|
|
65
|
+
elif isinstance(key, DictKey):
|
|
66
|
+
result.append(ENV_VAR_DICTKEY_PLACEHOLDER)
|
|
67
|
+
else:
|
|
68
|
+
# unsupported type; we can not generate a env var
|
|
69
|
+
return None
|
|
70
|
+
return prefix + env_var_delimiter.join(result)
|
|
71
|
+
|
|
72
|
+
def get_path_str(self) -> str:
|
|
73
|
+
return ".".join(str(p) for p in self.path)
|
|
74
|
+
|
|
75
|
+
def get_type_annotation_string(self):
|
|
76
|
+
return python_annotation_to_generic_readable(self.get_annotation())
|
|
77
|
+
|
|
78
|
+
def get_enum_vals(
|
|
79
|
+
self,
|
|
80
|
+
) -> List[Any] | None:
|
|
81
|
+
"""If the value has a fixed list of allowed values, this return the list of these values
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
List[Any]: List of allowed values for the field
|
|
85
|
+
"""
|
|
86
|
+
annot = self.get_annotation()
|
|
87
|
+
if has_literal(annot):
|
|
88
|
+
return get_literal_list(annot)
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
def get_entry_comment_header(self):
|
|
92
|
+
"""Generates a more simple header comment of keys that do not have its in pydantic.fields.FieldInfo instance"""
|
|
93
|
+
comment: List[str] = []
|
|
94
|
+
data_header = {}
|
|
95
|
+
# Title
|
|
96
|
+
key = self.field_name
|
|
97
|
+
path = self.get_path_str()
|
|
98
|
+
header_line = f"## {key}"
|
|
99
|
+
data_header["YAML-path: "] = f"{path}"
|
|
100
|
+
data_header["Env-var: "] = f"'{self.get_env_var_scheme()}'"
|
|
101
|
+
comment.append(header_line)
|
|
102
|
+
comment.extend(get_str_dict_as_table(data_header).rstrip().split("\n"))
|
|
103
|
+
return comment
|
|
104
|
+
|
|
105
|
+
def get_field_comment_header(self):
|
|
106
|
+
comment: List[str] = []
|
|
107
|
+
data_header = {}
|
|
108
|
+
# Title
|
|
109
|
+
key = self.field_name
|
|
110
|
+
path = self.get_path_str()
|
|
111
|
+
header_line = f"## {key}"
|
|
112
|
+
field_info: fields.FieldInfo = (
|
|
113
|
+
self.field_info if self.field_info else fields.FieldInfo()
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if field_info.title:
|
|
117
|
+
header_line += f" - {field_info.title}"
|
|
118
|
+
header_line += f" ###"
|
|
119
|
+
comment.append(header_line)
|
|
120
|
+
# Data fields
|
|
121
|
+
if key != path:
|
|
122
|
+
data_header["YAML-path: "] = f"{path}"
|
|
123
|
+
|
|
124
|
+
if self.get_type_annotation_string():
|
|
125
|
+
data_header["Type: "] = f"{self.get_type_annotation_string()}"
|
|
126
|
+
data_header["Required: "] = f"{field_info.is_required()}"
|
|
127
|
+
if hasattr(field_info, "default") and field_info.default != PydanticUndefined:
|
|
128
|
+
if field_info.default is not None:
|
|
129
|
+
def_val = f"'{json.dumps(nested_pydantic_to_dict(field_info.default))}'"
|
|
130
|
+
else:
|
|
131
|
+
def_val = "null/None"
|
|
132
|
+
data_header["Default: "] = def_val
|
|
133
|
+
if self.get_enum_vals():
|
|
134
|
+
data_header["Allowed vals: "] = f"{self.get_enum_vals()}"
|
|
135
|
+
|
|
136
|
+
if field_info.metadata:
|
|
137
|
+
data_header["Constraints: "] = f"{field_info.metadata}"
|
|
138
|
+
|
|
139
|
+
data_header["Env-var: "] = f"'{self.get_env_var_scheme()}'"
|
|
140
|
+
if field_info.description:
|
|
141
|
+
data_header["Description: "] = f"{field_info.description}"
|
|
142
|
+
|
|
143
|
+
comment.extend(get_str_dict_as_table(data_header).rstrip().split("\n"))
|
|
144
|
+
|
|
145
|
+
if field_info.examples:
|
|
146
|
+
comment.extend(self._generate_examples_comment_text(key))
|
|
147
|
+
|
|
148
|
+
return comment
|
|
149
|
+
|
|
150
|
+
def _generate_examples_comment_text(
|
|
151
|
+
self, key: str, indent_depth: int = 0
|
|
152
|
+
) -> List[str] | None:
|
|
153
|
+
if not self.field_info.examples:
|
|
154
|
+
return None
|
|
155
|
+
text_lines = []
|
|
156
|
+
for index, example in enumerate(self.field_info.examples):
|
|
157
|
+
text_lines.append(
|
|
158
|
+
f"Example No. {index+1}:"
|
|
159
|
+
if len(self.field_info.examples) > 1
|
|
160
|
+
else "Example:"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
example_as_yaml = yaml.dump(nested_pydantic_to_dict({key: example}))
|
|
164
|
+
text_lines.extend(
|
|
165
|
+
indent_multilines(
|
|
166
|
+
text=example_as_yaml.split("\n"),
|
|
167
|
+
indent_depth=0,
|
|
168
|
+
line_prefix=">",
|
|
169
|
+
extra_indent_depth_after_prefix=indent_depth,
|
|
170
|
+
add_extra_indent_for_subsequent_lines_after_line_prefix=False,
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
return text_lines[:-1]
|
psyplus/psyplus.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
from typing import (
|
|
4
|
+
List,
|
|
5
|
+
Any,
|
|
6
|
+
Dict,
|
|
7
|
+
Union,
|
|
8
|
+
Type,
|
|
9
|
+
)
|
|
10
|
+
import inspect
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, fields
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
from pydantic_settings import BaseSettings
|
|
16
|
+
from pydantic_core import PydanticUndefined
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
from psyplus.yaml_pydantic_metadata_comment_injector import YamlFileGenerator
|
|
23
|
+
from psyplus.env_var_handler import EnvVarHandlerExtended
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class YamlSettingsPlus:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
model: Type[BaseSettings],
|
|
30
|
+
file_path: Union[str, Path] = None,
|
|
31
|
+
parse_finde_grained_env_var: bool = True,
|
|
32
|
+
):
|
|
33
|
+
self.parse_finde_grained_env_var = parse_finde_grained_env_var
|
|
34
|
+
self.config_file: Path = (
|
|
35
|
+
file_path if isinstance(file_path, Path) else Path(file_path)
|
|
36
|
+
)
|
|
37
|
+
self.model: Type[BaseSettings] = model
|
|
38
|
+
self._settings_cache: BaseSettings = None
|
|
39
|
+
|
|
40
|
+
def get_config(self) -> BaseSettings:
|
|
41
|
+
with open(self.config_file) as file:
|
|
42
|
+
raw_yaml_object = file.read()
|
|
43
|
+
obj: Dict = yaml.safe_load(raw_yaml_object)
|
|
44
|
+
if self.parse_finde_grained_env_var:
|
|
45
|
+
env_var_handler = EnvVarHandlerExtended(self.model)
|
|
46
|
+
obj = env_var_handler.settings_as_dict
|
|
47
|
+
self._settings_cache = self.model.model_validate(obj)
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
def generate_config_file(self, overwrite_existing: bool = False, exists_ok=True):
|
|
51
|
+
null_placeholder = "NULL_PLACEHOLDER_328472384623746012386389621948"
|
|
52
|
+
dummy_values = self._get_fields_filler(
|
|
53
|
+
required_only=False,
|
|
54
|
+
use_example_values_if_exists=False,
|
|
55
|
+
fallback_fill_value=null_placeholder,
|
|
56
|
+
)
|
|
57
|
+
# print("dummy_values", dummy_values)
|
|
58
|
+
config = self.model.model_validate(dummy_values)
|
|
59
|
+
self._generate_file(
|
|
60
|
+
config,
|
|
61
|
+
overwrite_existing=overwrite_existing,
|
|
62
|
+
generate_with_example_values=True,
|
|
63
|
+
exists_ok=exists_ok,
|
|
64
|
+
replace_pattern={null_placeholder: "null"},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def generate_config_file_with_examples_values(
|
|
68
|
+
self, overwrite_existing: bool = False
|
|
69
|
+
):
|
|
70
|
+
dummy_values = self._get_fields_filler(
|
|
71
|
+
required_only=True, use_example_values_if_exists=True
|
|
72
|
+
)
|
|
73
|
+
print("dummy_values", dummy_values)
|
|
74
|
+
config = self.model.model_validate(dummy_values)
|
|
75
|
+
return config
|
|
76
|
+
filegen = YamlFileGenerator(config)
|
|
77
|
+
filegen.parse_pydantic_model()
|
|
78
|
+
print(filegen.get_yaml())
|
|
79
|
+
exit()
|
|
80
|
+
self._generate_file(
|
|
81
|
+
config,
|
|
82
|
+
generate_with_example_values=True,
|
|
83
|
+
overwrite_existing=overwrite_existing,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def generate_config_file_from_config_object(self, config: BaseSettings):
|
|
87
|
+
self._generate_file(config)
|
|
88
|
+
|
|
89
|
+
def generate_config_file_with_only_required_keys(self):
|
|
90
|
+
config = self.model.model_validate(
|
|
91
|
+
self._get_fields_filler(
|
|
92
|
+
required_only=True,
|
|
93
|
+
use_example_values_if_exists=True,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
self._generate_file(config, generate_with_optional_fields=False)
|
|
97
|
+
|
|
98
|
+
def generate_markdown_doc(self):
|
|
99
|
+
raise NotImplementedError()
|
|
100
|
+
|
|
101
|
+
def _generate_file(
|
|
102
|
+
self,
|
|
103
|
+
config: BaseSettings,
|
|
104
|
+
overwrite_existing: bool = False,
|
|
105
|
+
exists_ok: bool = False,
|
|
106
|
+
generate_with_optional_fields: bool = True,
|
|
107
|
+
comment_out_optional_fields: bool = True,
|
|
108
|
+
generate_with_comment_desc_header: bool = True,
|
|
109
|
+
generate_with_example_values: bool = False,
|
|
110
|
+
replace_pattern: Dict = None,
|
|
111
|
+
):
|
|
112
|
+
self.config_file.parent.mkdir(exist_ok=True, parents=True)
|
|
113
|
+
if self.config_file.is_file() and not overwrite_existing:
|
|
114
|
+
if exists_ok:
|
|
115
|
+
return
|
|
116
|
+
else:
|
|
117
|
+
raise FileExistsError(
|
|
118
|
+
f"Can not generate config file at {self.config_file}. File allready exists."
|
|
119
|
+
)
|
|
120
|
+
if replace_pattern is None:
|
|
121
|
+
replace_pattern = {}
|
|
122
|
+
yaml_content: str = yaml.dump(config.model_dump(), sort_keys=False)
|
|
123
|
+
from psyplus.yaml_pydantic_metadata_comment_injector import YamlFileGenerator
|
|
124
|
+
|
|
125
|
+
y = YamlFileGenerator(settings_instance=config)
|
|
126
|
+
y.parse_pydantic_model()
|
|
127
|
+
yaml_content = y.get_yaml()
|
|
128
|
+
for key, val in replace_pattern.items():
|
|
129
|
+
yaml_content = yaml_content.replace(key, val)
|
|
130
|
+
|
|
131
|
+
with open(self.config_file, "w") as file:
|
|
132
|
+
file.write(yaml_content)
|
|
133
|
+
|
|
134
|
+
def _get_fields_filler(
|
|
135
|
+
self,
|
|
136
|
+
required_only: bool = True,
|
|
137
|
+
use_example_values_if_exists: bool = False,
|
|
138
|
+
fallback_fill_value: Any = "",
|
|
139
|
+
) -> Dict:
|
|
140
|
+
"""Needed for creating dummy values for non nullable values. Otherwise we are not able to initialize a living config from the model
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
required_only (bool, optional): _description_. Defaults to True.
|
|
144
|
+
use_example_values_if_exists (bool, optional): _description_. Defaults to False.
|
|
145
|
+
fallback_fill_value (Any, optional): _description_. Defaults to None.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Dict: _description_
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def parse_model_class(m_cls: Type[BaseSettings | BaseModel]) -> Dict:
|
|
152
|
+
result: Dict = {}
|
|
153
|
+
for key, field in m_cls.model_fields.items():
|
|
154
|
+
if key == "only_for_groupnames_starting_with":
|
|
155
|
+
print(
|
|
156
|
+
"only_for_groupnames_starting_with.is_required()",
|
|
157
|
+
field.is_required(),
|
|
158
|
+
)
|
|
159
|
+
# if not required_only or field.is_required():
|
|
160
|
+
if True:
|
|
161
|
+
if use_example_values_if_exists and field.examples:
|
|
162
|
+
example = field.examples[0]
|
|
163
|
+
# We want to generate a example models and there are examples in the annotation
|
|
164
|
+
# if it is a real config object we pass it to as a values else we try to create a json compatible string
|
|
165
|
+
|
|
166
|
+
result[key] = example
|
|
167
|
+
"""
|
|
168
|
+
if inspect.isclass(field.annotation) and issubclass(
|
|
169
|
+
field.annotation, BaseModel
|
|
170
|
+
):
|
|
171
|
+
result[key] = example
|
|
172
|
+
else:
|
|
173
|
+
result[key] = self.jsonfy_example(example)
|
|
174
|
+
"""
|
|
175
|
+
elif inspect.isclass(field.annotation) and issubclass(
|
|
176
|
+
field.annotation, BaseModel | BaseSettings
|
|
177
|
+
):
|
|
178
|
+
if field.default is not PydanticUndefined:
|
|
179
|
+
result[key] = field.default
|
|
180
|
+
elif field.default_factory is not None:
|
|
181
|
+
print("field.default_factory", field.default_factory)
|
|
182
|
+
result[key] = field.default_factory()
|
|
183
|
+
else:
|
|
184
|
+
result[key] = parse_model_class(field.annotation)
|
|
185
|
+
elif field.annotation == Any:
|
|
186
|
+
result[key] = ""
|
|
187
|
+
elif type(field.annotation) in (typing._GenericAlias, type):
|
|
188
|
+
# This is a basic type. we can provide some reasonable sane default values like 0 for int or "" for str
|
|
189
|
+
if hasattr(field.annotation, "__origin__"):
|
|
190
|
+
result[key] = field.annotation.__origin__()
|
|
191
|
+
elif type(field.annotation) == type:
|
|
192
|
+
# we have a basic type
|
|
193
|
+
result[key] = field.annotation()
|
|
194
|
+
|
|
195
|
+
elif (
|
|
196
|
+
isinstance(field, fields.FieldInfo)
|
|
197
|
+
and field.default_factory is not None
|
|
198
|
+
):
|
|
199
|
+
result[key] = self.jsonfy_example(field.default_factory())
|
|
200
|
+
else:
|
|
201
|
+
result[key] = (
|
|
202
|
+
fallback_fill_value
|
|
203
|
+
if field.is_required()
|
|
204
|
+
else self.jsonfy_example(field.default)
|
|
205
|
+
)
|
|
206
|
+
return result
|
|
207
|
+
|
|
208
|
+
return parse_model_class(self.model)
|
|
209
|
+
|
|
210
|
+
def jsonfy_example(self, val: Any) -> List | Dict:
|
|
211
|
+
if isinstance(val, dict):
|
|
212
|
+
result: Dict = {}
|
|
213
|
+
for k, v in val.items():
|
|
214
|
+
result[k] = self.jsonfy_example(v)
|
|
215
|
+
return result
|
|
216
|
+
elif isinstance(val, (list, set, tuple)):
|
|
217
|
+
return [self.jsonfy_example(i) for i in val]
|
|
218
|
+
elif isinstance(val, BaseModel):
|
|
219
|
+
return val.model_dump_json()
|
|
220
|
+
elif val is not None:
|
|
221
|
+
return str(val)
|
|
222
|
+
else:
|
|
223
|
+
return None
|
psyplus/utils.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from typing import (
|
|
2
|
+
Any,
|
|
3
|
+
Union,
|
|
4
|
+
Dict,
|
|
5
|
+
List,
|
|
6
|
+
get_origin,
|
|
7
|
+
Literal,
|
|
8
|
+
get_args,
|
|
9
|
+
Generator,
|
|
10
|
+
)
|
|
11
|
+
from pydantic_core import PydanticUndefined
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
from pydantic_settings import BaseSettings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
import datetime
|
|
17
|
+
from pydantic import (
|
|
18
|
+
PastDate,
|
|
19
|
+
FutureDate,
|
|
20
|
+
PastDatetime,
|
|
21
|
+
FutureDatetime,
|
|
22
|
+
AwareDatetime,
|
|
23
|
+
NaiveDatetime,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def python_annotation_to_generic_readable(
|
|
28
|
+
annotation,
|
|
29
|
+
) -> str:
|
|
30
|
+
# https://docs.pydantic.dev/2.5/api/json_schema/#pydantic.json_schema.GenerateJsonSchema
|
|
31
|
+
JSON_BASIC_TYPES = Literal["boolean", "number", "string", "array", "object"]
|
|
32
|
+
|
|
33
|
+
# https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-00#section-10.2.1
|
|
34
|
+
JSON_SUBSCHEMAS = Literal["allOf", "anyOf", "oneOf", "not"]
|
|
35
|
+
|
|
36
|
+
PYTHON_SCALAR_TYPES = [
|
|
37
|
+
int,
|
|
38
|
+
float,
|
|
39
|
+
str,
|
|
40
|
+
bool,
|
|
41
|
+
datetime.time,
|
|
42
|
+
datetime.date,
|
|
43
|
+
datetime.datetime,
|
|
44
|
+
PastDate,
|
|
45
|
+
FutureDate,
|
|
46
|
+
PastDatetime,
|
|
47
|
+
FutureDatetime,
|
|
48
|
+
AwareDatetime,
|
|
49
|
+
NaiveDatetime,
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
def stringifiy_annotation(annot) -> str:
|
|
53
|
+
if type(annot) == tuple:
|
|
54
|
+
res = []
|
|
55
|
+
for item in annot:
|
|
56
|
+
res.append(stringifiy_annotation(item))
|
|
57
|
+
if res:
|
|
58
|
+
return ",".join(res)
|
|
59
|
+
if is_typingOptional(annot):
|
|
60
|
+
return stringifiy_annotation(get_typingOptionalArg(annot))
|
|
61
|
+
elif annot == Any:
|
|
62
|
+
return None
|
|
63
|
+
elif annot in PYTHON_SCALAR_TYPES:
|
|
64
|
+
return annot.__name__
|
|
65
|
+
elif get_origin(annot) == list or annot == list:
|
|
66
|
+
list_annotation_args = get_args(annot)
|
|
67
|
+
if list_annotation_args:
|
|
68
|
+
return "List of " + stringifiy_annotation(list_annotation_args)
|
|
69
|
+
else:
|
|
70
|
+
return "List"
|
|
71
|
+
elif get_origin(annot) == dict or annot == dict:
|
|
72
|
+
dict_annotation_args = get_args(annot)
|
|
73
|
+
if dict_annotation_args:
|
|
74
|
+
return f"Dictionary of ({stringifiy_annotation(get_args(annot))})"
|
|
75
|
+
else:
|
|
76
|
+
return "Dictionary"
|
|
77
|
+
elif get_origin(annot) == Literal:
|
|
78
|
+
return "Enum"
|
|
79
|
+
else:
|
|
80
|
+
return "Object"
|
|
81
|
+
|
|
82
|
+
return stringifiy_annotation(annotation)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_typingOptional(annotation: Any) -> bool:
|
|
86
|
+
return (
|
|
87
|
+
hasattr(annotation, "__origin__")
|
|
88
|
+
and annotation.__origin__ is Union
|
|
89
|
+
and annotation.__args__[1] is type(None)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_typingOptionalArg(annotation) -> Any:
|
|
94
|
+
if is_typingOptional(annotation):
|
|
95
|
+
return annotation.__args__[0]
|
|
96
|
+
return annotation
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def clean_annotation(annotation) -> Any:
|
|
100
|
+
"""extract actual type annotation from field annotations like typing.Optional,...
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
annotation (_type_): _description_
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Any: _description_
|
|
107
|
+
"""
|
|
108
|
+
if is_typingOptional(annotation=annotation):
|
|
109
|
+
annotation = get_typingOptionalArg(annotation=annotation)
|
|
110
|
+
return clean_annotation(annotation=annotation)
|
|
111
|
+
return annotation
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def has_literal(annotation: Any) -> bool:
|
|
115
|
+
clean_annot = clean_annotation(annotation=annotation)
|
|
116
|
+
if hasattr(clean_annot, "__origin__"):
|
|
117
|
+
return clean_annot.__origin__ == Literal
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def get_literal_list(annotation: Any) -> List[Any] | None:
|
|
121
|
+
clean_annot = clean_annotation(annotation=annotation)
|
|
122
|
+
if hasattr(clean_annot, "__origin__") and clean_annot.__origin__ == Literal:
|
|
123
|
+
return list(clean_annot.__args__)
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def nested_pydantic_to_dict(obj: Any) -> Any:
|
|
128
|
+
if isinstance(obj, (BaseModel, BaseSettings)):
|
|
129
|
+
return nested_pydantic_to_dict(obj.model_dump())
|
|
130
|
+
elif isinstance(obj, dict):
|
|
131
|
+
return {k: nested_pydantic_to_dict(v) for k, v in obj.items()}
|
|
132
|
+
elif isinstance(obj, (list, tuple)):
|
|
133
|
+
return [nested_pydantic_to_dict(item) for item in obj]
|
|
134
|
+
else:
|
|
135
|
+
return obj
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def get_dict_val_key_insensitive(
|
|
139
|
+
dictionary: Dict, key: str, default: Any = PydanticUndefined
|
|
140
|
+
):
|
|
141
|
+
for k in dictionary.keys():
|
|
142
|
+
if k.upper() == key.upper():
|
|
143
|
+
return dictionary[k]
|
|
144
|
+
if default != PydanticUndefined:
|
|
145
|
+
raise KeyError(f"Can not find key '{key}' in {dictionary.keys()}")
|
|
146
|
+
return default
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def env_to_nested_dict(key: str | List[str], val: Any, delimiter: str = "__") -> Dict:
|
|
150
|
+
"""Convert a env var key to a nestesd dict.
|
|
151
|
+
e.g.
|
|
152
|
+
`MYDICT__NEXTKEY__WHATEVER=val111` -> `{'MYDICT': {'NEXTKEY': {'WHATEVER': 'val111'}}}`
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Dict:
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
if isinstance(key, str):
|
|
159
|
+
fragments = key.split(delimiter)
|
|
160
|
+
elif isinstance(key, list):
|
|
161
|
+
fragments = key
|
|
162
|
+
else:
|
|
163
|
+
raise ValueError(f"key must be str or list but is {type(key)}")
|
|
164
|
+
result = {}
|
|
165
|
+
temp = result
|
|
166
|
+
|
|
167
|
+
for frag in fragments[:-1]:
|
|
168
|
+
temp = temp.setdefault(frag, {})
|
|
169
|
+
temp[fragments[-1]] = val
|
|
170
|
+
|
|
171
|
+
return result
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def get_str_dict_as_table(
|
|
175
|
+
d: Dict, vertical_seperator: str = "", respect_line_breaks_in_val: bool = True
|
|
176
|
+
) -> str:
|
|
177
|
+
length_key_column = (max(len(string) for string in d.keys()) if d.keys() else 0) + 1
|
|
178
|
+
result = ""
|
|
179
|
+
for key, val in d.items():
|
|
180
|
+
if respect_line_breaks_in_val:
|
|
181
|
+
val = str(val).split("\n")
|
|
182
|
+
result += f"{key.ljust(length_key_column)}{vertical_seperator}{val[0]}\n"
|
|
183
|
+
for v in val[1:]:
|
|
184
|
+
result += f"{''.ljust(length_key_column)}{vertical_seperator}{v}\n"
|
|
185
|
+
else:
|
|
186
|
+
result += f"{key.ljust(length_key_column)}{vertical_seperator}{val}\n"
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def indent_multilines(
|
|
191
|
+
text: List[str],
|
|
192
|
+
indent_depth: int = 0,
|
|
193
|
+
line_prefix: str = "",
|
|
194
|
+
line_suffix: str = "",
|
|
195
|
+
extra_indent_depth_after_prefix: int = 0,
|
|
196
|
+
add_extra_indent_for_subsequent_lines_after_line_prefix: bool = False,
|
|
197
|
+
indent: str = " ",
|
|
198
|
+
) -> Generator[str, None, None]:
|
|
199
|
+
indent = f"{indent_depth*indent}"
|
|
200
|
+
inner_indent = f"{extra_indent_depth_after_prefix*indent}"
|
|
201
|
+
for index, line in enumerate(text):
|
|
202
|
+
line_prefix_real = f"{line_prefix}{inner_indent}"
|
|
203
|
+
if index != 0 and add_extra_indent_for_subsequent_lines_after_line_prefix:
|
|
204
|
+
line_prefix_real = f"{line_prefix_real}{indent}"
|
|
205
|
+
yield f"{indent}{line_prefix_real}{line}{line_suffix}"
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from pydantic_settings import BaseSettings
|
|
3
|
+
from typing import List, get_args, Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
from psyplus.utils import clean_annotation
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
from ruamel.yaml import YAML, CommentedMap, CommentedSeq
|
|
10
|
+
from io import StringIO
|
|
11
|
+
|
|
12
|
+
from psyplus.field_container import FieldInfoContainer, ListIndex, DictKey
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class YamlFileGenerator:
|
|
16
|
+
def __init__(
|
|
17
|
+
self, settings_instance: BaseSettings | BaseModel, indent_size: int = 2
|
|
18
|
+
):
|
|
19
|
+
self.indent_size = indent_size
|
|
20
|
+
self.yaml = YAML()
|
|
21
|
+
|
|
22
|
+
# https://yaml.readthedocs.io/en/latest/detail/#indentation-of-block-sequences
|
|
23
|
+
# > It is best to always have sequence >= offset + 2 but this is not enforced. Depending on your structure, not following this advice might lead to invalid output.
|
|
24
|
+
self.yaml.indent(sequence=indent_size + 2, offset=indent_size)
|
|
25
|
+
self.settings = settings_instance
|
|
26
|
+
|
|
27
|
+
def parse_pydantic_model(self):
|
|
28
|
+
self.model: CommentedMap = self._parse_pydantic_model(
|
|
29
|
+
self.settings, parent_path=[]
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def get_yaml(self) -> str:
|
|
33
|
+
stream = StringIO()
|
|
34
|
+
self.yaml.dump(self.model, stream)
|
|
35
|
+
|
|
36
|
+
return stream.getvalue()
|
|
37
|
+
|
|
38
|
+
def _parse_pydantic_model(
|
|
39
|
+
self,
|
|
40
|
+
setting_inst: BaseSettings | BaseModel,
|
|
41
|
+
parent_path: List[ListIndex | DictKey | str],
|
|
42
|
+
) -> CommentedMap:
|
|
43
|
+
level = len(parent_path)
|
|
44
|
+
result: CommentedMap = CommentedMap()
|
|
45
|
+
for key, field_info in setting_inst.model_fields.items():
|
|
46
|
+
path = parent_path.copy() + [key]
|
|
47
|
+
field_value = getattr(setting_inst, key)
|
|
48
|
+
if isinstance(field_value, (BaseSettings, BaseModel)):
|
|
49
|
+
result[key] = self._parse_pydantic_model(field_value, path)
|
|
50
|
+
elif isinstance(field_value, dict):
|
|
51
|
+
result[key] = self._parse_dict(field_value, field_info.annotation, path)
|
|
52
|
+
elif isinstance(field_value, list):
|
|
53
|
+
result[key] = self._parse_list(field_value, field_info.annotation, path)
|
|
54
|
+
else:
|
|
55
|
+
result[key] = field_value
|
|
56
|
+
header_comment = FieldInfoContainer(
|
|
57
|
+
path=path, field_name=key, field_info=field_info
|
|
58
|
+
).get_field_comment_header()
|
|
59
|
+
result.yaml_set_comment_before_after_key(
|
|
60
|
+
key=key,
|
|
61
|
+
before="\n" + "\n".join(header_comment),
|
|
62
|
+
indent=level * self.indent_size,
|
|
63
|
+
)
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
def _parse_dict(
|
|
67
|
+
self,
|
|
68
|
+
value,
|
|
69
|
+
field_annotation: Any,
|
|
70
|
+
parent_path: List[ListIndex | DictKey | str],
|
|
71
|
+
) -> CommentedMap:
|
|
72
|
+
level = len(parent_path)
|
|
73
|
+
result: CommentedMap = CommentedMap()
|
|
74
|
+
if isinstance(value, dict):
|
|
75
|
+
if field_annotation is not None:
|
|
76
|
+
dict_annotation_args = get_args(clean_annotation(field_annotation))
|
|
77
|
+
if dict_annotation_args:
|
|
78
|
+
dict_key_annotation, dict_val_annotation = dict_annotation_args
|
|
79
|
+
for key, val in value.items():
|
|
80
|
+
path = parent_path.copy() + [DictKey(key=key)]
|
|
81
|
+
if isinstance(val, (BaseSettings, BaseModel)):
|
|
82
|
+
result[key] = self._parse_pydantic_model(val, parent_path=path)
|
|
83
|
+
elif isinstance(val, dict):
|
|
84
|
+
result[key] = self._parse_dict(
|
|
85
|
+
val, dict_val_annotation, parent_path=path
|
|
86
|
+
)
|
|
87
|
+
elif isinstance(val, list):
|
|
88
|
+
result[key] = self._parse_list(
|
|
89
|
+
val, dict_val_annotation, parent_path=path
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
result[key] = val
|
|
93
|
+
header_comment = FieldInfoContainer(
|
|
94
|
+
path=path, field_name=key, annotation=field_annotation
|
|
95
|
+
).get_field_comment_header()
|
|
96
|
+
result.yaml_set_comment_before_after_key(
|
|
97
|
+
key=key,
|
|
98
|
+
before="\n" + "\n".join(header_comment),
|
|
99
|
+
indent=level * self.indent_size,
|
|
100
|
+
)
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
def _parse_list(
|
|
104
|
+
self,
|
|
105
|
+
value,
|
|
106
|
+
field_annotation: Any,
|
|
107
|
+
parent_path: List[ListIndex | DictKey | str],
|
|
108
|
+
) -> CommentedSeq:
|
|
109
|
+
level = len(parent_path)
|
|
110
|
+
result: CommentedSeq = CommentedSeq()
|
|
111
|
+
if isinstance(value, list):
|
|
112
|
+
list_val_annotation = get_args(clean_annotation(field_annotation))
|
|
113
|
+
for index, item in enumerate(value):
|
|
114
|
+
path = parent_path.copy() + [ListIndex(index=index)]
|
|
115
|
+
if isinstance(item, (BaseSettings, BaseModel)):
|
|
116
|
+
result.append(self._parse_pydantic_model(item, parent_path=path))
|
|
117
|
+
elif isinstance(item, dict):
|
|
118
|
+
result.append(
|
|
119
|
+
self._parse_dict(item, list_val_annotation, parent_path=path)
|
|
120
|
+
)
|
|
121
|
+
elif isinstance(item, list):
|
|
122
|
+
result.append(
|
|
123
|
+
self._parse_list(item, list_val_annotation, parent_path=path)
|
|
124
|
+
)
|
|
125
|
+
else:
|
|
126
|
+
result.append(item)
|
|
127
|
+
header_comment = FieldInfoContainer(
|
|
128
|
+
path=path,
|
|
129
|
+
field_name=f"List[{index}]",
|
|
130
|
+
annotation=list_val_annotation,
|
|
131
|
+
).get_field_comment_header()
|
|
132
|
+
result.yaml_set_comment_before_after_key(
|
|
133
|
+
key=index,
|
|
134
|
+
before="\n" + "\n".join(header_comment),
|
|
135
|
+
indent=level * self.indent_size,
|
|
136
|
+
)
|
|
137
|
+
return result
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Deutsche Zentrum für Diabetesforschung e.V.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: psyplus
|
|
3
|
+
Version: 0.9.2
|
|
4
|
+
Summary: A helper module that builds upon pydantic-settings to generate, read and comment a config file in yaml
|
|
5
|
+
Author-email: Tim Bleimehl <bleimehl@helmholtz-munich.de>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Source, https://github.com/DZD-eV-Diabetes-Research/pydantic-settings-yaml-plus
|
|
8
|
+
Project-URL: Issues, https://github.com/DZD-eV-Diabetes-Research/pydantic-settings-yaml-plus/issues
|
|
9
|
+
Keywords: DZD,pydantic,pydantic-settings
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: pydantic
|
|
18
|
+
Requires-Dist: pydantic-setting
|
|
19
|
+
Requires-Dist: ruamel.yaml
|
|
20
|
+
Requires-Dist: pyaml
|
|
21
|
+
|
|
22
|
+
# pydantic-settings-yaml-plus
|
|
23
|
+
A helper module that builds upon [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) to generate, read and comment a config file in yaml ~~and improve ENV var capabilities~~
|
|
24
|
+
|
|
25
|
+
> [!WARNING]
|
|
26
|
+
> work in progress. please ignore this repo for now
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
- [pydantic-settings-yaml-plus](#pydantic-settings-yaml-plus)
|
|
30
|
+
- [Target use cases](#target-use-cases)
|
|
31
|
+
- [Features](#features)
|
|
32
|
+
- [Known Issues](#known-issues)
|
|
33
|
+
- [Goals](#goals)
|
|
34
|
+
- [ToDo / ToInvestigate](#todo--toinvestigate)
|
|
35
|
+
- [Ideas/Roadmap](#ideasroadmap)
|
|
36
|
+
- [How to use](#how-to-use)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
## Target use cases
|
|
40
|
+
|
|
41
|
+
* **Config File Provisioning** The idea is to use this module during the build/release- (deploy on pypi.org) or init(First start)-process of your python module to generate a documented yaml file the user can understand.
|
|
42
|
+
* ~~**Extended Nested Env Var Parsing** Provide complex configs via env var without the need to write json~~ Needs a rewrite with pydantic-settings 2.x
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
## Features
|
|
47
|
+
|
|
48
|
+
* Generate a commented/documented yaml file based on your `pydantic-settings`.`BaseSettings` class
|
|
49
|
+
* ~~Support for nested listed and dict and var. E.g. `MYLIST__0__MYKEY=val` -> `config`.`MYLIST``[0]`.`MYKEY`=`val`~~ Needs a rewrite with pydantic-settings 2.x
|
|
50
|
+
|
|
51
|
+
## Known Issues
|
|
52
|
+
* Multiline values are crippled
|
|
53
|
+
|
|
54
|
+
## Goals
|
|
55
|
+
|
|
56
|
+
* Have a single source of truth for config and all its meta-data/documentation (type, descpription, examples)
|
|
57
|
+
* ~~All keys/values are addressable by env vars~~
|
|
58
|
+
|
|
59
|
+
### ToDo / ToInvestigate
|
|
60
|
+
* What about date(times) support?
|
|
61
|
+
* Make indexed env vars work again!
|
|
62
|
+
* Remove debug prints
|
|
63
|
+
* Write some more test
|
|
64
|
+
* Make pypi package
|
|
65
|
+
|
|
66
|
+
### Ideas/Roadmap
|
|
67
|
+
* Generate a mark down doc of all settings
|
|
68
|
+
* Generate template (minimal with required values only or maximum with all values listed) and example config files (all YAML only!)
|
|
69
|
+
* generate diff betwen current config and config model (when config model changed after update)
|
|
70
|
+
* update existing config files metadata
|
|
71
|
+
* Update info, descs
|
|
72
|
+
* Add missing/new required values
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
## How to use
|
|
78
|
+
|
|
79
|
+
Lets have the following examplary pydantic-settings config.py file:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from typing import List, Dict, Optional, Literal, Annotated
|
|
83
|
+
|
|
84
|
+
from pydantic import Field
|
|
85
|
+
from pydantic_settings import BaseSettings
|
|
86
|
+
|
|
87
|
+
from pathlib import Path, PurePath
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class DatabaseServerSettings(BaseSettings):
|
|
91
|
+
host: Optional[str] = Field(
|
|
92
|
+
default="localhost",
|
|
93
|
+
description="The Hostname the database will be available at",
|
|
94
|
+
)
|
|
95
|
+
port: Optional[int] = Field(
|
|
96
|
+
default=5678, description="The port to connect to the database"
|
|
97
|
+
)
|
|
98
|
+
database_names: List[str] = Field(
|
|
99
|
+
description="The names of the databases to use",
|
|
100
|
+
examples=[["mydb", "theotherdb"]],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class MyAppConfig(BaseSettings):
|
|
105
|
+
log_level: Optional[Literal["INFO", "DEBUG"]] = "INFO"
|
|
106
|
+
app_name: Optional[str] = Field(
|
|
107
|
+
default="THE APP",
|
|
108
|
+
description="The display name of the app",
|
|
109
|
+
examples=["THAT APP", "THIS APP"],
|
|
110
|
+
)
|
|
111
|
+
storage_dir: Optional[str] = Field(
|
|
112
|
+
description="A directory to store the file of the apps.",
|
|
113
|
+
default_factory=lambda: str(Path(PurePath(Path().home(), ".config/myapp/"))),
|
|
114
|
+
)
|
|
115
|
+
admin_pw: Annotated[str, Field(description="The init password the admin account")]
|
|
116
|
+
database_server: DatabaseServerSettings = Field(
|
|
117
|
+
description="The settings for the database server",
|
|
118
|
+
examples=[
|
|
119
|
+
DatabaseServerSettings(
|
|
120
|
+
host="db.company.org", port=1234, database_names=["db1", "db2"]
|
|
121
|
+
)
|
|
122
|
+
],
|
|
123
|
+
)
|
|
124
|
+
init_values: Dict[str, str]
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
With the help of psyplus we can generate a fully documented
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from psyplus import YamlSettingsPlus
|
|
132
|
+
from config import MyAppConfig
|
|
133
|
+
|
|
134
|
+
yaml_handler = YamlSettingsPlus(MyAppConfig, "test.config.yaml")
|
|
135
|
+
yaml_handler.generate_config_file(overwrite_existing=True)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
which will generate a yaml file `./test.config.yaml` that looks like the following:
|
|
139
|
+
|
|
140
|
+
```yaml
|
|
141
|
+
# ## log_level ###
|
|
142
|
+
# Type: Enum
|
|
143
|
+
# Required: False
|
|
144
|
+
# Default: '"INFO"'
|
|
145
|
+
# Allowed vals: ['INFO', 'DEBUG']
|
|
146
|
+
# Env-var: 'LOG_LEVEL'
|
|
147
|
+
log_level: INFO
|
|
148
|
+
|
|
149
|
+
# ## app_name ###
|
|
150
|
+
# Type: str
|
|
151
|
+
# Required: False
|
|
152
|
+
# Default: '"THE APP"'
|
|
153
|
+
# Env-var: 'APP_NAME'
|
|
154
|
+
# Description: The display name of the app
|
|
155
|
+
# Example No. 1:
|
|
156
|
+
# >app_name: THAT APP
|
|
157
|
+
# >
|
|
158
|
+
# Example No. 2:
|
|
159
|
+
# >app_name: THIS APP
|
|
160
|
+
app_name: THE APP
|
|
161
|
+
|
|
162
|
+
# ## storage_dir ###
|
|
163
|
+
# Type: str
|
|
164
|
+
# Required: False
|
|
165
|
+
# Env-var: 'STORAGE_DIR'
|
|
166
|
+
# Description: A directory to store the file of the apps.
|
|
167
|
+
storage_dir: /home/tim/.config/myapp
|
|
168
|
+
|
|
169
|
+
# ## admin_pw ###
|
|
170
|
+
# Type: str
|
|
171
|
+
# Required: True
|
|
172
|
+
# Env-var: 'ADMIN_PW'
|
|
173
|
+
# Description: The init password the admin account
|
|
174
|
+
admin_pw: ''
|
|
175
|
+
|
|
176
|
+
# ## database_server ###
|
|
177
|
+
# Type: Object
|
|
178
|
+
# Required: True
|
|
179
|
+
# Env-var: 'DATABASE_SERVER'
|
|
180
|
+
# Description: The settings for the database server
|
|
181
|
+
# Example:
|
|
182
|
+
# >database_server:
|
|
183
|
+
# > database_names:
|
|
184
|
+
# > - db1
|
|
185
|
+
# > - db2
|
|
186
|
+
# > host: db.company.org
|
|
187
|
+
# > port: 1234
|
|
188
|
+
database_server:
|
|
189
|
+
|
|
190
|
+
# ## host ###
|
|
191
|
+
# YAML-path: database_server.host
|
|
192
|
+
# Type: str
|
|
193
|
+
# Required: False
|
|
194
|
+
# Default: '"localhost"'
|
|
195
|
+
# Env-var: 'DATABASE_SERVER__HOST'
|
|
196
|
+
# Description: The Hostname the database will be available at
|
|
197
|
+
host: localhost
|
|
198
|
+
|
|
199
|
+
# ## port ###
|
|
200
|
+
# YAML-path: database_server.port
|
|
201
|
+
# Type: int
|
|
202
|
+
# Required: False
|
|
203
|
+
# Default: '5678'
|
|
204
|
+
# Env-var: 'DATABASE_SERVER__PORT'
|
|
205
|
+
# Description: The port to connect to the database
|
|
206
|
+
port: 5678
|
|
207
|
+
|
|
208
|
+
# ## database_names ###
|
|
209
|
+
# YAML-path: database_server.database_names
|
|
210
|
+
# Type: List of str
|
|
211
|
+
# Required: True
|
|
212
|
+
# Env-var: 'DATABASE_SERVER__DATABASE_NAMES'
|
|
213
|
+
# Description: The names of the databases to use
|
|
214
|
+
# Example:
|
|
215
|
+
# >database_names:
|
|
216
|
+
# >- mydb
|
|
217
|
+
# >- theotherdb
|
|
218
|
+
database_names: []
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
To use this yaml file you just psyplus: need to parse it and validate on your pydantic-setting model.
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
from psyplus import YamlSettingsPlus
|
|
225
|
+
|
|
226
|
+
yaml_handler = YamlSettingsPlus(MyAppConfig, "test.config.yaml")
|
|
227
|
+
config: MyAppConfig = yaml_handler.get_config()
|
|
228
|
+
print(config.database_server.host)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Alternativly you can parse and validate the pydantic-settings model yourself:
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
import yaml # pip install PyYAML
|
|
235
|
+
|
|
236
|
+
with open("test.config.yaml") as file:
|
|
237
|
+
raw_yaml_str = file.read()
|
|
238
|
+
obj: Dict = yaml.safe_load(raw_yaml_str)
|
|
239
|
+
config: MyAppConfig = MyAppConfig.model_validate(obj)
|
|
240
|
+
|
|
241
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
psyplus/__init__.py,sha256=bPyOH6PATVFpL8jGAfknuREUfiBMlYqfCN1z_IiIoLo,103
|
|
2
|
+
psyplus/env_var_handler.py,sha256=dT8O-4JhfWIjQYJX6fzdEvDds_wMo7760gjcTCHMBXU,10288
|
|
3
|
+
psyplus/field_container.py,sha256=dD3lAjv8tmWfwWks_hcb5th_srfMGuvMqQq17yezl6E,5628
|
|
4
|
+
psyplus/psyplus.py,sha256=BNs2nxeFXkbpQC3wXvBWNpWx5eqgwdQbF-BiqsCeCMo,8710
|
|
5
|
+
psyplus/utils.py,sha256=aWPm4UnKjAjzBPbkbHqZRBifLK4JLGaFmTLu248zJHU,6227
|
|
6
|
+
psyplus/yaml_pydantic_metadata_comment_injector.py,sha256=H87eG4PrrQ95yncLtCBryiOb6uWsdgQjFU_SnnY8J8g,5602
|
|
7
|
+
psyplus-0.9.2.dist-info/LICENSE,sha256=TuZlsZ-MBR0SxDZR0I8eTSMK2oI-UPKSs8Du8cSpxU0,1101
|
|
8
|
+
psyplus-0.9.2.dist-info/METADATA,sha256=sKjkXtglqFZr9vZXg3MWhTTJWeXDQzZQDWahI9rSojA,7349
|
|
9
|
+
psyplus-0.9.2.dist-info/WHEEL,sha256=nCVcAvsfA9TDtwGwhYaRrlPhTLV9m-Ga6mdyDtuwK18,91
|
|
10
|
+
psyplus-0.9.2.dist-info/top_level.txt,sha256=WbyNW_Spt0-iBhS-I_NwcM_TrFqpnsRlBaPMOgdL5iw,8
|
|
11
|
+
psyplus-0.9.2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
psyplus
|