Skip to content

Commit 66a1e53

Browse files
0.2.3
1 parent 48be5f8 commit 66a1e53

File tree

16 files changed

+200
-79
lines changed

16 files changed

+200
-79
lines changed

readme.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,11 @@ sub.cancel()
157157
```
158158

159159
# Changelog
160-
#### 0.2.2 (25.03.2022)
160+
#### 0.2.3 (08.04.2022)
161+
- Added extra kwargs check for pydantic fields
162+
- Added option to get generated yaml as a string
163+
164+
#### 0.2.2 (31.03.2022)
161165
- Added convenience base classes ``AppBaseModel`` and ``BaseModel``
162166
- Works with private attributes and class functions
163167
- Fixed an issue where multiline comments would not be created properly

src/easyconfig/__const__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,6 @@ def __str__(self):
1111

1212
MISSING: Final = _MissingType.MISSING_OBJ
1313
MISSING_TYPE: Final = Literal[_MissingType.MISSING_OBJ]
14+
15+
16+
ARG_NAME_IN_FILE: Final = 'in_file'

src/easyconfig/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@
77

88
# isort: split
99

10-
from easyconfig.config_objs import create_app_config
10+
from easyconfig.create_app_config import create_app_config

src/easyconfig/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '0.2.2'
1+
__version__ = '0.2.3'

src/easyconfig/config_objs/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22

33
# isort: split
44

5-
from .app_config import AppConfig, create_app_config
5+
from .app_config import AppConfig
66
from .object_config import ConfigObj, HINT_CONFIG_OBJ, HINT_CONFIG_OBJ_TYPE
Lines changed: 13 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
from inspect import isfunction
1+
from io import StringIO
22
from pathlib import Path
3-
from typing import Any, Callable, Dict, Optional, Tuple, TypeVar, Union
3+
from typing import Optional, Tuple, Union
44

5-
from pydantic import BaseModel, Extra
5+
from pydantic import BaseModel
66
from typing_extensions import Self
77

88
from easyconfig.__const__ import MISSING, MISSING_TYPE
99
from easyconfig.yaml import cmap_from_model, CommentedMap, write_aligned_yaml, yaml_rt
1010

11+
from ..errors import DefaultNotSet
1112
from .object_config import ConfigObj
1213

1314

@@ -43,9 +44,9 @@ def load_config_file(self, path: Union[Path, str] = None):
4344

4445
# create default config file
4546
if self._file_defaults is not None and not self._file_path.is_file():
46-
c_map = cmap_from_model(self._file_defaults)
47+
__yaml = self.generate_default_yaml()
4748
with self._file_path.open(mode='w', encoding='utf-8') as f:
48-
write_aligned_yaml(c_map, f, extra_indent=1)
49+
f.write(__yaml)
4950

5051
# Load data from file
5152
with self._file_path.open('r', encoding='utf-8') as file:
@@ -56,35 +57,12 @@ def load_config_file(self, path: Union[Path, str] = None):
5657
# load c_map data (which is a dict)
5758
self.load_config_dict(cfg)
5859

60+
def generate_default_yaml(self) -> str:
5961

60-
TYPE_WRAPPED = TypeVar('TYPE_WRAPPED', bound=BaseModel)
62+
if self._file_defaults is None:
63+
raise DefaultNotSet()
6164

62-
63-
def create_app_config(model: TYPE_WRAPPED,
64-
file_values: Union[MISSING_TYPE, None, BaseModel, Dict[str, Any],
65-
Callable[[], Union[BaseModel, Dict[str, Any]]]] = MISSING,
66-
validate_file_values=True) -> TYPE_WRAPPED:
67-
68-
# Implicit default
69-
if file_values is MISSING:
70-
file_values = model
71-
72-
# if it's a callback we get the values
73-
if isfunction(file_values):
74-
file_values = file_values()
75-
76-
# Validate default
77-
if file_values is not None:
78-
if isinstance(file_values, dict):
79-
if validate_file_values:
80-
class NoExtraEntries(model.__class__, extra=Extra.forbid):
81-
pass
82-
NoExtraEntries.parse_obj(file_values)
83-
84-
file_values = model.__class__.parse_obj(file_values)
85-
86-
app_cfg = AppConfig.from_model(model)
87-
88-
assert file_values is None or isinstance(file_values, BaseModel)
89-
app_cfg._file_defaults = file_values
90-
return app_cfg
65+
buffer = StringIO()
66+
c_map = cmap_from_model(self._file_defaults)
67+
write_aligned_yaml(c_map, buffer, extra_indent=1)
68+
return buffer.getvalue()

src/easyconfig/config_objs/object_config.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ def __init__(self, model: BaseModel,
4141

4242
self._last_model: BaseModel = model
4343

44+
@property
45+
def _full_obj_path(self) -> str:
46+
return '.'.join(self._obj_path)
47+
4448
@classmethod
4549
def from_model(cls, model: BaseModel,
4650
path: Tuple[str, ...] = ('__root__', ),
@@ -138,7 +142,7 @@ def _set_values(self, obj: BaseModel) -> bool:
138142
return propagate
139143

140144
def __repr__(self):
141-
return f'<{self.__class__.__name__} {".".join(self._obj_path)}>'
145+
return f'<{self.__class__.__name__} {self._full_obj_path}>'
142146

143147
# def __getattr__(self, item):
144148
# # delegate call to model
@@ -150,7 +154,7 @@ def __repr__(self):
150154
def subscribe_for_changes(self, func: Callable[[], Any], propagate: bool = False, on_next_load: bool = True) \
151155
-> 'easyconfig.config_objs.ConfigObjSubscription':
152156

153-
target = f'{func.__name__} @ {".".join(self._obj_path)}'
157+
target = f'{func.__name__} @ {self._full_obj_path}'
154158
for sub in self._obj_subscriptions:
155159
if sub.func is func:
156160
raise DuplicateSubscriptionError(f'{target} is already subscribed!')
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from inspect import isfunction
2+
from typing import Any, Callable, Dict, FrozenSet, Iterable, Optional, TypeVar, Union
3+
4+
from pydantic import BaseModel
5+
6+
from easyconfig.__const__ import ARG_NAME_IN_FILE, MISSING, MISSING_TYPE
7+
from easyconfig.config_objs.app_config import AppConfig, yaml_rt
8+
from easyconfig.errors import ExtraKwArgsNotAllowed
9+
10+
TYPE_WRAPPED = TypeVar('TYPE_WRAPPED', bound=BaseModel)
11+
TYPE_DEFAULTS = Union[BaseModel, Dict[str, Any]]
12+
13+
14+
def check_field_args(model: AppConfig, allowed: FrozenSet[str]):
15+
"""Check extra args of pydantic fields"""
16+
17+
# Model fields
18+
for name, field in model._obj_model_fields.items():
19+
if not set(field.field_info.extra).issubset(allowed):
20+
forbidden = sorted(set(field.field_info.extra) - allowed)
21+
raise ExtraKwArgsNotAllowed(f'Extra kwargs for field "{name}" of {model._last_model.__class__.__name__} '
22+
f'are not allowed: {", ".join(forbidden)}')
23+
24+
# Submodels
25+
for name, sub_model in model._obj_children.items():
26+
if isinstance(sub_model, tuple):
27+
for _sub_model in sub_model:
28+
check_field_args(model, allowed)
29+
else:
30+
check_field_args(sub_model, allowed)
31+
32+
33+
def get_file_values(model: TYPE_WRAPPED, file_values: Union[
34+
MISSING_TYPE, None, TYPE_DEFAULTS, Callable[[], TYPE_DEFAULTS]] = MISSING) -> Optional[BaseModel]:
35+
36+
# Implicit default
37+
if file_values is MISSING:
38+
file_values = model
39+
40+
# if it's a callback we get the values
41+
if isfunction(file_values):
42+
file_values = file_values()
43+
44+
# dict -> build models
45+
if isinstance(file_values, dict):
46+
file_values = model.__class__.parse_obj(file_values)
47+
48+
if file_values is not None and not isinstance(file_values, BaseModel):
49+
raise ValueError(
50+
f'Default must be None or an instance of {BaseModel.__class__.__name__}! Got {type(file_values)}')
51+
52+
return file_values
53+
54+
55+
def create_app_config(
56+
model: TYPE_WRAPPED,
57+
file_values: Union[MISSING_TYPE, None, TYPE_DEFAULTS, Callable[[], TYPE_DEFAULTS]] = MISSING,
58+
validate_file_values=True,
59+
check_field_extra_args: Optional[Iterable[str]] = (ARG_NAME_IN_FILE, )) -> TYPE_WRAPPED:
60+
61+
app_cfg = AppConfig.from_model(model)
62+
app_cfg._file_defaults = get_file_values(model, file_values)
63+
64+
# ensure that the extra args have no typos
65+
if check_field_extra_args is not None:
66+
check_field_args(app_cfg, frozenset(check_field_extra_args))
67+
68+
# validate the default file
69+
if file_values is not None and validate_file_values:
70+
_yaml = app_cfg.generate_default_yaml()
71+
_dict = yaml_rt.load(_yaml)
72+
model.__class__.parse_obj(_dict)
73+
74+
return app_cfg

src/easyconfig/errors/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
from .errors import DuplicateSubscriptionError, FunctionCallNotAllowedError, \
2-
ReferenceFolderMissingError, SubscriptionAlreadyCanceledError
1+
from .errors import DefaultNotSet, DuplicateSubscriptionError, ExtraKwArgsNotAllowed, \
2+
FunctionCallNotAllowedError, SubscriptionAlreadyCanceledError
33
from .handler import set_exception_handler

src/easyconfig/errors/errors.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ class DuplicateSubscriptionError(EasyConfigError):
1010
pass
1111

1212

13-
class ReferenceFolderMissingError(EasyConfigError):
13+
class ExtraKwArgsNotAllowed(EasyConfigError):
14+
pass
15+
16+
17+
class DefaultNotSet(EasyConfigError):
1418
pass
1519

1620

0 commit comments

Comments
 (0)