pulse-framework 0.1.62__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.
- pulse/__init__.py +1493 -0
- pulse/_examples.py +29 -0
- pulse/app.py +1086 -0
- pulse/channel.py +607 -0
- pulse/cli/__init__.py +0 -0
- pulse/cli/cmd.py +575 -0
- pulse/cli/dependencies.py +181 -0
- pulse/cli/folder_lock.py +134 -0
- pulse/cli/helpers.py +271 -0
- pulse/cli/logging.py +102 -0
- pulse/cli/models.py +35 -0
- pulse/cli/packages.py +262 -0
- pulse/cli/processes.py +292 -0
- pulse/cli/secrets.py +39 -0
- pulse/cli/uvicorn_log_config.py +87 -0
- pulse/code_analysis.py +38 -0
- pulse/codegen/__init__.py +0 -0
- pulse/codegen/codegen.py +359 -0
- pulse/codegen/templates/__init__.py +0 -0
- pulse/codegen/templates/layout.py +106 -0
- pulse/codegen/templates/route.py +345 -0
- pulse/codegen/templates/routes_ts.py +42 -0
- pulse/codegen/utils.py +20 -0
- pulse/component.py +237 -0
- pulse/components/__init__.py +0 -0
- pulse/components/for_.py +83 -0
- pulse/components/if_.py +86 -0
- pulse/components/react_router.py +94 -0
- pulse/context.py +108 -0
- pulse/cookies.py +322 -0
- pulse/decorators.py +344 -0
- pulse/dom/__init__.py +0 -0
- pulse/dom/elements.py +1024 -0
- pulse/dom/events.py +445 -0
- pulse/dom/props.py +1250 -0
- pulse/dom/svg.py +0 -0
- pulse/dom/tags.py +328 -0
- pulse/dom/tags.pyi +480 -0
- pulse/env.py +178 -0
- pulse/form.py +538 -0
- pulse/helpers.py +541 -0
- pulse/hooks/__init__.py +0 -0
- pulse/hooks/core.py +452 -0
- pulse/hooks/effects.py +88 -0
- pulse/hooks/init.py +668 -0
- pulse/hooks/runtime.py +464 -0
- pulse/hooks/setup.py +254 -0
- pulse/hooks/stable.py +138 -0
- pulse/hooks/state.py +192 -0
- pulse/js/__init__.py +125 -0
- pulse/js/__init__.pyi +115 -0
- pulse/js/_types.py +299 -0
- pulse/js/array.py +339 -0
- pulse/js/console.py +50 -0
- pulse/js/date.py +119 -0
- pulse/js/document.py +145 -0
- pulse/js/error.py +140 -0
- pulse/js/json.py +66 -0
- pulse/js/map.py +97 -0
- pulse/js/math.py +69 -0
- pulse/js/navigator.py +79 -0
- pulse/js/number.py +57 -0
- pulse/js/obj.py +81 -0
- pulse/js/object.py +172 -0
- pulse/js/promise.py +172 -0
- pulse/js/pulse.py +115 -0
- pulse/js/react.py +495 -0
- pulse/js/regexp.py +57 -0
- pulse/js/set.py +124 -0
- pulse/js/string.py +38 -0
- pulse/js/weakmap.py +53 -0
- pulse/js/weakset.py +48 -0
- pulse/js/window.py +205 -0
- pulse/messages.py +202 -0
- pulse/middleware.py +471 -0
- pulse/plugin.py +96 -0
- pulse/proxy.py +242 -0
- pulse/py.typed +0 -0
- pulse/queries/__init__.py +0 -0
- pulse/queries/client.py +609 -0
- pulse/queries/common.py +101 -0
- pulse/queries/effect.py +55 -0
- pulse/queries/infinite_query.py +1418 -0
- pulse/queries/mutation.py +295 -0
- pulse/queries/protocol.py +136 -0
- pulse/queries/query.py +1314 -0
- pulse/queries/store.py +120 -0
- pulse/react_component.py +88 -0
- pulse/reactive.py +1208 -0
- pulse/reactive_extensions.py +1172 -0
- pulse/render_session.py +768 -0
- pulse/renderer.py +584 -0
- pulse/request.py +205 -0
- pulse/routing.py +598 -0
- pulse/serializer.py +279 -0
- pulse/state.py +556 -0
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +111 -0
- pulse/transpiler/assets.py +81 -0
- pulse/transpiler/builtins.py +1029 -0
- pulse/transpiler/dynamic_import.py +130 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/errors.py +96 -0
- pulse/transpiler/function.py +611 -0
- pulse/transpiler/id.py +18 -0
- pulse/transpiler/imports.py +341 -0
- pulse/transpiler/js_module.py +336 -0
- pulse/transpiler/modules/__init__.py +33 -0
- pulse/transpiler/modules/asyncio.py +57 -0
- pulse/transpiler/modules/json.py +24 -0
- pulse/transpiler/modules/math.py +265 -0
- pulse/transpiler/modules/pulse/__init__.py +5 -0
- pulse/transpiler/modules/pulse/tags.py +250 -0
- pulse/transpiler/modules/typing.py +63 -0
- pulse/transpiler/nodes.py +1987 -0
- pulse/transpiler/py_module.py +135 -0
- pulse/transpiler/transpiler.py +1100 -0
- pulse/transpiler/vdom.py +256 -0
- pulse/types/__init__.py +0 -0
- pulse/types/event_handler.py +50 -0
- pulse/user_session.py +386 -0
- pulse/version.py +69 -0
- pulse_framework-0.1.62.dist-info/METADATA +198 -0
- pulse_framework-0.1.62.dist-info/RECORD +126 -0
- pulse_framework-0.1.62.dist-info/WHEEL +4 -0
- pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,1172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import weakref
|
|
5
|
+
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
|
|
6
|
+
from dataclasses import MISSING as _DC_MISSING
|
|
7
|
+
from dataclasses import FrozenInstanceError as _DC_FrozenInstanceError
|
|
8
|
+
from dataclasses import InitVar as _DC_InitVar
|
|
9
|
+
from dataclasses import dataclass as _dc_dataclass
|
|
10
|
+
from dataclasses import fields as _dc_fields
|
|
11
|
+
from dataclasses import is_dataclass
|
|
12
|
+
from typing import Any as _Any
|
|
13
|
+
from typing import (
|
|
14
|
+
Generic,
|
|
15
|
+
Protocol,
|
|
16
|
+
SupportsIndex,
|
|
17
|
+
TypeAlias,
|
|
18
|
+
TypeVar,
|
|
19
|
+
cast,
|
|
20
|
+
overload,
|
|
21
|
+
override,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from pulse.reactive import Computed, Signal, Untrack
|
|
25
|
+
|
|
26
|
+
T1 = TypeVar("T1")
|
|
27
|
+
T1_co = TypeVar("T1_co", covariant=True)
|
|
28
|
+
T2 = TypeVar("T2")
|
|
29
|
+
T2_co = TypeVar("T2_co", covariant=True)
|
|
30
|
+
T3 = TypeVar("T3")
|
|
31
|
+
|
|
32
|
+
T = TypeVar("T")
|
|
33
|
+
S = TypeVar("S")
|
|
34
|
+
|
|
35
|
+
KT = TypeVar
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_MISSING = object()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SupportsKeysAndGetItem(Protocol[T1, T2_co]):
|
|
42
|
+
def keys(self) -> Iterable[T1]: ...
|
|
43
|
+
def __getitem__(self, key: T1, /) -> T2_co: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Return an iterable view that subscribes to per-key signals during iteration
|
|
47
|
+
class ReactiveDictItems(Generic[T1, T2]):
|
|
48
|
+
__slots__ = ("_host",) # pyright: ignore[reportUnannotatedClassAttribute]
|
|
49
|
+
_host: ReactiveDict[T1, T2]
|
|
50
|
+
|
|
51
|
+
def __init__(self, host: ReactiveDict[T1, T2]) -> None:
|
|
52
|
+
self._host = host
|
|
53
|
+
|
|
54
|
+
def __iter__(self):
|
|
55
|
+
for k in self._host:
|
|
56
|
+
yield (k, self._host[k])
|
|
57
|
+
|
|
58
|
+
def __len__(self) -> int:
|
|
59
|
+
return len(self._host)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ReactiveDictValues(Generic[T1, T2]):
|
|
63
|
+
__slots__ = ("_host",) # pyright: ignore[reportUnannotatedClassAttribute]
|
|
64
|
+
_host: ReactiveDict[T1, T2]
|
|
65
|
+
|
|
66
|
+
def __init__(self, host: ReactiveDict[T1, T2]) -> None:
|
|
67
|
+
self._host = host
|
|
68
|
+
|
|
69
|
+
def __iter__(self):
|
|
70
|
+
for k in self._host:
|
|
71
|
+
yield self._host[k]
|
|
72
|
+
|
|
73
|
+
def __len__(self) -> int:
|
|
74
|
+
return len(self._host)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ReactiveDict(dict[T1, T2]):
|
|
78
|
+
"""A dict-like container with per-key reactivity.
|
|
79
|
+
|
|
80
|
+
Reading a key registers a dependency on that key's Signal. Writing a key
|
|
81
|
+
updates only that key's Signal. Iteration, membership checks, and len are
|
|
82
|
+
reactive to structural changes.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
initial: Initial key-value pairs to populate the dict.
|
|
86
|
+
|
|
87
|
+
Example:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
data = ReactiveDict({"name": "Alice", "age": 30})
|
|
91
|
+
print(data["name"]) # "Alice" (registers dependency)
|
|
92
|
+
data["age"] = 31 # Updates age signal only
|
|
93
|
+
data.unwrap() # {"name": "Alice", "age": 31}
|
|
94
|
+
```
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
__slots__ = ("_signals", "_structure") # pyright: ignore[reportUnannotatedClassAttribute]
|
|
98
|
+
|
|
99
|
+
def __init__(self, initial: Mapping[T1, T2] | None = None) -> None:
|
|
100
|
+
super().__init__()
|
|
101
|
+
self._signals: dict[T1, Signal[_Any]] = {}
|
|
102
|
+
self._structure: Signal[int] = Signal(0)
|
|
103
|
+
if initial:
|
|
104
|
+
for k, v in initial.items():
|
|
105
|
+
v = reactive(v)
|
|
106
|
+
super().__setitem__(k, v)
|
|
107
|
+
self._signals[k] = Signal(v)
|
|
108
|
+
|
|
109
|
+
# ---- helpers ----
|
|
110
|
+
def _bump_structure(self) -> None:
|
|
111
|
+
self._structure.write(self._structure.read() + 1)
|
|
112
|
+
|
|
113
|
+
# --- Mapping protocol ---
|
|
114
|
+
@override
|
|
115
|
+
def __getitem__(self, key: T1) -> T2:
|
|
116
|
+
if key not in self._signals:
|
|
117
|
+
# Lazily create missing key with sentinel so it can be reactive
|
|
118
|
+
self._signals[key] = Signal(_MISSING)
|
|
119
|
+
val = self._signals[key].read()
|
|
120
|
+
# Preserve dict.__getitem__ typing by casting. Semantics: return None
|
|
121
|
+
# only if the stored value is explicitly None; otherwise unwrap sentinel.
|
|
122
|
+
return cast(T2, None) if val is _MISSING else cast(T2, val)
|
|
123
|
+
|
|
124
|
+
@override
|
|
125
|
+
def __setitem__(self, key: T1, value: T2) -> None:
|
|
126
|
+
self.set(key, value)
|
|
127
|
+
|
|
128
|
+
@override
|
|
129
|
+
def __delitem__(self, key: T1) -> None:
|
|
130
|
+
# Remove from mapping but preserve signal object for subscribers
|
|
131
|
+
if key not in self._signals:
|
|
132
|
+
self._signals[key] = Signal(_MISSING)
|
|
133
|
+
else:
|
|
134
|
+
self._signals[key].write(_MISSING)
|
|
135
|
+
if super().__contains__(key):
|
|
136
|
+
super().__delitem__(key)
|
|
137
|
+
self._bump_structure()
|
|
138
|
+
|
|
139
|
+
@overload
|
|
140
|
+
def get(self, key: T1, default: None = None, /) -> T2 | None: ...
|
|
141
|
+
@overload
|
|
142
|
+
def get(self, key: T1, default: T2, /) -> T2: ...
|
|
143
|
+
@overload
|
|
144
|
+
def get(self, key: T1, default: T3) -> T2 | T3: ...
|
|
145
|
+
@override
|
|
146
|
+
def get(self, key: T1, default: T3 | None = None) -> T2 | T3 | None:
|
|
147
|
+
# Ensure a per-key signal exists so get() can subscribe even when absent
|
|
148
|
+
sig = self._signals.get(key)
|
|
149
|
+
if sig is None:
|
|
150
|
+
sig = cast(Signal[T2], Signal(_MISSING))
|
|
151
|
+
self._signals[key] = sig
|
|
152
|
+
val = sig.read()
|
|
153
|
+
return default if val is _MISSING else val
|
|
154
|
+
|
|
155
|
+
@override
|
|
156
|
+
def __iter__(self) -> Iterator[T1]:
|
|
157
|
+
# Reactive to structural changes
|
|
158
|
+
self._structure.read()
|
|
159
|
+
return super().__iter__()
|
|
160
|
+
|
|
161
|
+
@override
|
|
162
|
+
def __len__(self) -> int:
|
|
163
|
+
self._structure.read()
|
|
164
|
+
return super().__len__()
|
|
165
|
+
|
|
166
|
+
# The base __contains__ annotates key as type `object`, which is not strict enough
|
|
167
|
+
@override
|
|
168
|
+
def __contains__(self, key: T1) -> bool: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
169
|
+
# Subscribe to the per-key value signal so presence checks are reactive
|
|
170
|
+
sig = self._signals.get(key)
|
|
171
|
+
if sig is None:
|
|
172
|
+
sig = Signal(_MISSING)
|
|
173
|
+
self._signals[key] = sig
|
|
174
|
+
sig.read()
|
|
175
|
+
return dict.__contains__(self, key)
|
|
176
|
+
|
|
177
|
+
# --- Mutation helpers ---
|
|
178
|
+
def set(self, key: T1, value: T2) -> None:
|
|
179
|
+
value = reactive(value)
|
|
180
|
+
was_present = super().__contains__(key)
|
|
181
|
+
sig = self._signals.get(key)
|
|
182
|
+
if sig is None:
|
|
183
|
+
self._signals[key] = Signal(value)
|
|
184
|
+
else:
|
|
185
|
+
sig.write(value)
|
|
186
|
+
super().__setitem__(key, value)
|
|
187
|
+
if not was_present:
|
|
188
|
+
self._bump_structure()
|
|
189
|
+
|
|
190
|
+
@overload
|
|
191
|
+
def update(self, m: SupportsKeysAndGetItem[T1, T2], /) -> None: ...
|
|
192
|
+
@overload
|
|
193
|
+
def update(
|
|
194
|
+
self: "ReactiveDict[str, T2]",
|
|
195
|
+
m: SupportsKeysAndGetItem[str, T2],
|
|
196
|
+
/,
|
|
197
|
+
**kwargs: T2,
|
|
198
|
+
) -> None: ...
|
|
199
|
+
@overload
|
|
200
|
+
def update(self, m: Iterable[tuple[T1, T2]], /) -> None: ...
|
|
201
|
+
@overload
|
|
202
|
+
def update(
|
|
203
|
+
self: "ReactiveDict[str, T2]", m: Iterable[tuple[str, T2]], /, **kwargs: T2
|
|
204
|
+
) -> None: ...
|
|
205
|
+
@overload
|
|
206
|
+
def update(self: "ReactiveDict[str, T2]", **kwargs: T2) -> None: ...
|
|
207
|
+
@override
|
|
208
|
+
# Pyright also doesn't want to accept this overloaded override, but it's
|
|
209
|
+
# exactly correct.
|
|
210
|
+
def update( # pyright: ignore[reportIncompatibleMethodOverride]
|
|
211
|
+
self,
|
|
212
|
+
other: _Any = None,
|
|
213
|
+
**kwargs: T2,
|
|
214
|
+
) -> None:
|
|
215
|
+
# Match dict.update semantics
|
|
216
|
+
if other is not None:
|
|
217
|
+
if isinstance(other, Mapping) or hasattr(other, "keys"):
|
|
218
|
+
# Mapping-like: iterate keys and fetch via __getitem__
|
|
219
|
+
keys_iter = other.keys()
|
|
220
|
+
for k in keys_iter:
|
|
221
|
+
self.set(cast(T1, k), cast(T2, other[k]))
|
|
222
|
+
else:
|
|
223
|
+
# Iterable of key/value pairs
|
|
224
|
+
for k, v in other:
|
|
225
|
+
self.set(k, v)
|
|
226
|
+
if kwargs:
|
|
227
|
+
for k, v in kwargs.items():
|
|
228
|
+
self.set(cast(T1, k), v)
|
|
229
|
+
|
|
230
|
+
def delete(self, key: T1) -> None:
|
|
231
|
+
if key in self._signals:
|
|
232
|
+
# Preserve signal and mark as not present; do not raise
|
|
233
|
+
self._signals[key].write(_MISSING)
|
|
234
|
+
if super().__contains__(key):
|
|
235
|
+
super().__delitem__(key)
|
|
236
|
+
self._bump_structure()
|
|
237
|
+
|
|
238
|
+
# ---- standard dict methods ----
|
|
239
|
+
# I have no idea why Pyright is not happy with this override, but *shrug*
|
|
240
|
+
@override
|
|
241
|
+
def keys(self):
|
|
242
|
+
self._structure.read()
|
|
243
|
+
return super().keys()
|
|
244
|
+
|
|
245
|
+
# This overload is incompatible because we return a different wrapper
|
|
246
|
+
@override
|
|
247
|
+
def items(self): # pyright: ignore[reportIncompatibleMethodOverride]
|
|
248
|
+
return ReactiveDictItems(self)
|
|
249
|
+
|
|
250
|
+
# This overload is incompatible because we return a different wrapper
|
|
251
|
+
@override
|
|
252
|
+
def values(self): # pyright: ignore[reportIncompatibleMethodOverride]
|
|
253
|
+
return ReactiveDictValues(self)
|
|
254
|
+
|
|
255
|
+
@overload
|
|
256
|
+
def pop(self, key: T1, /) -> T2: ...
|
|
257
|
+
|
|
258
|
+
@overload
|
|
259
|
+
def pop(self, key: T1, default: T2, /) -> T2: ...
|
|
260
|
+
@overload
|
|
261
|
+
def pop(self, key: T1, default: T3, /) -> T2 | T3: ...
|
|
262
|
+
|
|
263
|
+
@override
|
|
264
|
+
def pop(self, key: T1, default: T3 = _MISSING) -> T2 | T3:
|
|
265
|
+
if super().__contains__(key):
|
|
266
|
+
val = cast(T2, dict.__getitem__(self, key))
|
|
267
|
+
self.__delitem__(key)
|
|
268
|
+
return val
|
|
269
|
+
if default is _MISSING:
|
|
270
|
+
raise KeyError(key)
|
|
271
|
+
return default
|
|
272
|
+
|
|
273
|
+
@override
|
|
274
|
+
def popitem(self) -> tuple[T1, T2]:
|
|
275
|
+
if not super().__len__():
|
|
276
|
+
raise KeyError("popitem(): dictionary is empty")
|
|
277
|
+
k, v = super().popitem()
|
|
278
|
+
# Preserve and update reactive metadata
|
|
279
|
+
sig = self._signals.get(k)
|
|
280
|
+
if sig is None:
|
|
281
|
+
self._signals[k] = Signal(_MISSING)
|
|
282
|
+
else:
|
|
283
|
+
sig.write(_MISSING)
|
|
284
|
+
self._bump_structure()
|
|
285
|
+
return k, v
|
|
286
|
+
|
|
287
|
+
@overload
|
|
288
|
+
def setdefault(self, key: T1, default: None = None, /) -> T2 | None: ...
|
|
289
|
+
@overload
|
|
290
|
+
def setdefault(self, key: T1, default: T2, /) -> T2: ...
|
|
291
|
+
|
|
292
|
+
@override
|
|
293
|
+
def setdefault(self, key: T1, default: T2 | None = None) -> T2 | None:
|
|
294
|
+
if super().__contains__(key):
|
|
295
|
+
# Return current value without structural change
|
|
296
|
+
if key not in self._signals:
|
|
297
|
+
self._signals[key] = Signal(_MISSING)
|
|
298
|
+
return self._signals[key].read()
|
|
299
|
+
# Insert default
|
|
300
|
+
self.set(key, default) # pyright: ignore[reportArgumentType]
|
|
301
|
+
# Read structure after write to suppress immediate rerun of the current
|
|
302
|
+
# effect (if this is used in an effect) caused by the structural bump
|
|
303
|
+
# performed in set().
|
|
304
|
+
self._structure.read()
|
|
305
|
+
sig = self._signals.get(key)
|
|
306
|
+
if sig is None:
|
|
307
|
+
sig = cast(Signal[T2], Signal(_MISSING))
|
|
308
|
+
self._signals[key] = sig
|
|
309
|
+
return sig.read()
|
|
310
|
+
|
|
311
|
+
@override
|
|
312
|
+
def clear(self) -> None:
|
|
313
|
+
if not super().__len__():
|
|
314
|
+
return
|
|
315
|
+
for k in list(super().keys()):
|
|
316
|
+
# Use our deletion to keep signals/presence updated
|
|
317
|
+
self.__delitem__(k)
|
|
318
|
+
# bump already done per key; nothing else needed
|
|
319
|
+
|
|
320
|
+
@override
|
|
321
|
+
def copy(self):
|
|
322
|
+
# Shallow copy preserving current values
|
|
323
|
+
return ReactiveDict(self)
|
|
324
|
+
|
|
325
|
+
def __copy__(self):
|
|
326
|
+
return self.copy()
|
|
327
|
+
|
|
328
|
+
def __deepcopy__(self, memo: dict[int, _Any]):
|
|
329
|
+
if id(self) in memo:
|
|
330
|
+
return memo[id(self)]
|
|
331
|
+
result = type(self)()
|
|
332
|
+
memo[id(self)] = result
|
|
333
|
+
for key in dict.__iter__(self):
|
|
334
|
+
key = cast(T1, key)
|
|
335
|
+
key_copy = copy.deepcopy(key, memo)
|
|
336
|
+
value_copy = copy.deepcopy(cast(T2, dict.__getitem__(self, key)), memo)
|
|
337
|
+
result.set(key_copy, value_copy)
|
|
338
|
+
return result
|
|
339
|
+
|
|
340
|
+
@overload
|
|
341
|
+
@classmethod
|
|
342
|
+
def fromkeys(
|
|
343
|
+
cls, iterable: Iterable[S], value: None = None, /
|
|
344
|
+
) -> ReactiveDict[S, _Any | None]: ...
|
|
345
|
+
@overload
|
|
346
|
+
@classmethod
|
|
347
|
+
def fromkeys(cls, iterable: Iterable[S], value: T, /) -> ReactiveDict[S, T]: ...
|
|
348
|
+
|
|
349
|
+
@override
|
|
350
|
+
@classmethod
|
|
351
|
+
def fromkeys(
|
|
352
|
+
cls, iterable: Iterable[S], value: T | None = None, /
|
|
353
|
+
) -> ReactiveDict[S, _Any | None] | ReactiveDict[S, T]:
|
|
354
|
+
rd: ReactiveDict[S, T | None] = cls() # pyright: ignore[reportAssignmentType]
|
|
355
|
+
for k in iterable:
|
|
356
|
+
rd.set(k, value)
|
|
357
|
+
return rd
|
|
358
|
+
|
|
359
|
+
# PEP 584 dict union operators
|
|
360
|
+
@override
|
|
361
|
+
def __ior__(self, other: Mapping[T1, T2]) -> ReactiveDict[T1, T2]: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
362
|
+
self.update(other)
|
|
363
|
+
return self
|
|
364
|
+
|
|
365
|
+
@override
|
|
366
|
+
def __or__(self, other: Mapping[T1, T2]) -> ReactiveDict[T1, T2]: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
367
|
+
result = ReactiveDict(self)
|
|
368
|
+
result.update(other)
|
|
369
|
+
return result
|
|
370
|
+
|
|
371
|
+
@override
|
|
372
|
+
def __ror__(self, other: Mapping[T1, T2]) -> ReactiveDict[T1, T2]: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
373
|
+
result = ReactiveDict(other)
|
|
374
|
+
result.update(self)
|
|
375
|
+
return result
|
|
376
|
+
|
|
377
|
+
def unwrap(self) -> dict[T1, _Any]:
|
|
378
|
+
"""Return a plain dict while subscribing to contained signals.
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
A plain dict with all reactive containers recursively unwrapped.
|
|
382
|
+
"""
|
|
383
|
+
self._structure.read()
|
|
384
|
+
result: dict[T1, _Any] = {}
|
|
385
|
+
for key in dict.__iter__(self):
|
|
386
|
+
result[key] = unwrap(self[key])
|
|
387
|
+
return result
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# Copied from the built-in types
|
|
391
|
+
# =====
|
|
392
|
+
_T_contra = TypeVar("_T_contra", contravariant=True)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
class SupportsBool(Protocol):
|
|
396
|
+
def __bool__(self) -> bool: ...
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class SupportsDunderLT(Protocol[_T_contra]):
|
|
400
|
+
def __lt__(self, other: _T_contra, /) -> SupportsBool: ...
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class SupportsDunderGT(Protocol[_T_contra]):
|
|
404
|
+
def __gt__(self, other: _T_contra, /) -> SupportsBool: ...
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
SupportsRichComparison: TypeAlias = SupportsDunderLT[_Any] | SupportsDunderGT[_Any]
|
|
408
|
+
SupportsRichComparisonT = TypeVar(
|
|
409
|
+
"SupportsRichComparisonT", bound=SupportsRichComparison
|
|
410
|
+
)
|
|
411
|
+
# ====
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class ReactiveList(list[T1]):
|
|
415
|
+
"""A list with item-level reactivity and structural change signaling.
|
|
416
|
+
|
|
417
|
+
Index reads depend on that index's Signal. Setting an index writes to that
|
|
418
|
+
index's Signal. Structural operations (append/insert/pop/etc.) trigger a
|
|
419
|
+
structural version Signal. Iteration subscribes to all item signals and
|
|
420
|
+
structural changes. len() subscribes to structural changes.
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
initial: Initial items to populate the list.
|
|
424
|
+
|
|
425
|
+
Example:
|
|
426
|
+
|
|
427
|
+
```python
|
|
428
|
+
items = ReactiveList([1, 2, 3])
|
|
429
|
+
print(items[0]) # 1 (registers dependency on index 0)
|
|
430
|
+
items.append(4) # Triggers structural change
|
|
431
|
+
items.unwrap() # [1, 2, 3, 4]
|
|
432
|
+
```
|
|
433
|
+
"""
|
|
434
|
+
|
|
435
|
+
__slots__ = ("_signals", "_structure") # pyright: ignore[reportUnannotatedClassAttribute]
|
|
436
|
+
|
|
437
|
+
def __init__(self, initial: Iterable[T1] | None = None) -> None:
|
|
438
|
+
super().__init__()
|
|
439
|
+
self._signals: list[Signal[T1]] = []
|
|
440
|
+
self._structure: Signal[int] = Signal(0)
|
|
441
|
+
if initial:
|
|
442
|
+
for item in initial:
|
|
443
|
+
v = reactive(item)
|
|
444
|
+
self._signals.append(Signal(v))
|
|
445
|
+
super().append(v)
|
|
446
|
+
|
|
447
|
+
# ---- helpers ----
|
|
448
|
+
def _bump_structure(self):
|
|
449
|
+
self._structure.write(self._structure.read() + 1)
|
|
450
|
+
|
|
451
|
+
@property
|
|
452
|
+
def version(self) -> int:
|
|
453
|
+
"""Reactive counter that increments on any structural change."""
|
|
454
|
+
return self._structure.read()
|
|
455
|
+
|
|
456
|
+
@overload
|
|
457
|
+
def __getitem__(self, i: SupportsIndex, /) -> T1:
|
|
458
|
+
"""Return self[index]."""
|
|
459
|
+
...
|
|
460
|
+
|
|
461
|
+
@overload
|
|
462
|
+
def __getitem__(self, s: slice, /) -> list[T1]:
|
|
463
|
+
"""Return self[index]."""
|
|
464
|
+
...
|
|
465
|
+
|
|
466
|
+
@override
|
|
467
|
+
def __getitem__(self, idx: SupportsIndex | slice):
|
|
468
|
+
if isinstance(idx, slice):
|
|
469
|
+
# Return a plain list of values (non-reactive slice)
|
|
470
|
+
start, stop, step = idx.indices(len(self))
|
|
471
|
+
return [self._signals[i].read() for i in range(start, stop, step)]
|
|
472
|
+
return self._signals[idx].read()
|
|
473
|
+
|
|
474
|
+
@overload
|
|
475
|
+
def __setitem__(self, key: SupportsIndex, value: T1, /) -> None:
|
|
476
|
+
"""Set self[key] to value."""
|
|
477
|
+
...
|
|
478
|
+
|
|
479
|
+
@overload
|
|
480
|
+
def __setitem__(self, key: slice, value: Iterable[T1], /) -> None:
|
|
481
|
+
"""Set self[key] to value."""
|
|
482
|
+
...
|
|
483
|
+
|
|
484
|
+
@override
|
|
485
|
+
def __setitem__(self, key: SupportsIndex | slice, value: T1 | Iterable[T1]):
|
|
486
|
+
if isinstance(key, slice):
|
|
487
|
+
value = cast(Iterable[T1], value)
|
|
488
|
+
replacement_seq = list(value)
|
|
489
|
+
start, stop, step = key.indices(len(self))
|
|
490
|
+
target_indices = list(range(start, stop, step))
|
|
491
|
+
|
|
492
|
+
if len(replacement_seq) == len(target_indices):
|
|
493
|
+
wrapped = [reactive(v) for v in replacement_seq]
|
|
494
|
+
super().__setitem__(key, wrapped)
|
|
495
|
+
for i, v in zip(target_indices, wrapped, strict=True):
|
|
496
|
+
self._signals[i].write(v)
|
|
497
|
+
return
|
|
498
|
+
|
|
499
|
+
super().__setitem__(key, replacement_seq)
|
|
500
|
+
self._signals = [Signal(reactive(v)) for v in super().__iter__()]
|
|
501
|
+
self._bump_structure()
|
|
502
|
+
return
|
|
503
|
+
# normal index
|
|
504
|
+
value = cast(T1, value)
|
|
505
|
+
v = reactive(value)
|
|
506
|
+
super().__setitem__(key, v)
|
|
507
|
+
self._signals[key].write(v)
|
|
508
|
+
|
|
509
|
+
@override
|
|
510
|
+
def __delitem__(self, idx: SupportsIndex | slice):
|
|
511
|
+
if isinstance(idx, slice):
|
|
512
|
+
super().__delitem__(idx)
|
|
513
|
+
self._signals = [Signal(v) for v in super().__iter__()]
|
|
514
|
+
self._bump_structure()
|
|
515
|
+
return
|
|
516
|
+
super().__delitem__(idx)
|
|
517
|
+
del self._signals[idx]
|
|
518
|
+
self._bump_structure()
|
|
519
|
+
|
|
520
|
+
# ---- structural operations ----
|
|
521
|
+
@override
|
|
522
|
+
def append(self, value: T1) -> None:
|
|
523
|
+
v = reactive(value)
|
|
524
|
+
super().append(v)
|
|
525
|
+
self._signals.append(Signal(v))
|
|
526
|
+
self._bump_structure()
|
|
527
|
+
|
|
528
|
+
@override
|
|
529
|
+
def extend(self, values: Iterable[T1]) -> None:
|
|
530
|
+
any_added = False
|
|
531
|
+
for v in values:
|
|
532
|
+
vv = reactive(v)
|
|
533
|
+
super().append(vv)
|
|
534
|
+
self._signals.append(Signal(vv))
|
|
535
|
+
any_added = True
|
|
536
|
+
if any_added:
|
|
537
|
+
self._bump_structure()
|
|
538
|
+
|
|
539
|
+
@override
|
|
540
|
+
def insert(self, index: SupportsIndex, value: T1) -> None:
|
|
541
|
+
v = reactive(value)
|
|
542
|
+
super().insert(index, v)
|
|
543
|
+
self._signals.insert(index, Signal(v))
|
|
544
|
+
self._bump_structure()
|
|
545
|
+
|
|
546
|
+
@override
|
|
547
|
+
def pop(self, index: SupportsIndex = -1):
|
|
548
|
+
val = super().pop(index)
|
|
549
|
+
del self._signals[index]
|
|
550
|
+
self._bump_structure()
|
|
551
|
+
return val
|
|
552
|
+
|
|
553
|
+
def unwrap(self) -> list[_Any]:
|
|
554
|
+
"""Return a plain list while subscribing to element signals.
|
|
555
|
+
|
|
556
|
+
Returns:
|
|
557
|
+
A plain list with all reactive containers recursively unwrapped.
|
|
558
|
+
"""
|
|
559
|
+
self._structure()
|
|
560
|
+
return [unwrap(self[i]) for i in range(len(self._signals))]
|
|
561
|
+
|
|
562
|
+
@override
|
|
563
|
+
def remove(self, value: _Any) -> None:
|
|
564
|
+
idx = super().index(value)
|
|
565
|
+
self.pop(idx)
|
|
566
|
+
|
|
567
|
+
@override
|
|
568
|
+
def clear(self) -> None:
|
|
569
|
+
super().clear()
|
|
570
|
+
self._signals.clear()
|
|
571
|
+
self._bump_structure()
|
|
572
|
+
|
|
573
|
+
@override
|
|
574
|
+
def reverse(self) -> None:
|
|
575
|
+
super().reverse()
|
|
576
|
+
self._signals.reverse()
|
|
577
|
+
self._bump_structure()
|
|
578
|
+
|
|
579
|
+
@overload
|
|
580
|
+
def sort(
|
|
581
|
+
self: ReactiveList[SupportsRichComparisonT],
|
|
582
|
+
*,
|
|
583
|
+
key: None = None,
|
|
584
|
+
reverse: bool = False,
|
|
585
|
+
) -> None:
|
|
586
|
+
"""
|
|
587
|
+
Sort the list in ascending order and return None.
|
|
588
|
+
|
|
589
|
+
The sort is in-place (i.e. the list itself is modified) and stable (i.e. the
|
|
590
|
+
order of two equal elements is maintained).
|
|
591
|
+
|
|
592
|
+
If a key function is given, apply it once to each list item and sort them,
|
|
593
|
+
ascending or descending, according to their function values.
|
|
594
|
+
|
|
595
|
+
The reverse flag can be set to sort in descending order.
|
|
596
|
+
"""
|
|
597
|
+
...
|
|
598
|
+
|
|
599
|
+
@overload
|
|
600
|
+
def sort(
|
|
601
|
+
self, *, key: Callable[[T1], SupportsRichComparison], reverse: bool = False
|
|
602
|
+
) -> None:
|
|
603
|
+
"""
|
|
604
|
+
Sort the list in ascending order and return None.
|
|
605
|
+
|
|
606
|
+
The sort is in-place (i.e. the list itself is modified) and stable (i.e. the
|
|
607
|
+
order of two equal elements is maintained).
|
|
608
|
+
|
|
609
|
+
If a key function is given, apply it once to each list item and sort them,
|
|
610
|
+
ascending or descending, according to their function values.
|
|
611
|
+
|
|
612
|
+
The reverse flag can be set to sort in descending order.
|
|
613
|
+
"""
|
|
614
|
+
...
|
|
615
|
+
|
|
616
|
+
@override
|
|
617
|
+
def sort(
|
|
618
|
+
self,
|
|
619
|
+
key: Callable[[T1], SupportsRichComparison] | None = None,
|
|
620
|
+
reverse: bool = False,
|
|
621
|
+
) -> None:
|
|
622
|
+
# To preserve per-index subscriptions, we have to reorder signals to match
|
|
623
|
+
# new order. We'll compute the permutation by sorting indices based on
|
|
624
|
+
# current values.
|
|
625
|
+
current = list(super().__iter__())
|
|
626
|
+
idxs = list(range(len(current)))
|
|
627
|
+
|
|
628
|
+
# Create a key that uses the same key as provided to sort, but applied to value.
|
|
629
|
+
def key_for_index(i: int):
|
|
630
|
+
v = current[i]
|
|
631
|
+
return key(v) if callable(key) else v
|
|
632
|
+
|
|
633
|
+
# Apply sort to underlying list
|
|
634
|
+
idxs.sort(key=key_for_index, reverse=reverse) # pyright: ignore[reportCallIssue, reportArgumentType]
|
|
635
|
+
# Reorder signals to match
|
|
636
|
+
self._signals = [self._signals[i] for i in idxs]
|
|
637
|
+
self._bump_structure()
|
|
638
|
+
|
|
639
|
+
# Make len() and iteration reactive to structural changes
|
|
640
|
+
@override
|
|
641
|
+
def __len__(self) -> int:
|
|
642
|
+
self._structure.read()
|
|
643
|
+
return super().__len__()
|
|
644
|
+
|
|
645
|
+
@override
|
|
646
|
+
def __iter__(self) -> Iterator[T1]:
|
|
647
|
+
self._structure.read()
|
|
648
|
+
for sig in self._signals:
|
|
649
|
+
yield sig.read()
|
|
650
|
+
|
|
651
|
+
def __copy__(self):
|
|
652
|
+
result = type(self)()
|
|
653
|
+
for value in super().__iter__():
|
|
654
|
+
result.append(copy.copy(value))
|
|
655
|
+
return result
|
|
656
|
+
|
|
657
|
+
def __deepcopy__(self, memo: dict[int, _Any]):
|
|
658
|
+
if id(self) in memo:
|
|
659
|
+
return memo[id(self)]
|
|
660
|
+
result = type(self)()
|
|
661
|
+
memo[id(self)] = result
|
|
662
|
+
for value in super().__iter__():
|
|
663
|
+
result.append(copy.deepcopy(value, memo))
|
|
664
|
+
return result
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
class ReactiveSet(set[T1]):
|
|
668
|
+
"""A set with per-element membership reactivity.
|
|
669
|
+
|
|
670
|
+
`x in s` reads a membership Signal for element `x`. Mutations update
|
|
671
|
+
membership Signals for affected elements. Iteration subscribes to
|
|
672
|
+
membership signals for all elements.
|
|
673
|
+
|
|
674
|
+
Args:
|
|
675
|
+
initial: Initial elements to populate the set.
|
|
676
|
+
|
|
677
|
+
Example:
|
|
678
|
+
|
|
679
|
+
```python
|
|
680
|
+
tags = ReactiveSet({"python", "react"})
|
|
681
|
+
print("python" in tags) # True (registers dependency)
|
|
682
|
+
tags.add("typescript") # Updates membership signal
|
|
683
|
+
tags.unwrap() # {"python", "react", "typescript"}
|
|
684
|
+
```
|
|
685
|
+
"""
|
|
686
|
+
|
|
687
|
+
__slots__ = ("_signals",) # pyright: ignore[reportUnannotatedClassAttribute]
|
|
688
|
+
|
|
689
|
+
def __init__(self, initial: Iterable[T1] | None = None) -> None:
|
|
690
|
+
super().__init__()
|
|
691
|
+
self._signals: dict[T1, Signal[bool]] = {}
|
|
692
|
+
if initial:
|
|
693
|
+
for v in initial:
|
|
694
|
+
vv = reactive(v)
|
|
695
|
+
super().add(vv)
|
|
696
|
+
self._signals[vv] = Signal(True)
|
|
697
|
+
|
|
698
|
+
# same as dict, set.__contains__ defines the argument as `object`, which is not correct
|
|
699
|
+
@override
|
|
700
|
+
def __contains__(self, element: T1) -> bool: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
701
|
+
sig = self._signals.get(element)
|
|
702
|
+
if sig is None:
|
|
703
|
+
present = set.__contains__(self, element)
|
|
704
|
+
self._signals[element] = Signal(bool(present))
|
|
705
|
+
sig = self._signals[element]
|
|
706
|
+
return bool(sig.read())
|
|
707
|
+
|
|
708
|
+
@override
|
|
709
|
+
def __iter__(self) -> Iterator[T1]:
|
|
710
|
+
# Subscribe to membership signals and return present elements
|
|
711
|
+
present = [elem for elem, sig in self._signals.items() if sig.read()]
|
|
712
|
+
return iter(present)
|
|
713
|
+
|
|
714
|
+
@override
|
|
715
|
+
def add(self, element: T1) -> None:
|
|
716
|
+
element = reactive(element)
|
|
717
|
+
super().add(element)
|
|
718
|
+
sig = self._signals.get(element)
|
|
719
|
+
if sig is None:
|
|
720
|
+
self._signals[element] = Signal(True)
|
|
721
|
+
else:
|
|
722
|
+
sig.write(True)
|
|
723
|
+
|
|
724
|
+
@override
|
|
725
|
+
def discard(self, element: T1) -> None:
|
|
726
|
+
element = reactive(element)
|
|
727
|
+
if element in self:
|
|
728
|
+
super().discard(element)
|
|
729
|
+
sig = self._signals.get(element)
|
|
730
|
+
if sig is None:
|
|
731
|
+
self._signals[element] = Signal(False)
|
|
732
|
+
else:
|
|
733
|
+
sig.write(False)
|
|
734
|
+
|
|
735
|
+
@override
|
|
736
|
+
def remove(self, element: T1) -> None:
|
|
737
|
+
if element not in self:
|
|
738
|
+
raise KeyError(element)
|
|
739
|
+
self.discard(element)
|
|
740
|
+
|
|
741
|
+
@override
|
|
742
|
+
def clear(self) -> None:
|
|
743
|
+
for v in list(self):
|
|
744
|
+
self.discard(v)
|
|
745
|
+
|
|
746
|
+
@override
|
|
747
|
+
def update(self, *others: Iterable[T1]) -> None:
|
|
748
|
+
for it in others:
|
|
749
|
+
for v in it:
|
|
750
|
+
self.add(v)
|
|
751
|
+
|
|
752
|
+
@override
|
|
753
|
+
def difference_update(self, *others: Iterable[T1]) -> None:
|
|
754
|
+
to_remove: set[T1] = set()
|
|
755
|
+
for it in others:
|
|
756
|
+
for v in it:
|
|
757
|
+
if v in self:
|
|
758
|
+
to_remove.add(v)
|
|
759
|
+
for v in to_remove:
|
|
760
|
+
self.discard(v)
|
|
761
|
+
|
|
762
|
+
def unwrap(self) -> set[_Any]:
|
|
763
|
+
"""Return a plain set while subscribing to membership signals.
|
|
764
|
+
|
|
765
|
+
Returns:
|
|
766
|
+
A plain set with all reactive containers recursively unwrapped.
|
|
767
|
+
"""
|
|
768
|
+
result: set[_Any] = set()
|
|
769
|
+
for value in set.__iter__(self):
|
|
770
|
+
_ = value in self
|
|
771
|
+
result.add(unwrap(value))
|
|
772
|
+
return result
|
|
773
|
+
|
|
774
|
+
def __copy__(self):
|
|
775
|
+
return type(self)(list(set.__iter__(self))) # pyright: ignore[reportUnknownArgumentType]
|
|
776
|
+
|
|
777
|
+
def __deepcopy__(self, memo: dict[int, _Any]):
|
|
778
|
+
if id(self) in memo:
|
|
779
|
+
return memo[id(self)]
|
|
780
|
+
result = type(self)()
|
|
781
|
+
memo[id(self)] = result
|
|
782
|
+
for value in set.__iter__(self):
|
|
783
|
+
result.add(copy.deepcopy(cast(T1, value), memo))
|
|
784
|
+
return result
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
# ---- Reactive dataclass support ----
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
# Fallback storage for signal instances on objects that cannot hold attributes
|
|
791
|
+
# (e.g., slotted dataclasses). Keys are object ids; entries are cleaned up via
|
|
792
|
+
# a weakref finalizer when possible. This avoids requiring objects to be hashable.
|
|
793
|
+
_INSTANCE_SIGNAL_STORE_BY_ID: dict[int, dict[str, Signal[_Any]]] = {}
|
|
794
|
+
_INSTANCE_STORE_WEAKREFS: dict[int, weakref.ref[_Any]] = {}
|
|
795
|
+
|
|
796
|
+
# Cache mapping original dataclass type -> generated reactive dataclass subclass
|
|
797
|
+
_REACTIVE_DATACLASS_CACHE: dict[type, type] = {}
|
|
798
|
+
|
|
799
|
+
# Track objects currently initializing via dataclass __init__ or reactive upgrade
|
|
800
|
+
_INITIALIZING_OBJECT_IDS: set[int] = set()
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _copy_dataclass_params(parent: type) -> dict[str, _Any]:
|
|
804
|
+
params = getattr(parent, "__dataclass_params__", None)
|
|
805
|
+
if params is None:
|
|
806
|
+
return {}
|
|
807
|
+
copied: dict[str, _Any] = {}
|
|
808
|
+
for key in (
|
|
809
|
+
"init",
|
|
810
|
+
"repr",
|
|
811
|
+
"eq",
|
|
812
|
+
"order",
|
|
813
|
+
"unsafe_hash",
|
|
814
|
+
"frozen",
|
|
815
|
+
"match_args",
|
|
816
|
+
"kw_only",
|
|
817
|
+
"slots",
|
|
818
|
+
"weakref_slot",
|
|
819
|
+
):
|
|
820
|
+
if hasattr(params, key):
|
|
821
|
+
copied[key] = getattr(params, key)
|
|
822
|
+
return copied
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _get_reactive_dataclass_class(parent: type) -> type:
|
|
826
|
+
# Already reactive?
|
|
827
|
+
if getattr(parent, "__is_reactive_dataclass__", False):
|
|
828
|
+
return parent
|
|
829
|
+
cached = _REACTIVE_DATACLASS_CACHE.get(parent)
|
|
830
|
+
if cached is not None:
|
|
831
|
+
return cached
|
|
832
|
+
if not is_dataclass(parent):
|
|
833
|
+
raise TypeError("_get_reactive_dataclass_class expects a dataclass type")
|
|
834
|
+
|
|
835
|
+
subclass_name = f"Reactive{parent.__name__}"
|
|
836
|
+
subclass = type(
|
|
837
|
+
subclass_name,
|
|
838
|
+
(parent,),
|
|
839
|
+
{
|
|
840
|
+
"__module__": parent.__module__,
|
|
841
|
+
"__doc__": getattr(parent, "__doc__", None),
|
|
842
|
+
},
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
# Mirror parent dataclass parameters when generating dataclass on subclass
|
|
846
|
+
dc_kwargs = _copy_dataclass_params(parent)
|
|
847
|
+
reactive_subclass = reactive_dataclass(subclass, **dc_kwargs) # type: ignore[arg-type]
|
|
848
|
+
reactive_subclass.__is_reactive_dataclass__ = True
|
|
849
|
+
reactive_subclass.__reactive_base__ = parent
|
|
850
|
+
|
|
851
|
+
# Hide InitVar attributes on instances by shadowing with a descriptor that raises
|
|
852
|
+
class _HiddenInitVar:
|
|
853
|
+
def __get__(self, obj: _Any, objtype: type[_Any] | None = None):
|
|
854
|
+
raise AttributeError
|
|
855
|
+
|
|
856
|
+
parent_annotations = getattr(parent, "__annotations__", {}) or {}
|
|
857
|
+
for _name, _anno in parent_annotations.items():
|
|
858
|
+
# Detect dataclasses.InitVar annotations (e.g., InitVar[int])
|
|
859
|
+
try:
|
|
860
|
+
if isinstance(_anno, _DC_InitVar):
|
|
861
|
+
setattr(reactive_subclass, _name, _HiddenInitVar())
|
|
862
|
+
except Exception:
|
|
863
|
+
pass
|
|
864
|
+
|
|
865
|
+
# Wrap __init__ to allow field assignment during construction even if frozen
|
|
866
|
+
original_init = getattr(reactive_subclass, "__init__", None)
|
|
867
|
+
if callable(original_init):
|
|
868
|
+
|
|
869
|
+
def _wrapped_init(self: _Any, *args: _Any, **kwargs: _Any):
|
|
870
|
+
_INITIALIZING_OBJECT_IDS.add(id(self))
|
|
871
|
+
try:
|
|
872
|
+
return original_init(self, *args, **kwargs)
|
|
873
|
+
finally:
|
|
874
|
+
_INITIALIZING_OBJECT_IDS.discard(id(self))
|
|
875
|
+
|
|
876
|
+
reactive_subclass.__init__ = _wrapped_init # pyright: ignore[reportAttributeAccessIssue]
|
|
877
|
+
|
|
878
|
+
_REACTIVE_DATACLASS_CACHE[parent] = reactive_subclass
|
|
879
|
+
return reactive_subclass
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
class ReactiveProperty(Generic[T1]):
|
|
883
|
+
"""Unified reactive descriptor used for State fields and dataclass fields."""
|
|
884
|
+
|
|
885
|
+
name: str | None
|
|
886
|
+
private_name: str | None
|
|
887
|
+
owner_name: str | None
|
|
888
|
+
default: T1 | _Any
|
|
889
|
+
|
|
890
|
+
def __init__(self, name: str | None = None, default: T1 | None = _MISSING):
|
|
891
|
+
self.name = name
|
|
892
|
+
self.private_name = None
|
|
893
|
+
self.owner_name = None
|
|
894
|
+
self.default = reactive(default) if default is not _MISSING else _MISSING
|
|
895
|
+
|
|
896
|
+
def __set_name__(self, owner: type[_Any], name: str):
|
|
897
|
+
self.name = self.name or name
|
|
898
|
+
self.private_name = f"__signal_{self.name}"
|
|
899
|
+
self.owner_name = getattr(owner, "__name__", owner.__class__.__name__)
|
|
900
|
+
|
|
901
|
+
def _get_signal(self, obj: _Any) -> Signal[T1]:
|
|
902
|
+
priv = cast(str, self.private_name)
|
|
903
|
+
# Try fast path: attribute on the instance
|
|
904
|
+
try:
|
|
905
|
+
sig = getattr(obj, priv)
|
|
906
|
+
except AttributeError:
|
|
907
|
+
sig = None
|
|
908
|
+
|
|
909
|
+
# Fallback store for slotted instances (no __dict__) using id(obj)
|
|
910
|
+
if sig is None:
|
|
911
|
+
per_obj = _INSTANCE_SIGNAL_STORE_BY_ID.get(id(obj))
|
|
912
|
+
if per_obj is not None:
|
|
913
|
+
sig = per_obj.get(priv)
|
|
914
|
+
|
|
915
|
+
if sig is None:
|
|
916
|
+
init_value = None if self.default is _MISSING else self.default
|
|
917
|
+
sig = Signal(init_value, name=f"{self.owner_name}.{self.name}")
|
|
918
|
+
# Try to attach to the instance; if that fails (e.g., __slots__), use fallback store
|
|
919
|
+
try:
|
|
920
|
+
setattr(obj, priv, sig)
|
|
921
|
+
except Exception:
|
|
922
|
+
obj_id = id(obj)
|
|
923
|
+
mapping = _INSTANCE_SIGNAL_STORE_BY_ID.get(obj_id)
|
|
924
|
+
if mapping is None:
|
|
925
|
+
mapping = {}
|
|
926
|
+
_INSTANCE_SIGNAL_STORE_BY_ID[obj_id] = mapping
|
|
927
|
+
# Install a weakref to clean up when object is GC'd
|
|
928
|
+
try:
|
|
929
|
+
_INSTANCE_STORE_WEAKREFS[obj_id] = weakref.ref(
|
|
930
|
+
obj,
|
|
931
|
+
lambda _r, oid=obj_id: (
|
|
932
|
+
_INSTANCE_SIGNAL_STORE_BY_ID.pop(oid, None),
|
|
933
|
+
_INSTANCE_STORE_WEAKREFS.pop(oid, None),
|
|
934
|
+
),
|
|
935
|
+
)
|
|
936
|
+
except TypeError:
|
|
937
|
+
# Object not weakref-able; best effort leak-free by reusing id slot if recreated
|
|
938
|
+
pass
|
|
939
|
+
mapping[priv] = sig
|
|
940
|
+
return cast(Signal[T1], sig)
|
|
941
|
+
|
|
942
|
+
def __get__(self, obj: _Any, objtype: type[_Any] | None = None) -> T1:
|
|
943
|
+
if obj is None:
|
|
944
|
+
return self # pyright: ignore[reportReturnType]
|
|
945
|
+
# If there is no signal yet and there was no default, mirror normal attribute error
|
|
946
|
+
priv = cast(str, self.private_name)
|
|
947
|
+
sig = getattr(obj, priv, None)
|
|
948
|
+
if sig is None and self.default is _MISSING:
|
|
949
|
+
owner = self.owner_name or obj.__class__.__name__
|
|
950
|
+
raise AttributeError(
|
|
951
|
+
f"Reactive property '{owner}.{self.name}' accessed before initialization"
|
|
952
|
+
)
|
|
953
|
+
return self._get_signal(obj).read()
|
|
954
|
+
|
|
955
|
+
def __set__(self, obj: _Any, value: T1) -> None:
|
|
956
|
+
sig = self._get_signal(obj)
|
|
957
|
+
value = reactive(value)
|
|
958
|
+
sig.write(value)
|
|
959
|
+
|
|
960
|
+
# Helper for State.properties() discovery
|
|
961
|
+
def get_signal(self, obj: _Any) -> Signal[_Any]:
|
|
962
|
+
return self._get_signal(obj)
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
class DataclassReactiveProperty(ReactiveProperty[T1]):
|
|
966
|
+
"""Reactive descriptor for dataclass fields with frozen enforcement."""
|
|
967
|
+
|
|
968
|
+
def __init__(self, name: str | None = None, default: T1 | None = _MISSING):
|
|
969
|
+
super().__init__(name, default)
|
|
970
|
+
self.owner_cls: type | None = None
|
|
971
|
+
|
|
972
|
+
@override
|
|
973
|
+
def __set_name__(self, owner: type[_Any], name: str):
|
|
974
|
+
super().__set_name__(owner, name)
|
|
975
|
+
self.owner_cls = owner
|
|
976
|
+
|
|
977
|
+
@override
|
|
978
|
+
def __set__(self, obj: _Any, value: T1) -> None:
|
|
979
|
+
# Enforce frozen dataclasses semantics
|
|
980
|
+
owner = self.owner_cls or obj.__class__
|
|
981
|
+
params = getattr(owner, "__dataclass_params__", None)
|
|
982
|
+
if (
|
|
983
|
+
params is not None
|
|
984
|
+
and getattr(params, "frozen", False)
|
|
985
|
+
and id(obj) not in _INITIALIZING_OBJECT_IDS
|
|
986
|
+
):
|
|
987
|
+
# Match dataclasses' message
|
|
988
|
+
raise _DC_FrozenInstanceError(f"cannot assign to field '{self.name}'")
|
|
989
|
+
super().__set__(obj, value)
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
@overload
|
|
993
|
+
def reactive_dataclass(cls: type, /, **dataclass_kwargs: _Any) -> type: ...
|
|
994
|
+
@overload
|
|
995
|
+
def reactive_dataclass(
|
|
996
|
+
**dataclass_kwargs: _Any,
|
|
997
|
+
) -> Callable[[type], type]: ...
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def reactive_dataclass(
|
|
1001
|
+
cls: type | None = None, /, **dataclass_kwargs: _Any
|
|
1002
|
+
) -> Callable[[type], type] | type:
|
|
1003
|
+
"""Decorator to make a dataclass' fields reactive.
|
|
1004
|
+
|
|
1005
|
+
Usage:
|
|
1006
|
+
@reactive_dataclass
|
|
1007
|
+
@dataclass
|
|
1008
|
+
class Model: ...
|
|
1009
|
+
|
|
1010
|
+
Or simply:
|
|
1011
|
+
@reactive_dataclass
|
|
1012
|
+
class Model: ... # will be dataclass()-ed with defaults
|
|
1013
|
+
"""
|
|
1014
|
+
|
|
1015
|
+
def _wrap(
|
|
1016
|
+
cls_param: type,
|
|
1017
|
+
) -> type:
|
|
1018
|
+
# ensure it's a dataclass
|
|
1019
|
+
klass: type = cls_param
|
|
1020
|
+
if not is_dataclass(klass):
|
|
1021
|
+
klass = cast(type, _dc_dataclass(klass, **dataclass_kwargs)) # pyright: ignore[reportUnknownArgumentType]
|
|
1022
|
+
|
|
1023
|
+
# Replace fields with DataclassReactiveProperty descriptors
|
|
1024
|
+
for f in _dc_fields(klass):
|
|
1025
|
+
# Skip ClassVars or InitVars implicitly as dataclasses excludes them from fields()
|
|
1026
|
+
default_val = f.default if f.default is not _DC_MISSING else _MISSING
|
|
1027
|
+
rp = DataclassReactiveProperty(f.name, default_val)
|
|
1028
|
+
setattr(klass, f.name, rp)
|
|
1029
|
+
# When assigning descriptors post-class-creation, __set_name__ is not called automatically
|
|
1030
|
+
rp.__set_name__(klass, f.name)
|
|
1031
|
+
|
|
1032
|
+
return klass
|
|
1033
|
+
|
|
1034
|
+
if cls is None:
|
|
1035
|
+
return _wrap
|
|
1036
|
+
return _wrap(cls)
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
# ---- Auto-wrapping helpers ----
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
@overload
|
|
1043
|
+
def reactive(value: dict[T1, T2]) -> ReactiveDict[T1, T2]: ...
|
|
1044
|
+
@overload
|
|
1045
|
+
def reactive(value: list[T1]) -> ReactiveList[T1]: ...
|
|
1046
|
+
@overload
|
|
1047
|
+
def reactive(value: set[T1]) -> ReactiveSet[T1]: ...
|
|
1048
|
+
@overload
|
|
1049
|
+
def reactive(value: T1) -> T1: ...
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def reactive(value: _Any) -> _Any:
|
|
1053
|
+
"""Wrap built-in collections in their reactive counterparts if not already reactive.
|
|
1054
|
+
|
|
1055
|
+
Converts:
|
|
1056
|
+
- dict -> ReactiveDict
|
|
1057
|
+
- list -> ReactiveList
|
|
1058
|
+
- set -> ReactiveSet
|
|
1059
|
+
- dataclass instance -> reactive dataclass with Signal-backed fields
|
|
1060
|
+
|
|
1061
|
+
Leaves other values (primitives, already-reactive containers) untouched.
|
|
1062
|
+
|
|
1063
|
+
Args:
|
|
1064
|
+
value: The value to make reactive.
|
|
1065
|
+
|
|
1066
|
+
Returns:
|
|
1067
|
+
The reactive version of the value, or the original if already reactive
|
|
1068
|
+
or not a supported collection type.
|
|
1069
|
+
|
|
1070
|
+
Example:
|
|
1071
|
+
|
|
1072
|
+
```python
|
|
1073
|
+
data = reactive({"key": "value"}) # ReactiveDict
|
|
1074
|
+
items = reactive([1, 2, 3]) # ReactiveList
|
|
1075
|
+
tags = reactive({"a", "b"}) # ReactiveSet
|
|
1076
|
+
```
|
|
1077
|
+
"""
|
|
1078
|
+
if isinstance(value, ReactiveDict | ReactiveList | ReactiveSet):
|
|
1079
|
+
return value
|
|
1080
|
+
# Dataclass instance: upgrade to reactive subclass in-place
|
|
1081
|
+
if not isinstance(value, type) and is_dataclass(value):
|
|
1082
|
+
# Already reactive instance?
|
|
1083
|
+
if getattr(type(value), "__is_reactive_dataclass__", False):
|
|
1084
|
+
return value
|
|
1085
|
+
base_cls = cast(type, type(value))
|
|
1086
|
+
reactive_cls = _get_reactive_dataclass_class(base_cls)
|
|
1087
|
+
# Capture current field values
|
|
1088
|
+
field_values: dict[str, _Any] = {}
|
|
1089
|
+
for f in _dc_fields(base_cls): # type: ignore[arg-type]
|
|
1090
|
+
try:
|
|
1091
|
+
field_values[f.name] = getattr(value, f.name)
|
|
1092
|
+
except Exception:
|
|
1093
|
+
field_values[f.name] = None
|
|
1094
|
+
# For dict-backed instances, drop raw attrs to avoid stale shadowing
|
|
1095
|
+
if hasattr(value, "__dict__") and isinstance(value.__dict__, dict):
|
|
1096
|
+
for name in field_values.keys():
|
|
1097
|
+
value.__dict__.pop(name, None)
|
|
1098
|
+
# Swap class
|
|
1099
|
+
value.__class__ = reactive_cls # pyright: ignore[reportAttributeAccessIssue]
|
|
1100
|
+
# Write back via descriptors (handles frozen via object.__setattr__)
|
|
1101
|
+
_INITIALIZING_OBJECT_IDS.add(id(value))
|
|
1102
|
+
try:
|
|
1103
|
+
for name, v in field_values.items():
|
|
1104
|
+
object.__setattr__(value, name, reactive(v))
|
|
1105
|
+
finally:
|
|
1106
|
+
_INITIALIZING_OBJECT_IDS.discard(id(value))
|
|
1107
|
+
return value
|
|
1108
|
+
if isinstance(value, dict):
|
|
1109
|
+
return ReactiveDict(value) # pyright: ignore[reportUnknownArgumentType]
|
|
1110
|
+
if isinstance(value, list):
|
|
1111
|
+
return ReactiveList(value) # pyright: ignore[reportUnknownArgumentType]
|
|
1112
|
+
if isinstance(value, set):
|
|
1113
|
+
return ReactiveSet(value) # pyright: ignore[reportUnknownArgumentType]
|
|
1114
|
+
if isinstance(value, type) and is_dataclass(value):
|
|
1115
|
+
return _get_reactive_dataclass_class(value)
|
|
1116
|
+
return value
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def unwrap(value: _Any, untrack: bool = False) -> _Any:
|
|
1120
|
+
"""Recursively unwrap reactive containers into plain Python values.
|
|
1121
|
+
|
|
1122
|
+
Converts:
|
|
1123
|
+
- Signal/Computed -> their read() value
|
|
1124
|
+
- ReactiveDict -> dict
|
|
1125
|
+
- ReactiveList -> list
|
|
1126
|
+
- ReactiveSet -> set
|
|
1127
|
+
- Other Mapping/Sequence types are recursively unwrapped
|
|
1128
|
+
|
|
1129
|
+
Args:
|
|
1130
|
+
value: The value to unwrap.
|
|
1131
|
+
untrack: If True, don't track dependencies during unwrapping.
|
|
1132
|
+
Defaults to False.
|
|
1133
|
+
|
|
1134
|
+
Returns:
|
|
1135
|
+
A plain Python value with all reactive containers unwrapped.
|
|
1136
|
+
|
|
1137
|
+
Example:
|
|
1138
|
+
|
|
1139
|
+
```python
|
|
1140
|
+
count = Signal(5)
|
|
1141
|
+
data = ReactiveDict({"count": count})
|
|
1142
|
+
unwrap(data) # {"count": 5}
|
|
1143
|
+
```
|
|
1144
|
+
"""
|
|
1145
|
+
|
|
1146
|
+
def _unwrap(v: _Any) -> _Any:
|
|
1147
|
+
if isinstance(v, (Signal, Computed)):
|
|
1148
|
+
return _unwrap(v.unwrap())
|
|
1149
|
+
if isinstance(v, ReactiveDict):
|
|
1150
|
+
return v.unwrap()
|
|
1151
|
+
if isinstance(v, ReactiveList):
|
|
1152
|
+
return v.unwrap()
|
|
1153
|
+
if isinstance(v, ReactiveSet):
|
|
1154
|
+
return v.unwrap()
|
|
1155
|
+
if isinstance(v, Mapping):
|
|
1156
|
+
return {k: _unwrap(val) for k, val in v.items()}
|
|
1157
|
+
if isinstance(v, Sequence) and not isinstance(v, (str, bytes, bytearray)):
|
|
1158
|
+
if isinstance(v, tuple):
|
|
1159
|
+
# Preserve namedtuple types
|
|
1160
|
+
if hasattr(v, "_fields"):
|
|
1161
|
+
return type(v)(*(_unwrap(val) for val in v))
|
|
1162
|
+
else:
|
|
1163
|
+
return tuple(_unwrap(val) for val in v)
|
|
1164
|
+
return [_unwrap(val) for val in v]
|
|
1165
|
+
if isinstance(v, set):
|
|
1166
|
+
return {_unwrap(val) for val in v}
|
|
1167
|
+
return v
|
|
1168
|
+
|
|
1169
|
+
if untrack:
|
|
1170
|
+
with Untrack():
|
|
1171
|
+
return _unwrap(value)
|
|
1172
|
+
return _unwrap(value)
|