memwal 0.1.5.dev0__tar.gz → 0.1.5.dev1__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.
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/PKG-INFO +1 -1
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/memwal/__init__.py +1 -1
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/memwal/middleware.py +132 -12
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/pyproject.toml +1 -1
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/tests/test_middleware.py +124 -2
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/.gitignore +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/CHANGELOG.md +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/README.md +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/examples/.env.example +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/examples/.gitignore +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/examples/async_remember_demo.py +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/examples/interactive_demo.py +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/examples/verify_credentials.py +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/memwal/client.py +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/memwal/compatibility.py +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/memwal/types.py +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/memwal/utils.py +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/run_tests.py +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/tests/__init__.py +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/tests/test_client.py +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/tests/test_env_presets.py +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/tests/test_integration.py +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/tests/test_signing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memwal
|
|
3
|
-
Version: 0.1.5.
|
|
3
|
+
Version: 0.1.5.dev1
|
|
4
4
|
Summary: Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing
|
|
5
5
|
Project-URL: Homepage, https://memory.walrus.xyz
|
|
6
6
|
Project-URL: Documentation, https://memory.walrus.xyz
|
|
@@ -130,14 +130,124 @@ def _format_memories(
|
|
|
130
130
|
])
|
|
131
131
|
|
|
132
132
|
|
|
133
|
-
|
|
134
|
-
"""
|
|
133
|
+
class _PendingSaves:
|
|
134
|
+
"""Tracks in-flight fire-and-forget auto-save work (asyncio Tasks and
|
|
135
|
+
background Threads) so a caller can drain it deterministically via
|
|
136
|
+
:meth:`flush`/:meth:`flush_sync` instead of losing it silently when the
|
|
137
|
+
process exits before it completes. Tasks scheduled via
|
|
138
|
+
``loop.create_task()`` with no other reference are only weakly held by
|
|
139
|
+
the event loop and can be cancelled or garbage-collected before they
|
|
140
|
+
run — this keeps a strong reference until each one is done.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(self) -> None:
|
|
144
|
+
self._tasks: "set[asyncio.Task[Any]]" = set()
|
|
145
|
+
self._threads: List[threading.Thread] = []
|
|
146
|
+
self._lock = threading.Lock()
|
|
147
|
+
|
|
148
|
+
def track_task(self, task: "asyncio.Task[Any]") -> None:
|
|
149
|
+
self._tasks.add(task)
|
|
150
|
+
task.add_done_callback(self._tasks.discard)
|
|
151
|
+
|
|
152
|
+
def spawn_thread(self, target: Callable[[], None]) -> None:
|
|
153
|
+
"""Run `target` in a new daemon thread, tracked until it completes.
|
|
154
|
+
The thread untracks itself on completion -- a long-lived client
|
|
155
|
+
that keeps using fire-and-forget saves but never calls flush()
|
|
156
|
+
would otherwise accumulate one Thread object per save, forever.
|
|
157
|
+
"""
|
|
158
|
+
def _run_and_untrack() -> None:
|
|
159
|
+
try:
|
|
160
|
+
target()
|
|
161
|
+
finally:
|
|
162
|
+
with self._lock:
|
|
163
|
+
if thread in self._threads:
|
|
164
|
+
self._threads.remove(thread)
|
|
165
|
+
|
|
166
|
+
thread = threading.Thread(target=_run_and_untrack, daemon=True)
|
|
167
|
+
with self._lock:
|
|
168
|
+
self._threads.append(thread)
|
|
169
|
+
thread.start()
|
|
170
|
+
|
|
171
|
+
async def flush(self) -> None:
|
|
172
|
+
"""Await every pending task, then join every pending thread."""
|
|
173
|
+
if self._tasks:
|
|
174
|
+
await asyncio.gather(*list(self._tasks), return_exceptions=True)
|
|
175
|
+
self._join_threads()
|
|
176
|
+
|
|
177
|
+
def flush_sync(self) -> None:
|
|
178
|
+
"""Join every pending thread. A pending Task belongs to the loop
|
|
179
|
+
that created it (e.g. an earlier `await llm.ainvoke(...)` call) and
|
|
180
|
+
cannot be awaited from a different one -- if any are still pending
|
|
181
|
+
here, the caller mixed sync and async entry points on the same
|
|
182
|
+
wrapped client, and this cleanup path can't safely drain them. Log
|
|
183
|
+
and continue draining threads rather than raise out of what's
|
|
184
|
+
usually shutdown code."""
|
|
185
|
+
if self._tasks:
|
|
186
|
+
async def _drain(tasks: "list[asyncio.Task[Any]]") -> None:
|
|
187
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
asyncio.run(_drain(list(self._tasks)))
|
|
191
|
+
except RuntimeError:
|
|
192
|
+
logger.warning(
|
|
193
|
+
"Walrus Memory flush_sync() could not await %d pending "
|
|
194
|
+
"task(s) bound to a different event loop (mixing sync "
|
|
195
|
+
"and async calls on the same wrapped client?) — those "
|
|
196
|
+
"saves may be lost.",
|
|
197
|
+
len(self._tasks),
|
|
198
|
+
)
|
|
199
|
+
self._join_threads()
|
|
200
|
+
|
|
201
|
+
def _join_threads(self) -> None:
|
|
202
|
+
with self._lock:
|
|
203
|
+
threads, self._threads = self._threads, []
|
|
204
|
+
for thread in threads:
|
|
205
|
+
thread.join()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _expose_memwal_controls(obj: Any, memwal: MemWal, pending: _PendingSaves) -> None:
|
|
209
|
+
"""Attach the underlying client and a way to drain pending auto-saves —
|
|
210
|
+
without this, a short-lived process has no way to avoid silently
|
|
211
|
+
losing writes still in flight when it exits.
|
|
212
|
+
|
|
213
|
+
Uses object.__setattr__ rather than plain attribute assignment: a
|
|
214
|
+
LangChain BaseChatModel is a Pydantic model, and Pydantic's __setattr__
|
|
215
|
+
rejects assignment to any name that isn't a declared field (or a
|
|
216
|
+
leading-underscore private attribute) — memwal_flush/memwal_flush_sync
|
|
217
|
+
are neither, so plain `obj.memwal_flush = ...` raises ValueError on a
|
|
218
|
+
real LangChain model (masked by tests using MagicMock, which doesn't
|
|
219
|
+
enforce this). object.__setattr__ bypasses that check and writes
|
|
220
|
+
straight into the instance's __dict__, where normal attribute lookup
|
|
221
|
+
(obj.memwal_flush) still finds it — OpenAI clients aren't Pydantic
|
|
222
|
+
models and work the same way either way.
|
|
223
|
+
"""
|
|
224
|
+
object.__setattr__(obj, "_memwal", memwal)
|
|
225
|
+
object.__setattr__(obj, "memwal_flush", pending.flush)
|
|
226
|
+
object.__setattr__(obj, "memwal_flush_sync", pending.flush_sync)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def _warn_if_cancelled(coro: Any, label: str) -> None:
|
|
230
|
+
try:
|
|
231
|
+
await coro
|
|
232
|
+
except asyncio.CancelledError:
|
|
233
|
+
logger.warning(
|
|
234
|
+
"Walrus Memory %s was cancelled before it completed — the process "
|
|
235
|
+
"likely exited without draining pending saves. Call flush()/"
|
|
236
|
+
"flush_sync() before exiting to avoid silent data loss.",
|
|
237
|
+
label,
|
|
238
|
+
)
|
|
239
|
+
raise
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _fire_and_forget(coro: Any, pending: _PendingSaves, label: str = "auto-save") -> None:
|
|
243
|
+
"""Schedule an async coroutine as fire-and-forget, tracked in `pending`.
|
|
135
244
|
|
|
136
245
|
Works whether or not an event loop is already running.
|
|
137
246
|
"""
|
|
138
247
|
try:
|
|
139
248
|
loop = asyncio.get_running_loop()
|
|
140
|
-
loop.create_task(coro)
|
|
249
|
+
task = loop.create_task(_warn_if_cancelled(coro, label))
|
|
250
|
+
pending.track_task(task)
|
|
141
251
|
except RuntimeError:
|
|
142
252
|
# No running loop -- run in a background thread
|
|
143
253
|
def _run() -> None:
|
|
@@ -146,8 +256,7 @@ def _fire_and_forget(coro: Any) -> None:
|
|
|
146
256
|
except Exception:
|
|
147
257
|
logger.debug("Fire-and-forget analyze() failed", exc_info=True)
|
|
148
258
|
|
|
149
|
-
|
|
150
|
-
thread.start()
|
|
259
|
+
pending.spawn_thread(_run)
|
|
151
260
|
|
|
152
261
|
|
|
153
262
|
def _run_blocking(coro_factory: Callable[[], Any]) -> Any:
|
|
@@ -226,6 +335,7 @@ def with_memwal_langchain(
|
|
|
226
335
|
)
|
|
227
336
|
|
|
228
337
|
log = logger.debug if not debug else logger.warning
|
|
338
|
+
pending = _PendingSaves()
|
|
229
339
|
|
|
230
340
|
original_agenerate = llm._agenerate
|
|
231
341
|
original_generate = llm._generate
|
|
@@ -289,7 +399,7 @@ def with_memwal_langchain(
|
|
|
289
399
|
result = await original_agenerate(enriched, *args, **kwargs)
|
|
290
400
|
|
|
291
401
|
for msg_list in messages:
|
|
292
|
-
_fire_and_forget(_post_analyze(msg_list))
|
|
402
|
+
_fire_and_forget(_post_analyze(msg_list), pending)
|
|
293
403
|
|
|
294
404
|
return result
|
|
295
405
|
|
|
@@ -315,7 +425,7 @@ def with_memwal_langchain(
|
|
|
315
425
|
result = original_generate(enriched, *args, **kwargs)
|
|
316
426
|
|
|
317
427
|
for msg_list in messages:
|
|
318
|
-
_fire_and_forget(_post_analyze(msg_list))
|
|
428
|
+
_fire_and_forget(_post_analyze(msg_list), pending)
|
|
319
429
|
|
|
320
430
|
return result
|
|
321
431
|
|
|
@@ -323,6 +433,8 @@ def with_memwal_langchain(
|
|
|
323
433
|
llm._agenerate = patched_agenerate # type: ignore[assignment]
|
|
324
434
|
llm._generate = patched_generate # type: ignore[assignment]
|
|
325
435
|
|
|
436
|
+
_expose_memwal_controls(llm, memwal, pending)
|
|
437
|
+
|
|
326
438
|
return llm
|
|
327
439
|
|
|
328
440
|
|
|
@@ -379,13 +491,20 @@ def with_memwal_openai(
|
|
|
379
491
|
)
|
|
380
492
|
|
|
381
493
|
log = logger.debug if not debug else logger.warning
|
|
494
|
+
pending = _PendingSaves()
|
|
382
495
|
|
|
383
496
|
is_async = hasattr(client, "_async_client") or type(client).__name__ == "AsyncOpenAI"
|
|
384
497
|
|
|
385
498
|
if is_async:
|
|
386
|
-
_wrap_async_openai(
|
|
499
|
+
_wrap_async_openai(
|
|
500
|
+
client, memwal, namespace, max_memories, auto_save, min_relevance, log, pending
|
|
501
|
+
)
|
|
387
502
|
else:
|
|
388
|
-
_wrap_sync_openai(
|
|
503
|
+
_wrap_sync_openai(
|
|
504
|
+
client, memwal, namespace, max_memories, auto_save, min_relevance, log, pending
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
_expose_memwal_controls(client, memwal, pending)
|
|
389
508
|
|
|
390
509
|
return client
|
|
391
510
|
|
|
@@ -398,6 +517,7 @@ def _wrap_async_openai(
|
|
|
398
517
|
auto_save: bool,
|
|
399
518
|
min_relevance: float,
|
|
400
519
|
log: Callable[..., Any],
|
|
520
|
+
pending: _PendingSaves,
|
|
401
521
|
) -> None:
|
|
402
522
|
"""Wrap an async OpenAI client's chat.completions.create."""
|
|
403
523
|
original_create = client.chat.completions.create
|
|
@@ -437,7 +557,7 @@ def _wrap_async_openai(
|
|
|
437
557
|
except Exception as e:
|
|
438
558
|
log(f"[Walrus Memory] Auto-save failed: {e}")
|
|
439
559
|
|
|
440
|
-
_fire_and_forget(_analyze())
|
|
560
|
+
_fire_and_forget(_analyze(), pending)
|
|
441
561
|
|
|
442
562
|
return result
|
|
443
563
|
|
|
@@ -452,6 +572,7 @@ def _wrap_sync_openai(
|
|
|
452
572
|
auto_save: bool,
|
|
453
573
|
min_relevance: float,
|
|
454
574
|
log: Callable[..., Any],
|
|
575
|
+
pending: _PendingSaves,
|
|
455
576
|
) -> None:
|
|
456
577
|
"""Wrap a sync OpenAI client's chat.completions.create."""
|
|
457
578
|
original_create = client.chat.completions.create
|
|
@@ -498,8 +619,7 @@ def _wrap_sync_openai(
|
|
|
498
619
|
except Exception as e:
|
|
499
620
|
log(f"[Walrus Memory] Auto-save failed: {e}")
|
|
500
621
|
|
|
501
|
-
|
|
502
|
-
thread.start()
|
|
622
|
+
pending.spawn_thread(_analyze)
|
|
503
623
|
|
|
504
624
|
return result
|
|
505
625
|
|
|
@@ -17,16 +17,19 @@ from __future__ import annotations
|
|
|
17
17
|
|
|
18
18
|
import asyncio
|
|
19
19
|
import json
|
|
20
|
-
|
|
20
|
+
import threading
|
|
21
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
21
22
|
|
|
22
23
|
import httpx
|
|
23
24
|
import nacl.signing
|
|
24
25
|
import respx
|
|
25
26
|
|
|
27
|
+
from memwal.client import MemWal
|
|
26
28
|
from memwal.middleware import (
|
|
27
29
|
_find_last_user_message,
|
|
28
30
|
_format_memories,
|
|
29
31
|
_inject_openai_memory,
|
|
32
|
+
_PendingSaves,
|
|
30
33
|
with_memwal_langchain,
|
|
31
34
|
with_memwal_openai,
|
|
32
35
|
)
|
|
@@ -444,6 +447,24 @@ class TestWithMemWalLangChain:
|
|
|
444
447
|
await smart_llm._agenerate([[SystemMessage("only system")]])
|
|
445
448
|
assert not recall_route.called
|
|
446
449
|
|
|
450
|
+
def test_wraps_a_real_pydantic_backed_chat_model(self) -> None:
|
|
451
|
+
"""with_memwal_langchain must work on a real BaseChatModel, not just
|
|
452
|
+
a MagicMock. LangChain chat models are Pydantic models that reject
|
|
453
|
+
assignment of undeclared fields under normal attribute assignment
|
|
454
|
+
-- MagicMock doesn't enforce that, so it silently hides this."""
|
|
455
|
+
from langchain_core.language_models.fake_chat_models import FakeListChatModel
|
|
456
|
+
|
|
457
|
+
llm = FakeListChatModel(responses=["canned response"])
|
|
458
|
+
|
|
459
|
+
smart_llm = with_memwal_langchain(
|
|
460
|
+
llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
assert smart_llm is llm
|
|
464
|
+
assert smart_llm._memwal is not None
|
|
465
|
+
assert callable(smart_llm.memwal_flush)
|
|
466
|
+
assert callable(smart_llm.memwal_flush_sync)
|
|
467
|
+
|
|
447
468
|
|
|
448
469
|
# ============================================================
|
|
449
470
|
# OpenAI middleware tests
|
|
@@ -587,6 +608,41 @@ class TestWithMemWalOpenAI:
|
|
|
587
608
|
await asyncio.sleep(0.05)
|
|
588
609
|
assert analyze_route.called
|
|
589
610
|
|
|
611
|
+
@respx.mock
|
|
612
|
+
async def test_memwal_flush_awaits_pending_autosave(self) -> None:
|
|
613
|
+
"""memwal_flush() deterministically waits for the fire-and-forget
|
|
614
|
+
analyze() call, instead of the caller having to guess a sleep
|
|
615
|
+
duration and hope the background task finished in time."""
|
|
616
|
+
_mock_seal_session_prereqs()
|
|
617
|
+
client = self._make_async_client()
|
|
618
|
+
|
|
619
|
+
respx.post(_RECALL_URL).mock(return_value=_mock_recall([]))
|
|
620
|
+
|
|
621
|
+
gate = asyncio.Event()
|
|
622
|
+
completed = {"value": False}
|
|
623
|
+
|
|
624
|
+
async def gated_analyze(self, *args, **kwargs):
|
|
625
|
+
await gate.wait()
|
|
626
|
+
completed["value"] = True
|
|
627
|
+
|
|
628
|
+
with patch.object(MemWal, "analyze", gated_analyze):
|
|
629
|
+
smart = with_memwal_openai(
|
|
630
|
+
client, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=True
|
|
631
|
+
)
|
|
632
|
+
await smart.chat.completions.create(
|
|
633
|
+
model="gpt-4o",
|
|
634
|
+
messages=[{"role": "user", "content": "remember this"}],
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
# Fire-and-forget: the response came back, but the gated
|
|
638
|
+
# analyze() call has not completed yet.
|
|
639
|
+
assert completed["value"] is False
|
|
640
|
+
|
|
641
|
+
gate.set()
|
|
642
|
+
await smart.memwal_flush()
|
|
643
|
+
|
|
644
|
+
assert completed["value"] is True
|
|
645
|
+
|
|
590
646
|
@respx.mock
|
|
591
647
|
async def test_min_relevance_filter(self) -> None:
|
|
592
648
|
"""Memories with relevance below min_relevance are not injected."""
|
|
@@ -671,4 +727,70 @@ class TestWithMemWalOpenAI:
|
|
|
671
727
|
assert "TV support appointment is 9 AM to noon" not in system_msgs[0]["content"]
|
|
672
728
|
user_msgs = [m for m in captured if isinstance(m, dict) and m.get("role") == "user"]
|
|
673
729
|
assert len(user_msgs) == 2
|
|
674
|
-
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
# ============================================================
|
|
733
|
+
# _PendingSaves
|
|
734
|
+
# ============================================================
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
class TestPendingSaves:
|
|
738
|
+
"""Tests for _PendingSaves, isolated from the middleware wrappers."""
|
|
739
|
+
|
|
740
|
+
def test_flush_sync_does_not_crash_on_a_task_from_a_different_loop(self) -> None:
|
|
741
|
+
"""Reproduces the real bug: a caller that mixes an earlier async
|
|
742
|
+
call (which populates self._tasks on that call's event loop) with
|
|
743
|
+
flush_sync() from sync code afterward. asyncio.Tasks are bound to
|
|
744
|
+
the loop that created them and can't be awaited from a new one --
|
|
745
|
+
flush_sync() must not raise out of this, since it's typically
|
|
746
|
+
called during cleanup."""
|
|
747
|
+
pending = _PendingSaves()
|
|
748
|
+
task_holder: dict = {}
|
|
749
|
+
release = threading.Event()
|
|
750
|
+
|
|
751
|
+
def _run_other_loop() -> None:
|
|
752
|
+
async def _pending_forever() -> None:
|
|
753
|
+
await asyncio.get_event_loop().run_in_executor(None, release.wait)
|
|
754
|
+
|
|
755
|
+
loop = asyncio.new_event_loop()
|
|
756
|
+
asyncio.set_event_loop(loop)
|
|
757
|
+
task = loop.create_task(_pending_forever())
|
|
758
|
+
task_holder["task"] = task
|
|
759
|
+
pending.track_task(task)
|
|
760
|
+
# Keep this loop alive long enough for the main thread's
|
|
761
|
+
# flush_sync() to observe the still-pending, cross-loop task.
|
|
762
|
+
loop.run_until_complete(asyncio.sleep(0.3))
|
|
763
|
+
|
|
764
|
+
other_loop_thread = threading.Thread(target=_run_other_loop)
|
|
765
|
+
other_loop_thread.start()
|
|
766
|
+
|
|
767
|
+
# Give the other thread time to create its loop and schedule the task.
|
|
768
|
+
import time
|
|
769
|
+
|
|
770
|
+
time.sleep(0.05)
|
|
771
|
+
assert task_holder.get("task") is not None
|
|
772
|
+
assert not task_holder["task"].done()
|
|
773
|
+
|
|
774
|
+
try:
|
|
775
|
+
pending.flush_sync() # must not raise
|
|
776
|
+
finally:
|
|
777
|
+
release.set()
|
|
778
|
+
other_loop_thread.join(timeout=2)
|
|
779
|
+
|
|
780
|
+
def test_completed_threads_are_untracked_without_flushing(self) -> None:
|
|
781
|
+
"""A long-lived client that keeps using fire-and-forget saves but
|
|
782
|
+
never calls flush()/flush_sync() must not accumulate one Thread
|
|
783
|
+
object per save forever."""
|
|
784
|
+
pending = _PendingSaves()
|
|
785
|
+
done = threading.Event()
|
|
786
|
+
|
|
787
|
+
pending.spawn_thread(done.set)
|
|
788
|
+
|
|
789
|
+
assert done.wait(timeout=2), "background thread never ran"
|
|
790
|
+
# Give the thread's own cleanup a moment to run after done.set()
|
|
791
|
+
# returns (the Event is set from inside the tracked callable,
|
|
792
|
+
# microseconds before the thread function itself returns).
|
|
793
|
+
import time
|
|
794
|
+
|
|
795
|
+
time.sleep(0.05)
|
|
796
|
+
assert len(pending._threads) == 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|