Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"

[project]
name = "skelet"
version = "0.0.17"
version = "0.0.18"
authors = [{ name = "Evgeniy Blinov", email = "zheni-b@yandex.ru" }]
description = 'Collect all the settings in one place'
readme = "README.md"
requires-python = ">=3.8"
dependencies = [
'printo>=0.0.22',
'printo>=0.0.26',
'locklib>=0.0.21',
'simtypes>=0.0.13',
'denial>=0.0.13',
Expand Down
80 changes: 65 additions & 15 deletions skelet/fields/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@
cast,
get_origin,
get_type_hints,
overload,
)

from denial import InnerNoneType
from locklib import ContextLockProtocol
from sigmatch import PossibleCallMatcher, SignatureMismatchError
from printo import superrepr
from sigmatch import PossibleCallMatcher, SignatureError, SignatureMismatchError
from simtypes import check

from skelet.sources.abstract import AbstractSource, ExpectedType
Expand All @@ -32,8 +34,9 @@

sentinel = InnerNoneType()


class FieldDescriptor(Generic[ValueType, StorageType]):
def __init__( # noqa: PLR0913, PLR0915
def __init__( # noqa: PLR0913
self,
default: Union[ValueType, InnerNoneType] = sentinel,
/,
Expand All @@ -55,30 +58,26 @@ def __init__( # noqa: PLR0913, PLR0915
if default_factory is not None:
if default is not sentinel:
raise ValueError('You can define a default value or a factory for default values, but not all at the same time.')
if not PossibleCallMatcher().match(default_factory):
raise SignatureMismatchError('The default value factory should not expect any arguments.')
self.check_callback_signature(default_factory, PossibleCallMatcher(), 'default_factory', 'with no arguments')

if validation is not None:
validation_matcher = PossibleCallMatcher('.')
if isinstance(validation, dict):
for validator_message, validator in validation.items():
if not validation_matcher.match(validator):
raise SignatureMismatchError(f'Field validator with message {validator_message!r} is incorrect: a function that accepts only one positional argument is expected.')
elif not validation_matcher.match(validation):
raise SignatureMismatchError('A function that accepts only one positional argument is expected as a field validator.')
self.check_callback_signature(validator, validation_matcher, f'validation item with key {superrepr(validator_message)}', 'with one positional argument: value is the field value being validated')
else:
self.check_callback_signature(validation, validation_matcher, 'validation', 'with one positional argument: value is the field value being validated')

if action is not None and not PossibleCallMatcher('...').match(action):
raise SignatureMismatchError('The callback for each field change must take 3 arguments: the old field value, the new value, and the storage object itself.')
if action is not None:
self.check_callback_signature(action, PossibleCallMatcher('...'), 'action', 'with three positional arguments: old_value is the previous field value, new_value is the assigned field value, and storage is the Storage instance')

if conflicts is not None:
conflict_checker_matcher = PossibleCallMatcher('....')
for potentially_conflicting_field_name, conflict_checker in conflicts.items():
if not conflict_checker_matcher.match(conflict_checker):
raise SignatureMismatchError(f'The function for checking conflicts with field {potentially_conflicting_field_name!r} is bad; it should take four positional arguments: the old value of this field, the new value of this field, the old value of the conflicting field, and the new value of the conflicting field (for reverse checks).')
self.check_callback_signature(conflict_checker, conflict_checker_matcher, f'conflicts item for field {superrepr(potentially_conflicting_field_name)}', "with four positional arguments: old is this field's previous value, new is this field's candidate value, other_old is the conflicting field's previous value, and other_new is the conflicting field's candidate value")

if conversion is not None:
if not PossibleCallMatcher('.').match(conversion):
raise SignatureMismatchError('The value converter must accept only one argument: the value before conversion.')
self.check_callback_signature(conversion, PossibleCallMatcher('.'), 'conversion', 'with one positional argument: value is the raw field value before conversion')
if default is not sentinel:
self._default_before_conversion: Union[ValueType, InnerNoneType] = default
self._default: Union[ValueType, InnerNoneType] = sentinel
Expand Down Expand Up @@ -321,9 +320,60 @@ def _get_normal_sources(self, instance: Storage) -> SourcesCollection[ExpectedTy

return cast(SourcesCollection[ExpectedType], SourcesCollection(result))

def check_callback_signature(self, callback: Callable[..., Any], matcher: PossibleCallMatcher, callback_setting: str, call_description: str) -> None:
try:
matcher.match(callback, raise_exception=True)
except (SignatureError, ValueError, RuntimeError) as exception:
raise SignatureMismatchError(f'Callback configured in {callback_setting} is invalid: skelet calls it {call_description}, but {superrepr(callback)} cannot be called in that form.') from exception


@overload
def Field(
default: Union[ValueType, InnerNoneType] = sentinel,
/,
default_factory: Optional[Callable[[], ValueType]] = None,
doc: Optional[str] = None,
alias: Optional[str] = None,
sources: Optional[List[Union[AbstractSource[ExpectedType], EllipsisType]]] = None,
read_only: bool = False,
validation: Optional[Union[Dict[str, Callable[[ValueType], bool]], Callable[[ValueType], bool]]] = None,
validate_default: bool = True,
secret: bool = False,
action: Optional[ChangeAction[ValueType, StorageType]] = None,
read_lock: bool = False,
conflicts: Optional[Dict[str, Callable[[ValueType, ValueType, Any, Any], bool]]] = None,
reverse_conflicts: bool = True,
conversion: None = None,
share_mutex_with: Optional[Sequence[str]] = None,
) -> ValueType:
... # pragma: no cover


@overload
def Field(
default: Union[ValueType, InnerNoneType] = sentinel,
/,
default_factory: Optional[Callable[[], ValueType]] = None,
doc: Optional[str] = None,
alias: Optional[str] = None,
sources: Optional[List[Union[AbstractSource[ExpectedType], EllipsisType]]] = None,
read_only: bool = False,
validation: Optional[Union[Dict[str, Callable[[ValueType], bool]], Callable[[ValueType], bool]]] = None,
validate_default: bool = True,
secret: bool = False,
action: Optional[ChangeAction[ValueType, StorageType]] = None,
read_lock: bool = False,
conflicts: Optional[Dict[str, Callable[[ValueType, ValueType, Any, Any], bool]]] = None,
reverse_conflicts: bool = True,
*,
conversion: Callable[[ValueType], ValueType],
share_mutex_with: Optional[Sequence[str]] = None,
) -> ValueType:
... # pragma: no cover


def Field( # noqa: PLR0913, N802
default: Any = sentinel,
default: Union[Any, InnerNoneType] = sentinel,
/,
default_factory: Optional[Callable[[], Any]] = None,
doc: Optional[str] = None,
Expand Down
2 changes: 1 addition & 1 deletion skelet/sources/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

sentinel = InnerNoneType()

@repred(prefer_positional=True) # type: ignore[call-overload]
@repred(prefer_positional=True) # type: ignore[arg-type]
class SourcesCollection(AbstractSource[ExpectedType]):
def __init__(self, sources: List[AbstractSource[ExpectedType]]) -> None:
self.sources = sources
Expand Down
2 changes: 1 addition & 1 deletion skelet/sources/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from skelet.sources.abstract import AbstractSource, ExpectedType


@repred(prefer_positional=True) # type: ignore[call-overload]
@repred(prefer_positional=True) # type: ignore[arg-type]
class MemorySource(AbstractSource[ExpectedType]):
def __init__(self, data: Dict[str, ExpectedType]) -> None:
self.data = data
Expand Down
26 changes: 26 additions & 0 deletions tests/typing/test_advanced_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ class Config(Storage):
assert_type(config.name, str)


@pytest.mark.mypy_testing
def test_field_with_variadic_action() -> None:
def variadic_action(*args: Any) -> None:
pass

class Config(Storage):
name: str = Field('default', action=variadic_action)

config = Config()
assert_type(config.name, str)


@pytest.mark.mypy_testing
def test_field_with_conflicts() -> None:
def check_conflict(_old: Any, new: Any, _other_old: Any, other_new: Any) -> bool:
Expand All @@ -34,6 +46,20 @@ class Config(Storage):
assert_type(config.b, int)


@pytest.mark.mypy_testing
def test_field_with_variadic_conflicts() -> None:
def variadic_conflict(*args: Any) -> bool:
return False

class Config(Storage):
a: int = Field(1)
b: int = Field(2, conflicts={'a': variadic_conflict})

config = Config()
assert_type(config.a, int)
assert_type(config.b, int)


@pytest.mark.mypy_testing
def test_field_with_conversion_and_validation() -> None:
class Config(Storage):
Expand Down
6 changes: 4 additions & 2 deletions tests/typing/test_custom_types.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import cast

import pytest
from typing_extensions import assert_type

Expand All @@ -7,7 +9,7 @@
@pytest.mark.mypy_testing
def test_natural_number_field() -> None:
class Config(Storage):
count: NaturalNumber = Field(1)
count: NaturalNumber = Field(cast(NaturalNumber, 1))

config = Config()
assert_type(config.count, NaturalNumber)
Expand All @@ -16,7 +18,7 @@ class Config(Storage):
@pytest.mark.mypy_testing
def test_non_negative_int_field() -> None:
class Config(Storage):
count: NonNegativeInt = Field(0)
count: NonNegativeInt = Field(cast(NonNegativeInt, 0))

config = Config()
assert_type(config.count, NonNegativeInt)
53 changes: 45 additions & 8 deletions tests/typing/test_field_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,13 @@ class Config(Storage):
assert_type(config.items, List[int])


def _make_empty_dict() -> Dict[str, int]:
return {}


@pytest.mark.mypy_testing
def test_field_dict_type() -> None:
def make_empty_dict() -> Dict[str, int]:
return {}

class Config(Storage):
mapping: Dict[str, int] = Field(default_factory=_make_empty_dict)
mapping: Dict[str, int] = Field(default_factory=make_empty_dict)

config = Config()
assert_type(config.mapping, Dict[str, int])
Expand All @@ -115,6 +114,18 @@ class Config(Storage):
assert_type(config.items, List[Any])


@pytest.mark.mypy_testing
def test_field_with_typed_default_factory() -> None:
def make_default_age() -> int:
return 18

class Config(Storage):
age: int = Field(default_factory=make_default_age)

config = Config()
assert_type(config.age, int)


@pytest.mark.mypy_testing
def test_multiple_fields() -> None:
class Config(Storage):
Expand Down Expand Up @@ -146,6 +157,18 @@ class Config(Storage):
assert_type(config.age, int)


@pytest.mark.mypy_testing
def test_field_with_variadic_validation() -> None:
def variadic_validator(*args: Any) -> bool:
return bool(args)

class Config(Storage):
age: int = Field(18, validation=variadic_validator)

config = Config()
assert_type(config.age, int)


@pytest.mark.mypy_testing
def test_field_with_dict_validation() -> None:
class Config(Storage):
Expand Down Expand Up @@ -191,14 +214,28 @@ class Config(Storage):
assert_type(config.value, int)


def _str_to_int(x: Any) -> int:
return int(x)
@pytest.mark.mypy_testing
def test_field_with_typed_conversion() -> None:
def double(value: int) -> int:
return value * 2

class Config(Storage):
value: int = Field(0, conversion=double)

config = Config()
assert_type(config.value, int)


@pytest.mark.mypy_testing
def test_field_conversion_type_widening() -> None:
def make_raw_value() -> Union[str, int]:
return '0'

def normalize(value: Union[str, int]) -> Union[str, int]:
return int(value)

class Config(Storage):
value: Union[str, int] = Field('0', conversion=_str_to_int)
value: Union[str, int] = Field(default_factory=make_raw_value, conversion=normalize)

config = Config()
assert_type(config.value, Union[str, int])
Loading
Loading