Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ __pycache__/
# C extensions
*.so

# Jupyter Notebook
.ipynb_checkpoints
*.ipynb

# Distribution / packaging
.Python
build/
Expand Down
5 changes: 4 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
# pre-commit autoupdate

default_language_version:
python: python3.11
# Use the system 'python3' interpreter so pre-commit picks up the active
# Python in your PATH (or the project's virtualenv). This avoids failures
# when a specific patch-level interpreter like python3.11 is not installed.
python: python3
Comment thread
ibaig-mdsol marked this conversation as resolved.

repos:
# ---------------------------------------------------------------------------
Expand Down
5 changes: 1 addition & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from dataconnect import DataConnectClientfrom dataconnect import DataConnectClient

# dataconnect-library-python

Python SDK for the [Medidata DataConnect](https://github.com/mdsol/dataconnect-library-r) service.
Expand All @@ -8,8 +6,7 @@ Python SDK for the [Medidata DataConnect](https://github.com/mdsol/dataconnect-l

## Transport note

The DataConnect service uses **Apache Arrow Flight** (gRPC binary protocol),
**not** a plain REST/HTTP API. `pyarrow.flight` is the primary transport
The DataConnect service uses **Apache Arrow Flight** (gRPC binary protocol), and **not** a plain REST/HTTP API. `pyarrow.flight` is the primary transport
dependency.

---
Expand Down
3 changes: 2 additions & 1 deletion dataconnect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@
ServerError,
ValidationError,
)
from dataconnect.models import Study, StudyEnvironment
from dataconnect.models import DatasetVersion, Study, StudyEnvironment

__all__ = [
# Client
"DataConnectClient",
# Domain models
"Study",
"StudyEnvironment",
"DatasetVersion",
# Exceptions — catch these in user application code
"DataConnectError",
"ConnectionError",
Expand Down
7 changes: 6 additions & 1 deletion dataconnect/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
from __future__ import annotations

from types import TracebackType
from uuid import UUID

from dataconnect.models import Study
from dataconnect.models import DatasetVersion, Study
from dataconnect.service import DataConnectService, DefaultDataConnectService

_DEFAULT_HOST = "enodia-gateway.platform.imedidata.com"
Expand Down Expand Up @@ -46,6 +47,10 @@ def get_studies(self) -> list[Study]:
"""List the studies the client is authorized to access."""
return self._service.get_studies()

def get_dataset_versions(self, dataset_uuid: UUID) -> list[DatasetVersion]:
"""List the dataset versions the client is authorized to access."""
return self._service.get_dataset_versions(dataset_uuid)

# Lifecycle

def close(self) -> None:
Expand Down
9 changes: 9 additions & 0 deletions dataconnect/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,12 @@ class Study:
uuid: UUID
name: str
environments: list[StudyEnvironment] = field(default_factory=list)


@dataclass(frozen=True)
class DatasetVersion:
study_uuid: UUID
study_environment_uuid: UUID
dataset_uuid: UUID
dataset_name: str
dataset_version: str
6 changes: 5 additions & 1 deletion dataconnect/service/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
from __future__ import annotations

from abc import ABC, abstractmethod
from uuid import UUID

from dataconnect.models import Study
from dataconnect.models import DatasetVersion, Study


class DataConnectService(ABC):
Expand All @@ -13,5 +14,8 @@ class DataConnectService(ABC):
@abstractmethod
def get_studies(self) -> list[Study]: ...

@abstractmethod
def get_dataset_versions(self, dataset_uuid: UUID) -> list[DatasetVersion]: ...

@abstractmethod
def close(self) -> None: ...
27 changes: 25 additions & 2 deletions dataconnect/service/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

from uuid import UUID

from dataconnect.exceptions import (
AuthenticationError,
AuthorizationError,
Expand All @@ -12,9 +14,9 @@
ServerError,
ValidationError,
)
from dataconnect.models import Study
from dataconnect.models import DatasetVersion, Study
from dataconnect.service.base import DataConnectService
from dataconnect.service.mappers import resource_to_study
from dataconnect.service.mappers import resource_to_dataset_version, resource_to_study
from dataconnect.transport.base import Transport
from dataconnect.transport.errors import (
TransportAuthenticationError,
Expand All @@ -29,6 +31,7 @@

# Server action identifiers
_ACTION_LIST_STUDIES = "studies.list"
_ACTION_LIST_DATASET_VERSIONS = "dataset_versions.list"


def _translate_error(ex: TransportError) -> DataConnectError:
Expand Down Expand Up @@ -72,6 +75,26 @@ def get_studies(self) -> list[Study]:
except (IndexError, KeyError, TypeError, ValueError) as ex:
raise ValidationError(f"Unexpected studies response format: {ex}") from ex

def get_dataset_versions(self, dataset_uuid: UUID) -> list[DatasetVersion]:
# Input validation: ensure callers pass a UUID
if not isinstance(dataset_uuid, UUID):
raise ValidationError("dataset_uuid must be a valid UUID")

if dataset_uuid.int == 0:
raise ValidationError("dataset_uuid must not be empty")

request = ResourceQuery(action=_ACTION_LIST_DATASET_VERSIONS).append_body({"dataset_uuid": str(dataset_uuid)})
Comment thread
ibaig-mdsol marked this conversation as resolved.

try:
resources = self._transport.list_resources(request)
except TransportError as ex:
raise _translate_error(ex) from ex

try:
return [resource_to_dataset_version(r) for r in resources]
except (IndexError, KeyError, TypeError, ValueError) as ex:
raise ValidationError(f"Unexpected dataset versions response format: {ex}") from ex

def close(self) -> None:

try:
Expand Down
19 changes: 18 additions & 1 deletion dataconnect/service/mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from uuid import UUID

from dataconnect.exceptions import NotFoundError
from dataconnect.models import Study, StudyEnvironment
from dataconnect.models import DatasetVersion, Study, StudyEnvironment
from dataconnect.transport.models import ResourceInfo


Expand All @@ -28,3 +28,20 @@ def resource_to_study(resource: ResourceInfo) -> Study:
name=data["name"],
environments=[StudyEnvironment(uuid=UUID(e["uuid"]), name=e["name"]) for e in data.get("environments", [])],
)


def resource_to_dataset_version(resource: ResourceInfo) -> DatasetVersion:
"""Parse a transport-layer ``ResourceInfo`` into a ``DatasetVersion`` domain object."""

if not resource or not resource.endpoints or not resource.endpoints[0].ticket:
raise NotFoundError("Invalid resource: missing endpoints or ticket")
Comment thread
ibaig-mdsol marked this conversation as resolved.

data = json.loads(resource.endpoints[0].ticket.decode("utf-8"))

return DatasetVersion(
study_uuid=UUID(data["study_uuid"]),
study_environment_uuid=UUID(data["study_env_uuid"]),
dataset_uuid=UUID(data["dataset_uuid"]),
dataset_name=data["dataset_name"],
dataset_version=data["dataset_version"],
)
1 change: 1 addition & 0 deletions dataconnect/transport/arrow_flight/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def _to_resource_info(info: flight.FlightInfo) -> ResourceInfo:
# Maps service-layer action names to the flight_type value the Arrow Flight server expects.
_ACTION_FLIGHT_TYPE: dict[str, str] = {
"studies.list": "STUDIES",
"dataset_versions.list": "VERSIONS",
}


Expand Down
104 changes: 102 additions & 2 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,108 @@
from __future__ import annotations

import sys
from types import ModuleType
from uuid import UUID

import pytest

from dataconnect.client import DataConnectClient
from dataconnect.models import DatasetVersion, Study

def test_client() -> None:
assert True

class _FakeService:
def __init__(self, studies: list[Study] | None = None, versions: list[DatasetVersion] | None = None) -> None:
self._studies = studies or []
self._versions = versions or []
self.closed = 0
self.last_dataset_uuid: UUID | None = None

def get_studies(self) -> list[Study]:
return self._studies

def get_dataset_versions(self, dataset_uuid: UUID) -> list[DatasetVersion]:
self.last_dataset_uuid = dataset_uuid
return self._versions

def close(self) -> None:
self.closed += 1


def test_get_studies_returns_service_result() -> None:
studies = [Study(uuid=UUID("64a98a9b-1512-44c8-92af-e4cab0183670"), name="Study A")]
client = DataConnectClient(_FakeService(studies=studies))

assert client.get_studies() == studies


def test_get_dataset_versions_forwards_uuid_to_service() -> None:
dataset_uuid = UUID("073410b6-79be-3e7d-ae37-92f6e054013e")
versions = [
DatasetVersion(
study_uuid=UUID("64a98a9b-1512-44c8-92af-e4cab0183670"),
study_environment_uuid=UUID("4d1fd10d-5b57-4fd8-a436-f4ec59ce2e4a"),
dataset_uuid=dataset_uuid,
dataset_name="labs",
dataset_version="1",
)
]
service = _FakeService(versions=versions)
client = DataConnectClient(service)

result = client.get_dataset_versions(dataset_uuid)

assert result == versions
assert service.last_dataset_uuid == dataset_uuid


def test_context_manager_closes_service() -> None:
service = _FakeService()

with DataConnectClient(service) as client:
assert isinstance(client, DataConnectClient)

assert service.closed == 1


def test_connect_uses_arrow_transport_and_default_service(monkeypatch: pytest.MonkeyPatch) -> None:
import dataconnect.client as client_mod

captured: dict[str, object] = {}

class FakeArrowFlightTransport:
def __init__(self, host: str, port: int, use_tls: bool, token: str = "") -> None:
captured["host"] = host
captured["port"] = port
captured["use_tls"] = use_tls
captured["token"] = token

class FakeDefaultService:
def __init__(self, transport: object) -> None:
captured["transport"] = transport

def get_studies(self) -> list[Study]:
return []

def get_dataset_versions(self, dataset_uuid: UUID) -> list[DatasetVersion]:
return []

def close(self) -> None:
return None

fake_transport_module = ModuleType("dataconnect.transport.arrow_flight.transport")
fake_transport_module.ArrowFlightTransport = FakeArrowFlightTransport

monkeypatch.setitem(sys.modules, "dataconnect.transport.arrow_flight.transport", fake_transport_module)
monkeypatch.setattr(client_mod, "DefaultDataConnectService", FakeDefaultService)

client = client_mod.DataConnectClient.connect(host="sandbox.example", port=9443, use_tls=False, token="abc123")

assert isinstance(client, client_mod.DataConnectClient)
assert captured["host"] == "sandbox.example"
assert captured["port"] == 9443
assert captured["use_tls"] is False
assert captured["token"] == "abc123"
assert isinstance(captured["transport"], FakeArrowFlightTransport)


@pytest.mark.benchmark
Expand Down
Loading
Loading