Skip to content

Commit 41d3ee7

Browse files
Require from_snapshot_id_exclusive and drop range builders on IncrementalAppendScan
Address review feedback: remove the from_snapshot_exclusive/to_snapshot_inclusive fluent builders (which clashed with the public from/to attributes and left them mutable) in favour of setting the snapshot range at construction, mirroring DataScan.snapshot_id and Spark's required start-snapshot-id option surface. - from_snapshot_id_exclusive is now a required, keyword-only argument; the deferred plan-time 'start snapshot not set' error is gone (fail-fast). - Range-first, keyword-only signatures on Table.incremental_append_scan and IncrementalAppendScan.__init__. - StagedTable.incremental_append_scan keeps from optional so a no-arg call raises the staged-table ValueError, not a TypeError. - Simplify user-facing docstrings to Spark's option surface (the expired-cursor behaviour stays covered by tests and internal comments). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a7e3f24 commit 41d3ee7

3 files changed

Lines changed: 67 additions & 90 deletions

File tree

pyiceberg/table/__init__.py

Lines changed: 20 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1264,11 +1264,12 @@ def scan(
12641264

12651265
def incremental_append_scan(
12661266
self,
1267+
*,
1268+
from_snapshot_id_exclusive: int,
1269+
to_snapshot_id_inclusive: int | None = None,
12671270
row_filter: str | BooleanExpression = ALWAYS_TRUE,
12681271
selected_fields: tuple[str, ...] = ("*",),
12691272
case_sensitive: bool = True,
1270-
from_snapshot_id_exclusive: int | None = None,
1271-
to_snapshot_id_inclusive: int | None = None,
12721273
options: Properties = EMPTY_DICT,
12731274
limit: int | None = None,
12741275
) -> IncrementalAppendScan:
@@ -1278,6 +1279,11 @@ def incremental_append_scan(
12781279
range that match the provided row_filter, projected onto the table's current schema.
12791280
12801281
Args:
1282+
from_snapshot_id_exclusive:
1283+
ID of the snapshot to start the incremental scan from, exclusively.
1284+
to_snapshot_id_inclusive:
1285+
Optional ID of the snapshot to end the incremental scan at, inclusively. If not set, it defaults to
1286+
the table's current snapshot.
12811287
row_filter:
12821288
A string or BooleanExpression that describes the
12831289
desired rows.
@@ -1286,12 +1292,6 @@ def incremental_append_scan(
12861292
to return in the output dataframe.
12871293
case_sensitive:
12881294
If True column matching is case sensitive.
1289-
from_snapshot_id_exclusive:
1290-
Optional ID of the "from" snapshot, to start the incremental scan from, exclusively. This can be set
1291-
on the IncrementalAppendScan object returned, but ultimately must not be None.
1292-
to_snapshot_id_inclusive:
1293-
Optional ID of the "to" snapshot, to end the incremental scan at, inclusively. This can be set on the
1294-
IncrementalAppendScan object returned. If not set, it defaults to the table's current snapshot.
12951295
options:
12961296
Additional Table properties as a dictionary of
12971297
string key value pairs to use for this scan.
@@ -1833,11 +1833,12 @@ def scan(
18331833

18341834
def incremental_append_scan(
18351835
self,
1836+
*,
1837+
from_snapshot_id_exclusive: int | None = None,
1838+
to_snapshot_id_inclusive: int | None = None,
18361839
row_filter: str | BooleanExpression = ALWAYS_TRUE,
18371840
selected_fields: tuple[str, ...] = ("*",),
18381841
case_sensitive: bool = True,
1839-
from_snapshot_id_exclusive: int | None = None,
1840-
to_snapshot_id_inclusive: int | None = None,
18411842
options: Properties = EMPTY_DICT,
18421843
limit: int | None = None,
18431844
) -> IncrementalAppendScan:
@@ -2341,13 +2342,15 @@ def count(self) -> int:
23412342
return res
23422343

23432344

2344-
IAS = TypeVar("IAS", bound="IncrementalAppendScan", covariant=True)
2345-
2346-
23472345
class IncrementalAppendScan(BaseScan):
23482346
"""An incremental scan of a table's data that accumulates appended data between two snapshots.
23492347
23502348
Args:
2349+
from_snapshot_id_exclusive:
2350+
ID of the snapshot to start the incremental scan from, exclusively.
2351+
to_snapshot_id_inclusive:
2352+
Optional ID of the snapshot to end the incremental scan at, inclusively.
2353+
Omitting it will default to the table's current snapshot.
23512354
row_filter:
23522355
A string or BooleanExpression that describes the
23532356
desired rows
@@ -2363,27 +2366,21 @@ class IncrementalAppendScan(BaseScan):
23632366
An integer representing the number of rows to
23642367
return in the scan result. If None, fetches all
23652368
matching rows.
2366-
from_snapshot_id_exclusive:
2367-
Optional ID of the "from" snapshot, to start the incremental scan from, exclusively. When the scan is
2368-
ultimately planned, this must not be None. The snapshot does not need to be present in the table metadata
2369-
(it may have been expired), as long as it is the parent of some ancestor of the "to" snapshot.
2370-
to_snapshot_id_inclusive:
2371-
Optional ID of the "to" snapshot, to end the incremental scan at, inclusively.
2372-
Omitting it will default to the table's current snapshot.
23732369
"""
23742370

2375-
from_snapshot_id_exclusive: int | None
2371+
from_snapshot_id_exclusive: int
23762372
to_snapshot_id_inclusive: int | None
23772373

23782374
def __init__(
23792375
self,
23802376
table_metadata: TableMetadata,
23812377
io: FileIO,
2378+
*,
2379+
from_snapshot_id_exclusive: int,
2380+
to_snapshot_id_inclusive: int | None = None,
23822381
row_filter: str | BooleanExpression = ALWAYS_TRUE,
23832382
selected_fields: tuple[str, ...] = ("*",),
23842383
case_sensitive: bool = True,
2385-
from_snapshot_id_exclusive: int | None = None,
2386-
to_snapshot_id_inclusive: int | None = None,
23872384
options: Properties = EMPTY_DICT,
23882385
limit: int | None = None,
23892386
):
@@ -2399,28 +2396,6 @@ def __init__(
23992396
self.from_snapshot_id_exclusive = from_snapshot_id_exclusive
24002397
self.to_snapshot_id_inclusive = to_snapshot_id_inclusive
24012398

2402-
def from_snapshot_exclusive(self: IAS, from_snapshot_id_exclusive: int | None) -> IAS:
2403-
"""Instructs this scan to look for changes starting from a particular snapshot (exclusive).
2404-
2405-
Args:
2406-
from_snapshot_id_exclusive: the start snapshot ID (exclusive)
2407-
2408-
Returns:
2409-
this for method chaining
2410-
"""
2411-
return self.update(from_snapshot_id_exclusive=from_snapshot_id_exclusive)
2412-
2413-
def to_snapshot_inclusive(self: IAS, to_snapshot_id_inclusive: int | None) -> IAS:
2414-
"""Instructs this scan to look for changes up to a particular snapshot (inclusive).
2415-
2416-
Args:
2417-
to_snapshot_id_inclusive: the end snapshot ID (inclusive)
2418-
2419-
Returns:
2420-
this for method chaining
2421-
"""
2422-
return self.update(to_snapshot_id_inclusive=to_snapshot_id_inclusive)
2423-
24242399
def projection(self) -> Schema:
24252400
current_schema = self.table_metadata.schema()
24262401
if "*" in self.selected_fields:
@@ -2490,9 +2465,6 @@ def to_arrow_batch_reader(self) -> pa.RecordBatchReader:
24902465
return _to_arrow_batch_reader_via_file_scan_tasks(self, self.projection(), self.plan_files())
24912466

24922467
def _validate_and_resolve_snapshots(self) -> tuple[int, int]:
2493-
if self.from_snapshot_id_exclusive is None:
2494-
raise ValueError("Start snapshot is not set, please set from_snapshot_id_exclusive")
2495-
24962468
if self.to_snapshot_id_inclusive is None:
24972469
current_snapshot = self.table_metadata.current_snapshot()
24982470
if current_snapshot is None:

tests/integration/test_reads.py

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1279,10 +1279,9 @@ def test_scan_source_field_missing_in_spec(catalog: Catalog, spark: SparkSession
12791279
def test_incremental_append_scan_append_only(catalog: Catalog) -> None:
12801280
test_table = catalog.load_table("default.test_incremental_read")
12811281

1282-
scan = (
1283-
test_table.incremental_append_scan()
1284-
.from_snapshot_exclusive(test_table.snapshots()[0].snapshot_id)
1285-
.to_snapshot_inclusive(test_table.snapshots()[2].snapshot_id)
1282+
scan = test_table.incremental_append_scan(
1283+
from_snapshot_id_exclusive=test_table.snapshots()[0].snapshot_id,
1284+
to_snapshot_id_inclusive=test_table.snapshots()[2].snapshot_id,
12861285
)
12871286

12881287
# snapshots[1] adds 1 file (letter=b); snapshots[2] adds 2 files (letter=b, letter=c).
@@ -1334,10 +1333,9 @@ def test_incremental_append_scan_schema_evolution_within_range(catalog: Catalog)
13341333
# snapshots[1..2] are on the original schema (number, letter); snapshots[4] is on the evolved
13351334
# schema (number, letter, extra) after ALTER TABLE ADD COLUMN. The scan must project the older
13361335
# rows onto the current schema (extra -> null) and pick up the new value for the newer row.
1337-
scan = (
1338-
test_table.incremental_append_scan()
1339-
.from_snapshot_exclusive(test_table.snapshots()[0].snapshot_id)
1340-
.to_snapshot_inclusive(test_table.snapshots()[4].snapshot_id)
1336+
scan = test_table.incremental_append_scan(
1337+
from_snapshot_id_exclusive=test_table.snapshots()[0].snapshot_id,
1338+
to_snapshot_id_inclusive=test_table.snapshots()[4].snapshot_id,
13411339
)
13421340
assert len(list(scan.plan_files())) == 4
13431341

@@ -1361,10 +1359,10 @@ def test_incremental_append_scan_partition_pruning(catalog: Catalog) -> None:
13611359
# `letter=c` only appears in snapshots[2]. The manifest evaluator rejects snapshots[1]'s
13621360
# manifest (letter=b only); the partition evaluator rejects the letter=b entry in
13631361
# snapshots[2]'s manifest. One file remains.
1364-
scan = (
1365-
test_table.incremental_append_scan(row_filter=EqualTo("letter", "c"))
1366-
.from_snapshot_exclusive(test_table.snapshots()[0].snapshot_id)
1367-
.to_snapshot_inclusive(test_table.snapshots()[2].snapshot_id)
1362+
scan = test_table.incremental_append_scan(
1363+
row_filter=EqualTo("letter", "c"),
1364+
from_snapshot_id_exclusive=test_table.snapshots()[0].snapshot_id,
1365+
to_snapshot_id_inclusive=test_table.snapshots()[2].snapshot_id,
13681366
)
13691367
assert len(list(scan.plan_files())) == 1
13701368
assert scan.to_arrow()["number"].to_pylist() == [3]
@@ -1377,10 +1375,10 @@ def test_incremental_append_scan_metrics_pruning(catalog: Catalog) -> None:
13771375

13781376
# Non-partition predicate: the manifest/partition evaluators degenerate, leaving the per-file
13791377
# metrics evaluator to prune. `number=99` matches no file's [min, max] stats for `number`.
1380-
scan = (
1381-
test_table.incremental_append_scan(row_filter=EqualTo("number", 99))
1382-
.from_snapshot_exclusive(test_table.snapshots()[0].snapshot_id)
1383-
.to_snapshot_inclusive(test_table.snapshots()[2].snapshot_id)
1378+
scan = test_table.incremental_append_scan(
1379+
row_filter=EqualTo("number", 99),
1380+
from_snapshot_id_exclusive=test_table.snapshots()[0].snapshot_id,
1381+
to_snapshot_id_inclusive=test_table.snapshots()[2].snapshot_id,
13841382
)
13851383
assert len(list(scan.plan_files())) == 0
13861384
assert len(scan.to_arrow()) == 0
@@ -1391,10 +1389,10 @@ def test_incremental_append_scan_metrics_pruning(catalog: Catalog) -> None:
13911389
def test_incremental_append_scan_selected_fields(catalog: Catalog) -> None:
13921390
test_table = catalog.load_table("default.test_incremental_read")
13931391

1394-
scan = (
1395-
test_table.incremental_append_scan(selected_fields=("number",))
1396-
.from_snapshot_exclusive(test_table.snapshots()[0].snapshot_id)
1397-
.to_snapshot_inclusive(test_table.snapshots()[2].snapshot_id)
1392+
scan = test_table.incremental_append_scan(
1393+
selected_fields=("number",),
1394+
from_snapshot_id_exclusive=test_table.snapshots()[0].snapshot_id,
1395+
to_snapshot_id_inclusive=test_table.snapshots()[2].snapshot_id,
13981396
)
13991397
result_table = scan.to_arrow()
14001398
assert result_table.schema.equals(pa.schema([pa.field("number", pa.int32())]))
@@ -1406,10 +1404,10 @@ def test_incremental_append_scan_selected_fields(catalog: Catalog) -> None:
14061404
def test_incremental_append_scan_limit(catalog: Catalog) -> None:
14071405
test_table = catalog.load_table("default.test_incremental_read")
14081406

1409-
scan = (
1410-
test_table.incremental_append_scan(limit=2)
1411-
.from_snapshot_exclusive(test_table.snapshots()[0].snapshot_id)
1412-
.to_snapshot_inclusive(test_table.snapshots()[2].snapshot_id)
1407+
scan = test_table.incremental_append_scan(
1408+
limit=2,
1409+
from_snapshot_id_exclusive=test_table.snapshots()[0].snapshot_id,
1410+
to_snapshot_id_inclusive=test_table.snapshots()[2].snapshot_id,
14131411
)
14141412
assert len(scan.to_arrow()) == 2
14151413

tests/table/test_init.py

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -356,20 +356,23 @@ def test_data_scan_plan_files_no_current_snapshot(example_table_metadata_no_snap
356356

357357

358358
def test_incremental_append_scan_default(table_v2: Table) -> None:
359-
scan = table_v2.incremental_append_scan()
359+
scan = table_v2.incremental_append_scan(from_snapshot_id_exclusive=3051729675574597004)
360360
assert scan.row_filter == AlwaysTrue()
361361
assert scan.selected_fields == ("*",)
362362
assert scan.case_sensitive is True
363-
assert scan.from_snapshot_id_exclusive is None
363+
assert scan.from_snapshot_id_exclusive == 3051729675574597004
364364
assert scan.to_snapshot_id_inclusive is None
365365

366366

367367
def test_incremental_append_scan_chaining(table_v2: Table) -> None:
368368
older_snapshot_id, newer_snapshot_id = 3051729675574597004, 3055729675574597004
369+
# The snapshot range is set at construction; the snapshot-independent helpers still chain
370+
# and must round-trip the range through `update()`.
369371
scan = (
370-
table_v2.incremental_append_scan()
371-
.from_snapshot_exclusive(older_snapshot_id)
372-
.to_snapshot_inclusive(newer_snapshot_id)
372+
table_v2.incremental_append_scan(
373+
from_snapshot_id_exclusive=older_snapshot_id,
374+
to_snapshot_id_inclusive=newer_snapshot_id,
375+
)
373376
.filter(EqualTo("x", 1))
374377
.select("x", "y")
375378
.with_case_sensitive(False)
@@ -382,18 +385,19 @@ def test_incremental_append_scan_chaining(table_v2: Table) -> None:
382385

383386

384387
def test_incremental_append_scan_projection_uses_current_schema(table_v2: Table) -> None:
385-
scan = table_v2.incremental_append_scan(selected_fields=("x", "y"))
388+
scan = table_v2.incremental_append_scan(from_snapshot_id_exclusive=3051729675574597004, selected_fields=("x", "y"))
386389
projection_schema = scan.projection()
387390
assert projection_schema.schema_id == 1
388391
assert {f.name for f in projection_schema.fields} == {"x", "y"}
389392

390393

391394
def test_incremental_append_scan_requires_from_snapshot(table_v2: Table) -> None:
392-
scan = table_v2.incremental_append_scan(
393-
to_snapshot_id_inclusive=table_v2.current_snapshot().snapshot_id, # type: ignore[union-attr]
394-
)
395-
with pytest.raises(ValueError, match="Start snapshot is not set"):
396-
list(scan.plan_files())
395+
# `from_snapshot_id_exclusive` is a required argument, so omitting it fails fast at construction
396+
# rather than deferring the error to plan time.
397+
with pytest.raises(TypeError, match="from_snapshot_id_exclusive"):
398+
table_v2.incremental_append_scan(
399+
to_snapshot_id_inclusive=table_v2.current_snapshot().snapshot_id, # type: ignore[union-attr,call-arg]
400+
)
397401

398402

399403
def test_incremental_append_scan_invalid_to_snapshot(table_v2: Table) -> None:
@@ -470,19 +474,20 @@ def test_incremental_append_scan_admits_expired_from_snapshot(example_table_meta
470474

471475
def test_incremental_append_scan_update_preserves_type(table_v2: Table) -> None:
472476
# `update()` lives on BaseScan and uses `type(self)(...)` to construct the copy;
473-
# verify the concrete type is preserved through both `update` and the chaining helpers.
474-
base_scan = table_v2.incremental_append_scan()
477+
# verify the concrete type is preserved through both `update` and the fluent helpers.
478+
base_scan = table_v2.incremental_append_scan(from_snapshot_id_exclusive=123)
475479
base_type = type(base_scan)
476480
assert base_type.__name__ == "IncrementalAppendScan"
477-
assert type(base_scan.from_snapshot_exclusive(123)) is base_type
478-
assert type(base_scan.to_snapshot_inclusive(456)) is base_type
481+
assert type(base_scan.update(from_snapshot_id_exclusive=456)) is base_type
482+
assert type(base_scan.update(to_snapshot_id_inclusive=789)) is base_type
479483
assert type(base_scan.filter(EqualTo("x", 1))) is base_type
480484
assert type(base_scan.select("x")) is base_type
481485
assert type(base_scan.with_case_sensitive(False)) is base_type
482486

483-
# None round-trips through the chaining helpers (update() reconstructs by keyword).
484-
assert base_scan.from_snapshot_exclusive(123).from_snapshot_exclusive(None).from_snapshot_id_exclusive is None
485-
assert base_scan.to_snapshot_inclusive(456).to_snapshot_inclusive(None).to_snapshot_id_inclusive is None
487+
# The snapshot range round-trips through update() (which reconstructs by keyword).
488+
assert base_scan.update(from_snapshot_id_exclusive=456).from_snapshot_id_exclusive == 456
489+
assert base_scan.update(to_snapshot_id_inclusive=789).to_snapshot_id_inclusive == 789
490+
assert base_scan.update(to_snapshot_id_inclusive=789).update(to_snapshot_id_inclusive=None).to_snapshot_id_inclusive is None
486491

487492

488493
def test_incremental_append_scan_staged_table_raises(table_v2: Table) -> None:
@@ -494,6 +499,8 @@ def test_incremental_append_scan_staged_table_raises(table_v2: Table) -> None:
494499
io=table_v2.io,
495500
catalog=table_v2.catalog,
496501
)
502+
# No-arg call must raise the staged-table ValueError, not a TypeError for the missing
503+
# required `from_snapshot_id_exclusive` (the override keeps it optional, like StagedTable.scan).
497504
with pytest.raises(ValueError, match="Cannot scan a staged table"):
498505
staged_table.incremental_append_scan()
499506

0 commit comments

Comments
 (0)