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