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 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..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 spaces and 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 spaces and 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,14 +53,21 @@ 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 path using SQL.""" + from drs.utils import DremioAPIError + 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}" - return await run_query(client, sql) + warn( + f"Top-level folder creation is deprecated. " + f"Use `dremio space create {parts[0]}` instead. " + "On Space-Plugin-enabled clusters this will fail." + ) + quoted = quote_path_sql(path) + 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: @@ -77,6 +85,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) @@ -141,20 +165,23 @@ 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") 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). For top-level spaces use `dremio space create`." ), 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. + 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) @@ -166,12 +193,15 @@ 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_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 new file mode 100644 index 0000000..3d25f9c --- /dev/null +++ b/src/drs/commands/space.py @@ -0,0 +1,156 @@ +# +# 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 +from drs.utils import DremioAPIError, parse_path + +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} + + +_LEGACY_SPACE_NOT_SUPPORTED = "Legacy spaces are not supported" + + +async def create_space(client: DremioClient, name: str) -> dict: + """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: + 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.") + 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) + + +# -- 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/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 87b3ef0..eb27858 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 @@ -46,14 +47,56 @@ 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.""" +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 'CREATE SPACE "Analytics"' in sql + 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") + + +@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 @@ -68,6 +111,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 new file mode 100644 index 0000000..515c2ef --- /dev/null +++ b/tests/test_commands/test_space.py @@ -0,0 +1,164 @@ +# +# 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_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": []}) + 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_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.""" + 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 +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_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_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`.""" + with pytest.raises(ValueError, match="dremio folder delete"): + await delete_space(mock_client, "Analytics.reports")