robject 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.
- robject/__init__.py +37 -0
- robject/_core.py +358 -0
- robject/_object.py +428 -0
- robject/py.typed +0 -0
- robject-0.1.0.dist-info/METADATA +164 -0
- robject-0.1.0.dist-info/RECORD +9 -0
- robject-0.1.0.dist-info/WHEEL +5 -0
- robject-0.1.0.dist-info/licenses/LICENSE +21 -0
- robject-0.1.0.dist-info/top_level.txt +1 -0
robject/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""robject: reactive objects for Python.
|
|
2
|
+
|
|
3
|
+
Declare plain classes whose attributes are observable, derive cached
|
|
4
|
+
``computed`` properties that track their own dependencies, react to changes
|
|
5
|
+
with ``effect`` and ``subscribe``, and group writes with ``batch``.
|
|
6
|
+
|
|
7
|
+
>>> from robject import RObject, computed
|
|
8
|
+
>>> class Cart(RObject):
|
|
9
|
+
... price: float = 0.0
|
|
10
|
+
... qty: int = 1
|
|
11
|
+
... @computed
|
|
12
|
+
... def total(self) -> float:
|
|
13
|
+
... return self.price * self.qty
|
|
14
|
+
>>> cart = Cart(price=2.5, qty=4)
|
|
15
|
+
>>> cart.total
|
|
16
|
+
10.0
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from ._core import Effect, batch, effect, untracked
|
|
20
|
+
from ._object import Change, RObject, computed, field, fields, snapshot, subscribe
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"RObject",
|
|
26
|
+
"field",
|
|
27
|
+
"computed",
|
|
28
|
+
"effect",
|
|
29
|
+
"Effect",
|
|
30
|
+
"batch",
|
|
31
|
+
"untracked",
|
|
32
|
+
"subscribe",
|
|
33
|
+
"Change",
|
|
34
|
+
"fields",
|
|
35
|
+
"snapshot",
|
|
36
|
+
"__version__",
|
|
37
|
+
]
|
robject/_core.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Reactive core: signals, computed nodes, effects, batching and the scheduler.
|
|
2
|
+
|
|
3
|
+
This module knows nothing about :class:`robject.RObject`; it provides the
|
|
4
|
+
dependency-tracking graph that ``RObject`` fields and computed properties are
|
|
5
|
+
built on.
|
|
6
|
+
|
|
7
|
+
Model
|
|
8
|
+
-----
|
|
9
|
+
* A :class:`Signal` holds one value. Reading it while an observer is running
|
|
10
|
+
records a dependency; writing a different value invalidates its observers.
|
|
11
|
+
* A :class:`ComputedNode` is both an observer and a source. It is lazy: it is
|
|
12
|
+
only recomputed when read after one of its dependencies changed. Errors raised
|
|
13
|
+
by its function are cached exactly like values.
|
|
14
|
+
* An :class:`Effect` is an observer that re-runs its function whenever any
|
|
15
|
+
dependency changes.
|
|
16
|
+
* Notifications and effect re-runs are queued and delivered by :func:`flush`,
|
|
17
|
+
which happens immediately after a write, or when the outermost
|
|
18
|
+
:func:`batch` exits.
|
|
19
|
+
|
|
20
|
+
The graph is process-global and not thread-safe: use it from one thread.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import contextlib
|
|
26
|
+
from typing import Any, Callable, Dict, Iterator, List, Optional, Set
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Signal",
|
|
30
|
+
"ComputedNode",
|
|
31
|
+
"Effect",
|
|
32
|
+
"batch",
|
|
33
|
+
"untracked",
|
|
34
|
+
"effect",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
#: Upper bound on flush rounds before we assume effects are feeding each other.
|
|
38
|
+
MAX_FLUSH_ROUNDS = 100
|
|
39
|
+
|
|
40
|
+
_MISSING: Any = object()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def values_equal(old: Any, new: Any) -> bool:
|
|
44
|
+
"""Return True if a write of *new* over *old* is not a change."""
|
|
45
|
+
if old is new:
|
|
46
|
+
return True
|
|
47
|
+
try:
|
|
48
|
+
return bool(old == new)
|
|
49
|
+
except Exception: # pragma: no cover - exotic __eq__ implementations
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _Observer:
|
|
54
|
+
"""Something that reads sources and must be told when they change."""
|
|
55
|
+
|
|
56
|
+
__slots__ = ("sources", "__weakref__")
|
|
57
|
+
|
|
58
|
+
def __init__(self) -> None:
|
|
59
|
+
self.sources: Set["_Source"] = set()
|
|
60
|
+
|
|
61
|
+
def mark_dirty(self) -> None: # pragma: no cover - abstract
|
|
62
|
+
raise NotImplementedError
|
|
63
|
+
|
|
64
|
+
def _unlink(self) -> None:
|
|
65
|
+
for src in self.sources:
|
|
66
|
+
src.observers.discard(self)
|
|
67
|
+
self.sources.clear()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _Source:
|
|
71
|
+
"""Something whose reads can be tracked."""
|
|
72
|
+
|
|
73
|
+
__slots__ = ()
|
|
74
|
+
|
|
75
|
+
observers: Set[_Observer]
|
|
76
|
+
|
|
77
|
+
def _track(self) -> None:
|
|
78
|
+
current = _state.current
|
|
79
|
+
if current is not None:
|
|
80
|
+
current.sources.add(self)
|
|
81
|
+
self.observers.add(current)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _State:
|
|
85
|
+
"""Process-global scheduler state."""
|
|
86
|
+
|
|
87
|
+
def __init__(self) -> None:
|
|
88
|
+
self.current: Optional[_Observer] = None
|
|
89
|
+
self.batch_depth = 0
|
|
90
|
+
self.flushing = False
|
|
91
|
+
# Ordered "sets" (dicts keep insertion order) of pending work.
|
|
92
|
+
self.pending_hooks: Dict[int, Callable[[], None]] = {}
|
|
93
|
+
self.pending_effects: Dict[int, "Effect"] = {}
|
|
94
|
+
|
|
95
|
+
def has_work(self) -> bool:
|
|
96
|
+
return bool(self.pending_hooks or self.pending_effects)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_state = _State()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Signal(_Source):
|
|
103
|
+
"""A single observable value.
|
|
104
|
+
|
|
105
|
+
``on_change`` (if given) is called synchronously on every effective write
|
|
106
|
+
with ``(old, new)``; it is how :class:`robject.RObject` records pending
|
|
107
|
+
attribute notifications.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
__slots__ = ("value", "observers", "on_change")
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
value: Any,
|
|
115
|
+
on_change: Optional[Callable[[Any, Any], None]] = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
self.value = value
|
|
118
|
+
self.observers: Set[_Observer] = set()
|
|
119
|
+
self.on_change = on_change
|
|
120
|
+
|
|
121
|
+
def get(self) -> Any:
|
|
122
|
+
"""Return the value, recording a dependency for the running observer."""
|
|
123
|
+
self._track()
|
|
124
|
+
return self.value
|
|
125
|
+
|
|
126
|
+
def set(self, new: Any) -> None:
|
|
127
|
+
"""Store *new*; if it differs from the current value, propagate."""
|
|
128
|
+
old = self.value
|
|
129
|
+
if values_equal(old, new):
|
|
130
|
+
return
|
|
131
|
+
self.value = new
|
|
132
|
+
if self.on_change is not None:
|
|
133
|
+
self.on_change(old, new)
|
|
134
|
+
for obs in list(self.observers):
|
|
135
|
+
obs.mark_dirty()
|
|
136
|
+
flush()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _Failure:
|
|
140
|
+
"""A cached exception raised by a computed function."""
|
|
141
|
+
|
|
142
|
+
__slots__ = ("error",)
|
|
143
|
+
|
|
144
|
+
def __init__(self, error: BaseException) -> None:
|
|
145
|
+
self.error = error
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ComputedNode(_Observer, _Source):
|
|
149
|
+
"""A lazily evaluated, cached derivation of other sources.
|
|
150
|
+
|
|
151
|
+
``on_invalidate`` (if given) is called when the node becomes dirty; it is
|
|
152
|
+
how :class:`robject.RObject` schedules re-checks of watched computed
|
|
153
|
+
properties.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
__slots__ = ("fn", "observers", "dirty", "computing", "result", "on_invalidate")
|
|
157
|
+
|
|
158
|
+
def __init__(
|
|
159
|
+
self,
|
|
160
|
+
fn: Callable[[], Any],
|
|
161
|
+
on_invalidate: Optional[Callable[[], None]] = None,
|
|
162
|
+
) -> None:
|
|
163
|
+
_Observer.__init__(self)
|
|
164
|
+
self.fn = fn
|
|
165
|
+
self.observers: Set[_Observer] = set()
|
|
166
|
+
self.dirty = True
|
|
167
|
+
self.computing = False
|
|
168
|
+
self.result: Any = _MISSING
|
|
169
|
+
self.on_invalidate = on_invalidate
|
|
170
|
+
|
|
171
|
+
def get(self) -> Any:
|
|
172
|
+
"""Return the (possibly recomputed) value, recording a dependency."""
|
|
173
|
+
self._track()
|
|
174
|
+
return self.peek()
|
|
175
|
+
|
|
176
|
+
def peek(self) -> Any:
|
|
177
|
+
"""Return the value without recording a dependency."""
|
|
178
|
+
if self.dirty:
|
|
179
|
+
self._recompute()
|
|
180
|
+
if isinstance(self.result, _Failure):
|
|
181
|
+
raise self.result.error
|
|
182
|
+
return self.result
|
|
183
|
+
|
|
184
|
+
def _recompute(self) -> None:
|
|
185
|
+
if self.computing:
|
|
186
|
+
raise RuntimeError("cycle detected while evaluating a computed property")
|
|
187
|
+
self._unlink()
|
|
188
|
+
self.computing = True
|
|
189
|
+
prev, _state.current = _state.current, self
|
|
190
|
+
try:
|
|
191
|
+
self.result = self.fn()
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
self.result = _Failure(exc)
|
|
194
|
+
finally:
|
|
195
|
+
_state.current = prev
|
|
196
|
+
self.computing = False
|
|
197
|
+
self.dirty = False
|
|
198
|
+
|
|
199
|
+
def mark_dirty(self) -> None:
|
|
200
|
+
if self.dirty:
|
|
201
|
+
return
|
|
202
|
+
self.dirty = True
|
|
203
|
+
for obs in list(self.observers):
|
|
204
|
+
obs.mark_dirty()
|
|
205
|
+
if self.on_invalidate is not None:
|
|
206
|
+
self.on_invalidate()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class Effect(_Observer):
|
|
210
|
+
"""A side effect that re-runs whenever anything it read changes.
|
|
211
|
+
|
|
212
|
+
Create one with :func:`effect`. Call :meth:`dispose` to stop it.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
__slots__ = ("fn", "disposed", "runs")
|
|
216
|
+
|
|
217
|
+
def __init__(self, fn: Callable[[], Any]) -> None:
|
|
218
|
+
super().__init__()
|
|
219
|
+
self.fn = fn
|
|
220
|
+
self.disposed = False
|
|
221
|
+
#: How many times the effect function has run.
|
|
222
|
+
self.runs = 0
|
|
223
|
+
|
|
224
|
+
def run(self) -> None:
|
|
225
|
+
"""Run the effect function now, re-recording its dependencies."""
|
|
226
|
+
if self.disposed:
|
|
227
|
+
return
|
|
228
|
+
self._unlink()
|
|
229
|
+
prev, _state.current = _state.current, self
|
|
230
|
+
try:
|
|
231
|
+
self.runs += 1
|
|
232
|
+
self.fn()
|
|
233
|
+
finally:
|
|
234
|
+
_state.current = prev
|
|
235
|
+
|
|
236
|
+
def mark_dirty(self) -> None:
|
|
237
|
+
if not self.disposed:
|
|
238
|
+
_state.pending_effects.setdefault(id(self), self)
|
|
239
|
+
|
|
240
|
+
def dispose(self) -> None:
|
|
241
|
+
"""Stop the effect: drop its dependencies and never run it again."""
|
|
242
|
+
self.disposed = True
|
|
243
|
+
self._unlink()
|
|
244
|
+
_state.pending_effects.pop(id(self), None)
|
|
245
|
+
|
|
246
|
+
def __enter__(self) -> "Effect":
|
|
247
|
+
return self
|
|
248
|
+
|
|
249
|
+
def __exit__(self, *exc: object) -> None:
|
|
250
|
+
self.dispose()
|
|
251
|
+
|
|
252
|
+
def __repr__(self) -> str:
|
|
253
|
+
status = "disposed" if self.disposed else f"{len(self.sources)} deps"
|
|
254
|
+
return f"<Effect {getattr(self.fn, '__qualname__', self.fn)!s} ({status})>"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def schedule_hook(key: int, hook: Callable[[], None]) -> None:
|
|
258
|
+
"""Queue *hook* to run during the next flush (deduplicated by *key*)."""
|
|
259
|
+
_state.pending_hooks.setdefault(key, hook)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def flush() -> None:
|
|
263
|
+
"""Deliver pending notifications and re-run dirty effects.
|
|
264
|
+
|
|
265
|
+
Does nothing while inside :func:`batch` or while a flush is already in
|
|
266
|
+
progress (work queued by callbacks is picked up by the running flush).
|
|
267
|
+
Every queued callback runs even if an earlier one raised; the first
|
|
268
|
+
exception is re-raised once the queue has drained.
|
|
269
|
+
"""
|
|
270
|
+
if _state.flushing or _state.batch_depth:
|
|
271
|
+
return
|
|
272
|
+
_state.flushing = True
|
|
273
|
+
# Callbacks must never be tracked as dependencies of whatever observer
|
|
274
|
+
# happened to trigger this flush.
|
|
275
|
+
prev, _state.current = _state.current, None
|
|
276
|
+
errors: List[BaseException] = []
|
|
277
|
+
try:
|
|
278
|
+
rounds = 0
|
|
279
|
+
while _state.has_work():
|
|
280
|
+
rounds += 1
|
|
281
|
+
if rounds > MAX_FLUSH_ROUNDS:
|
|
282
|
+
_state.pending_hooks.clear()
|
|
283
|
+
_state.pending_effects.clear()
|
|
284
|
+
raise RuntimeError(
|
|
285
|
+
f"reactive updates did not settle after {MAX_FLUSH_ROUNDS} "
|
|
286
|
+
"rounds; an effect or subscriber probably writes to "
|
|
287
|
+
"something it depends on"
|
|
288
|
+
)
|
|
289
|
+
hooks = list(_state.pending_hooks.values())
|
|
290
|
+
effects = list(_state.pending_effects.values())
|
|
291
|
+
_state.pending_hooks.clear()
|
|
292
|
+
_state.pending_effects.clear()
|
|
293
|
+
for hook in hooks:
|
|
294
|
+
try:
|
|
295
|
+
hook()
|
|
296
|
+
except Exception as exc:
|
|
297
|
+
errors.append(exc)
|
|
298
|
+
for eff in effects:
|
|
299
|
+
try:
|
|
300
|
+
eff.run()
|
|
301
|
+
except Exception as exc:
|
|
302
|
+
errors.append(exc)
|
|
303
|
+
finally:
|
|
304
|
+
_state.current = prev
|
|
305
|
+
_state.flushing = False
|
|
306
|
+
if errors:
|
|
307
|
+
raise errors[0]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@contextlib.contextmanager
|
|
311
|
+
def batch() -> Iterator[None]:
|
|
312
|
+
"""Group writes so subscribers and effects run once, at the end.
|
|
313
|
+
|
|
314
|
+
Nested batches are allowed; delivery happens when the outermost one exits
|
|
315
|
+
(also when it exits with an exception, so no change is silently lost).
|
|
316
|
+
Usable as a context manager or as a decorator::
|
|
317
|
+
|
|
318
|
+
with batch():
|
|
319
|
+
cart.price = 3
|
|
320
|
+
cart.qty = 2
|
|
321
|
+
"""
|
|
322
|
+
_state.batch_depth += 1
|
|
323
|
+
try:
|
|
324
|
+
yield
|
|
325
|
+
finally:
|
|
326
|
+
_state.batch_depth -= 1
|
|
327
|
+
if _state.batch_depth == 0:
|
|
328
|
+
flush()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@contextlib.contextmanager
|
|
332
|
+
def untracked() -> Iterator[None]:
|
|
333
|
+
"""Read reactive values without recording them as dependencies."""
|
|
334
|
+
prev, _state.current = _state.current, None
|
|
335
|
+
try:
|
|
336
|
+
yield
|
|
337
|
+
finally:
|
|
338
|
+
_state.current = prev
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def effect(fn: Callable[[], Any]) -> Effect:
|
|
342
|
+
"""Run *fn* now and again whenever any reactive value it read changes.
|
|
343
|
+
|
|
344
|
+
Returns the :class:`Effect`; call ``.dispose()`` (or use it as a context
|
|
345
|
+
manager) to stop it. If the first run raises, the effect is disposed and
|
|
346
|
+
the exception propagates. Usable as a decorator::
|
|
347
|
+
|
|
348
|
+
@effect
|
|
349
|
+
def log():
|
|
350
|
+
print(cart.total)
|
|
351
|
+
"""
|
|
352
|
+
eff = Effect(fn)
|
|
353
|
+
try:
|
|
354
|
+
eff.run()
|
|
355
|
+
except BaseException:
|
|
356
|
+
eff.dispose() # the caller never receives it, so it must not linger
|
|
357
|
+
raise
|
|
358
|
+
return eff
|
robject/_object.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""The :class:`RObject` base class and its module-level helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import typing
|
|
7
|
+
from typing import (
|
|
8
|
+
Any,
|
|
9
|
+
Callable,
|
|
10
|
+
ClassVar,
|
|
11
|
+
Dict,
|
|
12
|
+
Generic,
|
|
13
|
+
List,
|
|
14
|
+
NamedTuple,
|
|
15
|
+
Optional,
|
|
16
|
+
Tuple,
|
|
17
|
+
Type,
|
|
18
|
+
TypeVar,
|
|
19
|
+
Union,
|
|
20
|
+
overload,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from ._core import _MISSING, ComputedNode, Signal, schedule_hook, values_equal
|
|
24
|
+
|
|
25
|
+
__all__ = ["RObject", "field", "computed", "Change", "subscribe", "fields", "snapshot"]
|
|
26
|
+
|
|
27
|
+
T = TypeVar("T")
|
|
28
|
+
|
|
29
|
+
_MUTABLE_DEFAULTS = (list, dict, set, bytearray)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Change(NamedTuple):
|
|
33
|
+
"""One delivered change: attribute *name* of *obj* went from *old* to *new*.
|
|
34
|
+
|
|
35
|
+
For a computed property whose previous evaluation raised, *old* is ``None``.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
obj: "RObject"
|
|
39
|
+
name: str
|
|
40
|
+
old: Any
|
|
41
|
+
new: Any
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --------------------------------------------------------------------------
|
|
45
|
+
# Field declarations
|
|
46
|
+
# --------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _FieldSpec:
|
|
50
|
+
"""Declaration-time configuration of one reactive field."""
|
|
51
|
+
|
|
52
|
+
__slots__ = ("name", "default", "default_factory")
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
default: Any = _MISSING,
|
|
57
|
+
default_factory: Optional[Callable[[], Any]] = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
if default is not _MISSING and default_factory is not None:
|
|
60
|
+
raise ValueError("cannot specify both default and default_factory")
|
|
61
|
+
self.name = ""
|
|
62
|
+
self.default = default
|
|
63
|
+
self.default_factory = default_factory
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def required(self) -> bool:
|
|
67
|
+
return self.default is _MISSING and self.default_factory is None
|
|
68
|
+
|
|
69
|
+
def make_default(self) -> Any:
|
|
70
|
+
if self.default_factory is not None:
|
|
71
|
+
return self.default_factory()
|
|
72
|
+
return self.default
|
|
73
|
+
|
|
74
|
+
def __repr__(self) -> str:
|
|
75
|
+
return f"field(name={self.name!r})"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def field(
|
|
79
|
+
*,
|
|
80
|
+
default: Any = _MISSING,
|
|
81
|
+
default_factory: Optional[Callable[[], Any]] = None,
|
|
82
|
+
) -> Any:
|
|
83
|
+
"""Declare a reactive field with an explicit default or default factory.
|
|
84
|
+
|
|
85
|
+
Plain ``name: type = value`` annotations already create fields; use this
|
|
86
|
+
for mutable defaults::
|
|
87
|
+
|
|
88
|
+
class Todo(RObject):
|
|
89
|
+
tags: list = field(default_factory=list)
|
|
90
|
+
"""
|
|
91
|
+
return _FieldSpec(default, default_factory)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class _FieldDescriptor:
|
|
95
|
+
"""Class-level descriptor that routes attribute access to a Signal."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, spec: _FieldSpec) -> None:
|
|
98
|
+
self.spec = spec
|
|
99
|
+
self.name = spec.name
|
|
100
|
+
|
|
101
|
+
def __get__(self, obj: Optional["RObject"], objtype: Any = None) -> Any:
|
|
102
|
+
if obj is None:
|
|
103
|
+
if self.spec.default is not _MISSING:
|
|
104
|
+
return self.spec.default
|
|
105
|
+
raise AttributeError(self.name)
|
|
106
|
+
return _rstate(obj).signals[self.name].get()
|
|
107
|
+
|
|
108
|
+
def __set__(self, obj: "RObject", value: Any) -> None:
|
|
109
|
+
_rstate(obj).signals[self.name].set(value)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class computed(Generic[T]):
|
|
113
|
+
"""Decorator for a cached, dependency-tracked, read-only property.
|
|
114
|
+
|
|
115
|
+
The function runs lazily on first access and again only after a reactive
|
|
116
|
+
value it read has changed. Exceptions are cached and re-raised on access
|
|
117
|
+
until a dependency changes::
|
|
118
|
+
|
|
119
|
+
class Cart(RObject):
|
|
120
|
+
price: float = 0.0
|
|
121
|
+
qty: int = 1
|
|
122
|
+
|
|
123
|
+
@computed
|
|
124
|
+
def total(self) -> float:
|
|
125
|
+
return self.price * self.qty
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(self, fn: Callable[[Any], T]) -> None:
|
|
129
|
+
self.fn = fn
|
|
130
|
+
self.name: str = fn.__name__
|
|
131
|
+
self.__doc__ = fn.__doc__
|
|
132
|
+
|
|
133
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
134
|
+
self.name = name
|
|
135
|
+
|
|
136
|
+
@overload
|
|
137
|
+
def __get__(self, obj: None, objtype: Any = None) -> "computed[T]": ...
|
|
138
|
+
|
|
139
|
+
@overload
|
|
140
|
+
def __get__(self, obj: "RObject", objtype: Any = None) -> T: ...
|
|
141
|
+
|
|
142
|
+
def __get__(self, obj: Optional["RObject"], objtype: Any = None) -> Any:
|
|
143
|
+
if obj is None:
|
|
144
|
+
return self
|
|
145
|
+
return _rstate(obj).node(self.name).get()
|
|
146
|
+
|
|
147
|
+
def __set__(self, obj: "RObject", value: Any) -> None:
|
|
148
|
+
raise AttributeError(f"computed property {self.name!r} is read-only")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# --------------------------------------------------------------------------
|
|
152
|
+
# Per-instance state
|
|
153
|
+
# --------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class _Subscription:
|
|
157
|
+
__slots__ = ("callback", "names", "active")
|
|
158
|
+
|
|
159
|
+
def __init__(self, callback: Callable[[Change], Any], names: Optional[frozenset]) -> None:
|
|
160
|
+
self.callback = callback
|
|
161
|
+
self.names = names
|
|
162
|
+
self.active = True
|
|
163
|
+
|
|
164
|
+
def wants(self, name: str) -> bool:
|
|
165
|
+
return self.names is None or name in self.names
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class _RState:
|
|
169
|
+
"""Reactive bookkeeping attached to one RObject instance."""
|
|
170
|
+
|
|
171
|
+
__slots__ = (
|
|
172
|
+
"obj",
|
|
173
|
+
"signals",
|
|
174
|
+
"nodes",
|
|
175
|
+
"subs",
|
|
176
|
+
"pending_fields",
|
|
177
|
+
"pending_computed",
|
|
178
|
+
"baselines",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def __init__(self, obj: "RObject") -> None:
|
|
182
|
+
self.obj = obj
|
|
183
|
+
self.signals: Dict[str, Signal] = {}
|
|
184
|
+
self.nodes: Dict[str, ComputedNode] = {}
|
|
185
|
+
self.subs: List[_Subscription] = []
|
|
186
|
+
self.pending_fields: Dict[str, Any] = {} # name -> value before first change
|
|
187
|
+
self.pending_computed: Dict[str, None] = {} # set of names
|
|
188
|
+
self.baselines: Dict[str, Any] = {} # computed name -> last delivered value
|
|
189
|
+
|
|
190
|
+
# -- construction -----------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def add_signal(self, name: str, value: Any) -> None:
|
|
193
|
+
self.signals[name] = Signal(value, on_change=lambda old, new: self._field_changed(name, old))
|
|
194
|
+
|
|
195
|
+
def node(self, name: str) -> ComputedNode:
|
|
196
|
+
node = self.nodes.get(name)
|
|
197
|
+
if node is None:
|
|
198
|
+
prop: computed[Any] = type(self.obj).__robject_computed__[name]
|
|
199
|
+
obj = self.obj
|
|
200
|
+
node = ComputedNode(lambda: prop.fn(obj), on_invalidate=lambda: self._computed_invalidated(name))
|
|
201
|
+
self.nodes[name] = node
|
|
202
|
+
return node
|
|
203
|
+
|
|
204
|
+
# -- change recording ---------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def watched(self, name: str) -> bool:
|
|
207
|
+
return any(sub.wants(name) for sub in self.subs)
|
|
208
|
+
|
|
209
|
+
def _field_changed(self, name: str, old: Any) -> None:
|
|
210
|
+
if not self.watched(name):
|
|
211
|
+
return
|
|
212
|
+
self.pending_fields.setdefault(name, old)
|
|
213
|
+
schedule_hook(id(self), self.deliver)
|
|
214
|
+
|
|
215
|
+
def _computed_invalidated(self, name: str) -> None:
|
|
216
|
+
if not self.watched(name):
|
|
217
|
+
return
|
|
218
|
+
self.pending_computed[name] = None
|
|
219
|
+
schedule_hook(id(self), self.deliver)
|
|
220
|
+
|
|
221
|
+
# -- delivery -----------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
def deliver(self) -> None:
|
|
224
|
+
"""Turn pending records into Change events and call subscribers."""
|
|
225
|
+
pending_fields, self.pending_fields = self.pending_fields, {}
|
|
226
|
+
pending_computed, self.pending_computed = self.pending_computed, {}
|
|
227
|
+
errors: List[BaseException] = []
|
|
228
|
+
changes: List[Change] = []
|
|
229
|
+
|
|
230
|
+
for name, old in pending_fields.items():
|
|
231
|
+
new = self.signals[name].value
|
|
232
|
+
if not values_equal(old, new): # coalesced writes may cancel out
|
|
233
|
+
changes.append(Change(self.obj, name, old, new))
|
|
234
|
+
|
|
235
|
+
# Declaration order, not invalidation order, so delivery is predictable.
|
|
236
|
+
for name in type(self.obj).__robject_computed__:
|
|
237
|
+
if name not in pending_computed or not self.watched(name):
|
|
238
|
+
continue
|
|
239
|
+
old = self.baselines.get(name, _MISSING)
|
|
240
|
+
try:
|
|
241
|
+
new = self.nodes[name].peek()
|
|
242
|
+
except Exception as exc:
|
|
243
|
+
errors.append(exc)
|
|
244
|
+
continue
|
|
245
|
+
self.baselines[name] = new
|
|
246
|
+
if old is _MISSING or not values_equal(old, new):
|
|
247
|
+
changes.append(Change(self.obj, name, None if old is _MISSING else old, new))
|
|
248
|
+
|
|
249
|
+
for change in changes:
|
|
250
|
+
for sub in list(self.subs):
|
|
251
|
+
if sub.active and sub.wants(change.name):
|
|
252
|
+
try:
|
|
253
|
+
sub.callback(change)
|
|
254
|
+
except Exception as exc:
|
|
255
|
+
errors.append(exc)
|
|
256
|
+
if errors:
|
|
257
|
+
raise errors[0]
|
|
258
|
+
|
|
259
|
+
def subscribe(self, callback: Callable[[Change], Any], names: Optional[frozenset]) -> Callable[[], None]:
|
|
260
|
+
sub = _Subscription(callback, names)
|
|
261
|
+
newly_watched = [
|
|
262
|
+
name
|
|
263
|
+
for name in type(self.obj).__robject_computed__
|
|
264
|
+
if sub.wants(name) and not self.watched(name)
|
|
265
|
+
]
|
|
266
|
+
self.subs.append(sub)
|
|
267
|
+
for name in newly_watched:
|
|
268
|
+
try:
|
|
269
|
+
self.baselines[name] = self.node(name).peek()
|
|
270
|
+
except Exception:
|
|
271
|
+
self.baselines.pop(name, None)
|
|
272
|
+
|
|
273
|
+
def unsubscribe() -> None:
|
|
274
|
+
if sub.active:
|
|
275
|
+
sub.active = False
|
|
276
|
+
self.subs.remove(sub)
|
|
277
|
+
|
|
278
|
+
return unsubscribe
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _rstate(obj: "RObject") -> _RState:
|
|
282
|
+
try:
|
|
283
|
+
return obj.__dict__["_robject_state"]
|
|
284
|
+
except KeyError:
|
|
285
|
+
raise RuntimeError(
|
|
286
|
+
f"{type(obj).__name__} instance is not initialised; "
|
|
287
|
+
"did a custom __init__ forget to call super().__init__()?"
|
|
288
|
+
) from None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# --------------------------------------------------------------------------
|
|
292
|
+
# RObject
|
|
293
|
+
# --------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _is_classvar(annotation: Any) -> bool:
|
|
297
|
+
if annotation is ClassVar or typing.get_origin(annotation) is ClassVar:
|
|
298
|
+
return True
|
|
299
|
+
return isinstance(annotation, str) and annotation.startswith(("ClassVar", "typing.ClassVar"))
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _own_annotations(cls: type) -> Dict[str, Any]:
|
|
303
|
+
try:
|
|
304
|
+
return dict(inspect.get_annotations(cls))
|
|
305
|
+
except Exception: # unresolved forward references on newer Pythons
|
|
306
|
+
return dict(cls.__dict__.get("__annotations__", {}))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class RObject:
|
|
310
|
+
"""Base class for objects with reactive (observable) attributes.
|
|
311
|
+
|
|
312
|
+
Every annotated class attribute becomes a reactive field; methods decorated
|
|
313
|
+
with :class:`computed` become cached derived properties. Construct
|
|
314
|
+
instances with keyword arguments::
|
|
315
|
+
|
|
316
|
+
class Point(RObject):
|
|
317
|
+
x: float = 0.0
|
|
318
|
+
y: float = 0.0
|
|
319
|
+
|
|
320
|
+
p = Point(x=1.0)
|
|
321
|
+
|
|
322
|
+
Subclasses that define ``__init__`` must call ``super().__init__(**kwargs)``.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
__robject_fields__: ClassVar[Dict[str, _FieldSpec]] = {}
|
|
326
|
+
__robject_computed__: ClassVar[Dict[str, computed[Any]]] = {}
|
|
327
|
+
|
|
328
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
329
|
+
super().__init_subclass__(**kwargs)
|
|
330
|
+
specs: Dict[str, _FieldSpec] = {}
|
|
331
|
+
props: Dict[str, computed[Any]] = {}
|
|
332
|
+
for base in reversed(cls.__mro__[1:]):
|
|
333
|
+
specs.update(getattr(base, "__robject_fields__", {}))
|
|
334
|
+
props.update(getattr(base, "__robject_computed__", {}))
|
|
335
|
+
|
|
336
|
+
annotations = _own_annotations(cls)
|
|
337
|
+
own = cls.__dict__
|
|
338
|
+
for name, value in own.items():
|
|
339
|
+
if isinstance(value, computed):
|
|
340
|
+
props[name] = value
|
|
341
|
+
specs.pop(name, None)
|
|
342
|
+
elif name in specs and name not in annotations:
|
|
343
|
+
# Redefining an inherited field's default: `x = 5`.
|
|
344
|
+
annotations[name] = None
|
|
345
|
+
for name, annotation in annotations.items():
|
|
346
|
+
if _is_classvar(annotation) or isinstance(own.get(name), computed):
|
|
347
|
+
continue
|
|
348
|
+
value = own.get(name, _MISSING)
|
|
349
|
+
if isinstance(value, _FieldSpec):
|
|
350
|
+
spec = value
|
|
351
|
+
elif value is _MISSING and name in specs:
|
|
352
|
+
continue # re-annotated without a new default: keep inherited
|
|
353
|
+
else:
|
|
354
|
+
if isinstance(value, _MUTABLE_DEFAULTS):
|
|
355
|
+
raise ValueError(
|
|
356
|
+
f"mutable default {type(value).__name__} for field {name!r} "
|
|
357
|
+
"is not allowed: use field(default_factory=...)"
|
|
358
|
+
)
|
|
359
|
+
spec = _FieldSpec(value)
|
|
360
|
+
spec.name = name
|
|
361
|
+
specs[name] = spec
|
|
362
|
+
props.pop(name, None)
|
|
363
|
+
setattr(cls, name, _FieldDescriptor(spec))
|
|
364
|
+
|
|
365
|
+
cls.__robject_fields__ = specs
|
|
366
|
+
cls.__robject_computed__ = props
|
|
367
|
+
|
|
368
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
369
|
+
state = _RState(self)
|
|
370
|
+
self.__dict__["_robject_state"] = state
|
|
371
|
+
specs = type(self).__robject_fields__
|
|
372
|
+
unknown = sorted(set(kwargs) - set(specs))
|
|
373
|
+
if unknown:
|
|
374
|
+
raise TypeError(f"{type(self).__name__}() got unexpected field(s): {', '.join(unknown)}")
|
|
375
|
+
missing = [n for n, s in specs.items() if s.required and n not in kwargs]
|
|
376
|
+
if missing:
|
|
377
|
+
raise TypeError(f"{type(self).__name__}() missing required field(s): {', '.join(missing)}")
|
|
378
|
+
for name, spec in specs.items():
|
|
379
|
+
state.add_signal(name, kwargs[name] if name in kwargs else spec.make_default())
|
|
380
|
+
|
|
381
|
+
def __repr__(self) -> str:
|
|
382
|
+
state = self.__dict__.get("_robject_state")
|
|
383
|
+
if state is None:
|
|
384
|
+
return f"<uninitialised {type(self).__name__}>"
|
|
385
|
+
parts = ", ".join(f"{n}={s.value!r}" for n, s in state.signals.items())
|
|
386
|
+
return f"{type(self).__name__}({parts})"
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# --------------------------------------------------------------------------
|
|
390
|
+
# Module-level helpers (kept off the class so they never clash with fields)
|
|
391
|
+
# --------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def subscribe(obj: RObject, callback: Callable[[Change], Any], *names: str) -> Callable[[], None]:
|
|
395
|
+
"""Call ``callback(change)`` whenever an attribute of *obj* changes.
|
|
396
|
+
|
|
397
|
+
With no *names*, every field and computed property is watched; otherwise
|
|
398
|
+
only the named ones. Watched computed properties are re-evaluated eagerly
|
|
399
|
+
after their dependencies change and notify only if their value differs.
|
|
400
|
+
Inside :func:`batch`, repeated writes to one attribute are coalesced into a
|
|
401
|
+
single change (and dropped if the final value equals the original).
|
|
402
|
+
|
|
403
|
+
Returns a function that cancels the subscription.
|
|
404
|
+
"""
|
|
405
|
+
cls = type(obj)
|
|
406
|
+
known = set(cls.__robject_fields__) | set(cls.__robject_computed__)
|
|
407
|
+
bad = [n for n in names if n not in known]
|
|
408
|
+
if bad:
|
|
409
|
+
raise ValueError(f"{cls.__name__} has no reactive attribute(s): {', '.join(bad)}")
|
|
410
|
+
return _rstate(obj).subscribe(callback, frozenset(names) if names else None)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def fields(obj_or_cls: Union[RObject, Type[RObject]]) -> Tuple[str, ...]:
|
|
414
|
+
"""Return the names of the reactive fields, in declaration order."""
|
|
415
|
+
cls = obj_or_cls if isinstance(obj_or_cls, type) else type(obj_or_cls)
|
|
416
|
+
return tuple(cls.__robject_fields__)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def snapshot(obj: RObject, *, include_computed: bool = False) -> Dict[str, Any]:
|
|
420
|
+
"""Return a plain ``dict`` of the field values (and optionally computed ones).
|
|
421
|
+
|
|
422
|
+
Reads go through the normal attribute path, so calling this inside an
|
|
423
|
+
effect makes the effect depend on every included attribute.
|
|
424
|
+
"""
|
|
425
|
+
names = list(type(obj).__robject_fields__)
|
|
426
|
+
if include_computed:
|
|
427
|
+
names += list(type(obj).__robject_computed__)
|
|
428
|
+
return {name: getattr(obj, name) for name in names}
|
robject/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: robject
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reactive objects for Python: observable fields, auto-tracked computed properties, effects, subscriptions and batching.
|
|
5
|
+
Author: nehz
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://example.com/robject
|
|
8
|
+
Keywords: reactive,observable,signals,computed,state,observer
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# robject
|
|
26
|
+
|
|
27
|
+
**Reactive objects for Python.** Declare a class the way you would a dataclass,
|
|
28
|
+
and its attributes become observable. Derived values recompute only when what
|
|
29
|
+
they read actually changed, side effects re-run automatically, and a burst of
|
|
30
|
+
writes can be grouped into a single notification.
|
|
31
|
+
|
|
32
|
+
It's the signals / computed / effect model from modern UI frameworks, packaged
|
|
33
|
+
as plain Python classes. Pure standard library, no dependencies.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from robject import RObject, computed, effect, batch
|
|
37
|
+
|
|
38
|
+
class Cart(RObject):
|
|
39
|
+
price: float = 0.0
|
|
40
|
+
qty: int = 1
|
|
41
|
+
|
|
42
|
+
@computed
|
|
43
|
+
def total(self) -> float:
|
|
44
|
+
return self.price * self.qty
|
|
45
|
+
|
|
46
|
+
cart = Cart(price=2.5)
|
|
47
|
+
effect(lambda: print("total is", cart.total)) # prints: total is 2.5
|
|
48
|
+
|
|
49
|
+
with batch():
|
|
50
|
+
cart.price = 3.0
|
|
51
|
+
cart.qty = 4
|
|
52
|
+
# prints once: total is 12.0
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Features
|
|
56
|
+
|
|
57
|
+
- **Reactive fields** from ordinary class annotations, with defaults,
|
|
58
|
+
required fields, `field(default_factory=...)` and inheritance.
|
|
59
|
+
- **`@computed` properties**: lazy, cached and read-only. Dependencies are tracked
|
|
60
|
+
automatically, including dynamic ones (branches) and ones on other objects.
|
|
61
|
+
Exceptions are cached too, and cycles are detected.
|
|
62
|
+
- **Effects** re-run whenever anything they read changes. They are glitch-free:
|
|
63
|
+
an effect never sees a half-updated state.
|
|
64
|
+
- **Subscriptions** deliver `Change(obj, name, old, new)` events for fields and
|
|
65
|
+
computed properties. A computed property notifies only when its value really
|
|
66
|
+
changes.
|
|
67
|
+
- **Batching** coalesces writes. Subscribers get one event per attribute (none
|
|
68
|
+
if the value ends up back where it started), and effects run once.
|
|
69
|
+
- **Safety rails**: an error in one callback doesn't stop the others (the first
|
|
70
|
+
one is re-raised afterwards), and runaway feedback loops raise instead of hanging.
|
|
71
|
+
- Typed (`py.typed`), standard library only, Python 3.10+.
|
|
72
|
+
|
|
73
|
+
## Install
|
|
74
|
+
|
|
75
|
+
From a checkout (the package is not published to PyPI):
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
python3 -m venv .venv && . .venv/bin/activate
|
|
79
|
+
pip install .
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Quickstart
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from robject import RObject, Change, batch, computed, effect, field, subscribe
|
|
86
|
+
|
|
87
|
+
class Todo(RObject):
|
|
88
|
+
title: str # required
|
|
89
|
+
done: bool = False
|
|
90
|
+
tags: list = field(default_factory=list) # mutable defaults need a factory
|
|
91
|
+
|
|
92
|
+
class TodoList(RObject):
|
|
93
|
+
items: tuple = ()
|
|
94
|
+
|
|
95
|
+
@computed
|
|
96
|
+
def remaining(self) -> int:
|
|
97
|
+
return sum(not t.done for t in self.items)
|
|
98
|
+
|
|
99
|
+
todos = TodoList(items=(Todo(title="write docs"), Todo(title="ship")))
|
|
100
|
+
|
|
101
|
+
# Subscribe to specific attributes (or all of them, by passing no names).
|
|
102
|
+
unsubscribe = subscribe(todos, lambda ch: print(f"{ch.name}: {ch.old} -> {ch.new}"), "remaining")
|
|
103
|
+
|
|
104
|
+
todos.items[0].done = True # prints: remaining: 2 -> 1
|
|
105
|
+
|
|
106
|
+
# Effects track whatever they read, across objects.
|
|
107
|
+
watcher = effect(lambda: print("left:", todos.remaining)) # prints: left: 1
|
|
108
|
+
|
|
109
|
+
with batch(): # delivery waits until the block ends
|
|
110
|
+
todos.items[1].done = True # remaining: 1 -> 0 ...
|
|
111
|
+
todos.items = todos.items + (Todo(title="celebrate"),) # ... -> 1
|
|
112
|
+
# The effect re-runs once and prints: left: 1
|
|
113
|
+
# `remaining` ended where it started, so the subscriber prints nothing.
|
|
114
|
+
|
|
115
|
+
watcher.dispose()
|
|
116
|
+
unsubscribe()
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Fields are tracked by assignment. Mutating a list in place (`todo.tags.append(...)`)
|
|
120
|
+
is invisible, so assign a new value instead (`todo.tags = [*todo.tags, "x"]`), or use
|
|
121
|
+
immutable containers such as tuples.
|
|
122
|
+
|
|
123
|
+
## API overview
|
|
124
|
+
|
|
125
|
+
Everything below is importable from `robject`.
|
|
126
|
+
|
|
127
|
+
| Name | Description |
|
|
128
|
+
| --- | --- |
|
|
129
|
+
| `RObject` | Base class. Annotated class attributes become reactive fields (except `ClassVar`). Instances are constructed with keyword arguments only. Unknown names and missing required fields raise `TypeError`. A subclass that defines `__init__` must call `super().__init__(**kwargs)`. `repr()` shows the field values. |
|
|
130
|
+
| `field(*, default=..., default_factory=None)` | Explicit field declaration. You need it for mutable defaults: a bare `list`/`dict`/`set`/`bytearray` default raises `ValueError`. |
|
|
131
|
+
| `computed` | Decorator for a lazy, cached, read-only property whose dependencies are tracked. Assigning to it raises `AttributeError`. Exceptions are cached until a dependency changes, and self-reference raises `RuntimeError("cycle detected ...")`. |
|
|
132
|
+
| `effect(fn) -> Effect` | Runs `fn()` now and again after any reactive value it read changes. If the first run raises, the effect is disposed and the exception propagates. Also works as a decorator. |
|
|
133
|
+
| `Effect` | Returned by `effect`. Has `.dispose()`, `.run()`, a `.runs` counter and `.disposed`. It is also a context manager that disposes on exit. |
|
|
134
|
+
| `subscribe(obj, callback, *names) -> unsubscribe` | Calls `callback(Change)` when the named fields or computed properties of `obj` change. With no names it watches all of them. Unknown names raise `ValueError`. Watched computed properties are re-evaluated eagerly. The returned function cancels the subscription and is idempotent. |
|
|
135
|
+
| `Change` | `NamedTuple(obj, name, old, new)`. For a computed property whose previous evaluation raised, `old` is `None`. |
|
|
136
|
+
| `batch()` | Context manager (and decorator) that defers delivery until the outermost batch exits, including on exception. Reads inside a batch already see the new values. |
|
|
137
|
+
| `untracked()` | Context manager. Reads inside it don't become dependencies. |
|
|
138
|
+
| `fields(obj_or_cls) -> tuple[str, ...]` | Field names in declaration order, with base classes first. |
|
|
139
|
+
| `snapshot(obj, *, include_computed=False) -> dict` | Plain `dict` of the current values. Reading it inside an effect makes the effect depend on every included attribute. |
|
|
140
|
+
| `__version__` | `"0.1.0"` |
|
|
141
|
+
|
|
142
|
+
### Semantics worth knowing
|
|
143
|
+
|
|
144
|
+
- A write that is `==` to the current value is a no-op.
|
|
145
|
+
- Outside a batch, delivery is synchronous: by the time an assignment returns,
|
|
146
|
+
subscribers and effects have run.
|
|
147
|
+
- Subscribers and effects may write to reactive values. Their work is processed
|
|
148
|
+
in further rounds of the same flush. If 100 rounds pass without settling, the
|
|
149
|
+
flush raises `RuntimeError("reactive updates did not settle ...")`.
|
|
150
|
+
- An effect's own reads are tracked; reads made by callbacks it triggers are not.
|
|
151
|
+
- The reactive graph is process-global and **not thread-safe**. Use it from one
|
|
152
|
+
thread, such as a UI or event-loop thread.
|
|
153
|
+
|
|
154
|
+
## Development
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
python3 -m unittest discover -s tests -v
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The tests use only `unittest`, so `python3 -m pytest` works too if you have pytest.
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
robject/__init__.py,sha256=x05mKUDF38fT6EkYWzNMIROYSAkPlBuzLIksM7_Pz1s,878
|
|
2
|
+
robject/_core.py,sha256=75EXFSLPhhVQ_YZMfKpyGkkFuZf98Uy83hmpN9eMDQ0,10805
|
|
3
|
+
robject/_object.py,sha256=fNe1CA71J45t2OTp85_K_yRkjXWsIihbyg7Utm0Xs1Y,15111
|
|
4
|
+
robject/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
robject-0.1.0.dist-info/licenses/LICENSE,sha256=pAfYREEW9GAy7cnK20OXjDn7ofJahYny9GCuIZQTDAA,1061
|
|
6
|
+
robject-0.1.0.dist-info/METADATA,sha256=XhZCL2wHsAVHIU3LRwTiUWC8yqDgudsYfP3-amrDCdY,7482
|
|
7
|
+
robject-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
robject-0.1.0.dist-info/top_level.txt,sha256=xO157N6oXUedXt7mIGC7c6yG-zibrMIcUG49pG2tVbI,8
|
|
9
|
+
robject-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nehz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
robject
|