diff --git a/.claude/skills/contributing-to-weaviate-cli/SKILL.md b/.claude/skills/contributing-to-weaviate-cli/SKILL.md index c0fdf83..cda93d5 100644 --- a/.claude/skills/contributing-to-weaviate-cli/SKILL.md +++ b/.claude/skills/contributing-to-weaviate-cli/SKILL.md @@ -44,10 +44,10 @@ weaviate-cli/ defaults.py # Dataclass defaults for all commands utils.py # Shared helpers: get_client, pp_objects, parse_permission commands/ # Click command definitions (one file per group) - create.py # create collection, tenants, data, backup, role, user, alias, replication - get.py # get collection, tenants, shards, backup, role, user, nodes, alias, replication + create.py # create collection, tenants, data, backup, role, user, alias, namespace, replication + get.py # get collection, tenants, shards, backup, role, user, nodes, alias, namespace, replication update.py # update collection, tenants, shards, data, user, alias - delete.py # delete collection, tenants, data, role, user, alias, replication + delete.py # delete collection, tenants, data, role, user, alias, namespace, replication query.py # query data, replications, sharding-state restore.py # restore backup cancel.py # cancel backup, replication @@ -61,7 +61,8 @@ weaviate-cli/ data_manager.py # DataManager: ingest/update/delete data backup_manager.py # BackupManager: backup/restore operations role_manager.py # RoleManager: RBAC role operations - user_manager.py # UserManager: RBAC user operations + user_manager.py # UserManager: RBAC user operations (supports namespace-scoped users) + namespace_manager.py # NamespaceManager: namespace CRUD (Weaviate 1.38.0+) node_manager.py # NodeManager: node information shard_manager.py # ShardManager: shard operations cluster_manager.py # ClusterManager: replication operations diff --git a/.claude/skills/contributing-to-weaviate-cli/references/architecture.md b/.claude/skills/contributing-to-weaviate-cli/references/architecture.md index edd3430..11f87ed 100644 --- a/.claude/skills/contributing-to-weaviate-cli/references/architecture.md +++ b/.claude/skills/contributing-to-weaviate-cli/references/architecture.md @@ -81,7 +81,7 @@ class CollectionManager: self.client.collections.create(name=collection, ...) ``` -Manager files: `collection_manager.py`, `tenant_manager.py`, `data_manager.py`, `backup_manager.py`, `export_manager.py`, `role_manager.py`, `user_manager.py`, `node_manager.py`, `shard_manager.py`, `cluster_manager.py`, `alias_manager.py`, `benchmark_manager.py`, `config_manager.py` +Manager files: `collection_manager.py`, `tenant_manager.py`, `data_manager.py`, `backup_manager.py`, `export_manager.py`, `role_manager.py`, `user_manager.py`, `namespace_manager.py`, `node_manager.py`, `shard_manager.py`, `cluster_manager.py`, `alias_manager.py`, `benchmark_manager.py`, `config_manager.py` Managers handle: - Input validation and error messages diff --git a/.claude/skills/operating-weaviate-cli/SKILL.md b/.claude/skills/operating-weaviate-cli/SKILL.md index ef90b83..68e688f 100644 --- a/.claude/skills/operating-weaviate-cli/SKILL.md +++ b/.claude/skills/operating-weaviate-cli/SKILL.md @@ -113,10 +113,10 @@ weaviate-cli [--config-file FILE] [--user USER] [--json] [opti | Group | Description | |-------|-------------| -| `create` | Create collections, tenants, data, backups, exports, roles, users, aliases, replications | -| `get` | Inspect collections, tenants, shards, backups, exports, roles, users, nodes, aliases, replications | -| `update` | Update collections, tenants, shards, data, users, aliases | -| `delete` | Delete collections, tenants, data, roles, users, aliases, replications | +| `create` | Create collections, tenants, data, backups, exports, roles, users, aliases, namespaces, replications | +| `get` | Inspect collections, tenants, shards, backups, exports, roles, users, nodes, aliases, namespaces, replications | +| `update` | Update collections, tenants, shards, data, users, aliases, namespaces | +| `delete` | Delete collections, tenants, data, roles, users, aliases, namespaces, replications | | `query` | Query data (fetch/vector/keyword/hybrid/uuid), replications, sharding state | | `restore` | Restore backups | | `cancel` | Cancel backups, exports, and replications | @@ -134,6 +134,9 @@ weaviate-cli [--config-file FILE] [--user USER] [--json] [opti weaviate-cli create collection --collection MyCollection --replication_factor 3 --vector_index hnsw --vectorizer none --json weaviate-cli get collection --json # List all weaviate-cli get collection --collection MyCollection --json # Specific +weaviate-cli get collection --collection Movies --namespace myns --json # Operator: qualified target +weaviate-cli get collection --list-qualified-keys --json # Operator: list using full keys (no shell glob issue) +weaviate-cli get collection --namespace '*' --json # Same; * must be quoted in the shell weaviate-cli update collection --collection MyCollection --description "Updated" --replication_factor 5 --json weaviate-cli delete collection --collection MyCollection --json weaviate-cli delete collection --all --json @@ -284,6 +287,47 @@ Permission format: `action:target`. See [references/rbac.md](references/rbac.md) **Shell quoting**: Permissions with wildcards must be quoted to prevent shell globbing: `-p 'crud_data:*'` (not `-p crud_data:*`, which fails in zsh with `no matches found`). +### Namespaces + +```bash +weaviate-cli create namespace --name tenantswest --json +weaviate-cli create namespace --name tenantswest --home_node node1 --json # pin shards to a node +weaviate-cli get namespace --name tenantswest --json # shows home_node + read-only state +weaviate-cli get namespace --all --json +weaviate-cli update namespace --name tenantswest --home_node node2 --json # change home node only +weaviate-cli delete namespace --name tenantswest --json +``` + +Namespace names must be **3–36 lowercase alphanumeric characters starting with a letter** (regexp: `[a-z][a-z0-9]{2,35}`). + +`--home_node` pins which cluster node holds the namespace's shards (optional on create, required on update). `update namespace` only changes the home node; existing live shards are not moved. `get namespace` surfaces the read-only `state` (`active`/`deleting`) and `home_node` when the server returns them. + +**Prerequisite**: Requires Weaviate 1.38.0+. The CLI defers the version check to the python client. + +#### Namespace-scoped DB users + +On namespace-enabled clusters, a DB user is bound to a namespace by passing a +namespace-qualified id `:` as `--user_name` (there is no `--namespace` +flag; the server derives the namespace from the qualified id): + +```bash +weaviate-cli create user --user_name tenantswest:scoped-user --json +weaviate-cli get user --user_name tenantswest:scoped-user --json # output includes "namespace" +``` + +#### Granting namespace permissions + +```bash +weaviate-cli create role --role_name NsAdmin -p manage_namespaces:tenantswest --json +weaviate-cli assign permission -p manage_namespaces:tenantseast --role_name NsAdmin --json +``` + +Multiple namespaces in one permission: `-p manage_namespaces:ns1,ns2`. The wildcard form +`manage_namespaces` (without a name) is rejected — every grant must name an explicit +namespace. + +See [references/namespaces.md](references/namespaces.md). + ### Cluster & Nodes ```bash @@ -461,6 +505,7 @@ When new commands or options are added to `weaviate-cli`: - [references/backups.md](references/backups.md) -- Backup/restore options and notes - [references/exports.md](references/exports.md) -- Collection export options and notes - [references/rbac.md](references/rbac.md) -- Permission format, actions, and examples +- [references/namespaces.md](references/namespaces.md) -- Namespace CRUD, RBAC, and namespace-scoped users - [references/cluster.md](references/cluster.md) -- Nodes, shards, replication operations - [references/benchmark.md](references/benchmark.md) -- Benchmark options and output modes - [references/config-management.md](references/config-management.md) -- Config patterns and decision tree diff --git a/.claude/skills/operating-weaviate-cli/references/collections.md b/.claude/skills/operating-weaviate-cli/references/collections.md index d1c916d..34a6c5e 100644 --- a/.claude/skills/operating-weaviate-cli/references/collections.md +++ b/.claude/skills/operating-weaviate-cli/references/collections.md @@ -7,6 +7,29 @@ Manage Weaviate collections (schemas). weaviate-cli get collection --json ``` +## Namespaced clusters (namespace-scoped user vs global operator) + +With **namespace-scoped** credentials, `list_all` may return keys like `myns:Movies` while the API expects `Movies`. Omit `--namespace` (default): the CLI strips the prefix when fetching each collection. + +With **global/operator** credentials, pass the qualified name. Either use a full `--collection myns:Movies`, or combine short name and namespace: + +```bash +weaviate-cli get collection --collection Movies --namespace myns --json +``` + +When **listing** all collections as an operator across namespaces, use **`--list-qualified-keys`** so each server key is passed verbatim to `get` (avoids shell globbing on `*`). Alternatively quote: `--namespace '*'`. + +```bash +weaviate-cli get collection --list-qualified-keys --json +weaviate-cli get collection --namespace '*' --json +``` + +To list only collections under one namespace: + +```bash +weaviate-cli get collection --namespace myns --json +``` + ## Get Specific Collection ```bash weaviate-cli get collection --collection "CollectionName" --json diff --git a/.claude/skills/operating-weaviate-cli/references/namespaces.md b/.claude/skills/operating-weaviate-cli/references/namespaces.md new file mode 100644 index 0000000..8583431 --- /dev/null +++ b/.claude/skills/operating-weaviate-cli/references/namespaces.md @@ -0,0 +1,124 @@ +# Namespaces Reference + +Manage Weaviate namespaces, namespace-scoped DB users, and `manage_namespaces` +permissions. **Requires Weaviate 1.38.0+.** + +## CRUD + +```bash +weaviate-cli create namespace --name tenantswest --json +weaviate-cli create namespace --name tenantswest --home_node node1 --json +weaviate-cli get namespace --name tenantswest --json +weaviate-cli get namespace --all --json +weaviate-cli update namespace --name tenantswest --home_node node2 --json +weaviate-cli delete namespace --name tenantswest --json +``` + +### Namespace name rules + +A namespace name must match the server-side validation: + +- 3 to 36 characters long +- only lowercase letters and digits (regexp: `[a-z][a-z0-9]*`) +- must start with a letter + +The CLI does not pre-validate the name; the server returns an error if it is invalid. + +### Home node + +`--home_node` (optional on `create`, required on `update`) pins which cluster node +holds the namespace's shards. It must be a current storage candidate; when omitted on +`create`, the cluster picks one automatically. + +```bash +# Pin a home node at creation time +weaviate-cli create namespace --name tenantswest --home_node node1 --json + +# Move future placements to a different node (existing live shards are NOT moved) +weaviate-cli update namespace --name tenantswest --home_node node2 --json +``` + +`update namespace` is backed by the `PUT /namespaces/{name}` endpoint and only changes +the home node — it is the single mutable field on a namespace. + +### State + +Namespaces carry a read-only `state` field (`active` or `deleting`). When the server +reports it, `get namespace` surfaces it: a `State:` line in text output and a `"state"` +key in JSON. `home_node` is shown the same way (a `Home node:` line / `"home_node"` +key). Both keys are omitted when the server does not return them (e.g. older clusters). + +## Namespace-scoped DB users + +On namespace-enabled clusters, a dynamic DB user is bound to a namespace by giving it +a **namespace-qualified id** of the form `:`. There is no separate +`--namespace` flag — the namespace is part of the `--user_name` value, which the CLI +forwards verbatim to `client.users.db.create(user_id=...)`. The server derives the +namespace from the qualified id. + +```bash +# Create a user inside a namespace (qualified id ":") +weaviate-cli create user --user_name tenantswest:scoped-user --json +# Output JSON includes user_name and api_key. + +# Inspect — for a global operator, text output adds a "Namespace:" line and JSON adds +# a "namespace" key (the server still returns it in the user response). +weaviate-cli get user --user_name tenantswest:scoped-user --json +weaviate-cli get user --all --json +``` + +To create a non-namespaced user, pass an unqualified `--user_name` (no colon) — this is +the normal form on clusters where namespaces are not enabled. + +## RBAC: manage_namespaces + +```bash +# Single namespace +weaviate-cli create role --role_name NsAdmin -p manage_namespaces:tenantswest --json + +# Multiple namespaces in a single permission +weaviate-cli create role --role_name MultiNsAdmin -p manage_namespaces:tenantswest,tenantseast --json + +# Add or remove a permission on an existing role +weaviate-cli assign permission -p manage_namespaces:tenantssouth --role_name NsAdmin --json +weaviate-cli revoke permission -p manage_namespaces:tenantswest --role_name NsAdmin --json +``` + +`get role` includes a `Namespaces Permissions` block in text output and a +`permissions.namespaces` array in JSON output. + +### Restriction + +The wildcard form `manage_namespaces` (no namespace name) is rejected — every grant +must name an explicit namespace. Use a comma-separated list to grant on several at +once: + +```bash +-p manage_namespaces:ns1,ns2,ns3 +``` + +## Workflow + +1. `create namespace --name ` — provision the namespace. +2. `create role --role_name -p manage_namespaces:` — grant the management permission. +3. `create user --user_name :` — create a namespace-scoped DB user (qualified id). +4. `assign role --role_name --user_name ` — wire the role to the user. +5. Verify: `get namespace --name `, `get role --role_name `, `get user --user_name `. +6. Cleanup: `delete user` → `revoke role` (or `delete role`) → `delete namespace`. + +## Notes + +- The CLI delegates the version check (`>=1.38.0`) to the python client; older + servers return an error from the client itself. +- Namespaces are independent of multi-tenancy — they sit at the auth/account layer, + not the collection/tenant data layer. + +## Collections and `get collection --namespace` + +Collection API targets differ by credential type on namespace-enabled clusters: + +- **Namespace-scoped DB user:** use `get collection` without `--namespace` (the CLI + strips `namespace:` from keys returned by `list_all` when loading details). +- **Global operator:** qualify the collection, e.g. `get collection --collection Movies --namespace myns`, or pass `--collection myns:Movies`. For listing all namespaces at once use **`get collection --list-qualified-keys --json`** (recommended; unquoted `--namespace *` is expanded by the shell to filenames). Equivalent: `get collection --namespace '*' --json`. For one namespace only: `get collection --namespace myns --json`. + +See [collections.md](collections.md) for examples. diff --git a/.claude/skills/operating-weaviate-cli/references/rbac.md b/.claude/skills/operating-weaviate-cli/references/rbac.md index 24f05aa..818eb6d 100644 --- a/.claude/skills/operating-weaviate-cli/references/rbac.md +++ b/.claude/skills/operating-weaviate-cli/references/rbac.md @@ -80,6 +80,7 @@ Permissions use the format `action:target`. Multiple permissions can be specifie | Cluster | `read_cluster` | | Nodes | `read_nodes` | | Backups | `manage_backups` | +| Namespaces | `manage_namespaces` (Weaviate 1.38.0+) | **CRUD shorthands:** `crud_collections`, `crud_data`, `crud_tenants`, `crud_roles`, `crud_users`, `crud_aliases`, `cud_data`, `rd_data`, `cud_tenants`, `rd_tenants`, `rd_collections`, `cud_aliases`, `rd_aliases` @@ -95,8 +96,13 @@ Any combination of `c`, `r`, `u`, `d` prefix letters works (e.g., `cr_collection -p read_nodes:minimal -p manage_backups:Movies -p read_cluster +-p manage_namespaces:tenantswest +-p manage_namespaces:tenantswest,tenantseast ``` +`manage_namespaces` requires an explicit namespace name (or comma-separated list of names); +the wildcard form `manage_namespaces` alone is rejected. + ## Prerequisites 1. Cluster must have RBAC enabled diff --git a/requirements-dev.txt b/requirements-dev.txt index b856f00..bb6befb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,7 @@ -weaviate-client>=4.21.0 +# TODO: revert to a released version pin (e.g. weaviate-client>=4.x.x) once +# weaviate-python-client PR #2033 (https://github.com/weaviate/weaviate-python-client/pull/2033) +# merges and a release is cut. +weaviate-client @ git+https://github.com/weaviate/weaviate-python-client.git@26fe203e9b38b79551ae0972f023fdc42ff6cd8d click==8.1.7 twine pytest diff --git a/setup.cfg b/setup.cfg index 1169521..31cbb98 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,7 +37,7 @@ classifiers = include_package_data = True python_requires = >=3.9 install_requires = - weaviate-client>=4.21.0 + weaviate-client @ git+https://github.com/weaviate/weaviate-python-client.git@jose/namespaces click==8.1.7 semver>=3.0.2 numpy>=1.24.0 diff --git a/test/unittests/test_cli.py b/test/unittests/test_cli.py index 29fbc31..b012a33 100644 --- a/test/unittests/test_cli.py +++ b/test/unittests/test_cli.py @@ -50,3 +50,79 @@ def test_main_commands_registered(): assert "update" in main.commands assert "restore" in main.commands assert "query" in main.commands + + +def test_update_namespace_registered(): + assert "namespace" in main.commands["update"].commands + + +def test_create_namespace_forwards_home_node(cli_runner): + with ( + patch( + "weaviate_cli.commands.create.get_client_from_context", + return_value=MagicMock(), + ), + patch("weaviate_cli.commands.create.NamespaceManager") as mock_manager_cls, + ): + result = cli_runner.invoke( + main, + ["create", "namespace", "--name", "tenantswest", "--home_node", "node1"], + ) + assert result.exit_code == 0 + mock_manager_cls.return_value.create_namespace.assert_called_once_with( + name="tenantswest", home_node="node1", json_output=False + ) + + +def test_update_namespace_forwards_home_node(cli_runner): + with ( + patch( + "weaviate_cli.commands.update.get_client_from_context", + return_value=MagicMock(), + ), + patch("weaviate_cli.commands.update.NamespaceManager") as mock_manager_cls, + ): + result = cli_runner.invoke( + main, + ["update", "namespace", "--name", "tenantswest", "--home_node", "node2"], + ) + assert result.exit_code == 0 + mock_manager_cls.return_value.update_namespace.assert_called_once_with( + name="tenantswest", home_node="node2", json_output=False + ) + + +def test_update_namespace_requires_home_node(cli_runner): + result = cli_runner.invoke(main, ["update", "namespace", "--name", "tenantswest"]) + # Missing required --home_node is a usage error. + assert result.exit_code == 2 + assert "--home_node" in result.output + + +def test_create_user_forwards_qualified_user_name(cli_runner): + # A namespace-scoped user is created by passing a namespace-qualified id + # (":") as --user_name; the CLI forwards it verbatim. + with ( + patch( + "weaviate_cli.commands.create.get_client_from_context", + return_value=MagicMock(), + ), + patch("weaviate_cli.commands.create.UserManager") as mock_manager_cls, + ): + mock_manager_cls.return_value.create_user.return_value = "api-key" + result = cli_runner.invoke( + main, ["create", "user", "--user_name", "tenantswest:scoped"] + ) + assert result.exit_code == 0 + mock_manager_cls.return_value.create_user.assert_called_once_with( + user_name="tenantswest:scoped" + ) + + +def test_create_user_rejects_namespace_option(cli_runner): + # The dropped --namespace flag must no longer be accepted (usage error). + result = cli_runner.invoke( + main, ["create", "user", "--user_name", "scoped", "--namespace", "tenantswest"] + ) + assert result.exit_code == 2 + assert "--namespace" in result.output diff --git a/test/unittests/test_cli_bool_contract.py b/test/unittests/test_cli_bool_contract.py new file mode 100644 index 0000000..f15fcc0 --- /dev/null +++ b/test/unittests/test_cli_bool_contract.py @@ -0,0 +1,294 @@ +""" +End-to-end CLI regression tests for the bool / Optional contract. + +Several methods of the weaviate-python-client are *not* the usual "raise on +failure" style. Instead, they accept multiple HTTP status codes as "ok" and +return: + + * ``bool`` -- ``False`` means a logical no-op (404 not found, 409 + already in target state, ...) + * ``Optional[X]`` -- ``None`` means "not found" (404) + +The CLI must surface those as a non-zero exit code with a clear ``Error:`` +message -- never silently report success. These tests lock in that contract +end-to-end (CliRunner -> command -> manager -> mocked client method) so the +bug class cannot creep back in. + +If you add a new caller of one of these client APIs, add a test here. +""" + +import pytest +from click.testing import CliRunner +from unittest.mock import MagicMock, patch + +from cli import main + + +@pytest.fixture +def cli_runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def fake_client() -> MagicMock: + """A MagicMock standing in for a real WeaviateClient.""" + return MagicMock() + + +def _invoke(cli_runner: CliRunner, fake_client: MagicMock, command_module: str, args): + """Invoke ``main`` with ``args`` while patching + ``get_client_from_context`` in the targeted command module so the CLI gets + our fake client instead of opening a real connection.""" + target = f"{command_module}.get_client_from_context" + with patch(target, return_value=fake_client): + return cli_runner.invoke(main, args) + + +# --------------------------------------------------------------------------- +# delete user -- client.users.db.delete() returns False on 404 +# --------------------------------------------------------------------------- + + +def test_cli_delete_user_not_found_exits_nonzero(cli_runner, fake_client): + fake_client.users.db.delete.return_value = False + + result = _invoke( + cli_runner, + fake_client, + "weaviate_cli.commands.delete", + ["delete", "user", "--user_name", "ghost"], + ) + + assert result.exit_code == 1, result.output + assert "User 'ghost' not found." in result.output + assert "deleted successfully" not in result.output + + +def test_cli_delete_user_success(cli_runner, fake_client): + fake_client.users.db.delete.return_value = True + + result = _invoke( + cli_runner, + fake_client, + "weaviate_cli.commands.delete", + ["delete", "user", "--user_name", "alice"], + ) + + assert result.exit_code == 0, result.output + assert "alice" in result.output + assert "deleted successfully" in result.output + + +# --------------------------------------------------------------------------- +# update user --activate / --deactivate -- bool=False on 409 +# --------------------------------------------------------------------------- + + +def test_cli_update_user_activate_already_active_exits_nonzero(cli_runner, fake_client): + fake_client.users.db.activate.return_value = False + + result = _invoke( + cli_runner, + fake_client, + "weaviate_cli.commands.update", + ["update", "user", "--user_name", "alice", "--activate"], + ) + + assert result.exit_code == 1, result.output + assert "already active" in result.output + assert "activated successfully" not in result.output + + +def test_cli_update_user_activate_success(cli_runner, fake_client): + fake_client.users.db.activate.return_value = True + + result = _invoke( + cli_runner, + fake_client, + "weaviate_cli.commands.update", + ["update", "user", "--user_name", "alice", "--activate"], + ) + + assert result.exit_code == 0, result.output + assert "activated successfully" in result.output + + +def test_cli_update_user_deactivate_already_deactivated_exits_nonzero( + cli_runner, fake_client +): + fake_client.users.db.deactivate.return_value = False + + result = _invoke( + cli_runner, + fake_client, + "weaviate_cli.commands.update", + ["update", "user", "--user_name", "alice", "--deactivate"], + ) + + assert result.exit_code == 1, result.output + assert "already deactivated" in result.output + assert "deactivated successfully" not in result.output + + +def test_cli_update_user_deactivate_success(cli_runner, fake_client): + fake_client.users.db.deactivate.return_value = True + + result = _invoke( + cli_runner, + fake_client, + "weaviate_cli.commands.update", + ["update", "user", "--user_name", "alice", "--deactivate"], + ) + + assert result.exit_code == 0, result.output + assert "deactivated successfully" in result.output + + +# --------------------------------------------------------------------------- +# get user -- client.users.db.get() returns None on 404 +# --------------------------------------------------------------------------- + + +def test_cli_get_user_not_found_exits_nonzero(cli_runner, fake_client): + fake_client.users.db.get.return_value = None + + result = _invoke( + cli_runner, + fake_client, + "weaviate_cli.commands.get", + ["get", "user", "--user_name", "ghost"], + ) + + assert result.exit_code == 1, result.output + assert "User 'ghost' not found as a DB user." in result.output + # The Python client cannot fetch OIDC users by name, so the error must + # nudge callers toward `get role --user_type oidc` instead of leaving + # them stuck on a bare "not found". + assert "OIDC user" in result.output + assert "get role --user_name ghost --user_type oidc" in result.output + + +# --------------------------------------------------------------------------- +# delete alias -- client.alias.delete() returns False on 404 +# --------------------------------------------------------------------------- + + +def test_cli_delete_alias_not_found_exits_nonzero(cli_runner, fake_client): + fake_client.alias.delete.return_value = False + + result = _invoke( + cli_runner, + fake_client, + "weaviate_cli.commands.delete", + ["delete", "alias", "ghost_alias"], + ) + + assert result.exit_code == 1, result.output + assert "Alias 'ghost_alias' not found." in result.output + assert "deleted successfully" not in result.output + + +def test_cli_delete_alias_success(cli_runner, fake_client): + fake_client.alias.delete.return_value = True + + result = _invoke( + cli_runner, + fake_client, + "weaviate_cli.commands.delete", + ["delete", "alias", "my_alias"], + ) + + assert result.exit_code == 0, result.output + assert "deleted successfully" in result.output + + +# --------------------------------------------------------------------------- +# update alias -- client.alias.update() returns False on 404 +# --------------------------------------------------------------------------- + + +def test_cli_update_alias_not_found_exits_nonzero(cli_runner, fake_client): + fake_client.alias.update.return_value = False + + result = _invoke( + cli_runner, + fake_client, + "weaviate_cli.commands.update", + ["update", "alias", "ghost", "MyCollection"], + ) + + assert result.exit_code == 1, result.output + assert "Alias 'ghost' not found." in result.output + assert "updated successfully" not in result.output + + +def test_cli_update_alias_success(cli_runner, fake_client): + fake_client.alias.update.return_value = True + + result = _invoke( + cli_runner, + fake_client, + "weaviate_cli.commands.update", + ["update", "alias", "my_alias", "MyCollection"], + ) + + assert result.exit_code == 0, result.output + assert "updated successfully" in result.output + + +# --------------------------------------------------------------------------- +# delete data --uuid -- collection.data.delete_by_id() returns False on 404 +# --------------------------------------------------------------------------- + + +def test_cli_delete_data_by_uuid_not_found_exits_nonzero(cli_runner, fake_client): + # Pretend the collection exists and is not multi-tenant. + fake_client.collections.exists.return_value = True + mock_collection = MagicMock() + mock_collection.name = "TestCollection" + mock_collection.config.get.return_value.multi_tenancy_config.enabled = False + mock_collection.with_consistency_level.return_value.data.delete_by_id.return_value = ( + False + ) + fake_client.collections.get.return_value = mock_collection + + result = _invoke( + cli_runner, + fake_client, + "weaviate_cli.commands.delete", + [ + "delete", + "data", + "--collection", + "TestCollection", + "--uuid", + "00000000-0000-0000-0000-000000000000", + ], + ) + + assert result.exit_code == 1, result.output + assert "00000000-0000-0000-0000-000000000000" in result.output + assert "not found" in result.output + + +# --------------------------------------------------------------------------- +# get alias -- client.alias.get() returns None on 404 (CLI handles it +# explicitly, but pin the contract). +# --------------------------------------------------------------------------- + + +def test_cli_get_alias_not_found_reports_not_found(cli_runner, fake_client): + fake_client.alias.get.return_value = None + + result = _invoke( + cli_runner, + fake_client, + "weaviate_cli.commands.get", + ["get", "alias", "--alias_name", "ghost"], + ) + + # The get command currently treats a missing alias as a soft miss (exit 0 + # with an explanatory line). The important guard is that we never silently + # render a None alias as if it existed. + assert result.exit_code == 0, result.output + assert "Alias 'ghost' not found." in result.output diff --git a/test/unittests/test_managers/test_alias_manager.py b/test/unittests/test_managers/test_alias_manager.py index 1226a9a..c92b012 100644 --- a/test/unittests/test_managers/test_alias_manager.py +++ b/test/unittests/test_managers/test_alias_manager.py @@ -58,6 +58,7 @@ def test_update_alias_success( """ alias_name = "test_alias" collection_name = "NewTestCollection" + mock_client.alias.update.return_value = True alias_manager.update_alias(alias_name, collection_name) @@ -66,6 +67,22 @@ def test_update_alias_success( ) +def test_update_alias_not_found_raises( + alias_manager: AliasManager, mock_client: MagicMock +) -> None: + """The client returns False on 404 instead of raising.""" + alias_name = "missing_alias" + mock_client.alias.update.return_value = False + + with pytest.raises(Exception) as exc_info: + alias_manager.update_alias(alias_name, "TargetCollection") + + assert ( + f"Error updating alias '{alias_name}': Alias '{alias_name}' not found." + in str(exc_info.value) + ) + + def test_update_alias_error( alias_manager: AliasManager, mock_client: MagicMock ) -> None: @@ -120,12 +137,29 @@ def test_delete_alias_success( Test successful alias deletion. """ alias_name = "test_alias" + mock_client.alias.delete.return_value = True alias_manager.delete_alias(alias_name) mock_client.alias.delete.assert_called_once_with(alias_name=alias_name) +def test_delete_alias_not_found_raises( + alias_manager: AliasManager, mock_client: MagicMock +) -> None: + """The client returns False on 404 instead of raising.""" + alias_name = "missing_alias" + mock_client.alias.delete.return_value = False + + with pytest.raises(Exception) as exc_info: + alias_manager.delete_alias(alias_name) + + assert ( + f"Error deleting alias '{alias_name}': Alias '{alias_name}' not found." + in str(exc_info.value) + ) + + def test_delete_alias_error( alias_manager: AliasManager, mock_client: MagicMock ) -> None: @@ -219,6 +253,7 @@ def test_delete_alias_success_text( alias_manager: AliasManager, mock_client: MagicMock, capsys ) -> None: """Test delete_alias emits a text success message.""" + mock_client.alias.delete.return_value = True alias_manager.delete_alias("my_alias", json_output=False) mock_client.alias.delete.assert_called_once_with(alias_name="my_alias") @@ -231,6 +266,7 @@ def test_delete_alias_success_json( alias_manager: AliasManager, mock_client: MagicMock, capsys ) -> None: """Test delete_alias emits a JSON success message.""" + mock_client.alias.delete.return_value = True alias_manager.delete_alias("my_alias", json_output=True) out = capsys.readouterr().out @@ -283,6 +319,7 @@ def test_update_alias_json_output( alias_manager: AliasManager, mock_client: MagicMock, capsys ) -> None: """Test update_alias emits JSON when json_output=True.""" + mock_client.alias.update.return_value = True alias_manager.update_alias("my_alias", "NewCollection", json_output=True) out = capsys.readouterr().out @@ -295,6 +332,7 @@ def test_update_alias_text_output( alias_manager: AliasManager, mock_client: MagicMock, capsys ) -> None: """Test update_alias emits text when json_output=False.""" + mock_client.alias.update.return_value = True alias_manager.update_alias("my_alias", "NewCollection", json_output=False) out = capsys.readouterr().out diff --git a/test/unittests/test_managers/test_collection_manager.py b/test/unittests/test_managers/test_collection_manager.py index a677b25..3664e2e 100644 --- a/test/unittests/test_managers/test_collection_manager.py +++ b/test/unittests/test_managers/test_collection_manager.py @@ -1114,3 +1114,239 @@ def test_update_collection_async_replication_config_warns_on_old_version( captured.out + captured.err ) mock_collection.config.update.assert_called_once() + + +def _collection_schema_mock(multitenant: bool = False) -> MagicMock: + return MagicMock( + vector_config=None, + vectorizer=MagicMock(value="none"), + vector_index_type=MagicMock(value="hnsw"), + multi_tenancy_config=MagicMock( + enabled=multitenant, + auto_tenant_creation=False, + auto_tenant_activation=False, + ), + replication_config=MagicMock(factor=1), + ) + + +def test_get_collection_single_qualifies_with_namespace(mock_client): + mock_collections = MagicMock() + mock_client.collections = mock_collections + mock_collections.exists.return_value = True + mock_col = MagicMock() + mock_col.config.get.return_value.to_dict.return_value = {"foo": 1} + mock_collections.get.return_value = mock_col + + manager = CollectionManager(mock_client) + manager.get_collection( + collection="Movies", namespace="customer1", json_output=False + ) + + mock_collections.exists.assert_called_once_with("customer1:Movies") + mock_collections.get.assert_called_once_with("customer1:Movies") + + +def test_get_collection_single_keeps_qualified_when_collection_has_colon( + mock_client, +): + mock_collections = MagicMock() + mock_client.collections = mock_collections + mock_collections.exists.return_value = True + mock_col = MagicMock() + mock_col.config.get.return_value.to_dict.return_value = {} + mock_collections.get.return_value = mock_col + + manager = CollectionManager(mock_client) + manager.get_collection( + collection="customer1:Movies", namespace="customer1", json_output=False + ) + + mock_collections.get.assert_called_once_with("customer1:Movies") + + +def test_get_collection_single_namespace_star_requires_qualified_name(mock_client): + mock_client.collections = MagicMock() + manager = CollectionManager(mock_client) + with pytest.raises(Exception, match="--namespace"): + manager.get_collection(collection="Movies", namespace="*", json_output=False) + + +def test_get_collection_single_qualified_without_namespace_unchanged(mock_client): + """Global-style full name without --namespace must not be stripped.""" + mock_collections = MagicMock() + mock_client.collections = mock_collections + mock_collections.exists.return_value = True + mock_col = MagicMock() + mock_col.config.get.return_value.to_dict.return_value = {} + mock_collections.get.return_value = mock_col + + manager = CollectionManager(mock_client) + manager.get_collection( + collection="customer1:Movies", namespace=None, json_output=False + ) + + mock_collections.get.assert_called_once_with("customer1:Movies") + + +def test_get_collection_single_qualified_with_namespace_star(mock_client): + mock_collections = MagicMock() + mock_client.collections = mock_collections + mock_collections.exists.return_value = True + mock_col = MagicMock() + mock_col.config.get.return_value.to_dict.return_value = {} + mock_collections.get.return_value = mock_col + + manager = CollectionManager(mock_client) + manager.get_collection( + collection="customer1:Movies", namespace="*", json_output=False + ) + + mock_collections.get.assert_called_once_with("customer1:Movies") + + +def test_get_collection_list_strips_without_namespace(mock_client): + mock_collections = MagicMock() + mock_client.collections = mock_collections + mock_collections.list_all.return_value = {"customer1:Movies": object()} + + mock_col = MagicMock() + mock_collections.get.return_value = mock_col + mock_col.config.get.return_value = _collection_schema_mock() + + manager = CollectionManager(mock_client) + manager.get_collection(collection=None, json_output=True, namespace=None) + + mock_collections.get.assert_called_once_with("Movies") + + +def test_get_collection_list_uses_qualified_with_namespace_star(mock_client): + mock_collections = MagicMock() + mock_client.collections = mock_collections + mock_collections.list_all.return_value = ["customer1:M1", "ns2:M2"] + + mock_col = MagicMock() + mock_collections.get.return_value = mock_col + mock_col.config.get.return_value = _collection_schema_mock() + + manager = CollectionManager(mock_client) + manager.get_collection(collection=None, json_output=True, namespace="*") + + assert mock_collections.get.call_count == 2 + assert mock_collections.get.call_args_list[0].args[0] == "customer1:M1" + assert mock_collections.get.call_args_list[1].args[0] == "ns2:M2" + + +def test_get_collection_list_uses_qualified_with_list_qualified_keys(mock_client): + mock_collections = MagicMock() + mock_client.collections = mock_collections + mock_collections.list_all.return_value = ["customer1:M1", "ns2:M2"] + + mock_col = MagicMock() + mock_collections.get.return_value = mock_col + mock_col.config.get.return_value = _collection_schema_mock() + + manager = CollectionManager(mock_client) + manager.get_collection( + collection=None, + json_output=True, + namespace=None, + list_qualified_keys=True, + ) + + assert mock_collections.get.call_count == 2 + assert mock_collections.get.call_args_list[0].args[0] == "customer1:M1" + assert mock_collections.get.call_args_list[1].args[0] == "ns2:M2" + + +def test_get_collection_list_qualified_keys_conflicts_with_namespace(mock_client): + manager = CollectionManager(MagicMock()) + with pytest.raises(Exception, match="not both"): + manager.get_collection( + collection=None, + json_output=True, + namespace="customer1", + list_qualified_keys=True, + ) + + +def test_get_collection_list_qualified_keys_with_collection_rejected(mock_client): + manager = CollectionManager(MagicMock()) + with pytest.raises(Exception, match="list-qualified-keys"): + manager.get_collection( + collection="Movies", + json_output=True, + list_qualified_keys=True, + ) + + +def test_get_collection_list_no_namespace_404_raises_hint_for_namespaced_keys( + mock_client, +): + """When config.get() 404s on a stripped key, surface a namespace hint.""" + mock_collections = MagicMock() + mock_client.collections = mock_collections + mock_collections.list_all.return_value = [ + "customer1:Movies", + "customer1:Articles", + "otherteam:Books", + ] + mock_col = MagicMock() + mock_collections.get.return_value = mock_col + mock_col.config.get.side_effect = Exception( + "Collection configuration could not be retrieved.! Unexpected status code: 404" + ) + + manager = CollectionManager(mock_client) + with pytest.raises(Exception) as exc_info: + manager.get_collection(collection=None, json_output=False, namespace=None) + + msg = str(exc_info.value) + assert "--list-qualified-keys" in msg + assert "customer1" in msg + assert "otherteam" in msg + + +def test_get_collection_list_no_namespace_non_404_error_propagates(mock_client): + """Non-404 errors on stripped keys must propagate without the namespace hint.""" + mock_collections = MagicMock() + mock_client.collections = mock_collections + mock_collections.list_all.return_value = ["customer1:Movies"] + mock_col = MagicMock() + mock_collections.get.return_value = mock_col + mock_col.config.get.side_effect = Exception("Connection refused") + + manager = CollectionManager(mock_client) + with pytest.raises(Exception, match="Connection refused"): + manager.get_collection(collection=None, json_output=False, namespace=None) + + +def test_get_collection_list_no_namespace_no_hint_for_plain_keys(mock_client): + """Plain (non-prefixed) keys should work without any namespace hint.""" + mock_collections = MagicMock() + mock_client.collections = mock_collections + mock_collections.list_all.return_value = ["Movies", "Articles"] + + mock_col = MagicMock() + mock_collections.get.return_value = mock_col + mock_col.config.get.return_value = _collection_schema_mock() + + manager = CollectionManager(mock_client) + manager.get_collection(collection=None, json_output=True, namespace=None) + + assert mock_collections.get.call_count == 2 + + +def test_get_collection_list_filters_by_namespace(mock_client): + mock_collections = MagicMock() + mock_client.collections = mock_collections + mock_collections.list_all.return_value = ["customer1:A", "other:B"] + + mock_col = MagicMock() + mock_collections.get.return_value = mock_col + mock_col.config.get.return_value = _collection_schema_mock() + + manager = CollectionManager(mock_client) + manager.get_collection(collection=None, json_output=True, namespace="customer1") + + mock_collections.get.assert_called_once_with("customer1:A") diff --git a/test/unittests/test_managers/test_data_manager.py b/test/unittests/test_managers/test_data_manager.py index a3074bd..79532a8 100644 --- a/test/unittests/test_managers/test_data_manager.py +++ b/test/unittests/test_managers/test_data_manager.py @@ -448,6 +448,31 @@ def test_delete_data(mock_client): mock_client.collections.get.assert_called_once_with("TestCollection") +def test_delete_data_by_uuid_not_found_raises(mock_client): + """delete_by_id returns False on a 404; the manager must surface that as an error.""" + manager = DataManager(mock_client) + mock_collections = MagicMock() + mock_client.collections = mock_collections + mock_collections.exists.return_value = True + + mock_collection = MagicMock() + mock_collection.name = "TestCollection" + mock_collection.config.get.return_value.multi_tenancy_config.enabled = False + mock_collection.with_consistency_level.return_value.data.delete_by_id.return_value = ( + False + ) + mock_client.collections.get.return_value = mock_collection + + with pytest.raises( + Exception, match="Object '00000000-0000-0000-0000-000000000000' not found" + ): + manager.delete_data( + collection="TestCollection", + limit=1, + uuid="00000000-0000-0000-0000-000000000000", + ) + + # --------------------------------------------------------------------------- # update_data – parallel tenant processing # --------------------------------------------------------------------------- diff --git a/test/unittests/test_managers/test_namespace_manager.py b/test/unittests/test_managers/test_namespace_manager.py new file mode 100644 index 0000000..5c30517 --- /dev/null +++ b/test/unittests/test_managers/test_namespace_manager.py @@ -0,0 +1,297 @@ +import json +from types import SimpleNamespace + +import pytest +from unittest.mock import MagicMock + +from weaviate_cli.managers.namespace_manager import NamespaceManager + + +@pytest.fixture +def namespace_manager(mock_client: MagicMock) -> NamespaceManager: + mock_client.namespaces = MagicMock() + return NamespaceManager(mock_client) + + +def test_create_namespace_success_text( + namespace_manager: NamespaceManager, mock_client: MagicMock, capsys +) -> None: + name = "tenants_west" + mock_client.namespaces.create.return_value = SimpleNamespace(name=name) + + result = namespace_manager.create_namespace(name=name) + + mock_client.namespaces.create.assert_called_once_with(name=name) + assert result.name == name + out = capsys.readouterr().out + assert name in out + assert "created successfully" in out + + +def test_create_namespace_success_json( + namespace_manager: NamespaceManager, mock_client: MagicMock, capsys +) -> None: + name = "tenants_west" + mock_client.namespaces.create.return_value = SimpleNamespace(name=name) + + namespace_manager.create_namespace(name=name, json_output=True) + + out = capsys.readouterr().out + payload = json.loads(out) + assert payload["status"] == "success" + assert payload["namespace"] == name + + +def test_create_namespace_requires_name(namespace_manager: NamespaceManager) -> None: + with pytest.raises(Exception) as exc_info: + namespace_manager.create_namespace(name="") + assert "Namespace name is required." in str(exc_info.value) + + +def test_create_namespace_error( + namespace_manager: NamespaceManager, mock_client: MagicMock +) -> None: + mock_client.namespaces.create.side_effect = Exception("boom") + + with pytest.raises(Exception) as exc_info: + namespace_manager.create_namespace(name="ns") + assert "Error creating namespace 'ns': boom" in str(exc_info.value) + + +def test_get_namespace_returns_namespace( + namespace_manager: NamespaceManager, mock_client: MagicMock +) -> None: + expected = SimpleNamespace(name="ns") + mock_client.namespaces.get.return_value = expected + + result = namespace_manager.get_namespace(name="ns") + + assert result is expected + mock_client.namespaces.get.assert_called_once_with(name="ns") + + +def test_get_namespace_returns_none_when_missing( + namespace_manager: NamespaceManager, mock_client: MagicMock +) -> None: + mock_client.namespaces.get.return_value = None + + assert namespace_manager.get_namespace(name="ns") is None + + +def test_get_namespace_requires_name(namespace_manager: NamespaceManager) -> None: + with pytest.raises(Exception) as exc_info: + namespace_manager.get_namespace(name="") + assert "Namespace name is required." in str(exc_info.value) + + +def test_list_namespaces_success( + namespace_manager: NamespaceManager, mock_client: MagicMock +) -> None: + expected = [SimpleNamespace(name="a"), SimpleNamespace(name="b")] + mock_client.namespaces.list_all.return_value = expected + + result = namespace_manager.list_namespaces() + + assert result == expected + mock_client.namespaces.list_all.assert_called_once_with() + + +def test_list_namespaces_error( + namespace_manager: NamespaceManager, mock_client: MagicMock +) -> None: + mock_client.namespaces.list_all.side_effect = Exception("bad") + + with pytest.raises(Exception) as exc_info: + namespace_manager.list_namespaces() + assert "Error listing namespaces: bad" in str(exc_info.value) + + +def test_delete_namespace_success_text( + namespace_manager: NamespaceManager, mock_client: MagicMock, capsys +) -> None: + namespace_manager.delete_namespace(name="ns") + + mock_client.namespaces.delete.assert_called_once_with(name="ns") + out = capsys.readouterr().out + assert "ns" in out + assert "deleted successfully" in out + + +def test_delete_namespace_success_json( + namespace_manager: NamespaceManager, mock_client: MagicMock, capsys +) -> None: + namespace_manager.delete_namespace(name="ns", json_output=True) + + payload = json.loads(capsys.readouterr().out) + assert payload["status"] == "success" + assert "ns" in payload["message"] + + +def test_delete_namespace_requires_name( + namespace_manager: NamespaceManager, +) -> None: + with pytest.raises(Exception) as exc_info: + namespace_manager.delete_namespace(name="") + assert "Namespace name is required." in str(exc_info.value) + + +def test_delete_namespace_error( + namespace_manager: NamespaceManager, mock_client: MagicMock +) -> None: + mock_client.namespaces.delete.side_effect = Exception("nope") + + with pytest.raises(Exception) as exc_info: + namespace_manager.delete_namespace(name="ns") + assert "Error deleting namespace 'ns': nope" in str(exc_info.value) + + +def test_print_namespace_text(namespace_manager: NamespaceManager, capsys) -> None: + namespace_manager.print_namespace(SimpleNamespace(name="ns")) + assert capsys.readouterr().out.strip() == "Namespace: ns" + + +def test_print_namespace_json(namespace_manager: NamespaceManager, capsys) -> None: + namespace_manager.print_namespace(SimpleNamespace(name="ns"), json_output=True) + payload = json.loads(capsys.readouterr().out) + assert payload == {"name": "ns"} + + +def test_create_namespace_with_home_node( + namespace_manager: NamespaceManager, mock_client: MagicMock, capsys +) -> None: + name = "tenants_west" + mock_client.namespaces.create.return_value = SimpleNamespace( + name=name, home_node="node1", state="active" + ) + + namespace_manager.create_namespace(name=name, home_node="node1") + + mock_client.namespaces.create.assert_called_once_with(name=name, home_node="node1") + assert "created successfully" in capsys.readouterr().out + + +def test_create_namespace_omits_home_node_when_absent( + namespace_manager: NamespaceManager, mock_client: MagicMock +) -> None: + name = "tenants_west" + mock_client.namespaces.create.return_value = SimpleNamespace(name=name) + + namespace_manager.create_namespace(name=name) + + # home_node must not be forwarded when not provided (older-cluster compatible). + mock_client.namespaces.create.assert_called_once_with(name=name) + + +def test_create_namespace_json_includes_home_node_and_state( + namespace_manager: NamespaceManager, mock_client: MagicMock, capsys +) -> None: + name = "tenants_west" + mock_client.namespaces.create.return_value = SimpleNamespace( + name=name, home_node="node1", state="active" + ) + + namespace_manager.create_namespace(name=name, home_node="node1", json_output=True) + + payload = json.loads(capsys.readouterr().out) + assert payload["status"] == "success" + assert payload["namespace"] == name + assert payload["home_node"] == "node1" + assert payload["state"] == "active" + + +def test_namespace_to_dict_full() -> None: + ns = SimpleNamespace(name="ns", home_node="node1", state="active") + assert NamespaceManager.namespace_to_dict(ns) == { + "name": "ns", + "home_node": "node1", + "state": "active", + } + + +def test_namespace_to_dict_omits_unset_fields() -> None: + assert NamespaceManager.namespace_to_dict(SimpleNamespace(name="ns")) == { + "name": "ns" + } + ns = SimpleNamespace(name="ns", home_node=None, state=None) + assert NamespaceManager.namespace_to_dict(ns) == {"name": "ns"} + + +def test_print_namespace_text_with_home_node_and_state( + namespace_manager: NamespaceManager, capsys +) -> None: + namespace_manager.print_namespace( + SimpleNamespace(name="ns", home_node="node1", state="active") + ) + out = capsys.readouterr().out + assert "Namespace: ns" in out + assert "Home node: node1" in out + assert "State: active" in out + + +def test_print_namespace_json_with_home_node_and_state( + namespace_manager: NamespaceManager, capsys +) -> None: + namespace_manager.print_namespace( + SimpleNamespace(name="ns", home_node="node1", state="active"), + json_output=True, + ) + payload = json.loads(capsys.readouterr().out) + assert payload == {"name": "ns", "home_node": "node1", "state": "active"} + + +def test_update_namespace_success_text( + namespace_manager: NamespaceManager, mock_client: MagicMock, capsys +) -> None: + mock_client.namespaces.update.return_value = SimpleNamespace( + name="ns", home_node="node2", state="active" + ) + + result = namespace_manager.update_namespace(name="ns", home_node="node2") + + mock_client.namespaces.update.assert_called_once_with(name="ns", home_node="node2") + assert result.name == "ns" + out = capsys.readouterr().out + assert "updated successfully" in out + assert "node2" in out + + +def test_update_namespace_success_json( + namespace_manager: NamespaceManager, mock_client: MagicMock, capsys +) -> None: + mock_client.namespaces.update.return_value = SimpleNamespace( + name="ns", home_node="node2", state="active" + ) + + namespace_manager.update_namespace(name="ns", home_node="node2", json_output=True) + + payload = json.loads(capsys.readouterr().out) + assert payload["status"] == "success" + assert payload["namespace"] == "ns" + assert payload["home_node"] == "node2" + assert payload["state"] == "active" + + +def test_update_namespace_requires_name( + namespace_manager: NamespaceManager, +) -> None: + with pytest.raises(Exception) as exc_info: + namespace_manager.update_namespace(name="", home_node="node2") + assert "Namespace name is required." in str(exc_info.value) + + +def test_update_namespace_requires_home_node( + namespace_manager: NamespaceManager, +) -> None: + with pytest.raises(Exception) as exc_info: + namespace_manager.update_namespace(name="ns", home_node="") + assert "Home node is required." in str(exc_info.value) + + +def test_update_namespace_error( + namespace_manager: NamespaceManager, mock_client: MagicMock +) -> None: + mock_client.namespaces.update.side_effect = Exception("nope") + + with pytest.raises(Exception) as exc_info: + namespace_manager.update_namespace(name="ns", home_node="node2") + assert "Error updating namespace 'ns': nope" in str(exc_info.value) diff --git a/test/unittests/test_managers/test_user_manager.py b/test/unittests/test_managers/test_user_manager.py index 0385c07..7891cde 100644 --- a/test/unittests/test_managers/test_user_manager.py +++ b/test/unittests/test_managers/test_user_manager.py @@ -68,6 +68,20 @@ def test_create_user_no_name(user_manager): assert str(exc_info.value) == "User name is required." +def test_create_user_qualified_id_forwarded_as_user_id(user_manager): + # The server derives the namespace from a namespace-qualified id of the + # form ":"; the CLI must forward it verbatim as user_id + # without a separate namespace kwarg. + qualified_id = "my_ns:scoped_user" + expected_api_key = "ns-key" + user_manager.client.users.db.create.return_value = expected_api_key + + result = user_manager.create_user(user_name=qualified_id) + + assert result == expected_api_key + user_manager.client.users.db.create.assert_called_once_with(user_id=qualified_id) + + def test_create_user_error(user_manager): # Arrange user_name = "test_user" @@ -96,7 +110,7 @@ def test_update_user_rotate_key_success(user_manager): def test_update_user_activate_success(user_manager): # Arrange user_name = "test_user" - user_manager.client.users.db.activate.return_value = None + user_manager.client.users.db.activate.return_value = True # Act result = user_manager.update_user(user_name=user_name, activate=True) @@ -106,10 +120,23 @@ def test_update_user_activate_success(user_manager): user_manager.client.users.db.activate.assert_called_once_with(user_id=user_name) +def test_update_user_activate_already_active_raises(user_manager): + # The client returns False on a 409 (user already active) instead of raising. + user_name = "test_user" + user_manager.client.users.db.activate.return_value = False + + with pytest.raises(Exception) as exc_info: + user_manager.update_user(user_name=user_name, activate=True) + assert ( + str(exc_info.value) + == f"Error updating user '{user_name}': User '{user_name}' is already active." + ) + + def test_update_user_deactivate_success(user_manager): # Arrange user_name = "test_user" - user_manager.client.users.db.deactivate.return_value = None + user_manager.client.users.db.deactivate.return_value = True # Act result = user_manager.update_user(user_name=user_name, deactivate=True) @@ -119,6 +146,19 @@ def test_update_user_deactivate_success(user_manager): user_manager.client.users.db.deactivate.assert_called_once_with(user_id=user_name) +def test_update_user_deactivate_already_deactivated_raises(user_manager): + # The client returns False on a 409 (already deactivated) instead of raising. + user_name = "test_user" + user_manager.client.users.db.deactivate.return_value = False + + with pytest.raises(Exception) as exc_info: + user_manager.update_user(user_name=user_name, deactivate=True) + assert ( + str(exc_info.value) + == f"Error updating user '{user_name}': User '{user_name}' is already deactivated." + ) + + def test_update_user_invalid_combination(user_manager): # Arrange user_name = "test_user" @@ -167,6 +207,7 @@ def test_update_user_error(user_manager): def test_delete_user_success(user_manager): # Arrange user_name = "test_user" + user_manager.client.users.db.delete.return_value = True # Act user_manager.delete_user(user_name) @@ -175,6 +216,16 @@ def test_delete_user_success(user_manager): user_manager.client.users.db.delete.assert_called_once_with(user_id=user_name) +def test_delete_user_not_found_raises(user_manager): + # The client returns False on 404 instead of raising. + user_name = "missing_user" + user_manager.client.users.db.delete.return_value = False + + with pytest.raises(Exception) as exc_info: + user_manager.delete_user(user_name) + assert str(exc_info.value) == f"User '{user_name}' not found." + + def test_delete_user_no_name(user_manager): # Act & Assert with pytest.raises(Exception) as exc_info: @@ -193,6 +244,43 @@ def test_delete_user_error(user_manager): assert str(exc_info.value) == f"Error deleting user '{user_name}': Test error" +def test_get_user_by_name_returns_user(user_manager): + user_name = "existing" + expected = Mock() + user_manager.client.users.db.get.return_value = expected + + result = user_manager.get_user(user_name=user_name) + + assert result is expected + user_manager.client.users.db.get.assert_called_once_with(user_id=user_name) + + +def test_get_user_by_name_not_found_raises(user_manager): + # The client returns None on 404 instead of raising. When the DB lookup + # comes up empty we hint that the user might be OIDC, since the Python + # client cannot fetch OIDC users by name and a bare "not found" would be + # misleading in that case. + user_name = "missing" + user_manager.client.users.db.get.return_value = None + + with pytest.raises(Exception) as exc_info: + user_manager.get_user(user_name=user_name) + msg = str(exc_info.value) + assert f"User '{user_name}' not found as a DB user." in msg + assert "OIDC user" in msg + assert f"get role --user_name {user_name} --user_type oidc" in msg + + +def test_get_user_no_name_returns_current_user(user_manager): + expected = Mock() + user_manager.client.users.get_my_user.return_value = expected + + result = user_manager.get_user() + + assert result is expected + user_manager.client.users.get_my_user.assert_called_once_with() + + def test_add_role_db_success(user_manager): # Arrange role_name = ("test_role",) @@ -326,6 +414,50 @@ def test_print_user(user_manager, capsys): assert captured.out == f"User: {user}\n" +def test_print_db_user_with_namespace_text(user_manager, capsys): + user = Mock() + user.user_id = "scoped_user" + user.active = True + user.user_type = Mock(name="db") + user.user_type.name = "db" + user.role_names = ["admin"] + user.namespace = "tenants_west" + + user_manager.print_db_user(user, json_output=False) + + out = capsys.readouterr().out + assert "Namespace: tenants_west" in out + + +def test_print_db_user_with_namespace_json(user_manager, capsys): + user = Mock() + user.user_id = "scoped_user" + user.active = True + user.user_type = Mock(name="db") + user.user_type.name = "db" + user.role_names = ["admin"] + user.namespace = "tenants_west" + + user_manager.print_db_user(user, json_output=True) + + payload = json.loads(capsys.readouterr().out) + assert payload["namespace"] == "tenants_west" + + +def test_print_db_user_without_namespace_omits_field(user_manager, capsys): + user = Mock(spec=["user_id", "active", "user_type", "role_names"]) + user.user_id = "u" + user.active = True + user.user_type = Mock(name="db") + user.user_type.name = "db" + user.role_names = [] + + user_manager.print_db_user(user, json_output=True) + + payload = json.loads(capsys.readouterr().out) + assert "namespace" not in payload + + # --------------------------------------------------------------------------- # add_role json_output # --------------------------------------------------------------------------- diff --git a/test/unittests/test_utils.py b/test/unittests/test_utils.py index 1d14def..c9f51ee 100644 --- a/test/unittests/test_utils.py +++ b/test/unittests/test_utils.py @@ -357,6 +357,31 @@ def test_parse_permission_cluster(): assert parse_permission("read_cluster") == Permissions.cluster(read=True) +def test_parse_permission_namespaces(): + # Single namespace + assert parse_permission("manage_namespaces:my_ns") == Permissions.namespaces( + namespace="my_ns", manage=True + ) + + # Comma-separated list + assert parse_permission( + "manage_namespaces:ns_one,ns_two" + ) == Permissions.namespaces(namespace=["ns_one", "ns_two"], manage=True) + + +def test_parse_permission_namespaces_requires_name(): + with pytest.raises(ValueError, match="manage_namespaces requires"): + parse_permission("manage_namespaces") + + +def test_parse_permission_namespaces_empty_name(): + with pytest.raises(ValueError, match="must be non-empty"): + parse_permission("manage_namespaces:") + + with pytest.raises(ValueError, match="must be non-empty"): + parse_permission("manage_namespaces:ns1,,ns2") + + def test_parse_permission_invalid(): # Test invalid action with pytest.raises(ValueError, match="Invalid permission action: invalid_action"): diff --git a/weaviate_cli/commands/create.py b/weaviate_cli/commands/create.py index ef2591c..c8d4f05 100644 --- a/weaviate_cli/commands/create.py +++ b/weaviate_cli/commands/create.py @@ -18,6 +18,7 @@ parse_async_replication_config, ASYNC_REPLICATION_CONFIG_HELP, ) +from weaviate_cli.managers.namespace_manager import NamespaceManager from weaviate_cli.managers.collection_manager import CollectionManager from weaviate_cli.managers.tenant_manager import TenantManager from weaviate_cli.managers.data_manager import DataManager @@ -29,9 +30,11 @@ CreateBackupDefaults, CreateCollectionDefaults, CreateExportCollectionDefaults, + CreateNamespaceDefaults, CreateTenantsDefaults, CreateDataDefaults, CreateRoleDefaults, + CreateUserDefaults, PERMISSION_HELP_STRING, MAX_WORKERS, ) @@ -689,8 +692,12 @@ def create_role_cli( @create.command("user") @click.option( "--user_name", - default=None, - help="The name of the user to create.", + default=CreateUserDefaults.user_name, + help=( + "The name of the user to create. On namespace-enabled clusters " + "(Weaviate 1.38.0+) bind the user to a namespace by passing a " + "namespace-qualified id of the form ':'." + ), ) @click.option( "--store", @@ -702,7 +709,10 @@ def create_role_cli( ) @click.pass_context def create_user_cli( - ctx: click.Context, user_name: str, store: bool, json_output: bool + ctx: click.Context, + user_name: str, + store: bool, + json_output: bool, ) -> None: """Create a user in Weaviate.""" client = None @@ -976,3 +986,40 @@ def create_export_collection_cli( finally: if client: client.close() + + +@create.command("namespace") +@click.option( + "--name", + default=CreateNamespaceDefaults.name, + required=True, + help="The namespace name. Must be 3-36 lowercase alphanumeric characters starting with a letter.", +) +@click.option( + "--home_node", + default=CreateNamespaceDefaults.home_node, + help="Cluster node to place this namespace's shards on. Must be a current storage candidate. When omitted, the cluster picks one automatically.", +) +@click.option( + "--json", "json_output", is_flag=True, default=False, help="Output in JSON format." +) +@click.pass_context +def create_namespace_cli( + ctx: click.Context, name: str, home_node: Optional[str], json_output: bool +) -> None: + """Create a namespace in Weaviate (requires Weaviate 1.38.0+).""" + client: Optional[WeaviateClient] = None + try: + client = get_client_from_context(ctx) + namespace_man = NamespaceManager(client) + namespace_man.create_namespace( + name=name, home_node=home_node, json_output=json_output + ) + except Exception as e: + click.echo(f"Error: {e}") + if client: + client.close() + sys.exit(1) + finally: + if client: + client.close() diff --git a/weaviate_cli/commands/delete.py b/weaviate_cli/commands/delete.py index 04427e2..542c0a0 100644 --- a/weaviate_cli/commands/delete.py +++ b/weaviate_cli/commands/delete.py @@ -7,6 +7,7 @@ from weaviate_cli.completion.complete import collection_name_complete from weaviate_cli.managers.alias_manager import AliasManager +from weaviate_cli.managers.namespace_manager import NamespaceManager from weaviate_cli.managers.tenant_manager import TenantManager from weaviate_cli.utils import get_client_from_context from weaviate_cli.managers.collection_manager import CollectionManager @@ -16,6 +17,7 @@ from weaviate_cli.managers.user_manager import UserManager from weaviate_cli.defaults import ( DeleteCollectionDefaults, + DeleteNamespaceDefaults, DeleteTenantsDefaults, DeleteDataDefaults, DeleteRoleDefaults, @@ -279,6 +281,34 @@ def delete_user_cli(ctx, user_name, json_output): client.close() +@delete.command("namespace") +@click.option( + "--name", + default=DeleteNamespaceDefaults.name, + required=True, + help="The name of the namespace to delete.", +) +@click.option( + "--json", "json_output", is_flag=True, default=False, help="Output in JSON format." +) +@click.pass_context +def delete_namespace_cli(ctx: click.Context, name: str, json_output: bool) -> None: + """Delete a namespace in Weaviate (requires Weaviate 1.38.0+).""" + client = None + try: + client = get_client_from_context(ctx) + namespace_man = NamespaceManager(client) + namespace_man.delete_namespace(name=name, json_output=json_output) + except Exception as e: + click.echo(f"Error: {e}") + if client: + client.close() + sys.exit(1) + finally: + if client: + client.close() + + @delete.command("alias") @click.argument("alias_name") @click.option( diff --git a/weaviate_cli/commands/get.py b/weaviate_cli/commands/get.py index 78b1cc7..ff8bf24 100644 --- a/weaviate_cli/commands/get.py +++ b/weaviate_cli/commands/get.py @@ -9,6 +9,7 @@ ) from weaviate_cli.managers.alias_manager import AliasManager from weaviate_cli.managers.export_manager import ExportManager +from weaviate_cli.managers.namespace_manager import NamespaceManager from weaviate_cli.managers.role_manager import RoleManager from weaviate_cli.managers.tenant_manager import TenantManager from weaviate_cli.managers.user_manager import UserManager @@ -24,6 +25,7 @@ GetTenantsDefaults, GetShardsDefaults, GetCollectionDefaults, + GetNamespaceDefaults, GetRoleDefaults, GetUserDefaults, GetNodesDefaults, @@ -46,11 +48,34 @@ def get(): help="The name of the collection to get.", shell_complete=collection_name_complete, ) +@click.option( + "--namespace", + "namespace", + default=GetCollectionDefaults.namespace, + help=( + "Weaviate namespace for global/operator credentials: with --collection NAME, " + "resolves the API target to NAMESPACE:NAME. When listing all collections, " + "limits to that namespace. For all namespaces, use --list-qualified-keys " + "(recommended) or --namespace '*' — the latter must be quoted in the shell " + "because unquoted * is expanded to filenames." + ), +) +@click.option( + "--list-qualified-keys", + "list_qualified_keys", + is_flag=True, + default=GetCollectionDefaults.list_qualified_keys, + help=( + "When listing collections (no --collection), pass each server key verbatim " + "to the API (namespace:collection). Use this for global operator credentials " + "instead of --namespace '*', which breaks when * is not quoted." + ), +) @click.option( "--json", "json_output", is_flag=True, default=False, help="Output in JSON format." ) @click.pass_context -def get_collection_cli(ctx, collection, json_output): +def get_collection_cli(ctx, collection, namespace, list_qualified_keys, json_output): """Get all collections in Weaviate. If --collection is provided, get the specific collection.""" client = None @@ -58,7 +83,12 @@ def get_collection_cli(ctx, collection, json_output): client = get_client_from_context(ctx) collection_man = CollectionManager(client) # Call the function from get_collections.py with general arguments - collection_man.get_collection(collection=collection, json_output=json_output) + collection_man.get_collection( + collection=collection, + json_output=json_output, + namespace=namespace, + list_qualified_keys=list_qualified_keys, + ) except Exception as e: click.echo(f"Error: {e}") if client: @@ -306,15 +336,18 @@ def get_user_cli( if all: users = user_man.get_all_users() if json_output: - users_data = [ - { + users_data = [] + for u in users: + entry = { "user_id": u.user_id, "active": u.active, "user_type": u.user_type.name, "roles": list(u.role_names), } - for u in users - ] + namespace = getattr(u, "namespace", None) + if namespace is not None: + entry["namespace"] = namespace + users_data.append(entry) click.echo(json.dumps({"users": users_data}, indent=2, default=str)) else: click.echo("Users:") @@ -569,6 +602,82 @@ def get_replications_cli(ctx: click.Context, json_output: bool) -> None: client.close() +@get.command("namespace") +@click.option( + "--name", + default=GetNamespaceDefaults.name, + help="The namespace name to fetch.", +) +@click.option( + "--all", + "all_", + is_flag=True, + default=GetNamespaceDefaults.all, + help="List all namespaces visible to the current principal.", +) +@click.option( + "--json", "json_output", is_flag=True, default=False, help="Output in JSON format." +) +@click.pass_context +def get_namespace_cli( + ctx: click.Context, + name: Optional[str], + all_: bool, + json_output: bool, +) -> None: + """Get a namespace by name, or list all namespaces with --all (requires Weaviate 1.38.0+).""" + client = None + try: + if all_ and name: + raise Exception("Can't provide both --all and --name.") + if not all_ and not name: + raise Exception("Either --all or --name is required.") + + client = get_client_from_context(ctx) + namespace_man = NamespaceManager(client) + if all_: + namespaces = namespace_man.list_namespaces() + if json_output: + click.echo( + json.dumps( + { + "namespaces": [ + namespace_man.namespace_to_dict(ns) for ns in namespaces + ], + "total": len(namespaces), + }, + indent=2, + ) + ) + else: + click.echo("Namespaces") + separator = "-" * 50 + click.echo(separator) + if len(namespaces) == 0: + click.echo("No namespaces found.") + else: + for ns in namespaces: + namespace_man.print_namespace(ns) + click.echo(separator) + else: + namespace = namespace_man.get_namespace(name=name) + if namespace is None: + if json_output: + click.echo(json.dumps({"namespace": None}, indent=2)) + else: + click.echo(f"Namespace '{name}' not found.") + else: + namespace_man.print_namespace(namespace, json_output=json_output) + except Exception as e: + click.echo(f"Error: {e}") + if client: + client.close() + sys.exit(1) + finally: + if client: + client.close() + + @get.command("export-collection") @click.option( "--export_id", diff --git a/weaviate_cli/commands/update.py b/weaviate_cli/commands/update.py index 741e8a1..94fb1f7 100644 --- a/weaviate_cli/commands/update.py +++ b/weaviate_cli/commands/update.py @@ -5,6 +5,7 @@ from weaviate_cli.completion.complete import collection_name_complete from weaviate_cli.managers.alias_manager import AliasManager +from weaviate_cli.managers.namespace_manager import NamespaceManager from weaviate_cli.managers.tenant_manager import TenantManager from weaviate_cli.managers.user_manager import UserManager from weaviate_cli.utils import ( @@ -21,6 +22,7 @@ from weaviate_cli.defaults import UpdateShardsDefaults from weaviate_cli.defaults import UpdateDataDefaults from weaviate_cli.defaults import UpdateUserDefaults +from weaviate_cli.defaults import UpdateNamespaceDefaults # Update Group @@ -552,3 +554,41 @@ def update_alias_cli( finally: if client: client.close() + + +@update.command("namespace") +@click.option( + "--name", + default=UpdateNamespaceDefaults.name, + required=True, + help="The name of the namespace to update.", +) +@click.option( + "--home_node", + default=UpdateNamespaceDefaults.home_node, + required=True, + help="Cluster node to use for future placements. Must be a current storage candidate. Existing live shards are not moved.", +) +@click.option( + "--json", "json_output", is_flag=True, default=False, help="Output in JSON format." +) +@click.pass_context +def update_namespace_cli( + ctx: click.Context, name: str, home_node: str, json_output: bool +) -> None: + """Update the home node of a namespace in Weaviate (requires Weaviate 1.38.0+).""" + client = None + try: + client = get_client_from_context(ctx) + namespace_man = NamespaceManager(client) + namespace_man.update_namespace( + name=name, home_node=home_node, json_output=json_output + ) + except Exception as e: + click.echo(f"Error: {e}") + if client: + client.close() + sys.exit(1) + finally: + if client: + client.close() diff --git a/weaviate_cli/defaults.py b/weaviate_cli/defaults.py index 55e4cc5..58b1635 100644 --- a/weaviate_cli/defaults.py +++ b/weaviate_cli/defaults.py @@ -15,6 +15,7 @@ " User management: assign_and_revoke_users, read_users\n\n" " Node management: read_nodes\n\n" " Alias management: create_aliases, read_aliases, update_aliases, delete_aliases\n\n" + " Namespace management: manage_namespaces\n\n" " CRUD shorthands for collections, roles, tenants, users and data:\n\n" " crud_collections:collection,cud_data:collection,rd_collections,\n\n" " crud_roles:role,cud_tenants:tenant,rd_tenants,\n\n" @@ -28,6 +29,7 @@ " - *_aliases:collection_name:alias_name, can be specified multiple times\n\n" " - *_data:collection_name, can be specified multiple times\n\n" " - *_backups:collection_name, can be specified multiple times\n\n" + " - manage_namespaces:namespace_name, can be specified multiple times\n\n" " - read_nodes:verbosity (verbosity level)\n\n" "Examples:\n\n" " --permission crud_collections:Movies\n\n" @@ -47,6 +49,7 @@ " --permission crud_aliases:Movies,Books:Alias*\n\n" " --permission create_aliases:Banks\n\n" " --permission crud_users:user-1,user-2\n\n" + " --permission manage_namespaces:my_namespace\n\n" ) QUERY_MAXIMUM_RESULTS = 10000 MAX_OBJECTS_PER_BATCH = 5000 @@ -192,6 +195,12 @@ class DeleteRoleDefaults: @dataclass class GetCollectionDefaults: collection: Optional[str] = None + # For global/operator credentials against namespaced clusters: qualify API calls + # as namespace:collection. Use "*" when listing all collections to use server keys + # verbatim (no namespace prefix stripping). Prefer --list-qualified-keys over + # --namespace '*' because shells glob unquoted *. + namespace: Optional[str] = None + list_qualified_keys: bool = False @dataclass @@ -333,3 +342,32 @@ class GetExportCollectionDefaults: class CancelExportCollectionDefaults: export_id: str = "test-export" backend: str = "filesystem" + + +@dataclass +class CreateNamespaceDefaults: + name: Optional[str] = None + home_node: Optional[str] = None + + +@dataclass +class GetNamespaceDefaults: + name: Optional[str] = None + all: bool = False + + +@dataclass +class UpdateNamespaceDefaults: + name: Optional[str] = None + home_node: Optional[str] = None + + +@dataclass +class DeleteNamespaceDefaults: + name: Optional[str] = None + + +@dataclass +class CreateUserDefaults: + user_name: Optional[str] = None + store: bool = False diff --git a/weaviate_cli/managers/alias_manager.py b/weaviate_cli/managers/alias_manager.py index 47ef807..06b1e33 100644 --- a/weaviate_cli/managers/alias_manager.py +++ b/weaviate_cli/managers/alias_manager.py @@ -37,9 +37,11 @@ def update_alias( self, alias_name: str, collection: str, json_output: bool = False ) -> None: try: - self.client.alias.update( + updated = self.client.alias.update( alias_name=alias_name, new_target_collection=collection ) + if not updated: + raise Exception(f"Alias '{alias_name}' not found.") if json_output: click.echo( json.dumps( @@ -57,7 +59,8 @@ def update_alias( except Exception as e: raise Exception(f"Error updating alias '{alias_name}': {e}") - def get_alias(self, alias_name: str) -> AliasReturn: + def get_alias(self, alias_name: str) -> Optional[AliasReturn]: + """Get an alias by name. Returns ``None`` if the alias does not exist.""" try: return self.client.alias.get(alias_name=alias_name) except Exception as e: @@ -65,7 +68,9 @@ def get_alias(self, alias_name: str) -> AliasReturn: def delete_alias(self, alias_name: str, json_output: bool = False) -> None: try: - self.client.alias.delete(alias_name=alias_name) + deleted = self.client.alias.delete(alias_name=alias_name) + if not deleted: + raise Exception(f"Alias '{alias_name}' not found.") if json_output: click.echo( json.dumps( diff --git a/weaviate_cli/managers/collection_manager.py b/weaviate_cli/managers/collection_manager.py index 72be1a3..6efa1e9 100644 --- a/weaviate_cli/managers/collection_manager.py +++ b/weaviate_cli/managers/collection_manager.py @@ -21,6 +21,20 @@ class CollectionManager: def __init__(self, client: WeaviateClient) -> None: self.client = client + @staticmethod + def _normalize_collection_name(collection: str) -> str: + # In namespaced clusters, list_all() may return "namespace:Collection". + # Calls like client.collections.get(...) must use only the collection name + # for the current namespace-scoped user. + return collection.split(":", 1)[1] if ":" in collection else collection + + @staticmethod + def _api_name_from_list_key(list_key: str, namespace: Optional[str]) -> str: + """Resolve a key from list_all() for client.collections.get (list mode only).""" + if namespace is None: + return CollectionManager._normalize_collection_name(list_key) + return str(list_key) + def __get_total_objects_with_multitenant(self, col_obj: Collection) -> int: acc = 0 for tenant_name, tenant in col_obj.tenants.get().items(): @@ -35,13 +49,42 @@ def get_collection( self, collection: Optional[str] = GetCollectionDefaults.collection, json_output: bool = False, + namespace: Optional[str] = GetCollectionDefaults.namespace, + list_qualified_keys: bool = GetCollectionDefaults.list_qualified_keys, ) -> None: + if list_qualified_keys and namespace not in (None, "*"): + raise Exception( + "Use either --list-qualified-keys or --namespace , not both." + ) + effective_namespace = "*" if list_qualified_keys else namespace if collection is not None: - if not self.client.collections.exists(collection): + if list_qualified_keys: + raise Exception( + "--list-qualified-keys is only for listing collections " + "(omit --collection)." + ) + if effective_namespace == "*": + if ":" not in collection: + raise Exception( + "'--namespace *' (quote as '*' in the shell) is only for listing " + "all collections without --collection, or pass a qualified " + "--collection (namespace:collection). " + "You can also use --list-qualified-keys instead of --namespace '*'." + ) + api_name = collection + elif effective_namespace is not None: + api_name = ( + collection + if ":" in collection + else f"{effective_namespace}:{collection}" + ) + else: + api_name = collection + if not self.client.collections.exists(api_name): - raise Exception(f"Collection '{collection}' does not exist") - col_obj: Collection = self.client.collections.get(collection) + raise Exception(f"Collection '{api_name}' does not exist") + col_obj: Collection = self.client.collections.get(api_name) # Pretty print the dict structure click.echo(json.dumps(col_obj.config.get().to_dict(), indent=4)) else: @@ -55,9 +98,44 @@ def get_collection( return rows = [] - for col_name in collections: - col_obj = self.client.collections.get(col_name) - schema = col_obj.config.get() + for col_name_raw in collections: + if effective_namespace not in (None, "*") and not str( + col_name_raw + ).startswith(f"{effective_namespace}:"): + continue + api_name = self._api_name_from_list_key( + str(col_name_raw), effective_namespace + ) + col_obj = self.client.collections.get(api_name) + try: + schema = col_obj.config.get() + except Exception as e: + # When a global/operator user runs without --namespace, list_all() + # returns "ns:Collection" keys and the CLI strips the prefix, causing + # a 404 because the server needs the qualified name. + if ( + namespace is None + and not list_qualified_keys + and ":" in str(col_name_raw) + and ("404" in str(e) or "status code" in str(e).lower()) + ): + namespaces = sorted( + { + str(k).split(":", 1)[0] + for k in collections + if ":" in str(k) + } + ) + raise Exception( + f"Failed to retrieve collection '{api_name}' (original key: " + f"'{col_name_raw}'). " + f"If you are using global/operator credentials, pass " + f"'--list-qualified-keys' to list all namespaces, or " + f"'--namespace {namespaces[0]}' to list one. " + f"Available namespaces: {', '.join(namespaces)}. " + f"Original error: {e}" + ) from e + raise vectorizer = "None" vector_index_type = "None" named_vectors = "False" @@ -95,7 +173,7 @@ def get_collection( rows.append( { - "name": col_name, + "name": api_name, "multitenancy": schema.multi_tenancy_config.enabled, "tenant_count": tenant_count, "object_count": object_count, @@ -108,6 +186,17 @@ def get_collection( } ) + if not rows: + if json_output: + click.echo(json.dumps({"collections": [], "total": 0}, indent=2)) + else: + click.echo( + "No collections found in that namespace" + if effective_namespace not in (None, "*") + else "No collections found" + ) + return + def _print_text(): table = PrettyTable() table.field_names = [ @@ -136,7 +225,7 @@ def _print_text(): ) print("\nCollections:") print(table) - print(f"\nTotal: {len(collections)} collections") + print(f"\nTotal: {len(rows)} collections") print_json_or_text( {"collections": rows, "total": len(rows)}, @@ -812,6 +901,7 @@ def delete_collection( if all: collections: List[str] = self.client.collections.list_all() for collection in collections: + collection = self._normalize_collection_name(collection) if not json_output: click.echo(f"Deleting collection '{collection}'") self.client.collections.delete(collection) diff --git a/weaviate_cli/managers/data_manager.py b/weaviate_cli/managers/data_manager.py index d2fcc94..2037991 100644 --- a/weaviate_cli/managers/data_manager.py +++ b/weaviate_cli/managers/data_manager.py @@ -1299,8 +1299,12 @@ def __delete_data( if uuid: start_time = time.time() - collection.with_consistency_level(cl).data.delete_by_id(uuid=uuid) + deleted = collection.with_consistency_level(cl).data.delete_by_id(uuid=uuid) elapsed = time.time() - start_time + if not deleted: + raise Exception( + f"Object '{uuid}' not found in class '{collection.name}'." + ) if not json_output: print( f"Object deleted: {uuid} from class '{collection.name}'" @@ -1480,7 +1484,6 @@ def __query_data( elif search_type == "keyword": response = collection.with_consistency_level(cl).query.bm25( query=query, - return_objects=True, return_metadata=MetadataQuery(score=True, explain_score=True), limit=num_objects, ) diff --git a/weaviate_cli/managers/namespace_manager.py b/weaviate_cli/managers/namespace_manager.py new file mode 100644 index 0000000..c24f684 --- /dev/null +++ b/weaviate_cli/managers/namespace_manager.py @@ -0,0 +1,147 @@ +import json +from typing import List, Optional + +import click +from weaviate.client import WeaviateClient + +try: + from weaviate.namespaces.models import Namespace + + _NAMESPACE_SUPPORT = True +except ImportError: + Namespace = None # type: ignore[assignment,misc] + _NAMESPACE_SUPPORT = False + + +class NamespaceManager: + def __init__(self, client: WeaviateClient): + if not _NAMESPACE_SUPPORT: + raise RuntimeError( + "Namespace support requires a weaviate-client version that includes " + "the namespaces module. Please upgrade your weaviate-client package." + ) + self.client = client + + @staticmethod + def namespace_to_dict(namespace: Namespace) -> dict: + """Serialize a Namespace to a JSON-friendly dict. + + ``home_node`` and ``state`` are only present on clusters/clients that + support them (Weaviate 1.38.0+), so they are included only when set. + """ + payload: dict = {"name": namespace.name} + home_node = getattr(namespace, "home_node", None) + if home_node is not None: + payload["home_node"] = home_node + state = getattr(namespace, "state", None) + if state is not None: + payload["state"] = state + return payload + + def create_namespace( + self, name: str, home_node: Optional[str] = None, json_output: bool = False + ) -> Namespace: + if not name: + raise Exception("Namespace name is required.") + try: + kwargs: dict = {"name": name} + if home_node is not None: + kwargs["home_node"] = home_node + namespace = self.client.namespaces.create(**kwargs) + if json_output: + payload = { + "status": "success", + "message": f"Namespace '{namespace.name}' created successfully.", + "namespace": namespace.name, + } + payload.update( + { + k: v + for k, v in self.namespace_to_dict(namespace).items() + if k != "name" + } + ) + click.echo(json.dumps(payload, indent=2)) + else: + click.echo(f"Namespace '{namespace.name}' created successfully.") + return namespace + except Exception as e: + raise Exception(f"Error creating namespace '{name}': {e}") + + def update_namespace( + self, name: str, home_node: str, json_output: bool = False + ) -> Namespace: + if not name: + raise Exception("Namespace name is required.") + if not home_node: + raise Exception("Home node is required.") + try: + namespace = self.client.namespaces.update(name=name, home_node=home_node) + if json_output: + payload = { + "status": "success", + "message": f"Namespace '{namespace.name}' updated successfully.", + "namespace": namespace.name, + } + payload.update( + { + k: v + for k, v in self.namespace_to_dict(namespace).items() + if k != "name" + } + ) + click.echo(json.dumps(payload, indent=2)) + else: + click.echo( + f"Namespace '{namespace.name}' updated successfully " + f"(home node: {home_node})." + ) + return namespace + except Exception as e: + raise Exception(f"Error updating namespace '{name}': {e}") + + def get_namespace(self, name: str) -> Optional[Namespace]: + if not name: + raise Exception("Namespace name is required.") + try: + return self.client.namespaces.get(name=name) + except Exception as e: + raise Exception(f"Error getting namespace '{name}': {e}") + + def list_namespaces(self) -> List[Namespace]: + try: + return self.client.namespaces.list_all() + except Exception as e: + raise Exception(f"Error listing namespaces: {e}") + + def delete_namespace(self, name: str, json_output: bool = False) -> None: + if not name: + raise Exception("Namespace name is required.") + try: + self.client.namespaces.delete(name=name) + if json_output: + click.echo( + json.dumps( + { + "status": "success", + "message": f"Namespace '{name}' deleted successfully.", + }, + indent=2, + ) + ) + else: + click.echo(f"Namespace '{name}' deleted successfully.") + except Exception as e: + raise Exception(f"Error deleting namespace '{name}': {e}") + + def print_namespace(self, namespace: Namespace, json_output: bool = False) -> None: + if json_output: + click.echo(json.dumps(self.namespace_to_dict(namespace), indent=2)) + else: + click.echo(f"Namespace: {namespace.name}") + home_node = getattr(namespace, "home_node", None) + if home_node is not None: + click.echo(f" Home node: {home_node}") + state = getattr(namespace, "state", None) + if state is not None: + click.echo(f" State: {state}") diff --git a/weaviate_cli/managers/role_manager.py b/weaviate_cli/managers/role_manager.py index 45e5710..0ca9710 100644 --- a/weaviate_cli/managers/role_manager.py +++ b/weaviate_cli/managers/role_manager.py @@ -253,6 +253,14 @@ def role_to_dict(role: Role) -> dict: } for p in role.alias_permissions ] + if getattr(role, "namespaces_permissions", None): + role_data["permissions"]["namespaces"] = [ + { + "namespace": str(p.namespace), + "actions": [a.value for a in p.actions], + } + for p in role.namespaces_permissions + ] return role_data def print_role(self, role: Optional[Role] = None) -> None: @@ -331,3 +339,11 @@ def print_role(self, role: Optional[Role] = None) -> None: print( f" - Collection: {perm.collection}, Alias: {perm.alias}, Action: {', '.join([action.value for action in perm.actions])}" ) + + namespaces_permissions = getattr(role, "namespaces_permissions", None) + if namespaces_permissions: + print("\nNamespaces Permissions:") + for perm in namespaces_permissions: + print( + f" - Namespace: {perm.namespace}, Action: {', '.join([action.value for action in perm.actions])}" + ) diff --git a/weaviate_cli/managers/shard_manager.py b/weaviate_cli/managers/shard_manager.py index 3167e19..f5ed8ab 100644 --- a/weaviate_cli/managers/shard_manager.py +++ b/weaviate_cli/managers/shard_manager.py @@ -12,6 +12,10 @@ class ShardManager: def __init__(self, client: WeaviateClient): self.client = client + @staticmethod + def _normalize_collection_name(collection: str) -> str: + return collection.split(":", 1)[1] if ":" in collection else collection + def get_shards( self, collection: Optional[str] = GetShardsDefaults.collection, @@ -61,7 +65,8 @@ def get_shards( if json_output: result = [] for single_collection in all_collections: - col_obj = self.client.collections.get(single_collection) + collection_name = self._normalize_collection_name(single_collection) + col_obj = self.client.collections.get(collection_name) shards = col_obj.config.get_shards() shards_data = [] for shard in shards: @@ -76,7 +81,7 @@ def get_shards( ) result.append( { - "collection": single_collection, + "collection": collection_name, "shards": shards_data, "total_shards": len(shards), } @@ -93,10 +98,11 @@ def get_shards( ) else: for single_collection in all_collections: - col_obj = self.client.collections.get(single_collection) + collection_name = self._normalize_collection_name(single_collection) + col_obj = self.client.collections.get(collection_name) shards = col_obj.config.get_shards() click.echo( - f"Collection {single_collection:<29}: Shards {len(shards):<15}" + f"Collection {collection_name:<29}: Shards {len(shards):<15}" ) for shard in shards: self._print_echo_shard_info(shard) @@ -138,16 +144,17 @@ def update_shards( all_collections = self.client.collections.list_all() updated = [] for single_collection in all_collections: - col_obj = self.client.collections.get(single_collection) + collection_name = self._normalize_collection_name(single_collection) + col_obj = self.client.collections.get(collection_name) col_shards = [s.name for s in col_obj.config.get_shards()] col_obj.config.update_shards(status, col_shards) if json_output: updated.append( - {"collection": single_collection, "shards_updated": col_shards} + {"collection": collection_name, "shards_updated": col_shards} ) else: click.echo( - f"Shards '{col_shards}' updated to state '{status}' for collection '{single_collection}'" + f"Shards '{col_shards}' updated to state '{status}' for collection '{collection_name}'" ) if json_output: click.echo( diff --git a/weaviate_cli/managers/user_manager.py b/weaviate_cli/managers/user_manager.py index 75d430c..b91bb54 100644 --- a/weaviate_cli/managers/user_manager.py +++ b/weaviate_cli/managers/user_manager.py @@ -26,15 +26,31 @@ def get_user( self, user_name: Optional[str] = None, ) -> Union[OwnUser, UserDB]: - """Get a user in Weaviate. If no user name is provided, the current user is returned.""" + """Get a user in Weaviate. If no user name is provided, the current user is returned. + + Only DB users can be looked up by name: the Weaviate Python client + exposes ``users.db.get(...)`` but no equivalent ``users.oidc.get(...)``. + When the DB lookup returns ``None`` (404), the user may still exist + as an OIDC user — surface that possibility in the error message so + callers don't waste time chasing a "not found" that is in fact a DB + vs OIDC mismatch. + """ try: if user_name is None: return self.client.users.get_my_user() - else: - return self.client.users.db.get(user_id=user_name) + user = self.client.users.db.get(user_id=user_name) except Exception as e: raise Exception(f"Error getting user '{user_name}': {e}") + if user is None: + raise Exception( + f"User '{user_name}' not found as a DB user. " + f"If '{user_name}' is an OIDC user, the Weaviate Python " + "client does not support fetching OIDC users directly — use " + f"`get role --user_name {user_name} --user_type oidc` to " + "list their assigned roles instead." + ) + return user def get_all_users(self) -> List[UserDB]: """Get all users in Weaviate.""" @@ -49,7 +65,14 @@ def create_user( ) -> str: """ Create a user in Weaviate. - Returns the api key for the user. + + Args: + user_name: The id of the new user. On namespace-enabled clusters + (Weaviate 1.38.0+) bind the user to a namespace by passing a + namespace-qualified id of the form ``:``. + + Returns: + The api key for the user. """ if user_name is None: raise Exception("User name is required.") @@ -66,7 +89,11 @@ def update_user( deactivate: bool = False, ) -> Optional[str]: """Update a user in Weaviate. - Returns the api key for the user if the api key was rotated, otherwise returns None. + + Returns the api key for the user if the api key was rotated, otherwise + returns ``None``. Raises if ``activate``/``deactivate`` is a no-op + because the user is already in the requested state (the underlying + client returns ``False`` instead of raising on 409). """ if user_name is None: raise Exception("User name is required.") @@ -80,9 +107,13 @@ def update_user( if rotate_api_key: return self.client.users.db.rotate_key(user_id=user_name) if activate: - return self.client.users.db.activate(user_id=user_name) + if not self.client.users.db.activate(user_id=user_name): + raise Exception(f"User '{user_name}' is already active.") + return None if deactivate: - return self.client.users.db.deactivate(user_id=user_name) + if not self.client.users.db.deactivate(user_id=user_name): + raise Exception(f"User '{user_name}' is already deactivated.") + return None except Exception as e: raise Exception(f"Error updating user '{user_name}': {e}") @@ -90,13 +121,19 @@ def delete_user( self, user_name: Optional[str] = None, ) -> None: - """Delete a user in Weaviate.""" + """Delete a user in Weaviate. + + Raises if the user does not exist (the underlying client returns + ``False`` on a 404 instead of raising). + """ if user_name is None: raise Exception("User name is required.") try: - self.client.users.db.delete(user_id=user_name) + deleted = self.client.users.db.delete(user_id=user_name) except Exception as e: raise Exception(f"Error deleting user '{user_name}': {e}") + if not deleted: + raise Exception(f"User '{user_name}' not found.") def add_role( self, @@ -205,22 +242,23 @@ def print_own_user(self, user: OwnUser, json_output: bool = False) -> None: def print_db_user(self, user: UserDB, json_output: bool = False) -> None: """Print user roles in a human readable format.""" + namespace = getattr(user, "namespace", None) if json_output: - click.echo( - json.dumps( - { - "user_id": user.user_id, - "active": user.active, - "user_type": user.user_type.name, - "roles": list(user.role_names), - }, - indent=2, - ) - ) + payload = { + "user_id": user.user_id, + "active": user.active, + "user_type": user.user_type.name, + "roles": list(user.role_names), + } + if namespace is not None: + payload["namespace"] = namespace + click.echo(json.dumps(payload, indent=2)) return print(f"User: {user.user_id}") print(f"Active: {'Yes' if user.active else 'No'}") print(f"Type: {user.user_type.name}") + if namespace is not None: + print(f"Namespace: {namespace}") print(f"Roles:") if len(user.role_names) == 0: print(f" - No roles assigned") diff --git a/weaviate_cli/utils.py b/weaviate_cli/utils.py index 07d28f9..1c1b1d4 100644 --- a/weaviate_cli/utils.py +++ b/weaviate_cli/utils.py @@ -229,6 +229,7 @@ def parse_permission(perm: str) -> PermissionsCreateType: "backups", "nodes", "aliases", + "namespaces", ] crud_resources = ["collections", "data", "tenants", "roles", "users", "aliases"] parts = perm.split(":") @@ -249,6 +250,16 @@ def parse_permission(perm: str) -> PermissionsCreateType: tenant = parts[2].split(",") if len(parts) > 2 and "tenants" in action else None alias = parts[2].split(",") if len(parts) > 2 and "aliases" in action else "*" user = parts[1].split(",") if len(parts) > 1 and "users" in action else "*" + namespace = ( + parts[1].split(",") if len(parts) > 1 and action == "manage_namespaces" else "*" + ) + if isinstance(namespace, list): + if any(not n.strip() for n in namespace): + raise ValueError( + "manage_namespaces namespace names must be non-empty. " + "Example: manage_namespaces:ns1 or manage_namespaces:ns1,ns2" + ) + namespace = [n.strip() for n in namespace] verbosity = "minimal" if action == "read_nodes": @@ -275,6 +286,7 @@ def parse_permission(perm: str) -> PermissionsCreateType: if action in [ "read_cluster", "manage_backups", + "manage_namespaces", "read_nodes", "assign_and_revoke_users", ]: @@ -287,6 +299,7 @@ def parse_permission(perm: str) -> PermissionsCreateType: resource=action.split("_")[-1], user=user, collection=collection, + namespace=namespace, verbosity=verbosity, ) @@ -342,6 +355,7 @@ def _create_permission( collection: Union[str, Sequence[str]] = "*", tenant: Union[str, Sequence[str]] = "*", alias: Union[str, Sequence[str]] = "*", + namespace: Union[str, Sequence[str]] = "*", verbosity: str = "minimal", ) -> PermissionsCreateType: """Helper function to create individual RBAC permission objects.""" @@ -353,6 +367,13 @@ def _create_permission( return Permissions.cluster(read=True) elif resource == "backups": return Permissions.backup(manage=True, collection=collection) + elif resource == "namespaces": + if namespace == "*": + raise ValueError( + "manage_namespaces requires an explicit namespace name. " + "Example: --permission manage_namespaces:my_namespace" + ) + return Permissions.namespaces(namespace=namespace, manage=True) elif resource == "nodes": if verbosity == "minimal": if collection != "*":