From 4ad428ec064ccc45f91e302f5d590ffa1acfd29a Mon Sep 17 00:00:00 2001 From: Le Tien Phat <91601109+Niko1444@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:38:00 +0700 Subject: [PATCH] feat(python-sdk): list_namespaces() so an agent can discover namespaces [WALM-651] Python counterpart of the TS listNamespaces() from #830 (WALM-395). - MemWal.list_namespaces(cursor=None, limit=None) -> NamespacesResult: signed GET /v1/owners/{owner}/namespaces, metadata only, no SEAL session. The query string is signed, since the relayer verifies path_and_query. - Owner resolved once per client via POST /api/stats; concurrent first calls share one lookup, and a failed lookup is retried on the next call. - MemWalSync, MemWalMock and MemWalMockSync get the same method. The mock follows the relayer's cursor format and snapshot walk. - Bump memwal to 0.1.11; README, API reference and changelogs updated. --- docs/python-sdk/api-reference.md | 33 ++- docs/python-sdk/changelog.mdx | 10 +- packages/python-sdk-memwal/CHANGELOG.md | 6 + packages/python-sdk-memwal/README.md | 3 +- packages/python-sdk-memwal/memwal/__init__.py | 6 +- packages/python-sdk-memwal/memwal/client.py | 117 +++++++++- packages/python-sdk-memwal/memwal/mock.py | 94 +++++++- packages/python-sdk-memwal/memwal/types.py | 28 +++ packages/python-sdk-memwal/pyproject.toml | 2 +- .../tests/test_integration.py | 24 ++ .../tests/test_list_namespaces.py | 220 ++++++++++++++++++ packages/python-sdk-memwal/tests/test_mock.py | 102 ++++++++ scripts/verify-manual-sdk-release.mjs | 2 +- 13 files changed, 639 insertions(+), 8 deletions(-) create mode 100644 packages/python-sdk-memwal/tests/test_list_namespaces.py diff --git a/docs/python-sdk/api-reference.md b/docs/python-sdk/api-reference.md index b7816cb41..612b6b9d5 100644 --- a/docs/python-sdk/api-reference.md +++ b/docs/python-sdk/api-reference.md @@ -30,7 +30,7 @@ questions: - How does Ed25519 authentication work in the MemWal Python SDK? answer: >- The MemWal Python SDK API reference documents all methods on MemWal and MemWalSync - including remember, recall, analyze, ask, restore, health, and lower-level manual methods. + including remember, recall, analyze, ask, restore, list_namespaces, health, and lower-level manual methods. It also covers result dataclasses, exception hierarchy, middleware wrappers, utility functions for delegate key derivation, and the Ed25519 request signing protocol. --- @@ -183,6 +183,37 @@ RestoreResult(restored: int, skipped: int, total: int, namespace: str, owner: st `truncated=true` is known-retryable-incomplete (this call's `limit`, or a still-expandable sidecar candidate fetch); `truncated=false` is not proof the sidecar saw every onchain blob (WALM-451 `sourceCapped`). +### `list_namespaces(cursor=None, limit=None) -> NamespacesResult` + +List the namespaces this account holds memories in. Returns metadata only, with no blob fetch or decryption. + +Recall needs a namespace to search, so an agent on an unfamiliar account would otherwise have to guess names or fall back to `"default"`. Namespaces are flat and exact-match: to work with a prefix such as `proj/`, filter the names client-side and recall each one. + +- `cursor`: the previous page's `next_cursor`, to continue a walk or poll for namespaces changed since then +- `limit`: page size; the relayer defaults to `100` and clamps to `500` + +```python +NamespacesResult( + namespaces: list[NamespaceSummary], # NamespaceSummary(id, name, memory_count, storage_used, updated_at) + next_cursor: str | None, + has_more: bool, + snapshot_version: int, +) +``` + +Paginate on `has_more`, not on page length. The relayer clamps `limit`, so a caller asking for more than the cap gets exactly the cap back. + +```python +cursor = None +while True: + page = await memwal.list_namespaces(cursor=cursor) + for ns in page.namespaces: + print(ns.name, ns.memory_count) + cursor = page.next_cursor + if not page.has_more: + break +``` + ### `health() -> HealthResult` Check relayer health. No authentication — a successful response confirms the relayer is reachable, not that your `key`/`account_id` are valid. A signed call (e.g. `remember()`, `recall()`) can still fail with `401` immediately after a passing `health()`. Raises `MemWalError` on non-200. diff --git a/docs/python-sdk/changelog.mdx b/docs/python-sdk/changelog.mdx index 28977755e..04adee2d2 100644 --- a/docs/python-sdk/changelog.mdx +++ b/docs/python-sdk/changelog.mdx @@ -29,13 +29,21 @@ questions: - What changes were made in memwal 0.1.4? - Where can I find the release history for the Walrus Memory Python SDK? answer: >- - The latest Python SDK release is 0.1.10. `restore()` results include `failed` (default `0`) for permanent decrypt/UTF-8 failures instead of folding them into `skipped`. 0.1.9 reports HTTP 503 as a retryable upstream outage instead of a credential failure, rejects empty `remember_bulk_async` batches and misaligned relayer `job_ids`, aligns restore `truncated` docs with WALM-431 retryable semantics, and warns when `server_url` uses plaintext HTTP on a non-localhost host without logging URL credentials. + The latest Python SDK release is 0.1.11. It adds `list_namespaces()` so an agent can discover which namespaces hold memories instead of guessing. 0.1.10 adds `failed` to `restore()` results for permanent decrypt/UTF-8 failures instead of folding them into `skipped`. 0.1.9 reports HTTP 503 as a retryable upstream outage instead of a credential failure, rejects empty `remember_bulk_async` batches and misaligned relayer `job_ids`, aligns restore `truncated` docs with WALM-431 retryable semantics, and warns when `server_url` uses plaintext HTTP on a non-localhost host without logging URL credentials. --- Track what's new, changed, and fixed in `memwal` (Python). For the latest version, see the [PyPI project page](https://pypi.org/project/memwal/). +## 0.1.11 + +This release adds `list_namespaces()` for namespace discovery. + +### Added + +- `list_namespaces(cursor=None, limit=None)` lists the namespaces that hold memories (name, `memory_count`, `storage_used`, `updated_at`), so an agent can discover namespaces instead of guessing. Metadata only; no decryption. Paginate on `has_more`. `MemWalSync` and the mock clients have it too. + ## 0.1.10 This release adds `failed` on `restore()` results for permanent decrypt and UTF-8 failures. diff --git a/packages/python-sdk-memwal/CHANGELOG.md b/packages/python-sdk-memwal/CHANGELOG.md index 9593c4d2d..16cfbac8e 100644 --- a/packages/python-sdk-memwal/CHANGELOG.md +++ b/packages/python-sdk-memwal/CHANGELOG.md @@ -1,5 +1,11 @@ # memwal +## 0.1.11 + +### Added + +- `list_namespaces(cursor=None, limit=None)` lists the namespaces that hold memories (name, `memory_count`, `storage_used`, `updated_at`), so an agent can discover namespaces instead of guessing. Metadata only; no decryption. Paginate on `has_more`. `MemWalSync` and the mock clients have it too. + ## 0.1.10 ### Added diff --git a/packages/python-sdk-memwal/README.md b/packages/python-sdk-memwal/README.md index 1713142a9..718019e98 100644 --- a/packages/python-sdk-memwal/README.md +++ b/packages/python-sdk-memwal/README.md @@ -100,7 +100,7 @@ async def test_memory_flow(): assert "dark mode" in result.results[0].text ``` -The mock supports remember/job polling, bulk remember, recall, analyze, embed, ask, health, restore, `forget(blob_id)`, 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. +The mock supports remember/job polling, bulk remember, recall, analyze, embed, ask, health, restore, list_namespaces, `forget(blob_id)`, 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. ### Context Manager @@ -206,6 +206,7 @@ Create a new async client. | `await analyze(text, namespace?)` | Extract and store facts | | `await ask(question, limit?, namespace?)` | Ask a question answered using memories | | `await restore(namespace, limit?)` | Restore a namespace | +| `await list_namespaces(cursor?, limit?)` | List namespaces that hold memories; paginate on `has_more` | | `await health()` | Check server health | | `await remember_manual(opts)` | Store encrypted payload + pre-computed vector | | `await recall_manual(opts)` | Search with pre-computed vector | diff --git a/packages/python-sdk-memwal/memwal/__init__.py b/packages/python-sdk-memwal/memwal/__init__.py index 18c7f6e78..345808639 100644 --- a/packages/python-sdk-memwal/memwal/__init__.py +++ b/packages/python-sdk-memwal/memwal/__init__.py @@ -44,6 +44,8 @@ EmbedResult, HealthResult, MemWalConfig, + NamespacesResult, + NamespaceSummary, RecallManualHit, RecallManualOptions, RecallManualResult, @@ -114,6 +116,8 @@ "AnalyzedFact", "HealthResult", "RestoreResult", + "NamespaceSummary", + "NamespacesResult", "ScoringWeights", "RememberManualOptions", "RememberManualResult", @@ -122,4 +126,4 @@ "RecallManualResult", ] -__version__ = "0.1.10" +__version__ = "0.1.11" diff --git a/packages/python-sdk-memwal/memwal/client.py b/packages/python-sdk-memwal/memwal/client.py index e6faa2d62..c4e45ccc3 100644 --- a/packages/python-sdk-memwal/memwal/client.py +++ b/packages/python-sdk-memwal/memwal/client.py @@ -35,7 +35,7 @@ import uuid from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Sequence, Tuple, TypeVar, Union -from urllib.parse import ParseResult, urlparse +from urllib.parse import ParseResult, urlencode, urlparse import httpx import nacl.signing @@ -50,6 +50,8 @@ EmbedResult, HealthResult, MemWalConfig, + NamespacesResult, + NamespaceSummary, RecallManualHit, RecallManualOptions, RecallManualResult, @@ -289,6 +291,8 @@ def __init__(self, config: MemWalConfig) -> None: self._session_build_task: Optional[asyncio.Task[str]] = None self._relayer_version_metadata: Optional[Dict[str, Any]] = None self._compatibility_lock: Optional[asyncio.Lock] = None + self._owner_address: Optional[str] = None + self._owner_task: Optional[asyncio.Task[str]] = None # Preserve a generated key across an ambiguous transport failure. A # subsequent identical call then collapses onto the accepted paid job. self._pending_remember_keys: Dict[str, str] = {} @@ -962,6 +966,72 @@ async def restore(self, namespace: str, limit: int = 10) -> RestoreResult: failed=data.get("failed", 0), ) + async def list_namespaces( + self, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> NamespacesResult: + """List the namespaces this account holds memories in. + + Recall needs a namespace to search. Without this, an agent on an + unfamiliar account has to guess names or fall back to ``"default"``. + Returns metadata only: no blob fetch, no decryption. + + Namespaces are flat and exact-match. To work with a prefix such as + ``proj/``, filter the names here and recall each one. + + Paginate on ``has_more``, NOT page length: the relayer clamps + ``limit``, so asking for more than the cap returns exactly the cap. + + Example:: + + cursor = None + while True: + page = await memwal.list_namespaces(cursor=cursor) + for ns in page.namespaces: + print(ns.name, ns.memory_count) + cursor = page.next_cursor + if not page.has_more: + break + + Args: + cursor: A previous page's ``next_cursor``, to continue a walk or + poll for namespaces changed since then. Opaque; not a + timestamp or a namespace name. + limit: Page size. The relayer defaults to 100 and clamps to 500. + + Returns: + :class:`NamespacesResult`. + """ + owner = await self._resolve_owner() + + params: Dict[str, str] = {} + if cursor is not None: + params["updated_after"] = cursor + if limit is not None: + params["limit"] = str(limit) + query = urlencode(params) + + # The query string is part of the signed path: the relayer verifies + # against `path_and_query`, not `path`. + path = f"/v1/owners/{owner}/namespaces" + (f"?{query}" if query else "") + data = await self._signed_request("GET", path, {}, include_seal_session=False) + return NamespacesResult( + namespaces=[ + NamespaceSummary( + id=ns["id"], + name=ns["name"], + memory_count=ns["memory_count"], + storage_used=ns["storage_used"], + updated_at=ns["updated_at"], + ) + for ns in data["namespaces"] + ], + next_cursor=data.get("next_cursor"), + has_more=data["has_more"], + snapshot_version=data["snapshot_version"], + ) + async def health(self) -> HealthResult: """Check server health. No authentication required. @@ -1255,6 +1325,43 @@ async def _build_seal_session(self) -> str: finally: self._session_build_task = None + async def _resolve_owner_inner(self) -> str: + # POST /api/stats authenticates with the same delegate scheme and + # returns the owner the relayer resolved from our key. Same approach + # as the TypeScript SDK's resolveOwner(). + data = await self._signed_request( + "POST", + "/api/stats", + {"namespace": self._namespace}, + include_seal_session=False, + ) + owner = data.get("owner") + if not owner: + raise MemWalError( + "Walrus Memory could not resolve this account's owner address " + "(POST /api/stats returned no owner)." + ) + self._owner_address = owner + return owner + + async def _resolve_owner(self) -> str: + """Owner address for this account, memoised for the client's life. + + The owner-scoped read routes take the address in the path, but the + client is configured with only a delegate key and account id. + """ + if self._owner_address is not None: + return self._owner_address + + if self._owner_task is not None: + return await self._owner_task + + self._owner_task = asyncio.create_task(self._resolve_owner_inner()) + try: + return await self._owner_task + finally: + self._owner_task = None + async def _signed_request( self, method: str, @@ -1668,6 +1775,14 @@ def restore(self, namespace: str, limit: int = 10) -> RestoreResult: (matches server + TypeScript SDK).""" return self._run(self._inner.restore(namespace, limit)) + def list_namespaces( + self, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> NamespacesResult: + """Synchronous version of :meth:`MemWal.list_namespaces`.""" + return self._run(self._inner.list_namespaces(cursor, limit)) + def health(self) -> HealthResult: """Synchronous version of :meth:`MemWal.health`.""" return self._run(self._inner.health()) diff --git a/packages/python-sdk-memwal/memwal/mock.py b/packages/python-sdk-memwal/memwal/mock.py index 06da3d105..31737c024 100644 --- a/packages/python-sdk-memwal/memwal/mock.py +++ b/packages/python-sdk-memwal/memwal/mock.py @@ -3,11 +3,13 @@ from __future__ import annotations import asyncio +import base64 +import json import math import re import unicodedata from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional, Sequence, Union from .types import ( @@ -18,6 +20,8 @@ AskResult, EmbedResult, HealthResult, + NamespacesResult, + NamespaceSummary, RecallMemory, RecallParams, RecallResult, @@ -34,6 +38,8 @@ RestoreResult, ) +_MOCK_NAMESPACE_EPOCH = datetime(2026, 1, 1, tzinfo=timezone.utc) + @dataclass class MemWalMockSeed: @@ -66,6 +72,24 @@ def _distance(query_tokens: set[str], text: str) -> float: return 1.0 - matches / len(query_tokens) +def _mock_timestamp(sequence: int) -> str: + # Same shape as the TypeScript mock's Date.toISOString(), so fixed-width + # timestamps compare correctly as strings. + moment = _MOCK_NAMESPACE_EPOCH + timedelta(seconds=sequence) + return moment.strftime("%Y-%m-%dT%H:%M:%S.000Z") + + +def _encode_namespaces_cursor(payload: Dict[str, Any]) -> str: + # The relayer's cursor: URL-safe unpadded base64 of a JSON object. + raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _decode_namespaces_cursor(cursor: str) -> Dict[str, Any]: + padded = cursor + "=" * (-len(cursor) % 4) + return json.loads(base64.urlsafe_b64decode(padded)) + + def _validate_text(text: str, field: str = "text") -> None: if not isinstance(text, str) or not text.strip(): raise ValueError(f"{field} cannot be empty") @@ -344,6 +368,67 @@ async def restore(self, namespace: str, limit: int = 10) -> RestoreResult: failed=0, ) + async def list_namespaces( + self, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> NamespacesResult: + grouped: Dict[str, List[_Memory]] = {} + for memory in self._memories: + grouped.setdefault(memory.namespace, []).append(memory) + summaries = sorted( + ( + NamespaceSummary( + id=f"mock-ns-{name}", + name=name, + memory_count=len(memories), + storage_used=sum(len(m.text.encode("utf-8")) for m in memories), + updated_at=_mock_timestamp(max(m.sequence for m in memories)), + ) + for name, memories in grouped.items() + ), + key=lambda ns: (ns.updated_at, ns.name), + ) + + # Keyset walk pinned to a snapshot, like the relayer: writes made + # mid-walk surface on the next poll, not in the current walk. + after = _decode_namespaces_cursor(cursor) if cursor is not None else None + snapshot_at = (after or {}).get("snapshot_at") or _mock_timestamp(self._sequence) + remaining = [ + ns + for ns in summaries + if ns.updated_at <= snapshot_at + and ( + after is None + or (ns.updated_at, ns.name) > (after["updated_at"], after["namespace"]) + ) + ] + page = remaining if limit is None else remaining[:limit] + has_more = len(remaining) > len(page) + + watermark = ( + {"updated_at": page[-1].updated_at, "namespace": page[-1].name} if page else after + ) + next_cursor = None + if watermark is not None: + next_cursor = _encode_namespaces_cursor( + { + "updated_at": watermark["updated_at"], + "namespace": watermark["namespace"], + # A finished walk drops the snapshot so the next poll + # takes a fresh one. + "snapshot_at": snapshot_at if has_more else None, + } + ) + + return NamespacesResult( + namespaces=page, + next_cursor=next_cursor, + has_more=has_more, + # Matches the live relayer's current wire-format version. + snapshot_version=2, + ) + async def health(self) -> HealthResult: return HealthResult( status="ok", @@ -582,6 +667,13 @@ def ask( def restore(self, namespace: str, limit: int = 10) -> RestoreResult: return self._run(self._inner.restore(namespace, limit)) + def list_namespaces( + self, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ) -> NamespacesResult: + return self._run(self._inner.list_namespaces(cursor, limit)) + def health(self) -> HealthResult: return self._run(self._inner.health()) diff --git a/packages/python-sdk-memwal/memwal/types.py b/packages/python-sdk-memwal/memwal/types.py index 09fdc49d8..cadaed9be 100644 --- a/packages/python-sdk-memwal/memwal/types.py +++ b/packages/python-sdk-memwal/memwal/types.py @@ -228,6 +228,34 @@ class RestoreResult: failed: int = 0 +@dataclass +class NamespaceSummary: + """One namespace in a :meth:`MemWal.list_namespaces` page.""" + + id: str + name: str + memory_count: int + storage_used: int + #: ``MAX(updated_at)`` across the namespace's memories (RFC 3339), the + #: same value the relayer builds the keyset cursor from. + updated_at: str + + +@dataclass +class NamespacesResult: + """Result from list_namespaces().""" + + namespaces: List[NamespaceSummary] + #: Pass back as ``cursor`` on the next call. Set on every page, including + #: the last, so a caller that finished a walk can poll from it later. + next_cursor: Optional[str] + #: Whether to keep paginating. Do NOT infer this from page length: the + #: relayer clamps ``limit``, so asking for more than the cap returns + #: exactly the cap. + has_more: bool + snapshot_version: int + + @dataclass class AskMemory: """A memory used to answer a question.""" diff --git a/packages/python-sdk-memwal/pyproject.toml b/packages/python-sdk-memwal/pyproject.toml index 549018172..f81904191 100644 --- a/packages/python-sdk-memwal/pyproject.toml +++ b/packages/python-sdk-memwal/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "memwal" -version = "0.1.10" +version = "0.1.11" description = "Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing" readme = "README.md" license = "MIT" diff --git a/packages/python-sdk-memwal/tests/test_integration.py b/packages/python-sdk-memwal/tests/test_integration.py index 59f17f9ef..842e8a9ee 100644 --- a/packages/python-sdk-memwal/tests/test_integration.py +++ b/packages/python-sdk-memwal/tests/test_integration.py @@ -257,6 +257,30 @@ def test_remember_custom_namespace(self) -> None: assert result.namespace == _E2E_NAMESPACE_ALT +@requires_key +class TestListNamespaces: + """list_namespaces() against live server. Read-only; writes nothing.""" + + def test_walks_every_page_on_has_more(self) -> None: + # limit=1 forces a cursor into the signed query string on every page + # after the first, which is what the relayer verifies the signature over. + mw = _sync_client() + names: list[str] = [] + cursor = None + for _ in range(1000): + page = mw.list_namespaces(cursor=cursor, limit=1) + assert len(page.namespaces) <= 1 + assert page.snapshot_version >= 1 + names.extend(ns.name for ns in page.namespaces) + cursor = page.next_cursor + if not page.has_more: + break + else: + pytest.fail("namespace walk did not finish in 1000 pages") + assert len(names) == len(set(names)), "a single walk must not repeat a namespace" + print(f"\n namespaces={len(names)}") + + @requires_key class TestRecall: """recall() against live server.""" diff --git a/packages/python-sdk-memwal/tests/test_list_namespaces.py b/packages/python-sdk-memwal/tests/test_list_namespaces.py new file mode 100644 index 000000000..c0bb58fb2 --- /dev/null +++ b/packages/python-sdk-memwal/tests/test_list_namespaces.py @@ -0,0 +1,220 @@ +""" +Tests for ``MemWal.list_namespaces()`` — owner-scoped namespace discovery. + +Mirrors ``packages/sdk/test/list-namespaces.test.mjs`` so both SDKs pin the +same wire contract. +""" + +from __future__ import annotations + +import asyncio +from urllib.parse import parse_qs + +import httpx +import nacl.signing +import pytest +import respx + +from memwal import MemWal, MemWalError, MemWalSync, NamespacesResult, NamespaceSummary +from memwal.utils import build_signature_message, bytes_to_hex, sha256_hex + +_SERVER = "https://relayer.example" +_OWNER = "0xowner0000000000000000000000000000000000000000000000000000000001" +_NAMESPACES_URL = f"{_SERVER}/v1/owners/{_OWNER}/namespaces" +_KEY_HEX = bytes_to_hex(bytes(nacl.signing.SigningKey(b"\x01" * 32))) + +_STATS = {"memory_count": 0, "storage_bytes": 0, "namespace": "default", "owner": _OWNER} + +_ONE_PAGE = { + "namespaces": [ + { + "id": "ns-1", + "name": "work", + "memory_count": 12, + "storage_used": 2048, + "updated_at": "2026-08-20T10:00:00Z", + } + ], + "next_cursor": "eyJ1cGRhdGVkX2F0IjoiMjAyNi0wOC0yMFQxMDowMDowMFoifQ", + "has_more": False, + "snapshot_version": 2, +} + + +def _client() -> MemWal: + return MemWal.create(key=_KEY_HEX, account_id="0x1", server_url=_SERVER) + + +def _stub_relayer( + namespaces_body: dict = _ONE_PAGE, + stats_body: dict | None = None, +) -> tuple[respx.Route, respx.Route]: + """Stub the three calls a list_namespaces() round-trip makes: the + compatibility preflight, the owner resolution, and the read itself. + + Anything else (``/config``, Sui GraphQL for a SEAL session) is unmocked, + so respx fails the test if the client reaches for it. + """ + respx.get(f"{_SERVER}/version").mock( + return_value=httpx.Response( + 200, + json={ + "apiVersion": "1.0.0", + "relayerVersion": "1.0.0", + "minSupportedSdk": {"typescript": "0.0.4", "python": "0.1.0", "mcp": "0.0.1"}, + }, + ) + ) + stats = respx.post(f"{_SERVER}/api/stats").mock( + return_value=httpx.Response( + 200, + json=stats_body if stats_body is not None else _STATS, + ) + ) + namespaces = respx.get(_NAMESPACES_URL).mock( + return_value=httpx.Response(200, json=namespaces_body) + ) + return stats, namespaces + + +class TestListNamespaces: + @respx.mock + async def test_reads_the_owner_scoped_namespaces_path(self) -> None: + _, namespaces = _stub_relayer() + + await _client().list_namespaces() + + assert namespaces.call_count == 1 + request = namespaces.calls[0].request + assert request.method == "GET" + assert request.url.path == f"/v1/owners/{_OWNER}/namespaces" + + @respx.mock + async def test_resolves_the_owner_once_and_reuses_it(self) -> None: + stats, namespaces = _stub_relayer() + memwal = _client() + + await memwal.list_namespaces() + await memwal.list_namespaces() + + assert stats.call_count == 1, "owner resolution must be memoised across calls" + assert namespaces.call_count == 2 + + @respx.mock + async def test_concurrent_first_calls_share_one_owner_lookup(self) -> None: + stats, namespaces = _stub_relayer() + owner_response = stats.return_value + + async def slow_stats(request: httpx.Request) -> httpx.Response: + # respx otherwise answers without yielding, so the two calls + # would run back to back instead of overlapping. + await asyncio.sleep(0.01) + return owner_response + + stats.side_effect = slow_stats + memwal = _client() + + await asyncio.gather(memwal.list_namespaces(), memwal.list_namespaces()) + + assert stats.call_count == 1 + assert namespaces.call_count == 2 + + @respx.mock + async def test_forwards_cursor_as_updated_after_and_passes_limit(self) -> None: + _, namespaces = _stub_relayer() + + await _client().list_namespaces(cursor="opaque-cursor_1", limit=25) + + params = parse_qs(namespaces.calls[0].request.url.query.decode()) + assert params == {"updated_after": ["opaque-cursor_1"], "limit": ["25"]} + + @respx.mock + async def test_omits_query_params_that_were_not_supplied(self) -> None: + _, namespaces = _stub_relayer() + + await _client().list_namespaces() + + assert namespaces.calls[0].request.url.query == b"" + + @respx.mock + async def test_signature_covers_the_query_string(self) -> None: + # The relayer verifies against `path_and_query`, not `path`. + _, namespaces = _stub_relayer() + + await _client().list_namespaces(cursor="abc", limit=5) + + request = namespaces.calls[0].request + assert request.content == b"" + headers = request.headers + message = build_signature_message( + timestamp=headers["x-timestamp"], + method="GET", + path=f"/v1/owners/{_OWNER}/namespaces?updated_after=abc&limit=5", + body_sha256=sha256_hex(""), + nonce=headers["x-nonce"], + account_id=headers["x-account-id"], + ) + verify_key = nacl.signing.VerifyKey(bytes.fromhex(headers["x-public-key"])) + verify_key.verify(message.encode("utf-8"), bytes.fromhex(headers["x-signature"])) + + @respx.mock + async def test_returns_the_relayer_wire_shape(self) -> None: + _stub_relayer() + + result = await _client().list_namespaces() + + assert result == NamespacesResult( + namespaces=[ + NamespaceSummary( + id="ns-1", + name="work", + memory_count=12, + storage_used=2048, + updated_at="2026-08-20T10:00:00Z", + ) + ], + next_cursor=_ONE_PAGE["next_cursor"], + has_more=False, + snapshot_version=2, + ) + + @respx.mock + async def test_sends_no_seal_session_on_a_metadata_only_read(self) -> None: + stats, namespaces = _stub_relayer() + + await _client().list_namespaces() + + for route in (stats, namespaces): + assert "x-seal-session" not in route.calls[0].request.headers + + @respx.mock + async def test_raises_when_stats_returns_no_owner(self) -> None: + _stub_relayer(stats_body={"memory_count": 0, "storage_bytes": 0, "namespace": "default"}) + + with pytest.raises(MemWalError, match="owner"): + await _client().list_namespaces() + + @respx.mock + async def test_a_failed_owner_lookup_is_retried_on_the_next_call(self) -> None: + stats, namespaces = _stub_relayer() + stats.side_effect = [ + httpx.Response(503, text="busy"), + httpx.Response(200, json=_STATS), + ] + memwal = _client() + + with pytest.raises(MemWalError): + await memwal.list_namespaces() + await memwal.list_namespaces() + + assert stats.call_count == 2 + assert namespaces.call_count == 1 + + @respx.mock + def test_sync_wrapper(self) -> None: + _stub_relayer() + client = MemWalSync.create(key=_KEY_HEX, account_id="0x1", server_url=_SERVER) + + result = client.list_namespaces(limit=10) + + assert [ns.name for ns in result.namespaces] == ["work"] diff --git a/packages/python-sdk-memwal/tests/test_mock.py b/packages/python-sdk-memwal/tests/test_mock.py index 30ef35cba..9a3e006fc 100644 --- a/packages/python-sdk-memwal/tests/test_mock.py +++ b/packages/python-sdk-memwal/tests/test_mock.py @@ -1,6 +1,9 @@ """Offline mock client regression tests.""" +import base64 import inspect +import json +import re import pytest @@ -162,3 +165,102 @@ async def test_sync_mock_works_inside_an_existing_event_loop(): assert stored.namespace == "notebook" assert recalled.results[0].text == "called from a running loop" + + +@pytest.mark.asyncio +async def test_mock_list_namespaces_aggregates_memories_by_namespace(): + mock = MemWalMock.create( + initial_memories=[ + MemWalMockSeed(text="one", namespace="work"), + MemWalMockSeed(text="two", namespace="work"), + MemWalMockSeed(text="旅行", namespace="home"), + ] + ) + + page = await mock.list_namespaces() + by_name = {ns.name: ns for ns in page.namespaces} + + assert sorted(by_name) == ["home", "work"] + assert by_name["work"].memory_count == 2 + assert by_name["work"].storage_used == 6 + assert by_name["home"].storage_used == len("旅行".encode("utf-8")) + assert page.has_more is False + # Matches the live relayer's current wire-format version. + assert page.snapshot_version == 2 + + +@pytest.mark.asyncio +async def test_mock_list_namespaces_reports_has_more_when_limit_truncates(): + mock = MemWalMock.create( + initial_memories=[ + MemWalMockSeed(text="a", namespace="alpha"), + MemWalMockSeed(text="b", namespace="bravo"), + MemWalMockSeed(text="c", namespace="charlie"), + ] + ) + + page = await mock.list_namespaces(limit=2) + + assert len(page.namespaces) == 2 + assert page.has_more is True, "has_more is the pagination signal, not page length" + assert page.next_cursor + + +@pytest.mark.asyncio +async def test_mock_namespace_cursor_uses_the_relayer_wire_format_and_resets_after_a_walk(): + mock = MemWalMock.create( + initial_memories=[ + MemWalMockSeed(text="a", namespace="旅行"), + MemWalMockSeed(text="b", namespace="work"), + ] + ) + + first = await mock.list_namespaces(limit=1) + assert re.fullmatch(r"[A-Za-z0-9_-]+", first.next_cursor) + padded = first.next_cursor + "=" * (-len(first.next_cursor) % 4) + cursor = json.loads(base64.urlsafe_b64decode(padded)) + assert cursor["namespace"] == "旅行" + assert cursor["updated_at"] == first.namespaces[0].updated_at + assert cursor["snapshot_at"] + + last = await mock.list_namespaces(cursor=first.next_cursor) + assert [ns.name for ns in last.namespaces] == ["work"] + assert last.has_more is False + padded = last.next_cursor + "=" * (-len(last.next_cursor) % 4) + assert json.loads(base64.urlsafe_b64decode(padded))["snapshot_at"] is None + + empty = await mock.list_namespaces(cursor=last.next_cursor) + assert empty.namespaces == [] + assert empty.next_cursor == last.next_cursor + + +@pytest.mark.asyncio +async def test_mock_namespace_walk_defers_new_writes_until_the_next_poll(): + mock = MemWalMock.create( + initial_memories=[ + MemWalMockSeed(text="a", namespace="alpha"), + MemWalMockSeed(text="b", namespace="bravo"), + ] + ) + + first = await mock.list_namespaces(limit=1) + await mock.remember("new", "bravo") + last = await mock.list_namespaces(cursor=first.next_cursor) + assert last.namespaces == [] + assert last.has_more is False + + poll = await mock.list_namespaces(cursor=last.next_cursor) + assert [ns.name for ns in poll.namespaces] == ["bravo"] + assert poll.namespaces[0].memory_count == 2 + + +def test_sync_mock_list_namespaces_matches_production(): + mock = MemWalMockSync.create(namespace="sync") + mock.remember("sync memory") + + page = mock.list_namespaces(limit=10) + + assert [ns.name for ns in page.namespaces] == ["sync"] + assert inspect.signature(MemWalMockSync.list_namespaces) == inspect.signature( + MemWalSync.list_namespaces + ) diff --git a/scripts/verify-manual-sdk-release.mjs b/scripts/verify-manual-sdk-release.mjs index 66f89a563..5486d6341 100644 --- a/scripts/verify-manual-sdk-release.mjs +++ b/scripts/verify-manual-sdk-release.mjs @@ -11,7 +11,7 @@ const releases = [ }, { name: "Python SDK", - version: "0.1.10", + version: "0.1.11", manifests: [ ["packages/python-sdk-memwal/pyproject.toml", "toml-version"], ["packages/python-sdk-memwal/memwal/__init__.py", "python-version"],