Files
2026-07-13 13:17:40 +08:00

414 lines
15 KiB
Python

"""Tests for the Catalog connector API (ray.data.catalog)."""
import contextlib
import os
import pickle
from unittest import mock
import pyarrow as pa
import pyarrow.fs as pafs
import pyarrow.parquet as pq
import pytest
import ray
from ray.data.catalog import (
Catalog,
DatabricksUnityCatalog,
ReaderFormat,
ResolvedSource,
)
# conftest provides ray_start_regular_shared
from ray.data.tests.conftest import * # noqa: F401,F403
pytest.importorskip("databricks.sdk")
from databricks.sdk.service.catalog import ( # noqa: E402
AwsCredentials,
AzureUserDelegationSas,
DataSourceFormat,
GcpOauthToken,
GenerateTemporaryTableCredentialResponse,
TableInfo,
)
AWS_RESP = GenerateTemporaryTableCredentialResponse(
url="s3://bucket/path",
aws_temp_credentials=AwsCredentials(
access_key_id="AKIA",
secret_access_key="secret",
session_token="token",
),
)
def _mock_uc_sdk(*, data_source_format="DELTA", storage_location=None, creds=None):
"""Patch DatabricksUnityCatalog._workspace_client to return canned SDK responses.
Replaces the catalog's two SDK calls (``tables.get`` /
``generate_temporary_table_credentials``) so no Databricks workspace is hit.
Returns an already-started patcher; the caller is responsible for ``stop()``.
"""
creds = creds if creds is not None else AWS_RESP
table_info = TableInfo(
table_id="tid-123",
data_source_format=(
DataSourceFormat(data_source_format) if data_source_format else None
),
storage_location=storage_location,
)
client = mock.MagicMock()
client.tables.get.return_value = table_info
gen = client.temporary_table_credentials.generate_temporary_table_credentials
gen.return_value = creds
patcher = mock.patch.object(
DatabricksUnityCatalog, "_workspace_client", return_value=client
)
patcher.start()
return patcher
@pytest.fixture
def uc_catalog():
return DatabricksUnityCatalog(
url="https://dbc-test.cloud.databricks.com",
token="dapi-test",
region="us-west-2",
)
@pytest.fixture
def isolated_env(monkeypatch):
# resolve() exports vended creds to os.environ and may call ray.init.
# Isolate the env mutation and skip the real ray.init for unit tests.
monkeypatch.setattr("ray.is_initialized", lambda: True)
monkeypatch.setattr(os, "environ", dict(os.environ))
return os.environ
# ---------------------------------------------------------------------------
# DatabricksUnityCatalog.resolve
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("reader", [ReaderFormat.DELTA, ReaderFormat.PARQUET])
def test_resolve_storage_aws(uc_catalog, isolated_env, reader):
patcher = _mock_uc_sdk()
try:
resolved = uc_catalog.resolve("main.sales.txns", reader=reader)
finally:
patcher.stop()
assert resolved.path == "s3://bucket/path"
# AWS creds are exported to the environment for both readers.
assert isolated_env["AWS_ACCESS_KEY_ID"] == "AKIA"
assert isolated_env["AWS_SESSION_TOKEN"] == "token"
# Delta additionally gets an explicit S3FileSystem for the data scan;
# Parquet reads via the environment-configured filesystem.
if reader is ReaderFormat.DELTA:
assert isinstance(resolved.filesystem, pafs.S3FileSystem)
else:
assert resolved.filesystem is None
assert resolved.data_format is ReaderFormat.DELTA
def test_resolve_aws_requires_region(isolated_env):
catalog = DatabricksUnityCatalog(
url="https://h.databricks.com", token="t"
) # no region
patcher = _mock_uc_sdk()
try:
with pytest.raises(ValueError, match="region"):
catalog.resolve("main.sales.txns", reader=ReaderFormat.DELTA)
finally:
patcher.stop()
def test_resolve_initializes_ray_with_runtime_env(uc_catalog, monkeypatch):
# When Ray isn't running, vended creds are propagated via runtime_env.
monkeypatch.setattr(os, "environ", dict(os.environ))
monkeypatch.setattr("ray.is_initialized", lambda: False)
init_kwargs = {}
monkeypatch.setattr("ray.init", lambda **kw: init_kwargs.update(kw))
patcher = _mock_uc_sdk()
try:
uc_catalog.resolve("main.sales.txns", reader=ReaderFormat.PARQUET)
finally:
patcher.stop()
env_vars = init_kwargs["runtime_env"]["env_vars"]
assert env_vars["AWS_ACCESS_KEY_ID"] == "AKIA"
assert env_vars["AWS_SESSION_TOKEN"] == "token"
def test_resolve_retries_once_on_401(uc_catalog, isolated_env):
# On a 401, the provider is invalidated and the SDK call is retried once
# (mirrors the old request_with_401_retry behavior).
from databricks.sdk.errors import Unauthenticated
client = mock.MagicMock()
client.tables.get.side_effect = [
Unauthenticated("expired"),
TableInfo(
table_id="tid-123",
data_source_format=DataSourceFormat("DELTA"),
storage_location="s3://bucket/path",
),
]
gen = client.temporary_table_credentials.generate_temporary_table_credentials
gen.return_value = AWS_RESP
with mock.patch.object(
DatabricksUnityCatalog, "_workspace_client", return_value=client
), mock.patch.object(uc_catalog._provider, "invalidate") as invalidate:
resolved = uc_catalog.resolve("main.sales.txns", reader=ReaderFormat.PARQUET)
invalidate.assert_called_once()
assert client.tables.get.call_count == 2
assert resolved.path == "s3://bucket/path"
def test_resolve_iceberg(uc_catalog):
# Iceberg resolution does not hit the credential-vending REST endpoints.
resolved = uc_catalog.resolve("main.sales.txns", reader=ReaderFormat.ICEBERG)
assert resolved.path is None
assert resolved.filesystem is None
assert resolved.data_format is ReaderFormat.ICEBERG
ckw = resolved.catalog_kwargs
assert ckw["type"] == "rest"
assert ckw["uri"] == (
"https://dbc-test.cloud.databricks.com/api/2.1/unity-catalog/iceberg-rest"
)
assert ckw["token"] == "dapi-test"
assert ckw["header.X-Iceberg-Access-Delegation"] == "vended-credentials"
def test_resolve_azure_sets_env(isolated_env):
catalog = DatabricksUnityCatalog(url="https://h.databricks.com", token="t")
azure_resp = GenerateTemporaryTableCredentialResponse(
url="abfss://c@acct.dfs.core.windows.net/path",
azure_user_delegation_sas=AzureUserDelegationSas(sas_token="sv=2021&sig=abc"),
)
patcher = _mock_uc_sdk(creds=azure_resp)
try:
resolved = catalog.resolve("main.sales.txns", reader=ReaderFormat.DELTA)
finally:
patcher.stop()
# Azure creds flow via the environment (read by both pyarrow and the
# deltalake object_store log reader); no filesystem/storage_options.
assert resolved.filesystem is None
assert resolved.storage_options is None
assert isolated_env["AZURE_STORAGE_SAS_TOKEN"] == "sv=2021&sig=abc"
def test_resolve_azure_strips_leading_question_mark(isolated_env):
# UC may return the SAS as a full query string ("?sv=..."); the leading "?"
# must be stripped for AZURE_STORAGE_SAS_TOKEN.
catalog = DatabricksUnityCatalog(url="https://h.databricks.com", token="t")
azure_resp = GenerateTemporaryTableCredentialResponse(
url="abfss://c@acct.dfs.core.windows.net/path",
azure_user_delegation_sas=AzureUserDelegationSas(sas_token="?sv=2021&sig=abc"),
)
patcher = _mock_uc_sdk(creds=azure_resp)
try:
catalog.resolve("main.sales.txns", reader=ReaderFormat.DELTA)
finally:
patcher.stop()
assert isolated_env["AZURE_STORAGE_SAS_TOKEN"] == "sv=2021&sig=abc"
def _gcp_resp():
return GenerateTemporaryTableCredentialResponse(
url="gs://bucket/path",
gcp_oauth_token=GcpOauthToken(oauth_token="ya29.tok"),
expiration_time=4102444800000, # far-future epoch ms
)
def test_resolve_gcp_parquet_builds_filesystem(uc_catalog, isolated_env):
# GCP vends an OAuth token (not a service-account JSON); for Parquet it rides
# on an explicit GcsFileSystem (the data scan), never an env var.
patcher = _mock_uc_sdk(creds=_gcp_resp(), storage_location="gs://bucket/path")
try:
resolved = uc_catalog.resolve("main.sales.txns", reader=ReaderFormat.PARQUET)
finally:
patcher.stop()
assert isinstance(resolved.filesystem, pafs.GcsFileSystem)
assert "GOOGLE_APPLICATION_CREDENTIALS" not in isolated_env
def test_resolve_gcp_delta_raises(uc_catalog, isolated_env):
# deltalake's object_store can't use a GCS OAuth token, so GCP + Delta is
# rejected up front with an actionable error instead of failing deep in the
# log read against the GCE metadata server.
patcher = _mock_uc_sdk(creds=_gcp_resp(), storage_location="gs://bucket/path")
try:
with pytest.raises(RuntimeError, match="GCP-backed Delta"):
uc_catalog.resolve("main.sales.txns", reader=ReaderFormat.DELTA)
finally:
patcher.stop()
# ---------------------------------------------------------------------------
# Reader integration via a fake catalog (no network)
# ---------------------------------------------------------------------------
class _FakeCatalog(Catalog):
"""Returns a pre-baked ResolvedSource; records the reader it was asked for."""
def __init__(self, resolved):
self._resolved = resolved
self.calls = []
def resolve(self, table, *, reader):
self.calls.append((table, reader))
return self._resolved
def test_read_parquet_with_catalog(ray_start_regular_shared, tmp_path):
path = str(tmp_path / "data.parquet")
pq.write_table(pa.table({"id": [1, 2, 3]}), path)
catalog = _FakeCatalog(ResolvedSource(path=path))
ds = ray.data.read_parquet("main.db.tbl", catalog=catalog)
assert sorted(r["id"] for r in ds.take_all()) == [1, 2, 3]
assert catalog.calls == [("main.db.tbl", ReaderFormat.PARQUET)]
@pytest.mark.parametrize("reader", ["parquet", "delta"])
def test_catalog_filesystem_overrides_with_warning(reader):
# The catalog-resolved filesystem overrides a user-supplied one, but warns.
# The warning fires at the top of the reader body; suppress any downstream
# failure from the (intentionally unreachable) s3 path.
if reader == "delta":
pytest.importorskip("deltalake")
fs = pafs.S3FileSystem(
access_key="AKIA", secret_key="secret", session_token="t", region="us-west-2"
)
catalog = _FakeCatalog(ResolvedSource(path="s3://b/p", filesystem=fs))
read_fn = ray.data.read_parquet if reader == "parquet" else ray.data.read_delta
with mock.patch.object(ray.data.read_api.logger, "warning") as warn:
with contextlib.suppress(Exception):
read_fn("main.db.tbl", catalog=catalog, filesystem=pafs.LocalFileSystem())
assert any(
"Overriding the provided `filesystem`" in str(c) for c in warn.call_args_list
)
def test_read_delta_with_catalog(ray_start_regular_shared, tmp_path):
deltalake = pytest.importorskip("deltalake")
path = str(tmp_path / "delta-table")
deltalake.write_deltalake(path, pa.table({"id": [1, 2, 3]}))
catalog = _FakeCatalog(ResolvedSource(path=path))
ds = ray.data.read_delta("main.db.tbl", catalog=catalog)
assert sorted(r["id"] for r in ds.take_all()) == [1, 2, 3]
assert catalog.calls == [("main.db.tbl", ReaderFormat.DELTA)]
def test_read_iceberg_uses_catalog_resolved_kwargs():
catalog = _FakeCatalog(
ResolvedSource(catalog_kwargs={"type": "rest", "uri": "u", "token": "tk"})
)
with mock.patch(
"ray.data._internal.datasource.iceberg_datasource.IcebergDatasource"
) as ds_cls, mock.patch("ray.data.read_api.read_datasource"):
ray.data.read_iceberg(table_identifier="main.db.tbl", catalog=catalog)
_, kwargs = ds_cls.call_args
assert kwargs["catalog_kwargs"] == {"type": "rest", "uri": "u", "token": "tk"}
assert catalog.calls == [("main.db.tbl", ReaderFormat.ICEBERG)]
def test_read_iceberg_explicit_catalog_kwargs_take_precedence():
# When both catalog and catalog_kwargs are given, catalog is ignored.
catalog = _FakeCatalog(ResolvedSource(catalog_kwargs={"type": "rest", "uri": "u"}))
with mock.patch(
"ray.data._internal.datasource.iceberg_datasource.IcebergDatasource"
) as ds_cls, mock.patch("ray.data.read_api.read_datasource"):
ray.data.read_iceberg(
table_identifier="main.db.tbl",
catalog=catalog,
catalog_kwargs={"type": "sql", "uri": "explicit"},
)
_, kwargs = ds_cls.call_args
assert kwargs["catalog_kwargs"] == {"type": "sql", "uri": "explicit"}
assert catalog.calls == [] # catalog was not consulted
# ---------------------------------------------------------------------------
# Serialization
# ---------------------------------------------------------------------------
def test_unity_catalog_is_picklable(uc_catalog):
restored = pickle.loads(pickle.dumps(uc_catalog))
assert isinstance(restored, DatabricksUnityCatalog)
assert restored.region == "us-west-2"
def test_resolved_source_with_filesystem_is_picklable():
fs = pafs.S3FileSystem(
access_key="AKIA", secret_key="secret", session_token="t", region="us-west-2"
)
src = ResolvedSource(path="s3://b/p", filesystem=fs, data_format=ReaderFormat.DELTA)
restored = pickle.loads(pickle.dumps(src))
assert restored.path == "s3://b/p"
assert isinstance(restored.filesystem, pafs.S3FileSystem)
assert restored.data_format is ReaderFormat.DELTA
# ---------------------------------------------------------------------------
# Deprecated read_unity_catalog shim
# ---------------------------------------------------------------------------
def test_read_unity_catalog_deprecation_delegates():
with mock.patch("ray.data.read_api.read_delta") as read_delta:
with pytest.warns(DeprecationWarning, match="read_unity_catalog"):
ray.data.read_unity_catalog(
table="main.db.tbl",
url="https://h.databricks.com",
token="t",
data_format="delta",
)
read_delta.assert_called_once()
_, kwargs = read_delta.call_args
assert isinstance(kwargs["catalog"], DatabricksUnityCatalog)
def test_read_unity_catalog_infers_format_from_cred_url():
# Metadata omits both data_source_format and storage_location; the vended
# credential URL extension must still identify the format.
creds = GenerateTemporaryTableCredentialResponse(url="s3://bucket/data.parquet")
patcher = _mock_uc_sdk(data_source_format=None, storage_location=None, creds=creds)
try:
with mock.patch("ray.data.read_api.read_parquet") as read_parquet, pytest.warns(
DeprecationWarning
):
ray.data.read_unity_catalog(
table="main.db.tbl", url="https://h.databricks.com", token="t"
)
finally:
patcher.stop()
read_parquet.assert_called_once()
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))