Skip to content

Commit

Permalink
0.2.3
Browse files Browse the repository at this point in the history
  • Loading branch information
spacemanspiff2007 committed Apr 8, 2022
1 parent 48be5f8 commit 66a1e53
Show file tree
Hide file tree
Showing 16 changed files with 200 additions and 79 deletions.
6 changes: 5 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,11 @@ sub.cancel()
```

# Changelog
#### 0.2.2 (25.03.2022)
#### 0.2.3 (08.04.2022)
- Added extra kwargs check for pydantic fields
- Added option to get generated yaml as a string

#### 0.2.2 (31.03.2022)
- Added convenience base classes ``AppBaseModel`` and ``BaseModel``
- Works with private attributes and class functions
- Fixed an issue where multiline comments would not be created properly
Expand Down
3 changes: 3 additions & 0 deletions src/easyconfig/__const__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ def __str__(self):

MISSING: Final = _MissingType.MISSING_OBJ
MISSING_TYPE: Final = Literal[_MissingType.MISSING_OBJ]


ARG_NAME_IN_FILE: Final = 'in_file'
2 changes: 1 addition & 1 deletion src/easyconfig/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@

# isort: split

from easyconfig.config_objs import create_app_config
from easyconfig.create_app_config import create_app_config
2 changes: 1 addition & 1 deletion src/easyconfig/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '0.2.2'
__version__ = '0.2.3'
2 changes: 1 addition & 1 deletion src/easyconfig/config_objs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@

# isort: split

from .app_config import AppConfig, create_app_config
from .app_config import AppConfig
from .object_config import ConfigObj, HINT_CONFIG_OBJ, HINT_CONFIG_OBJ_TYPE
48 changes: 13 additions & 35 deletions src/easyconfig/config_objs/app_config.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from inspect import isfunction
from io import StringIO
from pathlib import Path
from typing import Any, Callable, Dict, Optional, Tuple, TypeVar, Union
from typing import Optional, Tuple, Union

from pydantic import BaseModel, Extra
from pydantic import BaseModel
from typing_extensions import Self

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

from ..errors import DefaultNotSet
from .object_config import ConfigObj


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

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

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

def generate_default_yaml(self) -> str:

TYPE_WRAPPED = TypeVar('TYPE_WRAPPED', bound=BaseModel)
if self._file_defaults is None:
raise DefaultNotSet()


def create_app_config(model: TYPE_WRAPPED,
file_values: Union[MISSING_TYPE, None, BaseModel, Dict[str, Any],
Callable[[], Union[BaseModel, Dict[str, Any]]]] = MISSING,
validate_file_values=True) -> TYPE_WRAPPED:

# Implicit default
if file_values is MISSING:
file_values = model

# if it's a callback we get the values
if isfunction(file_values):
file_values = file_values()

# Validate default
if file_values is not None:
if isinstance(file_values, dict):
if validate_file_values:
class NoExtraEntries(model.__class__, extra=Extra.forbid):
pass
NoExtraEntries.parse_obj(file_values)

file_values = model.__class__.parse_obj(file_values)

app_cfg = AppConfig.from_model(model)

assert file_values is None or isinstance(file_values, BaseModel)
app_cfg._file_defaults = file_values
return app_cfg
buffer = StringIO()
c_map = cmap_from_model(self._file_defaults)
write_aligned_yaml(c_map, buffer, extra_indent=1)
return buffer.getvalue()
8 changes: 6 additions & 2 deletions src/easyconfig/config_objs/object_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ def __init__(self, model: BaseModel,

self._last_model: BaseModel = model

@property
def _full_obj_path(self) -> str:
return '.'.join(self._obj_path)

@classmethod
def from_model(cls, model: BaseModel,
path: Tuple[str, ...] = ('__root__', ),
Expand Down Expand Up @@ -138,7 +142,7 @@ def _set_values(self, obj: BaseModel) -> bool:
return propagate

def __repr__(self):
return f'<{self.__class__.__name__} {".".join(self._obj_path)}>'
return f'<{self.__class__.__name__} {self._full_obj_path}>'

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

target = f'{func.__name__} @ {".".join(self._obj_path)}'
target = f'{func.__name__} @ {self._full_obj_path}'
for sub in self._obj_subscriptions:
if sub.func is func:
raise DuplicateSubscriptionError(f'{target} is already subscribed!')
Expand Down
74 changes: 74 additions & 0 deletions src/easyconfig/create_app_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from inspect import isfunction
from typing import Any, Callable, Dict, FrozenSet, Iterable, Optional, TypeVar, Union

from pydantic import BaseModel

from easyconfig.__const__ import ARG_NAME_IN_FILE, MISSING, MISSING_TYPE
from easyconfig.config_objs.app_config import AppConfig, yaml_rt
from easyconfig.errors import ExtraKwArgsNotAllowed

TYPE_WRAPPED = TypeVar('TYPE_WRAPPED', bound=BaseModel)
TYPE_DEFAULTS = Union[BaseModel, Dict[str, Any]]


def check_field_args(model: AppConfig, allowed: FrozenSet[str]):
"""Check extra args of pydantic fields"""

# Model fields
for name, field in model._obj_model_fields.items():
if not set(field.field_info.extra).issubset(allowed):
forbidden = sorted(set(field.field_info.extra) - allowed)
raise ExtraKwArgsNotAllowed(f'Extra kwargs for field "{name}" of {model._last_model.__class__.__name__} '
f'are not allowed: {", ".join(forbidden)}')

# Submodels
for name, sub_model in model._obj_children.items():
if isinstance(sub_model, tuple):
for _sub_model in sub_model:
check_field_args(model, allowed)
else:
check_field_args(sub_model, allowed)


def get_file_values(model: TYPE_WRAPPED, file_values: Union[
MISSING_TYPE, None, TYPE_DEFAULTS, Callable[[], TYPE_DEFAULTS]] = MISSING) -> Optional[BaseModel]:

# Implicit default
if file_values is MISSING:
file_values = model

# if it's a callback we get the values
if isfunction(file_values):
file_values = file_values()

# dict -> build models
if isinstance(file_values, dict):
file_values = model.__class__.parse_obj(file_values)

if file_values is not None and not isinstance(file_values, BaseModel):
raise ValueError(
f'Default must be None or an instance of {BaseModel.__class__.__name__}! Got {type(file_values)}')

return file_values


def create_app_config(
model: TYPE_WRAPPED,
file_values: Union[MISSING_TYPE, None, TYPE_DEFAULTS, Callable[[], TYPE_DEFAULTS]] = MISSING,
validate_file_values=True,
check_field_extra_args: Optional[Iterable[str]] = (ARG_NAME_IN_FILE, )) -> TYPE_WRAPPED:

app_cfg = AppConfig.from_model(model)
app_cfg._file_defaults = get_file_values(model, file_values)

# ensure that the extra args have no typos
if check_field_extra_args is not None:
check_field_args(app_cfg, frozenset(check_field_extra_args))

# validate the default file
if file_values is not None and validate_file_values:
_yaml = app_cfg.generate_default_yaml()
_dict = yaml_rt.load(_yaml)
model.__class__.parse_obj(_dict)

return app_cfg
4 changes: 2 additions & 2 deletions src/easyconfig/errors/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from .errors import DuplicateSubscriptionError, FunctionCallNotAllowedError, \
ReferenceFolderMissingError, SubscriptionAlreadyCanceledError
from .errors import DefaultNotSet, DuplicateSubscriptionError, ExtraKwArgsNotAllowed, \
FunctionCallNotAllowedError, SubscriptionAlreadyCanceledError
from .handler import set_exception_handler
6 changes: 5 additions & 1 deletion src/easyconfig/errors/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ class DuplicateSubscriptionError(EasyConfigError):
pass


class ReferenceFolderMissingError(EasyConfigError):
class ExtraKwArgsNotAllowed(EasyConfigError):
pass


class DefaultNotSet(EasyConfigError):
pass


Expand Down
7 changes: 7 additions & 0 deletions src/easyconfig/models/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,10 @@ def load_config_file(self, path: Union[Path, str] = None):
:returns: True if config changed else False
"""
pass

def generate_default_yaml(self) -> str:
"""Generate the default YAML structure
:returns: YAML structure as a string
"""
pass
4 changes: 2 additions & 2 deletions src/easyconfig/yaml/align.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from io import BytesIO
from io import StringIO
from typing import Union

from easyconfig.yaml import yaml_rt
Expand Down Expand Up @@ -55,7 +55,7 @@ def remove_none(obj: Union[dict]):
def write_aligned_yaml(obj, file_obj, extra_indent: int = 0):
assert extra_indent >= 0, extra_indent

buffer = BytesIO()
buffer = StringIO()
yaml_rt.dump(obj, buffer)

loaded_obj = yaml_rt.load(buffer.getvalue())
Expand Down
4 changes: 2 additions & 2 deletions src/easyconfig/yaml/from_model.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from pydantic import BaseModel
from pydantic.fields import ModelField

from easyconfig.__const__ import MISSING
from easyconfig.__const__ import ARG_NAME_IN_FILE, MISSING
from easyconfig.yaml import CommentedMap


Expand All @@ -17,7 +17,7 @@ def cmap_from_model(model: BaseModel, skip_none=True) -> CommentedMap:
yaml_key = field.alias
description = field_info.description

if not field_info.extra.get('in_file', True):
if not field_info.extra.get(ARG_NAME_IN_FILE, True):
continue

if isinstance(value, BaseModel):
Expand Down
60 changes: 60 additions & 0 deletions tests/test_app_creation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import pytest
from pydantic import BaseModel, Field, ValidationError

from easyconfig import create_app_config
from easyconfig.errors import DefaultNotSet, ExtraKwArgsNotAllowed


def test_simple():
class SimpleModel(BaseModel):
a: int = Field(5, alias='aaa')

create_app_config(SimpleModel(aaa=99))
create_app_config(SimpleModel(), {'aaa': 999})

with pytest.raises(ValidationError):
create_app_config(SimpleModel(), {'aaa': 'asdf'})


def test_default_yaml():
class SimpleModel(BaseModel):
a: int = Field(5, alias='aaa')

a = create_app_config(SimpleModel(aaa=99))
assert a.generate_default_yaml() == 'aaa: 99\n'

a = create_app_config(SimpleModel(), file_values=SimpleModel(aaa=12))
assert a.generate_default_yaml() == 'aaa: 12\n'

a = create_app_config(SimpleModel(), file_values=None)
with pytest.raises(DefaultNotSet):
a.generate_default_yaml()


def test_callback_for_default():
class SimpleModel(BaseModel):
a: int = Field(5, alias='aaa')

def get_default():
return SimpleModel(aaa=999)

a = create_app_config(SimpleModel(), get_default)
assert a._file_defaults.a == 999

a = create_app_config(SimpleModel(), lambda: {'aaa': 999})
assert a._file_defaults.a == 999


def test_extra_kwargs():
class SimpleModelOk(BaseModel):
a: int = Field(5, alias='aaa', in_file=False)

create_app_config(SimpleModelOk(aaa=99))

class SimpleModelErr(BaseModel):
a: int = Field(5, alias='aaa', in__file=False)

with pytest.raises(ExtraKwArgsNotAllowed) as e:
create_app_config(SimpleModelErr(aaa=99))

assert str(e.value) == 'Extra kwargs for field "a" of SimpleModelErr are not allowed: in__file'
29 changes: 0 additions & 29 deletions tests/test_config_objs/test_app_creation.py

This file was deleted.

Loading

0 comments on commit 66a1e53

Please sign in to comment.