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)