Skip to content

Commit 4e04939

Browse files
committed
Add snapshot selection to manifest inspection
Decode partition summaries with snapshot schemas and retained source fields so historical manifests remain readable after evolution. Generated-by: OpenAI Codex
1 parent 4e6033d commit 4e04939

3 files changed

Lines changed: 192 additions & 8 deletions

File tree

mkdocs/docs/api.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,20 @@ To show a table's current file manifests:
853853
table.inspect.manifests()
854854
```
855855

856+
To inspect a retained snapshot, pass its snapshot ID:
857+
858+
```python
859+
table.inspect.manifests(snapshot_id=123456789)
860+
```
861+
862+
Partition summaries use the snapshot's schema and each manifest's partition spec.
863+
If a snapshot has no recorded schema ID, the current schema is used. Sources dropped
864+
from that schema are resolved by field ID from retained schemas.
865+
An unknown or expired snapshot ID raises `ValueError`. Without a snapshot ID, a table
866+
with no snapshots returns an empty table with the manifest metadata schema.
867+
`all_manifests()` continues to include all retained snapshots, including repeated
868+
manifests, and does not accept a snapshot ID.
869+
856870
```python
857871
pyarrow.Table
858872
content: int8 not null

pyiceberg/table/inspect.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from __future__ import annotations
1818

1919
import itertools
20+
import warnings
2021
from collections.abc import Iterator
2122
from datetime import datetime, timezone
2223
from typing import TYPE_CHECKING, Any
@@ -558,7 +559,18 @@ def _partition_summaries_to_rows(
558559
rows = []
559560
for i, field_summary in enumerate(partition_summaries):
560561
field = spec.fields[i]
561-
partition_field_type = spec.partition_type(self.tbl.schema()).fields[i].field_type
562+
partition_schema = schema
563+
if field.source_id not in schema.field_ids:
564+
# Retained manifests can reference partition sources dropped before this snapshot.
565+
partition_schema = next(
566+
(
567+
old_schema
568+
for old_schema in reversed(self.tbl.metadata.schemas)
569+
if field.source_id in old_schema.field_ids
570+
),
571+
schema,
572+
)
573+
partition_field_type = spec.partition_type(partition_schema).fields[i].field_type
562574
lower_bound = (
563575
(
564576
field.transform.to_human_string(
@@ -590,6 +602,12 @@ def _partition_summaries_to_rows(
590602
specs = self.tbl.metadata.specs()
591603
manifests = []
592604
if snapshot:
605+
schema = self.tbl.schema()
606+
if snapshot.schema_id is not None:
607+
if snapshot_schema := self.tbl.metadata.schema_by_id(snapshot.schema_id):
608+
schema = snapshot_schema
609+
else:
610+
warnings.warn(f"Metadata does not contain schema with id: {snapshot.schema_id}", stacklevel=2)
593611
for manifest in snapshot.manifests(self.tbl.io):
594612
is_data_file = manifest.content == ManifestContent.DATA
595613
is_delete_file = manifest.content == ManifestContent.DELETES
@@ -619,15 +637,22 @@ def _partition_summaries_to_rows(
619637
schema=self._get_all_manifests_schema() if is_all_manifests_table else self._get_manifests_schema(),
620638
)
621639

622-
def manifests(self) -> pa.Table:
623-
"""Return the manifest files for the current snapshot as a PyArrow Table.
640+
def manifests(self, snapshot_id: int | None = None) -> pa.Table:
641+
"""Return the manifest files for a snapshot as a PyArrow Table.
642+
643+
Args:
644+
snapshot_id: Optional snapshot ID. If None, uses the current snapshot.
624645
625646
Returns:
626-
pa.Table: Manifest metadata for the current snapshot, or an empty
627-
table if the table has no snapshots.
647+
pa.Table: Manifest metadata for the selected snapshot, or an empty
648+
table if no snapshot ID is supplied and the table has no snapshots.
628649
See ``_get_manifests_schema`` for the full column list.
650+
651+
Raises:
652+
ValueError: If the supplied snapshot ID is not found.
629653
"""
630-
return self._generate_manifests_table(self.tbl.current_snapshot())
654+
snapshot = self._get_snapshot(snapshot_id) if snapshot_id is not None else self.tbl.current_snapshot()
655+
return self._generate_manifests_table(snapshot)
631656

632657
def metadata_log_entries(self) -> pa.Table:
633658
"""Return the metadata log of the table as a PyArrow Table.

tests/table/test_inspect.py

Lines changed: 147 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@
2727
from pyiceberg.schema import Schema
2828
from pyiceberg.table.inspect import InspectTable, _readable_bound
2929
from pyiceberg.table.snapshots import Snapshot
30-
from pyiceberg.transforms import IdentityTransform
30+
from pyiceberg.transforms import BucketTransform, IdentityTransform
3131
from pyiceberg.typedef import Record
32-
from pyiceberg.types import NestedField, StringType
32+
from pyiceberg.types import DoubleType, FloatType, IntegerType, LongType, NestedField, PrimitiveType, StringType
3333
from tests.catalog.test_base import InMemoryCatalog
3434

3535

@@ -106,3 +106,148 @@ def test_inspect_manifests_preserves_empty_string_bounds(catalog: InMemoryCatalo
106106
partition_summary = tbl.inspect.manifests().to_pydict()["partition_summaries"][0][0]
107107
assert partition_summary["lower_bound"] == ""
108108
assert partition_summary["upper_bound"] == ""
109+
110+
111+
def test_inspect_manifests_snapshot_selection(catalog: InMemoryCatalog) -> None:
112+
tbl = catalog.create_table("default.manifests", Schema(NestedField(1, "s", StringType())))
113+
empty = tbl.inspect.manifests()
114+
assert empty.num_rows == 0
115+
assert empty.equals(tbl.inspect.manifests(snapshot_id=None))
116+
assert tbl.inspect.all_manifests().num_rows == 0
117+
118+
data = pa.table({"s": ["first"]})
119+
tbl.append(data)
120+
first = tbl.current_snapshot()
121+
assert first is not None
122+
first_rows = tbl.inspect.manifests()
123+
tbl.append(data)
124+
current = tbl.current_snapshot()
125+
assert current is not None
126+
current_rows = tbl.inspect.manifests()
127+
128+
assert empty.schema == first_rows.schema == current_rows.schema
129+
assert first_rows.equals(tbl.inspect.manifests(snapshot_id=first.snapshot_id))
130+
assert current_rows.equals(tbl.inspect.manifests(snapshot_id=current.snapshot_id))
131+
assert first_rows["path"].to_pylist() == [manifest.manifest_path for manifest in first.manifests(tbl.io)]
132+
assert current_rows["path"].to_pylist() == [manifest.manifest_path for manifest in current.manifests(tbl.io)]
133+
assert first_rows.num_rows == 1
134+
assert current_rows.num_rows == 2
135+
assert first_rows["partition_summaries"].to_pylist() == [[]]
136+
137+
all_rows = tbl.inspect.all_manifests()
138+
assert (
139+
all_rows.schema.remove(all_rows.schema.get_field_index("key_metadata")).remove(
140+
all_rows.schema.get_field_index("reference_snapshot_id")
141+
)
142+
== empty.schema
143+
)
144+
for snapshot, expected in [(first, first_rows), (current, current_rows)]:
145+
rows = [row for row in all_rows.to_pylist() if row.pop("reference_snapshot_id") == snapshot.snapshot_id]
146+
assert all(row.pop("key_metadata") is None for row in rows)
147+
assert rows == expected.to_pylist()
148+
assert all_rows.num_rows == 3 # Shared manifests remain repeated for each reference snapshot.
149+
150+
tbl.maintenance.expire_snapshots().by_id(first.snapshot_id).commit()
151+
with pytest.raises(ValueError, match=f"Cannot find snapshot with ID {first.snapshot_id}"):
152+
tbl.inspect.manifests(snapshot_id=first.snapshot_id)
153+
154+
155+
@pytest.mark.parametrize("snapshot_id", [0, -1, 9223372036854775807])
156+
@pytest.mark.parametrize("populated", [False, True])
157+
def test_inspect_manifests_invalid_snapshot(catalog: InMemoryCatalog, snapshot_id: int, populated: bool) -> None:
158+
tbl = catalog.create_table("default.invalid_snapshot", Schema(NestedField(1, "s", StringType())))
159+
if populated:
160+
tbl.append(pa.table({"s": ["value"]}))
161+
with pytest.raises(ValueError, match=f"Cannot find snapshot with ID {snapshot_id}"):
162+
tbl.inspect.manifests(snapshot_id=snapshot_id)
163+
164+
165+
@pytest.mark.parametrize("value", ["old", "", None])
166+
def test_inspect_manifests_schema_and_partition_evolution(catalog: InMemoryCatalog, value: str | None) -> None:
167+
schema = Schema(NestedField(1, "s", StringType()), NestedField(2, "id", IntegerType()))
168+
spec = PartitionSpec(PartitionField(1, 1000, IdentityTransform(), "s_part"))
169+
tbl = catalog.create_table("default.evolved_manifests", schema, partition_spec=spec)
170+
tbl.append(pa.table({"s": [value], "id": [7]}, schema=pa.schema([("s", pa.string()), ("id", pa.int32())])))
171+
first = tbl.current_snapshot()
172+
assert first is not None
173+
first_rows = tbl.inspect.manifests()
174+
assert first_rows["partition_summaries"].to_pylist() == [
175+
[{"contains_null": value is None, "contains_nan": False, "lower_bound": value, "upper_bound": value}]
176+
]
177+
178+
with tbl.update_schema() as update:
179+
update.rename_column("s", "renamed")
180+
assert tbl.inspect.manifests(first.snapshot_id).equals(first_rows)
181+
with tbl.update_spec() as update:
182+
update.remove_field("s_part")
183+
update.add_field("id", BucketTransform(8), "id_bucket")
184+
with tbl.update_schema() as update:
185+
update.delete_column("renamed")
186+
# Reusing a name must not cause the old source ID to be resolved to the new type.
187+
update.add_column("s", LongType())
188+
tbl.append(pa.table({"s": [99], "id": [8]}, schema=pa.schema([("s", pa.int64()), ("id", pa.int32())])))
189+
current = tbl.current_snapshot()
190+
assert current is not None
191+
tbl = catalog.load_table(tbl.name())
192+
193+
assert tbl.inspect.manifests(first.snapshot_id).equals(first_rows)
194+
current_rows = tbl.inspect.manifests()
195+
assert current_rows.equals(tbl.inspect.manifests(current.snapshot_id))
196+
by_spec = {row["partition_spec_id"]: row for row in current_rows.to_pylist()}
197+
assert by_spec[spec.spec_id] == first_rows.to_pylist()[0]
198+
bucket = str(BucketTransform(8).transform(IntegerType())(8))
199+
assert by_spec[tbl.spec().spec_id]["partition_summaries"] == [
200+
{"contains_null": False, "contains_nan": False, "lower_bound": bucket, "upper_bound": bucket}
201+
]
202+
all_rows = tbl.inspect.all_manifests().to_pylist()
203+
assert len(all_rows) == 3
204+
assert [row["partition_summaries"] for row in all_rows if row["path"] == first_rows["path"][0].as_py()] == [
205+
first_rows["partition_summaries"][0].as_py(),
206+
first_rows["partition_summaries"][0].as_py(),
207+
]
208+
209+
210+
@pytest.mark.parametrize("missing_schema_id", [None, 999])
211+
def test_inspect_manifests_schema_fallback(catalog: InMemoryCatalog, missing_schema_id: int | None) -> None:
212+
schema = Schema(NestedField(1, "s", StringType()))
213+
spec = PartitionSpec(PartitionField(1, 1000, IdentityTransform(), "s"))
214+
tbl = catalog.create_table("default.legacy_manifests", schema, partition_spec=spec)
215+
tbl.append(pa.table({"s": ["legacy"]}))
216+
snapshot = tbl.current_snapshot()
217+
assert snapshot is not None
218+
expected = tbl.inspect.manifests()
219+
# Emulate legacy metadata while retaining the real local manifest and data files.
220+
tbl.metadata = tbl.metadata.model_copy(update={"snapshots": [snapshot.model_copy(update={"schema_id": missing_schema_id})]})
221+
if missing_schema_id is None:
222+
assert tbl.inspect.manifests(snapshot.snapshot_id).equals(expected)
223+
assert tbl.inspect.manifests().equals(expected)
224+
else:
225+
with pytest.warns(UserWarning, match=f"Metadata does not contain schema with id: {missing_schema_id}"):
226+
assert tbl.inspect.manifests(snapshot.snapshot_id).equals(expected)
227+
228+
229+
@pytest.mark.parametrize(
230+
("original_type", "promoted_type", "arrow_type", "value"),
231+
[(IntegerType(), LongType(), pa.int32(), 7), (FloatType(), DoubleType(), pa.float32(), 1.5)],
232+
)
233+
def test_inspect_manifests_promoted_partition_source(
234+
catalog: InMemoryCatalog,
235+
original_type: PrimitiveType,
236+
promoted_type: PrimitiveType,
237+
arrow_type: pa.DataType,
238+
value: int | float,
239+
) -> None:
240+
schema = Schema(NestedField(1, "p", original_type))
241+
spec = PartitionSpec(PartitionField(1, 1000, IdentityTransform(), "p"))
242+
tbl = catalog.create_table("default.promoted_manifests", schema, partition_spec=spec)
243+
tbl.append(pa.table({"p": [value]}, schema=pa.schema([("p", arrow_type)])))
244+
first = tbl.current_snapshot()
245+
assert first is not None
246+
expected = tbl.inspect.manifests()
247+
with tbl.update_schema() as update:
248+
update.update_column("p", field_type=promoted_type)
249+
tbl.append(pa.table({"p": [value]}, schema=pa.schema([("p", pa.int64() if isinstance(value, int) else pa.float64())])))
250+
251+
assert tbl.inspect.manifests(first.snapshot_id).equals(expected)
252+
for row in tbl.inspect.manifests().to_pylist() + tbl.inspect.all_manifests().to_pylist():
253+
assert row["partition_summaries"] == expected["partition_summaries"][0].as_py()

0 commit comments

Comments
 (0)