storebind 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- storebind/__init__.py +25 -0
- storebind/base.py +97 -0
- storebind/exceptions.py +22 -0
- storebind/py.typed +0 -0
- storebind/registry.py +549 -0
- storebind-0.1.0.dist-info/METADATA +39 -0
- storebind-0.1.0.dist-info/RECORD +8 -0
- storebind-0.1.0.dist-info/WHEEL +4 -0
storebind/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .base import BaseStore
|
|
2
|
+
from .exceptions import (
|
|
3
|
+
InvalidStateTypeError,
|
|
4
|
+
MissingStateValueError,
|
|
5
|
+
StateNotDeclaredError,
|
|
6
|
+
StoreAlreadyRegisteredError,
|
|
7
|
+
StoreBindError,
|
|
8
|
+
StoreNotRegisteredError,
|
|
9
|
+
)
|
|
10
|
+
from .registry import StoreRegistry
|
|
11
|
+
|
|
12
|
+
store = StoreRegistry()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"BaseStore",
|
|
17
|
+
"InvalidStateTypeError",
|
|
18
|
+
"MissingStateValueError",
|
|
19
|
+
"StateNotDeclaredError",
|
|
20
|
+
"StoreAlreadyRegisteredError",
|
|
21
|
+
"StoreBindError",
|
|
22
|
+
"StoreNotRegisteredError",
|
|
23
|
+
"StoreRegistry",
|
|
24
|
+
"store",
|
|
25
|
+
]
|
storebind/base.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from typing import Any, ClassVar, get_type_hints
|
|
5
|
+
|
|
6
|
+
from .exceptions import MissingStateValueError
|
|
7
|
+
|
|
8
|
+
_MISSING = object()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseStore:
|
|
12
|
+
"""
|
|
13
|
+
Base class for all StoreBind stores.
|
|
14
|
+
|
|
15
|
+
Store fields are declared using type annotations:
|
|
16
|
+
|
|
17
|
+
class ConfigStore(BaseStore):
|
|
18
|
+
theme: str = "dark"
|
|
19
|
+
style: str
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__storebind_internal_fields__: ClassVar[frozenset[str]] = frozenset(
|
|
23
|
+
{
|
|
24
|
+
"__storebind_internal_fields__",
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def __init__(self, **initial_values: Any) -> None:
|
|
29
|
+
field_types = self.field_types()
|
|
30
|
+
|
|
31
|
+
unknown_fields = set(initial_values) - set(field_types)
|
|
32
|
+
|
|
33
|
+
if unknown_fields:
|
|
34
|
+
fields = ", ".join(sorted(unknown_fields))
|
|
35
|
+
raise TypeError(
|
|
36
|
+
f"{type(self).__name__} received unknown state field(s): {fields}."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
missing_fields: list[str] = []
|
|
40
|
+
|
|
41
|
+
for field_name in field_types:
|
|
42
|
+
if field_name in initial_values:
|
|
43
|
+
value = initial_values[field_name]
|
|
44
|
+
else:
|
|
45
|
+
value = self._get_default(field_name)
|
|
46
|
+
|
|
47
|
+
if value is _MISSING:
|
|
48
|
+
missing_fields.append(field_name)
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
object.__setattr__(
|
|
52
|
+
self,
|
|
53
|
+
field_name,
|
|
54
|
+
deepcopy(value),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if missing_fields:
|
|
58
|
+
formatted_fields = ", ".join(sorted(missing_fields))
|
|
59
|
+
|
|
60
|
+
raise MissingStateValueError(
|
|
61
|
+
f"{type(self).__name__} requires initial values "
|
|
62
|
+
f"for: {formatted_fields}."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def field_types(cls) -> dict[str, Any]:
|
|
67
|
+
"""
|
|
68
|
+
Return declared store fields and their resolved types.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
annotations = get_type_hints(cls)
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
name: annotation
|
|
75
|
+
for name, annotation in annotations.items()
|
|
76
|
+
if not name.startswith("_")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def _get_default(cls, field_name: str) -> Any:
|
|
81
|
+
for base in cls.__mro__:
|
|
82
|
+
if field_name in base.__dict__:
|
|
83
|
+
return base.__dict__[field_name]
|
|
84
|
+
|
|
85
|
+
return _MISSING
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict[str, Any]:
|
|
88
|
+
return {
|
|
89
|
+
field_name: getattr(self, field_name) for field_name in self.field_types()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
def __repr__(self) -> str:
|
|
93
|
+
values = ", ".join(
|
|
94
|
+
f"{name}={value!r}" for name, value in self.to_dict().items()
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return f"{type(self).__name__}({values})"
|
storebind/exceptions.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class StoreBindError(Exception):
|
|
2
|
+
"""Base exception for StoreBind."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class StoreAlreadyRegisteredError(StoreBindError):
|
|
6
|
+
"""Raised when a store class is registered more than once."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StoreNotRegisteredError(StoreBindError):
|
|
10
|
+
"""Raised when accessing an unregistered store."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StateNotDeclaredError(StoreBindError):
|
|
14
|
+
"""Raised when accessing a state field not declared by the store."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MissingStateValueError(StoreBindError):
|
|
18
|
+
"""Raised when a required state field has no initial value."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InvalidStateTypeError(StoreBindError, TypeError):
|
|
22
|
+
"""Raised when a state value does not match its declared type."""
|
storebind/py.typed
ADDED
|
File without changes
|
storebind/registry.py
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import types
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from functools import wraps
|
|
7
|
+
from threading import RLock
|
|
8
|
+
from typing import (
|
|
9
|
+
Any,
|
|
10
|
+
TypeVar,
|
|
11
|
+
Union,
|
|
12
|
+
get_args,
|
|
13
|
+
get_origin,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from .base import BaseStore
|
|
17
|
+
from .exceptions import (
|
|
18
|
+
InvalidStateTypeError,
|
|
19
|
+
StateNotDeclaredError,
|
|
20
|
+
StoreAlreadyRegisteredError,
|
|
21
|
+
StoreNotRegisteredError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
StoreT = TypeVar("StoreT", bound=BaseStore)
|
|
25
|
+
ReturnT = TypeVar("ReturnT")
|
|
26
|
+
|
|
27
|
+
Subscriber = Callable[[Any, Any], None]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class StoreRegistry:
|
|
31
|
+
"""
|
|
32
|
+
Registry containing application-wide store instances.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
validate_types: bool = True,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._stores: dict[type[BaseStore], BaseStore] = {}
|
|
41
|
+
|
|
42
|
+
self._subscribers: dict[
|
|
43
|
+
tuple[type[BaseStore], str],
|
|
44
|
+
set[Subscriber],
|
|
45
|
+
] = {}
|
|
46
|
+
|
|
47
|
+
self._validate_types = validate_types
|
|
48
|
+
self._lock = RLock()
|
|
49
|
+
|
|
50
|
+
def register(
|
|
51
|
+
self,
|
|
52
|
+
store_type: type[StoreT],
|
|
53
|
+
/,
|
|
54
|
+
*,
|
|
55
|
+
replace: bool = False,
|
|
56
|
+
**initial_values: Any,
|
|
57
|
+
) -> StoreT:
|
|
58
|
+
"""
|
|
59
|
+
Register and instantiate a store class.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
if not isinstance(store_type, type):
|
|
63
|
+
raise TypeError("register() expects a BaseStore class, not an instance.")
|
|
64
|
+
|
|
65
|
+
if not issubclass(store_type, BaseStore):
|
|
66
|
+
raise TypeError(f"{store_type!r} must inherit from BaseStore.")
|
|
67
|
+
|
|
68
|
+
with self._lock:
|
|
69
|
+
if store_type in self._stores and not replace:
|
|
70
|
+
raise StoreAlreadyRegisteredError(
|
|
71
|
+
f"{store_type.__name__} is already registered."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
instance = store_type(**initial_values)
|
|
75
|
+
|
|
76
|
+
if self._validate_types:
|
|
77
|
+
self._validate_store(instance)
|
|
78
|
+
|
|
79
|
+
self._stores[store_type] = instance
|
|
80
|
+
|
|
81
|
+
if replace:
|
|
82
|
+
self._remove_store_subscribers(store_type)
|
|
83
|
+
|
|
84
|
+
return instance
|
|
85
|
+
|
|
86
|
+
def unregister(
|
|
87
|
+
self,
|
|
88
|
+
store_type: type[BaseStore],
|
|
89
|
+
) -> None:
|
|
90
|
+
with self._lock:
|
|
91
|
+
self._require_store(store_type)
|
|
92
|
+
|
|
93
|
+
del self._stores[store_type]
|
|
94
|
+
self._remove_store_subscribers(store_type)
|
|
95
|
+
|
|
96
|
+
def is_registered(
|
|
97
|
+
self,
|
|
98
|
+
store_type: type[BaseStore],
|
|
99
|
+
) -> bool:
|
|
100
|
+
with self._lock:
|
|
101
|
+
return store_type in self._stores
|
|
102
|
+
|
|
103
|
+
def use(
|
|
104
|
+
self,
|
|
105
|
+
store_type: type[StoreT],
|
|
106
|
+
) -> StoreT:
|
|
107
|
+
"""
|
|
108
|
+
Return the registered store instance.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
with self._lock:
|
|
112
|
+
instance = self._require_store(store_type)
|
|
113
|
+
|
|
114
|
+
return instance # type: ignore[return-value]
|
|
115
|
+
|
|
116
|
+
def get(
|
|
117
|
+
self,
|
|
118
|
+
store_type: type[BaseStore],
|
|
119
|
+
attribute_name: str,
|
|
120
|
+
) -> Any:
|
|
121
|
+
with self._lock:
|
|
122
|
+
instance = self._require_store(store_type)
|
|
123
|
+
|
|
124
|
+
self._require_attribute(
|
|
125
|
+
store_type,
|
|
126
|
+
attribute_name,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return getattr(instance, attribute_name)
|
|
130
|
+
|
|
131
|
+
def set(
|
|
132
|
+
self,
|
|
133
|
+
store_type: type[BaseStore],
|
|
134
|
+
attribute_name: str,
|
|
135
|
+
value: Any,
|
|
136
|
+
) -> Any:
|
|
137
|
+
self._require_attribute(
|
|
138
|
+
store_type,
|
|
139
|
+
attribute_name,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if self._validate_types:
|
|
143
|
+
self._validate_field_value(
|
|
144
|
+
store_type,
|
|
145
|
+
attribute_name,
|
|
146
|
+
value,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
subscriber_key = (
|
|
150
|
+
store_type,
|
|
151
|
+
attribute_name,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
with self._lock:
|
|
155
|
+
instance = self._require_store(store_type)
|
|
156
|
+
old_value = getattr(instance, attribute_name)
|
|
157
|
+
|
|
158
|
+
if self._values_equal(old_value, value):
|
|
159
|
+
return value
|
|
160
|
+
|
|
161
|
+
setattr(instance, attribute_name, value)
|
|
162
|
+
|
|
163
|
+
subscribers = tuple(
|
|
164
|
+
self._subscribers.get(
|
|
165
|
+
subscriber_key,
|
|
166
|
+
(),
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
for callback in subscribers:
|
|
171
|
+
callback(value, old_value)
|
|
172
|
+
|
|
173
|
+
return value
|
|
174
|
+
|
|
175
|
+
def inject(
|
|
176
|
+
self,
|
|
177
|
+
store_type: type[BaseStore],
|
|
178
|
+
attribute_name: str,
|
|
179
|
+
*,
|
|
180
|
+
as_: str,
|
|
181
|
+
) -> Callable[
|
|
182
|
+
[Callable[..., ReturnT]],
|
|
183
|
+
Callable[..., ReturnT],
|
|
184
|
+
]:
|
|
185
|
+
"""
|
|
186
|
+
Inject a store value into a function parameter.
|
|
187
|
+
|
|
188
|
+
Explicit caller arguments take priority over injection.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
if not as_:
|
|
192
|
+
raise ValueError("inject() requires a non-empty 'as_' parameter.")
|
|
193
|
+
|
|
194
|
+
self._require_attribute(
|
|
195
|
+
store_type,
|
|
196
|
+
attribute_name,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def decorator(
|
|
200
|
+
function: Callable[..., ReturnT],
|
|
201
|
+
) -> Callable[..., ReturnT]:
|
|
202
|
+
signature = inspect.signature(function)
|
|
203
|
+
|
|
204
|
+
if as_ not in signature.parameters:
|
|
205
|
+
raise TypeError(
|
|
206
|
+
f"{function.__qualname__}() has no parameter named {as_!r}."
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if inspect.iscoroutinefunction(function):
|
|
210
|
+
|
|
211
|
+
@wraps(function)
|
|
212
|
+
async def async_wrapper(
|
|
213
|
+
*args: Any,
|
|
214
|
+
**kwargs: Any,
|
|
215
|
+
) -> Any:
|
|
216
|
+
call_kwargs = self._build_injected_kwargs(
|
|
217
|
+
function,
|
|
218
|
+
signature,
|
|
219
|
+
args,
|
|
220
|
+
kwargs,
|
|
221
|
+
store_type,
|
|
222
|
+
attribute_name,
|
|
223
|
+
as_,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return await function(
|
|
227
|
+
*args,
|
|
228
|
+
**call_kwargs,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
return async_wrapper # type: ignore[return-value]
|
|
232
|
+
|
|
233
|
+
@wraps(function)
|
|
234
|
+
def sync_wrapper(
|
|
235
|
+
*args: Any,
|
|
236
|
+
**kwargs: Any,
|
|
237
|
+
) -> ReturnT:
|
|
238
|
+
call_kwargs = self._build_injected_kwargs(
|
|
239
|
+
function,
|
|
240
|
+
signature,
|
|
241
|
+
args,
|
|
242
|
+
kwargs,
|
|
243
|
+
store_type,
|
|
244
|
+
attribute_name,
|
|
245
|
+
as_,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
return function(
|
|
249
|
+
*args,
|
|
250
|
+
**call_kwargs,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
return sync_wrapper
|
|
254
|
+
|
|
255
|
+
return decorator
|
|
256
|
+
|
|
257
|
+
def capture(
|
|
258
|
+
self,
|
|
259
|
+
store_type: type[BaseStore],
|
|
260
|
+
attribute_name: str,
|
|
261
|
+
) -> Callable[
|
|
262
|
+
[Callable[..., ReturnT]],
|
|
263
|
+
Callable[..., ReturnT],
|
|
264
|
+
]:
|
|
265
|
+
"""
|
|
266
|
+
Write a function's return value into a store field.
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
self._require_attribute(
|
|
270
|
+
store_type,
|
|
271
|
+
attribute_name,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def decorator(
|
|
275
|
+
function: Callable[..., ReturnT],
|
|
276
|
+
) -> Callable[..., ReturnT]:
|
|
277
|
+
if inspect.iscoroutinefunction(function):
|
|
278
|
+
|
|
279
|
+
@wraps(function)
|
|
280
|
+
async def async_wrapper(
|
|
281
|
+
*args: Any,
|
|
282
|
+
**kwargs: Any,
|
|
283
|
+
) -> Any:
|
|
284
|
+
result = await function(
|
|
285
|
+
*args,
|
|
286
|
+
**kwargs,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
self.set(
|
|
290
|
+
store_type,
|
|
291
|
+
attribute_name,
|
|
292
|
+
result,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
return result
|
|
296
|
+
|
|
297
|
+
return async_wrapper # type: ignore[return-value]
|
|
298
|
+
|
|
299
|
+
@wraps(function)
|
|
300
|
+
def sync_wrapper(
|
|
301
|
+
*args: Any,
|
|
302
|
+
**kwargs: Any,
|
|
303
|
+
) -> ReturnT:
|
|
304
|
+
result = function(
|
|
305
|
+
*args,
|
|
306
|
+
**kwargs,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
self.set(
|
|
310
|
+
store_type,
|
|
311
|
+
attribute_name,
|
|
312
|
+
result,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
return result
|
|
316
|
+
|
|
317
|
+
return sync_wrapper
|
|
318
|
+
|
|
319
|
+
return decorator
|
|
320
|
+
|
|
321
|
+
def subscribe(
|
|
322
|
+
self,
|
|
323
|
+
store_type: type[BaseStore],
|
|
324
|
+
attribute_name: str,
|
|
325
|
+
callback: Subscriber,
|
|
326
|
+
*,
|
|
327
|
+
immediate: bool = False,
|
|
328
|
+
) -> Callable[[], None]:
|
|
329
|
+
self._require_store(store_type)
|
|
330
|
+
self._require_attribute(
|
|
331
|
+
store_type,
|
|
332
|
+
attribute_name,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
key = (
|
|
336
|
+
store_type,
|
|
337
|
+
attribute_name,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
with self._lock:
|
|
341
|
+
callbacks = self._subscribers.setdefault(
|
|
342
|
+
key,
|
|
343
|
+
set(),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
callbacks.add(callback)
|
|
347
|
+
|
|
348
|
+
if immediate:
|
|
349
|
+
current_value = self.get(
|
|
350
|
+
store_type,
|
|
351
|
+
attribute_name,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
callback(
|
|
355
|
+
current_value,
|
|
356
|
+
current_value,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def unsubscribe() -> None:
|
|
360
|
+
with self._lock:
|
|
361
|
+
callbacks = self._subscribers.get(key)
|
|
362
|
+
|
|
363
|
+
if callbacks is None:
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
callbacks.discard(callback)
|
|
367
|
+
|
|
368
|
+
if not callbacks:
|
|
369
|
+
self._subscribers.pop(
|
|
370
|
+
key,
|
|
371
|
+
None,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
return unsubscribe
|
|
375
|
+
|
|
376
|
+
def reset(self) -> None:
|
|
377
|
+
"""
|
|
378
|
+
Remove every registered store and subscriber.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
with self._lock:
|
|
382
|
+
self._stores.clear()
|
|
383
|
+
self._subscribers.clear()
|
|
384
|
+
|
|
385
|
+
def _require_store(
|
|
386
|
+
self,
|
|
387
|
+
store_type: type[StoreT],
|
|
388
|
+
) -> StoreT:
|
|
389
|
+
try:
|
|
390
|
+
instance = self._stores[store_type]
|
|
391
|
+
except KeyError:
|
|
392
|
+
raise StoreNotRegisteredError(
|
|
393
|
+
f"{store_type.__name__} is not registered."
|
|
394
|
+
) from None
|
|
395
|
+
|
|
396
|
+
return instance # type: ignore[return-value]
|
|
397
|
+
|
|
398
|
+
def _require_attribute(
|
|
399
|
+
self,
|
|
400
|
+
store_type: type[BaseStore],
|
|
401
|
+
attribute_name: str,
|
|
402
|
+
) -> None:
|
|
403
|
+
field_types = store_type.field_types()
|
|
404
|
+
|
|
405
|
+
if attribute_name not in field_types:
|
|
406
|
+
raise StateNotDeclaredError(
|
|
407
|
+
f"{store_type.__name__} has no declared state field {attribute_name!r}."
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
def _validate_store(
|
|
411
|
+
self,
|
|
412
|
+
instance: BaseStore,
|
|
413
|
+
) -> None:
|
|
414
|
+
store_type = type(instance)
|
|
415
|
+
|
|
416
|
+
for field_name in store_type.field_types():
|
|
417
|
+
self._validate_field_value(
|
|
418
|
+
store_type,
|
|
419
|
+
field_name,
|
|
420
|
+
getattr(instance, field_name),
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
def _validate_field_value(
|
|
424
|
+
self,
|
|
425
|
+
store_type: type[BaseStore],
|
|
426
|
+
attribute_name: str,
|
|
427
|
+
value: Any,
|
|
428
|
+
) -> None:
|
|
429
|
+
expected_type = store_type.field_types()[attribute_name]
|
|
430
|
+
|
|
431
|
+
if not self._matches_type(
|
|
432
|
+
value,
|
|
433
|
+
expected_type,
|
|
434
|
+
):
|
|
435
|
+
raise InvalidStateTypeError(
|
|
436
|
+
f"{store_type.__name__}.{attribute_name} "
|
|
437
|
+
f"expects {expected_type!r}, "
|
|
438
|
+
f"but received {type(value).__name__}."
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
def _matches_type(
|
|
442
|
+
self,
|
|
443
|
+
value: Any,
|
|
444
|
+
expected_type: Any,
|
|
445
|
+
) -> bool:
|
|
446
|
+
if expected_type is Any:
|
|
447
|
+
return True
|
|
448
|
+
|
|
449
|
+
origin = get_origin(expected_type)
|
|
450
|
+
arguments = get_args(expected_type)
|
|
451
|
+
|
|
452
|
+
if origin in {
|
|
453
|
+
Union,
|
|
454
|
+
types.UnionType,
|
|
455
|
+
}:
|
|
456
|
+
return any(self._matches_type(value, argument) for argument in arguments)
|
|
457
|
+
|
|
458
|
+
if origin is list:
|
|
459
|
+
if not isinstance(value, list):
|
|
460
|
+
return False
|
|
461
|
+
|
|
462
|
+
if not arguments:
|
|
463
|
+
return True
|
|
464
|
+
|
|
465
|
+
item_type = arguments[0]
|
|
466
|
+
|
|
467
|
+
return all(self._matches_type(item, item_type) for item in value)
|
|
468
|
+
|
|
469
|
+
if origin is dict:
|
|
470
|
+
if not isinstance(value, dict):
|
|
471
|
+
return False
|
|
472
|
+
|
|
473
|
+
if len(arguments) != 2:
|
|
474
|
+
return True
|
|
475
|
+
|
|
476
|
+
key_type, value_type = arguments
|
|
477
|
+
|
|
478
|
+
return all(
|
|
479
|
+
self._matches_type(key, key_type)
|
|
480
|
+
and self._matches_type(item, value_type)
|
|
481
|
+
for key, item in value.items()
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
if origin is tuple:
|
|
485
|
+
return isinstance(value, tuple)
|
|
486
|
+
|
|
487
|
+
if origin is set:
|
|
488
|
+
return isinstance(value, set)
|
|
489
|
+
|
|
490
|
+
if origin is not None:
|
|
491
|
+
try:
|
|
492
|
+
return isinstance(value, origin)
|
|
493
|
+
except TypeError:
|
|
494
|
+
return True
|
|
495
|
+
|
|
496
|
+
try:
|
|
497
|
+
return isinstance(value, expected_type)
|
|
498
|
+
except TypeError:
|
|
499
|
+
# Some typing constructs cannot be checked safely
|
|
500
|
+
# through isinstance().
|
|
501
|
+
return True
|
|
502
|
+
|
|
503
|
+
def _build_injected_kwargs(
|
|
504
|
+
self,
|
|
505
|
+
function: Callable[..., Any],
|
|
506
|
+
signature: inspect.Signature,
|
|
507
|
+
args: tuple[Any, ...],
|
|
508
|
+
kwargs: dict[str, Any],
|
|
509
|
+
store_type: type[BaseStore],
|
|
510
|
+
attribute_name: str,
|
|
511
|
+
parameter_name: str,
|
|
512
|
+
) -> dict[str, Any]:
|
|
513
|
+
call_kwargs = dict(kwargs)
|
|
514
|
+
|
|
515
|
+
try:
|
|
516
|
+
bound_arguments = signature.bind_partial(
|
|
517
|
+
*args,
|
|
518
|
+
**call_kwargs,
|
|
519
|
+
)
|
|
520
|
+
except TypeError:
|
|
521
|
+
# Preserve the original function call error.
|
|
522
|
+
return call_kwargs
|
|
523
|
+
|
|
524
|
+
if parameter_name not in bound_arguments.arguments:
|
|
525
|
+
call_kwargs[parameter_name] = self.get(
|
|
526
|
+
store_type,
|
|
527
|
+
attribute_name,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
return call_kwargs
|
|
531
|
+
|
|
532
|
+
def _remove_store_subscribers(
|
|
533
|
+
self,
|
|
534
|
+
store_type: type[BaseStore],
|
|
535
|
+
) -> None:
|
|
536
|
+
keys = [key for key in self._subscribers if key[0] is store_type]
|
|
537
|
+
|
|
538
|
+
for key in keys:
|
|
539
|
+
del self._subscribers[key]
|
|
540
|
+
|
|
541
|
+
@staticmethod
|
|
542
|
+
def _values_equal(
|
|
543
|
+
old_value: Any,
|
|
544
|
+
new_value: Any,
|
|
545
|
+
) -> bool:
|
|
546
|
+
try:
|
|
547
|
+
return bool(old_value == new_value)
|
|
548
|
+
except Exception:
|
|
549
|
+
return old_value is new_value
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: storebind
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed application state, dependency injection, and reactive subscriptions for Python.
|
|
5
|
+
Keywords: state-management,dependency-injection,reactive,store,typed-state
|
|
6
|
+
Author: Benjamin Chau
|
|
7
|
+
Author-email: Benjamin Chau <68836494+swarfte@users.noreply.github.com>
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 Benjamin Chau
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
Classifier: Development Status :: 3 - Alpha
|
|
30
|
+
Classifier: Intended Audience :: Developers
|
|
31
|
+
Classifier: Programming Language :: Python :: 3
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
36
|
+
Classifier: Typing :: Typed
|
|
37
|
+
Requires-Python: >=3.11
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
storebind/__init__.py,sha256=AD5bPYlGzP4-209b-ynFNE0GB8CNMDAKzfgd-dz1a2M,524
|
|
2
|
+
storebind/base.py,sha256=Yl3ks2-AHq3xTU7W4jhECvVsJY6lRj8wBCyEdSB1jx8,2586
|
|
3
|
+
storebind/exceptions.py,sha256=OPhl0GWSNodgWcnEOgMYE_Vz4A5j6rNrQWRDMY8gzdk,659
|
|
4
|
+
storebind/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
storebind/registry.py,sha256=6KMJBYEHa3Izy-iZ0ivNOLkPP5s_AgTcBeLS-TmH5RI,13819
|
|
6
|
+
storebind-0.1.0.dist-info/WHEEL,sha256=XjEbIc5-wIORjWaafhI6vBtlxDBp7S9KiujWF1EM7Ak,79
|
|
7
|
+
storebind-0.1.0.dist-info/METADATA,sha256=6wfCawZKIi3dGH_o5Nxy3OB5S0QCkICw7G5v3DrRzSs,2009
|
|
8
|
+
storebind-0.1.0.dist-info/RECORD,,
|