Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pyiceberg/catalog/rest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down
54 changes: 54 additions & 0 deletions pyiceberg/table/labels.py
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions tests/catalog/test_rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@
HttpMethod,
RestCatalog,
ScanPlanningMode,
TableResponse,
ViewResponse,
_RetryTimeoutHTTPAdapter,
)
from pyiceberg.exceptions import (
Expand All @@ -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
Expand Down Expand Up @@ -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()
67 changes: 67 additions & 0 deletions tests/table/test_labels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Licensed to the Apache Software Foundation (ASF) under one

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be nice to test this against the test-fixtures, but that's waiting on apache/iceberg#18047

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up here: #18047 is merged now, so the fixtures are ready. I'll add a fixture-based test as a follow-up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. One note on the fixture: no published apache/iceberg-rest-fixture image has #18047 yet. The newest image, latest, is from 2026-04-29, because the publish workflow is disabled. I opened apache/iceberg#18877 to publish the image again. After a new image ships, the test needs CATALOG_INCLUDE__LABELS=true on the rest service in dev/docker-compose-integration.yml. I can add the test then, if you want.

# 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)
Loading