Skip to content

Commit c4afc9f

Browse files
jfrancoaclaude
andcommitted
feat: support namespace home_node and update endpoint
The server gained an optional home_node on namespace create plus a new PUT /namespaces/{name} to modify it, and a read-only state field on the namespace object. Bring the client to parity: - Namespace model gains home_node and state (Literal active/deleting) - create() accepts optional home_node; new update() backed by PUT - create/get/list/update share a _ns_from_dict parser - sync/async .pyi stubs updated; NamespaceState exported from outputs - mock, unit, and integration tests for the new fields and update() Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ef7abac commit c4afc9f

8 files changed

Lines changed: 237 additions & 12 deletions

File tree

integration/test_namespaces.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ def _skip_if_unsupported(client: weaviate.WeaviateClient) -> None:
1818
pytest.skip(f"Namespaces require Weaviate {major}.{minor}.{patch}+")
1919

2020

21+
def _a_storage_candidate(client: weaviate.WeaviateClient) -> str:
22+
"""Return a node name usable as a ``home_node`` (must be a real storage candidate)."""
23+
nodes = client.cluster.nodes()
24+
assert nodes, "expected at least one cluster node"
25+
return nodes[0].name
26+
27+
2128
def test_create_and_get_namespace(client_factory: ClientFactory) -> None:
2229
with client_factory(ports=NS_PORTS, auth_credentials=ADMIN_KEY) as client:
2330
_skip_if_unsupported(client)
@@ -32,6 +39,43 @@ def test_create_and_get_namespace(client_factory: ClientFactory) -> None:
3239
client.namespaces.delete(name="testns")
3340

3441

42+
def test_create_namespace_with_home_node(client_factory: ClientFactory) -> None:
43+
with client_factory(ports=NS_PORTS, auth_credentials=ADMIN_KEY) as client:
44+
_skip_if_unsupported(client)
45+
46+
home_node = _a_storage_candidate(client)
47+
ns = client.namespaces.create(name="homenodens", home_node=home_node)
48+
try:
49+
assert ns.name == "homenodens"
50+
assert ns.home_node == home_node
51+
assert ns.state == "active"
52+
53+
fetched = client.namespaces.get(name="homenodens")
54+
assert fetched is not None
55+
assert fetched.home_node == home_node
56+
finally:
57+
client.namespaces.delete(name="homenodens")
58+
59+
60+
def test_update_namespace_home_node(client_factory: ClientFactory) -> None:
61+
with client_factory(ports=NS_PORTS, auth_credentials=ADMIN_KEY) as client:
62+
_skip_if_unsupported(client)
63+
64+
home_node = _a_storage_candidate(client)
65+
# Create without a home_node so the cluster auto-selects, then pin it explicitly.
66+
client.namespaces.create(name="updatens")
67+
try:
68+
updated = client.namespaces.update(name="updatens", home_node=home_node)
69+
assert updated.name == "updatens"
70+
assert updated.home_node == home_node
71+
72+
fetched = client.namespaces.get(name="updatens")
73+
assert fetched is not None
74+
assert fetched.home_node == home_node
75+
finally:
76+
client.namespaces.delete(name="updatens")
77+
78+
3579
def test_get_nonexistent_namespace_returns_none(client_factory: ClientFactory) -> None:
3680
with client_factory(ports=NS_PORTS, auth_credentials=ADMIN_KEY) as client:
3781
_skip_if_unsupported(client)

mock_tests/test_namespaces.py

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,76 @@ def handler(request: Request) -> Response:
100100

101101
assert isinstance(ns, Namespace)
102102
assert ns.name == "myns"
103+
assert ns.home_node is None
104+
assert ns.state is None
103105
assert captured["body"] == {}
104106
server.check_assertions()
105107

106108

109+
def test_namespaces_create_sends_home_node_when_provided(
110+
ns_client: Tuple[weaviate.WeaviateClient, HTTPServer],
111+
) -> None:
112+
"""When ``home_node`` is provided, ``create`` must carry it in the request body.
113+
114+
Dropping ``body['home_node'] = home_node`` would silently ignore the caller's
115+
placement choice and let the server auto-select instead.
116+
"""
117+
client, server = ns_client
118+
captured: Dict[str, Any] = {}
119+
120+
def handler(request: Request) -> Response:
121+
captured["body"] = json.loads(request.get_data(as_text=True) or "{}")
122+
return Response(
123+
json.dumps({"name": "myns", "home_node": "node1", "state": "active"}),
124+
status=201,
125+
)
126+
127+
server.expect_request("/v1/namespaces/myns", method="POST").respond_with_handler(handler)
128+
129+
ns = client.namespaces.create(name="myns", home_node="node1")
130+
131+
assert ns.name == "myns"
132+
assert ns.home_node == "node1"
133+
assert ns.state == "active"
134+
assert captured["body"] == {"home_node": "node1"}
135+
server.check_assertions()
136+
137+
138+
# ---------------------------------------------------------------------------
139+
# update
140+
# ---------------------------------------------------------------------------
141+
142+
143+
def test_namespaces_update_sends_put_and_parses_response(
144+
ns_client: Tuple[weaviate.WeaviateClient, HTTPServer],
145+
) -> None:
146+
"""``update`` must PUT ``{"home_node": ...}`` and return the updated namespace.
147+
148+
The server requires ``home_node`` in the body; sending anything else (or the
149+
wrong HTTP verb) would break the modify-placement contract.
150+
"""
151+
client, server = ns_client
152+
captured: Dict[str, Any] = {}
153+
154+
def handler(request: Request) -> Response:
155+
captured["body"] = json.loads(request.get_data(as_text=True) or "{}")
156+
return Response(
157+
json.dumps({"name": "myns", "home_node": "node2", "state": "active"}),
158+
status=200,
159+
)
160+
161+
server.expect_request("/v1/namespaces/myns", method="PUT").respond_with_handler(handler)
162+
163+
ns = client.namespaces.update(name="myns", home_node="node2")
164+
165+
assert isinstance(ns, Namespace)
166+
assert ns.name == "myns"
167+
assert ns.home_node == "node2"
168+
assert ns.state == "active"
169+
assert captured["body"] == {"home_node": "node2"}
170+
server.check_assertions()
171+
172+
107173
# ---------------------------------------------------------------------------
108174
# get
109175
# ---------------------------------------------------------------------------
@@ -121,6 +187,30 @@ def test_namespaces_get_returns_namespace_when_found(
121187

122188
assert ns is not None
123189
assert ns.name == "customer1"
190+
assert ns.home_node is None
191+
assert ns.state is None
192+
server.check_assertions()
193+
194+
195+
def test_namespaces_get_parses_home_node_and_state(
196+
ns_client: Tuple[weaviate.WeaviateClient, HTTPServer],
197+
) -> None:
198+
"""``get`` must surface the ``home_node`` and ``state`` fields from the response.
199+
200+
These fields were added to the server's namespace object after the client's
201+
initial implementation; this guards against the parser silently ignoring them.
202+
"""
203+
client, server = ns_client
204+
server.expect_request("/v1/namespaces/customer1", method="GET").respond_with_json(
205+
{"name": "customer1", "home_node": "node1", "state": "deleting"}, status=200
206+
)
207+
208+
ns = client.namespaces.get(name="customer1")
209+
210+
assert ns is not None
211+
assert ns.name == "customer1"
212+
assert ns.home_node == "node1"
213+
assert ns.state == "deleting"
124214
server.check_assertions()
125215

126216

@@ -161,6 +251,28 @@ def test_namespaces_list_all_parses_array(
161251
server.check_assertions()
162252

163253

254+
def test_namespaces_list_all_parses_home_node_and_state(
255+
ns_client: Tuple[weaviate.WeaviateClient, HTTPServer],
256+
) -> None:
257+
"""``list_all`` must surface ``home_node`` and ``state`` for each namespace."""
258+
client, server = ns_client
259+
server.expect_request("/v1/namespaces", method="GET").respond_with_json(
260+
[
261+
{"name": "ns1", "home_node": "node1", "state": "active"},
262+
{"name": "ns2"},
263+
],
264+
status=200,
265+
)
266+
267+
namespaces = client.namespaces.list_all()
268+
269+
assert namespaces[0].home_node == "node1"
270+
assert namespaces[0].state == "active"
271+
assert namespaces[1].home_node is None
272+
assert namespaces[1].state is None
273+
server.check_assertions()
274+
275+
164276
def test_namespaces_list_all_handles_null_response(
165277
ns_client: Tuple[weaviate.WeaviateClient, HTTPServer],
166278
) -> None:
@@ -216,11 +328,12 @@ def test_namespaces_delete_accepts_202(
216328
"method_call",
217329
[
218330
lambda c: c.namespaces.create(name="x"),
331+
lambda c: c.namespaces.update(name="x", home_node="n"),
219332
lambda c: c.namespaces.get(name="x"),
220333
lambda c: c.namespaces.list_all(),
221334
lambda c: c.namespaces.delete(name="x"),
222335
],
223-
ids=["create", "get", "list_all", "delete"],
336+
ids=["create", "update", "get", "list_all", "delete"],
224337
)
225338
def test_namespaces_methods_require_1_38(
226339
ns_client_old: weaviate.WeaviateClient,

test/test_namespaces.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from weaviate.classes.rbac import Actions, Permissions
2+
from weaviate.namespaces.models import Namespace
23
from weaviate.rbac.models import (
34
NamespacesAction,
45
NamespacesPermissionOutput,
@@ -106,6 +107,23 @@ def test_actions_namespaces_enum_accessible() -> None:
106107
assert Actions.Namespaces.MANAGE.value == "manage_namespaces"
107108

108109

110+
# --- Namespace model ---
111+
112+
113+
def test_namespace_optional_fields_default_to_none() -> None:
114+
ns = Namespace(name="customer1")
115+
assert ns.name == "customer1"
116+
assert ns.home_node is None
117+
assert ns.state is None
118+
119+
120+
def test_namespace_all_fields_set() -> None:
121+
ns = Namespace(name="customer1", home_node="node1", state="active")
122+
assert ns.name == "customer1"
123+
assert ns.home_node == "node1"
124+
assert ns.state == "active"
125+
126+
109127
# --- UserDB.namespace field ---
110128

111129

weaviate/namespaces/async_.pyi

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ from weaviate.namespaces.base import _NamespacesExecutor
55
from weaviate.namespaces.models import Namespace
66

77
class _NamespacesAsync(_NamespacesExecutor[ConnectionAsync]):
8-
async def create(self, *, name: str) -> Namespace: ...
8+
async def create(self, *, name: str, home_node: Optional[str] = None) -> Namespace: ...
9+
async def update(self, *, name: str, home_node: str) -> Namespace: ...
910
async def get(self, *, name: str) -> Optional[Namespace]: ...
1011
async def list_all(self) -> List[Namespace]: ...
1112
async def delete(self, *, name: str) -> None: ...

weaviate/namespaces/base.py

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,32 @@
1-
from typing import Generic, List, Optional
1+
from typing import Any, Dict, Generic, List, Optional, cast
22

33
from httpx import Response
44

55
from weaviate.connect import executor
66
from weaviate.connect.v4 import ConnectionType, _ExpectedStatusCodes
7-
from weaviate.namespaces.models import Namespace
7+
from weaviate.namespaces.models import Namespace, NamespaceState
88
from weaviate.util import _decode_json_response_dict, _decode_json_response_list
99

1010

1111
class _NamespacesExecutor(Generic[ConnectionType]):
1212
def __init__(self, connection: ConnectionType):
1313
self._connection = connection
1414

15-
def create(self, *, name: str) -> executor.Result[Namespace]:
15+
@staticmethod
16+
def _ns_from_dict(data: Dict[str, Any]) -> Namespace:
17+
return Namespace(
18+
name=data["name"],
19+
home_node=data.get("home_node"),
20+
state=cast(Optional[NamespaceState], data.get("state")),
21+
)
22+
23+
def create(self, *, name: str, home_node: Optional[str] = None) -> executor.Result[Namespace]:
1624
"""Create a new namespace.
1725
1826
Args:
1927
name: The namespace name. Must be 3-36 lowercase alphanumeric characters starting with a letter.
28+
home_node: The cluster node to place this namespace's shards on. Must be a current storage
29+
candidate. When omitted, the cluster picks one automatically.
2030
2131
Returns:
2232
The created Namespace.
@@ -26,17 +36,50 @@ def create(self, *, name: str) -> executor.Result[Namespace]:
2636
def resp(res: Response) -> Namespace:
2737
parsed = _decode_json_response_dict(res, "Create namespace")
2838
assert parsed is not None
29-
return Namespace(name=parsed["name"])
39+
return self._ns_from_dict(parsed)
40+
41+
body: Dict[str, Any] = {}
42+
if home_node is not None:
43+
body["home_node"] = home_node
3044

3145
return executor.execute(
3246
response_callback=resp,
3347
method=self._connection.post,
3448
path=f"/namespaces/{name}",
35-
weaviate_object={},
49+
weaviate_object=body,
3650
error_msg=f"Could not create namespace '{name}'",
3751
status_codes=_ExpectedStatusCodes(ok_in=[201], error="Create namespace"),
3852
)
3953

54+
def update(self, *, name: str, home_node: str) -> executor.Result[Namespace]:
55+
"""Update the home node of an existing namespace.
56+
57+
Changing the home node only affects future placement decisions; existing live shards
58+
are not moved.
59+
60+
Args:
61+
name: The name of the namespace to update.
62+
home_node: The cluster node to use for future placements. Must be a current storage candidate.
63+
64+
Returns:
65+
The updated Namespace.
66+
"""
67+
self._connection._weaviate_version.check_is_at_least_1_38_0("namespaces")
68+
69+
def resp(res: Response) -> Namespace:
70+
parsed = _decode_json_response_dict(res, "Update namespace")
71+
assert parsed is not None
72+
return self._ns_from_dict(parsed)
73+
74+
return executor.execute(
75+
response_callback=resp,
76+
method=self._connection.put,
77+
path=f"/namespaces/{name}",
78+
weaviate_object={"home_node": home_node},
79+
error_msg=f"Could not update namespace '{name}'",
80+
status_codes=_ExpectedStatusCodes(ok_in=[200], error="Update namespace"),
81+
)
82+
4083
def get(self, *, name: str) -> executor.Result[Optional[Namespace]]:
4184
"""Get a namespace by name.
4285
@@ -53,7 +96,7 @@ def resp(res: Response) -> Optional[Namespace]:
5396
return None
5497
parsed = _decode_json_response_dict(res, "Get namespace")
5598
assert parsed is not None
56-
return Namespace(name=parsed["name"])
99+
return self._ns_from_dict(parsed)
57100

58101
return executor.execute(
59102
response_callback=resp,
@@ -73,7 +116,7 @@ def list_all(self) -> executor.Result[List[Namespace]]:
73116

74117
def resp(res: Response) -> List[Namespace]:
75118
parsed = _decode_json_response_list(res, "List namespaces")
76-
return [Namespace(name=ns["name"]) for ns in (parsed or [])]
119+
return [self._ns_from_dict(ns) for ns in (parsed or [])]
77120

78121
return executor.execute(
79122
response_callback=resp,

weaviate/namespaces/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
from dataclasses import dataclass
2+
from typing import Literal, Optional
3+
4+
NamespaceState = Literal["active", "deleting"]
25

36

47
@dataclass
58
class Namespace:
69
name: str
10+
home_node: Optional[str] = None
11+
state: Optional[NamespaceState] = None

weaviate/namespaces/sync.pyi

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ from weaviate.namespaces.base import _NamespacesExecutor
55
from weaviate.namespaces.models import Namespace
66

77
class _Namespaces(_NamespacesExecutor[ConnectionSync]):
8-
def create(self, *, name: str) -> Namespace: ...
8+
def create(self, *, name: str, home_node: Optional[str] = None) -> Namespace: ...
9+
def update(self, *, name: str, home_node: str) -> Namespace: ...
910
def get(self, *, name: str) -> Optional[Namespace]: ...
1011
def list_all(self) -> List[Namespace]: ...
1112
def delete(self, *, name: str) -> None: ...

weaviate/outputs/namespaces.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
from weaviate.namespaces.models import Namespace
1+
from weaviate.namespaces.models import Namespace, NamespaceState
22

3-
__all__ = ["Namespace"]
3+
__all__ = ["Namespace", "NamespaceState"]

0 commit comments

Comments
 (0)