From 2e28fe2311c80fe96e159b33d73f5ce965bb6564 Mon Sep 17 00:00:00 2001 From: Alex Stephen Date: Wed, 25 Jun 2025 15:09:07 -0700 Subject: [PATCH 1/9] 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 0842f51cc8..a74eec566f 100644 --- a/pyiceberg/catalog/__init__.py +++ b/pyiceberg/catalog/__init__.py @@ -37,8 +37,10 @@ NamespaceAlreadyExistsError, NoSuchNamespaceError, NoSuchTableError, + NoSuchViewError, NotInstalledError, TableAlreadyExistsError, + ViewAlreadyExistsError, ) from pyiceberg.io import FileIO, load_file_io from pyiceberg.manifest import ManifestFile @@ -744,6 +746,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 0a44dbb2b8..1181be5240 100644 --- a/pyiceberg/catalog/rest/__init__.py +++ b/pyiceberg/catalog/rest/__init__.py @@ -176,6 +176,7 @@ class Endpoints: register_view: str = "namespaces/{namespace}/register-view" 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" # Use plan_id (underscore) for str.format; Capability paths use {plan-id} to match the REST spec. fetch_planning_result: str = "namespaces/{namespace}/tables/{table}/plan/{plan_id}" @@ -211,6 +212,7 @@ class Capability: V1_CREATE_VIEW = Endpoint(http_method=HttpMethod.POST, path=f"{API_PREFIX}/{Endpoints.create_view}") V1_REGISTER_VIEW = Endpoint(http_method=HttpMethod.POST, path=f"{API_PREFIX}/{Endpoints.register_view}") 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}") # Spec advertises {plan-id}; must match ConfigResponse endpoint strings from servers. V1_FETCH_TABLE_SCAN_PLAN = Endpoint( @@ -248,6 +250,7 @@ class Capability: Capability.V1_LOAD_VIEW, Capability.V1_CREATE_VIEW, Capability.V1_DELETE_VIEW, + Capability.V1_RENAME_VIEW, ) ) @@ -1856,6 +1859,18 @@ def drop_view(self, identifier: str | Identifier) -> 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 33c4dfeea7..6740bf1e26 100644 --- a/tests/catalog/test_rest.py +++ b/tests/catalog/test_rest.py @@ -3118,6 +3118,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) @@ -3592,3 +3593,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 7bc04cae7a349b2e96ba04c99433ff36a468d99f Mon Sep 17 00:00:00 2001 From: Alex Stephen Date: Wed, 25 Jun 2025 15:11:59 -0700 Subject: [PATCH 2/9] add abstract methods to additional classes --- pyiceberg/catalog/dynamodb.py | 4 ++++ pyiceberg/catalog/glue.py | 4 ++++ pyiceberg/catalog/hive.py | 4 ++++ pyiceberg/catalog/noop.py | 4 ++++ pyiceberg/catalog/sql.py | 3 +++ tests/catalog/test_rest.py | 1 - 6 files changed, 19 insertions(+), 1 deletion(-) diff --git a/pyiceberg/catalog/dynamodb.py b/pyiceberg/catalog/dynamodb.py index b4111a6fbb..d20c0268b7 100644 --- a/pyiceberg/catalog/dynamodb.py +++ b/pyiceberg/catalog/dynamodb.py @@ -584,6 +584,10 @@ def view_exists(self, identifier: str | Identifier) -> bool: def load_view(self, identifier: str | Identifier) -> View: raise NotImplementedError + @override + 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 a7d0683a07..e678a8b6f6 100644 --- a/pyiceberg/catalog/glue.py +++ b/pyiceberg/catalog/glue.py @@ -1016,6 +1016,10 @@ def view_exists(self, identifier: str | Identifier) -> bool: def load_view(self, identifier: str | Identifier) -> View: raise NotImplementedError + @override + def rename_view(self, from_identifier: str | Identifier, to_identifier: 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 a44da81b6c..2c48b48462 100644 --- a/pyiceberg/catalog/hive.py +++ b/pyiceberg/catalog/hive.py @@ -506,6 +506,10 @@ def view_exists(self, identifier: str | Identifier) -> bool: def load_view(self, identifier: str | Identifier) -> View: raise NotImplementedError + @override + 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: # Iceberg commits are not executed within a Hive transaction, so the lock component uses operationType=NO_TXN. # Setting it explicitly also matters for Hive 2.1.0, which rejects a lock component left at the default UNSET diff --git a/pyiceberg/catalog/noop.py b/pyiceberg/catalog/noop.py index df18c85f73..90b0f398e0 100644 --- a/pyiceberg/catalog/noop.py +++ b/pyiceberg/catalog/noop.py @@ -175,3 +175,7 @@ def create_view( @override def load_view(self, identifier: str | Identifier) -> View: raise NotImplementedError + + @override + def rename_view(self, from_identifier: str | Identifier, to_identifier: str | Identifier) -> None: + raise NotImplementedError diff --git a/pyiceberg/catalog/sql.py b/pyiceberg/catalog/sql.py index 13e67997d8..07c867b75e 100644 --- a/pyiceberg/catalog/sql.py +++ b/pyiceberg/catalog/sql.py @@ -847,3 +847,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 6740bf1e26..5929a637c0 100644 --- a/tests/catalog/test_rest.py +++ b/tests/catalog/test_rest.py @@ -3118,7 +3118,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 fe73423cfc00f25843fc04497eec29eaaf082da0 Mon Sep 17 00:00:00 2001 From: Alex Stephen Date: Tue, 7 Oct 2025 11:28:47 -0700 Subject: [PATCH 3/9] 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 1181be5240..4b79762631 100644 --- a/pyiceberg/catalog/rest/__init__.py +++ b/pyiceberg/catalog/rest/__init__.py @@ -1865,6 +1865,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 00817e6b13885689112dccb4ad7066f00a4e8093 Mon Sep 17 00:00:00 2001 From: Alex Stephen Date: Tue, 7 Oct 2025 11:31:53 -0700 Subject: [PATCH 4/9] 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 5929a637c0..ff14b0a832 100644 --- a/tests/catalog/test_rest.py +++ b/tests/catalog/test_rest.py @@ -3597,6 +3597,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={ @@ -3610,13 +3615,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={ @@ -3638,6 +3648,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={ @@ -3654,3 +3669,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 0f1187d79c959c4471c85a7eeba1e6335045441c Mon Sep 17 00:00:00 2001 From: Alex Stephen Date: Tue, 17 Mar 2026 13:41:31 -0700 Subject: [PATCH 5/9] rebase --- pyiceberg/catalog/__init__.py | 2 -- pyiceberg/catalog/bigquery_metastore.py | 3 +++ pyiceberg/catalog/rest/__init__.py | 2 +- pyiceberg/catalog/sql.py | 2 +- tests/catalog/test_rest.py | 4 ++-- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pyiceberg/catalog/__init__.py b/pyiceberg/catalog/__init__.py index a74eec566f..27441690cf 100644 --- a/pyiceberg/catalog/__init__.py +++ b/pyiceberg/catalog/__init__.py @@ -37,10 +37,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 1267233e94..31c102dfe8 100644 --- a/pyiceberg/catalog/bigquery_metastore.py +++ b/pyiceberg/catalog/bigquery_metastore.py @@ -334,6 +334,9 @@ def load_view(self, identifier: str | Identifier) -> View: raise NotImplementedError @override + 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, NoSuchNamespaceError) diff --git a/pyiceberg/catalog/rest/__init__.py b/pyiceberg/catalog/rest/__init__.py index 4b79762631..16acc4d655 100644 --- a/pyiceberg/catalog/rest/__init__.py +++ b/pyiceberg/catalog/rest/__init__.py @@ -1860,7 +1860,7 @@ def drop_view(self, identifier: str | Identifier) -> 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 07c867b75e..db80249166 100644 --- a/pyiceberg/catalog/sql.py +++ b/pyiceberg/catalog/sql.py @@ -848,5 +848,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 ff14b0a832..8e1086beeb 100644 --- a/tests/catalog/test_rest.py +++ b/tests/catalog/test_rest.py @@ -3614,8 +3614,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"}}""" ) From 02b6a83078bc94faffe7623cc1f9e2158d978445 Mon Sep 17 00:00:00 2001 From: Alex Stephen Date: Wed, 27 May 2026 20:50:46 +0000 Subject: [PATCH 6/9] PR comments --- pyiceberg/catalog/__init__.py | 1 + pyiceberg/catalog/rest/__init__.py | 3 ++- tests/catalog/test_rest.py | 3 ++- tests/integration/test_catalog.py | 20 ++++++++++++++++++++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/pyiceberg/catalog/__init__.py b/pyiceberg/catalog/__init__.py index 27441690cf..83eb5eb9b7 100644 --- a/pyiceberg/catalog/__init__.py +++ b/pyiceberg/catalog/__init__.py @@ -754,6 +754,7 @@ def rename_view(self, from_identifier: str | Identifier, to_identifier: str | Id Raises: NoSuchViewError: If a view with the name does not exist. + ViewAlreadyExistsError: If the target view already exists. """ @staticmethod diff --git a/pyiceberg/catalog/rest/__init__.py b/pyiceberg/catalog/rest/__init__.py index 16acc4d655..5e5038f69f 100644 --- a/pyiceberg/catalog/rest/__init__.py +++ b/pyiceberg/catalog/rest/__init__.py @@ -1861,6 +1861,7 @@ def drop_view(self, identifier: str | Identifier) -> None: @retry(**_RETRY_ARGS) def rename_view(self, from_identifier: str | Identifier, to_identifier: str | Identifier) -> None: + self._check_endpoint(Capability.V1_RENAME_VIEW) payload = { "source": self._split_identifier_for_json(from_identifier), "destination": self._split_identifier_for_json(to_identifier), @@ -1868,7 +1869,7 @@ def rename_view(self, from_identifier: str | Identifier, to_identifier: str | Id # 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"] + dest_namespace = self._split_identifier_for_json(to_identifier)["namespace"] if not self.namespace_exists(source_namespace): raise NoSuchNamespaceError(f"Source namespace does not exist: {source_namespace}") diff --git a/tests/catalog/test_rest.py b/tests/catalog/test_rest.py index 8e1086beeb..db8f5a9122 100644 --- a/tests/catalog/test_rest.py +++ b/tests/catalog/test_rest.py @@ -121,6 +121,7 @@ Capability.V1_CREATE_VIEW, Capability.V1_REGISTER_VIEW, Capability.V1_DELETE_VIEW, + Capability.V1_RENAME_VIEW, Capability.V1_SUBMIT_TABLE_SCAN_PLAN, Capability.V1_TABLE_SCAN_PLAN_TASKS, ] @@ -3710,4 +3711,4 @@ def test_rename_view_destination_namespace_does_not_exist(rest_mock: Mocker) -> 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) + assert "Destination namespace does not exist: ('non_existent_namespace',)" in str(exc_info.value) diff --git a/tests/integration/test_catalog.py b/tests/integration/test_catalog.py index decd0e11c5..6f40232fe7 100644 --- a/tests/integration/test_catalog.py +++ b/tests/integration/test_catalog.py @@ -767,6 +767,26 @@ def test_rest_identifiers_with_special_characters( @pytest.mark.integration +def test_rest_rename_view( + rest_catalog: RestCatalog, example_view_metadata_v1: dict[str, Any], database_name: str, view_name: str +) -> None: + from_identifier = (database_name, view_name) + to_identifier = (database_name, f"{view_name}_renamed") + + rest_catalog.create_namespace_if_not_exists(database_name) + view = View(from_identifier, ViewMetadata.model_validate(example_view_metadata_v1)) + + rest_catalog.create_view(from_identifier, view.schema(), view.current_version()) + assert rest_catalog.view_exists(from_identifier) + + rest_catalog.rename_view(from_identifier, to_identifier) + + assert not rest_catalog.view_exists(from_identifier) + assert rest_catalog.view_exists(to_identifier) + + +@pytest.mark.integration +@pytest.mark.skip(reason="Requires Iceberg REST Fixtures 1.11.x") def test_rest_custom_namespace_separator(rest_catalog: RestCatalog, table_schema_simple: Schema) -> None: """ Tests that the REST catalog correctly picks up the namespace-separator from the config endpoint. From da28da34cb99cc6065b193ac35a9965624627cb9 Mon Sep 17 00:00:00 2001 From: Alex Stephen Date: Mon, 5 Oct 2026 19:08:46 +0000 Subject: [PATCH 7/9] Address review comments on rename_view - Add @override to RestCatalog/SqlCatalog rename_view and restore it on BigQueryMetastoreCatalog.load_namespace_properties - Split identifiers once in RestCatalog.rename_view - Fix "fully classified" typo in the Catalog.rename_view docstring - Add a cross-namespace rename integration test - Drop a stale skip on test_rest_custom_namespace_separator reintroduced by rebase Co-Authored-By: Claude Opus 5.5 --- pyiceberg/catalog/__init__.py | 2 +- pyiceberg/catalog/bigquery_metastore.py | 1 + pyiceberg/catalog/rest/__init__.py | 20 ++++++++++---------- pyiceberg/catalog/sql.py | 1 + tests/integration/test_catalog.py | 22 +++++++++++++++++++++- 5 files changed, 34 insertions(+), 12 deletions(-) diff --git a/pyiceberg/catalog/__init__.py b/pyiceberg/catalog/__init__.py index 83eb5eb9b7..45d9c9fe35 100644 --- a/pyiceberg/catalog/__init__.py +++ b/pyiceberg/catalog/__init__.py @@ -746,7 +746,7 @@ def create_view( @abstractmethod def rename_view(self, from_identifier: str | Identifier, to_identifier: str | Identifier) -> None: - """Rename a fully classified view name. + """Rename a fully qualified view name. Args: from_identifier (str | Identifier): Existing view identifier. diff --git a/pyiceberg/catalog/bigquery_metastore.py b/pyiceberg/catalog/bigquery_metastore.py index 31c102dfe8..74d1c952bc 100644 --- a/pyiceberg/catalog/bigquery_metastore.py +++ b/pyiceberg/catalog/bigquery_metastore.py @@ -337,6 +337,7 @@ def load_view(self, identifier: str | Identifier) -> View: def rename_view(self, from_identifier: str | Identifier, to_identifier: str | Identifier) -> None: raise NotImplementedError + @override def load_namespace_properties(self, namespace: str | Identifier) -> Properties: dataset_name = self.identifier_to_database(namespace, NoSuchNamespaceError) diff --git a/pyiceberg/catalog/rest/__init__.py b/pyiceberg/catalog/rest/__init__.py index 5e5038f69f..ab024a0aa6 100644 --- a/pyiceberg/catalog/rest/__init__.py +++ b/pyiceberg/catalog/rest/__init__.py @@ -1859,23 +1859,23 @@ def drop_view(self, identifier: str | Identifier) -> None: except HTTPError as exc: _handle_non_200_response(exc, {404: NoSuchViewError}) + @override @retry(**_RETRY_ARGS) def rename_view(self, from_identifier: str | Identifier, to_identifier: str | Identifier) -> None: self._check_endpoint(Capability.V1_RENAME_VIEW) - payload = { - "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_json(to_identifier)["namespace"] + source = self._split_identifier_for_json(from_identifier) + destination = self._split_identifier_for_json(to_identifier) + # Ensure that namespaces exist on source and destination. + source_namespace = source["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}") + destination_namespace = destination["namespace"] + if not self.namespace_exists(destination_namespace): + raise NoSuchNamespaceError(f"Destination namespace does not exist: {destination_namespace}") + + payload = {"source": source, "destination": destination} response = self._session.post(self.url(Endpoints.rename_view), json=payload) try: response.raise_for_status() diff --git a/pyiceberg/catalog/sql.py b/pyiceberg/catalog/sql.py index db80249166..e600f4a3ac 100644 --- a/pyiceberg/catalog/sql.py +++ b/pyiceberg/catalog/sql.py @@ -848,5 +848,6 @@ def close(self) -> None: if hasattr(self, "engine"): self.engine.dispose() + @override def rename_view(self, from_identifier: str | Identifier, to_identifier: str | Identifier) -> None: raise NotImplementedError diff --git a/tests/integration/test_catalog.py b/tests/integration/test_catalog.py index 6f40232fe7..c3abaaf52f 100644 --- a/tests/integration/test_catalog.py +++ b/tests/integration/test_catalog.py @@ -786,7 +786,27 @@ def test_rest_rename_view( @pytest.mark.integration -@pytest.mark.skip(reason="Requires Iceberg REST Fixtures 1.11.x") +def test_rest_rename_view_across_namespaces( + rest_catalog: RestCatalog, example_view_metadata_v1: dict[str, Any], database_name: str, view_name: str +) -> None: + to_database_name = f"{database_name}_renamed" + from_identifier = (database_name, view_name) + to_identifier = (to_database_name, view_name) + + rest_catalog.create_namespace_if_not_exists(database_name) + rest_catalog.create_namespace_if_not_exists(to_database_name) + view = View(from_identifier, ViewMetadata.model_validate(example_view_metadata_v1)) + + rest_catalog.create_view(from_identifier, view.schema(), view.current_version()) + assert rest_catalog.view_exists(from_identifier) + + rest_catalog.rename_view(from_identifier, to_identifier) + + assert not rest_catalog.view_exists(from_identifier) + assert rest_catalog.view_exists(to_identifier) + + +@pytest.mark.integration def test_rest_custom_namespace_separator(rest_catalog: RestCatalog, table_schema_simple: Schema) -> None: """ Tests that the REST catalog correctly picks up the namespace-separator from the config endpoint. From 9143fa1b4db3c55c8754b3400aa49db7e3cdb927 Mon Sep 17 00:00:00 2001 From: Kevin Liu Date: Sat, 24 Oct 2026 22:01:02 -0700 Subject: [PATCH 8/9] Align rename_view ordering with sibling methods Put @retry before @override as in rename_table and drop_view, group SqlCatalog.rename_view with the other view stubs, and move the rename_view unit tests next to the other view tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pyiceberg/catalog/rest/__init__.py | 2 +- pyiceberg/catalog/sql.py | 8 +- tests/catalog/test_rest.py | 238 ++++++++++++++--------------- 3 files changed, 124 insertions(+), 124 deletions(-) diff --git a/pyiceberg/catalog/rest/__init__.py b/pyiceberg/catalog/rest/__init__.py index ab024a0aa6..5cccddc6b4 100644 --- a/pyiceberg/catalog/rest/__init__.py +++ b/pyiceberg/catalog/rest/__init__.py @@ -1859,8 +1859,8 @@ def drop_view(self, identifier: str | Identifier) -> None: except HTTPError as exc: _handle_non_200_response(exc, {404: NoSuchViewError}) - @override @retry(**_RETRY_ARGS) + @override def rename_view(self, from_identifier: str | Identifier, to_identifier: str | Identifier) -> None: self._check_endpoint(Capability.V1_RENAME_VIEW) source = self._split_identifier_for_json(from_identifier) diff --git a/pyiceberg/catalog/sql.py b/pyiceberg/catalog/sql.py index e600f4a3ac..94c7965a88 100644 --- a/pyiceberg/catalog/sql.py +++ b/pyiceberg/catalog/sql.py @@ -837,6 +837,10 @@ def drop_view(self, identifier: str | Identifier) -> None: def load_view(self, identifier: str | Identifier) -> View: raise NotImplementedError + @override + def rename_view(self, from_identifier: str | Identifier, to_identifier: str | Identifier) -> None: + raise NotImplementedError + def close(self) -> None: """Close the catalog and release database connections. @@ -847,7 +851,3 @@ def close(self) -> None: """ if hasattr(self, "engine"): self.engine.dispose() - - @override - 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 db8f5a9122..95e6a7a201 100644 --- a/tests/catalog/test_rest.py +++ b/tests/catalog/test_rest.py @@ -2951,6 +2951,125 @@ def test_drop_view_204(rest_mock: Mocker) -> None: RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN).drop_view(("some_namespace", "some_view")) +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={ + "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.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={ + "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.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={ + "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) + + +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) + + @mock.patch("google.auth.transport.requests.Request") @mock.patch("google.auth.load_credentials_from_file") def test_rest_catalog_with_google_credentials_path( @@ -3593,122 +3712,3 @@ 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.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={ - "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.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={ - "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.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={ - "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) - - -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 a070f06e41538333e83cb099d987db90057de286 Mon Sep 17 00:00:00 2001 From: Kevin Liu Date: Sat, 24 Oct 2026 22:01:12 -0700 Subject: [PATCH 9/9] Document rename_view and extend its test coverage Add a "Rename a view" docs section, list the errors rename_view raises in its docstring, assert view-endpoints-supported enables V1_RENAME_VIEW, and cover rename_view in the special-character identifiers integration test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- mkdocs/docs/api.md | 11 +++++++++++ pyiceberg/catalog/__init__.py | 3 ++- tests/catalog/test_rest.py | 1 + tests/integration/test_catalog.py | 6 +++++- 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/mkdocs/docs/api.md b/mkdocs/docs/api.md index 786223d47e..a732d2c0a3 100644 --- a/mkdocs/docs/api.md +++ b/mkdocs/docs/api.md @@ -1746,6 +1746,17 @@ To list views in the `default` namespace: catalog.list_views("default") ``` +## Rename a view + +To rename a view: + +```python +catalog.rename_view( + from_identifier="default.some_view", + to_identifier="default.some_view_renamed" +) +``` + ## Drop a view To drop a view: diff --git a/pyiceberg/catalog/__init__.py b/pyiceberg/catalog/__init__.py index 45d9c9fe35..022783b08a 100644 --- a/pyiceberg/catalog/__init__.py +++ b/pyiceberg/catalog/__init__.py @@ -754,7 +754,8 @@ def rename_view(self, from_identifier: str | Identifier, to_identifier: str | Id Raises: NoSuchViewError: If a view with the name does not exist. - ViewAlreadyExistsError: If the target view already exists. + ViewAlreadyExistsError: If a table or view with the target name already exists. + NoSuchNamespaceError: If the source or destination namespace does not exist. """ @staticmethod diff --git a/tests/catalog/test_rest.py b/tests/catalog/test_rest.py index 95e6a7a201..ad1e3c3296 100644 --- a/tests/catalog/test_rest.py +++ b/tests/catalog/test_rest.py @@ -3431,6 +3431,7 @@ def test_view_endpoints_enabled_with_config(self, requests_mock: Mocker) -> None # View endpoints should be supported when enabled catalog._check_endpoint(Capability.V1_LIST_VIEWS) catalog._check_endpoint(Capability.V1_DELETE_VIEW) + catalog._check_endpoint(Capability.V1_RENAME_VIEW) def test_table_uuid_check_on_commit(rest_mock: Mocker, example_table_metadata_v2: dict[str, Any]) -> None: diff --git a/tests/integration/test_catalog.py b/tests/integration/test_catalog.py index c3abaaf52f..ab9589cb85 100644 --- a/tests/integration/test_catalog.py +++ b/tests/integration/test_catalog.py @@ -731,6 +731,7 @@ def test_rest_identifiers_with_special_characters( table_identifier = (*namespace, f"table {name}") view_identifier = (*namespace, f"view {name}") registered_view_identifier = (*namespace, f"registered view {name}") + renamed_view_identifier = (*namespace, f"renamed view {name}") rest_catalog.create_namespace(parent) rest_catalog.create_namespace(namespace, properties={"key": name}) @@ -758,7 +759,10 @@ def test_rest_identifiers_with_special_characters( assert rest_catalog.view_exists(registered_view_identifier) assert sorted(rest_catalog.list_views(namespace)) == sorted([view_identifier, registered_view_identifier]) - rest_catalog.drop_view(registered_view_identifier) + rest_catalog.rename_view(registered_view_identifier, renamed_view_identifier) + assert sorted(rest_catalog.list_views(namespace)) == sorted([view_identifier, renamed_view_identifier]) + + rest_catalog.drop_view(renamed_view_identifier) rest_catalog.drop_view(view_identifier) rest_catalog.drop_table(table_identifier) rest_catalog.drop_namespace(namespace)