From 2d0869f9d93fcef8a7c777a189ba36240e3c0289 Mon Sep 17 00:00:00 2001 From: Alex Stephen Date: Wed, 25 Jun 2025 15:09:07 -0700 Subject: [PATCH 1/5] just need to add abstract methods --- pyiceberg/catalog/__init__.py | 14 +++++++ pyiceberg/catalog/rest/__init__.py | 15 +++++++ tests/catalog/test_rest.py | 63 ++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+) diff --git a/pyiceberg/catalog/__init__.py b/pyiceberg/catalog/__init__.py index 5797e1f050..e3472284e0 100644 --- a/pyiceberg/catalog/__init__.py +++ b/pyiceberg/catalog/__init__.py @@ -35,8 +35,10 @@ NamespaceAlreadyExistsError, NoSuchNamespaceError, NoSuchTableError, + NoSuchViewError, NotInstalledError, TableAlreadyExistsError, + ViewAlreadyExistsError, ) from pyiceberg.io import FileIO, load_file_io from pyiceberg.manifest import ManifestFile @@ -710,6 +712,18 @@ def create_view( ViewAlreadyExistsError: If a view with the name already exists. """ + @abstractmethod + def rename_view(self, from_identifier: str | Identifier, to_identifier: str | Identifier) -> None: + """Rename a fully classified view name. + + Args: + from_identifier (str | Identifier): Existing view identifier. + to_identifier (str | Identifier): New view identifier. + + Raises: + NoSuchViewError: If a view with the name does not exist. + """ + @staticmethod def identifier_to_tuple(identifier: str | Identifier) -> Identifier: """Parse an identifier to a tuple. diff --git a/pyiceberg/catalog/rest/__init__.py b/pyiceberg/catalog/rest/__init__.py index b617cfa7da..8a85c8e7fd 100644 --- a/pyiceberg/catalog/rest/__init__.py +++ b/pyiceberg/catalog/rest/__init__.py @@ -155,6 +155,7 @@ class Endpoints: create_view: str = "namespaces/{namespace}/views" drop_view: str = "namespaces/{namespace}/views/{view}" view_exists: str = "namespaces/{namespace}/views/{view}" + rename_view: str = "views/rename" plan_table_scan: str = "namespaces/{namespace}/tables/{table}/plan" fetch_scan_tasks: str = "namespaces/{namespace}/tables/{table}/tasks" @@ -182,6 +183,7 @@ class Capability: V1_LIST_VIEWS = Endpoint(http_method=HttpMethod.GET, path=f"{API_PREFIX}/{Endpoints.list_views}") V1_VIEW_EXISTS = Endpoint(http_method=HttpMethod.HEAD, path=f"{API_PREFIX}/{Endpoints.view_exists}") V1_DELETE_VIEW = Endpoint(http_method=HttpMethod.DELETE, path=f"{API_PREFIX}/{Endpoints.drop_view}") + V1_RENAME_VIEW = Endpoint(http_method=HttpMethod.POST, path=f"{API_PREFIX}/{Endpoints.rename_view}") V1_SUBMIT_TABLE_SCAN_PLAN = Endpoint(http_method=HttpMethod.POST, path=f"{API_PREFIX}/{Endpoints.plan_table_scan}") V1_TABLE_SCAN_PLAN_TASKS = Endpoint(http_method=HttpMethod.POST, path=f"{API_PREFIX}/{Endpoints.fetch_scan_tasks}") @@ -210,6 +212,7 @@ class Capability: ( Capability.V1_LIST_VIEWS, Capability.V1_DELETE_VIEW, + Capability.V1_RENAME_VIEW, ) ) @@ -1316,6 +1319,18 @@ def drop_view(self, identifier: str) -> None: except HTTPError as exc: _handle_non_200_response(exc, {404: NoSuchViewError}) + @retry(**_RETRY_ARGS) + def rename_view(self, from_identifier: Union[str, Identifier], to_identifier: Union[str, Identifier]) -> None: + payload = { + "source": self._split_identifier_for_json(from_identifier), + "destination": self._split_identifier_for_json(to_identifier), + } + response = self._session.post(self.url(Endpoints.rename_view), json=payload) + try: + response.raise_for_status() + except HTTPError as exc: + _handle_non_200_response(exc, {404: NoSuchViewError, 409: ViewAlreadyExistsError}) + def close(self) -> None: """Close the catalog and release Session connection adapters. diff --git a/tests/catalog/test_rest.py b/tests/catalog/test_rest.py index 99d1ef947b..d5a285e9ae 100644 --- a/tests/catalog/test_rest.py +++ b/tests/catalog/test_rest.py @@ -2292,6 +2292,7 @@ def test_rest_catalog_context_manager_with_exception_sigv4(self, rest_mock: Mock assert catalog is not None and hasattr(catalog, "_session") assert len(catalog._session.adapters) == self.EXPECTED_ADAPTERS_SIGV4 +<<<<<<< HEAD def test_server_side_planning_disabled_by_default(self, rest_mock: Mocker) -> None: catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN) @@ -2621,3 +2622,65 @@ def test_load_table_without_storage_credentials( ) assert actual.metadata.model_dump() == expected.metadata.model_dump() assert actual == expected + + +def test_rename_view_204(rest_mock: Mocker) -> None: + from_identifier = ("some_namespace", "old_view") + to_identifier = ("some_namespace", "new_view") + rest_mock.post( + f"{TEST_URI}v1/views/rename", + json={ + "source": {"namespace": ["some_namespace"], "name": "old_view"}, + "destination": {"namespace": ["some_namespace"], "name": "new_view"}, + }, + status_code=204, + request_headers=TEST_HEADERS, + ) + catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN) + catalog.rename_view(from_identifier, to_identifier) + assert ( + rest_mock.last_request.text + == """{"source": {"namespace": ["some_namespace"], "name": "old_view"}, "destination": {"namespace": ["some_namespace"], "name": "new_view"}}""" + ) + + +def test_rename_view_404(rest_mock: Mocker) -> None: + from_identifier = ("some_namespace", "non_existent_view") + to_identifier = ("some_namespace", "new_view") + rest_mock.post( + f"{TEST_URI}v1/views/rename", + json={ + "error": { + "message": "View does not exist: some_namespace.non_existent_view", + "type": "NoSuchViewException", + "code": 404, + } + }, + status_code=404, + request_headers=TEST_HEADERS, + ) + catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN) + with pytest.raises(NoSuchViewError) as exc_info: + catalog.rename_view(from_identifier, to_identifier) + assert "View does not exist: some_namespace.non_existent_view" in str(exc_info.value) + + +def test_rename_view_409(rest_mock: Mocker) -> None: + from_identifier = ("some_namespace", "old_view") + to_identifier = ("some_namespace", "existing_view") + rest_mock.post( + f"{TEST_URI}v1/views/rename", + json={ + "error": { + "message": "View already exists: some_namespace.existing_view", + "type": "ViewAlreadyExistsException", + "code": 409, + } + }, + status_code=409, + request_headers=TEST_HEADERS, + ) + catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN) + with pytest.raises(ViewAlreadyExistsError) as exc_info: + catalog.rename_view(from_identifier, to_identifier) + assert "View already exists: some_namespace.existing_view" in str(exc_info.value) From cf4a2b883e062c817bdc0243c69279ef15f7f01c Mon Sep 17 00:00:00 2001 From: Alex Stephen Date: Wed, 25 Jun 2025 15:11:59 -0700 Subject: [PATCH 2/5] add abstract methods to additional classes --- pyiceberg/catalog/dynamodb.py | 3 +++ pyiceberg/catalog/glue.py | 3 +++ pyiceberg/catalog/hive.py | 3 +++ pyiceberg/catalog/noop.py | 3 +++ pyiceberg/catalog/sql.py | 3 +++ tests/catalog/test_rest.py | 1 - 6 files changed, 15 insertions(+), 1 deletion(-) diff --git a/pyiceberg/catalog/dynamodb.py b/pyiceberg/catalog/dynamodb.py index b36bce8c41..286bccfa6f 100644 --- a/pyiceberg/catalog/dynamodb.py +++ b/pyiceberg/catalog/dynamodb.py @@ -558,6 +558,9 @@ def drop_view(self, identifier: str | Identifier) -> None: def view_exists(self, identifier: str | Identifier) -> bool: raise NotImplementedError + def rename_view(self, from_identifier: str | Identifier, to_identifier: str | Identifier) -> None: + raise NotImplementedError + def _get_iceberg_table_item(self, database_name: str, table_name: str) -> dict[str, Any]: try: return self._get_dynamo_item(identifier=f"{database_name}.{table_name}", namespace=database_name) diff --git a/pyiceberg/catalog/glue.py b/pyiceberg/catalog/glue.py index 83898d01db..c3ae33f7e8 100644 --- a/pyiceberg/catalog/glue.py +++ b/pyiceberg/catalog/glue.py @@ -830,6 +830,9 @@ def drop_view(self, identifier: str | Identifier) -> None: def view_exists(self, identifier: str | Identifier) -> bool: raise NotImplementedError + def rename_view(self, from_identifier: Union[str, Identifier], to_identifier: Union[str, Identifier]) -> None: + raise NotImplementedError + @staticmethod def __is_iceberg_table(table: "TableTypeDef") -> bool: return table.get("Parameters", {}).get(TABLE_TYPE, "").lower() == ICEBERG diff --git a/pyiceberg/catalog/hive.py b/pyiceberg/catalog/hive.py index cc6aca2167..87b06f0ad6 100644 --- a/pyiceberg/catalog/hive.py +++ b/pyiceberg/catalog/hive.py @@ -482,6 +482,9 @@ def list_views(self, namespace: str | Identifier) -> list[Identifier]: def view_exists(self, identifier: str | Identifier) -> bool: raise NotImplementedError + def rename_view(self, from_identifier: Union[str, Identifier], to_identifier: Union[str, Identifier]) -> None: + raise NotImplementedError + def _create_lock_request(self, database_name: str, table_name: str) -> LockRequest: lock_component: LockComponent = LockComponent( level=LockLevel.TABLE, type=LockType.EXCLUSIVE, dbname=database_name, tablename=table_name, isTransactional=True diff --git a/pyiceberg/catalog/noop.py b/pyiceberg/catalog/noop.py index c5399ad62e..983b4960ca 100644 --- a/pyiceberg/catalog/noop.py +++ b/pyiceberg/catalog/noop.py @@ -143,3 +143,6 @@ def create_view( properties: Properties = EMPTY_DICT, ) -> View: raise NotImplementedError + + def rename_view(self, from_identifier: Union[str, Identifier], to_identifier: Union[str, Identifier]) -> None: + raise NotImplementedError diff --git a/pyiceberg/catalog/sql.py b/pyiceberg/catalog/sql.py index e18a0598b9..6de470c141 100644 --- a/pyiceberg/catalog/sql.py +++ b/pyiceberg/catalog/sql.py @@ -754,3 +754,6 @@ def close(self) -> None: """ if hasattr(self, "engine"): self.engine.dispose() + + def rename_view(self, from_identifier: Union[str, Identifier], to_identifier: Union[str, Identifier]) -> None: + raise NotImplementedError diff --git a/tests/catalog/test_rest.py b/tests/catalog/test_rest.py index d5a285e9ae..e2a835b209 100644 --- a/tests/catalog/test_rest.py +++ b/tests/catalog/test_rest.py @@ -2292,7 +2292,6 @@ def test_rest_catalog_context_manager_with_exception_sigv4(self, rest_mock: Mock assert catalog is not None and hasattr(catalog, "_session") assert len(catalog._session.adapters) == self.EXPECTED_ADAPTERS_SIGV4 -<<<<<<< HEAD def test_server_side_planning_disabled_by_default(self, rest_mock: Mocker) -> None: catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN) From 8ee3da5f63cf9903c197148a7ccb27c045c02f36 Mon Sep 17 00:00:00 2001 From: Alex Stephen Date: Tue, 7 Oct 2025 11:28:47 -0700 Subject: [PATCH 3/5] Add namespace check on rename_view --- pyiceberg/catalog/rest/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pyiceberg/catalog/rest/__init__.py b/pyiceberg/catalog/rest/__init__.py index 8a85c8e7fd..0da8e7b1a9 100644 --- a/pyiceberg/catalog/rest/__init__.py +++ b/pyiceberg/catalog/rest/__init__.py @@ -1325,6 +1325,16 @@ def rename_view(self, from_identifier: Union[str, Identifier], to_identifier: Un "source": self._split_identifier_for_json(from_identifier), "destination": self._split_identifier_for_json(to_identifier), } + + # Ensure source and destination namespaces exist before rename. + source_namespace = self._split_identifier_for_json(from_identifier)["namespace"] + dest_namespace = self._split_identifier_for_path(to_identifier)["namespace"] + + if not self.namespace_exists(source_namespace): + raise NoSuchNamespaceError(f"Source namespace does not exist: {source_namespace}") + if not self.namespace_exists(dest_namespace): + raise NoSuchNamespaceError(f"Destination namespace does not exist: {dest_namespace}") + response = self._session.post(self.url(Endpoints.rename_view), json=payload) try: response.raise_for_status() From 9188ced102eb08cbac751b3351d8b90d513b5e23 Mon Sep 17 00:00:00 2001 From: Alex Stephen Date: Tue, 7 Oct 2025 11:31:53 -0700 Subject: [PATCH 4/5] Added test for namespace exists --- tests/catalog/test_rest.py | 59 +++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/tests/catalog/test_rest.py b/tests/catalog/test_rest.py index e2a835b209..10d1a1131f 100644 --- a/tests/catalog/test_rest.py +++ b/tests/catalog/test_rest.py @@ -2626,6 +2626,11 @@ def test_load_table_without_storage_credentials( def test_rename_view_204(rest_mock: Mocker) -> None: from_identifier = ("some_namespace", "old_view") to_identifier = ("some_namespace", "new_view") + rest_mock.head( + f"{TEST_URI}v1/namespaces/some_namespace", + status_code=200, + request_headers=TEST_HEADERS, + ) rest_mock.post( f"{TEST_URI}v1/views/rename", json={ @@ -2639,13 +2644,18 @@ def test_rename_view_204(rest_mock: Mocker) -> None: catalog.rename_view(from_identifier, to_identifier) assert ( rest_mock.last_request.text - == """{"source": {"namespace": ["some_namespace"], "name": "old_view"}, "destination": {"namespace": ["some_namespace"], "name": "new_view"}}""" + == '''{"source": {"namespace": ["some_namespace"], "name": "old_view"}, "destination": {"namespace": ["some_namespace"], "name": "new_view"}}''' ) def test_rename_view_404(rest_mock: Mocker) -> None: from_identifier = ("some_namespace", "non_existent_view") to_identifier = ("some_namespace", "new_view") + rest_mock.head( + f"{TEST_URI}v1/namespaces/some_namespace", + status_code=200, + request_headers=TEST_HEADERS, + ) rest_mock.post( f"{TEST_URI}v1/views/rename", json={ @@ -2667,6 +2677,11 @@ def test_rename_view_404(rest_mock: Mocker) -> None: def test_rename_view_409(rest_mock: Mocker) -> None: from_identifier = ("some_namespace", "old_view") to_identifier = ("some_namespace", "existing_view") + rest_mock.head( + f"{TEST_URI}v1/namespaces/some_namespace", + status_code=200, + request_headers=TEST_HEADERS, + ) rest_mock.post( f"{TEST_URI}v1/views/rename", json={ @@ -2683,3 +2698,45 @@ def test_rename_view_409(rest_mock: Mocker) -> None: with pytest.raises(ViewAlreadyExistsError) as exc_info: catalog.rename_view(from_identifier, to_identifier) assert "View already exists: some_namespace.existing_view" in str(exc_info.value) + + +def test_rename_view_source_namespace_does_not_exist(rest_mock: Mocker) -> None: + from_identifier = ("non_existent_namespace", "old_view") + to_identifier = ("some_namespace", "new_view") + + rest_mock.head( + f"{TEST_URI}v1/namespaces/non_existent_namespace", + status_code=404, + request_headers=TEST_HEADERS, + ) + rest_mock.head( + f"{TEST_URI}v1/namespaces/some_namespace", + status_code=200, + request_headers=TEST_HEADERS, + ) + + catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN) + with pytest.raises(NoSuchNamespaceError) as exc_info: + catalog.rename_view(from_identifier, to_identifier) + assert "Source namespace does not exist: ('non_existent_namespace',)" in str(exc_info.value) + + +def test_rename_view_destination_namespace_does_not_exist(rest_mock: Mocker) -> None: + from_identifier = ("some_namespace", "old_view") + to_identifier = ("non_existent_namespace", "new_view") + + rest_mock.head( + f"{TEST_URI}v1/namespaces/some_namespace", + status_code=200, + request_headers=TEST_HEADERS, + ) + rest_mock.head( + f"{TEST_URI}v1/namespaces/non_existent_namespace", + status_code=404, + request_headers=TEST_HEADERS, + ) + + catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN) + with pytest.raises(NoSuchNamespaceError) as exc_info: + catalog.rename_view(from_identifier, to_identifier) + assert "Destination namespace does not exist: non_existent_namespace" in str(exc_info.value) From c8996c2daf1e4c5f398543a243edcac602e6aa3a Mon Sep 17 00:00:00 2001 From: Alex Stephen Date: Tue, 17 Mar 2026 13:41:31 -0700 Subject: [PATCH 5/5] rebase --- pyiceberg/catalog/__init__.py | 2 -- pyiceberg/catalog/bigquery_metastore.py | 3 +++ pyiceberg/catalog/glue.py | 2 +- pyiceberg/catalog/hive.py | 2 +- pyiceberg/catalog/noop.py | 2 +- pyiceberg/catalog/rest/__init__.py | 2 +- pyiceberg/catalog/sql.py | 2 +- tests/catalog/test_rest.py | 4 ++-- 8 files changed, 10 insertions(+), 9 deletions(-) diff --git a/pyiceberg/catalog/__init__.py b/pyiceberg/catalog/__init__.py index e3472284e0..07bf56fab0 100644 --- a/pyiceberg/catalog/__init__.py +++ b/pyiceberg/catalog/__init__.py @@ -35,10 +35,8 @@ NamespaceAlreadyExistsError, NoSuchNamespaceError, NoSuchTableError, - NoSuchViewError, NotInstalledError, TableAlreadyExistsError, - ViewAlreadyExistsError, ) from pyiceberg.io import FileIO, load_file_io from pyiceberg.manifest import ManifestFile diff --git a/pyiceberg/catalog/bigquery_metastore.py b/pyiceberg/catalog/bigquery_metastore.py index 8739e83969..e4d1e452cb 100644 --- a/pyiceberg/catalog/bigquery_metastore.py +++ b/pyiceberg/catalog/bigquery_metastore.py @@ -310,6 +310,9 @@ def drop_view(self, identifier: str | Identifier) -> None: def view_exists(self, identifier: str | Identifier) -> bool: raise NotImplementedError + def rename_view(self, from_identifier: str | Identifier, to_identifier: str | Identifier) -> None: + raise NotImplementedError + def load_namespace_properties(self, namespace: str | Identifier) -> Properties: dataset_name = self.identifier_to_database(namespace) diff --git a/pyiceberg/catalog/glue.py b/pyiceberg/catalog/glue.py index c3ae33f7e8..f9d243b029 100644 --- a/pyiceberg/catalog/glue.py +++ b/pyiceberg/catalog/glue.py @@ -830,7 +830,7 @@ def drop_view(self, identifier: str | Identifier) -> None: def view_exists(self, identifier: str | Identifier) -> bool: raise NotImplementedError - def rename_view(self, from_identifier: Union[str, Identifier], to_identifier: Union[str, Identifier]) -> None: + def rename_view(self, from_identifier: str | Identifier, to_identifier: str | Identifier) -> None: raise NotImplementedError @staticmethod diff --git a/pyiceberg/catalog/hive.py b/pyiceberg/catalog/hive.py index 87b06f0ad6..54def038ae 100644 --- a/pyiceberg/catalog/hive.py +++ b/pyiceberg/catalog/hive.py @@ -482,7 +482,7 @@ def list_views(self, namespace: str | Identifier) -> list[Identifier]: def view_exists(self, identifier: str | Identifier) -> bool: raise NotImplementedError - def rename_view(self, from_identifier: Union[str, Identifier], to_identifier: Union[str, Identifier]) -> None: + def rename_view(self, from_identifier: str | Identifier, to_identifier: str | Identifier) -> None: raise NotImplementedError def _create_lock_request(self, database_name: str, table_name: str) -> LockRequest: diff --git a/pyiceberg/catalog/noop.py b/pyiceberg/catalog/noop.py index 983b4960ca..6a0e2976a7 100644 --- a/pyiceberg/catalog/noop.py +++ b/pyiceberg/catalog/noop.py @@ -144,5 +144,5 @@ def create_view( ) -> View: raise NotImplementedError - def rename_view(self, from_identifier: Union[str, Identifier], to_identifier: Union[str, Identifier]) -> None: + def rename_view(self, from_identifier: str | Identifier, to_identifier: str | Identifier) -> None: raise NotImplementedError diff --git a/pyiceberg/catalog/rest/__init__.py b/pyiceberg/catalog/rest/__init__.py index 0da8e7b1a9..30bf4b1af1 100644 --- a/pyiceberg/catalog/rest/__init__.py +++ b/pyiceberg/catalog/rest/__init__.py @@ -1320,7 +1320,7 @@ def drop_view(self, identifier: str) -> None: _handle_non_200_response(exc, {404: NoSuchViewError}) @retry(**_RETRY_ARGS) - def rename_view(self, from_identifier: Union[str, Identifier], to_identifier: Union[str, Identifier]) -> None: + def rename_view(self, from_identifier: str | Identifier, to_identifier: str | Identifier) -> None: payload = { "source": self._split_identifier_for_json(from_identifier), "destination": self._split_identifier_for_json(to_identifier), diff --git a/pyiceberg/catalog/sql.py b/pyiceberg/catalog/sql.py index 6de470c141..c1546040b1 100644 --- a/pyiceberg/catalog/sql.py +++ b/pyiceberg/catalog/sql.py @@ -755,5 +755,5 @@ def close(self) -> None: if hasattr(self, "engine"): self.engine.dispose() - def rename_view(self, from_identifier: Union[str, Identifier], to_identifier: Union[str, Identifier]) -> None: + def rename_view(self, from_identifier: str | Identifier, to_identifier: str | Identifier) -> None: raise NotImplementedError diff --git a/tests/catalog/test_rest.py b/tests/catalog/test_rest.py index 10d1a1131f..af7bcc1aed 100644 --- a/tests/catalog/test_rest.py +++ b/tests/catalog/test_rest.py @@ -2643,8 +2643,8 @@ def test_rename_view_204(rest_mock: Mocker) -> None: catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN) catalog.rename_view(from_identifier, to_identifier) assert ( - rest_mock.last_request.text - == '''{"source": {"namespace": ["some_namespace"], "name": "old_view"}, "destination": {"namespace": ["some_namespace"], "name": "new_view"}}''' + rest_mock.last_request.text == """{"source": {"namespace": ["some_namespace"], "name": "old_view"}, """ + """"destination": {"namespace": ["some_namespace"], "name": "new_view"}}""" )