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
9 changes: 7 additions & 2 deletions pyiceberg/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,9 +471,14 @@ def data_file_with_partition(partition_type: StructType, format_version: TableVe

class DataFile(Record):
@classmethod
def from_args(cls, _table_format_version: TableVersion = DEFAULT_READ_VERSION, **arguments: Any) -> DataFile:
def from_args(
cls, _table_format_version: TableVersion = DEFAULT_READ_VERSION, *, spec_id: int | None = None, **arguments: Any

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.

explicitly add spec_id to the function.
its called in

I think this is better. Otherwise caller has to set it after constructing DataFile. For example

data_file = DataFile.from_args()
data_file.spec_id = spec_id

) -> DataFile:
struct = DATA_FILE_TYPE[_table_format_version]
return super()._bind(struct, **arguments)
data_file = super()._bind(struct, **arguments)
Comment on lines +475 to +478
if spec_id is not None:
data_file.spec_id = spec_id
return data_file
Comment on lines +479 to +481

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.

this is using DataFile's own setter

# Spec ID should not be stored in the file
_spec_id: int
@property
def spec_id(self) -> int:
return self._spec_id
@spec_id.setter
def spec_id(self, value: int) -> None:
self._spec_id = value


@property
def content(self) -> DataFileContent:
Expand Down
3 changes: 3 additions & 0 deletions pyiceberg/typedef.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@ class Record(StructProtocol):

@classmethod
def _bind(cls, struct: StructType, **arguments: Any) -> Self:
field_names = {field.name for field in struct.fields}

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.

including this fix as part of the PR.

This is the footgun that was silently dropping fields. We now check for unknown fields.

This caught format_version=2, in tests/integration/test_rest_manifest.py which is invalid

if unknown_fields := arguments.keys() - field_names:
raise TypeError(f"Unexpected {cls.__name__} fields: {', '.join(sorted(unknown_fields))}")
Comment on lines +182 to +184
return cls(*[arguments[field.name] if field.name in arguments else field.initial_default for field in struct.fields])

def __init__(self, *data: Any) -> None:
Expand Down
15 changes: 9 additions & 6 deletions tests/avro/test_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,7 @@ def test_write_v2_referenced_data_file_with_fastavro() -> None:

@pytest.mark.parametrize("format_version", [1, 2])
def test_write_manifest_entry_with_fastavro_read_with_iceberg(format_version: TableVersion) -> None:
data_file_dict = {
"content": DataFileContent.DATA,
common_data_file_args = {
"file_path": "s3://some-path/some-file.parquet",
"file_format": FileFormat.PARQUET,
"partition": Record(),
Expand All @@ -281,16 +280,16 @@ def test_write_manifest_entry_with_fastavro_read_with_iceberg(format_version: Ta
"upper_bounds": {1: b"zzzzzzzzzzzzzzzz"},
"key_metadata": b"\xde\xad\xbe\xef",
"split_offsets": [4, 133697593],
"equality_ids": [],

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.

equality_ids is not a valid V1 field.

common_data_file_args is shared between v1 and v2 in this test file

"sort_order_id": 4,
"spec_id": 3,
}
data_file_v2 = DataFile.from_args(**data_file_dict) # type: ignore
data_file = DataFile.from_args(content=DataFileContent.DATA, **common_data_file_args) # type: ignore

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.

content is a V2 field.

common_data_file_args is shared between v1 and v2 in this test file

assert data_file.spec_id == 3

entry = ManifestEntry.from_args(
status=ManifestEntryStatus.ADDED,
snapshot_id=8638475580105682862,
data_file=data_file_v2,
data_file=data_file,
)

with TemporaryDirectory() as tmpdir:
Expand Down Expand Up @@ -322,7 +321,11 @@ def test_write_manifest_entry_with_fastavro_read_with_iceberg(format_version: Ta
avro_entry = next(it)

if format_version == 1:
data_file_v1 = DataFile.from_args(**data_file_dict, _table_format_version=format_version)
data_file_v1 = DataFile.from_args(
_table_format_version=format_version,
block_size_in_bytes=DEFAULT_BLOCK_SIZE,

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.

block_size_in_bytes is required in V1.

**common_data_file_args, # type: ignore
)

assert avro_entry == ManifestEntry.from_args(
status=1,
Expand Down
3 changes: 1 addition & 2 deletions tests/integration/test_rest_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ def test_write_sample_manifest(table_test_all_types: Table, compression: AvroCom
test_schema = table_test_all_types.schema()
test_spec = table_test_all_types.spec()
wrapped_data_file_v2_debug = DataFile.from_args(
format_version=2,

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.

this is not a valid field, the typedef.py change caught it 😄

content=entry.data_file.content,
file_path=entry.data_file.file_path,
file_format=entry.data_file.file_format,
Expand All @@ -112,7 +111,7 @@ def test_write_sample_manifest(table_test_all_types: Table, compression: AvroCom
wrapped_entry_v2 = copy(entry)
wrapped_entry_v2.data_file = wrapped_data_file_v2_debug
wrapped_entry_v2_dict = todict(wrapped_entry_v2, [field.name for field in test_spec.fields])
for field in ("first_row_id", "content_offset", "content_size_in_bytes"):
for field in ("first_row_id", "content_offset", "content_size_in_bytes", "spec_id"):

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.

spec_id is not serialized so we need to remove it for this comparison to work

del wrapped_entry_v2_dict["data_file"][field]

with TemporaryDirectory() as tmpdir:
Expand Down
18 changes: 18 additions & 0 deletions tests/test_typedef.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import pytest

from pyiceberg.typedef import FrozenDict, KeyDefaultDict, Record
from pyiceberg.types import IntegerType, NestedField, StructType


def test_setitem_frozendict() -> None:
Expand Down Expand Up @@ -47,3 +48,20 @@ def test_record_named_args() -> None:
assert r[2] is True

assert repr(r) == "Record[1, a, True]"


def test_record_bind_rejects_unknown_arguments() -> None:
struct = StructType(NestedField(1, "known", IntegerType()))

with pytest.raises(TypeError, match="Unexpected Record fields: unknown"):
Record._bind(struct, known=1, unknown=2)


def test_record_bind_rejects_non_schema_property() -> None:
class RecordWithProperty(Record):
@property
def computed(self) -> int:
return 1

with pytest.raises(TypeError, match="Unexpected RecordWithProperty fields: computed"):
RecordWithProperty._bind(StructType(), computed=1)
Loading