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
55 changes: 37 additions & 18 deletions dataconnect/service/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import pandas as pd

from dataconnect.exceptions import ErrorDetail
from dataconnect.models import Dataset, DatasetVersion, PaginatedResponse, Pagination, Study
from dataconnect.service.base import DataConnectService
from dataconnect.service.error_handler import translate_error
Expand All @@ -18,13 +19,12 @@
from dataconnect.service.validators import validate_positive_int, validate_uuid
from dataconnect.transport.base import Transport
from dataconnect.transport.errors import TransportError
from dataconnect.transport.models import ResourceQuery
from dataconnect.transport.models import DatasetTicket, ResourceQuery
Comment thread
afieraru-mdsol marked this conversation as resolved.

# Server action identifiers
_ACTION_LIST_STUDIES = "studies.list"
_ACTION_LIST_DATASETS = "datasets.list"
_ACTION_LIST_DATASET_VERSIONS = "dataset_versions.list"
_ACTION_FETCH_TICKET = "data.fetch_ticket"


class DefaultDataConnectService(DataConnectService):
Expand Down Expand Up @@ -85,27 +85,46 @@ def get_dataset_versions(self, dataset_uuid: UUID) -> list[DatasetVersion]:
raise translate_error(ex) from ex

def fetch_data(self, dataset_uuid: UUID, first_n_rows: int | None = None) -> pd.DataFrame:
"""Fetch data for a dataset"""

validate_uuid(
dataset_uuid,
field_name="dataset_uuid",
error_code="VAL_C_DATASET_UUID",
message="Invalid dataset_uuid.",
details=[
ErrorDetail(
field="dataset_uuid",
message="dataset_uuid must be a valid UUID.",
expected="Review and provide the correct dataset_uuid.",
)
],
)

if not dataset_uuid or not str(dataset_uuid).strip():
raise ValueError("dataset_uuid must be provided.")

if dataset_uuid.int == 0:
raise ValueError("dataset_uuid must not be an empty UUID.")

if first_n_rows is not None and (not isinstance(first_n_rows, int) or first_n_rows <= 0):
raise ValueError("first_n_rows must be a positive integer when provided.")
if first_n_rows is not None:
validate_positive_int(
first_n_rows,
field_name="first_n_rows",
error_code="VAL_C_FIRST_N_ROWS",
message="Invalid first_n_rows.",
details=[
ErrorDetail(
field="first_n_rows",
message=(f"Received {first_n_rows} for first_n_rows, which is not a positive integer."),
expected=(
"Set first_n_rows to 1 or greater, or omit the parameter to retrieve the full dataset"
),
)
],
)

request = ResourceQuery(action=_ACTION_FETCH_TICKET).append_body(
{
"study_env_uuid": None,
"dataset_name": None,
"dataset_uuid": str(dataset_uuid),
"limit": first_n_rows,
}
ticket = DatasetTicket(
dataset_uuid=str(dataset_uuid),
limit=first_n_rows,
)

try:
table = self._transport.do_get(request)
table = self._transport.get_ticket(ticket)
Comment thread
afieraru-mdsol marked this conversation as resolved.
return resource_to_fetched_data(table)
except TransportError as ex:
raise translate_error(ex) from ex
Expand Down
29 changes: 23 additions & 6 deletions dataconnect/service/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from datetime import UTC, datetime
from uuid import UUID

from dataconnect.exceptions import ValidationError
from dataconnect.exceptions import ErrorDetail, ValidationError


def validate_search_study_name(search_study_name: str | None) -> None:
Expand All @@ -18,7 +18,14 @@ def validate_search_study_name(search_study_name: str | None) -> None:
raise ValidationError("search_study_name must be a string")


def validate_uuid(value: object, *, field_name: str, error_code: str) -> None:
def validate_uuid(
value: object,
*,
field_name: str,
error_code: str,
message: str | None = None,
details: list[ErrorDetail] | None = None,
) -> None:
"""Ensure *value* is a non-zero UUID.

Raises:
Expand All @@ -27,19 +34,28 @@ def validate_uuid(value: object, *, field_name: str, error_code: str) -> None:
if not isinstance(value, UUID):
raise ValidationError(
error_code=error_code,
message=f"{field_name} must be a valid UUID.",
message=message or f"{field_name} must be a valid UUID.",
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
details=details,
)

if value.int == 0:
raise ValidationError(
error_code=error_code,
message=f"{field_name} must not be empty.",
message=message or f"{field_name} must not be empty.",
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
details=details,
)


def validate_positive_int(value: object, *, field_name: str, error_code: str) -> None:
def validate_positive_int(
value: object,
*,
field_name: str,
error_code: str,
message: str | None = None,
details: list[ErrorDetail] | None = None,
) -> None:
"""Ensure *value* is an integer >= 1.

Raises:
Expand All @@ -48,6 +64,7 @@ def validate_positive_int(value: object, *, field_name: str, error_code: str) ->
if not isinstance(value, int) or value < 1:
raise ValidationError(
error_code=error_code,
message=f"{field_name} must be a positive integer.",
message=message or f"{field_name} must be a positive integer.",
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
details=details,
)
17 changes: 4 additions & 13 deletions dataconnect/transport/arrow_flight/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

import base64
import dataclasses
import json
import platform
import subprocess
Expand All @@ -19,7 +20,7 @@
from dataconnect.transport.arrow_flight.error_handler import parse_dataconnect_error
from dataconnect.transport.base import Transport
from dataconnect.transport.errors import TransportValidationError
from dataconnect.transport.models import DataRef, DataTable, ResourceInfo, ResourceQuery
from dataconnect.transport.models import DataRef, DatasetTicket, DataTable, ResourceInfo, ResourceQuery


def _to_resource_info(info: flight.FlightInfo) -> ResourceInfo:
Expand Down Expand Up @@ -167,20 +168,10 @@ def list_resources(self, request: ResourceQuery) -> list[ResourceInfo]:
except Exception as ex:
raise parse_dataconnect_error(ex) from ex

def do_get(self, request: ResourceQuery) -> DataTable:
def get_ticket(self, ticket: DatasetTicket) -> DataTable:
"""Call FlightClient.do_get and read all chunks into a single pa.Table."""

flight_type = _ACTION_FLIGHT_TYPE.get(request.action)

if flight_type is None:
raise TransportValidationError(
error_code="VAL_001",
message=f"Unsupported action: {request.action}",
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
)

body = json.loads(request.body) if request.body else {}
ticket_bytes = json.dumps({**body, "flight_type": flight_type}, separators=(",", ":")).encode("utf-8")
ticket_bytes = json.dumps(dataclasses.asdict(ticket), separators=(",", ":")).encode("utf-8")
ticket = flight.Ticket(ticket_bytes)

try:
Expand Down
6 changes: 3 additions & 3 deletions dataconnect/transport/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from abc import ABC, abstractmethod

from dataconnect.transport.models import DataTable, ResourceInfo, ResourceQuery
from dataconnect.transport.models import DatasetTicket, DataTable, ResourceInfo, ResourceQuery


class Transport(ABC):
Expand All @@ -24,8 +24,8 @@ def list_resources(self, request: ResourceQuery) -> list[ResourceInfo]:
"""

@abstractmethod
def do_get(self, request: ResourceQuery) -> DataTable:
"""Fetch the full dataset described by ``request``.
def get_ticket(self, ticket: DatasetTicket) -> DataTable:
"""Fetch the full dataset described by ``ticket``.

All record batches from the stream are read and returned as a single
``DataTable`` containing the complete result set.
Expand Down
11 changes: 11 additions & 0 deletions dataconnect/transport/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ class DataRef:
ticket: bytes

Comment thread
afieraru-mdsol marked this conversation as resolved.

@dataclass(frozen=True)
class DatasetTicket:
"""A data ticket for a specific dataset, containing all information needed to fetch the data."""

dataset_uuid: str
limit: int | None = None
study_env_uuid: str | None = None
dataset_name: str | None = None
dataset_version: str | None = None
Comment thread
afieraru-mdsol marked this conversation as resolved.


@dataclass(frozen=True)
class ResourceInfo:
"""Technology-agnostic representation of a single resource."""
Expand Down
Loading
Loading