diff --git a/pyiceberg/catalog/rest/__init__.py b/pyiceberg/catalog/rest/__init__.py index c8e2399fe8..95db70c2a7 100644 --- a/pyiceberg/catalog/rest/__init__.py +++ b/pyiceberg/catalog/rest/__init__.py @@ -88,6 +88,7 @@ TableIdentifier, TableProperties, ) +from pyiceberg.table.labels import Labels from pyiceberg.table.metadata import TableMetadata from pyiceberg.table.sorting import UNSORTED_SORT_ORDER, SortOrder, assign_fresh_sort_order_ids from pyiceberg.table.update import ( @@ -357,6 +358,7 @@ class TableResponse(IcebergBaseModel): metadata: TableMetadata config: Properties = Field(default_factory=dict) storage_credentials: list[StorageCredential] = Field(alias="storage-credentials", default_factory=list) + labels: Labels = Field(default_factory=Labels) class LoadCredentialsResponse(IcebergBaseModel): @@ -367,6 +369,7 @@ class ViewResponse(IcebergBaseModel): metadata_location: str = Field(..., alias="metadata-location") metadata: ViewMetadata config: Properties = Field(default_factory=dict) + labels: Labels = Field(default_factory=Labels) class CreateTableRequest(IcebergBaseModel): diff --git a/pyiceberg/table/labels.py b/pyiceberg/table/labels.py new file mode 100644 index 0000000000..104a3e25e6 --- /dev/null +++ b/pyiceberg/table/labels.py @@ -0,0 +1,54 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from typing import Any + +from pydantic import Field, model_validator + +from pyiceberg.typedef import IcebergBaseModel + + +class FieldLabel(IcebergBaseModel): + """Labels attached to a single field, identified by its field ID.""" + + field_id: int = Field(alias="field-id") + labels: dict[str, str] + + +class Labels(IcebergBaseModel): + """Optional labels that a catalog returns with a table or view. + + Labels are enrichment supplied by the catalog, such as ownership or classification. + They are not table state and are not written to the table metadata. + """ + + object_labels: dict[str, str] = Field(alias="object-labels", default_factory=dict) + fields: list[FieldLabel] = Field(default_factory=list) + + @model_validator(mode="before") + @classmethod + def treat_null_as_absent(cls, data: Any) -> Any: + # A key with a null value parses the same as a missing key. + if isinstance(data, dict): + return {key: value for key, value in data.items() if value is not None} + if isinstance(data, cls): + return data + # Like Java, a null or non-object value reads as empty labels. + return {} + + def is_empty(self) -> bool: + """Return True when there are neither object-level nor field-level labels.""" + return not self.object_labels and not self.fields diff --git a/tests/catalog/test_rest.py b/tests/catalog/test_rest.py index ad1e3c3296..632dc7b5fb 100644 --- a/tests/catalog/test_rest.py +++ b/tests/catalog/test_rest.py @@ -51,6 +51,8 @@ HttpMethod, RestCatalog, ScanPlanningMode, + TableResponse, + ViewResponse, _RetryTimeoutHTTPAdapter, ) from pyiceberg.exceptions import ( @@ -71,6 +73,7 @@ from pyiceberg.partitioning import PartitionField, PartitionSpec from pyiceberg.schema import Schema from pyiceberg.table import Table +from pyiceberg.table.labels import FieldLabel, Labels from pyiceberg.table.metadata import TableMetadataV1 from pyiceberg.table.sorting import SortField, SortOrder from pyiceberg.transforms import IdentityTransform, TruncateTransform @@ -3713,3 +3716,21 @@ def test_load_table_without_storage_credentials( ) assert actual.metadata.model_dump() == expected.metadata.model_dump() assert actual == expected + + +def test_load_responses_parse_labels( + example_table_metadata_with_snapshot_v1_rest_json: dict[str, Any], example_view_metadata_rest_json: dict[str, Any] +) -> None: + labels_json = {"object-labels": {"owner": "team-a"}, "fields": [{"field-id": 1, "labels": {"classification": "pii"}}]} + expected = Labels(object_labels={"owner": "team-a"}, fields=[FieldLabel(field_id=1, labels={"classification": "pii"})]) + cases: list[tuple[type[TableResponse | ViewResponse], dict[str, Any]]] = [ + (TableResponse, example_table_metadata_with_snapshot_v1_rest_json), + (ViewResponse, example_view_metadata_rest_json), + ] + # Like Java, a null or non-object value reads as empty labels. + invalid_values: list[Any] = [None, "oops", [], 1, True] + for response_type, payload in cases: + assert response_type.model_validate({**payload, "labels": labels_json}).labels == expected + assert response_type.model_validate(payload).labels == Labels() + for invalid in invalid_values: + assert response_type.model_validate({**payload, "labels": invalid}).labels == Labels() diff --git a/tests/table/test_labels.py b/tests/table/test_labels.py new file mode 100644 index 0000000000..49fab32712 --- /dev/null +++ b/tests/table/test_labels.py @@ -0,0 +1,67 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +import pytest +from pydantic import ValidationError + +from pyiceberg.table.labels import FieldLabel, Labels + + +def test_labels_from_json() -> None: + cases = [ + ('{"object-labels": null, "fields": null}', Labels()), + ( + '{"object-labels": {"owner": "team-a", "sensitivity": "high"}}', + Labels(object_labels={"owner": "team-a", "sensitivity": "high"}), + ), + ( + '{"fields": [{"field-id": 3, "labels": {"pii": "true"}}]}', + Labels(fields=[FieldLabel(field_id=3, labels={"pii": "true"})]), + ), + ( + """ + { + "object-labels": {"owner": "team-a"}, + "fields": [ + {"field-id": 1, "labels": {"classification": "pii"}}, + {"field-id": 2, "labels": {}} + ] + } + """, + Labels( + object_labels={"owner": "team-a"}, + fields=[FieldLabel(field_id=1, labels={"classification": "pii"}), FieldLabel(field_id=2, labels={})], + ), + ), + ] + for json, expected in cases: + assert Labels.model_validate_json(json) == expected + + +def test_labels_is_empty() -> None: + assert Labels().is_empty() + assert not Labels(object_labels={"owner": "team-a"}).is_empty() + assert not Labels(fields=[FieldLabel(field_id=1, labels={})]).is_empty() + + +def test_field_label_requires_field_id_and_labels() -> None: + cases = [ + ('{"labels": {}}', "1 validation error for FieldLabel\nfield-id\n Field required"), + ('{"field-id": 1}', "1 validation error for FieldLabel\nlabels\n Field required"), + ] + for json, message in cases: + with pytest.raises(ValidationError, match=message): + FieldLabel.model_validate_json(json)