Skip to content
Open
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
52 changes: 48 additions & 4 deletions cu-cli/packages/core/src/cu_cli_core/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
95 changes: 71 additions & 24 deletions cu-cli/packages/core/tests/test_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
34 changes: 34 additions & 0 deletions cu-cli/packages/standalone/tests/unit/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading