From 7689acc8fd9937c5e4cce7ca7f9588522e473a8f Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Tue, 5 May 2026 16:15:00 +0200 Subject: [PATCH 1/5] 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 c636cb3b3c7d45a965a689697e8f019586602314 Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Tue, 5 May 2026 16:28:44 +0200 Subject: [PATCH 2/5] 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 f2babd27670729677c7784a0e30a27d4f8ec7328 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 3/5] 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 abeeb6af17bd65234af55265358bfc9f509d7666 Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Wed, 6 May 2026 22:20:02 +0200 Subject: [PATCH 4/5] 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 5629aeefdfb4ea9c4a95de4d57170ac7925df7fb Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Thu, 7 May 2026 15:39:23 +0200 Subject: [PATCH 5/5] 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()