fix(python): support legacy blob query bytes (#4947)

## Summary

- Let local plain queries over row-addressable legacy v1 and v2 blob
columns use `blob_mode="bytes"` without importing `pylance` through
pandas, batch, or Arrow conversion.
- Preserve aliases, filters, ordering, limits, null and empty payloads,
empty result schemas, and the caller's `_rowid` choice.
- Build the hidden row-id query separately so reusing an async builder
for lazy reads or non-blob projections cannot retain an automatic
`_rowid`.
- Keep byte reads closed for blobs inside lists, remote or namespace
pushdown, and query shapes whose result rows cannot be matched back to
the source. Blob v2 lazy auto-row-id behavior is unchanged.

Fixes #4645

## Validation

- 26 focused Python tests passed with the test subprocess blocking all
`lance` imports. The persisted fixture writes and reopens a storage-v2.1
table, then covers sync and async pandas, batches, and Arrow conversion
plus async builder reuse.
- The existing unsupported vector and hybrid reranker regressions now
assert the current eligibility diagnostic, and the legacy table-level
Arrow regression now asserts successful bytes, schema, values, and
hidden row IDs. Both focused tests passed.
- The runtime receipt records no installed `pylance` distribution and no
importable `lance` module. The Python-only change was verified with an
existing compatible 0.42.0b9 extension; the relevant query and fetch
APIs are unchanged between that source and this 0.42.0b10 base.
- Ruff format and lint pass for every changed Python file.
This commit is contained in:
Xuanwo
2026-11-11 10:08:13 +08:00
committed by GitHub
parent 4d22c0ce2b
commit 1e5b6deee3
10 changed files with 493 additions and 129 deletions
+12 -8
View File
@@ -348,7 +348,7 @@ def test_plain_scan_query_to_pandas_blob_bytes_projection(tmp_db, blob_schema):
@pytest.mark.parametrize("blob_mode", ["bytes", "descriptions"])
def test_plain_scan_query_to_pandas_blob_mode_does_not_collect_arrow(
def test_plain_scan_query_to_pandas_blob_mode_uses_native_path(
tmp_db, monkeypatch, blob_mode
):
pytest.importorskip("lance")
@@ -362,7 +362,8 @@ def test_plain_scan_query_to_pandas_blob_mode_does_not_collect_arrow(
def fail_to_arrow(*args, **kwargs):
raise AssertionError("to_arrow should not be called before native pandas")
monkeypatch.setattr(query, "to_arrow", fail_to_arrow)
if blob_mode == "descriptions":
monkeypatch.setattr(query, "to_arrow", fail_to_arrow)
df = query.to_pandas(blob_mode=blob_mode)
@@ -488,7 +489,7 @@ async def test_async_plain_scan_query_to_pandas_blob_bytes_projection(
@pytest.mark.asyncio
@pytest.mark.parametrize("blob_mode", ["bytes", "descriptions"])
async def test_async_plain_scan_query_to_pandas_blob_mode_does_not_collect_arrow(
async def test_async_plain_scan_query_to_pandas_blob_mode_uses_native_path(
tmp_db_async, monkeypatch, blob_mode
):
pytest.importorskip("lance")
@@ -502,7 +503,8 @@ async def test_async_plain_scan_query_to_pandas_blob_mode_does_not_collect_arrow
async def fail_to_arrow(*args, **kwargs):
raise AssertionError("to_arrow should not be called before native pandas")
monkeypatch.setattr(query, "to_arrow", fail_to_arrow)
if blob_mode == "descriptions":
monkeypatch.setattr(query, "to_arrow", fail_to_arrow)
df = await query.to_pandas(blob_mode=blob_mode)
@@ -541,12 +543,14 @@ def test_vector_query_to_pandas_legacy_blob_descriptions_and_bytes(tmp_db):
df = query.to_pandas(blob_mode="descriptions")
assert df["blob"].tolist() == [{"position": 0, "size": 3}]
with pytest.raises(ValueError, match="legacy blob columns"):
with pytest.raises(ValueError, match=r"\['blob'\].*local plain scans"):
query.to_pandas(blob_mode="bytes")
with pytest.raises(ValueError, match="legacy blob columns"):
with pytest.raises(ValueError, match=r"\['blob'\].*local plain scans"):
query.to_arrow(blob_mode="bytes")
with pytest.raises(ValueError, match="legacy blob columns"):
table.to_arrow(blob_mode="bytes")
table_arrow = table.to_arrow(blob_mode="bytes")
assert table_arrow.schema.field("blob").type == pa.large_binary()
assert "_rowid" not in table_arrow.column_names
assert table_arrow["blob"].to_pylist() == [b"one", b"two", b"three", b"four"]
assert table.to_pandas(blob_mode="bytes")["blob"].tolist() == [
b"one",
b"two",