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 ADDED
@@ -0,0 +1,2 @@
1
+ from psyplus.psyplus import YamlSettingsPlus
2
+ from psyplus.env_var_handler import EnvVarHandlerExtended
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (73.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ psyplus