diff --git a/robosystems_client/api/extensions_robo_ledger/get_report_bundle_download_url.py b/robosystems_client/api/extensions_robo_ledger/get_report_bundle_download_url.py deleted file mode 100644 index e5c4061..0000000 --- a/robosystems_client/api/extensions_robo_ledger/get_report_bundle_download_url.py +++ /dev/null @@ -1,259 +0,0 @@ -from http import HTTPStatus -from typing import Any -from urllib.parse import quote - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.get_report_bundle_download_url_report_bundle_download_response import ( - GetReportBundleDownloadUrlReportBundleDownloadResponse, -) -from ...models.http_validation_error import HTTPValidationError -from ...types import UNSET, Response, Unset - - -def _get_kwargs( - graph_id: str, - report_id: str, - *, - format_: str | Unset = "jsonld", - expires_in: int | Unset = 300, -) -> dict[str, Any]: - - params: dict[str, Any] = {} - - params["format"] = format_ - - params["expires_in"] = expires_in - - params = {k: v for k, v in params.items() if v is not UNSET and v is not None} - - _kwargs: dict[str, Any] = { - "method": "get", - "url": "/extensions/roboledger/{graph_id}/reports/{report_id}/download".format( - graph_id=quote(str(graph_id), safe=""), - report_id=quote(str(report_id), safe=""), - ), - "params": params, - } - - return _kwargs - - -def _parse_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> ( - GetReportBundleDownloadUrlReportBundleDownloadResponse | HTTPValidationError | None -): - if response.status_code == 200: - response_200 = GetReportBundleDownloadUrlReportBundleDownloadResponse.from_dict( - response.json() - ) - - return response_200 - - if response.status_code == 422: - response_422 = HTTPValidationError.from_dict(response.json()) - - return response_422 - - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[ - GetReportBundleDownloadUrlReportBundleDownloadResponse | HTTPValidationError -]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - graph_id: str, - report_id: str, - *, - client: AuthenticatedClient, - format_: str | Unset = "jsonld", - expires_in: int | Unset = 300, -) -> Response[ - GetReportBundleDownloadUrlReportBundleDownloadResponse | HTTPValidationError -]: - """Download Report bundle - - Return the published Report's serialization bundle. ``format=jsonld`` (default) returns a JSON - envelope containing a short-lived presigned URL to the stamped JSON-LD bundle in S3. - ``format=xbrl-2.1`` rebuilds the bundle on-demand and streams an XBRL 2.1 zip directly. 404 when the - Report has no stamped bundle (published before the serialization feature shipped — JSON-LD only). - - Args: - graph_id (str): - report_id (str): Report identifier (rpt_-prefixed ULID). - format_ (str | Unset): Serialization flavor. ``jsonld`` returns a presigned URL to the - stored JSON-LD bundle; ``xbrl-2.1`` streams a freshly-emitted XBRL zip directly. Other RDF - / XBRL flavors slot in as their producers ship. Default: 'jsonld'. - expires_in (int | Unset): Presigned URL lifetime in seconds (min 60, max 3600). Ignored - for XBRL flavors (streamed directly, no URL). Default: 300. - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[GetReportBundleDownloadUrlReportBundleDownloadResponse | HTTPValidationError] - """ - - kwargs = _get_kwargs( - graph_id=graph_id, - report_id=report_id, - format_=format_, - expires_in=expires_in, - ) - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - graph_id: str, - report_id: str, - *, - client: AuthenticatedClient, - format_: str | Unset = "jsonld", - expires_in: int | Unset = 300, -) -> ( - GetReportBundleDownloadUrlReportBundleDownloadResponse | HTTPValidationError | None -): - """Download Report bundle - - Return the published Report's serialization bundle. ``format=jsonld`` (default) returns a JSON - envelope containing a short-lived presigned URL to the stamped JSON-LD bundle in S3. - ``format=xbrl-2.1`` rebuilds the bundle on-demand and streams an XBRL 2.1 zip directly. 404 when the - Report has no stamped bundle (published before the serialization feature shipped — JSON-LD only). - - Args: - graph_id (str): - report_id (str): Report identifier (rpt_-prefixed ULID). - format_ (str | Unset): Serialization flavor. ``jsonld`` returns a presigned URL to the - stored JSON-LD bundle; ``xbrl-2.1`` streams a freshly-emitted XBRL zip directly. Other RDF - / XBRL flavors slot in as their producers ship. Default: 'jsonld'. - expires_in (int | Unset): Presigned URL lifetime in seconds (min 60, max 3600). Ignored - for XBRL flavors (streamed directly, no URL). Default: 300. - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - GetReportBundleDownloadUrlReportBundleDownloadResponse | HTTPValidationError - """ - - return sync_detailed( - graph_id=graph_id, - report_id=report_id, - client=client, - format_=format_, - expires_in=expires_in, - ).parsed - - -async def asyncio_detailed( - graph_id: str, - report_id: str, - *, - client: AuthenticatedClient, - format_: str | Unset = "jsonld", - expires_in: int | Unset = 300, -) -> Response[ - GetReportBundleDownloadUrlReportBundleDownloadResponse | HTTPValidationError -]: - """Download Report bundle - - Return the published Report's serialization bundle. ``format=jsonld`` (default) returns a JSON - envelope containing a short-lived presigned URL to the stamped JSON-LD bundle in S3. - ``format=xbrl-2.1`` rebuilds the bundle on-demand and streams an XBRL 2.1 zip directly. 404 when the - Report has no stamped bundle (published before the serialization feature shipped — JSON-LD only). - - Args: - graph_id (str): - report_id (str): Report identifier (rpt_-prefixed ULID). - format_ (str | Unset): Serialization flavor. ``jsonld`` returns a presigned URL to the - stored JSON-LD bundle; ``xbrl-2.1`` streams a freshly-emitted XBRL zip directly. Other RDF - / XBRL flavors slot in as their producers ship. Default: 'jsonld'. - expires_in (int | Unset): Presigned URL lifetime in seconds (min 60, max 3600). Ignored - for XBRL flavors (streamed directly, no URL). Default: 300. - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[GetReportBundleDownloadUrlReportBundleDownloadResponse | HTTPValidationError] - """ - - kwargs = _get_kwargs( - graph_id=graph_id, - report_id=report_id, - format_=format_, - expires_in=expires_in, - ) - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - graph_id: str, - report_id: str, - *, - client: AuthenticatedClient, - format_: str | Unset = "jsonld", - expires_in: int | Unset = 300, -) -> ( - GetReportBundleDownloadUrlReportBundleDownloadResponse | HTTPValidationError | None -): - """Download Report bundle - - Return the published Report's serialization bundle. ``format=jsonld`` (default) returns a JSON - envelope containing a short-lived presigned URL to the stamped JSON-LD bundle in S3. - ``format=xbrl-2.1`` rebuilds the bundle on-demand and streams an XBRL 2.1 zip directly. 404 when the - Report has no stamped bundle (published before the serialization feature shipped — JSON-LD only). - - Args: - graph_id (str): - report_id (str): Report identifier (rpt_-prefixed ULID). - format_ (str | Unset): Serialization flavor. ``jsonld`` returns a presigned URL to the - stored JSON-LD bundle; ``xbrl-2.1`` streams a freshly-emitted XBRL zip directly. Other RDF - / XBRL flavors slot in as their producers ship. Default: 'jsonld'. - expires_in (int | Unset): Presigned URL lifetime in seconds (min 60, max 3600). Ignored - for XBRL flavors (streamed directly, no URL). Default: 300. - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - GetReportBundleDownloadUrlReportBundleDownloadResponse | HTTPValidationError - """ - - return ( - await asyncio_detailed( - graph_id=graph_id, - report_id=report_id, - client=client, - format_=format_, - expires_in=expires_in, - ) - ).parsed diff --git a/robosystems_client/api/extensions_robo_ledger/op_create_information_block.py b/robosystems_client/api/extensions_robo_ledger/op_create_information_block.py index 1bbe18e..af8b438 100644 --- a/robosystems_client/api/extensions_robo_ledger/op_create_information_block.py +++ b/robosystems_client/api/extensions_robo_ledger/op_create_information_block.py @@ -122,7 +122,10 @@ def sync_detailed( Generic Information Block construction entry. `block_type` selects the registered block type; `payload` is validated against that type's creation schema at dispatch. Schedule dispatches to the - existing Schedule machinery; statement block types raise 501 (use create-report instead). + existing Schedule machinery; statement block types raise 501 (use create-report instead). Authoring + schedules for a close? Call `get-close-playbook` (mode='initiate') first — one schedule is a single + debit/credit element pair, so multi-line entries become multiple schedules, and element ids must be + real (discover via get-graph-schema / get-unmapped-elements / suggest-mapping). **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -170,7 +173,10 @@ def sync( Generic Information Block construction entry. `block_type` selects the registered block type; `payload` is validated against that type's creation schema at dispatch. Schedule dispatches to the - existing Schedule machinery; statement block types raise 501 (use create-report instead). + existing Schedule machinery; statement block types raise 501 (use create-report instead). Authoring + schedules for a close? Call `get-close-playbook` (mode='initiate') first — one schedule is a single + debit/credit element pair, so multi-line entries become multiple schedules, and element ids must be + real (discover via get-graph-schema / get-unmapped-elements / suggest-mapping). **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -213,7 +219,10 @@ async def asyncio_detailed( Generic Information Block construction entry. `block_type` selects the registered block type; `payload` is validated against that type's creation schema at dispatch. Schedule dispatches to the - existing Schedule machinery; statement block types raise 501 (use create-report instead). + existing Schedule machinery; statement block types raise 501 (use create-report instead). Authoring + schedules for a close? Call `get-close-playbook` (mode='initiate') first — one schedule is a single + debit/credit element pair, so multi-line entries become multiple schedules, and element ids must be + real (discover via get-graph-schema / get-unmapped-elements / suggest-mapping). **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -259,7 +268,10 @@ async def asyncio( Generic Information Block construction entry. `block_type` selects the registered block type; `payload` is validated against that type's creation schema at dispatch. Schedule dispatches to the - existing Schedule machinery; statement block types raise 501 (use create-report instead). + existing Schedule machinery; statement block types raise 501 (use create-report instead). Authoring + schedules for a close? Call `get-close-playbook` (mode='initiate') first — one schedule is a single + debit/credit element pair, so multi-line entries become multiple schedules, and element ids must be + real (discover via get-graph-schema / get-unmapped-elements / suggest-mapping). **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. diff --git a/robosystems_client/api/extensions_robo_ledger/op_create_taxonomy_block.py b/robosystems_client/api/extensions_robo_ledger/op_create_taxonomy_block.py index 53495f2..298aa0d 100644 --- a/robosystems_client/api/extensions_robo_ledger/op_create_taxonomy_block.py +++ b/robosystems_client/api/extensions_robo_ledger/op_create_taxonomy_block.py @@ -116,7 +116,10 @@ def sync_detailed( Create a taxonomy block atomically: one envelope carrying the taxonomy row plus its structures, elements, associations, and rules. Dispatches by `taxonomy_type` — `chart_of_accounts` (declarative tenant CoA) is supported; `reporting_extension` / `custom_ontology` / `reporting_standard` are not - yet implemented. + yet implemented. NOT the path for a functional close schedule: a structure with + block_type='schedule' here is a bare ontology row with none of the schedule machinery (per-period + facts, schedule_entry_due obligations, closing-entry generator). To create a working schedule use + create-information-block(block_type='schedule'). **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -169,7 +172,10 @@ def sync( Create a taxonomy block atomically: one envelope carrying the taxonomy row plus its structures, elements, associations, and rules. Dispatches by `taxonomy_type` — `chart_of_accounts` (declarative tenant CoA) is supported; `reporting_extension` / `custom_ontology` / `reporting_standard` are not - yet implemented. + yet implemented. NOT the path for a functional close schedule: a structure with + block_type='schedule' here is a bare ontology row with none of the schedule machinery (per-period + facts, schedule_entry_due obligations, closing-entry generator). To create a working schedule use + create-information-block(block_type='schedule'). **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -217,7 +223,10 @@ async def asyncio_detailed( Create a taxonomy block atomically: one envelope carrying the taxonomy row plus its structures, elements, associations, and rules. Dispatches by `taxonomy_type` — `chart_of_accounts` (declarative tenant CoA) is supported; `reporting_extension` / `custom_ontology` / `reporting_standard` are not - yet implemented. + yet implemented. NOT the path for a functional close schedule: a structure with + block_type='schedule' here is a bare ontology row with none of the schedule machinery (per-period + facts, schedule_entry_due obligations, closing-entry generator). To create a working schedule use + create-information-block(block_type='schedule'). **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -268,7 +277,10 @@ async def asyncio( Create a taxonomy block atomically: one envelope carrying the taxonomy row plus its structures, elements, associations, and rules. Dispatches by `taxonomy_type` — `chart_of_accounts` (declarative tenant CoA) is supported; `reporting_extension` / `custom_ontology` / `reporting_standard` are not - yet implemented. + yet implemented. NOT the path for a functional close schedule: a structure with + block_type='schedule' here is a bare ontology row with none of the schedule machinery (per-period + facts, schedule_entry_due obligations, closing-entry generator). To create a working schedule use + create-information-block(block_type='schedule'). **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. diff --git a/robosystems_client/clients/ledger_client.py b/robosystems_client/clients/ledger_client.py index 46961d6..99bd31c 100644 --- a/robosystems_client/clients/ledger_client.py +++ b/robosystems_client/clients/ledger_client.py @@ -210,6 +210,7 @@ ) from ..graphql.queries.ledger import ( GET_PUBLISH_LIST_QUERY, + GET_REPORT_DOWNLOAD_URL_QUERY, GET_REPORT_PACKAGE_QUERY, GET_REPORT_QUERY, GET_STATEMENT_QUERY, @@ -218,6 +219,7 @@ parse_publish_list, parse_publish_lists, parse_report, + parse_report_download_url, parse_report_package, parse_reports, parse_statement, @@ -318,6 +320,10 @@ # server-suggested filename when writing the bundle to disk. _FILENAME_PATTERN = re.compile(r'filename="?([^";]+)"?', re.IGNORECASE) +# Map the wire flavor strings the facade accepts to the GraphQL +# ``ReportDownloadFormat`` enum names used as query variables. +_DOWNLOAD_FORMAT_ALIASES = {"jsonld": "JSONLD", "xbrl-2.1": "XBRL_2_1"} + def _parse_filename(content_disposition: str) -> str | None: """Extract the ``filename`` value from a Content-Disposition header. @@ -1768,24 +1774,24 @@ def download_report_bundle( to: str | Path | None = None, expires_in: int = 300, ) -> ReportBundleDownload: - """Download a Report's serialization bundle (JSON-LD or XBRL 2.1). + """Download a published Report's serialization bundle (JSON-LD or XBRL 2.1). - JSON-LD path: backend returns a JSON envelope with a short-lived - presigned URL to the S3-stamped bundle; client follows the URL - and pulls the bytes. XBRL path: backend streams the zip directly - (per spec — XBRL is on-demand emit, not stored). + A download is a read, so the presigned URL is resolved through the + GraphQL ``reportDownloadUrl`` field (the REST download route was + retired). Every flavor resolves to a short-lived presigned S3 URL — + JSON-LD is stamped at publish time; XBRL is materialized + cached on + first request. The client follows the URL and pulls the bytes. Args: graph_id: Graph identifier owning the Report. report_id: Report identifier (``rpt_``-prefixed ULID). - format: Serialization flavor — ``"jsonld"`` for the JSON-LD - bundle, ``"xbrl-2.1"`` for the XBRL 2.1 zip. Other RDF / - XBRL flavors slot in as their producers ship. + format: Serialization flavor — ``"jsonld"`` (default) or + ``"xbrl-2.1"``. The enum names ``"JSONLD"`` / ``"XBRL_2_1"`` + are also accepted. to: Optional file path to write the bytes to. When set, the returned ``ReportBundleDownload.path`` points at the written file. - expires_in: Presigned URL lifetime in seconds (JSON-LD only; - ignored for XBRL flavors). + expires_in: Presigned URL lifetime in seconds (60–3600). Returns: :class:`ReportBundleDownload` with the artifact bytes, @@ -1793,66 +1799,50 @@ def download_report_bundle( and (when ``to`` is set) the path written. Raises: - RuntimeError: backend returned non-2xx, the presigned URL - could not be followed, or no API key is configured on the - client. - httpx.TimeoutException: the request exceeded ``self.timeout`` - (passed through unwrapped so callers with their own - retry / backoff strategy can distinguish a timeout from a - generic failure). - httpx.RequestError: any other transport-level failure - (DNS, connection refused, TLS); not wrapped so the original + RuntimeError: the report doesn't exist, or the presigned URL + could not be followed. + GraphQLError: the report exists but has no published bundle + (``REPORT_BUNDLE_NOT_AVAILABLE``), or another GraphQL error. + httpx.TimeoutException: following the presigned URL exceeded + ``self.timeout`` (passed through unwrapped so callers with + their own retry / backoff can distinguish it from a generic + failure). + httpx.RequestError: any other transport-level failure (DNS, + connection refused, TLS); not wrapped so the original networking context surfaces in tracebacks. """ - if not self.token: - raise RuntimeError("No API key provided. Set X-API-Key in headers.") - base = self.base_url.rstrip("/") - url = ( - f"{base}/extensions/roboledger/{graph_id}/reports/{report_id}" - f"/download?format={format}&expires_in={expires_in}" + gql_format = _DOWNLOAD_FORMAT_ALIASES.get(format.lower(), format) + data = self._query( + graph_id, + GET_REPORT_DOWNLOAD_URL_QUERY, + {"reportId": report_id, "format": gql_format, "expiresIn": expires_in}, ) - headers = {**self.headers, "X-API-Key": self.token} + info = parse_report_download_url(data) + if info is None: + raise RuntimeError(f"Report '{report_id}' not found.") + + download_url = info["download_url"] with httpx.Client(timeout=self.timeout) as client: - response = client.get(url, headers=headers) - if response.status_code != 200: - raise RuntimeError( - f"Download bundle failed ({response.status_code}): {response.text}" - ) - content_type = response.headers.get("content-type", "") - if "application/json" in content_type: - # JSON-LD path — envelope contains a presigned URL to follow - envelope = response.json() - download_url = envelope["download_url"] - artifact = client.get(download_url) - if artifact.status_code != 200: - raise RuntimeError( - f"Failed to follow presigned URL ({artifact.status_code}): {artifact.text}" - ) - filename = ( - _parse_filename(artifact.headers.get("content-disposition", "")) - or f"{report_id}-g{envelope.get('generation_count', 1)}.jsonld" - ) - result = ReportBundleDownload( - content=artifact.content, - filename=filename, - format=str(envelope.get("format", format)), - content_type=str(envelope.get("content_type", "application/ld+json")), - generation_count=envelope.get("generation_count"), - ) - else: - # XBRL path — body IS the zip - filename = ( - _parse_filename(response.headers.get("content-disposition", "")) - or f"{report_id}.zip" - ) - generation_header = response.headers.get("x-bundle-generation") - result = ReportBundleDownload( - content=response.content, - filename=filename, - format=response.headers.get("x-bundle-format", format), - content_type=content_type or "application/zip", - generation_count=int(generation_header) if generation_header else None, - ) + # Presigned URL is pre-authorized — no auth headers attached. + artifact = client.get(download_url) + if artifact.status_code != 200: + raise RuntimeError( + f"Failed to follow presigned URL ({artifact.status_code}): {artifact.text}" + ) + + generation_count = info.get("generation_count") + default_ext = "zip" if gql_format == "XBRL_2_1" else "jsonld" + filename = ( + _parse_filename(artifact.headers.get("content-disposition", "")) + or f"{report_id}-g{generation_count or 1}.{default_ext}" + ) + result = ReportBundleDownload( + content=artifact.content, + filename=filename, + format=str(info.get("format", format)), + content_type=str(info.get("content_type", "")), + generation_count=generation_count, + ) if to is not None: path = Path(to) path.parent.mkdir(parents=True, exist_ok=True) diff --git a/robosystems_client/graphql/queries/ledger/__init__.py b/robosystems_client/graphql/queries/ledger/__init__.py index 55107b6..0ee94a6 100644 --- a/robosystems_client/graphql/queries/ledger/__init__.py +++ b/robosystems_client/graphql/queries/ledger/__init__.py @@ -851,6 +851,31 @@ def parse_report(data: dict[str, Any]) -> dict[str, Any] | None: return keys_to_snake(r) if r is not None else None +# Presigned-URL download for a published Report's serialization bundle. +# Replaces the retired `GET .../reports/{id}/download` REST resource — a +# download is a read, so it lives on the read surface. Every flavor +# resolves to a short-lived presigned S3 URL the client follows directly +# (JSON-LD stamped at publish; XBRL materialized + cached on first +# request). `format` takes the `ReportDownloadFormat` enum (JSONLD, +# XBRL_2_1). +GET_REPORT_DOWNLOAD_URL_QUERY = """ +query GetLedgerReportDownloadUrl( + $reportId: String! + $format: ReportDownloadFormat = JSONLD + $expiresIn: Int = 300 +) { + reportDownloadUrl(reportId: $reportId, format: $format, expiresIn: $expiresIn) { + downloadUrl expiresAt contentType format generationCount + } +} +""".strip() + + +def parse_report_download_url(data: dict[str, Any]) -> dict[str, Any] | None: + r = data.get("reportDownloadUrl") + return keys_to_snake(r) if r is not None else None + + # Report rehydrated as a package — Report metadata + N rendered # `InformationBlock` envelopes (one per attached FactSet). Drives the # `/reports/[id]` package viewer and replaces the per-statement diff --git a/robosystems_client/models/__init__.py b/robosystems_client/models/__init__.py index 9b42177..528ea37 100644 --- a/robosystems_client/models/__init__.py +++ b/robosystems_client/models/__init__.py @@ -209,9 +209,6 @@ from .get_operation_status_response_getoperationstatus import ( GetOperationStatusResponseGetoperationstatus, ) -from .get_report_bundle_download_url_report_bundle_download_response import ( - GetReportBundleDownloadUrlReportBundleDownloadResponse, -) from .graph_capacity_response import GraphCapacityResponse from .graph_info import GraphInfo from .graph_limits_response import GraphLimitsResponse @@ -869,7 +866,6 @@ "GetCurrentAuthUserResponseGetcurrentauthuser", "GetFileInfoResponse", "GetOperationStatusResponseGetoperationstatus", - "GetReportBundleDownloadUrlReportBundleDownloadResponse", "GraphCapacityResponse", "GraphInfo", "GraphLimitsResponse", diff --git a/robosystems_client/models/create_schedule_request.py b/robosystems_client/models/create_schedule_request.py index 01891f3..d7e6e27 100644 --- a/robosystems_client/models/create_schedule_request.py +++ b/robosystems_client/models/create_schedule_request.py @@ -23,16 +23,20 @@ class CreateScheduleRequest: """ Attributes: name (str): Schedule name - element_ids (list[str]): Element IDs to include + element_ids (list[str]): CoA element ids the schedule touches (the `id` from get-unmapped-elements, not taxonomy + qnames) — typically the same debit + credit ids used in entry_template. period_start (datetime.date): First period start period_end (datetime.date): Last period end monthly_amount (int): Monthly amount in cents entry_template (EntryTemplateRequest): taxonomy_id (None | str | Unset): Taxonomy ID (auto-creates if omitted) schedule_metadata (None | ScheduleMetadataRequest | Unset): - closed_through (datetime.date | None | Unset): If provided, facts with period_end ≤ this date are flagged as - 'historical' (already reflected in opening balances, ignored by the close workflow). Used during initial ledger - setup to create schedules whose early facts have already been captured elsewhere. + closed_through (datetime.date | None | Unset): Watermark for onboarding. Facts with period_end ≤ this date are + flagged 'historical' and their schedule_entry_due obligations are emitted 'voided', so the close workflow starts + drafting at the first open period. Set this to the last day of the fiscal calendar's closed_through month + (calendar '2026-05' → '2026-05-31') whether those months were actually closed in RoboLedger or just baseline- + watermarked at initialization. Omitting it (when prior periods exist) leaves pre-watermark periods as 'pending' + obligations that block the first close. source_transaction_id (None | str | Unset): Free-form reference to the originating GL transaction (e.g. an import ID, ledger entry ID, or external system key). Stored in artifact_mechanics for audit; no FK constraint. """ diff --git a/robosystems_client/models/entry_template_request.py b/robosystems_client/models/entry_template_request.py index ac35d55..12c6605 100644 --- a/robosystems_client/models/entry_template_request.py +++ b/robosystems_client/models/entry_template_request.py @@ -16,8 +16,11 @@ class EntryTemplateRequest: """ Attributes: - debit_element_id (str): Element to debit (e.g., Depreciation Expense) - credit_element_id (str): Element to credit (e.g., Accumulated Depreciation) + debit_element_id (str): CoA element id to debit (e.g. Depreciation Expense). This is a chart-of-accounts element + id — the `id` returned by get-unmapped-elements / get-graph-schema — NOT a taxonomy qname. + credit_element_id (str): CoA element id to credit (e.g. Accumulated Depreciation). A chart-of-accounts element + id (see get-unmapped-elements), not a taxonomy qname. One template = one debit/credit pair; model a multi- + account entry as several schedules. entry_type (EntryTemplateRequestEntryType | Unset): Entry type for generated entries Default: EntryTemplateRequestEntryType.CLOSING. memo_template (str | Unset): Memo template ({structure_name} is replaced) Default: ''. diff --git a/robosystems_client/models/get_report_bundle_download_url_report_bundle_download_response.py b/robosystems_client/models/get_report_bundle_download_url_report_bundle_download_response.py deleted file mode 100644 index c6eb91a..0000000 --- a/robosystems_client/models/get_report_bundle_download_url_report_bundle_download_response.py +++ /dev/null @@ -1,103 +0,0 @@ -from __future__ import annotations - -import datetime -from collections.abc import Mapping -from typing import Any, TypeVar - -from attrs import define as _attrs_define -from attrs import field as _attrs_field -from dateutil.parser import isoparse - -T = TypeVar("T", bound="GetReportBundleDownloadUrlReportBundleDownloadResponse") - - -@_attrs_define -class GetReportBundleDownloadUrlReportBundleDownloadResponse: - """Presigned-URL response for a Report bundle download. - - Mirrors :class:`BackupDownloadUrlResponse` in shape — the frontend - treats both the same way (fetch, follow URL, GET the artifact). - - Only returned for RDF-family flavors (JSON-LD) where the artifact - is stored in S3. XBRL flavors stream the binary content directly - in the response body (no JSON wrapper). - - Attributes: - download_url (str): Presigned URL that streams the bundle directly from S3. - expires_at (datetime.datetime): UTC timestamp at which the presigned URL stops working. - content_type (str): MIME type of the artifact behind the URL. - format_ (str): Serialization flavor delivered by this URL — matches the ``format`` query parameter. - generation_count (int): Bundle generation number stamped on the Report. - """ - - download_url: str - expires_at: datetime.datetime - content_type: str - format_: str - generation_count: int - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - download_url = self.download_url - - expires_at = self.expires_at.isoformat() - - content_type = self.content_type - - format_ = self.format_ - - generation_count = self.generation_count - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "download_url": download_url, - "expires_at": expires_at, - "content_type": content_type, - "format": format_, - "generation_count": generation_count, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - download_url = d.pop("download_url") - - expires_at = isoparse(d.pop("expires_at")) - - content_type = d.pop("content_type") - - format_ = d.pop("format") - - generation_count = d.pop("generation_count") - - get_report_bundle_download_url_report_bundle_download_response = cls( - download_url=download_url, - expires_at=expires_at, - content_type=content_type, - format_=format_, - generation_count=generation_count, - ) - - get_report_bundle_download_url_report_bundle_download_response.additional_properties = d - return get_report_bundle_download_url_report_bundle_download_response - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/tests/test_ledger_client.py b/tests/test_ledger_client.py index 6678a6d..ed4f75a 100644 --- a/tests/test_ledger_client.py +++ b/tests/test_ledger_client.py @@ -1823,103 +1823,91 @@ def test_financial_statement_analysis_with_ticker( assert body.fiscal_year == 2025 -# ── Bundle download (REST GET to /reports/{id}/download) ────────────── +# ── Bundle download (GraphQL reportDownloadUrl → follow presigned URL) ── @pytest.mark.unit class TestDownloadReportBundle: - """``download_report_bundle`` bypasses the typed op_* generators and - hits the endpoint via httpx directly — these tests stub the httpx - Client at the call site.""" - - def _mock_json_envelope(self, gen: int = 1) -> Mock: - """Mock the initial JSON envelope response for the JSON-LD path.""" - resp = Mock() - resp.status_code = 200 - resp.headers = {"content-type": "application/json"} - resp.json.return_value = { - "download_url": "https://s3.example.com/bundles/rpt_01/g1.jsonld", - "expires_at": "2026-05-28T12:30:00Z", - "content_type": "application/ld+json", - "format": "jsonld", - "generation_count": gen, + """``download_report_bundle`` resolves a presigned URL via GraphQL + (``reportDownloadUrl``), then follows it with httpx to pull the + bytes — these tests stub ``_query`` and the httpx Client.""" + + def _gql_data(self, content_type: str, fmt: str, gen: int = 1) -> dict: + """Raw GraphQL `data` for the reportDownloadUrl field (camelCase).""" + return { + "reportDownloadUrl": { + "downloadUrl": "https://s3.example.com/bundles/rpt_01/g1", + "expiresAt": "2026-05-28T12:30:00Z", + "contentType": content_type, + "format": fmt, + "generationCount": gen, + } } - return resp - def _mock_artifact(self, content: bytes, filename: str = "rpt_01-g1.jsonld") -> Mock: + def _mock_artifact(self, content: bytes, filename: str) -> Mock: resp = Mock() resp.status_code = 200 resp.headers = {"content-disposition": f'attachment; filename="{filename}"'} resp.content = content return resp - def _mock_xbrl_response(self, content: bytes, gen: int = 1) -> Mock: - """Mock the direct binary-stream response for the XBRL path.""" - resp = Mock() - resp.status_code = 200 - resp.headers = { - "content-type": "application/zip", - "content-disposition": f'attachment; filename="rpt_01-g{gen}.zip"', - "x-bundle-format": "xbrl-2.1", - "x-bundle-generation": str(gen), - } - resp.content = content - resp.text = "" - return resp - - @patch("robosystems_client.clients.ledger_client.httpx.Client") - def test_jsonld_download_follows_presigned_url( - self, mock_client_cls, mock_config, graph_id - ): - """JSON-LD path: envelope GET → presigned URL GET → bytes.""" + def _patch_httpx(self, mock_client_cls, artifact: Mock) -> Mock: mock_client = Mock() mock_client.__enter__ = Mock(return_value=mock_client) mock_client.__exit__ = Mock(return_value=None) - mock_client.get.side_effect = [ - self._mock_json_envelope(gen=2), - self._mock_artifact(b'{"@graph": []}', "rpt_01-g2.jsonld"), - ] + mock_client.get.return_value = artifact mock_client_cls.return_value = mock_client + return mock_client - result = LedgerClient(mock_config).download_report_bundle( - graph_id, "rpt_01", format="jsonld" + @patch("robosystems_client.clients.ledger_client.httpx.Client") + def test_jsonld_download_follows_presigned_url( + self, mock_client_cls, mock_config, graph_id + ): + """JSON-LD: GraphQL resolves the URL, client follows it for bytes.""" + mock_client = self._patch_httpx( + mock_client_cls, self._mock_artifact(b'{"@graph": []}', "rpt_01-g2.jsonld") ) + with patch.object( + LedgerClient, + "_query", + return_value=self._gql_data("application/ld+json", "jsonld", gen=2), + ) as mock_query: + result = LedgerClient(mock_config).download_report_bundle( + graph_id, "rpt_01", format="jsonld" + ) assert result.content == b'{"@graph": []}' assert result.filename == "rpt_01-g2.jsonld" assert result.format == "jsonld" assert result.content_type == "application/ld+json" assert result.generation_count == 2 - # First request hits our endpoint with X-API-Key auth. - first_call = mock_client.get.call_args_list[0] - assert "/reports/rpt_01/download" in first_call.args[0] - assert "format=jsonld" in first_call.args[0] - headers = first_call.kwargs["headers"] - assert headers["X-API-Key"] == "test-api-key" + # GraphQL var carries the enum NAME, not the wire flavor string. + assert mock_query.call_args.args[2]["format"] == "JSONLD" + # The bytes come from following the presigned URL. + assert mock_client.get.call_args.args[0].startswith("https://s3.example.com/") @patch("robosystems_client.clients.ledger_client.httpx.Client") - def test_xbrl_download_streams_zip_directly( + def test_xbrl_download_follows_presigned_url( self, mock_client_cls, mock_config, graph_id ): - """XBRL path: single GET, binary response, no presigned URL hop.""" + """XBRL: same presigned-URL path now (no more direct zip stream).""" zip_bytes = b"PK\x03\x04dummy-zip-payload" - mock_client = Mock() - mock_client.__enter__ = Mock(return_value=mock_client) - mock_client.__exit__ = Mock(return_value=None) - mock_client.get.return_value = self._mock_xbrl_response(zip_bytes, gen=3) - mock_client_cls.return_value = mock_client - - result = LedgerClient(mock_config).download_report_bundle( - graph_id, "rpt_01", format="xbrl-2.1" - ) + self._patch_httpx(mock_client_cls, self._mock_artifact(zip_bytes, "rpt_01-g3.zip")) + with patch.object( + LedgerClient, + "_query", + return_value=self._gql_data("application/zip", "xbrl-2.1", gen=3), + ) as mock_query: + result = LedgerClient(mock_config).download_report_bundle( + graph_id, "rpt_01", format="xbrl-2.1" + ) assert result.content == zip_bytes assert result.filename == "rpt_01-g3.zip" assert result.format == "xbrl-2.1" assert result.content_type == "application/zip" assert result.generation_count == 3 - # XBRL path only makes one HTTP call. - assert mock_client.get.call_count == 1 + assert mock_query.call_args.args[2]["format"] == "XBRL_2_1" @patch("robosystems_client.clients.ledger_client.httpx.Client") def test_to_arg_writes_bytes_to_disk( @@ -1927,16 +1915,16 @@ def test_to_arg_writes_bytes_to_disk( ): """``to=path`` writes the artifact and exposes ``result.path``.""" zip_bytes = b"PK\x03\x04zip" - mock_client = Mock() - mock_client.__enter__ = Mock(return_value=mock_client) - mock_client.__exit__ = Mock(return_value=None) - mock_client.get.return_value = self._mock_xbrl_response(zip_bytes) - mock_client_cls.return_value = mock_client - + self._patch_httpx(mock_client_cls, self._mock_artifact(zip_bytes, "rpt_01-g1.zip")) target = tmp_path / "subdir" / "out.zip" - result = LedgerClient(mock_config).download_report_bundle( - graph_id, "rpt_01", format="xbrl-2.1", to=str(target) - ) + with patch.object( + LedgerClient, + "_query", + return_value=self._gql_data("application/zip", "xbrl-2.1"), + ): + result = LedgerClient(mock_config).download_report_bundle( + graph_id, "rpt_01", format="xbrl-2.1", to=str(target) + ) assert result.path == target assert target.read_bytes() == zip_bytes @@ -1944,19 +1932,25 @@ def test_to_arg_writes_bytes_to_disk( assert target.parent.is_dir() @patch("robosystems_client.clients.ledger_client.httpx.Client") - def test_non_200_endpoint_response_raises( - self, mock_client_cls, mock_config, graph_id - ): - err_resp = Mock(status_code=404, text="not found") + def test_presigned_url_non_200_raises(self, mock_client_cls, mock_config, graph_id): + err_resp = Mock(status_code=403, text="expired") err_resp.headers = {} - mock_client = Mock() - mock_client.__enter__ = Mock(return_value=mock_client) - mock_client.__exit__ = Mock(return_value=None) - mock_client.get.return_value = err_resp - mock_client_cls.return_value = mock_client + self._patch_httpx(mock_client_cls, err_resp) + with patch.object( + LedgerClient, + "_query", + return_value=self._gql_data("application/ld+json", "jsonld"), + ): + with pytest.raises(RuntimeError, match="Failed to follow presigned URL"): + LedgerClient(mock_config).download_report_bundle(graph_id, "rpt_01") - with pytest.raises(RuntimeError, match="Download bundle failed"): - LedgerClient(mock_config).download_report_bundle(graph_id, "rpt_missing") + @patch("robosystems_client.clients.ledger_client.httpx.Client") + def test_missing_report_raises(self, mock_client_cls, mock_config, graph_id): + with patch.object(LedgerClient, "_query", return_value={"reportDownloadUrl": None}): + with pytest.raises(RuntimeError, match="not found"): + LedgerClient(mock_config).download_report_bundle(graph_id, "rpt_missing") + # Never reaches the artifact fetch. + mock_client_cls.assert_not_called() def test_no_token_raises(self, mock_config, graph_id): mock_config["token"] = None