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 +575 -0
- nervio/py.typed +0 -0
- nervio-0.2.0.dist-info/METADATA +125 -0
- nervio-0.2.0.dist-info/RECORD +6 -0
- nervio-0.2.0.dist-info/WHEEL +4 -0
- nervio-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|
+
[](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,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.
|