Skip to content
Draft
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
58 changes: 58 additions & 0 deletions cognite/client/_api/diagrams.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,3 +394,61 @@ def convert(self, detect_job: DiagramDetectResults) -> DiagramConvertResults:
items=self._process_detect_job(detect_job),
job_cls=DiagramConvertResults,
)

def download_converted_file(
self,
job_id: int,
*,
file_id: int | None = None,
file_external_id: str | None = None,
page: int = 1,
mime_type: Literal["image/png", "image/svg+xml"] = "image/png",
) -> bytes:
"""Download a converted diagram file (PNG or SVG) for a specific page of a completed convert job.

The converted file is the output of a diagram convert job, which produces interactive
diagram images with detected annotations highlighted. Use this method to retrieve
the binary content of a specific page.

Args:
job_id (int): The ID of the completed diagram convert job.
file_id (int | None): The CDF file ID. Either file_id or file_external_id must be provided.
file_external_id (str | None): The CDF file external ID. Either file_id or file_external_id must be provided.
page (int): The page number to download (1-indexed). Defaults to 1.
mime_type (Literal["image/png", "image/svg+xml"]): The desired output format. Defaults to "image/png".

Returns:
bytes: The binary content of the converted diagram (PNG or SVG).

Raises:
ValueError: If neither file_id nor file_external_id is provided, or if both are provided.

Examples:
Download PNG of page 1 from a convert job:

>>> from cognite.client import CogniteClient
>>> client = CogniteClient()
>>> png_bytes = client.diagrams.download_converted_file(
... job_id=123, file_id=456, page=1
... )

Download SVG using file external ID:

>>> svg_bytes = client.diagrams.download_converted_file(
... job_id=123, file_external_id="my-diagram.pdf", mime_type="image/svg+xml"
... )
"""
if (file_id is None) == (file_external_id is None):
raise ValueError("Exactly one of file_id or file_external_id must be provided")
params: dict[str, Any] = {"page": page}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The type hint for params can be more specific. According to the style guide (line 43), Any should be avoided when possible. The dictionary values are either integers (page, file_id) or a string (file_external_id), so dict[str, int | str] is a more precise type hint.

Suggested change
params: dict[str, Any] = {"page": page}
params: dict[str, int | str] = {"page": page}
References
  1. The style guide requires using specific types instead of Any whenever possible. (link)

if file_id is not None:
params["file_id"] = file_id
else:
params["file_external_id"] = file_external_id
res = self._do_request(
"GET",
f"{self._RESOURCE_PATH}/convert/{job_id}/download",
params={to_camel_case(k): v for k, v in params.items()},
accept=mime_type,
)
return res.content
70 changes: 70 additions & 0 deletions scripts/verify_diagram_download.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Verification script for diagram_download_converted_file.

Run this script to verify the usefulness of the diagram download converted file endpoint.
Requires: CogniteClient configured with valid credentials and a completed diagram convert job.

Usage:
# Using file_id (from a completed convert job):
python scripts/verify_diagram_download.py --job-id 123 --file-id 456

# Using file_external_id:
python scripts/verify_diagram_download.py --job-id 123 --file-external-id "my-diagram.pdf"

# Download as SVG instead of PNG:
python scripts/verify_diagram_download.py --job-id 123 --file-id 456 --format svg

# Download a specific page:
python scripts/verify_diagram_download.py --job-id 123 --file-id 456 --page 2
"""
from __future__ import annotations

import argparse
from pathlib import Path

from cognite.client import CogniteClient


def main() -> None:
parser = argparse.ArgumentParser(description="Verify diagram_download_converted_file endpoint")
parser.add_argument("--job-id", type=int, required=True, help="Diagram convert job ID")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--file-id", type=int, help="CDF file ID")
group.add_argument("--file-external-id", type=str, help="CDF file external ID")
parser.add_argument("--page", type=int, default=1, help="Page number (default: 1)")
parser.add_argument(
"--format",
choices=["png", "svg"],
default="png",
help="Output format (default: png)",
)
parser.add_argument(
"--output",
type=Path,
help="Save to file (optional). If not set, prints size and first bytes.",
)
args = parser.parse_args()

client = CogniteClient()

mime_type = "image/png" if args.format == "png" else "image/svg+xml"
kwargs: dict = {"job_id": args.job_id, "page": args.page, "mime_type": mime_type}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The kwargs dictionary is untyped (dict is equivalent to dict[Any, Any]). The style guide emphasizes strong typing and using specific types (lines 5, 42, 43). The keys are strings and the values are a mix of integers and strings. Please provide a more specific type hint like dict[str, int | str].

Suggested change
kwargs: dict = {"job_id": args.job_id, "page": args.page, "mime_type": mime_type}
kwargs: dict[str, int | str] = {"job_id": args.job_id, "page": args.page, "mime_type": mime_type}
References
  1. The style guide requires extensive use of type hints and avoiding Any when possible. (link)

if args.file_id is not None:
kwargs["file_id"] = args.file_id
else:
kwargs["file_external_id"] = args.file_external_id

print(f"Downloading converted diagram: job_id={args.job_id}, page={args.page}, format={args.format}")
content = client.diagrams.download_converted_file(**kwargs)

print(f"Downloaded {len(content)} bytes")
if args.output:
args.output.write_bytes(content)
print(f"Saved to {args.output}")
else:
preview = content[:50] if len(content) >= 50 else content
print(f"First bytes (hex): {preview.hex()[:80]}...")


if __name__ == "__main__":
main()
47 changes: 47 additions & 0 deletions tests/tests_unit/test_api/test_diagrams.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,50 @@ def test_run_diagram_detect(
job_bundle, _unposted_jobs = cognite_client.diagrams.detect(
file_ids=file_ids, entities=entities, multiple_jobs=True
)


class TestDiagramDownloadConvertedFile:
@patch.object(DiagramsAPI, "_do_request")
def test_download_converted_file_with_file_id(
self, mocked_do_request: MagicMock, cognite_client: CogniteClient
) -> None:
mock_response = Mock()
mock_response.content = b"fake-png-bytes"
mocked_do_request.return_value = mock_response

result = cognite_client.diagrams.download_converted_file(
job_id=123, file_id=456, page=2, mime_type="image/png"
)

assert result == b"fake-png-bytes"
mocked_do_request.assert_called_once()
call_kwargs = mocked_do_request.call_args[1]
assert call_kwargs["accept"] == "image/png"
assert call_kwargs["params"] == {"fileId": 456, "page": 2}

@patch.object(DiagramsAPI, "_do_request")
def test_download_converted_file_with_file_external_id(
self, mocked_do_request: MagicMock, cognite_client: CogniteClient
) -> None:
mock_response = Mock()
mock_response.content = b"fake-svg-bytes"
mocked_do_request.return_value = mock_response

result = cognite_client.diagrams.download_converted_file(
job_id=789, file_external_id="my-diagram.pdf", mime_type="image/svg+xml"
)

assert result == b"fake-svg-bytes"
mocked_do_request.assert_called_once()
call_kwargs = mocked_do_request.call_args[1]
assert call_kwargs["accept"] == "image/svg+xml"
assert call_kwargs["params"] == {"fileExternalId": "my-diagram.pdf", "page": 1}

def test_download_converted_file_requires_file_identifier(self, cognite_client: CogniteClient) -> None:
with pytest.raises(ValueError, match="Exactly one of file_id or file_external_id must be provided"):
cognite_client.diagrams.download_converted_file(job_id=123)

with pytest.raises(ValueError, match="Exactly one of file_id or file_external_id must be provided"):
cognite_client.diagrams.download_converted_file(
job_id=123, file_id=456, file_external_id="both-provided"
)
1 change: 1 addition & 0 deletions tests/tests_unit/test_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1368,6 +1368,7 @@ def test_is_retryable_resource_api_endpoints(self, api_client_with_token, method
("POST", "https://api.cognitedata.com/api/v1/projects/bla/context/diagram/convert", True),
("POST", "https://api.cognitedata.com/api/v1/projects/bla/context/diagram/detect", True),
("GET", "https://api.cognitedata.com/api/v1/projects/bla/context/diagram/convert/123", True),
("GET", "https://api.cognitedata.com/api/v1/projects/bla/context/diagram/convert/123/download", True),
("GET", "https://api.cognitedata.com/api/v1/projects/bla/context/diagram/detect/456", True),
# Simulators
("POST", "https://api.cognitedata.com/api/v1/projects/bla/simulators/list", True),
Expand Down
Loading