From 4a805bd64eedc9a3101a2bf59bdf59b34a3c1ba6 Mon Sep 17 00:00:00 2001 From: Jeroen Schmidt Date: Mon, 13 Jul 2026 10:30:00 +0200 Subject: [PATCH 1/8] feat: add ManageSnapshots.fast_forward_branch test: fast_forward_branch auto-creates missing from_branch test: fast_forward_branch is a no-op when snapshots already equal test: fast_forward_branch rejects a tag as the source ref test: fast_forward_branch rejects missing to_ref test: fast_forward_branch rejects non-ancestor target test: fast_forward_branch preserves retention fields test: fast_forward_branch composes in a manage_snapshots chain test(integration): fast_forward_branch on real catalogs docs: add fast_forward_branch section with WAP example chore: apply ruff-format and add None-checks for mypy --- mkdocs/docs/api.md | 66 +++++++ pyiceberg/exceptions.py | 12 ++ pyiceberg/table/update/snapshot.py | 71 ++++++- tests/integration/test_snapshot_operations.py | 33 ++++ tests/table/test_manage_snapshots.py | 173 ++++++++++++++++++ tests/test_exceptions.py | 38 ++++ 6 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 tests/test_exceptions.py diff --git a/mkdocs/docs/api.md b/mkdocs/docs/api.md index 1e17e64f42..989c7956f8 100644 --- a/mkdocs/docs/api.md +++ b/mkdocs/docs/api.md @@ -1483,6 +1483,72 @@ Remove an existing branch: table.manage_snapshots().remove_branch("dev").commit() ``` +#### Fast-forwarding a branch + +Fast-forward `from_branch` to point at the snapshot referenced by +`to_ref`. `to_ref` may be a branch or tag; `from_branch` must be a +branch. If `from_branch` does not yet exist it is created pointing at +`to_ref`'s snapshot. If both already point at the same snapshot the +call is a no-op. Otherwise `from_branch`'s current snapshot must be an +ancestor of `to_ref`'s snapshot; if not, `NotAncestorError` is raised. + +```python +with table.manage_snapshots() as ms: + ms.fast_forward_branch("main", "audit-branch") +``` + +##### End-to-end: write-audit-publish + +The canonical use case for fast-forward is the write-audit-publish +(WAP) pattern: writes proceed on a side branch, validation runs +against that branch, and only after validation succeeds is the main +branch advanced to publish the new data. + +```python +import pyarrow as pa +import pyarrow.compute as pc +from pyiceberg.catalog import load_catalog + +catalog = load_catalog("prod") +table = catalog.load_table("sales.orders") + +# 1. WRITE — create a side branch off main and append to it. +main_snapshot_id = table.current_snapshot().snapshot_id +table.manage_snapshots().create_branch( + snapshot_id=main_snapshot_id, + branch_name="audit", +).commit() + +new_rows = pa.table({ + "order_id": [1001, 1002, 1003], + "amount": [ 49.99, 129.00, 12.50], +}) +table.append(new_rows, branch="audit") + +# 2. AUDIT — scan the audit branch and run whatever validation +# your data-quality contract requires. Nothing on `main` has +# changed yet, so readers of `main` still see the pre-write state. +audit_snapshot_id = table.refs()["audit"].snapshot_id +audit_data = table.scan(snapshot_id=audit_snapshot_id).to_arrow() + +assert audit_data.num_rows > 0, "audit branch is empty" +assert pc.all(pc.greater(audit_data["amount"], 0)).as_py(), \ + "found non-positive amounts" + +# 3. PUBLISH — validation passed; fast-forward main to audit. +# Because the audit branch was created from main and only appended +# to, main's current snapshot is an ancestor of audit's snapshot, +# so the fast-forward is valid. +with table.manage_snapshots() as ms: + ms.fast_forward_branch("main", "audit") + ms.remove_branch("audit") # optional: clean up the side branch +``` + +If validation fails, callers simply skip the fast-forward step. The +audit branch (and its data files) can then be inspected, rewritten, +or removed via `remove_branch` and subsequent snapshot expiration — +without ever having polluted `main`. + ## Table Maintenance PyIceberg provides table maintenance operations through the `table.maintenance` API. This provides a clean interface for performing maintenance tasks like snapshot expiration. diff --git a/pyiceberg/exceptions.py b/pyiceberg/exceptions.py index 019ccff894..4a601b0d13 100644 --- a/pyiceberg/exceptions.py +++ b/pyiceberg/exceptions.py @@ -146,3 +146,15 @@ class WaitingForLockException(Exception): class ValidationException(Exception): """Raised when validation fails.""" + + +class NoSuchSnapshotRefError(ValueError): + """Raised when a named snapshot ref (branch or tag) does not exist.""" + + +class SnapshotRefTypeError(ValueError): + """Raised when an operation expects a branch and gets a tag (or vice versa).""" + + +class NotAncestorError(ValueError): + """Raised when an operation requires ancestry between two snapshots and it does not hold.""" diff --git a/pyiceberg/table/update/snapshot.py b/pyiceberg/table/update/snapshot.py index 6bddd27905..b282bb7943 100644 --- a/pyiceberg/table/update/snapshot.py +++ b/pyiceberg/table/update/snapshot.py @@ -28,7 +28,11 @@ from typing import TYPE_CHECKING, Generic from pyiceberg.avro.codecs import AvroCompressionCodec -from pyiceberg.exceptions import ValidationException +from pyiceberg.exceptions import ( + NoSuchSnapshotRefError, + NotAncestorError, + SnapshotRefTypeError, +) from pyiceberg.expressions import AlwaysFalse, BooleanExpression, Or from pyiceberg.expressions.visitors import ( ROWS_MIGHT_NOT_MATCH, @@ -59,6 +63,7 @@ SnapshotSummaryCollector, Summary, ancestors_of, + is_ancestor_of, latest_ancestor_before_timestamp, update_snapshot_summaries, ) @@ -1232,6 +1237,70 @@ def _current_ancestors(self) -> set[int]: ) } + def fast_forward_branch(self, from_branch: str, to_ref: str) -> ManageSnapshots: + """Fast-forward ``from_branch`` to the snapshot referenced by ``to_ref``. + + If ``from_branch`` does not exist, it is created pointing at ``to_ref``'s snapshot (Java/Spark parity). + If both refs already point to the same snapshot the call is a no-op. + Otherwise ``from_branch`` must be a branch (not a tag) and its current + snapshot must be an ancestor of ``to_ref``'s snapshot. + + Note: + Unlike Java's ``ManageSnapshots.fastForwardBranch``, this method does not + provide Java-parity for intra-chain semantics. + + 1) Java maintains a mutable ``updatedRefs`` map that reflects prior operations + in the same chain, so calling ``createBranch`` and then ``fastForwardBranch`` + on the same ref in one chain observes the freshly-created state. + + 2) pyiceberg's ``ManageSnapshots`` accumulates ``SetSnapshotRefUpdate`` values without + mutating ``table_metadata.refs`` between chained calls; + -> The no-op and ancestry checks below operate on committed metadata only. + -> Callers that need Java-parity behavior should commit between chain steps. + + Args: + from_branch: name of the branch to advance. + to_ref: name of the branch or tag whose snapshot ``from_branch`` will point to. + + Returns: + This for method chaining. + + Raises: + NoSuchSnapshotRefError: ``to_ref`` does not exist. + SnapshotRefTypeError: ``from_branch`` exists but is a tag. + NotAncestorError: ``from_branch``'s snapshot is not an ancestor of ``to_ref``'s snapshot. + """ + refs = self._transaction.table_metadata.refs + + if to_ref not in refs: + raise NoSuchSnapshotRefError(f"Ref does not exist: {to_ref}") + to_snapshot_id = refs[to_ref].snapshot_id + + if from_branch not in refs: + return self.create_branch(snapshot_id=to_snapshot_id, branch_name=from_branch) + + from_ref = refs[from_branch] + if from_ref.snapshot_ref_type != SnapshotRefType.BRANCH: + raise SnapshotRefTypeError(f"Ref {from_branch} is a tag, not a branch") + + if from_ref.snapshot_id == to_snapshot_id: + return self + + if not is_ancestor_of(to_snapshot_id, from_ref.snapshot_id, self._transaction.table_metadata): + raise NotAncestorError(f"Cannot fast-forward: {from_branch} is not an ancestor of {to_ref}") + + update, requirement = self._transaction._set_ref_snapshot( + snapshot_id=to_snapshot_id, + ref_name=from_branch, + type=SnapshotRefType.BRANCH, + max_ref_age_ms=from_ref.max_ref_age_ms, + max_snapshot_age_ms=from_ref.max_snapshot_age_ms, + min_snapshots_to_keep=from_ref.min_snapshots_to_keep, + ) + self._updates += update + self._requirements += requirement + return self + class ExpireSnapshots(UpdateTableMetadata["ExpireSnapshots"]): """Expire snapshots by ID. diff --git a/tests/integration/test_snapshot_operations.py b/tests/integration/test_snapshot_operations.py index 07fb77edbb..6dda20313b 100644 --- a/tests/integration/test_snapshot_operations.py +++ b/tests/integration/test_snapshot_operations.py @@ -282,6 +282,39 @@ def test_rollback_to_timestamp_no_valid_snapshot(table_with_snapshots: Table) -> table_with_snapshots.manage_snapshots().rollback_to_timestamp(timestamp_ms=oldest_timestamp).commit() +@pytest.mark.integration +@pytest.mark.parametrize("catalog", [lf("session_catalog_hive"), lf("session_catalog")]) +def test_fast_forward_branch(catalog: Catalog) -> None: + identifier = "default.test_table_snapshot_operations" + tbl = catalog.load_table(identifier) + assert len(tbl.history()) > 2 + + # Create a side branch off an older snapshot on main, then append to it + # so that the side branch is a strict descendant of main's older snapshot + # but the *current* main is not yet caught up. + current_snapshot = tbl.current_snapshot() + assert current_snapshot is not None + main_snapshot_id = current_snapshot.snapshot_id + side_branch = "audit_ff" + + tbl.manage_snapshots().create_branch(snapshot_id=main_snapshot_id, branch_name=side_branch).commit() + + arrow_schema = tbl.schema().as_arrow() + new_rows = pa.Table.from_pylist([{col.name: None for col in arrow_schema}], schema=arrow_schema) + tbl.append(new_rows, branch=side_branch) + + # Validate appending to the side branch has advanced its snapshot. + tbl = catalog.load_table(identifier) + audit_snapshot_id = tbl.refs()[side_branch].snapshot_id + assert audit_snapshot_id != main_snapshot_id, "side-branch append should have advanced it" + + # Fast-forward main to the side branch. + tbl.manage_snapshots().fast_forward_branch(from_branch="main", to_ref=side_branch).commit() + + tbl = catalog.load_table(identifier) + assert tbl.refs()["main"].snapshot_id == audit_snapshot_id + + @pytest.mark.integration def test_rollback_to_timestamp(table_with_snapshots: Table) -> None: current_snapshot = table_with_snapshots.current_snapshot() diff --git a/tests/table/test_manage_snapshots.py b/tests/table/test_manage_snapshots.py index 93301a01c7..b77a68373d 100644 --- a/tests/table/test_manage_snapshots.py +++ b/tests/table/test_manage_snapshots.py @@ -19,7 +19,13 @@ import pytest +from pyiceberg.exceptions import ( + NoSuchSnapshotRefError, + NotAncestorError, + SnapshotRefTypeError, +) from pyiceberg.table import CommitTableResponse, Table +from pyiceberg.table.refs import SnapshotRefType from pyiceberg.table.update import SetSnapshotRefUpdate, TableUpdate @@ -177,3 +183,170 @@ def test_set_current_snapshot_chained_with_create_tag(table_v2: Table) -> None: # The main branch should point to the same snapshot as the tag main_update = next(u for u in set_ref_updates if u.ref_name == "main") assert main_update.snapshot_id == snapshot_one + + +def test_fast_forward_branch_advances_to_descendant(table_v2: Table) -> None: + parent_snapshot_id = 3051729675574597004 + child_snapshot_id = 3055729675574597004 + + # Create a lagging branch at the parent snapshot, then reset the mock so + # the next commit's updates are the fast-forward alone. + table_v2.catalog = MagicMock() + table_v2.catalog.commit_table.return_value = _mock_commit_response(table_v2) + table_v2.manage_snapshots().create_branch(snapshot_id=parent_snapshot_id, branch_name="lagging").commit() + table_v2.catalog.commit_table.reset_mock() + + table_v2.manage_snapshots().fast_forward_branch(from_branch="lagging", to_ref="main").commit() + + updates = _get_updates(table_v2.catalog) + set_ref_updates = [u for u in updates if isinstance(u, SetSnapshotRefUpdate)] + + assert len(set_ref_updates) == 1 + update = set_ref_updates[0] + assert update.ref_name == "lagging" + assert update.snapshot_id == child_snapshot_id + assert update.type == "branch" + + +def test_fast_forward_branch_creates_missing_from(table_v2: Table) -> None: + current_snapshot = table_v2.current_snapshot() + assert current_snapshot is not None + main_snapshot_id = current_snapshot.snapshot_id + + table_v2.catalog = MagicMock() + table_v2.catalog.commit_table.return_value = _mock_commit_response(table_v2) + + table_v2.manage_snapshots().fast_forward_branch(from_branch="brand-new", to_ref="main").commit() + + updates = _get_updates(table_v2.catalog) + set_ref_updates = [u for u in updates if isinstance(u, SetSnapshotRefUpdate)] + + assert len(set_ref_updates) == 1 + assert set_ref_updates[0].ref_name == "brand-new" + assert set_ref_updates[0].snapshot_id == main_snapshot_id + assert set_ref_updates[0].type == "branch" + + +def test_fast_forward_branch_noop_when_already_equal(table_v2: Table) -> None: + # The no-op check operates on committed metadata only (see + # ``fast_forward_branch`` docstring — intra-chain Java-parity is not + # implemented). Inject a second branch pointing at main's snapshot + # directly into metadata, then confirm the fast-forward stages nothing. + from pyiceberg.table.refs import SnapshotRef + + current_snapshot = table_v2.current_snapshot() + assert current_snapshot is not None + main_snapshot_id = current_snapshot.snapshot_id + table_v2.metadata.refs["peer"] = SnapshotRef( + snapshot_id=main_snapshot_id, + snapshot_ref_type="branch", + ) + + table_v2.catalog = MagicMock() + table_v2.catalog.commit_table.return_value = _mock_commit_response(table_v2) + + table_v2.manage_snapshots().fast_forward_branch(from_branch="peer", to_ref="main").commit() + + # A pure no-op stages no updates; commit_transaction short-circuits. + table_v2.catalog.commit_table.assert_not_called() + + +def test_fast_forward_branch_rejects_tag_as_source(table_v2: Table) -> None: + # Precondition: the fixture provides a tag named "test". + assert table_v2.metadata.refs["test"].snapshot_ref_type == SnapshotRefType.TAG + table_v2.catalog = MagicMock() + + with pytest.raises(SnapshotRefTypeError, match="Ref test is a tag, not a branch"): + table_v2.manage_snapshots().fast_forward_branch(from_branch="test", to_ref="main").commit() + + table_v2.catalog.commit_table.assert_not_called() + + +def test_fast_forward_branch_rejects_missing_to_ref(table_v2: Table) -> None: + table_v2.catalog = MagicMock() + + with pytest.raises(NoSuchSnapshotRefError, match="Ref does not exist: nonexistent"): + table_v2.manage_snapshots().fast_forward_branch(from_branch="main", to_ref="nonexistent").commit() + + table_v2.catalog.commit_table.assert_not_called() + + +def test_fast_forward_branch_rejects_non_ancestor(table_v2: Table) -> None: + # Non-ancestor check operates on committed metadata only (see + # ``fast_forward_branch`` docstring — intra-chain Java-parity is not + # implemented). Inject "ahead" branch directly into metadata so the + # ancestry check can observe it. + from pyiceberg.table.refs import SnapshotRef + + newer_snapshot_id = 3055729675574597004 # main's current snapshot; "test" tag points at older snapshot + + table_v2.metadata.refs["ahead"] = SnapshotRef( + snapshot_id=newer_snapshot_id, + snapshot_ref_type="branch", + ) + + table_v2.catalog = MagicMock() + + # Try to fast-forward "ahead" (at newer) backwards to "test" (at older). + with pytest.raises(NotAncestorError, match="Cannot fast-forward: ahead is not an ancestor of test"): + table_v2.manage_snapshots().fast_forward_branch(from_branch="ahead", to_ref="test").commit() + + table_v2.catalog.commit_table.assert_not_called() + + +def test_fast_forward_branch_preserves_retention_fields(table_v2: Table) -> None: + from pyiceberg.table.refs import SnapshotRef + + parent_snapshot_id = 3051729675574597004 + child_snapshot_id = 3055729675574597004 + + # Inject a branch with all three retention fields set into the metadata. + table_v2.metadata.refs["retained"] = SnapshotRef( + snapshot_id=parent_snapshot_id, + snapshot_ref_type="branch", + max_ref_age_ms=1000, + max_snapshot_age_ms=2000, + min_snapshots_to_keep=3, + ) + + table_v2.catalog = MagicMock() + table_v2.catalog.commit_table.return_value = _mock_commit_response(table_v2) + + table_v2.manage_snapshots().fast_forward_branch(from_branch="retained", to_ref="main").commit() + + updates = _get_updates(table_v2.catalog) + set_ref_updates = [u for u in updates if isinstance(u, SetSnapshotRefUpdate)] + + assert len(set_ref_updates) == 1 + update = set_ref_updates[0] + assert update.ref_name == "retained" + assert update.snapshot_id == child_snapshot_id + assert update.max_ref_age_ms == 1000 + assert update.max_snapshot_age_ms == 2000 + assert update.min_snapshots_to_keep == 3 + + +def test_fast_forward_branch_chains(table_v2: Table) -> None: + parent_snapshot_id = 3051729675574597004 + + table_v2.catalog = MagicMock() + table_v2.catalog.commit_table.return_value = _mock_commit_response(table_v2) + + with table_v2.manage_snapshots() as ms: + ms.create_branch(snapshot_id=parent_snapshot_id, branch_name="stream").fast_forward_branch( + from_branch="stream", to_ref="main" + ).create_tag(snapshot_id=parent_snapshot_id, tag_name="stream-v1") + + updates = _get_updates(table_v2.catalog) + set_ref_updates = [u for u in updates if isinstance(u, SetSnapshotRefUpdate)] + + ref_names = {u.ref_name for u in set_ref_updates} + assert "stream" in ref_names # from create_branch AND fast_forward_branch + assert "stream-v1" in ref_names # from create_tag + + # There should be two updates for `stream` (create at parent, then fast-forward to child) + # and one for `stream-v1`. The commit protocol accepts multiple updates for the same ref. + stream_updates = [u for u in set_ref_updates if u.ref_name == "stream"] + assert len(stream_updates) == 2 + assert stream_updates[0].snapshot_id == parent_snapshot_id + assert stream_updates[1].snapshot_id == 3055729675574597004 diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000000..d59a35779a --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,38 @@ +# 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. +import pytest + +from pyiceberg.exceptions import ( + NoSuchSnapshotRefError, + NotAncestorError, + SnapshotRefTypeError, +) + + +def test_no_such_snapshot_ref_error_is_value_error() -> None: + with pytest.raises(ValueError): + raise NoSuchSnapshotRefError("nope") + + +def test_snapshot_ref_type_error_is_value_error() -> None: + with pytest.raises(ValueError): + raise SnapshotRefTypeError("nope") + + +def test_not_ancestor_error_is_value_error() -> None: + with pytest.raises(ValueError): + raise NotAncestorError("nope") From 5a986892641a047b06bb9d0d15ad75cfeabc7204 Mon Sep 17 00:00:00 2001 From: Jeroen Schmidt Date: Mon, 13 Jul 2026 23:27:47 +0200 Subject: [PATCH 2/8] feat: intra-chain ref lookups via _effective_refs in ManageSnapshots --- pyiceberg/table/update/snapshot.py | 51 +++++++++++++++------- tests/table/test_manage_snapshots.py | 64 ++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 16 deletions(-) diff --git a/pyiceberg/table/update/snapshot.py b/pyiceberg/table/update/snapshot.py index b282bb7943..c599569517 100644 --- a/pyiceberg/table/update/snapshot.py +++ b/pyiceberg/table/update/snapshot.py @@ -56,7 +56,7 @@ ) from pyiceberg.partitioning import PartitionSpec from pyiceberg.schema import Schema -from pyiceberg.table.refs import MAIN_BRANCH, SnapshotRefType +from pyiceberg.table.refs import MAIN_BRANCH, SnapshotRef, SnapshotRefType from pyiceberg.table.snapshots import ( Operation, Snapshot, @@ -1048,6 +1048,35 @@ def _commit_if_ref_updates_exist(self) -> None: self._updates = () self._requirements = () + def _effective_refs(self) -> dict[str, SnapshotRef]: + """Return refs as they would appear after all currently-staged updates. + + Committed refs from ``table_metadata.refs`` overlaid with the effects + of every ``SetSnapshotRefUpdate`` / ``RemoveSnapshotRefUpdate`` that has + been accumulated onto ``self._updates`` in this chain, in order. Later + stages win. Callers use this instead of ``table_metadata.refs`` when a + decision needs to observe the results of earlier operations in the + same ``manage_snapshots()`` chain. + + Note that this projection is for *decision-making* only. Requirements + emitted via ``_set_ref_snapshot`` continue to reference committed + state, which is what the catalog checks at commit time and what makes + concurrent-write detection correct. + """ + refs: dict[str, SnapshotRef] = dict(self._transaction.table_metadata.refs) + for update in self._updates: + if isinstance(update, SetSnapshotRefUpdate): + refs[update.ref_name] = SnapshotRef( + snapshot_id=update.snapshot_id, + snapshot_ref_type=update.type, + max_ref_age_ms=update.max_ref_age_ms, + max_snapshot_age_ms=update.max_snapshot_age_ms, + min_snapshots_to_keep=update.min_snapshots_to_keep, + ) + elif isinstance(update, RemoveSnapshotRefUpdate): + refs.pop(update.ref_name, None) + return refs + def _remove_ref_snapshot(self, ref_name: str) -> ManageSnapshots: """Remove a snapshot ref. @@ -1243,20 +1272,10 @@ def fast_forward_branch(self, from_branch: str, to_ref: str) -> ManageSnapshots: If ``from_branch`` does not exist, it is created pointing at ``to_ref``'s snapshot (Java/Spark parity). If both refs already point to the same snapshot the call is a no-op. Otherwise ``from_branch`` must be a branch (not a tag) and its current - snapshot must be an ancestor of ``to_ref``'s snapshot. - - Note: - Unlike Java's ``ManageSnapshots.fastForwardBranch``, this method does not - provide Java-parity for intra-chain semantics. - - 1) Java maintains a mutable ``updatedRefs`` map that reflects prior operations - in the same chain, so calling ``createBranch`` and then ``fastForwardBranch`` - on the same ref in one chain observes the freshly-created state. - - 2) pyiceberg's ``ManageSnapshots`` accumulates ``SetSnapshotRefUpdate`` values without - mutating ``table_metadata.refs`` between chained calls; - -> The no-op and ancestry checks below operate on committed metadata only. - -> Callers that need Java-parity behavior should commit between chain steps. + snapshot must be an ancestor of ``to_ref``'s snapshot. Within a single + ``manage_snapshots()`` chain, ref lookups observe earlier staged + operations via :meth:`_effective_refs`, so `create_branch(...)` followed + by `fast_forward_branch(...)` on the same ref works as expected. Args: from_branch: name of the branch to advance. @@ -1270,7 +1289,7 @@ def fast_forward_branch(self, from_branch: str, to_ref: str) -> ManageSnapshots: SnapshotRefTypeError: ``from_branch`` exists but is a tag. NotAncestorError: ``from_branch``'s snapshot is not an ancestor of ``to_ref``'s snapshot. """ - refs = self._transaction.table_metadata.refs + refs = self._effective_refs() if to_ref not in refs: raise NoSuchSnapshotRefError(f"Ref does not exist: {to_ref}") diff --git a/tests/table/test_manage_snapshots.py b/tests/table/test_manage_snapshots.py index b77a68373d..f3721640c6 100644 --- a/tests/table/test_manage_snapshots.py +++ b/tests/table/test_manage_snapshots.py @@ -350,3 +350,67 @@ def test_fast_forward_branch_chains(table_v2: Table) -> None: assert len(stream_updates) == 2 assert stream_updates[0].snapshot_id == parent_snapshot_id assert stream_updates[1].snapshot_id == 3055729675574597004 + + +def test_fast_forward_branch_preserves_retention_intra_chain(table_v2: Table) -> None: + """ + With _effective_refs, a fast-forward that observes a same-chain create_branch preserves the branch's retention fields. + Without _effective_refs, these fields would be ignored on the fast_forward branch creation. + """ + parent_snapshot_id = 3051729675574597004 + child_snapshot_id = 3055729675574597004 # main's current snapshot + + table_v2.catalog = MagicMock() + table_v2.catalog.commit_table.return_value = _mock_commit_response(table_v2) + + ( + table_v2.manage_snapshots() + .create_branch( + snapshot_id=parent_snapshot_id, + branch_name="feature", + max_ref_age_ms=5000, + max_snapshot_age_ms=6000, + min_snapshots_to_keep=7, + ) + .fast_forward_branch(from_branch="feature", to_ref="main") + .commit() + ) + + updates = _get_updates(table_v2.catalog) + feature_updates = [u for u in updates if isinstance(u, SetSnapshotRefUpdate) and u.ref_name == "feature"] + assert len(feature_updates) == 2 + + # First: create_branch stages "feature" at parent with retention. + assert feature_updates[0].snapshot_id == parent_snapshot_id + assert feature_updates[0].max_ref_age_ms == 5000 + assert feature_updates[0].max_snapshot_age_ms == 6000 + assert feature_updates[0].min_snapshots_to_keep == 7 + + # Second: fast_forward_branch observes the staged branch via _effective_refs, + # advances it to main's snapshot, and preserves retention fields. + assert feature_updates[1].snapshot_id == child_snapshot_id + assert feature_updates[1].max_ref_age_ms == 5000 + assert feature_updates[1].max_snapshot_age_ms == 6000 + assert feature_updates[1].min_snapshots_to_keep == 7 + + +def test_fast_forward_branch_rejects_intra_chain_tag(table_v2: Table) -> None: + """ + A tag staged earlier in the same chain must be observed as a tag by a later fast_forward_branch. + + Without _effective_refs, the tag wouldn't appear in refs and fast_forward_branch's auto-create path would + silently create a branch of the same name, subverting the tag. + """ + parent_snapshot_id = 3051729675574597004 + + table_v2.catalog = MagicMock() + + with pytest.raises(SnapshotRefTypeError, match="Ref mytag is a tag, not a branch"): + ( + table_v2.manage_snapshots() + .create_tag(snapshot_id=parent_snapshot_id, tag_name="mytag") + .fast_forward_branch(from_branch="mytag", to_ref="main") + .commit() + ) + + table_v2.catalog.commit_table.assert_not_called() From 9835368e49fd80003e26a431bd04c601bfa65a25 Mon Sep 17 00:00:00 2001 From: Jeroen Schmidt Date: Wed, 22 Jul 2026 21:28:52 +0200 Subject: [PATCH 3/8] test(integration): fast_forward_branch preserves retention intra-chain --- tests/integration/test_snapshot_operations.py | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/integration/test_snapshot_operations.py b/tests/integration/test_snapshot_operations.py index 6dda20313b..0d91f9307a 100644 --- a/tests/integration/test_snapshot_operations.py +++ b/tests/integration/test_snapshot_operations.py @@ -315,6 +315,47 @@ def test_fast_forward_branch(catalog: Catalog) -> None: assert tbl.refs()["main"].snapshot_id == audit_snapshot_id +@pytest.mark.integration +@pytest.mark.parametrize("catalog", [lf("session_catalog_hive"), lf("session_catalog")]) +def test_fast_forward_branch_preserves_retention_intra_chain(catalog: Catalog) -> None: + identifier = "default.test_table_snapshot_operations" + tbl = catalog.load_table(identifier) + assert len(tbl.history()) > 2 + + # Pick an older snapshot as the branch's starting point so the subsequent + # fast-forward to main is a real advance, not a no-op. + older_snapshot_id = tbl.history()[-3].snapshot_id + current_snapshot = tbl.current_snapshot() + assert current_snapshot is not None + main_snapshot_id = current_snapshot.snapshot_id + + branch_name = "retention_intra_chain" + max_ref = 3_600_000 # 1h — distinct value per field so a swap would be caught + max_snap = 7_200_000 # 2h + min_keep = 5 + + # Chain create_branch (with retention) + fast_forward_branch in one commit. + # _effective_refs lets the fast-forward observe the same-chain create and + # carry the retention fields onto the second staged SetSnapshotRefUpdate. + tbl.manage_snapshots().create_branch( + snapshot_id=older_snapshot_id, + branch_name=branch_name, + max_ref_age_ms=max_ref, + max_snapshot_age_ms=max_snap, + min_snapshots_to_keep=min_keep, + ).fast_forward_branch( + from_branch=branch_name, + to_ref="main", + ).commit() + + tbl = catalog.load_table(identifier) + ref = tbl.refs()[branch_name] + assert ref.snapshot_id == main_snapshot_id + assert ref.max_ref_age_ms == max_ref + assert ref.max_snapshot_age_ms == max_snap + assert ref.min_snapshots_to_keep == min_keep + + @pytest.mark.integration def test_rollback_to_timestamp(table_with_snapshots: Table) -> None: current_snapshot = table_with_snapshots.current_snapshot() From fa0ffe5fe67b87eace03ac49260bbe8844c3ffc8 Mon Sep 17 00:00:00 2001 From: Jeroen Schmidt Date: Fri, 4 Sep 2026 12:53:47 +0200 Subject: [PATCH 4/8] docs(fix): Cleanup Example & Add Mermaid Diagram --- mkdocs/docs/api.md | 84 +++++++++++++++++++++++++++++++++------------- mkdocs/mkdocs.yml | 10 ++++++ uv.lock | 84 +++++++++++++++++++--------------------------- 3 files changed, 105 insertions(+), 73 deletions(-) diff --git a/mkdocs/docs/api.md b/mkdocs/docs/api.md index 989c7956f8..1f5d4d6692 100644 --- a/mkdocs/docs/api.md +++ b/mkdocs/docs/api.md @@ -1485,24 +1485,66 @@ table.manage_snapshots().remove_branch("dev").commit() #### Fast-forwarding a branch -Fast-forward `from_branch` to point at the snapshot referenced by -`to_ref`. `to_ref` may be a branch or tag; `from_branch` must be a -branch. If `from_branch` does not yet exist it is created pointing at -`to_ref`'s snapshot. If both already point at the same snapshot the -call is a no-op. Otherwise `from_branch`'s current snapshot must be an -ancestor of `to_ref`'s snapshot; if not, `NotAncestorError` is raised. +Fast-forward the `main` branch to the `audit-branch` branch: ```python with table.manage_snapshots() as ms: - ms.fast_forward_branch("main", "audit-branch") + ms.fast_forward_branch(from_branch="main", to_ref="audit-branch") ``` -##### End-to-end: write-audit-publish +Fast-forward `from_branch` to point at the snapshot referenced by `to_ref`. +`to_ref` may be a branch or tag. `from_branch` must be a branch. -The canonical use case for fast-forward is the write-audit-publish -(WAP) pattern: writes proceed on a side branch, validation runs -against that branch, and only after validation succeeds is the main -branch advanced to publish the new data. + + +!!! info "Fast Forward Behavior" + + * Case 1: If `from_branch` does not yet exist it is created and pointing at `to_ref`'s + snapshot. The default retention properties are applied on the auto-created snapshot. + * Case 2:** If both already point at the same snapshot the call is a no-op. + * Case 3: Otherwise `from_branch`'s current snapshot must be an ancestor of `to_ref`'s snapshot; + if not, `NotAncestorError` is raised. + + + +#### Example Use-Case: write-audit-publish (WAP) + +The use of branching & fast-forwarding enable the usage of the write-audit-publish (WAP) process: + +1. Writes proceed on a side branch +2. Audit Validation runs against that branch +3. Publish the new data by fast-forwarding the main branch + +```mermaid +--- +title: Conceptually Illustration of the WAP Process +--- +flowchart LR + + subgraph audit [audit branch] + s1_audit["snapshot_1"] -- "1.2 append(new_rows)" --> s2_audit["snapshot_2"] + v@{ shape: comment, label: '2. Validation Performed & Passed' } + s2_audit ~~~ v + v -.-> s2_audit + end + + subgraph main [main branch] + s1["snapshot_1"] + s2_main["snapshot_2"] + + end + + s1 -. "1.1 create_branch" .-> s1_audit + s2_audit -. "3. fast_forward_branch" .-> s2_main + +``` + +If validation fails, callers simply skip the fast-forward step. The +audit branch (and its data files) can then be inspected, rewritten, +or removed via `remove_branch` and subsequent snapshot expiration - +without ever having polluted the data on `main`. + +##### Programmatic Example ```python import pyarrow as pa @@ -1513,6 +1555,7 @@ catalog = load_catalog("prod") table = catalog.load_table("sales.orders") # 1. WRITE — create a side branch off main and append to it. +# 1.1 Create the Branch main_snapshot_id = table.current_snapshot().snapshot_id table.manage_snapshots().create_branch( snapshot_id=main_snapshot_id, @@ -1523,11 +1566,12 @@ new_rows = pa.table({ "order_id": [1001, 1002, 1003], "amount": [ 49.99, 129.00, 12.50], }) + +# 1.2 Write into the branch table.append(new_rows, branch="audit") -# 2. AUDIT — scan the audit branch and run whatever validation -# your data-quality contract requires. Nothing on `main` has -# changed yet, so readers of `main` still see the pre-write state. +# 2. AUDIT — scan the audit branch and run whatever validation your data-quality contract requires. +# Nothing on `main` has changed yet, so readers of `main` still see the pre-write state. audit_snapshot_id = table.refs()["audit"].snapshot_id audit_data = table.scan(snapshot_id=audit_snapshot_id).to_arrow() @@ -1536,19 +1580,13 @@ assert pc.all(pc.greater(audit_data["amount"], 0)).as_py(), \ "found non-positive amounts" # 3. PUBLISH — validation passed; fast-forward main to audit. -# Because the audit branch was created from main and only appended -# to, main's current snapshot is an ancestor of audit's snapshot, -# so the fast-forward is valid. +# fast-forward can occur because the audit branch was created from main and +# main's current snapshot is an ancestor of audit's snapshot. with table.manage_snapshots() as ms: ms.fast_forward_branch("main", "audit") ms.remove_branch("audit") # optional: clean up the side branch ``` -If validation fails, callers simply skip the fast-forward step. The -audit branch (and its data files) can then be inspected, rewritten, -or removed via `remove_branch` and subsequent snapshot expiration — -without ever having polluted `main`. - ## Table Maintenance PyIceberg provides table maintenance operations through the `table.maintenance` API. This provides a clean interface for performing maintenance tasks like snapshot expiration. diff --git a/mkdocs/mkdocs.yml b/mkdocs/mkdocs.yml index b9e92d3686..48c7a861b5 100644 --- a/mkdocs/mkdocs.yml +++ b/mkdocs/mkdocs.yml @@ -61,5 +61,15 @@ markdown_extensions: - pymdownx.highlight: anchor_linenums: true - pymdownx.superfences + - pymdownx.superfences: + preserve_tabs: true + custom_fences: + # Mermaid diagrams + # Needed so that Superfences doesn't break Mermaid + # See: https://facelessuser.github.io/pymdown-extensions/extras/mermaid/#using-in-mkdocs + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - toc: permalink: true diff --git a/uv.lock b/uv.lock index 2f9d1d8d79..c3b51631b1 100644 --- a/uv.lock +++ b/uv.lock @@ -1399,7 +1399,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1971,18 +1971,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/a1/1f7f0c555f5858fd2906fe9f7b0a3554fddb85cb70df7a6aaec41dc292c2/greenlet-3.5.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c180d22d325fb613956b443c3c6f4406eb70e6defc70d3974da2a7b59e06f48c", size = 285838, upload-time = "2026-06-26T18:21:05.167Z" }, { url = "https://files.pythonhosted.org/packages/0a/29/be9f43ed61677a5759b38c8a9389248133c8c731bbfc0574ecdff66c99fc/greenlet-3.5.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483d08c11181c83a6ce1a7a61df0f624a208ec40817a3bb2302714592eee4f04", size = 602342, upload-time = "2026-06-26T19:07:06.908Z" }, { url = "https://files.pythonhosted.org/packages/b9/42/ba41c97ec36aa4b3ec25e5aa691d79561254805fad7f2f826dd6770587e2/greenlet-3.5.3-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1dae6e0091eae084317e411f047f0b7cb241c6db570f7c45fd6b900a274914ce", size = 615541, upload-time = "2026-06-26T19:10:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/2e/8c/231ca675b0df779816950ca66b40b1fa14dbff4a0ed9814a9a29ec399140/greenlet-3.5.3-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f6ff50ff8dbd51fae9b37f4101648b04ea0df19b3f50ab2beb5061e7716a5c8", size = 622473, upload-time = "2026-06-26T19:24:12.786Z" }, { url = "https://files.pythonhosted.org/packages/f5/c7/28747042e1df8a9cd120a1ebe15529fc4be3b486e13e8d551ff307a82412/greenlet-3.5.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcd2d72ccd70a1ec68ba6ef93e7fbb4420ef9997dabc7010d893bd4015e0bec", size = 615675, upload-time = "2026-06-26T18:32:14.444Z" }, - { url = "https://files.pythonhosted.org/packages/81/fe/dd97c483a3ff82849196ccd07851600edd3ac9de74669ca8a6022ada9ea1/greenlet-3.5.3-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:37bf9c538f5ae6e63d643f88dec37c0c83bdf0e2ebc62961dedcf458822f7b71", size = 418421, upload-time = "2026-06-26T19:25:34.503Z" }, { url = "https://files.pythonhosted.org/packages/cc/a8/b85525a6c8fba9f009a5f7c8df1545de8fb0f0bf3e0179194ef4e500317f/greenlet-3.5.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:73f152c895e09907e0dbe24f6c2db37beb085cd63db91c3825a0fcd0064124a8", size = 1575057, upload-time = "2026-06-26T19:09:00.264Z" }, { url = "https://files.pythonhosted.org/packages/03/79/fb76edb218fe6735ab0edeba176c7ab80df9618f7c02ce4208979f3ae7db/greenlet-3.5.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8bdb43e1a1d1873721acab2be99c5befd4d2044ddfd52e4d610801019880a702", size = 1641692, upload-time = "2026-06-26T18:31:41.454Z" }, { url = "https://files.pythonhosted.org/packages/6b/79/86fe3ee50ed55d9b3907eecd3208b5c3fe8a79515519aae98b4753c3fa1d/greenlet-3.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:0909f9355a9f24845d3299f3112e266a06afb68302041989fd26bd68894933db", size = 238742, upload-time = "2026-06-26T18:20:40.758Z" }, { url = "https://files.pythonhosted.org/packages/51/58/5404031044f55afad7aad1aff8be3f22b1bed03e237cfeabbc7e5c8cfde0/greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4", size = 287424, upload-time = "2026-06-26T18:20:31.469Z" }, { url = "https://files.pythonhosted.org/packages/b4/bf/1c65e9b94a54d547068fa5b5a8a06f221f3316b48908e08668d29c77cb50/greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc", size = 606523, upload-time = "2026-06-26T19:07:08.859Z" }, { url = "https://files.pythonhosted.org/packages/b8/c7/b66baacc95775ad511287acb0137b95574a9ce5491902372b7564799d790/greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6", size = 618315, upload-time = "2026-06-26T19:10:06.055Z" }, - { url = "https://files.pythonhosted.org/packages/b0/a0/68afd1ebad40db87dac0a28ffa120726b98bf9c7c40c481b0f63c105d298/greenlet-3.5.3-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e18619ba655ac05d78d80fc83cac4ba892bd6927b99e3b8237aee861aaacc8bb", size = 626155, upload-time = "2026-06-26T19:24:14.44Z" }, { url = "https://files.pythonhosted.org/packages/78/2b/28ed29463522fdbe4c15b1f63922041626a7478316b34ab4adda3f0a4aba/greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7", size = 617381, upload-time = "2026-06-26T18:32:16.077Z" }, - { url = "https://files.pythonhosted.org/packages/07/7f/e327d912239ec4b3b49999e3967389bcf1ee8722b9ee9194d2752ecd558a/greenlet-3.5.3-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d27c0c653a60d9535f690226474a5cc1036a8b0d7b57504d1c4f89c44a07a80c", size = 421083, upload-time = "2026-06-26T19:25:35.804Z" }, { url = "https://files.pythonhosted.org/packages/2a/7b/ad04e9d1337fc04965dc9fc616b6a72cb65a24b800a014c011ec812f5489/greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8", size = 1577771, upload-time = "2026-06-26T19:09:01.537Z" }, { url = "https://files.pythonhosted.org/packages/d8/33/6c87ab7ba663f70ca21f3022aad1ffe56d3f3e0521e836c2415e13abcc3c/greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8", size = 1644048, upload-time = "2026-06-26T18:31:42.996Z" }, { url = "https://files.pythonhosted.org/packages/1c/35/f0d8ee998b422cf8693b270f098e55d8d4ec8006b061b333f54f177d28d9/greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7", size = 239137, upload-time = "2026-06-26T18:23:21.664Z" }, @@ -1990,9 +1986,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, - { url = "https://files.pythonhosted.org/packages/fb/fb/d97dc261209c80744b7c8132693a30d70ec6e7315e632cb0a10b3fec94dd/greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23", size = 622351, upload-time = "2026-06-26T19:24:16.32Z" }, { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/e5fee13cbbd0e8de312d9a146584b8a51891c68847330ef9dc8b5109d23f/greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c", size = 425395, upload-time = "2026-06-26T19:25:37.144Z" }, { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, @@ -2000,9 +1994,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" }, { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" }, { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" }, - { url = "https://files.pythonhosted.org/packages/57/61/2f5b1adf256d039f5dab8005de8d3d7ad2b0070a3219c0e036b3fbfeb440/greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1", size = 625580, upload-time = "2026-06-26T19:24:18.344Z" }, { url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d9/ab7fc9e543e44d6879b0a6ef9a4b2188940fd180cc65d6f646883ddf7201/greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda", size = 427933, upload-time = "2026-06-26T19:25:38.219Z" }, { url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" }, { url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" }, { url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" }, @@ -2010,9 +2002,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" }, { url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" }, { url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" }, - { url = "https://files.pythonhosted.org/packages/86/a9/73fa62893d5b84b4205544e6b673c654cc43aa5b9899bac00f04d64af73d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814", size = 670657, upload-time = "2026-06-26T19:24:19.967Z" }, { url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" }, - { url = "https://files.pythonhosted.org/packages/29/7e/2ffce64929fb3cab7b65d5a0b20aaf9764e227681d731b041077fc9a525a/greenlet-3.5.3-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260", size = 473497, upload-time = "2026-06-26T19:25:39.421Z" }, { url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" }, { url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" }, { url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" }, @@ -2020,18 +2010,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" }, { url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" }, { url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" }, - { url = "https://files.pythonhosted.org/packages/cb/73/8faec206b851c22b1733545fda900829a1f3f5b1c78ae7e0fb3dba57d9f4/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d", size = 659582, upload-time = "2026-06-26T19:24:21.357Z" }, { url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" }, - { url = "https://files.pythonhosted.org/packages/b4/55/50c19e49f8045834ada71ef12f8ad048eba8517c6aa41161bed676328fae/greenlet-3.5.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0", size = 491037, upload-time = "2026-06-26T19:25:40.672Z" }, { url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" }, { url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" }, { url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" }, { url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" }, { url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" }, { url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" }, - { url = "https://files.pythonhosted.org/packages/25/aa/952cf28c2ff949a8c971134fb43854dd7eaa737218723aaef758f8c9aead/greenlet-3.5.3-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cefa9cef4b371f9844c6053db71f1138bc6807bab1578b0dae5149c1f1141357", size = 674261, upload-time = "2026-06-26T19:24:22.79Z" }, { url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f2/b00d6f5e63e531a93562b2ec1a4c320fbee91f580fc42e6417af69d706e5/greenlet-3.5.3-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:6219b6d04dbf6ba6084d77dc609e8473060dc55f759cbf626d512122781fa128", size = 480322, upload-time = "2026-06-26T19:25:41.852Z" }, { url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" }, { url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" }, { url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" }, @@ -2039,9 +2025,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" }, { url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" }, { url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" }, - { url = "https://files.pythonhosted.org/packages/e9/39/0e0938a75115b939d42733a2a12e1d349653c9531fe6fe563e8a681f04e6/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d192579ed281051396dddd7f7754dac6259e6b1fb26378c87b66622f8e3f91", size = 663706, upload-time = "2026-06-26T19:24:24.312Z" }, { url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" }, - { url = "https://files.pythonhosted.org/packages/5b/41/35d1c678cdb3c3b9e6bee691728e563cfb294202b23c7a4c3c2ccc343589/greenlet-3.5.3-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:4399eb8d041f20b68d943918bc55502a93d6fdc0a37c14da7881c04139acee9d", size = 498803, upload-time = "2026-06-26T19:25:43.063Z" }, { url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" }, { url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" }, { url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" }, @@ -2284,7 +2268,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.11'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ @@ -2333,17 +2317,17 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version < '3.11'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "jedi", marker = "python_full_version < '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, - { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, - { name = "pygments", marker = "python_full_version < '3.11'" }, - { name = "stack-data", marker = "python_full_version < '3.11'" }, - { name = "traitlets", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "exceptiongroup" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } wheels = [ @@ -2369,18 +2353,18 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version >= '3.11'" }, - { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, - { name = "jedi", marker = "python_full_version >= '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, - { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, - { name = "psutil", marker = "python_full_version >= '3.11' and sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, - { name = "pygments", marker = "python_full_version >= '3.11'" }, - { name = "stack-data", marker = "python_full_version >= '3.11'" }, - { name = "traitlets", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" } wheels = [ @@ -2392,7 +2376,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -4068,10 +4052,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "python-dateutil", marker = "python_full_version < '3.11'" }, - { name = "pytz", marker = "python_full_version < '3.11'" }, - { name = "tzdata", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -4143,9 +4127,9 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, - { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } wheels = [ @@ -4259,7 +4243,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "ptyprocess" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ From b42a94b635b6d723a037e69e70c531c7d0c388aa Mon Sep 17 00:00:00 2001 From: Jeroen Schmidt Date: Fri, 4 Sep 2026 14:16:01 +0200 Subject: [PATCH 5/8] tests(PR Comments 1): Add fast_forward tag case tests(PR Comments 2): Add more noop cases tests(cleanup): Rename tests with naming format `test__{method}__with_{condition}__{outcome}`. tests(cleanup): Move tests into test class groupings tests(cleanup): Added explicit `table_v2_main_behind` fixture & update the tests to have main be fast-forwarded to reduce confusion against WAP process. --- pyiceberg/table/update/snapshot.py | 14 +- tests/table/test_manage_snapshots.py | 434 ++++++++++++++++----------- 2 files changed, 274 insertions(+), 174 deletions(-) diff --git a/pyiceberg/table/update/snapshot.py b/pyiceberg/table/update/snapshot.py index c599569517..b6f456099d 100644 --- a/pyiceberg/table/update/snapshot.py +++ b/pyiceberg/table/update/snapshot.py @@ -32,6 +32,7 @@ NoSuchSnapshotRefError, NotAncestorError, SnapshotRefTypeError, + ValidationException, ) from pyiceberg.expressions import AlwaysFalse, BooleanExpression, Or from pyiceberg.expressions.visitors import ( @@ -1269,12 +1270,13 @@ def _current_ancestors(self) -> set[int]: def fast_forward_branch(self, from_branch: str, to_ref: str) -> ManageSnapshots: """Fast-forward ``from_branch`` to the snapshot referenced by ``to_ref``. - If ``from_branch`` does not exist, it is created pointing at ``to_ref``'s snapshot (Java/Spark parity). - If both refs already point to the same snapshot the call is a no-op. - Otherwise ``from_branch`` must be a branch (not a tag) and its current - snapshot must be an ancestor of ``to_ref``'s snapshot. Within a single - ``manage_snapshots()`` chain, ref lookups observe earlier staged - operations via :meth:`_effective_refs`, so `create_branch(...)` followed + * If ``from_branch`` does not exist, it is created pointing at ``to_ref``'s snapshot (Java/Spark parity). + * If both refs already point to the same snapshot the call is a no-op. + * Otherwise ``from_branch`` must be a branch (not a tag) and its current snapshot + must be an ancestor of ``to_ref``'s snapshot. + + Within a single ``manage_snapshots()`` chain, ref lookups observe earlier staged + operations via :meth:`_effective_refs`. This means that `create_branch(...)` followed by `fast_forward_branch(...)` on the same ref works as expected. Args: diff --git a/tests/table/test_manage_snapshots.py b/tests/table/test_manage_snapshots.py index f3721640c6..37607132ac 100644 --- a/tests/table/test_manage_snapshots.py +++ b/tests/table/test_manage_snapshots.py @@ -14,6 +14,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +from typing import Any from unittest.mock import MagicMock from uuid import uuid4 @@ -24,9 +25,15 @@ NotAncestorError, SnapshotRefTypeError, ) +from pyiceberg.io.pyarrow import PyArrowFileIO from pyiceberg.table import CommitTableResponse, Table -from pyiceberg.table.refs import SnapshotRefType -from pyiceberg.table.update import SetSnapshotRefUpdate, TableUpdate +from pyiceberg.table.metadata import TableMetadataUtil +from pyiceberg.table.refs import SnapshotRef, SnapshotRefType +from pyiceberg.table.update import SetSnapshotRefUpdate, TableRequirement, TableUpdate + +# The two snapshots in the table_v2 fixture: CHILD's parent is PARENT. +PARENT_SNAPSHOT_ID = 3051729675574597004 +CHILD_SNAPSHOT_ID = 3055729675574597004 def _mock_commit_response(table: Table) -> CommitTableResponse: @@ -42,6 +49,39 @@ def _get_updates(mock_catalog: MagicMock) -> tuple[TableUpdate, ...]: return args[2] +def _get_requirements(mock_catalog: MagicMock) -> tuple[TableRequirement, ...]: + args, _ = mock_catalog.commit_table.call_args + return args[1] + + +@pytest.fixture +def table_v2_main_behind(example_table_metadata_v2: dict[str, Any]) -> Table: + """``table_v2`` with main rewound to the parent snapshot and an "audit" branch at the head. + + This is the write-audit-publish mid-flight state: main lags, the side branch is ahead, so + fast-forwarding *main* onto it is a real advance — the direction the API docs document. + Built before parsing because ``current_snapshot_id`` is frozen on the metadata model. + """ + metadata = TableMetadataUtil.parse_obj( + { + **example_table_metadata_v2, + "current-snapshot-id": PARENT_SNAPSHOT_ID, + "refs": { + "main": {"snapshot-id": PARENT_SNAPSHOT_ID, "type": "branch"}, + "audit": {"snapshot-id": CHILD_SNAPSHOT_ID, "type": "branch"}, + "test": {"snapshot-id": PARENT_SNAPSHOT_ID, "type": "tag", "max-ref-age-ms": 10000000}, + }, + } + ) + return Table( + identifier=("database", "table"), + metadata=metadata, + metadata_location="s3://bucket/test/location/metadata/v1.json", + io=PyArrowFileIO(), + catalog=MagicMock(), + ) + + def test_set_current_snapshot_basic(table_v2: Table) -> None: snapshot_one = 3051729675574597004 @@ -185,232 +225,290 @@ def test_set_current_snapshot_chained_with_create_tag(table_v2: Table) -> None: assert main_update.snapshot_id == snapshot_one -def test_fast_forward_branch_advances_to_descendant(table_v2: Table) -> None: - parent_snapshot_id = 3051729675574597004 - child_snapshot_id = 3055729675574597004 +class TestFastForwardBranchSuccess: + def test_fast_forward_branch__with_existing_from_branch__advances_to_descendant_branch( + self, table_v2_main_behind: Table + ) -> None: + """Publishing main onto a side branch that is ahead of it — the documented direction.""" + table = table_v2_main_behind + table.catalog = MagicMock() + table.catalog.commit_table.return_value = _mock_commit_response(table) + + table.manage_snapshots().fast_forward_branch(from_branch="main", to_ref="audit").commit() + + set_ref_updates = [u for u in _get_updates(table.catalog) if isinstance(u, SetSnapshotRefUpdate)] + + assert len(set_ref_updates) == 1 + update = set_ref_updates[0] + assert update.ref_name == "main" + assert update.snapshot_id == CHILD_SNAPSHOT_ID + assert update.type == "branch" + + def test_fast_forward_branch__with_existing_from_branch__advances_to_descendant_tag( + self, table_v2_main_behind: Table + ) -> None: + """A tag is a valid fast-forward target, even though a tag is rejected as the source.""" + table = table_v2_main_behind + + # Tag the head so main can be published onto a tag rather than a branch. The fixture's + # own "test" tag points at the parent, so a new one is needed here. + table.metadata.refs["at-head"] = SnapshotRef( + snapshot_id=CHILD_SNAPSHOT_ID, + snapshot_ref_type="tag", + ) - # Create a lagging branch at the parent snapshot, then reset the mock so - # the next commit's updates are the fast-forward alone. - table_v2.catalog = MagicMock() - table_v2.catalog.commit_table.return_value = _mock_commit_response(table_v2) - table_v2.manage_snapshots().create_branch(snapshot_id=parent_snapshot_id, branch_name="lagging").commit() - table_v2.catalog.commit_table.reset_mock() + table.catalog = MagicMock() + table.catalog.commit_table.return_value = _mock_commit_response(table) - table_v2.manage_snapshots().fast_forward_branch(from_branch="lagging", to_ref="main").commit() + table.manage_snapshots().fast_forward_branch(from_branch="main", to_ref="at-head").commit() - updates = _get_updates(table_v2.catalog) - set_ref_updates = [u for u in updates if isinstance(u, SetSnapshotRefUpdate)] + set_ref_updates = [u for u in _get_updates(table.catalog) if isinstance(u, SetSnapshotRefUpdate)] - assert len(set_ref_updates) == 1 - update = set_ref_updates[0] - assert update.ref_name == "lagging" - assert update.snapshot_id == child_snapshot_id - assert update.type == "branch" + assert len(set_ref_updates) == 1 + update = set_ref_updates[0] + assert update.ref_name == "main" + assert update.snapshot_id == CHILD_SNAPSHOT_ID + assert update.type == "branch" + def test_fast_forward_branch__with_no_from_branch__creates_missing_from_branch(self, table_v2: Table) -> None: + current_snapshot = table_v2.current_snapshot() + assert current_snapshot is not None + main_snapshot_id = current_snapshot.snapshot_id -def test_fast_forward_branch_creates_missing_from(table_v2: Table) -> None: - current_snapshot = table_v2.current_snapshot() - assert current_snapshot is not None - main_snapshot_id = current_snapshot.snapshot_id + table_v2.catalog = MagicMock() + table_v2.catalog.commit_table.return_value = _mock_commit_response(table_v2) - table_v2.catalog = MagicMock() - table_v2.catalog.commit_table.return_value = _mock_commit_response(table_v2) + table_v2.manage_snapshots().fast_forward_branch(from_branch="brand-new", to_ref="main").commit() - table_v2.manage_snapshots().fast_forward_branch(from_branch="brand-new", to_ref="main").commit() + updates = _get_updates(table_v2.catalog) + set_ref_updates = [u for u in updates if isinstance(u, SetSnapshotRefUpdate)] - updates = _get_updates(table_v2.catalog) - set_ref_updates = [u for u in updates if isinstance(u, SetSnapshotRefUpdate)] + assert len(set_ref_updates) == 1 + assert set_ref_updates[0].ref_name == "brand-new" + assert set_ref_updates[0].snapshot_id == main_snapshot_id + assert set_ref_updates[0].type == "branch" - assert len(set_ref_updates) == 1 - assert set_ref_updates[0].ref_name == "brand-new" - assert set_ref_updates[0].snapshot_id == main_snapshot_id - assert set_ref_updates[0].type == "branch" + def test_fast_forward_branch__when_refs_equal__does_not_commit(self, table_v2: Table) -> None: + """A fast-forward between two refs already at the same snapshot never reaches the catalog. + "audit" is injected straight into ``metadata.refs`` at main's own head, so the equality + short-circuit is reached without any earlier staged operation in the chain. -def test_fast_forward_branch_noop_when_already_equal(table_v2: Table) -> None: - # The no-op check operates on committed metadata only (see - # ``fast_forward_branch`` docstring — intra-chain Java-parity is not - # implemented). Inject a second branch pointing at main's snapshot - # directly into metadata, then confirm the fast-forward stages nothing. - from pyiceberg.table.refs import SnapshotRef + This is the transaction-level guarantee: the whole transaction ends up empty, so + ``commit_transaction`` short-circuits. That the *operation* staged nothing is pinned + separately by ``..._when_noop_and_other_chained_updates__stages_only_the_other``. + """ + table_v2.metadata.refs["audit"] = SnapshotRef( + snapshot_id=table_v2.metadata.refs["main"].snapshot_id, + snapshot_ref_type="branch", + ) - current_snapshot = table_v2.current_snapshot() - assert current_snapshot is not None - main_snapshot_id = current_snapshot.snapshot_id - table_v2.metadata.refs["peer"] = SnapshotRef( - snapshot_id=main_snapshot_id, - snapshot_ref_type="branch", - ) + table_v2.catalog = MagicMock() + table_v2.catalog.commit_table.return_value = _mock_commit_response(table_v2) - table_v2.catalog = MagicMock() - table_v2.catalog.commit_table.return_value = _mock_commit_response(table_v2) + table_v2.manage_snapshots().fast_forward_branch(from_branch="main", to_ref="audit").commit() - table_v2.manage_snapshots().fast_forward_branch(from_branch="peer", to_ref="main").commit() + # A pure no-op stages no updates; commit_transaction short-circuits. + table_v2.catalog.commit_table.assert_not_called() - # A pure no-op stages no updates; commit_transaction short-circuits. - table_v2.catalog.commit_table.assert_not_called() + def test_fast_forward_branch__with_operation_chaining__succeeds(self, table_v2: Table) -> None: + parent_snapshot_id = 3051729675574597004 + table_v2.catalog = MagicMock() + table_v2.catalog.commit_table.return_value = _mock_commit_response(table_v2) -def test_fast_forward_branch_rejects_tag_as_source(table_v2: Table) -> None: - # Precondition: the fixture provides a tag named "test". - assert table_v2.metadata.refs["test"].snapshot_ref_type == SnapshotRefType.TAG - table_v2.catalog = MagicMock() + with table_v2.manage_snapshots() as ms: + ms.create_branch(snapshot_id=parent_snapshot_id, branch_name="stream").fast_forward_branch( + from_branch="stream", to_ref="main" + ).create_tag(snapshot_id=parent_snapshot_id, tag_name="stream-v1") - with pytest.raises(SnapshotRefTypeError, match="Ref test is a tag, not a branch"): - table_v2.manage_snapshots().fast_forward_branch(from_branch="test", to_ref="main").commit() + updates = _get_updates(table_v2.catalog) + set_ref_updates = [u for u in updates if isinstance(u, SetSnapshotRefUpdate)] - table_v2.catalog.commit_table.assert_not_called() + ref_names = {u.ref_name for u in set_ref_updates} + assert "stream" in ref_names # from create_branch AND fast_forward_branch + assert "stream-v1" in ref_names # from create_tag + # There should be two updates for `stream` (create at parent, then fast-forward to child) + # and one for `stream-v1`. The commit protocol accepts multiple updates for the same ref. + stream_updates = [u for u in set_ref_updates if u.ref_name == "stream"] + assert len(stream_updates) == 2 + assert stream_updates[0].snapshot_id == parent_snapshot_id + assert stream_updates[1].snapshot_id == 3055729675574597004 -def test_fast_forward_branch_rejects_missing_to_ref(table_v2: Table) -> None: - table_v2.catalog = MagicMock() + def test_fast_forward_branch__when_noop_and_other_chained_updates__stages_only_the_other(self, table_v2: Table) -> None: + """A no-op fast-forward contributes neither an update nor a requirement.""" - with pytest.raises(NoSuchSnapshotRefError, match="Ref does not exist: nonexistent"): - table_v2.manage_snapshots().fast_forward_branch(from_branch="main", to_ref="nonexistent").commit() + # "audit" is put at the very snapshot "main" already points to, so publishing main is a no-op. + table_v2.metadata.refs["audit"] = SnapshotRef( + snapshot_id=table_v2.metadata.refs["main"].snapshot_id, + snapshot_ref_type="branch", + ) - table_v2.catalog.commit_table.assert_not_called() + table_v2.catalog = MagicMock() + table_v2.catalog.commit_table.return_value = _mock_commit_response(table_v2) + # The tag is the unrelated real update that keeps the transaction non-empty; its + # snapshot just needs to exist and is deliberately not main's. + tagged_snapshot_id = 3051729675574597004 -def test_fast_forward_branch_rejects_non_ancestor(table_v2: Table) -> None: - # Non-ancestor check operates on committed metadata only (see - # ``fast_forward_branch`` docstring — intra-chain Java-parity is not - # implemented). Inject "ahead" branch directly into metadata so the - # ancestry check can observe it. - from pyiceberg.table.refs import SnapshotRef + ( + table_v2.manage_snapshots() + .fast_forward_branch(from_branch="main", to_ref="audit") + .create_tag(snapshot_id=tagged_snapshot_id, tag_name="t1") + .commit() + ) - newer_snapshot_id = 3055729675574597004 # main's current snapshot; "test" tag points at older snapshot + set_ref_updates = [u for u in _get_updates(table_v2.catalog) if isinstance(u, SetSnapshotRefUpdate)] - table_v2.metadata.refs["ahead"] = SnapshotRef( - snapshot_id=newer_snapshot_id, - snapshot_ref_type="branch", - ) + # Only the tag is staged; the no-op fast-forward adds nothing. + assert len(set_ref_updates) == 1 + assert set_ref_updates[0].ref_name == "t1" - table_v2.catalog = MagicMock() + # No requirement either. A stray AssertRefSnapshotId for "main" would make this + # transaction conflict with a concurrent writer for no reason. + assert all(getattr(r, "ref", None) != "main" for r in _get_requirements(table_v2.catalog)) - # Try to fast-forward "ahead" (at newer) backwards to "test" (at older). - with pytest.raises(NotAncestorError, match="Cannot fast-forward: ahead is not an ancestor of test"): - table_v2.manage_snapshots().fast_forward_branch(from_branch="ahead", to_ref="test").commit() + def test_fast_forward_branch__when_applied_twice_in_one_chain__second_is_noop(self, table_v2_main_behind: Table) -> None: + """The second fast-forward observes the first via ``_effective_refs`` and stages nothing.""" + table = table_v2_main_behind + table.catalog = MagicMock() + table.catalog.commit_table.return_value = _mock_commit_response(table) - table_v2.catalog.commit_table.assert_not_called() + ( + table.manage_snapshots() + .fast_forward_branch(from_branch="main", to_ref="audit") + .fast_forward_branch(from_branch="main", to_ref="audit") # this is a noop + .commit() + ) + main_updates = [u for u in _get_updates(table.catalog) if isinstance(u, SetSnapshotRefUpdate) and u.ref_name == "main"] -def test_fast_forward_branch_preserves_retention_fields(table_v2: Table) -> None: - from pyiceberg.table.refs import SnapshotRef + assert len(main_updates) == 1 + assert main_updates[0].snapshot_id == CHILD_SNAPSHOT_ID - parent_snapshot_id = 3051729675574597004 - child_snapshot_id = 3055729675574597004 - # Inject a branch with all three retention fields set into the metadata. - table_v2.metadata.refs["retained"] = SnapshotRef( - snapshot_id=parent_snapshot_id, - snapshot_ref_type="branch", - max_ref_age_ms=1000, - max_snapshot_age_ms=2000, - min_snapshots_to_keep=3, - ) +class TestFastForwardRejectionCases: + def test_fast_forward_branch__with_tag_as_source__rejects(self, table_v2: Table) -> None: + # Precondition: the fixture provides a tag named "test". + assert table_v2.metadata.refs["test"].snapshot_ref_type == SnapshotRefType.TAG + table_v2.catalog = MagicMock() - table_v2.catalog = MagicMock() - table_v2.catalog.commit_table.return_value = _mock_commit_response(table_v2) + with pytest.raises(SnapshotRefTypeError, match="Ref test is a tag, not a branch"): + table_v2.manage_snapshots().fast_forward_branch(from_branch="test", to_ref="main").commit() - table_v2.manage_snapshots().fast_forward_branch(from_branch="retained", to_ref="main").commit() + table_v2.catalog.commit_table.assert_not_called() - updates = _get_updates(table_v2.catalog) - set_ref_updates = [u for u in updates if isinstance(u, SetSnapshotRefUpdate)] + def test_fast_forward_branch__with_missing_to_ref__rejects(self, table_v2: Table) -> None: + table_v2.catalog = MagicMock() - assert len(set_ref_updates) == 1 - update = set_ref_updates[0] - assert update.ref_name == "retained" - assert update.snapshot_id == child_snapshot_id - assert update.max_ref_age_ms == 1000 - assert update.max_snapshot_age_ms == 2000 - assert update.min_snapshots_to_keep == 3 + with pytest.raises(NoSuchSnapshotRefError, match="Ref does not exist: nonexistent"): + table_v2.manage_snapshots().fast_forward_branch(from_branch="main", to_ref="nonexistent").commit() + table_v2.catalog.commit_table.assert_not_called() -def test_fast_forward_branch_chains(table_v2: Table) -> None: - parent_snapshot_id = 3051729675574597004 + def test_fast_forward_branch__with_non_ancestor__rejects(self, table_v2: Table) -> None: + """A branch ahead of ``to_ref`` cannot be fast-forwarded backwards onto it. - table_v2.catalog = MagicMock() - table_v2.catalog.commit_table.return_value = _mock_commit_response(table_v2) + No setup is needed: main already sits at the head while the "test" tag points at the + parent, so publishing main onto that tag is a backwards move. + """ + table_v2.catalog = MagicMock() - with table_v2.manage_snapshots() as ms: - ms.create_branch(snapshot_id=parent_snapshot_id, branch_name="stream").fast_forward_branch( - from_branch="stream", to_ref="main" - ).create_tag(snapshot_id=parent_snapshot_id, tag_name="stream-v1") + with pytest.raises(NotAncestorError, match="Cannot fast-forward: main is not an ancestor of test"): + table_v2.manage_snapshots().fast_forward_branch(from_branch="main", to_ref="test").commit() - updates = _get_updates(table_v2.catalog) - set_ref_updates = [u for u in updates if isinstance(u, SetSnapshotRefUpdate)] + table_v2.catalog.commit_table.assert_not_called() - ref_names = {u.ref_name for u in set_ref_updates} - assert "stream" in ref_names # from create_branch AND fast_forward_branch - assert "stream-v1" in ref_names # from create_tag + def test_fast_forward_branch__with_tagging_in_operation_chaining__rejects(self, table_v2: Table) -> None: + """ + A tag staged earlier in the same chain must be observed as a tag by a later fast_forward_branch. - # There should be two updates for `stream` (create at parent, then fast-forward to child) - # and one for `stream-v1`. The commit protocol accepts multiple updates for the same ref. - stream_updates = [u for u in set_ref_updates if u.ref_name == "stream"] - assert len(stream_updates) == 2 - assert stream_updates[0].snapshot_id == parent_snapshot_id - assert stream_updates[1].snapshot_id == 3055729675574597004 + Without _effective_refs, the tag wouldn't appear in refs and fast_forward_branch's auto-create path would + silently create a branch of the same name, subverting the tag. + """ + parent_snapshot_id = 3051729675574597004 + table_v2.catalog = MagicMock() -def test_fast_forward_branch_preserves_retention_intra_chain(table_v2: Table) -> None: - """ - With _effective_refs, a fast-forward that observes a same-chain create_branch preserves the branch's retention fields. - Without _effective_refs, these fields would be ignored on the fast_forward branch creation. - """ - parent_snapshot_id = 3051729675574597004 - child_snapshot_id = 3055729675574597004 # main's current snapshot + with pytest.raises(SnapshotRefTypeError, match="Ref mytag is a tag, not a branch"): + ( + table_v2.manage_snapshots() + .create_tag(snapshot_id=parent_snapshot_id, tag_name="mytag") + .fast_forward_branch(from_branch="mytag", to_ref="main") + .commit() + ) - table_v2.catalog = MagicMock() - table_v2.catalog.commit_table.return_value = _mock_commit_response(table_v2) + table_v2.catalog.commit_table.assert_not_called() - ( - table_v2.manage_snapshots() - .create_branch( - snapshot_id=parent_snapshot_id, - branch_name="feature", - max_ref_age_ms=5000, - max_snapshot_age_ms=6000, - min_snapshots_to_keep=7, - ) - .fast_forward_branch(from_branch="feature", to_ref="main") - .commit() - ) - updates = _get_updates(table_v2.catalog) - feature_updates = [u for u in updates if isinstance(u, SetSnapshotRefUpdate) and u.ref_name == "feature"] - assert len(feature_updates) == 2 +class TestFastForwardRetentionCases: + def test_fast_forward_branch__with_retention_fields__fields_get_preserved(self, table_v2_main_behind: Table) -> None: + """Retention configured on the branch being published survives the advance.""" + table = table_v2_main_behind - # First: create_branch stages "feature" at parent with retention. - assert feature_updates[0].snapshot_id == parent_snapshot_id - assert feature_updates[0].max_ref_age_ms == 5000 - assert feature_updates[0].max_snapshot_age_ms == 6000 - assert feature_updates[0].min_snapshots_to_keep == 7 + # Give main all three retention fields, leaving it at the parent so + # current-snapshot-id and the main ref stay in agreement. + table.metadata.refs["main"] = SnapshotRef( + snapshot_id=PARENT_SNAPSHOT_ID, + snapshot_ref_type="branch", + max_ref_age_ms=1000, + max_snapshot_age_ms=2000, + min_snapshots_to_keep=3, + ) - # Second: fast_forward_branch observes the staged branch via _effective_refs, - # advances it to main's snapshot, and preserves retention fields. - assert feature_updates[1].snapshot_id == child_snapshot_id - assert feature_updates[1].max_ref_age_ms == 5000 - assert feature_updates[1].max_snapshot_age_ms == 6000 - assert feature_updates[1].min_snapshots_to_keep == 7 + table.catalog = MagicMock() + table.catalog.commit_table.return_value = _mock_commit_response(table) + table.manage_snapshots().fast_forward_branch(from_branch="main", to_ref="audit").commit() -def test_fast_forward_branch_rejects_intra_chain_tag(table_v2: Table) -> None: - """ - A tag staged earlier in the same chain must be observed as a tag by a later fast_forward_branch. + set_ref_updates = [u for u in _get_updates(table.catalog) if isinstance(u, SetSnapshotRefUpdate)] - Without _effective_refs, the tag wouldn't appear in refs and fast_forward_branch's auto-create path would - silently create a branch of the same name, subverting the tag. - """ - parent_snapshot_id = 3051729675574597004 + assert len(set_ref_updates) == 1 + update = set_ref_updates[0] + assert update.ref_name == "main" + assert update.snapshot_id == CHILD_SNAPSHOT_ID + assert update.max_ref_age_ms == 1000 + assert update.max_snapshot_age_ms == 2000 + assert update.min_snapshots_to_keep == 3 - table_v2.catalog = MagicMock() + def test_fast_forward_branch__with_operation_chaining__preserves_retention(self, table_v2: Table) -> None: + """ + With _effective_refs, a fast-forward that observes a same-chain create_branch preserves the branch's retention fields. + Without _effective_refs, these fields would be ignored on the fast_forward branch creation. + """ + parent_snapshot_id = 3051729675574597004 + child_snapshot_id = 3055729675574597004 # main's current snapshot + + table_v2.catalog = MagicMock() + table_v2.catalog.commit_table.return_value = _mock_commit_response(table_v2) - with pytest.raises(SnapshotRefTypeError, match="Ref mytag is a tag, not a branch"): ( table_v2.manage_snapshots() - .create_tag(snapshot_id=parent_snapshot_id, tag_name="mytag") - .fast_forward_branch(from_branch="mytag", to_ref="main") + .create_branch( + snapshot_id=parent_snapshot_id, + branch_name="feature", + max_ref_age_ms=5000, + max_snapshot_age_ms=6000, + min_snapshots_to_keep=7, + ) + .fast_forward_branch(from_branch="feature", to_ref="main") .commit() ) - table_v2.catalog.commit_table.assert_not_called() + updates = _get_updates(table_v2.catalog) + feature_updates = [u for u in updates if isinstance(u, SetSnapshotRefUpdate) and u.ref_name == "feature"] + assert len(feature_updates) == 2 + + # First: create_branch stages "feature" at parent with retention. + assert feature_updates[0].snapshot_id == parent_snapshot_id + assert feature_updates[0].max_ref_age_ms == 5000 + assert feature_updates[0].max_snapshot_age_ms == 6000 + assert feature_updates[0].min_snapshots_to_keep == 7 + + # Second: fast_forward_branch observes the staged branch via _effective_refs, + # advances it to main's snapshot, and preserves retention fields. + assert feature_updates[1].snapshot_id == child_snapshot_id + assert feature_updates[1].max_ref_age_ms == 5000 + assert feature_updates[1].max_snapshot_age_ms == 6000 + assert feature_updates[1].min_snapshots_to_keep == 7 From 1a983736c48b773fc8349d5d2fe9496d50c8047b Mon Sep 17 00:00:00 2001 From: J Schmidt Date: Tue, 8 Sep 2026 17:43:47 +0200 Subject: [PATCH 6/8] NIT: snapshot.py | Using walrus notation for single lookup. Co-authored-by: Fokko Driesprong --- pyiceberg/table/update/snapshot.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyiceberg/table/update/snapshot.py b/pyiceberg/table/update/snapshot.py index b6f456099d..96f25f244c 100644 --- a/pyiceberg/table/update/snapshot.py +++ b/pyiceberg/table/update/snapshot.py @@ -1293,9 +1293,8 @@ def fast_forward_branch(self, from_branch: str, to_ref: str) -> ManageSnapshots: """ refs = self._effective_refs() - if to_ref not in refs: + if (to_snapshot_id := refs.get(to_ref)) is None: raise NoSuchSnapshotRefError(f"Ref does not exist: {to_ref}") - to_snapshot_id = refs[to_ref].snapshot_id if from_branch not in refs: return self.create_branch(snapshot_id=to_snapshot_id, branch_name=from_branch) From beddca3c7edf8f4e4e2b81f71ef1868cf59b97cd Mon Sep 17 00:00:00 2001 From: Jeroen Schmidt Date: Tue, 8 Sep 2026 19:41:01 +0200 Subject: [PATCH 7/8] Trigger Build From fcf9faccdefc6672391e934848f52f5628959bae Mon Sep 17 00:00:00 2001 From: Jeroen Schmidt Date: Tue, 8 Sep 2026 20:03:09 +0200 Subject: [PATCH 8/8] fix: Bug introduced by single lookup walrus operation --- pyiceberg/table/update/snapshot.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyiceberg/table/update/snapshot.py b/pyiceberg/table/update/snapshot.py index 96f25f244c..8024e808b2 100644 --- a/pyiceberg/table/update/snapshot.py +++ b/pyiceberg/table/update/snapshot.py @@ -1293,8 +1293,9 @@ def fast_forward_branch(self, from_branch: str, to_ref: str) -> ManageSnapshots: """ refs = self._effective_refs() - if (to_snapshot_id := refs.get(to_ref)) is None: + if (to_snapshot_ref := refs.get(to_ref)) is None: raise NoSuchSnapshotRefError(f"Ref does not exist: {to_ref}") + to_snapshot_id = to_snapshot_ref.snapshot_id if from_branch not in refs: return self.create_branch(snapshot_id=to_snapshot_id, branch_name=from_branch)