Skip to content

Commit c3c8c39

Browse files
Евгений БлиновЕвгений Блинов
authored andcommitted
Add support for instance-level sources with ellipsis hierarchy
resolution
1 parent dd08174 commit c3c8c39

6 files changed

Lines changed: 600 additions & 16 deletions

File tree

README.md

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -330,15 +330,16 @@ Each field value is resolved in the following order:
330330

331331
```mermaid
332332
graph TD;
333-
A[Default values] --> B(Data sources in the order listed) --> C(The values set at runtime)
333+
A[Default values] --> B(Class sources) --> C(Field sources) --> D(Instance sources) --> E(The values set at runtime)
334334
```
335335

336336
That is, values obtained from sources have higher priority than default values, but can be overwritten (unless you [prohibit it](#read-only-fields)) by other values at runtime.
337337

338-
There are two ways to specify a list of sources:
338+
There are three ways to specify a list of sources:
339339

340340
- For the **whole class**.
341341
- For a **specific field**.
342+
- For a **specific instance**.
342343

343344
To specify a list of sources for the entire class, pass it to the class constructor:
344345

@@ -363,9 +364,37 @@ class MyClass(Storage, sources=[TOMLSource('pyproject.toml', table='tool.my_tool
363364
some_field = Field('some_value', sources=[TOMLSource('config_for_this_field.toml'), ...])
364365
```
365366

367+
Finally, you can specify a list of sources for a specific instance by passing it as the `_sources` argument when creating the object:
368+
369+
```python
370+
instance = MyClass(_sources=[TOMLSource('instance_config.toml')])
371+
```
372+
373+
Without an ellipsis, instance-level sources completely replace both class-level and field-level sources. If you want instance-level sources to have the highest priority while still falling back to other sources, use an ellipsis:
374+
375+
```python
376+
instance = MyClass(_sources=[TOMLSource('instance_config.toml'), ...])
377+
```
378+
379+
In this case, instance sources are checked first, and if a value is not found, the lookup falls back to the sources that the field would normally use without `_sources`. The fallback rules are:
380+
381+
- If a field has no `sources` parameter → fallback to class-level sources directly.
382+
- If a field has `sources` without `...` → fallback to field-level sources only (class-level sources are **not** included).
383+
- If a field has `sources` with `...` → fallback to field-level sources, then class-level sources.
384+
385+
> ⚠️ This means that `...` in `_sources` does **not** always reach class-level sources. If a field defines its own `sources` without `...`, class-level sources are excluded for that field even when instance-level `_sources` contains `...`:
386+
>
387+
> ```python
388+
> class MyClass(Storage, sources=[EnvSource()]):
389+
> # This field's sources do not include ..., so EnvSource() is unreachable for it:
390+
> some_field = Field('default', sources=[TOMLSource('field_config.toml')])
391+
> ```
392+
393+
Only `list` and `tuple` are accepted as the `_sources` collection type.
394+
366395
All values from sources are loaded when the config object is created. This means that (theoretically) during program execution, you can, for example, change a configuration file, then create a new storage object, and its contents will be different. The old object will not automatically know that the config file has been changed. Avoid this pattern, as it can lead to subtle bugs.
367396
368-
Each data source behaves like a mapping, and field values are looked up by field name. If no value is found in any of the sources, only then will the default value be used. The order in which the contents of the sources are checked corresponds to the order in which the sources themselves are listed, with sources for a field having higher priority than sources for the class as a whole.
397+
Each data source behaves like a mapping, and field values are looked up by field name. If no value is found in any of the sources, only then will the default value be used. The order in which the contents of the sources are checked corresponds to the order in which the sources themselves are listed. When multiple levels of sources are combined via ellipsis, instance-level sources have the highest priority, followed by field-level sources, and then class-level sources.
369398
370399
For any field, you can change the key used to search for its value in the sources using the `alias` parameter:
371400

skelet/fields/base.py

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from collections.abc import Sequence
2+
from sys import version_info
3+
from threading import Lock
14
from typing import (
25
Any,
36
Callable,
@@ -13,16 +16,6 @@
1316
get_type_hints,
1417
)
1518

16-
# TODO: check, EllipsisType was added to types module in Python 3.10.
17-
try:
18-
from types import EllipsisType # type: ignore[attr-defined, unused-ignore]
19-
except ImportError: # pragma: no cover
20-
EllipsisType = type(...) # type: ignore[misc, unused-ignore]
21-
22-
from collections.abc import Sequence
23-
from sys import version_info
24-
from threading import Lock
25-
2619
from denial import InnerNoneType
2720
from locklib import ContextLockProtocol
2821
from sigmatch import PossibleCallMatcher, SignatureMismatchError
@@ -31,6 +24,7 @@
3124
from skelet.sources.abstract import AbstractSource, ExpectedType
3225
from skelet.sources.collection import SourcesCollection
3326
from skelet.storage import Storage
27+
from skelet.types import EllipsisType
3428

3529
ValueType = TypeVar('ValueType')
3630

@@ -255,19 +249,40 @@ def raise_exception_in_storage(self, exception: BaseException, raising_on: bool)
255249
self.exception = exception
256250

257251
def get_sources(self, instance: Storage) -> SourcesCollection[ExpectedType]:
252+
instance_sources_raw = instance.__instance_sources__
253+
254+
if instance_sources_raw is None:
255+
return self._get_normal_sources(instance)
256+
257+
has_ellipsis = False
258+
instance_only: List[AbstractSource[ExpectedType]] = []
259+
for source in instance_sources_raw:
260+
if source is Ellipsis:
261+
has_ellipsis = True
262+
else:
263+
instance_only.append(cast(AbstractSource[ExpectedType], source))
264+
265+
if not has_ellipsis:
266+
return cast(SourcesCollection[ExpectedType], SourcesCollection(instance_only))
267+
268+
normal_sources: SourcesCollection[ExpectedType] = self._get_normal_sources(instance)
269+
combined: List[AbstractSource[ExpectedType]] = list(instance_only) + list(normal_sources.sources)
270+
return cast(SourcesCollection[ExpectedType], SourcesCollection(combined))
271+
272+
def _get_normal_sources(self, instance: Storage) -> SourcesCollection[ExpectedType]:
258273
if self.sources is None:
259274
return instance.__sources__
260275

261-
result = []
276+
result: List[AbstractSource[ExpectedType]] = []
262277
there_is_ellipsis = False
263278

264279
for source in self.sources:
265280
if source is Ellipsis:
266281
there_is_ellipsis = True
267282
else:
268-
result.append(source)
283+
result.append(cast(AbstractSource[ExpectedType], source))
269284

270285
if there_is_ellipsis:
271-
result.extend(instance.__sources__.sources)
286+
result.extend(instance.__sources__.sources)
272287

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

skelet/storage.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from collections import defaultdict
2+
from collections.abc import Sequence
23
from threading import Lock
34
from typing import Any, Dict, List, Optional, Tuple, Union
45

@@ -8,6 +9,7 @@
89

910
from skelet.sources.abstract import AbstractSource, ExpectedType
1011
from skelet.sources.collection import SourcesCollection
12+
from skelet.types import InstanceSourceItem
1113

1214
sentinel = InnerNoneType()
1315

@@ -17,8 +19,23 @@ class Storage:
1719
__field_names__: Union[List[str], Tuple[str, ...]] = ()
1820
__reverse_conflicts__: Dict[str, List[str]]
1921
__sources__: SourcesCollection # type: ignore[type-arg]
22+
__instance_sources__: Optional[Sequence[InstanceSourceItem]]
23+
24+
@staticmethod
25+
def _pop_and_validate_instance_sources(kwargs: Dict[str, Any]) -> Optional[Sequence['InstanceSourceItem']]:
26+
raw = kwargs.pop('_sources', sentinel)
27+
if raw is sentinel:
28+
return None
29+
if not isinstance(raw, (list, tuple)):
30+
raise TypeError('_sources must be a list or a tuple.')
31+
for item in raw:
32+
if item is not Ellipsis and not isinstance(item, AbstractSource):
33+
raise TypeError(f'Each element of _sources must be a source or Ellipsis, got {type(item).__name__}.')
34+
return raw
2035

2136
def __init__(self, **kwargs: Any) -> None:
37+
self.__instance_sources__ = self._pop_and_validate_instance_sources(kwargs)
38+
2239
self.__values__: Dict[str, Any] = {}
2340
self.__locks__ = {field_name: Lock() for field_name in self.__field_names__}
2441
deduplicated_fields = set(self.__field_names__)

skelet/types.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from typing import Any, Union
2+
3+
from skelet.sources.abstract import AbstractSource
4+
5+
__all__ = ['EllipsisType', 'InstanceSourceItem']
6+
7+
# EllipsisType was added to the types module in Python 3.10.
8+
try:
9+
from types import EllipsisType # type: ignore[attr-defined, unused-ignore]
10+
except ImportError: # pragma: no cover
11+
EllipsisType = type(...) # type: ignore[misc, unused-ignore]
12+
13+
InstanceSourceItem = Union[AbstractSource[Any], EllipsisType]

tests/functions/test_asdict.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,36 @@ class SomeClass(Storage, sources=[MemorySource({'field': 42, 'second_field': 43}
4141
second_field = Field()
4242

4343
assert asdict(SomeClass()) == {'field': 42, 'second_field': 43}
44+
45+
46+
@pytest.mark.parametrize('collection_type', [list, tuple])
47+
def test_instance_source_values_as_dict(collection_type):
48+
class SomeClass(Storage):
49+
field = Field(0)
50+
second_field = Field(0)
51+
52+
instance = SomeClass(_sources=collection_type([MemorySource({'field': 42, 'second_field': 43})]))
53+
54+
assert asdict(instance) == {'field': 42, 'second_field': 43}
55+
56+
57+
@pytest.mark.parametrize('collection_type', [list, tuple])
58+
def test_instance_source_with_ellipsis_hierarchy_as_dict(collection_type):
59+
class SomeClass(Storage, sources=[MemorySource({'class_field': 30})]):
60+
instance_field = Field(0)
61+
field_field = Field(0, sources=[MemorySource({'field_field': 20}), ...])
62+
class_field = Field(0)
63+
64+
instance = SomeClass(_sources=collection_type([MemorySource({'instance_field': 10}), ...]))
65+
66+
assert asdict(instance) == {'instance_field': 10, 'field_field': 20, 'class_field': 30}
67+
68+
69+
@pytest.mark.parametrize('collection_type', [list, tuple])
70+
def test_instance_source_ellipsis_does_not_reach_class_when_field_has_no_ellipsis_as_dict(collection_type):
71+
class SomeClass(Storage, sources=[MemorySource({'field': 99})]):
72+
field = Field(0, sources=[MemorySource({'other': 1})])
73+
74+
instance = SomeClass(_sources=collection_type([MemorySource({'other': 2}), ...]))
75+
76+
assert asdict(instance) == {'field': 0}

0 commit comments

Comments
 (0)