diff --git a/flowbio/cli/_main.py b/flowbio/cli/_main.py index 2196f08..e7fc634 100644 --- a/flowbio/cli/_main.py +++ b/flowbio/cli/_main.py @@ -89,7 +89,7 @@ def _dispatch(args: argparse.Namespace) -> int: output.emit_error(str(error)) return int(ExitCode.USAGE) except FlowApiError as error: - details = error.errors if isinstance(error, AnnotationValidationError) else None + details = error.errors if isinstance(error, AnnotationValidationError) else error.details output.emit_error( error.message, status_code=error.status_code, details=details, ) diff --git a/flowbio/cli/_output.py b/flowbio/cli/_output.py index f5839b1..739702f 100644 --- a/flowbio/cli/_output.py +++ b/flowbio/cli/_output.py @@ -94,13 +94,20 @@ def emit_raw(self, body: str) -> None: def format_issue(issue: JsonValue) -> str: - """Render a Flow ``{row, message}`` issue dict as a readable line. + """Render a Flow issue dict as a readable line. - Falls back to ``str`` for any other shape so unexpected payloads still - surface intact. + Handles both the annotation ``{row, message}`` shape and the API error + envelope's ``{field, code, message}`` detail shape, prefixing with whichever + locator is present. 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 "" + if row is not None: + prefix = f"row {row}: " + elif issue.get("field") is not None: + prefix = f"{issue['field']}: " + else: + prefix = "" return f"{prefix}{issue['message']}" return str(issue) diff --git a/flowbio/v2/_transport.py b/flowbio/v2/_transport.py index 7832c91..dbf1fb4 100644 --- a/flowbio/v2/_transport.py +++ b/flowbio/v2/_transport.py @@ -78,6 +78,7 @@ def _raise_for_error(self, response: httpx.Response) -> None: if response.is_success: return + details: list[dict] | None = None try: body = response.json() except ValueError: @@ -85,11 +86,22 @@ def _raise_for_error(self, response: httpx.Response) -> None: # HTML/plain-text on 5xx instead of the API's JSON envelope. message = self._non_json_error_message(response) else: - message = body.get("error", body) + message, details = self._unpack_error_body(body) exception_class = self._STATUS_TO_EXCEPTION.get( response.status_code, FlowApiError, ) - raise exception_class(response.status_code, message) + raise exception_class(response.status_code, message, details) + + @staticmethod + def _unpack_error_body(body: dict) -> tuple[str | dict, list[dict] | None]: + error = body.get("error", body) + # The v2 API wraps errors in a {code, message, details} envelope; older + # endpoints put a bare string (or their own dict) under "error". Unwrap + # the envelope to its human message and per-field details, and leave any + # other shape untouched. + if isinstance(error, dict) and "message" in error: + return error["message"], error.get("details") or None + return error, None @staticmethod def _non_json_error_message(response: httpx.Response) -> str: diff --git a/flowbio/v2/exceptions.py b/flowbio/v2/exceptions.py index a035e76..71796ba 100644 --- a/flowbio/v2/exceptions.py +++ b/flowbio/v2/exceptions.py @@ -17,11 +17,20 @@ class FlowApiError(Exception): :param status_code: The HTTP status code from the response. :param message: The error message — either a string or a dict of field-level errors (e.g. ``{"field": ["error message"]}``). + :param details: Optional per-field problems from the server's error + envelope, each a ``{"field", "code", "message"}`` dict. Set when the + response carries the structured ``details`` list; ``None`` otherwise. """ - def __init__(self, status_code: int, message: str | dict[str, list[str]]) -> None: + def __init__( + self, + status_code: int, + message: str | dict[str, list[str]], + details: list[dict] | None = None, + ) -> None: self.status_code = status_code self.message = message + self.details = details super().__init__(str(message)) diff --git a/setup.py b/setup.py index 73479a2..bab87fd 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="flowbio", - version="0.11.0", + version="0.11.1", description="A client for the Flow API.", long_description=long_description, long_description_content_type="text/markdown", diff --git a/tests/unit/cli/test_samples.py b/tests/unit/cli/test_samples.py index e228779..23ef963 100644 --- a/tests/unit/cli/test_samples.py +++ b/tests/unit/cli/test_samples.py @@ -1151,10 +1151,17 @@ def test_trailing_blank_row_is_skipped( def test_api_rejection_propagates_as_error( self, run_cli, tmp_path: Path, ) -> None: + detail_message = "Sample type 'bogus' does not exist" route = respx.post(SAMPLE_IMPORTS_URL).mock( return_value=httpx.Response( HTTPStatus.UNPROCESSABLE_ENTITY, - json={"error": "sample type 'bogus' does not exist"}, + json={"error": { + "code": "validation_error", + "message": "Invalid sample import request", + "details": [ + {"field": "0.sample_type", "code": "invalid", "message": detail_message}, + ], + }}, ), ) sheet = _write_import_sheet(tmp_path, _import_record(sample_type="bogus")) @@ -1165,7 +1172,9 @@ def test_api_rejection_propagates_as_error( assert result.exit_code == 1 assert route.call_count == 1 - assert "bogus" in result.stderr + assert "Invalid sample import request" in result.stderr + assert f"0.sample_type: {detail_message}" in result.stderr + assert "{'" not in result.stderr @respx.mock def test_non_csv_sheet_is_usage_error(self, run_cli, tmp_path: Path) -> None: @@ -1616,9 +1625,11 @@ def test_reports_job_with_no_execution_yet(self, run_cli) -> None: @respx.mock def test_unknown_job_id_is_not_found(self, run_cli) -> None: + not_found_message = "sample import 999 does not exist" respx.get(f"{SAMPLE_IMPORTS_URL}/999").mock( return_value=httpx.Response( - HTTPStatus.NOT_FOUND, json={"error": "sample import 999 does not exist"}, + HTTPStatus.NOT_FOUND, + json={"error": {"code": "not_found", "message": not_found_message, "details": []}}, ), ) @@ -1627,6 +1638,8 @@ def test_unknown_job_id_is_not_found(self, run_cli) -> None: ) assert result.exit_code == 4 + assert not_found_message in result.stderr + assert "{'" not in result.stderr def test_missing_job_id_is_usage_error(self, run_cli) -> None: result = run_cli("--token", TOKEN, "samples", "import-status")