Skip to content

Commit e8d6fe9

Browse files
committed
Make delete file sets read-only and clarify DV validation
1 parent e71c8d1 commit e8d6fe9

6 files changed

Lines changed: 82 additions & 27 deletions

File tree

‎pyiceberg/table/__init__.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2267,13 +2267,13 @@ def from_rest_response(
22672267

22682268
data_file = _rest_file_to_data_file(rest_task.data_file)
22692269

2270-
resolved_deletes = DeleteFileSet()
2270+
resolved_deletes: list[DataFile] = []
22712271
if rest_task.delete_file_references:
22722272
for idx in rest_task.delete_file_references:
22732273
delete_file = delete_files[idx]
22742274
if isinstance(delete_file, RESTEqualityDeleteFile):
22752275
raise NotImplementedError(f"PyIceberg does not yet support equality deletes: {delete_file.file_path}")
2276-
resolved_deletes.add(_rest_file_to_data_file(delete_file))
2276+
resolved_deletes.append(_rest_file_to_data_file(delete_file))
22772277

22782278
return FileScanTask(
22792279
data_file=data_file,

‎pyiceberg/table/delete_file.py‎

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,8 @@
1616
# under the License.
1717
from __future__ import annotations
1818

19-
from collections.abc import Iterable, Iterator, MutableSet
19+
from collections.abc import Iterable, Iterator, Set
2020
from dataclasses import dataclass
21-
from typing import Any
2221

2322
from pyiceberg.manifest import DataFile
2423

@@ -41,15 +40,15 @@ def from_file(cls, delete_file: DataFile) -> DeleteFileKey:
4140
)
4241

4342

44-
class DeleteFileSet(MutableSet[DataFile]):
45-
"""Set-like delete-file collection keyed by location and content range."""
43+
class DeleteFileSet(Set[DataFile]):
44+
"""Read-only delete-file collection keyed by location and content range."""
4645

4746
_files: dict[DeleteFileKey, DataFile]
4847

4948
def __init__(self, delete_files: Iterable[DataFile] = ()) -> None:
5049
self._files = {}
5150
for delete_file in delete_files:
52-
self.add(delete_file)
51+
self._files.setdefault(DeleteFileKey.from_file(delete_file), delete_file)
5352

5453
def __contains__(self, delete_file: object) -> bool:
5554
"""Return whether the delete file is present."""
@@ -63,34 +62,25 @@ def __len__(self) -> int:
6362
"""Return the number of delete files."""
6463
return len(self._files)
6564

66-
def add(self, delete_file: DataFile) -> None:
67-
self._files.setdefault(DeleteFileKey.from_file(delete_file), delete_file)
68-
69-
def discard(self, delete_file: DataFile) -> None:
70-
self._files.pop(DeleteFileKey.from_file(delete_file), None)
71-
72-
def update(self, delete_files: Iterable[DataFile]) -> None:
73-
for delete_file in delete_files:
74-
self.add(delete_file)
75-
7665
def __repr__(self) -> str:
7766
"""Return a string representation of the delete file set."""
7867
return f"{type(self).__name__}({list(self)!r})"
7968

80-
def __eq__(self, other: Any) -> bool:
69+
def __eq__(self, other: object) -> bool:
8170
"""Compare delete file sets by delete file identity."""
8271
if isinstance(other, DeleteFileSet):
8372
return self._files.keys() == other._files.keys()
8473

85-
if not isinstance(other, Iterable):
74+
if not isinstance(other, Set):
75+
return NotImplemented
76+
77+
if len(self) != len(other):
8678
return False
8779

8880
other_keys: set[DeleteFileKey] = set()
89-
other_count = 0
9081
for delete_file in other:
9182
if not isinstance(delete_file, DataFile):
9283
return False
9384
other_keys.add(DeleteFileKey.from_file(delete_file))
94-
other_count += 1
9585

96-
return len(other_keys) == other_count and set(self._files) == other_keys
86+
return self._files.keys() == other_keys

‎pyiceberg/table/delete_file_index.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,21 +130,21 @@ def for_data_file(self, seq_num: int, data_file: DataFile, partition_key: Record
130130
if self.is_empty():
131131
return DeleteFileSet()
132132

133-
deletes = DeleteFileSet()
133+
deletes: list[DataFile] = []
134134
spec_id = data_file.spec_id or 0
135135

136136
key = _partition_key(spec_id, partition_key)
137137
partition_deletes = self._by_partition.get(key)
138138
if partition_deletes:
139139
for delete_file in partition_deletes.filter_by_seq(seq_num):
140140
if _applies_to_data_file(delete_file, data_file):
141-
deletes.add(delete_file)
141+
deletes.append(delete_file)
142142

143143
path_deletes = self._by_path.get(data_file.file_path)
144144
if path_deletes:
145-
deletes.update(path_deletes.filter_by_seq(seq_num))
145+
deletes.extend(path_deletes.filter_by_seq(seq_num))
146146

147-
return deletes
147+
return DeleteFileSet(deletes)
148148

149149
def referenced_delete_files(self) -> list[DataFile]:
150150
data_files: list[DataFile] = []

‎pyiceberg/table/deletion_vector.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,11 @@ def _validate_deletion_vector_content(dv: "DataFile") -> None:
143143

144144

145145
def has_deletion_vector_content_reference(dv: "DataFile") -> bool:
146-
"""Return whether a deletion vector is described by manifest content-range metadata."""
146+
"""Return whether any manifest content-reference field is present.
147+
148+
Partial references must be validated as range reads. Whole-Puffin reads
149+
are only used when all content-reference fields are absent.
150+
"""
147151
return dv.content_offset is not None or dv.content_size_in_bytes is not None or dv.referenced_data_file is not None
148152

149153

‎tests/io/test_pyarrow.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1909,6 +1909,38 @@ def test_read_deletes_from_whole_puffin_file(tmp_path: Path) -> None:
19091909
assert deletes == {referenced_data_file: pa.chunked_array([[1, 3, 5]])}
19101910

19111911

1912+
@pytest.mark.parametrize(
1913+
("missing_field", "error"),
1914+
[
1915+
("content_offset", "content offset is missing"),
1916+
("content_size_in_bytes", "content size is missing"),
1917+
("referenced_data_file", "referenced data file is missing"),
1918+
],
1919+
)
1920+
def test_read_deletes_rejects_partial_content_reference(tmp_path: Path, missing_field: str, error: str) -> None:
1921+
referenced_data_file = f"{tmp_path}/data.parquet"
1922+
delete_file_path = f"{tmp_path}/deletes.puffin"
1923+
puffin_payload = _deletion_vector_puffin_payload(referenced_data_file)
1924+
blob = PuffinFile(puffin_payload).footer.blobs[0]
1925+
1926+
with open(delete_file_path, "wb") as f:
1927+
f.write(puffin_payload)
1928+
1929+
dv = DataFile.from_args(
1930+
_table_format_version=3,
1931+
content=DataFileContent.POSITION_DELETES,
1932+
file_path=delete_file_path,
1933+
file_format=FileFormat.PUFFIN,
1934+
record_count=3,
1935+
content_offset=blob.offset if missing_field != "content_offset" else None,
1936+
content_size_in_bytes=blob.length if missing_field != "content_size_in_bytes" else None,
1937+
referenced_data_file=referenced_data_file if missing_field != "referenced_data_file" else None,
1938+
)
1939+
1940+
with pytest.raises(ValueError, match=error):
1941+
_read_deletes(PyArrowFileIO(), dv)
1942+
1943+
19121944
def test_read_deletion_vector_blob_from_content_range(tmp_path: Path) -> None:
19131945
referenced_data_file = f"{tmp_path}/data.parquet"
19141946
dv_blob = _deletion_vector_blob(_deletion_vector_bitmap_payload())

‎tests/table/test_delete_file_index.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
# KIND, either express or implied. See the License for the
1515
# specific language governing permissions and limitations
1616
# under the License.
17+
from collections.abc import Callable, Iterable, Set
18+
1719
import pytest
1820

1921
from pyiceberg.manifest import DataFile, DataFileContent, FileFormat, ManifestEntry, ManifestEntryStatus
@@ -190,6 +192,33 @@ def test_delete_file_set_uses_content_range_identity() -> None:
190192
assert len(DeleteFileSet([first_dv, second_dv])) == 2
191193

192194

195+
@pytest.mark.parametrize("collection_type", [DeleteFileSet, set, frozenset])
196+
@pytest.mark.parametrize(("content_offset", "content_size_in_bytes", "equal"), [(4, 10, True), (40, 10, False), (4, 12, False)])
197+
def test_delete_file_set_equality(
198+
collection_type: Callable[[Iterable[DataFile]], Set[DataFile]], content_offset: int, content_size_in_bytes: int, equal: bool
199+
) -> None:
200+
dv = _create_deletion_vector(content_offset=4, content_size_in_bytes=10).data_file
201+
other_dv = _create_deletion_vector(content_offset=content_offset, content_size_in_bytes=content_size_in_bytes).data_file
202+
deletes = DeleteFileSet([dv])
203+
other = collection_type([other_dv])
204+
205+
assert (deletes == other) is equal
206+
assert (other == deletes) is equal
207+
assert (deletes != other) is not equal
208+
209+
210+
def test_delete_file_set_does_not_compare_with_non_sets() -> None:
211+
dv = _create_deletion_vector(content_offset=4, content_size_in_bytes=10).data_file
212+
deletes = DeleteFileSet([dv])
213+
iterator = iter([dv])
214+
215+
assert deletes != [dv]
216+
assert deletes != (dv,)
217+
assert deletes != iterator
218+
assert next(iterator) is dv
219+
assert DeleteFileSet() != []
220+
221+
193222
def test_dvs_with_same_file_path_and_different_content_ranges_are_not_deduped() -> None:
194223
index = DeleteFileIndex()
195224
shared_file_path = "s3://bucket/deletion-vectors.bin"

0 commit comments

Comments
 (0)