This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-4148-2ecd87b222f5c7fe020c93816f8c3830412b7034
in repository https://gitbox.apache.org/repos/asf/iceberg-python.git

commit 35bb9bb62a00dcf8560d970403fef1a8047809e8
Author: Xander <[email protected]>
AuthorDate: Sat Oct 24 17:31:06 2026 +0000

    feat(encryption): [7/N] Add encryption key updates (#4148)
    
    feat(encryption): Add encryption key updates
---
 pyiceberg/table/update/__init__.py | 47 ++++++++++++++++++++++-
 tests/table/test_init.py           | 76 +++++++++++++++++++++++++++++++++++++-
 2 files changed, 119 insertions(+), 4 deletions(-)

diff --git a/pyiceberg/table/update/__init__.py 
b/pyiceberg/table/update/__init__.py
index 64838b0bd..aaa4a51b7 100644
--- a/pyiceberg/table/update/__init__.py
+++ b/pyiceberg/table/update/__init__.py
@@ -29,7 +29,13 @@ from pydantic import Field, field_validator, 
model_serializer, model_validator
 from pyiceberg.exceptions import CommitFailedException
 from pyiceberg.partitioning import PARTITION_FIELD_ID_START, PartitionSpec
 from pyiceberg.schema import Schema
-from pyiceberg.table.metadata import SUPPORTED_TABLE_FORMAT_VERSION, 
TableMetadata, TableMetadataUtil
+from pyiceberg.table.metadata import (
+    SUPPORTED_TABLE_FORMAT_VERSION,
+    EncryptedKey,
+    TableMetadata,
+    TableMetadataUtil,
+    TableMetadataV3,
+)
 from pyiceberg.table.refs import MAIN_BRANCH, SnapshotRef, SnapshotRefType
 from pyiceberg.table.snapshots import (
     MetadataLogEntry,
@@ -222,6 +228,16 @@ class RemovePartitionStatisticsUpdate(IcebergBaseModel):
     snapshot_id: int = Field(alias="snapshot-id")
 
 
+class AddEncryptionKeyUpdate(IcebergBaseModel):
+    action: Literal["add-encryption-key"] = Field(default="add-encryption-key")
+    encryption_key: EncryptedKey = Field(alias="encryption-key")
+
+
+class RemoveEncryptionKeyUpdate(IcebergBaseModel):
+    action: Literal["remove-encryption-key"] = 
Field(default="remove-encryption-key")
+    key_id: str = Field(alias="key-id")
+
+
 TableUpdate = Annotated[
     AssignUUIDUpdate
     | UpgradeFormatVersionUpdate
@@ -243,7 +259,9 @@ TableUpdate = Annotated[
     | RemovePartitionSpecsUpdate
     | RemoveSchemasUpdate
     | SetPartitionStatisticsUpdate
-    | RemovePartitionStatisticsUpdate,
+    | RemovePartitionStatisticsUpdate
+    | AddEncryptionKeyUpdate
+    | RemoveEncryptionKeyUpdate,
     Field(discriminator="action"),
 ]
 
@@ -680,6 +698,31 @@ def _(
     return base_metadata.model_copy(update={"partition_statistics": 
statistics})
 
 
+@_apply_table_update.register(AddEncryptionKeyUpdate)
+def _(update: AddEncryptionKeyUpdate, base_metadata: TableMetadata, context: 
_TableMetadataUpdateContext) -> TableMetadata:
+    if not isinstance(base_metadata, TableMetadataV3):
+        raise ValueError(f"Encryption keys are not supported in format version 
{base_metadata.format_version}")
+
+    if any(key.key_id == update.encryption_key.key_id for key in 
base_metadata.encryption_keys):
+        return base_metadata
+
+    context.add_update(update)
+    return base_metadata.model_copy(update={"encryption_keys": 
base_metadata.encryption_keys + [update.encryption_key]})
+
+
+@_apply_table_update.register(RemoveEncryptionKeyUpdate)
+def _(update: RemoveEncryptionKeyUpdate, base_metadata: TableMetadata, 
context: _TableMetadataUpdateContext) -> TableMetadata:
+    if not isinstance(base_metadata, TableMetadataV3):
+        raise ValueError(f"Encryption keys are not supported in format version 
{base_metadata.format_version}")
+
+    encryption_keys = [key for key in base_metadata.encryption_keys if 
key.key_id != update.key_id]
+    if len(encryption_keys) == len(base_metadata.encryption_keys):
+        return base_metadata
+
+    context.add_update(update)
+    return base_metadata.model_copy(update={"encryption_keys": 
encryption_keys})
+
+
 def update_table_metadata(
     base_metadata: TableMetadata,
     updates: tuple[TableUpdate, ...],
diff --git a/tests/table/test_init.py b/tests/table/test_init.py
index 94614c9d6..5ee5e4566 100644
--- a/tests/table/test_init.py
+++ b/tests/table/test_init.py
@@ -22,7 +22,7 @@ from pathlib import Path
 from typing import Any
 
 import pytest
-from pydantic import BaseModel, ValidationError
+from pydantic import BaseModel, TypeAdapter, ValidationError
 from pytest_lazy_fixtures import lf
 
 from pyiceberg.catalog import Catalog
@@ -46,7 +46,7 @@ from pyiceberg.table import (
     Table,
     TableIdentifier,
 )
-from pyiceberg.table.metadata import TableMetadataUtil, TableMetadataV1, 
TableMetadataV2, _generate_snapshot_id
+from pyiceberg.table.metadata import EncryptedKey, TableMetadataUtil, 
TableMetadataV1, TableMetadataV2, _generate_snapshot_id
 from pyiceberg.table.refs import MAIN_BRANCH, SnapshotRef, SnapshotRefType
 from pyiceberg.table.snapshots import (
     MetadataLogEntry,
@@ -63,6 +63,7 @@ from pyiceberg.table.sorting import (
 )
 from pyiceberg.table.statistics import BlobMetadata, PartitionStatisticsFile, 
StatisticsFile
 from pyiceberg.table.update import (
+    AddEncryptionKeyUpdate,
     AddPartitionSpecUpdate,
     AddSnapshotUpdate,
     AddSortOrderUpdate,
@@ -74,6 +75,7 @@ from pyiceberg.table.update import (
     AssertLastAssignedPartitionId,
     AssertRefSnapshotId,
     AssertTableUUID,
+    RemoveEncryptionKeyUpdate,
     RemovePartitionSpecsUpdate,
     RemovePartitionStatisticsUpdate,
     RemovePropertiesUpdate,
@@ -86,6 +88,7 @@ from pyiceberg.table.update import (
     SetPropertiesUpdate,
     SetSnapshotRefUpdate,
     SetStatisticsUpdate,
+    TableUpdate,
     _apply_table_update,
     _TableMetadataUpdateContext,
     update_table_metadata,
@@ -1951,6 +1954,75 @@ def 
test_remove_partition_statistics_update_with_invalid_snapshot_id(table_v2_wi
         )
 
 
+def test_add_encryption_key_update(table_v3: Table) -> None:
+    kek = EncryptedKey(key_id="kek", encrypted_key_metadata=b"wrapped-kek", 
encrypted_by_id="master", properties={"a": "b"})
+    manifest_list_key = EncryptedKey(key_id="manifest-list-key", 
encrypted_key_metadata=b"wrapped-dek", encrypted_by_id="kek")
+
+    new_metadata = update_table_metadata(
+        table_v3.metadata,
+        (AddEncryptionKeyUpdate(encryption_key=kek), 
AddEncryptionKeyUpdate(encryption_key=manifest_list_key)),
+    )
+
+    assert new_metadata.encryption_keys == [kek, manifest_list_key]
+
+
+def test_add_encryption_key_update_with_existing_key_id(table_v3: Table) -> 
None:
+    key = EncryptedKey(key_id="kek", encrypted_key_metadata=b"wrapped-kek")
+    base_metadata = update_table_metadata(table_v3.metadata, 
(AddEncryptionKeyUpdate(encryption_key=key),))
+
+    context = _TableMetadataUpdateContext()
+    duplicate = EncryptedKey(key_id="kek", encrypted_key_metadata=b"other")
+    new_metadata = 
_apply_table_update(AddEncryptionKeyUpdate(encryption_key=duplicate), 
base_metadata, context)
+
+    assert new_metadata.encryption_keys == [key]
+    assert not context.has_changes()
+
+
+def test_remove_encryption_key_update(table_v3: Table) -> None:
+    first = EncryptedKey(key_id="first", encrypted_key_metadata=b"first")
+    second = EncryptedKey(key_id="second", encrypted_key_metadata=b"second")
+    base_metadata = update_table_metadata(
+        table_v3.metadata, (AddEncryptionKeyUpdate(encryption_key=first), 
AddEncryptionKeyUpdate(encryption_key=second))
+    )
+
+    new_metadata = update_table_metadata(base_metadata, 
(RemoveEncryptionKeyUpdate(key_id="first"),))
+
+    assert new_metadata.encryption_keys == [second]
+
+
+def test_remove_encryption_key_update_with_missing_key_id(table_v3: Table) -> 
None:
+    context = _TableMetadataUpdateContext()
+    new_metadata = 
_apply_table_update(RemoveEncryptionKeyUpdate(key_id="missing"), 
table_v3.metadata, context)
+
+    assert new_metadata == table_v3.metadata
+    assert not context.has_changes()
+
+
[email protected](
+    "update",
+    [
+        AddEncryptionKeyUpdate(encryption_key=EncryptedKey(key_id="a", 
encrypted_key_metadata=b"key")),
+        RemoveEncryptionKeyUpdate(key_id="a"),
+    ],
+)
+def test_encryption_key_updates_require_v3(table_v2: Table, update: 
TableUpdate) -> None:
+    with pytest.raises(ValueError, match="Encryption keys are not supported in 
format version 2"):
+        update_table_metadata(table_v2.metadata, (update,))
+
+
+def test_serialize_encryption_key_updates() -> None:
+    add = AddEncryptionKeyUpdate(encryption_key=EncryptedKey(key_id="a", 
encrypted_key_metadata=b"key", encrypted_by_id="b"))
+    remove = RemoveEncryptionKeyUpdate(key_id="a")
+
+    assert json.loads(add.model_dump_json()) == {
+        "action": "add-encryption-key",
+        "encryption-key": {"key-id": "a", "encrypted-key-metadata": "a2V5", 
"encrypted-by-id": "b", "properties": {}},
+    }
+    assert json.loads(remove.model_dump_json()) == {"action": 
"remove-encryption-key", "key-id": "a"}
+    assert TypeAdapter(TableUpdate).validate_json(add.model_dump_json()) == add
+    assert TypeAdapter(TableUpdate).validate_json(remove.model_dump_json()) == 
remove
+
+
 def test_add_snapshot_update_fails_without_first_row_id(table_v3: Table) -> 
None:
     new_snapshot = Snapshot(
         snapshot_id=25,

Reply via email to