memwal 0.1.11.dev8__tar.gz → 0.1.11.dev10__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.dev8 → memwal-0.1.11.dev10}/CHANGELOG.md +5 -0
  2. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/PKG-INFO +2 -1
  3. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/README.md +1 -0
  4. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/memwal/__init__.py +1 -1
  5. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/memwal/client.py +126 -9
  6. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/pyproject.toml +1 -1
  7. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/tests/test_client.py +148 -0
  8. memwal-0.1.11.dev10/tests/test_remember_derived_key.py +250 -0
  9. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/.gitignore +0 -0
  10. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/examples/.env.example +0 -0
  11. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/examples/.gitignore +0 -0
  12. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/examples/async_remember_demo.py +0 -0
  13. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/examples/interactive_demo.py +0 -0
  14. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/examples/verify_credentials.py +0 -0
  15. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/memwal/compatibility.py +0 -0
  16. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/memwal/middleware.py +0 -0
  17. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/memwal/mock.py +0 -0
  18. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/memwal/types.py +0 -0
  19. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/memwal/utils.py +0 -0
  20. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
  21. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/run_tests.py +0 -0
  22. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/tests/__init__.py +0 -0
  23. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/tests/test_auth_rejected_message.py +0 -0
  24. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/tests/test_blank_text.py +0 -0
  25. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/tests/test_env_presets.py +0 -0
  26. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/tests/test_integration.py +0 -0
  27. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/tests/test_list_namespaces.py +0 -0
  28. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/tests/test_middleware.py +0 -0
  29. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/tests/test_mock.py +0 -0
  30. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/tests/test_normalize_server_url.py +0 -0
  31. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/tests/test_polling_delay.py +0 -0
  32. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/tests/test_retry_after_http_date.py +0 -0
  33. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/tests/test_signing.py +0 -0
  34. {memwal-0.1.11.dev8 → memwal-0.1.11.dev10}/tests/test_transport_retry.py +0 -0
@@ -2,8 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ### Added
6
+
7
+ - `get_remember_status(job_id)` on `MemWal` and `MemWalSync` returns `RememberJobStatus` from `GET /api/remember/{job_id}`, accepting 200 and 404 like the TypeScript SDK. (WALM-832, #1096)
8
+
5
9
  ### Fixed
6
10
 
11
+ - `get_remember_status` and `wait_for_remember_job` reject a job id outside `[A-Za-z0-9_-]+` before the request. `.`, `..`, a backslash, and encoded dot-dot used to pass a denylist and be rewritten after signing. An empty id used to call `GET /api/remember/` and raise `json.JSONDecodeError`. An unparseable 404 is `not_found` / `MemWalRememberJobNotFound`, not a JSON error. `uuid.UUID` is coerced with `str()`. Valid UUID job ids are unchanged. (WALM-833, #1100, #1216)
7
12
  - `remember`, `remember_bulk`, `analyze`, and `embed` reject whitespace-only text before the request. A blank body used to be accepted and then sit until the caller timed out. (#1129)
8
13
  - A 401 `AUTH_REJECTED` now says a wallet address is not a MemWal account ID. (#1132)
9
14
  - `wait_for_remember_jobs` / `remember_bulk_and_wait` no longer return a batch they could not read as if it were still uploading. When every status poll is rate-limited, the wait makes one confirming read and raises `MemWalRateLimited` (`status` 429, `job_ids`, `retry_after`) if that is refused too. An item still unsettled at the deadline names its last known status, or says no read got through. (WALM-671, #967)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memwal
3
- Version: 0.1.11.dev8
3
+ Version: 0.1.11.dev10
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
@@ -240,6 +240,7 @@ Create a new async client.
240
240
  | Method | Description |
241
241
  |--------|-------------|
242
242
  | `await remember(text, namespace?)` | Accept a background remember job and return `job_id` |
243
+ | `await get_remember_status(job_id)` | Read one remember job without waiting |
243
244
  | `await wait_for_remember_job(job_id, ...)` | Poll one remember job until it is searchable |
244
245
  | `await remember_and_wait(text, namespace?, ...)` | Store a memory and wait until it is searchable |
245
246
  | `await remember_bulk(items)` | Accept several background remember jobs |
@@ -201,6 +201,7 @@ Create a new async client.
201
201
  | Method | Description |
202
202
  |--------|-------------|
203
203
  | `await remember(text, namespace?)` | Accept a background remember job and return `job_id` |
204
+ | `await get_remember_status(job_id)` | Read one remember job without waiting |
204
205
  | `await wait_for_remember_job(job_id, ...)` | Poll one remember job until it is searchable |
205
206
  | `await remember_and_wait(text, namespace?, ...)` | Store a memory and wait until it is searchable |
206
207
  | `await remember_bulk(items)` | Accept several background remember jobs |
@@ -128,4 +128,4 @@ __all__ = [
128
128
  "RecallManualResult",
129
129
  ]
130
130
 
131
- __version__ = "0.1.11.dev8"
131
+ __version__ = "0.1.11.dev10"
@@ -27,6 +27,7 @@ from __future__ import annotations
27
27
 
28
28
  import asyncio
29
29
  import base64
30
+ import hashlib
30
31
  import json
31
32
  import logging
32
33
  import math
@@ -68,6 +69,7 @@ from .types import (
68
69
  RememberBulkResult,
69
70
  RememberBulkStatusItem,
70
71
  RememberBulkStatusResult,
72
+ RememberJobStatus,
71
73
  RememberManualOptions,
72
74
  RememberManualResult,
73
75
  RememberResult,
@@ -136,6 +138,55 @@ def _reject_blank_text(text: object, message: str = "Text cannot be empty") -> N
136
138
  raise ValueError(message)
137
139
 
138
140
 
141
+ IDEMPOTENCY_BUCKET_MS = 30 * 60 * 1000
142
+
143
+
144
+ def _derived_idempotency_key(request_identity: str) -> str:
145
+ """Window over which the same (namespace, text) resolves to one key.
146
+
147
+ `_pending_remember_keys` only dedupes retries that reuse one client instance,
148
+ and callers such as sidecars or serverless functions build a fresh client
149
+ per session — so a random key lets a replay mint a second paid blob. The
150
+ bucket bounds the collapse: `remember_jobs` rows are never pruned, so an
151
+ unbucketed key would dedupe against a job from any point in history.
152
+
153
+ Matches TypeScript SDK derivedIdempotencyKey contract.
154
+ """
155
+ bucket = int(time.time() * 1000) // IDEMPOTENCY_BUCKET_MS
156
+ payload = f"{bucket}\0{request_identity}".encode("utf-8")
157
+ return f"r1-{hashlib.sha256(payload).hexdigest()}"
158
+
159
+
160
+ def _require_remember_job_id(job_id: object) -> str:
161
+ """String form of a remember job id.
162
+
163
+ ``uuid.UUID`` used to interpolate into the path and still does. Any other
164
+ non-string is a type mistake: ``not_found`` would hide a job that exists.
165
+ """
166
+
167
+ if isinstance(job_id, uuid.UUID):
168
+ return str(job_id)
169
+ if not isinstance(job_id, str):
170
+ raise TypeError(
171
+ f"remember job id must be a string, got {type(job_id).__name__}"
172
+ )
173
+ return job_id
174
+
175
+
176
+ def _remember_status_path(job_id: str) -> Optional[str]:
177
+ """``/api/remember/{job_id}`` when the id is one safe segment, else None.
178
+
179
+ httpx rewrites the path after it is signed. ``.`` requests
180
+ ``/api/remember``, ``..`` requests ``/api``, and a NUL raises
181
+ ``InvalidURL``. An allowlist keeps those from leaving the client. A
182
+ normal UUID is unchanged. (#1100, #1216)
183
+ """
184
+
185
+ if re.fullmatch(r"[A-Za-z0-9_-]+", job_id) is None:
186
+ return None
187
+ return f"/api/remember/{job_id}"
188
+
189
+
139
190
  def normalize_server_url(url: str) -> str:
140
191
  """Strip a trailing slash and warn on plaintext HTTP to a remote host.
141
192
 
@@ -466,9 +517,11 @@ class MemWal:
466
517
  resolved_namespace = namespace or self._namespace
467
518
  request_identity = f"{resolved_namespace}\0{text}"
468
519
  generated_key = idempotency_key is None
469
- resolved_key = idempotency_key or self._pending_remember_keys.get(request_identity)
470
- if resolved_key is None:
471
- resolved_key = str(uuid.uuid4())
520
+ resolved_key = (
521
+ idempotency_key
522
+ or self._pending_remember_keys.get(request_identity)
523
+ or _derived_idempotency_key(request_identity)
524
+ )
472
525
  if generated_key:
473
526
  self._pending_remember_keys[request_identity] = resolved_key
474
527
 
@@ -502,6 +555,55 @@ class MemWal:
502
555
  remaining_s = (deadline_ms - _now_ms()) / 1000.0
503
556
  return max(0.001, min(60.0, remaining_s))
504
557
 
558
+ async def get_remember_status(self, job_id: str) -> RememberJobStatus:
559
+ """One-shot lookup of a remember job. Mirrors TS ``getRememberStatus``.
560
+
561
+ ``GET /api/remember/{job_id}``, accepting 200 and 404. A 404, or a
562
+ body with no ``status``, is ``not_found`` — not an exception. A job
563
+ id outside ``[A-Za-z0-9_-]+`` is ``not_found`` without a request.
564
+ """
565
+
566
+ job_id = _require_remember_job_id(job_id)
567
+ path = _remember_status_path(job_id)
568
+ if path is None:
569
+ return RememberJobStatus(
570
+ job_id=job_id,
571
+ status="not_found",
572
+ error=f"Job {job_id} not found",
573
+ )
574
+ try:
575
+ data = await self._signed_request(
576
+ "GET",
577
+ path,
578
+ {},
579
+ accepted_statuses=(200, 404),
580
+ # Status is metadata. No SEAL decrypt, so no session. Matches TS.
581
+ include_seal_session=False,
582
+ )
583
+ except (json.JSONDecodeError, UnicodeDecodeError):
584
+ data = None
585
+ if not isinstance(data, dict):
586
+ return RememberJobStatus(
587
+ job_id=job_id,
588
+ status="not_found",
589
+ error=f"remember job not found: {job_id}",
590
+ )
591
+ status = data.get("status")
592
+ if not status:
593
+ return RememberJobStatus(
594
+ job_id=data.get("job_id") or job_id,
595
+ status="not_found",
596
+ error=data.get("error") or f"remember job not found: {job_id}",
597
+ )
598
+ return RememberJobStatus(
599
+ job_id=data.get("job_id", job_id),
600
+ status=status,
601
+ owner=data.get("owner"),
602
+ namespace=data.get("namespace"),
603
+ blob_id=data.get("blob_id"),
604
+ error=data.get("error"),
605
+ )
606
+
505
607
  async def wait_for_remember_job(
506
608
  self,
507
609
  job_id: str,
@@ -520,6 +622,11 @@ class MemWal:
520
622
  - First poll is immediate; later polls grow 1.5x toward 5s.
521
623
  """
522
624
 
625
+ job_id = _require_remember_job_id(job_id)
626
+ path = _remember_status_path(job_id)
627
+ if path is None:
628
+ raise MemWalRememberJobNotFound(job_id)
629
+
523
630
  deadline_ms = _now_ms() + timeout_ms
524
631
  attempt = 0
525
632
  saw_rate_limit = False
@@ -541,7 +648,7 @@ class MemWal:
541
648
  try:
542
649
  data = await self._signed_request(
543
650
  "GET",
544
- f"/api/remember/{job_id}",
651
+ path,
545
652
  {},
546
653
  accepted_statuses=(200, 404),
547
654
  timeout=poll_timeout,
@@ -550,12 +657,14 @@ class MemWal:
550
657
  if "unexpected keyword argument" in str(err) and "timeout" in str(err):
551
658
  data = await self._signed_request(
552
659
  "GET",
553
- f"/api/remember/{job_id}",
660
+ path,
554
661
  {},
555
662
  accepted_statuses=(200, 404),
556
663
  )
557
664
  else:
558
665
  raise
666
+ except (json.JSONDecodeError, UnicodeDecodeError):
667
+ raise MemWalRememberJobNotFound(job_id) from None
559
668
  except _HttpStatusError as err:
560
669
  if _is_transient_polling_status(err.status):
561
670
  last_refusal = err.status
@@ -570,6 +679,8 @@ class MemWal:
570
679
  last_refusal = 0
571
680
  continue
572
681
 
682
+ if not isinstance(data, dict):
683
+ raise MemWalRememberJobNotFound(job_id)
573
684
  status_str = data.get("status")
574
685
  if status_str is None or status_str == "not_found":
575
686
  raise MemWalRememberJobNotFound(job_id)
@@ -615,12 +726,15 @@ class MemWal:
615
726
  ``failed``). Mirrors TS ``rememberAndWait``.
616
727
  """
617
728
 
729
+ _reject_blank_text(text)
618
730
  resolved_namespace = namespace or self._namespace
619
731
  request_identity = f"{resolved_namespace}\0{text}"
620
732
  generated_key = idempotency_key is None
621
- resolved_key = idempotency_key or self._pending_remember_keys.get(request_identity)
622
- if resolved_key is None:
623
- resolved_key = str(uuid.uuid4())
733
+ resolved_key = (
734
+ idempotency_key
735
+ or self._pending_remember_keys.get(request_identity)
736
+ or _derived_idempotency_key(request_identity)
737
+ )
624
738
  if generated_key:
625
739
  self._pending_remember_keys[request_identity] = resolved_key
626
740
 
@@ -1610,7 +1724,6 @@ class MemWal:
1610
1724
  - ``x-account-id``: Walrus Memory account object ID
1611
1725
  - ``Content-Type``: application/json
1612
1726
  """
1613
- import uuid
1614
1727
 
1615
1728
  await self._ensure_compatible_relayer()
1616
1729
 
@@ -1940,6 +2053,10 @@ class MemWalSync:
1940
2053
  ) -> RememberAcceptedResult:
1941
2054
  return self._run(self._inner.remember_async(text, namespace, idempotency_key))
1942
2055
 
2056
+ def get_remember_status(self, job_id: str) -> RememberJobStatus:
2057
+ """Synchronous version of :meth:`MemWal.get_remember_status`."""
2058
+ return self._run(self._inner.get_remember_status(job_id))
2059
+
1943
2060
  def wait_for_remember_job(
1944
2061
  self,
1945
2062
  job_id: str,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memwal"
7
- version = "0.1.11.dev8"
7
+ version = "0.1.11.dev10"
8
8
  description = "Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
  import base64
11
11
  import json
12
12
  import time
13
+ import uuid
13
14
  from datetime import datetime, timedelta, timezone
14
15
  from typing import Any, Sequence
15
16
 
@@ -24,6 +25,7 @@ from memwal.client import (
24
25
  MemWalCompatibilityError,
25
26
  MemWalError,
26
27
  MemWalRateLimited,
28
+ MemWalRememberJobNotFound,
27
29
  MemWalRememberJobTimeout,
28
30
  MemWalSync,
29
31
  _HttpStatusError,
@@ -36,6 +38,7 @@ from memwal.types import (
36
38
  RememberBulkOptions,
37
39
  RememberBulkStatusItem,
38
40
  RememberBulkStatusResult,
41
+ RememberJobStatus,
39
42
  RememberManualOptions,
40
43
  ScoringWeights,
41
44
  )
@@ -642,6 +645,151 @@ class TestRememberJobTimeoutDetail:
642
645
  )
643
646
 
644
647
 
648
+ # ============================================================
649
+ # one-job remember status
650
+ # ============================================================
651
+
652
+
653
+ _NORMAL_JOB_ID = "550e8400-e29b-41d4-a716-446655440000"
654
+
655
+
656
+ class TestRememberJobStatus:
657
+ @respx.mock
658
+ async def test_unsafe_job_id_is_not_requested(self, memwal_client: MemWal) -> None:
659
+ route = respx.route().mock(return_value=httpx.Response(500, text="nope"))
660
+ for job_id in ("", "abc/def?x=1", "job 1", "a\tb", "?", "/", ".", "..", "%2e%2e", "a\\b"):
661
+ status = await memwal_client.get_remember_status(job_id)
662
+ assert status == RememberJobStatus(
663
+ job_id=job_id,
664
+ status="not_found",
665
+ error=f"Job {job_id} not found",
666
+ )
667
+ with pytest.raises(MemWalRememberJobNotFound) as exc:
668
+ await memwal_client.wait_for_remember_job(
669
+ job_id, poll_interval_ms=0, timeout_ms=5_000
670
+ )
671
+ assert exc.value.status == 404
672
+ assert exc.value.job_id == job_id
673
+ assert not isinstance(exc.value, json.JSONDecodeError)
674
+ assert route.call_count == 0
675
+
676
+ async def test_non_string_job_id_is_a_type_error(self, memwal_client: MemWal) -> None:
677
+ with pytest.raises(TypeError):
678
+ await memwal_client.get_remember_status(1) # type: ignore[arg-type]
679
+ with pytest.raises(TypeError):
680
+ await memwal_client.wait_for_remember_job(1, poll_interval_ms=0, timeout_ms=5_000) # type: ignore[arg-type]
681
+
682
+ @respx.mock
683
+ async def test_uuid_object_reads_the_string_path(self, memwal_client: MemWal) -> None:
684
+ _mock_version()
685
+ job = uuid.UUID(_NORMAL_JOB_ID)
686
+ route = respx.get(f"{_TEST_SERVER}/api/remember/{job}").mock(
687
+ return_value=httpx.Response(200, json={"job_id": str(job), "status": "running"})
688
+ )
689
+ status = await memwal_client.get_remember_status(job) # type: ignore[arg-type]
690
+ assert status.status == "running"
691
+ assert status.job_id == str(job)
692
+ assert route.call_count == 1
693
+
694
+ @respx.mock
695
+ async def test_non_object_404_body_is_not_found(self, memwal_client: MemWal) -> None:
696
+ mock_seal_session_prereqs()
697
+ route = respx.get(f"{_TEST_SERVER}/api/remember/job-1")
698
+ for body in (b"null", b"[]", b'"Not Found"', b"<html>nope</html>"):
699
+ route.mock(return_value=httpx.Response(404, content=body))
700
+ status = await memwal_client.get_remember_status("job-1")
701
+ assert status.status == "not_found"
702
+ assert status.job_id == "job-1"
703
+ with pytest.raises(MemWalRememberJobNotFound) as exc:
704
+ await memwal_client.wait_for_remember_job(
705
+ "job-1", poll_interval_ms=0, timeout_ms=5_000
706
+ )
707
+ assert exc.value.status == 404
708
+
709
+ @respx.mock
710
+ async def test_empty_404_body_is_not_found(self, memwal_client: MemWal) -> None:
711
+ mock_seal_session_prereqs()
712
+ respx.get(f"{_TEST_SERVER}/api/remember/job-1").mock(
713
+ return_value=httpx.Response(404, content=b"")
714
+ )
715
+ status = await memwal_client.get_remember_status("job-1")
716
+ assert status.status == "not_found"
717
+ assert status.job_id == "job-1"
718
+ with pytest.raises(MemWalRememberJobNotFound) as exc:
719
+ await memwal_client.wait_for_remember_job(
720
+ "job-1", poll_interval_ms=0, timeout_ms=5_000
721
+ )
722
+ assert exc.value.status == 404
723
+
724
+ @respx.mock
725
+ async def test_reads_a_normal_job_id(self, memwal_client: MemWal) -> None:
726
+ mock_seal_session_prereqs()
727
+ route = respx.get(f"{_TEST_SERVER}/api/remember/{_NORMAL_JOB_ID}").mock(
728
+ return_value=httpx.Response(
729
+ 200,
730
+ json={
731
+ "job_id": _NORMAL_JOB_ID,
732
+ "status": "done",
733
+ "blob_id": "blob-1",
734
+ "owner": "0xowner",
735
+ "namespace": "profile",
736
+ },
737
+ )
738
+ )
739
+
740
+ status = await memwal_client.get_remember_status(_NORMAL_JOB_ID)
741
+ assert status == RememberJobStatus(
742
+ job_id=_NORMAL_JOB_ID,
743
+ status="done",
744
+ owner="0xowner",
745
+ namespace="profile",
746
+ blob_id="blob-1",
747
+ )
748
+ assert "x-seal-session" not in route.calls[0].request.headers
749
+
750
+ result = await memwal_client.wait_for_remember_job(
751
+ _NORMAL_JOB_ID, poll_interval_ms=0, timeout_ms=5_000
752
+ )
753
+ assert result.blob_id == "blob-1"
754
+ assert result.namespace == "profile"
755
+ assert [str(call.request.url) for call in route.calls] == [
756
+ f"{_TEST_SERVER}/api/remember/{_NORMAL_JOB_ID}",
757
+ f"{_TEST_SERVER}/api/remember/{_NORMAL_JOB_ID}",
758
+ ]
759
+
760
+ @respx.mock
761
+ def test_sync_reads_a_normal_job_id(self) -> None:
762
+ _mock_version()
763
+ route = respx.get(f"{_TEST_SERVER}/api/remember/{_NORMAL_JOB_ID}").mock(
764
+ return_value=httpx.Response(
765
+ 200,
766
+ json={
767
+ "job_id": _NORMAL_JOB_ID,
768
+ "status": "running",
769
+ "namespace": "profile",
770
+ },
771
+ )
772
+ )
773
+ client = MemWalSync.create(
774
+ key=_TEST_KEY_HEX,
775
+ account_id=_TEST_ACCOUNT_ID,
776
+ server_url=_TEST_SERVER,
777
+ )
778
+ try:
779
+ missing = client.get_remember_status("")
780
+ assert missing.status == "not_found"
781
+ with pytest.raises(MemWalRememberJobNotFound):
782
+ client.wait_for_remember_job("abc/def?x=1", poll_interval_ms=0, timeout_ms=5_000)
783
+ status = client.get_remember_status(_NORMAL_JOB_ID)
784
+ assert status.status == "running"
785
+ assert status.namespace == "profile"
786
+ assert str(route.calls[0].request.url) == (
787
+ f"{_TEST_SERVER}/api/remember/{_NORMAL_JOB_ID}"
788
+ )
789
+ finally:
790
+ client.close()
791
+
792
+
645
793
  # ============================================================
646
794
  # recall() tests
647
795
  # ============================================================
@@ -0,0 +1,250 @@
1
+ """
2
+ Tests for derived idempotency keys in Python SDK (GH #1094, WALM-800).
3
+
4
+ Validates parity with TypeScript SDK specification:
5
+ - An omitted idempotency_key is derived from bucket + namespace + text:
6
+ r1-{sha256(bucket \0 namespace \0 text)}
7
+ - Repeated identical writes reuse the same idempotency key across client instances
8
+ - Different texts or namespaces produce distinct keys
9
+ - Explicit idempotency keys are respected
10
+ - MemWalSync synchronous wrapper inherits the same behavior
11
+ """
12
+
13
+ import hashlib
14
+ import json
15
+ import time
16
+ from unittest.mock import patch
17
+
18
+ import httpx
19
+ import pytest
20
+ import respx
21
+
22
+ from memwal.client import (
23
+ IDEMPOTENCY_BUCKET_MS,
24
+ MemWal,
25
+ MemWalRememberJobTimeout,
26
+ MemWalSync,
27
+ _derived_idempotency_key,
28
+ )
29
+ from memwal.utils import bytes_to_hex
30
+
31
+ _TEST_KEY_HEX = bytes_to_hex(b"\x01" * 32)
32
+ _TEST_ACCOUNT_ID = "0xabc123"
33
+ _TEST_SERVER = "http://localhost:8000"
34
+
35
+
36
+ def _make_client(namespace: str = "default") -> MemWal:
37
+ client = MemWal.create(
38
+ key=_TEST_KEY_HEX,
39
+ account_id=_TEST_ACCOUNT_ID,
40
+ server_url=_TEST_SERVER,
41
+ namespace=namespace,
42
+ )
43
+ # Cache a valid seal session (expiry in milliseconds) to avoid needing live Sui RPC
44
+ client._session_cache = ("fake-seal-session", int(time.time() * 1000) + 3_600_000)
45
+ return client
46
+
47
+
48
+ def _mock_version_and_config():
49
+ respx.get(f"{_TEST_SERVER}/version").mock(
50
+ return_value=httpx.Response(
51
+ 200,
52
+ json={
53
+ "apiVersion": "1.0.0",
54
+ "relayerVersion": "1.0.0",
55
+ "minSupportedSdk": {"python": "0.1.0"},
56
+ },
57
+ )
58
+ )
59
+ respx.get(f"{_TEST_SERVER}/config").mock(
60
+ return_value=httpx.Response(
61
+ 200,
62
+ json={"packageId": "0x1", "network": "testnet"},
63
+ )
64
+ )
65
+
66
+
67
+ def test_derived_idempotency_key_format_and_cross_language_parity():
68
+ """Verify byte-for-byte SHA256 digest parity with TypeScript SDK."""
69
+ bucket = 123456
70
+ request_identity = "default\0I am allergic to peanuts"
71
+
72
+ # Pre-calculated reference matching TypeScript:
73
+ # r1-${sha256("123456\0default\0I am allergic to peanuts")}
74
+ expected_digest = hashlib.sha256(f"{bucket}\0{request_identity}".encode("utf-8")).hexdigest()
75
+ expected_key = f"r1-{expected_digest}"
76
+ assert expected_key == "r1-8690455a552eca1cc0d77d60b2a7c2a05a2dd836874f5106b1c9967dc5adc083"
77
+
78
+ with patch("time.time", return_value=(bucket * IDEMPOTENCY_BUCKET_MS) / 1000.0):
79
+ actual_key = _derived_idempotency_key(request_identity)
80
+ assert actual_key == expected_key
81
+ assert actual_key.startswith("r1-")
82
+ assert len(actual_key) == 3 + 64
83
+
84
+
85
+ @respx.mock
86
+ @pytest.mark.asyncio
87
+ async def test_same_fact_reuses_one_idempotency_key_across_client_instances():
88
+ """Two instances build what sidecars or serverless functions run on reconnect.
89
+
90
+ A per-instance random uuid4 made the replay look like a new write, so the
91
+ relayer minted a second paid Walrus blob. The content-based derived key
92
+ ensures both send the identical key.
93
+ """
94
+ _mock_version_and_config()
95
+ posted_bodies = []
96
+
97
+ def handle_remember(request: httpx.Request) -> httpx.Response:
98
+ posted_bodies.append(json.loads(request.content))
99
+ return httpx.Response(202, json={"job_id": "job-1", "status": "pending"})
100
+
101
+ respx.post(f"{_TEST_SERVER}/api/remember").mock(side_effect=handle_remember)
102
+ respx.get(f"{_TEST_SERVER}/api/remember/job-1").mock(
103
+ return_value=httpx.Response(
104
+ 200,
105
+ json={
106
+ "job_id": "job-1",
107
+ "status": "done",
108
+ "blob_id": "blob-1",
109
+ "owner": _TEST_ACCOUNT_ID,
110
+ "namespace": "default",
111
+ },
112
+ )
113
+ )
114
+
115
+ client1 = _make_client()
116
+ await client1.remember_and_wait("a durable fact")
117
+
118
+ client2 = _make_client()
119
+ await client2.remember_and_wait("a durable fact")
120
+
121
+ assert len(posted_bodies) == 2
122
+ assert posted_bodies[0]["idempotency_key"] == posted_bodies[1]["idempotency_key"]
123
+ assert posted_bodies[0]["idempotency_key"].startswith("r1-")
124
+
125
+
126
+ @respx.mock
127
+ @pytest.mark.asyncio
128
+ async def test_remember_async_reuses_derived_key():
129
+ """remember() (async accept) also derives the content key."""
130
+ _mock_version_and_config()
131
+ posted_bodies = []
132
+
133
+ def handle_remember(request: httpx.Request) -> httpx.Response:
134
+ posted_bodies.append(json.loads(request.content))
135
+ job_id = f"job-{len(posted_bodies)}"
136
+ return httpx.Response(202, json={"job_id": job_id, "status": "pending"})
137
+
138
+ respx.post(f"{_TEST_SERVER}/api/remember").mock(side_effect=handle_remember)
139
+
140
+ client = _make_client()
141
+ await client.remember("user likes pizza")
142
+ await client.remember("user likes pizza")
143
+
144
+ assert len(posted_bodies) == 2
145
+ assert posted_bodies[0]["idempotency_key"] == posted_bodies[1]["idempotency_key"]
146
+
147
+
148
+ @respx.mock
149
+ @pytest.mark.asyncio
150
+ async def test_different_text_and_namespaces_get_different_keys():
151
+ """Distinct writes must not collapse onto one job."""
152
+ _mock_version_and_config()
153
+ posted_bodies = []
154
+
155
+ def handle_remember(request: httpx.Request) -> httpx.Response:
156
+ posted_bodies.append(json.loads(request.content))
157
+ job_id = f"job-{len(posted_bodies)}"
158
+ return httpx.Response(202, json={"job_id": job_id, "status": "pending"})
159
+
160
+ respx.post(f"{_TEST_SERVER}/api/remember").mock(side_effect=handle_remember)
161
+
162
+ client = _make_client()
163
+ await client.remember("fact one")
164
+ await client.remember("fact two")
165
+ await client.remember("fact one", namespace="other-namespace")
166
+
167
+ keys = [b["idempotency_key"] for b in posted_bodies]
168
+ assert len(set(keys)) == 3, "distinct writes must produce distinct keys"
169
+
170
+
171
+ @respx.mock
172
+ @pytest.mark.asyncio
173
+ async def test_explicit_idempotency_key_still_wins():
174
+ """When the caller passes an explicit key, that key is sent directly."""
175
+ _mock_version_and_config()
176
+ posted_bodies = []
177
+
178
+ def handle_remember(request: httpx.Request) -> httpx.Response:
179
+ posted_bodies.append(json.loads(request.content))
180
+ return httpx.Response(202, json={"job_id": "job-explicit", "status": "pending"})
181
+
182
+ respx.post(f"{_TEST_SERVER}/api/remember").mock(side_effect=handle_remember)
183
+
184
+ client = _make_client()
185
+ await client.remember("a fact", idempotency_key="caller-owned-key")
186
+
187
+ assert len(posted_bodies) == 1
188
+ assert posted_bodies[0]["idempotency_key"] == "caller-owned-key"
189
+
190
+
191
+ @respx.mock
192
+ @pytest.mark.asyncio
193
+ async def test_remember_and_wait_preserves_key_on_polling_timeout():
194
+ """If polling times out, the pending key stays in the map for immediate retry."""
195
+ _mock_version_and_config()
196
+ posted_bodies = []
197
+
198
+ def handle_remember(request: httpx.Request) -> httpx.Response:
199
+ posted_bodies.append(json.loads(request.content))
200
+ return httpx.Response(202, json={"job_id": "slow-job", "status": "pending"})
201
+
202
+ respx.post(f"{_TEST_SERVER}/api/remember").mock(side_effect=handle_remember)
203
+ respx.get(f"{_TEST_SERVER}/api/remember/slow-job").mock(
204
+ return_value=httpx.Response(200, json={"job_id": "slow-job", "status": "running"})
205
+ )
206
+
207
+ client = _make_client()
208
+ with pytest.raises(MemWalRememberJobTimeout):
209
+ await client.remember_and_wait("timeout fact", poll_interval_ms=10, timeout_ms=50)
210
+
211
+ # Key must still be in pending map
212
+ request_identity = "default\0timeout fact"
213
+ assert request_identity in client._pending_remember_keys
214
+ pending_key = client._pending_remember_keys[request_identity]
215
+
216
+ # Retry uses the exact same pending key
217
+ with pytest.raises(MemWalRememberJobTimeout):
218
+ await client.remember_and_wait("timeout fact", poll_interval_ms=10, timeout_ms=50)
219
+
220
+ assert len(posted_bodies) == 2
221
+ assert posted_bodies[0]["idempotency_key"] == pending_key
222
+ assert posted_bodies[1]["idempotency_key"] == pending_key
223
+
224
+
225
+ @respx.mock
226
+ def test_sync_client_uses_derived_key():
227
+ """MemWalSync synchronous client derives and reuses content keys."""
228
+ _mock_version_and_config()
229
+ posted_bodies = []
230
+
231
+ def handle_remember(request: httpx.Request) -> httpx.Response:
232
+ posted_bodies.append(json.loads(request.content))
233
+ return httpx.Response(202, json={"job_id": "job-sync", "status": "pending"})
234
+
235
+ respx.post(f"{_TEST_SERVER}/api/remember").mock(side_effect=handle_remember)
236
+
237
+ sync_client = MemWalSync.create(
238
+ key=_TEST_KEY_HEX,
239
+ account_id=_TEST_ACCOUNT_ID,
240
+ server_url=_TEST_SERVER,
241
+ namespace="default",
242
+ )
243
+ sync_client._inner._session_cache = ("fake-seal-session", int(time.time() * 1000) + 3_600_000)
244
+
245
+ sync_client.remember("sync fact")
246
+ sync_client.remember("sync fact")
247
+
248
+ assert len(posted_bodies) == 2
249
+ assert posted_bodies[0]["idempotency_key"] == posted_bodies[1]["idempotency_key"]
250
+ assert posted_bodies[0]["idempotency_key"].startswith("r1-")
File without changes
File without changes