fix(python): handle empty batch iterables with a schema (#4538)

## Summary

`create_table(data=iter(()), schema=...)` currently raises
`RuntimeError: coroutine raised StopIteration`, although an empty list
with the same schema works. A tuple of batches also fails because the
helper calls `next()` before obtaining an iterator.

Normalize the iterable once and use the supplied schema for an empty
reader. Without a schema, raise a clear `ValueError`. Nonempty schema
inference and batch casting are unchanged.

Tests cover synchronous/asynchronous creation, empty/nonempty iterators
and tuples, and schema-aware `add()` sanitization. The default
schema-less `add()` rejection is deliberately unchanged.

## Validation

- Built the current Rust/PyO3 extension from base `9f37f392` with the
repository's `ci` profile; both Python-only arms use the same verified
extension and locked Python dependency versions.
- Complete `python/tests/test_table.py` +
`python/tests/test_scannable.py`: **268 passed, 0 skipped**. The
unchanged production code with identical tests has **24 failures / 244
passes**.
- Both arms used four-CPU affinity. An initial unrestricted-affinity run
exhausted thread creation under the private memory limit; that failed
attempt is not counted as a pass.
- Package-wide Ruff check/format pass. Repository-wide checks still
report pre-existing findings in unchanged `docs/web-tests`; those files
are not modified here.

AI-assisted change, reproduced against unchanged production code and
validated with a native source build. This does not claim the full
repository/platform matrix or a performance improvement.

Signed-off-by: Xucheng Zhou <aden1350@outlook.com>
This commit is contained in:
adenzhou1350
2026-10-23 03:19:06 +08:00
committed by GitHub
parent b6d15558ec
commit 9f4cd1618b
2 changed files with 87 additions and 3 deletions
+12 -3
View File
@@ -469,7 +469,7 @@ def _into_pyarrow_reader(
):
return data.collect().to_arrow().to_reader()
elif isinstance(data, Iterable):
return _iterator_to_reader(data)
return _iterator_to_reader(data, schema)
else:
raise TypeError(
f"Unknown data type {type(data)}. "
@@ -479,10 +479,19 @@ def _into_pyarrow_reader(
)
def _iterator_to_reader(data: Iterable) -> pa.RecordBatchReader:
def _iterator_to_reader(
data: Iterable, schema: Optional[pa.Schema] = None
) -> pa.RecordBatchReader:
# Each batch is treated as it's own reader, mainly so we can
# re-use the _into_pyarrow_reader logic.
first = _into_pyarrow_reader(next(data))
data = iter(data)
try:
first_batch = next(data)
except StopIteration:
if schema is None:
raise ValueError("Cannot create table from empty iterable without a schema")
return pa.RecordBatchReader.from_batches(schema, [])
first = _into_pyarrow_reader(first_batch)
schema = first.schema
def gen():
+75
View File
@@ -4847,6 +4847,81 @@ def test_stats(mem_db: DBConnection):
assert stats_with_index["fragment_stats"]["num_fragments"] == 1
@pytest.mark.parametrize("iterable_type", [iter, tuple])
@pytest.mark.parametrize("empty", [False, True])
@pytest.mark.parametrize("with_schema", [False, True])
def test_create_table_recordbatch_iterable(
mem_db: DBConnection, iterable_type, empty, with_schema
):
schema = pa.schema([pa.field("id", pa.int64())])
batches = [] if empty else [pa.RecordBatch.from_pydict({"id": [1]}, schema)]
data = iterable_type(batches)
if empty and not with_schema:
with pytest.raises(ValueError, match="empty iterable without a schema"):
mem_db.create_table("test", data=data)
return
table = mem_db.create_table(
"test", data=data, schema=schema if with_schema else None
)
assert table.schema == schema
assert table.to_arrow().to_pylist() == ([] if empty else [{"id": 1}])
@pytest.mark.asyncio
@pytest.mark.parametrize("iterable_type", [iter, tuple])
@pytest.mark.parametrize("empty", [False, True])
@pytest.mark.parametrize("with_schema", [False, True])
async def test_create_table_recordbatch_iterable_async(
mem_db_async: AsyncConnection, iterable_type, empty, with_schema
):
schema = pa.schema([pa.field("id", pa.int64())])
batches = [] if empty else [pa.RecordBatch.from_pydict({"id": [1]}, schema)]
data = iterable_type(batches)
if empty and not with_schema:
with pytest.raises(ValueError, match="empty iterable without a schema"):
await mem_db_async.create_table("test", data=data)
return
table = await mem_db_async.create_table(
"test", data=data, schema=schema if with_schema else None
)
assert await table.schema() == schema
assert (await table.to_arrow()).to_pylist() == ([] if empty else [{"id": 1}])
@pytest.mark.parametrize("iterable_type", [iter, tuple])
@pytest.mark.parametrize("on_bad_vectors", ["error", "drop", "fill", "null"])
def test_add_empty_recordbatch_iterable(
mem_db: DBConnection, iterable_type, on_bad_vectors
):
table = mem_db.create_table("test", [{"id": 1}])
if on_bad_vectors == "error":
# The default scannable path still requires an input schema. This
# fix does not change its validation policy for schema-less input.
with pytest.raises(ValueError, match="empty iterator"):
table.add(iterable_type([]), on_bad_vectors=on_bad_vectors)
else:
# Sanitization has the existing table schema available.
table.add(iterable_type([]), on_bad_vectors=on_bad_vectors)
assert table.to_arrow().to_pylist() == [{"id": 1}]
@pytest.mark.asyncio
@pytest.mark.parametrize("iterable_type", [iter, tuple])
@pytest.mark.parametrize("on_bad_vectors", ["error", "drop", "fill", "null"])
async def test_add_empty_recordbatch_iterable_async(
mem_db_async: AsyncConnection, iterable_type, on_bad_vectors
):
table = await mem_db_async.create_table("test", [{"id": 1}])
if on_bad_vectors == "error":
with pytest.raises(ValueError, match="empty iterator"):
await table.add(iterable_type([]), on_bad_vectors=on_bad_vectors)
else:
await table.add(iterable_type([]), on_bad_vectors=on_bad_vectors)
assert (await table.to_arrow()).to_pylist() == [{"id": 1}]
def test_create_table_empty_list_with_schema(mem_db: DBConnection):
"""Test creating table with empty list data and schema