From 795ace4696d6ae0ebdf1b6b5f3b36a83f320c0e1 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Mon, 9 Mar 2026 16:16:29 +0000 Subject: [PATCH 1/5] feat(exceptions): add AnnotationValidationError for annotation upload errors The annotation endpoint returns validation errors in a different format ({"validation": [...]}) than the standard {"error": ...} response. A dedicated exception preserves the structured error list so callers can inspect individual validation failures. FLOW-547 Co-Authored-By: Claude Opus 4.6 --- flowbio/v2/__init__.py | 2 ++ flowbio/v2/exceptions.py | 14 ++++++++++++++ tests/unit/v2/test_exceptions.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/flowbio/v2/__init__.py b/flowbio/v2/__init__.py index e89b4c8..f27aece 100644 --- a/flowbio/v2/__init__.py +++ b/flowbio/v2/__init__.py @@ -30,9 +30,11 @@ """ from flowbio.v2.auth import TokenCredentials, UsernamePasswordCredentials from flowbio.v2.client import Client, ClientConfig +from flowbio.v2.exceptions import AnnotationValidationError from flowbio.v2.samples import MetadataAttribute, Organism, Project, Sample, SampleType __all__ = [ + "AnnotationValidationError", "Client", "ClientConfig", "MetadataAttribute", diff --git a/flowbio/v2/exceptions.py b/flowbio/v2/exceptions.py index 700af8f..dfe228c 100644 --- a/flowbio/v2/exceptions.py +++ b/flowbio/v2/exceptions.py @@ -35,6 +35,20 @@ class BadRequestError(FlowApiError): pass +class AnnotationValidationError(BadRequestError): + """Raised when the annotation upload returns hard validation errors. + + The annotation endpoint returns ``{"validation": [...]}`` with status 400 + for errors that cannot be ignored (as opposed to warnings which can). + + :param errors: List of validation error dicts from the response. + """ + + def __init__(self, errors: list[dict]) -> None: + self.errors = errors + super().__init__(400, f"Annotation has {len(errors)} validation error(s)") + + class NotFoundError(FlowApiError): """Raised when the API returns a 404 Not Found response.""" diff --git a/tests/unit/v2/test_exceptions.py b/tests/unit/v2/test_exceptions.py index d27abf5..2998d21 100644 --- a/tests/unit/v2/test_exceptions.py +++ b/tests/unit/v2/test_exceptions.py @@ -1,4 +1,5 @@ from flowbio.v2.exceptions import ( + AnnotationValidationError, AuthenticationError, BadRequestError, FlowApiError, @@ -73,6 +74,34 @@ def test_stores_status_code_and_message(self) -> None: assert error.message == message +class TestAnnotationValidationError: + + def test_is_bad_request_error(self) -> None: + error = AnnotationValidationError( + errors=[{"row": 1, "message": "Invalid scientist"}], + ) + + assert isinstance(error, BadRequestError) + + def test_stores_validation_errors(self) -> None: + validation_errors = [ + {"row": 1, "message": "Invalid scientist"}, + {"row": 2, "message": "Missing barcode"}, + ] + + error = AnnotationValidationError(errors=validation_errors) + + assert error.errors == validation_errors + + def test_str_includes_error_count(self) -> None: + error = AnnotationValidationError( + errors=[{"row": 1, "message": "Invalid scientist"}], + ) + + assert "1" in str(error) + assert "validation" in str(error).lower() + + class TestNotFoundError: def test_is_flow_api_error(self) -> None: From fda0939b643206a320a4b4b6b6afd08d3c16c4e2 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Mon, 9 Mar 2026 16:21:06 +0000 Subject: [PATCH 2/5] refactor(transport): preserve full response body in error fallback Change _raise_for_error fallback from "Unknown error" to the full body dict when the response has no "error" key. This preserves structured annotation validation data ({"validation": [...]}) in the exception. Also replace magic HTTP status code numbers with HTTPStatus constants in _transport.py and exceptions.py. FLOW-547 Co-Authored-By: Claude Opus 4.6 --- flowbio/v2/_transport.py | 9 +++++---- flowbio/v2/exceptions.py | 7 ++++++- tests/unit/v2/test_transport.py | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/flowbio/v2/_transport.py b/flowbio/v2/_transport.py index f985b6e..d391adb 100644 --- a/flowbio/v2/_transport.py +++ b/flowbio/v2/_transport.py @@ -1,3 +1,4 @@ +from http import HTTPStatus from importlib.metadata import PackageNotFoundError, version import httpx @@ -37,9 +38,9 @@ def __init__(self, base_url: str, connection_retries: int = 3) -> None: ) _STATUS_TO_EXCEPTION: dict[int, type[FlowApiError]] = { - 400: BadRequestError, - 401: AuthenticationError, - 404: NotFoundError, + HTTPStatus.BAD_REQUEST: BadRequestError, + HTTPStatus.UNAUTHORIZED: AuthenticationError, + HTTPStatus.NOT_FOUND: NotFoundError, } def _raise_for_error(self, response: httpx.Response) -> None: @@ -47,7 +48,7 @@ def _raise_for_error(self, response: httpx.Response) -> None: return body = response.json() - message = body.get("error", "Unknown error") + message = body.get("error", body) exception_class = self._STATUS_TO_EXCEPTION.get( response.status_code, FlowApiError, ) diff --git a/flowbio/v2/exceptions.py b/flowbio/v2/exceptions.py index dfe228c..91d6eb7 100644 --- a/flowbio/v2/exceptions.py +++ b/flowbio/v2/exceptions.py @@ -5,6 +5,8 @@ HTTP status code and error message from the response. """ +from http import HTTPStatus + class FlowApiError(Exception): """Base exception for all Flow API errors. @@ -46,7 +48,10 @@ class AnnotationValidationError(BadRequestError): def __init__(self, errors: list[dict]) -> None: self.errors = errors - super().__init__(400, f"Annotation has {len(errors)} validation error(s)") + super().__init__( + HTTPStatus.BAD_REQUEST, + f"Annotation has {len(errors)} validation error(s)", + ) class NotFoundError(FlowApiError): diff --git a/tests/unit/v2/test_transport.py b/tests/unit/v2/test_transport.py index 0300e09..66ec4a5 100644 --- a/tests/unit/v2/test_transport.py +++ b/tests/unit/v2/test_transport.py @@ -1,4 +1,5 @@ import json +from http import HTTPStatus from unittest.mock import patch import httpx @@ -291,6 +292,23 @@ def test_raises_flow_api_error_for_unexpected_status_codes(self) -> None: assert exc_info.value.message == error_message +class TestTransportErrorBodyPreservation: + + @respx.mock + def test_400_without_error_key_preserves_full_body(self) -> None: + body = {"validation": [{"row": 1, "message": "Invalid scientist"}]} + respx.post(f"{DEFAULT_BASE_URL}/upload/annotation").mock( + return_value=httpx.Response(HTTPStatus.BAD_REQUEST, json=body), + ) + + transport = HttpTransport(DEFAULT_BASE_URL) + + with pytest.raises(BadRequestError) as exc_info: + transport.post("/upload/annotation", data={}) + + assert exc_info.value.message == body + + class TestTransportGetBytes: @respx.mock From f07a1d731161d2fa0c010031a7cfa04260eac909 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Tue, 10 Mar 2026 10:18:48 +0000 Subject: [PATCH 3/5] refactor(samples): extract _upload_in_chunks from _upload_file Generalize the chunked upload method so it can be reused for multiplexed and annotation uploads. The method now takes an endpoint and a static extra_fields dict instead of sample-specific parameters. is_last_sample is now sent as a static per-file flag rather than being computed per-chunk, relying on the API's updated finalization logic (is_last_sample AND data.is_ready). FLOW-547 Co-Authored-By: Claude Opus 4.6 --- flowbio/v2/samples.py | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/flowbio/v2/samples.py b/flowbio/v2/samples.py index 92b22db..17e8703 100644 --- a/flowbio/v2/samples.py +++ b/flowbio/v2/samples.py @@ -214,13 +214,17 @@ def upload_sample( result: dict = {} for file_index, (data_type, file_path) in enumerate(files): is_last_file = file_index == len(files) - 1 - result = self._upload_file( - file_path=file_path, - is_last_file=is_last_file, - previous_data_ids=previous_data_ids, - sample_fields=self._build_sample_fields( - name, sample_type, metadata, project_id, organism_id, - ), + fields = self._build_sample_fields( + name, sample_type, metadata, project_id, organism_id, + ) + result = self._upload_in_chunks( + "/upload/sample", + file_path, + extra_fields={ + "is_last_sample": is_last_file, + "previous_data": previous_data_ids, + **(fields or {}), + }, ) if not is_last_file: previous_data_ids.append(result["data_id"]) @@ -325,12 +329,11 @@ def _create_metadata_attribute(self, item: dict) -> MetadataAttribute: item["options"] = self._resolve_options(item) return MetadataAttribute(**item) - def _upload_file( + def _upload_in_chunks( self, + endpoint: str, file_path: Path, - is_last_file: bool, - previous_data_ids: list[str], - sample_fields: dict[str, str], + extra_fields: dict | None = None, ) -> dict: chunk_size = self._config.chunk_size file_size = file_path.stat().st_size @@ -346,25 +349,22 @@ def _upload_file( ) for chunk_index in chunks: is_last_chunk = chunk_index == num_chunks - 1 - is_last_sample = is_last_file and is_last_chunk - with open(file_path, "rb") as f: - f.seek(chunk_index * chunk_size) - chunk = f.read(chunk_size) form_data: dict[str, str] = { "filename": file_path.name, "expected_file_size": str(chunk_index * chunk_size), "is_last": is_last_chunk, "data": data_id, - "is_last_sample": is_last_sample, - "previous_data": previous_data_ids, - **(sample_fields or {}), + **(extra_fields or {}), } + with open(file_path, "rb") as f: + f.seek(chunk_index * chunk_size) + chunk = f.read(chunk_size) result = self._transport.post( - "/upload/sample", + endpoint, data=form_data, files={"blob": (file_path.name, chunk, "application/octet-stream")}, ) - data_id = result["data_id"] + data_id = result.get("data_id") or result.get("id") return result _VALID_READS_KEYS = {"reads1", "reads2"} From 1ef88bc37a17ad854f2876837112ffc904cb01d0 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Tue, 10 Mar 2026 10:52:33 +0000 Subject: [PATCH 4/5] feat(samples): add upload_multiplexed_data() for multiplexed uploads Upload multiplexed reads and an annotation sheet in a single call. The annotation is uploaded first so reads files are not uploaded if the annotation is invalid. - Annotation hard validation errors raise AnnotationValidationError - Annotation warnings are auto-accepted by default (ignore_warnings=True) and included in the result for inspection - Set ignore_warnings=False to raise AnnotationValidationError on warnings - Supports single-end and paired-end reads with chunked uploads FLOW-547 Co-Authored-By: Claude Opus 4.6 --- flowbio/v2/__init__.py | 3 +- flowbio/v2/samples.py | 114 ++++++++++++++ tests/unit/v2/test_samples.py | 279 +++++++++++++++++++++++++++++++++- 3 files changed, 394 insertions(+), 2 deletions(-) diff --git a/flowbio/v2/__init__.py b/flowbio/v2/__init__.py index f27aece..aa77442 100644 --- a/flowbio/v2/__init__.py +++ b/flowbio/v2/__init__.py @@ -31,13 +31,14 @@ from flowbio.v2.auth import TokenCredentials, UsernamePasswordCredentials from flowbio.v2.client import Client, ClientConfig from flowbio.v2.exceptions import AnnotationValidationError -from flowbio.v2.samples import MetadataAttribute, Organism, Project, Sample, SampleType +from flowbio.v2.samples import MetadataAttribute, MultiplexedUpload, Organism, Project, Sample, SampleType __all__ = [ "AnnotationValidationError", "Client", "ClientConfig", "MetadataAttribute", + "MultiplexedUpload", "Organism", "Project", "Sample", diff --git a/flowbio/v2/samples.py b/flowbio/v2/samples.py index 17e8703..86add37 100644 --- a/flowbio/v2/samples.py +++ b/flowbio/v2/samples.py @@ -35,6 +35,7 @@ from tqdm import tqdm from flowbio.v2._pagination import PageIterator +from flowbio.v2.exceptions import AnnotationValidationError, BadRequestError if TYPE_CHECKING: from flowbio.v2.client import ClientConfig @@ -138,6 +139,20 @@ class Sample(BaseModel, frozen=True): id: str +class MultiplexedUpload(BaseModel, frozen=True): + """Result of a multiplexed data upload. + + :param data_ids: IDs for the uploaded multiplexed reads data. + :param annotation_id: ID for the uploaded annotation data. + :param warnings: Annotation warnings returned by the server. + Empty if the annotation was accepted without warnings. + """ + + data_ids: list[str] + annotation_id: str + warnings: list[dict] + + class SampleResource: """Provides access to sample-related API endpoints. @@ -230,6 +245,82 @@ def upload_sample( previous_data_ids.append(result["data_id"]) return Sample(id=result["sample_id"]) + def upload_multiplexed_data( + self, + reads: dict[str, Path], + annotation: Path, + ignore_warnings: bool = True, + ) -> MultiplexedUpload: + """Upload multiplexed reads and an annotation sheet. + + Validates and uploads the annotation sheet first, so that reads + files are not uploaded if the annotation is invalid. Then uploads + one or two reads files to ``/upload/multiplexed``. + + By default, annotation warnings are automatically accepted (the + upload is retried with ``ignore_warnings=True``) and included in + the result for inspection. Set ``ignore_warnings=False`` to + reject the upload on warnings instead. + + Requires authentication. + + Example:: + + from pathlib import Path + + result = client.samples.upload_multiplexed_data( + reads={"reads1": Path("multiplexed_R1.fastq.gz")}, + annotation=Path("annotation.xlsx"), + ) + print(f"Data IDs: {result.data_ids}") + print(f"Annotation ID: {result.annotation_id}") + if result.warnings: + print(f"Warnings: {result.warnings}") + + :param reads: A mapping of reads keys to file paths. Use + ``reads1`` for single-end, or ``reads1`` and ``reads2`` for + paired-end. ``reads1`` is always uploaded first:: + + # Single-end + {"reads1": Path("multiplexed.fastq.gz")} + + # Paired-end + {"reads1": Path("R1.fastq.gz"), "reads2": Path("R2.fastq.gz")} + + :param annotation: Path to the annotation sheet (``.xlsx`` or + ``.csv``). Use :meth:`get_annotation_template` to download a + template. + :param ignore_warnings: If ``True`` (the default), annotation + warnings are automatically accepted and included in the + result. If ``False``, warnings cause a + :class:`BadRequestError` to be raised. + :raises ValueError: If reads keys are invalid (e.g. ``reads3``) + or ``reads2`` is provided without ``reads1``. + :raises AnnotationValidationError: If the annotation has hard + validation errors that cannot be ignored. + :raises BadRequestError: If ``ignore_warnings=False`` and the + annotation has warnings. + """ + files = self._ordered_files(reads) + + annotation_id, warnings = self._upload_annotation( + annotation, ignore_warnings, + ) + + data_ids: list[str] = [] + for _, file_path in files: + extra_fields = {"reads1": data_ids[0]} if data_ids else {} + result = self._upload_in_chunks( + "/upload/multiplexed", file_path, extra_fields, + ) + data_ids.append(result["id"]) + + return MultiplexedUpload( + data_ids=data_ids, + annotation_id=annotation_id, + warnings=warnings, + ) + def get_annotation_template(self, sample_type: str = "generic") -> bytes: """Download an annotation sheet template for multiplexed uploads. @@ -367,6 +458,29 @@ def _upload_in_chunks( data_id = result.get("data_id") or result.get("id") return result + def _upload_annotation( + self, file_path: Path, ignore_warnings: bool, + ) -> tuple[str, list[dict]]: + try: + result = self._upload_in_chunks("/upload/annotation", file_path) + return result["id"], [] + except BadRequestError as e: + if isinstance(e.message, dict) and "validation" in e.message: + raise AnnotationValidationError(errors=e.message["validation"]) from e + if isinstance(e.message, dict) and "warnings" in e.message: + if not ignore_warnings: + raise AnnotationValidationError( + errors=e.message["warnings"], + ) from e + warnings = e.message["warnings"] + result = self._upload_in_chunks( + "/upload/annotation", + file_path, + extra_fields={"ignore_warnings": True}, + ) + return result["id"], warnings + raise + _VALID_READS_KEYS = {"reads1", "reads2"} @staticmethod diff --git a/tests/unit/v2/test_samples.py b/tests/unit/v2/test_samples.py index 36c0ba3..7df401a 100644 --- a/tests/unit/v2/test_samples.py +++ b/tests/unit/v2/test_samples.py @@ -6,9 +6,10 @@ import respx from flowbio.v2.client import Client, ClientConfig -from flowbio.v2.exceptions import NotFoundError +from flowbio.v2.exceptions import AnnotationValidationError, NotFoundError from flowbio.v2.samples import ( MetadataAttribute, + MultiplexedUpload, Organism, Project, SampleResource, @@ -553,3 +554,279 @@ def test_raises_not_found_error_for_nonexistent_type(self) -> None: client.samples.get_annotation_template(sample_type="nonexistent") assert exc_info.value.message == error_message + + +class TestUploadMultiplexedData: + + @respx.mock + def test_single_end_with_annotation(self, tmp_path: Path) -> None: + reads_file = tmp_path / "reads.fastq" + reads_file.write_bytes(b"ATCGATCG") + annotation_file = tmp_path / "annotation.xlsx" + annotation_file.write_bytes(b"annotation-content") + multiplexed_data_id = "mux_data_1" + annotation_id = "ann_1" + respx.post(f"{DEFAULT_BASE_URL}/upload/multiplexed").mock( + return_value=httpx.Response(200, json={"id": multiplexed_data_id}), + ) + respx.post(f"{DEFAULT_BASE_URL}/upload/annotation").mock( + return_value=httpx.Response(200, json={"id": annotation_id}), + ) + + client = Client() + result = client.samples.upload_multiplexed_data( + reads={"reads1": reads_file}, + annotation=annotation_file, + ) + + assert result == MultiplexedUpload( + data_ids=[multiplexed_data_id], + annotation_id=annotation_id, + warnings=[], + ) + + @respx.mock + def test_uploads_annotation_before_reads(self, tmp_path: Path) -> None: + reads_file = tmp_path / "reads.fastq" + reads_file.write_bytes(b"ATCG") + annotation_file = tmp_path / "annotation.xlsx" + annotation_file.write_bytes(b"annotation") + call_order: list[str] = [] + ann_route = respx.post(f"{DEFAULT_BASE_URL}/upload/annotation") + ann_route.mock( + return_value=httpx.Response(200, json={"id": "ann_1"}), + ) + ann_route.mock(side_effect=lambda req: ( + call_order.append("annotation"), + httpx.Response(200, json={"id": "ann_1"}), + )[1]) + mux_route = respx.post(f"{DEFAULT_BASE_URL}/upload/multiplexed") + mux_route.mock(side_effect=lambda req: ( + call_order.append("multiplexed"), + httpx.Response(200, json={"id": "mux_1"}), + )[1]) + + client = Client() + client.samples.upload_multiplexed_data( + reads={"reads1": reads_file}, + annotation=annotation_file, + ) + + assert call_order == ["annotation", "multiplexed"] + + @respx.mock + def test_paired_end_sends_reads1_on_second_request(self, tmp_path: Path) -> None: + file1 = tmp_path / "R1.fastq" + file2 = tmp_path / "R2.fastq" + file1.write_bytes(b"READ1") + file2.write_bytes(b"READ2") + annotation_file = tmp_path / "annotation.xlsx" + annotation_file.write_bytes(b"annotation") + first_data_id = "mux_1" + mux_route = respx.post(f"{DEFAULT_BASE_URL}/upload/multiplexed") + mux_route.side_effect = [ + httpx.Response(200, json={"id": first_data_id}), + httpx.Response(200, json={"id": "mux_2"}), + ] + respx.post(f"{DEFAULT_BASE_URL}/upload/annotation").mock( + return_value=httpx.Response(200, json={"id": "ann_1"}), + ) + + client = Client() + client.samples.upload_multiplexed_data( + reads={"reads1": file1, "reads2": file2}, + annotation=annotation_file, + ) + + first_request = mux_route.calls[0].request + second_request = mux_route.calls[1].request + assert b"reads1" not in first_request.content + assert first_data_id.encode() in second_request.content + + @respx.mock + def test_paired_end_with_annotation(self, tmp_path: Path) -> None: + file1 = tmp_path / "R1.fastq" + file2 = tmp_path / "R2.fastq" + file1.write_bytes(b"READ1") + file2.write_bytes(b"READ2") + annotation_file = tmp_path / "annotation.xlsx" + annotation_file.write_bytes(b"annotation") + mux_route = respx.post(f"{DEFAULT_BASE_URL}/upload/multiplexed") + mux_route.side_effect = [ + httpx.Response(200, json={"id": "mux_1"}), + httpx.Response(200, json={"id": "mux_2"}), + ] + respx.post(f"{DEFAULT_BASE_URL}/upload/annotation").mock( + return_value=httpx.Response(200, json={"id": "ann_1"}), + ) + + client = Client() + result = client.samples.upload_multiplexed_data( + reads={"reads1": file1, "reads2": file2}, + annotation=annotation_file, + ) + + assert result == MultiplexedUpload( + data_ids=["mux_1", "mux_2"], + annotation_id="ann_1", + warnings=[], + ) + assert mux_route.call_count == 2 + + @respx.mock + def test_validation_errors_raise_without_uploading_reads( + self, tmp_path: Path, + ) -> None: + reads_file = tmp_path / "reads.fastq" + reads_file.write_bytes(b"ATCG") + annotation_file = tmp_path / "annotation.xlsx" + annotation_file.write_bytes(b"annotation") + validation_errors = [{"row": 1, "message": "Invalid scientist"}] + mux_route = respx.post(f"{DEFAULT_BASE_URL}/upload/multiplexed").mock( + return_value=httpx.Response(200, json={"id": "mux_1"}), + ) + respx.post(f"{DEFAULT_BASE_URL}/upload/annotation").mock( + return_value=httpx.Response( + 400, json={"validation": validation_errors}, + ), + ) + + client = Client() + + with pytest.raises(AnnotationValidationError) as exc_info: + client.samples.upload_multiplexed_data( + reads={"reads1": reads_file}, + annotation=annotation_file, + ) + + assert exc_info.value.errors == validation_errors + assert mux_route.call_count == 0 + + @respx.mock + def test_annotation_warnings_auto_retries_and_returns_warnings( + self, tmp_path: Path, + ) -> None: + reads_file = tmp_path / "reads.fastq" + reads_file.write_bytes(b"ATCG") + annotation_file = tmp_path / "annotation.xlsx" + annotation_file.write_bytes(b"annotation") + warnings = [{"row": 1, "message": "Unknown barcode"}] + ann_route = respx.post(f"{DEFAULT_BASE_URL}/upload/annotation") + ann_route.side_effect = [ + httpx.Response(400, json={"warnings": warnings}), + httpx.Response(200, json={"id": "ann_1"}), + ] + respx.post(f"{DEFAULT_BASE_URL}/upload/multiplexed").mock( + return_value=httpx.Response(200, json={"id": "mux_1"}), + ) + + client = Client() + result = client.samples.upload_multiplexed_data( + reads={"reads1": reads_file}, + annotation=annotation_file, + ) + + assert result.warnings == warnings + assert result.annotation_id == "ann_1" + retry_request = ann_route.calls[1].request + assert b"ignore_warnings" in retry_request.content + + @respx.mock + def test_ignore_warnings_false_raises_annotation_validation_error( + self, tmp_path: Path, + ) -> None: + reads_file = tmp_path / "reads.fastq" + reads_file.write_bytes(b"ATCG") + annotation_file = tmp_path / "annotation.xlsx" + annotation_file.write_bytes(b"annotation") + warnings = [{"row": 1, "message": "Unknown barcode"}] + respx.post(f"{DEFAULT_BASE_URL}/upload/annotation").mock( + return_value=httpx.Response(400, json={"warnings": warnings}), + ) + + client = Client() + + with pytest.raises(AnnotationValidationError) as exc_info: + client.samples.upload_multiplexed_data( + reads={"reads1": reads_file}, + annotation=annotation_file, + ignore_warnings=False, + ) + + assert exc_info.value.errors == warnings + + @respx.mock + def test_annotation_without_warnings_returns_empty_warnings( + self, tmp_path: Path, + ) -> None: + reads_file = tmp_path / "reads.fastq" + reads_file.write_bytes(b"ATCG") + annotation_file = tmp_path / "annotation.xlsx" + annotation_file.write_bytes(b"annotation") + respx.post(f"{DEFAULT_BASE_URL}/upload/multiplexed").mock( + return_value=httpx.Response(200, json={"id": "mux_1"}), + ) + respx.post(f"{DEFAULT_BASE_URL}/upload/annotation").mock( + return_value=httpx.Response(200, json={"id": "ann_1"}), + ) + + client = Client() + result = client.samples.upload_multiplexed_data( + reads={"reads1": reads_file}, + annotation=annotation_file, + ) + + assert result.warnings == [] + + def test_rejects_invalid_reads_keys(self, tmp_path: Path) -> None: + file_path = tmp_path / "reads.fastq" + file_path.write_bytes(b"ATCG") + annotation_file = tmp_path / "annotation.xlsx" + annotation_file.write_bytes(b"annotation") + + client = Client() + + with pytest.raises(ValueError, match="reads3"): + client.samples.upload_multiplexed_data( + reads={"reads1": file_path, "reads3": file_path}, + annotation=annotation_file, + ) + + def test_rejects_reads2_without_reads1(self, tmp_path: Path) -> None: + file_path = tmp_path / "reads.fastq" + file_path.write_bytes(b"ATCG") + annotation_file = tmp_path / "annotation.xlsx" + annotation_file.write_bytes(b"annotation") + + client = Client() + + with pytest.raises(ValueError, match="reads1"): + client.samples.upload_multiplexed_data( + reads={"reads2": file_path}, + annotation=annotation_file, + ) + + @respx.mock + def test_chunked_multiplexed_upload(self, tmp_path: Path) -> None: + reads_file = tmp_path / "reads.fastq" + reads_file.write_bytes(b"A" * 100) + annotation_file = tmp_path / "annotation.xlsx" + annotation_file.write_bytes(b"annotation") + mux_route = respx.post(f"{DEFAULT_BASE_URL}/upload/multiplexed") + mux_route.side_effect = [ + httpx.Response(200, json={"id": "mux_1"}), + httpx.Response(200, json={"id": "mux_1"}), + httpx.Response(200, json={"id": "mux_1"}), + ] + respx.post(f"{DEFAULT_BASE_URL}/upload/annotation").mock( + return_value=httpx.Response(200, json={"id": "ann_1"}), + ) + + client = Client(config=ClientConfig(chunk_size=40, show_progress=False)) + result = client.samples.upload_multiplexed_data( + reads={"reads1": reads_file}, + annotation=annotation_file, + ) + + assert mux_route.call_count == 3 + assert result.data_ids == ["mux_1"] From cfcb27335c24a4748be4dba513a60a7531ab0311 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Tue, 10 Mar 2026 14:47:42 +0000 Subject: [PATCH 5/5] fix(samples): address PR review feedback - Fix docstring: `ignore_warnings=False` raises `AnnotationValidationError`, not `BadRequestError` - Open file once before chunk loop instead of reopening per chunk - Fix `form_data` type annotation to `str | bool | list[str] | None` - Add comment clarifying `expected_file_size` is a byte offset --- flowbio/v2/samples.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/flowbio/v2/samples.py b/flowbio/v2/samples.py index 86add37..3b2043d 100644 --- a/flowbio/v2/samples.py +++ b/flowbio/v2/samples.py @@ -298,8 +298,8 @@ def upload_multiplexed_data( or ``reads2`` is provided without ``reads1``. :raises AnnotationValidationError: If the annotation has hard validation errors that cannot be ignored. - :raises BadRequestError: If ``ignore_warnings=False`` and the - annotation has warnings. + :raises AnnotationValidationError: If ``ignore_warnings=False`` + and the annotation has warnings. """ files = self._ordered_files(reads) @@ -438,24 +438,24 @@ def _upload_in_chunks( desc=f"Uploading {file_path.name}", unit="chunk", ) - for chunk_index in chunks: - is_last_chunk = chunk_index == num_chunks - 1 - form_data: dict[str, str] = { - "filename": file_path.name, - "expected_file_size": str(chunk_index * chunk_size), - "is_last": is_last_chunk, - "data": data_id, - **(extra_fields or {}), - } - with open(file_path, "rb") as f: - f.seek(chunk_index * chunk_size) + with open(file_path, "rb") as f: + for chunk_index in chunks: + is_last_chunk = chunk_index == num_chunks - 1 + form_data: dict[str, str | bool | list[str] | None] = { + "filename": file_path.name, + # API uses this as a byte offset to verify upload resumption + "expected_file_size": str(chunk_index * chunk_size), + "is_last": is_last_chunk, + "data": data_id, + **(extra_fields or {}), + } chunk = f.read(chunk_size) - result = self._transport.post( - endpoint, - data=form_data, - files={"blob": (file_path.name, chunk, "application/octet-stream")}, - ) - data_id = result.get("data_id") or result.get("id") + result = self._transport.post( + endpoint, + data=form_data, + files={"blob": (file_path.name, chunk, "application/octet-stream")}, + ) + data_id = result.get("data_id") or result.get("id") return result def _upload_annotation(