From d2f3f185729fe1e0c30271018c5f440bed682ddc Mon Sep 17 00:00:00 2001 From: Sandhya S Date: Wed, 13 May 2026 16:42:51 -0700 Subject: [PATCH 1/7] feat: add `dremio space` commands and reject single-component paths in `folder create` Spaces and folders are distinct catalog concepts but the CLI blurred them: `dremio folder create "foo"` silently rewrote to CREATE SPACE, which breaks on projects without Space Plugin and hides the semantic difference. Changes: - `dremio folder create` now rejects single-component paths with a clear error pointing users to `dremio space create ` instead. - New `dremio space` command group with `list`, `get`, `create`, and `delete` subcommands. `space list` filters the catalog to SPACE entities; the others delegate to the existing folder helpers. Closes #13 Co-Authored-By: Claude Sonnet 4.6 --- src/drs/cli.py | 2 + src/drs/commands/folder.py | 25 +++--- src/drs/commands/space.py | 130 +++++++++++++++++++++++++++++ tests/test_commands/test_folder.py | 12 +-- tests/test_commands/test_space.py | 80 ++++++++++++++++++ 5 files changed, 230 insertions(+), 19 deletions(-) create mode 100644 src/drs/commands/space.py create mode 100644 tests/test_commands/test_space.py diff --git a/src/drs/cli.py b/src/drs/cli.py index 346ed1f..ae2c972 100644 --- a/src/drs/cli.py +++ b/src/drs/cli.py @@ -41,6 +41,7 @@ role, schema, setup, + space, tag, user, wiki, @@ -69,6 +70,7 @@ app.add_typer(grant.app, name="grant") app.add_typer(project.app, name="project") app.add_typer(chat.app, name="chat") +app.add_typer(space.app, name="space") app.command("setup")(setup.setup_command) # Global state for config diff --git a/src/drs/commands/folder.py b/src/drs/commands/folder.py index abe0547..a2a2cfb 100644 --- a/src/drs/commands/folder.py +++ b/src/drs/commands/folder.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # -"""dremio folder — manage spaces and folders in the Dremio catalog.""" +"""dremio folder — manage folders in the Dremio catalog.""" from __future__ import annotations @@ -28,7 +28,7 @@ from drs.utils import handle_api_error, parse_path, quote_path_sql app = typer.Typer( - help="Manage spaces and folders in the Dremio catalog.", context_settings={"help_option_names": ["-h", "--help"]} + help="Manage folders in the Dremio catalog.", context_settings={"help_option_names": ["-h", "--help"]} ) @@ -52,13 +52,16 @@ async def get_entity(client: DremioClient, path: str) -> dict: async def create_folder(client: DremioClient, path: str) -> dict: - """Create a space (single component) or folder (nested path) using SQL.""" + """Create a folder at the given nested path using SQL.""" parts = parse_path(path) if len(parts) == 1: - sql = f'CREATE SPACE "{parts[0]}"' - else: - quoted = quote_path_sql(path) - sql = f"CREATE FOLDER {quoted}" + raise ValueError( + f"Cannot create a top-level folder '{parts[0]}'. " + "Use `dremio space create ` to create a space, " + "or provide a fully qualified path like `.`." + ) + quoted = quote_path_sql(path) + sql = f"CREATE FOLDER {quoted}" return await run_query(client, sql) @@ -147,14 +150,14 @@ def cli_get( @app.command("create") def cli_create( path: str = typer.Argument( - help="Space name (single component) or dot-separated folder path (e.g., myspace.newfolder)" + help="Dot-separated folder path (e.g., myspace.newfolder). Must be nested inside a space." ), fmt: OutputFormat = typer.Option(OutputFormat.json, "--output", "-o", help="Output format"), ) -> None: - """Create a space or folder. + """Create a folder at the given path. - Single path component (e.g., 'Analytics') creates a space. - Nested path (e.g., 'Analytics.reports') creates a folder. + Path must be nested inside a space (e.g., 'Analytics.reports'). + To create a top-level space, use `dremio space create ` instead. """ client = _get_client() _run_command(create_folder(client, path), client, fmt) diff --git a/src/drs/commands/space.py b/src/drs/commands/space.py new file mode 100644 index 0000000..75b0c7d --- /dev/null +++ b/src/drs/commands/space.py @@ -0,0 +1,130 @@ +# +# Copyright (C) 2017-2026 Dremio Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""dremio space — manage spaces in the Dremio catalog.""" + +from __future__ import annotations + +import asyncio + +import typer + +from drs.client import DremioClient +from drs.commands.folder import delete_entity, get_entity, list_catalog +from drs.commands.query import run_query +from drs.output import OutputFormat, error, output + +app = typer.Typer( + help="Manage spaces in the Dremio catalog.", context_settings={"help_option_names": ["-h", "--help"]} +) + + +async def list_spaces(client: DremioClient) -> dict: + """List all spaces in the catalog.""" + result = await list_catalog(client) + spaces = [e for e in result.get("entities", []) if e.get("containerType") == "SPACE"] + return {"entities": spaces} + + +async def create_space(client: DremioClient, name: str) -> dict: + """Create a space using CREATE SPACE SQL.""" + sql = f'CREATE SPACE "{name}"' + return await run_query(client, sql) + + +async def get_space(client: DremioClient, name: str) -> dict: + """Get space metadata by name.""" + return await get_entity(client, name) + + +async def delete_space(client: DremioClient, name: str) -> dict: + """Delete a space by name.""" + return await delete_entity(client, name) + + +# -- CLI wrappers -- + + +def _get_client() -> DremioClient: + from drs.cli import get_client + + return get_client() + + +def _run_command(coro, client, fmt: OutputFormat = OutputFormat.json, fields: str | None = None) -> None: + async def _execute(): + try: + return await coro + finally: + await client.close() + + try: + result = asyncio.run(_execute()) + except Exception as exc: + from drs.utils import DremioAPIError + + if isinstance(exc, DremioAPIError): + error(str(exc)) + raise typer.Exit(1) + if isinstance(exc, ValueError): + error(str(exc)) + raise typer.Exit(1) + raise + output(result, fmt, fields=fields) + + +@app.command("list") +def cli_list( + fmt: OutputFormat = typer.Option(OutputFormat.json, "--output", "-o", help="Output format"), + fields: str = typer.Option(None, "--fields", "-f", help="Comma-separated fields to include"), +) -> None: + """List all spaces in the catalog.""" + client = _get_client() + _run_command(list_spaces(client), client, fmt, fields=fields) + + +@app.command("get") +def cli_get( + name: str = typer.Argument(help="Space name"), + fmt: OutputFormat = typer.Option(OutputFormat.json, "--output", "-o", help="Output format"), + fields: str = typer.Option(None, "--fields", "-f", help="Comma-separated fields to include"), +) -> None: + """Get metadata for a space by name.""" + client = _get_client() + _run_command(get_space(client, name), client, fmt, fields=fields) + + +@app.command("create") +def cli_create( + name: str = typer.Argument(help="Space name to create"), + fmt: OutputFormat = typer.Option(OutputFormat.json, "--output", "-o", help="Output format"), +) -> None: + """Create a new space.""" + client = _get_client() + _run_command(create_space(client, name), client, fmt) + + +@app.command("delete") +def cli_delete( + name: str = typer.Argument(help="Space name to delete"), + dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be deleted without deleting"), + fmt: OutputFormat = typer.Option(OutputFormat.json, "--output", "-o", help="Output format"), +) -> None: + """Delete a space. Cannot be undone.""" + client = _get_client() + if dry_run: + _run_command(get_space(client, name), client, fmt) + return + _run_command(delete_space(client, name), client, fmt) diff --git a/tests/test_commands/test_folder.py b/tests/test_commands/test_folder.py index 87b3ef0..c86cfc8 100644 --- a/tests/test_commands/test_folder.py +++ b/tests/test_commands/test_folder.py @@ -46,14 +46,10 @@ async def test_get_entity_handles_dots_in_quotes(mock_client) -> None: @pytest.mark.asyncio -async def test_create_folder_single_creates_space(mock_client) -> None: - """Single-component path should CREATE SPACE.""" - mock_client.submit_sql = AsyncMock(return_value={"id": "job-1"}) - mock_client.get_job_status = AsyncMock(return_value={"jobState": "COMPLETED", "rowCount": 0}) - mock_client.get_job_results = AsyncMock(return_value={"rows": []}) - await create_folder(mock_client, "Analytics") - sql = mock_client.submit_sql.call_args[0][0] - assert 'CREATE SPACE "Analytics"' in sql +async def test_create_folder_single_raises_error(mock_client) -> None: + """Single-component path should raise ValueError pointing to `dremio space create`.""" + with pytest.raises(ValueError, match="dremio space create"): + await create_folder(mock_client, "Analytics") @pytest.mark.asyncio diff --git a/tests/test_commands/test_space.py b/tests/test_commands/test_space.py new file mode 100644 index 0000000..7c04509 --- /dev/null +++ b/tests/test_commands/test_space.py @@ -0,0 +1,80 @@ +# +# Copyright (C) 2017-2026 Dremio Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Tests for dremio space commands.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from drs.commands.space import create_space, delete_space, get_space, list_spaces + + +@pytest.mark.asyncio +async def test_list_spaces_filters_by_container_type(mock_client) -> None: + mock_client.get_catalog_entity = AsyncMock( + return_value={ + "data": [ + {"id": "s1", "containerType": "SPACE", "path": ["Analytics"]}, + {"id": "src1", "containerType": "SOURCE", "path": ["S3Source"]}, + {"id": "s2", "containerType": "SPACE", "path": ["Engineering"]}, + {"id": "home1", "containerType": "HOME", "path": ["@admin"]}, + ] + } + ) + result = await list_spaces(mock_client) + assert result == { + "entities": [ + {"id": "s1", "containerType": "SPACE", "path": ["Analytics"]}, + {"id": "s2", "containerType": "SPACE", "path": ["Engineering"]}, + ] + } + + +@pytest.mark.asyncio +async def test_list_spaces_empty_catalog(mock_client) -> None: + mock_client.get_catalog_entity = AsyncMock(return_value={"data": []}) + result = await list_spaces(mock_client) + assert result == {"entities": []} + + +@pytest.mark.asyncio +async def test_create_space_runs_correct_sql(mock_client) -> None: + mock_client.submit_sql = AsyncMock(return_value={"id": "job-1"}) + mock_client.get_job_status = AsyncMock(return_value={"jobState": "COMPLETED", "rowCount": 0}) + mock_client.get_job_results = AsyncMock(return_value={"rows": []}) + await create_space(mock_client, "Analytics") + sql = mock_client.submit_sql.call_args[0][0] + assert sql == 'CREATE SPACE "Analytics"' + + +@pytest.mark.asyncio +async def test_get_space(mock_client) -> None: + mock_client.get_catalog_by_path = AsyncMock( + return_value={"id": "s1", "containerType": "SPACE", "path": ["Analytics"]} + ) + result = await get_space(mock_client, "Analytics") + mock_client.get_catalog_by_path.assert_called_once_with(["Analytics"]) + assert result["id"] == "s1" + + +@pytest.mark.asyncio +async def test_delete_space(mock_client) -> None: + mock_client.get_catalog_by_path = AsyncMock(return_value={"id": "s1", "tag": "v1", "containerType": "SPACE"}) + mock_client.delete_catalog_entity = AsyncMock(return_value={"status": "ok"}) + await delete_space(mock_client, "Analytics") + mock_client.delete_catalog_entity.assert_called_once_with("s1", tag="v1") From 3901116860d05ce7d31ae392d903cb2ca4cdc165 Mon Sep 17 00:00:00 2001 From: Sandhya S Date: Wed, 13 May 2026 16:47:31 -0700 Subject: [PATCH 2/7] style: apply ruff formatting to space.py Co-Authored-By: Claude Sonnet 4.6 --- src/drs/commands/space.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/drs/commands/space.py b/src/drs/commands/space.py index 75b0c7d..bc2bd85 100644 --- a/src/drs/commands/space.py +++ b/src/drs/commands/space.py @@ -26,9 +26,7 @@ from drs.commands.query import run_query from drs.output import OutputFormat, error, output -app = typer.Typer( - help="Manage spaces in the Dremio catalog.", context_settings={"help_option_names": ["-h", "--help"]} -) +app = typer.Typer(help="Manage spaces in the Dremio catalog.", context_settings={"help_option_names": ["-h", "--help"]}) async def list_spaces(client: DremioClient) -> dict: From e5d44eb301c2b43946a982677d4fb1112f590d1a Mon Sep 17 00:00:00 2001 From: Sandhya S Date: Wed, 13 May 2026 17:34:57 -0700 Subject: [PATCH 3/7] fix: fall back to CREATE FOLDER on pre-Space-Plugin clusters On DCS clusters without Space Plugin enabled, DCSCoordinatorCatalogServiceImpl.createSpace() rejects CREATE SPACE SQL with "Legacy spaces are not supported." In that case, fall back to CREATE FOLDER with a single-component path, which works on those clusters because the single-component validation was added alongside Space Plugin. All other failures (entity already exists, permissions, etc.) are propagated immediately without triggering the fallback. Co-Authored-By: Claude Sonnet 4.6 --- src/drs/commands/space.py | 20 ++++++++++++-- tests/test_commands/test_space.py | 46 ++++++++++++++++++++++++++++--- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/drs/commands/space.py b/src/drs/commands/space.py index bc2bd85..5828e0b 100644 --- a/src/drs/commands/space.py +++ b/src/drs/commands/space.py @@ -25,6 +25,7 @@ from drs.commands.folder import delete_entity, get_entity, list_catalog from drs.commands.query import run_query from drs.output import OutputFormat, error, output +from drs.utils import DremioAPIError app = typer.Typer(help="Manage spaces in the Dremio catalog.", context_settings={"help_option_names": ["-h", "--help"]}) @@ -36,10 +37,23 @@ async def list_spaces(client: DremioClient) -> dict: return {"entities": spaces} +_LEGACY_SPACE_NOT_SUPPORTED = "Legacy spaces are not supported" + + async def create_space(client: DremioClient, name: str) -> dict: - """Create a space using CREATE SPACE SQL.""" - sql = f'CREATE SPACE "{name}"' - return await run_query(client, sql) + """Create a space. + + Uses CREATE SPACE SQL. On pre-Space-Plugin clusters that reject it with + "Legacy spaces are not supported", falls back to CREATE FOLDER with a + single-component path. All other failures are propagated as DremioAPIError. + """ + result = await run_query(client, f'CREATE SPACE "{name}"') + if result.get("state") == "FAILED": + err = result.get("error", "") + if _LEGACY_SPACE_NOT_SUPPORTED in err: + return await run_query(client, f'CREATE FOLDER "{name}"') + raise DremioAPIError(0, err) + return result async def get_space(client: DremioClient, name: str) -> dict: diff --git a/tests/test_commands/test_space.py b/tests/test_commands/test_space.py index 7c04509..a58b1a1 100644 --- a/tests/test_commands/test_space.py +++ b/tests/test_commands/test_space.py @@ -53,13 +53,51 @@ async def test_list_spaces_empty_catalog(mock_client) -> None: @pytest.mark.asyncio -async def test_create_space_runs_correct_sql(mock_client) -> None: +async def test_create_space_sp_enabled(mock_client) -> None: + """On SP-enabled clusters CREATE SPACE SQL succeeds on first try.""" mock_client.submit_sql = AsyncMock(return_value={"id": "job-1"}) mock_client.get_job_status = AsyncMock(return_value={"jobState": "COMPLETED", "rowCount": 0}) mock_client.get_job_results = AsyncMock(return_value={"rows": []}) - await create_space(mock_client, "Analytics") - sql = mock_client.submit_sql.call_args[0][0] - assert sql == 'CREATE SPACE "Analytics"' + result = await create_space(mock_client, "Analytics") + assert mock_client.submit_sql.call_count == 1 + assert mock_client.submit_sql.call_args[0][0] == 'CREATE SPACE "Analytics"' + assert result["state"] == "COMPLETED" + + +@pytest.mark.asyncio +async def test_create_space_pre_sp_falls_back_to_create_folder(mock_client) -> None: + """On pre-SP clusters, CREATE SPACE fails with the known sentinel; falls back to CREATE FOLDER.""" + mock_client.submit_sql = AsyncMock(side_effect=[{"id": "job-1"}, {"id": "job-2"}]) + mock_client.get_job_status = AsyncMock( + side_effect=[ + { + "jobState": "FAILED", + "errorMessage": "Cannot create space [Analytics]. Legacy spaces are not supported.", + }, + {"jobState": "COMPLETED", "rowCount": 0}, + ] + ) + mock_client.get_job_results = AsyncMock(return_value={"rows": []}) + result = await create_space(mock_client, "Analytics") + assert mock_client.submit_sql.call_count == 2 + assert mock_client.submit_sql.call_args_list[0][0][0] == 'CREATE SPACE "Analytics"' + assert mock_client.submit_sql.call_args_list[1][0][0] == 'CREATE FOLDER "Analytics"' + assert result["state"] == "COMPLETED" + + +@pytest.mark.asyncio +async def test_create_space_other_failure_is_raised(mock_client) -> None: + """Failures unrelated to the SP sentinel (e.g., already exists) are raised, not fallen back.""" + from drs.utils import DremioAPIError + + mock_client.submit_sql = AsyncMock(return_value={"id": "job-1"}) + mock_client.get_job_status = AsyncMock( + return_value={"jobState": "FAILED", "errorMessage": "Space [Analytics] already exists."} + ) + mock_client.get_job_results = AsyncMock(return_value={"rows": []}) + with pytest.raises(DremioAPIError, match="already exists"): + await create_space(mock_client, "Analytics") + assert mock_client.submit_sql.call_count == 1 @pytest.mark.asyncio From 149e47dc50ffc0d379c4d39c97edf4df99b32a55 Mon Sep 17 00:00:00 2001 From: Sandhya S Date: Thu, 14 May 2026 15:23:05 -0700 Subject: [PATCH 4/7] fix: tighten space/folder boundary guards and error propagation - folder get/delete reject single-component paths (redirect to space get/delete) - space get/delete reject multi-component paths (redirect to folder get/delete) - create_folder propagates SQL job failures as DremioAPIError - create_space fallback (pre-SP clusters): check fallback result and raise on failure; rewrite opaque "Folder [[source, name]] already exists" to "Space [name] already exists" Co-Authored-By: Claude Sonnet 4.6 --- src/drs/commands/folder.py | 30 +++++++++++++++---- src/drs/commands/space.py | 14 +++++++-- tests/test_commands/test_folder.py | 48 +++++++++++++++++++++++++++++- tests/test_commands/test_space.py | 38 +++++++++++++++++++++++ 4 files changed, 122 insertions(+), 8 deletions(-) diff --git a/src/drs/commands/folder.py b/src/drs/commands/folder.py index a2a2cfb..8e65782 100644 --- a/src/drs/commands/folder.py +++ b/src/drs/commands/folder.py @@ -60,9 +60,13 @@ async def create_folder(client: DremioClient, path: str) -> dict: "Use `dremio space create ` to create a space, " "or provide a fully qualified path like `.`." ) + from drs.utils import DremioAPIError + quoted = quote_path_sql(path) - sql = f"CREATE FOLDER {quoted}" - return await run_query(client, sql) + result = await run_query(client, f"CREATE FOLDER {quoted}") + if result.get("state") == "FAILED": + raise DremioAPIError(0, result.get("error", "")) + return result async def delete_entity(client: DremioClient, path: str) -> dict: @@ -80,6 +84,22 @@ async def delete_entity(client: DremioClient, path: str) -> dict: raise handle_api_error(exc) from exc +async def get_folder(client: DremioClient, path: str) -> dict: + """Get a folder by path; rejects top-level (single-component) paths.""" + parts = parse_path(path) + if len(parts) == 1: + raise ValueError(f"'{parts[0]}' is a top-level space. Use `dremio space get {parts[0]}` instead.") + return await get_entity(client, path) + + +async def delete_folder(client: DremioClient, path: str) -> dict: + """Delete a folder by path; rejects top-level (single-component) paths.""" + parts = parse_path(path) + if len(parts) == 1: + raise ValueError(f"'{parts[0]}' is a top-level space. Use `dremio space delete {parts[0]}` instead.") + return await delete_entity(client, path) + + async def grants(client: DremioClient, path: str) -> dict: """Get ACL grants on a catalog entity.""" parts = parse_path(path) @@ -144,7 +164,7 @@ def cli_get( ) -> None: """Get full metadata for a catalog entity by path.""" client = _get_client() - _run_command(get_entity(client, path), client, fmt, fields=fields) + _run_command(get_folder(client, path), client, fmt, fields=fields) @app.command("create") @@ -172,9 +192,9 @@ def cli_delete( """Delete a catalog entity (space, folder, view, etc.). Cannot be undone.""" client = _get_client() if dry_run: - _run_command(get_entity(client, path), client, fmt) + _run_command(get_folder(client, path), client, fmt) return - _run_command(delete_entity(client, path), client, fmt) + _run_command(delete_folder(client, path), client, fmt) @app.command("grants") diff --git a/src/drs/commands/space.py b/src/drs/commands/space.py index 5828e0b..9b7f015 100644 --- a/src/drs/commands/space.py +++ b/src/drs/commands/space.py @@ -25,7 +25,7 @@ from drs.commands.folder import delete_entity, get_entity, list_catalog from drs.commands.query import run_query from drs.output import OutputFormat, error, output -from drs.utils import DremioAPIError +from drs.utils import DremioAPIError, parse_path app = typer.Typer(help="Manage spaces in the Dremio catalog.", context_settings={"help_option_names": ["-h", "--help"]}) @@ -51,18 +51,28 @@ async def create_space(client: DremioClient, name: str) -> dict: if result.get("state") == "FAILED": err = result.get("error", "") if _LEGACY_SPACE_NOT_SUPPORTED in err: - return await run_query(client, f'CREATE FOLDER "{name}"') + fallback = await run_query(client, f'CREATE FOLDER "{name}"') + if fallback.get("state") == "FAILED": + fallback_err = fallback.get("error", "") + if "already exists" in fallback_err: + raise DremioAPIError(0, f"Space [{name}] already exists.") + raise DremioAPIError(0, fallback_err) + return fallback raise DremioAPIError(0, err) return result async def get_space(client: DremioClient, name: str) -> dict: """Get space metadata by name.""" + if len(parse_path(name)) > 1: + raise ValueError(f"'{name}' is a nested path. Use `dremio folder get {name}` for folders.") return await get_entity(client, name) async def delete_space(client: DremioClient, name: str) -> dict: """Delete a space by name.""" + if len(parse_path(name)) > 1: + raise ValueError(f"'{name}' is a nested path. Use `dremio folder delete {name}` for folders.") return await delete_entity(client, name) diff --git a/tests/test_commands/test_folder.py b/tests/test_commands/test_folder.py index c86cfc8..76c2a84 100644 --- a/tests/test_commands/test_folder.py +++ b/tests/test_commands/test_folder.py @@ -21,7 +21,8 @@ import pytest -from drs.commands.folder import create_folder, delete_entity, get_entity, grants +from drs.commands.folder import create_folder, delete_entity, delete_folder, get_entity, get_folder, grants +from drs.utils import DremioAPIError @pytest.mark.asyncio @@ -52,6 +53,20 @@ async def test_create_folder_single_raises_error(mock_client) -> None: await create_folder(mock_client, "Analytics") +@pytest.mark.asyncio +async def test_get_folder_single_raises_error(mock_client) -> None: + """Single-component path in get_folder should raise ValueError pointing to `dremio space get`.""" + with pytest.raises(ValueError, match="dremio space get"): + await get_folder(mock_client, "Analytics") + + +@pytest.mark.asyncio +async def test_delete_folder_single_raises_error(mock_client) -> None: + """Single-component path in delete_folder should raise ValueError pointing to `dremio space delete`.""" + with pytest.raises(ValueError, match="dremio space delete"): + await delete_folder(mock_client, "Analytics") + + @pytest.mark.asyncio async def test_create_folder_nested_creates_folder(mock_client) -> None: """Nested path should CREATE FOLDER.""" @@ -64,6 +79,37 @@ async def test_create_folder_nested_creates_folder(mock_client) -> None: assert '"Analytics"."reports"' in sql +@pytest.mark.asyncio +async def test_create_folder_failure_is_raised(mock_client) -> None: + """SQL failures (e.g., namespace not found) are raised as DremioAPIError.""" + mock_client.submit_sql = AsyncMock(return_value={"id": "job-1"}) + mock_client.get_job_status = AsyncMock( + return_value={ + "jobState": "FAILED", + "errorMessage": "NoSuchNamespaceException: Namespace does not exist: NoSuchSpace", + } + ) + mock_client.get_job_results = AsyncMock(return_value={"rows": []}) + with pytest.raises(DremioAPIError, match="Namespace does not exist"): + await create_folder(mock_client, "NoSuchSpace.folder1") + + +@pytest.mark.asyncio +async def test_get_folder_nested_delegates_to_get_entity(mock_client) -> None: + mock_client.get_catalog_by_path = AsyncMock(return_value={"id": "f1", "entityType": "folder"}) + result = await get_folder(mock_client, "myspace.reports") + mock_client.get_catalog_by_path.assert_called_once_with(["myspace", "reports"]) + assert result["id"] == "f1" + + +@pytest.mark.asyncio +async def test_delete_folder_nested_delegates_to_delete_entity(mock_client) -> None: + mock_client.get_catalog_by_path = AsyncMock(return_value={"id": "f1", "tag": "v1"}) + mock_client.delete_catalog_entity = AsyncMock(return_value={"status": "ok"}) + await delete_folder(mock_client, "myspace.reports") + mock_client.delete_catalog_entity.assert_called_once_with("f1", tag="v1") + + @pytest.mark.asyncio async def test_delete_entity(mock_client) -> None: mock_client.get_catalog_by_path = AsyncMock(return_value={"id": "entity-1", "tag": "v1", "entityType": "space"}) diff --git a/tests/test_commands/test_space.py b/tests/test_commands/test_space.py index a58b1a1..2aea54d 100644 --- a/tests/test_commands/test_space.py +++ b/tests/test_commands/test_space.py @@ -85,6 +85,30 @@ async def test_create_space_pre_sp_falls_back_to_create_folder(mock_client) -> N assert result["state"] == "COMPLETED" +@pytest.mark.asyncio +async def test_create_space_pre_sp_fallback_failure_is_raised(mock_client) -> None: + """If the CREATE FOLDER fallback also fails (e.g., already exists), the error is raised.""" + from drs.utils import DremioAPIError + + mock_client.submit_sql = AsyncMock(side_effect=[{"id": "job-1"}, {"id": "job-2"}]) + mock_client.get_job_status = AsyncMock( + side_effect=[ + { + "jobState": "FAILED", + "errorMessage": "Cannot create space [Analytics]. Legacy spaces are not supported.", + }, + { + "jobState": "FAILED", + "errorMessage": "Folder [[serverlessproject, Analytics]] already exists.", + }, + ] + ) + mock_client.get_job_results = AsyncMock(return_value={"rows": []}) + with pytest.raises(DremioAPIError, match="Space \\[Analytics\\] already exists"): + await create_space(mock_client, "Analytics") + assert mock_client.submit_sql.call_count == 2 + + @pytest.mark.asyncio async def test_create_space_other_failure_is_raised(mock_client) -> None: """Failures unrelated to the SP sentinel (e.g., already exists) are raised, not fallen back.""" @@ -110,9 +134,23 @@ async def test_get_space(mock_client) -> None: assert result["id"] == "s1" +@pytest.mark.asyncio +async def test_get_space_nested_path_raises_error(mock_client) -> None: + """Multi-component path should raise ValueError pointing to `dremio folder get`.""" + with pytest.raises(ValueError, match="dremio folder get"): + await get_space(mock_client, "Analytics.reports") + + @pytest.mark.asyncio async def test_delete_space(mock_client) -> None: mock_client.get_catalog_by_path = AsyncMock(return_value={"id": "s1", "tag": "v1", "containerType": "SPACE"}) mock_client.delete_catalog_entity = AsyncMock(return_value={"status": "ok"}) await delete_space(mock_client, "Analytics") mock_client.delete_catalog_entity.assert_called_once_with("s1", tag="v1") + + +@pytest.mark.asyncio +async def test_delete_space_nested_path_raises_error(mock_client) -> None: + """Multi-component path should raise ValueError pointing to `dremio folder delete`.""" + with pytest.raises(ValueError, match="dremio folder delete"): + await delete_space(mock_client, "Analytics.reports") From cc8f927a97e361c22cf758f18b20a890d498aebb Mon Sep 17 00:00:00 2001 From: Sandhya S Date: Thu, 14 May 2026 15:26:12 -0700 Subject: [PATCH 5/7] fix: verify containerType == SPACE before delete_space Prevents dremio space delete from deleting non-space entities (sources, home folders) that happen to share a single-component name. Co-Authored-By: Claude Sonnet 4.6 --- src/drs/commands/space.py | 4 ++++ tests/test_commands/test_space.py | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/src/drs/commands/space.py b/src/drs/commands/space.py index 9b7f015..3d25f9c 100644 --- a/src/drs/commands/space.py +++ b/src/drs/commands/space.py @@ -73,6 +73,10 @@ async def delete_space(client: DremioClient, name: str) -> dict: """Delete a space by name.""" if len(parse_path(name)) > 1: raise ValueError(f"'{name}' is a nested path. Use `dremio folder delete {name}` for folders.") + entity = await get_entity(client, name) + if entity.get("containerType") != "SPACE": + kind = entity.get("containerType", "entity").lower() + raise ValueError(f"'{name}' is a {kind}, not a space.") return await delete_entity(client, name) diff --git a/tests/test_commands/test_space.py b/tests/test_commands/test_space.py index 2aea54d..515c2ef 100644 --- a/tests/test_commands/test_space.py +++ b/tests/test_commands/test_space.py @@ -149,6 +149,14 @@ async def test_delete_space(mock_client) -> None: mock_client.delete_catalog_entity.assert_called_once_with("s1", tag="v1") +@pytest.mark.asyncio +async def test_delete_space_wrong_container_type_raises_error(mock_client) -> None: + """Deleting a non-space entity (e.g. a source) via space delete should raise ValueError.""" + mock_client.get_catalog_by_path = AsyncMock(return_value={"id": "src1", "tag": "v1", "containerType": "SOURCE"}) + with pytest.raises(ValueError, match="source, not a space"): + await delete_space(mock_client, "S3Source") + + @pytest.mark.asyncio async def test_delete_space_nested_path_raises_error(mock_client) -> None: """Multi-component path should raise ValueError pointing to `dremio folder delete`.""" From 48fe2df1c258f930db063ea97081beca4552df6b Mon Sep 17 00:00:00 2001 From: Sandhya S Date: Fri, 15 May 2026 13:00:20 -0700 Subject: [PATCH 6/7] fix: align folder/space command contracts and expand introspect schema - folder create: replace ValueError with deprecation warning + server-driven behavior (pre-SP succeeds, SP fails); update help text to match - folder get/delete: keep single-component redirect; update help text - folder group: update module docstring and app help to reflect dual purpose (nested folders + top-level catalog listing) - output.py: add warn() for stderr deprecation warnings - introspect.py: add space.list/get/create/delete entries; fix stale folder.create description and sql_template - tests: add deprecation warning, pre-SP success, and SP failure tests for folder create single-component path Co-Authored-By: Claude Sonnet 4.6 --- src/drs/commands/folder.py | 35 ++++++++++-------- src/drs/introspect.py | 57 +++++++++++++++++++++++++++--- src/drs/output.py | 5 +++ tests/test_commands/test_folder.py | 38 ++++++++++++++++++-- 4 files changed, 114 insertions(+), 21 deletions(-) diff --git a/src/drs/commands/folder.py b/src/drs/commands/folder.py index 8e65782..6701acf 100644 --- a/src/drs/commands/folder.py +++ b/src/drs/commands/folder.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # -"""dremio folder — manage folders in the Dremio catalog.""" +"""dremio folder — manage nested folders and list top-level catalog entities.""" from __future__ import annotations @@ -24,11 +24,12 @@ from drs.client import DremioClient from drs.commands.query import run_query -from drs.output import OutputFormat, error, output +from drs.output import OutputFormat, error, output, warn from drs.utils import handle_api_error, parse_path, quote_path_sql app = typer.Typer( - help="Manage folders in the Dremio catalog.", context_settings={"help_option_names": ["-h", "--help"]} + help="Manage nested folders and list top-level catalog entities. Use `dremio space` for top-level spaces.", + context_settings={"help_option_names": ["-h", "--help"]}, ) @@ -52,16 +53,16 @@ async def get_entity(client: DremioClient, path: str) -> dict: async def create_folder(client: DremioClient, path: str) -> dict: - """Create a folder at the given nested path using SQL.""" + """Create a folder at the given path using SQL.""" + from drs.utils import DremioAPIError + parts = parse_path(path) if len(parts) == 1: - raise ValueError( - f"Cannot create a top-level folder '{parts[0]}'. " - "Use `dremio space create ` to create a space, " - "or provide a fully qualified path like `.`." + warn( + f"Top-level folder creation is deprecated. " + f"Use `dremio space create {parts[0]}` instead. " + "On Space-Plugin-enabled clusters this will fail." ) - from drs.utils import DremioAPIError - quoted = quote_path_sql(path) result = await run_query(client, f"CREATE FOLDER {quoted}") if result.get("state") == "FAILED": @@ -170,14 +171,17 @@ def cli_get( @app.command("create") def cli_create( path: str = typer.Argument( - help="Dot-separated folder path (e.g., myspace.newfolder). Must be nested inside a space." + help="Dot-separated folder path (e.g., myspace.newfolder). For top-level spaces use `dremio space create`." ), fmt: OutputFormat = typer.Option(OutputFormat.json, "--output", "-o", help="Output format"), ) -> None: """Create a folder at the given path. - Path must be nested inside a space (e.g., 'Analytics.reports'). - To create a top-level space, use `dremio space create ` instead. + Nested paths (e.g. 'Analytics.reports') create a folder inside a space. + Single-component paths attempt top-level creation for compatibility with + pre-Space-Plugin clusters; this is deprecated — use `dremio space create` + instead. On Space-Plugin-enabled clusters, single-component paths will + fail server-side. """ client = _get_client() _run_command(create_folder(client, path), client, fmt) @@ -189,7 +193,10 @@ def cli_delete( dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be deleted without deleting"), fmt: OutputFormat = typer.Option(OutputFormat.json, "--output", "-o", help="Output format"), ) -> None: - """Delete a catalog entity (space, folder, view, etc.). Cannot be undone.""" + """Delete a nested catalog entity by path. Cannot be undone. + + Single-component paths (top-level spaces) are rejected — use `dremio space delete` instead. + """ client = _get_client() if dry_run: _run_command(get_folder(client, path), client, fmt) diff --git a/src/drs/introspect.py b/src/drs/introspect.py index b317e0a..bd0dbc5 100644 --- a/src/drs/introspect.py +++ b/src/drs/introspect.py @@ -115,17 +115,17 @@ "folder.create": { "group": "folder", "command": "create", - "description": "Create a space (single name) or folder (nested path) using SQL.", + "description": "Create a folder using SQL. Nested paths (e.g. space.folder) create a folder inside a space. Single-component paths are deprecated; use `dremio space create` instead — on Space-Plugin-enabled clusters they will fail server-side.", "mechanism": "SQL", "mutating": True, - "sql_template": 'CREATE SPACE "{name}" / CREATE FOLDER {path}', + "sql_template": "CREATE FOLDER {path}", "parameters": [ { "name": "path", "type": "string", "required": True, "positional": True, - "description": "Space name or dot-separated folder path", + "description": "Dot-separated folder path (e.g. myspace.newfolder). Single-component paths are deprecated; use `dremio space create ` for top-level spaces.", }, {"name": "output", "type": "enum", "required": False, "default": "json", "enum": ["json", "csv", "pretty"]}, ], @@ -133,7 +133,7 @@ "folder.delete": { "group": "folder", "command": "delete", - "description": "Delete a catalog entity (space, folder, view, etc.). Cannot be undone.", + "description": "Delete a nested catalog entity by path. Single-component paths are rejected — use `dremio space delete` instead. Cannot be undone.", "mechanism": "REST", "mutating": True, "endpoints": ["GET /v0/projects/{pid}/catalog/by-path/{path}", "DELETE /v0/projects/{pid}/catalog/{id}"], @@ -154,6 +154,55 @@ {"name": "output", "type": "enum", "required": False, "default": "json", "enum": ["json", "csv", "pretty"]}, ], }, + # -- Space -- + "space.list": { + "group": "space", + "command": "list", + "description": "List all spaces in the catalog (containerType == SPACE).", + "mechanism": "REST", + "endpoints": ["GET /v0/projects/{pid}/catalog"], + "parameters": [ + {"name": "output", "type": "enum", "required": False, "default": "json", "enum": ["json", "csv", "pretty"]}, + {"name": "fields", "type": "string", "required": False}, + ], + }, + "space.get": { + "group": "space", + "command": "get", + "description": "Get metadata for a top-level space by name. Rejects nested paths.", + "mechanism": "REST", + "endpoints": ["GET /v0/projects/{pid}/catalog/by-path/{name}"], + "parameters": [ + {"name": "name", "type": "string", "required": True, "positional": True, "description": "Space name"}, + {"name": "output", "type": "enum", "required": False, "default": "json", "enum": ["json", "csv", "pretty"]}, + {"name": "fields", "type": "string", "required": False}, + ], + }, + "space.create": { + "group": "space", + "command": "create", + "description": "Create a space. Runs CREATE SPACE SQL; on pre-Space-Plugin clusters falls back to CREATE FOLDER on the legacy sentinel.", + "mechanism": "SQL", + "mutating": True, + "sql_template": 'CREATE SPACE "{name}"', + "parameters": [ + {"name": "name", "type": "string", "required": True, "positional": True, "description": "Space name"}, + {"name": "output", "type": "enum", "required": False, "default": "json", "enum": ["json", "csv", "pretty"]}, + ], + }, + "space.delete": { + "group": "space", + "command": "delete", + "description": "Delete a top-level space by name. Validates containerType == SPACE before deleting. Rejects nested paths.", + "mechanism": "REST", + "mutating": True, + "endpoints": ["GET /v0/projects/{pid}/catalog/by-path/{name}", "DELETE /v0/projects/{pid}/catalog/{id}"], + "parameters": [ + {"name": "name", "type": "string", "required": True, "positional": True, "description": "Space name"}, + {"name": "dry_run", "type": "boolean", "required": False, "default": False}, + {"name": "output", "type": "enum", "required": False, "default": "json", "enum": ["json", "csv", "pretty"]}, + ], + }, # -- Search (top-level) -- "search": { "group": None, diff --git a/src/drs/output.py b/src/drs/output.py index 19c3276..0397a3f 100644 --- a/src/drs/output.py +++ b/src/drs/output.py @@ -121,3 +121,8 @@ def _list_table(rows: list) -> str: def error(msg: str) -> None: """Print error to stderr.""" print(f"Error: {msg}", file=sys.stderr) + + +def warn(msg: str) -> None: + """Print a deprecation warning to stderr without exiting.""" + print(f"Warning: {msg}", file=sys.stderr) diff --git a/tests/test_commands/test_folder.py b/tests/test_commands/test_folder.py index 76c2a84..eb27858 100644 --- a/tests/test_commands/test_folder.py +++ b/tests/test_commands/test_folder.py @@ -47,9 +47,41 @@ async def test_get_entity_handles_dots_in_quotes(mock_client) -> None: @pytest.mark.asyncio -async def test_create_folder_single_raises_error(mock_client) -> None: - """Single-component path should raise ValueError pointing to `dremio space create`.""" - with pytest.raises(ValueError, match="dremio space create"): +async def test_create_folder_single_emits_deprecation_warning(mock_client, capsys) -> None: + """Single-component path emits a deprecation warning to stderr.""" + mock_client.submit_sql = AsyncMock(return_value={"id": "job-1"}) + mock_client.get_job_status = AsyncMock(return_value={"jobState": "COMPLETED", "rowCount": 0}) + mock_client.get_job_results = AsyncMock(return_value={"rows": []}) + await create_folder(mock_client, "Analytics") + stderr = capsys.readouterr().err + assert "deprecated" in stderr + assert "dremio space create Analytics" in stderr + + +@pytest.mark.asyncio +async def test_create_folder_single_succeeds_on_pre_sp(mock_client) -> None: + """On pre-SP clusters, single-component CREATE FOLDER succeeds.""" + mock_client.submit_sql = AsyncMock(return_value={"id": "job-1"}) + mock_client.get_job_status = AsyncMock(return_value={"jobState": "COMPLETED", "rowCount": 0}) + mock_client.get_job_results = AsyncMock(return_value={"rows": []}) + result = await create_folder(mock_client, "Analytics") + assert result["state"] == "COMPLETED" + sql = mock_client.submit_sql.call_args[0][0] + assert sql == 'CREATE FOLDER "Analytics"' + + +@pytest.mark.asyncio +async def test_create_folder_single_sp_failure_raises_error(mock_client) -> None: + """On SP-enabled clusters, single-component CREATE FOLDER fails server-side and raises DremioAPIError.""" + mock_client.submit_sql = AsyncMock(return_value={"id": "job-1"}) + mock_client.get_job_status = AsyncMock( + return_value={ + "jobState": "FAILED", + "errorMessage": "Top-level folder creation is not allowed. Use CREATE SPACE instead.", + } + ) + mock_client.get_job_results = AsyncMock(return_value={"rows": []}) + with pytest.raises(DremioAPIError, match="Top-level folder creation is not allowed"): await create_folder(mock_client, "Analytics") From 00764c9b0dda7cef9d4ea5a89820c130e2dc361f Mon Sep 17 00:00:00 2001 From: Sandhya S Date: Fri, 15 May 2026 13:05:47 -0700 Subject: [PATCH 7/7] docs: update README for dremio space command group - Add dremio space to command summary table - Split CRUD table: Spaces row -> space.*, Folders row -> nested-only - Fix folder create description: uses CREATE FOLDER; single-component paths deprecated and may fail on Space-Plugin-enabled clusters - Add space.py to file tree Co-Authored-By: Claude Sonnet 4.6 --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 634b402..aa43ef3 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,8 @@ If this returns `{"job_id": "...", "state": "COMPLETED", "rowCount": 1, "rows": | Group | Commands | What it does | |-------|----------|--------------| | `dremio query` | `run`, `status`, `cancel` | Execute SQL, check job status, cancel running jobs | -| `dremio folder` | `list`, `get`, `create`, `delete`, `grants` | Browse and manage spaces/folders, view ACLs | +| `dremio space` | `list`, `get`, `create`, `delete` | Manage top-level spaces in the catalog | +| `dremio folder` | `list`, `get`, `create`, `delete`, `grants` | Browse top-level catalog entities and manage nested folders, view ACLs | | `dremio schema` | `describe`, `lineage`, `sample` | Column types, dependency graph, preview rows | | `dremio wiki` | `get`, `update` | Read and update wiki documentation on entities | | `dremio tag` | `get`, `update` | Read and update tags on entities | @@ -214,7 +215,8 @@ Every Dremio object has consistent CLI commands using standard CRUD verbs (`list | Object | List | Get | Create | Update | Delete | |--------|------|-----|--------|--------|--------| -| **Spaces/Folders** | `folder list` | `folder get` | `folder create` | — | `folder delete` | +| **Spaces** | `space list` | `space get` | `space create` | — | `space delete` | +| **Folders** | — | `folder get` | `folder create` | — | `folder delete` | | **Tables/Views** | `folder get` | `schema describe/sample` | `query run` (DDL) | `query run` (DDL) | `folder delete` | | **Wiki** | — | `wiki get` | `wiki update` | `wiki update` | — | | **Tags** | — | `tag get` | `tag update` | `tag update` | — | @@ -226,7 +228,7 @@ Every Dremio object has consistent CLI commands using standard CRUD verbs (`list | **Projects** | `project list` | `project get` | `project create` | `project update` | `project delete` | | **Jobs** | `job list` | `job get/profile` | `query run` | — | `query cancel` | -`folder create` uses SQL under the hood (`CREATE SPACE` for top-level, `CREATE FOLDER` for nested paths). All other mutations use the REST API. +`space create` uses SQL (`CREATE SPACE`) for top-level space creation. `folder create` uses `CREATE FOLDER` for all paths; single-component paths are deprecated and may fail on Space-Plugin-enabled clusters — use `dremio space create` instead. All other mutations use the REST API. ## How it works @@ -360,6 +362,7 @@ src/drs/ introspect.py # Command schema registry for dremio describe commands/ query.py # run, status, cancel + space.py # list, get, create, delete folder.py # list, get, create, delete, grants schema.py # describe, lineage, sample wiki.py # get, update