Metadata-Version: 2.4
Name: nervio
Version: 0.2.0
Summary: Thread-safe, zero-dependency fine-grained reactivity for Python: signal / computed / effect with glitch-free pull propagation, resilient error recovery, and per-task/thread scoping.
Project-URL: Homepage, https://github.com/ElEscribanoSilente/Nervio
Project-URL: Repository, https://github.com/ElEscribanoSilente/Nervio
Project-URL: Issues, https://github.com/ElEscribanoSilente/Nervio/issues
Project-URL: Changelog, https://github.com/ElEscribanoSilente/Nervio/blob/main/CHANGELOG.md
Author: ElEscribanoSilente
License: MIT
License-File: LICENSE
Keywords: asyncio,computed,effect,fine-grained,reactive,reactivity,signals,state,thread-safe,zero-dependency
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
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
Classifier: Typing :: Typed
Requires-Python: >=3.8
Description-Content-Type: text/markdown

# nervio

[![CI](https://github.com/ElEscribanoSilente/Nervio/actions/workflows/ci.yml/badge.svg)](https://github.com/ElEscribanoSilente/Nervio/actions/workflows/ci.yml)

Reactividad de grano fino para Python, **sin dependencias y segura entre hilos**. `signal` / `computed` / `effect` con tracking automático de dependencias, propagación *pull glitch-free* (estados `clean/check/dirty`, en la línea de SolidJS y [Reactively](https://github.com/milomg/reactively)), recuperación de errores auditada y ámbitos por tarea `asyncio`/hilo.

- **Tracking automático**: lo que leas dentro de un `computed`/`effect` se vuelve dependencia. Sin `subscribe` manual.
- **Perezoso y cacheado**: un `computed` solo se recalcula cuando lo lees *y* una dependencia real cambió.
- **Glitch-free**: en diamantes (A→B, A→C, B+C→D), D se recalcula **una vez** y nunca ve estados intermedios inconsistentes.
- **Seguro entre hilos**: un mismo grafo puede compartirse entre hilos (lock global reentrante; `update()` atómico), y el estado de scheduling va por ejecutor (tarea `asyncio` o hilo), nunca heredado.
- **Sin fugas**: los `computed` abandonados se recolectan (refs fuente→observador débiles); los `effect` viven hasta su `dispose()`.
- **~580 líneas, un solo módulo, tipado (PEP 561).**

```bash
pip install nervio
```

## En 30 segundos

```python
from nervio import signal, computed, effect, batch

precio   = signal(100)
cantidad = signal(2)
total    = computed(lambda: precio.value * cantidad.value)   # perezoso

effect(lambda: print(f"Total: {total.value}"))   # imprime "Total: 200" ya mismo

precio.value = 150          # -> "Total: 300"
with batch():               # varias escrituras, un solo recálculo
    precio.value = 200
    cantidad.value = 3      # -> "Total: 600" (una vez)
```

## API

| | |
|---|---|
| `signal(v, *, equals=None)` | Estado reactivo. `.value` (lee y suscribe), `.value = x` / `.set(x)` / `.update(fn)` (escribe), `.peek()` (lee sin suscribir). `equals=False` propaga siempre (útil para objetos mutados in-place). |
| `computed(fn, *, equals=None)` | Valor derivado perezoso y cacheado. `.value` / `.peek()`. Sin referencias (tuyas ni de nodos aguas abajo) **se recolecta solo**: no hace falta disponerlo. |
| `effect(fn) -> dispose` | Corre `fn` ahora y ante cada cambio de sus dependencias. Queda **anclado internamente**: sigue corriendo aunque descartes el handle; solo `dispose()` (o el del `root` que lo contiene) lo libera. |
| `batch()` | Context manager: agrupa escrituras; los effects corren una sola vez al salir. |
| `untrack(fn)` | Ejecuta `fn` leyendo señales **sin** registrarlas como dependencias. |
| `on_cleanup(fn)` | Dentro de un effect/root: registra limpieza que corre antes del próximo re-run y al disponer. |
| `root() -> dispose` | Context manager que agrupa effects/computeds creados dentro para disponerlos juntos. |

### Dependencias dinámicas, limpieza, alcance

```python
from nervio import signal, effect, on_cleanup, root

modo = signal("a"); a = signal(1); b = signal(2)

# Las dependencias se recalculan en cada corrida: aquí se suscribe a `a` O a `b`, nunca a ambas.
effect(lambda: print(a.value if modo.value == "a" else b.value))

# Limpieza por corrida (cerrar sockets, cancelar timers, etc.)
def watcher():
    conn = abrir(modo.value)
    on_cleanup(conn.close)        # corre antes del próximo run y al disponer
effect(watcher)

# Disponer un subgrafo completo de una vez
with root() as dispose:
    effect(lambda: ...)
    effect(lambda: ...)
# ...más tarde:
dispose()
```

## Cómo funciona

Escribir una señal solo **marca** el subgrafo (fase barata: observadores directos → `dirty`, el resto → `check`). Nada se recalcula ahí. El recálculo ocurre al **leer** un `computed` o al vaciar la cola de `effect`s: un nodo en `check` pregunta a sus fuentes y solo se recalcula si alguna resultó `dirty`; si ninguna cambió, vuelve a `clean` sin trabajo. Eso da el mínimo de recálculos y consistencia sin *glitches* dentro de cada ejecutor. Todo el estado vivo —observador, dueño, cola de `effect`s y profundidad de `batch`— vive en un **ámbito por ejecutor** (la tarea `asyncio` actual o, en su defecto, el hilo), que **nunca se hereda**: una tarea creada con `create_task` dentro de un `batch` o de un `effect` estrena ámbito limpio (nada de batches heredados que nunca cierran ni suscripciones fantasma). Las mutaciones del grafo (escrituras, tracking, recomputos, flush, disposición) se serializan con un **lock global reentrante**, así varios hilos pueden compartir un mismo grafo sin corromperlo. Las referencias tienen dirección: dependiente→fuente es **fuerte** (lo que un effect vivo necesita no puede morir) y fuente→observador es **débil** con compactación perezosa (un computed abandonado se recolecta solo); los effects se anclan en un registro interno hasta su `dispose()`.

## Errores

Si un `effect` —o un `computed` del que depende— lanza una excepción, el error se **propaga** a quien disparó el recálculo (la escritura `.value` / `.set`, o la salida del `batch`), pero el grafo **no queda corrupto**: el `effect` se **recupera** y vuelve a ejecutarse en el siguiente cambio de una dependencia, y las dependencias registradas antes del fallo **se conservan** (ninguna escritura futura queda muda). Un `effect` que falla tampoco impide que corran los demás de la misma cola. Esto vale también para `BaseException` (`KeyboardInterrupt`, `CancelledError`): se re-lanzan de inmediato, pero el effect no muere.

- Si la **primera corrida** de un `effect(fn)` lanza, el effect **no queda registrado** (se libera y la excepción se propaga): como el llamador aún no recibió su `dispose`, un nodo a medio suscribir sería imposible de liberar.
- Un **`on_cleanup` que lanza** tampoco envenena: corren **todos** los cleanups del run (el roto no frena a los demás ni queda re-registrado), el primer error se propaga una sola vez, y el effect se recupera en el siguiente cambio. Si ocurre durante un `dispose()`, el desmontaje **se completa igual** (nada queda a medio disponer) y el error se re-lanza al final.
- Si **varios** effects fallan en el mismo flush, se re-lanza **el primero**; los demás corren igual pero sus errores no se reportan. En multihilo, el error aflora en el **hilo escritor** que disparó el flush, aunque el effect lo haya creado otro hilo.
- **Trade-off**: un `computed` que lanza *mientras lo evalúa un effect* se resetea a `clean`; una **lectura directa** de ese `computed` —en la ventana entre el fallo y el siguiente cambio— devuelve su último valor bueno en vez de reintentar. Tras cualquier cambio de una fuente recalcula fresco. (Un `computed` leído directo, sin `effect` de por medio, conserva el reintento en cada lectura.)

## Límites (a propósito)

- **Hilos: seguro, pero serializado.** Un mismo grafo puede mutarse desde varios hilos: un lock global reentrante serializa escrituras, tracking, recomputos y disposición (`update()` es leer-modificar-escribir **atómico**: sin updates perdidos). El costo: el lock se sostiene mientras corre tu código (`fn` de effects/computeds, `equals`, cleanups) — **no bloquees ahí esperando a otro hilo que también use señales** (deadlock clásico), ni hagas I/O lenta (frena el grafo entero). `peek()` de señales es lock-free (puede ver el valor inmediatamente anterior a una escritura en curso).
- **`batch` difiere effects; no es una transacción con aislamiento.** Otros hilos pueden *leer* los valores intermedios entre las escrituras de un batch ajeno (la garantía glitch-free es por-ejecutor, no inter-hilo). Un effect ya ensuciado por un batch abierto corre cuando ese batch cierre, aunque otro hilo vuelva a escribir sus señales: mantén los batch **cortos y síncronos**. Si el ejecutor dueño muere con el batch abierto (hilo o tarea abandonados), sus effects pendientes los **adopta** la siguiente escritura — no se pierden.
- **El batch pertenece al ejecutor exacto que lo abre.** Las tareas creadas dentro no lo heredan, y el código corrido en *otras* tareas del mismo hilo —p.ej. dentro de un `asyncio.run(...)`— tampoco lo ve. *(Async: un `await` dentro de un `batch()` difiere los effects de **esa** tarea hasta salir; las demás tareas no se ven afectadas.)* Con frameworks async sin tareas `asyncio` (trio, curio), el ámbito es el **hilo**: todas las tareas de ese hilo comparten batch y cola.
- **Profundidad sin límite en grafo caliente**: marcado, resolución y recuperación son **iterativos** — cadenas de miles de computeds funcionan para escrituras y actualizaciones. Solo la **primera** evaluación de una cadena *fría* anida llamadas de usuario y queda limitada por el stack de Python (~200 niveles): caliéntala leyendo los computeds conforme la construyes.
- **Ciclos: detectados y rechazados.** Un computed/effect que participa (directa o indirectamente) en su **propio recálculo** lanza `RuntimeError` con mensaje claro; los ciclos por *ping-pong* de effects que se escriben señales entre sí los corta un tope duro de re-cálculos. No diseñes ciclos: el error existe para encontrarlos, no es una semántica. *(En cadenas frías muy profundas un ciclo puede aflorar como `RecursionError` antes de alcanzar la detección.)*
- **Memoria**: un `effect` nunca dispuesto vive (y retiene su clausura) para siempre — guarda el handle de `dispose` o créalo dentro de un `root()`. Los `computed` abandonados se recolectan solos. *(En intérpretes sin recolección por conteo de referencias —PyPy— la liberación puede diferirse hasta el siguiente ciclo de GC; las refs muertas se compactan solas en la siguiente escritura.)*
- Para arrays de numpy u objetos con `==` no booleano, pasa `equals=` propio o `equals=False`.

## Licencia

MIT.
