| ################################################################################ | |
| # 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 os | |
| import shutil | |
| import tempfile | |
| import unittest | |
| import pyarrow as pa | |
| import pytest | |
| pypaimon = pytest.importorskip("pypaimon") | |
| daft = pytest.importorskip("daft") | |
| from daft import col | |
| from pypaimon import CatalogFactory, Schema | |
| from pypaimon.daft import open_blob, read_blob, read_paimon | |
| from pypaimon.daft.daft_compat import has_file_range_reads | |
| @unittest.skipUnless(has_file_range_reads(), "installed Daft lacks File range reads") | |
| class DaftReadBlobTest(unittest.TestCase): | |
| """read_blob reads a blob File column to bytes via pypaimon's FileIO and must | |
| return exactly the same bytes as the native per-row File.open() path.""" | |
| N = 40 | |
| def setUp(self): | |
| self.tempdir = tempfile.mkdtemp() | |
| self.catalog_options = {"warehouse": os.path.join(self.tempdir, "wh")} | |
| self.catalog = CatalogFactory.create(self.catalog_options) | |
| self.catalog.create_database("default", True) | |
| self.table = "default.blob_read_test" | |
| pa_schema = pa.schema([("id", pa.int32()), ("content", pa.large_binary())]) | |
| self.catalog.create_table(self.table, Schema.from_pyarrow_schema( | |
| pa_schema, options={"row-tracking.enabled": "true", "data-evolution.enabled": "true"}), False) | |
| self.payloads = [bytes([i & 0xFF]) + os.urandom(1023) for i in range(self.N)] | |
| t = self.catalog.get_table(self.table) | |
| w = t.new_batch_write_builder().new_write() | |
| w.write_arrow(pa.Table.from_pydict( | |
| {"id": list(range(self.N)), "content": self.payloads}, schema=pa_schema)) | |
| t.new_batch_write_builder().new_commit().commit(w.prepare_commit()) | |
| w.close() | |
| def tearDown(self): | |
| shutil.rmtree(self.tempdir, ignore_errors=True) | |
| def test_read_blob_matches_file_open(self): | |
| df = read_paimon(self.table, self.catalog_options) | |
| # content is exposed as a lazy File (default read path unchanged) | |
| self.assertEqual(str(df.schema()["content"].dtype), "File[Unknown]") | |
| # new path: read_blob -> bytes | |
| out = df.select(col("id"), read_blob(col("content"), self.catalog_options, self.table) | |
| .alias("img")).collect().to_pydict() | |
| got = dict(zip(out["id"], out["img"])) | |
| for i in range(self.N): | |
| self.assertEqual(got[i], self.payloads[i]) | |
| # original path still works and yields identical bytes | |
| for r in read_paimon(self.table, self.catalog_options).select(col("id"), col("content")).to_pylist(): | |
| with r["content"].open() as h: | |
| self.assertEqual(h.read(), self.payloads[r["id"]]) | |
| def test_open_blob_full_read(self): | |
| rows = read_paimon(self.table, self.catalog_options).select( | |
| col("id"), col("content")).to_pylist() | |
| for r in rows: | |
| with open_blob(r["content"], self.catalog_options, self.table) as f: | |
| self.assertEqual(f.read(), self.payloads[r["id"]]) | |
| def test_open_blob_partial_read(self): | |
| rows = read_paimon(self.table, self.catalog_options).select( | |
| col("id"), col("content")).to_pylist() | |
| for r in rows: | |
| with open_blob(r["content"], self.catalog_options, self.table) as f: | |
| header = f.read(16) | |
| self.assertEqual(header, self.payloads[r["id"]][:16]) | |
| def test_open_blob_seek(self): | |
| r = read_paimon(self.table, self.catalog_options).select( | |
| col("id"), col("content")).to_pylist()[0] | |
| with open_blob(r["content"], self.catalog_options, self.table) as f: | |
| f.seek(100) | |
| self.assertEqual(f.tell(), 100) | |
| chunk = f.read(50) | |
| self.assertEqual(chunk, self.payloads[r["id"]][100:150]) | |
| def test_no_file_size_stat(self): | |
| """File.size() does a network stat; read_blob and open_blob must resolve | |
| ranges from embedded metadata (file._inner) and never call it.""" | |
| rows = read_paimon(self.table, self.catalog_options).select( | |
| col("id"), col("content")).to_pylist() | |
| file_cls = type(rows[0]["content"]) | |
| calls = {"n": 0} | |
| orig = file_cls.size | |
| def boom(self, *a, **k): | |
| calls["n"] += 1 | |
| raise AssertionError("File.size() should not be called") | |
| file_cls.size = boom | |
| try: | |
| with open_blob(rows[0]["content"], self.catalog_options, self.table) as f: | |
| self.assertEqual(f.read(), self.payloads[rows[0]["id"]]) | |
| out = read_paimon(self.table, self.catalog_options).select( | |
| col("id"), read_blob(col("content"), self.catalog_options, self.table).alias("img") | |
| ).collect().to_pydict() | |
| got = dict(zip(out["id"], out["img"])) | |
| for i in range(self.N): | |
| self.assertEqual(got[i], self.payloads[i]) | |
| finally: | |
| file_cls.size = orig | |
| self.assertEqual(calls["n"], 0) | |
| if __name__ == "__main__": | |
| unittest.main() |