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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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