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
26 changes: 18 additions & 8 deletions dataconnect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,31 @@
from __future__ import annotations

from dataconnect.client import DataConnectClient
from dataconnect.exceptions import AuthenticationError, ConnectionError, DataConnectError
from dataconnect.models import (
Dataset,
DatasetVersion,
Study,
StudyEnvironment,
from dataconnect.exceptions import (
AuthenticationError,
AuthorizationError,
ConnectionError,
DataConnectError,
NotFoundError,
QueryError,
ServerError,
ValidationError,
)
from dataconnect.models import Study, StudyEnvironment

__all__ = [
# Client
"DataConnectClient",
# Domain models
"Study",
"StudyEnvironment",
"Dataset",
"DatasetVersion",
# Exceptions — catch these in user application code
"DataConnectError",
"ConnectionError",
"AuthenticationError",
"AuthorizationError",
"NotFoundError",
"QueryError",
"ServerError",
"ValidationError",
]
85 changes: 28 additions & 57 deletions dataconnect/client.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,16 @@
"""Public API for the DataConnect client library."""
"""Public API for the DataConnect client library.

``DataConnectClient`` is a thin façade over ``DataConnectService``.
The ``connect()`` class method is the composition root — the only place in
the SDK where concrete implementation types are wired together.
"""

from __future__ import annotations

import json
from types import TracebackType
from typing import Any

import pyarrow as pa

from dataconnect import _encoding
from dataconnect.auth import BearerTokenAuth
from dataconnect.framework.pyarrow_transport import PyArrowFlightTransport
from dataconnect.framework.transport import FlightTransport
from dataconnect.models import Dataset, Study

# Flight actions / commands
_ACTION_LIST_STUDIES = "studies.list"
_ACTION_LIST_DATASETS = "datasets.list"
_ACTION_LIST_DATASET_VERSIONS = "dataset_versions.list"
_ACTION_FETCH_TICKET = "data.fetch_ticket"
_CMD_PUBLISH = "publish"
_CMD_DRY_PUBLISH = "dry_publish"
from dataconnect.models import Study
from dataconnect.service import DataConnectService, DefaultDataConnectService

_DEFAULT_HOST = "enodia-gateway.platform.imedidata.com"
_DEFAULT_PORT = 443
Expand All @@ -29,9 +19,9 @@
class DataConnectClient:
"""Client for interacting with DataConnect services."""

def __init__(self, transport: FlightTransport) -> None:
"""Initialize the DataConnect client with a specified transport."""
self._transport = transport
def __init__(self, service: DataConnectService) -> None:
"""Initialize the client with an injected service implementation."""
self._service = service

@classmethod
def connect(
Expand All @@ -41,37 +31,26 @@ def connect(
use_tls: bool = True,
token: str = "",
) -> DataConnectClient:
"""Open connection to a Flight server."""
location = f"grpc+tls://{host}:{port}"
transport = PyArrowFlightTransport(
location=location,
credentials=BearerTokenAuth(token),
)
return cls(transport)

def studies(self) -> list[Study]:

# Import is deferred so pyarrow.flight is only loaded when this factory
# is called — callers injecting a custom transport are unaffected.
from dataconnect.transport.arrow_flight.transport import ArrowFlightTransport

transport = ArrowFlightTransport(host=host, port=port, use_tls=use_tls, token=token)

return cls(DefaultDataConnectService(transport))

# Public API

Comment thread
slingampalli-mdsol marked this conversation as resolved.
def get_studies(self) -> list[Study]:
"""List the studies the client is authorized to access."""
rows = self._action_json(_ACTION_LIST_STUDIES, None)
return [Study(**r) for r in rows]

def datasets(self, study_uuid: str) -> list[Dataset]:
"""List the datasets available for a given study."""
body = {"study_uuid": study_uuid}
rows = self._action_json(_ACTION_LIST_DATASETS, {"study_uuid": body})
return [Dataset(**r) for r in rows]

def fetch_data(self, dataset_uuid: str) -> pa.Table:
"""Fetch the data for a given dataset as a PyArrow Table."""
body = {"dataset_uuid": dataset_uuid}
results = self._transport.do_action(_ACTION_FETCH_TICKET, _encoding.dumps(body))
if not results:
raise RuntimeError("Server returned no data for the fetch_data action.")
return self._transport.do_get(results).read_all()
return self._service.get_studies()

# Lifecycle

def close(self) -> None:
"""Close the underlying transport connection."""
self._transport.close()
"""Close the underlying connection."""
self._service.close()

def __enter__(self) -> DataConnectClient:
return self
Expand All @@ -82,12 +61,4 @@ def __exit__(
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
self._transport.close()

# Helpers
def _action_json(self, action: str, body: dict[str, Any] | None) -> Any:
"""Execute a Flight action and return the result as JSON."""
results = self._transport.do_action(action, _encoding.dumps(body or {}))
if not results:
return []
return json.loads(results.decode("utf-8"))
self._service.close()
40 changes: 36 additions & 4 deletions dataconnect/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
"""Public exceptions for DataConnect."""
"""Public exceptions for DataConnect.

Hierarchy
---------
DataConnectError
├── ConnectionError — unable to reach server
├── AuthenticationError — authentication failure from server
├── AuthorizationError — authorization failure from server
├── NotFoundError — requested resource does not exist
├── QueryError — server rejected query / stream read failure
├── ServerError — unexpected server-side error
└── ValidationError — server response was malformed or unexpected
"""

from __future__ import annotations

Expand All @@ -8,12 +20,32 @@ class DataConnectError(Exception):


class ConnectionError(DataConnectError):
"""Error connecting to the DataConnect server."""
"""Unable to establish or maintain a connection to the server."""


class AuthenticationError(DataConnectError):
"""Error authenticating with the DataConnect server."""
"""Authentication failure."""


class AuthorizationError(DataConnectError):
"""Authorization failure."""


class NotFoundError(DataConnectError):
"""The requested resource (study, dataset, etc.) was not found."""


class QueryError(DataConnectError):
"""Error executing a query."""
"""The server rejected the query or a data-stream read failed."""


class ServerError(DataConnectError):
"""Unexpected server-side error."""

def __init__(self, message: str, status_code: int = 0) -> None:
super().__init__(message)
self.status_code = status_code


class ValidationError(DataConnectError):
"""Server returned data in an unexpected or invalid format."""
23 changes: 6 additions & 17 deletions dataconnect/models.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,17 @@
from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class Study:
id: str
name: str
from dataclasses import dataclass, field
from uuid import UUID


@dataclass(frozen=True)
class StudyEnvironment:
"""Environment variables for a study."""


@dataclass(frozen=True)
class Dataset:
id: str
study_id: str
uuid: UUID
name: str


@dataclass(frozen=True)
class DatasetVersion:
id: str
dataset_id: str
class Study:
uuid: UUID
name: str
environments: list[StudyEnvironment] = field(default_factory=list)
9 changes: 9 additions & 0 deletions dataconnect/service/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Service layer — public symbols re-exported for import convenience."""

from dataconnect.service.base import DataConnectService
from dataconnect.service.default import DefaultDataConnectService

__all__ = [
"DataConnectService",
"DefaultDataConnectService",
]
17 changes: 17 additions & 0 deletions dataconnect/service/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Abstract service interface for DataConnect."""

from __future__ import annotations

from abc import ABC, abstractmethod

from dataconnect.models import Study


class DataConnectService(ABC):
"""Abstract service interface — defines all operations available to the client."""

@abstractmethod
def get_studies(self) -> list[Study]: ...

@abstractmethod
def close(self) -> None: ...
80 changes: 80 additions & 0 deletions dataconnect/service/default.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Default service implementation — domain logic, encoding, and error translation."""

from __future__ import annotations

from dataconnect.exceptions import (
AuthenticationError,
AuthorizationError,
ConnectionError,
DataConnectError,
NotFoundError,
QueryError,
ServerError,
ValidationError,
)
from dataconnect.models import Study
from dataconnect.service.base import DataConnectService
from dataconnect.service.mappers import resource_to_study
from dataconnect.transport.base import Transport
from dataconnect.transport.errors import (
TransportAuthenticationError,
TransportAuthorizationError,
TransportConnectionError,
TransportError,
TransportIOError,
TransportNotFoundError,
TransportStatusError,
)
from dataconnect.transport.models import ResourceQuery

# Server action identifiers
_ACTION_LIST_STUDIES = "studies.list"


def _translate_error(ex: TransportError) -> DataConnectError:
"""Map a ``TransportError`` to the appropriate public ``DataConnectError``."""

if isinstance(ex, TransportAuthenticationError):
return AuthenticationError(str(ex))
if isinstance(ex, TransportAuthorizationError):
return AuthorizationError(str(ex))
if isinstance(ex, TransportNotFoundError):
return NotFoundError(str(ex))
if isinstance(ex, TransportStatusError):
return ServerError(str(ex), status_code=ex.status_code)
if isinstance(ex, TransportConnectionError):
return ConnectionError(str(ex))
if isinstance(ex, TransportIOError):
return QueryError(str(ex))

return ServerError(str(ex))


class DefaultDataConnectService(DataConnectService):
"""Concrete service injected with an abstract ``Transport``."""

def __init__(self, transport: Transport) -> None:
self._transport = transport

# DataConnectService

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

request = ResourceQuery(action=_ACTION_LIST_STUDIES)

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

try:
return [resource_to_study(r) for r in resources]
except (KeyError, TypeError, ValueError) as ex:
Comment thread
slingampalli-mdsol marked this conversation as resolved.
raise ValidationError(f"Unexpected studies response format: {ex}") from ex

def close(self) -> None:

try:
self._transport.close()
except TransportError as ex:
raise ConnectionError(str(ex)) from ex
26 changes: 26 additions & 0 deletions dataconnect/service/mappers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Resource → domain model mappers.

Each function takes a transport-layer ``ResourceInfo`` and returns a public
domain model. All wire-format knowledge (JSON encoding, field names, byte
decoding) is isolated here.
"""

from __future__ import annotations

import json
from uuid import UUID

from dataconnect.models import Study, StudyEnvironment
from dataconnect.transport.models import ResourceInfo


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

Comment thread
slingampalli-mdsol marked this conversation as resolved.
data = json.loads(resource.endpoints[0].ticket.decode("utf-8"))

return Study(
uuid=UUID(data["uuid"]),
name=data["name"],
environments=[StudyEnvironment(uuid=UUID(e["uuid"]), name=e["name"]) for e in data.get("environments", [])],
)
16 changes: 16 additions & 0 deletions dataconnect/transport/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""Transport layer — public exports.

Transport errors (``transport/errors.py``) are intentionally NOT re-exported
here. They are internal to the transport layer and must not be caught by
user-facing code.
"""

from dataconnect.transport.base import Transport
from dataconnect.transport.models import DataRef, ResourceInfo, ResourceQuery

__all__ = [
"Transport",
"ResourceQuery",
"ResourceInfo",
"DataRef",
]
Loading
Loading