diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30abc7c..1592bd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,13 +66,13 @@ jobs: - name: Install SemaPact Databricks extra from project metadata run: uv pip install --python .venv/bin/python -e ".[databricks]" pytest - - name: Verify Databricks SDK and existing Unity import boundaries + - name: Verify Databricks SDK, client factory, and existing Unity import boundaries run: >- .venv/bin/python -c - "from databricks.sdk import WorkspaceClient; from databricks.sdk.service.catalog import TableInfo; from semapact.importers.unity_importer import import_unity_contract; print(WorkspaceClient.__name__, TableInfo.__name__, import_unity_contract.__name__)" + "import inspect; from databricks.sdk import WorkspaceClient; from databricks.sdk.service.catalog import TableInfo; from semapact.importers.unity_importer import import_unity_contract; from semapact.platforms.databricks import create_databricks_workspace_client; params = inspect.signature(WorkspaceClient).parameters; assert {'host', 'token', 'profile'} <= set(params); print(WorkspaceClient.__name__, TableInfo.__name__, import_unity_contract.__name__, create_databricks_workspace_client.__name__)" - - name: Run Databricks observation tests with official SDK installed - run: .venv/bin/python -m pytest tests/test_observation_databricks.py + - name: Run Databricks platform-boundary tests with official SDK installed + run: .venv/bin/python -m pytest tests/test_databricks_client.py tests/test_observation_databricks.py coverage: runs-on: ubuntu-latest diff --git a/semapact/platforms/__init__.py b/semapact/platforms/__init__.py new file mode 100644 index 0000000..fffc453 --- /dev/null +++ b/semapact/platforms/__init__.py @@ -0,0 +1 @@ +"""External platform access boundaries for SemaPact.""" diff --git a/semapact/platforms/databricks/__init__.py b/semapact/platforms/databricks/__init__.py new file mode 100644 index 0000000..2df926e --- /dev/null +++ b/semapact/platforms/databricks/__init__.py @@ -0,0 +1,5 @@ +"""Databricks platform-access helpers.""" + +from semapact.platforms.databricks.client import create_databricks_workspace_client + +__all__ = ["create_databricks_workspace_client"] diff --git a/semapact/platforms/databricks/client.py b/semapact/platforms/databricks/client.py new file mode 100644 index 0000000..913a71d --- /dev/null +++ b/semapact/platforms/databricks/client.py @@ -0,0 +1,68 @@ +"""Databricks authenticated-client construction boundary. + +This module owns construction of an initialized Databricks ``WorkspaceClient``. +Downstream platform capabilities such as observation consume the resulting +client and remain independent from the authentication mechanism used to create +it. + +SemaPact forwards only the connection/authentication hints supplied by the +caller. The Databricks SDK remains responsible for selecting and validating the +authentication mechanism, including its default/unified authentication chain. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from databricks.sdk import WorkspaceClient + + +def create_databricks_workspace_client( + *, + workspace_url: str | None = None, + token: str | None = None, + profile: str | None = None, +) -> WorkspaceClient: + """Create a Databricks SDK client from the auth hints the caller has. + + SemaPact does not choose an authentication provider. Non-empty values are + forwarded to ``WorkspaceClient`` and omitted values are left for the SDK to + resolve from its standard configuration/authentication chain. Calling this + function with no arguments is therefore equivalent to ``WorkspaceClient()``. + + The function performs no credential logging or serialization. + """ + kwargs: dict[str, str] = {} + + host = _clean_optional(workspace_url) + if host: + kwargs["host"] = host.rstrip("/") + + if token and token.strip(): + kwargs["token"] = token + + selected_profile = _clean_optional(profile) + if selected_profile: + kwargs["profile"] = selected_profile + + workspace_client_cls = _load_workspace_client_class() + return workspace_client_cls(**kwargs) + + +def _clean_optional(value: str | None) -> str | None: + if value is None: + return None + cleaned = value.strip() + return cleaned or None + + +def _load_workspace_client_class() -> Any: + """Load the optional Databricks SDK only when client construction is used.""" + try: + from databricks.sdk import WorkspaceClient + except ImportError as exc: + raise RuntimeError( + 'Databricks support requires the optional extra: pip install "semapact[databricks]"' + ) from exc + return WorkspaceClient diff --git a/tests/test_databricks_client.py b/tests/test_databricks_client.py new file mode 100644 index 0000000..dfd3190 --- /dev/null +++ b/tests/test_databricks_client.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import pytest + +from semapact.platforms.databricks import client as databricks_client +from semapact.platforms.databricks.client import create_databricks_workspace_client + + +class _FakeWorkspaceClient: + def __init__(self, **kwargs: str) -> None: + self.kwargs = kwargs + + +def _use_fake_workspace_client(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + databricks_client, + "_load_workspace_client_class", + lambda: _FakeWorkspaceClient, + ) + + +def test_create_databricks_workspace_client_forwards_available_hints( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_fake_workspace_client(monkeypatch) + + client = create_databricks_workspace_client( + workspace_url=" https://adb.example/ ", + token="secret-token", + profile=" prod ", + ) + + assert isinstance(client, _FakeWorkspaceClient) + assert client.kwargs == { + "host": "https://adb.example", + "token": "secret-token", + "profile": "prod", + } + + +@pytest.mark.parametrize( + ("kwargs", "expected"), + [ + ({}, {}), + ({"workspace_url": "https://adb.example/"}, {"host": "https://adb.example"}), + ({"token": "secret-token"}, {"token": "secret-token"}), + ({"profile": "prod"}, {"profile": "prod"}), + ], +) +def test_create_databricks_workspace_client_leaves_missing_auth_to_sdk( + monkeypatch: pytest.MonkeyPatch, + kwargs: dict[str, str], + expected: dict[str, str], +) -> None: + _use_fake_workspace_client(monkeypatch) + + client = create_databricks_workspace_client(**kwargs) + + assert isinstance(client, _FakeWorkspaceClient) + assert client.kwargs == expected + + +def test_create_databricks_workspace_client_omits_blank_hints( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_fake_workspace_client(monkeypatch) + + client = create_databricks_workspace_client( + workspace_url=" ", + token=" ", + profile=" ", + ) + + assert isinstance(client, _FakeWorkspaceClient) + assert client.kwargs == {}