Metadata-Version: 2.4
Name: robject
Version: 0.1.0
Summary: Reactive objects for Python: observable fields, auto-tracked computed properties, effects, subscriptions and batching.
Author: nehz
License-Expression: MIT
Project-URL: Homepage, https://example.com/robject
Keywords: reactive,observable,signals,computed,state,observer
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# 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.

```python
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.
- **`@computed` properties**: 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):

```bash
python3 -m venv .venv && . .venv/bin/activate
pip install .
```

## Quickstart

```python
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

```bash
python3 -m unittest discover -s tests -v
```

The tests use only `unittest`, so `python3 -m pytest` works too if you have pytest.

## License

MIT
