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

Fokko 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 e5988277 Add support for registering views (#3288)
e5988277 is described below

commit e5988277fc841566b308c670176ab43592a25692
Author: Yuya Ebihara <[email protected]>
AuthorDate: Mon May 11 13:18:22 2026 +0900

    Add support for registering views (#3288)
    
    # Rationale for this change
    
    - Relates to https://github.com/apache/iceberg/pull/14869
    
    ## Are these changes tested?
    
    tests/catalog/test_rest.py contains new tests.
    
    ## Are there any user-facing changes?
    
    This PR adds a new `register_view` method to `catalog`.
    
    <!-- In the case of user-facing changes, please add the changelog label.
    -->
---
 mkdocs/docs/api.md                      | 11 ++++++
 pyiceberg/catalog/__init__.py           | 16 ++++++++
 pyiceberg/catalog/bigquery_metastore.py |  3 ++
 pyiceberg/catalog/dynamodb.py           |  3 ++
 pyiceberg/catalog/glue.py               |  3 ++
 pyiceberg/catalog/hive.py               |  3 ++
 pyiceberg/catalog/noop.py               |  3 ++
 pyiceberg/catalog/rest/__init__.py      | 30 +++++++++++++++
 pyiceberg/catalog/sql.py                |  3 ++
 tests/catalog/test_rest.py              | 67 +++++++++++++++++++++++++++++++++
 10 files changed, 142 insertions(+)

diff --git a/mkdocs/docs/api.md b/mkdocs/docs/api.md
index 22d6b2e3..29d09e26 100644
--- a/mkdocs/docs/api.md
+++ b/mkdocs/docs/api.md
@@ -1529,6 +1529,17 @@ catalog = load_catalog("default")
 catalog.view_exists("default.bar")
 ```
 
+## Register a view
+
+To register a view using existing metadata:
+
+```python
+catalog.register_view(
+    identifier="docs_example.bids",
+    metadata_location="s3://warehouse/path/to/metadata.json"
+)
+```
+
 ## Table Statistics Management
 
 Manage table statistics with operations through the `Table` API:
diff --git a/pyiceberg/catalog/__init__.py b/pyiceberg/catalog/__init__.py
index 5db35ac3..ef3e51ae 100644
--- a/pyiceberg/catalog/__init__.py
+++ b/pyiceberg/catalog/__init__.py
@@ -690,6 +690,22 @@ class Catalog(ABC):
             ValueError: If removals and updates have overlapping keys.
         """
 
+    @abstractmethod
+    def register_view(self, identifier: str | Identifier, metadata_location: 
str) -> View:
+        """Register a new view using existing metadata.
+
+        Args:
+            identifier (Union[str, Identifier]): View identifier for the view
+            metadata_location (str): The location to the metadata
+
+        Returns:
+            View: The newly registered view
+
+        Raises:
+            ViewAlreadyExistsError: If the view already exists.
+            TableAlreadyExistsError: If a table with the same name already 
exists.
+        """
+
     @abstractmethod
     def drop_view(self, identifier: str | Identifier) -> None:
         """Drop a view.
diff --git a/pyiceberg/catalog/bigquery_metastore.py 
b/pyiceberg/catalog/bigquery_metastore.py
index 1b8a172e..d389e2e1 100644
--- a/pyiceberg/catalog/bigquery_metastore.py
+++ b/pyiceberg/catalog/bigquery_metastore.py
@@ -305,6 +305,9 @@ class BigQueryMetastoreCatalog(MetastoreCatalog):
     def list_views(self, namespace: str | Identifier) -> list[Identifier]:
         raise NotImplementedError
 
+    def register_view(self, identifier: str | Identifier, metadata_location: 
str) -> View:
+        raise NotImplementedError
+
     def drop_view(self, identifier: str | Identifier) -> None:
         raise NotImplementedError
 
diff --git a/pyiceberg/catalog/dynamodb.py b/pyiceberg/catalog/dynamodb.py
index aa66f08b..2aaf11c8 100644
--- a/pyiceberg/catalog/dynamodb.py
+++ b/pyiceberg/catalog/dynamodb.py
@@ -553,6 +553,9 @@ class DynamoDbCatalog(MetastoreCatalog):
     def list_views(self, namespace: str | Identifier) -> list[Identifier]:
         raise NotImplementedError
 
+    def register_view(self, identifier: str | Identifier, metadata_location: 
str) -> View:
+        raise NotImplementedError
+
     def drop_view(self, identifier: str | Identifier) -> None:
         raise NotImplementedError
 
diff --git a/pyiceberg/catalog/glue.py b/pyiceberg/catalog/glue.py
index a21fe6da..92d0aa45 100644
--- a/pyiceberg/catalog/glue.py
+++ b/pyiceberg/catalog/glue.py
@@ -970,6 +970,9 @@ class GlueCatalog(MetastoreCatalog):
     def list_views(self, namespace: str | Identifier) -> list[Identifier]:
         raise NotImplementedError
 
+    def register_view(self, identifier: str | Identifier, metadata_location: 
str) -> View:
+        raise NotImplementedError
+
     def drop_view(self, identifier: str | Identifier) -> None:
         raise NotImplementedError
 
diff --git a/pyiceberg/catalog/hive.py b/pyiceberg/catalog/hive.py
index afca5954..8d2a7430 100644
--- a/pyiceberg/catalog/hive.py
+++ b/pyiceberg/catalog/hive.py
@@ -854,6 +854,9 @@ class HiveCatalog(MetastoreCatalog):
 
         return PropertiesUpdateSummary(removed=list(removed or []), 
updated=list(updated or []), missing=list(expected_to_change))
 
+    def register_view(self, identifier: str | Identifier, metadata_location: 
str) -> View:
+        raise NotImplementedError
+
     def drop_view(self, identifier: str | Identifier) -> None:
         raise NotImplementedError
 
diff --git a/pyiceberg/catalog/noop.py b/pyiceberg/catalog/noop.py
index 15243436..3a1a8e7c 100644
--- a/pyiceberg/catalog/noop.py
+++ b/pyiceberg/catalog/noop.py
@@ -132,6 +132,9 @@ class NoopCatalog(Catalog):
     def namespace_exists(self, namespace: str | Identifier) -> bool:
         raise NotImplementedError
 
+    def register_view(self, identifier: str | Identifier, metadata_location: 
str) -> View:
+        raise NotImplementedError
+
     def drop_view(self, identifier: str | Identifier) -> None:
         raise NotImplementedError
 
diff --git a/pyiceberg/catalog/rest/__init__.py 
b/pyiceberg/catalog/rest/__init__.py
index b3a80e11..8c5647e7 100644
--- a/pyiceberg/catalog/rest/__init__.py
+++ b/pyiceberg/catalog/rest/__init__.py
@@ -154,6 +154,7 @@ class Endpoints:
     list_views: str = "namespaces/{namespace}/views"
     load_view: str = "namespaces/{namespace}/views/{view}"
     create_view: str = "namespaces/{namespace}/views"
+    register_view: str = "namespaces/{namespace}/register-view"
     drop_view: str = "namespaces/{namespace}/views/{view}"
     view_exists: str = "namespaces/{namespace}/views/{view}"
     plan_table_scan: str = "namespaces/{namespace}/tables/{table}/plan"
@@ -183,6 +184,7 @@ class Capability:
     V1_LIST_VIEWS = Endpoint(http_method=HttpMethod.GET, 
path=f"{API_PREFIX}/{Endpoints.list_views}")
     V1_LOAD_VIEW = Endpoint(http_method=HttpMethod.GET, 
path=f"{API_PREFIX}/{Endpoints.load_view}")
     V1_VIEW_EXISTS = Endpoint(http_method=HttpMethod.HEAD, 
path=f"{API_PREFIX}/{Endpoints.view_exists}")
+    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_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}")
@@ -322,6 +324,11 @@ class RegisterTableRequest(IcebergBaseModel):
     overwrite: bool
 
 
+class RegisterViewRequest(IcebergBaseModel):
+    name: str
+    metadata_location: str = Field(..., alias="metadata-location")
+
+
 class ConfigResponse(IcebergBaseModel):
     defaults: Properties | None = Field(default_factory=dict)
     overrides: Properties | None = Field(default_factory=dict)
@@ -1332,6 +1339,29 @@ class RestCatalog(Catalog):
 
         return False
 
+    @retry(**_RETRY_ARGS)
+    def register_view(self, identifier: str | Identifier, metadata_location: 
str) -> View:
+        self._check_endpoint(Capability.V1_REGISTER_VIEW)
+        namespace_and_view = self._split_identifier_for_path(identifier, 
IdentifierKind.VIEW)
+        namespace = namespace_and_view["namespace"]
+        view = namespace_and_view["view"]
+        if self.table_exists(identifier):
+            raise TableAlreadyExistsError(f"Table {namespace}.{view} already 
exists")
+
+        request = RegisterViewRequest(name=view, 
metadata_location=metadata_location)
+        serialized_json = request.model_dump_json().encode(UTF8)
+        response = self._session.post(
+            self.url(Endpoints.register_view, namespace=namespace),
+            data=serialized_json,
+        )
+        try:
+            response.raise_for_status()
+        except HTTPError as exc:
+            _handle_non_200_response(exc, {409: ViewAlreadyExistsError})
+
+        view_response = ViewResponse.model_validate_json(response.text)
+        return self._response_to_view(self.identifier_to_tuple(identifier), 
view_response)
+
     @retry(**_RETRY_ARGS)
     def drop_view(self, identifier: str) -> None:
         self._check_endpoint(Capability.V1_DELETE_VIEW)
diff --git a/pyiceberg/catalog/sql.py b/pyiceberg/catalog/sql.py
index dc703e95..92ac5375 100644
--- a/pyiceberg/catalog/sql.py
+++ b/pyiceberg/catalog/sql.py
@@ -745,6 +745,9 @@ class SqlCatalog(MetastoreCatalog):
     def view_exists(self, identifier: str | Identifier) -> bool:
         raise NotImplementedError
 
+    def register_view(self, identifier: str | Identifier, metadata_location: 
str) -> View:
+        raise NotImplementedError
+
     def drop_view(self, identifier: str | Identifier) -> None:
         raise NotImplementedError
 
diff --git a/tests/catalog/test_rest.py b/tests/catalog/test_rest.py
index bd77f8ad..edbe67d9 100644
--- a/tests/catalog/test_rest.py
+++ b/tests/catalog/test_rest.py
@@ -105,6 +105,7 @@ TEST_SUPPORTED_ENDPOINTS = [
     Capability.V1_LIST_VIEWS,
     Capability.V1_LOAD_VIEW,
     Capability.V1_VIEW_EXISTS,
+    Capability.V1_REGISTER_VIEW,
     Capability.V1_DELETE_VIEW,
     Capability.V1_SUBMIT_TABLE_SCAN_PLAN,
     Capability.V1_TABLE_SCAN_PLAN_TASKS,
@@ -2182,6 +2183,72 @@ def test_table_identifier_in_commit_table_request(
     )
 
 
+def test_register_view_200(rest_mock: Mocker, example_view_metadata_rest_json: 
dict[str, Any]) -> None:
+    rest_mock.head(
+        f"{TEST_URI}v1/namespaces/default/tables/registered_view",
+        status_code=404,
+        request_headers=TEST_HEADERS,
+    )
+    rest_mock.post(
+        f"{TEST_URI}v1/namespaces/default/register-view",
+        json=example_view_metadata_rest_json,
+        status_code=200,
+        request_headers=TEST_HEADERS,
+    )
+
+    catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN)
+    actual = catalog.register_view(
+        identifier=("default", "registered_view"), 
metadata_location="s3://warehouse/database/view/metadata.json"
+    )
+    expected = View(
+        identifier=("default", "registered_view"),
+        metadata=ViewMetadata(**example_view_metadata_rest_json["metadata"]),
+    )
+    assert actual == expected
+
+
+def test_register_view_409_view(rest_mock: Mocker) -> None:
+    rest_mock.head(
+        f"{TEST_URI}v1/namespaces/default/tables/registered_view",
+        status_code=404,
+        request_headers=TEST_HEADERS,
+    )
+    rest_mock.post(
+        f"{TEST_URI}v1/namespaces/default/register-view",
+        json={
+            "error": {
+                "message": "View already exists: default.view in warehouse 
8bcb0838-50fc-472d-9ddb-8feb89ef5f1e",
+                "type": "AlreadyExistsException",
+                "code": 409,
+            }
+        },
+        status_code=409,
+        request_headers=TEST_HEADERS,
+    )
+
+    catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN)
+    with pytest.raises(ViewAlreadyExistsError) as e:
+        catalog.register_view(
+            identifier=("default", "registered_view"), 
metadata_location="s3://warehouse/database/view/metadata.json"
+        )
+    assert "View already exists" in str(e.value)
+
+
+def test_register_view_409_table(rest_mock: Mocker) -> None:
+    rest_mock.head(
+        f"{TEST_URI}v1/namespaces/default/tables/registered_view",
+        status_code=200,
+        request_headers=TEST_HEADERS,
+    )
+
+    catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN)
+    with pytest.raises(TableAlreadyExistsError) as e:
+        catalog.register_view(
+            identifier=("default", "registered_view"), 
metadata_location="s3://warehouse/database/view/metadata.json"
+        )
+    assert "Table default.registered_view already exists" in str(e.value)
+
+
 def test_drop_view_invalid_namespace(rest_mock: Mocker) -> None:
     view = "view"
     with pytest.raises(NoSuchIdentifierError) as e:

Reply via email to