Skip to content

dicomtrolley.rad69

Retrieve data from servers using the rad69 protocol

see [rad69 document set] (https://gazelle.ihe.net/content/rad-69-retrieve-imaging-document-set) And the corresponding [transaction] (https://profiles.ihe.net/ITI/TF/Volume2/ITI-43.html)

Rad69

Bases: Downloader

A connection to a Rad69 server

Source code in dicomtrolley/rad69.py
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
class Rad69(Downloader):
    """A connection to a Rad69 server"""

    def __init__(
        self,
        session,
        url,
        request_per_series=True,
        errors_to_ignore=None,
        use_async=False,
        max_workers=4,
    ):
        """
        Parameters
        ----------
        session: requests.session
            A logged in session over which rad69 calls can be made
        url: str
            rad69 endpoint, including protocol and port. Like https://server:2525/rids
        request_per_series: bool, optional
            If true, split rad69 requests per series when downloading. If false,
            request all instances at once. Splitting reduces load on server.
            defaults to True.
        errors_to_ignore: List[Type], optional
            Errors of this type encountered during download are caught and skipped.
            Defaults to empty list, meaning any error is propagated
        use_async: bool, optional
            If True, download will split instances into chunks and download each
            chunk in a separate thread. If False, use single thread Defaults to False
        max_workers: int, optional
            Only used of use_async=True. Number of workers to use for multi-threading
        """

        self.session = session
        self.url = url

        # Number of bytes to read each time when streaming chunked rad69 responses.
        # Defaults to 5MB (5242880 bytes)
        self.http_chunk_size = 5242880

        if errors_to_ignore is None:
            errors_to_ignore = []
        self.errors_to_ignore = errors_to_ignore
        self.template = RAD69_SOAP_REQUEST_TEMPLATE
        self.post_headers = {"Content-Type": "application/soap+xml"}
        self.request_per_series = request_per_series
        self.use_async = use_async
        self.max_workers = max_workers

    def datasets(self, objects: Sequence[DICOMDownloadable]):
        """Retrieve all instances via rad69

        A Rad69 request typically contains multiple instances. The data for all
        instances is then streamed back as one multipart http response

        Raises
        ------
        NonInstanceParameterError
            If objects contain non-instance targets like a StudyInstanceUID.
            Rad69 can only download instances

        Returns
        -------
        Iterator[Dataset, None, None]
        """
        if self.use_async:
            yield from self.datasets_async(
                objects, max_workers=self.max_workers
            )
        else:
            yield from self.datasets_single_thread(objects)

    def datasets_single_thread(self, objects: Sequence[DICOMDownloadable]):
        """Retrieve all instances via rad69, without async

        A Rad69 request typically contains multiple instances. The data for all
        instances is then streamed back as one multipart http response

        Raises
        ------
        NonInstanceParameterError
            If objects contain non-instance targets like a StudyInstanceUID.
            Rad69 can only download instances

        Returns
        -------
        Iterator[Dataset, None, None]
        """
        instances = to_instance_refs(objects)  # raise exception if needed
        logger.info(f"Downloading {len(instances)} instances")
        if self.request_per_series:
            per_series: Dict[str, List[InstanceReference]] = defaultdict(list)
            for x in instances:
                per_series[x.series_uid].append(x)
            logger.info(
                f"Splitting per series. Found {len(per_series)} series"
            )
            return chain.from_iterable(
                self.series_download_iterator(x, index)
                for index, x in enumerate(per_series.values())
            )

        else:
            return self.download_iterator(instances)

    def datasets_async(
        self, objects: Sequence[DICOMDownloadable], max_workers
    ):
        """Split instances into chunks and retrieve each chunk in separate thread

        Parameters
        ----------
        objects: Sequence[DICOMDownloadable]
            Retrieve dataset for each instance in these objects
        max_workers: int
            Use this number of workers in ThreadPoolExecutor. Defaults to
            default for ThreadPoolExecutor

        Notes
        -----
        rad69 allows any number of slices to be combined in one request. The response
        is a chunked multi-part http response with all image data. Requesting each
        slice individually is inefficient. Requesting all slices in one thread might
        limit speed. Somewhere in the middle seems the best bet for optimal speed.
        This function splits all instances between the available workers and lets
        workers process the response streams.

        Raises
        ------
        DICOMTrolleyError
            When a server response cannot be parsed as DICOM

        Returns
        -------
        Iterator[Dataset, None, None]
        """
        instances = to_instance_refs(objects)  # raise exception if needed

        # max_workers=None means let the executor figure it out. But for rad69 we
        # still need to determine how many instances to retrieve at once with each
        # worker. Unlimited workers make no sense here. Just use a single thread.
        if max_workers is None:
            max_workers = 1

        with FuturesSession(
            session=self.session,
            executor=ThreadPoolExecutor(max_workers=max_workers),
        ) as futures_session:
            futures = []
            for instance_bin in self.split_instances(instances, max_workers):
                futures.append(
                    futures_session.post(
                        url=self.url,
                        headers=self.post_headers,
                        data=self.create_instances_request(instance_bin),
                    )
                )

            for future in as_completed(futures):
                yield from self.parse_rad69_response(future.result())

    def series_download_iterator(
        self, instances: Sequence[InstanceReference], index=0
    ):
        """Identical to create_download_iterator, except adds a debug log call"""
        if instances:
            logger.debug(
                f"Downloading series {index}: " f"{instances[0].series_uid}"
            )
        return self.download_iterator(instances)

    def download_iterator(self, instances: Sequence[InstanceReference]):
        """Perform a rad69 request and iterate over the returned datasets

        Returns
        -------
        Iterator[Dataset, None, None]
            All datasets included in the response
        """
        response = self.session.post(
            url=self.url,
            headers=self.post_headers,
            data=self.create_instances_request(instances),
            stream=True,
        )

        return self.parse_rad69_response(response)

    def create_instance_request(self, instance: InstanceReference):
        """Create the SOAP xml structure needed to request an instance from a rad69
        server
        """
        return self.create_instances_request(instances=[instance])

    def create_instances_request(self, instances: Sequence[InstanceReference]):
        """Create the SOAP xml structure to request all given instances from server"""
        # Turn instanceReference list back into study level
        tree = DICOMParseTree()
        for instance in instances:
            tree.insert(
                data=[],
                study_uid=instance.study_uid,
                series_uid=instance.series_uid,
                instance_uid=instance.instance_uid,
            )
        studies = tree.as_studies()

        return Template(self.template).render(
            uuid=str(uuid.uuid4()),
            studies=studies,
            transfer_syntax_list=[
                "1.2.840.10008.1.2.4.70",
                "1.2.840.10008.1.2",
                "1.2.840.10008.1.2.1",
            ],
        )

    def get_dataset(self, instance: InstanceReference):
        """Get DICOM dataset for the given instance (slice)

        Raises
        ------
        DICOMTrolleyError
            If getting does not work for some reason

        Note
        ----
        Getting single datasets is not efficient in rad69. If you want to loop
        this function, use Rad69.datasets() instead

        Returns
        -------
        Dataset
            A pydicom dataset
        """

        response = self.session.post(
            url=self.url,
            headers=self.post_headers,
            data=self.create_instance_request(instance),
        )
        return list(self.parse_rad69_response(response))[0]

    def verify_response(self, response):
        """Check for errors in rad69 response and handle them"""

    @staticmethod
    def check_for_response_errors(response):
        """Raise exceptions if this response is not a multi-part rad69 soap response.

        Parameters
        ----------
        response: response
            response as returned from a rad69 call

        Raises
        ------
        DICOMTrolleyError
            If response is not as expected
        """
        if response.status_code != 200:
            raise DICOMTrolleyError(
                f"Calling {response.url} failed ({response.status_code} - "
                f"{response.reason})\n"
                f"response content was {str(response.content[:300])}"
            )

        # check multipart
        if "Content-type" not in response.headers:
            raise DICOMTrolleyError(
                f"Expected multipart response, but got no content type for this"
                f" response. Start of response: {str(response.content[:300])}"
            )

        if "multipart" not in response.headers["Content-Type"].lower():
            # Maybe server is sending back a valid soap error document.
            Rad69.parse_rad69_soap_error_response(response)

    @staticmethod
    def parse_rad69_soap_error_response(response):
        """Interpret response as rad69 error, raise more helpful exceptions

        Raises
        ------
        XDSMissingDocumentError:
            If data for any requested id could not be found
        Rad69ServerError
            For any unspecified but valid rad69 error response
        DICOMTrolleyError
            if this is not a valid rad69 error response.

        Notes
        -----
        This just pragmatically interprets the first error returned and assumes the
        rest are the same. Not quite right but let's not overdo it.
        """

        tree = ElementTree.fromstring(response.text)
        errors = tree.findall(RAD69_SOAP_RESPONSE_ERROR_XPATH)
        if not errors:
            raise DICOMTrolleyError(
                f"Could not find any rad69 soap errors in "
                f"response: {response.content[:900]}"
            )

        error_code = errors[0].attrib.get("errorCode")
        error_text = (
            f"Server returns {len(errors)} errors. "
            f"First error: {str(errors[0].attrib)}"
        )
        if error_code == "XDSMissingDocument":
            raise XDSMissingDocumentError(error_text)
        else:
            raise Rad69ServerError(error_text)

    def parse_rad69_response(self, response):
        """Extract datasets out of http response from a rad69 server

        Raises
        ------
        DICOMTrolleyError
            If response is not as expected or if parsing fails

        Returns
        -------
        Iterator[Dataset, None, None]
            All datasets included in this response
        """
        logger.debug("Parsing rad69 response")
        try:
            self.check_for_response_errors(response)
        except DICOMTrolleyError as e:
            self.handle_response_error(e)  # might re-raise
            return None  # error not re-raised. Skip this response # noqa

        part_stream = HTTPMultiPartStream(
            response, stream_chunk_size=self.http_chunk_size
        )
        soap_part = None
        for part in part_stream:
            if not soap_part:
                logger.debug("Discarding initial rad69 soap part")
                soap_part = part  # skip soap part of the response
                continue
            dicom_bytes = part.content
            raw = DicomBytesIO(dicom_bytes)
            try:
                yield dcmread(raw)
            except InvalidDicomError as e:
                raise DICOMTrolleyError(
                    f"Error parsing response as dicom: {e}."
                    f" Response content (first 300 elements) was"
                    f" {str(response.content[:300])}"
                ) from e

    def handle_response_error(self, error):
        """Handle exceptions raised during rad69 request or download"""
        if any(issubclass(type(error), x) for x in self.errors_to_ignore):
            logger.warning(f"Ignoring error on ignore list: {str(error)}")
            return None
        else:
            raise error

    @staticmethod
    def split_instances(instances: Sequence[InstanceReference], num_bins):
        """Split the given instance references into even piles"""
        bin_size = math.ceil(len(instances) / num_bins)
        for i in range(0, len(instances), bin_size):
            yield instances[i : i + bin_size]

__init__(session, url, request_per_series=True, errors_to_ignore=None, use_async=False, max_workers=4)

Parameters

session: requests.session A logged in session over which rad69 calls can be made url: str rad69 endpoint, including protocol and port. Like https://server:2525/rids request_per_series: bool, optional If true, split rad69 requests per series when downloading. If false, request all instances at once. Splitting reduces load on server. defaults to True. errors_to_ignore: List[Type], optional Errors of this type encountered during download are caught and skipped. Defaults to empty list, meaning any error is propagated use_async: bool, optional If True, download will split instances into chunks and download each chunk in a separate thread. If False, use single thread Defaults to False max_workers: int, optional Only used of use_async=True. Number of workers to use for multi-threading

Source code in dicomtrolley/rad69.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def __init__(
    self,
    session,
    url,
    request_per_series=True,
    errors_to_ignore=None,
    use_async=False,
    max_workers=4,
):
    """
    Parameters
    ----------
    session: requests.session
        A logged in session over which rad69 calls can be made
    url: str
        rad69 endpoint, including protocol and port. Like https://server:2525/rids
    request_per_series: bool, optional
        If true, split rad69 requests per series when downloading. If false,
        request all instances at once. Splitting reduces load on server.
        defaults to True.
    errors_to_ignore: List[Type], optional
        Errors of this type encountered during download are caught and skipped.
        Defaults to empty list, meaning any error is propagated
    use_async: bool, optional
        If True, download will split instances into chunks and download each
        chunk in a separate thread. If False, use single thread Defaults to False
    max_workers: int, optional
        Only used of use_async=True. Number of workers to use for multi-threading
    """

    self.session = session
    self.url = url

    # Number of bytes to read each time when streaming chunked rad69 responses.
    # Defaults to 5MB (5242880 bytes)
    self.http_chunk_size = 5242880

    if errors_to_ignore is None:
        errors_to_ignore = []
    self.errors_to_ignore = errors_to_ignore
    self.template = RAD69_SOAP_REQUEST_TEMPLATE
    self.post_headers = {"Content-Type": "application/soap+xml"}
    self.request_per_series = request_per_series
    self.use_async = use_async
    self.max_workers = max_workers

check_for_response_errors(response) staticmethod

Raise exceptions if this response is not a multi-part rad69 soap response.

Parameters

response: response response as returned from a rad69 call

Raises

DICOMTrolleyError If response is not as expected

Source code in dicomtrolley/rad69.py
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
@staticmethod
def check_for_response_errors(response):
    """Raise exceptions if this response is not a multi-part rad69 soap response.

    Parameters
    ----------
    response: response
        response as returned from a rad69 call

    Raises
    ------
    DICOMTrolleyError
        If response is not as expected
    """
    if response.status_code != 200:
        raise DICOMTrolleyError(
            f"Calling {response.url} failed ({response.status_code} - "
            f"{response.reason})\n"
            f"response content was {str(response.content[:300])}"
        )

    # check multipart
    if "Content-type" not in response.headers:
        raise DICOMTrolleyError(
            f"Expected multipart response, but got no content type for this"
            f" response. Start of response: {str(response.content[:300])}"
        )

    if "multipart" not in response.headers["Content-Type"].lower():
        # Maybe server is sending back a valid soap error document.
        Rad69.parse_rad69_soap_error_response(response)

create_instance_request(instance)

Create the SOAP xml structure needed to request an instance from a rad69 server

Source code in dicomtrolley/rad69.py
229
230
231
232
233
def create_instance_request(self, instance: InstanceReference):
    """Create the SOAP xml structure needed to request an instance from a rad69
    server
    """
    return self.create_instances_request(instances=[instance])

create_instances_request(instances)

Create the SOAP xml structure to request all given instances from server

Source code in dicomtrolley/rad69.py
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
def create_instances_request(self, instances: Sequence[InstanceReference]):
    """Create the SOAP xml structure to request all given instances from server"""
    # Turn instanceReference list back into study level
    tree = DICOMParseTree()
    for instance in instances:
        tree.insert(
            data=[],
            study_uid=instance.study_uid,
            series_uid=instance.series_uid,
            instance_uid=instance.instance_uid,
        )
    studies = tree.as_studies()

    return Template(self.template).render(
        uuid=str(uuid.uuid4()),
        studies=studies,
        transfer_syntax_list=[
            "1.2.840.10008.1.2.4.70",
            "1.2.840.10008.1.2",
            "1.2.840.10008.1.2.1",
        ],
    )

datasets(objects)

Retrieve all instances via rad69

A Rad69 request typically contains multiple instances. The data for all instances is then streamed back as one multipart http response

Raises

NonInstanceParameterError If objects contain non-instance targets like a StudyInstanceUID. Rad69 can only download instances

Returns

Iterator[Dataset, None, None]

Source code in dicomtrolley/rad69.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def datasets(self, objects: Sequence[DICOMDownloadable]):
    """Retrieve all instances via rad69

    A Rad69 request typically contains multiple instances. The data for all
    instances is then streamed back as one multipart http response

    Raises
    ------
    NonInstanceParameterError
        If objects contain non-instance targets like a StudyInstanceUID.
        Rad69 can only download instances

    Returns
    -------
    Iterator[Dataset, None, None]
    """
    if self.use_async:
        yield from self.datasets_async(
            objects, max_workers=self.max_workers
        )
    else:
        yield from self.datasets_single_thread(objects)

datasets_async(objects, max_workers)

Split instances into chunks and retrieve each chunk in separate thread

Parameters

objects: Sequence[DICOMDownloadable] Retrieve dataset for each instance in these objects max_workers: int Use this number of workers in ThreadPoolExecutor. Defaults to default for ThreadPoolExecutor

Notes

rad69 allows any number of slices to be combined in one request. The response is a chunked multi-part http response with all image data. Requesting each slice individually is inefficient. Requesting all slices in one thread might limit speed. Somewhere in the middle seems the best bet for optimal speed. This function splits all instances between the available workers and lets workers process the response streams.

Raises

DICOMTrolleyError When a server response cannot be parsed as DICOM

Returns

Iterator[Dataset, None, None]

Source code in dicomtrolley/rad69.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
def datasets_async(
    self, objects: Sequence[DICOMDownloadable], max_workers
):
    """Split instances into chunks and retrieve each chunk in separate thread

    Parameters
    ----------
    objects: Sequence[DICOMDownloadable]
        Retrieve dataset for each instance in these objects
    max_workers: int
        Use this number of workers in ThreadPoolExecutor. Defaults to
        default for ThreadPoolExecutor

    Notes
    -----
    rad69 allows any number of slices to be combined in one request. The response
    is a chunked multi-part http response with all image data. Requesting each
    slice individually is inefficient. Requesting all slices in one thread might
    limit speed. Somewhere in the middle seems the best bet for optimal speed.
    This function splits all instances between the available workers and lets
    workers process the response streams.

    Raises
    ------
    DICOMTrolleyError
        When a server response cannot be parsed as DICOM

    Returns
    -------
    Iterator[Dataset, None, None]
    """
    instances = to_instance_refs(objects)  # raise exception if needed

    # max_workers=None means let the executor figure it out. But for rad69 we
    # still need to determine how many instances to retrieve at once with each
    # worker. Unlimited workers make no sense here. Just use a single thread.
    if max_workers is None:
        max_workers = 1

    with FuturesSession(
        session=self.session,
        executor=ThreadPoolExecutor(max_workers=max_workers),
    ) as futures_session:
        futures = []
        for instance_bin in self.split_instances(instances, max_workers):
            futures.append(
                futures_session.post(
                    url=self.url,
                    headers=self.post_headers,
                    data=self.create_instances_request(instance_bin),
                )
            )

        for future in as_completed(futures):
            yield from self.parse_rad69_response(future.result())

datasets_single_thread(objects)

Retrieve all instances via rad69, without async

A Rad69 request typically contains multiple instances. The data for all instances is then streamed back as one multipart http response

Raises

NonInstanceParameterError If objects contain non-instance targets like a StudyInstanceUID. Rad69 can only download instances

Returns

Iterator[Dataset, None, None]

Source code in dicomtrolley/rad69.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
def datasets_single_thread(self, objects: Sequence[DICOMDownloadable]):
    """Retrieve all instances via rad69, without async

    A Rad69 request typically contains multiple instances. The data for all
    instances is then streamed back as one multipart http response

    Raises
    ------
    NonInstanceParameterError
        If objects contain non-instance targets like a StudyInstanceUID.
        Rad69 can only download instances

    Returns
    -------
    Iterator[Dataset, None, None]
    """
    instances = to_instance_refs(objects)  # raise exception if needed
    logger.info(f"Downloading {len(instances)} instances")
    if self.request_per_series:
        per_series: Dict[str, List[InstanceReference]] = defaultdict(list)
        for x in instances:
            per_series[x.series_uid].append(x)
        logger.info(
            f"Splitting per series. Found {len(per_series)} series"
        )
        return chain.from_iterable(
            self.series_download_iterator(x, index)
            for index, x in enumerate(per_series.values())
        )

    else:
        return self.download_iterator(instances)

download_iterator(instances)

Perform a rad69 request and iterate over the returned datasets

Returns

Iterator[Dataset, None, None] All datasets included in the response

Source code in dicomtrolley/rad69.py
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
def download_iterator(self, instances: Sequence[InstanceReference]):
    """Perform a rad69 request and iterate over the returned datasets

    Returns
    -------
    Iterator[Dataset, None, None]
        All datasets included in the response
    """
    response = self.session.post(
        url=self.url,
        headers=self.post_headers,
        data=self.create_instances_request(instances),
        stream=True,
    )

    return self.parse_rad69_response(response)

get_dataset(instance)

Get DICOM dataset for the given instance (slice)

Raises

DICOMTrolleyError If getting does not work for some reason

Note

Getting single datasets is not efficient in rad69. If you want to loop this function, use Rad69.datasets() instead

Returns

Dataset A pydicom dataset

Source code in dicomtrolley/rad69.py
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
def get_dataset(self, instance: InstanceReference):
    """Get DICOM dataset for the given instance (slice)

    Raises
    ------
    DICOMTrolleyError
        If getting does not work for some reason

    Note
    ----
    Getting single datasets is not efficient in rad69. If you want to loop
    this function, use Rad69.datasets() instead

    Returns
    -------
    Dataset
        A pydicom dataset
    """

    response = self.session.post(
        url=self.url,
        headers=self.post_headers,
        data=self.create_instance_request(instance),
    )
    return list(self.parse_rad69_response(response))[0]

handle_response_error(error)

Handle exceptions raised during rad69 request or download

Source code in dicomtrolley/rad69.py
396
397
398
399
400
401
402
def handle_response_error(self, error):
    """Handle exceptions raised during rad69 request or download"""
    if any(issubclass(type(error), x) for x in self.errors_to_ignore):
        logger.warning(f"Ignoring error on ignore list: {str(error)}")
        return None
    else:
        raise error

parse_rad69_response(response)

Extract datasets out of http response from a rad69 server

Raises

DICOMTrolleyError If response is not as expected or if parsing fails

Returns

Iterator[Dataset, None, None] All datasets included in this response

Source code in dicomtrolley/rad69.py
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
def parse_rad69_response(self, response):
    """Extract datasets out of http response from a rad69 server

    Raises
    ------
    DICOMTrolleyError
        If response is not as expected or if parsing fails

    Returns
    -------
    Iterator[Dataset, None, None]
        All datasets included in this response
    """
    logger.debug("Parsing rad69 response")
    try:
        self.check_for_response_errors(response)
    except DICOMTrolleyError as e:
        self.handle_response_error(e)  # might re-raise
        return None  # error not re-raised. Skip this response # noqa

    part_stream = HTTPMultiPartStream(
        response, stream_chunk_size=self.http_chunk_size
    )
    soap_part = None
    for part in part_stream:
        if not soap_part:
            logger.debug("Discarding initial rad69 soap part")
            soap_part = part  # skip soap part of the response
            continue
        dicom_bytes = part.content
        raw = DicomBytesIO(dicom_bytes)
        try:
            yield dcmread(raw)
        except InvalidDicomError as e:
            raise DICOMTrolleyError(
                f"Error parsing response as dicom: {e}."
                f" Response content (first 300 elements) was"
                f" {str(response.content[:300])}"
            ) from e

parse_rad69_soap_error_response(response) staticmethod

Interpret response as rad69 error, raise more helpful exceptions

Raises

XDSMissingDocumentError: If data for any requested id could not be found Rad69ServerError For any unspecified but valid rad69 error response DICOMTrolleyError if this is not a valid rad69 error response.

Notes

This just pragmatically interprets the first error returned and assumes the rest are the same. Not quite right but let's not overdo it.

Source code in dicomtrolley/rad69.py
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
@staticmethod
def parse_rad69_soap_error_response(response):
    """Interpret response as rad69 error, raise more helpful exceptions

    Raises
    ------
    XDSMissingDocumentError:
        If data for any requested id could not be found
    Rad69ServerError
        For any unspecified but valid rad69 error response
    DICOMTrolleyError
        if this is not a valid rad69 error response.

    Notes
    -----
    This just pragmatically interprets the first error returned and assumes the
    rest are the same. Not quite right but let's not overdo it.
    """

    tree = ElementTree.fromstring(response.text)
    errors = tree.findall(RAD69_SOAP_RESPONSE_ERROR_XPATH)
    if not errors:
        raise DICOMTrolleyError(
            f"Could not find any rad69 soap errors in "
            f"response: {response.content[:900]}"
        )

    error_code = errors[0].attrib.get("errorCode")
    error_text = (
        f"Server returns {len(errors)} errors. "
        f"First error: {str(errors[0].attrib)}"
    )
    if error_code == "XDSMissingDocument":
        raise XDSMissingDocumentError(error_text)
    else:
        raise Rad69ServerError(error_text)

series_download_iterator(instances, index=0)

Identical to create_download_iterator, except adds a debug log call

Source code in dicomtrolley/rad69.py
202
203
204
205
206
207
208
209
210
def series_download_iterator(
    self, instances: Sequence[InstanceReference], index=0
):
    """Identical to create_download_iterator, except adds a debug log call"""
    if instances:
        logger.debug(
            f"Downloading series {index}: " f"{instances[0].series_uid}"
        )
    return self.download_iterator(instances)

split_instances(instances, num_bins) staticmethod

Split the given instance references into even piles

Source code in dicomtrolley/rad69.py
404
405
406
407
408
409
@staticmethod
def split_instances(instances: Sequence[InstanceReference], num_bins):
    """Split the given instance references into even piles"""
    bin_size = math.ceil(len(instances) / num_bins)
    for i in range(0, len(instances), bin_size):
        yield instances[i : i + bin_size]

verify_response(response)

Check for errors in rad69 response and handle them

Source code in dicomtrolley/rad69.py
284
285
def verify_response(self, response):
    """Check for errors in rad69 response and handle them"""

Rad69ServerError

Bases: DICOMTrolleyError

Represents a valid error response from a rad69 server

Source code in dicomtrolley/rad69.py
412
413
414
415
class Rad69ServerError(DICOMTrolleyError):
    """Represents a valid error response from a rad69 server"""

    pass

XDSMissingDocumentError

Bases: Rad69ServerError

Some requested ID could not be found on the server

Source code in dicomtrolley/rad69.py
418
419
420
421
class XDSMissingDocumentError(Rad69ServerError):
    """Some requested ID could not be found on the server"""

    pass