Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
8e3086b
adding nifti wador retrieve, view endpoints
chinacat567 Jun 27, 2025
14df02d
sync to async
chinacat567 Jun 28, 2025
76ec022
adding nifti view urls, sample test
chinacat567 Jun 30, 2025
6e4374c
adding multipart boundary
chinacat567 Jul 3, 2025
1c2205c
lint fix
chinacat567 Jul 3, 2025
6b4ddf8
test fixes
chinacat567 Jul 3, 2025
a9726a8
refac for filename and json metadata
chinacat567 Jul 9, 2025
699609b
adding test, fixing broken url
chinacat567 Jul 10, 2025
40253c2
lint fix
chinacat567 Jul 10, 2025
d8bd125
pyproject update
chinacat567 Jul 10, 2025
3d00b2a
fixing renderer classes in the view
chinacat567 Jul 14, 2025
6aec4b0
adding tests
chinacat567 Jul 16, 2025
b0c5215
update for json files
chinacat567 Jul 16, 2025
b293e76
lint fix
chinacat567 Jul 16, 2025
8106940
removing redundant code
chinacat567 Jul 16, 2025
bbfb9e8
fixing redundant code in client.py
chinacat567 Jul 16, 2025
1f383fd
fixing nit comment
chinacat567 Jul 16, 2025
d6d4b93
refac wador_utils to process a single series during nifti conversion
chinacat567 Jul 21, 2025
a50cde1
renaming DICOM_SAMPLE_DIR env variable
chinacat567 Jul 21, 2025
f9373e5
removing useless static method
chinacat567 Jul 21, 2025
5977968
making file/os functions async
chinacat567 Jul 21, 2025
fb5919a
updating yielding logic for json, nifti files
chinacat567 Jul 21, 2025
4250007
raise error when content disposition is not present
chinacat567 Jul 21, 2025
20c140a
adding iter methods to adit client
chinacat567 Jul 21, 2025
0431cc7
co-pilot fixes
chinacat567 Jul 21, 2025
27f96f0
env variable fix
chinacat567 Jul 21, 2025
8687598
env var fix
chinacat567 Jul 21, 2025
0534c66
adding streaming for nifti files
chinacat567 Jul 22, 2025
36a364c
fixing fetch task await and expcetion handling in fetch_dicom_data
chinacat567 Jul 22, 2025
e4e89b8
using aiofiles temp directory
chinacat567 Jul 22, 2025
e88976b
improving dcm2niix exception handling
chinacat567 Jul 22, 2025
f312f14
updating wador_utils for SR/invalid files
chinacat567 Jul 22, 2025
f0a1dca
env var fix
chinacat567 Jul 22, 2025
f41d341
aiofiles fix
chinacat567 Jul 22, 2025
0b0b332
use list comprehension
chinacat567 Jul 23, 2025
7369f43
adding test settings
chinacat567 Jul 23, 2025
c902e49
removing MultipartDecoder for retrieve methods, using _decode_multipa…
chinacat567 Jul 24, 2025
b925022
adding tests for iter methods
chinacat567 Jul 24, 2025
7edf07c
fixing await pattern on async function
chinacat567 Jul 24, 2025
af66b1b
adding bval/bvac files
chinacat567 Jul 24, 2025
0295bcc
dicom to nifti fix
chinacat567 Jul 24, 2025
ad60d1d
testing
chinacat567 Jul 24, 2025
967cd6b
lint fix
chinacat567 Jul 24, 2025
25a4846
restoring ci.yml and pyrpoject
chinacat567 Jul 24, 2025
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
284 changes: 283 additions & 1 deletion adit-client/adit_client/client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import importlib.metadata
from typing import Iterator
from io import BytesIO
from typing import Iterator, Union

from dicomweb_client import DICOMwebClient, session_utils
from pydicom import Dataset
Expand Down Expand Up @@ -67,6 +68,287 @@ def retrieve_study(
additional_params=additional_params,
)

def retrieve_nifti_study(self, ae_title: str, study_uid: str) -> list[tuple[str, BytesIO]]:
Comment thread
chinacat567 marked this conversation as resolved.
"""
Retrieve NIfTI files from the API for a specific study.

Args:
ae_title: The AE title of the server.
study_uid: The study instance UID.

Returns:
A list of tuples containing the filename and file content.
"""
# Construct the full URL
url = f"{self.server_url}/api/dicom-web/{ae_title}/wadors/studies/{study_uid}/nifti"

# Call the API
dicomweb_client = self._create_dicom_web_client(ae_title)
response = dicomweb_client._http_get(
url,
headers={"Accept": "multipart/related; type=application/octet-stream"},
stream=True,
)

# Use the _iter_multipart_response method to process the response with stream=False
# to load all data at once for the retrieval method
return list(self._iter_multipart_response(response, stream=False))

def _extract_filename(self, content_disposition: str) -> str:
"""Extract filename from Content-Disposition header.

Parameters
----------
content_disposition: str
The Content-Disposition header value

Returns
-------
str
The extracted filename

Raises
------
ValueError
If no filename is found in the Content-Disposition header
"""
if not content_disposition or "filename=" not in content_disposition:
raise ValueError("No filename found in Content-Disposition header")

filename = content_disposition.split("filename=")[1].strip('"')
return filename

def _extract_part_content_with_headers(self, part: bytes) -> Union[bytes, None]:
"""Extract content from a single part of a multipart response message, including headers.

This method performs the same validation as _extract_part_content in DICOMWebClient but
returns the whole part including headers instead of just the content. It is used to patch
the DICOMwebClient's method to allow access to headers in multipart responses.

Parameters
----------
part: bytes
Individual part of a multipart message

Returns
-------
Union[bytes, None]
Content of the message part (including headers) or ``None`` if part is empty
"""
if part in (b"", b"--", b"\r\n") or part.startswith(b"--\r\n"):
return None

return part

def _iter_multipart_response(self, response, stream=False) -> Iterator[tuple[str, BytesIO]]:
"""
Process a multipart response in chunks, yielding files as they are received.

Args:
response: The streaming response object from requests.
stream: Whether to stream the data in chunks (True) or load it all at once (False).

Yields:
Tuples containing the filename and file content as they are received.

Raises:
ValueError: If no filename can be determined from headers
"""
# Create a DICOMwebClient instance to access _decode_multipart_message
dicomweb_client = self._create_dicom_web_client("")

# Store the original method to restore it later
original_extract_method = dicomweb_client._extract_part_content

try:
# Replace the extract method with our version that includes headers
dicomweb_client._extract_part_content = self._extract_part_content_with_headers

# Use the DICOMwebClient's _decode_multipart_message method to process the response
for part in dicomweb_client._decode_multipart_message(response, stream=stream):
# Extract headers from the part
headers = {}
content = part

# Try to parse headers if we have a complete part with headers
idx = part.find(b"\r\n\r\n")
if idx > -1:
headers_bytes = part[:idx]
content = part[idx + 4 :]

for header_line in headers_bytes.split(b"\r\n"):
if header_line and b":" in header_line:
name, value = header_line.split(b":", 1)
headers[name.decode("utf-8").strip()] = value.decode("utf-8").strip()

# Try to get filename from Content-Disposition header in part headers
content_disposition = headers.get("Content-Disposition")
if content_disposition:
filename = self._extract_filename(content_disposition)
else:
# Fallback to response headers if part headers not found
for header, value in response.headers.items():
if header.lower() == "content-disposition":
filename = self._extract_filename(value)
break
else:
# No Content-Disposition header found in part or response
raise ValueError("No Content-Disposition header found in response")

# Yield the filename and content as BytesIO
yield (filename, BytesIO(content))
finally:
# Restore the original method
dicomweb_client._extract_part_content = original_extract_method

def iter_nifti_study(self, ae_title: str, study_uid: str) -> Iterator[tuple[str, BytesIO]]:
"""
Iterate over NIfTI files from the API for a specific study.

Args:
ae_title: The AE title of the server.
study_uid: The study instance UID.

Yields:
Tuples containing the filename and file content as they are received from the API.
"""
# Construct the full URL
url = f"{self.server_url}/api/dicom-web/{ae_title}/wadors/studies/{study_uid}/nifti"

# Create client and set up the streaming request
dicomweb_client = self._create_dicom_web_client(ae_title)
response = dicomweb_client._http_get(
url,
headers={"Accept": "multipart/related; type=application/octet-stream"},
stream=True,
)

yield from self._iter_multipart_response(response, stream=True)

def retrieve_nifti_series(
self, ae_title: str, study_uid: str, series_uid: str
) -> list[tuple[str, BytesIO]]:
"""
Retrieve NIfTI files from the API for a specific series.

Args:
ae_title: The AE title of the server.
study_uid: The study instance UID.
series_uid: The series instance UID.

Returns:
A list of tuples containing the filename and file content.
"""
# Construct the full URL
url = (
f"{self.server_url}/api/dicom-web/{ae_title}/wadors/studies/{study_uid}/"
f"series/{series_uid}/nifti"
)

# Call the API
dicomweb_client = self._create_dicom_web_client(ae_title)
response = dicomweb_client._http_get(
url,
headers={"Accept": "multipart/related; type=application/octet-stream"},
stream=True,
)

# Use the _iter_multipart_response method to process the response with stream=False
return list(self._iter_multipart_response(response, stream=False))

def iter_nifti_series(
self, ae_title: str, study_uid: str, series_uid: str
) -> Iterator[tuple[str, BytesIO]]:
"""
Iterate over NIfTI files from the API for a specific series.

Args:
ae_title: The AE title of the server.
study_uid: The study instance UID.
series_uid: The series instance UID.

Yields:
Tuples containing the filename and file content as they are received from the API.
"""
# Construct the full URL
url = (
f"{self.server_url}/api/dicom-web/{ae_title}/wadors/studies/{study_uid}/"
f"series/{series_uid}/nifti"
)

# Create client and set up the streaming request
dicomweb_client = self._create_dicom_web_client(ae_title)
response = dicomweb_client._http_get(
url,
headers={"Accept": "multipart/related; type=application/octet-stream"},
stream=True,
)

yield from self._iter_multipart_response(response, stream=True)

def retrieve_nifti_image(
self, ae_title: str, study_uid: str, series_uid: str, image_uid: str
) -> list[tuple[str, BytesIO]]:
"""
Retrieve NIfTI files from the API for a specific image.

Args:
ae_title: The AE title of the server.
study_uid: The study instance UID.
series_uid: The series instance UID.
image_uid: The SOP instance UID.

Returns:
A list of tuples containing the filename and file content.
"""
# Construct the full URL
url = (
f"{self.server_url}/api/dicom-web/{ae_title}/wadors/studies/{study_uid}/"
f"series/{series_uid}/instances/{image_uid}/nifti"
)

# Call the API
dicomweb_client = self._create_dicom_web_client(ae_title)
response = dicomweb_client._http_get(
url,
headers={"Accept": "multipart/related; type=application/octet-stream"},
stream=True,
)

# Use the _iter_multipart_response method to process the response with stream=False
return list(self._iter_multipart_response(response, stream=False))

def iter_nifti_image(
self, ae_title: str, study_uid: str, series_uid: str, image_uid: str
) -> Iterator[tuple[str, BytesIO]]:
"""
Iterate over NIfTI files from the API for a specific image.

Args:
ae_title: The AE title of the server.
study_uid: The study instance UID.
series_uid: The series instance UID.
image_uid: The SOP instance UID.

Yields:
Tuples containing the filename and file content as they are received from the API.
"""
# Construct the full URL
url = (
f"{self.server_url}/api/dicom-web/{ae_title}/wadors/studies/{study_uid}/"
f"series/{series_uid}/instances/{image_uid}/nifti"
)

# Create client and set up the streaming request
dicomweb_client = self._create_dicom_web_client(ae_title)
response = dicomweb_client._http_get(
url,
headers={"Accept": "multipart/related; type=application/octet-stream"},
stream=True,
)

yield from self._iter_multipart_response(response, stream=True)

def retrieve_study_metadata(
self, ae_title: str, study_uid: str, pseudonym: str | None = None
) -> list[dict[str, dict]]:
Expand Down
6 changes: 4 additions & 2 deletions adit-client/tests/test_client.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from __future__ import annotations

import pytest
from adit_client.client import AditClient
from adit_client.utils.testing_helpers import create_admin_with_group_and_token
from pytest_django.live_server_helper import LiveServer
from pytest_mock import MockerFixture

from adit.core.models import DicomServer
from adit_client.client import AditClient
from adit_client.utils.testing_helpers import create_admin_with_group_and_token


@pytest.mark.django_db
Expand Down
54 changes: 54 additions & 0 deletions adit/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,60 @@ class RetriableDicomError(Exception):
pass


class DicomConversionError(Exception):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I would here also prefer DcmToNiftiConversionError as it's clearer. Also why is this in core? It's only used in dicom_web!

"""Base exception for DICOM to NIfTI conversion errors."""

pass


class NoValidDicomError(DicomConversionError):
"""Exception raised when no valid DICOM files are found."""

pass


class InvalidDicomError(DicomConversionError):
"""Exception raised when DICOM files are invalid or corrupt."""

pass


class OutputDirectoryError(DicomConversionError):
"""Exception raised when there are issues with the output directory."""

pass


class InputDirectoryError(DicomConversionError):
"""Exception raised when there are issues with the input directory."""

pass


class ExternalToolError(DicomConversionError):
"""Exception raised when there are issues with the external dcm2niix tool."""

pass


class NoSpatialDataError(DicomConversionError):
"""Exception raised when DICOM data doesn't have spatial attributes."""

pass


class NoMemoryError(DicomConversionError):
"""Exception raised when the system runs out of memory during conversion."""

pass


class UnknownFormatError(DicomConversionError):
"""Exception raised when input contains unsupported format."""

pass


class BatchFileSizeError(Exception):
def __init__(self, batch_tasks_count: int, max_batch_size: int) -> None:
super().__init__("Too many batch tasks.")
Expand Down
Loading