memwal 0.2.2.dev1__tar.gz → 0.2.2.dev3__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.dev1 → memwal-0.2.2.dev3}/CHANGELOG.md +2 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/PKG-INFO +2 -2
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/README.md +1 -1
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/memwal/__init__.py +1 -1
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/memwal/client.py +17 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/memwal/mock.py +2 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/memwal/types.py +1 -1
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/memwal/utils.py +22 -1
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/notebooks/walrus_memory_python_sdk.ipynb +2 -2
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/pyproject.toml +1 -1
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/run_tests.py +1 -1
- memwal-0.2.2.dev3/tests/test_ask_validation.py +56 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/tests/test_env_presets.py +2 -2
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/tests/test_integration.py +20 -19
- memwal-0.2.2.dev3/tests/test_recall_validation.py +84 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/.gitignore +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/examples/.env.example +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/examples/.gitignore +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/examples/async_remember_demo.py +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/examples/interactive_demo.py +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/examples/verify_credentials.py +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/memwal/compatibility.py +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/memwal/middleware.py +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/tests/__init__.py +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/tests/test_auth_rejected_message.py +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/tests/test_blank_text.py +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/tests/test_client.py +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/tests/test_destroy_and_cancel_shield.py +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/tests/test_empty_namespace.py +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/tests/test_list_namespaces.py +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/tests/test_middleware.py +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/tests/test_missing_response_fields.py +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/tests/test_mock.py +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/tests/test_nonfinite_validation.py +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/tests/test_normalize_server_url.py +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/tests/test_polling_delay.py +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/tests/test_recall_weights.py +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/tests/test_remember_derived_key.py +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/tests/test_retry_after_http_date.py +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/tests/test_signing.py +0 -0
- {memwal-0.2.2.dev1 → memwal-0.2.2.dev3}/tests/test_transport_retry.py +0 -0
|
@@ -12,6 +12,8 @@ Unreleased.
|
|
|
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
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)
|
|
15
17
|
|
|
16
18
|
## 0.2.1
|
|
17
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.dev3
|
|
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
|
|
@@ -181,7 +181,7 @@ memwal = MemWal.create(
|
|
|
181
181
|
|-------|-------------|
|
|
182
182
|
| `prod` | `https://relayer.memory.walrus.xyz` |
|
|
183
183
|
| `dev` | `https://relayer.dev.memwal.ai` |
|
|
184
|
-
| `staging` | `https://relayer
|
|
184
|
+
| `staging` | `https://relayer.dev.memwal.ai` |
|
|
185
185
|
|
|
186
186
|
Precedence: any explicit **`server_url` wins over `env`**, including the hosted
|
|
187
187
|
default. `env` applies only when `server_url` is omitted (`None`). An unknown
|
|
@@ -140,7 +140,7 @@ memwal = MemWal.create(
|
|
|
140
140
|
|-------|-------------|
|
|
141
141
|
| `prod` | `https://relayer.memory.walrus.xyz` |
|
|
142
142
|
| `dev` | `https://relayer.dev.memwal.ai` |
|
|
143
|
-
| `staging` | `https://relayer
|
|
143
|
+
| `staging` | `https://relayer.dev.memwal.ai` |
|
|
144
144
|
|
|
145
145
|
Precedence: any explicit **`server_url` wins over `env`**, including the hosted
|
|
146
146
|
default. `env` applies only when `server_url` is omitted (`None`). An unknown
|
|
@@ -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")
|
|
@@ -160,6 +161,18 @@ def _require(data: object, field: str, endpoint: str) -> Any:
|
|
|
160
161
|
return data[field]
|
|
161
162
|
|
|
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
|
+
|
|
163
176
|
IDEMPOTENCY_BUCKET_MS = 30 * 60 * 1000
|
|
164
177
|
|
|
165
178
|
|
|
@@ -1194,7 +1207,9 @@ class MemWal:
|
|
|
1194
1207
|
sort = params.sort
|
|
1195
1208
|
else:
|
|
1196
1209
|
query_text = query
|
|
1210
|
+
_reject_blank_text(query_text, "query cannot be empty")
|
|
1197
1211
|
_check_limit(limit)
|
|
1212
|
+
validate_max_distance(max_distance)
|
|
1198
1213
|
body = {
|
|
1199
1214
|
"query": query_text,
|
|
1200
1215
|
"limit": limit,
|
|
@@ -1371,6 +1386,8 @@ class MemWal:
|
|
|
1371
1386
|
Returns:
|
|
1372
1387
|
:class:`AskResult` with answer, memories_used count, and memories list.
|
|
1373
1388
|
"""
|
|
1389
|
+
_reject_blank_text(question, "question cannot be empty")
|
|
1390
|
+
_require_positive_int(limit, "limit")
|
|
1374
1391
|
data = await self._signed_request("POST", "/api/ask", {
|
|
1375
1392
|
"question": question,
|
|
1376
1393
|
"limit": limit,
|
|
@@ -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
|
|
@@ -24,7 +24,7 @@ DEFAULT_SERVER_URL = "https://relayer.memory.walrus.xyz"
|
|
|
24
24
|
ENV_PRESETS = {
|
|
25
25
|
"prod": "https://relayer.memory.walrus.xyz",
|
|
26
26
|
"dev": "https://relayer.dev.memwal.ai",
|
|
27
|
-
"staging": "https://relayer
|
|
27
|
+
"staging": "https://relayer.dev.memwal.ai",
|
|
28
28
|
"local": "http://127.0.0.1:8000",
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -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")
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"- optional OpenAI and LangChain middleware examples\n",
|
|
31
31
|
"- cleanup and troubleshooting\n",
|
|
32
32
|
"\n",
|
|
33
|
-
"The notebook defaults to `staging` for test credentials and uses Railway-backed Walrus Memory domains: `https://relayer
|
|
33
|
+
"The notebook defaults to `staging` for test credentials and uses Railway-backed Walrus Memory domains: `https://relayer.dev.memwal.ai` for staging and `https://relayer.memory.walrus.xyz` for production. You can switch to `prod` when you have production credentials.\n"
|
|
34
34
|
]
|
|
35
35
|
},
|
|
36
36
|
{
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"\n",
|
|
92
92
|
"ALLOWED_ENVS = {\"staging\", \"prod\"}\n",
|
|
93
93
|
"SERVER_URL_PRESETS = {\n",
|
|
94
|
-
" \"staging\": \"https://relayer
|
|
94
|
+
" \"staging\": \"https://relayer.dev.memwal.ai\",\n",
|
|
95
95
|
" \"prod\": \"https://relayer.memory.walrus.xyz\",\n",
|
|
96
96
|
"}\n",
|
|
97
97
|
"DEFAULT_ENV = \"staging\"\n",
|
|
@@ -26,7 +26,7 @@ BOLD = "\033[1m"
|
|
|
26
26
|
DIM = "\033[2m"
|
|
27
27
|
RESET = "\033[0m"
|
|
28
28
|
|
|
29
|
-
SERVER_URL = os.environ.get("MEMWAL_SERVER_URL", "https://relayer
|
|
29
|
+
SERVER_URL = os.environ.get("MEMWAL_SERVER_URL", "https://relayer.dev.memwal.ai")
|
|
30
30
|
PRIVATE_KEY = os.environ.get("MEMWAL_PRIVATE_KEY", "")
|
|
31
31
|
ACCOUNT_ID = os.environ.get("MEMWAL_ACCOUNT_ID", "")
|
|
32
32
|
HAS_KEY = bool(PRIVATE_KEY and ACCOUNT_ID)
|
|
@@ -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"}
|
|
@@ -19,7 +19,7 @@ ACCOUNT = "0x" + "ab" * 32
|
|
|
19
19
|
[
|
|
20
20
|
("prod", "https://relayer.memory.walrus.xyz"),
|
|
21
21
|
("dev", "https://relayer.dev.memwal.ai"),
|
|
22
|
-
("staging", "https://relayer
|
|
22
|
+
("staging", "https://relayer.dev.memwal.ai"),
|
|
23
23
|
("local", "http://127.0.0.1:8000"),
|
|
24
24
|
],
|
|
25
25
|
)
|
|
@@ -52,7 +52,7 @@ def test_unknown_env_raises():
|
|
|
52
52
|
|
|
53
53
|
def test_create_threads_env_through_to_client():
|
|
54
54
|
client = MemWal.create(key=KEY, account_id=ACCOUNT, env="staging")
|
|
55
|
-
assert client._server_url == "https://relayer
|
|
55
|
+
assert client._server_url == "https://relayer.dev.memwal.ai"
|
|
56
56
|
|
|
57
57
|
|
|
58
58
|
def test_sync_create_threads_env_through():
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"""
|
|
3
3
|
Integration tests for the Walrus Memory Python SDK against a live server.
|
|
4
4
|
|
|
5
|
-
Targets MEMWAL_SERVER_URL (default: https://relayer
|
|
5
|
+
Targets MEMWAL_SERVER_URL (default: https://relayer.dev.memwal.ai).
|
|
6
6
|
|
|
7
7
|
No-auth tests (always run, no env vars needed):
|
|
8
8
|
- /health endpoint
|
|
@@ -31,7 +31,7 @@ Usage:
|
|
|
31
31
|
# Run against staging using env vars
|
|
32
32
|
export MEMWAL_PRIVATE_KEY="<your-ed25519-delegate-private-key-hex>"
|
|
33
33
|
export MEMWAL_ACCOUNT_ID="0x-your-walrus-memory-account-id"
|
|
34
|
-
export MEMWAL_SERVER_URL="https://relayer
|
|
34
|
+
export MEMWAL_SERVER_URL="https://relayer.dev.memwal.ai"
|
|
35
35
|
python -m pytest tests/test_integration.py -v
|
|
36
36
|
"""
|
|
37
37
|
|
|
@@ -54,7 +54,7 @@ pytestmark = pytest.mark.integration
|
|
|
54
54
|
|
|
55
55
|
# ── Config ───────────────────────────────────────────────────────────────────
|
|
56
56
|
|
|
57
|
-
SERVER_URL = os.environ.get("MEMWAL_SERVER_URL", "https://relayer
|
|
57
|
+
SERVER_URL = os.environ.get("MEMWAL_SERVER_URL", "https://relayer.dev.memwal.ai")
|
|
58
58
|
PRIVATE_KEY_HEX = os.environ.get("MEMWAL_PRIVATE_KEY", "")
|
|
59
59
|
ACCOUNT_ID = os.environ.get("MEMWAL_ACCOUNT_ID", "")
|
|
60
60
|
|
|
@@ -98,6 +98,9 @@ def _async_client(namespace: str = _E2E_NAMESPACE) -> MemWal:
|
|
|
98
98
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
99
99
|
|
|
100
100
|
|
|
101
|
+
_HTTP_CLIENT = httpx.Client(timeout=30, transport=httpx.HTTPTransport(retries=3))
|
|
102
|
+
|
|
103
|
+
|
|
101
104
|
def _raw_signed_request(
|
|
102
105
|
method: str,
|
|
103
106
|
path: str,
|
|
@@ -125,20 +128,19 @@ def _raw_signed_request(
|
|
|
125
128
|
signature_hex = signed.signature.hex()
|
|
126
129
|
pub_key_hex = pub_key_override or signing_key.verify_key.encode().hex()
|
|
127
130
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
)
|
|
131
|
+
return _HTTP_CLIENT.request(
|
|
132
|
+
method,
|
|
133
|
+
f"{base_url}{path}",
|
|
134
|
+
content=body_bytes,
|
|
135
|
+
headers={
|
|
136
|
+
"Content-Type": "application/json",
|
|
137
|
+
"x-public-key": pub_key_hex,
|
|
138
|
+
"x-signature": signature_hex,
|
|
139
|
+
"x-timestamp": timestamp,
|
|
140
|
+
"x-nonce": nonce,
|
|
141
|
+
"x-account-id": ACCOUNT_ID or "0x0",
|
|
142
|
+
},
|
|
143
|
+
)
|
|
142
144
|
|
|
143
145
|
|
|
144
146
|
# ── No-auth tests (always run) ────────────────────────────────────────────────
|
|
@@ -168,8 +170,7 @@ class TestAuthRejection:
|
|
|
168
170
|
|
|
169
171
|
def test_unsigned_request_rejected(self) -> None:
|
|
170
172
|
"""Request with no auth headers → 401."""
|
|
171
|
-
|
|
172
|
-
resp = client.post(f"{SERVER_URL}/api/remember", json=self._body)
|
|
173
|
+
resp = _HTTP_CLIENT.post(f"{SERVER_URL}/api/remember", json=self._body)
|
|
173
174
|
assert resp.status_code == 401, f"Expected 401, got {resp.status_code}: {resp.text}"
|
|
174
175
|
|
|
175
176
|
def test_wrong_signature_rejected(self) -> None:
|
|
@@ -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
|