Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions flowbio/cli/_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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))


Expand Down
27 changes: 26 additions & 1 deletion flowbio/cli/_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +68 to +82

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We haven't added tests for this



def format_issue(issue: JsonValue) -> str:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be a private function I'm guessing?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And we can move it back to _samples.py?

"""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)
12 changes: 2 additions & 10 deletions flowbio/cli/_samples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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}",
Expand All @@ -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]:
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
50 changes: 49 additions & 1 deletion tests/unit/cli/test_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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)
26 changes: 25 additions & 1 deletion tests/unit/cli/test_samples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Loading