Skip to content

Commit ea9bf86

Browse files
Fix: REMOVE staging operation no longer requires staging_allowed_local_path (#726) (#846)
Fix: REMOVE staging op no longer requires staging_allowed_local_path (#726) A REMOVE staging operation deletes a remote resource and never touches the local filesystem, yet _handle_staging_operation raised a ProgrammingError demanding staging_allowed_local_path before dispatching to any handler. This forced callers to pass a dummy path (e.g. "/") just to run a REMOVE, even though the value is never used. Handle REMOVE before the staging_allowed_local_path validation block, mirroring how the __input_stream__ PUT path already bypasses it. GET and local-file PUT continue to require staging_allowed_local_path. Closes #726 Signed-off-by: Vikrant Puppala <vikrant.puppala@databricks.com>
1 parent a238dc9 commit ea9bf86

3 files changed

Lines changed: 110 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Release History
22

3+
# Unreleased
4+
- Fix: `REMOVE` staging operations no longer require `staging_allowed_local_path` to be set, since removing a remote file does not touch the local filesystem (databricks/databricks-sql-python#726)
5+
36
# 4.3.0 (2026-06-12)
47
- **New: optional Rust kernel backend (`use_kernel=True`).** Adds an alternative connection path backed by the native [`databricks-sql-kernel`](https://pypi.org/project/databricks-sql-kernel/) client (a Rust core exposed via PyO3), installable with the new `databricks-sql-connector[kernel]` extra. The kernel talks to Databricks over the **SEA (Statement Execution API) HTTP transport** — not Thrift — with CloudFetch and inline-Arrow result fetching, so `use_kernel=True` gives you a modern SEA-native client through the same DB-API surface. Supports PAT, OAuth M2M, and OAuth U2M auth. Requires Python >= 3.10 (the kernel wheel is `cp310-abi3`); on older interpreters the extra is a no-op and `use_kernel=True` raises a clear `ImportError`. The default backend remains Thrift — opt in per connection.
58
- Kernel backend behavior is aligned with the Thrift backend so application code works the same either way: consistent cursor-state tracking (`query_id` / `get_query_state`), metadata (catalogs/schemas/tables/columns with JDBC-style filter semantics and case-insensitive `table_types`), DML `rowcount`, server-sourced async execution state, sync `cancel()`, fail-loud staging/volume operations, and structured error context (SQLSTATE, diagnostic info). Kernel logs surface through Python `logging` under the `databricks.sql.kernel` logger (databricks/databricks-sql-python#824, #825, #830, #838, #839 by @vikrantpuppala)

src/databricks/sql/client.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,6 +1081,14 @@ def _handle_staging_operation(
10811081
headers=headers,
10821082
)
10831083

1084+
# REMOVE deletes a remote resource and never touches the local
1085+
# filesystem, so it does not require staging_allowed_local_path.
1086+
if row.operation == "REMOVE":
1087+
return self._handle_staging_remove(
1088+
presigned_url=row.presignedUrl,
1089+
headers=headers,
1090+
)
1091+
10841092
# For non-streaming operations, validate staging_allowed_local_path
10851093
if isinstance(staging_allowed_local_path, type(str())):
10861094
_staging_allowed_local_paths = [staging_allowed_local_path]
@@ -1133,14 +1141,12 @@ def _handle_staging_operation(
11331141
)
11341142

11351143
# TODO: Create a retry loop here to re-attempt if the request times out or fails
1144+
# REMOVE is handled above, before staging_allowed_local_path validation,
1145+
# since it does not touch the local filesystem.
11361146
if row.operation == "GET":
11371147
return self._handle_staging_get(**handler_args)
11381148
elif row.operation == "PUT":
11391149
return self._handle_staging_put(**handler_args)
1140-
elif row.operation == "REMOVE":
1141-
# Local file isn't needed to remove a remote resource
1142-
handler_args.pop("local_file")
1143-
return self._handle_staging_remove(**handler_args)
11441150
else:
11451151
raise ProgrammingError(
11461152
f"Operation {row.operation} is not supported. "

tests/unit/test_staging_remove.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
from unittest.mock import patch, Mock, MagicMock
2+
3+
import pytest
4+
5+
import databricks.sql.client as client
6+
7+
8+
class TestStagingRemove:
9+
"""Unit tests for staging REMOVE operations.
10+
11+
REMOVE deletes a remote resource and never touches the local filesystem,
12+
so it must not require ``staging_allowed_local_path`` to be configured
13+
(see GitHub issue #726).
14+
"""
15+
16+
@pytest.fixture
17+
def cursor(self):
18+
return client.Cursor(connection=Mock(), backend=Mock())
19+
20+
def _setup_mock_remove_response(self, cursor):
21+
"""Set up a mock staging REMOVE response with no localFile."""
22+
mock_result_set = Mock()
23+
mock_result_set.is_staging_operation = True
24+
cursor.backend.execute_command.return_value = mock_result_set
25+
26+
mock_row = Mock()
27+
mock_row.operation = "REMOVE"
28+
# The server does not include a localFile for REMOVE.
29+
mock_row.localFile = None
30+
mock_row.presignedUrl = "https://example.com/delete"
31+
mock_row.headers = "{}"
32+
mock_result_set.fetchone.return_value = mock_row
33+
34+
cursor.active_result_set = mock_result_set
35+
return mock_result_set
36+
37+
def test_remove_without_staging_allowed_local_path(self, cursor):
38+
"""REMOVE must succeed when staging_allowed_local_path is None."""
39+
40+
self._setup_mock_remove_response(cursor)
41+
42+
with patch.object(cursor, "_handle_staging_remove") as mock_remove:
43+
cursor._handle_staging_operation(staging_allowed_local_path=None)
44+
45+
mock_remove.assert_called_once()
46+
# local_file must not be forwarded to the REMOVE handler.
47+
assert "local_file" not in mock_remove.call_args.kwargs
48+
assert (
49+
mock_remove.call_args.kwargs["presigned_url"]
50+
== "https://example.com/delete"
51+
)
52+
53+
def test_remove_with_staging_allowed_local_path(self, cursor):
54+
"""REMOVE must still work when staging_allowed_local_path is provided."""
55+
56+
self._setup_mock_remove_response(cursor)
57+
58+
with patch.object(cursor, "_handle_staging_remove") as mock_remove:
59+
cursor._handle_staging_operation(staging_allowed_local_path="/tmp")
60+
61+
mock_remove.assert_called_once()
62+
63+
def test_get_still_requires_staging_allowed_local_path(self, cursor):
64+
"""GET must still fail when staging_allowed_local_path is None."""
65+
66+
mock_result_set = Mock()
67+
mock_result_set.is_staging_operation = True
68+
mock_row = Mock()
69+
mock_row.operation = "GET"
70+
mock_row.localFile = "/tmp/file.csv"
71+
mock_row.presignedUrl = "https://example.com/download"
72+
mock_row.headers = "{}"
73+
mock_result_set.fetchone.return_value = mock_row
74+
cursor.active_result_set = mock_result_set
75+
76+
with pytest.raises(client.ProgrammingError) as excinfo:
77+
cursor._handle_staging_operation(staging_allowed_local_path=None)
78+
assert "You must provide at least one staging_allowed_local_path" in str(
79+
excinfo.value
80+
)
81+
82+
def test_unsupported_operation_still_errors(self, cursor):
83+
"""An unknown operation must still raise, even with a path configured."""
84+
85+
mock_result_set = Mock()
86+
mock_result_set.is_staging_operation = True
87+
mock_row = Mock()
88+
mock_row.operation = "COPY"
89+
mock_row.localFile = None
90+
mock_row.presignedUrl = "https://example.com/copy"
91+
mock_row.headers = "{}"
92+
mock_result_set.fetchone.return_value = mock_row
93+
cursor.active_result_set = mock_result_set
94+
95+
with pytest.raises(client.ProgrammingError) as excinfo:
96+
cursor._handle_staging_operation(staging_allowed_local_path="/tmp")
97+
assert "is not supported" in str(excinfo.value)

0 commit comments

Comments
 (0)