memwal 0.2.2.dev0__tar.gz → 0.2.2.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.2.2.dev0 → memwal-0.2.2.dev2}/CHANGELOG.md +3 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/PKG-INFO +1 -1
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/memwal/__init__.py +1 -1
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/memwal/client.py +67 -25
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/memwal/mock.py +2 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/memwal/utils.py +22 -1
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/pyproject.toml +1 -1
- memwal-0.2.2.dev2/tests/test_ask_validation.py +56 -0
- memwal-0.2.2.dev2/tests/test_missing_response_fields.py +125 -0
- memwal-0.2.2.dev2/tests/test_recall_validation.py +84 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/.gitignore +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/README.md +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/examples/.env.example +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/examples/.gitignore +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/examples/async_remember_demo.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/examples/interactive_demo.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/examples/verify_credentials.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/memwal/compatibility.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/memwal/middleware.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/memwal/types.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/run_tests.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/tests/__init__.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/tests/test_auth_rejected_message.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/tests/test_blank_text.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/tests/test_client.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/tests/test_destroy_and_cancel_shield.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/tests/test_empty_namespace.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/tests/test_env_presets.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/tests/test_integration.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/tests/test_list_namespaces.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/tests/test_middleware.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/tests/test_mock.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/tests/test_nonfinite_validation.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/tests/test_normalize_server_url.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/tests/test_polling_delay.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/tests/test_recall_weights.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/tests/test_remember_derived_key.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/tests/test_retry_after_http_date.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/tests/test_signing.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev2}/tests/test_transport_retry.py +0 -0
|
@@ -11,6 +11,9 @@ Unreleased.
|
|
|
11
11
|
### Fixed
|
|
12
12
|
|
|
13
13
|
- Shield shared `_build_seal_session` and `_resolve_owner` initialization tasks with `asyncio.shield`, clear the shared task slot via a completion callback that consumes exceptions on orphaned tasks, and cancel/drain in-flight initialization tasks in `close()` so cancelling one concurrent caller does not abort sibling callers or leave unretrieved task exceptions. (#1029)
|
|
14
|
+
- `health`, `ask`, `restore`, `list_namespaces`, `remember_manual`, and `recall` raise `MemWalError` naming the endpoint and field when a 2xx response omits a required field. They used to raise a bare `KeyError` that callers catching `MemWalError` never saw. (#1250, #1256)
|
|
15
|
+
- `ask()` rejects a blank `question` and a `limit` that is not a positive integer before it signs the request. A NaN `limit` used to serialize as an invalid-JSON `NaN` token, and negative or fractional values were sent as-is. (#1249, #1255)
|
|
16
|
+
- `recall()` rejects a blank `query` and a `max_distance` that is NaN, infinite, negative, or not a number before it signs the request, and `MemWalMock.recall()` rejects the same `max_distance`. A NaN or negative threshold used to return an empty page that looked like an empty namespace. (#1248, #1254)
|
|
14
17
|
|
|
15
18
|
## 0.2.1
|
|
16
19
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: memwal
|
|
3
|
-
Version: 0.2.2.
|
|
3
|
+
Version: 0.2.2.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
|
|
@@ -86,6 +86,7 @@ from .utils import (
|
|
|
86
86
|
sha256_hex,
|
|
87
87
|
sign_message,
|
|
88
88
|
sign_sui_personal_message,
|
|
89
|
+
validate_max_distance,
|
|
89
90
|
)
|
|
90
91
|
|
|
91
92
|
T = TypeVar("T")
|
|
@@ -142,6 +143,36 @@ def _reject_blank_text(text: object, message: str = "Text cannot be empty") -> N
|
|
|
142
143
|
raise ValueError(message)
|
|
143
144
|
|
|
144
145
|
|
|
146
|
+
def _require(data: object, field: str, endpoint: str) -> Any:
|
|
147
|
+
"""``data[field]`` from a parsed 2xx body, or a ``MemWalError`` naming both.
|
|
148
|
+
|
|
149
|
+
A bare ``KeyError`` is not a ``MemWalError``, carries only the field name,
|
|
150
|
+
and is raised far from the response that caused it (#1250). Optional
|
|
151
|
+
fields keep using ``.get()`` with a documented default.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
if not isinstance(data, dict):
|
|
155
|
+
raise MemWalError(
|
|
156
|
+
f"{endpoint} response was {type(data).__name__}, expected an object "
|
|
157
|
+
f"with {field!r}"
|
|
158
|
+
)
|
|
159
|
+
if field not in data:
|
|
160
|
+
raise MemWalError(f"{endpoint} response missing {field!r}")
|
|
161
|
+
return data[field]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _require_positive_int(value: object, name: str) -> None:
|
|
165
|
+
"""Reject anything but a positive ``int`` before it is signed (#1249).
|
|
166
|
+
|
|
167
|
+
``json.dumps`` writes ``float("nan")`` as a bare ``NaN`` token, which is
|
|
168
|
+
not valid JSON, and negative or fractional values were forwarded as-is.
|
|
169
|
+
``bool`` is an ``int`` subclass and is rejected explicitly.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
173
|
+
raise ValueError(f"{name} must be a positive integer, got {value!r}")
|
|
174
|
+
|
|
175
|
+
|
|
145
176
|
IDEMPOTENCY_BUCKET_MS = 30 * 60 * 1000
|
|
146
177
|
|
|
147
178
|
|
|
@@ -1176,7 +1207,9 @@ class MemWal:
|
|
|
1176
1207
|
sort = params.sort
|
|
1177
1208
|
else:
|
|
1178
1209
|
query_text = query
|
|
1210
|
+
_reject_blank_text(query_text, "query cannot be empty")
|
|
1179
1211
|
_check_limit(limit)
|
|
1212
|
+
validate_max_distance(max_distance)
|
|
1180
1213
|
body = {
|
|
1181
1214
|
"query": query_text,
|
|
1182
1215
|
"limit": limit,
|
|
@@ -1189,9 +1222,9 @@ class MemWal:
|
|
|
1189
1222
|
data = await self._signed_request("POST", "/api/recall", body)
|
|
1190
1223
|
memories = [
|
|
1191
1224
|
RecallMemory(
|
|
1192
|
-
blob_id=m
|
|
1193
|
-
text=m
|
|
1194
|
-
distance=m
|
|
1225
|
+
blob_id=_require(m, "blob_id", "POST /api/recall"),
|
|
1226
|
+
text=_require(m, "text", "POST /api/recall"),
|
|
1227
|
+
distance=_require(m, "distance", "POST /api/recall"),
|
|
1195
1228
|
score=m.get("score"),
|
|
1196
1229
|
created_at=m.get("created_at"),
|
|
1197
1230
|
)
|
|
@@ -1353,18 +1386,24 @@ class MemWal:
|
|
|
1353
1386
|
Returns:
|
|
1354
1387
|
:class:`AskResult` with answer, memories_used count, and memories list.
|
|
1355
1388
|
"""
|
|
1389
|
+
_reject_blank_text(question, "question cannot be empty")
|
|
1390
|
+
_require_positive_int(limit, "limit")
|
|
1356
1391
|
data = await self._signed_request("POST", "/api/ask", {
|
|
1357
1392
|
"question": question,
|
|
1358
1393
|
"limit": limit,
|
|
1359
1394
|
"namespace": self._resolve_namespace(namespace),
|
|
1360
1395
|
})
|
|
1361
1396
|
memories = [
|
|
1362
|
-
AskMemory(
|
|
1397
|
+
AskMemory(
|
|
1398
|
+
blob_id=_require(m, "blob_id", "POST /api/ask"),
|
|
1399
|
+
text=_require(m, "text", "POST /api/ask"),
|
|
1400
|
+
distance=_require(m, "distance", "POST /api/ask"),
|
|
1401
|
+
)
|
|
1363
1402
|
for m in data.get("memories", [])
|
|
1364
1403
|
]
|
|
1365
1404
|
return AskResult(
|
|
1366
|
-
answer=data
|
|
1367
|
-
memories_used=data
|
|
1405
|
+
answer=_require(data, "answer", "POST /api/ask"),
|
|
1406
|
+
memories_used=_require(data, "memories_used", "POST /api/ask"),
|
|
1368
1407
|
memories=memories,
|
|
1369
1408
|
)
|
|
1370
1409
|
|
|
@@ -1424,12 +1463,13 @@ class MemWal:
|
|
|
1424
1463
|
"namespace": namespace,
|
|
1425
1464
|
"limit": limit,
|
|
1426
1465
|
})
|
|
1466
|
+
endpoint = "POST /api/restore"
|
|
1427
1467
|
return RestoreResult(
|
|
1428
|
-
restored=data
|
|
1429
|
-
skipped=data
|
|
1430
|
-
total=data
|
|
1431
|
-
namespace=data
|
|
1432
|
-
owner=data
|
|
1468
|
+
restored=_require(data, "restored", endpoint),
|
|
1469
|
+
skipped=_require(data, "skipped", endpoint),
|
|
1470
|
+
total=_require(data, "total", endpoint),
|
|
1471
|
+
namespace=_require(data, "namespace", endpoint),
|
|
1472
|
+
owner=_require(data, "owner", endpoint),
|
|
1433
1473
|
# Relayers older than WALM-319 omit `truncated` entirely --
|
|
1434
1474
|
# treat "not present" as "not known to be truncated" rather
|
|
1435
1475
|
# than require every relayer version to send it.
|
|
@@ -1492,20 +1532,21 @@ class MemWal:
|
|
|
1492
1532
|
# against `path_and_query`, not `path`.
|
|
1493
1533
|
path = f"/v1/owners/{owner}/namespaces" + (f"?{query}" if query else "")
|
|
1494
1534
|
data = await self._signed_request("GET", path, {}, include_seal_session=False)
|
|
1535
|
+
endpoint = "GET /v1/owners/{owner}/namespaces"
|
|
1495
1536
|
return NamespacesResult(
|
|
1496
1537
|
namespaces=[
|
|
1497
1538
|
NamespaceSummary(
|
|
1498
|
-
id=ns
|
|
1499
|
-
name=ns
|
|
1500
|
-
memory_count=ns
|
|
1501
|
-
storage_used=ns
|
|
1502
|
-
updated_at=ns
|
|
1539
|
+
id=_require(ns, "id", endpoint),
|
|
1540
|
+
name=_require(ns, "name", endpoint),
|
|
1541
|
+
memory_count=_require(ns, "memory_count", endpoint),
|
|
1542
|
+
storage_used=_require(ns, "storage_used", endpoint),
|
|
1543
|
+
updated_at=_require(ns, "updated_at", endpoint),
|
|
1503
1544
|
)
|
|
1504
|
-
for ns in data
|
|
1545
|
+
for ns in _require(data, "namespaces", endpoint)
|
|
1505
1546
|
],
|
|
1506
1547
|
next_cursor=data.get("next_cursor"),
|
|
1507
|
-
has_more=data
|
|
1508
|
-
snapshot_version=data
|
|
1548
|
+
has_more=_require(data, "has_more", endpoint),
|
|
1549
|
+
snapshot_version=_require(data, "snapshot_version", endpoint),
|
|
1509
1550
|
)
|
|
1510
1551
|
|
|
1511
1552
|
async def health(self) -> HealthResult:
|
|
@@ -1523,8 +1564,8 @@ class MemWal:
|
|
|
1523
1564
|
raise MemWalError(f"Health check failed: {response.status_code}")
|
|
1524
1565
|
data = response.json()
|
|
1525
1566
|
return HealthResult(
|
|
1526
|
-
status=data
|
|
1527
|
-
version=data
|
|
1567
|
+
status=_require(data, "status", "GET /health"),
|
|
1568
|
+
version=_require(data, "version", "GET /health"),
|
|
1528
1569
|
relayer_version=data.get("relayerVersion"),
|
|
1529
1570
|
api_version=data.get("apiVersion"),
|
|
1530
1571
|
min_supported_sdk=data.get("minSupportedSdk"),
|
|
@@ -1569,11 +1610,12 @@ class MemWal:
|
|
|
1569
1610
|
},
|
|
1570
1611
|
include_seal_session=False,
|
|
1571
1612
|
)
|
|
1613
|
+
endpoint = "POST /api/remember/manual"
|
|
1572
1614
|
return RememberManualResult(
|
|
1573
|
-
id=data
|
|
1574
|
-
blob_id=data
|
|
1575
|
-
owner=data
|
|
1576
|
-
namespace=data
|
|
1615
|
+
id=_require(data, "id", endpoint),
|
|
1616
|
+
blob_id=_require(data, "blob_id", endpoint),
|
|
1617
|
+
owner=_require(data, "owner", endpoint),
|
|
1618
|
+
namespace=_require(data, "namespace", endpoint),
|
|
1577
1619
|
)
|
|
1578
1620
|
|
|
1579
1621
|
async def recall_manual(self, opts: RecallManualOptions) -> RecallManualResult:
|
|
@@ -38,6 +38,7 @@ from .types import (
|
|
|
38
38
|
RememberResult,
|
|
39
39
|
RestoreResult,
|
|
40
40
|
)
|
|
41
|
+
from .utils import validate_max_distance
|
|
41
42
|
|
|
42
43
|
_MOCK_NAMESPACE_EPOCH = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
43
44
|
|
|
@@ -285,6 +286,7 @@ class MemWalMock:
|
|
|
285
286
|
query_text = query
|
|
286
287
|
_validate_text(query_text, "query")
|
|
287
288
|
_check_limit(limit)
|
|
289
|
+
validate_max_distance(max_distance)
|
|
288
290
|
if sort not in (None, "relevance", "recent"):
|
|
289
291
|
raise ValueError("sort must be 'relevance' or 'recent'")
|
|
290
292
|
resolved_namespace = self._namespace if namespace is None else namespace
|
|
@@ -9,8 +9,9 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import base64
|
|
11
11
|
import hashlib
|
|
12
|
+
import math
|
|
12
13
|
from datetime import datetime, timezone
|
|
13
|
-
from typing import Tuple, Union
|
|
14
|
+
from typing import Optional, Tuple, Union
|
|
14
15
|
|
|
15
16
|
import nacl.signing
|
|
16
17
|
|
|
@@ -373,3 +374,23 @@ def sign_sui_personal_message(
|
|
|
373
374
|
public_key_bytes = bytes(signing_key.verify_key)
|
|
374
375
|
serialized = bytes([_SUI_ED25519_SCHEME_FLAG]) + signature_bytes + public_key_bytes
|
|
375
376
|
return base64.b64encode(serialized).decode("ascii")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def validate_max_distance(max_distance: Optional[float]) -> None:
|
|
380
|
+
"""Reject a ``max_distance`` that would silently drop every hit (#1248).
|
|
381
|
+
|
|
382
|
+
The recall filter keeps ``distance < max_distance``. Every comparison with
|
|
383
|
+
NaN is False and real distances are never negative, so NaN or a negative
|
|
384
|
+
threshold returns an empty page that looks like an empty namespace.
|
|
385
|
+
Mirrors the TypeScript ``maxDistance`` guard (#1227).
|
|
386
|
+
"""
|
|
387
|
+
|
|
388
|
+
if max_distance is None:
|
|
389
|
+
return
|
|
390
|
+
if (
|
|
391
|
+
isinstance(max_distance, bool)
|
|
392
|
+
or not isinstance(max_distance, (int, float))
|
|
393
|
+
or not math.isfinite(max_distance)
|
|
394
|
+
or max_distance < 0
|
|
395
|
+
):
|
|
396
|
+
raise ValueError("max_distance must be a non-negative finite number")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""``ask()`` validates its question and limit before signing (#1249).
|
|
2
|
+
|
|
3
|
+
``remember``/``analyze``/``embed`` reject blank text before signing; ``ask``
|
|
4
|
+
did not, so an empty question reached the LLM-backed endpoint. Its
|
|
5
|
+
``limit`` was also forwarded verbatim, including a literal ``NaN`` token
|
|
6
|
+
that is not valid JSON.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
from typing import Any, List
|
|
14
|
+
|
|
15
|
+
import nacl.signing
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from memwal import MemWal
|
|
19
|
+
from memwal.utils import bytes_to_hex
|
|
20
|
+
|
|
21
|
+
_KEY_HEX = bytes_to_hex(bytes(nacl.signing.SigningKey(b"\x01" * 32)))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _client(sent: List[str]) -> MemWal:
|
|
25
|
+
client = MemWal.create(key=_KEY_HEX, account_id="0x1", server_url="https://relayer.example")
|
|
26
|
+
|
|
27
|
+
async def fake_signed_request(method, path, body, **kwargs): # type: ignore[no-untyped-def]
|
|
28
|
+
# Same serialization the real request path uses for the signed body.
|
|
29
|
+
sent.append(json.dumps(body))
|
|
30
|
+
return {"answer": "hi", "memories_used": 0, "memories": []}
|
|
31
|
+
|
|
32
|
+
client._signed_request = fake_signed_request # type: ignore[method-assign]
|
|
33
|
+
return client
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.parametrize("question", ["", " ", "\t\n"])
|
|
37
|
+
async def test_blank_question_rejected_before_signing(question: str) -> None:
|
|
38
|
+
sent: List[str] = []
|
|
39
|
+
with pytest.raises(ValueError, match="question cannot be empty"):
|
|
40
|
+
await _client(sent).ask(question)
|
|
41
|
+
assert sent == []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.parametrize("limit", [math.nan, math.inf, -1, 0, 2.5, "5", True, None])
|
|
45
|
+
async def test_bad_limit_rejected_before_signing(limit: Any) -> None:
|
|
46
|
+
sent: List[str] = []
|
|
47
|
+
with pytest.raises(ValueError, match="limit must be a positive integer"):
|
|
48
|
+
await _client(sent).ask("q?", limit=limit)
|
|
49
|
+
assert sent == []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def test_valid_call_is_sent_unchanged() -> None:
|
|
53
|
+
sent: List[str] = []
|
|
54
|
+
result = await _client(sent).ask("q?", limit=3)
|
|
55
|
+
assert result.answer == "hi"
|
|
56
|
+
assert json.loads(sent[0]) == {"question": "q?", "limit": 3, "namespace": "default"}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""A 2xx body missing a required field raises ``MemWalError`` (#1250).
|
|
2
|
+
|
|
3
|
+
#1219 covers 2xx bodies that are not JSON. This is the valid-JSON sibling:
|
|
4
|
+
six methods subscripted response fields directly, so a relayer that drifted
|
|
5
|
+
or a proxy rewriting the body surfaced as a bare ``KeyError`` that callers
|
|
6
|
+
catching ``MemWalError`` (the documented error type) never saw.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import nacl.signing
|
|
15
|
+
import pytest
|
|
16
|
+
import respx
|
|
17
|
+
|
|
18
|
+
from memwal import MemWal, MemWalError
|
|
19
|
+
from memwal.types import RememberManualOptions
|
|
20
|
+
from memwal.utils import bytes_to_hex
|
|
21
|
+
|
|
22
|
+
_SERVER = "https://relayer.example"
|
|
23
|
+
_KEY_HEX = bytes_to_hex(bytes(nacl.signing.SigningKey(b"\x01" * 32)))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _client(body: Any) -> MemWal:
|
|
27
|
+
client = MemWal.create(key=_KEY_HEX, account_id="0x1", server_url=_SERVER)
|
|
28
|
+
|
|
29
|
+
async def fake_signed_request(method, path, req_body, **kwargs): # type: ignore[no-untyped-def]
|
|
30
|
+
return body
|
|
31
|
+
|
|
32
|
+
async def fake_owner() -> str:
|
|
33
|
+
return "0xowner"
|
|
34
|
+
|
|
35
|
+
client._signed_request = fake_signed_request # type: ignore[method-assign]
|
|
36
|
+
client._resolve_owner = fake_owner # type: ignore[method-assign]
|
|
37
|
+
return client
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _assert_names(exc: pytest.ExceptionInfo[MemWalError], endpoint: str, field: str) -> None:
|
|
41
|
+
assert not isinstance(exc.value, KeyError)
|
|
42
|
+
message = str(exc.value)
|
|
43
|
+
assert endpoint in message, message
|
|
44
|
+
assert field in message, message
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@respx.mock
|
|
48
|
+
async def test_health_missing_status() -> None:
|
|
49
|
+
respx.get(f"{_SERVER}/health").mock(return_value=httpx.Response(200, json={}))
|
|
50
|
+
client = MemWal.create(key=_KEY_HEX, account_id="0x1", server_url=_SERVER)
|
|
51
|
+
with pytest.raises(MemWalError) as exc:
|
|
52
|
+
await client.health()
|
|
53
|
+
_assert_names(exc, "GET /health", "status")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@respx.mock
|
|
57
|
+
async def test_health_non_object_body() -> None:
|
|
58
|
+
respx.get(f"{_SERVER}/health").mock(return_value=httpx.Response(200, json=["ok"]))
|
|
59
|
+
client = MemWal.create(key=_KEY_HEX, account_id="0x1", server_url=_SERVER)
|
|
60
|
+
with pytest.raises(MemWalError) as exc:
|
|
61
|
+
await client.health()
|
|
62
|
+
_assert_names(exc, "GET /health", "status")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def test_ask_missing_memories_used() -> None:
|
|
66
|
+
with pytest.raises(MemWalError) as exc:
|
|
67
|
+
await _client({"answer": "hi"}).ask("q?")
|
|
68
|
+
_assert_names(exc, "POST /api/ask", "memories_used")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def test_ask_memory_entry_missing_text() -> None:
|
|
72
|
+
body = {"answer": "hi", "memories_used": 1, "memories": [{"blob_id": "b", "distance": 0.1}]}
|
|
73
|
+
with pytest.raises(MemWalError) as exc:
|
|
74
|
+
await _client(body).ask("q?")
|
|
75
|
+
_assert_names(exc, "POST /api/ask", "text")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def test_restore_missing_restored() -> None:
|
|
79
|
+
with pytest.raises(MemWalError) as exc:
|
|
80
|
+
await _client({}).restore("ns")
|
|
81
|
+
_assert_names(exc, "POST /api/restore", "restored")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def test_list_namespaces_missing_namespaces() -> None:
|
|
85
|
+
with pytest.raises(MemWalError) as exc:
|
|
86
|
+
await _client({}).list_namespaces()
|
|
87
|
+
_assert_names(exc, "namespaces", "namespaces")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def test_list_namespaces_entry_missing_name() -> None:
|
|
91
|
+
body: Dict[str, Any] = {
|
|
92
|
+
"namespaces": [{"id": "ns-1"}],
|
|
93
|
+
"has_more": False,
|
|
94
|
+
"snapshot_version": 1,
|
|
95
|
+
}
|
|
96
|
+
with pytest.raises(MemWalError) as exc:
|
|
97
|
+
await _client(body).list_namespaces()
|
|
98
|
+
_assert_names(exc, "GET /v1/owners/", "name")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def test_remember_manual_missing_id() -> None:
|
|
102
|
+
opts = RememberManualOptions(encrypted_data="ZGF0YQ==", vector=[0.1])
|
|
103
|
+
with pytest.raises(MemWalError) as exc:
|
|
104
|
+
await _client({}).remember_manual(opts)
|
|
105
|
+
_assert_names(exc, "POST /api/remember/manual", "id")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def test_recall_entry_missing_text() -> None:
|
|
109
|
+
body = {"results": [{"blob_id": "b", "distance": 0.1}], "total": 1}
|
|
110
|
+
with pytest.raises(MemWalError) as exc:
|
|
111
|
+
await _client(body).recall("coffee")
|
|
112
|
+
_assert_names(exc, "POST /api/recall", "text")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def test_well_formed_bodies_still_parse() -> None:
|
|
116
|
+
ask = await _client({"answer": "hi", "memories_used": 0}).ask("q?")
|
|
117
|
+
assert ask.answer == "hi" and ask.memories == []
|
|
118
|
+
recall = await _client(
|
|
119
|
+
{"results": [{"blob_id": "b", "text": "t", "distance": 0.1}], "total": 1}
|
|
120
|
+
).recall("coffee")
|
|
121
|
+
assert recall.results[0].text == "t"
|
|
122
|
+
restore = await _client(
|
|
123
|
+
{"restored": 1, "skipped": 0, "total": 1, "namespace": "ns", "owner": "0xo"}
|
|
124
|
+
).restore("ns")
|
|
125
|
+
assert restore.truncated is False and restore.failed == 0
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""``recall()`` input validation in the real client and the mock (#1248).
|
|
2
|
+
|
|
3
|
+
Ports the TypeScript guards from #1228 (blank query) and #1227 (NaN /
|
|
4
|
+
negative ``maxDistance``). Before this, the Python client signed an empty
|
|
5
|
+
query, and a NaN or negative ``max_distance`` silently dropped every hit the
|
|
6
|
+
relayer returned, which looked exactly like an empty namespace.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from typing import Any, Dict, List
|
|
13
|
+
|
|
14
|
+
import nacl.signing
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from memwal import MemWal, MemWalMock, RecallParams
|
|
18
|
+
from memwal.utils import bytes_to_hex
|
|
19
|
+
|
|
20
|
+
_KEY_HEX = bytes_to_hex(bytes(nacl.signing.SigningKey(b"\x01" * 32)))
|
|
21
|
+
_HITS = {
|
|
22
|
+
"results": [
|
|
23
|
+
{"blob_id": "b1", "text": "coffee", "distance": 0.5},
|
|
24
|
+
{"blob_id": "b2", "text": "tea", "distance": 1.0},
|
|
25
|
+
],
|
|
26
|
+
"total": 2,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_BAD_DISTANCES = [math.nan, -1.0, -0.001, math.inf, "0.5", True]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _client(sent: List[Dict[str, Any]]) -> MemWal:
|
|
33
|
+
client = MemWal.create(key=_KEY_HEX, account_id="0x1", server_url="https://relayer.example")
|
|
34
|
+
|
|
35
|
+
async def fake_signed_request(method, path, body, **kwargs): # type: ignore[no-untyped-def]
|
|
36
|
+
sent.append(body)
|
|
37
|
+
return _HITS
|
|
38
|
+
|
|
39
|
+
client._signed_request = fake_signed_request # type: ignore[method-assign]
|
|
40
|
+
return client
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.parametrize("query", ["", " ", "\t\n"])
|
|
44
|
+
async def test_client_rejects_blank_query_before_signing(query: str) -> None:
|
|
45
|
+
sent: List[Dict[str, Any]] = []
|
|
46
|
+
with pytest.raises(ValueError, match="query cannot be empty"):
|
|
47
|
+
await _client(sent).recall(query)
|
|
48
|
+
with pytest.raises(ValueError, match="query cannot be empty"):
|
|
49
|
+
await _client(sent).recall(RecallParams(query=query))
|
|
50
|
+
assert sent == []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.parametrize("max_distance", _BAD_DISTANCES)
|
|
54
|
+
async def test_client_rejects_bad_max_distance_before_signing(max_distance: Any) -> None:
|
|
55
|
+
sent: List[Dict[str, Any]] = []
|
|
56
|
+
with pytest.raises(ValueError, match="max_distance must be a non-negative finite number"):
|
|
57
|
+
await _client(sent).recall("coffee", max_distance=max_distance)
|
|
58
|
+
with pytest.raises(ValueError, match="max_distance must be a non-negative finite number"):
|
|
59
|
+
await _client(sent).recall(RecallParams(query="coffee", max_distance=max_distance))
|
|
60
|
+
assert sent == []
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def test_client_valid_max_distance_still_filters() -> None:
|
|
64
|
+
sent: List[Dict[str, Any]] = []
|
|
65
|
+
result = await _client(sent).recall("coffee", max_distance=0.75)
|
|
66
|
+
assert [m.blob_id for m in result.results] == ["b1"]
|
|
67
|
+
assert result.total == 2
|
|
68
|
+
zero = await _client(sent).recall("coffee", max_distance=0)
|
|
69
|
+
assert zero.results == []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.parametrize("max_distance", _BAD_DISTANCES)
|
|
73
|
+
async def test_mock_rejects_bad_max_distance(max_distance: Any) -> None:
|
|
74
|
+
mock = MemWalMock.create()
|
|
75
|
+
await mock.remember("I like coffee")
|
|
76
|
+
with pytest.raises(ValueError, match="max_distance must be a non-negative finite number"):
|
|
77
|
+
await mock.recall("coffee", max_distance=max_distance)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def test_mock_valid_max_distance_still_filters() -> None:
|
|
81
|
+
mock = MemWalMock.create()
|
|
82
|
+
await mock.remember("I like coffee")
|
|
83
|
+
result = await mock.recall("coffee", max_distance=2.0)
|
|
84
|
+
assert len(result.results) == 1
|
|
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
|
|
File without changes
|
|
File without changes
|