memwal 0.1.7.dev0__tar.gz → 0.1.7.dev2__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.7.dev0 → memwal-0.1.7.dev2}/CHANGELOG.md +6 -0
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/PKG-INFO +1 -1
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/memwal/__init__.py +3 -1
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/memwal/client.py +22 -2
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/memwal/utils.py +52 -0
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/pyproject.toml +1 -1
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/tests/test_client.py +25 -1
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/tests/test_integration.py +72 -60
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/tests/test_signing.py +59 -3
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/.gitignore +0 -0
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/README.md +0 -0
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/examples/.env.example +0 -0
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/examples/.gitignore +0 -0
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/examples/async_remember_demo.py +0 -0
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/examples/interactive_demo.py +0 -0
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/examples/verify_credentials.py +0 -0
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/memwal/compatibility.py +0 -0
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/memwal/middleware.py +0 -0
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/memwal/mock.py +0 -0
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/memwal/types.py +0 -0
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/run_tests.py +0 -0
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/tests/__init__.py +0 -0
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/tests/test_auth_rejected_message.py +0 -0
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/tests/test_env_presets.py +0 -0
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/tests/test_middleware.py +0 -0
- {memwal-0.1.7.dev0 → memwal-0.1.7.dev2}/tests/test_mock.py +0 -0
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# memwal
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Added `MemWalClockDriftError`, raised when the relayer rejects a request because the signed timestamp is outside its accepted clock-drift window (`401` + `x-auth-error: ERR_TIMESTAMP_OUT_OF_BOUNDS`). Surfaces an actionable "synchronize the client clock" message instead of an opaque `401`. Subclasses `MemWalError`, so existing `except MemWalError` handlers still catch it.
|
|
8
|
+
|
|
3
9
|
## 0.1.7
|
|
4
10
|
|
|
5
11
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: memwal
|
|
3
|
-
Version: 0.1.7.
|
|
3
|
+
Version: 0.1.7.dev2
|
|
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
|
|
@@ -24,6 +24,7 @@ Quick start::
|
|
|
24
24
|
|
|
25
25
|
from .client import (
|
|
26
26
|
MemWal,
|
|
27
|
+
MemWalClockDriftError,
|
|
27
28
|
MemWalCompatibilityError,
|
|
28
29
|
MemWalError,
|
|
29
30
|
MemWalRememberJobFailed,
|
|
@@ -78,6 +79,7 @@ __all__ = [
|
|
|
78
79
|
"MemWalMockSeed",
|
|
79
80
|
"MemWalError",
|
|
80
81
|
"MemWalCompatibilityError",
|
|
82
|
+
"MemWalClockDriftError",
|
|
81
83
|
"MemWalRememberJobFailed",
|
|
82
84
|
"MemWalRememberJobNotFound",
|
|
83
85
|
"MemWalRememberJobTimeout",
|
|
@@ -120,4 +122,4 @@ __all__ = [
|
|
|
120
122
|
"RecallManualResult",
|
|
121
123
|
]
|
|
122
124
|
|
|
123
|
-
__version__ = "0.1.7.
|
|
125
|
+
__version__ = "0.1.7.dev2"
|
|
@@ -73,6 +73,7 @@ from .utils import (
|
|
|
73
73
|
bytes_to_hex,
|
|
74
74
|
delegate_key_to_sui_address,
|
|
75
75
|
encode_sui_private_key,
|
|
76
|
+
normalize_private_key,
|
|
76
77
|
sha256_hex,
|
|
77
78
|
sign_message,
|
|
78
79
|
sign_sui_personal_message,
|
|
@@ -213,8 +214,8 @@ class MemWal:
|
|
|
213
214
|
"""
|
|
214
215
|
|
|
215
216
|
def __init__(self, config: MemWalConfig) -> None:
|
|
216
|
-
self.
|
|
217
|
-
self.
|
|
217
|
+
self._private_key_hex = normalize_private_key(config.key)
|
|
218
|
+
self._signing_key = build_signing_key(self._private_key_hex)
|
|
218
219
|
self._account_id = config.account_id
|
|
219
220
|
self._server_url = config.server_url.rstrip("/")
|
|
220
221
|
self._namespace = config.namespace
|
|
@@ -1189,6 +1190,16 @@ class MemWal:
|
|
|
1189
1190
|
f"(HTTP 426 Upgrade Required). Relayer response: "
|
|
1190
1191
|
f"{err_text[:300] or 'upgrade required'}"
|
|
1191
1192
|
)
|
|
1193
|
+
# A stale/future-dated signature is rejected with 401 + a machine-
|
|
1194
|
+
# readable reason header. Surface it as an actionable clock-drift
|
|
1195
|
+
# error rather than an opaque 401 so the caller can fix node time.
|
|
1196
|
+
if response.headers.get("x-auth-error") == "ERR_TIMESTAMP_OUT_OF_BOUNDS":
|
|
1197
|
+
raise MemWalClockDriftError(
|
|
1198
|
+
"Request rejected: signed timestamp is outside the relayer's "
|
|
1199
|
+
"accepted clock-drift window. Synchronize this client's clock "
|
|
1200
|
+
"(NTP); if the deployment needs a wider tolerance, raise "
|
|
1201
|
+
"AUTH_MAX_CLOCK_DRIFT_SECS on the relayer."
|
|
1202
|
+
)
|
|
1192
1203
|
raise _HttpStatusError(
|
|
1193
1204
|
status=response.status_code,
|
|
1194
1205
|
body=err_text,
|
|
@@ -1209,6 +1220,15 @@ class MemWalCompatibilityError(MemWalError):
|
|
|
1209
1220
|
pass
|
|
1210
1221
|
|
|
1211
1222
|
|
|
1223
|
+
class MemWalClockDriftError(MemWalError):
|
|
1224
|
+
"""Raised when the relayer rejects a request because the signed timestamp is
|
|
1225
|
+
outside its accepted clock-drift window (401 + ``x-auth-error:
|
|
1226
|
+
ERR_TIMESTAMP_OUT_OF_BOUNDS``). Indicates the client's clock is skewed
|
|
1227
|
+
relative to the relayer; sync via NTP or widen the relayer's window."""
|
|
1228
|
+
|
|
1229
|
+
pass
|
|
1230
|
+
|
|
1231
|
+
|
|
1212
1232
|
class _HttpStatusError(MemWalError):
|
|
1213
1233
|
"""Internal: raised when an HTTP response status is not in ``accepted_statuses``.
|
|
1214
1234
|
|
|
@@ -232,6 +232,58 @@ def bech32_encode(hrp: str, data: bytes) -> str:
|
|
|
232
232
|
return hrp + "1" + "".join(_BECH32_CHARSET[d] for d in combined)
|
|
233
233
|
|
|
234
234
|
|
|
235
|
+
def bech32_decode(bech: str) -> Tuple[str, bytes]:
|
|
236
|
+
"""Decode a bech32 string into its human-readable part and 5-bit data."""
|
|
237
|
+
if bech != bech.lower() and bech != bech.upper():
|
|
238
|
+
raise ValueError("bech32 string is mixed case")
|
|
239
|
+
bech = bech.lower()
|
|
240
|
+
pos = bech.rfind("1")
|
|
241
|
+
if pos < 1 or pos + 7 > len(bech):
|
|
242
|
+
raise ValueError("bech32 string has no valid separator")
|
|
243
|
+
|
|
244
|
+
hrp = bech[:pos]
|
|
245
|
+
try:
|
|
246
|
+
data = bytes(_BECH32_CHARSET.index(c) for c in bech[pos + 1 :])
|
|
247
|
+
except ValueError as exc:
|
|
248
|
+
raise ValueError("bech32 string has a character outside the charset") from exc
|
|
249
|
+
|
|
250
|
+
if _bech32_polymod(_bech32_hrp_expand(hrp) + data) != 1:
|
|
251
|
+
raise ValueError("bech32 checksum mismatch")
|
|
252
|
+
return hrp, data[:-6]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def decode_sui_private_key(encoded: str) -> bytes:
|
|
256
|
+
"""Decode a Sui bech32 ``suiprivkey1...`` string to its 32-byte Ed25519 seed.
|
|
257
|
+
|
|
258
|
+
Inverse of :func:`encode_sui_private_key`. Mirrors ``decodeSuiPrivateKey``
|
|
259
|
+
from ``@mysten/sui``, which the TypeScript SDK uses.
|
|
260
|
+
"""
|
|
261
|
+
hrp, data = bech32_decode(encoded)
|
|
262
|
+
if hrp != "suiprivkey":
|
|
263
|
+
raise ValueError(f"expected a suiprivkey string, got prefix {hrp!r}")
|
|
264
|
+
|
|
265
|
+
payload = _convertbits(data, 5, 8, pad=False)
|
|
266
|
+
if not payload or payload[0] != _SUI_ED25519_SCHEME_FLAG:
|
|
267
|
+
raise ValueError("only Ed25519 private keys are supported")
|
|
268
|
+
|
|
269
|
+
seed = payload[1:]
|
|
270
|
+
if len(seed) != 32:
|
|
271
|
+
raise ValueError(f"Ed25519 seed must be exactly 32 bytes, got {len(seed)}")
|
|
272
|
+
return seed
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def normalize_private_key(key: str) -> str:
|
|
276
|
+
"""Accept either a hex seed or a Sui ``suiprivkey1...`` string, return hex.
|
|
277
|
+
|
|
278
|
+
The TypeScript SDK takes both, and the dashboard hands out the bech32 form,
|
|
279
|
+
so hex-only input would reject a key a user reasonably expects to work.
|
|
280
|
+
"""
|
|
281
|
+
candidate = key.strip()
|
|
282
|
+
if candidate.lower().startswith("suiprivkey1"):
|
|
283
|
+
return bytes_to_hex(decode_sui_private_key(candidate))
|
|
284
|
+
return candidate[2:] if candidate.startswith("0x") else candidate
|
|
285
|
+
|
|
286
|
+
|
|
235
287
|
def encode_sui_private_key(seed_bytes: bytes) -> str:
|
|
236
288
|
"""Encode a 32-byte Ed25519 seed to Sui bech32 `suiprivkey...` format."""
|
|
237
289
|
if len(seed_bytes) != 32:
|
|
@@ -17,7 +17,13 @@ import nacl.signing
|
|
|
17
17
|
import pytest
|
|
18
18
|
import respx
|
|
19
19
|
|
|
20
|
-
from memwal.client import
|
|
20
|
+
from memwal.client import (
|
|
21
|
+
MemWal,
|
|
22
|
+
MemWalClockDriftError,
|
|
23
|
+
MemWalCompatibilityError,
|
|
24
|
+
MemWalError,
|
|
25
|
+
MemWalSync,
|
|
26
|
+
)
|
|
21
27
|
from memwal.types import (
|
|
22
28
|
RecallManualOptions,
|
|
23
29
|
RecallParams,
|
|
@@ -468,6 +474,24 @@ class TestErrorHandling:
|
|
|
468
474
|
):
|
|
469
475
|
await memwal_client.recall("test")
|
|
470
476
|
|
|
477
|
+
@respx.mock
|
|
478
|
+
async def test_clock_drift_header_raises_clock_drift_error(
|
|
479
|
+
self, memwal_client: MemWal
|
|
480
|
+
) -> None:
|
|
481
|
+
"""A 401 carrying x-auth-error: ERR_TIMESTAMP_OUT_OF_BOUNDS should surface
|
|
482
|
+
as an actionable MemWalClockDriftError, not an opaque HTTP error."""
|
|
483
|
+
mock_seal_session_prereqs()
|
|
484
|
+
respx.post(f"{_TEST_SERVER}/api/remember").mock(
|
|
485
|
+
return_value=httpx.Response(
|
|
486
|
+
401,
|
|
487
|
+
headers={"x-auth-error": "ERR_TIMESTAMP_OUT_OF_BOUNDS"},
|
|
488
|
+
text="",
|
|
489
|
+
)
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
with pytest.raises(MemWalClockDriftError, match="clock-drift window"):
|
|
493
|
+
await memwal_client.remember("test")
|
|
494
|
+
|
|
471
495
|
@respx.mock
|
|
472
496
|
async def test_500_raises_memwal_error(self, memwal_client: MemWal) -> None:
|
|
473
497
|
"""Server errors should raise MemWalError."""
|
|
@@ -58,6 +58,18 @@ SERVER_URL = os.environ.get("MEMWAL_SERVER_URL", "https://relayer-staging.memory
|
|
|
58
58
|
PRIVATE_KEY_HEX = os.environ.get("MEMWAL_PRIVATE_KEY", "")
|
|
59
59
|
ACCOUNT_ID = os.environ.get("MEMWAL_ACCOUNT_ID", "")
|
|
60
60
|
|
|
61
|
+
# A live write runs embed -> SEAL encrypt -> Walrus upload -> on-chain metadata.
|
|
62
|
+
# Measured around 44s against the dev relayer, so the SDK's 60s default leaves
|
|
63
|
+
# too little headroom to be reliable in CI. 120s matches what the SDK already
|
|
64
|
+
# uses for bulk pipelines.
|
|
65
|
+
_REMEMBER_TIMEOUT_MS = int(os.environ.get("MEMWAL_REMEMBER_TIMEOUT_MS", "120000"))
|
|
66
|
+
|
|
67
|
+
# Every authenticated test writes into a namespace unique to this run. The bench
|
|
68
|
+
# account is shared, and `default` in particular is what real users get, so a
|
|
69
|
+
# recurring job must not leave live Walrus blobs there.
|
|
70
|
+
_E2E_NAMESPACE = f"sdk-e2e-{uuid.uuid4().hex[:8]}"
|
|
71
|
+
_E2E_NAMESPACE_ALT = f"{_E2E_NAMESPACE}-alt"
|
|
72
|
+
|
|
61
73
|
HAS_KEY = bool(PRIVATE_KEY_HEX and ACCOUNT_ID)
|
|
62
74
|
|
|
63
75
|
requires_key = pytest.mark.skipif(
|
|
@@ -65,6 +77,24 @@ requires_key = pytest.mark.skipif(
|
|
|
65
77
|
reason="MEMWAL_PRIVATE_KEY and MEMWAL_ACCOUNT_ID not set",
|
|
66
78
|
)
|
|
67
79
|
|
|
80
|
+
|
|
81
|
+
def _sync_client(namespace: str = _E2E_NAMESPACE) -> MemWalSync:
|
|
82
|
+
return MemWalSync.create(
|
|
83
|
+
key=PRIVATE_KEY_HEX,
|
|
84
|
+
account_id=ACCOUNT_ID,
|
|
85
|
+
server_url=SERVER_URL,
|
|
86
|
+
namespace=namespace,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _async_client(namespace: str = _E2E_NAMESPACE) -> MemWal:
|
|
91
|
+
return MemWal.create(
|
|
92
|
+
key=PRIVATE_KEY_HEX,
|
|
93
|
+
account_id=ACCOUNT_ID,
|
|
94
|
+
server_url=SERVER_URL,
|
|
95
|
+
namespace=namespace,
|
|
96
|
+
)
|
|
97
|
+
|
|
68
98
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
69
99
|
|
|
70
100
|
|
|
@@ -189,37 +219,42 @@ class TestRemember:
|
|
|
189
219
|
"""remember() / remember_and_wait() against live server."""
|
|
190
220
|
|
|
191
221
|
def test_remember_returns_job_id_and_status(self) -> None:
|
|
192
|
-
mw =
|
|
193
|
-
|
|
194
|
-
)
|
|
195
|
-
result = mw.remember("Integration test: the sky is blue", namespace="sdk-test")
|
|
222
|
+
mw = _sync_client()
|
|
223
|
+
result = mw.remember("Integration test: the sky is blue")
|
|
196
224
|
assert result.job_id is not None and isinstance(result.job_id, str)
|
|
197
225
|
assert result.status in ("pending", "running")
|
|
198
226
|
print(f"\n accepted job={result.job_id[:8]}... status={result.status}")
|
|
199
227
|
|
|
200
228
|
def test_remember_and_wait_returns_blob_and_owner(self) -> None:
|
|
201
|
-
mw =
|
|
202
|
-
|
|
229
|
+
mw = _sync_client()
|
|
230
|
+
result = mw.remember_and_wait(
|
|
231
|
+
"Integration test: the sky is blue", timeout_ms=_REMEMBER_TIMEOUT_MS
|
|
203
232
|
)
|
|
204
|
-
result = mw.remember_and_wait("Integration test: the sky is blue", namespace="sdk-test")
|
|
205
233
|
assert result.id is not None and isinstance(result.id, str)
|
|
206
234
|
assert result.blob_id is not None and isinstance(result.blob_id, str)
|
|
207
235
|
assert result.owner.startswith("0x")
|
|
208
236
|
print(f"\n done job={result.id[:8]}... blob={result.blob_id[:8]}...")
|
|
209
237
|
|
|
210
|
-
def
|
|
211
|
-
|
|
212
|
-
|
|
238
|
+
def test_remember_uses_the_client_namespace(self) -> None:
|
|
239
|
+
"""Omitting `namespace` falls back to the one the client was built with.
|
|
240
|
+
|
|
241
|
+
The literal `"default"` fallback is asserted in the mocked suite; proving
|
|
242
|
+
it here would mean writing a live blob into the namespace real users get.
|
|
243
|
+
"""
|
|
244
|
+
mw = _sync_client()
|
|
245
|
+
result = mw.remember_and_wait(
|
|
246
|
+
"Integration test: namespace fallback", timeout_ms=_REMEMBER_TIMEOUT_MS
|
|
213
247
|
)
|
|
214
|
-
result
|
|
215
|
-
assert result.namespace == "default"
|
|
248
|
+
assert result.namespace == _E2E_NAMESPACE
|
|
216
249
|
|
|
217
250
|
def test_remember_custom_namespace(self) -> None:
|
|
218
|
-
mw =
|
|
219
|
-
|
|
251
|
+
mw = _sync_client()
|
|
252
|
+
result = mw.remember_and_wait(
|
|
253
|
+
"Integration test: custom namespace",
|
|
254
|
+
namespace=_E2E_NAMESPACE_ALT,
|
|
255
|
+
timeout_ms=_REMEMBER_TIMEOUT_MS,
|
|
220
256
|
)
|
|
221
|
-
result
|
|
222
|
-
assert result.namespace == "sdk-test"
|
|
257
|
+
assert result.namespace == _E2E_NAMESPACE_ALT
|
|
223
258
|
|
|
224
259
|
|
|
225
260
|
@requires_key
|
|
@@ -227,25 +262,19 @@ class TestRecall:
|
|
|
227
262
|
"""recall() against live server."""
|
|
228
263
|
|
|
229
264
|
def test_recall_returns_list(self) -> None:
|
|
230
|
-
mw =
|
|
231
|
-
key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
|
|
232
|
-
)
|
|
265
|
+
mw = _sync_client()
|
|
233
266
|
result = mw.recall("sky blue", limit=5)
|
|
234
267
|
assert isinstance(result.results, list)
|
|
235
268
|
assert result.total >= 0
|
|
236
269
|
print(f"\n recall total={result.total}")
|
|
237
270
|
|
|
238
271
|
def test_recall_respects_limit(self) -> None:
|
|
239
|
-
mw =
|
|
240
|
-
key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
|
|
241
|
-
)
|
|
272
|
+
mw = _sync_client()
|
|
242
273
|
result = mw.recall("test", limit=2)
|
|
243
274
|
assert len(result.results) <= 2
|
|
244
275
|
|
|
245
276
|
def test_recall_result_has_expected_fields(self) -> None:
|
|
246
|
-
mw =
|
|
247
|
-
key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
|
|
248
|
-
)
|
|
277
|
+
mw = _sync_client()
|
|
249
278
|
result = mw.recall("test", limit=3)
|
|
250
279
|
for mem in result.results:
|
|
251
280
|
assert isinstance(mem.text, str)
|
|
@@ -258,13 +287,8 @@ class TestAnalyze:
|
|
|
258
287
|
"""analyze() against live server."""
|
|
259
288
|
|
|
260
289
|
def test_analyze_returns_facts(self) -> None:
|
|
261
|
-
mw =
|
|
262
|
-
|
|
263
|
-
)
|
|
264
|
-
result = mw.analyze(
|
|
265
|
-
"I love hiking and my favorite food is pho.",
|
|
266
|
-
namespace="sdk-test",
|
|
267
|
-
)
|
|
290
|
+
mw = _sync_client()
|
|
291
|
+
result = mw.analyze("I love hiking and my favorite food is pho.")
|
|
268
292
|
assert isinstance(result.facts, list)
|
|
269
293
|
assert result.total >= 0
|
|
270
294
|
assert result.owner.startswith("0x")
|
|
@@ -278,9 +302,7 @@ class TestAsk:
|
|
|
278
302
|
"""ask() against live server."""
|
|
279
303
|
|
|
280
304
|
def test_ask_returns_string_answer(self) -> None:
|
|
281
|
-
mw =
|
|
282
|
-
key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
|
|
283
|
-
)
|
|
305
|
+
mw = _sync_client()
|
|
284
306
|
result = mw.ask("What outdoor activities do I enjoy?", limit=3)
|
|
285
307
|
assert isinstance(result.answer, str)
|
|
286
308
|
assert len(result.answer) > 0
|
|
@@ -301,12 +323,10 @@ class TestFullFlow:
|
|
|
301
323
|
text = f"SDK e2e test {unique}: quantum entanglement in photonics"
|
|
302
324
|
ns = f"sdk-e2e-{unique}"
|
|
303
325
|
|
|
304
|
-
mw =
|
|
305
|
-
key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
|
|
306
|
-
)
|
|
326
|
+
mw = _sync_client()
|
|
307
327
|
|
|
308
328
|
# Store a distinctive memory in an isolated namespace
|
|
309
|
-
mem = mw.remember_and_wait(text, namespace=ns)
|
|
329
|
+
mem = mw.remember_and_wait(text, namespace=ns, timeout_ms=_REMEMBER_TIMEOUT_MS)
|
|
310
330
|
assert mem.id is not None
|
|
311
331
|
|
|
312
332
|
# Recall — should find the stored memory
|
|
@@ -320,11 +340,11 @@ class TestFullFlow:
|
|
|
320
340
|
|
|
321
341
|
def test_remember_then_ask_uses_memory(self) -> None:
|
|
322
342
|
"""remember → ask — answer should reference the stored fact."""
|
|
323
|
-
mw =
|
|
324
|
-
|
|
343
|
+
mw = _sync_client()
|
|
344
|
+
mw.remember_and_wait(
|
|
345
|
+
"I am allergic to shellfish", timeout_ms=_REMEMBER_TIMEOUT_MS
|
|
325
346
|
)
|
|
326
|
-
mw.
|
|
327
|
-
result = mw.ask("What are my food allergies?", limit=3, namespace="sdk-test")
|
|
347
|
+
result = mw.ask("What are my food allergies?", limit=3)
|
|
328
348
|
assert isinstance(result.answer, str)
|
|
329
349
|
assert len(result.answer) > 0
|
|
330
350
|
print(f"\n ask answer: {result.answer[:100]}")
|
|
@@ -338,38 +358,30 @@ class TestAsync:
|
|
|
338
358
|
"""Async client variants."""
|
|
339
359
|
|
|
340
360
|
async def test_async_health(self) -> None:
|
|
341
|
-
async with
|
|
342
|
-
key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
|
|
343
|
-
) as mw:
|
|
361
|
+
async with _async_client() as mw:
|
|
344
362
|
result = await mw.health()
|
|
345
363
|
assert result.status == "ok"
|
|
346
364
|
|
|
347
365
|
async def test_async_remember(self) -> None:
|
|
348
|
-
async with
|
|
349
|
-
key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
|
|
350
|
-
) as mw:
|
|
366
|
+
async with _async_client() as mw:
|
|
351
367
|
result = await mw.remember("Async SDK test: Paris is the capital of France")
|
|
352
368
|
assert result.job_id is not None
|
|
353
369
|
assert result.status in ("pending", "running")
|
|
354
370
|
|
|
355
371
|
async def test_async_recall(self) -> None:
|
|
356
|
-
async with
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
372
|
+
async with _async_client() as mw:
|
|
373
|
+
await mw.remember_and_wait(
|
|
374
|
+
"Async SDK test: I enjoy reading", timeout_ms=_REMEMBER_TIMEOUT_MS
|
|
375
|
+
)
|
|
360
376
|
result = await mw.recall("reading books", limit=3)
|
|
361
377
|
assert isinstance(result.results, list)
|
|
362
378
|
|
|
363
379
|
async def test_async_analyze(self) -> None:
|
|
364
|
-
async with
|
|
365
|
-
|
|
366
|
-
) as mw:
|
|
367
|
-
result = await mw.analyze("I drink tea every morning.", namespace="sdk-test")
|
|
380
|
+
async with _async_client() as mw:
|
|
381
|
+
result = await mw.analyze("I drink tea every morning.")
|
|
368
382
|
assert isinstance(result.facts, list)
|
|
369
383
|
|
|
370
384
|
async def test_async_ask(self) -> None:
|
|
371
|
-
async with
|
|
372
|
-
key=PRIVATE_KEY_HEX, account_id=ACCOUNT_ID, server_url=SERVER_URL
|
|
373
|
-
) as mw:
|
|
385
|
+
async with _async_client() as mw:
|
|
374
386
|
result = await mw.ask("What do I drink?", limit=3)
|
|
375
387
|
assert isinstance(result.answer, str)
|
|
@@ -12,6 +12,7 @@ import hashlib
|
|
|
12
12
|
import json
|
|
13
13
|
|
|
14
14
|
import nacl.signing
|
|
15
|
+
import pytest
|
|
15
16
|
|
|
16
17
|
from memwal.utils import (
|
|
17
18
|
build_seal_session_personal_message,
|
|
@@ -144,7 +145,10 @@ class TestBuildSignatureMessage:
|
|
|
144
145
|
nonce="550e8400-e29b-41d4-a716-446655440000",
|
|
145
146
|
account_id="0xabc123",
|
|
146
147
|
)
|
|
147
|
-
assert result ==
|
|
148
|
+
assert result == (
|
|
149
|
+
"1700000000.POST./api/remember.abc123"
|
|
150
|
+
".550e8400-e29b-41d4-a716-446655440000.0xabc123"
|
|
151
|
+
)
|
|
148
152
|
|
|
149
153
|
def test_get_method(self) -> None:
|
|
150
154
|
result = build_signature_message(
|
|
@@ -175,7 +179,9 @@ class TestBuildSignatureMessage:
|
|
|
175
179
|
nonce = "550e8400-e29b-41d4-a716-446655440000"
|
|
176
180
|
account_id = "0xabc123"
|
|
177
181
|
|
|
178
|
-
message = build_signature_message(
|
|
182
|
+
message = build_signature_message(
|
|
183
|
+
timestamp, method, path, body_hash, nonce=nonce, account_id=account_id
|
|
184
|
+
)
|
|
179
185
|
sig_hex, pub_hex = sign_message(message, signing_key)
|
|
180
186
|
|
|
181
187
|
# Verify (as the server would)
|
|
@@ -239,9 +245,10 @@ class TestDelegateKeyUtils:
|
|
|
239
245
|
|
|
240
246
|
def test_sui_address_matches_typescript_sdk(self):
|
|
241
247
|
"""Verify blake2b-256(0x00 || pubkey) matches TS delegateKeyToSuiAddress output."""
|
|
242
|
-
from memwal.utils import delegate_key_to_public_key, delegate_key_to_sui_address
|
|
243
248
|
import hashlib
|
|
244
249
|
|
|
250
|
+
from memwal.utils import delegate_key_to_public_key, delegate_key_to_sui_address
|
|
251
|
+
|
|
245
252
|
pub = delegate_key_to_public_key(self._KEY)
|
|
246
253
|
scheme_input = bytes([0x00]) + pub
|
|
247
254
|
expected = "0x" + hashlib.blake2b(scheme_input, digest_size=32).hexdigest()
|
|
@@ -272,3 +279,52 @@ class TestSealSessionUtils:
|
|
|
272
279
|
raw = json.loads(json.dumps(signature)) # assert it's JSON-safe text
|
|
273
280
|
assert isinstance(raw, str)
|
|
274
281
|
assert len(base64.b64decode(signature)) == 97
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class TestPrivateKeyFormats:
|
|
285
|
+
"""The dashboard hands out `suiprivkey1...`; the SDK must take both forms."""
|
|
286
|
+
|
|
287
|
+
_SEED_HEX = "17dc3c1eecfcdc014c0eba65c1f87897abbbd214fa32d4018f48669b5d37c413"
|
|
288
|
+
|
|
289
|
+
def test_decode_reverses_encode(self) -> None:
|
|
290
|
+
from memwal.utils import decode_sui_private_key, encode_sui_private_key
|
|
291
|
+
|
|
292
|
+
seed = bytes.fromhex(self._SEED_HEX)
|
|
293
|
+
assert decode_sui_private_key(encode_sui_private_key(seed)) == seed
|
|
294
|
+
|
|
295
|
+
def test_normalize_accepts_every_accepted_form(self) -> None:
|
|
296
|
+
from memwal.utils import encode_sui_private_key, normalize_private_key
|
|
297
|
+
|
|
298
|
+
seed = bytes.fromhex(self._SEED_HEX)
|
|
299
|
+
for form in (
|
|
300
|
+
self._SEED_HEX,
|
|
301
|
+
f"0x{self._SEED_HEX}",
|
|
302
|
+
encode_sui_private_key(seed),
|
|
303
|
+
f" {encode_sui_private_key(seed)} ",
|
|
304
|
+
):
|
|
305
|
+
assert normalize_private_key(form) == self._SEED_HEX
|
|
306
|
+
|
|
307
|
+
def test_bech32_key_produces_the_same_client_identity(self) -> None:
|
|
308
|
+
from memwal.client import MemWal
|
|
309
|
+
from memwal.utils import encode_sui_private_key
|
|
310
|
+
|
|
311
|
+
bech32 = encode_sui_private_key(bytes.fromhex(self._SEED_HEX))
|
|
312
|
+
from_hex = MemWal.create(key=self._SEED_HEX, account_id="0xtest")
|
|
313
|
+
from_bech32 = MemWal.create(key=bech32, account_id="0xtest")
|
|
314
|
+
assert from_bech32._private_key_hex == from_hex._private_key_hex
|
|
315
|
+
assert bytes(from_bech32._signing_key) == bytes(from_hex._signing_key)
|
|
316
|
+
|
|
317
|
+
def test_rejects_a_non_ed25519_scheme(self) -> None:
|
|
318
|
+
from memwal.utils import _convertbits, bech32_encode, decode_sui_private_key
|
|
319
|
+
|
|
320
|
+
payload = bytes([1]) + bytes.fromhex(self._SEED_HEX) # scheme flag 1 = secp256k1
|
|
321
|
+
with pytest.raises(ValueError, match="Ed25519"):
|
|
322
|
+
decode_sui_private_key(bech32_encode("suiprivkey", _convertbits(payload, 8, 5)))
|
|
323
|
+
|
|
324
|
+
def test_rejects_a_corrupted_checksum(self) -> None:
|
|
325
|
+
from memwal.utils import decode_sui_private_key, encode_sui_private_key
|
|
326
|
+
|
|
327
|
+
encoded = encode_sui_private_key(bytes.fromhex(self._SEED_HEX))
|
|
328
|
+
corrupted = encoded[:-1] + ("q" if encoded[-1] != "q" else "p")
|
|
329
|
+
with pytest.raises(ValueError, match="checksum"):
|
|
330
|
+
decode_sui_private_key(corrupted)
|
|
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
|