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.
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/PKG-INFO +1 -1
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/memwal/__init__.py +1 -1
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/memwal/client.py +31 -7
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/pyproject.toml +1 -1
- memwal-0.1.11.dev9/tests/test_remember_derived_key.py +250 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/.gitignore +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/CHANGELOG.md +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/README.md +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/examples/.env.example +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/examples/.gitignore +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/examples/async_remember_demo.py +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/examples/interactive_demo.py +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/examples/verify_credentials.py +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/memwal/compatibility.py +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/memwal/middleware.py +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/memwal/mock.py +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/memwal/types.py +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/memwal/utils.py +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/run_tests.py +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/__init__.py +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_auth_rejected_message.py +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_blank_text.py +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_client.py +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_env_presets.py +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_integration.py +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_list_namespaces.py +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_middleware.py +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_mock.py +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_normalize_server_url.py +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_polling_delay.py +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_retry_after_http_date.py +0 -0
- {memwal-0.1.11.dev8 → memwal-0.1.11.dev9}/tests/test_signing.py +0 -0
- {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.
|
|
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
|
|
@@ -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 =
|
|
470
|
-
|
|
471
|
-
|
|
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 =
|
|
622
|
-
|
|
623
|
-
|
|
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
|
|
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|