From 8f90b9695969b6d4752e6904abfa4016a17981f5 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Tue, 9 Jun 2026 16:00:54 +0100 Subject: [PATCH 1/3] fix(cli): surface annotation validation error detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AnnotationValidationError` carries the per-row problems in its `.errors` list, but the CLI's central error renderer only printed `error.message` — the summary "Annotation has N validation error(s)". A user running `samples upload-multiplexed` against a sheet the server rejects therefore saw the count but not what was actually wrong. `_dispatch` now passes those errors to `Output.emit_error`, which renders them one per line in human mode and under an `errors` key in the `--json` error document. The `{row, message}` formatting is unified into a shared `format_issue` helper reused by the multiplexed-upload warning output. Co-Authored-By: Claude Opus 4.8 --- flowbio/cli/_main.py | 7 +++++-- flowbio/cli/_output.py | 27 ++++++++++++++++++++++++++- flowbio/cli/_samples.py | 12 ++---------- tests/unit/cli/test_samples.py | 26 +++++++++++++++++++++++++- 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/flowbio/cli/_main.py b/flowbio/cli/_main.py index 1398237..6b8ba1b 100644 --- a/flowbio/cli/_main.py +++ b/flowbio/cli/_main.py @@ -21,7 +21,7 @@ from flowbio.cli._progress import progress_config from flowbio.cli._types import BaseUrl, Token from flowbio.v2.client import Client -from flowbio.v2.exceptions import FlowApiError +from flowbio.v2.exceptions import AnnotationValidationError, FlowApiError # A handler runs one command against an authenticated client and returns the # process exit code. Errors are raised (not returned) and mapped centrally. @@ -88,7 +88,10 @@ def _dispatch(args: argparse.Namespace) -> int: output.emit_error(str(error)) return int(ExitCode.USAGE) except FlowApiError as error: - output.emit_error(error.message, status_code=error.status_code) + details = error.errors if isinstance(error, AnnotationValidationError) else None + output.emit_error( + error.message, status_code=error.status_code, details=details, + ) return int(exit_code_for(error)) diff --git a/flowbio/cli/_output.py b/flowbio/cli/_output.py index 6e1a77f..c6fa8d5 100644 --- a/flowbio/cli/_output.py +++ b/flowbio/cli/_output.py @@ -53,18 +53,43 @@ def emit_advisory(self, message: str) -> None: if not self.json_mode: print(message, file=self.stderr) - def emit_error(self, message: JsonValue, status_code: int | None = None) -> None: + def emit_error( + self, + message: JsonValue, + status_code: int | None = None, + details: list[JsonValue] | None = None, + ) -> None: """Emit an error to stderr. :param message: The error message — a string, or a structured value (e.g. the library's field-level error dict) preserved as-is in JSON. :param status_code: The HTTP status code, when the error came from the server. Included in the JSON document where present. + :param details: Per-item detail behind a summary message (e.g. the + individual annotation validation errors). Listed under ``errors`` in + ``--json`` mode and one per line in human mode. """ if self.json_mode: document: dict[str, JsonValue] = {"message": message} if status_code is not None: document["status_code"] = int(status_code) + if details: + document["errors"] = details print(json.dumps(document), file=self.stderr) else: print(f"Error: {message}", file=self.stderr) + for detail in details or []: + print(f" {format_issue(detail)}", file=self.stderr) + + +def format_issue(issue: JsonValue) -> str: + """Render a Flow ``{row, message}`` issue dict as a readable line. + + Falls back to ``str`` for any other shape so unexpected payloads still + surface intact. + """ + if isinstance(issue, dict) and "message" in issue: + row = issue.get("row") + prefix = f"row {row}: " if row is not None else "" + return f"{prefix}{issue['message']}" + return str(issue) diff --git a/flowbio/cli/_samples.py b/flowbio/cli/_samples.py index 9cdee41..a415435 100644 --- a/flowbio/cli/_samples.py +++ b/flowbio/cli/_samples.py @@ -13,7 +13,7 @@ from flowbio.cli._exit_codes import CliUsageError, ExitCode from flowbio.cli._files import existing_file -from flowbio.cli._output import Output +from flowbio.cli._output import Output, format_issue from flowbio.v2.client import Client @@ -220,7 +220,7 @@ def _upload_multiplexed_command( if upload.warnings: output.emit_advisory("Annotation warnings:") for warning in upload.warnings: - output.emit_advisory(f" {_format_warning(warning)}") + output.emit_advisory(f" {format_issue(warning)}") output.emit_result( f"Uploaded multiplexed data {', '.join(upload.data_ids)} " f"with annotation {upload.annotation_id}", @@ -233,14 +233,6 @@ def _upload_multiplexed_command( return ExitCode.SUCCESS -def _format_warning(warning: dict) -> str: - if "message" not in warning: - return str(warning) - row = warning.get("row") - prefix = f"row {row}: " if row is not None else "" - return f"{prefix}{warning['message']}" - - def _merge_metadata( pairs: list[str] | None, json_text: str | None, ) -> dict[str, str]: diff --git a/tests/unit/cli/test_samples.py b/tests/unit/cli/test_samples.py index 2a21d82..8364370 100644 --- a/tests/unit/cli/test_samples.py +++ b/tests/unit/cli/test_samples.py @@ -438,10 +438,11 @@ def test_reject_warnings_rejects_upload( def test_annotation_validation_failure_rejects_upload( self, run_cli, tmp_path: Path, ) -> None: + detail = "Invalid scientist" respx.post(ANNOTATION_UPLOAD_URL).mock( return_value=httpx.Response( HTTPStatus.BAD_REQUEST, - json={"validation": [{"row": 1, "message": "Invalid scientist"}]}, + json={"validation": [{"row": 1, "message": detail}]}, ), ) multiplexed = _mock_multiplexed() @@ -455,3 +456,26 @@ def test_annotation_validation_failure_rejects_upload( assert result.exit_code == 5 assert multiplexed.call_count == 0 + assert f"row 1: {detail}" in result.stderr + + @respx.mock + def test_annotation_validation_errors_in_json_error_document( + self, run_cli, tmp_path: Path, + ) -> None: + errors = [{"row": 1, "message": "Invalid scientist"}] + respx.post(ANNOTATION_UPLOAD_URL).mock( + return_value=httpx.Response( + HTTPStatus.BAD_REQUEST, json={"validation": errors}, + ), + ) + + result = run_cli( + "--token", TOKEN, "samples", "upload-multiplexed", + "--reads1", str(_reads(tmp_path, "r1.fq.gz")), + "--annotation", str(_annotation(tmp_path)), + "--no-progress", "--json", + ) + + assert result.exit_code == 5 + assert result.stdout == "" + assert json.loads(result.stderr)["errors"] == errors From 5f17798b73d6407e8afe172990af58dafdae5b13 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Tue, 9 Jun 2026 16:10:15 +0100 Subject: [PATCH 2/3] chore: bump version to 0.7.0 Releases the new `samples annotation-template` and `samples upload-multiplexed` CLI commands and the annotation validation-error reporting. The bump also gives `uvx`/pip a fresh cache key so the new code is picked up rather than a cached 0.6.0 build. Co-Authored-By: Claude Opus 4.8 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b5d36b3..a3017b7 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="flowbio", - version="0.6.0", + version="0.7.0", description="A client for the Flow API.", long_description=long_description, long_description_content_type="text/markdown", From 0d9842af148b56d455f76b1a4ee16c2dc0337db7 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Tue, 9 Jun 2026 16:23:04 +0100 Subject: [PATCH 3/3] test(cli): cover emit_error details and format_issue Backfills the coverage flagged in review: a direct test of the `details` parameter added to `Output.emit_error` (rendered one-per-line in human mode and under an `errors` key in `--json`), and unit tests for the public `format_issue` helper including its fall-back for issue dicts without a `message` key. Co-Authored-By: Claude Opus 4.8 --- tests/unit/cli/test_output.py | 50 ++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/tests/unit/cli/test_output.py b/tests/unit/cli/test_output.py index 5baca0a..0006d14 100644 --- a/tests/unit/cli/test_output.py +++ b/tests/unit/cli/test_output.py @@ -3,7 +3,7 @@ from http import HTTPStatus from flowbio.cli._exit_codes import CliUsageError, ExitCode, exit_code_for -from flowbio.cli._output import Output +from flowbio.cli._output import Output, format_issue from flowbio.v2.exceptions import ( AnnotationValidationError, AuthenticationError, @@ -88,6 +88,25 @@ def test_error_goes_to_stderr_only(self) -> None: assert out.getvalue() == "" assert message in err.getvalue() + def test_error_renders_details_one_per_line_after_summary(self) -> None: + out, err = io.StringIO(), io.StringIO() + output = Output(json_mode=False, stdout=out, stderr=err) + + output.emit_error( + "Annotation has 2 validation error(s)", + status_code=HTTPStatus.BAD_REQUEST, + details=[ + {"row": 1, "message": "Invalid scientist"}, + {"row": 3, "message": "Unknown organism"}, + ], + ) + + lines = err.getvalue().splitlines() + assert lines[0] == "Error: Annotation has 2 validation error(s)" + assert "row 1: Invalid scientist" in lines[1] + assert "row 3: Unknown organism" in lines[2] + assert out.getvalue() == "" + class TestJsonOutput: @@ -131,3 +150,32 @@ def test_error_omits_status_code_when_absent(self) -> None: output.emit_error(message) assert json.loads(err.getvalue()) == {"message": message} + + def test_error_includes_details_under_errors_key(self) -> None: + message = "Annotation has 1 validation error(s)" + errors = [{"row": 1, "message": "Invalid scientist"}] + out, err = io.StringIO(), io.StringIO() + output = Output(json_mode=True, stdout=out, stderr=err) + + output.emit_error(message, status_code=HTTPStatus.BAD_REQUEST, details=errors) + + assert json.loads(err.getvalue()) == { + "message": message, + "status_code": int(HTTPStatus.BAD_REQUEST), + "errors": errors, + } + assert out.getvalue() == "" + + +class TestFormatIssue: + + def test_formats_row_and_message(self) -> None: + assert format_issue({"row": 1, "message": "bad"}) == "row 1: bad" + + def test_message_without_row_has_no_prefix(self) -> None: + assert format_issue({"message": "bad"}) == "bad" + + def test_falls_back_to_str_for_other_shapes(self) -> None: + issue = {"code": "E123"} + + assert format_issue(issue) == str(issue)