Skip to content

Commit

Permalink
Add support for download
Browse files Browse the repository at this point in the history
  • Loading branch information
gacou54 committed Dec 12, 2023
1 parent 569ac9a commit bbd901b
Show file tree
Hide file tree
Showing 11 changed files with 151 additions and 26 deletions.
5 changes: 4 additions & 1 deletion pyorthanc/_resources/instance.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from datetime import datetime
from typing import Any, Dict, List, TYPE_CHECKING
from typing import Any, BinaryIO, Dict, List, TYPE_CHECKING, Union

import pydicom

Expand Down Expand Up @@ -42,6 +42,9 @@ def get_dicom_file_content(self) -> bytes:
"""
return self.client.get_instances_id_file(self.id_)

def download(self, target_path: Union[str, BinaryIO], with_progres: bool = False) -> None:
self._download_file(f'{self.client.url}/instances/{self.id_}/file', target_path, with_progres)

@property
def uid(self) -> str:
"""Get SOPInstanceUID"""
Expand Down
5 changes: 4 additions & 1 deletion pyorthanc/_resources/patient.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import warnings
from datetime import datetime
from typing import Dict, List
from typing import BinaryIO, Dict, List, Union

from httpx import ReadTimeout

Expand Down Expand Up @@ -107,6 +107,9 @@ def get_zip(self) -> bytes:
"""
return self.client.get_patients_id_archive(self.id_)

def download(self, target_path: Union[str, BinaryIO], with_progres: bool = False) -> None:
self._download_file(f'{self.client.url}/patients/{self.id_}/archive', target_path, with_progres)

def get_patient_module(self, simplify: bool = False, short: bool = False) -> Dict:
"""Get patient module in a simplified version

Expand Down
44 changes: 43 additions & 1 deletion pyorthanc/_resources/resource.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from typing import Any, Dict, List, Optional
import abc
from typing import Any, BinaryIO, Dict, List, Optional, Union

from httpx._types import QueryParamTypes

from .. import errors, util
from ..client import Orthanc
Expand Down Expand Up @@ -41,6 +44,7 @@ def identifier(self) -> str:
"""
return self.id_

@abc.abstractmethod
def get_main_information(self):
raise NotImplementedError

Expand All @@ -62,6 +66,44 @@ def _make_response_format_params(self, simplify: bool = False, short: bool = Fal

return params

def _download_file(
self, url: str,
path: Union[str, BinaryIO],
with_progress: bool = False,
params: Optional[QueryParamTypes] = None):
if isinstance(path, str):
file = open(path, 'wb')
elif isinstance(path, BinaryIO):
file = path
else:
raise TypeError(f'"path" must be a file-like object or a file path, got "{type(path)}".')

try:
with self.client.stream('GET', url, params=params) as response:
if with_progress:
try:
from tqdm import tqdm
except ModuleNotFoundError:
raise ModuleNotFoundError(
'Optional dependency tqdm have to be installed for the progress indicator. '
'Install with `pip install pyorthanc[progress]` or `pip install pyorthanc[all]'
)

last_num_bytes_downloaded = response.num_bytes_downloaded

with tqdm(unit='B', unit_scale=True, desc=self.__repr__()) as progress:
for chunk in response.iter_bytes():
file.write(chunk)
progress.update(response.num_bytes_downloaded - last_num_bytes_downloaded)
last_num_bytes_downloaded = response.num_bytes_downloaded

else:
for chunk in response.iter_bytes():
file.write(chunk)

finally:
file.close()

def __eq__(self, other: 'Resource') -> bool:
return self.id_ == other.id_

Expand Down
5 changes: 4 additions & 1 deletion pyorthanc/_resources/series.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from datetime import datetime
from typing import Dict, List, TYPE_CHECKING
from typing import BinaryIO, Dict, List, TYPE_CHECKING, Union

from httpx import ReadTimeout

Expand Down Expand Up @@ -570,6 +570,9 @@ def get_zip(self) -> bytes:
"""
return self.client.get_series_id_archive(self.id_)

def download(self, target_path: Union[str, BinaryIO], with_progres: bool = False) -> None:
self._download_file(f'{self.client.url}/series/{self.id_}/archive', target_path, with_progres)

def get_shared_tags(self, simplify: bool = False, short: bool = False) -> Dict:
"""Retrieve the shared tags of the series"""
params = self._make_response_format_params(simplify, short)
Expand Down
5 changes: 4 additions & 1 deletion pyorthanc/_resources/study.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from datetime import datetime
from typing import Dict, List, TYPE_CHECKING
from typing import BinaryIO, Dict, List, TYPE_CHECKING, Union

from httpx import ReadTimeout

Expand Down Expand Up @@ -528,6 +528,9 @@ def get_zip(self) -> bytes:
"""
return self.client.get_studies_id_archive(self.id_)

def download(self, target_path: Union[str, BinaryIO], with_progres: bool = False) -> None:
self._download_file(f'{self.client.url}/studies/{self.id_}/archive', target_path, with_progres)

def get_shared_tags(self, simplify: bool = False, short: bool = False) -> Dict:
"""Retrieve the shared tags of the study"""
params = self._make_response_format_params(simplify, short)
Expand Down
12 changes: 11 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,17 @@ keywords = ["Orthanc", "DICOM", "Medical-Imaging"]
python = "^3.8"
httpx = ">=0.24.1,<1.0.0"
pydicom = "^2.3.0"
tqdm = { version = "^4.66.1", optional = true }


[tool.poetry.extras]
progress = ["tqdm"]
all = ["progress"]


[tool.poetry.group.docs.dependencies]
mkdocs = "^1.5.3"
mkdocstrings = {extras = ["python"], version = "^0.23.0"}
mkdocstrings = { extras = ["python"], version = "^0.23.0" }
mkdocs-material = "^9.4.6"
jinja2 = "^3.1.2"

Expand All @@ -29,6 +36,9 @@ jinja2 = "^3.1.2"
pytest = "^7.4.3"
simple-openapi-client = "^0.5.3"




[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
8 changes: 8 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import tempfile

import pytest

from pyorthanc import AsyncOrthanc, Instance, Modality, Orthanc, Patient, Series, Study
Expand Down Expand Up @@ -89,3 +91,9 @@ def series(client_with_data_and_labels):
@pytest.fixture
def instance(client_with_data_and_labels):
return Instance(client=client_with_data_and_labels, id_=an_instance.IDENTIFIER)


@pytest.fixture
def tmp_dir():
with tempfile.TemporaryDirectory() as dir_path:
yield dir_path
32 changes: 29 additions & 3 deletions tests/test_instance.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import io
from datetime import datetime

import pydicom
Expand All @@ -21,7 +22,7 @@ def test_attributes(instance):
assert instance.get_main_information().keys() == an_instance.INFORMATION.keys()

assert instance.uid == an_instance.INFORMATION['MainDicomTags']['SOPInstanceUID']
assert type(instance.file_size) == int
assert isinstance(instance.file_size, int)
assert instance.creation_date == EXPECTED_DATE
assert instance.labels == [LABEL_INSTANCE]
assert instance.series_identifier == a_series.IDENTIFIER
Expand Down Expand Up @@ -65,13 +66,13 @@ def test_get_tag_content(instance):
def test_anonymize(instance):
anonymized_instance = instance.anonymize(remove=['InstanceCreationDate'])

assert type(anonymized_instance) == bytes
assert isinstance(anonymized_instance, bytes)


def test_modify(instance):
modified_instance = instance.modify(replace={'NumberOfFrames': '10'})

assert type(modified_instance) == bytes
assert isinstance(modified_instance, bytes)


def test_pydicom(instance):
Expand All @@ -88,3 +89,28 @@ def test_label(instance, label):

instance.remove_label(label)
assert label not in instance.labels


def test_get_dicom_file_content(instance):
result = instance.get_dicom_file_content()

assert isinstance(result, bytes)
pydicom.dcmread(io.BytesIO(result)) # Assert not raised


def test_download(instance, tmp_dir):
# Test with buffer
buffer = io.BytesIO()
instance.download(buffer)
pydicom.dcmread(buffer) # Assert not raised

# Test with filepath
instance.download(f'{tmp_dir}/file.dcm')
pydicom.dcmread(f'{tmp_dir}/file.dcm') # Assert not raised

# Test with progress enable
buffer = io.BytesIO()
instance.download(buffer, with_progres=True)
pydicom.dcmread(buffer) # Assert not raised


15 changes: 12 additions & 3 deletions tests/test_patient.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,20 @@ def test_attributes(patient):
def test_zip(patient):
result = patient.get_zip()

assert type(result) is bytes
assert isinstance(result, bytes)
zipfile = ZipFile(io.BytesIO(result))
assert zipfile.testzip() is None # Verify that zip files are valid (if it is, returns None)


def test_download(patient: Patient, tmp_dir: str):
buffer = io.BytesIO()
patient.download(buffer)
assert ZipFile(buffer).testzip() is None # Verify that zip files are valid (if it is, returns None)

patient.download(f'{tmp_dir}/file.zip')
assert ZipFile(f'{tmp_dir}/file.zip').testzip() is None


def test_patient_module(patient):
result = patient.get_patient_module()
assert result == a_patient.MODULE
Expand Down Expand Up @@ -77,7 +86,7 @@ def test_anonymize(patient):
assert anonymize_patient.name == a_patient.NAME


def test_anonymize_as_job(patient):
def test_anonymize_as_job(patient: Patient):
job = patient.anonymize_as_job(remove=['PatientName'])
job.wait_until_completion()
anonymize_patient = Patient(job.content['ID'], patient.client)
Expand Down Expand Up @@ -145,7 +154,7 @@ def test_modify_as_job_remove(patient):
assert 'ID' not in job.content # Has no effect because PatientID can't be removed


def test_modify_as_job_replace(patient):
def test_modify_as_job_replace(patient: Patient):
job = patient.modify_as_job(replace={'PatientName': 'NewName'})
assert patient.name == a_patient.NAME

Expand Down
23 changes: 16 additions & 7 deletions tests/test_series.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from .data import a_patient, a_series, a_study


def test_attributes(series):
def test_attributes(series: Series):
assert series.get_main_information().keys() == a_series.INFORMATION.keys()

assert series.identifier == a_series.IDENTIFIER
Expand Down Expand Up @@ -54,7 +54,16 @@ def test_zip(series):
assert zipfile.testzip() is None # Verify that zip files are valid (if it is, returns None)


def test_anonymize(series):
def test_download(series: Series, tmp_dir: str):
buffer = io.BytesIO()
series.download(buffer)
assert ZipFile(buffer).testzip() is None # Verify that zip files are valid (if it is, returns None)

series.download(f'{tmp_dir}/file.zip')
assert ZipFile(f'{tmp_dir}/file.zip').testzip() is None


def test_anonymize(series: Series):
anonymized_series = series.anonymize(remove=['Modality'])
assert anonymized_series.uid != a_series.INFORMATION['MainDicomTags']['SeriesInstanceUID']
with pytest.raises(errors.TagDoesNotExistError):
Expand All @@ -71,7 +80,7 @@ def test_anonymize(series):
a_series.INFORMATION['MainDicomTags']['StationName']


def test_anonymize_as_job(series):
def test_anonymize_as_job(series: Series):
job = series.anonymize_as_job(remove=['Modality'])
job.wait_until_completion()
anonymized_series = Series(job.content['ID'], series.client)
Expand All @@ -94,7 +103,7 @@ def test_anonymize_as_job(series):
a_series.INFORMATION['MainDicomTags']['StationName']


def test_modify_remove(series):
def test_modify_remove(series: Series):
assert series.manufacturer == a_series.MANUFACTURER

modified_series = series.modify(remove=['Manufacturer'])
Expand All @@ -120,7 +129,7 @@ def test_modify_remove(series):
series.modify(remove=['SeriesInstanceUID'], force=True)


def test_modify_replace(series):
def test_modify_replace(series: Series):
assert series.manufacturer == a_series.MANUFACTURER

modified_series = series.modify(replace={'Manufacturer': 'new-manufacturer'})
Expand Down Expand Up @@ -150,7 +159,7 @@ def test_modify_replace(series):
assert modified_series.id_ != series.id_


def test_modify_as_job_remove(series):
def test_modify_as_job_remove(series: Series):
# Create new modified series
job = series.modify_as_job(remove=['Manufacturer'])
job.wait_until_completion()
Expand Down Expand Up @@ -184,7 +193,7 @@ def test_modify_as_job_remove(series):
assert 'ID' not in job.content # Has no effect because SeriesInstanceUID can't be removed


def test_modify_as_job_replace(series):
def test_modify_as_job_replace(series: Series):
job = series.modify_as_job(replace={'Manufacturer': 'new-manufacturer'})
job.wait_until_completion()
modified_series = Series(job.content['ID'], series.client)
Expand Down
Loading

0 comments on commit bbd901b

Please sign in to comment.