From c351821cd7f74bf96dceef8458cf03b9fdd07bc3 Mon Sep 17 00:00:00 2001 From: pomponchik Date: Wed, 29 Apr 2026 19:19:11 +0300 Subject: [PATCH 01/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] +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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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):