414 lines
15 KiB
Python
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__]))
|