Skip to content

Commit f9ea4d2

Browse files
authored
Merge pull request #16 from mdsol/features/framework-2
[MCC-1498582] - Create layered design
2 parents 1298fd7 + 7d9a0c1 commit f9ea4d2

14 files changed

Lines changed: 455 additions & 86 deletions

File tree

‎dataconnect/__init__.py‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,31 @@
33
from __future__ import annotations
44

55
from dataconnect.client import DataConnectClient
6-
from dataconnect.exceptions import AuthenticationError, ConnectionError, DataConnectError
7-
from dataconnect.models import (
8-
Dataset,
9-
DatasetVersion,
10-
Study,
11-
StudyEnvironment,
6+
from dataconnect.exceptions import (
7+
AuthenticationError,
8+
AuthorizationError,
9+
ConnectionError,
10+
DataConnectError,
11+
NotFoundError,
12+
QueryError,
13+
ServerError,
14+
ValidationError,
1215
)
16+
from dataconnect.models import Study, StudyEnvironment
1317

1418
__all__ = [
19+
# Client
1520
"DataConnectClient",
21+
# Domain models
1622
"Study",
1723
"StudyEnvironment",
18-
"Dataset",
19-
"DatasetVersion",
24+
# Exceptions — catch these in user application code
2025
"DataConnectError",
2126
"ConnectionError",
2227
"AuthenticationError",
28+
"AuthorizationError",
29+
"NotFoundError",
30+
"QueryError",
31+
"ServerError",
32+
"ValidationError",
2333
]

‎dataconnect/client.py‎

Lines changed: 28 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,16 @@
1-
"""Public API for the DataConnect client library."""
1+
"""Public API for the DataConnect client library.
2+
3+
``DataConnectClient`` is a thin façade over ``DataConnectService``.
4+
The ``connect()`` class method is the composition root — the only place in
5+
the SDK where concrete implementation types are wired together.
6+
"""
27

38
from __future__ import annotations
49

5-
import json
610
from types import TracebackType
7-
from typing import Any
8-
9-
import pyarrow as pa
10-
11-
from dataconnect import _encoding
12-
from dataconnect.auth import BearerTokenAuth
13-
from dataconnect.framework.pyarrow_transport import PyArrowFlightTransport
14-
from dataconnect.framework.transport import FlightTransport
15-
from dataconnect.models import Dataset, Study
1611

17-
# Flight actions / commands
18-
_ACTION_LIST_STUDIES = "studies.list"
19-
_ACTION_LIST_DATASETS = "datasets.list"
20-
_ACTION_LIST_DATASET_VERSIONS = "dataset_versions.list"
21-
_ACTION_FETCH_TICKET = "data.fetch_ticket"
22-
_CMD_PUBLISH = "publish"
23-
_CMD_DRY_PUBLISH = "dry_publish"
12+
from dataconnect.models import Study
13+
from dataconnect.service import DataConnectService, DefaultDataConnectService
2414

2515
_DEFAULT_HOST = "enodia-gateway.platform.imedidata.com"
2616
_DEFAULT_PORT = 443
@@ -29,9 +19,9 @@
2919
class DataConnectClient:
3020
"""Client for interacting with DataConnect services."""
3121

32-
def __init__(self, transport: FlightTransport) -> None:
33-
"""Initialize the DataConnect client with a specified transport."""
34-
self._transport = transport
22+
def __init__(self, service: DataConnectService) -> None:
23+
"""Initialize the client with an injected service implementation."""
24+
self._service = service
3525

3626
@classmethod
3727
def connect(
@@ -41,37 +31,26 @@ def connect(
4131
use_tls: bool = True,
4232
token: str = "",
4333
) -> DataConnectClient:
44-
"""Open connection to a Flight server."""
45-
location = f"grpc+tls://{host}:{port}"
46-
transport = PyArrowFlightTransport(
47-
location=location,
48-
credentials=BearerTokenAuth(token),
49-
)
50-
return cls(transport)
51-
52-
def studies(self) -> list[Study]:
34+
35+
# Import is deferred so pyarrow.flight is only loaded when this factory
36+
# is called — callers injecting a custom transport are unaffected.
37+
from dataconnect.transport.arrow_flight.transport import ArrowFlightTransport
38+
39+
transport = ArrowFlightTransport(host=host, port=port, use_tls=use_tls, token=token)
40+
41+
return cls(DefaultDataConnectService(transport))
42+
43+
# Public API
44+
45+
def get_studies(self) -> list[Study]:
5346
"""List the studies the client is authorized to access."""
54-
rows = self._action_json(_ACTION_LIST_STUDIES, None)
55-
return [Study(**r) for r in rows]
56-
57-
def datasets(self, study_uuid: str) -> list[Dataset]:
58-
"""List the datasets available for a given study."""
59-
body = {"study_uuid": study_uuid}
60-
rows = self._action_json(_ACTION_LIST_DATASETS, {"study_uuid": body})
61-
return [Dataset(**r) for r in rows]
62-
63-
def fetch_data(self, dataset_uuid: str) -> pa.Table:
64-
"""Fetch the data for a given dataset as a PyArrow Table."""
65-
body = {"dataset_uuid": dataset_uuid}
66-
results = self._transport.do_action(_ACTION_FETCH_TICKET, _encoding.dumps(body))
67-
if not results:
68-
raise RuntimeError("Server returned no data for the fetch_data action.")
69-
return self._transport.do_get(results).read_all()
47+
return self._service.get_studies()
7048

7149
# Lifecycle
50+
7251
def close(self) -> None:
73-
"""Close the underlying transport connection."""
74-
self._transport.close()
52+
"""Close the underlying connection."""
53+
self._service.close()
7554

7655
def __enter__(self) -> DataConnectClient:
7756
return self
@@ -82,12 +61,4 @@ def __exit__(
8261
exc_value: BaseException | None,
8362
traceback: TracebackType | None,
8463
) -> None:
85-
self._transport.close()
86-
87-
# Helpers
88-
def _action_json(self, action: str, body: dict[str, Any] | None) -> Any:
89-
"""Execute a Flight action and return the result as JSON."""
90-
results = self._transport.do_action(action, _encoding.dumps(body or {}))
91-
if not results:
92-
return []
93-
return json.loads(results.decode("utf-8"))
64+
self._service.close()

‎dataconnect/exceptions.py‎

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
1-
"""Public exceptions for DataConnect."""
1+
"""Public exceptions for DataConnect.
2+
3+
Hierarchy
4+
---------
5+
DataConnectError
6+
├── ConnectionError — unable to reach server
7+
├── AuthenticationError — authentication failure from server
8+
├── AuthorizationError — authorization failure from server
9+
├── NotFoundError — requested resource does not exist
10+
├── QueryError — server rejected query / stream read failure
11+
├── ServerError — unexpected server-side error
12+
└── ValidationError — server response was malformed or unexpected
13+
"""
214

315
from __future__ import annotations
416

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

921

1022
class ConnectionError(DataConnectError):
11-
"""Error connecting to the DataConnect server."""
23+
"""Unable to establish or maintain a connection to the server."""
1224

1325

1426
class AuthenticationError(DataConnectError):
15-
"""Error authenticating with the DataConnect server."""
27+
"""Authentication failure."""
28+
29+
30+
class AuthorizationError(DataConnectError):
31+
"""Authorization failure."""
32+
33+
34+
class NotFoundError(DataConnectError):
35+
"""The requested resource (study, dataset, etc.) was not found."""
1636

1737

1838
class QueryError(DataConnectError):
19-
"""Error executing a query."""
39+
"""The server rejected the query or a data-stream read failed."""
40+
41+
42+
class ServerError(DataConnectError):
43+
"""Unexpected server-side error."""
44+
45+
def __init__(self, message: str, status_code: int = 0) -> None:
46+
super().__init__(message)
47+
self.status_code = status_code
48+
49+
50+
class ValidationError(DataConnectError):
51+
"""Server returned data in an unexpected or invalid format."""

‎dataconnect/models.py‎

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,17 @@
11
from __future__ import annotations
22

3-
from dataclasses import dataclass
4-
5-
6-
@dataclass(frozen=True)
7-
class Study:
8-
id: str
9-
name: str
3+
from dataclasses import dataclass, field
4+
from uuid import UUID
105

116

127
@dataclass(frozen=True)
138
class StudyEnvironment:
14-
"""Environment variables for a study."""
15-
16-
17-
@dataclass(frozen=True)
18-
class Dataset:
19-
id: str
20-
study_id: str
9+
uuid: UUID
2110
name: str
2211

2312

2413
@dataclass(frozen=True)
25-
class DatasetVersion:
26-
id: str
27-
dataset_id: str
14+
class Study:
15+
uuid: UUID
2816
name: str
17+
environments: list[StudyEnvironment] = field(default_factory=list)

‎dataconnect/service/__init__.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Service layer — public symbols re-exported for import convenience."""
2+
3+
from dataconnect.service.base import DataConnectService
4+
from dataconnect.service.default import DefaultDataConnectService
5+
6+
__all__ = [
7+
"DataConnectService",
8+
"DefaultDataConnectService",
9+
]

‎dataconnect/service/base.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Abstract service interface for DataConnect."""
2+
3+
from __future__ import annotations
4+
5+
from abc import ABC, abstractmethod
6+
7+
from dataconnect.models import Study
8+
9+
10+
class DataConnectService(ABC):
11+
"""Abstract service interface — defines all operations available to the client."""
12+
13+
@abstractmethod
14+
def get_studies(self) -> list[Study]: ...
15+
16+
@abstractmethod
17+
def close(self) -> None: ...

‎dataconnect/service/default.py‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Default service implementation — domain logic, encoding, and error translation."""
2+
3+
from __future__ import annotations
4+
5+
from dataconnect.exceptions import (
6+
AuthenticationError,
7+
AuthorizationError,
8+
ConnectionError,
9+
DataConnectError,
10+
NotFoundError,
11+
QueryError,
12+
ServerError,
13+
ValidationError,
14+
)
15+
from dataconnect.models import Study
16+
from dataconnect.service.base import DataConnectService
17+
from dataconnect.service.mappers import resource_to_study
18+
from dataconnect.transport.base import Transport
19+
from dataconnect.transport.errors import (
20+
TransportAuthenticationError,
21+
TransportAuthorizationError,
22+
TransportConnectionError,
23+
TransportError,
24+
TransportIOError,
25+
TransportNotFoundError,
26+
TransportStatusError,
27+
)
28+
from dataconnect.transport.models import ResourceQuery
29+
30+
# Server action identifiers
31+
_ACTION_LIST_STUDIES = "studies.list"
32+
33+
34+
def _translate_error(ex: TransportError) -> DataConnectError:
35+
"""Map a ``TransportError`` to the appropriate public ``DataConnectError``."""
36+
37+
if isinstance(ex, TransportAuthenticationError):
38+
return AuthenticationError(str(ex))
39+
if isinstance(ex, TransportAuthorizationError):
40+
return AuthorizationError(str(ex))
41+
if isinstance(ex, TransportNotFoundError):
42+
return NotFoundError(str(ex))
43+
if isinstance(ex, TransportStatusError):
44+
return ServerError(str(ex), status_code=ex.status_code)
45+
if isinstance(ex, TransportConnectionError):
46+
return ConnectionError(str(ex))
47+
if isinstance(ex, TransportIOError):
48+
return QueryError(str(ex))
49+
50+
return ServerError(str(ex))
51+
52+
53+
class DefaultDataConnectService(DataConnectService):
54+
"""Concrete service injected with an abstract ``Transport``."""
55+
56+
def __init__(self, transport: Transport) -> None:
57+
self._transport = transport
58+
59+
# DataConnectService
60+
61+
def get_studies(self) -> list[Study]:
62+
63+
request = ResourceQuery(action=_ACTION_LIST_STUDIES)
64+
65+
try:
66+
resources = self._transport.list_resources(request)
67+
except TransportError as ex:
68+
raise _translate_error(ex) from ex
69+
70+
try:
71+
return [resource_to_study(r) for r in resources]
72+
except (KeyError, TypeError, ValueError) as ex:
73+
raise ValidationError(f"Unexpected studies response format: {ex}") from ex
74+
75+
def close(self) -> None:
76+
77+
try:
78+
self._transport.close()
79+
except TransportError as ex:
80+
raise ConnectionError(str(ex)) from ex

‎dataconnect/service/mappers.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Resource → domain model mappers.
2+
3+
Each function takes a transport-layer ``ResourceInfo`` and returns a public
4+
domain model. All wire-format knowledge (JSON encoding, field names, byte
5+
decoding) is isolated here.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
from uuid import UUID
12+
13+
from dataconnect.models import Study, StudyEnvironment
14+
from dataconnect.transport.models import ResourceInfo
15+
16+
17+
def resource_to_study(resource: ResourceInfo) -> Study:
18+
"""Parse a transport-layer ``ResourceInfo`` into a ``Study`` domain object."""
19+
20+
data = json.loads(resource.endpoints[0].ticket.decode("utf-8"))
21+
22+
return Study(
23+
uuid=UUID(data["uuid"]),
24+
name=data["name"],
25+
environments=[StudyEnvironment(uuid=UUID(e["uuid"]), name=e["name"]) for e in data.get("environments", [])],
26+
)

‎dataconnect/transport/__init__.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Transport layer — public exports.
2+
3+
Transport errors (``transport/errors.py``) are intentionally NOT re-exported
4+
here. They are internal to the transport layer and must not be caught by
5+
user-facing code.
6+
"""
7+
8+
from dataconnect.transport.base import Transport
9+
from dataconnect.transport.models import DataRef, ResourceInfo, ResourceQuery
10+
11+
__all__ = [
12+
"Transport",
13+
"ResourceQuery",
14+
"ResourceInfo",
15+
"DataRef",
16+
]

0 commit comments

Comments
 (0)