diff --git a/cu-cli/packages/core/src/cu_cli_core/analysis.py b/cu-cli/packages/core/src/cu_cli_core/analysis.py index 100875f..adc28af 100644 --- a/cu-cli/packages/core/src/cu_cli_core/analysis.py +++ b/cu-cli/packages/core/src/cu_cli_core/analysis.py @@ -25,6 +25,8 @@ from pathlib import Path from typing import Any, Callable, Mapping, Optional, Sequence +from .errors import ServiceError, ServiceErrorDetail + RESULT_SUFFIXES = (".result.md", ".result.json") @@ -211,6 +213,48 @@ def _capture_raw_response( return deserialized, pipeline_response.http_response +def _service_error_details(error: object) -> tuple[ServiceErrorDetail, ...]: + """Flatten a service error and its nested errors for frontend rendering.""" + pending = [error] + details: list[ServiceErrorDetail] = [] + while pending: + item = pending.pop(0) + if not isinstance(item, Mapping): + continue + details.append( + ServiceErrorDetail( + code=item.get("code"), + message=item.get("message"), + target=item.get("target"), + ) + ) + nested = item.get("innererror", item.get("innerError")) + if nested is not None: + pending.append(nested) + children = item.get("details") + if isinstance(children, list): + pending.extend(children) + return tuple(detail for detail in details if detail.code or detail.message or detail.target) + + +def _raw_analysis_result(raw_response: Any) -> Any: + """Deserialize a raw response and reject a service-reported failed operation.""" + result = raw_response.json() + if not isinstance(result, Mapping) or str(result.get("status", "")).lower() != "failed": + return result + + details = _service_error_details(result.get("error")) + description = "; ".join( + ": ".join(part for part in (detail.code, detail.message) if part) + for detail in details + if detail.code or detail.message + ) + message = "Analysis failed according to the service response." + if description: + message = f"{message} {description}" + raise ServiceError(message, details=details, context={"status": result.get("status")}) + + def analyze_bytes( client: Any, analyzer_id: str, @@ -244,7 +288,7 @@ def analyze_bytes_with_usage( completed = poller.result() if raw_json: _, raw_response = completed - result = raw_response.json() + result = _raw_analysis_result(raw_response) else: result = completed return AnalyzeResponse(result=result, usage=getattr(poller, "usage", None)) @@ -289,7 +333,7 @@ def analyze_url_with_usage( completed = poller.result() if raw_json: _, raw_response = completed - result = raw_response.json() + result = _raw_analysis_result(raw_response) else: result = completed return AnalyzeResponse(result=result, usage=getattr(poller, "usage", None)) @@ -327,7 +371,7 @@ def analyze_bytes_inline_with_usage( ) if raw_json: response, raw_response = completed - result = raw_response.json() + result = _raw_analysis_result(raw_response) else: response = completed result = response.result @@ -366,7 +410,7 @@ def analyze_url_inline_with_usage( ) if raw_json: response, raw_response = completed - result = raw_response.json() + result = _raw_analysis_result(raw_response) else: response = completed result = response.result diff --git a/cu-cli/packages/core/tests/test_analysis.py b/cu-cli/packages/core/tests/test_analysis.py index 4fd3fe5..2e51a51 100644 --- a/cu-cli/packages/core/tests/test_analysis.py +++ b/cu-cli/packages/core/tests/test_analysis.py @@ -40,6 +40,7 @@ plan_jobs, ) from cu_cli_core.contracts import AnalyzeRequest +from cu_cli_core.errors import ServiceError from cu_cli_core.operations.analysis import execute_analyze pytestmark = pytest.mark.unit @@ -70,23 +71,31 @@ def __init__(self, payload): class _FakeClient: """Records calls and echoes a deterministic result per analyzer/bytes.""" - def __init__(self): + def __init__(self, *, raw_status="Succeeded", raw_error=None): self.calls: list[tuple[str, int]] = [] self.url_calls: list[tuple[str, str]] = [] + self.raw_status = raw_status + self.raw_error = raw_error + + def _raw(self, result, **metadata): + payload = {"status": self.raw_status, "result": result} + payload.update(metadata) + if self.raw_error is not None: + payload["error"] = self.raw_error + return payload def begin_analyze_binary(self, *, analyzer_id, binary_input, cls=None): self.calls.append((analyzer_id, len(binary_input))) deserialized = {"analyzer_id": analyzer_id, "size": len(binary_input)} - raw = { - "id": "operation-id", - "status": "Succeeded", - "result": { + raw = self._raw( + { "analyzerId": analyzer_id, "size": len(binary_input), "serviceOnly": True, }, - "usage": {"documentPagesStandard": 1}, - } + id="operation-id", + usage={"documentPagesStandard": 1}, + ) result = ( cls(_FakePipelineResponse(raw), deserialized, {}) if cls is not None @@ -100,14 +109,11 @@ def analyze_binary_inline(self, *, analyzer_id, binary_input, cls=None): "result": {"analyzer_id": analyzer_id, "size": len(binary_input)}, "usage": {"documentPagesMinimalInline": 1}, })() - raw = { - "status": "Succeeded", - "result": { - "analyzerId": analyzer_id, - "size": len(binary_input), - "serviceOnly": True, - }, - } + raw = self._raw({ + "analyzerId": analyzer_id, + "size": len(binary_input), + "serviceOnly": True, + }) return ( cls(_FakePipelineResponse(raw), response, {}) if cls is not None @@ -118,11 +124,10 @@ def begin_analyze(self, *, analyzer_id, inputs, cls=None): url = inputs[0].url self.url_calls.append((analyzer_id, url)) deserialized = {"analyzer_id": analyzer_id, "url": url} - raw = { - "id": "operation-id", - "status": "Succeeded", - "result": {"analyzerId": analyzer_id, "url": url, "serviceOnly": True}, - } + raw = self._raw( + {"analyzerId": analyzer_id, "url": url, "serviceOnly": True}, + id="operation-id", + ) result = ( cls(_FakePipelineResponse(raw), deserialized, {}) if cls is not None @@ -137,10 +142,7 @@ def analyze_inline(self, *, analyzer_id, inputs, cls=None): "result": {"analyzer_id": analyzer_id, "url": url}, "usage": {"documentPagesMinimalInline": 1}, })() - raw = { - "status": "Succeeded", - "result": {"analyzerId": analyzer_id, "url": url, "serviceOnly": True}, - } + raw = self._raw({"analyzerId": analyzer_id, "url": url, "serviceOnly": True}) return ( cls(_FakePipelineResponse(raw), response, {}) if cls is not None @@ -274,6 +276,51 @@ def test_usage_aware_helpers_retain_lro_and_inline_usage(): assert inline.usage == {"documentPagesMinimalInline": 1} +@pytest.mark.parametrize( + "invoke", + [ + lambda client: analyze_bytes_with_usage( + client, "prebuilt-layout", b"invalid", raw_json=True + ), + lambda client: analyze_url_with_usage( + client, "prebuilt-layout", "https://example.test/invalid", raw_json=True + ), + lambda client: analyze_bytes_inline_with_usage( + client, "prebuilt-layout", b"invalid", raw_json=True + ), + lambda client: analyze_url_inline_with_usage( + client, "prebuilt-layout", "https://example.test/invalid", raw_json=True + ), + ], + ids=["lro-binary", "lro-url", "inline-binary", "inline-url"], +) +def test_raw_json_helpers_raise_for_failed_service_envelope(invoke): + client = _FakeClient( + raw_status="Failed", + raw_error={ + "code": "InvalidRequest", + "message": "The request is invalid.", + "innererror": { + "code": "InvalidContent", + "details": [{"code": "InvalidFormat", "message": "Unsupported content."}], + }, + }, + ) + + with pytest.raises(ServiceError) as exc_info: + invoke(client) + + error = exc_info.value + assert [detail.code for detail in error.details] == [ + "InvalidRequest", + "InvalidContent", + "InvalidFormat", + ] + assert "InvalidRequest" in str(error) + assert "InvalidContent" in str(error) + assert "InvalidFormat" in str(error) + + def test_analyze_many_collects_all_successes(): jobs = [AnalyzeJob(input_ref=f"f{i}", analyzer_id="a") for i in range(10)] result = analyze_many( diff --git a/cu-cli/packages/standalone/tests/unit/test_cli.py b/cu-cli/packages/standalone/tests/unit/test_cli.py index 060b170..73293a0 100644 --- a/cu-cli/packages/standalone/tests/unit/test_cli.py +++ b/cu-cli/packages/standalone/tests/unit/test_cli.py @@ -20,6 +20,7 @@ from cu_cli.cli import main from cu_cli.core.analyze import AnalyzeResponse from cu_cli.errors import CuCliError +from cu_cli_core.errors import ServiceError, ServiceErrorDetail import pytest @@ -3001,6 +3002,39 @@ def _fake_run_one(_client, _job): assert "boom" in entry["error"] +def test_analyze_service_failed_envelope_is_reported_as_failure(monkeypatch): + Path("only.pdf").write_bytes(b"not a supported document") + + def _fake_run_one(_client, _job): + raise ServiceError( + "Analysis failed according to the service response. InvalidRequest: invalid input; " + "InvalidContent: unsupported content; InvalidFormat: unsupported format", + details=( + ServiceErrorDetail("InvalidRequest", "invalid input"), + ServiceErrorDetail("InvalidContent", "unsupported content"), + ServiceErrorDetail("InvalidFormat", "unsupported format"), + ), + ) + + monkeypatch.setattr("cu_cli.commands.analyze.build_client", lambda *_a, **_k: object()) + monkeypatch.setattr("cu_cli.commands.analyze._run_one", _fake_run_one) + + res = _run( + "analyze", "only.pdf", "--analyzer", "prebuilt-layout", + "--json", "--output-dir", "out", "--report-file", "report.json", + ) + + assert res.exit_code == 1, res.output + assert not (Path("out") / "only.pdf.result.json").exists() + report = json.loads(Path("report.json").read_text(encoding="utf-8")) + assert report["counts"]["failed"] == 1 + entry = report["results"][0] + assert entry["status"] == "failed" + assert "InvalidRequest" in entry["error"] + assert "InvalidContent" in entry["error"] + assert "InvalidFormat" in entry["error"] + + def test_analyze_empty_markdown_error_is_user_facing_in_output_and_report(monkeypatch): # Regression: identify an empty Markdown view without guessing that the # successfully analyzed input was corrupt or unsupported.