memwal 0.1.11.dev8__tar.gz → 0.1.11.dev9__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.dev9}/PKG-INFO +1 -1
  2. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/memwal/__init__.py +1 -1
  3. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/memwal/client.py +31 -7
  4. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/pyproject.toml +1 -1
  5. memwal-0.1.11.dev9/tests/test_remember_derived_key.py +250 -0
  6. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/.gitignore +0 -0
  7. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/CHANGELOG.md +0 -0
  8. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/README.md +0 -0
  9. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/examples/.env.example +0 -0
  10. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/examples/.gitignore +0 -0
  11. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/examples/async_remember_demo.py +0 -0
  12. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/examples/interactive_demo.py +0 -0
  13. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/examples/verify_credentials.py +0 -0
  14. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/memwal/compatibility.py +0 -0
  15. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/memwal/middleware.py +0 -0
  16. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/memwal/mock.py +0 -0
  17. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/memwal/types.py +0 -0
  18. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/memwal/utils.py +0 -0
  19. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
  20. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/run_tests.py +0 -0
  21. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/__init__.py +0 -0
  22. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_auth_rejected_message.py +0 -0
  23. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_blank_text.py +0 -0
  24. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_client.py +0 -0
  25. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_env_presets.py +0 -0
  26. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_integration.py +0 -0
  27. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_list_namespaces.py +0 -0
  28. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_middleware.py +0 -0
  29. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_mock.py +0 -0
  30. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_normalize_server_url.py +0 -0
  31. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_polling_delay.py +0 -0
  32. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_retry_after_http_date.py +0 -0
  33. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_signing.py +0 -0
  34. {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/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.dev8
3
+ Version: 0.1.11.dev9
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.dev8"
131
+ __version__ = "0.1.11.dev9"
@@ -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
@@ -136,6 +137,25 @@ def _reject_blank_text(text: object, message: str = "Text cannot be empty") -> N
136
137
  raise ValueError(message)
137
138
 
138
139
 
140
+ IDEMPOTENCY_BUCKET_MS = 30 * 60 * 1000
141
+
142
+
143
+ def _derived_idempotency_key(request_identity: str) -> str:
144
+ """Window over which the same (namespace, text) resolves to one key.
145
+
146
+ `_pending_remember_keys` only dedupes retries that reuse one client instance,
147
+ and callers such as sidecars or serverless functions build a fresh client
148
+ per session — so a random key lets a replay mint a second paid blob. The
149
+ bucket bounds the collapse: `remember_jobs` rows are never pruned, so an
150
+ unbucketed key would dedupe against a job from any point in history.
151
+
152
+ Matches TypeScript SDK derivedIdempotencyKey contract.
153
+ """
154
+ bucket = int(time.time() * 1000) // IDEMPOTENCY_BUCKET_MS
155
+ payload = f"{bucket}\0{request_identity}".encode("utf-8")
156
+ return f"r1-{hashlib.sha256(payload).hexdigest()}"
157
+
158
+
139
159
  def normalize_server_url(url: str) -> str:
140
160
  """Strip a trailing slash and warn on plaintext HTTP to a remote host.
141
161
 
@@ -466,9 +486,11 @@ class MemWal:
466
486
  resolved_namespace = namespace or self._namespace
467
487
  request_identity = f"{resolved_namespace}\0{text}"
468
488
  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())
489
+ resolved_key = (
490
+ idempotency_key
491
+ or self._pending_remember_keys.get(request_identity)
492
+ or _derived_idempotency_key(request_identity)
493
+ )
472
494
  if generated_key:
473
495
  self._pending_remember_keys[request_identity] = resolved_key
474
496
 
@@ -615,12 +637,15 @@ class MemWal:
615
637
  ``failed``). Mirrors TS ``rememberAndWait``.
616
638
  """
617
639
 
640
+ _reject_blank_text(text)
618
641
  resolved_namespace = namespace or self._namespace
619
642
  request_identity = f"{resolved_namespace}\0{text}"
620
643
  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())
644
+ resolved_key = (
645
+ idempotency_key
646
+ or self._pending_remember_keys.get(request_identity)
647
+ or _derived_idempotency_key(request_identity)
648
+ )
624
649
  if generated_key:
625
650
  self._pending_remember_keys[request_identity] = resolved_key
626
651
 
@@ -1610,7 +1635,6 @@ class MemWal:
1610
1635
  - ``x-account-id``: Walrus Memory account object ID
1611
1636
  - ``Content-Type``: application/json
1612
1637
  """
1613
- import uuid
1614
1638
 
1615
1639
  await self._ensure_compatible_relayer()
1616
1640
 
@@ -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.dev9"
8
8
  description = "Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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
File without changes
File without changes