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.
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/PKG-INFO +1 -1
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/memwal/__init__.py +1 -1
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/memwal/client.py +42 -7
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/memwal/middleware.py +11 -16
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/pyproject.toml +1 -1
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/tests/test_client.py +69 -1
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/tests/test_middleware.py +78 -0
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/.gitignore +0 -0
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/CHANGELOG.md +0 -0
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/README.md +0 -0
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/examples/.env.example +0 -0
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/examples/.gitignore +0 -0
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/examples/async_remember_demo.py +0 -0
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/examples/interactive_demo.py +0 -0
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/examples/verify_credentials.py +0 -0
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/memwal/compatibility.py +0 -0
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/memwal/mock.py +0 -0
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/memwal/types.py +0 -0
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/memwal/utils.py +0 -0
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/run_tests.py +0 -0
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/tests/__init__.py +0 -0
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/tests/test_auth_rejected_message.py +0 -0
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/tests/test_env_presets.py +0 -0
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/tests/test_integration.py +0 -0
- {memwal-0.1.7.dev2 → memwal-0.1.7.dev4}/tests/test_mock.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
#
|
|
1330
|
-
#
|
|
1331
|
-
#
|
|
1332
|
-
|
|
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,
|
|
1376
|
+
return pool.submit(asyncio.run, wrapped).result()
|
|
1342
1377
|
else:
|
|
1343
|
-
return asyncio.run(
|
|
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
|
-
#
|
|
410
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
@@ -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 =
|
|
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
|
|
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
|