memwal 0.2.2.dev0__tar.gz → 0.2.2.dev1__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.dev1}/CHANGELOG.md +1 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/PKG-INFO +1 -1
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/memwal/__init__.py +1 -1
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/memwal/client.py +50 -25
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/pyproject.toml +1 -1
- memwal-0.2.2.dev1/tests/test_missing_response_fields.py +125 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/.gitignore +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/README.md +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/examples/.env.example +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/examples/.gitignore +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/examples/async_remember_demo.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/examples/interactive_demo.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/examples/verify_credentials.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/memwal/compatibility.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/memwal/middleware.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/memwal/mock.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/memwal/types.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/memwal/utils.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/run_tests.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/tests/__init__.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/tests/test_auth_rejected_message.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/tests/test_blank_text.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/tests/test_client.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/tests/test_destroy_and_cancel_shield.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/tests/test_empty_namespace.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/tests/test_env_presets.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/tests/test_integration.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/tests/test_list_namespaces.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/tests/test_middleware.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/tests/test_mock.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/tests/test_nonfinite_validation.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/tests/test_normalize_server_url.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/tests/test_polling_delay.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/tests/test_recall_weights.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/tests/test_remember_derived_key.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/tests/test_retry_after_http_date.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/tests/test_signing.py +0 -0
- {memwal-0.2.2.dev0 → memwal-0.2.2.dev1}/tests/test_transport_retry.py +0 -0
|
@@ -11,6 +11,7 @@ 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)
|
|
14
15
|
|
|
15
16
|
## 0.2.1
|
|
16
17
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: memwal
|
|
3
|
-
Version: 0.2.2.
|
|
3
|
+
Version: 0.2.2.dev1
|
|
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
|
|
@@ -142,6 +142,24 @@ def _reject_blank_text(text: object, message: str = "Text cannot be empty") -> N
|
|
|
142
142
|
raise ValueError(message)
|
|
143
143
|
|
|
144
144
|
|
|
145
|
+
def _require(data: object, field: str, endpoint: str) -> Any:
|
|
146
|
+
"""``data[field]`` from a parsed 2xx body, or a ``MemWalError`` naming both.
|
|
147
|
+
|
|
148
|
+
A bare ``KeyError`` is not a ``MemWalError``, carries only the field name,
|
|
149
|
+
and is raised far from the response that caused it (#1250). Optional
|
|
150
|
+
fields keep using ``.get()`` with a documented default.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
if not isinstance(data, dict):
|
|
154
|
+
raise MemWalError(
|
|
155
|
+
f"{endpoint} response was {type(data).__name__}, expected an object "
|
|
156
|
+
f"with {field!r}"
|
|
157
|
+
)
|
|
158
|
+
if field not in data:
|
|
159
|
+
raise MemWalError(f"{endpoint} response missing {field!r}")
|
|
160
|
+
return data[field]
|
|
161
|
+
|
|
162
|
+
|
|
145
163
|
IDEMPOTENCY_BUCKET_MS = 30 * 60 * 1000
|
|
146
164
|
|
|
147
165
|
|
|
@@ -1189,9 +1207,9 @@ class MemWal:
|
|
|
1189
1207
|
data = await self._signed_request("POST", "/api/recall", body)
|
|
1190
1208
|
memories = [
|
|
1191
1209
|
RecallMemory(
|
|
1192
|
-
blob_id=m
|
|
1193
|
-
text=m
|
|
1194
|
-
distance=m
|
|
1210
|
+
blob_id=_require(m, "blob_id", "POST /api/recall"),
|
|
1211
|
+
text=_require(m, "text", "POST /api/recall"),
|
|
1212
|
+
distance=_require(m, "distance", "POST /api/recall"),
|
|
1195
1213
|
score=m.get("score"),
|
|
1196
1214
|
created_at=m.get("created_at"),
|
|
1197
1215
|
)
|
|
@@ -1359,12 +1377,16 @@ class MemWal:
|
|
|
1359
1377
|
"namespace": self._resolve_namespace(namespace),
|
|
1360
1378
|
})
|
|
1361
1379
|
memories = [
|
|
1362
|
-
AskMemory(
|
|
1380
|
+
AskMemory(
|
|
1381
|
+
blob_id=_require(m, "blob_id", "POST /api/ask"),
|
|
1382
|
+
text=_require(m, "text", "POST /api/ask"),
|
|
1383
|
+
distance=_require(m, "distance", "POST /api/ask"),
|
|
1384
|
+
)
|
|
1363
1385
|
for m in data.get("memories", [])
|
|
1364
1386
|
]
|
|
1365
1387
|
return AskResult(
|
|
1366
|
-
answer=data
|
|
1367
|
-
memories_used=data
|
|
1388
|
+
answer=_require(data, "answer", "POST /api/ask"),
|
|
1389
|
+
memories_used=_require(data, "memories_used", "POST /api/ask"),
|
|
1368
1390
|
memories=memories,
|
|
1369
1391
|
)
|
|
1370
1392
|
|
|
@@ -1424,12 +1446,13 @@ class MemWal:
|
|
|
1424
1446
|
"namespace": namespace,
|
|
1425
1447
|
"limit": limit,
|
|
1426
1448
|
})
|
|
1449
|
+
endpoint = "POST /api/restore"
|
|
1427
1450
|
return RestoreResult(
|
|
1428
|
-
restored=data
|
|
1429
|
-
skipped=data
|
|
1430
|
-
total=data
|
|
1431
|
-
namespace=data
|
|
1432
|
-
owner=data
|
|
1451
|
+
restored=_require(data, "restored", endpoint),
|
|
1452
|
+
skipped=_require(data, "skipped", endpoint),
|
|
1453
|
+
total=_require(data, "total", endpoint),
|
|
1454
|
+
namespace=_require(data, "namespace", endpoint),
|
|
1455
|
+
owner=_require(data, "owner", endpoint),
|
|
1433
1456
|
# Relayers older than WALM-319 omit `truncated` entirely --
|
|
1434
1457
|
# treat "not present" as "not known to be truncated" rather
|
|
1435
1458
|
# than require every relayer version to send it.
|
|
@@ -1492,20 +1515,21 @@ class MemWal:
|
|
|
1492
1515
|
# against `path_and_query`, not `path`.
|
|
1493
1516
|
path = f"/v1/owners/{owner}/namespaces" + (f"?{query}" if query else "")
|
|
1494
1517
|
data = await self._signed_request("GET", path, {}, include_seal_session=False)
|
|
1518
|
+
endpoint = "GET /v1/owners/{owner}/namespaces"
|
|
1495
1519
|
return NamespacesResult(
|
|
1496
1520
|
namespaces=[
|
|
1497
1521
|
NamespaceSummary(
|
|
1498
|
-
id=ns
|
|
1499
|
-
name=ns
|
|
1500
|
-
memory_count=ns
|
|
1501
|
-
storage_used=ns
|
|
1502
|
-
updated_at=ns
|
|
1522
|
+
id=_require(ns, "id", endpoint),
|
|
1523
|
+
name=_require(ns, "name", endpoint),
|
|
1524
|
+
memory_count=_require(ns, "memory_count", endpoint),
|
|
1525
|
+
storage_used=_require(ns, "storage_used", endpoint),
|
|
1526
|
+
updated_at=_require(ns, "updated_at", endpoint),
|
|
1503
1527
|
)
|
|
1504
|
-
for ns in data
|
|
1528
|
+
for ns in _require(data, "namespaces", endpoint)
|
|
1505
1529
|
],
|
|
1506
1530
|
next_cursor=data.get("next_cursor"),
|
|
1507
|
-
has_more=data
|
|
1508
|
-
snapshot_version=data
|
|
1531
|
+
has_more=_require(data, "has_more", endpoint),
|
|
1532
|
+
snapshot_version=_require(data, "snapshot_version", endpoint),
|
|
1509
1533
|
)
|
|
1510
1534
|
|
|
1511
1535
|
async def health(self) -> HealthResult:
|
|
@@ -1523,8 +1547,8 @@ class MemWal:
|
|
|
1523
1547
|
raise MemWalError(f"Health check failed: {response.status_code}")
|
|
1524
1548
|
data = response.json()
|
|
1525
1549
|
return HealthResult(
|
|
1526
|
-
status=data
|
|
1527
|
-
version=data
|
|
1550
|
+
status=_require(data, "status", "GET /health"),
|
|
1551
|
+
version=_require(data, "version", "GET /health"),
|
|
1528
1552
|
relayer_version=data.get("relayerVersion"),
|
|
1529
1553
|
api_version=data.get("apiVersion"),
|
|
1530
1554
|
min_supported_sdk=data.get("minSupportedSdk"),
|
|
@@ -1569,11 +1593,12 @@ class MemWal:
|
|
|
1569
1593
|
},
|
|
1570
1594
|
include_seal_session=False,
|
|
1571
1595
|
)
|
|
1596
|
+
endpoint = "POST /api/remember/manual"
|
|
1572
1597
|
return RememberManualResult(
|
|
1573
|
-
id=data
|
|
1574
|
-
blob_id=data
|
|
1575
|
-
owner=data
|
|
1576
|
-
namespace=data
|
|
1598
|
+
id=_require(data, "id", endpoint),
|
|
1599
|
+
blob_id=_require(data, "blob_id", endpoint),
|
|
1600
|
+
owner=_require(data, "owner", endpoint),
|
|
1601
|
+
namespace=_require(data, "namespace", endpoint),
|
|
1577
1602
|
)
|
|
1578
1603
|
|
|
1579
1604
|
async def recall_manual(self, opts: RecallManualOptions) -> RecallManualResult:
|
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|