robject
Reactive objects for Python. Declare a class the way you would a dataclass, and its attributes become observable. Derived values recompute only when what they read actually changed, side effects re-run automatically, and a burst of writes can be grouped into a single notification.
It's the signals / computed / effect model from modern UI frameworks, packaged as plain Python classes. Pure standard library, no dependencies.
from robject import RObject, computed, effect, batch
class Cart(RObject):
price: float = 0.0
qty: int = 1
@computed
def total(self) -> float:
return self.price * self.qty
cart = Cart(price=2.5)
effect(lambda: print("total is", cart.total)) # prints: total is 2.5
with batch():
cart.price = 3.0
cart.qty = 4
# prints once: total is 12.0
Features
- Reactive fields from ordinary class annotations, with defaults,
required fields,
field(default_factory=...)and inheritance. @computedproperties: lazy, cached and read-only. Dependencies are tracked automatically, including dynamic ones (branches) and ones on other objects. Exceptions are cached too, and cycles are detected.- Effects re-run whenever anything they read changes. They are glitch-free: an effect never sees a half-updated state.
- Subscriptions deliver
Change(obj, name, old, new)events for fields and computed properties. A computed property notifies only when its value really changes. - Batching coalesces writes. Subscribers get one event per attribute (none if the value ends up back where it started), and effects run once.
- Safety rails: an error in one callback doesn't stop the others (the first one is re-raised afterwards), and runaway feedback loops raise instead of hanging.
- Typed (
py.typed), standard library only, Python 3.10+.
Install
From a checkout (the package is not published to PyPI):
python3 -m venv .venv && . .venv/bin/activate
pip install .
Quickstart
from robject import RObject, Change, batch, computed, effect, field, subscribe
class Todo(RObject):
title: str # required
done: bool = False
tags: list = field(default_factory=list) # mutable defaults need a factory
class TodoList(RObject):
items: tuple = ()
@computed
def remaining(self) -> int:
return sum(not t.done for t in self.items)
todos = TodoList(items=(Todo(title="write docs"), Todo(title="ship")))
# Subscribe to specific attributes (or all of them, by passing no names).
unsubscribe = subscribe(todos, lambda ch: print(f"{ch.name}: {ch.old} -> {ch.new}"), "remaining")
todos.items[0].done = True # prints: remaining: 2 -> 1
# Effects track whatever they read, across objects.
watcher = effect(lambda: print("left:", todos.remaining)) # prints: left: 1
with batch(): # delivery waits until the block ends
todos.items[1].done = True # remaining: 1 -> 0 ...
todos.items = todos.items + (Todo(title="celebrate"),) # ... -> 1
# The effect re-runs once and prints: left: 1
# `remaining` ended where it started, so the subscriber prints nothing.
watcher.dispose()
unsubscribe()
Fields are tracked by assignment. Mutating a list in place (todo.tags.append(...))
is invisible, so assign a new value instead (todo.tags = [*todo.tags, "x"]), or use
immutable containers such as tuples.
API overview
Everything below is importable from robject.
| Name | Description |
|---|---|
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. |
field(*, default=..., default_factory=None) |
Explicit field declaration. You need it for mutable defaults: a bare list/dict/set/bytearray default raises ValueError. |
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 ..."). |
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. |
Effect |
Returned by effect. Has .dispose(), .run(), a .runs counter and .disposed. It is also a context manager that disposes on exit. |
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. |
Change |
NamedTuple(obj, name, old, new). For a computed property whose previous evaluation raised, old is None. |
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. |
untracked() |
Context manager. Reads inside it don't become dependencies. |
fields(obj_or_cls) -> tuple[str, ...] |
Field names in declaration order, with base classes first. |
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. |
__version__ |
"0.1.0" |
Semantics worth knowing
- A write that is
==to the current value is a no-op. - Outside a batch, delivery is synchronous: by the time an assignment returns, subscribers and effects have run.
- Subscribers and effects may write to reactive values. Their work is processed
in further rounds of the same flush. If 100 rounds pass without settling, the
flush raises
RuntimeError("reactive updates did not settle ..."). - An effect's own reads are tracked; reads made by callbacks it triggers are not.
- The reactive graph is process-global and not thread-safe. Use it from one thread, such as a UI or event-loop thread.
Development
python3 -m unittest discover -s tests -v
The tests use only unittest, so python3 -m pytest works too if you have pytest.
License
MIT
Metadata
Release files for robject 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| robject-0.1.0.tar.gz | 18.4 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| robject-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 32.1 kB
Release files / robject-0.1.0.tar.gz
| Download URL | robject-0.1.0.tar.gz |
|---|---|
| Size | 18.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ef8774c453c10d759dba96eab45c3163e3b6b58eea125a6e8676877b1b9c2b6e
|
|
BLAKE2b-256 checksum How to use checksums |
38db77d3772cbec330962aa9e9356f088f8fb331c6a46008d774fe382d14e22a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.5
|
Release files / robject-0.1.0-py3-none-any.whl
| Download URL | robject-0.1.0-py3-none-any.whl |
|---|---|
| Size | 13.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ff84c0e12b519c72d2f0f2ddd3a779b42259772604dca4fe88a41745fa0052d0
|
|
BLAKE2b-256 checksum How to use checksums |
2302d78306400a0e9335a9a6ac8c192c9c6fca9a203b7bd952e3fa135ed0b3aa
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.5
|
Security for robject 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.