memwal 0.1.7.dev2__tar.gz → 0.1.7.dev4__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 (27) hide show
  1. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/PKG-INFO +1 -1
  2. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/memwal/__init__.py +1 -1
  3. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/memwal/client.py +42 -7
  4. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/memwal/middleware.py +11 -16
  5. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/pyproject.toml +1 -1
  6. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/tests/test_client.py +69 -1
  7. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/tests/test_middleware.py +78 -0
  8. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/.gitignore +0 -0
  9. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/CHANGELOG.md +0 -0
  10. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/README.md +0 -0
  11. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/examples/.env.example +0 -0
  12. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/examples/.gitignore +0 -0
  13. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/examples/async_remember_demo.py +0 -0
  14. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/examples/interactive_demo.py +0 -0
  15. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/examples/verify_credentials.py +0 -0
  16. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/memwal/compatibility.py +0 -0
  17. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/memwal/mock.py +0 -0
  18. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/memwal/types.py +0 -0
  19. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/memwal/utils.py +0 -0
  20. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
  21. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/run_tests.py +0 -0
  22. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/tests/__init__.py +0 -0
  23. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/tests/test_auth_rejected_message.py +0 -0
  24. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/tests/test_env_presets.py +0 -0
  25. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/tests/test_integration.py +0 -0
  26. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/tests/test_mock.py +0 -0
  27. {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/tests/test_signing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memwal
3
- Version: 0.1.7.dev2
3
+ Version: 0.1.7.dev4
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
@@ -122,4 +122,4 @@ __all__ = [
122
122
  "RecallManualResult",
123
123
  ]
124
124
 
125
- __version__ = "0.1.7.dev2"
125
+ __version__ = "0.1.7.dev4"
@@ -28,6 +28,7 @@ from __future__ import annotations
28
28
  import asyncio
29
29
  import base64
30
30
  import json
31
+ import logging
31
32
  import random
32
33
  import time
33
34
  import uuid
@@ -90,6 +91,9 @@ AUTH_REJECTED_MESSAGE = (
90
91
  )
91
92
 
92
93
 
94
+ logger = logging.getLogger("memwal")
95
+
96
+
93
97
  # ============================================================
94
98
  # Polling helpers (PR #121 parity with TS SDK)
95
99
  # ============================================================
@@ -1277,6 +1281,38 @@ class MemWalRememberJobTimeout(MemWalError):
1277
1281
  self.timeout_ms = timeout_ms
1278
1282
 
1279
1283
 
1284
+ async def _discard_http_client(memwal: MemWal) -> None:
1285
+ """Close the cached httpx client, and drop it either way.
1286
+
1287
+ ``close()`` can fail when the client belongs to an event loop that has
1288
+ already finished — the caller still needs ``_client`` cleared so the next
1289
+ request builds one in a loop that is actually running.
1290
+ """
1291
+ try:
1292
+ await memwal.close()
1293
+ except Exception:
1294
+ logger.debug("Closing the cached HTTP client failed", exc_info=True)
1295
+ finally:
1296
+ memwal._client = None
1297
+
1298
+
1299
+ async def _with_fresh_http_client(memwal: MemWal, coro: Any) -> Any:
1300
+ """Run ``coro`` with an httpx client owned by the *current* event loop.
1301
+
1302
+ The sync entry points execute each coroutine in a throwaway ``asyncio.run()``
1303
+ loop, and an ``httpx.AsyncClient`` is bound to the loop that created it, so
1304
+ one can never be reused across calls. Both ends are closed rather than just
1305
+ dropped (GH #606): on the way in for anything an earlier loop left behind —
1306
+ e.g. a caller mixing ``await memwal.recall()`` with the sync wrapper — and in
1307
+ ``finally`` for the client this call created, while its loop is still alive.
1308
+ """
1309
+ await _discard_http_client(memwal)
1310
+ try:
1311
+ return await coro
1312
+ finally:
1313
+ await _discard_http_client(memwal)
1314
+
1315
+
1280
1316
  class MemWalSync:
1281
1317
  """Synchronous wrapper around the async :class:`MemWal` client.
1282
1318
 
@@ -1326,11 +1362,10 @@ class MemWalSync:
1326
1362
  except RuntimeError:
1327
1363
  loop = None
1328
1364
 
1329
- # Reset the httpx client before every asyncio.run() path so it is
1330
- # recreated inside the loop that will use it. This matters in
1331
- # notebooks/Jupyter where the sync wrapper runs coroutines in worker
1332
- # threads with short-lived event loops.
1333
- self._inner._client = None
1365
+ # The httpx client is created and closed inside the same short-lived
1366
+ # loop that uses it. This matters in notebooks/Jupyter where the sync
1367
+ # wrapper runs coroutines in worker threads with their own event loops.
1368
+ wrapped = _with_fresh_http_client(self._inner, coro)
1334
1369
 
1335
1370
  if loop is not None and loop.is_running():
1336
1371
  # Already inside an event loop (e.g. Jupyter).
@@ -1338,9 +1373,9 @@ class MemWalSync:
1338
1373
  import concurrent.futures
1339
1374
 
1340
1375
  with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
1341
- return pool.submit(asyncio.run, coro).result()
1376
+ return pool.submit(asyncio.run, wrapped).result()
1342
1377
  else:
1343
- return asyncio.run(coro)
1378
+ return asyncio.run(wrapped)
1344
1379
 
1345
1380
  def remember(
1346
1381
  self,
@@ -54,7 +54,7 @@ from typing import (
54
54
  Optional,
55
55
  )
56
56
 
57
- from .client import MemWal
57
+ from .client import MemWal, _with_fresh_http_client
58
58
  from .types import RecallMemory
59
59
 
60
60
  if TYPE_CHECKING:
@@ -403,24 +403,20 @@ def with_memwal_langchain(
403
403
 
404
404
  return result
405
405
 
406
+ def _run_memwal(coro_factory: Callable[[], Any]) -> Any:
407
+ # Keep httpx clients bound to the short-lived loop that uses them.
408
+ return _run_blocking(lambda: _with_fresh_http_client(memwal, coro_factory()))
409
+
406
410
  def patched_generate(
407
411
  messages: List[List[BaseMessage]], *args: Any, **kwargs: Any
408
412
  ) -> ChatResult:
409
- # For sync generate, we inject memories synchronously via asyncio.run
410
- import asyncio
411
-
413
+ # Inject via _run_blocking so recall still runs when a loop is already
414
+ # running (notebooks, async hosts) — the same helper the sync OpenAI
415
+ # wrapper uses. Passing the messages through untouched there made
416
+ # Walrus Memory look connected while recall silently never ran.
412
417
  enriched = []
413
418
  for msg_list in messages:
414
- try:
415
- loop = asyncio.get_running_loop()
416
- except RuntimeError:
417
- loop = None
418
-
419
- if loop is not None and loop.is_running():
420
- # Already in async context -- cannot use asyncio.run
421
- enriched.append(msg_list)
422
- else:
423
- enriched.append(asyncio.run(_inject_memories(msg_list)))
419
+ enriched.append(_run_memwal(lambda: _inject_memories(msg_list)))
424
420
 
425
421
  result = original_generate(enriched, *args, **kwargs)
426
422
 
@@ -579,8 +575,7 @@ def _wrap_sync_openai(
579
575
 
580
576
  def _run_memwal(coro_factory: Callable[[], Any]) -> Any:
581
577
  # Keep httpx clients bound to the short-lived loop that uses them.
582
- memwal._client = None
583
- return _run_blocking(coro_factory)
578
+ return _run_blocking(lambda: _with_fresh_http_client(memwal, coro_factory()))
584
579
 
585
580
  def patched_create(*args: Any, **kwargs: Any) -> Any:
586
581
  messages = kwargs.get("messages") or (args[0] if args else None)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memwal"
7
- version = "0.1.7.dev2"
7
+ version = "0.1.7.dev4"
8
8
  description = "Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -120,9 +120,26 @@ def memwal_client() -> MemWal:
120
120
  # ============================================================
121
121
 
122
122
 
123
+ class _FakeHttpClient:
124
+ """Stand-in for httpx.AsyncClient, tracking whether it was closed."""
125
+
126
+ def __init__(self) -> None:
127
+ self.is_closed = False
128
+
129
+ async def aclose(self) -> None:
130
+ self.is_closed = True
131
+
132
+
123
133
  class _SyncRunInner:
134
+ """Minimal stand-in mirroring MemWal's httpx client lifecycle."""
135
+
124
136
  def __init__(self) -> None:
125
- self._client = object()
137
+ self._client: Any = _FakeHttpClient()
138
+
139
+ async def close(self) -> None:
140
+ if self._client is not None and not self._client.is_closed:
141
+ await self._client.aclose()
142
+ self._client = None
126
143
 
127
144
 
128
145
  class TestMemWalSyncRun:
@@ -138,6 +155,57 @@ class TestMemWalSyncRun:
138
155
  assert result == "ok"
139
156
  assert inner._client is None
140
157
 
158
+ async def test_closes_the_client_it_replaces(self) -> None:
159
+ """GH #606: _run() used to null out _client without closing it, leaking
160
+ the connection pool of every client an earlier event loop left behind."""
161
+ inner = _SyncRunInner()
162
+ orphan = inner._client
163
+ sync = MemWalSync(inner) # type: ignore[arg-type]
164
+
165
+ async def operation() -> str:
166
+ return "ok"
167
+
168
+ assert not orphan.is_closed
169
+ assert sync._run(operation()) == "ok"
170
+
171
+ assert orphan.is_closed, "the replaced httpx client was never closed"
172
+ assert inner._client is None
173
+
174
+ async def test_closes_the_client_created_during_the_call(self) -> None:
175
+ """The per-call client is closed inside the loop that created it, so
176
+ repeated sync calls do not accumulate open pools."""
177
+ inner = _SyncRunInner()
178
+ await inner.close()
179
+ sync = MemWalSync(inner) # type: ignore[arg-type]
180
+ created: list = []
181
+
182
+ async def operation() -> str:
183
+ # Stands in for the lazy `_http` property building a client inside
184
+ # whichever loop is currently running.
185
+ inner._client = _FakeHttpClient()
186
+ created.append(inner._client)
187
+ return "ok"
188
+
189
+ assert sync._run(operation()) == "ok"
190
+
191
+ assert len(created) == 1
192
+ assert created[0].is_closed, "the per-call httpx client was left open"
193
+ assert inner._client is None
194
+
195
+ async def test_closes_client_even_when_the_operation_raises(self) -> None:
196
+ inner = _SyncRunInner()
197
+ orphan = inner._client
198
+ sync = MemWalSync(inner) # type: ignore[arg-type]
199
+
200
+ async def failing() -> str:
201
+ raise ValueError("boom")
202
+
203
+ with pytest.raises(ValueError, match="boom"):
204
+ sync._run(failing())
205
+
206
+ assert orphan.is_closed
207
+ assert inner._client is None
208
+
141
209
 
142
210
  # ============================================================
143
211
  # remember() tests
@@ -447,6 +447,84 @@ class TestWithMemWalLangChain:
447
447
  await smart_llm._agenerate([[SystemMessage("only system")]])
448
448
  assert not recall_route.called
449
449
 
450
+ def _capturing_generate(self, captured: list):
451
+ """Replacement for llm._generate that records the batch it received."""
452
+ from langchain_core.messages import AIMessage
453
+ from langchain_core.outputs import ChatGeneration, ChatResult
454
+
455
+ def _generate(messages_batch, *a, **kw):
456
+ captured.extend(messages_batch)
457
+ return ChatResult(
458
+ generations=[ChatGeneration(message=AIMessage(content="ok"))]
459
+ )
460
+
461
+ return _generate
462
+
463
+ @respx.mock
464
+ def test_sync_generate_injects_memories(self) -> None:
465
+ """The plain sync path (no running loop) recalls and injects."""
466
+ _mock_seal_session_prereqs()
467
+ from langchain_core.messages import HumanMessage
468
+
469
+ llm = self._make_llm()
470
+ captured: list = []
471
+ llm._generate = self._capturing_generate(captured)
472
+
473
+ recall_route = respx.post(_RECALL_URL).mock(
474
+ return_value=_mock_recall([
475
+ {"blob_id": "b1", "text": "User loves coffee", "distance": 0.05}
476
+ ])
477
+ )
478
+
479
+ smart_llm = with_memwal_langchain(
480
+ llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
481
+ )
482
+ smart_llm._generate([[HumanMessage("What do I drink?")]])
483
+
484
+ assert recall_route.called
485
+ assert any("User loves coffee" in m.content for m in captured[0])
486
+
487
+ @respx.mock
488
+ async def test_sync_generate_injects_memories_inside_running_loop(self) -> None:
489
+ """GH #607: sync _generate must still recall when a loop is already
490
+ running (notebooks, async hosts).
491
+
492
+ It used to append the untouched msg_list in that branch, so callers got
493
+ a normal LLM answer with no memory context and no warning -- Walrus
494
+ Memory looked connected while recall never ran.
495
+ """
496
+ _mock_seal_session_prereqs()
497
+ from langchain_core.messages import HumanMessage, SystemMessage
498
+
499
+ llm = self._make_llm()
500
+ captured: list = []
501
+ llm._generate = self._capturing_generate(captured)
502
+
503
+ recall_route = respx.post(_RECALL_URL).mock(
504
+ return_value=_mock_recall([
505
+ {"blob_id": "b1", "text": "User loves coffee", "distance": 0.05}
506
+ ])
507
+ )
508
+
509
+ smart_llm = with_memwal_langchain(
510
+ llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
511
+ )
512
+
513
+ # An async test body is itself a running loop -- the exact condition
514
+ # that used to disable injection.
515
+ assert asyncio.get_running_loop().is_running()
516
+ smart_llm._generate([[HumanMessage("What do I drink?")]])
517
+
518
+ assert recall_route.called, "recall was skipped inside a running loop"
519
+ assert len(captured) == 1
520
+ injected = captured[0]
521
+ assert len(injected) == 3, "guard + memory message were not injected"
522
+ assert any(
523
+ isinstance(m, HumanMessage) and "User loves coffee" in m.content
524
+ for m in injected
525
+ )
526
+ assert any(isinstance(m, SystemMessage) for m in injected)
527
+
450
528
  def test_wraps_a_real_pydantic_backed_chat_model(self) -> None:
451
529
  """with_memwal_langchain must work on a real BaseChatModel, not just
452
530
  a MagicMock. LangChain chat models are Pydantic models that reject
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes