blob: 1bb358c3d8d55e29dfd44a2c660708195a9db5b0 [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.
################################################################################
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()