From da03d94332d425a51097df370732a56f279948f4 Mon Sep 17 00:00:00 2001 From: Irfan Baig Date: Thu, 7 May 2026 10:08:24 -0500 Subject: [PATCH 1/4] feat: MCC-1496348 dataset versions --- .gitignore | 4 + .pre-commit-config.yaml | 5 +- README.md | 5 +- dataconnect/__init__.py | 3 +- dataconnect/client.py | 7 +- dataconnect/models.py | 9 ++ dataconnect/service/base.py | 6 +- dataconnect/service/default.py | 22 +++- dataconnect/service/mappers.py | 23 +++- .../transport/arrow_flight/transport.py | 5 +- tests/test_client.py | 104 +++++++++++++++- tests/test_service.py | 113 ++++++++++++++++++ 12 files changed, 291 insertions(+), 15 deletions(-) create mode 100644 tests/test_service.py diff --git a/.gitignore b/.gitignore index 4ea5ccd..7b20724 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ __pycache__/ # C extensions *.so +# Jupyter Notebook +.ipynb_checkpoints +*.ipynb + # Distribution / packaging .Python build/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 86ded94..ef4d154 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 repos: # --------------------------------------------------------------------------- diff --git a/README.md b/README.md index 186a77f..6d82c77 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. --- diff --git a/dataconnect/__init__.py b/dataconnect/__init__.py index f9b6f02..61c055e 100644 --- a/dataconnect/__init__.py +++ b/dataconnect/__init__.py @@ -13,7 +13,7 @@ ServerError, ValidationError, ) -from dataconnect.models import Study, StudyEnvironment +from dataconnect.models import DatasetVersion, Study, StudyEnvironment __all__ = [ # Client @@ -21,6 +21,7 @@ # Domain models "Study", "StudyEnvironment", + "DatasetVersion", # Exceptions — catch these in user application code "DataConnectError", "ConnectionError", diff --git a/dataconnect/client.py b/dataconnect/client.py index 84ce20d..637121e 100644 --- a/dataconnect/client.py +++ b/dataconnect/client.py @@ -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" @@ -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: diff --git a/dataconnect/models.py b/dataconnect/models.py index c429250..cb222c4 100644 --- a/dataconnect/models.py +++ b/dataconnect/models.py @@ -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_env_uuid: UUID + dataset_uuid: UUID + dataset_name: str + dataset_version: str diff --git a/dataconnect/service/base.py b/dataconnect/service/base.py index 78a26b0..938c6a9 100644 --- a/dataconnect/service/base.py +++ b/dataconnect/service/base.py @@ -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): @@ -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: ... diff --git a/dataconnect/service/default.py b/dataconnect/service/default.py index 2135b89..21906d9 100644 --- a/dataconnect/service/default.py +++ b/dataconnect/service/default.py @@ -2,6 +2,8 @@ from __future__ import annotations +from uuid import UUID + from dataconnect.exceptions import ( AuthenticationError, AuthorizationError, @@ -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, @@ -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: @@ -69,9 +72,22 @@ def get_studies(self) -> list[Study]: try: return [resource_to_study(r) for r in resources] - except (KeyError, TypeError, ValueError) as ex: + 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]: + request = ResourceQuery(action=_ACTION_LIST_DATASET_VERSIONS).append_body({"dataset_uuid": str(dataset_uuid)}) + + 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: diff --git a/dataconnect/service/mappers.py b/dataconnect/service/mappers.py index 00eb9c2..dd44d35 100644 --- a/dataconnect/service/mappers.py +++ b/dataconnect/service/mappers.py @@ -10,13 +10,17 @@ import json from uuid import UUID -from dataconnect.models import Study, StudyEnvironment +from dataconnect.exceptions import NotFoundError +from dataconnect.models import DatasetVersion, 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.""" + if not resource or not resource.endpoints or not resource.endpoints[0].ticket: + raise NotFoundError("Invalid resource: missing endpoints or ticket") + data = json.loads(resource.endpoints[0].ticket.decode("utf-8")) return Study( @@ -24,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") + + data = json.loads(resource.endpoints[0].ticket.decode("utf-8")) + + return DatasetVersion( + study_uuid=UUID(data["study_uuid"]), + study_env_uuid=UUID(data["study_env_uuid"]), + dataset_uuid=UUID(data["dataset_uuid"]), + dataset_name=data["dataset_name"], + dataset_version=data["dataset_version"], + ) diff --git a/dataconnect/transport/arrow_flight/transport.py b/dataconnect/transport/arrow_flight/transport.py index 8fae243..df7eaa4 100644 --- a/dataconnect/transport/arrow_flight/transport.py +++ b/dataconnect/transport/arrow_flight/transport.py @@ -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", } @@ -77,7 +78,9 @@ def list_resources(self, request: ResourceQuery) -> list[ResourceInfo]: flight_type = _ACTION_FLIGHT_TYPE.get(request.action) if flight_type is None: - raise TransportConnectionError(f"Unknown action: {request.action!r}") + raise TransportStatusError( + f"Unknown action: {request.action!r}", status_code=3, grpc_status="INVALID_ARGUMENT" + ) body = json.loads(request.body) if request.body else {} criteria = json.dumps({**body, "flight_type": flight_type}, separators=(",", ":")).encode("utf-8") diff --git a/tests/test_client.py b/tests/test_client.py index cb45e43..ecd408b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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_env_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 diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..38967bf --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import json +from uuid import UUID + +import pytest + +from dataconnect.exceptions import ConnectionError, ValidationError +from dataconnect.models import DatasetVersion +from dataconnect.service.default import DefaultDataConnectService +from dataconnect.transport.errors import TransportConnectionError +from dataconnect.transport.models import DataRef, ResourceInfo, ResourceQuery + + +class _FakeTransport: + def __init__( + self, + resources: list[ResourceInfo] | None = None, + error: Exception | None = None, + ) -> None: + self._resources = resources or [] + self._error = error + self.last_request: ResourceQuery | None = None + + def list_resources(self, request: ResourceQuery) -> list[ResourceInfo]: + self.last_request = request + if self._error is not None: + raise self._error + return self._resources + + def close(self) -> None: + return None + + +def _resource_with_ticket_json(payload: dict[str, object]) -> ResourceInfo: + return ResourceInfo( + descriptor=b"", + endpoints=[DataRef(ticket=json.dumps(payload).encode("utf-8"))], + total_records=1, + schema_bytes=b"", + ) + + +def test_get_dataset_versions_returns_mapped_models_and_builds_request() -> None: + dataset_uuid = UUID("073410b6-79be-3e7d-ae37-92f6e054013e") + payload = { + "study_uuid": "64a98a9b-1512-44c8-92af-e4cab0183670", + "study_env_uuid": "4d1fd10d-5b57-4fd8-a436-f4ec59ce2e4a", + "dataset_uuid": str(dataset_uuid), + "dataset_name": "labs", + "dataset_version": "1", + } + transport = _FakeTransport(resources=[_resource_with_ticket_json(payload)]) + service = DefaultDataConnectService(transport) + + result = service.get_dataset_versions(dataset_uuid) + + assert result == [ + DatasetVersion( + study_uuid=UUID("64a98a9b-1512-44c8-92af-e4cab0183670"), + study_env_uuid=UUID("4d1fd10d-5b57-4fd8-a436-f4ec59ce2e4a"), + dataset_uuid=dataset_uuid, + dataset_name="labs", + dataset_version="1", + ) + ] + assert transport.last_request is not None + assert transport.last_request.action == "dataset_versions.list" + assert json.loads(transport.last_request.body) == {"dataset_uuid": str(dataset_uuid)} + + +def test_get_dataset_versions_translates_transport_errors() -> None: + dataset_uuid = UUID("073410b6-79be-3e7d-ae37-92f6e054013e") + transport = _FakeTransport(error=TransportConnectionError("cannot connect")) + service = DefaultDataConnectService(transport) + + with pytest.raises(ConnectionError, match="cannot connect"): + service.get_dataset_versions(dataset_uuid) + + +def test_get_dataset_versions_raises_validation_error_on_bad_payload() -> None: + dataset_uuid = UUID("073410b6-79be-3e7d-ae37-92f6e054013e") + bad_payload = { + "study_uuid": "64a98a9b-1512-44c8-92af-e4cab0183670", + "study_env_uuid": "4d1fd10d-5b57-4fd8-a436-f4ec59ce2e4a", + "dataset_uuid": str(dataset_uuid), + "dataset_name": "labs", + # dataset_version intentionally omitted to trigger mapper validation failure. + } + transport = _FakeTransport(resources=[_resource_with_ticket_json(bad_payload)]) + service = DefaultDataConnectService(transport) + + with pytest.raises(ValidationError, match="Unexpected dataset versions response format"): + service.get_dataset_versions(dataset_uuid) + + +def test_manual_resource_query_body_can_be_used_directly() -> None: + """Assert that constructing a ResourceQuery with a JSON body string works as expected. + + This mirrors the alternative to `append_body` where the caller provides a + JSON-encoded `body` explicitly (using json.dumps to guarantee valid JSON). + """ + dataset_uuid = UUID("073410b6-79be-3e7d-ae37-92f6e054013e") + + # Build the compact JSON string the same way append_body would + + body_str = json.dumps({"dataset_uuid": str(dataset_uuid)}, separators=(",", ":")) + + rq = ResourceQuery(action="dataset_versions.list", body=body_str) + + assert json.loads(rq.body) == {"dataset_uuid": str(dataset_uuid)} + # compact representation (no spaces) + assert rq.body == body_str From e663b3524f4e1e8aa943623f6e96fe48f712eb7b Mon Sep 17 00:00:00 2001 From: Irfan Baig Date: Thu, 7 May 2026 16:04:13 -0500 Subject: [PATCH 2/4] feat: rename study_env_uuid to study_environment_uuid --- dataconnect/models.py | 2 +- dataconnect/service/mappers.py | 2 +- tests/test_client.py | 2 +- tests/test_service.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dataconnect/models.py b/dataconnect/models.py index cb222c4..117f560 100644 --- a/dataconnect/models.py +++ b/dataconnect/models.py @@ -20,7 +20,7 @@ class Study: @dataclass(frozen=True) class DatasetVersion: study_uuid: UUID - study_env_uuid: UUID + study_environment_uuid: UUID dataset_uuid: UUID dataset_name: str dataset_version: str diff --git a/dataconnect/service/mappers.py b/dataconnect/service/mappers.py index dd44d35..1422b81 100644 --- a/dataconnect/service/mappers.py +++ b/dataconnect/service/mappers.py @@ -40,7 +40,7 @@ def resource_to_dataset_version(resource: ResourceInfo) -> DatasetVersion: return DatasetVersion( study_uuid=UUID(data["study_uuid"]), - study_env_uuid=UUID(data["study_env_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"], diff --git a/tests/test_client.py b/tests/test_client.py index ecd408b..38b1563 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -40,7 +40,7 @@ def test_get_dataset_versions_forwards_uuid_to_service() -> None: versions = [ DatasetVersion( study_uuid=UUID("64a98a9b-1512-44c8-92af-e4cab0183670"), - study_env_uuid=UUID("4d1fd10d-5b57-4fd8-a436-f4ec59ce2e4a"), + study_environment_uuid=UUID("4d1fd10d-5b57-4fd8-a436-f4ec59ce2e4a"), dataset_uuid=dataset_uuid, dataset_name="labs", dataset_version="1", diff --git a/tests/test_service.py b/tests/test_service.py index 38967bf..e21a322 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -58,7 +58,7 @@ def test_get_dataset_versions_returns_mapped_models_and_builds_request() -> None assert result == [ DatasetVersion( study_uuid=UUID("64a98a9b-1512-44c8-92af-e4cab0183670"), - study_env_uuid=UUID("4d1fd10d-5b57-4fd8-a436-f4ec59ce2e4a"), + study_environment_uuid=UUID("4d1fd10d-5b57-4fd8-a436-f4ec59ce2e4a"), dataset_uuid=dataset_uuid, dataset_name="labs", dataset_version="1", From 744698481e712d9d3f0fe17b325473506385aac7 Mon Sep 17 00:00:00 2001 From: Irfan Baig Date: Fri, 8 May 2026 08:05:48 -0500 Subject: [PATCH 3/4] feat: Added Input validation --- dataconnect/service/default.py | 4 ++++ tests/test_service.py | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/dataconnect/service/default.py b/dataconnect/service/default.py index 21906d9..52ea9dd 100644 --- a/dataconnect/service/default.py +++ b/dataconnect/service/default.py @@ -76,6 +76,10 @@ def get_studies(self) -> list[Study]: 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") + request = ResourceQuery(action=_ACTION_LIST_DATASET_VERSIONS).append_body({"dataset_uuid": str(dataset_uuid)}) try: diff --git a/tests/test_service.py b/tests/test_service.py index e21a322..ed0e92d 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -111,3 +111,27 @@ def test_manual_resource_query_body_can_be_used_directly() -> None: assert json.loads(rq.body) == {"dataset_uuid": str(dataset_uuid)} # compact representation (no spaces) assert rq.body == body_str + + +def test_get_dataset_versions_raises_validation_error_on_invalid_uuid_input() -> None: + """Passing a non-UUID to the service should raise ValidationError.""" + transport = _FakeTransport(resources=[]) + service = DefaultDataConnectService(transport) + + with pytest.raises(ValidationError) as excinfo: + service.get_dataset_versions("not-a-uuid") + + # Ensure our validation code path is exercised + assert "dataset_uuid must be a valid UUID" in str(excinfo.value) + + +def test_get_dataset_versions_raises_validation_error_on_empty_input() -> None: + """Passing an empty string to the service should raise ValidationError.""" + transport = _FakeTransport(resources=[]) + service = DefaultDataConnectService(transport) + + with pytest.raises(ValidationError) as excinfo: + service.get_dataset_versions("") + + # Ensure our validation code path is exercised + assert "dataset_uuid must be a valid UUID" in str(excinfo.value) From 5006045d5e318d54a6e3c4ce4a0e4b56e6e8e2c3 Mon Sep 17 00:00:00 2001 From: Irfan Baig Date: Fri, 8 May 2026 11:20:00 -0500 Subject: [PATCH 4/4] feat: Check for empty (all zeroes) dataset_uuid --- dataconnect/service/default.py | 3 +++ tests/test_service.py | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/dataconnect/service/default.py b/dataconnect/service/default.py index 52ea9dd..204586f 100644 --- a/dataconnect/service/default.py +++ b/dataconnect/service/default.py @@ -80,6 +80,9 @@ def get_dataset_versions(self, dataset_uuid: UUID) -> list[DatasetVersion]: 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)}) try: diff --git a/tests/test_service.py b/tests/test_service.py index ed0e92d..2873713 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -135,3 +135,15 @@ def test_get_dataset_versions_raises_validation_error_on_empty_input() -> None: # Ensure our validation code path is exercised assert "dataset_uuid must be a valid UUID" in str(excinfo.value) + + +def test_get_dataset_versions_raises_validation_error_on_zero_input() -> None: + """Passing an empty string to the service should raise ValidationError.""" + transport = _FakeTransport(resources=[]) + service = DefaultDataConnectService(transport) + + with pytest.raises(ValidationError) as excinfo: + service.get_dataset_versions(UUID(int=0)) + + # Ensure our validation code path is exercised + assert "dataset_uuid must not be empty" in str(excinfo.value)