@@ -29,28 +29,28 @@ def init_config(
2929 config : Union [str , Path ] = 'config.yaml' ,
3030 default_config : Union [str , Path ] = 'default.config.yaml' ,
3131 * ,
32- merge_configs : bool = True ,
33- sections_ignored_on_merge : Optional [List [str ]] = None ,
3432 convert_keys_to_snake_case : bool = False ,
3533 add_underscore_prefix_to_keywords : bool = False ,
3634 raise_error_non_identifiers : bool = False ,
35+ merge_configs : bool = True ,
36+ sections_ignored_on_merge : Optional [List [str ]] = None ,
3737 validate_data_types : bool = True ,
3838 allow_extra_sections : bool = True ,
3939 warn_extra_sections : bool = True ,
4040) -> PyyaConfig :
4141 """Initialize attribute-stylish configuration from YAML file.
4242
4343 Args:
44- config: path to config file
44+ config: path to production config file
4545 default_config: path to default config file
46- merge_configs: merge default config with config (setting to `False` disables other flags)
47- sections_ignored_on_merge: list of sections to ignore when merging configs
4846 convert_keys_to_snake_case: convert config section names to snake case
4947 add_underscore_prefix_to_keywords: add underscore prefix to Python keywords
5048 raise_error_non_identifiers: raise error if config section name is not a valid identifier
51- validate_data_types: raise error if data types in config are not the same as default (makes sense only if merge is enabled)
52- allow_extra_sections: raise error if there are extra sections in config (may break if section name formatting is enabled)
53- warn_extra_sections: warn about extra keys and values
49+ merge_configs: merge default config with production config (setting to `False` disables flags below)
50+ sections_ignored_on_merge: list of sections to ignore when merging configs
51+ validate_data_types: raise error if data types in production config are not the same as default
52+ allow_extra_sections: raise error on any extra sections in production config
53+ warn_extra_sections: if extra sections are allowed, warn about extra keys and values
5454 """
5555
5656 def _merge_configs (
@@ -59,6 +59,7 @@ def _merge_configs(
5959 if sections is None :
6060 sections = [] # for logging
6161 for section , entry in _default_raw_data .items ():
62+ sections .append (section )
6263 if sections_ignored_on_merge :
6364 if section in sections_ignored_on_merge :
6465 logger .debug (f'section `{ section } ` ignored on merge' )
@@ -67,26 +68,17 @@ def _merge_configs(
6768 # is it fine to proccess already poped dicts on recursion?
6869 entry = _pop_ignored_keys (entry )
6970 if section not in _raw_data or _raw_data [section ] is None :
70- f_section = _sanitize_section (section )
71- sections .append (f_section )
72- if f_section not in _raw_data :
73- if isinstance (entry , Dict ):
74- entry = _sanitize_keys (entry )
75- _raw_data [f_section ] = entry
71+ if section not in _raw_data :
72+ _raw_data [section ] = entry
7673 logger .debug (f'section `{ "." .join (sections )} ` with value `{ entry } ` taken from { default_config } ' )
7774 else :
7875 logger .debug (f'section `{ "." .join (sections )} ` already exists in { config } , skipping' )
7976 elif isinstance (entry , Dict ):
80- sections .append (section )
8177 _merge_configs (_raw_data [section ], entry , sections )
82- f_section = _sanitize_section (section )
83- _raw_data [f_section ] = _raw_data .pop (section , None )
8478 # TODO: add support for merging lists
8579 else :
86- f_section = _sanitize_section (section )
87- sections .append (f_section )
88- if f_section not in _raw_data :
89- _raw_data [f_section ] = _raw_data .pop (section , None )
80+ if section not in _raw_data :
81+ _raw_data [section ] = _raw_data .pop (section , None )
9082 else :
9183 logger .debug (f'section `{ "." .join (sections )} ` already exists in { config } , skipping' )
9284 sections .pop ()
@@ -114,26 +106,28 @@ def _pop_ignored_keys(data: ConfigType) -> ConfigType:
114106 return data
115107
116108 def _sanitize_keys (data : ConfigType ) -> ConfigType :
117- for key , entry in data .copy ().items ():
109+ for key in data .copy ():
110+ entry = data .pop (key , None )
111+ key = _sanitize_section (key )
118112 if isinstance (entry , Dict ):
119- _sanitize_keys (entry )
113+ data [ key ] = _sanitize_keys (entry )
120114 else :
121- data [_sanitize_section ( key ) ] = data . pop ( key , None )
115+ data [key ] = entry
122116 return data
123117
124118 def _pop_nested (d : Dict [str , Any ], dotted_key : str , default : Any = None ) -> Any :
125119 keys = dotted_key .split ('.' )
126120 current = d
127121
128122 for k in keys [:- 1 ]:
129- if not isinstance (current , dict ) or k not in current :
123+ if not isinstance (current , Dict ) or k not in current :
130124 return default
131125 current = current [k ]
132126
133127 return current .pop (keys [- 1 ], default )
134128
135129 # https://stackoverflow.com/questions/73958753/return-all-extra-passed-to-pydantic-model
136- class NewBase (BaseModel ):
130+ class ExtraBase (BaseModel ):
137131 model_config = ConfigDict (strict = True , extra = 'allow' if allow_extra_sections else 'forbid' )
138132 extra : Dict [str , Any ] = Field (default = {}, exclude = True )
139133
@@ -155,15 +149,14 @@ def validator(cls, values: Any) -> Any:
155149 def extra_flat (self ) -> Any :
156150 extra_flat = {** self .extra }
157151 for name , value in self :
158- if isinstance (value , NewBase ):
152+ if isinstance (value , ExtraBase ):
159153 data = {f'{ name } .{ k } ' : v for k , v in value .extra_flat .items ()}
160154 extra_flat .update (data )
161155 return extra_flat
162156
163157 def _model_from_dict (name : str , data : Dict [str , Any ]) -> Type [BaseModel ]:
164158 fields : Dict [Any , Any ] = {}
165159 for section , entry in data .items ():
166- section = _sanitize_section (section )
167160 if isinstance (entry , Dict ):
168161 nested_model = _model_from_dict (section , entry )
169162 fields [section ] = (nested_model , entry )
@@ -178,12 +171,12 @@ def _model_from_dict(name: str, data: Dict[str, Any]) -> Type[BaseModel]:
178171 fields [section ] = (List [Any ], entry )
179172 else :
180173 fields [section ] = (type (entry ), entry )
181- model = create_model (name , ** fields , __base__ = NewBase )
182- return model
174+ return create_model (name , ** fields , __base__ = ExtraBase )
183175
184176 try :
185177 with open (Path (config )) as fstream :
186178 _raw_data : ConfigType = _yaml .safe_load (fstream ) or {}
179+ _raw_data = _sanitize_keys (_raw_data )
187180 except _yaml .YAMLError as e :
188181 err_msg = f'{ config } file is corrupted: { e } '
189182 logger .error (err_msg )
@@ -193,10 +186,19 @@ def _model_from_dict(name: str, data: Dict[str, Any]) -> Type[BaseModel]:
193186 _raw_data = {}
194187
195188 if merge_configs :
189+ if sections_ignored_on_merge is None :
190+ sections_ignored_on_merge = []
191+ try :
192+ sections_ignored_on_merge = [_sanitize_section (s ) for s in sections_ignored_on_merge ]
193+ except Exception as e :
194+ err_msg = f'Failed parsing `sections_ignored_on_merge`: { e !r} '
195+ logger .error (err_msg )
196+ raise PyyaError (err_msg ) from None
196197 try :
197198 try :
198199 with open (Path (default_config )) as fstream :
199200 _default_raw_data : Optional [ConfigType ] = _yaml .safe_load (fstream )
201+ _default_raw_data = _sanitize_keys (_default_raw_data )
200202 except _yaml .YAMLError as e :
201203 err_msg = f'{ default_config } file is corrupted: { e } '
202204 logger .error (err_msg )
@@ -209,16 +211,14 @@ def _model_from_dict(name: str, data: Dict[str, Any]) -> Type[BaseModel]:
209211 # create copy for logging (only overwritten fields)
210212 _raw_data_copy = deepcopy (_raw_data )
211213 _merge_configs (_raw_data , _default_raw_data )
212- logger .debug (f'\n \n Resulting config after merge:\n \n { pformat (_raw_data )} ' )
214+ logger .debug (f'Resulting config after merge:\n { pformat (_raw_data )} ' )
213215 if validate_data_types :
214216 ConfigModel = _model_from_dict ('ConfigModel' , _default_raw_data )
215217 try :
216218 validated_raw_data = ConfigModel .model_validate (_raw_data )
217219 if extra_sections := validated_raw_data .extra_flat : # type: ignore
218220 if warn_extra_sections :
219- logger .warning (
220- f'\n \n The following extra sections will be ignored:\n \n { pformat (extra_sections )} '
221- )
221+ logger .warning (f'The following extra sections will be ignored:\n { pformat (extra_sections )} ' )
222222 # remove extra sections from resulting config
223223 for k in extra_sections :
224224 _pop_nested (_raw_data_copy , k )
@@ -227,18 +227,11 @@ def _model_from_dict(name: str, data: Dict[str, Any]) -> Type[BaseModel]:
227227 err_msg = f'Failed validating config file: { e !r} '
228228 logger .error (err_msg )
229229 raise PyyaError (err_msg ) from None
230- # replace formatted sections in the copy of the config for logging
231- for k in _raw_data_copy .copy ():
232- sk = _sanitize_section (k )
233- if sk in _raw_data :
234- _raw_data_copy .pop (k , None )
235- _raw_data_copy [sk ] = _raw_data [sk ]
236230 if _raw_data_copy :
237- logger .info (f'\n \n The following sections were overwritten:\n \n { pformat (_raw_data_copy )} ' )
231+ logger .info (f'The following sections were overwritten:\n { pformat (_raw_data_copy )} ' )
238232 try :
239- raw_data = _munchify (_raw_data )
240- logger .debug (f'\n \n Resulting config:\n \n { pformat (raw_data )} ' )
241- return raw_data
233+ logger .debug (f'Resulting config:\n { pformat (_raw_data )} ' )
234+ return _munchify (_raw_data )
242235 except Exception as e :
243236 err_msg = f'Failed parsing config file: { e !r} '
244237 logger .error (err_msg )
0 commit comments