memwal 0.1.11.dev10__tar.gz → 0.1.11.dev12__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.11.dev10 → memwal-0.1.11.dev12}/PKG-INFO +1 -1
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/memwal/__init__.py +1 -1
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/memwal/client.py +3 -5
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/memwal/middleware.py +106 -15
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/memwal/mock.py +5 -4
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/memwal/utils.py +3 -1
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/pyproject.toml +1 -1
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_client.py +47 -1
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_middleware.py +154 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_mock.py +5 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_signing.py +9 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/.gitignore +0 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/CHANGELOG.md +0 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/README.md +0 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/examples/.env.example +0 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/examples/.gitignore +0 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/examples/async_remember_demo.py +0 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/examples/interactive_demo.py +0 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/examples/verify_credentials.py +0 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/memwal/compatibility.py +0 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/memwal/types.py +0 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/run_tests.py +0 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/__init__.py +0 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_auth_rejected_message.py +0 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_blank_text.py +0 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_env_presets.py +0 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_integration.py +0 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_list_namespaces.py +0 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_normalize_server_url.py +0 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_polling_delay.py +0 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_remember_derived_key.py +0 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_retry_after_http_date.py +0 -0
- {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_transport_retry.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: memwal
|
|
3
|
-
Version: 0.1.11.
|
|
3
|
+
Version: 0.1.11.dev12
|
|
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
|
|
@@ -1063,14 +1063,12 @@ class MemWal:
|
|
|
1063
1063
|
dropped = int(raw_dropped)
|
|
1064
1064
|
except (TypeError, ValueError):
|
|
1065
1065
|
dropped = 0
|
|
1066
|
+
total_count = data.get("total", len(memories))
|
|
1066
1067
|
if max_distance is not None:
|
|
1067
1068
|
memories = [m for m in memories if m.distance < max_distance]
|
|
1068
|
-
return RecallResult(
|
|
1069
|
-
results=memories, total=len(memories), dropped_count=dropped
|
|
1070
|
-
)
|
|
1071
1069
|
return RecallResult(
|
|
1072
1070
|
results=memories,
|
|
1073
|
-
total=
|
|
1071
|
+
total=total_count,
|
|
1074
1072
|
dropped_count=dropped,
|
|
1075
1073
|
)
|
|
1076
1074
|
|
|
@@ -1817,7 +1815,7 @@ class MemWalClockDriftError(MemWalError):
|
|
|
1817
1815
|
|
|
1818
1816
|
def _redact_internal_urls(text: str) -> str:
|
|
1819
1817
|
return re.sub(
|
|
1820
|
-
r"https?://(?:localhost|127\.0\.0\.
|
|
1818
|
+
r"https?://(?:(?:[\w-]+\.)*localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[::1\]|0\.0\.0\.0)(?::\d+)?[^\s)\]>'\"]*",
|
|
1821
1819
|
"[internal]",
|
|
1822
1820
|
text,
|
|
1823
1821
|
flags=re.IGNORECASE,
|
|
@@ -140,10 +140,67 @@ class _PendingSaves:
|
|
|
140
140
|
run — this keeps a strong reference until each one is done.
|
|
141
141
|
"""
|
|
142
142
|
|
|
143
|
-
def __init__(
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
on_save_error: Optional[Callable[[BaseException], None]] = None,
|
|
146
|
+
) -> None:
|
|
144
147
|
self._tasks: "set[asyncio.Task[Any]]" = set()
|
|
145
148
|
self._threads: List[threading.Thread] = []
|
|
149
|
+
self._failures: List[tuple[str, str]] = []
|
|
150
|
+
self._dropped = 0
|
|
151
|
+
self._on_save_error = on_save_error
|
|
146
152
|
self._lock = threading.Lock()
|
|
153
|
+
self._callback_threads: set[int] = set()
|
|
154
|
+
|
|
155
|
+
def note_error(self, err: BaseException) -> None:
|
|
156
|
+
"""Remember a background analyze failure so flush does not report success.
|
|
157
|
+
|
|
158
|
+
The ledger is updated before the callback. A flush from that callback
|
|
159
|
+
returns without draining or clearing the failure. A flush from any
|
|
160
|
+
other thread still waits and raises. The ledger keeps only a type and
|
|
161
|
+
a one-line message, so a client that never flushes does not pin the
|
|
162
|
+
request traceback.
|
|
163
|
+
"""
|
|
164
|
+
type_name = type(err).__name__
|
|
165
|
+
message = str(err).splitlines()[0][:200] if str(err) else ""
|
|
166
|
+
with self._lock:
|
|
167
|
+
if len(self._failures) < 8:
|
|
168
|
+
self._failures.append((type_name, message))
|
|
169
|
+
else:
|
|
170
|
+
self._dropped += 1
|
|
171
|
+
if self._on_save_error is None:
|
|
172
|
+
return
|
|
173
|
+
ident = threading.get_ident()
|
|
174
|
+
with self._lock:
|
|
175
|
+
self._callback_threads.add(ident)
|
|
176
|
+
try:
|
|
177
|
+
self._on_save_error(err)
|
|
178
|
+
except Exception:
|
|
179
|
+
logger.warning("Walrus Memory on_save_error raised", exc_info=True)
|
|
180
|
+
finally:
|
|
181
|
+
with self._lock:
|
|
182
|
+
self._callback_threads.discard(ident)
|
|
183
|
+
|
|
184
|
+
def _flush_called_from_callback(self) -> bool:
|
|
185
|
+
with self._lock:
|
|
186
|
+
return threading.get_ident() in self._callback_threads
|
|
187
|
+
|
|
188
|
+
def _note_result(self, result: Any) -> None:
|
|
189
|
+
if isinstance(result, BaseException):
|
|
190
|
+
self.note_error(result)
|
|
191
|
+
|
|
192
|
+
def _raise_if_failed(self) -> None:
|
|
193
|
+
with self._lock:
|
|
194
|
+
failures, self._failures = self._failures, []
|
|
195
|
+
dropped, self._dropped = self._dropped, 0
|
|
196
|
+
total = len(failures) + dropped
|
|
197
|
+
if total == 0:
|
|
198
|
+
return
|
|
199
|
+
type_name, message = failures[0] if failures else ("Error", "")
|
|
200
|
+
detail = f"{type_name}: {message}" if message else type_name
|
|
201
|
+
raise RuntimeError(
|
|
202
|
+
f"Walrus Memory auto-save failed ({total} call(s)): {detail}"
|
|
203
|
+
)
|
|
147
204
|
|
|
148
205
|
def track_task(self, task: "asyncio.Task[Any]") -> None:
|
|
149
206
|
self._tasks.add(task)
|
|
@@ -169,25 +226,44 @@ class _PendingSaves:
|
|
|
169
226
|
thread.start()
|
|
170
227
|
|
|
171
228
|
async def flush(self) -> None:
|
|
172
|
-
"""Await every pending task, then join every pending thread.
|
|
229
|
+
"""Await every pending task, then join every pending thread.
|
|
230
|
+
|
|
231
|
+
Raises when any background auto-save failed. The chat call itself
|
|
232
|
+
still returned; this is the signal for a caller that waited. A call
|
|
233
|
+
made from ``on_save_error`` returns before draining, joining, or
|
|
234
|
+
clearing the failure being recorded.
|
|
235
|
+
"""
|
|
236
|
+
if self._flush_called_from_callback():
|
|
237
|
+
return
|
|
173
238
|
if self._tasks:
|
|
174
|
-
|
|
239
|
+
tasks = list(self._tasks)
|
|
240
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
241
|
+
for result in results:
|
|
242
|
+
self._note_result(result)
|
|
175
243
|
self._join_threads()
|
|
244
|
+
self._raise_if_failed()
|
|
176
245
|
|
|
177
246
|
def flush_sync(self) -> None:
|
|
178
|
-
"""Join every pending thread
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
247
|
+
"""Join every pending thread and raise when a background auto-save failed.
|
|
248
|
+
|
|
249
|
+
A pending Task belongs to the loop that created it. When this thread
|
|
250
|
+
has no running loop, those tasks are awaited here. When it does, they
|
|
251
|
+
are logged and left for :meth:`flush`. A call made from
|
|
252
|
+
``on_save_error`` returns before draining, joining, or clearing the
|
|
253
|
+
failure being recorded.
|
|
254
|
+
"""
|
|
255
|
+
if self._flush_called_from_callback():
|
|
256
|
+
return
|
|
185
257
|
if self._tasks:
|
|
186
|
-
|
|
187
|
-
|
|
258
|
+
tasks = list(self._tasks)
|
|
259
|
+
|
|
260
|
+
async def _drain(pending_tasks: "list[asyncio.Task[Any]]") -> None:
|
|
261
|
+
results = await asyncio.gather(*pending_tasks, return_exceptions=True)
|
|
262
|
+
for result in results:
|
|
263
|
+
self._note_result(result)
|
|
188
264
|
|
|
189
265
|
try:
|
|
190
|
-
asyncio.run(_drain(
|
|
266
|
+
asyncio.run(_drain(tasks))
|
|
191
267
|
except (RuntimeError, ValueError):
|
|
192
268
|
logger.warning(
|
|
193
269
|
"Walrus Memory flush_sync() could not await %d pending "
|
|
@@ -197,10 +273,14 @@ class _PendingSaves:
|
|
|
197
273
|
len(self._tasks),
|
|
198
274
|
)
|
|
199
275
|
self._join_threads()
|
|
276
|
+
self._raise_if_failed()
|
|
200
277
|
|
|
201
278
|
def _join_threads(self) -> None:
|
|
279
|
+
current = threading.current_thread()
|
|
202
280
|
with self._lock:
|
|
203
281
|
threads, self._threads = self._threads, []
|
|
282
|
+
self._threads.extend(thread for thread in threads if thread is current)
|
|
283
|
+
threads = [thread for thread in threads if thread is not current]
|
|
204
284
|
for thread in threads:
|
|
205
285
|
thread.join()
|
|
206
286
|
|
|
@@ -291,6 +371,7 @@ def with_memwal_langchain(
|
|
|
291
371
|
min_relevance: float = 0.3,
|
|
292
372
|
debug: bool = False,
|
|
293
373
|
env: Optional[str] = None,
|
|
374
|
+
on_save_error: Optional[Callable[[BaseException], None]] = None,
|
|
294
375
|
) -> "BaseChatModel":
|
|
295
376
|
"""Wrap a LangChain ``BaseChatModel`` with Walrus Memory management.
|
|
296
377
|
|
|
@@ -313,6 +394,9 @@ def with_memwal_langchain(
|
|
|
313
394
|
debug: Enable debug logging.
|
|
314
395
|
env: Optional relayer preset (``"prod"`` / ``"dev"`` / ``"staging"`` /
|
|
315
396
|
``"local"``). Same precedence as :meth:`MemWal.create`.
|
|
397
|
+
on_save_error: Called with the original exception when a background
|
|
398
|
+
auto-save fails. ``flush`` / ``flush_sync`` from this callback does
|
|
399
|
+
not clear that failure.
|
|
316
400
|
|
|
317
401
|
Returns:
|
|
318
402
|
A wrapped ``BaseChatModel`` that automatically uses Walrus Memory.
|
|
@@ -335,7 +419,7 @@ def with_memwal_langchain(
|
|
|
335
419
|
)
|
|
336
420
|
|
|
337
421
|
log = logger.debug if not debug else logger.warning
|
|
338
|
-
pending = _PendingSaves()
|
|
422
|
+
pending = _PendingSaves(on_save_error)
|
|
339
423
|
|
|
340
424
|
original_agenerate = llm._agenerate
|
|
341
425
|
original_generate = llm._generate
|
|
@@ -408,6 +492,7 @@ def with_memwal_langchain(
|
|
|
408
492
|
await memwal.analyze(user_text, namespace)
|
|
409
493
|
except Exception as e:
|
|
410
494
|
log(f"[Walrus Memory] Auto-save failed: {e}")
|
|
495
|
+
pending.note_error(e)
|
|
411
496
|
|
|
412
497
|
async def patched_agenerate(
|
|
413
498
|
messages: List[List[BaseMessage]], *args: Any, **kwargs: Any
|
|
@@ -470,6 +555,7 @@ def with_memwal_openai(
|
|
|
470
555
|
min_relevance: float = 0.3,
|
|
471
556
|
debug: bool = False,
|
|
472
557
|
env: Optional[str] = None,
|
|
558
|
+
on_save_error: Optional[Callable[[BaseException], None]] = None,
|
|
473
559
|
) -> Any:
|
|
474
560
|
"""Wrap an OpenAI client with Walrus Memory management.
|
|
475
561
|
|
|
@@ -494,6 +580,9 @@ def with_memwal_openai(
|
|
|
494
580
|
debug: Enable debug logging.
|
|
495
581
|
env: Optional relayer preset (``"prod"`` / ``"dev"`` / ``"staging"`` /
|
|
496
582
|
``"local"``). Same precedence as :meth:`MemWal.create`.
|
|
583
|
+
on_save_error: Called with the original exception when a background
|
|
584
|
+
auto-save fails. ``flush`` / ``flush_sync`` from this callback does
|
|
585
|
+
not clear that failure.
|
|
497
586
|
|
|
498
587
|
Returns:
|
|
499
588
|
The same client, with ``chat.completions.create`` wrapped to use Walrus Memory.
|
|
@@ -507,7 +596,7 @@ def with_memwal_openai(
|
|
|
507
596
|
)
|
|
508
597
|
|
|
509
598
|
log = logger.debug if not debug else logger.warning
|
|
510
|
-
pending = _PendingSaves()
|
|
599
|
+
pending = _PendingSaves(on_save_error)
|
|
511
600
|
|
|
512
601
|
is_async = hasattr(client, "_async_client") or type(client).__name__ == "AsyncOpenAI"
|
|
513
602
|
|
|
@@ -572,6 +661,7 @@ def _wrap_async_openai(
|
|
|
572
661
|
await memwal.analyze(user_text, namespace)
|
|
573
662
|
except Exception as e:
|
|
574
663
|
log(f"[Walrus Memory] Auto-save failed: {e}")
|
|
664
|
+
pending.note_error(e)
|
|
575
665
|
|
|
576
666
|
_fire_and_forget(_analyze(), pending)
|
|
577
667
|
|
|
@@ -633,6 +723,7 @@ def _wrap_sync_openai(
|
|
|
633
723
|
_run_memwal(lambda: memwal.analyze(user_text, namespace))
|
|
634
724
|
except Exception as e:
|
|
635
725
|
log(f"[Walrus Memory] Auto-save failed: {e}")
|
|
726
|
+
pending.note_error(e)
|
|
636
727
|
|
|
637
728
|
pending.spawn_thread(_analyze)
|
|
638
729
|
|
|
@@ -278,14 +278,15 @@ class MemWalMock:
|
|
|
278
278
|
for memory in self._memories
|
|
279
279
|
if memory.namespace == resolved_namespace
|
|
280
280
|
]
|
|
281
|
-
if max_distance is not None:
|
|
282
|
-
ranked = [item for item in ranked if item[1] < max_distance]
|
|
283
281
|
ranked.sort(key=lambda item: (item[1], item[0].sequence))
|
|
282
|
+
page = ranked[:limit]
|
|
283
|
+
if max_distance is not None:
|
|
284
|
+
page = [item for item in page if item[1] < max_distance]
|
|
284
285
|
results = [
|
|
285
286
|
RecallMemory(blob_id=memory.blob_id, text=memory.text, distance=score)
|
|
286
|
-
for memory, score in
|
|
287
|
+
for memory, score in page
|
|
287
288
|
]
|
|
288
|
-
return RecallResult(results=results, total=len(
|
|
289
|
+
return RecallResult(results=results, total=len(ranked[:limit]))
|
|
289
290
|
|
|
290
291
|
async def analyze(
|
|
291
292
|
self,
|
|
@@ -299,7 +299,9 @@ def normalize_private_key(key: str) -> str:
|
|
|
299
299
|
candidate = key.strip()
|
|
300
300
|
if candidate.lower().startswith("suiprivkey1"):
|
|
301
301
|
return bytes_to_hex(decode_sui_private_key(candidate))
|
|
302
|
-
|
|
302
|
+
if candidate.startswith(("0x", "0X")):
|
|
303
|
+
return candidate[2:]
|
|
304
|
+
return candidate
|
|
303
305
|
|
|
304
306
|
|
|
305
307
|
def encode_sui_private_key(seed_bytes: bytes) -> str:
|
|
@@ -935,9 +935,33 @@ class TestRecall:
|
|
|
935
935
|
body = json.loads(route.calls[0].request.content)
|
|
936
936
|
assert body["limit"] == 10
|
|
937
937
|
assert len(result.results) == 1
|
|
938
|
-
assert result.total ==
|
|
938
|
+
assert result.total == 2
|
|
939
939
|
assert result.results[0].blob_id == "b1"
|
|
940
940
|
|
|
941
|
+
@respx.mock
|
|
942
|
+
async def test_max_distance_filters_all_preserves_total(
|
|
943
|
+
self, memwal_client: MemWal
|
|
944
|
+
) -> None:
|
|
945
|
+
"""When all matches exceed max_distance, results is empty but total
|
|
946
|
+
preserves matches (#1066)."""
|
|
947
|
+
mock_seal_session_prereqs()
|
|
948
|
+
respx.post(f"{_TEST_SERVER}/api/recall").mock(
|
|
949
|
+
return_value=httpx.Response(
|
|
950
|
+
200,
|
|
951
|
+
json={
|
|
952
|
+
"results": [
|
|
953
|
+
{"blob_id": "b1", "text": "I love coffee", "distance": 0.8},
|
|
954
|
+
{"blob_id": "b2", "text": "I live in Tokyo", "distance": 0.9},
|
|
955
|
+
],
|
|
956
|
+
"total": 2,
|
|
957
|
+
},
|
|
958
|
+
)
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
result = await memwal_client.recall("coffee", limit=10, max_distance=0.5)
|
|
962
|
+
assert len(result.results) == 0
|
|
963
|
+
assert result.total == 2
|
|
964
|
+
|
|
941
965
|
@respx.mock
|
|
942
966
|
async def test_get_signed_request_uses_empty_body_hash_and_no_wire_body(
|
|
943
967
|
self, memwal_client: MemWal
|
|
@@ -1583,3 +1607,25 @@ class TestSealSession:
|
|
|
1583
1607
|
assert exported["ttlMin"] == 5
|
|
1584
1608
|
assert exported["sessionKey"].startswith("suiprivkey")
|
|
1585
1609
|
assert exported["personalMessageSignature"]
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
class TestRedactInternalUrls:
|
|
1613
|
+
@pytest.mark.parametrize(
|
|
1614
|
+
"raw, expected",
|
|
1615
|
+
[
|
|
1616
|
+
("Error at http://localhost:8080/path", "Error at [internal]"),
|
|
1617
|
+
("Error at http://127.0.0.1:3000/api", "Error at [internal]"),
|
|
1618
|
+
("Error at http://127.0.0.2:8000/status", "Error at [internal]"),
|
|
1619
|
+
("Error at http://127.128.0.1:9000/info", "Error at [internal]"),
|
|
1620
|
+
("Error at http://[::1]:9000/seal/encrypt", "Error at [internal]"),
|
|
1621
|
+
("Error at http://0.0.0.0:8000/health", "Error at [internal]"),
|
|
1622
|
+
("Error at http://sidecar.localhost:8080/v1", "Error at [internal]"),
|
|
1623
|
+
("Error at http://sub.domain.localhost:3000/test", "Error at [internal]"),
|
|
1624
|
+
("Error at https://api.wal.app/v1/remember", "Error at https://api.wal.app/v1/remember"),
|
|
1625
|
+
],
|
|
1626
|
+
)
|
|
1627
|
+
def test_redact_internal_urls(self, raw: str, expected: str) -> None:
|
|
1628
|
+
from memwal.client import _redact_internal_urls
|
|
1629
|
+
|
|
1630
|
+
assert _redact_internal_urls(raw) == expected
|
|
1631
|
+
|
|
@@ -1000,3 +1000,157 @@ class TestPendingSaves:
|
|
|
1000
1000
|
|
|
1001
1001
|
time.sleep(0.05)
|
|
1002
1002
|
assert len(pending._threads) == 0
|
|
1003
|
+
|
|
1004
|
+
def test_flush_raises_when_a_background_save_failed(self) -> None:
|
|
1005
|
+
seen: list[BaseException] = []
|
|
1006
|
+
pending = _PendingSaves(seen.append)
|
|
1007
|
+
pending.note_error(RuntimeError("analyze down"))
|
|
1008
|
+
|
|
1009
|
+
async def go() -> None:
|
|
1010
|
+
await pending.flush()
|
|
1011
|
+
|
|
1012
|
+
try:
|
|
1013
|
+
asyncio.run(go())
|
|
1014
|
+
except RuntimeError as err:
|
|
1015
|
+
assert "auto-save failed" in str(err)
|
|
1016
|
+
else:
|
|
1017
|
+
raise AssertionError("flush did not raise")
|
|
1018
|
+
assert len(seen) == 1
|
|
1019
|
+
|
|
1020
|
+
def test_flush_sync_raises_when_a_background_save_failed(self) -> None:
|
|
1021
|
+
pending = _PendingSaves()
|
|
1022
|
+
pending.note_error(RuntimeError("analyze down"))
|
|
1023
|
+
try:
|
|
1024
|
+
pending.flush_sync()
|
|
1025
|
+
except RuntimeError as err:
|
|
1026
|
+
assert "auto-save failed" in str(err)
|
|
1027
|
+
else:
|
|
1028
|
+
raise AssertionError("flush_sync did not raise")
|
|
1029
|
+
|
|
1030
|
+
def test_flush_sees_a_failing_task_without_note_error(self) -> None:
|
|
1031
|
+
pending = _PendingSaves()
|
|
1032
|
+
|
|
1033
|
+
async def fail() -> None:
|
|
1034
|
+
raise RuntimeError("analyze down")
|
|
1035
|
+
|
|
1036
|
+
async def go() -> None:
|
|
1037
|
+
task = asyncio.create_task(fail())
|
|
1038
|
+
pending.track_task(task)
|
|
1039
|
+
await pending.flush()
|
|
1040
|
+
|
|
1041
|
+
try:
|
|
1042
|
+
asyncio.run(go())
|
|
1043
|
+
except RuntimeError as err:
|
|
1044
|
+
assert "analyze down" in str(err)
|
|
1045
|
+
else:
|
|
1046
|
+
raise AssertionError("flush did not raise")
|
|
1047
|
+
|
|
1048
|
+
def test_callback_flush_does_not_swallow_the_failure(self) -> None:
|
|
1049
|
+
pending = _PendingSaves()
|
|
1050
|
+
|
|
1051
|
+
def on_error(_err: BaseException) -> None:
|
|
1052
|
+
pending.flush_sync()
|
|
1053
|
+
|
|
1054
|
+
pending._on_save_error = on_error
|
|
1055
|
+
pending.note_error(RuntimeError("analyze down"))
|
|
1056
|
+
|
|
1057
|
+
async def go() -> None:
|
|
1058
|
+
await pending.flush()
|
|
1059
|
+
|
|
1060
|
+
try:
|
|
1061
|
+
asyncio.run(go())
|
|
1062
|
+
except RuntimeError as err:
|
|
1063
|
+
assert "analyze down" in str(err)
|
|
1064
|
+
else:
|
|
1065
|
+
raise AssertionError("flush did not raise")
|
|
1066
|
+
|
|
1067
|
+
def test_worker_callback_flush_sync_keeps_the_failure(self) -> None:
|
|
1068
|
+
pending = _PendingSaves()
|
|
1069
|
+
callback_error: list[str] = []
|
|
1070
|
+
saw_self = []
|
|
1071
|
+
|
|
1072
|
+
def on_error(_err: BaseException) -> None:
|
|
1073
|
+
try:
|
|
1074
|
+
pending.flush_sync()
|
|
1075
|
+
except Exception as err:
|
|
1076
|
+
callback_error.append(repr(err))
|
|
1077
|
+
with pending._lock:
|
|
1078
|
+
saw_self.append(threading.current_thread() in pending._threads)
|
|
1079
|
+
|
|
1080
|
+
pending._on_save_error = on_error
|
|
1081
|
+
done = threading.Event()
|
|
1082
|
+
|
|
1083
|
+
def fail() -> None:
|
|
1084
|
+
pending.note_error(RuntimeError("analyze down"))
|
|
1085
|
+
done.set()
|
|
1086
|
+
|
|
1087
|
+
pending.spawn_thread(fail)
|
|
1088
|
+
assert done.wait(2)
|
|
1089
|
+
assert callback_error == []
|
|
1090
|
+
assert saw_self == [True]
|
|
1091
|
+
try:
|
|
1092
|
+
pending.flush_sync()
|
|
1093
|
+
except RuntimeError as err:
|
|
1094
|
+
assert "analyze down" in str(err)
|
|
1095
|
+
else:
|
|
1096
|
+
raise AssertionError("flush_sync did not raise")
|
|
1097
|
+
|
|
1098
|
+
def test_two_worker_callbacks_do_not_join_each_other(self) -> None:
|
|
1099
|
+
"""Overlapping on_save_error flushes must not join the other worker.
|
|
1100
|
+
|
|
1101
|
+
Each callback returns before drain, join, and raise. The later caller
|
|
1102
|
+
flush still reports both failures.
|
|
1103
|
+
"""
|
|
1104
|
+
pending = _PendingSaves()
|
|
1105
|
+
entered = threading.Barrier(2)
|
|
1106
|
+
callback_error: list[str] = []
|
|
1107
|
+
done = threading.Barrier(3)
|
|
1108
|
+
|
|
1109
|
+
def on_error(_err: BaseException) -> None:
|
|
1110
|
+
entered.wait(2)
|
|
1111
|
+
try:
|
|
1112
|
+
pending.flush_sync()
|
|
1113
|
+
except Exception as err:
|
|
1114
|
+
callback_error.append(repr(err))
|
|
1115
|
+
|
|
1116
|
+
pending._on_save_error = on_error
|
|
1117
|
+
|
|
1118
|
+
def fail(message: str):
|
|
1119
|
+
def run() -> None:
|
|
1120
|
+
pending.note_error(RuntimeError(message))
|
|
1121
|
+
done.wait(2)
|
|
1122
|
+
return run
|
|
1123
|
+
|
|
1124
|
+
pending.spawn_thread(fail("analyze down"))
|
|
1125
|
+
pending.spawn_thread(fail("analyze also down"))
|
|
1126
|
+
done.wait(2)
|
|
1127
|
+
assert callback_error == []
|
|
1128
|
+
try:
|
|
1129
|
+
pending.flush_sync()
|
|
1130
|
+
except RuntimeError as err:
|
|
1131
|
+
assert "2 call" in str(err)
|
|
1132
|
+
assert "analyze down" in str(err)
|
|
1133
|
+
else:
|
|
1134
|
+
raise AssertionError("flush_sync did not raise")
|
|
1135
|
+
|
|
1136
|
+
def test_flush_from_another_thread_still_raises_during_callback(self) -> None:
|
|
1137
|
+
"""The callback thread bails out. Any other flush still reports the failure."""
|
|
1138
|
+
import time
|
|
1139
|
+
|
|
1140
|
+
pending = _PendingSaves()
|
|
1141
|
+
started = threading.Event()
|
|
1142
|
+
|
|
1143
|
+
def on_error(_err: BaseException) -> None:
|
|
1144
|
+
started.set()
|
|
1145
|
+
time.sleep(0.2)
|
|
1146
|
+
|
|
1147
|
+
pending._on_save_error = on_error
|
|
1148
|
+
pending.spawn_thread(lambda: pending.note_error(RuntimeError("analyze down")))
|
|
1149
|
+
assert started.wait(2)
|
|
1150
|
+
try:
|
|
1151
|
+
pending.flush_sync()
|
|
1152
|
+
except RuntimeError as err:
|
|
1153
|
+
assert "analyze down" in str(err)
|
|
1154
|
+
else:
|
|
1155
|
+
raise AssertionError("flush_sync did not raise")
|
|
1156
|
+
|
|
@@ -57,6 +57,11 @@ async def test_mock_isolates_namespaces_and_honors_max_distance():
|
|
|
57
57
|
assert [memory.text for memory in alice.results] == ["Alice likes ramen"]
|
|
58
58
|
assert [memory.text for memory in bob.results] == ["Bob likes tacos"]
|
|
59
59
|
assert empty.total == 0
|
|
60
|
+
dropped = await mock.recall(
|
|
61
|
+
RecallParams(query="likes", namespace="user-a", max_distance=0)
|
|
62
|
+
)
|
|
63
|
+
assert dropped.results == []
|
|
64
|
+
assert dropped.total == 1
|
|
60
65
|
|
|
61
66
|
|
|
62
67
|
@pytest.mark.asyncio
|
|
@@ -324,11 +324,20 @@ class TestPrivateKeyFormats:
|
|
|
324
324
|
for form in (
|
|
325
325
|
self._SEED_HEX,
|
|
326
326
|
f"0x{self._SEED_HEX}",
|
|
327
|
+
f"0X{self._SEED_HEX}",
|
|
328
|
+
f" 0X{self._SEED_HEX} ",
|
|
327
329
|
encode_sui_private_key(seed),
|
|
328
330
|
f" {encode_sui_private_key(seed)} ",
|
|
329
331
|
):
|
|
330
332
|
assert normalize_private_key(form) == self._SEED_HEX
|
|
331
333
|
|
|
334
|
+
def test_uppercase_0x_prefix_key_produces_valid_client(self) -> None:
|
|
335
|
+
from memwal.client import MemWal
|
|
336
|
+
|
|
337
|
+
upper_0x = f"0X{self._SEED_HEX}"
|
|
338
|
+
client = MemWal.create(key=upper_0x, account_id="0xtest")
|
|
339
|
+
assert client._private_key_hex == self._SEED_HEX
|
|
340
|
+
|
|
332
341
|
def test_bech32_key_produces_the_same_client_identity(self) -> None:
|
|
333
342
|
from memwal.client import MemWal
|
|
334
343
|
from memwal.utils import encode_sui_private_key
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|