nervio 0.2.0__tar.gz
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.
- nervio-0.2.0/.gitignore +9 -0
- nervio-0.2.0/CHANGELOG.md +48 -0
- nervio-0.2.0/LICENSE +21 -0
- nervio-0.2.0/PKG-INFO +125 -0
- nervio-0.2.0/README.md +96 -0
- nervio-0.2.0/nervio/__init__.py +575 -0
- nervio-0.2.0/nervio/py.typed +0 -0
- nervio-0.2.0/pyproject.toml +45 -0
- nervio-0.2.0/test_nervio.py +875 -0
nervio-0.2.0/.gitignore
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 — 2026-06-12
|
|
4
|
+
|
|
5
|
+
Primera versión publicada. Respecto al 0.1.0 interno, el núcleo reactivo es el
|
|
6
|
+
mismo (propagación *pull* glitch-free de tres estados, estilo Reactively), pero
|
|
7
|
+
la robustez se reconstruyó entera tras varias rondas de auditoría adversarial
|
|
8
|
+
(14 bugs reales corregidos, suite de 9 → 41 tests-ancla, verificada en CPython
|
|
9
|
+
3.8 y 3.13).
|
|
10
|
+
|
|
11
|
+
### Seguridad entre hilos
|
|
12
|
+
- Un mismo grafo puede mutarse desde varios hilos: lock global reentrante
|
|
13
|
+
serializa escrituras, tracking, recomputos, flush y disposición.
|
|
14
|
+
- `Signal.update()` es leer-modificar-escribir **atómico** (sin updates perdidos).
|
|
15
|
+
- El estado vivo (observador, dueño, cola de effects, batch) vive en un ámbito
|
|
16
|
+
por ejecutor (tarea `asyncio` o hilo) que **nunca se hereda**: una tarea creada
|
|
17
|
+
dentro de un `batch`/`effect` estrena ámbito limpio.
|
|
18
|
+
- Effects huérfanos (su hilo/tarea murió con la cola pendiente) los **adopta**
|
|
19
|
+
la siguiente escritura: ninguna reacción se pierde.
|
|
20
|
+
|
|
21
|
+
### Recuperación de errores
|
|
22
|
+
- Un `effect` que lanza se recupera: vuelve a ejecutarse en el siguiente cambio,
|
|
23
|
+
sin abortar a los demás de la cola; las dependencias registradas antes del
|
|
24
|
+
fallo se conservan. `BaseException` (`KeyboardInterrupt`, `CancelledError`)
|
|
25
|
+
se re-lanza sin matar el nodo.
|
|
26
|
+
- Un `on_cleanup` que lanza no envenena: corren todos los cleanups, el error se
|
|
27
|
+
propaga una vez y el nodo queda consistente; durante `dispose()` el desmontaje
|
|
28
|
+
se completa igual.
|
|
29
|
+
- Si la primera corrida de `effect()` lanza, el effect no queda registrado.
|
|
30
|
+
|
|
31
|
+
### Grafo
|
|
32
|
+
- Marcado, resolución y recuperación **iterativos**: cadenas de miles de
|
|
33
|
+
computeds sin `RecursionError` (la primera evaluación de una cadena fría sigue
|
|
34
|
+
limitada por el stack de Python; caliéntala leyendo al construir).
|
|
35
|
+
- **Ciclos detectados**: participar en el propio recálculo lanza `RuntimeError`
|
|
36
|
+
claro; el ping-pong entre effects lo corta un tope duro de re-cálculos.
|
|
37
|
+
- **Ciclo de vida sin fugas**: refs fuente→observador débiles (un `computed`
|
|
38
|
+
abandonado se recolecta solo); los `effect` quedan anclados hasta su
|
|
39
|
+
`dispose()` aunque se descarte el handle.
|
|
40
|
+
|
|
41
|
+
### Empaquetado
|
|
42
|
+
- Conversión a package con `py.typed` (PEP 561): los tipos son consumibles.
|
|
43
|
+
- Metadata en inglés, URLs del repositorio, CI con matriz 3.8–3.13.
|
|
44
|
+
|
|
45
|
+
## 0.1.0
|
|
46
|
+
|
|
47
|
+
Versión interna inicial: `signal` / `computed` / `effect` / `batch` / `untrack`
|
|
48
|
+
/ `root` / `on_cleanup` con tracking automático y propagación pull glitch-free.
|
nervio-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ElEscribanoSilente
|
|
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.
|
nervio-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nervio
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
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.
|
|
5
|
+
Project-URL: Homepage, https://github.com/ElEscribanoSilente/Nervio
|
|
6
|
+
Project-URL: Repository, https://github.com/ElEscribanoSilente/Nervio
|
|
7
|
+
Project-URL: Issues, https://github.com/ElEscribanoSilente/Nervio/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/ElEscribanoSilente/Nervio/blob/main/CHANGELOG.md
|
|
9
|
+
Author: ElEscribanoSilente
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: asyncio,computed,effect,fine-grained,reactive,reactivity,signals,state,thread-safe,zero-dependency
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.8
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# nervio
|
|
31
|
+
|
|
32
|
+
[](https://github.com/ElEscribanoSilente/Nervio/actions/workflows/ci.yml)
|
|
33
|
+
|
|
34
|
+
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.
|
|
35
|
+
|
|
36
|
+
- **Tracking automático**: lo que leas dentro de un `computed`/`effect` se vuelve dependencia. Sin `subscribe` manual.
|
|
37
|
+
- **Perezoso y cacheado**: un `computed` solo se recalcula cuando lo lees *y* una dependencia real cambió.
|
|
38
|
+
- **Glitch-free**: en diamantes (A→B, A→C, B+C→D), D se recalcula **una vez** y nunca ve estados intermedios inconsistentes.
|
|
39
|
+
- **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.
|
|
40
|
+
- **Sin fugas**: los `computed` abandonados se recolectan (refs fuente→observador débiles); los `effect` viven hasta su `dispose()`.
|
|
41
|
+
- **~580 líneas, un solo módulo, tipado (PEP 561).**
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install nervio
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## En 30 segundos
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from nervio import signal, computed, effect, batch
|
|
51
|
+
|
|
52
|
+
precio = signal(100)
|
|
53
|
+
cantidad = signal(2)
|
|
54
|
+
total = computed(lambda: precio.value * cantidad.value) # perezoso
|
|
55
|
+
|
|
56
|
+
effect(lambda: print(f"Total: {total.value}")) # imprime "Total: 200" ya mismo
|
|
57
|
+
|
|
58
|
+
precio.value = 150 # -> "Total: 300"
|
|
59
|
+
with batch(): # varias escrituras, un solo recálculo
|
|
60
|
+
precio.value = 200
|
|
61
|
+
cantidad.value = 3 # -> "Total: 600" (una vez)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## API
|
|
65
|
+
|
|
66
|
+
| | |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `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). |
|
|
69
|
+
| `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. |
|
|
70
|
+
| `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. |
|
|
71
|
+
| `batch()` | Context manager: agrupa escrituras; los effects corren una sola vez al salir. |
|
|
72
|
+
| `untrack(fn)` | Ejecuta `fn` leyendo señales **sin** registrarlas como dependencias. |
|
|
73
|
+
| `on_cleanup(fn)` | Dentro de un effect/root: registra limpieza que corre antes del próximo re-run y al disponer. |
|
|
74
|
+
| `root() -> dispose` | Context manager que agrupa effects/computeds creados dentro para disponerlos juntos. |
|
|
75
|
+
|
|
76
|
+
### Dependencias dinámicas, limpieza, alcance
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from nervio import signal, effect, on_cleanup, root
|
|
80
|
+
|
|
81
|
+
modo = signal("a"); a = signal(1); b = signal(2)
|
|
82
|
+
|
|
83
|
+
# Las dependencias se recalculan en cada corrida: aquí se suscribe a `a` O a `b`, nunca a ambas.
|
|
84
|
+
effect(lambda: print(a.value if modo.value == "a" else b.value))
|
|
85
|
+
|
|
86
|
+
# Limpieza por corrida (cerrar sockets, cancelar timers, etc.)
|
|
87
|
+
def watcher():
|
|
88
|
+
conn = abrir(modo.value)
|
|
89
|
+
on_cleanup(conn.close) # corre antes del próximo run y al disponer
|
|
90
|
+
effect(watcher)
|
|
91
|
+
|
|
92
|
+
# Disponer un subgrafo completo de una vez
|
|
93
|
+
with root() as dispose:
|
|
94
|
+
effect(lambda: ...)
|
|
95
|
+
effect(lambda: ...)
|
|
96
|
+
# ...más tarde:
|
|
97
|
+
dispose()
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Cómo funciona
|
|
101
|
+
|
|
102
|
+
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()`.
|
|
103
|
+
|
|
104
|
+
## Errores
|
|
105
|
+
|
|
106
|
+
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.
|
|
107
|
+
|
|
108
|
+
- 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.
|
|
109
|
+
- 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.
|
|
110
|
+
- 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.
|
|
111
|
+
- **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.)
|
|
112
|
+
|
|
113
|
+
## Límites (a propósito)
|
|
114
|
+
|
|
115
|
+
- **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).
|
|
116
|
+
- **`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.
|
|
117
|
+
- **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.
|
|
118
|
+
- **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.
|
|
119
|
+
- **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.)*
|
|
120
|
+
- **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.)*
|
|
121
|
+
- Para arrays de numpy u objetos con `==` no booleano, pasa `equals=` propio o `equals=False`.
|
|
122
|
+
|
|
123
|
+
## Licencia
|
|
124
|
+
|
|
125
|
+
MIT.
|
nervio-0.2.0/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# nervio
|
|
2
|
+
|
|
3
|
+
[](https://github.com/ElEscribanoSilente/Nervio/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
- **Tracking automático**: lo que leas dentro de un `computed`/`effect` se vuelve dependencia. Sin `subscribe` manual.
|
|
8
|
+
- **Perezoso y cacheado**: un `computed` solo se recalcula cuando lo lees *y* una dependencia real cambió.
|
|
9
|
+
- **Glitch-free**: en diamantes (A→B, A→C, B+C→D), D se recalcula **una vez** y nunca ve estados intermedios inconsistentes.
|
|
10
|
+
- **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.
|
|
11
|
+
- **Sin fugas**: los `computed` abandonados se recolectan (refs fuente→observador débiles); los `effect` viven hasta su `dispose()`.
|
|
12
|
+
- **~580 líneas, un solo módulo, tipado (PEP 561).**
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install nervio
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## En 30 segundos
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from nervio import signal, computed, effect, batch
|
|
22
|
+
|
|
23
|
+
precio = signal(100)
|
|
24
|
+
cantidad = signal(2)
|
|
25
|
+
total = computed(lambda: precio.value * cantidad.value) # perezoso
|
|
26
|
+
|
|
27
|
+
effect(lambda: print(f"Total: {total.value}")) # imprime "Total: 200" ya mismo
|
|
28
|
+
|
|
29
|
+
precio.value = 150 # -> "Total: 300"
|
|
30
|
+
with batch(): # varias escrituras, un solo recálculo
|
|
31
|
+
precio.value = 200
|
|
32
|
+
cantidad.value = 3 # -> "Total: 600" (una vez)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## API
|
|
36
|
+
|
|
37
|
+
| | |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `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). |
|
|
40
|
+
| `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. |
|
|
41
|
+
| `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. |
|
|
42
|
+
| `batch()` | Context manager: agrupa escrituras; los effects corren una sola vez al salir. |
|
|
43
|
+
| `untrack(fn)` | Ejecuta `fn` leyendo señales **sin** registrarlas como dependencias. |
|
|
44
|
+
| `on_cleanup(fn)` | Dentro de un effect/root: registra limpieza que corre antes del próximo re-run y al disponer. |
|
|
45
|
+
| `root() -> dispose` | Context manager que agrupa effects/computeds creados dentro para disponerlos juntos. |
|
|
46
|
+
|
|
47
|
+
### Dependencias dinámicas, limpieza, alcance
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from nervio import signal, effect, on_cleanup, root
|
|
51
|
+
|
|
52
|
+
modo = signal("a"); a = signal(1); b = signal(2)
|
|
53
|
+
|
|
54
|
+
# Las dependencias se recalculan en cada corrida: aquí se suscribe a `a` O a `b`, nunca a ambas.
|
|
55
|
+
effect(lambda: print(a.value if modo.value == "a" else b.value))
|
|
56
|
+
|
|
57
|
+
# Limpieza por corrida (cerrar sockets, cancelar timers, etc.)
|
|
58
|
+
def watcher():
|
|
59
|
+
conn = abrir(modo.value)
|
|
60
|
+
on_cleanup(conn.close) # corre antes del próximo run y al disponer
|
|
61
|
+
effect(watcher)
|
|
62
|
+
|
|
63
|
+
# Disponer un subgrafo completo de una vez
|
|
64
|
+
with root() as dispose:
|
|
65
|
+
effect(lambda: ...)
|
|
66
|
+
effect(lambda: ...)
|
|
67
|
+
# ...más tarde:
|
|
68
|
+
dispose()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Cómo funciona
|
|
72
|
+
|
|
73
|
+
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()`.
|
|
74
|
+
|
|
75
|
+
## Errores
|
|
76
|
+
|
|
77
|
+
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.
|
|
78
|
+
|
|
79
|
+
- 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.
|
|
80
|
+
- 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.
|
|
81
|
+
- 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.
|
|
82
|
+
- **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.)
|
|
83
|
+
|
|
84
|
+
## Límites (a propósito)
|
|
85
|
+
|
|
86
|
+
- **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).
|
|
87
|
+
- **`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.
|
|
88
|
+
- **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.
|
|
89
|
+
- **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.
|
|
90
|
+
- **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.)*
|
|
91
|
+
- **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.)*
|
|
92
|
+
- Para arrays de numpy u objetos con `==` no booleano, pasa `equals=` propio o `equals=False`.
|
|
93
|
+
|
|
94
|
+
## Licencia
|
|
95
|
+
|
|
96
|
+
MIT.
|