Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions packages/python-sdk-memwal/memwal/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,14 @@ def __init__(self, config: MemWalConfig) -> None:
# subsequent identical call then collapses onto the accepted paid job.
self._pending_remember_keys: Dict[str, str] = {}

def _resolve_namespace(self, namespace: Optional[str]) -> str:
"""``None`` uses the client default; do not treat ``""`` as missing."""
if namespace is None:
return self._namespace
if not namespace:
raise ValueError("namespace cannot be empty")
return namespace

@classmethod
def create(
cls,
Expand Down Expand Up @@ -514,7 +522,7 @@ async def remember(
status (``"pending"``).
"""
_reject_blank_text(text)
resolved_namespace = namespace or self._namespace
resolved_namespace = self._resolve_namespace(namespace)
request_identity = f"{resolved_namespace}\0{text}"
generated_key = idempotency_key is None
resolved_key = (
Expand Down Expand Up @@ -727,7 +735,7 @@ async def remember_and_wait(
"""

_reject_blank_text(text)
resolved_namespace = namespace or self._namespace
resolved_namespace = self._resolve_namespace(namespace)
request_identity = f"{resolved_namespace}\0{text}"
generated_key = idempotency_key is None
resolved_key = (
Expand Down Expand Up @@ -773,7 +781,7 @@ async def remember_bulk_async(
payload_items: List[Dict[str, Any]] = [
{
"text": item.text,
"namespace": item.namespace or self._namespace,
"namespace": self._resolve_namespace(item.namespace),
}
for item in items
]
Expand Down Expand Up @@ -1052,7 +1060,7 @@ async def recall(
body = {
"query": query_text,
"limit": limit,
"namespace": namespace or self._namespace,
"namespace": self._resolve_namespace(namespace),
}
if scoring_weights is not None:
body["scoring_weights"] = scoring_weights.to_wire()
Expand Down Expand Up @@ -1127,7 +1135,7 @@ async def analyze(
_reject_blank_text(text)
body: Dict[str, Any] = {
"text": text,
"namespace": namespace or self._namespace,
"namespace": self._resolve_namespace(namespace),
}
wire_occurred_at = _occurred_at_to_wire(occurred_at)
if wire_occurred_at is not None:
Expand Down Expand Up @@ -1228,7 +1236,7 @@ async def ask(
data = await self._signed_request("POST", "/api/ask", {
"question": question,
"limit": limit,
"namespace": namespace or self._namespace,
"namespace": self._resolve_namespace(namespace),
})
memories = [
AskMemory(blob_id=m["blob_id"], text=m["text"], distance=m["distance"])
Expand Down Expand Up @@ -1426,7 +1434,7 @@ async def remember_manual(self, opts: RememberManualOptions) -> RememberManualRe
{
"encrypted_data": opts.encrypted_data,
"vector": opts.vector,
"namespace": opts.namespace or self._namespace,
"namespace": self._resolve_namespace(opts.namespace),
},
include_seal_session=False,
)
Expand All @@ -1452,7 +1460,7 @@ async def recall_manual(self, opts: RecallManualOptions) -> RecallManualResult:
body: Dict[str, Any] = {
"vector": opts.vector,
"limit": opts.limit,
"namespace": opts.namespace or self._namespace,
"namespace": self._resolve_namespace(opts.namespace),
}
if opts.scoring_weights is not None:
body["scoring_weights"] = opts.scoring_weights.to_wire()
Expand Down
6 changes: 4 additions & 2 deletions packages/python-sdk-memwal/memwal/mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,9 @@ async def recall(
raise ValueError("limit must be a non-negative integer")
if sort not in (None, "relevance", "recent"):
raise ValueError("sort must be 'relevance' or 'recent'")
resolved_namespace = namespace or self._namespace
resolved_namespace = self._namespace if namespace is None else namespace
if not resolved_namespace:
raise ValueError("namespace cannot be empty")
query_tokens = _tokens(query_text)
ranked = [
(memory, _distance(query_tokens, memory.text))
Expand Down Expand Up @@ -509,7 +511,7 @@ def _store(
blob_id: Optional[str] = None,
) -> _Memory:
_validate_text(text)
resolved_namespace = namespace or self._namespace
resolved_namespace = self._namespace if namespace is None else namespace
if not resolved_namespace:
raise ValueError("namespace cannot be empty")
self._sequence += 1
Expand Down
144 changes: 144 additions & 0 deletions packages/python-sdk-memwal/tests/test_empty_namespace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""#1012: an explicit empty namespace must not be rewritten to the default.

The client used ``namespace or self._namespace``, so ``namespace=""`` landed
in the default partition, while the TypeScript SDK sends "" and the relayer
rejects it. Only ``None`` means "use the client default"; "" fails before
anything is signed, like the mock's empty-namespace check. Whitespace-only
names are not rejected: the relayer's validate_namespace only refuses "", a
NUL byte and over-long names, and keeps tab/newline/CR namespaces readable.
"""

from __future__ import annotations

import pytest

from memwal import (
MemWal,
RecallManualOptions,
RecallParams,
RememberBulkItem,
RememberManualOptions,
)
from memwal.mock import MemWalMock


class CaptureMemWal(MemWal):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.captured = []

async def _ensure_compatible_relayer(self):
return {
"apiVersion": "1.0.0",
"relayerVersion": "capture",
"minSupportedSdk": {"python": "0.0.0"},
}

async def _signed_request(
self,
method,
path,
body,
accepted_statuses=(200,),
include_seal_session=True,
timeout=None,
):
self.captured.append((method, path, body))
if path == "/api/remember":
return {"job_id": "job-1", "status": "pending"}
if path == "/api/remember/bulk":
return {"job_ids": ["job"] * len(body["items"]), "total": len(body["items"])}
if path == "/api/remember/manual":
return {"id": "1", "blob_id": "b", "owner": "0x1", "namespace": body["namespace"]}
if path == "/api/analyze":
return {"facts": [], "total": 0, "owner": "0x1"}
if path == "/api/ask":
return {"answer": "", "memories_used": 0, "memories": []}
return {"results": [], "total": 0}


def _client():
return CaptureMemWal.create(
key="11" * 32,
account_id="0x" + "ab" * 32,
server_url="https://relayer.example",
namespace="work",
)


def _sent_namespaces(client):
sent = []
for _, _, body in client.captured:
sent.extend(item["namespace"] for item in body.get("items", []))
if "namespace" in body:
sent.append(body["namespace"])
return sent


CALLS = [
("remember", lambda c, ns: c.remember("x", namespace=ns)),
("remember_async", lambda c, ns: c.remember_async("x", namespace=ns)),
("remember_bulk_async", lambda c, ns: c.remember_bulk_async([RememberBulkItem("x", ns)])),
("recall", lambda c, ns: c.recall("q", namespace=ns)),
("recall_params", lambda c, ns: c.recall(RecallParams(query="q", namespace=ns))),
("analyze", lambda c, ns: c.analyze("x", namespace=ns)),
("ask", lambda c, ns: c.ask("q", namespace=ns)),
(
"remember_manual",
lambda c, ns: c.remember_manual(
RememberManualOptions(encrypted_data="AAAA", vector=[0.1], namespace=ns)
),
),
(
"recall_manual",
lambda c, ns: c.recall_manual(RecallManualOptions(vector=[0.1], namespace=ns)),
),
]


@pytest.mark.parametrize("call", CALLS, ids=lambda x: x[0])
async def test_empty_namespace_is_rejected_not_defaulted(call):
c = _client()
with pytest.raises(ValueError, match="namespace cannot be empty"):
await call[1](c, "")
assert c.captured == []


@pytest.mark.parametrize("namespace", [" ", " ", "\t", "\n", "\r"], ids=repr)
@pytest.mark.parametrize("call", CALLS, ids=lambda x: x[0])
async def test_whitespace_namespace_is_sent_as_is(call, namespace):
"""Whitespace names are valid for the relayer and the TypeScript SDK."""
c = _client()
await call[1](c, namespace)
assert _sent_namespaces(c) == [namespace]


@pytest.mark.parametrize(
"name", ["recall", "recall_params", "analyze", "ask", "remember_manual", "recall_manual"]
)
async def test_none_uses_client_default_and_explicit_name_is_kept(name):
call = dict(CALLS)[name]
c = _client()
await call(c, None)
await call(c, "profile")
assert [b["namespace"] for _, _, b in c.captured] == ["work", "profile"]


async def test_mock_rejects_empty_namespace_on_recall_too():
mock = MemWalMock(namespace="work")
with pytest.raises(ValueError, match="namespace cannot be empty"):
await mock.recall("q", namespace="")


@pytest.mark.parametrize("namespace", [" ", "\t", "\n", "\r"], ids=repr)
async def test_mock_keeps_whitespace_namespaces_separate(namespace):
mock = MemWalMock(namespace="work")
await mock.remember("blank name fact", namespace=namespace)
assert len((await mock.recall("blank name fact", namespace=namespace)).results) == 1
assert (await mock.recall("blank name fact")).results == []


async def test_mock_rejects_empty_namespace_on_remember():
mock = MemWalMock(namespace="work")
with pytest.raises(ValueError, match="namespace cannot be empty"):
await mock.remember("x", namespace="")
Loading