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.
Files changed (34) hide show
  1. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/PKG-INFO +1 -1
  2. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/memwal/__init__.py +1 -1
  3. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/memwal/client.py +3 -5
  4. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/memwal/middleware.py +106 -15
  5. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/memwal/mock.py +5 -4
  6. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/memwal/utils.py +3 -1
  7. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/pyproject.toml +1 -1
  8. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_client.py +47 -1
  9. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_middleware.py +154 -0
  10. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_mock.py +5 -0
  11. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_signing.py +9 -0
  12. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/.gitignore +0 -0
  13. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/CHANGELOG.md +0 -0
  14. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/README.md +0 -0
  15. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/examples/.env.example +0 -0
  16. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/examples/.gitignore +0 -0
  17. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/examples/async_remember_demo.py +0 -0
  18. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/examples/interactive_demo.py +0 -0
  19. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/examples/verify_credentials.py +0 -0
  20. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/memwal/compatibility.py +0 -0
  21. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/memwal/types.py +0 -0
  22. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
  23. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/run_tests.py +0 -0
  24. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/__init__.py +0 -0
  25. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_auth_rejected_message.py +0 -0
  26. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_blank_text.py +0 -0
  27. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_env_presets.py +0 -0
  28. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_integration.py +0 -0
  29. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_list_namespaces.py +0 -0
  30. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_normalize_server_url.py +0 -0
  31. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_polling_delay.py +0 -0
  32. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_remember_derived_key.py +0 -0
  33. {memwal-0.1.11.dev10 → memwal-0.1.11.dev12}/tests/test_retry_after_http_date.py +0 -0
  34. {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.dev10
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
@@ -128,4 +128,4 @@ __all__ = [
128
128
  "RecallManualResult",
129
129
  ]
130
130
 
131
- __version__ = "0.1.11.dev10"
131
+ __version__ = "0.1.11.dev12"
@@ -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=data.get("total", len(memories)),
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\.1)(?::\d+)?[^\s)\]>'\"]*",
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__(self) -> None:
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
- await asyncio.gather(*list(self._tasks), return_exceptions=True)
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. 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."""
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
- async def _drain(tasks: "list[asyncio.Task[Any]]") -> None:
187
- await asyncio.gather(*tasks, return_exceptions=True)
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(list(self._tasks)))
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 ranked[:limit]
287
+ for memory, score in page
287
288
  ]
288
- return RecallResult(results=results, total=len(results))
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
- return candidate[2:] if candidate.startswith("0x") else candidate
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:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memwal"
7
- version = "0.1.11.dev10"
7
+ version = "0.1.11.dev12"
8
8
  description = "Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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 == 1
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