This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-python.git


The following commit(s) were added to refs/heads/main by this push:
     new ea139cc82 Add rename_view to REST Catalog (#4081)
ea139cc82 is described below

commit ea139cc8277c2aa5764b186f3f8fc50e6a98d830
Author: Alex Stephen <[email protected]>
AuthorDate: Sun Oct 25 05:25:27 2026 +0000

    Add rename_view to REST Catalog (#4081)
    
    * just need to add abstract methods
    
    * add abstract methods to additional classes
    
    * Add namespace check on rename_view
    
    * Added test for namespace exists
    
    * rebase
    
    * PR comments
    
    * 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 <[email protected]>
    
    * 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 <[email protected]>
    
    * 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 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
    Co-authored-by: Kevin Liu <[email protected]>
    Co-authored-by: Copilot App <[email protected]>
---
 mkdocs/docs/api.md                      |  11 +++
 pyiceberg/catalog/__init__.py           |  14 ++++
 pyiceberg/catalog/bigquery_metastore.py |   4 ++
 pyiceberg/catalog/dynamodb.py           |   4 ++
 pyiceberg/catalog/glue.py               |   4 ++
 pyiceberg/catalog/hive.py               |   4 ++
 pyiceberg/catalog/noop.py               |   4 ++
 pyiceberg/catalog/rest/__init__.py      |  26 +++++++
 pyiceberg/catalog/sql.py                |   4 ++
 tests/catalog/test_rest.py              | 121 ++++++++++++++++++++++++++++++++
 tests/integration/test_catalog.py       |  46 +++++++++++-
 11 files changed, 241 insertions(+), 1 deletion(-)

diff --git a/mkdocs/docs/api.md b/mkdocs/docs/api.md
index 786223d47..a732d2c0a 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 0842f51cc..022783b08 100644
--- a/pyiceberg/catalog/__init__.py
+++ b/pyiceberg/catalog/__init__.py
@@ -744,6 +744,20 @@ class Catalog(ABC):
             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 qualified 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.
+            ViewAlreadyExistsError: If a table or view with the target name 
already exists.
+            NoSuchNamespaceError: If the source or destination namespace does 
not exist.
+        """
+
     @staticmethod
     def identifier_to_tuple(identifier: str | Identifier) -> Identifier:
         """Parse an identifier to a tuple.
diff --git a/pyiceberg/catalog/bigquery_metastore.py 
b/pyiceberg/catalog/bigquery_metastore.py
index 1267233e9..74d1c952b 100644
--- a/pyiceberg/catalog/bigquery_metastore.py
+++ b/pyiceberg/catalog/bigquery_metastore.py
@@ -333,6 +333,10 @@ class BigQueryMetastoreCatalog(MetastoreCatalog):
     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
+
     @override
     def load_namespace_properties(self, namespace: str | Identifier) -> 
Properties:
         dataset_name = self.identifier_to_database(namespace, 
NoSuchNamespaceError)
diff --git a/pyiceberg/catalog/dynamodb.py b/pyiceberg/catalog/dynamodb.py
index b4111a6fb..d20c0268b 100644
--- a/pyiceberg/catalog/dynamodb.py
+++ b/pyiceberg/catalog/dynamodb.py
@@ -584,6 +584,10 @@ class DynamoDbCatalog(MetastoreCatalog):
     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 a7d0683a0..e678a8b6f 100644
--- a/pyiceberg/catalog/glue.py
+++ b/pyiceberg/catalog/glue.py
@@ -1016,6 +1016,10 @@ class GlueCatalog(MetastoreCatalog):
     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 a44da81b6..2c48b4846 100644
--- a/pyiceberg/catalog/hive.py
+++ b/pyiceberg/catalog/hive.py
@@ -506,6 +506,10 @@ class HiveCatalog(MetastoreCatalog):
     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 df18c85f7..90b0f398e 100644
--- a/pyiceberg/catalog/noop.py
+++ b/pyiceberg/catalog/noop.py
@@ -175,3 +175,7 @@ class NoopCatalog(Catalog):
     @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/rest/__init__.py 
b/pyiceberg/catalog/rest/__init__.py
index 0a44dbb2b..5cccddc6b 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 @@ VIEW_ENDPOINTS: frozenset[Endpoint] = frozenset(
         Capability.V1_LOAD_VIEW,
         Capability.V1_CREATE_VIEW,
         Capability.V1_DELETE_VIEW,
+        Capability.V1_RENAME_VIEW,
     )
 )
 
@@ -1856,6 +1859,29 @@ class RestCatalog(Catalog):
         except HTTPError as exc:
             _handle_non_200_response(exc, {404: NoSuchViewError})
 
+    @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)
+        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}")
+
+        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()
+        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/pyiceberg/catalog/sql.py b/pyiceberg/catalog/sql.py
index 13e67997d..94c7965a8 100644
--- a/pyiceberg/catalog/sql.py
+++ b/pyiceberg/catalog/sql.py
@@ -837,6 +837,10 @@ class SqlCatalog(MetastoreCatalog):
     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.
 
diff --git a/tests/catalog/test_rest.py b/tests/catalog/test_rest.py
index 33c4dfeea..ad1e3c329 100644
--- a/tests/catalog/test_rest.py
+++ b/tests/catalog/test_rest.py
@@ -121,6 +121,7 @@ TEST_SUPPORTED_ENDPOINTS = [
     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,
 ]
@@ -2950,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(
@@ -3311,6 +3431,7 @@ class TestRestCatalogClose:
         # 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 decd0e11c..ab9589cb8 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)
@@ -766,6 +770,46 @@ def test_rest_identifiers_with_special_characters(
         assert not rest_catalog.namespace_exists(parent)
 
 
[email protected]
+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)
+
+
[email protected]
+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:
     """

Reply via email to