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:
"""