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

geruh 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 7fb55cd5 feat: add load_view to REST catalog (#3224)
7fb55cd5 is described below

commit 7fb55cd5a80e2f93ec22f1e0f01bd499088847f0
Author: spr0els <[email protected]>
AuthorDate: Thu May 7 21:57:05 2026 +0200

    feat: add load_view to REST catalog (#3224)
---
 pyiceberg/catalog/__init__.py           | 14 ++++++++++
 pyiceberg/catalog/bigquery_metastore.py |  4 +++
 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      | 17 ++++++++++++
 pyiceberg/catalog/sql.py                |  3 +++
 tests/catalog/test_rest.py              | 33 +++++++++++++++++++++++
 tests/conftest.py                       |  7 +++++
 tests/integration/test_rest_catalog.py  | 48 +++++++++++++++++++++++++++++++++
 11 files changed, 138 insertions(+)

diff --git a/pyiceberg/catalog/__init__.py b/pyiceberg/catalog/__init__.py
index beb02b80..5db35ac3 100644
--- a/pyiceberg/catalog/__init__.py
+++ b/pyiceberg/catalog/__init__.py
@@ -646,6 +646,20 @@ class Catalog(ABC):
             NoSuchNamespaceError: If a namespace with the given name does not 
exist.
         """
 
+    @abstractmethod
+    def load_view(self, identifier: str | Identifier) -> View:
+        """Load the view's metadata and returns the view instance.
+
+        Args:
+            identifier (str | Identifier): View identifier.
+
+        Returns:
+            View: the view instance with its metadata.
+
+        Raises:
+            NoSuchViewError: If a view with the name does not exist.
+        """
+
     @abstractmethod
     def load_namespace_properties(self, namespace: str | Identifier) -> 
Properties:
         """Get properties for a namespace.
diff --git a/pyiceberg/catalog/bigquery_metastore.py 
b/pyiceberg/catalog/bigquery_metastore.py
index 339d4715..1b8a172e 100644
--- a/pyiceberg/catalog/bigquery_metastore.py
+++ b/pyiceberg/catalog/bigquery_metastore.py
@@ -41,6 +41,7 @@ from pyiceberg.table.sorting import UNSORTED_SORT_ORDER, 
SortOrder
 from pyiceberg.table.update import TableRequirement, TableUpdate
 from pyiceberg.typedef import EMPTY_DICT, Identifier, Properties
 from pyiceberg.utils.config import Config
+from pyiceberg.view import View
 
 if TYPE_CHECKING:
     import pyarrow as pa
@@ -310,6 +311,9 @@ class BigQueryMetastoreCatalog(MetastoreCatalog):
     def view_exists(self, identifier: str | Identifier) -> bool:
         raise NotImplementedError
 
+    def load_view(self, identifier: str | Identifier) -> View:
+        raise NotImplementedError
+
     def load_namespace_properties(self, namespace: str | Identifier) -> 
Properties:
         dataset_name = self.identifier_to_database(namespace)
 
diff --git a/pyiceberg/catalog/dynamodb.py b/pyiceberg/catalog/dynamodb.py
index a4d900e1..aa66f08b 100644
--- a/pyiceberg/catalog/dynamodb.py
+++ b/pyiceberg/catalog/dynamodb.py
@@ -559,6 +559,9 @@ class DynamoDbCatalog(MetastoreCatalog):
     def view_exists(self, identifier: str | Identifier) -> bool:
         raise NotImplementedError
 
+    def load_view(self, identifier: str | Identifier) -> View:
+        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 81c4c57d..a21fe6da 100644
--- a/pyiceberg/catalog/glue.py
+++ b/pyiceberg/catalog/glue.py
@@ -976,6 +976,9 @@ class GlueCatalog(MetastoreCatalog):
     def view_exists(self, identifier: str | Identifier) -> bool:
         raise NotImplementedError
 
+    def load_view(self, identifier: str | Identifier) -> View:
+        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 3453f926..afca5954 100644
--- a/pyiceberg/catalog/hive.py
+++ b/pyiceberg/catalog/hive.py
@@ -486,6 +486,9 @@ class HiveCatalog(MetastoreCatalog):
     def view_exists(self, identifier: str | Identifier) -> bool:
         raise NotImplementedError
 
+    def load_view(self, identifier: str | Identifier) -> View:
+        raise NotImplementedError
+
     def _create_lock_request(self, database_name: str, table_name: str) -> 
LockRequest:
         lock_component: LockComponent = LockComponent(
             level=LockLevel.TABLE, type=LockType.EXCLUSIVE, 
dbname=database_name, tablename=table_name, isTransactional=True
diff --git a/pyiceberg/catalog/noop.py b/pyiceberg/catalog/noop.py
index cb714e1c..15243436 100644
--- a/pyiceberg/catalog/noop.py
+++ b/pyiceberg/catalog/noop.py
@@ -144,3 +144,6 @@ class NoopCatalog(Catalog):
         properties: Properties = EMPTY_DICT,
     ) -> View:
         raise NotImplementedError
+
+    def load_view(self, identifier: str | Identifier) -> View:
+        raise NotImplementedError
diff --git a/pyiceberg/catalog/rest/__init__.py 
b/pyiceberg/catalog/rest/__init__.py
index ca0ff75e..b3a80e11 100644
--- a/pyiceberg/catalog/rest/__init__.py
+++ b/pyiceberg/catalog/rest/__init__.py
@@ -152,6 +152,7 @@ class Endpoints:
     get_token: str = "oauth/tokens"
     rename_table: str = "tables/rename"
     list_views: str = "namespaces/{namespace}/views"
+    load_view: str = "namespaces/{namespace}/views/{view}"
     create_view: str = "namespaces/{namespace}/views"
     drop_view: str = "namespaces/{namespace}/views/{view}"
     view_exists: str = "namespaces/{namespace}/views/{view}"
@@ -180,6 +181,7 @@ class Capability:
     V1_REGISTER_TABLE = Endpoint(http_method=HttpMethod.POST, 
path=f"{API_PREFIX}/{Endpoints.register_table}")
 
     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_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}")
@@ -209,6 +211,7 @@ DEFAULT_ENDPOINTS: frozenset[Endpoint] = frozenset(
 VIEW_ENDPOINTS: frozenset[Endpoint] = frozenset(
     (
         Capability.V1_LIST_VIEWS,
+        Capability.V1_LOAD_VIEW,
         Capability.V1_DELETE_VIEW,
     )
 )
@@ -1109,6 +1112,20 @@ class RestCatalog(Catalog):
             _handle_non_200_response(exc, {404: NoSuchNamespaceError})
         return [(*view.namespace, view.name) for view in 
ListViewsResponse.model_validate_json(response.text).identifiers]
 
+    @retry(**_RETRY_ARGS)
+    def load_view(self, identifier: str | Identifier) -> View:
+        self._check_endpoint(Capability.V1_LOAD_VIEW)
+        response = self._session.get(
+            self.url(Endpoints.load_view, prefixed=True, 
**self._split_identifier_for_path(identifier, IdentifierKind.VIEW))
+        )
+        try:
+            response.raise_for_status()
+        except HTTPError as exc:
+            _handle_non_200_response(exc, {404: NoSuchViewError})
+
+        view_response = ViewResponse.model_validate_json(response.text)
+        return self._response_to_view(self.identifier_to_tuple(identifier), 
view_response)
+
     @retry(**_RETRY_ARGS)
     def commit_table(
         self, table: Table, requirements: tuple[TableRequirement, ...], 
updates: tuple[TableUpdate, ...]
diff --git a/pyiceberg/catalog/sql.py b/pyiceberg/catalog/sql.py
index a65b5f0d..dc703e95 100644
--- a/pyiceberg/catalog/sql.py
+++ b/pyiceberg/catalog/sql.py
@@ -748,6 +748,9 @@ class SqlCatalog(MetastoreCatalog):
     def drop_view(self, identifier: str | Identifier) -> None:
         raise NotImplementedError
 
+    def load_view(self, identifier: str | Identifier) -> View:
+        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 e61274ba..bd77f8ad 100644
--- a/tests/catalog/test_rest.py
+++ b/tests/catalog/test_rest.py
@@ -103,6 +103,7 @@ TEST_SUPPORTED_ENDPOINTS = [
     Capability.V1_RENAME_TABLE,
     Capability.V1_REGISTER_TABLE,
     Capability.V1_LIST_VIEWS,
+    Capability.V1_LOAD_VIEW,
     Capability.V1_VIEW_EXISTS,
     Capability.V1_DELETE_VIEW,
     Capability.V1_SUBMIT_TABLE_SCAN_PLAN,
@@ -1449,6 +1450,38 @@ def test_create_view_409(
     assert "View already exists" in str(e.value)
 
 
+def test_load_view_200(rest_mock: Mocker, example_view_metadata_rest_json: 
dict[str, Any]) -> None:
+    rest_mock.get(
+        f"{TEST_URI}v1/namespaces/fokko/views/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.load_view(("fokko", "view"))
+    expected = View(identifier=("fokko", "view"), 
metadata=ViewMetadata(**example_view_metadata_rest_json["metadata"]))
+    assert actual == expected
+
+
+def test_load_view_404(rest_mock: Mocker) -> None:
+    rest_mock.get(
+        f"{TEST_URI}v1/namespaces/fokko/views/non_existent_view",
+        json={
+            "error": {
+                "message": "View does not exist: examples.non_existent_view in 
warehouse 8bcb0838-50fc-472d-9ddb-8feb89ef5f1e",
+                "type": "NoSuchViewException",
+                "code": 404,
+            }
+        },
+        status_code=404,
+        request_headers=TEST_HEADERS,
+    )
+
+    with pytest.raises(NoSuchViewError) as e:
+        RestCatalog("rest", uri=TEST_URI, 
token=TEST_TOKEN).load_view(("fokko", "non_existent_view"))
+    assert "View does not exist" in str(e.value)
+
+
 def test_create_table_if_not_exists_200(
     rest_mock: Mocker, table_schema_simple: Schema, 
example_table_metadata_no_snapshot_v1_rest_json: dict[str, Any]
 ) -> None:
diff --git a/tests/conftest.py b/tests/conftest.py
index d1a9f928..b74e2eca 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -2353,6 +2353,13 @@ def table_list(table_name: str) -> list[str]:
     return [f"{table_name}_{idx}" for idx in range(NUM_TABLES)]
 
 
[email protected]()
+def view_name() -> str:
+    prefix = "my_iceberg_view-"
+    random_tag = "".join(choice(string.ascii_letters) for _ in 
range(RANDOM_LENGTH))
+    return (prefix + random_tag).lower()
+
+
 @pytest.fixture()
 def database_name() -> str:
     prefix = "my_iceberg_database-"
diff --git a/tests/integration/test_rest_catalog.py 
b/tests/integration/test_rest_catalog.py
index 18aa9431..05039a98 100644
--- a/tests/integration/test_rest_catalog.py
+++ b/tests/integration/test_rest_catalog.py
@@ -16,10 +16,15 @@
 # under the License.
 # pylint:disable=redefined-outer-name
 
+import time
+
 import pytest
 from pytest_lazy_fixtures import lf
 
 from pyiceberg.catalog.rest import RestCatalog
+from pyiceberg.exceptions import NoSuchViewError
+from pyiceberg.schema import Schema
+from pyiceberg.view.metadata import SQLViewRepresentation, ViewVersion
 
 TEST_NAMESPACE_IDENTIFIER = "TEST NS"
 
@@ -62,3 +67,46 @@ def test_create_namespace_if_already_existing(catalog: 
RestCatalog) -> None:
     catalog.create_namespace_if_not_exists(TEST_NAMESPACE_IDENTIFIER)
 
     assert catalog.namespace_exists(TEST_NAMESPACE_IDENTIFIER)
+
+
[email protected]
[email protected]("catalog", [lf("session_catalog")])
+def test_load_view(catalog: RestCatalog, table_schema_nested: Schema, 
database_name: str, view_name: str) -> None:
+    identifier = (database_name, view_name)
+    if not catalog.namespace_exists(database_name):
+        catalog.create_namespace(database_name)
+
+    view_version = ViewVersion(
+        version_id=1,
+        schema_id=1,
+        timestamp_ms=int(time.time() * 1000),
+        summary={},
+        representations=[
+            SQLViewRepresentation(
+                type="sql",
+                sql="SELECT 1 as some_col",
+                dialect="spark",
+            )
+        ],
+        default_namespace=["default"],
+    )
+    view = catalog.create_view(identifier, table_schema_nested, 
view_version=view_version)
+    loaded_view = catalog.load_view(identifier)
+    assert view == loaded_view
+
+
[email protected]
[email protected]("catalog", [lf("session_catalog")])
+def test_load_view_with_table_ident(
+    catalog: RestCatalog, table_name: str, table_schema_nested: Schema, 
database_name: str
+) -> None:
+    table_identifier = (database_name, table_name)
+    if not catalog.namespace_exists(database_name):
+        catalog.create_namespace(database_name)
+
+    if not catalog.table_exists(table_identifier):
+        catalog.create_table(table_identifier, table_schema_nested)
+
+    assert catalog.table_exists(table_identifier)
+    with pytest.raises(NoSuchViewError):
+        catalog.load_view(table_identifier)

Reply via email to