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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ mutants
CLAUDE.md
AGENTS.md
.qwen
coverage.xml
58 changes: 45 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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})
```

Expand Down Expand Up @@ -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).
Expand All @@ -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
Expand All @@ -118,14 +132,30 @@ 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

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
...
```

Expand Down Expand Up @@ -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()

Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions skelet/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion skelet/fields/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
126 changes: 125 additions & 1 deletion skelet/storage.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import inspect
from collections import defaultdict
from threading import Lock
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
from typing import (
Any,
Callable,
ClassVar,
Dict,
List,
Optional,
Sequence,
Tuple,
Union,
cast,
get_origin,
)

from denial import InnerNoneType
from locklib import ContextLockProtocol
Expand All @@ -10,6 +23,23 @@
from skelet.sources.collection import SourcesCollection
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 _annotationlib_get_annotations,
)
except ImportError: # pragma: no cover
_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
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()

class Storage:
Expand All @@ -31,6 +61,98 @@ 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 = dict(get_annotations(cls))
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 = cast(FieldDescriptor[Any, Any], 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 = cast(FieldDescriptor[Any, Any], 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 = cast(FieldDescriptor[Any, Any], 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()
result.extend([*annotated_field_names, *data_field_names])

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)

Expand Down Expand Up @@ -93,6 +215,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:
Expand Down
11 changes: 10 additions & 1 deletion tests/typing/test_field_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down
Loading
Loading