nervio 0.2.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.
nervio/__init__.py ADDED
@@ -0,0 +1,575 @@
1
+ """nervio — reactividad de grano fino, sin dependencias.
2
+
3
+ signal / computed / effect con tracking automático de dependencias y
4
+ propagación *pull* glitch-free (estados clean/check/dirty, estilo Reactively):
5
+ los `computed` se recalculan perezosamente y solo si una dependencia real cambió;
6
+ los diamantes (A→B, A→C, B+C→D) nunca producen lecturas inconsistentes ni
7
+ recálculos de más.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import operator
12
+ import sys
13
+ import threading
14
+ import weakref
15
+ from collections import deque
16
+ from typing import Callable, Generic, List, TypeVar
17
+
18
+ T = TypeVar("T")
19
+
20
+ __all__ = [
21
+ "signal", "computed", "effect", "batch", "untrack", "root", "on_cleanup",
22
+ "Signal", "Computed",
23
+ ]
24
+ __version__ = "0.2.0"
25
+
26
+ _CLEAN, _CHECK, _DIRTY = 0, 1, 2
27
+
28
+ # Un único lock reentrante serializa toda mutación del grafo: escrituras, tracking,
29
+ # recomputos, flush y disposición. Corrección primero: varios hilos pueden tocar el
30
+ # MISMO grafo sin corromper sus listas internas (observers/sources) y sin glitches
31
+ # entre hilos. Nunca se sostiene a través de un `await` (la librería no tiene puntos
32
+ # async), así que no introduce deadlocks propios; la regla para el usuario es no
33
+ # bloquear dentro de un effect/computed esperando a otro hilo que también use señales.
34
+ _lock = threading.RLock()
35
+
36
+
37
+ class _Scope:
38
+ """Estado vivo de un ámbito de ejecución: observador en curso (tracking), dueño
39
+ actual (cleanups/disposición), cola de effects y profundidad de batch.
40
+
41
+ El ámbito se resuelve por identidad de ejecución —la tarea asyncio actual o, en
42
+ su defecto, el hilo— y NO por contextvars: `asyncio.create_task(...)` copia el
43
+ contexto del padre, y heredar su batch/observador dejaba effects diferidos para
44
+ siempre (el hijo nunca puede cerrar un batch ajeno) y suscripciones fantasma.
45
+ Cada tarea e hilo estrena ámbito limpio.
46
+ """
47
+
48
+ __slots__ = ("observer", "owner", "batch_depth", "flushing", "pending", "__weakref__")
49
+
50
+ def __init__(self):
51
+ self.observer = None # nodo en ejecución, para auto-tracking
52
+ self.owner = None # dueño actual, para cleanups/disposición
53
+ self.batch_depth = 0
54
+ self.flushing = False
55
+ self.pending = deque() # effects en cola para correr tras una escritura/batch
56
+
57
+
58
+ class _ThreadScope(threading.local):
59
+ def __init__(self):
60
+ self.scope = _Scope()
61
+
62
+
63
+ _thread_scope = _ThreadScope()
64
+ # tarea asyncio -> ámbito; claves débiles: al morir la tarea, su ámbito se libera solo
65
+ _task_scopes: "weakref.WeakKeyDictionary" = weakref.WeakKeyDictionary()
66
+
67
+ # Ancla de los effects vivos. Las referencias fuente->observador son DÉBILES (un
68
+ # computed abandonado se recolecta solo), así que algo tiene que retener a los
69
+ # effects aunque el usuario tire el handle de dispose: viven aquí hasta su
70
+ # dispose() explícito (o el de su root). Mutado solo bajo _lock.
71
+ _live_effects: set = set()
72
+
73
+
74
+ def _scope() -> _Scope:
75
+ """Ámbito del ejecutor actual: la tarea asyncio si la hay; si no, el hilo."""
76
+ aio = sys.modules.get("asyncio") # no importa asyncio si el usuario no lo usa
77
+ if aio is not None:
78
+ current = getattr(aio, "current_task", None) # tolera stubs/imports parciales
79
+ task = None
80
+ if current is not None:
81
+ try:
82
+ task = current()
83
+ except RuntimeError: # sin event loop corriendo en este hilo
84
+ task = None
85
+ if task is not None:
86
+ with _lock:
87
+ sc = _task_scopes.get(task)
88
+ if sc is None:
89
+ sc = _task_scopes[task] = _Scope()
90
+ return sc
91
+ return _thread_scope.scope
92
+
93
+
94
+ def _never_equal(_a, _b) -> bool:
95
+ return False
96
+
97
+
98
+ class _Node:
99
+ """Nodo del grafo reactivo. Signal: fn=None. Computed/Effect: fn dado."""
100
+
101
+ def __init__(self, value=None, fn=None, equals=None, is_effect=False):
102
+ self._value = value
103
+ self.fn = fn
104
+ if equals is None:
105
+ self.equals = operator.eq
106
+ elif equals is False:
107
+ self.equals = _never_equal
108
+ else:
109
+ self.equals = equals
110
+ # computed/effect arrancan sucios (necesitan primer cómputo); signal limpio.
111
+ self.state = _CLEAN if fn is None else _DIRTY
112
+ self.sources: List["_Node"] = [] # refs FUERTES hacia abajo
113
+ self.observers: List["weakref.ref"] = [] # refs DÉBILES hacia arriba
114
+ self.is_effect = is_effect
115
+ self.cleanups: List[Callable[[], None]] = []
116
+ self.owned: List["_Node"] = []
117
+ self.disposed = False
118
+ # weakref al _Scope en cuya cola espera este effect, o None si no espera en
119
+ # ninguna. Si el scope dueño muere (hilo/tarea abandonados con cola no vacía),
120
+ # el siguiente escritor lo detecta y lo adopta: ningún effect queda mudo.
121
+ self._queued_in = None
122
+ # True mientras corre el propio _recompute: reentrar ahí es un ciclo
123
+ # (el nodo se lee/escribe a sí mismo, directa o indirectamente).
124
+ self._computing = False
125
+
126
+ # --- lectura con tracking -------------------------------------------------
127
+ def _get(self):
128
+ with _lock:
129
+ obs = _scope().observer
130
+ if obs is not None and obs is not self and self not in obs.sources:
131
+ obs.sources.append(self)
132
+ refs = self.observers
133
+ if len(refs) >= 64 and not len(refs) % 64:
134
+ self._live_observers() # compactación oportunista: una señal que
135
+ # nunca cambia no acumula refs muertas
136
+ # ref débil hacia arriba: la fuente no retiene a su observador
137
+ # (weakref.ref devuelve la ref canónica cacheada del objeto)
138
+ refs.append(weakref.ref(obs))
139
+ if self.fn is not None:
140
+ self._update_if_necessary()
141
+ return self._value
142
+
143
+ def _live_observers(self):
144
+ """Observadores vivos (deref de las weakrefs), compactando las muertas.
145
+
146
+ source->observer es débil: un computed abandonado (sin refs del usuario
147
+ ni de nodos aguas abajo) se recolecta solo en vez de quedar retenido
148
+ para siempre por sus fuentes. Se llama con _lock tomado."""
149
+ refs = self.observers
150
+ live, live_refs = [], []
151
+ for r in refs:
152
+ o = r()
153
+ if o is not None:
154
+ live.append(o)
155
+ live_refs.append(r)
156
+ if len(live_refs) != len(refs):
157
+ refs[:] = live_refs
158
+ return live
159
+
160
+ def _peek(self):
161
+ if self.fn is None:
162
+ return self._value
163
+ with _lock:
164
+ self._update_if_necessary()
165
+ return self._value
166
+
167
+ # --- propagación (fase de marcado, barata; se llama con _lock tomado) ------
168
+ def _mark(self, state):
169
+ if self.state < state:
170
+ self.state = state
171
+ if self.is_effect:
172
+ self._enqueue()
173
+ # recorrido iterativo en preorden DFS (mismo orden de encolado que la
174
+ # versión recursiva): grafos de profundidad arbitraria sin RecursionError
175
+ stack = list(reversed(self._live_observers()))
176
+ while stack:
177
+ o = stack.pop()
178
+ if o.state < _CHECK: # primera marca: propaga a su subgrafo
179
+ o.state = _CHECK
180
+ if o.is_effect:
181
+ o._enqueue()
182
+ stack.extend(reversed(o._live_observers()))
183
+ elif o.is_effect: # ya estaba sucio: chequeo de adopción
184
+ o._enqueue()
185
+ elif self.is_effect and self.state != _CLEAN and not self._computing:
186
+ # ya estaba sucio: si su cola murió, el escritor lo adopta. Nunca mientras
187
+ # _computing: esa marca viene de su propio run (p.ej. un computed fresco
188
+ # recién calculado dentro de él) y encolarla dejaría una entrada fantasma
189
+ # que bloquearía la adopción desde otros hilos/tareas.
190
+ self._enqueue()
191
+
192
+ def _enqueue(self):
193
+ # Idempotente: no encola si ya espera en la cola de un scope vivo. Si su
194
+ # cola murió con el scope (weakref muerta), lo encola el scope actual.
195
+ if self._queued_in is not None and self._queued_in() is not None:
196
+ return
197
+ sc = _scope()
198
+ sc.pending.append(self)
199
+ self._queued_in = weakref.ref(sc)
200
+
201
+ # --- resolución perezosa (fase de pull; se llama con _lock tomado) ---------
202
+ def _update_if_necessary(self):
203
+ if self.state == _CLEAN:
204
+ return
205
+ if self.state == _DIRTY:
206
+ self._recompute()
207
+ self.state = _CLEAN
208
+ return
209
+ # _CHECK: resuelve las fuentes primero y recomputa solo si alguna lo
210
+ # ensució. Descenso con pila explícita (no recursión): cadenas de miles
211
+ # de computeds no revientan el stack. Nota: en grafo CALIENTE las fn de
212
+ # usuario leen fuentes ya resueltas y no anidan; solo la primera
213
+ # evaluación de una cadena fría anida frames (límite: stack de Python).
214
+ stack = [[self, 0]]
215
+ on_path = {id(self)} # guardia de ciclo: sin ella un ciclo seria bucle infinito
216
+ while stack:
217
+ frame = stack[-1]
218
+ node = frame[0]
219
+ if node.state == _DIRTY: # una fuente lo ensució (o arrancó sucio)
220
+ node._recompute()
221
+ node.state = _CLEAN
222
+ on_path.discard(id(node))
223
+ stack.pop()
224
+ continue
225
+ if node.state == _CHECK:
226
+ srcs = node.sources
227
+ i = frame[1]
228
+ pushed = False
229
+ while i < len(srcs):
230
+ src = srcs[i]
231
+ i += 1
232
+ if src.state != _CLEAN:
233
+ if id(src) in on_path:
234
+ raise RuntimeError(
235
+ "nervio: ciclo reactivo detectado entre computeds")
236
+ frame[1] = i
237
+ stack.append([src, 0])
238
+ on_path.add(id(src))
239
+ pushed = True
240
+ break
241
+ if pushed:
242
+ continue
243
+ node.state = _CLEAN # ninguna fuente cambió: limpio sin recomputar
244
+ on_path.discard(id(node))
245
+ stack.pop()
246
+
247
+ def _recompute(self):
248
+ if self._computing:
249
+ raise RuntimeError(
250
+ "nervio: ciclo reactivo: el computed/effect participa en su propio "
251
+ "recálculo (se lee o se fuerza a sí mismo, directa o indirectamente)")
252
+ self._computing = True
253
+ try:
254
+ self._run_cleanups()
255
+ old_sources = self.sources[:]
256
+ for src in self.sources: # re-tracking limpio: deps dinámicas correctas
257
+ try:
258
+ src.observers.remove(weakref.ref(self))
259
+ except ValueError:
260
+ pass # la ref pudo compactarse ya
261
+ self.sources.clear()
262
+ sc = _scope()
263
+ prev_obs, prev_owner = sc.observer, sc.owner
264
+ sc.observer = sc.owner = self
265
+ try:
266
+ new = self.fn()
267
+ except BaseException:
268
+ # fn falló a medias: conserva también las dependencias del run anterior,
269
+ # así toda escritura que antes lo despertaba sigue despertándolo (sin
270
+ # esto, una dep aún no releída al lanzar quedaría desuscrita y muda).
271
+ for src in old_sources:
272
+ if not src.disposed and src not in self.sources:
273
+ self.sources.append(src)
274
+ src.observers.append(weakref.ref(self))
275
+ raise
276
+ finally:
277
+ sc.observer, sc.owner = prev_obs, prev_owner
278
+ if self.is_effect:
279
+ self._value = new
280
+ elif not self.equals(self._value, new):
281
+ self._value = new
282
+ for o in self._live_observers():
283
+ o._mark(_DIRTY)
284
+ finally:
285
+ self._computing = False
286
+
287
+ # --- limpieza y disposición ----------------------------------------------
288
+ def _run_cleanups(self):
289
+ # Un cleanup (o el dispose de un hijo) que lanza no aborta a los demás, y
290
+ # las listas SIEMPRE se vacían: el cleanup roto no queda re-registrado, así
291
+ # que no envenena los runs futuros. Corren todos; el primer error se
292
+ # re-lanza al final (un KeyboardInterrupt/CancelledError posterior desplaza
293
+ # a un Exception anterior para no quedar enmascarado).
294
+ error = None
295
+ for child in self.owned:
296
+ try:
297
+ child._dispose()
298
+ except BaseException as exc:
299
+ if error is None or (isinstance(error, Exception)
300
+ and not isinstance(exc, Exception)):
301
+ error = exc
302
+ self.owned.clear()
303
+ for fn in reversed(self.cleanups):
304
+ try:
305
+ fn()
306
+ except BaseException as exc:
307
+ if error is None or (isinstance(error, Exception)
308
+ and not isinstance(exc, Exception)):
309
+ error = exc
310
+ self.cleanups.clear()
311
+ if error is not None:
312
+ raise error
313
+
314
+ def _dispose(self):
315
+ error = None
316
+ with _lock:
317
+ if self.disposed:
318
+ return
319
+ self.disposed = True
320
+ try:
321
+ self._run_cleanups()
322
+ except BaseException as exc:
323
+ error = exc # el desmontaje se completa igual; se re-lanza al final:
324
+ # un cleanup roto no puede dejar un nodo a medio disponer
325
+ for src in self.sources:
326
+ try:
327
+ src.observers.remove(weakref.ref(self))
328
+ except ValueError:
329
+ pass
330
+ self.sources.clear()
331
+ for r in self.observers: # simetría: quitarse de las sources de quien
332
+ obs = r()
333
+ if obs is None:
334
+ continue
335
+ try: # lo observa, o su próximo re-tracking revienta
336
+ obs.sources.remove(self)
337
+ except ValueError:
338
+ pass
339
+ self.observers.clear()
340
+ self.state = _CLEAN
341
+ _live_effects.discard(self) # un effect dispuesto suelta su ancla
342
+ self.fn = None # suelta la clausura: un handle de dispose retenido no
343
+ # debe fijar en memoria el subgrafo que aquella capturaba
344
+ if error is not None:
345
+ raise error
346
+
347
+
348
+ class Signal(_Node, Generic[T]):
349
+ """Estado reactivo. Lee con `.value` (suscribe) o `.peek()` (no); escribe con `.value=`/`.set()`."""
350
+
351
+ def __init__(self, value: T, *, equals=None):
352
+ super().__init__(value=value, fn=None, equals=equals)
353
+
354
+ @property
355
+ def value(self) -> T:
356
+ return self._get()
357
+
358
+ @value.setter
359
+ def value(self, v: T) -> None:
360
+ self.set(v)
361
+
362
+ def peek(self) -> T:
363
+ """Lee el valor sin suscribirse. Lock-free: bajo concurrencia puede ver el
364
+ valor inmediatamente anterior a una escritura en curso de otro hilo."""
365
+ return self._value
366
+
367
+ def set(self, v: T) -> T:
368
+ """Asigna v; propaga solo si difiere del actual. Devuelve v."""
369
+ with _lock:
370
+ if not self.equals(self._value, v):
371
+ self._value = v
372
+ for o in self._live_observers():
373
+ o._mark(_DIRTY)
374
+ _flush()
375
+ return v
376
+
377
+ def update(self, fn: Callable[[T], T]) -> T:
378
+ """Asigna fn(actual) de forma atómica (leer-modificar-escribir bajo el lock:
379
+ seguro entre hilos, sin updates perdidos). Devuelve el nuevo valor."""
380
+ with _lock:
381
+ return self.set(fn(self._value))
382
+
383
+ def __call__(self) -> T:
384
+ return self._get()
385
+
386
+ def __repr__(self) -> str:
387
+ return f"Signal({self._value!r})"
388
+
389
+
390
+ class Computed(_Node, Generic[T]):
391
+ """Valor derivado, cacheado y perezoso: recalcula al leerse solo si cambió una dependencia."""
392
+
393
+ def __init__(self, fn: Callable[[], T], *, equals=None):
394
+ super().__init__(value=None, fn=fn, equals=equals)
395
+ _attach(self)
396
+
397
+ @property
398
+ def value(self) -> T:
399
+ return self._get()
400
+
401
+ def peek(self) -> T:
402
+ """Lee (recalculando si hace falta) sin suscribirse."""
403
+ return self._peek()
404
+
405
+ def __call__(self) -> T:
406
+ return self._get()
407
+
408
+ def __repr__(self) -> str:
409
+ st = ("clean", "check", "dirty")[self.state]
410
+ return f"Computed(state={st})"
411
+
412
+
413
+ def _attach(node: _Node) -> None:
414
+ with _lock:
415
+ owner = _scope().owner
416
+ if owner is not None:
417
+ owner.owned.append(node)
418
+
419
+
420
+ def _recover(node: "_Node") -> None:
421
+ """Tras un error de cómputo en el flush, devuelve `node` y su subgrafo de
422
+ fuentes a CLEAN. Así el próximo cambio vuelve a marcar y propagar con
423
+ normalidad (y a recalcular con valores frescos), en vez de dejar el effect
424
+ colgado para siempre: un nodo `dirty` fuera de la cola ya no se re-encola.
425
+ Iterativo: recuperarse de un error en una cadena profunda no debe lanzar
426
+ un RecursionError en pleno manejo de errores."""
427
+ stack = [node]
428
+ while stack:
429
+ n = stack.pop()
430
+ if n.state == _CLEAN:
431
+ continue
432
+ n.state = _CLEAN
433
+ stack.extend(n.sources)
434
+
435
+
436
+ def _flush(sc=None) -> None:
437
+ # Se llama con _lock tomado (desde set/update, effect() o batch.__exit__).
438
+ # batch.__exit__ pasa SU scope: si el __exit__ corre en otro contexto (p.ej.
439
+ # GeneratorExit al GC-earse una tarea abandonada), se drena la cola correcta.
440
+ if sc is None:
441
+ sc = _scope()
442
+ if sc.batch_depth or sc.flushing:
443
+ return # dentro de batch o de otro flush: el bucle externo drena la cola
444
+ sc.flushing = True
445
+ guard = 0
446
+ error = None
447
+ try:
448
+ pending = sc.pending
449
+ while pending:
450
+ node = pending.popleft()
451
+ node._queued_in = None
452
+ if node.disposed or node.state == _CLEAN:
453
+ continue
454
+ try:
455
+ node._update_if_necessary()
456
+ except Exception as exc: # un effect que falla no debe quedar colgado
457
+ _recover(node) # ni abortar el resto de la cola; se recupera
458
+ if error is None:
459
+ error = exc # se re-lanza el primero tras drenar la cola
460
+ except BaseException: # KeyboardInterrupt/CancelledError: recupera
461
+ _recover(node) # el nodo y re-lanza de inmediato
462
+ raise
463
+ guard += 1
464
+ if guard > 1_000_000:
465
+ while pending: # los descartados quedan CLEAN y fuera de cola:
466
+ n = pending.popleft()
467
+ n._queued_in = None
468
+ _recover(n) # la próxima escritura los revive
469
+ raise RuntimeError("nervio: posible ciclo reactivo (demasiados re-cálculos)")
470
+ finally:
471
+ sc.flushing = False
472
+ if error is not None:
473
+ raise error
474
+
475
+
476
+ def signal(value: T, *, equals=None) -> Signal[T]:
477
+ """Crea estado reactivo. `equals=False` fuerza propagar siempre (objetos mutados in-place)."""
478
+ return Signal(value, equals=equals)
479
+
480
+
481
+ def computed(fn: Callable[[], T], *, equals=None) -> Computed[T]:
482
+ """Crea un valor derivado perezoso y cacheado a partir de `fn`."""
483
+ return Computed(fn, equals=equals)
484
+
485
+
486
+ def effect(fn: Callable[[], None]) -> Callable[[], None]:
487
+ """Corre `fn` ahora y cada vez que cambie una dependencia leída. Devuelve `dispose()`.
488
+
489
+ Si la primera corrida lanza, el effect NO queda registrado (se dispone y la
490
+ excepción se propaga): el llamador no recibió el dispose, así que un nodo a
491
+ medio suscribir sería un zombi imposible de liberar."""
492
+ node = _Node(value=None, fn=fn, is_effect=True)
493
+ with _lock:
494
+ _attach(node)
495
+ _live_effects.add(node) # ancla: las fuentes ya no retienen (refs débiles)
496
+ try:
497
+ node._update_if_necessary() # arranca DIRTY -> corre de inmediato
498
+ except BaseException:
499
+ try:
500
+ node._dispose()
501
+ except BaseException:
502
+ pass # el desmontaje se completó igual (garantía de _dispose); para
503
+ # el llamador importa el error original de la fn, no el del cleanup
504
+ raise
505
+ _flush() # drena lo encolado durante la corrida inicial (p.ej. otros effects
506
+ # marcados por un computed recién calculado); en batch/flush es no-op
507
+ # y el drenador activo lo recogerá
508
+ return node._dispose
509
+
510
+
511
+ class batch:
512
+ """Context manager: agrupa escrituras; los effects corren una sola vez al salir.
513
+
514
+ El batch pertenece al ejecutor EXACTO (tarea asyncio o hilo) que lo abre:
515
+ - Las tareas creadas dentro NO lo heredan (sus escrituras disparan effects
516
+ de inmediato), y el código corrido en otras tareas del mismo hilo —p.ej.
517
+ dentro de un `asyncio.run(...)`— tampoco lo ve.
518
+ - No es una transacción con aislamiento: otros hilos pueden leer los valores
519
+ intermedios entre las escrituras del batch. Lo único que difiere son los
520
+ effects que ESTE batch ensució; un effect ya ensuciado por el batch que
521
+ otro hilo vuelva a tocar correrá al cerrarse el batch, no antes. Mantén
522
+ los batch cortos y síncronos."""
523
+
524
+ def __enter__(self) -> "batch":
525
+ self._scope = _scope()
526
+ self._scope.batch_depth += 1
527
+ return self
528
+
529
+ def __exit__(self, *exc) -> bool:
530
+ sc = self._scope
531
+ sc.batch_depth -= 1
532
+ if sc.batch_depth == 0: # el batch más externo: ahora sí vaciamos
533
+ with _lock:
534
+ _flush(sc) # SU cola, aunque __exit__ corra en otro contexto
535
+ return False
536
+
537
+
538
+ def untrack(fn: Callable[[], T]) -> T:
539
+ """Ejecuta `fn` sin registrar las señales que lea como dependencias. Devuelve su resultado."""
540
+ sc = _scope()
541
+ prev = sc.observer
542
+ sc.observer = None
543
+ try:
544
+ return fn()
545
+ finally:
546
+ sc.observer = prev
547
+
548
+
549
+ def on_cleanup(fn: Callable[[], None]) -> Callable[[], None]:
550
+ """Registra `fn` para correr antes del próximo re-run del effect/root actual y al disponerlo."""
551
+ owner = _scope().owner
552
+ if owner is not None:
553
+ with _lock:
554
+ owner.cleanups.append(fn)
555
+ return fn
556
+
557
+
558
+ class root:
559
+ """Context manager: agrupa effects/computeds creados dentro para disponerlos juntos.
560
+
561
+ Uso: `with root() as dispose: ...` y luego `dispose()` libera todo el subgrafo.
562
+ """
563
+
564
+ def __enter__(self) -> Callable[[], None]:
565
+ self._node = _Node(value=None, fn=None)
566
+ _attach(self._node)
567
+ sc = _scope()
568
+ self._scope = sc
569
+ self._prev_owner = sc.owner
570
+ sc.owner = self._node
571
+ return self._node._dispose
572
+
573
+ def __exit__(self, *exc) -> bool:
574
+ self._scope.owner = self._prev_owner
575
+ return False
nervio/py.typed ADDED
File without changes
@@ -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
+ [![CI](https://github.com/ElEscribanoSilente/Nervio/actions/workflows/ci.yml/badge.svg)](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.
@@ -0,0 +1,6 @@
1
+ nervio/__init__.py,sha256=B9MZxJSU8P-HfI_VsH28lcfeY08NOvwaes0p1TuNkfg,23741
2
+ nervio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ nervio-0.2.0.dist-info/METADATA,sha256=z8-TdBZNEzJPxMH3DDFxyy03NbakA68qjjijSxC8430,11241
4
+ nervio-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
5
+ nervio-0.2.0.dist-info/licenses/LICENSE,sha256=ICO9rlTUZFkWtxxYR_WGObXzZNp859eX3Kx5odkr9y8,1075
6
+ nervio-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.