Skip to content

Commit 828f166

Browse files
Improved logging for extra fields, fixed some bugs and added new one
1 parent 4fb2d39 commit 828f166

File tree

6 files changed

+84
-81
lines changed

6 files changed

+84
-81
lines changed

README.md

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ redis:
5454
database:
5555
username: username
5656
password: password
57+
name: db
5758
```
5859
5960
Import configuration files in your Python code with `pyya`:
@@ -67,18 +68,22 @@ logger.setLevel(logging.INFO)
6768
6869
config = init_config(
6970
'config.yaml', 'default.config.yaml',
70-
merge_configs = True,
71-
sections_ignored_on_merge = ['redis'], # do not include redis in your config
7271
convert_keys_to_snake_case = False,
7372
add_underscore_prefix_to_keywords = False,
7473
raise_error_non_identifiers = False,
74+
merge_configs = True,
75+
sections_ignored_on_merge = ['redis'], # do not include redis in your config
7576
validate_data_types = True,
7677
allow_extra_sections = True,
7778
warn_extra_sections = True,
7879
)
7980
print(json.dumps(config))
8081
8182
# Output:
83+
# 2025-09-05 09:13:17,280 WARNING pyya The following extra sections will be ignored:
84+
# {'database.name': 'db'}
85+
# 2025-09-05 09:13:17,281 INFO pyya The following sections were overwritten:
86+
# {database: {'host': 'localhost', 'port': 5432}}
8287
# {database: {"host": "localhost", "port": 5432, "username": "username", "password": "password"}}
8388
8489
```
@@ -89,19 +94,6 @@ Under the hood `pyya` uses [PyYAML](https://pypi.org/project/PyYAML/) to parse Y
8994

9095
### Flags
9196

92-
```python
93-
# merge default and production configuration files
94-
# setting to `False` disables other flags and makes default config optional
95-
# `False` means "open config file and apply `yaml.safe_load` and `munchify` with no formatting"
96-
merge_configs=True
97-
```
98-
99-
```python
100-
# list of sections to ignore when merging configs
101-
# it is useful when you have examples in your default config but do not want to have in the main one
102-
sections_ignored_on_merge=None
103-
```
104-
10597
```python
10698
# convert `camelCase` or `PascalCase` keys to `snake_case`
10799
convert_keys_to_snake_case=False
@@ -118,18 +110,31 @@ raise_error_non_identifiers=False
118110
```
119111

120112
```python
121-
# raise error if data types in config are not the same as default (makes sense only if merge is enabled)
113+
# merge default and production configuration files
114+
# setting to `False` disables below flags and makes default config optional
115+
# `False` means "open config file and apply `yaml.safe_load` and `munchify` with specified formatting"
116+
merge_configs=True
117+
```
118+
119+
```python
120+
# list of sections to ignore when merging configs
121+
# it is useful when you have examples in your default config but do not want to have in the main one
122+
sections_ignored_on_merge=None
123+
```
124+
125+
```python
126+
# raise error if data types in production config are not the same as default
122127
# validation based on data types inferred from default config
123128
validate_data_types=True
124129
```
125130

126131
```python
127-
# raise error if there are extra sections in config (may break if section name formatting is enabled)
132+
# raise error on any extra sections in production config
128133
allow_extra_sections=True
129134
```
130135

131136
```python
132-
# warn about extra keys and values
137+
# if extra sections are allowed, warn about extra keys and values
133138
warn_extra_sections=True
134139
```
135140

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ python_version = "3.8"
3838
cache_dir = ".mypy_cache/strict"
3939
allow_redefinition = true
4040
strict_optional = false
41-
show_error_codes = true
4241
show_column_numbers = true
4342
warn_no_return = true
4443
disallow_any_unimported = false

pyya.egg-info/PKG-INFO

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ redis:
8181
database:
8282
username: username
8383
password: password
84+
name: db
8485
```
8586

8687
Import configuration files in your Python code with `pyya`:
@@ -94,18 +95,22 @@ logger.setLevel(logging.INFO)
9495

9596
config = init_config(
9697
'config.yaml', 'default.config.yaml',
97-
merge_configs = True,
98-
sections_ignored_on_merge = ['redis'], # do not include redis in your config
9998
convert_keys_to_snake_case = False,
10099
add_underscore_prefix_to_keywords = False,
101100
raise_error_non_identifiers = False,
101+
merge_configs = True,
102+
sections_ignored_on_merge = ['redis'], # do not include redis in your config
102103
validate_data_types = True,
103104
allow_extra_sections = True,
104105
warn_extra_sections = True,
105106
)
106107
print(json.dumps(config))
107108

108109
# Output:
110+
# 2025-09-05 09:13:17,280 WARNING pyya The following extra sections will be ignored:
111+
# {'database.name': 'db'}
112+
# 2025-09-05 09:13:17,281 INFO pyya The following sections were overwritten:
113+
# {database: {'host': 'localhost', 'port': 5432}}
109114
# {database: {"host": "localhost", "port": 5432, "username": "username", "password": "password"}}
110115

111116
```
@@ -116,19 +121,6 @@ Under the hood `pyya` uses [PyYAML](https://pypi.org/project/PyYAML/) to parse Y
116121

117122
### Flags
118123

119-
```python
120-
# merge default and production configuration files
121-
# setting to `False` disables other flags and makes default config optional
122-
# `False` means "open config file and apply `yaml.safe_load` and `munchify` with no formatting"
123-
merge_configs=True
124-
```
125-
126-
```python
127-
# list of sections to ignore when merging configs
128-
# it is useful when you have examples in your default config but do not want to have in the main one
129-
sections_ignored_on_merge=None
130-
```
131-
132124
```python
133125
# convert `camelCase` or `PascalCase` keys to `snake_case`
134126
convert_keys_to_snake_case=False
@@ -145,18 +137,31 @@ raise_error_non_identifiers=False
145137
```
146138

147139
```python
148-
# raise error if data types in config are not the same as default (makes sense only if merge is enabled)
140+
# merge default and production configuration files
141+
# setting to `False` disables below flags and makes default config optional
142+
# `False` means "open config file and apply `yaml.safe_load` and `munchify` with specified formatting"
143+
merge_configs=True
144+
```
145+
146+
```python
147+
# list of sections to ignore when merging configs
148+
# it is useful when you have examples in your default config but do not want to have in the main one
149+
sections_ignored_on_merge=None
150+
```
151+
152+
```python
153+
# raise error if data types in production config are not the same as default
149154
# validation based on data types inferred from default config
150155
validate_data_types=True
151156
```
152157

153158
```python
154-
# raise error if there are extra sections in config (may break if section name formatting is enabled)
159+
# raise error on any extra sections in production config
155160
allow_extra_sections=True
156161
```
157162

158163
```python
159-
# warn about extra keys and values
164+
# if extra sections are allowed, warn about extra keys and values
160165
warn_extra_sections=True
161166
```
162167

pyya.egg-info/SOURCES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ LICENSE
22
README.md
33
pyproject.toml
44
pyya/__init__.py
5+
pyya/py.typed
56
pyya.egg-info/PKG-INFO
67
pyya.egg-info/SOURCES.txt
78
pyya.egg-info/dependency_links.txt

pyya/__init__.py

Lines changed: 37 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -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\nResulting 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\nThe 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\nThe 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\nResulting 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)

pyya/py.typed

Whitespace-only changes.

0 commit comments

Comments
 (0)