memwal 0.2.2.dev2__tar.gz → 0.2.2.dev4__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.dev2 → memwal-0.2.2.dev4}/CHANGELOG.md +2 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/PKG-INFO +4 -3
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/README.md +3 -2
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/memwal/__init__.py +5 -1
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/memwal/client.py +130 -11
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/memwal/compatibility.py +6 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/memwal/mock.py +11 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/memwal/types.py +9 -1
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/notebooks/walrus_memory_python_sdk.ipynb +2 -2
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/pyproject.toml +1 -1
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/run_tests.py +1 -1
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_env_presets.py +2 -2
- memwal-0.2.2.dev4/tests/test_forget.py +91 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_integration.py +20 -19
- memwal-0.2.2.dev4/tests/test_json_body.py +310 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_mock.py +24 -1
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/.gitignore +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/examples/.env.example +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/examples/.gitignore +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/examples/async_remember_demo.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/examples/interactive_demo.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/examples/verify_credentials.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/memwal/middleware.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/memwal/utils.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/__init__.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_ask_validation.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_auth_rejected_message.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_blank_text.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_client.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_destroy_and_cancel_shield.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_empty_namespace.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_list_namespaces.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_middleware.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_missing_response_fields.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_nonfinite_validation.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_normalize_server_url.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_polling_delay.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_recall_validation.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_recall_weights.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_remember_derived_key.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_retry_after_http_date.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_signing.py +0 -0
- {memwal-0.2.2.dev2 → memwal-0.2.2.dev4}/tests/test_transport_retry.py +0 -0
|
@@ -6,10 +6,12 @@ Unreleased.
|
|
|
6
6
|
|
|
7
7
|
### Added
|
|
8
8
|
|
|
9
|
+
- `forget(namespace=None)` (and alias `forget_namespace(namespace=None)`) on `MemWal` and `MemWalSync` wraps `POST /api/forget` and returns `ForgetResult` (`deleted`, `namespace`, `owner`). It deletes the owner's index rows for the namespace while leaving Walrus blobs in place. `MemWalMock` and `MemWalMockSync` retain synchronous per-blob `forget(blob_id) -> bool` and add `forget_namespace(namespace=None) -> ForgetResult` for namespace erasure. (#1061)
|
|
9
10
|
- `MemWal.destroy()` and `MemWalSync.destroy()` (plus `close(clear_keys=True)`) zero the client's internal `bytearray` seed copy in place, clear `_private_key_hex`, drop `_signing_key` and cached SEAL session credentials, cancel in-flight initialization tasks, and reject any subsequent or in-flight operation with `MemWalError`. `MemWal.create()` and `MemWalSync.create()` also accept 32-byte `bytes` / `bytearray` keys and copy caller `bytearray` buffers so `destroy()` leaves the caller's buffer intact. (#975)
|
|
10
11
|
|
|
11
12
|
### Fixed
|
|
12
13
|
|
|
14
|
+
- `health()`, `compatibility()`, `_fetch_server_config()`, package-version preflight probes, and `_signed_request()` raise `MemWalJsonBodyError` (a `MemWalError` subclass carrying `.status`, `.endpoint`, and `.body`) instead of Python's raw `json.JSONDecodeError` when a 2xx response has an empty or non-JSON body. (#1219)
|
|
13
15
|
- 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
16
|
- `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
17
|
- `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)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: memwal
|
|
3
|
-
Version: 0.2.2.
|
|
3
|
+
Version: 0.2.2.dev4
|
|
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
|
|
@@ -147,7 +147,7 @@ async def test_memory_flow():
|
|
|
147
147
|
assert "dark mode" in result.results[0].text
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
-
The mock supports remember/job polling, bulk remember, recall, analyze, embed, ask, health, restore, list_namespaces, `forget(blob_id)
|
|
150
|
+
The mock supports remember/job polling, bulk remember, recall, analyze, embed, ask, health, restore, list_namespaces, `forget(blob_id)` (synchronous per-blob deletion returning `bool`), `forget_namespace(namespace?)` (namespace erasure returning `ForgetResult`), and `clear(namespace?)`. For deterministic behavior, `analyze` stores its full input as one fact instead of invoking an LLM extractor. Its simple relevance score is for application tests, not production search-quality evaluation.
|
|
151
151
|
|
|
152
152
|
### Context Manager
|
|
153
153
|
|
|
@@ -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
|
|
@@ -254,6 +254,7 @@ Create a new async client.
|
|
|
254
254
|
| `await analyze(text, namespace?)` | Extract and store facts |
|
|
255
255
|
| `await ask(question, limit?, namespace?)` | Ask a question answered using memories |
|
|
256
256
|
| `await restore(namespace, limit?)` | Restore a namespace |
|
|
257
|
+
| `await forget(namespace?)` | Delete index rows for the namespace. Walrus blobs stay. Returns `ForgetResult`. |
|
|
257
258
|
| `await list_namespaces(cursor?, limit?)` | List namespaces that hold memories; paginate on `has_more` |
|
|
258
259
|
| `await health()` | Check server health |
|
|
259
260
|
| `await remember_manual(opts)` | Store encrypted payload + pre-computed vector |
|
|
@@ -106,7 +106,7 @@ async def test_memory_flow():
|
|
|
106
106
|
assert "dark mode" in result.results[0].text
|
|
107
107
|
```
|
|
108
108
|
|
|
109
|
-
The mock supports remember/job polling, bulk remember, recall, analyze, embed, ask, health, restore, list_namespaces, `forget(blob_id)
|
|
109
|
+
The mock supports remember/job polling, bulk remember, recall, analyze, embed, ask, health, restore, list_namespaces, `forget(blob_id)` (synchronous per-blob deletion returning `bool`), `forget_namespace(namespace?)` (namespace erasure returning `ForgetResult`), and `clear(namespace?)`. For deterministic behavior, `analyze` stores its full input as one fact instead of invoking an LLM extractor. Its simple relevance score is for application tests, not production search-quality evaluation.
|
|
110
110
|
|
|
111
111
|
### Context Manager
|
|
112
112
|
|
|
@@ -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
|
|
@@ -213,6 +213,7 @@ Create a new async client.
|
|
|
213
213
|
| `await analyze(text, namespace?)` | Extract and store facts |
|
|
214
214
|
| `await ask(question, limit?, namespace?)` | Ask a question answered using memories |
|
|
215
215
|
| `await restore(namespace, limit?)` | Restore a namespace |
|
|
216
|
+
| `await forget(namespace?)` | Delete index rows for the namespace. Walrus blobs stay. Returns `ForgetResult`. |
|
|
216
217
|
| `await list_namespaces(cursor?, limit?)` | List namespaces that hold memories; paginate on `has_more` |
|
|
217
218
|
| `await health()` | Check server health |
|
|
218
219
|
| `await remember_manual(opts)` | Store encrypted payload + pre-computed vector |
|
|
@@ -27,6 +27,7 @@ from .client import (
|
|
|
27
27
|
MemWalClockDriftError,
|
|
28
28
|
MemWalCompatibilityError,
|
|
29
29
|
MemWalError,
|
|
30
|
+
MemWalJsonBodyError,
|
|
30
31
|
MemWalRateLimited,
|
|
31
32
|
MemWalRememberJobFailed,
|
|
32
33
|
MemWalRememberJobNotFound,
|
|
@@ -43,6 +44,7 @@ from .types import (
|
|
|
43
44
|
AskMemory,
|
|
44
45
|
AskResult,
|
|
45
46
|
EmbedResult,
|
|
47
|
+
ForgetResult,
|
|
46
48
|
HealthResult,
|
|
47
49
|
MemWalConfig,
|
|
48
50
|
NamespacesResult,
|
|
@@ -83,6 +85,7 @@ __all__ = [
|
|
|
83
85
|
"MemWalError",
|
|
84
86
|
"MemWalCompatibilityError",
|
|
85
87
|
"MemWalClockDriftError",
|
|
88
|
+
"MemWalJsonBodyError",
|
|
86
89
|
"MemWalRateLimited",
|
|
87
90
|
"MemWalRememberJobFailed",
|
|
88
91
|
"MemWalRememberJobNotFound",
|
|
@@ -118,6 +121,7 @@ __all__ = [
|
|
|
118
121
|
"AnalyzedFact",
|
|
119
122
|
"HealthResult",
|
|
120
123
|
"RestoreResult",
|
|
124
|
+
"ForgetResult",
|
|
121
125
|
"NamespaceSummary",
|
|
122
126
|
"NamespacesResult",
|
|
123
127
|
"ScoringWeights",
|
|
@@ -128,4 +132,4 @@ __all__ = [
|
|
|
128
132
|
"RecallManualResult",
|
|
129
133
|
]
|
|
130
134
|
|
|
131
|
-
__version__ = "0.2.2.
|
|
135
|
+
__version__ = "0.2.2.dev4"
|
|
@@ -51,6 +51,7 @@ from .types import (
|
|
|
51
51
|
AskMemory,
|
|
52
52
|
AskResult,
|
|
53
53
|
EmbedResult,
|
|
54
|
+
ForgetResult,
|
|
54
55
|
HealthResult,
|
|
55
56
|
MemWalConfig,
|
|
56
57
|
NamespacesResult,
|
|
@@ -675,7 +676,7 @@ class MemWal:
|
|
|
675
676
|
if generated_key:
|
|
676
677
|
self._pending_remember_keys.pop(request_identity, None)
|
|
677
678
|
return RememberAcceptedResult(
|
|
678
|
-
job_id=data
|
|
679
|
+
job_id=_require(data, "job_id", "POST /api/remember"),
|
|
679
680
|
status=data.get("status", "pending"),
|
|
680
681
|
)
|
|
681
682
|
|
|
@@ -717,6 +718,10 @@ class MemWal:
|
|
|
717
718
|
# Status is metadata. No SEAL decrypt, so no session. Matches TS.
|
|
718
719
|
include_seal_session=False,
|
|
719
720
|
)
|
|
721
|
+
except MemWalJsonBodyError as err:
|
|
722
|
+
if err.status != 404:
|
|
723
|
+
raise
|
|
724
|
+
data = None
|
|
720
725
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
721
726
|
data = None
|
|
722
727
|
if not isinstance(data, dict):
|
|
@@ -800,6 +805,10 @@ class MemWal:
|
|
|
800
805
|
)
|
|
801
806
|
else:
|
|
802
807
|
raise
|
|
808
|
+
except MemWalJsonBodyError as err:
|
|
809
|
+
if err.status == 404:
|
|
810
|
+
raise MemWalRememberJobNotFound(job_id) from None
|
|
811
|
+
raise
|
|
803
812
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
804
813
|
raise MemWalRememberJobNotFound(job_id) from None
|
|
805
814
|
except _HttpStatusError as err:
|
|
@@ -1244,6 +1253,30 @@ class MemWal:
|
|
|
1244
1253
|
dropped_count=dropped,
|
|
1245
1254
|
)
|
|
1246
1255
|
|
|
1256
|
+
async def forget(self, namespace: Optional[str] = None) -> ForgetResult:
|
|
1257
|
+
"""Delete this owner's index rows for ``namespace``.
|
|
1258
|
+
|
|
1259
|
+
Wraps ``POST /api/forget``. Walrus blobs persist; the rows stop being
|
|
1260
|
+
retrievable. Defaults to the client namespace.
|
|
1261
|
+
"""
|
|
1262
|
+
resolved_namespace = self._resolve_namespace(namespace)
|
|
1263
|
+
data = await self._signed_request(
|
|
1264
|
+
"POST",
|
|
1265
|
+
"/api/forget",
|
|
1266
|
+
{"namespace": resolved_namespace},
|
|
1267
|
+
include_seal_session=False,
|
|
1268
|
+
)
|
|
1269
|
+
endpoint = "POST /api/forget"
|
|
1270
|
+
return ForgetResult(
|
|
1271
|
+
deleted=int(_require(data, "deleted", endpoint)),
|
|
1272
|
+
namespace=_require(data, "namespace", endpoint),
|
|
1273
|
+
owner=_require(data, "owner", endpoint),
|
|
1274
|
+
)
|
|
1275
|
+
|
|
1276
|
+
async def forget_namespace(self, namespace: Optional[str] = None) -> ForgetResult:
|
|
1277
|
+
"""Alias for :meth:`forget` matching :meth:`MemWalMock.forget_namespace`."""
|
|
1278
|
+
return await self.forget(namespace)
|
|
1279
|
+
|
|
1247
1280
|
async def analyze(
|
|
1248
1281
|
self,
|
|
1249
1282
|
text: str,
|
|
@@ -1562,7 +1595,7 @@ class MemWal:
|
|
|
1562
1595
|
raise _destroyed_client_error()
|
|
1563
1596
|
if response.status_code != 200:
|
|
1564
1597
|
raise MemWalError(f"Health check failed: {response.status_code}")
|
|
1565
|
-
data = response
|
|
1598
|
+
data = _parse_json_response(response, "GET /health")
|
|
1566
1599
|
return HealthResult(
|
|
1567
1600
|
status=_require(data, "status", "GET /health"),
|
|
1568
1601
|
version=_require(data, "version", "GET /health"),
|
|
@@ -1690,7 +1723,7 @@ class MemWal:
|
|
|
1690
1723
|
if self._destroyed:
|
|
1691
1724
|
raise _destroyed_client_error()
|
|
1692
1725
|
if version_response.status_code == 200:
|
|
1693
|
-
metadata = version_response
|
|
1726
|
+
metadata = _parse_json_response(version_response, "GET /version")
|
|
1694
1727
|
elif version_response.status_code in (404, 405):
|
|
1695
1728
|
health_response = await self._http.get(f"{self._server_url}/health")
|
|
1696
1729
|
if self._destroyed:
|
|
@@ -1701,7 +1734,7 @@ class MemWal:
|
|
|
1701
1734
|
f"GET /version returned {version_response.status_code}, "
|
|
1702
1735
|
f"and GET /health returned {health_response.status_code}"
|
|
1703
1736
|
)
|
|
1704
|
-
metadata = health_response
|
|
1737
|
+
metadata = _parse_json_response(health_response, "GET /health")
|
|
1705
1738
|
else:
|
|
1706
1739
|
raise MemWalError(
|
|
1707
1740
|
"Walrus Memory compatibility check failed: "
|
|
@@ -1723,7 +1756,11 @@ class MemWal:
|
|
|
1723
1756
|
if response.status_code != 200:
|
|
1724
1757
|
raise MemWalError(f"GET /config returned {response.status_code}")
|
|
1725
1758
|
|
|
1726
|
-
data = response
|
|
1759
|
+
data = _parse_json_response(response, "GET /config")
|
|
1760
|
+
if not isinstance(data, dict):
|
|
1761
|
+
raise MemWalError(
|
|
1762
|
+
f"GET /config response was {type(data).__name__}, expected an object"
|
|
1763
|
+
)
|
|
1727
1764
|
package_id = data.get("packageId")
|
|
1728
1765
|
network = data.get("network")
|
|
1729
1766
|
sui_rpc_url = data.get("suiRpcUrl")
|
|
@@ -1758,8 +1795,9 @@ class MemWal:
|
|
|
1758
1795
|
)
|
|
1759
1796
|
if response.status_code != 200:
|
|
1760
1797
|
raise MemWalError(f"Sui GraphQL returned {response.status_code}")
|
|
1761
|
-
body = response
|
|
1762
|
-
|
|
1798
|
+
body = _parse_json_response(response, f"POST {graphql_url}")
|
|
1799
|
+
data = body.get("data") if isinstance(body, dict) else None
|
|
1800
|
+
obj = data.get("object") if isinstance(data, dict) else None
|
|
1763
1801
|
if not isinstance(obj, dict) or obj.get("version") is None:
|
|
1764
1802
|
raise MemWalError(f"Sui GraphQL could not resolve package {package_id}")
|
|
1765
1803
|
return obj["version"]
|
|
@@ -1780,8 +1818,8 @@ class MemWal:
|
|
|
1780
1818
|
if response.status_code != 200:
|
|
1781
1819
|
raise MemWalError(f"sui_getObject returned {response.status_code}")
|
|
1782
1820
|
|
|
1783
|
-
body = response
|
|
1784
|
-
result = body.get("result", {})
|
|
1821
|
+
body = _parse_json_response(response, f"POST {sui_rpc_url}")
|
|
1822
|
+
result = body.get("result", {}) if isinstance(body, dict) else {}
|
|
1785
1823
|
version = None
|
|
1786
1824
|
if isinstance(result, dict):
|
|
1787
1825
|
data = result.get("data")
|
|
@@ -1904,7 +1942,7 @@ class MemWal:
|
|
|
1904
1942
|
)
|
|
1905
1943
|
if self._destroyed:
|
|
1906
1944
|
raise _destroyed_client_error()
|
|
1907
|
-
owner = data.get("owner")
|
|
1945
|
+
owner = data.get("owner") if isinstance(data, dict) else None
|
|
1908
1946
|
if not owner:
|
|
1909
1947
|
raise MemWalError(
|
|
1910
1948
|
"Walrus Memory could not resolve this account's owner address "
|
|
@@ -2052,7 +2090,15 @@ class MemWal:
|
|
|
2052
2090
|
retry_after=response.headers.get("retry-after"),
|
|
2053
2091
|
)
|
|
2054
2092
|
|
|
2055
|
-
|
|
2093
|
+
endpoint = f"{method_upper} {path}"
|
|
2094
|
+
data = _parse_json_response(response, endpoint)
|
|
2095
|
+
if response.status_code != 404 and not isinstance(data, dict):
|
|
2096
|
+
raise MemWalJsonBodyError(
|
|
2097
|
+
status=response.status_code,
|
|
2098
|
+
endpoint=endpoint,
|
|
2099
|
+
body=response.text,
|
|
2100
|
+
)
|
|
2101
|
+
return data
|
|
2056
2102
|
|
|
2057
2103
|
|
|
2058
2104
|
class MemWalError(Exception):
|
|
@@ -2076,6 +2122,71 @@ class MemWalClockDriftError(MemWalError):
|
|
|
2076
2122
|
pass
|
|
2077
2123
|
|
|
2078
2124
|
|
|
2125
|
+
class MemWalJsonBodyError(MemWalError):
|
|
2126
|
+
"""Raised when an HTTP response that should carry JSON has an empty or non-JSON body."""
|
|
2127
|
+
|
|
2128
|
+
def __init__(
|
|
2129
|
+
self,
|
|
2130
|
+
status: int,
|
|
2131
|
+
endpoint: str,
|
|
2132
|
+
body: str,
|
|
2133
|
+
*,
|
|
2134
|
+
empty: bool = False,
|
|
2135
|
+
) -> None:
|
|
2136
|
+
redacted = _redact_internal_urls(body.strip()[:200]) if body else ""
|
|
2137
|
+
if empty:
|
|
2138
|
+
message = (
|
|
2139
|
+
f"MemWal ({endpoint}): HTTP {status} response had an empty body; "
|
|
2140
|
+
"expected JSON"
|
|
2141
|
+
)
|
|
2142
|
+
elif redacted:
|
|
2143
|
+
message = (
|
|
2144
|
+
f"MemWal ({endpoint}): HTTP {status} response was not JSON: "
|
|
2145
|
+
f"{redacted}"
|
|
2146
|
+
)
|
|
2147
|
+
else:
|
|
2148
|
+
message = f"MemWal ({endpoint}): HTTP {status} response was not JSON"
|
|
2149
|
+
super().__init__(message)
|
|
2150
|
+
self.status = status
|
|
2151
|
+
self.endpoint = endpoint
|
|
2152
|
+
self.body = body
|
|
2153
|
+
|
|
2154
|
+
|
|
2155
|
+
def _parse_json_response(response: httpx.Response, endpoint: str) -> Any:
|
|
2156
|
+
"""Decode a JSON response body or raise :class:`MemWalJsonBodyError` (#1219).
|
|
2157
|
+
|
|
2158
|
+
Mirrors the TypeScript SDK's ``jsonBodyError`` guard (#1201 / #1207): an
|
|
2159
|
+
empty or non-JSON 2xx response from a relayer or proxy in front of one
|
|
2160
|
+
surfaces as a :class:`MemWalError` naming the endpoint and HTTP status
|
|
2161
|
+
rather than Python's raw :class:`json.JSONDecodeError` or
|
|
2162
|
+
:class:`UnicodeDecodeError`.
|
|
2163
|
+
"""
|
|
2164
|
+
try:
|
|
2165
|
+
return response.json()
|
|
2166
|
+
except UnicodeDecodeError as exc:
|
|
2167
|
+
raise MemWalJsonBodyError(
|
|
2168
|
+
status=response.status_code,
|
|
2169
|
+
endpoint=endpoint,
|
|
2170
|
+
body="",
|
|
2171
|
+
) from exc
|
|
2172
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
2173
|
+
doc = getattr(exc, "doc", None)
|
|
2174
|
+
if isinstance(doc, str):
|
|
2175
|
+
raw = doc
|
|
2176
|
+
else:
|
|
2177
|
+
try:
|
|
2178
|
+
raw = response.text
|
|
2179
|
+
except UnicodeDecodeError:
|
|
2180
|
+
raw = ""
|
|
2181
|
+
is_empty = not raw or not raw.strip()
|
|
2182
|
+
raise MemWalJsonBodyError(
|
|
2183
|
+
status=response.status_code,
|
|
2184
|
+
endpoint=endpoint,
|
|
2185
|
+
body=raw,
|
|
2186
|
+
empty=is_empty,
|
|
2187
|
+
) from exc
|
|
2188
|
+
|
|
2189
|
+
|
|
2079
2190
|
def _redact_internal_urls(text: str) -> str:
|
|
2080
2191
|
return re.sub(
|
|
2081
2192
|
r"https?://(?:(?:[\w-]+\.)*localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[::1\]|0\.0\.0\.0)(?::\d+)?[^\s)\]>'\"]*",
|
|
@@ -2396,6 +2507,14 @@ class MemWalSync:
|
|
|
2396
2507
|
:class:`RecallParams` for the recommended object-style call)."""
|
|
2397
2508
|
return self._run(self._inner.recall(query, limit, namespace, max_distance))
|
|
2398
2509
|
|
|
2510
|
+
def forget(self, namespace: Optional[str] = None) -> ForgetResult:
|
|
2511
|
+
"""Synchronous version of :meth:`MemWal.forget`."""
|
|
2512
|
+
return self._run(self._inner.forget(namespace))
|
|
2513
|
+
|
|
2514
|
+
def forget_namespace(self, namespace: Optional[str] = None) -> ForgetResult:
|
|
2515
|
+
"""Synchronous version of :meth:`MemWal.forget_namespace`."""
|
|
2516
|
+
return self._run(self._inner.forget_namespace(namespace))
|
|
2517
|
+
|
|
2399
2518
|
def analyze(
|
|
2400
2519
|
self,
|
|
2401
2520
|
text: str,
|
|
@@ -12,6 +12,12 @@ SUPPORTED_RELAYER_API_MAJOR = 1
|
|
|
12
12
|
def compatibility_error(metadata: Dict[str, Any], server_url: str) -> Optional[str]:
|
|
13
13
|
"""Return an actionable error string when relayer metadata is unsupported."""
|
|
14
14
|
|
|
15
|
+
if not isinstance(metadata, dict):
|
|
16
|
+
return (
|
|
17
|
+
f"Walrus Memory relayer at {server_url} does not expose compatibility metadata. "
|
|
18
|
+
"Upgrade the relayer to a version that serves GET /version, or use an older SDK."
|
|
19
|
+
)
|
|
20
|
+
|
|
15
21
|
api_version = metadata.get("apiVersion")
|
|
16
22
|
relayer_version = metadata.get("relayerVersion")
|
|
17
23
|
min_supported = metadata.get("minSupportedSdk")
|
|
@@ -20,6 +20,7 @@ from .types import (
|
|
|
20
20
|
AskMemory,
|
|
21
21
|
AskResult,
|
|
22
22
|
EmbedResult,
|
|
23
|
+
ForgetResult,
|
|
23
24
|
HealthResult,
|
|
24
25
|
NamespacesResult,
|
|
25
26
|
NamespaceSummary,
|
|
@@ -497,6 +498,13 @@ class MemWalMock:
|
|
|
497
498
|
return True
|
|
498
499
|
return False
|
|
499
500
|
|
|
501
|
+
async def forget_namespace(self, namespace: Optional[str] = None) -> ForgetResult:
|
|
502
|
+
resolved = self._namespace if namespace is None else namespace
|
|
503
|
+
if not resolved:
|
|
504
|
+
raise ValueError("namespace cannot be empty")
|
|
505
|
+
deleted = self.clear(resolved)
|
|
506
|
+
return ForgetResult(deleted=deleted, namespace=resolved, owner=self._owner)
|
|
507
|
+
|
|
500
508
|
def clear(self, namespace: Optional[str] = None) -> int:
|
|
501
509
|
removed = [
|
|
502
510
|
memory
|
|
@@ -717,6 +725,9 @@ class MemWalMockSync:
|
|
|
717
725
|
def forget(self, blob_id: str) -> bool:
|
|
718
726
|
return self._inner.forget(blob_id)
|
|
719
727
|
|
|
728
|
+
def forget_namespace(self, namespace: Optional[str] = None) -> ForgetResult:
|
|
729
|
+
return self._run(self._inner.forget_namespace(namespace))
|
|
730
|
+
|
|
720
731
|
def clear(self, namespace: Optional[str] = None) -> int:
|
|
721
732
|
return self._inner.clear(namespace)
|
|
722
733
|
|
|
@@ -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
|
|
|
@@ -84,6 +84,14 @@ class RememberResult:
|
|
|
84
84
|
namespace: str
|
|
85
85
|
|
|
86
86
|
|
|
87
|
+
@dataclass
|
|
88
|
+
class ForgetResult:
|
|
89
|
+
"""Result from POST /api/forget. Walrus blobs are not deleted."""
|
|
90
|
+
|
|
91
|
+
deleted: int
|
|
92
|
+
namespace: str
|
|
93
|
+
owner: str
|
|
94
|
+
|
|
87
95
|
@dataclass
|
|
88
96
|
class RecallMemory:
|
|
89
97
|
"""A single recalled memory."""
|
|
@@ -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)
|
|
@@ -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():
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from memwal import ForgetResult, MemWal, MemWalSync
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CaptureMemWal(MemWal):
|
|
7
|
+
def __init__(self, *args, **kwargs):
|
|
8
|
+
super().__init__(*args, **kwargs)
|
|
9
|
+
self.captured = []
|
|
10
|
+
|
|
11
|
+
async def _ensure_compatible_relayer(self):
|
|
12
|
+
return {
|
|
13
|
+
"apiVersion": "1.0.0",
|
|
14
|
+
"relayerVersion": "capture",
|
|
15
|
+
"minSupportedSdk": {"python": "0.0.0"},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async def _signed_request(
|
|
19
|
+
self,
|
|
20
|
+
method,
|
|
21
|
+
path,
|
|
22
|
+
body,
|
|
23
|
+
accepted_statuses=(200,),
|
|
24
|
+
include_seal_session=True,
|
|
25
|
+
*args,
|
|
26
|
+
**kwargs,
|
|
27
|
+
):
|
|
28
|
+
self.captured.append((method, path, body))
|
|
29
|
+
return {"deleted": 2, "namespace": body["namespace"], "owner": "0xowner"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.asyncio
|
|
33
|
+
async def test_forget_posts_the_client_namespace():
|
|
34
|
+
client = CaptureMemWal.create(
|
|
35
|
+
key="11" * 32,
|
|
36
|
+
account_id="0x" + "ab" * 32,
|
|
37
|
+
server_url="https://relayer.example",
|
|
38
|
+
namespace="profile",
|
|
39
|
+
)
|
|
40
|
+
result = await client.forget()
|
|
41
|
+
assert result == ForgetResult(deleted=2, namespace="profile", owner="0xowner")
|
|
42
|
+
alias = await client.forget_namespace("custom")
|
|
43
|
+
assert alias == ForgetResult(deleted=2, namespace="custom", owner="0xowner")
|
|
44
|
+
assert client.captured == [
|
|
45
|
+
("POST", "/api/forget", {"namespace": "profile"}),
|
|
46
|
+
("POST", "/api/forget", {"namespace": "custom"}),
|
|
47
|
+
]
|
|
48
|
+
await client.close()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.asyncio
|
|
52
|
+
async def test_forget_rejects_empty_namespace_without_deleting_default():
|
|
53
|
+
client = CaptureMemWal.create(
|
|
54
|
+
key="11" * 32,
|
|
55
|
+
account_id="0x" + "ab" * 32,
|
|
56
|
+
server_url="https://relayer.example",
|
|
57
|
+
namespace="important",
|
|
58
|
+
)
|
|
59
|
+
with pytest.raises(ValueError, match="namespace cannot be empty"):
|
|
60
|
+
await client.forget("")
|
|
61
|
+
with pytest.raises(ValueError, match="namespace cannot be empty"):
|
|
62
|
+
await client.forget_namespace("")
|
|
63
|
+
assert client.captured == []
|
|
64
|
+
await client.close()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_sync_client_has_forget():
|
|
68
|
+
assert hasattr(MemWal, "forget")
|
|
69
|
+
assert hasattr(MemWalSync, "forget")
|
|
70
|
+
assert hasattr(MemWal, "forget_namespace")
|
|
71
|
+
assert hasattr(MemWalSync, "forget_namespace")
|
|
72
|
+
sync_client = MemWalSync(
|
|
73
|
+
CaptureMemWal.create(
|
|
74
|
+
key="11" * 32,
|
|
75
|
+
account_id="0x" + "ab" * 32,
|
|
76
|
+
server_url="https://relayer.example",
|
|
77
|
+
namespace="important",
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
with pytest.raises(ValueError, match="namespace cannot be empty"):
|
|
81
|
+
sync_client.forget("")
|
|
82
|
+
with pytest.raises(ValueError, match="namespace cannot be empty"):
|
|
83
|
+
sync_client.forget_namespace("")
|
|
84
|
+
assert sync_client._inner.captured == []
|
|
85
|
+
assert sync_client.forget_namespace("archive") == ForgetResult(
|
|
86
|
+
deleted=2, namespace="archive", owner="0xowner"
|
|
87
|
+
)
|
|
88
|
+
assert sync_client._inner.captured == [
|
|
89
|
+
("POST", "/api/forget", {"namespace": "archive"})
|
|
90
|
+
]
|
|
91
|
+
sync_client.close()
|
|
@@ -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,310 @@
|
|
|
1
|
+
"""Empty or non-JSON 2xx responses raise ``MemWalJsonBodyError`` (#1219).
|
|
2
|
+
|
|
3
|
+
Mirrors ``packages/sdk/test/json-body.test.mjs`` (#1201 / #1207): a relayer or
|
|
4
|
+
proxy in front of one that answers 2xx with an empty or non-JSON body must
|
|
5
|
+
raise a :class:`MemWalError` naming the endpoint and HTTP status rather than
|
|
6
|
+
leaking Python's raw :class:`json.JSONDecodeError`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import nacl.signing
|
|
15
|
+
import pytest
|
|
16
|
+
import respx
|
|
17
|
+
|
|
18
|
+
from memwal import (
|
|
19
|
+
MemWal,
|
|
20
|
+
MemWalCompatibilityError,
|
|
21
|
+
MemWalError,
|
|
22
|
+
MemWalJsonBodyError,
|
|
23
|
+
MemWalRememberJobNotFound,
|
|
24
|
+
MemWalSync,
|
|
25
|
+
)
|
|
26
|
+
from memwal.types import RememberManualOptions
|
|
27
|
+
from memwal.utils import bytes_to_hex
|
|
28
|
+
|
|
29
|
+
_SERVER = "https://relayer.example"
|
|
30
|
+
_KEY_HEX = bytes_to_hex(bytes(nacl.signing.SigningKey(b"\x11" * 32)))
|
|
31
|
+
_VERSION_JSON = {
|
|
32
|
+
"apiVersion": "1.0.0",
|
|
33
|
+
"relayerVersion": "1.0.0",
|
|
34
|
+
"minSupportedSdk": {"python": "0.1.0"},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _client() -> MemWal:
|
|
39
|
+
return MemWal.create(key=_KEY_HEX, account_id="0x1", server_url=_SERVER)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _mock_version() -> None:
|
|
43
|
+
respx.get(f"{_SERVER}/version").mock(
|
|
44
|
+
return_value=httpx.Response(200, json=_VERSION_JSON)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _mock_seal_session_prereqs() -> None:
|
|
49
|
+
_mock_version()
|
|
50
|
+
respx.get(f"{_SERVER}/config").mock(
|
|
51
|
+
return_value=httpx.Response(
|
|
52
|
+
200,
|
|
53
|
+
json={
|
|
54
|
+
"packageId": "0xpkg",
|
|
55
|
+
"network": "testnet",
|
|
56
|
+
"suiRpcUrl": "https://fullnode.testnet.sui.io:443",
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
respx.post("https://graphql.testnet.sui.io/graphql").mock(
|
|
61
|
+
return_value=httpx.Response(
|
|
62
|
+
200,
|
|
63
|
+
json={"data": {"object": {"version": 1}}},
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@respx.mock
|
|
69
|
+
async def test_health_rejects_empty_200_with_memwal_error() -> None:
|
|
70
|
+
respx.get(f"{_SERVER}/health").mock(return_value=httpx.Response(200, content=b" "))
|
|
71
|
+
with pytest.raises(MemWalJsonBodyError) as exc:
|
|
72
|
+
await _client().health()
|
|
73
|
+
assert isinstance(exc.value, MemWalError)
|
|
74
|
+
assert not isinstance(exc.value, json.JSONDecodeError)
|
|
75
|
+
assert exc.value.status == 200
|
|
76
|
+
assert exc.value.endpoint == "GET /health"
|
|
77
|
+
assert "GET /health" in str(exc.value)
|
|
78
|
+
assert "empty body; expected JSON" in str(exc.value)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@respx.mock
|
|
82
|
+
async def test_health_rejects_non_json_200_and_redacts_internal_urls() -> None:
|
|
83
|
+
respx.get(f"{_SERVER}/health").mock(
|
|
84
|
+
return_value=httpx.Response(
|
|
85
|
+
200,
|
|
86
|
+
text="<html>upstream http://localhost:8080/health failed</html>",
|
|
87
|
+
headers={"content-type": "text/html"},
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
with pytest.raises(MemWalJsonBodyError) as exc:
|
|
91
|
+
await _client().health()
|
|
92
|
+
assert isinstance(exc.value, MemWalError)
|
|
93
|
+
assert not isinstance(exc.value, json.JSONDecodeError)
|
|
94
|
+
assert exc.value.status == 200
|
|
95
|
+
assert exc.value.endpoint == "GET /health"
|
|
96
|
+
assert "response was not JSON" in str(exc.value)
|
|
97
|
+
assert "localhost" not in str(exc.value)
|
|
98
|
+
assert "[internal]" in str(exc.value)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@respx.mock
|
|
102
|
+
async def test_compatibility_rejects_empty_and_non_json_version_200() -> None:
|
|
103
|
+
route = respx.get(f"{_SERVER}/version")
|
|
104
|
+
for content, expected_phrase in (
|
|
105
|
+
(b"", "empty body; expected JSON"),
|
|
106
|
+
(b"nope", "response was not JSON"),
|
|
107
|
+
):
|
|
108
|
+
route.mock(return_value=httpx.Response(200, content=content))
|
|
109
|
+
with pytest.raises(MemWalJsonBodyError) as exc:
|
|
110
|
+
await _client().compatibility()
|
|
111
|
+
assert exc.value.status == 200
|
|
112
|
+
assert exc.value.endpoint == "GET /version"
|
|
113
|
+
assert expected_phrase in str(exc.value)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@respx.mock
|
|
117
|
+
async def test_compatibility_rejects_non_json_health_fallback_200() -> None:
|
|
118
|
+
respx.get(f"{_SERVER}/version").mock(return_value=httpx.Response(404))
|
|
119
|
+
respx.get(f"{_SERVER}/health").mock(
|
|
120
|
+
return_value=httpx.Response(200, text="bad gateway html")
|
|
121
|
+
)
|
|
122
|
+
with pytest.raises(MemWalJsonBodyError) as exc:
|
|
123
|
+
await _client().compatibility()
|
|
124
|
+
assert exc.value.status == 200
|
|
125
|
+
assert exc.value.endpoint == "GET /health"
|
|
126
|
+
assert "response was not JSON" in str(exc.value)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@respx.mock
|
|
130
|
+
async def test_compatibility_rejects_non_object_json_200() -> None:
|
|
131
|
+
respx.get(f"{_SERVER}/version").mock(return_value=httpx.Response(200, content=b"null"))
|
|
132
|
+
with pytest.raises(MemWalCompatibilityError):
|
|
133
|
+
await _client().compatibility()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@respx.mock
|
|
137
|
+
async def test_config_and_package_version_reject_non_json_200() -> None:
|
|
138
|
+
_mock_version()
|
|
139
|
+
respx.get(f"{_SERVER}/config").mock(return_value=httpx.Response(200, content=b""))
|
|
140
|
+
with pytest.raises(MemWalJsonBodyError) as exc:
|
|
141
|
+
await _client().recall("coffee")
|
|
142
|
+
assert exc.value.status == 200
|
|
143
|
+
assert exc.value.endpoint == "GET /config"
|
|
144
|
+
assert "empty body; expected JSON" in str(exc.value)
|
|
145
|
+
|
|
146
|
+
respx.get(f"{_SERVER}/config").mock(
|
|
147
|
+
return_value=httpx.Response(
|
|
148
|
+
200,
|
|
149
|
+
json={
|
|
150
|
+
"packageId": "0xpkg",
|
|
151
|
+
"network": "testnet",
|
|
152
|
+
"suiRpcUrl": "https://fullnode.testnet.sui.io:443",
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
respx.post("https://graphql.testnet.sui.io/graphql").mock(
|
|
157
|
+
return_value=httpx.Response(200, text="not-json")
|
|
158
|
+
)
|
|
159
|
+
with pytest.raises(MemWalJsonBodyError) as gql_exc:
|
|
160
|
+
await _client().recall("coffee")
|
|
161
|
+
assert gql_exc.value.status == 200
|
|
162
|
+
assert "graphql.testnet.sui.io" in gql_exc.value.endpoint
|
|
163
|
+
assert "response was not JSON" in str(gql_exc.value)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@respx.mock
|
|
167
|
+
async def test_remember_rejects_empty_202_without_polling() -> None:
|
|
168
|
+
_mock_seal_session_prereqs()
|
|
169
|
+
respx.post(f"{_SERVER}/api/remember").mock(
|
|
170
|
+
return_value=httpx.Response(202, content=b"")
|
|
171
|
+
)
|
|
172
|
+
poll_route = respx.get(url__regex=rf"^{_SERVER}/api/remember/.+").mock(
|
|
173
|
+
return_value=httpx.Response(200, json={"job_id": "j1", "status": "done"})
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
client = _client()
|
|
177
|
+
with pytest.raises(MemWalJsonBodyError) as exc:
|
|
178
|
+
await client.remember_and_wait("hello", timeout_ms=5_000)
|
|
179
|
+
assert exc.value.status == 202
|
|
180
|
+
assert exc.value.endpoint == "POST /api/remember"
|
|
181
|
+
assert "empty body; expected JSON" in str(exc.value)
|
|
182
|
+
assert poll_route.call_count == 0
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@respx.mock
|
|
186
|
+
async def test_signed_requests_reject_empty_non_json_and_non_object_200() -> None:
|
|
187
|
+
_mock_seal_session_prereqs()
|
|
188
|
+
respx.post(f"{_SERVER}/api/stats").mock(
|
|
189
|
+
return_value=httpx.Response(200, json={"owner": "0xowner"})
|
|
190
|
+
)
|
|
191
|
+
ns_route = respx.get(f"{_SERVER}/v1/owners/0xowner/namespaces")
|
|
192
|
+
for body in (b"", b"<html>error</html>", b"null", b"[]"):
|
|
193
|
+
ns_route.mock(return_value=httpx.Response(200, content=body))
|
|
194
|
+
with pytest.raises(MemWalJsonBodyError) as exc:
|
|
195
|
+
await _client().list_namespaces()
|
|
196
|
+
assert exc.value.status == 200
|
|
197
|
+
assert exc.value.endpoint == "GET /v1/owners/0xowner/namespaces"
|
|
198
|
+
|
|
199
|
+
restore_route = respx.post(f"{_SERVER}/api/restore")
|
|
200
|
+
restore_route.mock(return_value=httpx.Response(200, content=b""))
|
|
201
|
+
with pytest.raises(MemWalJsonBodyError) as restore_exc:
|
|
202
|
+
await _client().restore("default")
|
|
203
|
+
assert restore_exc.value.status == 200
|
|
204
|
+
assert restore_exc.value.endpoint == "POST /api/restore"
|
|
205
|
+
|
|
206
|
+
manual_route = respx.post(f"{_SERVER}/api/remember/manual")
|
|
207
|
+
manual_route.mock(return_value=httpx.Response(200, text="nope"))
|
|
208
|
+
with pytest.raises(MemWalJsonBodyError) as manual_exc:
|
|
209
|
+
await _client().remember_manual(
|
|
210
|
+
RememberManualOptions(encrypted_data="ZGF0YQ==", vector=[0.1])
|
|
211
|
+
)
|
|
212
|
+
assert manual_exc.value.status == 200
|
|
213
|
+
assert manual_exc.value.endpoint == "POST /api/remember/manual"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@respx.mock
|
|
217
|
+
async def test_remember_status_distinguishes_200_non_json_from_404_non_json() -> None:
|
|
218
|
+
_mock_seal_session_prereqs()
|
|
219
|
+
route = respx.get(f"{_SERVER}/api/remember/job-1")
|
|
220
|
+
|
|
221
|
+
# 200 with empty or non-JSON body raises MemWalJsonBodyError
|
|
222
|
+
for content in (b"", b"<html>bad</html>"):
|
|
223
|
+
route.mock(return_value=httpx.Response(200, content=content))
|
|
224
|
+
with pytest.raises(MemWalJsonBodyError) as exc:
|
|
225
|
+
await _client().get_remember_status("job-1")
|
|
226
|
+
assert exc.value.status == 200
|
|
227
|
+
assert exc.value.endpoint == "GET /api/remember/job-1"
|
|
228
|
+
|
|
229
|
+
with pytest.raises(MemWalJsonBodyError) as wait_exc:
|
|
230
|
+
await _client().wait_for_remember_job(
|
|
231
|
+
"job-1", poll_interval_ms=0, timeout_ms=5_000
|
|
232
|
+
)
|
|
233
|
+
assert wait_exc.value.status == 200
|
|
234
|
+
|
|
235
|
+
# 404 with empty or non-JSON body remains not_found
|
|
236
|
+
route.mock(return_value=httpx.Response(404, content=b""))
|
|
237
|
+
status = await _client().get_remember_status("job-1")
|
|
238
|
+
assert status.status == "not_found"
|
|
239
|
+
with pytest.raises(MemWalRememberJobNotFound):
|
|
240
|
+
await _client().wait_for_remember_job(
|
|
241
|
+
"job-1", poll_interval_ms=0, timeout_ms=5_000
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@respx.mock
|
|
246
|
+
def test_sync_client_surfaces_memwal_json_body_error() -> None:
|
|
247
|
+
respx.get(f"{_SERVER}/health").mock(return_value=httpx.Response(200, content=b""))
|
|
248
|
+
sync_client = MemWalSync.create(key=_KEY_HEX, account_id="0x1", server_url=_SERVER)
|
|
249
|
+
with pytest.raises(MemWalJsonBodyError) as exc:
|
|
250
|
+
sync_client.health()
|
|
251
|
+
assert exc.value.status == 200
|
|
252
|
+
assert exc.value.endpoint == "GET /health"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@respx.mock
|
|
256
|
+
async def test_health_rejects_malformed_utf8_inside_json_string() -> None:
|
|
257
|
+
route = respx.get(f"{_SERVER}/health")
|
|
258
|
+
for raw in (
|
|
259
|
+
b'{"status":"ok","version":"\xff"}',
|
|
260
|
+
b"\x80\x81\x82",
|
|
261
|
+
):
|
|
262
|
+
route.mock(
|
|
263
|
+
return_value=httpx.Response(
|
|
264
|
+
200,
|
|
265
|
+
content=raw,
|
|
266
|
+
headers={"content-type": "application/json"},
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
with pytest.raises(MemWalJsonBodyError) as exc:
|
|
270
|
+
await _client().health()
|
|
271
|
+
assert isinstance(exc.value, MemWalError)
|
|
272
|
+
assert not isinstance(exc.value, UnicodeDecodeError)
|
|
273
|
+
assert exc.value.status == 200
|
|
274
|
+
assert exc.value.endpoint == "GET /health"
|
|
275
|
+
assert "response was not JSON" in str(exc.value)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@respx.mock
|
|
279
|
+
async def test_health_accepts_utf8_bom_and_utf16_json_bodies() -> None:
|
|
280
|
+
route = respx.get(f"{_SERVER}/health")
|
|
281
|
+
payload = '{"status":"ok","version":"0.2.2"}'
|
|
282
|
+
for encoded in (
|
|
283
|
+
b"\xef\xbb\xbf" + payload.encode("utf-8"),
|
|
284
|
+
payload.encode("utf-16"),
|
|
285
|
+
payload.encode("utf-16-le"),
|
|
286
|
+
payload.encode("utf-16-be"),
|
|
287
|
+
):
|
|
288
|
+
route.mock(
|
|
289
|
+
return_value=httpx.Response(
|
|
290
|
+
200,
|
|
291
|
+
content=encoded,
|
|
292
|
+
headers={"content-type": "application/json"},
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
health = await _client().health()
|
|
296
|
+
assert health.status == "ok"
|
|
297
|
+
assert health.version == "0.2.2"
|
|
298
|
+
|
|
299
|
+
# BOM-only or UTF-16 whitespace-only payloads still surface as empty JSON body errors
|
|
300
|
+
for empty_encoded in (
|
|
301
|
+
b"\xef\xbb\xbf",
|
|
302
|
+
b"\xef\xbb\xbf ",
|
|
303
|
+
" ".encode("utf-16"),
|
|
304
|
+
):
|
|
305
|
+
route.mock(return_value=httpx.Response(200, content=empty_encoded))
|
|
306
|
+
with pytest.raises(MemWalJsonBodyError) as exc:
|
|
307
|
+
await _client().health()
|
|
308
|
+
assert exc.value.status == 200
|
|
309
|
+
assert exc.value.endpoint == "GET /health"
|
|
310
|
+
assert "empty body; expected JSON" in str(exc.value)
|
|
@@ -121,7 +121,18 @@ async def test_mock_supports_jobs_bulk_analyze_forget_and_clear():
|
|
|
121
121
|
|
|
122
122
|
assert mock.forget("mock-blob-000001") is True
|
|
123
123
|
assert mock.forget("missing") is False
|
|
124
|
-
assert mock.
|
|
124
|
+
assert (await mock.recall("single fact")).total == 0
|
|
125
|
+
assert (await mock.recall("bulk", namespace="one")).total == 1
|
|
126
|
+
assert (await mock.recall("bulk", namespace="two")).total == 1
|
|
127
|
+
assert (await mock.recall("durable analyzed fact", namespace="analysis")).total == 1
|
|
128
|
+
with pytest.raises(ValueError, match="namespace cannot be empty"):
|
|
129
|
+
await mock.forget_namespace("")
|
|
130
|
+
forgotten = await mock.forget_namespace("one")
|
|
131
|
+
assert forgotten.namespace == "one"
|
|
132
|
+
assert forgotten.deleted == 1
|
|
133
|
+
assert (await mock.recall("bulk", namespace="two")).total == 1
|
|
134
|
+
assert (await mock.recall("durable analyzed fact", namespace="analysis")).total == 1
|
|
135
|
+
assert mock.clear("one") == 0
|
|
125
136
|
assert (await mock.recall("bulk", namespace="one")).total == 0
|
|
126
137
|
|
|
127
138
|
|
|
@@ -171,6 +182,17 @@ def test_sync_mock_wraps_core_flows_and_accepts_production_options():
|
|
|
171
182
|
assert analyzed_wait.succeeded == 1
|
|
172
183
|
assert recalled.results[0].text == "sync memory"
|
|
173
184
|
|
|
185
|
+
assert recalled.total == 5
|
|
186
|
+
assert mock.forget(stored.blob_id) is True
|
|
187
|
+
assert mock.forget("missing-blob") is False
|
|
188
|
+
assert mock.recall("sync").total == 4
|
|
189
|
+
with pytest.raises(ValueError, match="namespace cannot be empty"):
|
|
190
|
+
mock.forget_namespace("")
|
|
191
|
+
forgotten = mock.forget_namespace("sync")
|
|
192
|
+
assert forgotten.namespace == "sync"
|
|
193
|
+
assert forgotten.deleted == 4
|
|
194
|
+
assert mock.recall("sync").total == 0
|
|
195
|
+
|
|
174
196
|
|
|
175
197
|
def test_sync_mock_polling_and_analyze_signatures_match_production():
|
|
176
198
|
methods = (
|
|
@@ -182,6 +204,7 @@ def test_sync_mock_polling_and_analyze_signatures_match_production():
|
|
|
182
204
|
"remember_bulk_and_wait",
|
|
183
205
|
"analyze",
|
|
184
206
|
"analyze_and_wait",
|
|
207
|
+
"forget_namespace",
|
|
185
208
|
)
|
|
186
209
|
|
|
187
210
|
def parameter_contract(method):
|
|
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
|