From fdd87ad39acbc997bb423cf5c67ac491374ddd4b Mon Sep 17 00:00:00 2001 From: azzamallow Date: Tue, 10 Mar 2026 14:06:38 +1100 Subject: [PATCH 1/5] Add v2 API support with factory pattern Extract BaseMDClient from MDClient, split into MDClientV1 and MDClientV2. MDClient() factory returns the correct client based on version param. V2 client exposes uploads (replaces experiments), datasets (flat payload, cancel support), jobs, and health resources. File uploader accepts resource_path for v2 /uploads endpoint. --- src/md_python/__init__.py | 6 + src/md_python/base_client.py | 55 +++++ src/md_python/client.py | 84 +++----- src/md_python/client_v1.py | 20 ++ src/md_python/client_v2.py | 22 ++ src/md_python/models/dataset_builders.py | 4 +- src/md_python/resources/datasets.py | 4 +- src/md_python/resources/experiments.py | 4 +- src/md_python/resources/health.py | 4 +- src/md_python/resources/v2/__init__.py | 9 + src/md_python/resources/v2/datasets.py | 160 ++++++++++++++ src/md_python/resources/v2/jobs.py | 34 +++ src/md_python/resources/v2/uploads.py | 154 ++++++++++++++ src/md_python/uploads.py | 7 +- tests/resources/test_datasets.py | 2 +- tests/resources/test_datasets_wait.py | 2 +- tests/resources/test_experiments.py | 2 +- tests/resources/test_experiments_wait.py | 2 +- tests/resources/test_health.py | 2 +- tests/resources/v2/__init__.py | 0 tests/resources/v2/test_datasets.py | 260 +++++++++++++++++++++++ tests/resources/v2/test_jobs.py | 55 +++++ tests/resources/v2/test_uploads.py | 222 +++++++++++++++++++ tests/test_client_factory.py | 99 +++++++++ 24 files changed, 1139 insertions(+), 74 deletions(-) create mode 100644 src/md_python/base_client.py create mode 100644 src/md_python/client_v1.py create mode 100644 src/md_python/client_v2.py create mode 100644 src/md_python/resources/v2/__init__.py create mode 100644 src/md_python/resources/v2/datasets.py create mode 100644 src/md_python/resources/v2/jobs.py create mode 100644 src/md_python/resources/v2/uploads.py create mode 100644 tests/resources/v2/__init__.py create mode 100644 tests/resources/v2/test_datasets.py create mode 100644 tests/resources/v2/test_jobs.py create mode 100644 tests/resources/v2/test_uploads.py create mode 100644 tests/test_client_factory.py diff --git a/src/md_python/__init__.py b/src/md_python/__init__.py index 295c30f..6bfbe1d 100644 --- a/src/md_python/__init__.py +++ b/src/md_python/__init__.py @@ -2,7 +2,10 @@ MD Python Client - A Python client for the Mass Dynamics API """ +from .base_client import BaseMDClient from .client import MDClient +from .client_v1 import MDClientV1 +from .client_v2 import MDClientV2 from .models import ( Dataset, Experiment, @@ -16,6 +19,9 @@ __all__ = [ "MDClient", + "MDClientV1", + "MDClientV2", + "BaseMDClient", "Experiment", "Dataset", "SampleMetadata", diff --git a/src/md_python/base_client.py b/src/md_python/base_client.py new file mode 100644 index 0000000..d27531f --- /dev/null +++ b/src/md_python/base_client.py @@ -0,0 +1,55 @@ +""" +Base client class for the MD Python client +""" + +import os +from typing import Optional + +import requests +from dotenv import load_dotenv + +load_dotenv() + +DEFAULT_BASE_URL = "https://app.massdynamics.com/api" + + +class BaseMDClient: + """Base client with shared auth, base URL, and HTTP transport""" + + ACCEPT_HEADER: str + + base_url: str + api_token: str + + def __init__(self, api_token: Optional[str] = None, base_url: Optional[str] = None): + base = base_url or os.getenv("MD_API_BASE_URL") or DEFAULT_BASE_URL + token = api_token or os.getenv("MD_AUTH_TOKEN") + + if not token: + raise ValueError("MD_AUTH_TOKEN must be set or passed as api_token") + + self.base_url: str = base + self.api_token: str = token + + def _get_headers(self) -> dict: + """Get common headers for API requests""" + return { + "accept": self.ACCEPT_HEADER, + "Authorization": f"Bearer {self.api_token}", + } + + def _make_request( + self, + method: str, + endpoint: str, + headers: Optional[dict] = None, + json: Optional[dict] = None, + ) -> requests.Response: + """Make HTTP request to the API""" + url = f"{self.base_url}{endpoint}" + request_headers = self._get_headers() + + if headers: + request_headers.update(headers) + + return requests.request(method, url, headers=request_headers, json=json) diff --git a/src/md_python/client.py b/src/md_python/client.py index 659ec47..0726aa2 100644 --- a/src/md_python/client.py +++ b/src/md_python/client.py @@ -1,63 +1,31 @@ """ -Main client class for the MD Python client +MDClient factory for the MD Python client """ -import os from typing import Optional -import requests -from dotenv import load_dotenv - -from .resources import Datasets, Experiments, Health - -# Load environment variables from .env file -load_dotenv() - - -class MDClient: - """Enhanced MD Client that combines simplicity with type safety""" - - base_url: str # Default base URL - api_token: str - - def __init__(self, api_token: Optional[str] = None, base_url: Optional[str] = None): - - base = base_url or os.getenv("MD_API_BASE_URL") - token = api_token or os.getenv("MD_AUTH_TOKEN") - - if not base: - raise ValueError("MD_API_BASE_URL must be set or passed as base_url") - if not token: - raise ValueError("MD_AUTH_TOKEN must be set or passed as api_token") - - self.base_url: str = base - self.api_token: str = token - - # Nested resource structure - self.health = Health(self) - self.experiments = Experiments(self) - self.datasets = Datasets(self) - - def _get_headers(self) -> dict: - """Get common headers for API requests""" - return { - "accept": "application/vnd.md-v1+json", - "Authorization": f"Bearer {self.api_token}", - } - - def _make_request( - self, - method: str, - endpoint: str, - headers: Optional[dict] = None, - json: Optional[dict] = None, - ) -> requests.Response: - """Make HTTP request to the API""" - url = f"{self.base_url}{endpoint}" - request_headers = self._get_headers() - - # Merge any additional headers if provided - if headers: - request_headers.update(headers) - - return requests.request(method, url, headers=request_headers, json=json) +from .base_client import BaseMDClient +from .client_v1 import MDClientV1 +from .client_v2 import MDClientV2 + + +def MDClient( + api_token: Optional[str] = None, + base_url: Optional[str] = None, + version: str = "v1", +) -> BaseMDClient: + """Factory that returns the correct client for the requested API version. + + Args: + api_token: Bearer token for authentication + base_url: API base URL (defaults to MD_API_BASE_URL env var or production) + version: API version — "v1" or "v2" + + Returns: + MDClientV1 or MDClientV2 + """ + if version == "v1": + return MDClientV1(api_token=api_token, base_url=base_url) + if version == "v2": + return MDClientV2(api_token=api_token, base_url=base_url) + raise ValueError(f"Unsupported API version: {version}. Use 'v1' or 'v2'.") diff --git a/src/md_python/client_v1.py b/src/md_python/client_v1.py new file mode 100644 index 0000000..36a6e09 --- /dev/null +++ b/src/md_python/client_v1.py @@ -0,0 +1,20 @@ +""" +V1 API client for the MD Python client +""" + +from typing import Optional + +from .base_client import BaseMDClient +from .resources import Datasets, Experiments, Health + + +class MDClientV1(BaseMDClient): + """V1 API client — experiments, datasets, health""" + + ACCEPT_HEADER = "application/vnd.md-v1+json" + + def __init__(self, api_token: Optional[str] = None, base_url: Optional[str] = None): + super().__init__(api_token=api_token, base_url=base_url) + self.health = Health(self) + self.experiments = Experiments(self) + self.datasets = Datasets(self) diff --git a/src/md_python/client_v2.py b/src/md_python/client_v2.py new file mode 100644 index 0000000..bfa66a3 --- /dev/null +++ b/src/md_python/client_v2.py @@ -0,0 +1,22 @@ +""" +V2 API client for the MD Python client +""" + +from typing import Optional + +from .base_client import BaseMDClient +from .resources import Health +from .resources.v2 import Datasets, Jobs, Uploads + + +class MDClientV2(BaseMDClient): + """V2 API client — uploads, datasets, jobs, health""" + + ACCEPT_HEADER = "application/vnd.md-v2+json" + + def __init__(self, api_token: Optional[str] = None, base_url: Optional[str] = None): + super().__init__(api_token=api_token, base_url=base_url) + self.health = Health(self) + self.uploads = Uploads(self) + self.datasets = Datasets(self) + self.jobs = Jobs(self) diff --git a/src/md_python/models/dataset_builders.py b/src/md_python/models/dataset_builders.py index 50884f6..1548597 100644 --- a/src/md_python/models/dataset_builders.py +++ b/src/md_python/models/dataset_builders.py @@ -9,7 +9,7 @@ from .metadata import SampleMetadata if TYPE_CHECKING: - from ..client import MDClient + from ..base_client import BaseMDClient @pydantic_dataclass @@ -31,7 +31,7 @@ def validate(self) -> None: """Validate input fields; subclasses must implement.""" ... - def run(self, client: "MDClient") -> str: + def run(self, client: "BaseMDClient") -> str: """Create the dataset via the API and return the new dataset_id.""" self.validate() return client.datasets.create(self.to_dataset()) diff --git a/src/md_python/resources/datasets.py b/src/md_python/resources/datasets.py index 3428b06..5c2440c 100644 --- a/src/md_python/resources/datasets.py +++ b/src/md_python/resources/datasets.py @@ -8,13 +8,13 @@ from ..models import Dataset if TYPE_CHECKING: - from ..client import MDClient + from ..base_client import BaseMDClient class Datasets: """Datasets resource""" - def __init__(self, client: "MDClient"): + def __init__(self, client: "BaseMDClient"): self._client = client def create(self, dataset: Dataset) -> str: diff --git a/src/md_python/resources/experiments.py b/src/md_python/resources/experiments.py index bcded45..cb7d450 100644 --- a/src/md_python/resources/experiments.py +++ b/src/md_python/resources/experiments.py @@ -9,13 +9,13 @@ from ..uploads import Uploads if TYPE_CHECKING: - from ..client import MDClient + from ..base_client import BaseMDClient class Experiments: """Experiments resource""" - def __init__(self, client: "MDClient"): + def __init__(self, client: "BaseMDClient"): self._client = client self._uploads = Uploads(client) diff --git a/src/md_python/resources/health.py b/src/md_python/resources/health.py index 42a3f87..ab77970 100644 --- a/src/md_python/resources/health.py +++ b/src/md_python/resources/health.py @@ -5,13 +5,13 @@ from typing import TYPE_CHECKING, Any, Dict if TYPE_CHECKING: - from ..client import MDClient + from ..base_client import BaseMDClient class Health: """Health check resource""" - def __init__(self, client: "MDClient"): + def __init__(self, client: "BaseMDClient"): self._client = client def check(self) -> Dict[str, Any]: diff --git a/src/md_python/resources/v2/__init__.py b/src/md_python/resources/v2/__init__.py new file mode 100644 index 0000000..e53e15a --- /dev/null +++ b/src/md_python/resources/v2/__init__.py @@ -0,0 +1,9 @@ +""" +V2 resource classes for the MD Python client +""" + +from .datasets import Datasets +from .jobs import Jobs +from .uploads import Uploads + +__all__ = ["Uploads", "Datasets", "Jobs"] diff --git a/src/md_python/resources/v2/datasets.py b/src/md_python/resources/v2/datasets.py new file mode 100644 index 0000000..5917c7d --- /dev/null +++ b/src/md_python/resources/v2/datasets.py @@ -0,0 +1,160 @@ +""" +Datasets resource for the MD Python v2 client +""" + +import time +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from ...models import Dataset + +if TYPE_CHECKING: + from ...base_client import BaseMDClient + + +class Datasets: + """V2 datasets resource — flat payload, no wrapper""" + + def __init__(self, client: "BaseMDClient"): + self._client = client + + def create(self, dataset: Dataset) -> str: + """Create a new dataset. + + V2 uses a flat payload (no wrapping 'dataset' key). + + Args: + dataset: Dataset object with creation parameters + + Returns: + Created dataset ID + """ + payload: Dict[str, Any] = { + "input_dataset_ids": [ + str(dataset_id) for dataset_id in dataset.input_dataset_ids + ], + "name": dataset.name, + "job_slug": dataset.job_slug, + "job_run_params": dataset.job_run_params or {}, + } + + response = self._client._make_request( + method="POST", + endpoint="/datasets", + json=payload, + headers={"Content-Type": "application/json"}, + ) + + if response.status_code in (200, 201): + return str(response.json()["dataset_id"]) + else: + raise Exception( + f"Failed to create dataset: {response.status_code} - {response.text}" + ) + + def list_by_experiment(self, experiment_id: str) -> List[Dataset]: + """Get datasets belonging to an experiment""" + response = self._client._make_request( + method="GET", + endpoint=f"/datasets?experiment_id={experiment_id}", + ) + + if response.status_code == 200: + return [Dataset.from_json(d) for d in response.json()] + else: + raise Exception( + f"Failed to get datasets: {response.status_code} - {response.text}" + ) + + def delete(self, dataset_id: str) -> bool: + """Delete a dataset by ID""" + response = self._client._make_request( + method="DELETE", + endpoint=f"/datasets/{dataset_id}", + ) + + if response.status_code == 204: + return True + else: + raise Exception( + f"Failed to delete dataset: {response.status_code} - {response.text}" + ) + + def retry(self, dataset_id: str) -> bool: + """Retry a failed dataset""" + response = self._client._make_request( + method="POST", + endpoint=f"/datasets/{dataset_id}/retry", + ) + + if response.status_code == 200: + return True + else: + raise Exception( + f"Failed to retry dataset: {response.status_code} - {response.text}" + ) + + def cancel(self, dataset_id: str) -> bool: + """Cancel a processing dataset""" + response = self._client._make_request( + method="POST", + endpoint=f"/datasets/{dataset_id}/cancel", + ) + + if response.status_code == 200: + return True + else: + raise Exception( + f"Failed to cancel dataset: {response.status_code} - {response.text}" + ) + + def wait_until_complete( + self, + experiment_id: str, + dataset_id: str, + poll_s: int = 5, + timeout_s: int = 1800, + ) -> Dataset: + """Poll the dataset until it reaches a terminal state.""" + end = time.monotonic() + timeout_s + last: Optional[str] = None + while time.monotonic() < end: + dds = self.list_by_experiment(experiment_id=experiment_id) + ds = next((d for d in dds if str(d.id) == dataset_id), None) + if ds: + state = ds.state + if state != last: + print(f"state={state}") + last = state + + if state in {"COMPLETED"}: + return ds + elif state in {"FAILED", "ERROR", "CANCELLED"}: + raise Exception(f"Dataset {dataset_id} failed: {state}") + else: + if last is None: + print("waiting for dataset to appear...") + time.sleep(poll_s) + + raise TimeoutError( + f"Dataset {dataset_id} not terminal within {timeout_s}s (last state={last})" + ) + + def find_initial_dataset(self, experiment_id: str) -> Optional[Dataset]: + """Return the initial dataset for an experiment.""" + datasets = self.list_by_experiment(experiment_id=experiment_id) + + if not datasets: + raise ValueError(f"No datasets found for experiment {experiment_id}") + + intensity = [d for d in datasets if getattr(d, "type", None) == "INTENSITY"] + if not intensity: + raise ValueError( + f"No intensity dataset found for experiment {experiment_id}" + ) + + if len(intensity) == 1: + return intensity[0] + + raise ValueError( + f"Multiple intensity datasets found for experiment {experiment_id}" + ) diff --git a/src/md_python/resources/v2/jobs.py b/src/md_python/resources/v2/jobs.py new file mode 100644 index 0000000..5926fa0 --- /dev/null +++ b/src/md_python/resources/v2/jobs.py @@ -0,0 +1,34 @@ +""" +Jobs resource for the MD Python v2 client +""" + +from typing import TYPE_CHECKING, Any, Dict, List + +if TYPE_CHECKING: + from ...base_client import BaseMDClient + + +class Jobs: + """V2 jobs resource""" + + def __init__(self, client: "BaseMDClient"): + self._client = client + + def list(self) -> List[Dict[str, Any]]: + """List all available dataset jobs. + + Returns: + List of job dictionaries with id, name, slug, etc. + """ + response = self._client._make_request( + method="GET", + endpoint="/jobs", + ) + + if response.status_code == 200: + result: List[Dict[str, Any]] = response.json() + return result + else: + raise Exception( + f"Failed to list jobs: {response.status_code} - {response.text}" + ) diff --git a/src/md_python/resources/v2/uploads.py b/src/md_python/resources/v2/uploads.py new file mode 100644 index 0000000..dc6201e --- /dev/null +++ b/src/md_python/resources/v2/uploads.py @@ -0,0 +1,154 @@ +""" +Uploads resource for the MD Python v2 client +""" + +import time +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from ...models import Experiment, SampleMetadata +from ...uploads import Uploads as FileUploader + +if TYPE_CHECKING: + from ...base_client import BaseMDClient + + +class Uploads: + """V2 uploads resource — replaces v1 experiments""" + + def __init__(self, client: "BaseMDClient"): + self._client = client + self._uploader = FileUploader(client, resource_path="/uploads") + + def create(self, experiment: Experiment) -> str: + """Create a new upload and optionally upload files. + + Args: + experiment: Experiment object with upload configuration + + Returns: + Upload ID (experiment UUID) + """ + if not experiment.file_location and not experiment.s3_bucket: + raise ValueError( + "Either file_location or s3_bucket must be provided" + ) + + if experiment.file_location and not experiment.filenames: + raise ValueError("filenames must be provided when using file_location") + + payload: Dict[str, Any] = { + "name": experiment.name, + "source": experiment.source, + "filenames": experiment.filenames, + } + + if experiment.file_location: + payload["file_location"] = experiment.file_location + if experiment.filenames: + file_sizes = self._uploader.file_sizes_for_api( + experiment.filenames, experiment.file_location + ) + payload["file_sizes"] = file_sizes + else: + payload["s3_bucket"] = experiment.s3_bucket + payload["s3_prefix"] = experiment.s3_prefix + + response = self._client._make_request( + method="POST", + endpoint="/uploads", + json=payload, + headers={"Content-Type": "application/json"}, + ) + + if response.status_code not in (200, 201): + raise Exception( + f"Failed to create upload: {response.status_code} - {response.text}" + ) + + response_data = response.json() + upload_id = str(response_data["id"]) + + if "uploads" in response_data and experiment.file_location: + self._uploader.upload_files( + response_data["uploads"], experiment.file_location, upload_id + ) + self._client._make_request( + method="POST", + endpoint=f"/uploads/{upload_id}/start_workflow", + headers={"Content-Type": "application/json"}, + ) + + return upload_id + + def get_by_id(self, upload_id: str) -> Optional[Experiment]: + """Get an upload by its ID""" + response = self._client._make_request( + method="GET", endpoint=f"/uploads/{upload_id}" + ) + + if response.status_code == 200: + return Experiment.from_json(response.json()) + else: + raise Exception( + f"Failed to get upload: {response.status_code} - {response.text}" + ) + + def get_by_name(self, name: str) -> Optional[Experiment]: + """Get an upload by its name""" + response = self._client._make_request( + method="GET", endpoint=f"/uploads?name={name}" + ) + + if response.status_code == 200: + return Experiment.from_json(response.json()) + else: + raise Exception( + f"Failed to get upload by name: {response.status_code} - {response.text}" + ) + + def update_sample_metadata( + self, upload_id: str, sample_metadata: SampleMetadata + ) -> bool: + """Update an upload's sample metadata""" + response = self._client._make_request( + method="PUT", + endpoint=f"/uploads/{upload_id}/sample_metadata", + json={"sample_metadata": sample_metadata.data}, + headers={"Content-Type": "application/json"}, + ) + + if response.status_code == 200: + return True + else: + raise Exception( + f"Failed to update sample metadata: {response.status_code} - {response.text}" + ) + + def wait_until_complete( + self, upload_id: str, poll_s: int = 5, timeout_s: int = 1800 + ) -> Experiment: + """Poll the upload until it reaches a terminal state.""" + end = time.monotonic() + timeout_s + last: Optional[str] = None + while time.monotonic() < end: + exp = self.get_by_id(upload_id) + status = getattr(exp, "status", None) + if status != last: + print(f"status={status}") + last = status + + if not status: + time.sleep(poll_s) + continue + + s = status.upper() + if s in {"COMPLETED"}: + return exp # type: ignore[return-value] + if s in {"FAILED", "ERROR", "CANCELLED"}: + raise Exception(f"Upload {upload_id} failed: {status}") + + time.sleep(poll_s) + + raise TimeoutError( + f"Upload {upload_id} not terminal within {timeout_s}s (last status={last})" + ) diff --git a/src/md_python/uploads.py b/src/md_python/uploads.py index 5b02936..e2eb1a6 100644 --- a/src/md_python/uploads.py +++ b/src/md_python/uploads.py @@ -8,14 +8,15 @@ import requests if TYPE_CHECKING: - from .client import MDClient + from .base_client import BaseMDClient class Uploads: """File upload for the MD Python client""" - def __init__(self, client: "MDClient"): + def __init__(self, client: "BaseMDClient", resource_path: str = "/experiments"): self._client = client + self._resource_path = resource_path def _get_file_path(self, file_location: str, filename: str) -> str: """File path from location and filename @@ -169,7 +170,7 @@ def complete_multipart_upload( """ response = self._client._make_request( method="POST", - endpoint=f"/experiments/{experiment_id}/uploads/complete", + endpoint=f"{self._resource_path}/{experiment_id}/uploads/complete", json={"filename": filename, "upload_id": upload_session_id}, headers={"Content-Type": "application/json"}, ) diff --git a/tests/resources/test_datasets.py b/tests/resources/test_datasets.py index d2b75b4..cc9f882 100644 --- a/tests/resources/test_datasets.py +++ b/tests/resources/test_datasets.py @@ -7,7 +7,7 @@ import pytest -from md_python.client import MDClient +from md_python.client import MDClientV1 as MDClient from md_python.models import Dataset from md_python.resources.datasets import Datasets diff --git a/tests/resources/test_datasets_wait.py b/tests/resources/test_datasets_wait.py index 3254d29..dbecf27 100644 --- a/tests/resources/test_datasets_wait.py +++ b/tests/resources/test_datasets_wait.py @@ -2,7 +2,7 @@ import pytest -from md_python.client import MDClient +from md_python.client import MDClientV1 as MDClient from md_python.models import Dataset from md_python.resources.datasets import Datasets diff --git a/tests/resources/test_experiments.py b/tests/resources/test_experiments.py index 09ec439..ee9df03 100644 --- a/tests/resources/test_experiments.py +++ b/tests/resources/test_experiments.py @@ -2,7 +2,7 @@ import pytest -from md_python.client import MDClient +from md_python.client import MDClientV1 as MDClient from md_python.models import Experiment, ExperimentDesign, SampleMetadata from md_python.resources.experiments import Experiments diff --git a/tests/resources/test_experiments_wait.py b/tests/resources/test_experiments_wait.py index f9efe0f..de2532a 100644 --- a/tests/resources/test_experiments_wait.py +++ b/tests/resources/test_experiments_wait.py @@ -1,6 +1,6 @@ import pytest -from md_python.client import MDClient +from md_python.client import MDClientV1 as MDClient from md_python.models import Experiment from md_python.resources.experiments import Experiments diff --git a/tests/resources/test_health.py b/tests/resources/test_health.py index 413c832..3633b10 100644 --- a/tests/resources/test_health.py +++ b/tests/resources/test_health.py @@ -2,7 +2,7 @@ import pytest -from md_python.client import MDClient +from md_python.client import MDClientV1 as MDClient from md_python.resources.health import Health diff --git a/tests/resources/v2/__init__.py b/tests/resources/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/resources/v2/test_datasets.py b/tests/resources/v2/test_datasets.py new file mode 100644 index 0000000..f868e02 --- /dev/null +++ b/tests/resources/v2/test_datasets.py @@ -0,0 +1,260 @@ +from unittest.mock import Mock +from uuid import UUID + +import pytest + +from md_python.client_v2 import MDClientV2 +from md_python.models import Dataset +from md_python.resources.v2.datasets import Datasets + + +class TestV2Datasets: + + @pytest.fixture + def mock_client(self): + return Mock(spec=MDClientV2) + + @pytest.fixture + def datasets(self, mock_client): + return Datasets(mock_client) + + @pytest.fixture + def sample_dataset(self): + return Dataset( + input_dataset_ids=[UUID("2b1a5c27-ac95-456c-b2ff-eccfb3ab3d1e")], + name="Test dataset", + job_slug="demo_flow", + job_run_params={"param": "value"}, + ) + + def test_create_success(self, datasets, sample_dataset, mock_client): + mock_response = Mock() + mock_response.status_code = 201 + mock_response.json.return_value = {"dataset_id": "abc123"} + mock_client._make_request.return_value = mock_response + + result = datasets.create(sample_dataset) + + assert result == "abc123" + call_args = mock_client._make_request.call_args + assert call_args[1]["method"] == "POST" + assert call_args[1]["endpoint"] == "/datasets" + + payload = call_args[1]["json"] + assert "dataset" not in payload + assert payload["name"] == "Test dataset" + assert payload["job_slug"] == "demo_flow" + assert payload["input_dataset_ids"] == ["2b1a5c27-ac95-456c-b2ff-eccfb3ab3d1e"] + assert payload["job_run_params"] == {"param": "value"} + + def test_create_uses_flat_payload(self, datasets, sample_dataset, mock_client): + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"dataset_id": "flat-id"} + mock_client._make_request.return_value = mock_response + + datasets.create(sample_dataset) + + payload = mock_client._make_request.call_args[1]["json"] + assert "dataset" not in payload + assert "name" in payload + assert "job_slug" in payload + + def test_create_does_not_include_sample_names(self, datasets, mock_client): + dataset = Dataset( + input_dataset_ids=[UUID("2b1a5c27-ac95-456c-b2ff-eccfb3ab3d1e")], + name="No samples", + job_slug="demo_flow", + job_run_params={}, + sample_names=["s1", "s2"], + ) + mock_response = Mock() + mock_response.status_code = 201 + mock_response.json.return_value = {"dataset_id": "no-samples"} + mock_client._make_request.return_value = mock_response + + datasets.create(dataset) + + payload = mock_client._make_request.call_args[1]["json"] + assert "sample_names" not in payload + + def test_create_failure(self, datasets, sample_dataset, mock_client): + mock_response = Mock() + mock_response.status_code = 400 + mock_response.text = "Bad Request" + mock_client._make_request.return_value = mock_response + + with pytest.raises(Exception, match="Failed to create dataset: 400"): + datasets.create(sample_dataset) + + def test_list_by_experiment_success(self, datasets, mock_client): + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + { + "id": "a1b2c3d4e5f67890a1b2c3d4e5f67890", + "name": "DS1", + "job_slug": "flow_1", + "job_run_params": {}, + } + ] + mock_client._make_request.return_value = mock_response + + result = datasets.list_by_experiment("exp-1") + + assert len(result) == 1 + assert isinstance(result[0], Dataset) + assert result[0].name == "DS1" + + call_args = mock_client._make_request.call_args + assert call_args[1]["endpoint"] == "/datasets?experiment_id=exp-1" + + def test_list_by_experiment_no_custom_headers(self, datasets, mock_client): + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [] + mock_client._make_request.return_value = mock_response + + datasets.list_by_experiment("exp-1") + + call_args = mock_client._make_request.call_args + assert "headers" not in call_args[1] or call_args[1].get("headers") is None + + def test_list_by_experiment_failure(self, datasets, mock_client): + mock_response = Mock() + mock_response.status_code = 500 + mock_response.text = "Internal error" + mock_client._make_request.return_value = mock_response + + with pytest.raises(Exception, match="Failed to get datasets: 500"): + datasets.list_by_experiment("exp-1") + + def test_delete_success(self, datasets, mock_client): + mock_response = Mock() + mock_response.status_code = 204 + mock_client._make_request.return_value = mock_response + + result = datasets.delete("ds-1") + + assert result is True + call_args = mock_client._make_request.call_args + assert call_args[1]["method"] == "DELETE" + assert call_args[1]["endpoint"] == "/datasets/ds-1" + + def test_delete_failure(self, datasets, mock_client): + mock_response = Mock() + mock_response.status_code = 404 + mock_response.text = "Not found" + mock_client._make_request.return_value = mock_response + + with pytest.raises(Exception, match="Failed to delete dataset: 404"): + datasets.delete("ds-1") + + def test_retry_success(self, datasets, mock_client): + mock_response = Mock() + mock_response.status_code = 200 + mock_client._make_request.return_value = mock_response + + result = datasets.retry("ds-1") + + assert result is True + call_args = mock_client._make_request.call_args + assert call_args[1]["method"] == "POST" + assert call_args[1]["endpoint"] == "/datasets/ds-1/retry" + + def test_retry_failure(self, datasets, mock_client): + mock_response = Mock() + mock_response.status_code = 500 + mock_response.text = "Server error" + mock_client._make_request.return_value = mock_response + + with pytest.raises(Exception, match="Failed to retry dataset: 500"): + datasets.retry("ds-1") + + def test_cancel_success(self, datasets, mock_client): + mock_response = Mock() + mock_response.status_code = 200 + mock_client._make_request.return_value = mock_response + + result = datasets.cancel("ds-1") + + assert result is True + call_args = mock_client._make_request.call_args + assert call_args[1]["method"] == "POST" + assert call_args[1]["endpoint"] == "/datasets/ds-1/cancel" + + def test_cancel_failure(self, datasets, mock_client): + mock_response = Mock() + mock_response.status_code = 400 + mock_response.text = "Cannot cancel" + mock_client._make_request.return_value = mock_response + + with pytest.raises(Exception, match="Failed to cancel dataset: 400"): + datasets.cancel("ds-1") + + def test_wait_until_complete_success(self, datasets, mock_client, mocker): + completed_ds = Dataset( + input_dataset_ids=[], + name="n", + job_slug="j", + job_run_params={}, + state="COMPLETED", + id=UUID("11111111-1111-1111-1111-111111111111"), + ) + mocker.patch.object(datasets, "list_by_experiment", return_value=[completed_ds]) + + result = datasets.wait_until_complete( + "exp-1", "11111111-1111-1111-1111-111111111111", poll_s=0, timeout_s=1 + ) + + assert isinstance(result, Dataset) + + def test_wait_until_complete_failure(self, datasets, mock_client, mocker): + failed_ds = Dataset( + input_dataset_ids=[], + name="n", + job_slug="j", + job_run_params={}, + state="FAILED", + id=UUID("11111111-1111-1111-1111-111111111111"), + ) + mocker.patch.object(datasets, "list_by_experiment", return_value=[failed_ds]) + + with pytest.raises(Exception, match="failed"): + datasets.wait_until_complete( + "exp-1", "11111111-1111-1111-1111-111111111111", poll_s=0, timeout_s=1 + ) + + def test_find_initial_dataset_success(self, datasets, mock_client, mocker): + ds = Dataset( + input_dataset_ids=[], + name="n", + job_slug="j", + job_run_params={}, + id=UUID("11111111-1111-1111-1111-111111111111"), + ) + ds.type = "INTENSITY" + mocker.patch.object(datasets, "list_by_experiment", return_value=[ds]) + + result = datasets.find_initial_dataset("exp-1") + + assert result is ds + + def test_find_initial_dataset_no_datasets(self, datasets, mock_client, mocker): + mocker.patch.object(datasets, "list_by_experiment", return_value=[]) + + with pytest.raises(ValueError, match="No datasets found"): + datasets.find_initial_dataset("exp-1") + + def test_find_initial_dataset_no_intensity(self, datasets, mock_client, mocker): + ds = Dataset( + input_dataset_ids=[], + name="n", + job_slug="j", + job_run_params={}, + ) + ds.type = "OTHER" + mocker.patch.object(datasets, "list_by_experiment", return_value=[ds]) + + with pytest.raises(ValueError, match="No intensity dataset"): + datasets.find_initial_dataset("exp-1") diff --git a/tests/resources/v2/test_jobs.py b/tests/resources/v2/test_jobs.py new file mode 100644 index 0000000..e399df0 --- /dev/null +++ b/tests/resources/v2/test_jobs.py @@ -0,0 +1,55 @@ +from unittest.mock import Mock + +import pytest + +from md_python.client_v2 import MDClientV2 +from md_python.resources.v2.jobs import Jobs + + +class TestV2Jobs: + + @pytest.fixture + def mock_client(self): + return Mock(spec=MDClientV2) + + @pytest.fixture + def jobs(self, mock_client): + return Jobs(mock_client) + + def test_list_success(self, jobs, mock_client): + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + {"id": 1, "name": "Demo Flow", "slug": "demo_flow"}, + {"id": 2, "name": "Pairwise Comparison", "slug": "pairwise_comparison"}, + ] + mock_client._make_request.return_value = mock_response + + result = jobs.list() + + assert len(result) == 2 + assert result[0]["slug"] == "demo_flow" + assert result[1]["slug"] == "pairwise_comparison" + + call_args = mock_client._make_request.call_args + assert call_args[1]["method"] == "GET" + assert call_args[1]["endpoint"] == "/jobs" + + def test_list_empty(self, jobs, mock_client): + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [] + mock_client._make_request.return_value = mock_response + + result = jobs.list() + + assert result == [] + + def test_list_failure(self, jobs, mock_client): + mock_response = Mock() + mock_response.status_code = 500 + mock_response.text = "Internal Server Error" + mock_client._make_request.return_value = mock_response + + with pytest.raises(Exception, match="Failed to list jobs: 500"): + jobs.list() diff --git a/tests/resources/v2/test_uploads.py b/tests/resources/v2/test_uploads.py new file mode 100644 index 0000000..f8c4225 --- /dev/null +++ b/tests/resources/v2/test_uploads.py @@ -0,0 +1,222 @@ +from unittest.mock import Mock, patch + +import pytest + +from md_python.client_v2 import MDClientV2 +from md_python.models import Experiment, SampleMetadata +from md_python.resources.v2.uploads import Uploads + + +class TestV2Uploads: + + @pytest.fixture + def mock_client(self): + return Mock(spec=MDClientV2) + + @pytest.fixture + def uploads(self, mock_client): + return Uploads(mock_client) + + def test_create_with_s3_bucket(self, uploads, mock_client): + exp = Experiment( + name="S3 Upload", + source="maxquant", + s3_bucket="my-bucket", + s3_prefix="data/", + filenames=["a.txt"], + ) + + mock_response = Mock() + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "upload-123"} + mock_client._make_request.return_value = mock_response + + result = uploads.create(exp) + + assert result == "upload-123" + call_args = mock_client._make_request.call_args + assert call_args[1]["method"] == "POST" + assert call_args[1]["endpoint"] == "/uploads" + + payload = call_args[1]["json"] + assert payload["name"] == "S3 Upload" + assert payload["s3_bucket"] == "my-bucket" + assert payload["s3_prefix"] == "data/" + + def test_create_with_file_location(self, uploads, mock_client): + exp = Experiment( + name="Local Upload", + source="maxquant", + file_location="/tmp/files", + filenames=["data.raw"], + ) + + mock_response = Mock() + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "upload-456"} + mock_client._make_request.return_value = mock_response + + with patch.object(uploads._uploader, "file_sizes_for_api", return_value=[None]): + with patch.object(uploads._uploader, "upload_files"): + result = uploads.create(exp) + + assert result == "upload-456" + + def test_create_with_file_upload_triggers_workflow(self, uploads, mock_client): + exp = Experiment( + name="Upload With Files", + source="maxquant", + file_location="/tmp/files", + filenames=["data.raw"], + ) + + create_response = Mock() + create_response.status_code = 201 + create_response.json.return_value = { + "id": "upload-789", + "uploads": [{"filename": "data.raw", "url": "https://s3/presigned"}], + } + + workflow_response = Mock() + workflow_response.status_code = 200 + + mock_client._make_request.side_effect = [create_response, workflow_response] + + with patch.object(uploads._uploader, "file_sizes_for_api", return_value=[None]): + with patch.object(uploads._uploader, "upload_files"): + uploads.create(exp) + + assert mock_client._make_request.call_count == 2 + workflow_call = mock_client._make_request.call_args_list[1] + assert workflow_call[1]["method"] == "POST" + assert workflow_call[1]["endpoint"] == "/uploads/upload-789/start_workflow" + + def test_create_validation_no_source(self, uploads): + exp = Experiment(name="Bad", source="maxquant", filenames=[]) + + with pytest.raises(ValueError, match="file_location or s3_bucket"): + uploads.create(exp) + + def test_create_validation_file_location_without_filenames(self, uploads): + exp = Experiment( + name="Bad", + source="maxquant", + file_location="/tmp", + filenames=[], + ) + + with pytest.raises(ValueError, match="filenames must be provided"): + uploads.create(exp) + + def test_create_failure(self, uploads, mock_client): + exp = Experiment( + name="Fail", + source="maxquant", + s3_bucket="bucket", + filenames=["a.txt"], + ) + + mock_response = Mock() + mock_response.status_code = 422 + mock_response.text = "Unprocessable" + mock_client._make_request.return_value = mock_response + + with pytest.raises(Exception, match="Failed to create upload: 422"): + uploads.create(exp) + + def test_get_by_id_success(self, uploads, mock_client): + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "name": "Test Upload", + "source": "maxquant", + "status": "COMPLETED", + } + mock_client._make_request.return_value = mock_response + + result = uploads.get_by_id("upload-1") + + assert isinstance(result, Experiment) + assert result.name == "Test Upload" + + call_args = mock_client._make_request.call_args + assert call_args[1]["endpoint"] == "/uploads/upload-1" + + def test_get_by_id_failure(self, uploads, mock_client): + mock_response = Mock() + mock_response.status_code = 404 + mock_response.text = "Not found" + mock_client._make_request.return_value = mock_response + + with pytest.raises(Exception, match="Failed to get upload: 404"): + uploads.get_by_id("bad-id") + + def test_get_by_name_success(self, uploads, mock_client): + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "name": "Named Upload", + "source": "maxquant", + } + mock_client._make_request.return_value = mock_response + + result = uploads.get_by_name("Named Upload") + + assert isinstance(result, Experiment) + assert result.name == "Named Upload" + + call_args = mock_client._make_request.call_args + assert call_args[1]["endpoint"] == "/uploads?name=Named Upload" + + def test_get_by_name_failure(self, uploads, mock_client): + mock_response = Mock() + mock_response.status_code = 404 + mock_response.text = "Not found" + mock_client._make_request.return_value = mock_response + + with pytest.raises(Exception, match="Failed to get upload by name: 404"): + uploads.get_by_name("nope") + + def test_update_sample_metadata_success(self, uploads, mock_client): + sm = SampleMetadata(data=[["group"], ["a"], ["b"]]) + + mock_response = Mock() + mock_response.status_code = 200 + mock_client._make_request.return_value = mock_response + + result = uploads.update_sample_metadata("upload-1", sm) + + assert result is True + call_args = mock_client._make_request.call_args + assert call_args[1]["method"] == "PUT" + assert call_args[1]["endpoint"] == "/uploads/upload-1/sample_metadata" + assert call_args[1]["json"] == {"sample_metadata": sm.data} + + def test_update_sample_metadata_failure(self, uploads, mock_client): + sm = SampleMetadata(data=[["group"], ["a"]]) + + mock_response = Mock() + mock_response.status_code = 422 + mock_response.text = "Invalid" + mock_client._make_request.return_value = mock_response + + with pytest.raises(Exception, match="Failed to update sample metadata: 422"): + uploads.update_sample_metadata("upload-1", sm) + + def test_wait_until_complete_success(self, uploads, mock_client, mocker): + exp = Experiment(name="x", source="s", s3_bucket="b", filenames=[], status="COMPLETED") + mocker.patch.object(uploads, "get_by_id", return_value=exp) + + result = uploads.wait_until_complete("upload-1", poll_s=0, timeout_s=1) + + assert isinstance(result, Experiment) + + def test_wait_until_complete_failure(self, uploads, mock_client, mocker): + exp = Experiment(name="x", source="s", s3_bucket="b", filenames=[], status="FAILED") + mocker.patch.object(uploads, "get_by_id", return_value=exp) + + with pytest.raises(Exception, match="failed"): + uploads.wait_until_complete("upload-1", poll_s=0, timeout_s=1) + + def test_uploader_uses_uploads_resource_path(self, uploads): + assert uploads._uploader._resource_path == "/uploads" diff --git a/tests/test_client_factory.py b/tests/test_client_factory.py new file mode 100644 index 0000000..aeac670 --- /dev/null +++ b/tests/test_client_factory.py @@ -0,0 +1,99 @@ +import pytest + +from md_python.base_client import BaseMDClient +from md_python.client import MDClient +from md_python.client_v1 import MDClientV1 +from md_python.client_v2 import MDClientV2 + + +class TestMDClientFactory: + + def test_default_returns_v1(self): + client = MDClient(api_token="tok") + assert isinstance(client, MDClientV1) + + def test_explicit_v1(self): + client = MDClient(api_token="tok", version="v1") + assert isinstance(client, MDClientV1) + + def test_explicit_v2(self): + client = MDClient(api_token="tok", version="v2") + assert isinstance(client, MDClientV2) + + def test_invalid_version_raises(self): + with pytest.raises(ValueError, match="Unsupported API version"): + MDClient(api_token="tok", version="v3") + + def test_both_are_base_client_subclasses(self): + v1 = MDClient(api_token="tok", version="v1") + v2 = MDClient(api_token="tok", version="v2") + assert isinstance(v1, BaseMDClient) + assert isinstance(v2, BaseMDClient) + + def test_custom_base_url_forwarded(self): + client = MDClient(api_token="tok", base_url="https://custom.com/api", version="v2") + assert client.base_url == "https://custom.com/api" + + +class TestMDClientV2: + + def test_accept_header(self): + client = MDClientV2(api_token="tok") + assert client.ACCEPT_HEADER == "application/vnd.md-v2+json" + + def test_headers_contain_v2_accept(self): + client = MDClientV2(api_token="tok") + headers = client._get_headers() + assert headers["accept"] == "application/vnd.md-v2+json" + assert headers["Authorization"] == "Bearer tok" + + def test_has_uploads_resource(self): + client = MDClientV2(api_token="tok") + assert hasattr(client, "uploads") + + def test_has_datasets_resource(self): + client = MDClientV2(api_token="tok") + assert hasattr(client, "datasets") + + def test_has_jobs_resource(self): + client = MDClientV2(api_token="tok") + assert hasattr(client, "jobs") + + def test_has_health_resource(self): + client = MDClientV2(api_token="tok") + assert hasattr(client, "health") + + def test_no_experiments_resource(self): + client = MDClientV2(api_token="tok") + assert not hasattr(client, "experiments") + + def test_missing_token_raises(self): + with pytest.raises(ValueError, match="MD_AUTH_TOKEN"): + MDClientV2() + + +class TestMDClientV1Resources: + + def test_has_experiments(self): + client = MDClientV1(api_token="tok") + assert hasattr(client, "experiments") + + def test_has_datasets(self): + client = MDClientV1(api_token="tok") + assert hasattr(client, "datasets") + + def test_has_health(self): + client = MDClientV1(api_token="tok") + assert hasattr(client, "health") + + def test_no_uploads_resource(self): + client = MDClientV1(api_token="tok") + assert not hasattr(client, "uploads") + + def test_no_jobs_resource(self): + client = MDClientV1(api_token="tok") + assert not hasattr(client, "jobs") + + def test_accept_header(self): + client = MDClientV1(api_token="tok") + assert client.ACCEPT_HEADER == "application/vnd.md-v1+json" From e355dfe03e75fbe558c77f8c3b7d96d415c83446 Mon Sep 17 00:00:00 2001 From: azzamallow Date: Tue, 10 Mar 2026 14:11:08 +1100 Subject: [PATCH 2/5] Default to v2, move v1 docs to V1.md --- README.md | 171 +++++++++++++++++++---------------- V1.md | 90 ++++++++++++++++++ src/md_python/client.py | 2 +- tests/test_client.py | 2 +- tests/test_client_factory.py | 4 +- 5 files changed, 188 insertions(+), 81 deletions(-) create mode 100644 V1.md diff --git a/README.md b/README.md index 28c7c55..1dc6713 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # MD Python Client -A Python client for the Mass Dynamics API that provides a simple and type-safe interface for managing experiments and datasets. +A Python client for the Mass Dynamics API. ## Installation @@ -10,117 +10,134 @@ A Python client for the Mass Dynamics API that provides a simple and type-safe i pip install git+https://github.com/MassDynamics/md-python.git ``` -## Available Resources - -- **Experiments**: Create, retrieve, and update experiments -- **Datasets**: Create, retrieve, retry and delete datasets -- **Health**: Check API health status - ## Quick Start ```python -from md_python import MDClient, Experiment, Dataset, SampleMetadata, ExperimentDesign +from md_python import MDClient -# Initialise client client = MDClient(api_token="your_api_token") +``` -# Check API health -health_status = client.health.check() +The client defaults to the v2 API. For v1 usage, see [V1.md](V1.md). -# Create an experiment -sample_metadata = SampleMetadata.from_csv("sample_metadata.csv") -experiment = Experiment( - name="My Experiment", - description="Test experiment", - sample_metadata=sample_metadata -) -experiment_id = client.experiments.create(experiment) +## Resources -# Get experiment by name -exp = client.experiments.get_by_name("My Experiment") +- **Uploads**: Create, retrieve, and manage file uploads +- **Datasets**: Create, list, retry, cancel, and delete datasets +- **Jobs**: List available dataset jobs +- **Health**: Check API health status + +## Uploads + +Uploads replace v1 experiments. They handle file ingestion and workflow triggering. + +```python +from md_python import Experiment, SampleMetadata + +# Create an upload from S3 +upload = Experiment( + name="My Upload", + source="maxquant", + s3_bucket="my-bucket", + s3_prefix="data/", + filenames=["evidence.txt", "proteinGroups.txt"], +) +upload_id = client.uploads.create(upload) + +# Create an upload from local files +upload = Experiment( + name="My Upload", + source="maxquant", + file_location="/path/to/files", + filenames=["evidence.txt", "proteinGroups.txt"], +) +upload_id = client.uploads.create(upload) -# Get experiment by ID -exp = client.experiments.get_by_id(experiment_id) +# Get upload by ID or name +upload = client.uploads.get_by_id(upload_id) +upload = client.uploads.get_by_name("My Upload") -# Update experiment sample metadata +# Update sample metadata sample_metadata = SampleMetadata(data=[ - ["sample_name", "dose"], - ["1", "1"], - ["2", "20"], + ["sample_name", "condition"], + ["sample1", "control"], + ["sample2", "treated"], ]) -success = client.experiments.update_sample_metadata(experiment_id, sample_metadata) +client.uploads.update_sample_metadata(upload_id, sample_metadata) + +# Wait for upload processing to complete +upload = client.uploads.wait_until_complete(upload_id) +``` +## Datasets -# Create a new dataset +```python from uuid import UUID -new_dataset = Dataset( +from md_python import Dataset + +# Create a dataset +dataset = Dataset( input_dataset_ids=[UUID("existing-dataset-id")], name="Processed Data", - job_slug="data_processing", - job_run_params={"parameter1": "value1", "parameter2": "value2"} + job_slug="pairwise_comparison", + job_run_params={"condition_column": "condition"}, ) -dataset_id = client.datasets.create(new_dataset) +dataset_id = client.datasets.create(dataset) + +# List datasets for an experiment +datasets = client.datasets.list_by_experiment(experiment_id) + +# Find the initial intensity dataset +initial = client.datasets.find_initial_dataset(experiment_id) # Retry a failed dataset -success = client.datasets.retry(dataset_id) +client.datasets.retry(dataset_id) + +# Cancel a processing dataset +client.datasets.cancel(dataset_id) # Delete a dataset -deleted = client.datasets.delete(dataset_id) +client.datasets.delete(dataset_id) + +# Wait for a dataset to complete +ds = client.datasets.wait_until_complete(experiment_id, dataset_id) +``` + +## Jobs + +```python +# List available dataset jobs +jobs = client.jobs.list() +``` + +## Health -# List all datasets for an experiment -experiment_datasets = client.datasets.list_by_experiment(experiment_id) +```python +health_status = client.health.check() ``` -## Examples +## Custom Base URL -Comprehensive examples demonstrating how to use the MD Python client are available in the `examples/` directory: +```python +client = MDClient( + api_token="your_api_token", + base_url="https://xxx.massdynamics-example-installation.com/api", +) +``` -- **Experiment Examples** (`examples/experiment/`): - - Create experiments - - Retrieve experiments by ID or name - - Update sample metadata +## V1 API -- **Dataset Examples** (`examples/dataset/`): - - Create datasets - - Delete datasets - - Retry failed datasets - - List datasets by experiment +For v1 API usage, pass `version="v1"` or see [V1.md](V1.md). -- **Health Examples** (`examples/health/`): - - Check API health status +```python +client = MDClient(api_token="your_api_token", version="v1") +``` ## Development ```bash -# Clone the repository git clone https://github.com/MassDynamics/md-python.git cd md-python - -# Install development dependencies pip install -e ".[dev]" - -# Run tests pytest - -# Run type checking -mypy . - -# Format code with Black -black . - -# Sort imports -isort . -``` - -### Using Custom Base URL - -When developing or testing against an environment, you can specify a custom base URL: - -```python -from md_python import MDClient - -client = MDClient( - api_token="your_api_token", - base_url="https://xxx.massdynamics-example-installation.com/api" -) ``` diff --git a/V1.md b/V1.md new file mode 100644 index 0000000..a461b2f --- /dev/null +++ b/V1.md @@ -0,0 +1,90 @@ +# V1 API Client + +The v1 client uses the `experiments` and `datasets` resources with the `application/vnd.md-v1+json` accept header. + +## Initialisation + +```python +from md_python import MDClient + +client = MDClient(api_token="your_api_token", version="v1") +``` + +## Experiments + +```python +from md_python import Experiment, SampleMetadata, ExperimentDesign + +# Create an experiment +sample_metadata = SampleMetadata.from_csv("sample_metadata.csv") +experiment = Experiment( + name="My Experiment", + description="Test experiment", + sample_metadata=sample_metadata, +) +experiment_id = client.experiments.create(experiment) + +# Get experiment by name +exp = client.experiments.get_by_name("My Experiment") + +# Get experiment by ID +exp = client.experiments.get_by_id(experiment_id) + +# Update sample metadata +sample_metadata = SampleMetadata(data=[ + ["sample_name", "dose"], + ["1", "1"], + ["2", "20"], +]) +client.experiments.update_sample_metadata(experiment_id, sample_metadata) + +# Wait for experiment to complete +exp = client.experiments.wait_until_complete(experiment_id) +``` + +## Datasets + +```python +from uuid import UUID +from md_python import Dataset + +# Create a dataset +dataset = Dataset( + input_dataset_ids=[UUID("existing-dataset-id")], + name="Processed Data", + job_slug="data_processing", + job_run_params={"parameter1": "value1"}, +) +dataset_id = client.datasets.create(dataset) + +# List datasets for an experiment +datasets = client.datasets.list_by_experiment(experiment_id) + +# Find the initial intensity dataset +initial = client.datasets.find_initial_dataset(experiment_id) + +# Retry a failed dataset +client.datasets.retry(dataset_id) + +# Delete a dataset +client.datasets.delete(dataset_id) + +# Wait for a dataset to complete +ds = client.datasets.wait_until_complete(experiment_id, dataset_id) +``` + +## Health + +```python +health_status = client.health.check() +``` + +## Custom Base URL + +```python +client = MDClient( + api_token="your_api_token", + version="v1", + base_url="https://xxx.massdynamics-example-installation.com/api", +) +``` diff --git a/src/md_python/client.py b/src/md_python/client.py index 0726aa2..f53938c 100644 --- a/src/md_python/client.py +++ b/src/md_python/client.py @@ -12,7 +12,7 @@ def MDClient( api_token: Optional[str] = None, base_url: Optional[str] = None, - version: str = "v1", + version: str = "v2", ) -> BaseMDClient: """Factory that returns the correct client for the requested API version. diff --git a/tests/test_client.py b/tests/test_client.py index 44dd482..859fb15 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,7 +3,7 @@ import pytest import requests -from md_python.client import MDClient +from md_python.client_v1 import MDClientV1 as MDClient class TestMDClient: diff --git a/tests/test_client_factory.py b/tests/test_client_factory.py index aeac670..e53b618 100644 --- a/tests/test_client_factory.py +++ b/tests/test_client_factory.py @@ -8,9 +8,9 @@ class TestMDClientFactory: - def test_default_returns_v1(self): + def test_default_returns_v2(self): client = MDClient(api_token="tok") - assert isinstance(client, MDClientV1) + assert isinstance(client, MDClientV2) def test_explicit_v1(self): client = MDClient(api_token="tok", version="v1") From 5c47d49ea98f8b75a234873fc603f3b51b81eea6 Mon Sep 17 00:00:00 2001 From: azzamallow Date: Thu, 12 Mar 2026 07:53:33 +1100 Subject: [PATCH 3/5] Rename use of "Experiment" to "Upload" --- README.md | 14 ++-- src/md_python/__init__.py | 2 + src/md_python/models/__init__.py | 2 + src/md_python/models/upload.py | 91 ++++++++++++++++++++++++++ src/md_python/resources/v2/datasets.py | 22 +++---- src/md_python/resources/v2/uploads.py | 50 +++++++------- tests/resources/v2/test_datasets.py | 34 +++++----- tests/resources/v2/test_uploads.py | 40 +++++------ 8 files changed, 175 insertions(+), 80 deletions(-) create mode 100644 src/md_python/models/upload.py diff --git a/README.md b/README.md index 1dc6713..4ec14e4 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,10 @@ The client defaults to the v2 API. For v1 usage, see [V1.md](V1.md). Uploads replace v1 experiments. They handle file ingestion and workflow triggering. ```python -from md_python import Experiment, SampleMetadata +from md_python import Upload, SampleMetadata # Create an upload from S3 -upload = Experiment( +upload = Upload( name="My Upload", source="maxquant", s3_bucket="my-bucket", @@ -45,7 +45,7 @@ upload = Experiment( upload_id = client.uploads.create(upload) # Create an upload from local files -upload = Experiment( +upload = Upload( name="My Upload", source="maxquant", file_location="/path/to/files", @@ -84,11 +84,11 @@ dataset = Dataset( ) dataset_id = client.datasets.create(dataset) -# List datasets for an experiment -datasets = client.datasets.list_by_experiment(experiment_id) +# List datasets for an upload +datasets = client.datasets.list_by_upload(upload_id) # Find the initial intensity dataset -initial = client.datasets.find_initial_dataset(experiment_id) +initial = client.datasets.find_initial_dataset(upload_id) # Retry a failed dataset client.datasets.retry(dataset_id) @@ -100,7 +100,7 @@ client.datasets.cancel(dataset_id) client.datasets.delete(dataset_id) # Wait for a dataset to complete -ds = client.datasets.wait_until_complete(experiment_id, dataset_id) +ds = client.datasets.wait_until_complete(upload_id, dataset_id) ``` ## Jobs diff --git a/src/md_python/__init__.py b/src/md_python/__init__.py index 6bfbe1d..e7c40c9 100644 --- a/src/md_python/__init__.py +++ b/src/md_python/__init__.py @@ -14,6 +14,7 @@ NormalisationImputationDataset, PairwiseComparisonDataset, SampleMetadata, + Upload, ) from .resources import Datasets, Experiments, Health @@ -23,6 +24,7 @@ "MDClientV2", "BaseMDClient", "Experiment", + "Upload", "Dataset", "SampleMetadata", "ExperimentDesign", diff --git a/src/md_python/models/__init__.py b/src/md_python/models/__init__.py index 557a0a6..6d12e05 100644 --- a/src/md_python/models/__init__.py +++ b/src/md_python/models/__init__.py @@ -11,11 +11,13 @@ ) from .experiment import Experiment from .metadata import ExperimentDesign, SampleMetadata +from .upload import Upload __all__ = [ "SampleMetadata", "ExperimentDesign", "Experiment", + "Upload", "Dataset", "BaseDatasetBuilder", "MinimalDataset", diff --git a/src/md_python/models/upload.py b/src/md_python/models/upload.py new file mode 100644 index 0000000..9634fa2 --- /dev/null +++ b/src/md_python/models/upload.py @@ -0,0 +1,91 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Dict, List, Optional +from uuid import UUID + +from pydantic.dataclasses import dataclass as pydantic_dataclass + +from .metadata import ExperimentDesign, SampleMetadata + + +@pydantic_dataclass +@dataclass +class Upload: + name: str + source: str + id: Optional[UUID] = None + description: Optional[str] = None + experiment_design: Optional[ExperimentDesign] = None + labelling_method: Optional[str] = None + s3_bucket: Optional[str] = None + s3_prefix: Optional[str] = None + filenames: Optional[List[str]] = None + file_location: Optional[str] = None + sample_metadata: Optional[SampleMetadata] = None + created_at: Optional[datetime] = None + status: Optional[str] = None + + def __str__(self) -> str: + lines = [f"Upload: {self.name}"] + if self.id: + lines.append(f"ID: {self.id}") + if self.description: + lines.append(f"Description: {self.description}") + lines.append(f"Source: {self.source}") + if self.status: + lines.append(f"Status: {self.status}") + if self.labelling_method: + lines.append(f"Labelling Method: {self.labelling_method}") + if self.created_at: + lines.append(f"Created: {self.created_at}") + if self.s3_bucket: + lines.append(f"S3 Bucket: {self.s3_bucket}") + if self.s3_prefix: + lines.append(f"S3 Prefix: {self.s3_prefix}") + if self.filenames: + lines.append(f"Files: {len(self.filenames)} files") + if self.experiment_design: + lines.append("Experiment Design:") + lines.append(str(self.experiment_design)) + if self.sample_metadata: + lines.append("Sample Metadata:") + lines.append(str(self.sample_metadata)) + return "\n".join(lines) + + @classmethod + def _parse_iso_datetime(cls, datetime_str: Optional[str]) -> Optional[datetime]: + if datetime_str is not None and isinstance(datetime_str, str): + return datetime.fromisoformat(datetime_str.replace("Z", "+00:00")) + return None + + @classmethod + def from_json(cls, data: Dict[str, Any]) -> "Upload": + created_at = cls._parse_iso_datetime(data.get("created_at")) + + return cls( + id=UUID(data.get("id")) if data.get("id") else None, + name=data.get("name", ""), + description=data.get("description"), + labelling_method=data.get("labelling_method"), + source=data.get("source", ""), + s3_bucket=( + data.get("s3_bucket") if data.get("s3_bucket") is not None else "" + ), + s3_prefix=data.get("s3_prefix"), + filenames=( + data.get("filenames") if data.get("filenames") is not None else [] + ), + file_location=data.get("file_location"), + experiment_design=( + ExperimentDesign(data=data.get("experiment_design", [])) + if data.get("experiment_design") is not None + else None + ), + sample_metadata=( + SampleMetadata(data=data.get("sample_metadata", [])) + if data.get("sample_metadata") is not None + else None + ), + created_at=created_at, + status=data.get("status"), + ) diff --git a/src/md_python/resources/v2/datasets.py b/src/md_python/resources/v2/datasets.py index 5917c7d..41dc0a4 100644 --- a/src/md_python/resources/v2/datasets.py +++ b/src/md_python/resources/v2/datasets.py @@ -51,11 +51,11 @@ def create(self, dataset: Dataset) -> str: f"Failed to create dataset: {response.status_code} - {response.text}" ) - def list_by_experiment(self, experiment_id: str) -> List[Dataset]: - """Get datasets belonging to an experiment""" + def list_by_upload(self, upload_id: str) -> List[Dataset]: + """Get datasets belonging to an upload""" response = self._client._make_request( method="GET", - endpoint=f"/datasets?experiment_id={experiment_id}", + endpoint=f"/datasets?experiment_id={upload_id}", ) if response.status_code == 200: @@ -109,7 +109,7 @@ def cancel(self, dataset_id: str) -> bool: def wait_until_complete( self, - experiment_id: str, + upload_id: str, dataset_id: str, poll_s: int = 5, timeout_s: int = 1800, @@ -118,7 +118,7 @@ def wait_until_complete( end = time.monotonic() + timeout_s last: Optional[str] = None while time.monotonic() < end: - dds = self.list_by_experiment(experiment_id=experiment_id) + dds = self.list_by_upload(upload_id=upload_id) ds = next((d for d in dds if str(d.id) == dataset_id), None) if ds: state = ds.state @@ -139,22 +139,22 @@ def wait_until_complete( f"Dataset {dataset_id} not terminal within {timeout_s}s (last state={last})" ) - def find_initial_dataset(self, experiment_id: str) -> Optional[Dataset]: - """Return the initial dataset for an experiment.""" - datasets = self.list_by_experiment(experiment_id=experiment_id) + def find_initial_dataset(self, upload_id: str) -> Optional[Dataset]: + """Return the initial dataset for an upload.""" + datasets = self.list_by_upload(upload_id=upload_id) if not datasets: - raise ValueError(f"No datasets found for experiment {experiment_id}") + raise ValueError(f"No datasets found for upload {upload_id}") intensity = [d for d in datasets if getattr(d, "type", None) == "INTENSITY"] if not intensity: raise ValueError( - f"No intensity dataset found for experiment {experiment_id}" + f"No intensity dataset found for upload {upload_id}" ) if len(intensity) == 1: return intensity[0] raise ValueError( - f"Multiple intensity datasets found for experiment {experiment_id}" + f"Multiple intensity datasets found for upload {upload_id}" ) diff --git a/src/md_python/resources/v2/uploads.py b/src/md_python/resources/v2/uploads.py index dc6201e..56fc7f3 100644 --- a/src/md_python/resources/v2/uploads.py +++ b/src/md_python/resources/v2/uploads.py @@ -5,7 +5,7 @@ import time from typing import TYPE_CHECKING, Any, Dict, List, Optional -from ...models import Experiment, SampleMetadata +from ...models import SampleMetadata, Upload from ...uploads import Uploads as FileUploader if TYPE_CHECKING: @@ -19,39 +19,39 @@ def __init__(self, client: "BaseMDClient"): self._client = client self._uploader = FileUploader(client, resource_path="/uploads") - def create(self, experiment: Experiment) -> str: + def create(self, upload: Upload) -> str: """Create a new upload and optionally upload files. Args: - experiment: Experiment object with upload configuration + upload: Upload object with upload configuration Returns: - Upload ID (experiment UUID) + Upload ID """ - if not experiment.file_location and not experiment.s3_bucket: + if not upload.file_location and not upload.s3_bucket: raise ValueError( "Either file_location or s3_bucket must be provided" ) - if experiment.file_location and not experiment.filenames: + if upload.file_location and not upload.filenames: raise ValueError("filenames must be provided when using file_location") payload: Dict[str, Any] = { - "name": experiment.name, - "source": experiment.source, - "filenames": experiment.filenames, + "name": upload.name, + "source": upload.source, + "filenames": upload.filenames, } - if experiment.file_location: - payload["file_location"] = experiment.file_location - if experiment.filenames: + if upload.file_location: + payload["file_location"] = upload.file_location + if upload.filenames: file_sizes = self._uploader.file_sizes_for_api( - experiment.filenames, experiment.file_location + upload.filenames, upload.file_location ) payload["file_sizes"] = file_sizes else: - payload["s3_bucket"] = experiment.s3_bucket - payload["s3_prefix"] = experiment.s3_prefix + payload["s3_bucket"] = upload.s3_bucket + payload["s3_prefix"] = upload.s3_prefix response = self._client._make_request( method="POST", @@ -68,9 +68,9 @@ def create(self, experiment: Experiment) -> str: response_data = response.json() upload_id = str(response_data["id"]) - if "uploads" in response_data and experiment.file_location: + if "uploads" in response_data and upload.file_location: self._uploader.upload_files( - response_data["uploads"], experiment.file_location, upload_id + response_data["uploads"], upload.file_location, upload_id ) self._client._make_request( method="POST", @@ -80,27 +80,27 @@ def create(self, experiment: Experiment) -> str: return upload_id - def get_by_id(self, upload_id: str) -> Optional[Experiment]: + def get_by_id(self, upload_id: str) -> Optional[Upload]: """Get an upload by its ID""" response = self._client._make_request( method="GET", endpoint=f"/uploads/{upload_id}" ) if response.status_code == 200: - return Experiment.from_json(response.json()) + return Upload.from_json(response.json()) else: raise Exception( f"Failed to get upload: {response.status_code} - {response.text}" ) - def get_by_name(self, name: str) -> Optional[Experiment]: + def get_by_name(self, name: str) -> Optional[Upload]: """Get an upload by its name""" response = self._client._make_request( method="GET", endpoint=f"/uploads?name={name}" ) if response.status_code == 200: - return Experiment.from_json(response.json()) + return Upload.from_json(response.json()) else: raise Exception( f"Failed to get upload by name: {response.status_code} - {response.text}" @@ -126,13 +126,13 @@ def update_sample_metadata( def wait_until_complete( self, upload_id: str, poll_s: int = 5, timeout_s: int = 1800 - ) -> Experiment: + ) -> Upload: """Poll the upload until it reaches a terminal state.""" end = time.monotonic() + timeout_s last: Optional[str] = None while time.monotonic() < end: - exp = self.get_by_id(upload_id) - status = getattr(exp, "status", None) + upload = self.get_by_id(upload_id) + status = getattr(upload, "status", None) if status != last: print(f"status={status}") last = status @@ -143,7 +143,7 @@ def wait_until_complete( s = status.upper() if s in {"COMPLETED"}: - return exp # type: ignore[return-value] + return upload # type: ignore[return-value] if s in {"FAILED", "ERROR", "CANCELLED"}: raise Exception(f"Upload {upload_id} failed: {status}") diff --git a/tests/resources/v2/test_datasets.py b/tests/resources/v2/test_datasets.py index f868e02..4e18825 100644 --- a/tests/resources/v2/test_datasets.py +++ b/tests/resources/v2/test_datasets.py @@ -87,7 +87,7 @@ def test_create_failure(self, datasets, sample_dataset, mock_client): with pytest.raises(Exception, match="Failed to create dataset: 400"): datasets.create(sample_dataset) - def test_list_by_experiment_success(self, datasets, mock_client): + def test_list_by_upload_success(self, datasets, mock_client): mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = [ @@ -100,34 +100,34 @@ def test_list_by_experiment_success(self, datasets, mock_client): ] mock_client._make_request.return_value = mock_response - result = datasets.list_by_experiment("exp-1") + result = datasets.list_by_upload("upload-1") assert len(result) == 1 assert isinstance(result[0], Dataset) assert result[0].name == "DS1" call_args = mock_client._make_request.call_args - assert call_args[1]["endpoint"] == "/datasets?experiment_id=exp-1" + assert call_args[1]["endpoint"] == "/datasets?experiment_id=upload-1" - def test_list_by_experiment_no_custom_headers(self, datasets, mock_client): + def test_list_by_upload_no_custom_headers(self, datasets, mock_client): mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = [] mock_client._make_request.return_value = mock_response - datasets.list_by_experiment("exp-1") + datasets.list_by_upload("upload-1") call_args = mock_client._make_request.call_args assert "headers" not in call_args[1] or call_args[1].get("headers") is None - def test_list_by_experiment_failure(self, datasets, mock_client): + def test_list_by_upload_failure(self, datasets, mock_client): mock_response = Mock() mock_response.status_code = 500 mock_response.text = "Internal error" mock_client._make_request.return_value = mock_response with pytest.raises(Exception, match="Failed to get datasets: 500"): - datasets.list_by_experiment("exp-1") + datasets.list_by_upload("upload-1") def test_delete_success(self, datasets, mock_client): mock_response = Mock() @@ -201,10 +201,10 @@ def test_wait_until_complete_success(self, datasets, mock_client, mocker): state="COMPLETED", id=UUID("11111111-1111-1111-1111-111111111111"), ) - mocker.patch.object(datasets, "list_by_experiment", return_value=[completed_ds]) + mocker.patch.object(datasets, "list_by_upload", return_value=[completed_ds]) result = datasets.wait_until_complete( - "exp-1", "11111111-1111-1111-1111-111111111111", poll_s=0, timeout_s=1 + "upload-1", "11111111-1111-1111-1111-111111111111", poll_s=0, timeout_s=1 ) assert isinstance(result, Dataset) @@ -218,11 +218,11 @@ def test_wait_until_complete_failure(self, datasets, mock_client, mocker): state="FAILED", id=UUID("11111111-1111-1111-1111-111111111111"), ) - mocker.patch.object(datasets, "list_by_experiment", return_value=[failed_ds]) + mocker.patch.object(datasets, "list_by_upload", return_value=[failed_ds]) with pytest.raises(Exception, match="failed"): datasets.wait_until_complete( - "exp-1", "11111111-1111-1111-1111-111111111111", poll_s=0, timeout_s=1 + "upload-1", "11111111-1111-1111-1111-111111111111", poll_s=0, timeout_s=1 ) def test_find_initial_dataset_success(self, datasets, mock_client, mocker): @@ -234,17 +234,17 @@ def test_find_initial_dataset_success(self, datasets, mock_client, mocker): id=UUID("11111111-1111-1111-1111-111111111111"), ) ds.type = "INTENSITY" - mocker.patch.object(datasets, "list_by_experiment", return_value=[ds]) + mocker.patch.object(datasets, "list_by_upload", return_value=[ds]) - result = datasets.find_initial_dataset("exp-1") + result = datasets.find_initial_dataset("upload-1") assert result is ds def test_find_initial_dataset_no_datasets(self, datasets, mock_client, mocker): - mocker.patch.object(datasets, "list_by_experiment", return_value=[]) + mocker.patch.object(datasets, "list_by_upload", return_value=[]) with pytest.raises(ValueError, match="No datasets found"): - datasets.find_initial_dataset("exp-1") + datasets.find_initial_dataset("upload-1") def test_find_initial_dataset_no_intensity(self, datasets, mock_client, mocker): ds = Dataset( @@ -254,7 +254,7 @@ def test_find_initial_dataset_no_intensity(self, datasets, mock_client, mocker): job_run_params={}, ) ds.type = "OTHER" - mocker.patch.object(datasets, "list_by_experiment", return_value=[ds]) + mocker.patch.object(datasets, "list_by_upload", return_value=[ds]) with pytest.raises(ValueError, match="No intensity dataset"): - datasets.find_initial_dataset("exp-1") + datasets.find_initial_dataset("upload-1") diff --git a/tests/resources/v2/test_uploads.py b/tests/resources/v2/test_uploads.py index f8c4225..d653729 100644 --- a/tests/resources/v2/test_uploads.py +++ b/tests/resources/v2/test_uploads.py @@ -3,7 +3,7 @@ import pytest from md_python.client_v2 import MDClientV2 -from md_python.models import Experiment, SampleMetadata +from md_python.models import SampleMetadata, Upload from md_python.resources.v2.uploads import Uploads @@ -18,7 +18,7 @@ def uploads(self, mock_client): return Uploads(mock_client) def test_create_with_s3_bucket(self, uploads, mock_client): - exp = Experiment( + upload = Upload( name="S3 Upload", source="maxquant", s3_bucket="my-bucket", @@ -31,7 +31,7 @@ def test_create_with_s3_bucket(self, uploads, mock_client): mock_response.json.return_value = {"id": "upload-123"} mock_client._make_request.return_value = mock_response - result = uploads.create(exp) + result = uploads.create(upload) assert result == "upload-123" call_args = mock_client._make_request.call_args @@ -44,7 +44,7 @@ def test_create_with_s3_bucket(self, uploads, mock_client): assert payload["s3_prefix"] == "data/" def test_create_with_file_location(self, uploads, mock_client): - exp = Experiment( + upload = Upload( name="Local Upload", source="maxquant", file_location="/tmp/files", @@ -58,12 +58,12 @@ def test_create_with_file_location(self, uploads, mock_client): with patch.object(uploads._uploader, "file_sizes_for_api", return_value=[None]): with patch.object(uploads._uploader, "upload_files"): - result = uploads.create(exp) + result = uploads.create(upload) assert result == "upload-456" def test_create_with_file_upload_triggers_workflow(self, uploads, mock_client): - exp = Experiment( + upload = Upload( name="Upload With Files", source="maxquant", file_location="/tmp/files", @@ -84,7 +84,7 @@ def test_create_with_file_upload_triggers_workflow(self, uploads, mock_client): with patch.object(uploads._uploader, "file_sizes_for_api", return_value=[None]): with patch.object(uploads._uploader, "upload_files"): - uploads.create(exp) + uploads.create(upload) assert mock_client._make_request.call_count == 2 workflow_call = mock_client._make_request.call_args_list[1] @@ -92,13 +92,13 @@ def test_create_with_file_upload_triggers_workflow(self, uploads, mock_client): assert workflow_call[1]["endpoint"] == "/uploads/upload-789/start_workflow" def test_create_validation_no_source(self, uploads): - exp = Experiment(name="Bad", source="maxquant", filenames=[]) + upload = Upload(name="Bad", source="maxquant", filenames=[]) with pytest.raises(ValueError, match="file_location or s3_bucket"): - uploads.create(exp) + uploads.create(upload) def test_create_validation_file_location_without_filenames(self, uploads): - exp = Experiment( + upload = Upload( name="Bad", source="maxquant", file_location="/tmp", @@ -106,10 +106,10 @@ def test_create_validation_file_location_without_filenames(self, uploads): ) with pytest.raises(ValueError, match="filenames must be provided"): - uploads.create(exp) + uploads.create(upload) def test_create_failure(self, uploads, mock_client): - exp = Experiment( + upload = Upload( name="Fail", source="maxquant", s3_bucket="bucket", @@ -122,7 +122,7 @@ def test_create_failure(self, uploads, mock_client): mock_client._make_request.return_value = mock_response with pytest.raises(Exception, match="Failed to create upload: 422"): - uploads.create(exp) + uploads.create(upload) def test_get_by_id_success(self, uploads, mock_client): mock_response = Mock() @@ -136,7 +136,7 @@ def test_get_by_id_success(self, uploads, mock_client): result = uploads.get_by_id("upload-1") - assert isinstance(result, Experiment) + assert isinstance(result, Upload) assert result.name == "Test Upload" call_args = mock_client._make_request.call_args @@ -162,7 +162,7 @@ def test_get_by_name_success(self, uploads, mock_client): result = uploads.get_by_name("Named Upload") - assert isinstance(result, Experiment) + assert isinstance(result, Upload) assert result.name == "Named Upload" call_args = mock_client._make_request.call_args @@ -204,16 +204,16 @@ def test_update_sample_metadata_failure(self, uploads, mock_client): uploads.update_sample_metadata("upload-1", sm) def test_wait_until_complete_success(self, uploads, mock_client, mocker): - exp = Experiment(name="x", source="s", s3_bucket="b", filenames=[], status="COMPLETED") - mocker.patch.object(uploads, "get_by_id", return_value=exp) + upload = Upload(name="x", source="s", s3_bucket="b", filenames=[], status="COMPLETED") + mocker.patch.object(uploads, "get_by_id", return_value=upload) result = uploads.wait_until_complete("upload-1", poll_s=0, timeout_s=1) - assert isinstance(result, Experiment) + assert isinstance(result, Upload) def test_wait_until_complete_failure(self, uploads, mock_client, mocker): - exp = Experiment(name="x", source="s", s3_bucket="b", filenames=[], status="FAILED") - mocker.patch.object(uploads, "get_by_id", return_value=exp) + upload = Upload(name="x", source="s", s3_bucket="b", filenames=[], status="FAILED") + mocker.patch.object(uploads, "get_by_id", return_value=upload) with pytest.raises(Exception, match="failed"): uploads.wait_until_complete("upload-1", poll_s=0, timeout_s=1) From adc0de5351420b66ba8cab55b259a86ef147024f Mon Sep 17 00:00:00 2001 From: azzamallow Date: Thu, 12 Mar 2026 09:13:26 +1100 Subject: [PATCH 4/5] Fix issue where experiment_design and sample_metadata were optional, and issues relating to filesize --- src/md_python/resources/v2/uploads.py | 13 ++++++-- tests/resources/v2/test_uploads.py | 47 ++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/md_python/resources/v2/uploads.py b/src/md_python/resources/v2/uploads.py index 56fc7f3..9c947cb 100644 --- a/src/md_python/resources/v2/uploads.py +++ b/src/md_python/resources/v2/uploads.py @@ -5,7 +5,7 @@ import time from typing import TYPE_CHECKING, Any, Dict, List, Optional -from ...models import SampleMetadata, Upload +from ...models import ExperimentDesign, SampleMetadata, Upload from ...uploads import Uploads as FileUploader if TYPE_CHECKING: @@ -36,10 +36,18 @@ def create(self, upload: Upload) -> str: if upload.file_location and not upload.filenames: raise ValueError("filenames must be provided when using file_location") + if not upload.experiment_design: + raise ValueError("experiment_design is required") + + if not upload.sample_metadata: + raise ValueError("sample_metadata is required") + payload: Dict[str, Any] = { "name": upload.name, "source": upload.source, "filenames": upload.filenames, + "experiment_design": upload.experiment_design.data, + "sample_metadata": upload.sample_metadata.data, } if upload.file_location: @@ -48,7 +56,8 @@ def create(self, upload: Upload) -> str: file_sizes = self._uploader.file_sizes_for_api( upload.filenames, upload.file_location ) - payload["file_sizes"] = file_sizes + if any(s is not None for s in file_sizes): + payload["file_sizes"] = file_sizes else: payload["s3_bucket"] = upload.s3_bucket payload["s3_prefix"] = upload.s3_prefix diff --git a/tests/resources/v2/test_uploads.py b/tests/resources/v2/test_uploads.py index d653729..93278f0 100644 --- a/tests/resources/v2/test_uploads.py +++ b/tests/resources/v2/test_uploads.py @@ -3,9 +3,19 @@ import pytest from md_python.client_v2 import MDClientV2 -from md_python.models import SampleMetadata, Upload +from md_python.models import ExperimentDesign, SampleMetadata, Upload from md_python.resources.v2.uploads import Uploads +DESIGN = ExperimentDesign(data=[ + ["filename", "sample_name", "condition"], + ["a.txt", "s1", "ctrl"], +]) + +METADATA = SampleMetadata(data=[ + ["sample_name", "dose"], + ["s1", "1"], +]) + class TestV2Uploads: @@ -24,6 +34,8 @@ def test_create_with_s3_bucket(self, uploads, mock_client): s3_bucket="my-bucket", s3_prefix="data/", filenames=["a.txt"], + experiment_design=DESIGN, + sample_metadata=METADATA, ) mock_response = Mock() @@ -42,6 +54,8 @@ def test_create_with_s3_bucket(self, uploads, mock_client): assert payload["name"] == "S3 Upload" assert payload["s3_bucket"] == "my-bucket" assert payload["s3_prefix"] == "data/" + assert payload["experiment_design"] == DESIGN.data + assert payload["sample_metadata"] == METADATA.data def test_create_with_file_location(self, uploads, mock_client): upload = Upload( @@ -49,6 +63,8 @@ def test_create_with_file_location(self, uploads, mock_client): source="maxquant", file_location="/tmp/files", filenames=["data.raw"], + experiment_design=DESIGN, + sample_metadata=METADATA, ) mock_response = Mock() @@ -61,6 +77,8 @@ def test_create_with_file_location(self, uploads, mock_client): result = uploads.create(upload) assert result == "upload-456" + payload = mock_client._make_request.call_args[1]["json"] + assert "file_sizes" not in payload def test_create_with_file_upload_triggers_workflow(self, uploads, mock_client): upload = Upload( @@ -68,6 +86,8 @@ def test_create_with_file_upload_triggers_workflow(self, uploads, mock_client): source="maxquant", file_location="/tmp/files", filenames=["data.raw"], + experiment_design=DESIGN, + sample_metadata=METADATA, ) create_response = Mock() @@ -108,12 +128,37 @@ def test_create_validation_file_location_without_filenames(self, uploads): with pytest.raises(ValueError, match="filenames must be provided"): uploads.create(upload) + def test_create_validation_missing_experiment_design(self, uploads): + upload = Upload( + name="Bad", + source="maxquant", + s3_bucket="bucket", + filenames=["a.txt"], + ) + + with pytest.raises(ValueError, match="experiment_design is required"): + uploads.create(upload) + + def test_create_validation_missing_sample_metadata(self, uploads): + upload = Upload( + name="Bad", + source="maxquant", + s3_bucket="bucket", + filenames=["a.txt"], + experiment_design=DESIGN, + ) + + with pytest.raises(ValueError, match="sample_metadata is required"): + uploads.create(upload) + def test_create_failure(self, uploads, mock_client): upload = Upload( name="Fail", source="maxquant", s3_bucket="bucket", filenames=["a.txt"], + experiment_design=DESIGN, + sample_metadata=METADATA, ) mock_response = Mock() From 905f6f81cffd7b87d32eb4dd1a89fcb978ed3b59 Mon Sep 17 00:00:00 2001 From: azzamallow Date: Thu, 12 Mar 2026 13:50:40 +1100 Subject: [PATCH 5/5] Fix linting --- src/md_python/models/dataset_builders.py | 2 +- src/md_python/resources/datasets.py | 2 +- src/md_python/resources/v2/datasets.py | 8 ++----- src/md_python/resources/v2/uploads.py | 4 +--- tests/resources/v2/test_datasets.py | 5 ++++- tests/resources/v2/test_uploads.py | 28 +++++++++++++++--------- tests/test_client_factory.py | 4 +++- 7 files changed, 30 insertions(+), 23 deletions(-) diff --git a/src/md_python/models/dataset_builders.py b/src/md_python/models/dataset_builders.py index 1548597..6a1d4f9 100644 --- a/src/md_python/models/dataset_builders.py +++ b/src/md_python/models/dataset_builders.py @@ -34,7 +34,7 @@ def validate(self) -> None: def run(self, client: "BaseMDClient") -> str: """Create the dataset via the API and return the new dataset_id.""" self.validate() - return client.datasets.create(self.to_dataset()) + return client.datasets.create(self.to_dataset()) # type: ignore[attr-defined, no-any-return] @pydantic_dataclass diff --git a/src/md_python/resources/datasets.py b/src/md_python/resources/datasets.py index 5c2440c..1549e93 100644 --- a/src/md_python/resources/datasets.py +++ b/src/md_python/resources/datasets.py @@ -168,7 +168,7 @@ def find_initial_dataset(self, experiment_id: str) -> Optional[Dataset]: 3) First dataset if any """ datasets = self.list_by_experiment(experiment_id=experiment_id) - exp = self._client.experiments.get_by_id(experiment_id) + exp = self._client.experiments.get_by_id(experiment_id) # type: ignore[attr-defined] if exp is None: raise ValueError(f"Experiment {experiment_id} not found") experiment_name = exp.name diff --git a/src/md_python/resources/v2/datasets.py b/src/md_python/resources/v2/datasets.py index 41dc0a4..f6780ae 100644 --- a/src/md_python/resources/v2/datasets.py +++ b/src/md_python/resources/v2/datasets.py @@ -148,13 +148,9 @@ def find_initial_dataset(self, upload_id: str) -> Optional[Dataset]: intensity = [d for d in datasets if getattr(d, "type", None) == "INTENSITY"] if not intensity: - raise ValueError( - f"No intensity dataset found for upload {upload_id}" - ) + raise ValueError(f"No intensity dataset found for upload {upload_id}") if len(intensity) == 1: return intensity[0] - raise ValueError( - f"Multiple intensity datasets found for upload {upload_id}" - ) + raise ValueError(f"Multiple intensity datasets found for upload {upload_id}") diff --git a/src/md_python/resources/v2/uploads.py b/src/md_python/resources/v2/uploads.py index 9c947cb..d73791d 100644 --- a/src/md_python/resources/v2/uploads.py +++ b/src/md_python/resources/v2/uploads.py @@ -29,9 +29,7 @@ def create(self, upload: Upload) -> str: Upload ID """ if not upload.file_location and not upload.s3_bucket: - raise ValueError( - "Either file_location or s3_bucket must be provided" - ) + raise ValueError("Either file_location or s3_bucket must be provided") if upload.file_location and not upload.filenames: raise ValueError("filenames must be provided when using file_location") diff --git a/tests/resources/v2/test_datasets.py b/tests/resources/v2/test_datasets.py index 4e18825..039852d 100644 --- a/tests/resources/v2/test_datasets.py +++ b/tests/resources/v2/test_datasets.py @@ -222,7 +222,10 @@ def test_wait_until_complete_failure(self, datasets, mock_client, mocker): with pytest.raises(Exception, match="failed"): datasets.wait_until_complete( - "upload-1", "11111111-1111-1111-1111-111111111111", poll_s=0, timeout_s=1 + "upload-1", + "11111111-1111-1111-1111-111111111111", + poll_s=0, + timeout_s=1, ) def test_find_initial_dataset_success(self, datasets, mock_client, mocker): diff --git a/tests/resources/v2/test_uploads.py b/tests/resources/v2/test_uploads.py index 93278f0..450f603 100644 --- a/tests/resources/v2/test_uploads.py +++ b/tests/resources/v2/test_uploads.py @@ -6,15 +6,19 @@ from md_python.models import ExperimentDesign, SampleMetadata, Upload from md_python.resources.v2.uploads import Uploads -DESIGN = ExperimentDesign(data=[ - ["filename", "sample_name", "condition"], - ["a.txt", "s1", "ctrl"], -]) +DESIGN = ExperimentDesign( + data=[ + ["filename", "sample_name", "condition"], + ["a.txt", "s1", "ctrl"], + ] +) -METADATA = SampleMetadata(data=[ - ["sample_name", "dose"], - ["s1", "1"], -]) +METADATA = SampleMetadata( + data=[ + ["sample_name", "dose"], + ["s1", "1"], + ] +) class TestV2Uploads: @@ -249,7 +253,9 @@ def test_update_sample_metadata_failure(self, uploads, mock_client): uploads.update_sample_metadata("upload-1", sm) def test_wait_until_complete_success(self, uploads, mock_client, mocker): - upload = Upload(name="x", source="s", s3_bucket="b", filenames=[], status="COMPLETED") + upload = Upload( + name="x", source="s", s3_bucket="b", filenames=[], status="COMPLETED" + ) mocker.patch.object(uploads, "get_by_id", return_value=upload) result = uploads.wait_until_complete("upload-1", poll_s=0, timeout_s=1) @@ -257,7 +263,9 @@ def test_wait_until_complete_success(self, uploads, mock_client, mocker): assert isinstance(result, Upload) def test_wait_until_complete_failure(self, uploads, mock_client, mocker): - upload = Upload(name="x", source="s", s3_bucket="b", filenames=[], status="FAILED") + upload = Upload( + name="x", source="s", s3_bucket="b", filenames=[], status="FAILED" + ) mocker.patch.object(uploads, "get_by_id", return_value=upload) with pytest.raises(Exception, match="failed"): diff --git a/tests/test_client_factory.py b/tests/test_client_factory.py index e53b618..83e6082 100644 --- a/tests/test_client_factory.py +++ b/tests/test_client_factory.py @@ -31,7 +31,9 @@ def test_both_are_base_client_subclasses(self): assert isinstance(v2, BaseMDClient) def test_custom_base_url_forwarded(self): - client = MDClient(api_token="tok", base_url="https://custom.com/api", version="v2") + client = MDClient( + api_token="tok", base_url="https://custom.com/api", version="v2" + ) assert client.base_url == "https://custom.com/api"