diff --git a/databricks/sdk/service/_internal.py b/databricks/sdk/service/_internal.py index 1ab8f9e9d..4fb6e09b2 100644 --- a/databricks/sdk/service/_internal.py +++ b/databricks/sdk/service/_internal.py @@ -63,6 +63,16 @@ def _escape_multi_segment_path_parameter(param: str) -> str: return urllib.parse.quote(param) +def _escape_path_parameter(param: str) -> str: + """Percent-encode a single URL path segment. + + Unlike ``_escape_multi_segment_path_parameter``, this encodes ``/`` as + ``%2F`` so values that contain slashes (e.g. Unity Catalog column names) + remain a single path segment. + """ + return urllib.parse.quote(param, safe="") + + def _timestamp(d: Dict[str, any], field: str) -> Optional[Timestamp]: """ Helper function to convert a timestamp string to a Timestamp object. diff --git a/databricks/sdk/service/catalog.py b/databricks/sdk/service/catalog.py index 66a621934..8752593e1 100644 --- a/databricks/sdk/service/catalog.py +++ b/databricks/sdk/service/catalog.py @@ -18,6 +18,7 @@ from ..errors import OperationFailed from databricks.sdk.service._internal import ( _enum, + _escape_path_parameter, _from_dict, _int64, _repeated_dict, @@ -15500,7 +15501,7 @@ def delete(self, entity_type: str, entity_name: str, tag_key: str): self._api.do( "DELETE", - f"/api/2.1/unity-catalog/entity-tag-assignments/{entity_type}/{entity_name}/tags/{tag_key}", + f"/api/2.1/unity-catalog/entity-tag-assignments/{entity_type}/{_escape_path_parameter(entity_name)}/tags/{_escape_path_parameter(tag_key)}", headers=headers, ) @@ -15527,7 +15528,7 @@ def get(self, entity_type: str, entity_name: str, tag_key: str) -> EntityTagAssi res = self._api.do( "GET", - f"/api/2.1/unity-catalog/entity-tag-assignments/{entity_type}/{entity_name}/tags/{tag_key}", + f"/api/2.1/unity-catalog/entity-tag-assignments/{entity_type}/{_escape_path_parameter(entity_name)}/tags/{_escape_path_parameter(tag_key)}", headers=headers, ) return EntityTagAssignment.from_dict(res) @@ -15569,7 +15570,7 @@ def list( while True: json = self._api.do( "GET", - f"/api/2.1/unity-catalog/entity-tag-assignments/{entity_type}/{entity_name}/tags", + f"/api/2.1/unity-catalog/entity-tag-assignments/{entity_type}/{_escape_path_parameter(entity_name)}/tags", query=query, headers=headers, ) @@ -15631,7 +15632,7 @@ def update( res = self._api.do( "PATCH", - f"/api/2.1/unity-catalog/entity-tag-assignments/{entity_type}/{entity_name}/tags/{tag_key}", + f"/api/2.1/unity-catalog/entity-tag-assignments/{entity_type}/{_escape_path_parameter(entity_name)}/tags/{_escape_path_parameter(tag_key)}", query=query, body=body, headers=headers, diff --git a/tests/test_entity_tag_assignments_encoding.py b/tests/test_entity_tag_assignments_encoding.py new file mode 100644 index 000000000..80a015265 --- /dev/null +++ b/tests/test_entity_tag_assignments_encoding.py @@ -0,0 +1,58 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +from databricks.sdk.service.catalog import EntityTagAssignmentsAPI + + +def _client_with_mock_api(): + api = MagicMock() + api._cfg = SimpleNamespace(workspace_id=None) + api.do.return_value = {"tag_assignments": []} + return EntityTagAssignmentsAPI(api), api + + +def test_list_encodes_slash_in_entity_name(): + client, api = _client_with_mock_api() + entity_name = "main.my_schema.my_table.Inferences/Second" + list(client.list(entity_type="columns", entity_name=entity_name)) + method, path = api.do.call_args.args[:2] + assert method == "GET" + assert path == ( + "/api/2.1/unity-catalog/entity-tag-assignments/columns/" + "main.my_schema.my_table.Inferences%2FSecond/tags" + ) + + +def test_get_encodes_slash_in_entity_name_and_tag_key(): + client, api = _client_with_mock_api() + api.do.return_value = { + "entity_type": "columns", + "entity_name": "main.s.t.Inferences/Second", + "tag_key": "a/b", + } + client.get( + entity_type="columns", + entity_name="main.s.t.Inferences/Second", + tag_key="a/b", + ) + method, path = api.do.call_args.args[:2] + assert method == "GET" + assert path == ( + "/api/2.1/unity-catalog/entity-tag-assignments/columns/" + "main.s.t.Inferences%2FSecond/tags/a%2Fb" + ) + + +def test_delete_encodes_slash_in_entity_name(): + client, api = _client_with_mock_api() + client.delete( + entity_type="columns", + entity_name="cat.sch.tbl.Inferences/Second", + tag_key="env", + ) + method, path = api.do.call_args.args[:2] + assert method == "DELETE" + assert path == ( + "/api/2.1/unity-catalog/entity-tag-assignments/columns/" + "cat.sch.tbl.Inferences%2FSecond/tags/env" + ) diff --git a/tests/test_internal.py b/tests/test_internal.py index b438e811a..3711e6d50 100644 --- a/tests/test_internal.py +++ b/tests/test_internal.py @@ -10,6 +10,7 @@ _duration, _enum, _escape_multi_segment_path_parameter, + _escape_path_parameter, _fieldmask, _from_dict, _int64, @@ -93,6 +94,18 @@ def test_escape_multi_segment_path_parameter(): assert _escape_multi_segment_path_parameter("a#b") == "a%23b" +def test_escape_path_parameter_encodes_slash(): + # Single-segment encoding must turn "/" into "%2F" (issue #1493). + assert _escape_path_parameter("a/b") == "a%2Fb" + assert _escape_path_parameter("main.schema.table.Inferences/Second") == ( + "main.schema.table.Inferences%2FSecond" + ) + assert _escape_path_parameter("a b") == "a%20b" + assert _escape_path_parameter("a?b") == "a%3Fb" + assert _escape_path_parameter("a#b") == "a%23b" + assert _escape_path_parameter("a%b") == "a%25b" + + @pytest.mark.parametrize( "input_dict,field_name,expected_timestamp,description", [