Skip to content

Commit 6dabf8c

Browse files
committed
feat(ledger): add download_report_bundle method
Mirror of the TypeScript SDK's getReportBundleDownloadUrl + getReportBundleXbrlZip — one method, two flavors. Wraps the backend's GET /reports/{id}/download endpoint and handles both response shapes transparently: * format='jsonld' (default): backend returns a JSON envelope with a short-lived presigned URL; SDK follows the URL and pulls the bytes. Two HTTP calls. * format='xbrl-2.1': backend streams the zip body directly; SDK pulls one response. No presigned URL hop. Signature: download_report_bundle( graph_id: str, report_id: str, *, format: str = 'jsonld', to: str | Path | None = None, expires_in: int = 300, ) -> ReportBundleDownload Returns a ReportBundleDownload dataclass with: - content: bytes — raw artifact - filename: str — server-suggested filename (parsed from Content-Disposition; falls back to {report_id}.{ext}) - format, content_type, generation_count - path: Path | None — set when `to` was provided (the SDK writes the bytes and creates parent dirs) End-to-end verified against the live API: ~313KB JSON-LD bundle (251 @graph nodes) + ~10KB XBRL zip (5 files: instance.xml, report.xsd, report-pre/cal/def.xml). Tests cover JSON-LD two-hop path, XBRL one-hop path, on-disk write with parent dir creation, non-2xx error path, missing-token gate, and the Content-Disposition filename parser (quoted / unquoted / missing). 88 ledger-client tests pass. Unlocks bundle download in demo scripts (next commit) and any external Python consumer of the SDK.
1 parent 9028e43 commit 6dabf8c

2 files changed

Lines changed: 297 additions & 0 deletions

File tree

robosystems_client/clients/ledger_client.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,14 @@
2323
from __future__ import annotations
2424

2525
import datetime
26+
import re
27+
from dataclasses import dataclass
2628
from http import HTTPStatus
29+
from pathlib import Path
2730
from typing import Any
2831

32+
import httpx
33+
2934
from ..api.extensions_robo_ledger.op_auto_map_elements import (
3035
sync_detailed as op_auto_map_elements,
3136
)
@@ -308,6 +313,43 @@
308313
from ..types import UNSET
309314

310315

316+
# Captures the ``filename`` value from a Content-Disposition header, with
317+
# or without quotes. Used by ``download_report_bundle`` to recover the
318+
# server-suggested filename when writing the bundle to disk.
319+
_FILENAME_PATTERN = re.compile(r'filename="?([^";]+)"?', re.IGNORECASE)
320+
321+
322+
def _parse_filename(content_disposition: str) -> str | None:
323+
"""Extract the ``filename`` value from a Content-Disposition header.
324+
325+
Returns ``None`` when the header is empty or doesn't carry a
326+
filename — the caller falls back to a synthesized name in that case.
327+
"""
328+
if not content_disposition:
329+
return None
330+
match = _FILENAME_PATTERN.search(content_disposition)
331+
return match.group(1) if match else None
332+
333+
334+
@dataclass
335+
class ReportBundleDownload:
336+
"""Result of downloading a Report's serialization bundle.
337+
338+
``content`` is the raw artifact bytes. ``filename`` is the
339+
server-suggested name (``{report_id}-g{generation}.{ext}``).
340+
``path`` is populated when the caller passed a ``to=`` argument to
341+
:meth:`LedgerClient.download_report_bundle` — points at the file
342+
the SDK wrote to disk.
343+
"""
344+
345+
content: bytes
346+
filename: str
347+
format: str
348+
content_type: str
349+
generation_count: int | None
350+
path: Path | None = None
351+
352+
311353
class LedgerClient:
312354
"""High-level facade for the RoboLedger domain.
313355
@@ -1717,6 +1759,99 @@ def share_report(
17171759
envelope = self._call_op("Share report", response)
17181760
return self._typed_result("Share report", envelope, ShareReportResponse)
17191761

1762+
def download_report_bundle(
1763+
self,
1764+
graph_id: str,
1765+
report_id: str,
1766+
*,
1767+
format: str = "jsonld",
1768+
to: str | Path | None = None,
1769+
expires_in: int = 300,
1770+
) -> ReportBundleDownload:
1771+
"""Download a Report's serialization bundle (JSON-LD or XBRL 2.1).
1772+
1773+
JSON-LD path: backend returns a JSON envelope with a short-lived
1774+
presigned URL to the S3-stamped bundle; client follows the URL
1775+
and pulls the bytes. XBRL path: backend streams the zip directly
1776+
(per spec — XBRL is on-demand emit, not stored).
1777+
1778+
Args:
1779+
graph_id: Graph identifier owning the Report.
1780+
report_id: Report identifier (``rpt_``-prefixed ULID).
1781+
format: Serialization flavor — ``"jsonld"`` for the JSON-LD
1782+
bundle, ``"xbrl-2.1"`` for the XBRL 2.1 zip. Other RDF /
1783+
XBRL flavors slot in as their producers ship.
1784+
to: Optional file path to write the bytes to. When set, the
1785+
returned ``ReportBundleDownload.path`` points at the
1786+
written file.
1787+
expires_in: Presigned URL lifetime in seconds (JSON-LD only;
1788+
ignored for XBRL flavors).
1789+
1790+
Returns:
1791+
:class:`ReportBundleDownload` with the artifact bytes,
1792+
server-suggested filename, content type, generation count,
1793+
and (when ``to`` is set) the path written.
1794+
1795+
Raises:
1796+
RuntimeError: backend returned non-2xx or the presigned URL
1797+
could not be followed.
1798+
"""
1799+
if not self.token:
1800+
raise RuntimeError("No API key provided. Set X-API-Key in headers.")
1801+
base = self.base_url.rstrip("/")
1802+
url = (
1803+
f"{base}/extensions/roboledger/{graph_id}/reports/{report_id}"
1804+
f"/download?format={format}&expires_in={expires_in}"
1805+
)
1806+
headers = {**self.headers, "X-API-Key": self.token}
1807+
with httpx.Client(timeout=self.timeout) as client:
1808+
response = client.get(url, headers=headers)
1809+
if response.status_code != 200:
1810+
raise RuntimeError(
1811+
f"Download bundle failed ({response.status_code}): {response.text}"
1812+
)
1813+
content_type = response.headers.get("content-type", "")
1814+
if "application/json" in content_type:
1815+
# JSON-LD path — envelope contains a presigned URL to follow
1816+
envelope = response.json()
1817+
download_url = envelope["download_url"]
1818+
artifact = client.get(download_url)
1819+
if artifact.status_code != 200:
1820+
raise RuntimeError(
1821+
f"Failed to follow presigned URL ({artifact.status_code}): {artifact.text}"
1822+
)
1823+
filename = (
1824+
_parse_filename(artifact.headers.get("content-disposition", ""))
1825+
or f"{report_id}-g{envelope.get('generation_count', 1)}.jsonld"
1826+
)
1827+
result = ReportBundleDownload(
1828+
content=artifact.content,
1829+
filename=filename,
1830+
format=str(envelope.get("format", format)),
1831+
content_type=str(envelope.get("content_type", "application/ld+json")),
1832+
generation_count=envelope.get("generation_count"),
1833+
)
1834+
else:
1835+
# XBRL path — body IS the zip
1836+
filename = (
1837+
_parse_filename(response.headers.get("content-disposition", ""))
1838+
or f"{report_id}.zip"
1839+
)
1840+
generation_header = response.headers.get("x-bundle-generation")
1841+
result = ReportBundleDownload(
1842+
content=response.content,
1843+
filename=filename,
1844+
format=response.headers.get("x-bundle-format", format),
1845+
content_type=content_type or "application/zip",
1846+
generation_count=int(generation_header) if generation_header else None,
1847+
)
1848+
if to is not None:
1849+
path = Path(to)
1850+
path.parent.mkdir(parents=True, exist_ok=True)
1851+
path.write_bytes(result.content)
1852+
result.path = path
1853+
return result
1854+
17201855
def file_report(self, graph_id: str, report_id: str) -> ReportResponse:
17211856
"""Transition a Report's filing_status to 'filed' — locks the package.
17221857

tests/test_ledger_client.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1821,3 +1821,165 @@ def test_financial_statement_analysis_with_ticker(
18211821
body = mock_op.call_args.kwargs["body"]
18221822
assert body.ticker == "NVDA"
18231823
assert body.fiscal_year == 2025
1824+
1825+
1826+
# ── Bundle download (REST GET to /reports/{id}/download) ──────────────
1827+
1828+
1829+
@pytest.mark.unit
1830+
class TestDownloadReportBundle:
1831+
"""``download_report_bundle`` bypasses the typed op_* generators and
1832+
hits the endpoint via httpx directly — these tests stub the httpx
1833+
Client at the call site."""
1834+
1835+
def _mock_json_envelope(self, gen: int = 1) -> Mock:
1836+
"""Mock the initial JSON envelope response for the JSON-LD path."""
1837+
resp = Mock()
1838+
resp.status_code = 200
1839+
resp.headers = {"content-type": "application/json"}
1840+
resp.json.return_value = {
1841+
"download_url": "https://s3.example.com/bundles/rpt_01/g1.jsonld",
1842+
"expires_at": "2026-05-28T12:30:00Z",
1843+
"content_type": "application/ld+json",
1844+
"format": "jsonld",
1845+
"generation_count": gen,
1846+
}
1847+
return resp
1848+
1849+
def _mock_artifact(self, content: bytes, filename: str = "rpt_01-g1.jsonld") -> Mock:
1850+
resp = Mock()
1851+
resp.status_code = 200
1852+
resp.headers = {"content-disposition": f'attachment; filename="{filename}"'}
1853+
resp.content = content
1854+
return resp
1855+
1856+
def _mock_xbrl_response(self, content: bytes, gen: int = 1) -> Mock:
1857+
"""Mock the direct binary-stream response for the XBRL path."""
1858+
resp = Mock()
1859+
resp.status_code = 200
1860+
resp.headers = {
1861+
"content-type": "application/zip",
1862+
"content-disposition": f'attachment; filename="rpt_01-g{gen}.zip"',
1863+
"x-bundle-format": "xbrl-2.1",
1864+
"x-bundle-generation": str(gen),
1865+
}
1866+
resp.content = content
1867+
resp.text = ""
1868+
return resp
1869+
1870+
@patch("robosystems_client.clients.ledger_client.httpx.Client")
1871+
def test_jsonld_download_follows_presigned_url(
1872+
self, mock_client_cls, mock_config, graph_id
1873+
):
1874+
"""JSON-LD path: envelope GET → presigned URL GET → bytes."""
1875+
mock_client = Mock()
1876+
mock_client.__enter__ = Mock(return_value=mock_client)
1877+
mock_client.__exit__ = Mock(return_value=None)
1878+
mock_client.get.side_effect = [
1879+
self._mock_json_envelope(gen=2),
1880+
self._mock_artifact(b'{"@graph": []}', "rpt_01-g2.jsonld"),
1881+
]
1882+
mock_client_cls.return_value = mock_client
1883+
1884+
result = LedgerClient(mock_config).download_report_bundle(
1885+
graph_id, "rpt_01", format="jsonld"
1886+
)
1887+
1888+
assert result.content == b'{"@graph": []}'
1889+
assert result.filename == "rpt_01-g2.jsonld"
1890+
assert result.format == "jsonld"
1891+
assert result.content_type == "application/ld+json"
1892+
assert result.generation_count == 2
1893+
# First request hits our endpoint with X-API-Key auth.
1894+
first_call = mock_client.get.call_args_list[0]
1895+
assert "/reports/rpt_01/download" in first_call.args[0]
1896+
assert "format=jsonld" in first_call.args[0]
1897+
headers = first_call.kwargs["headers"]
1898+
assert headers["X-API-Key"] == "test-api-key"
1899+
1900+
@patch("robosystems_client.clients.ledger_client.httpx.Client")
1901+
def test_xbrl_download_streams_zip_directly(
1902+
self, mock_client_cls, mock_config, graph_id
1903+
):
1904+
"""XBRL path: single GET, binary response, no presigned URL hop."""
1905+
zip_bytes = b"PK\x03\x04dummy-zip-payload"
1906+
mock_client = Mock()
1907+
mock_client.__enter__ = Mock(return_value=mock_client)
1908+
mock_client.__exit__ = Mock(return_value=None)
1909+
mock_client.get.return_value = self._mock_xbrl_response(zip_bytes, gen=3)
1910+
mock_client_cls.return_value = mock_client
1911+
1912+
result = LedgerClient(mock_config).download_report_bundle(
1913+
graph_id, "rpt_01", format="xbrl-2.1"
1914+
)
1915+
1916+
assert result.content == zip_bytes
1917+
assert result.filename == "rpt_01-g3.zip"
1918+
assert result.format == "xbrl-2.1"
1919+
assert result.content_type == "application/zip"
1920+
assert result.generation_count == 3
1921+
# XBRL path only makes one HTTP call.
1922+
assert mock_client.get.call_count == 1
1923+
1924+
@patch("robosystems_client.clients.ledger_client.httpx.Client")
1925+
def test_to_arg_writes_bytes_to_disk(
1926+
self, mock_client_cls, mock_config, graph_id, tmp_path
1927+
):
1928+
"""``to=path`` writes the artifact and exposes ``result.path``."""
1929+
zip_bytes = b"PK\x03\x04zip"
1930+
mock_client = Mock()
1931+
mock_client.__enter__ = Mock(return_value=mock_client)
1932+
mock_client.__exit__ = Mock(return_value=None)
1933+
mock_client.get.return_value = self._mock_xbrl_response(zip_bytes)
1934+
mock_client_cls.return_value = mock_client
1935+
1936+
target = tmp_path / "subdir" / "out.zip"
1937+
result = LedgerClient(mock_config).download_report_bundle(
1938+
graph_id, "rpt_01", format="xbrl-2.1", to=str(target)
1939+
)
1940+
1941+
assert result.path == target
1942+
assert target.read_bytes() == zip_bytes
1943+
# Parent directories get created automatically.
1944+
assert target.parent.is_dir()
1945+
1946+
@patch("robosystems_client.clients.ledger_client.httpx.Client")
1947+
def test_non_200_endpoint_response_raises(
1948+
self, mock_client_cls, mock_config, graph_id
1949+
):
1950+
err_resp = Mock(status_code=404, text="not found")
1951+
err_resp.headers = {}
1952+
mock_client = Mock()
1953+
mock_client.__enter__ = Mock(return_value=mock_client)
1954+
mock_client.__exit__ = Mock(return_value=None)
1955+
mock_client.get.return_value = err_resp
1956+
mock_client_cls.return_value = mock_client
1957+
1958+
with pytest.raises(RuntimeError, match="Download bundle failed"):
1959+
LedgerClient(mock_config).download_report_bundle(graph_id, "rpt_missing")
1960+
1961+
def test_no_token_raises(self, mock_config, graph_id):
1962+
mock_config["token"] = None
1963+
with pytest.raises(RuntimeError, match="No API key"):
1964+
LedgerClient(mock_config).download_report_bundle(graph_id, "rpt_01")
1965+
1966+
1967+
def test_parse_filename_quoted():
1968+
from robosystems_client.clients.ledger_client import _parse_filename
1969+
1970+
assert _parse_filename('attachment; filename="rpt_01-g1.jsonld"') == (
1971+
"rpt_01-g1.jsonld"
1972+
)
1973+
1974+
1975+
def test_parse_filename_unquoted():
1976+
from robosystems_client.clients.ledger_client import _parse_filename
1977+
1978+
assert _parse_filename("attachment; filename=rpt_01-g1.zip") == "rpt_01-g1.zip"
1979+
1980+
1981+
def test_parse_filename_missing_returns_none():
1982+
from robosystems_client.clients.ledger_client import _parse_filename
1983+
1984+
assert _parse_filename("") is None
1985+
assert _parse_filename("attachment") is None

0 commit comments

Comments
 (0)