From d5debb8be16e644127b1f53ae367dfa48865b348 Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Tue, 5 May 2026 16:15:00 +0200 Subject: [PATCH 01/14] feat: add namespaces support Adds full CRUD support for the Weaviate namespaces feature (requires Weaviate 1.38.0+): create/get/list/delete namespace endpoints, RBAC manage_namespaces permission, namespace-scoped DB user creation, and UserDB.namespace field. Includes unit and integration test suites with a dedicated docker-compose fixture. Co-Authored-By: Claude Sonnet 4.6 --- ci/docker-compose-namespaces.yml | 40 ++++++++++ integration/test_namespaces.py | 120 ++++++++++++++++++++++++++++ test/test_namespaces.py | 130 +++++++++++++++++++++++++++++++ weaviate/client.py | 7 ++ weaviate/client.pyi | 4 + weaviate/namespaces/__init__.py | 4 + weaviate/namespaces/async_.py | 8 ++ weaviate/namespaces/async_.pyi | 11 +++ weaviate/namespaces/base.py | 102 ++++++++++++++++++++++++ weaviate/namespaces/models.py | 6 ++ weaviate/namespaces/sync.py | 8 ++ weaviate/namespaces/sync.pyi | 11 +++ weaviate/outputs/__init__.py | 2 + weaviate/outputs/namespaces.py | 3 + weaviate/rbac/models.py | 68 +++++++++++++++- weaviate/users/async_.pyi | 2 +- weaviate/users/base.py | 11 ++- weaviate/users/sync.pyi | 2 +- weaviate/users/users.py | 1 + weaviate/util.py | 4 + 20 files changed, 539 insertions(+), 5 deletions(-) create mode 100644 ci/docker-compose-namespaces.yml create mode 100644 integration/test_namespaces.py create mode 100644 test/test_namespaces.py create mode 100644 weaviate/namespaces/__init__.py create mode 100644 weaviate/namespaces/async_.py create mode 100644 weaviate/namespaces/async_.pyi create mode 100644 weaviate/namespaces/base.py create mode 100644 weaviate/namespaces/models.py create mode 100644 weaviate/namespaces/sync.py create mode 100644 weaviate/namespaces/sync.pyi create mode 100644 weaviate/outputs/namespaces.py diff --git a/ci/docker-compose-namespaces.yml b/ci/docker-compose-namespaces.yml new file mode 100644 index 000000000..e8a23bca8 --- /dev/null +++ b/ci/docker-compose-namespaces.yml @@ -0,0 +1,40 @@ +--- +version: '3.4' +services: + weaviate-namespaces: + command: + - --host + - 0.0.0.0 + - --port + - '8085' + - --scheme + - http + - --write-timeout=600s + image: semitechnologies/weaviate:${WEAVIATE_VERSION} + ports: + - 8094:8085 + - 50064:50051 + restart: on-failure:0 + environment: + # Namespaces feature — requires GraphQL disabled + NAMESPACES_ENABLED: "true" + DISABLE_GRAPHQL: "true" + # Static API key auth (operator-level access) + AUTHENTICATION_APIKEY_ENABLED: "true" + AUTHENTICATION_APIKEY_ALLOWED_KEYS: "admin-key,custom-key" + AUTHENTICATION_APIKEY_USERS: "admin-user,custom-user" + # Anonymous access must be off when RBAC is on + AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: "false" + # RBAC — required for namespace-scoped permission checks + AUTHORIZATION_ENABLE_RBAC: "true" + AUTHORIZATION_ADMIN_USERS: "admin-user" + # Dynamic DB users — needed to create namespaced users + AUTHENTICATION_DB_USERS_ENABLED: "true" + # Storage / cluster + PERSISTENCE_DATA_PATH: "./data-weaviate-0" + CLUSTER_IN_LOCALHOST: "true" + CLUSTER_GOSSIP_BIND_PORT: "7102" + CLUSTER_DATA_BIND_PORT: "7103" + RAFT_BOOTSTRAP_EXPECT: "1" + ENABLE_MODULES: "" +... diff --git a/integration/test_namespaces.py b/integration/test_namespaces.py new file mode 100644 index 000000000..b5cecd12b --- /dev/null +++ b/integration/test_namespaces.py @@ -0,0 +1,120 @@ +import pytest + +import weaviate +from integration.conftest import ClientFactory +from weaviate.auth import Auth +from weaviate.namespaces.models import Namespace +from weaviate.rbac.models import Permissions + +NS_PORTS = (8094, 50064) +ADMIN_KEY = Auth.api_key("admin-key") + +_MINIMUM_VERSION = (1, 38, 0) + + +def _skip_if_unsupported(client: weaviate.WeaviateClient) -> None: + major, minor, patch = _MINIMUM_VERSION + if client._connection._weaviate_version.is_lower_than(major, minor, patch): + pytest.skip(f"Namespaces require Weaviate {major}.{minor}.{patch}+") + + +def test_create_and_get_namespace(client_factory: ClientFactory) -> None: + with client_factory(ports=NS_PORTS, auth_credentials=ADMIN_KEY) as client: + _skip_if_unsupported(client) + + client.namespaces.create(name="testns") + try: + ns = client.namespaces.get(name="testns") + assert ns is not None + assert isinstance(ns, Namespace) + assert ns.name == "testns" + finally: + client.namespaces.delete(name="testns") + + +def test_get_nonexistent_namespace_returns_none(client_factory: ClientFactory) -> None: + with client_factory(ports=NS_PORTS, auth_credentials=ADMIN_KEY) as client: + _skip_if_unsupported(client) + + result = client.namespaces.get(name="doesnotexist") + assert result is None + + +def test_list_namespaces(client_factory: ClientFactory) -> None: + with client_factory(ports=NS_PORTS, auth_credentials=ADMIN_KEY) as client: + _skip_if_unsupported(client) + + client.namespaces.create(name="listns1") + client.namespaces.create(name="listns2") + try: + namespaces = client.namespaces.list_all() + names = [ns.name for ns in namespaces] + assert "listns1" in names + assert "listns2" in names + finally: + client.namespaces.delete(name="listns1") + client.namespaces.delete(name="listns2") + + +def test_delete_namespace(client_factory: ClientFactory) -> None: + with client_factory(ports=NS_PORTS, auth_credentials=ADMIN_KEY) as client: + _skip_if_unsupported(client) + + client.namespaces.create(name="deletens") + client.namespaces.delete(name="deletens") + + fetched = client.namespaces.get(name="deletens") + assert fetched is None + + +def test_create_namespaced_user(client_factory: ClientFactory) -> None: + with client_factory(ports=NS_PORTS, auth_credentials=ADMIN_KEY) as client: + _skip_if_unsupported(client) + + client.namespaces.create(name="usernstest") + # On namespace-enabled clusters the server qualifies the userId as + # "namespace:user_id" in storage. Operators must use that qualified form + # when calling get/delete. + qualified_id = "usernstest:nsuser1" + try: + api_key = client.users.db.create(user_id="nsuser1", namespace="usernstest") + assert isinstance(api_key, str) + assert len(api_key) > 0 + + user = client.users.db.get(user_id=qualified_id) + assert user is not None + assert user.user_id == qualified_id + assert user.namespace == "usernstest" + finally: + client.users.db.delete(user_id=qualified_id) + client.namespaces.delete(name="usernstest") + + +def test_namespace_permission_manage(client_factory: ClientFactory) -> None: + with client_factory(ports=NS_PORTS, auth_credentials=ADMIN_KEY) as client: + _skip_if_unsupported(client) + + role = client.roles.create( + role_name="ns-manager", + permissions=Permissions.namespaces(namespace="*", manage=True), + ) + assert any(p.namespace == "*" for p in role.namespaces_permissions) + + client.roles.delete("ns-manager") + + +def test_namespace_permission_multiple_namespaces( + client_factory: ClientFactory, +) -> None: + with client_factory(ports=NS_PORTS, auth_credentials=ADMIN_KEY) as client: + _skip_if_unsupported(client) + + role = client.roles.create( + role_name="ns-multi", + permissions=Permissions.namespaces(namespace=["ns1", "ns2"], manage=True), + ) + ns_names = {p.namespace for p in role.namespaces_permissions} + assert "ns1" in ns_names + assert "ns2" in ns_names + + client.roles.delete("ns-multi") diff --git a/test/test_namespaces.py b/test/test_namespaces.py new file mode 100644 index 000000000..63e8d6114 --- /dev/null +++ b/test/test_namespaces.py @@ -0,0 +1,130 @@ +from weaviate.classes.rbac import Actions, Permissions +from weaviate.rbac.models import ( + NamespacesAction, + NamespacesPermissionOutput, + Role, + WeaviateRole, + _NamespacesPermission, +) +from weaviate.users.users import UserDB, UserTypes + + +# --- Permissions.namespaces() factory --- + + +def test_namespaces_permission_no_actions() -> None: + permissions = Permissions.namespaces(namespace="ns1") + assert len(permissions) == 0 + + +def test_namespaces_permission_manage() -> None: + permissions = Permissions.namespaces(namespace="ns1", manage=True) + assert len(permissions) == 1 + assert NamespacesAction.MANAGE in permissions[0].actions + + +def test_namespaces_permission_wildcard() -> None: + permissions = Permissions.namespaces(namespace="*", manage=True) + assert len(permissions) == 1 + assert isinstance(permissions[0], _NamespacesPermission) + assert permissions[0].namespace == "*" + + +def test_namespaces_permission_multiple_namespaces() -> None: + permissions = Permissions.namespaces(namespace=["ns1", "ns2"], manage=True) + assert len(permissions) == 2 + ns_names = {p.namespace for p in permissions if isinstance(p, _NamespacesPermission)} + assert ns_names == {"ns1", "ns2"} + + +# --- _to_weaviate() serialization --- + + +def test_namespaces_permission_to_weaviate() -> None: + permissions = Permissions.namespaces(namespace="myns", manage=True) + assert isinstance(permissions[0], _NamespacesPermission) + wv = permissions[0]._to_weaviate() + assert len(wv) == 1 + assert wv[0]["action"] == "manage_namespaces" + assert wv[0].get("namespaces") == {"namespace": "myns"} + + +def test_namespaces_permission_to_weaviate_wildcard() -> None: + permissions = Permissions.namespaces(namespace="*", manage=True) + assert isinstance(permissions[0], _NamespacesPermission) + wv = permissions[0]._to_weaviate() + assert wv[0].get("namespaces") == {"namespace": "*"} + + +# --- Role._from_weaviate_role() parsing --- + + +def test_role_from_weaviate_role_parses_namespace_permission() -> None: + weaviate_role: WeaviateRole = { + "name": "ns-role", + "permissions": [ + {"action": "manage_namespaces", "namespaces": {"namespace": "customer1"}}, + ], + } + role = Role._from_weaviate_role(weaviate_role) + assert role.name == "ns-role" + assert len(role.namespaces_permissions) == 1 + perm = role.namespaces_permissions[0] + assert perm.namespace == "customer1" + assert NamespacesAction.MANAGE in perm.actions + + +def test_role_from_weaviate_role_joins_namespace_permissions() -> None: + weaviate_role: WeaviateRole = { + "name": "ns-role", + "permissions": [ + {"action": "manage_namespaces", "namespaces": {"namespace": "ns1"}}, + {"action": "manage_namespaces", "namespaces": {"namespace": "ns1"}}, + ], + } + role = Role._from_weaviate_role(weaviate_role) + # Duplicate permissions on same resource should be collapsed + assert len(role.namespaces_permissions) == 1 + + +def test_role_from_weaviate_role_namespace_in_permissions_list() -> None: + weaviate_role: WeaviateRole = { + "name": "ns-role", + "permissions": [ + {"action": "manage_namespaces", "namespaces": {"namespace": "*"}}, + ], + } + role = Role._from_weaviate_role(weaviate_role) + assert isinstance(role.permissions[0], NamespacesPermissionOutput) + + +# --- Actions enum --- + + +def test_actions_namespaces_enum_accessible() -> None: + assert Actions.Namespaces is NamespacesAction + assert Actions.Namespaces.MANAGE.value == "manage_namespaces" + + +# --- UserDB.namespace field --- + + +def test_userdb_namespace_field_defaults_to_none() -> None: + user = UserDB( + user_id="u1", + role_names=[], + user_type=UserTypes.DB_DYNAMIC, + active=True, + ) + assert user.namespace is None + + +def test_userdb_namespace_field_set() -> None: + user = UserDB( + user_id="ns1:u1", + role_names=[], + user_type=UserTypes.DB_DYNAMIC, + active=True, + namespace="ns1", + ) + assert user.namespace == "ns1" diff --git a/weaviate/client.py b/weaviate/client.py index fe5ad17fe..dc98e9850 100644 --- a/weaviate/client.py +++ b/weaviate/client.py @@ -22,6 +22,7 @@ from .embedded import EmbeddedOptions from .export import _Export, _ExportAsync from .groups import _Groups, _GroupsAsync +from .namespaces import _Namespaces, _NamespacesAsync from .rbac import _Roles, _RolesAsync from .tokenization import _Tokenization, _TokenizationAsync from .types import NUMBER @@ -52,6 +53,8 @@ class WeaviateAsyncClient(_WeaviateClientExecutor[ConnectionAsync]): debug (_DebugAsync): Debug object instance connected to the same Weaviate instance as the Client. This namespace contains functionality used to debug Weaviate clusters. As such, it is deemed experimental and is subject to change. We can make no guarantees about the stability of this namespace nor the potential for future breaking changes. Use at your own risk. + namespaces (_NamespacesAsync): Namespaces object instance connected to the same Weaviate instance as the Client. + This namespace contains all functionality to manage Weaviate namespaces. roles (_RolesAsync): Roles object instance connected to the same Weaviate instance as the Client. This namespace contains all functionality to manage Weaviate's RBAC functionality. users (_UsersAsync): Users object instance connected to the same Weaviate instance as the Client. @@ -84,6 +87,7 @@ def __init__( self.collections = _CollectionsAsync(self._connection) self.debug = _DebugAsync(self._connection) self.groups = _GroupsAsync(self._connection) + self.namespaces = _NamespacesAsync(self._connection) self.roles = _RolesAsync(self._connection) self.tokenization = _TokenizationAsync(self._connection) self.users = _UsersAsync(self._connection) @@ -120,6 +124,8 @@ class WeaviateClient(_WeaviateClientExecutor[ConnectionSync]): debug (_Debug): Debug object instance connected to the same Weaviate instance as the Client. This namespace contains functionality used to debug Weaviate clusters. As such, it is deemed experimental and is subject to change. We can make no guarantees about the stability of this namespace nor the potential for future breaking changes. Use at your own risk. + namespaces (_Namespaces): Namespaces object instance connected to the same Weaviate instance as the Client. + This namespace contains all functionality to manage Weaviate namespaces. roles (_Roles): Roles object instance connected to the same Weaviate instance as the Client. This namespace contains all functionality to manage Weaviate's RBAC functionality. users (_Users): Users object instance connected to the same Weaviate instance as the Client. @@ -161,6 +167,7 @@ def __init__( self.collections = collections self.debug = _Debug(self._connection) self.groups = _Groups(self._connection) + self.namespaces = _Namespaces(self._connection) self.roles = _Roles(self._connection) self.tokenization = _Tokenization(self._connection) self.users = _Users(self._connection) diff --git a/weaviate/client.pyi b/weaviate/client.pyi index d7b99eba6..587850b6b 100644 --- a/weaviate/client.pyi +++ b/weaviate/client.pyi @@ -13,6 +13,8 @@ from weaviate.collections.collections.sync import _Collections from weaviate.connect.v4 import ConnectionAsync, ConnectionSync from weaviate.groups.async_ import _GroupsAsync from weaviate.groups.sync import _Groups +from weaviate.namespaces.async_ import _NamespacesAsync +from weaviate.namespaces.sync import _Namespaces from weaviate.users.async_ import _UsersAsync from weaviate.users.sync import _Users @@ -37,6 +39,7 @@ class WeaviateAsyncClient(_WeaviateClientExecutor[ConnectionAsync]): cluster: _ClusterAsync debug: _DebugAsync groups: _GroupsAsync + namespaces: _NamespacesAsync roles: _RolesAsync tokenization: _TokenizationAsync users: _UsersAsync @@ -62,6 +65,7 @@ class WeaviateClient(_WeaviateClientExecutor[ConnectionSync]): cluster: _Cluster debug: _Debug groups: _Groups + namespaces: _Namespaces roles: _Roles tokenization: _Tokenization users: _Users diff --git a/weaviate/namespaces/__init__.py b/weaviate/namespaces/__init__.py new file mode 100644 index 000000000..586a64008 --- /dev/null +++ b/weaviate/namespaces/__init__.py @@ -0,0 +1,4 @@ +from .async_ import _NamespacesAsync +from .sync import _Namespaces + +__all__ = ["_Namespaces", "_NamespacesAsync"] diff --git a/weaviate/namespaces/async_.py b/weaviate/namespaces/async_.py new file mode 100644 index 000000000..738589ced --- /dev/null +++ b/weaviate/namespaces/async_.py @@ -0,0 +1,8 @@ +from weaviate.connect import executor +from weaviate.connect.v4 import ConnectionAsync +from weaviate.namespaces.base import _NamespacesExecutor + + +@executor.wrap("async") +class _NamespacesAsync(_NamespacesExecutor[ConnectionAsync]): + pass diff --git a/weaviate/namespaces/async_.pyi b/weaviate/namespaces/async_.pyi new file mode 100644 index 000000000..8a664135d --- /dev/null +++ b/weaviate/namespaces/async_.pyi @@ -0,0 +1,11 @@ +from typing import List, Optional + +from weaviate.connect.v4 import ConnectionAsync +from weaviate.namespaces.base import _NamespacesExecutor +from weaviate.namespaces.models import Namespace + +class _NamespacesAsync(_NamespacesExecutor[ConnectionAsync]): + async def create(self, *, name: str) -> Namespace: ... + async def get(self, *, name: str) -> Optional[Namespace]: ... + async def list_all(self) -> List[Namespace]: ... + async def delete(self, *, name: str) -> None: ... diff --git a/weaviate/namespaces/base.py b/weaviate/namespaces/base.py new file mode 100644 index 000000000..850529020 --- /dev/null +++ b/weaviate/namespaces/base.py @@ -0,0 +1,102 @@ +from typing import Generic, List, Optional + +from httpx import Response + +from weaviate.connect import executor +from weaviate.connect.v4 import ConnectionType, _ExpectedStatusCodes +from weaviate.namespaces.models import Namespace +from weaviate.util import _decode_json_response_dict + + +class _NamespacesExecutor(Generic[ConnectionType]): + def __init__(self, connection: ConnectionType): + self._connection = connection + + def create(self, *, name: str) -> executor.Result[Namespace]: + """Create a new namespace. + + Args: + name: The namespace name. Must be 3-36 lowercase alphanumeric characters starting with a letter. + + Returns: + The created Namespace. + """ + self._connection._weaviate_version.check_is_at_least_1_38_0("namespaces") + + def resp(res: Response) -> Namespace: + parsed = _decode_json_response_dict(res, "Create namespace") + assert parsed is not None + return Namespace(name=parsed["name"]) + + return executor.execute( + response_callback=resp, + method=self._connection.post, + path=f"/namespaces/{name}", + weaviate_object={}, + error_msg=f"Could not create namespace '{name}'", + status_codes=_ExpectedStatusCodes(ok_in=[201], error="Create namespace"), + ) + + def get(self, *, name: str) -> executor.Result[Optional[Namespace]]: + """Get a namespace by name. + + Args: + name: The name of the namespace to retrieve. + + Returns: + The Namespace, or None if it does not exist. + """ + self._connection._weaviate_version.check_is_at_least_1_38_0("namespaces") + + def resp(res: Response) -> Optional[Namespace]: + if res.status_code == 404: + return None + parsed = _decode_json_response_dict(res, "Get namespace") + assert parsed is not None + return Namespace(name=parsed["name"]) + + return executor.execute( + response_callback=resp, + method=self._connection.get, + path=f"/namespaces/{name}", + error_msg=f"Could not get namespace '{name}'", + status_codes=_ExpectedStatusCodes(ok_in=[200, 404], error="Get namespace"), + ) + + def list_all(self) -> executor.Result[List[Namespace]]: + """List all namespaces visible to the current principal. + + Returns: + A list of Namespace objects. + """ + self._connection._weaviate_version.check_is_at_least_1_38_0("namespaces") + + def resp(res: Response) -> List[Namespace]: + return [Namespace(name=ns["name"]) for ns in (res.json() or [])] + + return executor.execute( + response_callback=resp, + method=self._connection.get, + path="/namespaces", + error_msg="Could not list namespaces", + status_codes=_ExpectedStatusCodes(ok_in=[200], error="List namespaces"), + ) + + def delete(self, *, name: str) -> executor.Result[None]: + """Delete a namespace. + + Args: + name: The name of the namespace to delete. + """ + self._connection._weaviate_version.check_is_at_least_1_38_0("namespaces") + + def resp(res: Response) -> None: + return None + + return executor.execute( + response_callback=resp, + method=self._connection.delete, + path=f"/namespaces/{name}", + error_msg=f"Could not delete namespace '{name}'", + status_codes=_ExpectedStatusCodes(ok_in=[204], error="Delete namespace"), + ) diff --git a/weaviate/namespaces/models.py b/weaviate/namespaces/models.py new file mode 100644 index 000000000..061436ecd --- /dev/null +++ b/weaviate/namespaces/models.py @@ -0,0 +1,6 @@ +from dataclasses import dataclass + + +@dataclass +class Namespace: + name: str diff --git a/weaviate/namespaces/sync.py b/weaviate/namespaces/sync.py new file mode 100644 index 000000000..de0393c37 --- /dev/null +++ b/weaviate/namespaces/sync.py @@ -0,0 +1,8 @@ +from weaviate.connect import executor +from weaviate.connect.v4 import ConnectionSync +from weaviate.namespaces.base import _NamespacesExecutor + + +@executor.wrap("sync") +class _Namespaces(_NamespacesExecutor[ConnectionSync]): + pass diff --git a/weaviate/namespaces/sync.pyi b/weaviate/namespaces/sync.pyi new file mode 100644 index 000000000..46e4534b7 --- /dev/null +++ b/weaviate/namespaces/sync.pyi @@ -0,0 +1,11 @@ +from typing import List, Optional + +from weaviate.connect.v4 import ConnectionSync +from weaviate.namespaces.base import _NamespacesExecutor +from weaviate.namespaces.models import Namespace + +class _Namespaces(_NamespacesExecutor[ConnectionSync]): + def create(self, *, name: str) -> Namespace: ... + def get(self, *, name: str) -> Optional[Namespace]: ... + def list_all(self) -> List[Namespace]: ... + def delete(self, *, name: str) -> None: ... diff --git a/weaviate/outputs/__init__.py b/weaviate/outputs/__init__.py index 75cb031e0..f259a46ea 100644 --- a/weaviate/outputs/__init__.py +++ b/weaviate/outputs/__init__.py @@ -6,6 +6,7 @@ config, data, export, + namespaces, query, replication, tenants, @@ -21,6 +22,7 @@ "config", "data", "export", + "namespaces", "query", "replication", "tenants", diff --git a/weaviate/outputs/namespaces.py b/weaviate/outputs/namespaces.py new file mode 100644 index 000000000..5ba33f912 --- /dev/null +++ b/weaviate/outputs/namespaces.py @@ -0,0 +1,3 @@ +from weaviate.namespaces.models import Namespace + +__all__ = ["Namespace"] diff --git a/weaviate/rbac/models.py b/weaviate/rbac/models.py index 8e0989542..33eaeab9a 100644 --- a/weaviate/rbac/models.py +++ b/weaviate/rbac/models.py @@ -103,6 +103,10 @@ class PermissionsAlias(TypedDict): collection: str +class PermissionsNamespaces(TypedDict): + namespace: str + + # action is always present in WeaviatePermission class WeaviatePermissionRequired(TypedDict): action: str @@ -121,6 +125,7 @@ class WeaviatePermission( users: Optional[PermissionsUsers] aliases: Optional[PermissionsAlias] groups: Optional[PermissionsGroups] + namespaces: Optional[PermissionsNamespaces] class WeaviateRole(TypedDict): @@ -143,6 +148,7 @@ class WeaviateDBUserRoleNames(TypedDict): createdAt: NotRequired[str] lastUsedAt: NotRequired[str] apiKeyFirstLetters: NotRequired[str] + namespace: NotRequired[str] class _Action: @@ -273,6 +279,14 @@ def values() -> List[str]: return [action.value for action in ReplicateAction] +class NamespacesAction(str, _Action, Enum): + MANAGE = "manage_namespaces" + + @staticmethod + def values() -> List[str]: + return [action.value for action in NamespacesAction] + + ActionT = TypeVar("ActionT", bound=Enum) @@ -397,7 +411,10 @@ class _GroupsPermission(_Permission[GroupAction]): def _to_weaviate(self) -> List[WeaviatePermission]: return [ - {"action": action, "groups": {"group": self.group, "groupType": self.group_type}} + { + "action": action, + "groups": {"group": self.group, "groupType": self.group_type}, + } for action in self.actions ] @@ -437,6 +454,19 @@ def _to_weaviate(self) -> List[WeaviatePermission]: ] +class _NamespacesPermission(_Permission[NamespacesAction]): + namespace: str + + def _to_weaviate(self) -> List[WeaviatePermission]: + return [ + { + "action": action, + "namespaces": {"namespace": self.namespace}, + } + for action in self.actions + ] + + class _DataPermission(_Permission[DataAction]): collection: str tenant: str @@ -502,11 +532,16 @@ class TenantsPermissionOutput(_TenantsPermission): pass +class NamespacesPermissionOutput(_NamespacesPermission): + pass + + PermissionsOutputType = Union[ AliasPermissionOutput, ClusterPermissionOutput, CollectionsPermissionOutput, DataPermissionOutput, + NamespacesPermissionOutput, RolesPermissionOutput, UsersPermissionOutput, BackupsPermissionOutput, @@ -529,6 +564,7 @@ class Role(RoleBase): cluster_permissions: List[ClusterPermissionOutput] collections_permissions: List[CollectionsPermissionOutput] data_permissions: List[DataPermissionOutput] + namespaces_permissions: List[NamespacesPermissionOutput] roles_permissions: List[RolesPermissionOutput] users_permissions: List[UsersPermissionOutput] backups_permissions: List[BackupsPermissionOutput] @@ -545,6 +581,7 @@ def permissions(self) -> List[PermissionsOutputType]: permissions.extend(self.cluster_permissions) permissions.extend(self.collections_permissions) permissions.extend(self.data_permissions) + permissions.extend(self.namespaces_permissions) permissions.extend(self.roles_permissions) permissions.extend(self.users_permissions) permissions.extend(self.backups_permissions) @@ -561,6 +598,7 @@ def _from_weaviate_role(cls, role: WeaviateRole) -> "Role": cluster_permissions: List[ClusterPermissionOutput] = [] users_permissions: List[UsersPermissionOutput] = [] collections_permissions: List[CollectionsPermissionOutput] = [] + namespaces_permissions: List[NamespacesPermissionOutput] = [] roles_permissions: List[RolesPermissionOutput] = [] data_permissions: List[DataPermissionOutput] = [] backups_permissions: List[BackupsPermissionOutput] = [] @@ -677,6 +715,15 @@ def _from_weaviate_role(cls, role: WeaviateRole) -> "Role": actions={GroupAction(permission["action"])}, ) ) + elif permission["action"] in NamespacesAction.values(): + namespaces = permission.get("namespaces") + if namespaces is not None: + namespaces_permissions.append( + NamespacesPermissionOutput( + namespace=namespaces["namespace"], + actions={NamespacesAction(permission["action"])}, + ) + ) else: _Warnings.unknown_permission_encountered(permission) @@ -686,6 +733,7 @@ def _from_weaviate_role(cls, role: WeaviateRole) -> "Role": cluster_permissions=_join_permissions(cluster_permissions), users_permissions=_join_permissions(users_permissions), collections_permissions=_join_permissions(collections_permissions), + namespaces_permissions=_join_permissions(namespaces_permissions), roles_permissions=_join_permissions(roles_permissions), groups_permissions=_join_permissions(groups_permissions), data_permissions=_join_permissions(data_permissions), @@ -739,6 +787,7 @@ class Actions: Alias = AliasAction Data = DataAction Collections = CollectionsAction + Namespaces = NamespacesAction Roles = RolesAction Cluster = ClusterAction Nodes = NodesAction @@ -1074,3 +1123,20 @@ def cluster(*, read: bool = False) -> PermissionsCreateType: if read: return [_ClusterPermission(actions={ClusterAction.READ})] return [] + + @staticmethod + def namespaces( + *, + namespace: Union[str, Sequence[str]], + manage: bool = False, + ) -> PermissionsCreateType: + permissions: List[_Permission] = [] + if isinstance(namespace, str): + namespace = [namespace] + for ns in namespace: + permission = _NamespacesPermission(namespace=ns, actions=set()) + if manage: + permission.actions.add(NamespacesAction.MANAGE) + if len(permission.actions) > 0: + permissions.append(permission) + return permissions diff --git a/weaviate/users/async_.pyi b/weaviate/users/async_.pyi index 5770a5063..979f789ec 100644 --- a/weaviate/users/async_.pyi +++ b/weaviate/users/async_.pyi @@ -52,7 +52,7 @@ class _UsersDBAsync(_UsersDBExecutor[ConnectionAsync]): ) -> Union[Dict[str, Role], Dict[str, RoleBase]]: ... async def assign_roles(self, *, user_id: str, role_names: Union[str, List[str]]) -> None: ... async def revoke_roles(self, *, user_id: str, role_names: Union[str, List[str]]) -> None: ... - async def create(self, *, user_id: str) -> str: ... + async def create(self, *, user_id: str, namespace: Optional[str] = None) -> str: ... async def delete(self, *, user_id: str) -> bool: ... async def rotate_key(self, *, user_id: str) -> str: ... async def activate(self, *, user_id: str) -> bool: ... diff --git a/weaviate/users/base.py b/weaviate/users/base.py index 21692e757..4978205ee 100644 --- a/weaviate/users/base.py +++ b/weaviate/users/base.py @@ -360,11 +360,12 @@ def revoke_roles( USER_TYPE_DB, ) - def create(self, *, user_id: str) -> executor.Result[str]: + def create(self, *, user_id: str, namespace: Optional[str] = None) -> executor.Result[str]: """Create a new db user and return its API key. Args: user_id: The id of the new user. + namespace: The namespace to bind the user to. Required on namespace-enabled clusters. Returns: The API key of the newly created user. This key can not be retrieved later. @@ -375,11 +376,15 @@ def resp(res: Response) -> str: assert resp is not None return str(resp["apikey"]) + body: Dict[str, Any] = {} + if namespace is not None: + body["namespace"] = namespace + return executor.execute( response_callback=resp, method=self._connection.post, path=f"/users/db/{user_id}", - weaviate_object={}, + weaviate_object=body, error_msg=f"Could not create user '{user_id}'", status_codes=_ExpectedStatusCodes(ok_in=[201], error="Create user"), ) @@ -490,6 +495,7 @@ def resp(res: Response) -> Optional[UserDB]: ), last_used_time=_parse_last_used_at(parsed.get("lastUsedAt")), api_key_first_letters=parsed.get("apiKeyFirstLetters"), + namespace=parsed.get("namespace"), ) return executor.execute( @@ -524,6 +530,7 @@ def resp(res: Response) -> List[UserDB]: ), last_used_time=_parse_last_used_at(user.get("lastUsedAt")), api_key_first_letters=user.get("apiKeyFirstLetters"), + namespace=user.get("namespace"), ) for user in cast(List[WeaviateDBUserRoleNames], parsed) ] diff --git a/weaviate/users/sync.pyi b/weaviate/users/sync.pyi index 7e82eee19..251c9d97a 100644 --- a/weaviate/users/sync.pyi +++ b/weaviate/users/sync.pyi @@ -52,7 +52,7 @@ class _UsersDB(_UsersDBExecutor[ConnectionSync]): ) -> Union[Dict[str, Role], Dict[str, RoleBase]]: ... def assign_roles(self, *, user_id: str, role_names: Union[str, List[str]]) -> None: ... def revoke_roles(self, *, user_id: str, role_names: Union[str, List[str]]) -> None: ... - def create(self, *, user_id: str) -> str: ... + def create(self, *, user_id: str, namespace: Optional[str] = None) -> str: ... def delete(self, *, user_id: str) -> bool: ... def rotate_key(self, *, user_id: str) -> str: ... def activate(self, *, user_id: str) -> bool: ... diff --git a/weaviate/users/users.py b/weaviate/users/users.py index 7c213e141..eb53f9c2d 100644 --- a/weaviate/users/users.py +++ b/weaviate/users/users.py @@ -33,6 +33,7 @@ class UserDB(UserBase): created_at: Optional[datetime] = field(default=None) last_used_time: Optional[datetime] = field(default=None) api_key_first_letters: Optional[str] = field(default=None) + namespace: Optional[str] = field(default=None) @dataclass diff --git a/weaviate/util.py b/weaviate/util.py index 7ee9e5566..e0c104731 100644 --- a/weaviate/util.py +++ b/weaviate/util.py @@ -615,6 +615,10 @@ def check_is_at_least_1_32_0(self, feature: str) -> None: if not self >= _ServerVersion(1, 32, 0): raise WeaviateUnsupportedFeatureError(feature, str(self), "1.32.0") + def check_is_at_least_1_38_0(self, feature: str) -> None: + if not self >= _ServerVersion(1, 38, 0): + raise WeaviateUnsupportedFeatureError(feature, str(self), "1.38.0") + @property def supports_tenants_get_grpc(self) -> bool: return self >= _ServerVersion(1, 25, 0) From faa21f1ab317b3529165464cc35d078ca961bd58 Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Tue, 5 May 2026 16:28:44 +0200 Subject: [PATCH 02/14] Address review feedback - Add namespaces_permissions=[] to all Role(...) constructions in integration/test_rbac.py to match the new required dataclass field - Wait for namespaces port (8094) in ci/compose.sh - Use _decode_json_response_list helper in namespaces.list_all - Round-trip via roles.get(...) in namespace permission integration tests - Use pass instead of return None in delete callback Co-Authored-By: Claude Sonnet 4.6 --- ci/compose.sh | 2 +- integration/test_namespaces.py | 26 ++++++++++++++++---------- integration/test_rbac.py | 18 ++++++++++++++++++ weaviate/namespaces/base.py | 7 ++++--- 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/ci/compose.sh b/ci/compose.sh index f9b02fef7..eb2f635c5 100644 --- a/ci/compose.sh +++ b/ci/compose.sh @@ -21,7 +21,7 @@ function compose_down_all { } function all_weaviate_ports { - echo "8090 8093 8087 8088 8089 8086 8082 8083 8075 8092 8085 8080" # in alphabetic order of appearance in docker-compose files + echo "8090 8093 8087 8088 8089 8086 8082 8083 8075 8092 8094 8085 8080" # in alphabetic order of appearance in docker-compose files } function wait(){ diff --git a/integration/test_namespaces.py b/integration/test_namespaces.py index b5cecd12b..4277ce045 100644 --- a/integration/test_namespaces.py +++ b/integration/test_namespaces.py @@ -94,13 +94,16 @@ def test_namespace_permission_manage(client_factory: ClientFactory) -> None: with client_factory(ports=NS_PORTS, auth_credentials=ADMIN_KEY) as client: _skip_if_unsupported(client) - role = client.roles.create( + client.roles.create( role_name="ns-manager", permissions=Permissions.namespaces(namespace="*", manage=True), ) - assert any(p.namespace == "*" for p in role.namespaces_permissions) - - client.roles.delete("ns-manager") + try: + fetched = client.roles.get(role_name="ns-manager") + assert fetched is not None + assert any(p.namespace == "*" for p in fetched.namespaces_permissions) + finally: + client.roles.delete("ns-manager") def test_namespace_permission_multiple_namespaces( @@ -109,12 +112,15 @@ def test_namespace_permission_multiple_namespaces( with client_factory(ports=NS_PORTS, auth_credentials=ADMIN_KEY) as client: _skip_if_unsupported(client) - role = client.roles.create( + client.roles.create( role_name="ns-multi", permissions=Permissions.namespaces(namespace=["ns1", "ns2"], manage=True), ) - ns_names = {p.namespace for p in role.namespaces_permissions} - assert "ns1" in ns_names - assert "ns2" in ns_names - - client.roles.delete("ns-multi") + try: + fetched = client.roles.get(role_name="ns-multi") + assert fetched is not None + ns_names = {p.namespace for p in fetched.namespaces_permissions} + assert "ns1" in ns_names + assert "ns2" in ns_names + finally: + client.roles.delete("ns-multi") diff --git a/integration/test_rbac.py b/integration/test_rbac.py index 0f8657a2d..7578d7fe8 100644 --- a/integration/test_rbac.py +++ b/integration/test_rbac.py @@ -50,6 +50,7 @@ tenants_permissions=[], replicate_permissions=[], groups_permissions=[], + namespaces_permissions=[], ), None, ), @@ -69,6 +70,7 @@ tenants_permissions=[], replicate_permissions=[], groups_permissions=[], + namespaces_permissions=[], ), None, ), @@ -92,6 +94,7 @@ tenants_permissions=[], replicate_permissions=[], groups_permissions=[], + namespaces_permissions=[], ), None, ), @@ -113,6 +116,7 @@ tenants_permissions=[], replicate_permissions=[], groups_permissions=[], + namespaces_permissions=[], ), None, ), @@ -147,6 +151,7 @@ tenants_permissions=[], replicate_permissions=[], groups_permissions=[], + namespaces_permissions=[], ), None, ), @@ -170,6 +175,7 @@ tenants_permissions=[], replicate_permissions=[], groups_permissions=[], + namespaces_permissions=[], ), None, ), @@ -193,6 +199,7 @@ tenants_permissions=[], replicate_permissions=[], groups_permissions=[], + namespaces_permissions=[], ), None, ), @@ -216,6 +223,7 @@ tenants_permissions=[], replicate_permissions=[], groups_permissions=[], + namespaces_permissions=[], ), None, ), @@ -241,6 +249,7 @@ ], replicate_permissions=[], groups_permissions=[], + namespaces_permissions=[], ), None, ), @@ -283,6 +292,7 @@ ], replicate_permissions=[], groups_permissions=[], + namespaces_permissions=[], ), None, ), @@ -306,6 +316,7 @@ tenants_permissions=[], replicate_permissions=[], groups_permissions=[], + namespaces_permissions=[], ), None, ), @@ -348,6 +359,7 @@ ), ], groups_permissions=[], + namespaces_permissions=[], ), 32, ), @@ -373,6 +385,7 @@ tenants_permissions=[], replicate_permissions=[], groups_permissions=[], + namespaces_permissions=[], ), 32, # Minimum version for alias permissions ), @@ -398,6 +411,7 @@ tenants_permissions=[], replicate_permissions=[], groups_permissions=[], + namespaces_permissions=[], ), 32, # Minimum version for alias permissions ), @@ -423,6 +437,7 @@ tenants_permissions=[], replicate_permissions=[], groups_permissions=[], + namespaces_permissions=[], ), 32, # Minimum version for alias permissions ), @@ -446,6 +461,7 @@ tenants_permissions=[], replicate_permissions=[], groups_permissions=[], + namespaces_permissions=[], ), 37, # Minimum version for MCP permissions ), @@ -465,6 +481,7 @@ tenants_permissions=[], replicate_permissions=[], groups_permissions=[], + namespaces_permissions=[], ), 37, # Minimum version for MCP permissions ), @@ -490,6 +507,7 @@ actions={Actions.Groups.READ}, ) ], + namespaces_permissions=[], ), 32, # Minimum version for group permissions ), diff --git a/weaviate/namespaces/base.py b/weaviate/namespaces/base.py index 850529020..4baf9d02e 100644 --- a/weaviate/namespaces/base.py +++ b/weaviate/namespaces/base.py @@ -5,7 +5,7 @@ from weaviate.connect import executor from weaviate.connect.v4 import ConnectionType, _ExpectedStatusCodes from weaviate.namespaces.models import Namespace -from weaviate.util import _decode_json_response_dict +from weaviate.util import _decode_json_response_dict, _decode_json_response_list class _NamespacesExecutor(Generic[ConnectionType]): @@ -72,7 +72,8 @@ def list_all(self) -> executor.Result[List[Namespace]]: self._connection._weaviate_version.check_is_at_least_1_38_0("namespaces") def resp(res: Response) -> List[Namespace]: - return [Namespace(name=ns["name"]) for ns in (res.json() or [])] + parsed = _decode_json_response_list(res, "List namespaces") + return [Namespace(name=ns["name"]) for ns in (parsed or [])] return executor.execute( response_callback=resp, @@ -91,7 +92,7 @@ def delete(self, *, name: str) -> executor.Result[None]: self._connection._weaviate_version.check_is_at_least_1_38_0("namespaces") def resp(res: Response) -> None: - return None + pass return executor.execute( response_callback=resp, From 1e3d0d1e12b786062d18a5292ee69abdf18ea873 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 15:39:04 +0000 Subject: [PATCH 03/14] fix: export NamespacesPermissionOutput from weaviate/outputs/rbac.py Agent-Logs-Url: https://github.com/weaviate/weaviate-python-client/sessions/35eb3c0e-1e3a-4ae7-bde7-6c6c481dbe5a Co-authored-by: jfrancoa <23482278+jfrancoa@users.noreply.github.com> --- weaviate/outputs/rbac.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/weaviate/outputs/rbac.py b/weaviate/outputs/rbac.py index b1e29ebf6..fcb153470 100644 --- a/weaviate/outputs/rbac.py +++ b/weaviate/outputs/rbac.py @@ -6,6 +6,7 @@ DataPermissionOutput, GroupAssignment, GroupsPermissionOutput, + NamespacesPermissionOutput, NodesPermissionOutput, PermissionsOutputType, ReplicatePermissionOutput, @@ -22,6 +23,7 @@ "ClusterPermissionOutput", "CollectionsPermissionOutput", "DataPermissionOutput", + "NamespacesPermissionOutput", "NodesPermissionOutput", "RolesPermissionOutput", "RoleScope", From e4caee703925b9ba9ff1ea79c09ac323fe111cc8 Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Wed, 6 May 2026 22:20:02 +0200 Subject: [PATCH 04/14] fix: handle namespaced collection names in _capitalize_first_letter When a global user (operator) passes a namespaced collection name of the form "namespace:CollectionName" (required on namespace-enabled clusters), only the collection portion after the colon should be capitalized. The namespace prefix must stay lowercase as it follows [a-z][a-z0-9]*. This single-point fix covers all 30+ call sites that use _capitalize_first_letter, including collections.delete/get/exists, batch operations, RBAC permissions, backup/export, and filters. Co-Authored-By: Claude Sonnet 4.6 --- test/test_util.py | 29 +++++++++++++++++++++++++++++ weaviate/util.py | 8 ++++++++ 2 files changed, 37 insertions(+) diff --git a/test/test_util.py b/test/test_util.py index 417cb77ef..b78c8e778 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -10,6 +10,7 @@ from weaviate.exceptions import SchemaValidationException from weaviate.util import ( MINIMUM_NO_WARNING_VERSION, + _capitalize_first_letter, _datetime_from_weaviate_str, _is_sub_schema, _sanitize_str, @@ -495,3 +496,31 @@ def test_is_weaviate_client_too_old(current_version: str, latest_version: str, t ) def test_sanitize_str(in_str: str, out_str: str) -> None: assert _sanitize_str(in_str) == f'"{out_str}"' + + +@pytest.mark.parametrize( + "input_str,expected", + [ + # Plain collection names + ("article", "Article"), + ("Article", "Article"), + ("myCollection", "MyCollection"), + ("MyCollection", "MyCollection"), + ("a", "A"), + ("", ""), + # Wildcard passthrough + ("*", "*"), + # Namespaced collection: namespace stays lowercase, collection is capitalized + ("mynamespace:article", "mynamespace:Article"), + ("mynamespace:Article", "mynamespace:Article"), + ("mynamespace:myCollection", "mynamespace:MyCollection"), + ("ns1:article", "ns1:Article"), + # Namespace is already lowercase — must not be touched + ("customer1:orders", "customer1:Orders"), + ("customer1:Orders", "customer1:Orders"), + # Wildcard collection under a namespace + ("mynamespace:*", "mynamespace:*"), + ], +) +def test_capitalize_first_letter(input_str: str, expected: str) -> None: + assert _capitalize_first_letter(input_str) == expected diff --git a/weaviate/util.py b/weaviate/util.py index e0c104731..e7a8d4629 100644 --- a/weaviate/util.py +++ b/weaviate/util.py @@ -434,6 +434,10 @@ def generate_uuid5(identifier: Any, namespace: Any = "") -> str: def _capitalize_first_letter(string: str) -> str: """Capitalize only the first letter of the `string`. + For namespaced collection names of the form ``namespace:CollectionName``, + only the collection portion (after the colon) is capitalized; the namespace + prefix is preserved as-is to keep it lowercase. + Args: string: The string to be capitalized. @@ -442,6 +446,10 @@ def _capitalize_first_letter(string: str) -> str: """ if len(string) == 0: return "" + # Namespaced collection: keep namespace lowercase, capitalize collection only. + if ":" in string: + namespace, collection = string.split(":", 1) + return namespace + ":" + _capitalize_first_letter(collection) if len(string) == 1: return string.capitalize() return string[0].capitalize() + string[1:] From 5f4420908eb24ed9542ee1922c56ef5e7fd2d56b Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Thu, 7 May 2026 15:39:23 +0200 Subject: [PATCH 05/14] test: add mock tests for namespaces module and namespaced user creation Adds 15 mock-based tests in mock_tests/test_namespaces.py covering the HTTP contract for the new module without requiring a Weaviate cluster. Brings weaviate/namespaces/base.py from 33% to 100% coverage and exercises the new code paths in weaviate/users/base.py (namespace request body and UserDB.namespace response field). Notable cases that catch real regressions: - 404 on get -> None (guards ok_in=[200, 404]) - list_all on null/empty body -> [] (guards the "or []" fallback) - Every public method asserts the 1.38.0 version guard (catches future contributors lowering the minimum or adding a method without the guard) - POST /v1/users/db/{id} body shape with and without namespace - UserDB.namespace populated from server response and defaulting to None Co-Authored-By: Claude Opus 4.7 --- mock_tests/test_namespaces.py | 332 ++++++++++++++++++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 mock_tests/test_namespaces.py diff --git a/mock_tests/test_namespaces.py b/mock_tests/test_namespaces.py new file mode 100644 index 000000000..5348a05dc --- /dev/null +++ b/mock_tests/test_namespaces.py @@ -0,0 +1,332 @@ +"""Mock tests for the namespaces module and namespace-aware DB user creation. + +These tests exercise the HTTP contract (URLs, request bodies, status codes, +response shape) without requiring a running Weaviate cluster. They are the +counterpart to the integration tests in ``integration/test_namespaces.py`` +and are responsible for catching: + +- Regressions in the URL paths the client sends to the server. +- Regressions in the JSON body shape we send (e.g. dropping ``namespace`` from + user-create payloads). +- Regressions in response parsing, including the boundary case where the + server returns ``null`` for an empty namespace list. +- A future contributor accidentally lowering the version requirement guard + on namespace endpoints (currently 1.38.0+). +""" + +import json +from typing import Generator, Tuple + +import grpc +import pytest +from pytest_httpserver import HTTPServer +from werkzeug.wrappers import Request, Response + +import weaviate +from mock_tests.conftest import MOCK_IP, MOCK_PORT, MOCK_PORT_GRPC +from weaviate.exceptions import WeaviateUnsupportedFeatureError +from weaviate.namespaces.models import Namespace +from weaviate.users.users import UserDB + +NAMESPACES_MIN_VERSION = "1.38.0" + + +def _setup_meta(server: HTTPServer, version: str) -> None: + """Wire up the minimum endpoints needed for ``connect_to_local`` to succeed. + + Uses ``skip_init_checks=True`` callers; only the version-loading path + (``/v1/meta``) is exercised. We deliberately avoid the shared + ``weaviate_mock`` fixture because it pins the version to 1.36, which would + short-circuit every namespace call with a version-guard error. + """ + server.expect_request("/v1/meta").respond_with_json({"version": version}) + server.expect_request("/v1/.well-known/openid-configuration").respond_with_response( + Response(json.dumps({}), status=404) + ) + server.expect_request("/v1/nodes").respond_with_json( + {"nodes": [{"gitHash": "ABC", "status": "HEALTHY"}]} + ) + + +@pytest.fixture(scope="function") +def ns_client( + ready_mock: HTTPServer, start_grpc_server: grpc.Server +) -> Generator[Tuple[weaviate.WeaviateClient, HTTPServer], None, None]: + """Client connected against a mock server reporting Weaviate ``1.38.0``.""" + _setup_meta(ready_mock, NAMESPACES_MIN_VERSION) + client = weaviate.connect_to_local( + port=MOCK_PORT, host=MOCK_IP, grpc_port=MOCK_PORT_GRPC, skip_init_checks=True + ) + yield client, ready_mock + client.close() + + +@pytest.fixture(scope="function") +def ns_client_old( + ready_mock: HTTPServer, start_grpc_server: grpc.Server +) -> Generator[weaviate.WeaviateClient, None, None]: + """Client connected against a server reporting an older version (1.37.99).""" + _setup_meta(ready_mock, "1.37.99") + client = weaviate.connect_to_local( + port=MOCK_PORT, host=MOCK_IP, grpc_port=MOCK_PORT_GRPC, skip_init_checks=True + ) + yield client + client.close() + + +# --------------------------------------------------------------------------- +# create +# --------------------------------------------------------------------------- + + +def test_namespaces_create_sends_post_and_parses_response( + ns_client: Tuple[weaviate.WeaviateClient, HTTPServer], +) -> None: + client, server = ns_client + server.expect_request("/v1/namespaces/myns", method="POST").respond_with_json( + {"name": "myns"}, status=201 + ) + + ns = client.namespaces.create(name="myns") + + assert isinstance(ns, Namespace) + assert ns.name == "myns" + server.check_assertions() + + +# --------------------------------------------------------------------------- +# get +# --------------------------------------------------------------------------- + + +def test_namespaces_get_returns_namespace_when_found( + ns_client: Tuple[weaviate.WeaviateClient, HTTPServer], +) -> None: + client, server = ns_client + server.expect_request("/v1/namespaces/customer1", method="GET").respond_with_json( + {"name": "customer1"}, status=200 + ) + + ns = client.namespaces.get(name="customer1") + + assert ns is not None + assert ns.name == "customer1" + server.check_assertions() + + +def test_namespaces_get_returns_none_on_404( + ns_client: Tuple[weaviate.WeaviateClient, HTTPServer], +) -> None: + """``get`` on a missing namespace must return None instead of raising. + + The 404 path is the documented ``does-not-exist`` signal. Without this test, + swapping ``ok_in=[200, 404]`` for ``[200]`` in ``namespaces/base.py`` would + silently start raising ``UnexpectedStatusCodeError``. + """ + client, server = ns_client + server.expect_request("/v1/namespaces/missing", method="GET").respond_with_response( + Response(status=404) + ) + + assert client.namespaces.get(name="missing") is None + server.check_assertions() + + +# --------------------------------------------------------------------------- +# list_all +# --------------------------------------------------------------------------- + + +def test_namespaces_list_all_parses_array( + ns_client: Tuple[weaviate.WeaviateClient, HTTPServer], +) -> None: + client, server = ns_client + server.expect_request("/v1/namespaces", method="GET").respond_with_json( + [{"name": "ns1"}, {"name": "ns2"}], status=200 + ) + + namespaces = client.namespaces.list_all() + + assert [ns.name for ns in namespaces] == ["ns1", "ns2"] + server.check_assertions() + + +def test_namespaces_list_all_handles_null_response( + ns_client: Tuple[weaviate.WeaviateClient, HTTPServer], +) -> None: + """``list_all`` must return an empty list when the server replies with null. + + The server may return ``null`` (or an empty body) when there are no + namespaces. The client should yield an empty list, not raise ``TypeError``. + This guards the ``or []`` fallback in ``namespaces.list_all``. + """ + client, server = ns_client + server.expect_request("/v1/namespaces", method="GET").respond_with_response( + Response(json.dumps(None), status=200, content_type="application/json") + ) + + assert client.namespaces.list_all() == [] + server.check_assertions() + + +def test_namespaces_list_all_handles_empty_array( + ns_client: Tuple[weaviate.WeaviateClient, HTTPServer], +) -> None: + client, server = ns_client + server.expect_request("/v1/namespaces", method="GET").respond_with_json([], status=200) + + assert client.namespaces.list_all() == [] + server.check_assertions() + + +# --------------------------------------------------------------------------- +# delete +# --------------------------------------------------------------------------- + + +def test_namespaces_delete_accepts_204( + ns_client: Tuple[weaviate.WeaviateClient, HTTPServer], +) -> None: + client, server = ns_client + server.expect_request("/v1/namespaces/myns", method="DELETE").respond_with_response( + Response(status=204) + ) + + # Must not raise; returns None. + assert client.namespaces.delete(name="myns") is None + server.check_assertions() + + +# --------------------------------------------------------------------------- +# Version guard — 1.38.0 minimum +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "method_call", + [ + lambda c: c.namespaces.create(name="x"), + lambda c: c.namespaces.get(name="x"), + lambda c: c.namespaces.list_all(), + lambda c: c.namespaces.delete(name="x"), + ], + ids=["create", "get", "list_all", "delete"], +) +def test_namespaces_methods_require_1_38( + ns_client_old: weaviate.WeaviateClient, method_call +) -> None: + """Every public namespace method must guard with ``check_is_at_least_1_38_0``. + + A new public method that forgets the guard would fail this test, alerting + the contributor before the request hits an older server and surfaces an + opaque 404/405. + """ + with pytest.raises(WeaviateUnsupportedFeatureError): + method_call(ns_client_old) + + +# --------------------------------------------------------------------------- +# Namespaced DB user creation +# --------------------------------------------------------------------------- + + +def test_users_db_create_includes_namespace_in_body( + ns_client: Tuple[weaviate.WeaviateClient, HTTPServer], +) -> None: + """When ``namespace`` is provided, the request body must carry it. + + Without this assertion, dropping ``body['namespace'] = namespace`` from + ``users/base.py`` would compile and pass type-checks but silently break + namespace-binding on multi-tenant clusters. + """ + client, server = ns_client + captured: dict = {} + + def handler(request: Request) -> Response: + captured["body"] = json.loads(request.get_data(as_text=True) or "{}") + return Response(json.dumps({"apikey": "secret-key"}), status=201) + + server.expect_request("/v1/users/db/alice", method="POST").respond_with_handler(handler) + + api_key = client.users.db.create(user_id="alice", namespace="customer1") + + assert api_key == "secret-key" + assert captured["body"] == {"namespace": "customer1"} + server.check_assertions() + + +def test_users_db_create_omits_namespace_when_not_provided( + ns_client: Tuple[weaviate.WeaviateClient, HTTPServer], +) -> None: + """The ``namespace`` key must not appear in the body when omitted by the caller. + + Otherwise we'd send ``"namespace": null`` and break older clusters that + don't recognize the field. + """ + client, server = ns_client + captured: dict = {} + + def handler(request: Request) -> Response: + captured["body"] = json.loads(request.get_data(as_text=True) or "{}") + return Response(json.dumps({"apikey": "k"}), status=201) + + server.expect_request("/v1/users/db/bob", method="POST").respond_with_handler(handler) + + client.users.db.create(user_id="bob") + + assert captured["body"] == {} + server.check_assertions() + + +def test_users_db_get_populates_namespace_field( + ns_client: Tuple[weaviate.WeaviateClient, HTTPServer], +) -> None: + """The ``namespace`` field on the server response must propagate to ``UserDB``. + + Callers rely on ``UserDB.namespace`` to introspect which namespace a user + belongs to. + """ + client, server = ns_client + server.expect_request("/v1/users/db/customer1:alice", method="GET").respond_with_json( + { + "userId": "customer1:alice", + "roles": [], + "dbUserType": "db_user", + "active": True, + "namespace": "customer1", + }, + status=200, + ) + + user = client.users.db.get(user_id="customer1:alice") + + assert isinstance(user, UserDB) + assert user.user_id == "customer1:alice" + assert user.namespace == "customer1" + server.check_assertions() + + +def test_users_db_get_namespace_is_none_when_absent( + ns_client: Tuple[weaviate.WeaviateClient, HTTPServer], +) -> None: + """A missing ``namespace`` field in the server response must yield ``None``. + + On non-namespace-enabled clusters the server returns no ``namespace`` key. + ``UserDB.namespace`` must default to ``None`` instead of raising. + """ + client, server = ns_client + server.expect_request("/v1/users/db/alice", method="GET").respond_with_json( + { + "userId": "alice", + "roles": [], + "dbUserType": "db_user", + "active": True, + }, + status=200, + ) + + user = client.users.db.get(user_id="alice") + + assert user is not None + assert user.namespace is None + server.check_assertions() From 349ad0c0924ba3f338a065c4d122598bccea3251 Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Fri, 8 May 2026 14:59:38 +0200 Subject: [PATCH 06/14] test: pin namespace create wire format and tighten type annotations - Capture and assert the create() request body is empty JSON, locking the contract that the namespace name lives in the URL path only - Annotate method_call parametrize argument as Callable - Use Dict[str, Any] instead of bare dict for captured request bodies Co-Authored-By: Claude Opus 4.7 --- mock_tests/test_namespaces.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/mock_tests/test_namespaces.py b/mock_tests/test_namespaces.py index 5348a05dc..8125032b7 100644 --- a/mock_tests/test_namespaces.py +++ b/mock_tests/test_namespaces.py @@ -15,7 +15,7 @@ """ import json -from typing import Generator, Tuple +from typing import Any, Callable, Dict, Generator, Tuple import grpc import pytest @@ -82,15 +82,25 @@ def ns_client_old( def test_namespaces_create_sends_post_and_parses_response( ns_client: Tuple[weaviate.WeaviateClient, HTTPServer], ) -> None: + """``create`` must POST an empty JSON body — the name lives in the URL path. + + Sending anything else (e.g. ``{"name": ...}``) would diverge from the server + contract and is the kind of refactor that's tempting but breaks on the wire. + """ client, server = ns_client - server.expect_request("/v1/namespaces/myns", method="POST").respond_with_json( - {"name": "myns"}, status=201 - ) + captured: Dict[str, Any] = {} + + def handler(request: Request) -> Response: + captured["body"] = json.loads(request.get_data(as_text=True) or "{}") + return Response(json.dumps({"name": "myns"}), status=201) + + server.expect_request("/v1/namespaces/myns", method="POST").respond_with_handler(handler) ns = client.namespaces.create(name="myns") assert isinstance(ns, Namespace) assert ns.name == "myns" + assert captured["body"] == {} server.check_assertions() @@ -213,7 +223,8 @@ def test_namespaces_delete_accepts_204( ids=["create", "get", "list_all", "delete"], ) def test_namespaces_methods_require_1_38( - ns_client_old: weaviate.WeaviateClient, method_call + ns_client_old: weaviate.WeaviateClient, + method_call: Callable[[weaviate.WeaviateClient], object], ) -> None: """Every public namespace method must guard with ``check_is_at_least_1_38_0``. @@ -240,7 +251,7 @@ def test_users_db_create_includes_namespace_in_body( namespace-binding on multi-tenant clusters. """ client, server = ns_client - captured: dict = {} + captured: Dict[str, Any] = {} def handler(request: Request) -> Response: captured["body"] = json.loads(request.get_data(as_text=True) or "{}") @@ -264,7 +275,7 @@ def test_users_db_create_omits_namespace_when_not_provided( don't recognize the field. """ client, server = ns_client - captured: dict = {} + captured: Dict[str, Any] = {} def handler(request: Request) -> Response: captured["body"] = json.loads(request.get_data(as_text=True) or "{}") From 2019e9c660fc33c5a38ec3600d8339688a235bcb Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Fri, 15 May 2026 11:46:10 +0200 Subject: [PATCH 07/14] fix: accept 202 for namespace delete to match server response The server returns 202 Accepted for DELETE /namespaces/{name} because cleanup of classes, aliases, and namespaced users completes asynchronously. The client was declaring ok_in=[204], so successful deletions surfaced as errors even though the server-side delete went through. Align the status code with the spec and document the async behaviour, and update the mock test accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) --- mock_tests/test_namespaces.py | 4 ++-- weaviate/namespaces/base.py | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/mock_tests/test_namespaces.py b/mock_tests/test_namespaces.py index 8125032b7..19c02f25a 100644 --- a/mock_tests/test_namespaces.py +++ b/mock_tests/test_namespaces.py @@ -194,12 +194,12 @@ def test_namespaces_list_all_handles_empty_array( # --------------------------------------------------------------------------- -def test_namespaces_delete_accepts_204( +def test_namespaces_delete_accepts_202( ns_client: Tuple[weaviate.WeaviateClient, HTTPServer], ) -> None: client, server = ns_client server.expect_request("/v1/namespaces/myns", method="DELETE").respond_with_response( - Response(status=204) + Response(status=202) ) # Must not raise; returns None. diff --git a/weaviate/namespaces/base.py b/weaviate/namespaces/base.py index 4baf9d02e..7bbb36ce4 100644 --- a/weaviate/namespaces/base.py +++ b/weaviate/namespaces/base.py @@ -86,6 +86,10 @@ def resp(res: Response) -> List[Namespace]: def delete(self, *, name: str) -> executor.Result[None]: """Delete a namespace. + The server marks the namespace for deletion and cleans up its classes, + aliases, and users asynchronously, so this call returns as soon as the + deletion has been accepted (HTTP 202), not when cleanup has finished. + Args: name: The name of the namespace to delete. """ @@ -99,5 +103,5 @@ def resp(res: Response) -> None: method=self._connection.delete, path=f"/namespaces/{name}", error_msg=f"Could not delete namespace '{name}'", - status_codes=_ExpectedStatusCodes(ok_in=[204], error="Delete namespace"), + status_codes=_ExpectedStatusCodes(ok_in=[202], error="Delete namespace"), ) From 1f252810f3a3770cd6759a93e7967c6ebc693884 Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Tue, 26 May 2026 14:37:44 +0200 Subject: [PATCH 08/14] 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) --- integration/test_namespaces.py | 44 +++++++++++++ mock_tests/test_namespaces.py | 115 ++++++++++++++++++++++++++++++++- test/test_namespaces.py | 18 ++++++ weaviate/namespaces/async_.pyi | 3 +- weaviate/namespaces/base.py | 57 ++++++++++++++-- weaviate/namespaces/models.py | 5 ++ weaviate/namespaces/sync.pyi | 3 +- weaviate/outputs/namespaces.py | 4 +- 8 files changed, 237 insertions(+), 12 deletions(-) diff --git a/integration/test_namespaces.py b/integration/test_namespaces.py index 4277ce045..bec91e3af 100644 --- a/integration/test_namespaces.py +++ b/integration/test_namespaces.py @@ -18,6 +18,13 @@ def _skip_if_unsupported(client: weaviate.WeaviateClient) -> None: pytest.skip(f"Namespaces require Weaviate {major}.{minor}.{patch}+") +def _a_storage_candidate(client: weaviate.WeaviateClient) -> str: + """Return a node name usable as a ``home_node`` (must be a real storage candidate).""" + nodes = client.cluster.nodes() + assert nodes, "expected at least one cluster node" + return nodes[0].name + + def test_create_and_get_namespace(client_factory: ClientFactory) -> None: with client_factory(ports=NS_PORTS, auth_credentials=ADMIN_KEY) as client: _skip_if_unsupported(client) @@ -32,6 +39,43 @@ def test_create_and_get_namespace(client_factory: ClientFactory) -> None: client.namespaces.delete(name="testns") +def test_create_namespace_with_home_node(client_factory: ClientFactory) -> None: + with client_factory(ports=NS_PORTS, auth_credentials=ADMIN_KEY) as client: + _skip_if_unsupported(client) + + home_node = _a_storage_candidate(client) + ns = client.namespaces.create(name="homenodens", home_node=home_node) + try: + assert ns.name == "homenodens" + assert ns.home_node == home_node + assert ns.state == "active" + + fetched = client.namespaces.get(name="homenodens") + assert fetched is not None + assert fetched.home_node == home_node + finally: + client.namespaces.delete(name="homenodens") + + +def test_update_namespace_home_node(client_factory: ClientFactory) -> None: + with client_factory(ports=NS_PORTS, auth_credentials=ADMIN_KEY) as client: + _skip_if_unsupported(client) + + home_node = _a_storage_candidate(client) + # Create without a home_node so the cluster auto-selects, then pin it explicitly. + client.namespaces.create(name="updatens") + try: + updated = client.namespaces.update(name="updatens", home_node=home_node) + assert updated.name == "updatens" + assert updated.home_node == home_node + + fetched = client.namespaces.get(name="updatens") + assert fetched is not None + assert fetched.home_node == home_node + finally: + client.namespaces.delete(name="updatens") + + def test_get_nonexistent_namespace_returns_none(client_factory: ClientFactory) -> None: with client_factory(ports=NS_PORTS, auth_credentials=ADMIN_KEY) as client: _skip_if_unsupported(client) diff --git a/mock_tests/test_namespaces.py b/mock_tests/test_namespaces.py index 19c02f25a..dee28e736 100644 --- a/mock_tests/test_namespaces.py +++ b/mock_tests/test_namespaces.py @@ -100,10 +100,76 @@ def handler(request: Request) -> Response: assert isinstance(ns, Namespace) assert ns.name == "myns" + assert ns.home_node is None + assert ns.state is None assert captured["body"] == {} server.check_assertions() +def test_namespaces_create_sends_home_node_when_provided( + ns_client: Tuple[weaviate.WeaviateClient, HTTPServer], +) -> None: + """When ``home_node`` is provided, ``create`` must carry it in the request body. + + Dropping ``body['home_node'] = home_node`` would silently ignore the caller's + placement choice and let the server auto-select instead. + """ + client, server = ns_client + captured: Dict[str, Any] = {} + + def handler(request: Request) -> Response: + captured["body"] = json.loads(request.get_data(as_text=True) or "{}") + return Response( + json.dumps({"name": "myns", "home_node": "node1", "state": "active"}), + status=201, + ) + + server.expect_request("/v1/namespaces/myns", method="POST").respond_with_handler(handler) + + ns = client.namespaces.create(name="myns", home_node="node1") + + assert ns.name == "myns" + assert ns.home_node == "node1" + assert ns.state == "active" + assert captured["body"] == {"home_node": "node1"} + server.check_assertions() + + +# --------------------------------------------------------------------------- +# update +# --------------------------------------------------------------------------- + + +def test_namespaces_update_sends_put_and_parses_response( + ns_client: Tuple[weaviate.WeaviateClient, HTTPServer], +) -> None: + """``update`` must PUT ``{"home_node": ...}`` and return the updated namespace. + + The server requires ``home_node`` in the body; sending anything else (or the + wrong HTTP verb) would break the modify-placement contract. + """ + client, server = ns_client + captured: Dict[str, Any] = {} + + def handler(request: Request) -> Response: + captured["body"] = json.loads(request.get_data(as_text=True) or "{}") + return Response( + json.dumps({"name": "myns", "home_node": "node2", "state": "active"}), + status=200, + ) + + server.expect_request("/v1/namespaces/myns", method="PUT").respond_with_handler(handler) + + ns = client.namespaces.update(name="myns", home_node="node2") + + assert isinstance(ns, Namespace) + assert ns.name == "myns" + assert ns.home_node == "node2" + assert ns.state == "active" + assert captured["body"] == {"home_node": "node2"} + server.check_assertions() + + # --------------------------------------------------------------------------- # get # --------------------------------------------------------------------------- @@ -121,6 +187,30 @@ def test_namespaces_get_returns_namespace_when_found( assert ns is not None assert ns.name == "customer1" + assert ns.home_node is None + assert ns.state is None + server.check_assertions() + + +def test_namespaces_get_parses_home_node_and_state( + ns_client: Tuple[weaviate.WeaviateClient, HTTPServer], +) -> None: + """``get`` must surface the ``home_node`` and ``state`` fields from the response. + + These fields were added to the server's namespace object after the client's + initial implementation; this guards against the parser silently ignoring them. + """ + client, server = ns_client + server.expect_request("/v1/namespaces/customer1", method="GET").respond_with_json( + {"name": "customer1", "home_node": "node1", "state": "deleting"}, status=200 + ) + + ns = client.namespaces.get(name="customer1") + + assert ns is not None + assert ns.name == "customer1" + assert ns.home_node == "node1" + assert ns.state == "deleting" server.check_assertions() @@ -161,6 +251,28 @@ def test_namespaces_list_all_parses_array( server.check_assertions() +def test_namespaces_list_all_parses_home_node_and_state( + ns_client: Tuple[weaviate.WeaviateClient, HTTPServer], +) -> None: + """``list_all`` must surface ``home_node`` and ``state`` for each namespace.""" + client, server = ns_client + server.expect_request("/v1/namespaces", method="GET").respond_with_json( + [ + {"name": "ns1", "home_node": "node1", "state": "active"}, + {"name": "ns2"}, + ], + status=200, + ) + + namespaces = client.namespaces.list_all() + + assert namespaces[0].home_node == "node1" + assert namespaces[0].state == "active" + assert namespaces[1].home_node is None + assert namespaces[1].state is None + server.check_assertions() + + def test_namespaces_list_all_handles_null_response( ns_client: Tuple[weaviate.WeaviateClient, HTTPServer], ) -> None: @@ -216,11 +328,12 @@ def test_namespaces_delete_accepts_202( "method_call", [ lambda c: c.namespaces.create(name="x"), + lambda c: c.namespaces.update(name="x", home_node="n"), lambda c: c.namespaces.get(name="x"), lambda c: c.namespaces.list_all(), lambda c: c.namespaces.delete(name="x"), ], - ids=["create", "get", "list_all", "delete"], + ids=["create", "update", "get", "list_all", "delete"], ) def test_namespaces_methods_require_1_38( ns_client_old: weaviate.WeaviateClient, diff --git a/test/test_namespaces.py b/test/test_namespaces.py index 63e8d6114..d2317ee8d 100644 --- a/test/test_namespaces.py +++ b/test/test_namespaces.py @@ -1,4 +1,5 @@ from weaviate.classes.rbac import Actions, Permissions +from weaviate.namespaces.models import Namespace from weaviate.rbac.models import ( NamespacesAction, NamespacesPermissionOutput, @@ -106,6 +107,23 @@ def test_actions_namespaces_enum_accessible() -> None: assert Actions.Namespaces.MANAGE.value == "manage_namespaces" +# --- Namespace model --- + + +def test_namespace_optional_fields_default_to_none() -> None: + ns = Namespace(name="customer1") + assert ns.name == "customer1" + assert ns.home_node is None + assert ns.state is None + + +def test_namespace_all_fields_set() -> None: + ns = Namespace(name="customer1", home_node="node1", state="active") + assert ns.name == "customer1" + assert ns.home_node == "node1" + assert ns.state == "active" + + # --- UserDB.namespace field --- diff --git a/weaviate/namespaces/async_.pyi b/weaviate/namespaces/async_.pyi index 8a664135d..7a57f8c5e 100644 --- a/weaviate/namespaces/async_.pyi +++ b/weaviate/namespaces/async_.pyi @@ -5,7 +5,8 @@ from weaviate.namespaces.base import _NamespacesExecutor from weaviate.namespaces.models import Namespace class _NamespacesAsync(_NamespacesExecutor[ConnectionAsync]): - async def create(self, *, name: str) -> Namespace: ... + async def create(self, *, name: str, home_node: Optional[str] = None) -> Namespace: ... + async def update(self, *, name: str, home_node: str) -> Namespace: ... async def get(self, *, name: str) -> Optional[Namespace]: ... async def list_all(self) -> List[Namespace]: ... async def delete(self, *, name: str) -> None: ... diff --git a/weaviate/namespaces/base.py b/weaviate/namespaces/base.py index 7bbb36ce4..4f496b5db 100644 --- a/weaviate/namespaces/base.py +++ b/weaviate/namespaces/base.py @@ -1,10 +1,10 @@ -from typing import Generic, List, Optional +from typing import Any, Dict, Generic, List, Optional, cast from httpx import Response from weaviate.connect import executor from weaviate.connect.v4 import ConnectionType, _ExpectedStatusCodes -from weaviate.namespaces.models import Namespace +from weaviate.namespaces.models import Namespace, NamespaceState from weaviate.util import _decode_json_response_dict, _decode_json_response_list @@ -12,11 +12,21 @@ class _NamespacesExecutor(Generic[ConnectionType]): def __init__(self, connection: ConnectionType): self._connection = connection - def create(self, *, name: str) -> executor.Result[Namespace]: + @staticmethod + def _ns_from_dict(data: Dict[str, Any]) -> Namespace: + return Namespace( + name=data["name"], + home_node=data.get("home_node"), + state=cast(Optional[NamespaceState], data.get("state")), + ) + + def create(self, *, name: str, home_node: Optional[str] = None) -> executor.Result[Namespace]: """Create a new namespace. Args: name: The namespace name. Must be 3-36 lowercase alphanumeric characters starting with a letter. + home_node: The cluster node to place this namespace's shards on. Must be a current storage + candidate. When omitted, the cluster picks one automatically. Returns: The created Namespace. @@ -26,17 +36,50 @@ def create(self, *, name: str) -> executor.Result[Namespace]: def resp(res: Response) -> Namespace: parsed = _decode_json_response_dict(res, "Create namespace") assert parsed is not None - return Namespace(name=parsed["name"]) + return self._ns_from_dict(parsed) + + body: Dict[str, Any] = {} + if home_node is not None: + body["home_node"] = home_node return executor.execute( response_callback=resp, method=self._connection.post, path=f"/namespaces/{name}", - weaviate_object={}, + weaviate_object=body, error_msg=f"Could not create namespace '{name}'", status_codes=_ExpectedStatusCodes(ok_in=[201], error="Create namespace"), ) + def update(self, *, name: str, home_node: str) -> executor.Result[Namespace]: + """Update the home node of an existing namespace. + + Changing the home node only affects future placement decisions; existing live shards + are not moved. + + Args: + name: The name of the namespace to update. + home_node: The cluster node to use for future placements. Must be a current storage candidate. + + Returns: + The updated Namespace. + """ + self._connection._weaviate_version.check_is_at_least_1_38_0("namespaces") + + def resp(res: Response) -> Namespace: + parsed = _decode_json_response_dict(res, "Update namespace") + assert parsed is not None + return self._ns_from_dict(parsed) + + return executor.execute( + response_callback=resp, + method=self._connection.put, + path=f"/namespaces/{name}", + weaviate_object={"home_node": home_node}, + error_msg=f"Could not update namespace '{name}'", + status_codes=_ExpectedStatusCodes(ok_in=[200], error="Update namespace"), + ) + def get(self, *, name: str) -> executor.Result[Optional[Namespace]]: """Get a namespace by name. @@ -53,7 +96,7 @@ def resp(res: Response) -> Optional[Namespace]: return None parsed = _decode_json_response_dict(res, "Get namespace") assert parsed is not None - return Namespace(name=parsed["name"]) + return self._ns_from_dict(parsed) return executor.execute( response_callback=resp, @@ -73,7 +116,7 @@ def list_all(self) -> executor.Result[List[Namespace]]: def resp(res: Response) -> List[Namespace]: parsed = _decode_json_response_list(res, "List namespaces") - return [Namespace(name=ns["name"]) for ns in (parsed or [])] + return [self._ns_from_dict(ns) for ns in (parsed or [])] return executor.execute( response_callback=resp, diff --git a/weaviate/namespaces/models.py b/weaviate/namespaces/models.py index 061436ecd..fcd24641d 100644 --- a/weaviate/namespaces/models.py +++ b/weaviate/namespaces/models.py @@ -1,6 +1,11 @@ from dataclasses import dataclass +from typing import Literal, Optional + +NamespaceState = Literal["active", "deleting"] @dataclass class Namespace: name: str + home_node: Optional[str] = None + state: Optional[NamespaceState] = None diff --git a/weaviate/namespaces/sync.pyi b/weaviate/namespaces/sync.pyi index 46e4534b7..4378b3112 100644 --- a/weaviate/namespaces/sync.pyi +++ b/weaviate/namespaces/sync.pyi @@ -5,7 +5,8 @@ from weaviate.namespaces.base import _NamespacesExecutor from weaviate.namespaces.models import Namespace class _Namespaces(_NamespacesExecutor[ConnectionSync]): - def create(self, *, name: str) -> Namespace: ... + def create(self, *, name: str, home_node: Optional[str] = None) -> Namespace: ... + def update(self, *, name: str, home_node: str) -> Namespace: ... def get(self, *, name: str) -> Optional[Namespace]: ... def list_all(self) -> List[Namespace]: ... def delete(self, *, name: str) -> None: ... diff --git a/weaviate/outputs/namespaces.py b/weaviate/outputs/namespaces.py index 5ba33f912..4d9769b48 100644 --- a/weaviate/outputs/namespaces.py +++ b/weaviate/outputs/namespaces.py @@ -1,3 +1,3 @@ -from weaviate.namespaces.models import Namespace +from weaviate.namespaces.models import Namespace, NamespaceState -__all__ = ["Namespace"] +__all__ = ["Namespace", "NamespaceState"] From 05655714b9f07ed95eb2007fff72093fd5cefd81 Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Wed, 27 May 2026 12:38:21 +0200 Subject: [PATCH 09/14] Fix copilot comments. - Order the ports following alphabetical order of docker-compose files. - Add verification of version in user creation if passing namespace. --- ci/compose.sh | 2 +- weaviate/users/base.py | 1 + weaviate/util.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ci/compose.sh b/ci/compose.sh index eb2f635c5..fefb472ab 100644 --- a/ci/compose.sh +++ b/ci/compose.sh @@ -21,7 +21,7 @@ function compose_down_all { } function all_weaviate_ports { - echo "8090 8093 8087 8088 8089 8086 8082 8083 8075 8092 8094 8085 8080" # in alphabetic order of appearance in docker-compose files + echo "8090 8093 8087 8088 8089 8086 8094 8082 8083 8075 8092 8085 8080" # in alphabetic order of appearance in docker-compose files } function wait(){ diff --git a/weaviate/users/base.py b/weaviate/users/base.py index 4978205ee..a284ee30c 100644 --- a/weaviate/users/base.py +++ b/weaviate/users/base.py @@ -378,6 +378,7 @@ def resp(res: Response) -> str: body: Dict[str, Any] = {} if namespace is not None: + self._connection._weaviate_version.check_is_at_least_1_38_0("users.db.create") body["namespace"] = namespace return executor.execute( diff --git a/weaviate/util.py b/weaviate/util.py index e7a8d4629..4ebde7394 100644 --- a/weaviate/util.py +++ b/weaviate/util.py @@ -436,7 +436,7 @@ def _capitalize_first_letter(string: str) -> str: For namespaced collection names of the form ``namespace:CollectionName``, only the collection portion (after the colon) is capitalized; the namespace - prefix is preserved as-is to keep it lowercase. + prefix is preserved as-is. Args: string: The string to be capitalized. From a4499d4d944faa9114ca7d888979d86115c4f75d Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Wed, 27 May 2026 12:46:59 +0200 Subject: [PATCH 10/14] fix(ci): exclude integration test files from coverage Codecov flagged 71 missing patch lines, all in integration/test_namespaces.py. The namespaces integration tests are version-gated to Weaviate 1.38+, but the CI integration matrix only runs 1.36, so they are skipped and their bodies count as uncovered. The bare `--cov` in the pytest invocation pulls test files into the report, and .coveragerc only omitted *tests*/* (mock_tests/), missing integration/. Add *integration/* to the omit lists. Test files are not coverage subjects, and the coverage they contribute to weaviate/ (via --cov=weaviate) is unaffected. Production code (weaviate/namespaces/*) remains 100% covered by mock+unit tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .coveragerc | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.coveragerc b/.coveragerc index e6c08bbc6..8908e5f88 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,9 +1,15 @@ +# Test directories are not coverage subjects. *tests*/* catches mock_tests/; +# *integration/* is excluded too because integration tests run against live +# clusters and version-gated suites (e.g. namespaces, which need Weaviate 1.38+) +# are skipped in the coverage job, which would otherwise count their unexecuted +# bodies as missing patch coverage. Excluding the test files does not affect the +# coverage they contribute to weaviate/ (measured via --cov=weaviate). [run] -omit = *tests*/*,*__init__.py,weaviate/proto/** +omit = *tests*/*,*integration/*,*__init__.py,weaviate/proto/** [report] -omit = *tests*/*,*__init__.py +omit = *tests*/*,*integration/*,*__init__.py exclude_lines = pragma: not covered @overload From c80ad3569bbf0dc18a569f8b048732c85f669d5d Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Wed, 27 May 2026 17:40:20 +0200 Subject: [PATCH 11/14] Add WEAVIATE_138 version. And add missing REPLICATION_MAXIMUM_FACTOR required for namespaces. --- .github/workflows/main.yaml | 4 +++- ci/docker-compose-namespaces.yml | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index ea7a6abfb..839dd031e 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -29,6 +29,7 @@ env: WEAVIATE_135: 1.35.18 WEAVIATE_136: 1.36.12 WEAVIATE_137: 1.37.5-e0fe0d5.amd64 + WEAVIATE_138: 1.38.0-rc.0 jobs: lint-and-format: @@ -320,7 +321,8 @@ jobs: $WEAVIATE_134, $WEAVIATE_135, $WEAVIATE_136, - $WEAVIATE_137 + $WEAVIATE_137, + $WEAVIATE_138 ] steps: - name: Checkout diff --git a/ci/docker-compose-namespaces.yml b/ci/docker-compose-namespaces.yml index e8a23bca8..89f572c16 100644 --- a/ci/docker-compose-namespaces.yml +++ b/ci/docker-compose-namespaces.yml @@ -18,6 +18,7 @@ services: environment: # Namespaces feature — requires GraphQL disabled NAMESPACES_ENABLED: "true" + REPLICATION_MAXIMUM_FACTOR: "1" DISABLE_GRAPHQL: "true" # Static API key auth (operator-level access) AUTHENTICATION_APIKEY_ENABLED: "true" @@ -32,6 +33,7 @@ services: AUTHENTICATION_DB_USERS_ENABLED: "true" # Storage / cluster PERSISTENCE_DATA_PATH: "./data-weaviate-0" + CLUSTER_HOSTNAME: "node1" CLUSTER_IN_LOCALHOST: "true" CLUSTER_GOSSIP_BIND_PORT: "7102" CLUSTER_DATA_BIND_PORT: "7103" From a6a58a3d5add85416070d7b7da9ef3af65a57f16 Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Thu, 28 May 2026 12:45:39 +0200 Subject: [PATCH 12/14] test: handle asynchronous namespace deletion in delete test test_delete_namespace asserted that get() returns None immediately after delete(), but namespace deletion is asynchronous: delete() returns 202, the server marks the namespace state="deleting", and a background sweep removes it later (NAMESPACE_CLEANUP_INTERVAL, default 30s). Against a real 1.38 cluster get() therefore returned Namespace(state="deleting"), failing the assertion. Poll until the namespace is gone (asserting it stays in "deleting" while present) with a 60s timeout that tolerates the default sweep interval. Also set NAMESPACE_CLEANUP_INTERVAL=2s in the namespaces CI compose so the sweep runs quickly and the test finishes in ~1s instead of ~30s. Verified against a live 1.38.0-rc.0 cluster: all 9 namespace integration tests pass (delete completes in ~0.6s with the shortened interval). Co-Authored-By: Claude Opus 4.7 (1M context) --- ci/docker-compose-namespaces.yml | 3 +++ integration/test_namespaces.py | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/ci/docker-compose-namespaces.yml b/ci/docker-compose-namespaces.yml index 89f572c16..25862be2b 100644 --- a/ci/docker-compose-namespaces.yml +++ b/ci/docker-compose-namespaces.yml @@ -18,6 +18,9 @@ services: environment: # Namespaces feature — requires GraphQL disabled NAMESPACES_ENABLED: "true" + # Sweep deleting namespaces quickly so delete-then-gone tests stay fast + # (defaults to 30s). + NAMESPACE_CLEANUP_INTERVAL: "2s" REPLICATION_MAXIMUM_FACTOR: "1" DISABLE_GRAPHQL: "true" # Static API key auth (operator-level access) diff --git a/integration/test_namespaces.py b/integration/test_namespaces.py index bec91e3af..37aee9a84 100644 --- a/integration/test_namespaces.py +++ b/integration/test_namespaces.py @@ -1,3 +1,5 @@ +import time + import pytest import weaviate @@ -107,7 +109,15 @@ def test_delete_namespace(client_factory: ClientFactory) -> None: client.namespaces.create(name="deletens") client.namespaces.delete(name="deletens") + # Deletion is asynchronous: delete() returns 202 and the server marks the + # namespace "deleting", then removes it on the background cleanup sweep + # (NAMESPACE_CLEANUP_INTERVAL). Poll until it is gone. + deadline = time.time() + 60 fetched = client.namespaces.get(name="deletens") + while fetched is not None and time.time() < deadline: + assert fetched.state == "deleting" + time.sleep(0.5) + fetched = client.namespaces.get(name="deletens") assert fetched is None From 26fe203e9b38b79551ae0972f023fdc42ff6cd8d Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Fri, 29 May 2026 10:42:11 +0200 Subject: [PATCH 13/14] feat: create db users via namespace-qualified id, drop namespace arg The server no longer accepts a separate `namespace` field when creating a DB user; the namespace is now derived from a namespace-qualified id of the form ":" passed in the URL path (weaviate PR #11501, resolveUserKeyForCreate). Update the client to match: - users.db.create: remove the `namespace` parameter; always POST an empty body. The (optionally qualified) id is already carried in the path. Drop the now-unused 1.38 version guard tied to the namespace field. - sync/async .pyi stubs: update the create signature accordingly. - UserDB.namespace and get/list parsing are unchanged: the server still returns `namespace` in the user response for global operators (handlers_db_users.go). Tests: - mock_tests: replace the namespace-in-body tests with test_users_db_create_qualified_user_id_goes_in_path (asserts the qualified id lands in the URL path and the body stays empty) and test_users_db_create_posts_empty_body. These pin the wire contract and are the version-independent regression guard. - integration: create the namespaced user with the qualified id directly. - Bump WEAVIATE_138 to 1.38.0-rc.0-b9ea106, the first build containing PR #11501. Verified: mock + unit suites pass (34); 8/9 namespace integration tests pass against a live cluster. The qualified-create integration test requires the b9ea106 image, which was still publishing to Docker Hub at commit time; the server source at b9ea106 is git-verified to implement this contract and CI runs the live check. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/main.yaml | 2 +- integration/test_namespaces.py | 8 ++++---- mock_tests/test_namespaces.py | 26 ++++++++++++++------------ weaviate/users/async_.pyi | 2 +- weaviate/users/base.py | 13 ++++--------- weaviate/users/sync.pyi | 2 +- 6 files changed, 25 insertions(+), 28 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 839dd031e..603f921b4 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -29,7 +29,7 @@ env: WEAVIATE_135: 1.35.18 WEAVIATE_136: 1.36.12 WEAVIATE_137: 1.37.5-e0fe0d5.amd64 - WEAVIATE_138: 1.38.0-rc.0 + WEAVIATE_138: 1.38.0-rc.0-b9ea106 jobs: lint-and-format: diff --git a/integration/test_namespaces.py b/integration/test_namespaces.py index 37aee9a84..1c579ae12 100644 --- a/integration/test_namespaces.py +++ b/integration/test_namespaces.py @@ -126,12 +126,12 @@ def test_create_namespaced_user(client_factory: ClientFactory) -> None: _skip_if_unsupported(client) client.namespaces.create(name="usernstest") - # On namespace-enabled clusters the server qualifies the userId as - # "namespace:user_id" in storage. Operators must use that qualified form - # when calling get/delete. + # On namespace-enabled clusters an operator creates a user with a + # namespace-qualified id ":"; the same qualified id is + # used for get/delete. qualified_id = "usernstest:nsuser1" try: - api_key = client.users.db.create(user_id="nsuser1", namespace="usernstest") + api_key = client.users.db.create(user_id=qualified_id) assert isinstance(api_key, str) assert len(api_key) > 0 diff --git a/mock_tests/test_namespaces.py b/mock_tests/test_namespaces.py index dee28e736..8a47ccb06 100644 --- a/mock_tests/test_namespaces.py +++ b/mock_tests/test_namespaces.py @@ -354,14 +354,14 @@ def test_namespaces_methods_require_1_38( # --------------------------------------------------------------------------- -def test_users_db_create_includes_namespace_in_body( +def test_users_db_create_qualified_user_id_goes_in_path( ns_client: Tuple[weaviate.WeaviateClient, HTTPServer], ) -> None: - """When ``namespace`` is provided, the request body must carry it. + """A namespace-qualified user id must be sent in the URL path, not the body. - Without this assertion, dropping ``body['namespace'] = namespace`` from - ``users/base.py`` would compile and pass type-checks but silently break - namespace-binding on multi-tenant clusters. + The server derives the namespace from the ``":"`` id in the + path, so the request body stays empty. This guards against reintroducing a + ``body['namespace']`` field, which the server no longer accepts. """ client, server = ns_client captured: Dict[str, Any] = {} @@ -370,22 +370,24 @@ def handler(request: Request) -> Response: captured["body"] = json.loads(request.get_data(as_text=True) or "{}") return Response(json.dumps({"apikey": "secret-key"}), status=201) - server.expect_request("/v1/users/db/alice", method="POST").respond_with_handler(handler) + server.expect_request("/v1/users/db/customer1:alice", method="POST").respond_with_handler( + handler + ) - api_key = client.users.db.create(user_id="alice", namespace="customer1") + api_key = client.users.db.create(user_id="customer1:alice") assert api_key == "secret-key" - assert captured["body"] == {"namespace": "customer1"} + assert captured["body"] == {} server.check_assertions() -def test_users_db_create_omits_namespace_when_not_provided( +def test_users_db_create_posts_empty_body( ns_client: Tuple[weaviate.WeaviateClient, HTTPServer], ) -> None: - """The ``namespace`` key must not appear in the body when omitted by the caller. + """``create`` must POST an empty body — there is no separate ``namespace`` field. - Otherwise we'd send ``"namespace": null`` and break older clusters that - don't recognize the field. + Sending anything else (e.g. a stray ``namespace`` key) would diverge from the + server contract, which expects only the (possibly qualified) id in the path. """ client, server = ns_client captured: Dict[str, Any] = {} diff --git a/weaviate/users/async_.pyi b/weaviate/users/async_.pyi index 979f789ec..5770a5063 100644 --- a/weaviate/users/async_.pyi +++ b/weaviate/users/async_.pyi @@ -52,7 +52,7 @@ class _UsersDBAsync(_UsersDBExecutor[ConnectionAsync]): ) -> Union[Dict[str, Role], Dict[str, RoleBase]]: ... async def assign_roles(self, *, user_id: str, role_names: Union[str, List[str]]) -> None: ... async def revoke_roles(self, *, user_id: str, role_names: Union[str, List[str]]) -> None: ... - async def create(self, *, user_id: str, namespace: Optional[str] = None) -> str: ... + async def create(self, *, user_id: str) -> str: ... async def delete(self, *, user_id: str) -> bool: ... async def rotate_key(self, *, user_id: str) -> str: ... async def activate(self, *, user_id: str) -> bool: ... diff --git a/weaviate/users/base.py b/weaviate/users/base.py index a284ee30c..8f3e7dd06 100644 --- a/weaviate/users/base.py +++ b/weaviate/users/base.py @@ -360,12 +360,12 @@ def revoke_roles( USER_TYPE_DB, ) - def create(self, *, user_id: str, namespace: Optional[str] = None) -> executor.Result[str]: + def create(self, *, user_id: str) -> executor.Result[str]: """Create a new db user and return its API key. Args: - user_id: The id of the new user. - namespace: The namespace to bind the user to. Required on namespace-enabled clusters. + user_id: The id of the new user. On namespace-enabled clusters an operator must + pass a namespace-qualified id of the form ``":"``. Returns: The API key of the newly created user. This key can not be retrieved later. @@ -376,16 +376,11 @@ def resp(res: Response) -> str: assert resp is not None return str(resp["apikey"]) - body: Dict[str, Any] = {} - if namespace is not None: - self._connection._weaviate_version.check_is_at_least_1_38_0("users.db.create") - body["namespace"] = namespace - return executor.execute( response_callback=resp, method=self._connection.post, path=f"/users/db/{user_id}", - weaviate_object=body, + weaviate_object={}, error_msg=f"Could not create user '{user_id}'", status_codes=_ExpectedStatusCodes(ok_in=[201], error="Create user"), ) diff --git a/weaviate/users/sync.pyi b/weaviate/users/sync.pyi index 251c9d97a..7e82eee19 100644 --- a/weaviate/users/sync.pyi +++ b/weaviate/users/sync.pyi @@ -52,7 +52,7 @@ class _UsersDB(_UsersDBExecutor[ConnectionSync]): ) -> Union[Dict[str, Role], Dict[str, RoleBase]]: ... def assign_roles(self, *, user_id: str, role_names: Union[str, List[str]]) -> None: ... def revoke_roles(self, *, user_id: str, role_names: Union[str, List[str]]) -> None: ... - def create(self, *, user_id: str, namespace: Optional[str] = None) -> str: ... + def create(self, *, user_id: str) -> str: ... def delete(self, *, user_id: str) -> bool: ... def rotate_key(self, *, user_id: str) -> str: ... def activate(self, *, user_id: str) -> bool: ... From b6205c7da07160bdf8f0622b1e30ee845bb2e52c Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Fri, 29 May 2026 11:00:39 +0200 Subject: [PATCH 14/14] ci: pin WEAVIATE_138 to 1.38.0-rc.0-381222f.amd64 First published build containing the namespace-qualified user-create change (PR #11501). Verified end-to-end: all 9 namespace integration tests pass against this image, including test_create_namespaced_user with a ":" id. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/main.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 603f921b4..a3bd41cb4 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -29,7 +29,7 @@ env: WEAVIATE_135: 1.35.18 WEAVIATE_136: 1.36.12 WEAVIATE_137: 1.37.5-e0fe0d5.amd64 - WEAVIATE_138: 1.38.0-rc.0-b9ea106 + WEAVIATE_138: 1.38.0-rc.0-381222f.amd64 jobs: lint-and-format: