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.
Files changed (24) hide show
  1. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/PKG-INFO +1 -1
  2. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/memwal/__init__.py +1 -1
  3. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/memwal/middleware.py +132 -12
  4. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/pyproject.toml +1 -1
  5. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/tests/test_middleware.py +124 -2
  6. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/.gitignore +0 -0
  7. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/CHANGELOG.md +0 -0
  8. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/README.md +0 -0
  9. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/examples/.env.example +0 -0
  10. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/examples/.gitignore +0 -0
  11. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/examples/async_remember_demo.py +0 -0
  12. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/examples/interactive_demo.py +0 -0
  13. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/examples/verify_credentials.py +0 -0
  14. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/memwal/client.py +0 -0
  15. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/memwal/compatibility.py +0 -0
  16. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/memwal/types.py +0 -0
  17. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/memwal/utils.py +0 -0
  18. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
  19. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/run_tests.py +0 -0
  20. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/tests/__init__.py +0 -0
  21. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/tests/test_client.py +0 -0
  22. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/tests/test_env_presets.py +0 -0
  23. {memwal-0.1.5.dev0 → memwal-0.1.5.dev1}/tests/test_integration.py +0 -0
  24. {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.dev0
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
@@ -116,4 +116,4 @@ __all__ = [
116
116
  "RecallManualResult",
117
117
  ]
118
118
 
119
- __version__ = "0.1.5.dev0"
119
+ __version__ = "0.1.5.dev1"
@@ -130,14 +130,124 @@ def _format_memories(
130
130
  ])
131
131
 
132
132
 
133
- def _fire_and_forget(coro: Any) -> None:
134
- """Schedule an async coroutine as fire-and-forget.
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
- thread = threading.Thread(target=_run, daemon=True)
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(client, memwal, namespace, max_memories, auto_save, min_relevance, log)
499
+ _wrap_async_openai(
500
+ client, memwal, namespace, max_memories, auto_save, min_relevance, log, pending
501
+ )
387
502
  else:
388
- _wrap_sync_openai(client, memwal, namespace, max_memories, auto_save, min_relevance, log)
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
- thread = threading.Thread(target=_analyze, daemon=True)
502
- thread.start()
622
+ pending.spawn_thread(_analyze)
503
623
 
504
624
  return result
505
625
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memwal"
7
- version = "0.1.5.dev0"
7
+ version = "0.1.5.dev1"
8
8
  description = "Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -17,16 +17,19 @@ from __future__ import annotations
17
17
 
18
18
  import asyncio
19
19
  import json
20
- from unittest.mock import AsyncMock, MagicMock
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
- assert "TV support appointment is 9 AM to noon" in user_msgs[0]["content"]
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