blob: 567f1444a79ac0f4a19301a877861660005495bd [file]
# 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.
from typing import Any
import pytest
from pydantic import TypeAdapter, ValidationError
from requests_mock import Mocker
from pyiceberg.catalog.rest import RestCatalog
from pyiceberg.catalog.rest.scan_planning import (
CountMap,
FetchScanTasksRequest,
PlanCancelled,
PlanCompleted,
PlanningResponse,
PlanSubmitted,
PlanTableScanRequest,
RESTDataFile,
RESTDeleteFile,
RESTEqualityDeleteFile,
RESTFileScanTask,
RESTPositionDeleteFile,
ScanTasks,
ValueMap,
)
from pyiceberg.expressions import AlwaysTrue, EqualTo, Reference
from pyiceberg.manifest import FileFormat
TEST_URI = "https://iceberg-test-catalog/"
@pytest.fixture
def rest_scan_catalog(requests_mock: Mocker) -> RestCatalog:
requests_mock.get(
f"{TEST_URI}v1/config",
json={
"defaults": {"rest-scan-planning-enabled": "true"},
"overrides": {},
"endpoints": [
"POST /v1/{prefix}/namespaces/{namespace}/tables/{table}/plan",
"POST /v1/{prefix}/namespaces/{namespace}/tables/{table}/tasks",
],
},
status_code=200,
)
return RestCatalog(
"test",
uri=TEST_URI,
**{"rest-scan-planning-enabled": "true"},
)
def _rest_data_file(
*,
file_path: str = "s3://bucket/table/data/file.parquet",
file_format: str = "parquet",
file_size_in_bytes: int = 1024,
record_count: int = 100,
) -> dict[str, Any]:
return {
"spec-id": 0,
"content": "data",
"file-path": file_path,
"file-format": file_format,
"file-size-in-bytes": file_size_in_bytes,
"record-count": record_count,
}
def _rest_position_delete_file(
*,
file_path: str = "s3://bucket/table/delete.parquet",
file_format: str = "parquet",
file_size_in_bytes: int = 256,
record_count: int = 5,
content_offset: int = 100,
content_size_in_bytes: int = 200,
) -> dict[str, Any]:
return {
"spec-id": 0,
"content": "position-deletes",
"file-path": file_path,
"file-format": file_format,
"file-size-in-bytes": file_size_in_bytes,
"record-count": record_count,
"content-offset": content_offset,
"content-size-in-bytes": content_size_in_bytes,
}
def _rest_equality_delete_file(
*,
file_path: str = "s3://bucket/table/eq-delete.parquet",
equality_ids: list[int],
file_format: str = "parquet",
file_size_in_bytes: int = 256,
record_count: int = 5,
) -> dict[str, Any]:
return {
"spec-id": 0,
"content": "equality-deletes",
"file-path": file_path,
"file-format": file_format,
"file-size-in-bytes": file_size_in_bytes,
"record-count": record_count,
"equality-ids": equality_ids,
}
def test_count_map_valid() -> None:
cm = CountMap(keys=[1, 2, 3], values=[100, 200, 300])
assert cm.to_dict() == {1: 100, 2: 200, 3: 300}
def test_count_map_empty() -> None:
cm = CountMap()
assert cm.to_dict() == {}
def test_count_map_length_mismatch() -> None:
with pytest.raises(ValidationError) as exc_info:
CountMap(keys=[1, 2, 3], values=[100, 200])
assert "must have same length" in str(exc_info.value)
def test_value_map_mixed_types() -> None:
vm = ValueMap(keys=[1, 2, 3], values=[True, 42, "val"])
assert vm.to_dict() == {1: True, 2: 42, 3: "val"}
def test_data_file_parsing() -> None:
data_file = _rest_data_file(file_path="s3://bucket/table/file.parquet")
df = RESTDataFile.model_validate(data_file)
assert df.content == "data"
assert df.file_path == "s3://bucket/table/file.parquet"
assert df.file_format == FileFormat.PARQUET
def test_data_file_with_stats() -> None:
data_file = _rest_data_file()
data_file_with_stats = {
**data_file,
"column-sizes": {"keys": [1, 2], "values": [500, 524]},
"value-counts": {"keys": [1, 2], "values": [100, 100]},
}
df = RESTDataFile.model_validate(data_file_with_stats)
assert df.column_sizes is not None
assert df.column_sizes.to_dict() == {1: 500, 2: 524}
def test_position_delete_file() -> None:
delete_file = _rest_position_delete_file(file_path="s3://bucket/table/delete.puffin", file_format="puffin")
pdf = RESTPositionDeleteFile.model_validate(delete_file)
assert pdf.content == "position-deletes"
assert pdf.content_offset == 100
assert pdf.content_size_in_bytes == 200
def test_equality_delete_file() -> None:
delete_file = _rest_equality_delete_file(equality_ids=[1, 2])
equality_delete = RESTEqualityDeleteFile.model_validate(delete_file)
assert equality_delete.content == "equality-deletes"
assert equality_delete.equality_ids == [1, 2]
def test_file_format_case_insensitive() -> None:
for fmt in ["parquet", "PARQUET", "Parquet"]:
data_file = _rest_data_file(file_format=fmt)
df = RESTDataFile.model_validate(data_file)
assert df.file_format == FileFormat.PARQUET
@pytest.mark.parametrize(
"format_str,expected",
[
("parquet", FileFormat.PARQUET),
("avro", FileFormat.AVRO),
("orc", FileFormat.ORC),
],
)
def test_file_formats(format_str: str, expected: FileFormat) -> None:
data_file = _rest_data_file(file_format=format_str)
df = RESTDataFile.model_validate(data_file)
assert df.file_format == expected
def test_delete_file_discriminator_position() -> None:
delete_file = _rest_position_delete_file()
result = TypeAdapter(RESTDeleteFile).validate_python(delete_file)
assert isinstance(result, RESTPositionDeleteFile)
def test_delete_file_discriminator_equality() -> None:
delete_file = _rest_equality_delete_file(equality_ids=[1, 2])
result = TypeAdapter(RESTDeleteFile).validate_python(delete_file)
assert isinstance(result, RESTEqualityDeleteFile)
def test_basic_scan_task() -> None:
data_file = _rest_data_file(file_path="s3://bucket/table/file.parquet")
data = {"data-file": data_file}
task = RESTFileScanTask.model_validate(data)
assert task.data_file.file_path == "s3://bucket/table/file.parquet"
assert task.delete_file_references is None
assert task.residual_filter is None
def test_scan_task_with_delete_references() -> None:
data_file = _rest_data_file()
data = {
"data-file": data_file,
"delete-file-references": [0, 1, 2],
}
task = RESTFileScanTask.model_validate(data)
assert task.delete_file_references == [0, 1, 2]
def test_scan_task_with_residual_filter_true() -> None:
data_file = _rest_data_file()
data = {
"data-file": data_file,
"residual-filter": True,
}
task = RESTFileScanTask.model_validate(data)
assert isinstance(task.residual_filter, AlwaysTrue)
def test_empty_scan_tasks() -> None:
data: dict[str, Any] = {
"delete-files": [],
"file-scan-tasks": [],
"plan-tasks": [],
}
scan_tasks = ScanTasks.model_validate(data)
assert len(scan_tasks.file_scan_tasks) == 0
assert len(scan_tasks.delete_files) == 0
assert len(scan_tasks.plan_tasks) == 0
def test_scan_tasks_with_files() -> None:
data_file = _rest_data_file(file_path="s3://bucket/table/data.parquet")
delete_file = _rest_position_delete_file()
data = {
"delete-files": [delete_file],
"file-scan-tasks": [
{
"data-file": data_file,
"delete-file-references": [0],
}
],
"plan-tasks": ["token-1", "token-2"],
}
scan_tasks = ScanTasks.model_validate(data)
assert len(scan_tasks.delete_files) == 1
assert len(scan_tasks.file_scan_tasks) == 1
assert len(scan_tasks.plan_tasks) == 2
def test_invalid_delete_file_reference() -> None:
data_file = _rest_data_file(file_path="s3://bucket/table/data.parquet")
data = {
"delete-files": [],
"file-scan-tasks": [
{
"data-file": data_file,
"delete-file-references": [0],
}
],
"plan-tasks": [],
}
with pytest.raises(ValidationError) as exc_info:
ScanTasks.model_validate(data)
assert "Invalid delete file reference" in str(exc_info.value)
def test_delete_files_require_file_scan_tasks() -> None:
delete_file = _rest_position_delete_file()
data = {
"delete-files": [delete_file],
"file-scan-tasks": [],
"plan-tasks": [],
}
with pytest.raises(ValidationError) as exc_info:
ScanTasks.model_validate(data)
assert "deleteFiles should only be returned with fileScanTasks" in str(exc_info.value)
def test_minimal_request() -> None:
request = PlanTableScanRequest()
dumped = request.model_dump(by_alias=True, exclude_none=True)
assert dumped == {"case-sensitive": True, "use-snapshot-schema": False}
def test_request_with_snapshot_id() -> None:
request = PlanTableScanRequest(snapshot_id=12345)
dumped = request.model_dump(by_alias=True, exclude_none=True)
assert dumped["snapshot-id"] == 12345
def test_request_with_select_and_filter() -> None:
request = PlanTableScanRequest(
select=["id", "name"],
filter=EqualTo(Reference("id"), 42),
)
dumped = request.model_dump(by_alias=True, exclude_none=True)
assert dumped["select"] == ["id", "name"]
assert "filter" in dumped
def test_incremental_scan_request() -> None:
request = PlanTableScanRequest(
start_snapshot_id=100,
end_snapshot_id=200,
)
dumped = request.model_dump(by_alias=True, exclude_none=True)
assert dumped["start-snapshot-id"] == 100
assert dumped["end-snapshot-id"] == 200
def test_start_snapshot_requires_end_snapshot() -> None:
with pytest.raises(ValidationError) as exc_info:
PlanTableScanRequest(start_snapshot_id=100)
assert "end-snapshot-id is required" in str(exc_info.value)
def test_snapshot_id_conflicts_with_start_snapshot() -> None:
with pytest.raises(ValidationError) as exc_info:
PlanTableScanRequest(snapshot_id=50, start_snapshot_id=100, end_snapshot_id=200)
assert "Cannot specify both" in str(exc_info.value)
def test_fetch_scan_tasks_request() -> None:
request = FetchScanTasksRequest(plan_task="token-abc-123")
dumped = request.model_dump(by_alias=True)
assert dumped == {"plan-task": "token-abc-123"}
def test_completed_response() -> None:
data = {
"status": "completed",
"plan-id": "plan-123",
"delete-files": [],
"file-scan-tasks": [],
"plan-tasks": [],
}
result = TypeAdapter(PlanningResponse).validate_python(data)
assert isinstance(result, PlanCompleted)
assert result.plan_id == "plan-123"
def test_completed_response_without_plan_id() -> None:
data = {
"status": "completed",
"delete-files": [],
"file-scan-tasks": [],
"plan-tasks": [],
}
result = TypeAdapter(PlanningResponse).validate_python(data)
assert isinstance(result, PlanCompleted)
assert result.plan_id is None
def test_completed_response_with_credentials() -> None:
data = {
"status": "completed",
"delete-files": [],
"file-scan-tasks": [],
"plan-tasks": [],
"storage-credentials": [
{"prefix": "s3://bucket/", "config": {}},
],
}
result = TypeAdapter(PlanningResponse).validate_python(data)
assert isinstance(result, PlanCompleted)
assert result.storage_credentials is not None
assert len(result.storage_credentials) == 1
def test_submitted_response() -> None:
data = {
"status": "submitted",
"plan-id": "drus-plan",
}
result = TypeAdapter(PlanningResponse).validate_python(data)
assert isinstance(result, PlanSubmitted)
assert result.plan_id == "drus-plan"
def test_submitted_response_without_plan_id() -> None:
data = {"status": "submitted"}
result = TypeAdapter(PlanningResponse).validate_python(data)
assert isinstance(result, PlanSubmitted)
def test_cancelled_response() -> None:
data = {"status": "cancelled"}
result = TypeAdapter(PlanningResponse).validate_python(data)
assert isinstance(result, PlanCancelled)
def test_plan_scan_completed_single_batch(rest_scan_catalog: RestCatalog, requests_mock: Mocker) -> None:
file_one = _rest_data_file(file_path="s3://bucket/tbl/data/file1.parquet")
requests_mock.post(
f"{TEST_URI}v1/namespaces/db/tables/tbl/plan",
json={
"status": "completed",
"plan-id": "plan-123",
"delete-files": [],
"file-scan-tasks": [{"data-file": file_one}],
"plan-tasks": [],
},
status_code=200,
)
request = PlanTableScanRequest()
tasks = list(rest_scan_catalog.plan_scan(("db", "tbl"), request))
assert len(tasks) == 1
assert tasks[0].file.file_path == "s3://bucket/tbl/data/file1.parquet"
def test_plan_scan_with_pagination(rest_scan_catalog: RestCatalog, requests_mock: Mocker) -> None:
file_one = _rest_data_file(file_path="s3://bucket/tbl/data/file1.parquet")
file_two = _rest_data_file(file_path="s3://bucket/tbl/data/file2.parquet")
requests_mock.post(
f"{TEST_URI}v1/namespaces/db/tables/tbl/plan",
json={
"status": "completed",
"plan-id": "plan-123",
"delete-files": [],
"file-scan-tasks": [{"data-file": file_one}],
"plan-tasks": ["token-batch-2"],
},
status_code=200,
)
requests_mock.post(
f"{TEST_URI}v1/namespaces/db/tables/tbl/tasks",
json={
"delete-files": [],
"file-scan-tasks": [{"data-file": file_two}],
"plan-tasks": [],
},
status_code=200,
)
request = PlanTableScanRequest()
tasks = list(rest_scan_catalog.plan_scan(("db", "tbl"), request))
assert len(tasks) == 2
assert tasks[0].file.file_path == "s3://bucket/tbl/data/file1.parquet"
assert tasks[1].file.file_path == "s3://bucket/tbl/data/file2.parquet"
def test_plan_scan_with_delete_files(rest_scan_catalog: RestCatalog, requests_mock: Mocker) -> None:
file_one = _rest_data_file(file_path="s3://bucket/tbl/data/file1.parquet")
delete_file = _rest_position_delete_file()
requests_mock.post(
f"{TEST_URI}v1/namespaces/db/tables/tbl/plan",
json={
"status": "completed",
"plan-id": "plan-123",
"delete-files": [delete_file],
"file-scan-tasks": [
{
"data-file": file_one,
"delete-file-references": [0],
}
],
"plan-tasks": [],
},
status_code=200,
)
request = PlanTableScanRequest()
tasks = list(rest_scan_catalog.plan_scan(("db", "tbl"), request))
assert len(tasks) == 1
assert tasks[0].file.file_path == "s3://bucket/tbl/data/file1.parquet"
assert len(tasks[0].delete_files) == 1
def test_plan_scan_async_not_supported(rest_scan_catalog: RestCatalog, requests_mock: Mocker) -> None:
requests_mock.post(
f"{TEST_URI}v1/namespaces/db/tables/tbl/plan",
json={
"status": "submitted",
"plan-id": "plan-456",
},
status_code=200,
)
request = PlanTableScanRequest()
with pytest.raises(NotImplementedError, match="Async scan planning not yet supported"):
list(rest_scan_catalog.plan_scan(("db", "tbl"), request))
def test_plan_scan_empty_result(rest_scan_catalog: RestCatalog, requests_mock: Mocker) -> None:
requests_mock.post(
f"{TEST_URI}v1/namespaces/db/tables/tbl/plan",
json={
"status": "completed",
"plan-id": "plan-123",
"delete-files": [],
"file-scan-tasks": [],
"plan-tasks": [],
},
status_code=200,
)
request = PlanTableScanRequest()
tasks = list(rest_scan_catalog.plan_scan(("db", "tbl"), request))
assert len(tasks) == 0
def test_plan_scan_cancelled(rest_scan_catalog: RestCatalog, requests_mock: Mocker) -> None:
requests_mock.post(
f"{TEST_URI}v1/namespaces/db/tables/tbl/plan",
json={"status": "cancelled"},
status_code=200,
)
request = PlanTableScanRequest()
with pytest.raises(RuntimeError, match="Received status: cancelled"):
list(rest_scan_catalog.plan_scan(("db", "tbl"), request))
def test_plan_scan_equality_deletes_not_supported(rest_scan_catalog: RestCatalog, requests_mock: Mocker) -> None:
file_one = _rest_data_file(file_path="s3://bucket/tbl/data/file1.parquet")
equality_delete = _rest_equality_delete_file(equality_ids=[1, 2])
requests_mock.post(
f"{TEST_URI}v1/namespaces/db/tables/tbl/plan",
json={
"status": "completed",
"plan-id": "plan-123",
"delete-files": [equality_delete],
"file-scan-tasks": [
{
"data-file": file_one,
"delete-file-references": [0],
}
],
"plan-tasks": [],
},
status_code=200,
)
request = PlanTableScanRequest()
with pytest.raises(NotImplementedError, match="PyIceberg does not yet support equality deletes"):
list(rest_scan_catalog.plan_scan(("db", "tbl"), request))