From c351821cd7f74bf96dceef8458cf03b9fdd07bc3 Mon Sep 17 00:00:00 2001 From: pomponchik Date: Wed, 29 Apr 2026 19:19:11 +0300 Subject: [PATCH 01/23] Bump the version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7feda48..2a94b05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "skelet" -version = "0.0.20" +version = "0.0.21" authors = [{ name = "Evgeniy Blinov", email = "zheni-b@yandex.ru" }] description = 'Collect all the settings in one place' readme = "README.md" From ed64d534b4c416f629f2cf15cd2a21f6450a5219 Mon Sep 17 00:00:00 2001 From: pomponchik Date: Thu, 30 Apr 2026 21:47:04 +0300 Subject: [PATCH 02/23] Support for shorthand fields --- skelet/fields/base.py | 2 +- skelet/storage.py | 110 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/skelet/fields/base.py b/skelet/fields/base.py index 0111119..2e20f34 100644 --- a/skelet/fields/base.py +++ b/skelet/fields/base.py @@ -205,7 +205,7 @@ def set_field_names(self, owner: Type[Storage], name: str) -> None: continue if parent is Storage: break - for field_name in cast(Storage, parent).__field_names__: + for field_name in getattr(parent, '__field_names__', ()): if field_name not in known_names: known_names.add(field_name) owner.__field_names__.append(field_name) diff --git a/skelet/storage.py b/skelet/storage.py index 42f93ce..0576b65 100644 --- a/skelet/storage.py +++ b/skelet/storage.py @@ -1,6 +1,16 @@ from collections import defaultdict from threading import Lock -from typing import Any, Dict, List, Optional, Sequence, Tuple, Union +from typing import ( + Any, + ClassVar, + Dict, + List, + Optional, + Sequence, + Tuple, + Union, + get_origin, +) from denial import InnerNoneType from locklib import ContextLockProtocol @@ -31,6 +41,102 @@ def _validate_instance_sources(raw: Optional[Sequence['InstanceSourceItem']]) -> raise TypeError(f'Each element of _sources must be a source or Ellipsis, got {type(item).__name__}.') return raw + @staticmethod + def _is_classvar_annotation(type_hint: Any) -> bool: + return type_hint is ClassVar or get_origin(type_hint) is ClassVar + + @staticmethod + def _can_be_shorthand_default(value: Any) -> bool: + if isinstance(value, (staticmethod, classmethod, property, type)): + return False + return not (hasattr(value, '__get__') or hasattr(value, '__set__') or hasattr(value, '__delete__')) + + @classmethod + def _parent_field_names(cls) -> List[str]: + result: List[str] = [] + known_names = set() + local_names = set(cls.__dict__) + + for parent in cls.__mro__: + if parent is cls: + continue + if parent is Storage: + break + for field_name in getattr(parent, '__field_names__', ()): + if field_name not in known_names and field_name not in local_names: + known_names.add(field_name) + result.append(field_name) + + return result + + @classmethod + def _prepare_shorthand_fields(cls) -> None: + from skelet.fields.base import Field, FieldDescriptor # noqa: PLC0415 + + annotations = cls.__dict__.get('__annotations__', {}) + classvar_names = {name for name, annotation in annotations.items() if cls._is_classvar_annotation(annotation)} + + for name in classvar_names: + if isinstance(cls.__dict__.get(name), FieldDescriptor): + raise TypeError(f'ClassVar field "{name}" cannot be defined as a skelet field.') + + for name in annotations: + if name in classvar_names: + continue + if name.startswith('_'): + raise ValueError(f'Field name "{name}" cannot start with an underscore.') + + for name in annotations: + if name in classvar_names: + continue + + if name not in cls.__dict__: + field = Field() + setattr(cls, name, field) + field.__set_name__(cls, name) + continue + + value = cls.__dict__[name] + if isinstance(value, FieldDescriptor) or not cls._can_be_shorthand_default(value): + continue + + field = Field(value) + setattr(cls, name, field) + field.__set_name__(cls, name) + + for name, value in tuple(cls.__dict__.items()): + if name.startswith('_') or name in annotations: + continue + if isinstance(value, FieldDescriptor) or not cls._can_be_shorthand_default(value): + continue + + field = Field(value) + setattr(cls, name, field) + field.__set_name__(cls, name) + + annotated_field_names = [] + data_field_names = [] + for name in annotations: + if name in classvar_names: + continue + if isinstance(cls.__dict__.get(name), FieldDescriptor): + annotated_field_names.append(name) + + for name, value in cls.__dict__.items(): + if name in annotations or name.startswith('_'): + continue + if isinstance(value, FieldDescriptor): + data_field_names.append(name) + + result = cls._parent_field_names() + known_names = set(result) + for name in [*annotated_field_names, *data_field_names]: + if name not in known_names: + known_names.add(name) + result.append(name) + + cls.__field_names__ = result if result else () + def __init__(self, *, _sources: Optional[Sequence['InstanceSourceItem']] = None, **kwargs: Any) -> None: self.__instance_sources__ = self._validate_instance_sources(_sources) @@ -93,6 +199,8 @@ def __init__(self, *, _sources: Optional[Sequence['InstanceSourceItem']] = None, def __init_subclass__(cls, reverse_conflicts: bool = True, sources: Optional[List[AbstractSource[ExpectedType]]] = None, **kwargs: Any): super().__init_subclass__(**kwargs) + cls._prepare_shorthand_fields() + for field_name in cls.__field_names__: field = getattr(cls, field_name) if field.exception is not None: From e75e101689846fb123e3c17a817eb9b8e1e04038 Mon Sep 17 00:00:00 2001 From: pomponchik Date: Thu, 30 Apr 2026 21:52:55 +0300 Subject: [PATCH 03/23] Tests for shorthand fields --- tests/units/test_storage.py | 449 +++++++++++++++++++++++++++++++++++- 1 file changed, 448 insertions(+), 1 deletion(-) diff --git a/tests/units/test_storage.py b/tests/units/test_storage.py index df468f5..7b89515 100644 --- a/tests/units/test_storage.py +++ b/tests/units/test_storage.py @@ -1,7 +1,7 @@ import sys from functools import partial from types import FunctionType -from typing import Any, List, Optional, Union +from typing import Any, ClassVar, List, Optional, Union import pytest from full_match import match @@ -19,6 +19,7 @@ Storage, TOMLSource, YAMLSource, + asdict, ) @@ -3979,3 +3980,449 @@ class SomeClass(Storage): assert instance.field == 42 assert calls == [] + + +def test_annotation_only_field_is_created_without_default(): + class SomeClass(Storage): + field: str + + assert SomeClass.__field_names__ == ['field'] + assert isinstance(SomeClass.field, FieldDescriptor) + + with pytest.raises(ValueError, match=match('The value for the "field" field is undefined. Set the default value, or specify the value when creating the instance.')): + SomeClass() + + assert SomeClass(field='abc').field == 'abc' + + +def test_annotation_only_field_is_in_repr_after_value_is_provided(): + class SomeClass(Storage): + field: str + + assert repr(SomeClass(field='abc')) == "SomeClass(field='abc')" + + +def test_annotated_default_is_field_default(): + class SomeClass(Storage): + field: str = 'abc' + + instance = SomeClass() + + assert isinstance(SomeClass.field, FieldDescriptor) + assert instance.field == 'abc' + assert repr(instance) == "SomeClass(field='abc')" + + +def test_untyped_default_is_field_without_runtime_type_check(): + class SomeClass(Storage): + field = 'abc' + + instance = SomeClass() + + assert isinstance(SomeClass.field, FieldDescriptor) + assert instance.field == 'abc' + + instance.field = 123 + + assert instance.field == 123 + + +def test_optional_none_default_is_not_required(): + class SomeClass(Storage): + field: Optional[str] = None + + assert SomeClass().field is None + assert SomeClass(field='abc').field == 'abc' + + with pytest.raises(TypeError, match=match('The value 123 (int) of the "field" field does not match the type Union.')): + SomeClass(field=123) + + +def test_non_optional_none_default_fails_on_class_creation(): + with pytest.raises(TypeError, match=match('The value None (NoneType) of the "field" field does not match the type str.')): + class SomeClass(Storage): + field: str = None + + +def test_untyped_none_default_is_allowed(): + class SomeClass(Storage): + field = None + + instance = SomeClass() + + assert instance.field is None + + instance.field = 123 + + assert instance.field == 123 + + +def test_any_annotation_disables_runtime_type_check(): + class SomeClass(Storage): + field: Any = 'abc' + + instance = SomeClass() + + instance.field = 123 + + assert instance.field == 123 + + +def test_annotated_default_wrong_type_fails_on_class_creation(): + with pytest.raises(TypeError, match=match('The value \'abc\' (str) of the "field" field does not match the type int.')): + class SomeClass(Storage): + field: int = 'abc' + + +def test_annotation_only_init_value_is_type_checked(): + class SomeClass(Storage): + field: int + + assert SomeClass(field=1).field == 1 + + with pytest.raises(TypeError, match=match('The value \'x\' (str) of the "field" field does not match the type int.')): + SomeClass(field='x') + + +def test_annotated_default_assignment_is_type_checked(): + class SomeClass(Storage): + field: int = 1 + + instance = SomeClass() + instance.field = 2 + + with pytest.raises(TypeError, match=match('The value \'x\' (str) of the "field" field does not match the type int.')): + instance.field = 'x' + + assert instance.field == 2 + + +def test_missing_required_shorthand_fields_report_first_missing(): + class SomeClass(Storage): + first: int + second: int + + with pytest.raises(ValueError, match=match('The value for the "first" field is undefined. Set the default value, or specify the value when creating the instance.')): + SomeClass() + + with pytest.raises(ValueError, match=match('The value for the "second" field is undefined. Set the default value, or specify the value when creating the instance.')): + SomeClass(first=1) + + instance = SomeClass(first=1, second=2) + + assert instance.first == 1 + assert instance.second == 2 + + +def test_positional_args_are_not_allowed_for_shorthand_fields(): + class SomeClass(Storage): + field: str + + with pytest.raises(TypeError): + SomeClass('abc') + + +def test_unknown_kwarg_is_rejected_for_shorthand_class(): + class SomeClass(Storage): + field: int = 1 + + with pytest.raises(KeyError, match=r'The "unknown" field is not defined.'): + SomeClass(unknown=1) + + +def test_delete_shorthand_field_is_forbidden(): + class SomeClass(Storage): + field: int = 1 + + with pytest.raises(AttributeError, match=match('You can\'t delete the "field" field value.')): + del SomeClass().field + + +def test_sources_fill_annotation_only_field(): + class SomeClass(Storage, sources=[MemorySource({'field': 5})]): + field: int + + assert SomeClass().field == 5 + + +def test_sources_override_shorthand_default(): + class SomeClass(Storage, sources=[MemorySource({'field': 5})]): + field: int = 1 + + assert SomeClass().field == 5 + + +def test_init_kwargs_override_sources_and_default(): + class SomeClass(Storage, sources=[MemorySource({'field': 5})]): + field: int = 1 + + assert SomeClass(field=10).field == 10 + + +def test_asdict_includes_all_shorthand_fields(): + class SomeClass(Storage): + required: int + defaulted: str = 'x' + untyped = True + + assert asdict(SomeClass(required=1)) == {'required': 1, 'defaulted': 'x', 'untyped': True} + + +def test_private_annotation_only_field_raises(): + with pytest.raises(ValueError, match=match('Field name "_field" cannot start with an underscore.')): + class SomeClass(Storage): + _field: int + + +def test_private_annotated_default_field_raises(): + with pytest.raises(ValueError, match=match('Field name "_field" cannot start with an underscore.')): + class SomeClass(Storage): + _field: int = 1 + + +def test_private_untyped_attribute_is_ignored(): + class SomeClass(Storage): + _field = 1 + + assert SomeClass.__field_names__ == () + assert SomeClass._field == 1 + assert repr(SomeClass()) == 'SomeClass()' + + +def test_public_classvar_is_ignored(): + class SomeClass(Storage): + field: ClassVar[str] = 'abc' + + assert SomeClass.__field_names__ == () + assert SomeClass.field == 'abc' + assert repr(SomeClass()) == 'SomeClass()' + + +def test_private_classvar_is_ignored_without_error(): + class SomeClass(Storage): + _field: ClassVar[str] = 'abc' + + assert SomeClass.__field_names__ == () + assert SomeClass._field == 'abc' + + +def test_classvar_with_explicit_field_raises(): + with pytest.raises(TypeError, match=match('ClassVar field "field" cannot be defined as a skelet field.')): + class SomeClass(Storage): + field: ClassVar[int] = Field(1) + + +def test_methods_and_descriptors_are_not_fields(): + class SomeClass(Storage): + def method(self): + return 'method' + + @property + def prop(self): + return 'prop' + + @staticmethod + def static(): + return 'static' + + @classmethod + def class_method(cls): + return cls.__name__ + + instance = SomeClass() + + assert SomeClass.__field_names__ == () + assert instance.method() == 'method' + assert instance.prop == 'prop' + assert SomeClass.static() == 'static' + assert instance.class_method() == 'SomeClass' + + +def test_annotated_descriptor_is_not_overwritten(): + class SomeDescriptor: + def __get__(self, instance, owner): + return 'descriptor' + + class SomeClass(Storage): + field: int = SomeDescriptor() + + assert SomeClass.__field_names__ == () + assert SomeClass().field == 'descriptor' + + +def test_nested_class_is_not_field(): + class SomeClass(Storage): + class Nested: + value = 1 + + assert SomeClass.__field_names__ == () + assert SomeClass.Nested.value == 1 + + +def test_explicit_field_still_works_unchanged(): + class SomeClass(Storage): + field: int = Field(1, validation=lambda value: value > 0) + + instance = SomeClass() + + assert instance.field == 1 + + with pytest.raises(ValueError, match=match('The value -1 (int) of the "field" field does not match the validation.')): + instance.field = -1 + + +def test_mixed_explicit_and_shorthand_fields_work_together(): + class SomeClass(Storage): + a: int + b: int = Field(2) + c: str = 'x' + d = 4 + + instance = SomeClass(a=1) + + assert SomeClass.__field_names__ == ['a', 'b', 'c', 'd'] + assert instance.a == 1 + assert instance.b == 2 + assert instance.c == 'x' + assert instance.d == 4 + + with pytest.raises(TypeError): + instance.a = 'bad' + with pytest.raises(TypeError): + instance.c = 5 + + instance.d = 'not checked' + + assert instance.d == 'not checked' + + +def test_stable_field_order_without_metaclass(): + class Parent(Storage): + parent_default: int = 1 + parent_untyped = 2 + + class Child(Parent): + child_required: int + child_default: int = 3 + child_explicit: int = Field(4) + child_untyped = 5 + + assert Child.__field_names__ == ['parent_default', 'parent_untyped', 'child_required', 'child_default', 'child_explicit', 'child_untyped'] + + +def test_child_overrides_parent_shorthand_with_shorthand(): + class Parent(Storage): + field: int = 1 + + class Child(Parent): + field: int = 2 + + assert Parent.__field_names__ == ['field'] + assert Child.__field_names__ == ['field'] + assert Parent().field == 1 + assert Child().field == 2 + + +def test_child_overrides_parent_explicit_with_shorthand(): + class Parent(Storage): + field: int = Field(1, validation=lambda value: value > 0) + + class Child(Parent): + field: int = -1 + + assert Parent().field == 1 + assert Child().field == -1 + + +def test_child_overrides_parent_shorthand_with_explicit(): + class Parent(Storage): + field: int = 1 + + class Child(Parent): + field: int = Field(-1, validation=lambda value: value < 0) + + assert Parent().field == 1 + assert Child().field == -1 + + with pytest.raises(ValueError, match=match('The value 1 (int) of the "field" field does not match the validation.')): + Child(field=1) + + +def test_multiple_inheritance_matches_existing_field_behavior(): + class ExplicitLeft(Storage): + left = Field(1) + + class ExplicitRight(Storage): + right = Field(2) + + class ExplicitChild(ExplicitLeft, ExplicitRight): + child = Field(3) + + class ShorthandLeft(Storage): + left = 1 + + class ShorthandRight(Storage): + right = 2 + + class ShorthandChild(ShorthandLeft, ShorthandRight): + child = 3 + + assert ShorthandChild.__field_names__ == ExplicitChild.__field_names__ + assert asdict(ShorthandChild()) == asdict(ExplicitChild()) + + +def test_non_storage_mixin_before_storage_is_ignored_for_explicit_fields(): + class Mixin: + mixin_value = 'mixin' + + class SomeClass(Mixin, Storage): + field = Field(1) + + assert SomeClass.__field_names__ == ['field'] + assert SomeClass.mixin_value == 'mixin' + assert SomeClass().field == 1 + + +def test_non_storage_mixin_before_storage_is_ignored_for_shorthand_fields(): + class Mixin: + mixin_value = 'mixin' + + class SomeClass(Mixin, Storage): + field = 1 + + assert SomeClass.__field_names__ == ['field'] + assert SomeClass.mixin_value == 'mixin' + assert SomeClass().field == 1 + + +def test_conflicts_can_reference_shorthand_field(): + class SomeClass(Storage): + a: int = Field(1, conflicts={'b': lambda old, new, other_old, other_new: new == other_old}) # noqa: ARG005 + b: int = 2 + + instance = SomeClass() + + with pytest.raises(ValueError, match=match('The new 2 (int) value of the "a" field conflicts with the 2 (int) value of the "b" field.')): + instance.a = 2 + + +def test_share_mutex_can_reference_shorthand_field(): + class SomeClass(Storage): + a: int = Field(1, share_mutex_with=['b']) + b: int = 2 + + instance = SomeClass() + + assert instance.__locks__['a'] is instance.__locks__['b'] + + +def test_shorthand_default_matches_field_default_for_mutables(): + class SomeClass(Storage): + items: list = [] # noqa: RUF012 + + first = SomeClass() + second = SomeClass() + + first.items.append(1) + + assert second.items == [1] From 5ce6150ea7fd7768e3274c4e11a0cd7bd936ee51 Mon Sep 17 00:00:00 2001 From: pomponchik Date: Thu, 30 Apr 2026 21:53:04 +0300 Subject: [PATCH 04/23] Typing tests --- tests/typing/test_negative_types.py | 98 +++++++++++++- tests/typing/test_storage_types.py | 201 +++++++++++++++++++++++++++- 2 files changed, 297 insertions(+), 2 deletions(-) diff --git a/tests/typing/test_negative_types.py b/tests/typing/test_negative_types.py index dec623a..d748410 100644 --- a/tests/typing/test_negative_types.py +++ b/tests/typing/test_negative_types.py @@ -1,4 +1,4 @@ -from typing import Any, List, Optional, Union +from typing import Any, ClassVar, List, Optional, Union import pytest @@ -241,3 +241,99 @@ class Config(Storage): def test_field_share_mutex_with_wrong_element_type() -> None: class Config(Storage): value: int = Field(1, share_mutex_with=[42]) # E: List item 0 has incompatible type "int"; expected "str" [list-item] + + +@pytest.mark.mypy_testing +def test_wrong_assignment_to_required_shorthand_field(): + class Config(Storage): + age: int + + config = Config(age=1) + config.age = 'x' # E: [assignment] + + +@pytest.mark.mypy_testing +def test_wrong_assignment_to_annotated_default_shorthand(): + class Config(Storage): + age: int = 1 + + config = Config() + config.age = 'x' # E: [assignment] + + +@pytest.mark.mypy_testing +def test_wrong_default_for_annotated_shorthand(): + class Config(Storage): + age: int = 'x' # E: [assignment] + + +@pytest.mark.mypy_testing +def test_wrong_assignment_to_optional_shorthand(): + class Config(Storage): + host: Optional[str] = None + + config = Config() + config.host = 1 # E: [assignment] + + +@pytest.mark.mypy_testing +def test_wrong_usage_of_optional_without_narrowing(): + def takes_str(value: str) -> None: + pass + + class Config(Storage): + host: Optional[str] = None + + config = Config() + takes_str(config.host) # E: [arg-type] + + +@pytest.mark.mypy_testing +def test_wrong_assignment_to_union_shorthand(): + class Config(Storage): + value: Union[int, str] = 1 + + config = Config() + config.value = None # E: [assignment] + + +@pytest.mark.mypy_testing +def test_wrong_assignment_to_container_shorthand(): + class Config(Storage): + items: List[int] = [] # noqa: RUF012 + + config = Config() + config.items = {'x': 1} # E: [assignment] + + +@pytest.mark.mypy_testing +def test_wrong_list_item_for_shorthand_container(): + class Config(Storage): + items: List[int] = ['x'] # E: [list-item] # noqa: RUF012 + + +@pytest.mark.mypy_testing +def test_wrong_append_to_shorthand_container(): + class Config(Storage): + items: List[int] = [] # noqa: RUF012 + + config = Config() + config.items.append('x') # E: [arg-type] + + +@pytest.mark.mypy_testing +def test_wrong_assignment_to_untyped_default_inferred_field(): + class Config(Storage): + name = 'Ann' + + config = Config() + config.name = 1 # E: [assignment] + + +@pytest.mark.mypy_testing +def test_wrong_classvar_instance_usage(): + class Config(Storage): + kind: ClassVar[str] = 'config' + + config = Config() + config.kind = 'other' # E: [misc] diff --git a/tests/typing/test_storage_types.py b/tests/typing/test_storage_types.py index c7401b6..a5d4c01 100644 --- a/tests/typing/test_storage_types.py +++ b/tests/typing/test_storage_types.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Any, ClassVar, List, Optional, Union import pytest from typing_extensions import assert_type @@ -57,3 +57,202 @@ class Config(Storage): config = Config() assert_type(config.count, int) + + +@pytest.mark.mypy_testing +def test_required_shorthand_field_usage(): + def takes_str(value: str) -> None: + pass + + class Config(Storage): + name: str + + config = Config(name='Ann') + takes_str(config.name) + config.name = 'Bob' + + +@pytest.mark.mypy_testing +def test_annotated_default_shorthand_usage(): + def takes_int(value: int) -> None: + pass + + class Config(Storage): + port: int = 8080 + + config = Config() + takes_int(config.port) + config.port = 8081 + + +@pytest.mark.mypy_testing +def test_untyped_default_shorthand_usage(): + def takes_str(value: str) -> None: + pass + + class Config(Storage): + name = 'Ann' + + config = Config() + takes_str(config.name) + config.name = 'Bob' + + +@pytest.mark.mypy_testing +def test_any_shorthand_usage(): + def takes_int(value: int) -> None: + pass + + def takes_str(value: str) -> None: + pass + + class Config(Storage): + payload: Any = 'x' + + config = Config() + config.payload = 1 + takes_int(config.payload) + config.payload = 'x' + takes_str(config.payload) + + +@pytest.mark.mypy_testing +def test_optional_none_shorthand_usage(): + def takes_str(value: str) -> None: + pass + + class Config(Storage): + host: Optional[str] = None + + config = Config() + config.host = 'localhost' + if config.host is not None: + takes_str(config.host) + config.host = None + + +@pytest.mark.mypy_testing +def test_union_shorthand_usage(): + def takes_int(value: int) -> None: + pass + + def takes_str(value: str) -> None: + pass + + class Config(Storage): + value: Union[int, str] = 1 + + config = Config() + if isinstance(config.value, int): + takes_int(config.value) + config.value = 'x' + if isinstance(config.value, str): + takes_str(config.value) + + +@pytest.mark.mypy_testing +def test_container_shorthand_usage(): + def takes_int(value: int) -> None: + pass + + def takes_int_list(value: List[int]) -> None: + pass + + class Config(Storage): + items: List[int] = [] # noqa: RUF012 + + config = Config() + config.items.append(1) + for item in config.items: + takes_int(item) + takes_int_list(config.items) + + +@pytest.mark.mypy_testing +def test_mixed_explicit_and_shorthand_usage(): + def takes_int(value: int) -> None: + pass + + def takes_str(value: str) -> None: + pass + + def takes_bool(value: bool) -> None: + pass + + class Config(Storage): + count: int = Field(1) + name: str + flag = True + + config = Config(name='Ann') + takes_int(config.count) + takes_str(config.name) + takes_bool(config.flag) + config.count = 2 + config.name = 'Bob' + config.flag = False + + +@pytest.mark.mypy_testing +def test_inherited_shorthand_usage(): + def takes_int(value: int) -> None: + pass + + def takes_str(value: str) -> None: + pass + + class BaseConfig(Storage): + count: int = 1 + + class Config(BaseConfig): + name: str = 'Ann' + + config = Config() + takes_int(config.count) + takes_str(config.name) + + +@pytest.mark.mypy_testing +def test_override_shorthand_with_explicit_usage(): + def takes_int(value: int) -> None: + pass + + class BaseConfig(Storage): + value: int = 1 + + class Config(BaseConfig): + value: int = Field(2) + + config = Config() + takes_int(config.value) + config.value = 3 + + +@pytest.mark.mypy_testing +def test_override_explicit_with_shorthand_usage(): + def takes_int(value: int) -> None: + pass + + class BaseConfig(Storage): + value: int = Field(1) + + class Config(BaseConfig): + value: int = 2 + + config = Config() + takes_int(config.value) + config.value = 3 + + +@pytest.mark.mypy_testing +def test_classvar_usage(): + def takes_str(value: str) -> None: + pass + + class Config(Storage): + kind: ClassVar[str] = 'config' + name: str = 'Ann' + + takes_str(Config.kind) + config = Config() + takes_str(config.name) From 1320eb973b1e3b1aca34e0ff2cbb32019066a080 Mon Sep 17 00:00:00 2001 From: pomponchik Date: Thu, 30 Apr 2026 21:58:58 +0300 Subject: [PATCH 05/23] readme fixes --- README.md | 58 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ee7e260..41d9b5e 100644 --- a/README.md +++ b/README.md @@ -53,13 +53,13 @@ pip install skelet You can also quickly try this package and others without installing them via [instld](https://github.com/pomponchik/instld). -Now let's create our first storage class. To do this, we need to inherit from the base class `Storage` and define fields using `Field`: +Now let's create our first storage class. To do this, we need to inherit from the base class `Storage` and define fields as class attributes. Use `Field` only when a field needs additional settings: ```python from skelet import Storage, Field, NonNegativeInt class ManDescription(Storage): - name: str = Field() + name: str age: NonNegativeInt = Field(validation={'You must be 18 or older to feel important': lambda x: x >= 18}) ``` @@ -91,7 +91,14 @@ That is already useful, but the rest of this guide covers more advanced features A default value is used when no other source provides one. It will be used until you override it. -You do not have to define a default value, but in this case you need to pass the value when creating the storage object. If you do set a default value, there are two ways to do this: +You do not have to define a default value, but in this case you need to pass the value when creating the storage object: + +```python +class UnremarkableSettingsStorage(Storage): + required_field: str +``` + +If you do set a default value, there are two ways to do this: - **Ordinary**. - **Lazy** (deferred). @@ -100,12 +107,19 @@ You can already see examples of ordinary default values above. Here's another on ```python class UnremarkableSettingsStorage(Storage): - ordinary_field: str = Field('I am the ordinary default value!') + ordinary_field: str = 'I am the ordinary default value!' print(UnremarkableSettingsStorage()) #> UnremarkableSettingsStorage(ordinary_field='I am the ordinary default value!') ``` +`None` is also an ordinary default value when you write it explicitly: + +```python +class UnremarkableSettingsStorage(Storage): + optional_field: str | None = None +``` + You can also pass a factory function via `default_factory` — it will be called each time a new object is created: ```python @@ -118,6 +132,22 @@ print(UnremarkableSettingsStorage()) Use this option when the default value is mutable, such as a `list` or `dict`. A new object will be created for this field every time a new storage object is created, so the same mutable object will not be shared between instances. +If you write a public class attribute without a type hint, it is still a field, but runtime type checking is disabled for it: + +```python +class UnremarkableSettingsStorage(Storage): + ordinary_field = 'I am a field without runtime type checking.' +``` + +Use `ClassVar` for public class-level constants that should not become fields: + +```python +from typing import ClassVar + +class UnremarkableSettingsStorage(Storage): + tool_name: ClassVar[str] = 'my-tool' +``` + ## Documenting fields @@ -125,7 +155,7 @@ You might be tempted to document a field with a comment: ```python class TheSecretFormula(Storage): - the_secret_ingredient: str = Field() # frogs' paws or something else nasty + the_secret_ingredient: str # frogs' paws or something else nasty ... ``` @@ -177,8 +207,8 @@ Type hints are optional. When specified, all values are checked against the hint ```python class HumanMeasurements(Storage): - number_of_legs: int = Field(2) - number_of_hands: int = Field(2) + number_of_legs: int = 2 + number_of_hands: int = 2 measurements = HumanMeasurements() @@ -195,6 +225,8 @@ The library supports only a runtime-checkable subset of typing constructs. Check The library deliberately does not attempt to implement full runtime type checking. If you need more powerful verification, it's better to rely on static tools like `mypy`. +Runtime type checking depends on type hints. For example, `field = 'abc'` may be treated as a `str` by static type checkers, but at runtime `skelet` will accept any value for this field because no type hint was provided. + The library also supports two additional types that allow you to narrow down the behavior of the basic int type: - `NaturalNumber` — as the name implies, only objects of type `int` greater than zero will be checked for this type. @@ -265,7 +297,7 @@ Sometimes, individual field values are [acceptable](#validation-of-values), but ```python class Dossier(Storage): - name: str = Field() + name: str is_jew: bool | None = Field(None, doc='Jews do not eat pork') eats_pork: bool | None = Field( None, @@ -416,7 +448,7 @@ Read more about the available types of sources below. from skelet import EnvSource class MyClass(Storage, sources=[EnvSource()]): - some_field = Field('some_value') + some_field: str = 'some_value' ``` By default, environment variables are searched for by key in the form of an attribute name, but the case is ignored. If you want to make the search case-sensitive, pass `True` as the `case_sensitive` parameter: @@ -521,9 +553,9 @@ class MyClass(Storage, sources=[ positional_arguments=['third_field'], ), ]): - first_field: str = Field('default') - second_field: str = Field('default') - third_field: str = Field('default') + first_field: str = 'default' + second_field: str = 'default' + third_field: str = 'default' ``` Now we can run our script, and the arguments will automatically populate the corresponding fields of our class: @@ -662,7 +694,7 @@ You can use `asdict()` to convert a storage object to a standard Python dictiona from skelet import asdict class FlyingConfig(Storage): - some_field: int = Field(42) + some_field: int = 42 data = asdict(FlyingConfig()) print(data) From 4af10591df38c1404a9c58c77150006735271636 Mon Sep 17 00:00:00 2001 From: pomponchik Date: Thu, 30 Apr 2026 22:05:39 +0300 Subject: [PATCH 06/23] Typing casts --- skelet/storage.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/skelet/storage.py b/skelet/storage.py index 0576b65..4d7529a 100644 --- a/skelet/storage.py +++ b/skelet/storage.py @@ -9,6 +9,7 @@ Sequence, Tuple, Union, + cast, get_origin, ) @@ -91,7 +92,7 @@ def _prepare_shorthand_fields(cls) -> None: continue if name not in cls.__dict__: - field = Field() + field = cast(FieldDescriptor[Any, Any], Field()) setattr(cls, name, field) field.__set_name__(cls, name) continue @@ -100,7 +101,7 @@ def _prepare_shorthand_fields(cls) -> None: if isinstance(value, FieldDescriptor) or not cls._can_be_shorthand_default(value): continue - field = Field(value) + field = cast(FieldDescriptor[Any, Any], Field(value)) setattr(cls, name, field) field.__set_name__(cls, name) @@ -110,7 +111,7 @@ def _prepare_shorthand_fields(cls) -> None: if isinstance(value, FieldDescriptor) or not cls._can_be_shorthand_default(value): continue - field = Field(value) + field = cast(FieldDescriptor[Any, Any], Field(value)) setattr(cls, name, field) field.__set_name__(cls, name) From 92ba252cf9bad42a13928475daecce5523aa55fe Mon Sep 17 00:00:00 2001 From: pomponchik Date: Fri, 1 May 2026 07:37:26 +0300 Subject: [PATCH 07/23] A bit of refactoring --- skelet/storage.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/skelet/storage.py b/skelet/storage.py index 4d7529a..c11ca25 100644 --- a/skelet/storage.py +++ b/skelet/storage.py @@ -130,11 +130,7 @@ def _prepare_shorthand_fields(cls) -> None: data_field_names.append(name) result = cls._parent_field_names() - known_names = set(result) - for name in [*annotated_field_names, *data_field_names]: - if name not in known_names: - known_names.add(name) - result.append(name) + result.extend([*annotated_field_names, *data_field_names]) cls.__field_names__ = result if result else () From ae94afa30bd2d930f97e427cdf09ee6bdfe8d458 Mon Sep 17 00:00:00 2001 From: pomponchik Date: Fri, 1 May 2026 07:37:34 +0300 Subject: [PATCH 08/23] +1 test --- tests/units/test_storage.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/units/test_storage.py b/tests/units/test_storage.py index 7b89515..8ea8d95 100644 --- a/tests/units/test_storage.py +++ b/tests/units/test_storage.py @@ -4395,6 +4395,10 @@ class SomeClass(Mixin, Storage): assert SomeClass().field == 1 +def test_storage_parent_field_names_are_empty(): + assert Storage._parent_field_names() == [] + + def test_conflicts_can_reference_shorthand_field(): class SomeClass(Storage): a: int = Field(1, conflicts={'b': lambda old, new, other_old, other_new: new == other_old}) # noqa: ARG005 From 3daf9205fd9b6608c8f9c6611e789f8a725cadc7 Mon Sep 17 00:00:00 2001 From: pomponchik Date: Fri, 1 May 2026 23:02:21 +0300 Subject: [PATCH 09/23] New annotation import --- skelet/storage.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/skelet/storage.py b/skelet/storage.py index c11ca25..633094b 100644 --- a/skelet/storage.py +++ b/skelet/storage.py @@ -21,6 +21,14 @@ from skelet.sources.collection import SourcesCollection from skelet.types import InstanceSourceItem +try: # pragma: no cover + from annotationlib import get_annotations # type: ignore[import-not-found] +except ImportError: # pragma: no cover + try: + from inspect import get_annotations + except ImportError: + get_annotations = lambda cls: cls.__dict__.get('__annotations__', {}) # noqa: E731 + sentinel = InnerNoneType() class Storage: @@ -74,7 +82,7 @@ def _parent_field_names(cls) -> List[str]: def _prepare_shorthand_fields(cls) -> None: from skelet.fields.base import Field, FieldDescriptor # noqa: PLC0415 - annotations = cls.__dict__.get('__annotations__', {}) + annotations = dict(get_annotations(cls)) classvar_names = {name for name, annotation in annotations.items() if cls._is_classvar_annotation(annotation)} for name in classvar_names: From 5066ad3ba34c64f9d03e0d8a60eced3220d0b7ef Mon Sep 17 00:00:00 2001 From: pomponchik Date: Sat, 2 May 2026 21:26:39 +0300 Subject: [PATCH 10/23] mypy fix --- skelet/storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skelet/storage.py b/skelet/storage.py index 633094b..80f4d9e 100644 --- a/skelet/storage.py +++ b/skelet/storage.py @@ -25,7 +25,7 @@ from annotationlib import get_annotations # type: ignore[import-not-found] except ImportError: # pragma: no cover try: - from inspect import get_annotations + from inspect import get_annotations # type: ignore[attr-defined, unused-ignore] except ImportError: get_annotations = lambda cls: cls.__dict__.get('__annotations__', {}) # noqa: E731 From 1e1849bb8bf0d3431551cb8646aae7e23593ee86 Mon Sep 17 00:00:00 2001 From: pomponchik Date: Sun, 3 May 2026 13:32:39 +0300 Subject: [PATCH 11/23] Add coverage xml report to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index bbe8147..8233813 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ mutants CLAUDE.md AGENTS.md .qwen +coverage.xml From 32db6c78510e70466404420bc02b35806fddfffe Mon Sep 17 00:00:00 2001 From: pomponchik Date: Tue, 5 May 2026 14:06:20 +0300 Subject: [PATCH 12/23] New annotation mechanism --- skelet/storage.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/skelet/storage.py b/skelet/storage.py index 80f4d9e..8b1d939 100644 --- a/skelet/storage.py +++ b/skelet/storage.py @@ -1,7 +1,9 @@ +import inspect from collections import defaultdict from threading import Lock from typing import ( Any, + Callable, ClassVar, Dict, List, @@ -22,12 +24,18 @@ from skelet.types import InstanceSourceItem try: # pragma: no cover - from annotationlib import get_annotations # type: ignore[import-not-found] + import annotationlib # type: ignore[import-not-found, unused-ignore] except ImportError: # pragma: no cover - try: - from inspect import get_annotations # type: ignore[attr-defined, unused-ignore] - except ImportError: - get_annotations = lambda cls: cls.__dict__.get('__annotations__', {}) # noqa: E731 + annotationlib = None + +_GetAnnotations = Callable[..., Dict[str, Any]] +_get_annotations = cast(Optional[_GetAnnotations], getattr(annotationlib, 'get_annotations', None) or getattr(inspect, 'get_annotations', None)) + + +def get_annotations(obj: Any, *, globals: Any = None, locals: Any = None, eval_str: bool = False) -> Dict[str, Any]: # noqa: A002 # pragma: no cover + if _get_annotations is not None: + return dict(_get_annotations(obj, globals=globals, locals=locals, eval_str=eval_str)) + return dict(getattr(obj, '__dict__', {}).get('__annotations__', {})) sentinel = InnerNoneType() From 46c4125a16f073216d3dd89b47856fdc2523fa35 Mon Sep 17 00:00:00 2001 From: pomponchik Date: Tue, 5 May 2026 22:06:14 +0300 Subject: [PATCH 13/23] Imports fix --- skelet/storage.py | 10 ++++++---- tests/units/test_storage.py | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/skelet/storage.py b/skelet/storage.py index 8b1d939..f16f688 100644 --- a/skelet/storage.py +++ b/skelet/storage.py @@ -23,13 +23,15 @@ from skelet.sources.collection import SourcesCollection from skelet.types import InstanceSourceItem +_GetAnnotations = Callable[..., Dict[str, Any]] try: # pragma: no cover - import annotationlib # type: ignore[import-not-found, unused-ignore] + from annotationlib import ( # type: ignore[import-not-found, unused-ignore] + get_annotations as _get_annotations_raw, + ) except ImportError: # pragma: no cover - annotationlib = None + _get_annotations_raw = getattr(inspect, 'get_annotations', None) -_GetAnnotations = Callable[..., Dict[str, Any]] -_get_annotations = cast(Optional[_GetAnnotations], getattr(annotationlib, 'get_annotations', None) or getattr(inspect, 'get_annotations', None)) +_get_annotations = cast(Optional[_GetAnnotations], _get_annotations_raw) def get_annotations(obj: Any, *, globals: Any = None, locals: Any = None, eval_str: bool = False) -> Dict[str, Any]: # noqa: A002 # pragma: no cover diff --git a/tests/units/test_storage.py b/tests/units/test_storage.py index 8ea8d95..4113004 100644 --- a/tests/units/test_storage.py +++ b/tests/units/test_storage.py @@ -4034,7 +4034,7 @@ class SomeClass(Storage): assert SomeClass().field is None assert SomeClass(field='abc').field == 'abc' - with pytest.raises(TypeError, match=match('The value 123 (int) of the "field" field does not match the type Union.')): + with pytest.raises(TypeError, match=r'^The value 123 \(int\) of the "field" field does not match the type (typing\.)?Union\.$'): SomeClass(field=123) From 4d6c043a3beef2b5f4b7fc8aad91baf0a1c0dabd Mon Sep 17 00:00:00 2001 From: pomponchik Date: Tue, 5 May 2026 23:26:54 +0300 Subject: [PATCH 14/23] Fix variable names --- skelet/storage.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/skelet/storage.py b/skelet/storage.py index f16f688..9b96ff2 100644 --- a/skelet/storage.py +++ b/skelet/storage.py @@ -24,14 +24,15 @@ from skelet.types import InstanceSourceItem _GetAnnotations = Callable[..., Dict[str, Any]] +_get_annotations: Optional[_GetAnnotations] try: # pragma: no cover from annotationlib import ( # type: ignore[import-not-found, unused-ignore] - get_annotations as _get_annotations_raw, + get_annotations as _annotationlib_get_annotations, ) except ImportError: # pragma: no cover - _get_annotations_raw = getattr(inspect, 'get_annotations', None) - -_get_annotations = cast(Optional[_GetAnnotations], _get_annotations_raw) + _get_annotations = cast(Optional[_GetAnnotations], getattr(inspect, 'get_annotations', None)) +else: # pragma: no cover + _get_annotations = cast(_GetAnnotations, _annotationlib_get_annotations) def get_annotations(obj: Any, *, globals: Any = None, locals: Any = None, eval_str: bool = False) -> Dict[str, Any]: # noqa: A002 # pragma: no cover From 386e5305555e6dc2a10ca0079c5392196c504eff Mon Sep 17 00:00:00 2001 From: pomponchik Date: Wed, 6 May 2026 21:58:57 +0300 Subject: [PATCH 15/23] F instead of Field --- skelet/__init__.py | 2 ++ tests/typing/test_field_types.py | 11 ++++++++++- tests/units/test_storage.py | 11 +++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/skelet/__init__.py b/skelet/__init__.py index c943cab..0457078 100644 --- a/skelet/__init__.py +++ b/skelet/__init__.py @@ -12,3 +12,5 @@ from skelet.sources.yaml import YAMLSource as YAMLSource from skelet.storage import Storage as Storage from skelet.sources.getter_for_libraries import for_tool as for_tool + +F = Field diff --git a/tests/typing/test_field_types.py b/tests/typing/test_field_types.py index 27eaa10..61cd699 100644 --- a/tests/typing/test_field_types.py +++ b/tests/typing/test_field_types.py @@ -3,7 +3,7 @@ import pytest from typing_extensions import assert_type -from skelet import Field, Storage +from skelet import F, Field, Storage @pytest.mark.mypy_testing @@ -30,6 +30,15 @@ class Config(Storage): assert_type(config.name, str) +@pytest.mark.mypy_testing +def test_field_short_alias() -> None: + class Config(Storage): + name: str = F() + + config = Config(name='hello') + assert_type(config.name, str) + + @pytest.mark.mypy_testing def test_field_optional_type() -> None: class Config(Storage): diff --git a/tests/units/test_storage.py b/tests/units/test_storage.py index 4113004..b1a0caa 100644 --- a/tests/units/test_storage.py +++ b/tests/units/test_storage.py @@ -10,6 +10,7 @@ from skelet import ( EnvSource, + F, Field, FieldDescriptor, JSONSource, @@ -30,6 +31,16 @@ class SomeClass(Storage): assert isinstance(SomeClass.field, FieldDescriptor) +def test_field_short_alias(): + class SomeClass(Storage): + field: str = F() + + some_object = SomeClass(field='value') + + assert some_object.field == 'value' + assert F is Field + + def test_try_to_use_field_outside_storage(): if sys.version_info < (3, 12): with pytest.raises(RuntimeError): From 411f1c87ca198907b90597f77144719cc677b0a8 Mon Sep 17 00:00:00 2001 From: pomponchik Date: Mon, 11 May 2026 18:16:16 +0300 Subject: [PATCH 16/23] Change secret to hide --- README.md | 8 +- skelet/fields/base.py | 14 +- skelet/storage.py | 8 +- tests/typing/test_field_types.py | 4 +- tests/units/test_storage.py | 218 +++++++++++++++---------------- 5 files changed, 126 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index 41d9b5e..48b4969 100644 --- a/README.md +++ b/README.md @@ -182,14 +182,14 @@ Some field values should not appear in logs or string representations. Secret fi ```python class TopStateSecrets(Storage): - who_killed_kennedy: str = Field('aliens', validation=lambda x: x != 'russians', secret=True) - red_buttons_password: str = Field('1234', secret=True) + who_killed_kennedy: str = Field('aliens', validation=lambda x: x != 'russians', hide=True) + red_buttons_password: str = Field('1234', hide=True) print(TopStateSecrets()) #> TopStateSecrets(who_killed_kennedy=***, red_buttons_password=***) ``` -If you mark a field with the `secret` flag, as in this example, its contents will be hidden in string representations and exception messages: +If you mark a field with the `hide` flag, as in this example, its contents will be hidden in string representations and exception messages: ```python secrets = TopStateSecrets() @@ -198,7 +198,7 @@ secrets.who_killed_kennedy = 'russians' #> ValueError: The value *** (str) of the "who_killed_kennedy" field does not match the validation. ``` -In all other respects, "secret" fields behave the same as regular ones, you can read values and write new ones. +In all other respects, hidden fields behave the same as regular ones, you can read values and write new ones. ## Type checking diff --git a/skelet/fields/base.py b/skelet/fields/base.py index 2e20f34..f67e11b 100644 --- a/skelet/fields/base.py +++ b/skelet/fields/base.py @@ -47,7 +47,7 @@ def __init__( # noqa: PLR0913 read_only: bool = False, validation: Optional[Union[Dict[str, Callable[[ValueType], bool]], Callable[[ValueType], bool]]] = None, validate_default: bool = True, - secret: bool = False, + hide: bool = False, action: Optional[ChangeAction[ValueType, StorageType]] = None, read_lock: bool = False, conflicts: Optional[Dict[str, Callable[[ValueType, ValueType, Any, Any], bool]]] = None, @@ -95,7 +95,7 @@ def __init__( # noqa: PLR0913 self.sources = sources self.validation = validation self.validate_default = validate_default - self.secret = secret + self.hide = hide self.change_action: Optional[ChangeAction[ValueType, StorageType]] = action self.conflicts = conflicts self.reverse_conflicts_on = reverse_conflicts @@ -273,7 +273,7 @@ def get_field_lock(self, instance: Storage) -> ContextLockProtocol: return instance.__locks__[cast(str, self.name)] def get_value_representation(self, value: ValueType) -> str: - base = '***' if self.secret else f'{value!r}' + base = '***' if self.hide else f'{value!r}' return f'{base} ({type(value).__name__})' def raise_exception_in_storage(self, exception: BaseException, raising_on: bool) -> None: @@ -338,7 +338,7 @@ def Field( read_only: bool = False, validation: Optional[Union[Dict[str, Callable[[ValueType], bool]], Callable[[ValueType], bool]]] = None, validate_default: bool = True, - secret: bool = False, + hide: bool = False, action: Optional[ChangeAction[ValueType, StorageType]] = None, read_lock: bool = False, conflicts: Optional[Dict[str, Callable[[ValueType, ValueType, Any, Any], bool]]] = None, @@ -360,7 +360,7 @@ def Field( read_only: bool = False, validation: Optional[Union[Dict[str, Callable[[ValueType], bool]], Callable[[ValueType], bool]]] = None, validate_default: bool = True, - secret: bool = False, + hide: bool = False, action: Optional[ChangeAction[ValueType, StorageType]] = None, read_lock: bool = False, conflicts: Optional[Dict[str, Callable[[ValueType, ValueType, Any, Any], bool]]] = None, @@ -382,7 +382,7 @@ def Field( # noqa: PLR0913, N802 read_only: bool = False, validation: Optional[Union[Dict[str, Callable[[Any], bool]], Callable[[Any], bool]]] = None, validate_default: bool = True, - secret: bool = False, + hide: bool = False, action: Optional[ChangeAction[Any, StorageType]] = None, read_lock: bool = False, conflicts: Optional[Dict[str, Callable[[Any, Any, Any, Any], bool]]] = None, @@ -399,7 +399,7 @@ def Field( # noqa: PLR0913, N802 read_only=read_only, validation=validation, validate_default=validate_default, - secret=secret, + hide=hide, action=action, read_lock=read_lock, conflicts=conflicts, diff --git a/skelet/storage.py b/skelet/storage.py index 9b96ff2..86db84b 100644 --- a/skelet/storage.py +++ b/skelet/storage.py @@ -253,11 +253,11 @@ def __init_subclass__(cls, reverse_conflicts: bool = True, sources: Optional[Lis def __repr__(self) -> str: fields_content = {} - secrets = {} + hidden_placeholders = {} for field_name in self.__field_names__: fields_content[field_name] = getattr(self, field_name) - if getattr(type(self), field_name).secret: - secrets[field_name] = '***' + if getattr(type(self), field_name).hide: + hidden_placeholders[field_name] = '***' - return describe_call(type(self).__name__, (), fields_content, placeholders=secrets) # type: ignore[arg-type] + return describe_call(type(self).__name__, (), fields_content, placeholders=hidden_placeholders) # type: ignore[arg-type] diff --git a/tests/typing/test_field_types.py b/tests/typing/test_field_types.py index 61cd699..5242dc3 100644 --- a/tests/typing/test_field_types.py +++ b/tests/typing/test_field_types.py @@ -188,9 +188,9 @@ class Config(Storage): @pytest.mark.mypy_testing -def test_field_secret() -> None: +def test_field_hide() -> None: class Config(Storage): - password: str = Field('secret', secret=True) + password: str = Field('secret', hide=True) config = Config() assert_type(config.password, str) diff --git a/tests/units/test_storage.py b/tests/units/test_storage.py index b1a0caa..e716ecc 100644 --- a/tests/units/test_storage.py +++ b/tests/units/test_storage.py @@ -424,15 +424,15 @@ class SomeClass(Storage): @pytest.mark.parametrize( - ('int_value', 'float_value', 'secret'), + ('int_value', 'float_value', 'hide'), [ ('***', '***', True), ("'15'", '15.0', False), ], ) -def test_simple_type_check_failed_when_set(int_value, float_value, secret): +def test_simple_type_check_failed_when_set(int_value, float_value, hide): class SomeClass(Storage): - field: int = Field(15, secret=secret) + field: int = Field(15, hide=hide) instance = SomeClass() @@ -447,15 +447,15 @@ class SomeClass(Storage): @pytest.mark.parametrize( - ('int_value', 'float_value', 'secret'), + ('int_value', 'float_value', 'hide'), [ ('***', '***', True), ("'15'", "15.0", False), ], ) -def test_simple_type_check_failed_when_set_with_doc(int_value, float_value, secret): +def test_simple_type_check_failed_when_set_with_doc(int_value, float_value, hide): class SomeClass(Storage): - field: int = Field(15, doc='some doc', secret=secret) + field: int = Field(15, doc='some doc', hide=hide) instance = SomeClass() @@ -482,29 +482,29 @@ class SomeClass(Storage): @pytest.mark.parametrize( - ('wrong_value', 'secret'), + ('wrong_value', 'hide'), [ ('***', True), ("'15'", False), ], ) -def test_type_check_when_define_default_failed(wrong_value, secret): +def test_type_check_when_define_default_failed(wrong_value, hide): with pytest.raises(TypeError, match=match(f'The value {wrong_value} (str) of the "field" field does not match the type int.')): class SomeClass(Storage): - field: int = Field('15', secret=secret) + field: int = Field('15', hide=hide) @pytest.mark.parametrize( - ('wrong_value', 'secret'), + ('wrong_value', 'hide'), [ ('***', True), ("'15'", False), ], ) -def test_type_check_when_define_default_failed_with_doc(wrong_value, secret): +def test_type_check_when_define_default_failed_with_doc(wrong_value, hide): with pytest.raises(TypeError, match=match(f'The value {wrong_value} (str) of the "field" field (some doc) does not match the type int.')): class SomeClass(Storage): - field: int = Field('15', doc='some doc', secret=secret) + field: int = Field('15', doc='some doc', hide=hide) def test_type_check_when_define_default_not_failed(): @@ -516,30 +516,30 @@ class SomeClass(Storage): @pytest.mark.parametrize( - ('wrong_value', 'secret'), + ('wrong_value', 'hide'), [ ('***', True), ("'kek'", False), ], ) -def test_type_check_when_redefine_defaults_initing_new_object_failed(wrong_value, secret): +def test_type_check_when_redefine_defaults_initing_new_object_failed(wrong_value, hide): class SomeClass(Storage): - field: int = Field(15, secret=secret) + field: int = Field(15, hide=hide) with pytest.raises(TypeError, match=match(f'The value {wrong_value} (str) of the "field" field does not match the type int.')): SomeClass(field='kek') @pytest.mark.parametrize( - ('wrong_value', 'secret'), + ('wrong_value', 'hide'), [ ('***', True), ("'kek'", False), ], ) -def test_type_check_when_redefine_defaults_initing_new_object_failed_with_doc(wrong_value, secret): +def test_type_check_when_redefine_defaults_initing_new_object_failed_with_doc(wrong_value, hide): class SomeClass(Storage): - field: int = Field(15, doc='some doc', secret=secret) + field: int = Field(15, doc='some doc', hide=hide) with pytest.raises(TypeError, match=match(f'The value {wrong_value} (str) of the "field" field (some doc) does not match the type int.')): SomeClass(field='kek') @@ -561,15 +561,15 @@ class SomeClass(Storage): @pytest.mark.parametrize( - ('wrong_value', 'secret'), + ('wrong_value', 'hide'), [ ('***', True), ("'kek'", False), ], ) -def test_more_examples_of_type_check_when_redefine_defaults_initing_new_object_failed(wrong_value, secret): +def test_more_examples_of_type_check_when_redefine_defaults_initing_new_object_failed(wrong_value, hide): class SomeClass(Storage): - field: Optional[int] = Field(15, secret=secret) + field: Optional[int] = Field(15, hide=hide) if sys.version_info < (3, 10): type_representation = 'typing.Union' @@ -604,15 +604,15 @@ class SecondClass(Storage): @pytest.mark.parametrize( - ('wrong_value', 'secret'), + ('wrong_value', 'hide'), [ ('***', True), ("'kek'", False), ], ) -def test_more_examples_of_type_check_when_redefine_defaults_initing_new_object_failed_with_doc(wrong_value, secret): +def test_more_examples_of_type_check_when_redefine_defaults_initing_new_object_failed_with_doc(wrong_value, hide): class SomeClass(Storage): - field: Optional[int] = Field(15, doc='some doc', secret=secret) + field: Optional[int] = Field(15, doc='some doc', hide=hide) if sys.version_info < (3, 10): type_representation = 'typing.Union' @@ -636,15 +636,15 @@ class SomeClass(Storage): @pytest.mark.parametrize( - ('wrong_value', 'secret'), + ('wrong_value', 'hide'), [ ('***', True), ('-1', False), ], ) -def test_validation_function_failed_when_set(wrong_value, secret): +def test_validation_function_failed_when_set(wrong_value, hide): class SomeClass(Storage): - field: int = Field(15, validation=lambda value: value > 0, secret=secret) + field: int = Field(15, validation=lambda value: value > 0, hide=hide) instance = SomeClass() @@ -653,15 +653,15 @@ class SomeClass(Storage): @pytest.mark.parametrize( - ('wrong_value', 'secret'), + ('wrong_value', 'hide'), [ ('***', True), ('-1', False), ], ) -def test_validation_function_failed_when_set_with_doc(wrong_value, secret): +def test_validation_function_failed_when_set_with_doc(wrong_value, hide): class SomeClass(Storage): - field: int = Field(15, validation=lambda value: value > 0, doc='some doc', secret=secret) + field: int = Field(15, validation=lambda value: value > 0, doc='some doc', hide=hide) instance = SomeClass() @@ -709,30 +709,30 @@ class SomeClass(Storage): @pytest.mark.parametrize( - ('wrong_value', 'secret'), + ('wrong_value', 'hide'), [ ('***', True), ('-1', False), ], ) -def test_validation_function_failed_when_init(wrong_value, secret): +def test_validation_function_failed_when_init(wrong_value, hide): class SomeClass(Storage): - field: int = Field(15, validation=lambda value: value > 0, secret=secret) + field: int = Field(15, validation=lambda value: value > 0, hide=hide) with pytest.raises(ValueError, match=match(f'The value {wrong_value} (int) of the "field" field does not match the validation.')): SomeClass(field=-1) @pytest.mark.parametrize( - ('wrong_value', 'secret'), + ('wrong_value', 'hide'), [ ('***', True), ('-1', False), ], ) -def test_validation_function_failed_when_init_with_doc(wrong_value, secret): +def test_validation_function_failed_when_init_with_doc(wrong_value, hide): class SomeClass(Storage): - field: int = Field(15, validation=lambda value: value > 0, doc='some doc', secret=secret) + field: int = Field(15, validation=lambda value: value > 0, doc='some doc', hide=hide) with pytest.raises(ValueError, match=match(f'The value {wrong_value} (int) of the "field" field (some doc) does not match the validation.')): SomeClass(field=-1) @@ -790,29 +790,29 @@ class SomeClass(Storage): @pytest.mark.parametrize( - ('wrong_value', 'secret'), + ('wrong_value', 'hide'), [ ('***', True), ('-15', False), ], ) -def test_validation_function_failed_when_default(wrong_value, secret): +def test_validation_function_failed_when_default(wrong_value, hide): with pytest.raises(ValueError, match=match(f'The value {wrong_value} (int) of the "field" field does not match the validation.')): class SomeClass(Storage): - field: int = Field(-15, validation=lambda value: value > 0, secret=secret) + field: int = Field(-15, validation=lambda value: value > 0, hide=hide) @pytest.mark.parametrize( - ('wrong_value', 'secret'), + ('wrong_value', 'hide'), [ ('***', True), ('-15', False), ], ) -def test_validation_function_failed_when_default_with_doc(wrong_value, secret): +def test_validation_function_failed_when_default_with_doc(wrong_value, hide): with pytest.raises(ValueError, match=match(f'The value {wrong_value} (int) of the "field" field (some doc) does not match the validation.')): class SomeClass(Storage): - field: int = Field(-15, validation=lambda value: value > 0, doc='some doc', secret=secret) + field: int = Field(-15, validation=lambda value: value > 0, doc='some doc', hide=hide) def test_validation_functions_dict_failed_when_default(): @@ -894,9 +894,9 @@ class SomeClass(Storage): SomeClass.field.check_type_hints = old_check_type_hints -def test_repr_for_secret_fields(): +def test_repr_for_hidden_fields(): class SomeClass(Storage): - field: int = Field(10, secret=True) + field: int = Field(10, hide=True) second_field: int = Field(100) instance = SomeClass() @@ -909,9 +909,9 @@ class SomeClass(Storage): assert repr(instance) == 'SomeClass(field=***, second_field=200)' -def test_change_value_of_secret_field(): +def test_change_value_of_hidden_field(): class SomeClass(Storage): - field: int = Field(10, secret=True) + field: int = Field(10, hide=True) instance = SomeClass() @@ -922,9 +922,9 @@ class SomeClass(Storage): assert instance.field == 20 -def test_change_value_of_secret_field_in_init(): +def test_change_value_of_hidden_field_in_init(): class SomeClass(Storage): - field: int = Field(10, secret=True) + field: int = Field(10, hide=True) instance = SomeClass(field=20) @@ -935,7 +935,7 @@ def test_set_action_for_set(): flags = [] class SomeClass(Storage): - field: int = Field(10, secret=True, action=lambda old, new, storage: flags.append(True)) # noqa: ARG005 + field: int = Field(10, hide=True, action=lambda old, new, storage: flags.append(True)) # noqa: ARG005 instance = SomeClass() @@ -954,7 +954,7 @@ def test_action_doesnt_work_when_new_value_is_same(): flags = [] class SomeClass(Storage): - field: int = Field(10, secret=True, action=lambda old, new, storage: flags.append(True)) # noqa: ARG005 + field: int = Field(10, hide=True, action=lambda old, new, storage: flags.append(True)) # noqa: ARG005 instance = SomeClass() @@ -974,7 +974,7 @@ class SomeClass(Storage): ) def test_read_lock_on(addictional_arguments): class SomeClass(Storage): - field: int = Field(10, secret=True, **addictional_arguments) + field: int = Field(10, hide=True, **addictional_arguments) instance = SomeClass() @@ -1005,7 +1005,7 @@ def get(self, key): # noqa: ARG002 def test_read_lock_off(): class SomeClass(Storage): - field: int = Field(10, secret=True, read_lock=False) + field: int = Field(10, hide=True, read_lock=False) instance = SomeClass() @@ -1146,7 +1146,7 @@ class SomeClass(Storage): # Check: exceptions messages for both types of fields on the both sides, direct and reverse @pytest.mark.parametrize( - 'main_field_is_secret', + 'main_field_is_hidden', [ True, False, @@ -1159,11 +1159,11 @@ class SomeClass(Storage): {'doc': 'some doc'}, ], ) -def test_basic_conflicting_fields(addictional_arguments, main_field_is_secret): +def test_basic_conflicting_fields(addictional_arguments, main_field_is_hidden): class SomeClass(Storage): - field: int = Field(10, conflicts={'other_field': lambda old, new, other_old, other_new: new > other_old, 'secret_other_field': lambda old, new, other_old, other_new: new < 0}, doc=addictional_arguments.get('doc'), secret=main_field_is_secret) # noqa: ARG005 + field: int = Field(10, conflicts={'other_field': lambda old, new, other_old, other_new: new > other_old, 'secret_other_field': lambda old, new, other_old, other_new: new < 0}, doc=addictional_arguments.get('doc'), hide=main_field_is_hidden) # noqa: ARG005 other_field: int = Field(20, doc=addictional_arguments.get('doc')) - secret_other_field: int = Field(20, secret=True, doc=addictional_arguments.get('doc')) + secret_other_field: int = Field(20, hide=True, doc=addictional_arguments.get('doc')) instance = SomeClass() @@ -1174,11 +1174,11 @@ class SomeClass(Storage): assert instance.field == 15 if 'doc' in addictional_arguments: - if main_field_is_secret: + if main_field_is_hidden: exception_message = 'The new *** (int) value of the "field" field (some doc) conflicts with the 20 (int) value of the "other_field" field (some doc).' else: exception_message = 'The new 21 (int) value of the "field" field (some doc) conflicts with the 20 (int) value of the "other_field" field (some doc).' - elif main_field_is_secret: + elif main_field_is_hidden: exception_message = 'The new *** (int) value of the "field" field conflicts with the 20 (int) value of the "other_field" field.' else: exception_message = 'The new 21 (int) value of the "field" field conflicts with the 20 (int) value of the "other_field" field.' @@ -1189,11 +1189,11 @@ class SomeClass(Storage): assert instance.field == 15 if 'doc' in addictional_arguments: - if main_field_is_secret: + if main_field_is_hidden: exception_message = 'The new *** (int) value of the "field" field (some doc) conflicts with the *** (int) value of the "secret_other_field" field (some doc).' else: exception_message = 'The new -1 (int) value of the "field" field (some doc) conflicts with the *** (int) value of the "secret_other_field" field (some doc).' - elif main_field_is_secret: + elif main_field_is_hidden: exception_message = 'The new *** (int) value of the "field" field conflicts with the *** (int) value of the "secret_other_field" field.' else: exception_message = 'The new -1 (int) value of the "field" field conflicts with the *** (int) value of the "secret_other_field" field.' @@ -1205,7 +1205,7 @@ class SomeClass(Storage): @pytest.mark.parametrize( - 'main_field_is_secret', + 'main_field_is_hidden', [ True, False, @@ -1218,11 +1218,11 @@ class SomeClass(Storage): {'doc': 'some doc'}, ], ) -def test_conflicting_fields_when_set_in_init(addictional_arguments, main_field_is_secret): +def test_conflicting_fields_when_set_in_init(addictional_arguments, main_field_is_hidden): class SomeClass(Storage): - field: int = Field(10, conflicts={'other_field': lambda old, new, other_old, other_new: new > other_old, 'secret_other_field': lambda old, new, other_old, other_new: new < 0}, doc=addictional_arguments.get('doc'), secret=main_field_is_secret) # noqa: ARG005 + field: int = Field(10, conflicts={'other_field': lambda old, new, other_old, other_new: new > other_old, 'secret_other_field': lambda old, new, other_old, other_new: new < 0}, doc=addictional_arguments.get('doc'), hide=main_field_is_hidden) # noqa: ARG005 other_field: int = Field(20, doc=addictional_arguments.get('doc')) - secret_other_field: int = Field(20, secret=True, doc=addictional_arguments.get('doc')) + secret_other_field: int = Field(20, hide=True, doc=addictional_arguments.get('doc')) instance = SomeClass() @@ -1233,11 +1233,11 @@ class SomeClass(Storage): assert instance.field == 15 if 'doc' in addictional_arguments: - if main_field_is_secret: + if main_field_is_hidden: exception_message = 'The new *** (int) value of the "field" field (some doc) conflicts with the 20 (int) value of the "other_field" field (some doc).' else: exception_message = 'The new 21 (int) value of the "field" field (some doc) conflicts with the 20 (int) value of the "other_field" field (some doc).' - elif main_field_is_secret: + elif main_field_is_hidden: exception_message = 'The new *** (int) value of the "field" field conflicts with the 20 (int) value of the "other_field" field.' else: exception_message = 'The new 21 (int) value of the "field" field conflicts with the 20 (int) value of the "other_field" field.' @@ -1246,11 +1246,11 @@ class SomeClass(Storage): SomeClass(field=21) if 'doc' in addictional_arguments: - if main_field_is_secret: + if main_field_is_hidden: exception_message = 'The new *** (int) value of the "field" field (some doc) conflicts with the *** (int) value of the "secret_other_field" field (some doc).' else: exception_message = 'The new -1 (int) value of the "field" field (some doc) conflicts with the *** (int) value of the "secret_other_field" field (some doc).' - elif main_field_is_secret: + elif main_field_is_hidden: exception_message = 'The new *** (int) value of the "field" field conflicts with the *** (int) value of the "secret_other_field" field.' else: exception_message = 'The new -1 (int) value of the "field" field conflicts with the *** (int) value of the "secret_other_field" field.' @@ -1260,7 +1260,7 @@ class SomeClass(Storage): @pytest.mark.parametrize( - 'are_fields_secret', + 'are_fields_hidden', [ True, False, @@ -1273,25 +1273,25 @@ class SomeClass(Storage): {'doc': 'some doc'}, ], ) -def test_conflicting_fields_when_defaults_are_conflicting(addictional_arguments, are_fields_secret): +def test_conflicting_fields_when_defaults_are_conflicting(addictional_arguments, are_fields_hidden): if 'doc' in addictional_arguments: - if are_fields_secret: + if are_fields_hidden: exception_message = 'The *** (int) default value of the "field" field (some doc) conflicts with the *** (int) value of the "other_field" field (some doc).' else: exception_message = 'The 21 (int) default value of the "field" field (some doc) conflicts with the 20 (int) value of the "other_field" field (some doc).' - elif are_fields_secret: + elif are_fields_hidden: exception_message = 'The *** (int) default value of the "field" field conflicts with the *** (int) value of the "other_field" field.' else: exception_message = 'The 21 (int) default value of the "field" field conflicts with the 20 (int) value of the "other_field" field.' with pytest.raises(ValueError, match=match(exception_message)): class SomeClass(Storage): - field: int = Field(21, conflicts={'other_field': lambda old, new, other_old, other_new: new > other_old, 'secret_other_field': lambda old, new, other_old, other_new: new > 30}, doc=addictional_arguments.get('doc'), secret=are_fields_secret) # noqa: ARG005 - other_field: int = Field(20, doc=addictional_arguments.get('doc'), secret=are_fields_secret) + field: int = Field(21, conflicts={'other_field': lambda old, new, other_old, other_new: new > other_old, 'secret_other_field': lambda old, new, other_old, other_new: new > 30}, doc=addictional_arguments.get('doc'), hide=are_fields_hidden) # noqa: ARG005 + other_field: int = Field(20, doc=addictional_arguments.get('doc'), hide=are_fields_hidden) @pytest.mark.parametrize( - 'are_fields_secret', + 'are_fields_hidden', [ True, False, @@ -1306,12 +1306,12 @@ class SomeClass(Storage): {'reverse_conflicts': True, 'doc': 'some doc'}, ], ) -def test_basic_conflicting_fields_reverse_when_its_on(addictional_arguments, are_fields_secret): +def test_basic_conflicting_fields_reverse_when_its_on(addictional_arguments, are_fields_hidden): doc = addictional_arguments.pop('doc', None) class SomeClass(Storage): - field: int = Field(10, conflicts={'other_field': lambda old, new, other_old, other_new: new > other_new}, doc=doc, secret=are_fields_secret, **addictional_arguments) # noqa: ARG005 - other_field: int = Field(20, doc=doc, secret=are_fields_secret, **addictional_arguments) + field: int = Field(10, conflicts={'other_field': lambda old, new, other_old, other_new: new > other_new}, doc=doc, hide=are_fields_hidden, **addictional_arguments) # noqa: ARG005 + other_field: int = Field(20, doc=doc, hide=are_fields_hidden, **addictional_arguments) instance = SomeClass() @@ -1323,11 +1323,11 @@ class SomeClass(Storage): assert instance.other_field == 30 if doc is not None: - if are_fields_secret: + if are_fields_hidden: exception_message = 'The new *** (int) value of the "other_field" field (some doc) conflicts with the *** (int) value of the "field" field (some doc).' else: exception_message = 'The new 5 (int) value of the "other_field" field (some doc) conflicts with the 10 (int) value of the "field" field (some doc).' - elif are_fields_secret: + elif are_fields_hidden: exception_message = 'The new *** (int) value of the "other_field" field conflicts with the *** (int) value of the "field" field.' else: exception_message = 'The new 5 (int) value of the "other_field" field conflicts with the 10 (int) value of the "field" field.' @@ -1340,7 +1340,7 @@ class SomeClass(Storage): @pytest.mark.parametrize( - 'are_fields_secret', + 'are_fields_hidden', [ True, False, @@ -1355,12 +1355,12 @@ class SomeClass(Storage): {'reverse_conflicts': True, 'doc': 'some doc'}, ], ) -def test_conflicting_fields_reverse_when_its_on_and_when_set_in_init(addictional_arguments, are_fields_secret): +def test_conflicting_fields_reverse_when_its_on_and_when_set_in_init(addictional_arguments, are_fields_hidden): doc = addictional_arguments.pop('doc', None) class SomeClass(Storage): - field: int = Field(10, conflicts={'other_field': lambda old, new, other_old, other_new: new > other_new}, doc=doc, secret=are_fields_secret, **addictional_arguments) # noqa: ARG005 - other_field: int = Field(20, doc=doc, secret=are_fields_secret, **addictional_arguments) + field: int = Field(10, conflicts={'other_field': lambda old, new, other_old, other_new: new > other_new}, doc=doc, hide=are_fields_hidden, **addictional_arguments) # noqa: ARG005 + other_field: int = Field(20, doc=doc, hide=are_fields_hidden, **addictional_arguments) instance = SomeClass() @@ -1372,11 +1372,11 @@ class SomeClass(Storage): assert instance.other_field == 30 if doc is not None: - if are_fields_secret: + if are_fields_hidden: exception_message = 'The new *** (int) value of the "other_field" field (some doc) conflicts with the *** (int) value of the "field" field (some doc).' else: exception_message = 'The new 5 (int) value of the "other_field" field (some doc) conflicts with the 10 (int) value of the "field" field (some doc).' - elif are_fields_secret: + elif are_fields_hidden: exception_message = 'The new *** (int) value of the "other_field" field conflicts with the *** (int) value of the "field" field.' else: exception_message = 'The new 5 (int) value of the "other_field" field conflicts with the 10 (int) value of the "field" field.' @@ -1394,7 +1394,7 @@ class SomeClass(Storage): ], ) @pytest.mark.parametrize( - 'are_fields_secret', + 'are_fields_hidden', [ True, False, @@ -1407,12 +1407,12 @@ class SomeClass(Storage): {'doc': 'some doc'}, ], ) -def test_basic_conflicting_fields_reverse_when_its_off(addictional_arguments, are_fields_secret, reverse_check_parameters): +def test_basic_conflicting_fields_reverse_when_its_off(addictional_arguments, are_fields_hidden, reverse_check_parameters): doc = addictional_arguments.pop('doc', None) class SomeClass(Storage, reverse_conflicts=reverse_check_parameters['class']): - field: int = Field(10, conflicts={'other_field': lambda old, new, other_old, other_new: new > other_new}, doc=doc, secret=are_fields_secret, **addictional_arguments, reverse_conflicts=reverse_check_parameters['field']) # noqa: ARG005 - other_field: int = Field(20, doc=doc, secret=are_fields_secret, **addictional_arguments) + field: int = Field(10, conflicts={'other_field': lambda old, new, other_old, other_new: new > other_new}, doc=doc, hide=are_fields_hidden, **addictional_arguments, reverse_conflicts=reverse_check_parameters['field']) # noqa: ARG005 + other_field: int = Field(20, doc=doc, hide=are_fields_hidden, **addictional_arguments) instance = SomeClass() @@ -1438,7 +1438,7 @@ class SomeClass(Storage, reverse_conflicts=reverse_check_parameters['class']): ], ) @pytest.mark.parametrize( - 'are_fields_secret', + 'are_fields_hidden', [ True, False, @@ -1451,12 +1451,12 @@ class SomeClass(Storage, reverse_conflicts=reverse_check_parameters['class']): {'doc': 'some doc'}, ], ) -def test_conflicting_fields_reverse_when_its_off_and_when_set_in_init(addictional_arguments, are_fields_secret, reverse_check_parameters): +def test_conflicting_fields_reverse_when_its_off_and_when_set_in_init(addictional_arguments, are_fields_hidden, reverse_check_parameters): doc = addictional_arguments.pop('doc', None) class SomeClass(Storage, reverse_conflicts=reverse_check_parameters['class']): - field: int = Field(10, conflicts={'other_field': lambda old, new, other_old, other_new: new > other_new}, doc=doc, secret=are_fields_secret, **addictional_arguments, reverse_conflicts=reverse_check_parameters['field']) # noqa: ARG005 - other_field: int = Field(20, doc=doc, secret=are_fields_secret, **addictional_arguments) + field: int = Field(10, conflicts={'other_field': lambda old, new, other_old, other_new: new > other_new}, doc=doc, hide=are_fields_hidden, **addictional_arguments, reverse_conflicts=reverse_check_parameters['field']) # noqa: ARG005 + other_field: int = Field(20, doc=doc, hide=are_fields_hidden, **addictional_arguments) instance = SomeClass() @@ -1474,7 +1474,7 @@ class SomeClass(Storage, reverse_conflicts=reverse_check_parameters['class']): @pytest.mark.parametrize( - 'main_field_is_secret', + 'main_field_is_hidden', [ True, False, @@ -1495,9 +1495,9 @@ class SomeClass(Storage, reverse_conflicts=reverse_check_parameters['class']): {'class': False, 'field': False}, ], ) -def test_conflicting_fields_when_reverse_check_off(addictional_arguments, main_field_is_secret, reverse_check_parameters): +def test_conflicting_fields_when_reverse_check_off(addictional_arguments, main_field_is_hidden, reverse_check_parameters): class SomeClass(Storage, reverse_conflicts=reverse_check_parameters['class']): - field: int = Field(10, conflicts={'other_field': lambda old, new, other_old, other_new: old > other_new}, doc=addictional_arguments.get('doc'), secret=main_field_is_secret, reverse_conflicts=reverse_check_parameters['field']) # noqa: ARG005 + field: int = Field(10, conflicts={'other_field': lambda old, new, other_old, other_new: old > other_new}, doc=addictional_arguments.get('doc'), hide=main_field_is_hidden, reverse_conflicts=reverse_check_parameters['field']) # noqa: ARG005 other_field: int = Field(20, doc=addictional_arguments.get('doc')) instance = SomeClass() @@ -1510,7 +1510,7 @@ class SomeClass(Storage, reverse_conflicts=reverse_check_parameters['class']): @pytest.mark.parametrize( - 'main_field_is_secret', + 'main_field_is_hidden', [ True, False, @@ -1531,9 +1531,9 @@ class SomeClass(Storage, reverse_conflicts=reverse_check_parameters['class']): {'class': False, 'field': False}, ], ) -def test_conflicting_fields_in_init_when_reverse_check_off(addictional_arguments, main_field_is_secret, reverse_check_parameters): +def test_conflicting_fields_in_init_when_reverse_check_off(addictional_arguments, main_field_is_hidden, reverse_check_parameters): class SomeClass(Storage, reverse_conflicts=reverse_check_parameters['class']): - field: int = Field(10, conflicts={'other_field': lambda old, new, other_old, other_new: old > other_new}, doc=addictional_arguments.get('doc'), secret=main_field_is_secret, reverse_conflicts=reverse_check_parameters['field']) # noqa: ARG005 + field: int = Field(10, conflicts={'other_field': lambda old, new, other_old, other_new: old > other_new}, doc=addictional_arguments.get('doc'), hide=main_field_is_hidden, reverse_conflicts=reverse_check_parameters['field']) # noqa: ARG005 other_field: int = Field(20, doc=addictional_arguments.get('doc')) instance = SomeClass(other_field=5) @@ -1543,7 +1543,7 @@ class SomeClass(Storage, reverse_conflicts=reverse_check_parameters['class']): @pytest.mark.parametrize( - 'main_field_is_secret', + 'main_field_is_hidden', [ True, False, @@ -1564,9 +1564,9 @@ class SomeClass(Storage, reverse_conflicts=reverse_check_parameters['class']): {'class': False, 'field': False}, ], ) -def test_conflicting_fields_in_defaults_when_reverse_check_off(addictional_arguments, main_field_is_secret, reverse_check_parameters): +def test_conflicting_fields_in_defaults_when_reverse_check_off(addictional_arguments, main_field_is_hidden, reverse_check_parameters): class SomeClass(Storage, reverse_conflicts=reverse_check_parameters['class']): - field: int = Field(10, conflicts={'other_field': lambda old, new, other_old, other_new: old > other_new}, doc=addictional_arguments.get('doc'), secret=main_field_is_secret, reverse_conflicts=reverse_check_parameters['field']) # noqa: ARG005 + field: int = Field(10, conflicts={'other_field': lambda old, new, other_old, other_new: old > other_new}, doc=addictional_arguments.get('doc'), hide=main_field_is_hidden, reverse_conflicts=reverse_check_parameters['field']) # noqa: ARG005 other_field: int = Field(5, doc=addictional_arguments.get('doc')) instance = SomeClass() @@ -2641,27 +2641,27 @@ class SomeClass(Storage): assert events == [('validation', 1), ('conversion', 1), ('validation', 2)] -def test_secret_value_is_masked_for_all_conversion_failure_phases(): +def test_hidden_value_is_masked_for_all_conversion_failure_phases(): class RawTypeFailure(Storage): - field: int = Field(1, conversion=lambda value: value, validation=lambda _value: True, secret=True) + field: int = Field(1, conversion=lambda value: value, validation=lambda _value: True, hide=True) with pytest.raises(TypeError, match=match('The value *** (str) of the "field" field does not match the type int.')): RawTypeFailure().field = 'bad' class RawValidationFailure(Storage): - field: int = Field(1, conversion=abs, validation=lambda value: value >= 0, secret=True) + field: int = Field(1, conversion=abs, validation=lambda value: value >= 0, hide=True) with pytest.raises(ValueError, match=match('The value *** (int) of the "field" field does not match the validation.')): RawValidationFailure().field = -1 class ConvertedTypeFailure(Storage): - field: int = Field(0, conversion=lambda value: 'bad' if value == 1 else value, validation=lambda _value: True, secret=True) + field: int = Field(0, conversion=lambda value: 'bad' if value == 1 else value, validation=lambda _value: True, hide=True) with pytest.raises(TypeError, match=match('The value *** (str) of the "field" field does not match the type int.')): ConvertedTypeFailure().field = 1 class ConvertedValidationFailure(Storage): - field: int = Field(0, conversion=lambda value: -value, validation=lambda value: value >= 0, secret=True) + field: int = Field(0, conversion=lambda value: -value, validation=lambda value: value >= 0, hide=True) with pytest.raises(ValueError, match=match('The value *** (int) of the "field" field does not match the validation.')): ConvertedValidationFailure().field = 1 @@ -3867,7 +3867,7 @@ class SomeClass(Storage): def test_instance_sources_repr(collection_type): class SomeClass(Storage): field: int = Field(100) - secret_field: int = Field(200, secret=True) + secret_field: int = Field(200, hide=True) instance = SomeClass(_sources=collection_type([MemorySource({'field': 42, 'secret_field': 99})])) From 298c647a1fee053756d7cc064c4c1d8769689dd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Mon, 11 May 2026 21:04:18 +0300 Subject: [PATCH 17/23] Add "Classes and their fields" section to README with examples and naming rules --- README.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 48b4969..bfdbe4e 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Keep all your project's settings in one place. Ensure type safety, thread safety ## Table of contents - [**Quick start**](#quick-start) +- [**Classes and their fields**](#classes-and-their-fields) - [**Default values**](#default-values) - [**Documenting fields**](#documenting-fields) - [**Secret fields**](#secret-fields) @@ -53,7 +54,7 @@ pip install skelet You can also quickly try this package and others without installing them via [instld](https://github.com/pomponchik/instld). -Now let's create our first storage class. To do this, we need to inherit from the base class `Storage` and define fields as class attributes. Use `Field` only when a field needs additional settings: +Now let's create our first [storage class](#classes-and-their-fields). To do this, we need to inherit from the base class `Storage` and define fields as class attributes. Use `Field` only when a field needs additional settings: ```python from skelet import Storage, Field, NonNegativeInt @@ -87,6 +88,21 @@ description.name = 3.14 That is already useful, but the rest of this guide covers more advanced features. +## Classes and their fields + +The main "player" in `skelet` is the `Storage` class, which you must inherit from while adding attributes. Attributes can have values or be value-less, and they can include type hints or be without them: + +```python +class SoccerTeam(Storage): + name: str + number_of_players: int = 11 +``` + +> ⚠️ An attribute name cannot begin with an underscore (_). Doing so will result in an exception being raised. + + + + ## Default values A default value is used when no other source provides one. It will be used until you override it. From c93983bd5b90f4471f28e0e2eadea05e9630ae1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Mon, 11 May 2026 21:20:26 +0300 Subject: [PATCH 18/23] Add Field documentation and introduce F alias --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index bfdbe4e..021375f 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,21 @@ class SoccerTeam(Storage): > ⚠️ An attribute name cannot begin with an underscore (_). Doing so will result in an exception being raised. +In most cases, a simple field form, as shown in the last example, will suffice. However, sometimes you need to further configure a field to give it "capabilities" such as automatic [validation of individual values](#validation-of-values) or checking adjacent fields for conflicts. To do this, instead of a "raw" value, you need to assign a value of type `Field`: +```python +... + number_of_players: int = Field(11, validation={'A team must have at least 7 players.': lambda x: x > 6, 'A team cannot have more than 11 players.': lambda x: x < 12}) +``` + +`Field` also has a shortened alias, `F`: + +```python +from skelet import F + +... + number_of_players: int = F(...) +``` ## Default values From 6ae47cabbdfabbf890a4b2993742fcf9777d93ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 12 May 2026 14:32:00 +0300 Subject: [PATCH 19/23] Add Typing :: Typed classifier to pyproject.toml --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 2a94b05..2c92452 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ classifiers = [ 'License :: OSI Approved :: MIT License', 'Intended Audience :: Developers', 'Topic :: Software Development :: Libraries', + 'Typing :: Typed', ] keywords = ['settings', 'configs'] From 625d3d81b8a91fe78ec6ca4ad2034bedd3fbd75e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 13 May 2026 12:13:41 +0300 Subject: [PATCH 20/23] Add pristan>=0.0.15 to dependencies --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 2c92452..fa0287c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ 'simtypes>=0.0.13', 'denial>=0.0.13', 'sigmatch>=0.0.10', + 'pristan>=0.0.15', 'tomli==2.3.0 ; python_version <= "3.12"', 'pyyaml==6.0.3', 'types-pyyaml==6.0.12.20241230', From 6569a322249e8f6e5392266c534c4a1cf49a21bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 13 May 2026 12:14:13 +0300 Subject: [PATCH 21/23] Refactor source getters to use pristan plugin system --- skelet/sources/getter_for_libraries.py | 67 ++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/skelet/sources/getter_for_libraries.py b/skelet/sources/getter_for_libraries.py index d7f3c39..eb87ead 100644 --- a/skelet/sources/getter_for_libraries.py +++ b/skelet/sources/getter_for_libraries.py @@ -1,8 +1,69 @@ from typing import List -from skelet import EnvSource, JSONSource, TOMLSource, YAMLSource +from pristan import slot + from skelet.sources.abstract import AbstractSource, ExpectedType +from skelet.sources.env import EnvSource +from skelet.sources.json import JSONSource +from skelet.sources.toml import TOMLSource +from skelet.sources.yaml import YAMLSource + + +@slot(entrypoint_group='skelet') +def for_tool(tool_name: str) -> List[AbstractSource[ExpectedType]]: # noqa: ARG001 + return [] + + +def validate_tool_name(tool_name: str) -> str: + if not tool_name.isidentifier(): + raise ValueError('The library name can only be a valid Python identifier.') + + return tool_name + + +@for_tool.plugin +def env(tool_name: str) -> EnvSource[ExpectedType]: + tool_name = validate_tool_name(tool_name) + return EnvSource(prefix=f'{tool_name}_'.upper()) + + +@for_tool.plugin +def toml(tool_name: str) -> TOMLSource[ExpectedType]: + tool_name = validate_tool_name(tool_name) + return TOMLSource(f'{tool_name}.toml') + + +@for_tool.plugin +def hidden_toml(tool_name: str) -> TOMLSource[ExpectedType]: + tool_name = validate_tool_name(tool_name) + return TOMLSource(f'.{tool_name}.toml') + + +@for_tool.plugin +def pyproject_toml(tool_name: str) -> TOMLSource[ExpectedType]: + tool_name = validate_tool_name(tool_name) + return TOMLSource('pyproject.toml', table=f'tool.{tool_name}') + + +@for_tool.plugin +def yaml(tool_name: str) -> YAMLSource[ExpectedType]: + tool_name = validate_tool_name(tool_name) + return YAMLSource(f'{tool_name}.yaml') + + +@for_tool.plugin +def hidden_yaml(tool_name: str) -> YAMLSource[ExpectedType]: + tool_name = validate_tool_name(tool_name) + return YAMLSource(f'.{tool_name}.yaml') + + +@for_tool.plugin +def json(tool_name: str) -> JSONSource[ExpectedType]: + tool_name = validate_tool_name(tool_name) + return JSONSource(f'{tool_name}.json') -def for_tool(tool_name: str) -> List[AbstractSource[ExpectedType]]: - return EnvSource.for_library(tool_name) + TOMLSource.for_library(tool_name) + YAMLSource.for_library(tool_name) + JSONSource.for_library(tool_name) # type: ignore[return-value, operator] +@for_tool.plugin +def hidden_json(tool_name: str) -> JSONSource[ExpectedType]: + tool_name = validate_tool_name(tool_name) + return JSONSource(f'.{tool_name}.json') From 97d76f69a546029cd3f95fae48fac04eb1e39060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 13 May 2026 12:14:35 +0300 Subject: [PATCH 22/23] Add tests for for_tool return type and plugin management --- tests/typing/test_sources_types.py | 6 +++ .../sources/test_getter_for_libraries.py | 48 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/tests/typing/test_sources_types.py b/tests/typing/test_sources_types.py index c2977c3..e18c896 100644 --- a/tests/typing/test_sources_types.py +++ b/tests/typing/test_sources_types.py @@ -29,6 +29,12 @@ class Config(Storage, sources=for_tool('my_tool')): assert_type(config.name, str) +@pytest.mark.mypy_testing +def test_for_tool_return_type() -> None: + sources: List[AbstractSource[Any]] = for_tool('my_tool') + assert_type(sources, List[AbstractSource[Any]]) + + @pytest.mark.mypy_testing def test_field_level_sources_memory() -> None: sources: FieldSources = [MemorySource({'name': 'val'})] diff --git a/tests/units/sources/test_getter_for_libraries.py b/tests/units/sources/test_getter_for_libraries.py index 581c118..b9d4903 100644 --- a/tests/units/sources/test_getter_for_libraries.py +++ b/tests/units/sources/test_getter_for_libraries.py @@ -1,3 +1,6 @@ +import pytest +from full_match import match + from skelet import EnvSource, JSONSource, TOMLSource, YAMLSource, for_tool @@ -41,3 +44,48 @@ def test_all_sources(): assert isinstance(sources[7], JSONSource) assert sources[7].path == '.kek.json' assert sources[7].allow_non_existent_files == True + + +def test_invalid_tool_name(): + with pytest.raises(ValueError, match=match('The library name can only be a valid Python identifier.')): + for_tool(':kek') + + +def test_builtin_plugins_order(): + assert for_tool.keys() == ( + 'env', + 'toml', + 'hidden_toml', + 'pyproject_toml', + 'yaml', + 'hidden_yaml', + 'json', + 'hidden_json', + ) + + +def test_dynamic_plugin_can_be_added_and_removed(): + @for_tool.plugin + def temporary_plugin(tool_name: str) -> JSONSource: + return JSONSource(f'{tool_name}.plugin.json') + + try: + assert 'temporary_plugin' in for_tool + + sources_before_removal = for_tool('kek') + + assert len(sources_before_removal) == 9 + assert isinstance(sources_before_removal[-1], JSONSource) + assert sources_before_removal[-1].path == 'kek.plugin.json' + + removed_plugins = for_tool.pop('temporary_plugin') + + assert len(removed_plugins) == 1 + assert 'temporary_plugin' not in for_tool + + sources_after_removal = for_tool('kek') + + assert len(sources_after_removal) == 8 + assert all(not (isinstance(source, JSONSource) and source.path == 'kek.plugin.json') for source in sources_after_removal) + finally: + for_tool.pop('temporary_plugin', None) From c8fb5fe30a24b7836da88c6e795cdccf7b7062d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 13 May 2026 12:24:34 +0300 Subject: [PATCH 23/23] Add "pragma: no cover" comment to for_tool for coverage tools --- skelet/sources/getter_for_libraries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skelet/sources/getter_for_libraries.py b/skelet/sources/getter_for_libraries.py index eb87ead..5ba2d78 100644 --- a/skelet/sources/getter_for_libraries.py +++ b/skelet/sources/getter_for_libraries.py @@ -11,7 +11,7 @@ @slot(entrypoint_group='skelet') def for_tool(tool_name: str) -> List[AbstractSource[ExpectedType]]: # noqa: ARG001 - return [] + return [] # pragma: no cover def validate_tool_name(tool_name: str) -> str: