| ################################################################################ |
| # 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 struct |
| import unittest |
| |
| import pyarrow as pa |
| import pytest |
| |
| pypaimon = pytest.importorskip("pypaimon") |
| daft = pytest.importorskip("daft") |
| |
| from daft.datatype import DataType |
| from daft.io import IOConfig, S3Config |
| |
| from pypaimon.daft.daft_blob import ( |
| blob_array_column_to_file_array, |
| blob_column_to_file_array, |
| blob_map_column_to_file_array, |
| ) |
| from pypaimon.daft.daft_compat import ( |
| file_range_position_field, |
| file_range_size_field, |
| has_file_range_reads, |
| ) |
| from pypaimon.daft.daft_io_config import ( |
| _convert_paimon_catalog_options_to_file_io_config, |
| serialize_io_config, |
| ) |
| from pypaimon.daft.daft_datasource import _PaimonPKSplitTask |
| from pypaimon.table.row.blob import BlobDescriptor |
| |
| |
| def _descriptor_column(specs): |
| """large_binary column of serialized BlobDescriptors (None for gaps).""" |
| out = [None if s is None else BlobDescriptor(*s).serialize() for s in specs] |
| return pa.array(out, type=pa.large_binary()) |
| |
| |
| def _descriptor_bytes(version=BlobDescriptor.CURRENT_VERSION, |
| magic=BlobDescriptor.MAGIC, |
| uri=b"oss://b/k", |
| offset=0, |
| length=1): |
| data = bytes([version]) |
| if version > 1: |
| data += struct.pack("<Q", magic) |
| data += struct.pack("<I", len(uri)) |
| data += uri |
| if offset is not None: |
| data += struct.pack("<q", offset) |
| if length is not None: |
| data += struct.pack("<q", length) |
| return data |
| |
| |
| class BlobColumnToFileArrayTest(unittest.TestCase): |
| # Pure config/arrow tests run on any installed Daft; only File-cast needs file ranges. |
| |
| def test_blob_column_to_file_array(self): |
| # io_config is null without creds; embedded only for valid (non-null) rows with creds. |
| col = _descriptor_column([("oss://b/k1", 0, 5), None, ("oss://b/k2", 8, 9)]) |
| bare = blob_column_to_file_array(col) |
| self.assertEqual(bare.field("url").to_pylist(), ["oss://b/k1", None, "oss://b/k2"]) |
| self.assertEqual(bare.field(file_range_position_field()).to_pylist(), [0, None, 8]) |
| self.assertEqual(bare.field(file_range_size_field()).to_pylist(), [5, None, 9]) |
| self.assertEqual(bare.field("io_config").null_count, 3) |
| |
| blob = serialize_io_config(IOConfig(s3=S3Config(key_id="AK", access_key="SK"))) |
| self.assertEqual(blob_column_to_file_array(col, blob).field("io_config").to_pylist(), |
| [blob, None, blob]) |
| |
| def test_blob_array_column_to_file_array(self): |
| descriptor_a = BlobDescriptor("oss://b/a", 1, 2).serialize() |
| descriptor_b = BlobDescriptor("oss://b/b", 3, 4).serialize() |
| column = pa.array( |
| [[descriptor_a, None, descriptor_b], None, []], |
| type=pa.list_(pa.large_binary()), |
| ) |
| |
| converted = blob_array_column_to_file_array(column) |
| |
| files = converted[0].as_py() |
| self.assertEqual( |
| [None if value is None else value["url"] for value in files], |
| ["oss://b/a", None, "oss://b/b"], |
| ) |
| self.assertEqual([ |
| None if value is None else value[file_range_position_field()] |
| for value in files |
| ], [1, None, 3]) |
| self.assertFalse(converted[1].is_valid) |
| self.assertEqual(converted[2].as_py(), []) |
| |
| def test_blob_map_column_to_file_array(self): |
| descriptor_a = BlobDescriptor("oss://b/a", 1, 2).serialize() |
| descriptor_b = BlobDescriptor("oss://b/b", 3, 4).serialize() |
| io_config = b"serialized-io-config" |
| column = pa.array( |
| [ |
| [(1, descriptor_a), (1, None), (2, descriptor_b)], |
| None, |
| [], |
| ], |
| type=pa.map_(pa.int32(), pa.large_binary()), |
| ) |
| |
| converted = blob_map_column_to_file_array(column, io_config) |
| |
| files = converted[0].as_py() |
| self.assertEqual([key for key, _ in files], [1, 1, 2]) |
| self.assertEqual( |
| [None if value is None else value["url"] for _, value in files], |
| ["oss://b/a", None, "oss://b/b"], |
| ) |
| self.assertEqual( |
| [ |
| None if value is None else value[file_range_position_field()] |
| for _, value in files |
| ], |
| [1, None, 3], |
| ) |
| self.assertEqual( |
| [None if value is None else value["io_config"] for _, value in files], |
| [io_config, None, io_config], |
| ) |
| self.assertFalse(converted[1].is_valid) |
| self.assertEqual(converted[2].as_py(), []) |
| |
| def test_malformed_blob_descriptor_raises_value_error(self): |
| cases = [ |
| ("empty", b"", "too short"), |
| ("unknown version", |
| _descriptor_bytes(version=BlobDescriptor.CURRENT_VERSION + 1), |
| "version"), |
| ("bad magic", _descriptor_bytes(magic=0), "magic header"), |
| ("truncated uri", |
| bytes([BlobDescriptor.CURRENT_VERSION]) |
| + struct.pack("<Q", BlobDescriptor.MAGIC) |
| + struct.pack("<I", 5) |
| + b"ab", |
| "URI length exceeds data size"), |
| ("truncated offset", _descriptor_bytes(uri=b"", offset=None, length=None) + b"\x00" * 7, |
| "missing offset/length"), |
| ("truncated length", _descriptor_bytes(uri=b"", length=None) + b"\x00" * 7, |
| "missing offset/length"), |
| ("invalid utf8 uri", _descriptor_bytes(uri=b"\xff"), "Invalid BlobDescriptor data"), |
| ] |
| |
| for name, raw, message in cases: |
| with self.subTest(name=name): |
| with self.assertRaisesRegex(ValueError, message): |
| blob_column_to_file_array(pa.array([raw], type=pa.large_binary())) |
| |
| def test_serialize_io_config_roundtrips(self): |
| s3 = IOConfig(s3=S3Config(key_id="AK", access_key="SK", session_token="TOK")) |
| self.assertEqual(IOConfig._from_serialized(serialize_io_config(s3)).s3.session_token, "TOK") |
| # OSS uses Daft's OpenDAL backend, which serializes differently from S3Config. |
| oss = {"access_key_id": "AK", "endpoint": "https://oss-test.example.com", "bucket": "b"} |
| cfg = IOConfig(opendal_backends={"oss": oss}) |
| self.assertEqual(IOConfig._from_serialized(serialize_io_config(cfg)).opendal_backends["oss"], oss) |
| |
| def test_file_io_config_routes_oss_through_s3(self): |
| # OSS -> Daft S3 client (File.open() over OpenDAL/OSS is broken on some Daft builds). |
| cfg = _convert_paimon_catalog_options_to_file_io_config({ |
| "warehouse": "oss://b", "fs.oss.endpoint": "oss-test.example.com", |
| "fs.oss.region": "test-region", "fs.oss.accessKeyId": "AK", |
| "fs.oss.accessKeySecret": "SK", "fs.oss.securityToken": "TOK", |
| }) |
| self.assertEqual(cfg.s3.key_id, "AK") |
| self.assertEqual(cfg.s3.endpoint_url, "https://oss-test.example.com") |
| self.assertTrue(cfg.s3.force_virtual_addressing) |
| self.assertEqual(dict(cfg.protocol_aliases)["oss"], "s3") |
| # No credentials: None when required; oss->s3 alias otherwise (env/instance creds). |
| self.assertIsNone(_convert_paimon_catalog_options_to_file_io_config({"warehouse": "oss://b"})) |
| env = _convert_paimon_catalog_options_to_file_io_config({"warehouse": "oss://b"}, require_credentials=False) |
| self.assertEqual(dict(env.protocol_aliases)["oss"], "s3") |
| |
| def test_explicit_io_config_used_when_no_derivable_credentials(self): |
| # Explicit io_config must reach blob File columns when no complete creds are derivable. |
| explicit = serialize_io_config(IOConfig(s3=S3Config(key_id="USERKEY", access_key="SK"))) |
| |
| def blob_key(catalog_options): |
| task = _PaimonPKSplitTask(catalog_options, None, None, {}, None, None, |
| blob_column_names={"x"}, explicit_io_config_bytes=explicit) |
| return IOConfig._from_serialized(task._blob_io_config_bytes(None)).s3.key_id |
| |
| self.assertEqual(blob_key({}), "USERKEY") |
| for opts in ( |
| {"warehouse": "oss://b", "fs.oss.endpoint": "oss-test.example.com"}, |
| {"warehouse": "oss://b", "fs.oss.accessKeyId": "PARTIAL"}, # no secret |
| {"warehouse": "s3://b", "fs.s3.endpoint": "https://s3.example.com"}, |
| {"warehouse": "s3a://b", "fs.s3.accessKeyId": "PARTIAL"}, # no secret |
| ): |
| self.assertEqual(blob_key(opts), "USERKEY") |
| |
| def test_with_oss_alias_augments_explicit_config(self): |
| # s3 config -> add oss->s3 alias + virtual-hosted, keep creds. |
| from pypaimon.daft.daft_io_config import _with_oss_alias |
| s3 = _with_oss_alias(IOConfig(s3=S3Config(key_id="AK", access_key="SK"))) |
| self.assertEqual(dict(s3.protocol_aliases)["oss"], "s3") |
| self.assertTrue(s3.s3.force_virtual_addressing) |
| self.assertEqual(s3.s3.key_id, "AK") |
| # opendal OSS config -> converted to S3Config + alias. |
| od = _with_oss_alias(IOConfig(opendal_backends={"oss": { |
| "access_key_id": "AK", "access_key_secret": "SK", "endpoint": "https://oss-test.example.com"}})) |
| self.assertEqual(dict(od.protocol_aliases)["oss"], "s3") |
| self.assertEqual(od.s3.key_id, "AK") |
| self.assertEqual(od.s3.endpoint_url, "https://oss-test.example.com") |
| |
| def test_explicit_io_config_gets_oss_alias_for_oss_blobs(self): |
| # An oss:// table's explicit-fallback io_config gets the s3 alias so File.open() works. |
| class _OssTable: |
| table_path = "oss://b/db.db/t" |
| |
| explicit = serialize_io_config(IOConfig(s3=S3Config(key_id="USERKEY", access_key="SK"))) |
| task = _PaimonPKSplitTask({}, None, None, {}, None, None, |
| blob_column_names={"x"}, explicit_io_config_bytes=explicit) |
| cfg = IOConfig._from_serialized(task._blob_io_config_bytes(_OssTable())) |
| self.assertEqual(cfg.s3.key_id, "USERKEY") |
| self.assertEqual(dict(cfg.protocol_aliases)["oss"], "s3") |
| |
| def test_oss_partial_credentials_cleared_for_env(self): |
| # OSS half key pair (no secret): keep only the oss->s3 alias, drop the partial key. |
| opts = {"warehouse": "oss://b", "fs.oss.endpoint": "oss-test.example.com", |
| "fs.oss.accessKeyId": "PARTIAL"} |
| self.assertIsNone(_convert_paimon_catalog_options_to_file_io_config(opts)) |
| cfg = _convert_paimon_catalog_options_to_file_io_config(opts, require_credentials=False) |
| self.assertIsNone(cfg.s3.key_id) |
| self.assertEqual(dict(cfg.protocol_aliases)["oss"], "s3") |
| |
| def test_non_oss_endpoint_kept_for_env_without_credentials(self): |
| # Custom-endpoint S3 (MinIO/Ceph), no key pair: None when required; env keeps endpoint, no creds. |
| opts = {"warehouse": "s3a://b", "fs.s3.endpoint": "https://minio.example.com"} |
| self.assertIsNone(_convert_paimon_catalog_options_to_file_io_config(opts)) |
| env = _convert_paimon_catalog_options_to_file_io_config(opts, require_credentials=False) |
| self.assertEqual(env.s3.endpoint_url, "https://minio.example.com") |
| self.assertIsNone(env.s3.key_id) |
| # A half key is dropped; the endpoint is still kept. |
| partial = _convert_paimon_catalog_options_to_file_io_config( |
| {"warehouse": "s3://b", "fs.s3.endpoint": "https://minio.example.com", |
| "fs.s3.accessKeyId": "PARTIAL"}, require_credentials=False) |
| self.assertEqual(partial.s3.endpoint_url, "https://minio.example.com") |
| self.assertIsNone(partial.s3.key_id) |
| |
| @pytest.mark.skipif(not has_file_range_reads(), reason="daft >= 0.7.11 required for File range metadata") |
| def test_cast_to_file_reconstructs_io_config(self): |
| # The crux: embedded bytes must survive the cast to DataType.file(). |
| blob = serialize_io_config(IOConfig(s3=S3Config(key_id="AK", region_name="test-region"))) |
| arr = blob_column_to_file_array(_descriptor_column([("s3://b/k", 0, 4)]), blob) |
| df = daft.from_arrow(pa.table({"f": arr})) |
| df = df.with_column("f", df["f"].cast(DataType.file())) |
| restored = IOConfig._from_serialized(df.to_arrow().column("f")[0].as_py()["io_config"]) |
| self.assertEqual(restored.s3.key_id, "AK") |
| self.assertEqual(restored.s3.region_name, "test-region") |
| |
| |
| if __name__ == "__main__": |
| unittest.main() |