diff --git a/README.md b/README.md index 28c7c55..4ec14e4 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 Upload, SampleMetadata + +# Create an upload from S3 +upload = Upload( + 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 = Upload( + 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 upload +datasets = client.datasets.list_by_upload(upload_id) + +# Find the initial intensity dataset +initial = client.datasets.find_initial_dataset(upload_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(upload_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/__init__.py b/src/md_python/__init__.py index 295c30f..e7c40c9 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, @@ -11,12 +14,17 @@ NormalisationImputationDataset, PairwiseComparisonDataset, SampleMetadata, + Upload, ) from .resources import Datasets, Experiments, Health __all__ = [ "MDClient", + "MDClientV1", + "MDClientV2", + "BaseMDClient", "Experiment", + "Upload", "Dataset", "SampleMetadata", "ExperimentDesign", 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..f53938c 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 = "v2", +) -> 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/__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/dataset_builders.py b/src/md_python/models/dataset_builders.py index 50884f6..6a1d4f9 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,10 +31,10 @@ 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()) + return client.datasets.create(self.to_dataset()) # type: ignore[attr-defined, no-any-return] @pydantic_dataclass 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/datasets.py b/src/md_python/resources/datasets.py index 3428b06..1549e93 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: @@ -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/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..f6780ae --- /dev/null +++ b/src/md_python/resources/v2/datasets.py @@ -0,0 +1,156 @@ +""" +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_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={upload_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, + upload_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_upload(upload_id=upload_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, 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 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 upload {upload_id}") + + if len(intensity) == 1: + return intensity[0] + + raise ValueError(f"Multiple intensity datasets found for upload {upload_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..d73791d --- /dev/null +++ b/src/md_python/resources/v2/uploads.py @@ -0,0 +1,161 @@ +""" +Uploads resource for the MD Python v2 client +""" + +import time +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from ...models import ExperimentDesign, SampleMetadata, Upload +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, upload: Upload) -> str: + """Create a new upload and optionally upload files. + + Args: + upload: Upload object with upload configuration + + Returns: + Upload ID + """ + if not upload.file_location and not upload.s3_bucket: + 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") + + 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: + payload["file_location"] = upload.file_location + if upload.filenames: + file_sizes = self._uploader.file_sizes_for_api( + upload.filenames, upload.file_location + ) + 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 + + 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 upload.file_location: + self._uploader.upload_files( + response_data["uploads"], upload.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[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 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[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 Upload.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 + ) -> Upload: + """Poll the upload until it reaches a terminal state.""" + end = time.monotonic() + timeout_s + last: Optional[str] = None + while time.monotonic() < end: + upload = self.get_by_id(upload_id) + status = getattr(upload, "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 upload # 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..039852d --- /dev/null +++ b/tests/resources/v2/test_datasets.py @@ -0,0 +1,263 @@ +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_upload_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_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=upload-1" + + 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_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_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_upload("upload-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_upload", return_value=[completed_ds]) + + result = datasets.wait_until_complete( + "upload-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_upload", return_value=[failed_ds]) + + with pytest.raises(Exception, match="failed"): + datasets.wait_until_complete( + "upload-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_upload", return_value=[ds]) + + 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_upload", return_value=[]) + + with pytest.raises(ValueError, match="No datasets found"): + datasets.find_initial_dataset("upload-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_upload", return_value=[ds]) + + with pytest.raises(ValueError, match="No intensity dataset"): + datasets.find_initial_dataset("upload-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..450f603 --- /dev/null +++ b/tests/resources/v2/test_uploads.py @@ -0,0 +1,275 @@ +from unittest.mock import Mock, patch + +import pytest + +from md_python.client_v2 import MDClientV2 +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: + + @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): + upload = Upload( + name="S3 Upload", + source="maxquant", + s3_bucket="my-bucket", + s3_prefix="data/", + filenames=["a.txt"], + experiment_design=DESIGN, + sample_metadata=METADATA, + ) + + 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(upload) + + 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/" + assert payload["experiment_design"] == DESIGN.data + assert payload["sample_metadata"] == METADATA.data + + def test_create_with_file_location(self, uploads, mock_client): + upload = Upload( + name="Local Upload", + source="maxquant", + file_location="/tmp/files", + filenames=["data.raw"], + experiment_design=DESIGN, + sample_metadata=METADATA, + ) + + 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(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( + name="Upload With Files", + source="maxquant", + file_location="/tmp/files", + filenames=["data.raw"], + experiment_design=DESIGN, + sample_metadata=METADATA, + ) + + 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(upload) + + 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): + upload = Upload(name="Bad", source="maxquant", filenames=[]) + + with pytest.raises(ValueError, match="file_location or s3_bucket"): + uploads.create(upload) + + def test_create_validation_file_location_without_filenames(self, uploads): + upload = Upload( + name="Bad", + source="maxquant", + file_location="/tmp", + filenames=[], + ) + + 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() + 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(upload) + + 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, Upload) + 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, Upload) + 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): + 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, Upload) + + def test_wait_until_complete_failure(self, uploads, mock_client, mocker): + 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) + + def test_uploader_uses_uploads_resource_path(self, uploads): + assert uploads._uploader._resource_path == "/uploads" 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 new file mode 100644 index 0000000..83e6082 --- /dev/null +++ b/tests/test_client_factory.py @@ -0,0 +1,101 @@ +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_v2(self): + client = MDClient(api_token="tok") + assert isinstance(client, MDClientV2) + + 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"