memwal 0.2.2.dev5__tar.gz → 0.2.3__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 (43) hide show
  1. {memwal-0.2.2.dev5 → memwal-0.2.3}/CHANGELOG.md +7 -0
  2. {memwal-0.2.2.dev5 → memwal-0.2.3}/PKG-INFO +1 -1
  3. {memwal-0.2.2.dev5 → memwal-0.2.3}/memwal/__init__.py +3 -1
  4. {memwal-0.2.2.dev5 → memwal-0.2.3}/memwal/client.py +70 -4
  5. {memwal-0.2.2.dev5 → memwal-0.2.3}/memwal/middleware.py +9 -3
  6. {memwal-0.2.2.dev5 → memwal-0.2.3}/memwal/mock.py +14 -1
  7. {memwal-0.2.2.dev5 → memwal-0.2.3}/memwal/types.py +3 -0
  8. {memwal-0.2.2.dev5 → memwal-0.2.3}/pyproject.toml +1 -1
  9. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_client.py +78 -4
  10. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_mock.py +16 -0
  11. {memwal-0.2.2.dev5 → memwal-0.2.3}/.gitignore +0 -0
  12. {memwal-0.2.2.dev5 → memwal-0.2.3}/README.md +0 -0
  13. {memwal-0.2.2.dev5 → memwal-0.2.3}/examples/.env.example +0 -0
  14. {memwal-0.2.2.dev5 → memwal-0.2.3}/examples/.gitignore +0 -0
  15. {memwal-0.2.2.dev5 → memwal-0.2.3}/examples/async_remember_demo.py +0 -0
  16. {memwal-0.2.2.dev5 → memwal-0.2.3}/examples/interactive_demo.py +0 -0
  17. {memwal-0.2.2.dev5 → memwal-0.2.3}/examples/verify_credentials.py +0 -0
  18. {memwal-0.2.2.dev5 → memwal-0.2.3}/memwal/compatibility.py +0 -0
  19. {memwal-0.2.2.dev5 → memwal-0.2.3}/memwal/utils.py +0 -0
  20. {memwal-0.2.2.dev5 → memwal-0.2.3}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
  21. {memwal-0.2.2.dev5 → memwal-0.2.3}/run_tests.py +0 -0
  22. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/__init__.py +0 -0
  23. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_ask_validation.py +0 -0
  24. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_auth_rejected_message.py +0 -0
  25. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_blank_text.py +0 -0
  26. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_destroy_and_cancel_shield.py +0 -0
  27. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_empty_namespace.py +0 -0
  28. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_env_presets.py +0 -0
  29. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_forget.py +0 -0
  30. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_integration.py +0 -0
  31. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_json_body.py +0 -0
  32. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_list_namespaces.py +0 -0
  33. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_middleware.py +0 -0
  34. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_missing_response_fields.py +0 -0
  35. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_nonfinite_validation.py +0 -0
  36. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_normalize_server_url.py +0 -0
  37. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_polling_delay.py +0 -0
  38. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_recall_validation.py +0 -0
  39. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_recall_weights.py +0 -0
  40. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_remember_derived_key.py +0 -0
  41. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_retry_after_http_date.py +0 -0
  42. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_signing.py +0 -0
  43. {memwal-0.2.2.dev5 → memwal-0.2.3}/tests/test_transport_retry.py +0 -0
@@ -1,5 +1,12 @@
1
1
  # memwal
2
2
 
3
+ ## 0.2.3
4
+
5
+ ### Added
6
+
7
+ - `RecallResult` gains `degraded: bool = False`, set to `True` whenever one or more vector matches were dropped, and `recall()` returns the degraded result (`results=[]`, `degraded=True`, `dropped_count`) by default when all matches fail to download or decrypt. (#1036, #1070)
8
+ - `recall()` and `RecallParams` accept `on_dropped: str = "ignore"` (`"ignore"` or `"throw"`) across `MemWal`, `MemWalSync`, `MemWalMock`, and `MemWalMockSync`. Passing `on_dropped="throw"` raises `MemWalRecallAllDropped` (`code = "RECALL_ALL_DROPPED"`, exposing `total`, `dropped_count`, and `namespace`) when all vector search matches are dropped during Walrus blob download or SEAL decryption. (#1036, #1070)
9
+
3
10
  ## 0.2.2
4
11
 
5
12
  Unreleased.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memwal
3
- Version: 0.2.2.dev5
3
+ Version: 0.2.3
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
@@ -29,6 +29,7 @@ from .client import (
29
29
  MemWalError,
30
30
  MemWalJsonBodyError,
31
31
  MemWalRateLimited,
32
+ MemWalRecallAllDropped,
32
33
  MemWalRememberJobFailed,
33
34
  MemWalRememberJobNotFound,
34
35
  MemWalRememberJobTimeout,
@@ -87,6 +88,7 @@ __all__ = [
87
88
  "MemWalClockDriftError",
88
89
  "MemWalJsonBodyError",
89
90
  "MemWalRateLimited",
91
+ "MemWalRecallAllDropped",
90
92
  "MemWalRememberJobFailed",
91
93
  "MemWalRememberJobNotFound",
92
94
  "MemWalRememberJobTimeout",
@@ -132,4 +134,4 @@ __all__ = [
132
134
  "RecallManualResult",
133
135
  ]
134
136
 
135
- __version__ = "0.2.2.dev5"
137
+ __version__ = "0.2.3"
@@ -453,6 +453,15 @@ def _check_limit(limit: Any) -> None:
453
453
  raise ValueError(f"limit must be a positive integer, got {limit!r}")
454
454
 
455
455
 
456
+ def _check_on_dropped(on_dropped: Any) -> str:
457
+ """Validate the recall all-dropped handling policy before signing."""
458
+ if on_dropped not in ("throw", "ignore"):
459
+ raise ValueError(
460
+ f"on_dropped must be 'throw' or 'ignore', got {on_dropped!r}"
461
+ )
462
+ return str(on_dropped)
463
+
464
+
456
465
  def _check_vector(vector: Any) -> None:
457
466
  """Reject a vector that is not a non-empty list of finite numbers (#1218).
458
467
 
@@ -1175,6 +1184,7 @@ class MemWal:
1175
1184
  limit: int = 10,
1176
1185
  namespace: Optional[str] = None,
1177
1186
  max_distance: Optional[float] = None,
1187
+ on_dropped: str = "ignore",
1178
1188
  ) -> RecallResult:
1179
1189
  """Recall memories similar to a query.
1180
1190
 
@@ -1200,6 +1210,11 @@ class MemWal:
1200
1210
  max_distance: Optional client-side relevance threshold. Memories
1201
1211
  with ``distance >= max_distance`` are dropped. Ignored when
1202
1212
  ``query`` is a :class:`RecallParams`.
1213
+ on_dropped: Policy when every matched memory failed download or
1214
+ decrypt (``"ignore"`` by default returns the degraded
1215
+ :class:`RecallResult` with ``degraded=True`` and
1216
+ ``dropped_count``; ``"throw"`` raises
1217
+ :class:`MemWalRecallAllDropped`).
1203
1218
 
1204
1219
  Returns:
1205
1220
  :class:`RecallResult` with decrypted text results.
@@ -1214,15 +1229,18 @@ class MemWal:
1214
1229
  max_distance = params.max_distance
1215
1230
  scoring_weights = params.scoring_weights
1216
1231
  sort = params.sort
1232
+ on_dropped = params.on_dropped
1217
1233
  else:
1218
1234
  query_text = query
1219
1235
  _reject_blank_text(query_text, "query cannot be empty")
1220
1236
  _check_limit(limit)
1221
1237
  validate_max_distance(max_distance)
1238
+ policy = _check_on_dropped(on_dropped)
1239
+ resolved_namespace = self._resolve_namespace(namespace)
1222
1240
  body = {
1223
1241
  "query": query_text,
1224
1242
  "limit": limit,
1225
- "namespace": self._resolve_namespace(namespace),
1243
+ "namespace": resolved_namespace,
1226
1244
  }
1227
1245
  if scoring_weights is not None:
1228
1246
  body["scoring_weights"] = scoring_weights.to_wire()
@@ -1241,16 +1259,34 @@ class MemWal:
1241
1259
  ]
1242
1260
  raw_dropped = data.get("dropped_count") or 0
1243
1261
  try:
1244
- dropped = int(raw_dropped)
1262
+ dropped = max(0, int(raw_dropped))
1245
1263
  except (TypeError, ValueError):
1246
1264
  dropped = 0
1247
- total_count = data.get("total", len(memories))
1265
+ degraded = bool(data.get("degraded")) or dropped > 0
1266
+ raw_total = data.get("total", len(memories))
1267
+ try:
1268
+ total_count = int(raw_total)
1269
+ except (TypeError, ValueError):
1270
+ total_count = len(memories)
1271
+
1272
+ if len(memories) == 0 and degraded:
1273
+ effective_dropped = dropped if dropped > 0 else max(1, total_count)
1274
+ effective_total = max(total_count, effective_dropped)
1275
+ if policy == "throw":
1276
+ raise MemWalRecallAllDropped(
1277
+ dropped_count=effective_dropped,
1278
+ total=effective_total,
1279
+ namespace=resolved_namespace,
1280
+ )
1281
+ dropped = effective_dropped
1282
+
1248
1283
  if max_distance is not None:
1249
1284
  memories = [m for m in memories if m.distance < max_distance]
1250
1285
  return RecallResult(
1251
1286
  results=memories,
1252
1287
  total=total_count,
1253
1288
  dropped_count=dropped,
1289
+ degraded=degraded,
1254
1290
  )
1255
1291
 
1256
1292
  async def forget(self, namespace: Optional[str] = None) -> ForgetResult:
@@ -2311,6 +2347,27 @@ class MemWalRememberJobTimeout(MemWalError):
2311
2347
  self.last_refusal = last_refusal
2312
2348
 
2313
2349
 
2350
+ class MemWalRecallAllDropped(MemWalError):
2351
+ """Raised by :meth:`MemWal.recall` when ``on_dropped="throw"`` is set and
2352
+ every matched memory failed to download or decrypt (``results == []`` and
2353
+ ``dropped_count > 0``).
2354
+
2355
+ By default (``on_dropped="ignore"``), :meth:`MemWal.recall` returns the
2356
+ degraded :class:`RecallResult` without raising.
2357
+ """
2358
+
2359
+ def __init__(self, dropped_count: int, total: int, namespace: str) -> None:
2360
+ noun = "match" if total == 1 else "matches"
2361
+ super().__init__(
2362
+ f'MemWal recall matched {total} {noun} in namespace "{namespace}", '
2363
+ f"but all {dropped_count} failed to download or decrypt"
2364
+ )
2365
+ self.code = "RECALL_ALL_DROPPED"
2366
+ self.dropped_count = dropped_count
2367
+ self.total = total
2368
+ self.namespace = namespace
2369
+
2370
+
2314
2371
  async def _discard_http_client(memwal: MemWal) -> None:
2315
2372
  """Close the cached httpx client, and drop it either way.
2316
2373
 
@@ -2502,10 +2559,19 @@ class MemWalSync:
2502
2559
  limit: int = 10,
2503
2560
  namespace: Optional[str] = None,
2504
2561
  max_distance: Optional[float] = None,
2562
+ on_dropped: str = "ignore",
2505
2563
  ) -> RecallResult:
2506
2564
  """Synchronous version of :meth:`MemWal.recall` (accepts
2507
2565
  :class:`RecallParams` for the recommended object-style call)."""
2508
- return self._run(self._inner.recall(query, limit, namespace, max_distance))
2566
+ return self._run(
2567
+ self._inner.recall(
2568
+ query,
2569
+ limit,
2570
+ namespace,
2571
+ max_distance,
2572
+ on_dropped=on_dropped,
2573
+ )
2574
+ )
2509
2575
 
2510
2576
  def forget(self, namespace: Optional[str] = None) -> ForgetResult:
2511
2577
  """Synchronous version of :meth:`MemWal.forget`."""
@@ -659,7 +659,9 @@ def with_memwal_langchain(
659
659
  return messages
660
660
 
661
661
  try:
662
- recall_result = await memwal.recall(user_text, max_memories, namespace)
662
+ recall_result = await memwal.recall(
663
+ user_text, max_memories, namespace, on_dropped="throw"
664
+ )
663
665
  relevant = [
664
666
  m for m in recall_result.results
665
667
  if (1 - m.distance) >= min_relevance
@@ -837,7 +839,9 @@ def _wrap_async_openai(
837
839
  user_text = _find_last_user_message(messages)
838
840
  if user_text:
839
841
  try:
840
- recall_result = await memwal.recall(user_text, max_memories, namespace)
842
+ recall_result = await memwal.recall(
843
+ user_text, max_memories, namespace, on_dropped="throw"
844
+ )
841
845
  relevant = [
842
846
  m for m in recall_result.results
843
847
  if (1 - m.distance) >= min_relevance
@@ -915,7 +919,9 @@ def _wrap_sync_openai(
915
919
  if user_text:
916
920
  try:
917
921
  recall_result = _run_memwal(
918
- lambda: memwal.recall(user_text, max_memories, namespace)
922
+ lambda: memwal.recall(
923
+ user_text, max_memories, namespace, on_dropped="throw"
924
+ )
919
925
  )
920
926
  relevant = [
921
927
  m for m in recall_result.results
@@ -267,6 +267,7 @@ class MemWalMock:
267
267
  limit: int = 10,
268
268
  namespace: Optional[str] = None,
269
269
  max_distance: Optional[float] = None,
270
+ on_dropped: str = "ignore",
270
271
  ) -> RecallResult:
271
272
  """Token-overlap recall over the in-memory store.
272
273
 
@@ -281,6 +282,7 @@ class MemWalMock:
281
282
  namespace = query.namespace
282
283
  max_distance = query.max_distance
283
284
  sort = query.sort
285
+ on_dropped = query.on_dropped
284
286
  query_text = query.query
285
287
  else:
286
288
  sort = None
@@ -288,6 +290,10 @@ class MemWalMock:
288
290
  _validate_text(query_text, "query")
289
291
  _check_limit(limit)
290
292
  validate_max_distance(max_distance)
293
+ if on_dropped not in ("throw", "ignore"):
294
+ raise ValueError(
295
+ f"on_dropped must be 'throw' or 'ignore', got {on_dropped!r}"
296
+ )
291
297
  if sort not in (None, "relevance", "recent"):
292
298
  raise ValueError("sort must be 'relevance' or 'recent'")
293
299
  resolved_namespace = self._namespace if namespace is None else namespace
@@ -670,9 +676,16 @@ class MemWalMockSync:
670
676
  limit: int = 10,
671
677
  namespace: Optional[str] = None,
672
678
  max_distance: Optional[float] = None,
679
+ on_dropped: str = "ignore",
673
680
  ) -> RecallResult:
674
681
  return self._run(
675
- self._inner.recall(query, limit, namespace, max_distance)
682
+ self._inner.recall(
683
+ query,
684
+ limit,
685
+ namespace,
686
+ max_distance,
687
+ on_dropped=on_dropped,
688
+ )
676
689
  )
677
690
 
678
691
  def analyze(
@@ -121,6 +121,7 @@ class RecallParams:
121
121
  max_distance: Optional[float] = None
122
122
  scoring_weights: Optional["ScoringWeights"] = None
123
123
  sort: Optional[str] = None
124
+ on_dropped: str = "ignore"
124
125
 
125
126
 
126
127
  @dataclass
@@ -132,6 +133,8 @@ class RecallResult:
132
133
  # Matches omitted because download or decrypt failed. 0 also means the
133
134
  # relayer omitted the field (older servers).
134
135
  dropped_count: int = 0
136
+ # True whenever one or more matched blobs were dropped (dropped_count > 0).
137
+ degraded: bool = False
135
138
 
136
139
 
137
140
  @dataclass
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memwal"
7
- version = "0.2.2.dev5"
7
+ version = "0.2.3"
8
8
  description = "Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -25,6 +25,7 @@ from memwal.client import (
25
25
  MemWalCompatibilityError,
26
26
  MemWalError,
27
27
  MemWalRateLimited,
28
+ MemWalRecallAllDropped,
28
29
  MemWalRememberJobNotFound,
29
30
  MemWalRememberJobTimeout,
30
31
  MemWalSync,
@@ -830,7 +831,9 @@ class TestRecall:
830
831
  assert result.results[1].blob_id == "b2"
831
832
 
832
833
  @respx.mock
833
- async def test_recall_surfaces_dropped_count(self, memwal_client: MemWal) -> None:
834
+ async def test_recall_returns_degraded_result_by_default_and_raises_on_throw(
835
+ self, memwal_client: MemWal
836
+ ) -> None:
834
837
  mock_seal_session_prereqs()
835
838
  respx.post(f"{_TEST_SERVER}/api/recall").mock(
836
839
  return_value=httpx.Response(
@@ -838,9 +841,80 @@ class TestRecall:
838
841
  json={"results": [], "total": 0, "dropped_count": 3},
839
842
  )
840
843
  )
841
- result = await memwal_client.recall("coffee")
842
- assert result.results == []
843
- assert result.dropped_count == 3
844
+ omitted = await memwal_client.recall("coffee")
845
+ assert omitted.results == []
846
+ assert omitted.dropped_count == 3
847
+ assert omitted.total == 0
848
+ assert omitted.degraded is True
849
+
850
+ omitted_params = await memwal_client.recall(RecallParams(query="coffee"))
851
+ assert omitted_params.results == []
852
+ assert omitted_params.dropped_count == 3
853
+ assert omitted_params.total == 0
854
+ assert omitted_params.degraded is True
855
+
856
+ explicit_ignore = await memwal_client.recall(
857
+ RecallParams(query="coffee", on_dropped="ignore")
858
+ )
859
+ assert explicit_ignore.results == []
860
+ assert explicit_ignore.dropped_count == 3
861
+ assert explicit_ignore.total == 0
862
+ assert explicit_ignore.degraded is True
863
+
864
+ with pytest.raises(MemWalRecallAllDropped) as exc_info:
865
+ await memwal_client.recall("coffee", on_dropped="throw")
866
+ assert exc_info.value.code == "RECALL_ALL_DROPPED"
867
+ assert exc_info.value.dropped_count == 3
868
+ assert exc_info.value.total == 3
869
+ assert exc_info.value.namespace == "default"
870
+
871
+ with pytest.raises(MemWalRecallAllDropped) as params_exc:
872
+ await memwal_client.recall(
873
+ RecallParams(query="coffee", namespace="profile", on_dropped="throw")
874
+ )
875
+ assert params_exc.value.code == "RECALL_ALL_DROPPED"
876
+ assert params_exc.value.dropped_count == 3
877
+ assert params_exc.value.total == 3
878
+ assert params_exc.value.namespace == "profile"
879
+
880
+ sync_client = MemWalSync(memwal_client)
881
+ sync_default = sync_client.recall("coffee")
882
+ assert sync_default.results == []
883
+ assert sync_default.dropped_count == 3
884
+ assert sync_default.total == 0
885
+ assert sync_default.degraded is True
886
+
887
+ with pytest.raises(MemWalRecallAllDropped):
888
+ sync_client.recall("coffee", on_dropped="throw")
889
+
890
+ with pytest.raises(ValueError, match="on_dropped"):
891
+ await memwal_client.recall("coffee", on_dropped="warn")
892
+
893
+ @respx.mock
894
+ async def test_recall_preserves_dropped_count_with_max_distance(
895
+ self, memwal_client: MemWal
896
+ ) -> None:
897
+ mock_seal_session_prereqs()
898
+ respx.post(f"{_TEST_SERVER}/api/recall").mock(
899
+ return_value=httpx.Response(
900
+ 200,
901
+ json={
902
+ "results": [
903
+ {"blob_id": "b1", "text": "near hit", "distance": 0.1},
904
+ {"blob_id": "b2", "text": "far hit", "distance": 0.9},
905
+ ],
906
+ "total": 2,
907
+ "dropped_count": 25,
908
+ },
909
+ )
910
+ )
911
+ result = await memwal_client.recall(
912
+ RecallParams(query="coffee", max_distance=0.5)
913
+ )
914
+ assert [m.blob_id for m in result.results] == ["b1"]
915
+ assert result.total == 2
916
+ assert result.dropped_count == 25
917
+ assert result.degraded is True
844
918
 
845
919
  @respx.mock
846
920
  async def test_recall_preserves_score_and_created_at(
@@ -8,6 +8,7 @@ import re
8
8
  import pytest
9
9
 
10
10
  from memwal import (
11
+ MemWal,
11
12
  MemWalMock,
12
13
  MemWalMockSeed,
13
14
  MemWalMockSync,
@@ -202,6 +203,7 @@ def test_sync_mock_polling_and_analyze_signatures_match_production():
202
203
  "remember_and_wait",
203
204
  "wait_for_remember_jobs",
204
205
  "remember_bulk_and_wait",
206
+ "recall",
205
207
  "analyze",
206
208
  "analyze_and_wait",
207
209
  "forget_namespace",
@@ -217,6 +219,20 @@ def test_sync_mock_polling_and_analyze_signatures_match_production():
217
219
  assert parameter_contract(getattr(MemWalMockSync, method_name)) == parameter_contract(
218
220
  getattr(MemWalSync, method_name)
219
221
  )
222
+ assert parameter_contract(MemWalMock.recall) == parameter_contract(MemWal.recall)
223
+ assert inspect.signature(MemWalMock.recall).parameters["on_dropped"].default == "ignore"
224
+ assert RecallParams(query="x").on_dropped == "ignore"
225
+
226
+ async_mock = MemWalMock.create(
227
+ namespace="sync",
228
+ initial_memories=[MemWalMockSeed(text="coffee preference", namespace="sync")],
229
+ )
230
+ sync_mock = MemWalMockSync(async_mock)
231
+ assert sync_mock.recall("coffee").total == 1
232
+ assert sync_mock.recall("coffee", on_dropped="ignore").total == 1
233
+ assert sync_mock.recall("coffee", on_dropped="throw").total == 1
234
+ with pytest.raises(ValueError, match="on_dropped"):
235
+ sync_mock.recall("coffee", on_dropped="warn")
220
236
 
221
237
 
222
238
  @pytest.mark.asyncio
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes