diff --git a/src/dicomweb_client/web.py b/src/dicomweb_client/web.py index 6876ec4..3da75f4 100644 --- a/src/dicomweb_client/web.py +++ b/src/dicomweb_client/web.py @@ -21,7 +21,7 @@ Union, Tuple, ) -from urllib.parse import urlparse +from urllib.parse import urlencode, urlparse from warnings import warn from xml.etree.ElementTree import ( Element, @@ -1628,7 +1628,8 @@ def search_for_studies( offset: Optional[int] = None, fields: Optional[Sequence[str]] = None, search_filters: Optional[Dict[str, Any]] = None, - get_remaining: bool = False + get_remaining: bool = False, + additional_params: Optional[Dict[str, Any]] = None ) -> List[Dict[str, dict]]: """Search for studies. @@ -1649,6 +1650,8 @@ def search_for_studies( get_remaining: bool, optional Whether remaining results should be included (this may repeatedly query the server for remaining results) + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP GET query parameters to include in the request. Returns ------- @@ -1658,10 +1661,12 @@ def search_for_studies( Note ---- - The server may only return a subset of search results. In this case, + - The server may only return a subset of search results. In this case, a warning will notify the client that there are remaining results. Remaining results can be requested via repeated calls using the `offset` parameter. + - If `additional_params` is provided, it will be merged into the standard query parameters, + with its values overwriting any existing keys if duplicates are present. """ # noqa: E501: E501 logger.info('search for studies') @@ -1673,6 +1678,8 @@ def search_for_studies( fields=fields, search_filters=search_filters ) + if additional_params: + params.update(additional_params) return self._http_get_application_json( url, params=params, @@ -1795,6 +1802,7 @@ def _get_bulkdata( media_types: Optional[Tuple[Union[str, Tuple[str, str]], ...]] = None, byte_range: Optional[Tuple[int, int]] = None, stream: bool = False, + additional_params: Optional[Dict[str, Any]] = None ) -> Iterator[bytes]: """Get bulk data items at a given location. @@ -1810,6 +1818,8 @@ def _get_bulkdata( stream: bool, optional Whether data should be streamed (i.e., requested using chunked transfer encoding) + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP GET query parameters Returns ------- @@ -1819,26 +1829,45 @@ def _get_bulkdata( """ # noqa: E501 if media_types is None: return self._http_get_multipart( - url, media_types, byte_range=byte_range, stream=stream + url, + media_types, + byte_range=byte_range, + params=additional_params, + stream=stream, ) common_media_types = self._get_common_media_types(media_types) if len(common_media_types) > 1: return self._http_get_multipart( - url, media_types, byte_range=byte_range, stream=stream + url, media_types, + byte_range=byte_range, + params=additional_params, + stream=stream, ) else: common_media_type = common_media_types[0] if common_media_type == 'application/octet-stream': return self._http_get_multipart_application_octet_stream( - url, media_types, byte_range=byte_range, stream=stream + url, + media_types, + byte_range=byte_range, + params=additional_params, + stream=stream ) elif common_media_type.startswith('image'): return self._http_get_multipart_image( - url, media_types, byte_range=byte_range, stream=stream + url, + media_types, + byte_range=byte_range, + params=additional_params, + stream=stream ) elif common_media_type.startswith('video'): return self._http_get_multipart_video( - url, media_types, byte_range=byte_range, stream=stream + url, + media_types, + byte_range=byte_range, + params=additional_params, + stream=stream ) else: raise ValueError( @@ -1850,7 +1879,8 @@ def retrieve_bulkdata( self, url: str, media_types: Optional[Tuple[Union[str, Tuple[str, str]], ...]] = None, - byte_range: Optional[Tuple[int, int]] = None + byte_range: Optional[Tuple[int, int]] = None, + additional_params: Optional[Dict[str, Any]] = None, ) -> List[bytes]: """Retrieve bulk data at a given location. @@ -1863,6 +1893,8 @@ def retrieve_bulkdata( corresponding transfer syntaxes byte_range: Union[Tuple[int, int], None], optional Start and end of byte range + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP GET query parameters Returns ------- @@ -1875,7 +1907,8 @@ def retrieve_bulkdata( url=url, media_types=media_types, byte_range=byte_range, - stream=False + stream=False, + additional_params=additional_params, ) ) @@ -1883,7 +1916,8 @@ def iter_bulkdata( self, url: str, media_types: Optional[Tuple[Union[str, Tuple[str, str]], ...]] = None, - byte_range: Optional[Tuple[int, int]] = None + byte_range: Optional[Tuple[int, int]] = None, + additional_params: Optional[Dict[str, Any]] = None, ) -> Iterator[bytes]: """Iterate over bulk data items at a given location. @@ -1896,6 +1930,8 @@ def iter_bulkdata( corresponding transfer syntaxes byte_range: Union[Tuple[int, int], None], optional Start and end of byte range + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP GET query parameters Returns ------- @@ -1911,14 +1947,16 @@ def iter_bulkdata( url=url, media_types=media_types, byte_range=byte_range, - stream=True + stream=True, + additional_params=additional_params, ) def _get_study( self, study_instance_uid: str, media_types: Optional[Tuple[Union[str, Tuple[str, str]], ...]] = None, - stream: bool = False + stream: bool = False, + additional_params: Optional[Dict[str, Any]] = None ) -> Iterator[pydicom.dataset.Dataset]: """Get all instances of a study. @@ -1932,6 +1970,8 @@ def _get_study( stream: bool, optional Whether data should be streamed (i.e., requested using chunked transfer encoding) + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP GET query parameters Returns ------- @@ -1947,6 +1987,7 @@ def _get_study( if media_types is None: return self._http_get_multipart_application_dicom( url, + params=additional_params, stream=stream ) common_media_types = self._get_common_media_types(media_types) @@ -1964,6 +2005,7 @@ def _get_study( return self._http_get_multipart_application_dicom( url, media_types=media_types, + params=additional_params, stream=stream ) @@ -1971,6 +2013,7 @@ def retrieve_study( self, study_instance_uid: str, media_types: Optional[Tuple[Union[str, Tuple[str, str]], ...]] = None, + additional_params: Optional[Dict[str, Any]] = None ) -> List[pydicom.dataset.Dataset]: """Retrieve all instances of a study. @@ -1981,6 +2024,8 @@ def retrieve_study( media_types: Union[Tuple[Union[str, Tuple[str, str]], ...], None], optional Acceptable media types and optionally the UIDs of the acceptable transfer syntaxes + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP GET query parameters Returns ------- @@ -2002,7 +2047,8 @@ def retrieve_study( self._get_study( study_instance_uid=study_instance_uid, media_types=media_types, - stream=False + stream=False, + additional_params=additional_params ) ) @@ -2010,6 +2056,7 @@ def iter_study( self, study_instance_uid: str, media_types: Optional[Tuple[Union[str, Tuple[str, str]], ...]] = None, + additional_params: Optional[Dict[str, Any]] = None ) -> Iterator[pydicom.dataset.Dataset]: """Iterate over all instances of a study. @@ -2020,6 +2067,8 @@ def iter_study( media_types: Union[Tuple[Union[str, Tuple[str, str]], ...], None], optional Acceptable media types and optionally the UIDs of the acceptable transfer syntaxes + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP GET query parameters Returns ------- @@ -2044,12 +2093,14 @@ def iter_study( return self._get_study( study_instance_uid=study_instance_uid, media_types=media_types, - stream=True + stream=True, + additional_params=additional_params ) def retrieve_study_metadata( self, - study_instance_uid: str + study_instance_uid: str, + additional_params: Optional[Dict[str, Any]] = None ) -> List[Dict[str, dict]]: """Retrieve metadata of all instances of a study. @@ -2057,6 +2108,8 @@ def retrieve_study_metadata( ---------- study_instance_uid: str Study Instance UID + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP GET query parameters Returns ------- @@ -2071,15 +2124,21 @@ def retrieve_study_metadata( ) url = self._get_studies_url(_Transaction.RETRIEVE, study_instance_uid) url += '/metadata' - return self._http_get_application_json(url) + return self._http_get_application_json(url, params=additional_params) - def delete_study(self, study_instance_uid: str) -> None: + def delete_study( + self, + study_instance_uid: str, + additional_params: Optional[Dict[str, Any]] = None + ) -> None: """Delete all instances of a study. Parameters ---------- study_instance_uid: str Study Instance UID + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP DELETE query parameters Note ---- @@ -2096,6 +2155,12 @@ def delete_study(self, study_instance_uid: str) -> None: 'Study Instance UID is required for deletion of a study.' ) url = self._get_studies_url(_Transaction.DELETE, study_instance_uid) + # Append query string if additional_params is provided + if additional_params: + additional_params_query_string = urlencode( + additional_params, doseq=True + ) + url += f'?{additional_params_query_string}' self._http_delete(url) def _assert_uid_format(self, uid: str) -> None: @@ -2129,7 +2194,8 @@ def search_for_series( offset: Optional[int] = None, fields: Optional[Sequence[str]] = None, search_filters: Optional[Dict[str, Any]] = None, - get_remaining: bool = False + get_remaining: bool = False, + additional_params: Optional[Dict[str, Any]] = None ) -> List[Dict[str, dict]]: """Search for series. @@ -2152,6 +2218,8 @@ def search_for_series( get_remaining: bool, optional Whether remaining results should be included (this may repeatedly query the server for remaining results) + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP GET query parameters to include in the request. Returns ------- @@ -2161,10 +2229,12 @@ def search_for_series( Note ---- - The server may only return a subset of search results. In this case, + - The server may only return a subset of search results. In this case, a warning will notify the client that there are remaining results. Remaining results can be requested via repeated calls using the `offset` parameter. + - If `additional_params` is provided, it will be merged into the standard query parameters, + with its values overwriting any existing keys if duplicates are present. """ # noqa: E501 if study_instance_uid is not None: @@ -2180,6 +2250,8 @@ def search_for_series( fields=fields, search_filters=search_filters ) + if additional_params: + params.update(additional_params) return self._http_get_application_json( url, params=params, @@ -2191,7 +2263,8 @@ def _get_series( study_instance_uid: str, series_instance_uid: str, media_types: Optional[Tuple[Union[str, Tuple[str, str]], ...]] = None, - stream: bool = False + stream: bool = False, + additional_params: Optional[Dict[str, Any]] = None ) -> Iterator[pydicom.dataset.Dataset]: """Get instances of a series. @@ -2207,6 +2280,8 @@ def _get_series( stream: bool, optional Whether data should be streamed (i.e., requested using chunked transfer encoding) + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP GET query parameters Returns ------- @@ -2236,6 +2311,7 @@ def _get_series( if media_types is None: return self._http_get_multipart_application_dicom( url, + params=additional_params, stream=stream ) common_media_types = self._get_common_media_types(media_types) @@ -2253,6 +2329,7 @@ def _get_series( return self._http_get_multipart_application_dicom( url, media_types=media_types, + params=additional_params, stream=stream ) @@ -2260,7 +2337,8 @@ def retrieve_series( self, study_instance_uid: str, series_instance_uid: str, - media_types: Optional[Tuple[Union[str, Tuple[str, str]], ...]] = None + media_types: Optional[Tuple[Union[str, Tuple[str, str]], ...]] = None, + additional_params: Optional[Dict[str, Any]] = None ) -> List[pydicom.dataset.Dataset]: """Retrieve all instances of a series. @@ -2273,6 +2351,8 @@ def retrieve_series( media_types: Union[Tuple[Union[str, Tuple[str, str]], ...], None], optional Acceptable media types and optionally the UIDs of the acceptable transfer syntaxes + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP GET query parameters Returns ------- @@ -2295,7 +2375,8 @@ def retrieve_series( study_instance_uid=study_instance_uid, series_instance_uid=series_instance_uid, media_types=media_types, - stream=False + stream=False, + additional_params=additional_params ) ) @@ -2303,7 +2384,8 @@ def iter_series( self, study_instance_uid: str, series_instance_uid: str, - media_types: Optional[Tuple[Union[str, Tuple[str, str]], ...]] = None + media_types: Optional[Tuple[Union[str, Tuple[str, str]], ...]] = None, + additional_params: Optional[Dict[str, Any]] = None ) -> Iterator[pydicom.dataset.Dataset]: """Iterate over all instances of a series. @@ -2316,6 +2398,8 @@ def iter_series( media_types: Union[Tuple[Union[str, Tuple[str, str]], ...], None], optional Acceptable media types and optionally the UIDs of the acceptable transfer syntaxes + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP GET query parameters Returns ------- @@ -2341,13 +2425,15 @@ def iter_series( study_instance_uid=study_instance_uid, series_instance_uid=series_instance_uid, media_types=media_types, - stream=True + stream=True, + additional_params=additional_params ) def retrieve_series_metadata( self, study_instance_uid: str, series_instance_uid: str, + additional_params: Optional[Dict[str, Any]] = None ) -> List[Dict[str, dict]]: """Retrieve metadata for all instances of a series. @@ -2357,6 +2443,8 @@ def retrieve_series_metadata( Study Instance UID series_instance_uid: str Series Instance UID + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP GET query parameters Returns ------- @@ -2386,7 +2474,7 @@ def retrieve_series_metadata( series_instance_uid ) url += '/metadata' - return self._http_get_application_json(url) + return self._http_get_application_json(url, params=additional_params) def retrieve_series_rendered( self, study_instance_uid, @@ -2465,7 +2553,8 @@ def retrieve_series_rendered( def delete_series( self, study_instance_uid: str, - series_instance_uid: str + series_instance_uid: str, + additional_params: Optional[Dict[str, Any]] = None ) -> None: """Delete all instances of a series. @@ -2475,6 +2564,8 @@ def delete_series( Study Instance UID series_instance_uid: str Series Instance UID + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP DELETE query parameters Note ---- @@ -2503,6 +2594,12 @@ def delete_series( study_instance_uid, series_instance_uid ) + # Append query string if additional_params is provided + if additional_params: + additional_params_query_string = urlencode( + additional_params, doseq=True + ) + url += f'?{additional_params_query_string}' self._http_delete(url) def search_for_instances( @@ -2514,7 +2611,8 @@ def search_for_instances( offset: Optional[int] = None, fields: Optional[Sequence[str]] = None, search_filters: Optional[Dict[str, Any]] = None, - get_remaining: bool = False + get_remaining: bool = False, + additional_params: Optional[Dict[str, Any]] = None ) -> List[Dict[str, dict]]: """Search for instances. @@ -2539,6 +2637,8 @@ def search_for_instances( get_remaining: bool, optional Whether remaining results should be included (this may repeatedly query the server for remaining results) + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP GET query parameters to include in the request. Returns ------- @@ -2548,10 +2648,12 @@ def search_for_instances( Note ---- - The server may only return a subset of search results. In this case, + - The server may only return a subset of search results. In this case, a warning will notify the client that there are remaining results. Remaining results can be requested via repeated calls using the `offset` parameter. + - If `additional_params` is provided, it will be merged into the standard query parameters, + with its values overwriting any existing keys if duplicates are present. """ # noqa: E501 message = 'search for instances' @@ -2573,6 +2675,8 @@ def search_for_instances( fields=fields, search_filters=search_filters ) + if additional_params: + params.update(additional_params) return self._http_get_application_json( url, params=params, @@ -2585,6 +2689,7 @@ def retrieve_instance( series_instance_uid: str, sop_instance_uid: str, media_types: Optional[Tuple[Union[str, Tuple[str, str]], ...]] = None, + additional_params: Optional[Dict[str, Any]] = None ) -> pydicom.dataset.Dataset: """Retrieve an individual instance. @@ -2599,6 +2704,8 @@ def retrieve_instance( media_types: Union[Tuple[Union[str, Tuple[str, str]], ...], None], optional Acceptable media types and optionally the UIDs of the acceptable transfer syntaxes + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP GET query parameters Returns ------- @@ -2657,7 +2764,11 @@ def retrieve_instance( f'Media type "{common_media_type}" is not supported for ' 'retrieval of an instance. It must be "application/dicom".' ) - iterator = self._http_get_multipart_application_dicom(url, media_types) + iterator = self._http_get_multipart_application_dicom( + url, + media_types=media_types, + params=additional_params + ) instances = list(iterator) if len(instances) > 1: # This should not occur, but safety first. @@ -2667,7 +2778,8 @@ def retrieve_instance( def store_instances( self, datasets: Sequence[pydicom.dataset.Dataset], - study_instance_uid: Optional[str] = None + study_instance_uid: Optional[str] = None, + additional_params: Optional[Dict[str, Any]] = None ) -> pydicom.dataset.Dataset: """Store instances. @@ -2677,6 +2789,8 @@ def store_instances( Instances that should be stored study_instance_uid: Union[str, None], optional Study Instance UID + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP POST query parameters Returns ------- @@ -2696,6 +2810,12 @@ def _iter_encoded_datasets(datasets): message += f' of study "{study_instance_uid}"' logger.info(message) url = self._get_studies_url(_Transaction.STORE, study_instance_uid) + # Append query string if additional_params is provided + if additional_params: + additional_params_query_string = urlencode( + additional_params, doseq=True + ) + url += f'?{additional_params_query_string}' encoded_datasets = _iter_encoded_datasets(datasets) return self._http_post_multipart_application_dicom( url, @@ -2706,7 +2826,8 @@ def delete_instance( self, study_instance_uid: str, series_instance_uid: str, - sop_instance_uid: str + sop_instance_uid: str, + additional_params: Optional[Dict[str, Any]] = None ) -> None: """Delete specified instance. @@ -2718,6 +2839,8 @@ def delete_instance( Series Instance UID sop_instance_uid: str SOP Instance UID + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP DELETE query parameters Note ---- @@ -2747,13 +2870,20 @@ def delete_instance( series_instance_uid, sop_instance_uid ) + # Append query string if additional_params is provided + if additional_params: + additional_params_query_string = urlencode( + additional_params, doseq=True + ) + url += f'?{additional_params_query_string}' self._http_delete(url) def retrieve_instance_metadata( self, study_instance_uid: str, series_instance_uid: str, - sop_instance_uid: str + sop_instance_uid: str, + additional_params: Optional[Dict[str, Any]] = None ) -> Dict[str, dict]: """Retrieve metadata of an individual instance. @@ -2765,6 +2895,8 @@ def retrieve_instance_metadata( Series Instance UID sop_instance_uid: str SOP Instance UID + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP GET query parameters Returns ------- @@ -2794,7 +2926,7 @@ def retrieve_instance_metadata( sop_instance_uid ) url += '/metadata' - return self._http_get_application_json(url)[0] + return self._http_get_application_json(url, params=additional_params)[0] def retrieve_instance_rendered( self, @@ -2884,7 +3016,8 @@ def _get_instance_frames( sop_instance_uid: str, frame_numbers: Sequence[int], media_types: Optional[Tuple[Union[str, Tuple[str, str]], ...]] = None, - stream: bool = False + stream: bool = False, + additional_params: Optional[Dict[str, Any]] = None ) -> Iterator[bytes]: """Get frames of an instance. @@ -2904,6 +3037,8 @@ def _get_instance_frames( stream: bool, optional Whether data should be streamed (i.e., requested using chunked transfer encoding) + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP GET query parameters Returns ------- @@ -2932,14 +3067,19 @@ def _get_instance_frames( frame_list = ','.join([str(n) for n in frame_numbers]) url += f'/frames/{frame_list}' if media_types is None: - return self._http_get_multipart(url, stream=stream) + return self._http_get_multipart( + url, + stream=stream, + params=additional_params + ) common_media_types = self._get_common_media_types(media_types) if len(common_media_types) > 1: return self._http_get_multipart( url, media_types=media_types, - stream=stream + stream=stream, + params=additional_params ) common_media_type = common_media_types[0] @@ -2947,25 +3087,29 @@ def _get_instance_frames( return self._http_get_multipart_application_octet_stream( url, media_types=media_types, - stream=stream + stream=stream, + params=additional_params ) elif common_media_type.startswith('image'): return self._http_get_multipart_image( url, media_types=media_types, - stream=stream + stream=stream, + params=additional_params ) elif common_media_type.startswith('video'): return self._http_get_multipart_video( url, media_types=media_types, - stream=stream + stream=stream, + params=additional_params ) elif common_media_type.startswith('*'): return self._http_get_multipart( url, media_types=media_types, - stream=stream + stream=stream, + params=additional_params ) else: raise ValueError( @@ -2979,7 +3123,8 @@ def retrieve_instance_frames( series_instance_uid: str, sop_instance_uid: str, frame_numbers: Sequence[int], - media_types: Optional[Tuple[Union[str, Tuple[str, str]], ...]] = None + media_types: Optional[Tuple[Union[str, Tuple[str, str]], ...]] = None, + additional_params: Optional[Dict[str, Any]] = None ) -> List[bytes]: """Retrieve one or more frames of an image instance. @@ -2996,6 +3141,8 @@ def retrieve_instance_frames( media_types: Union[Tuple[Union[str, Tuple[str, str]], ...], None], optional Acceptable media types and optionally the UIDs of the corresponding transfer syntaxes + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP GET query parameters Returns ------- @@ -3010,7 +3157,8 @@ def retrieve_instance_frames( sop_instance_uid=sop_instance_uid, frame_numbers=frame_numbers, media_types=media_types, - stream=False + stream=False, + additional_params=additional_params ) ) @@ -3020,7 +3168,8 @@ def iter_instance_frames( series_instance_uid: str, sop_instance_uid: str, frame_numbers: Sequence[int], - media_types: Optional[Tuple[Union[str, Tuple[str, str]], ...]] = None + media_types: Optional[Tuple[Union[str, Tuple[str, str]], ...]] = None, + additional_params: Optional[Dict[str, Any]] = None ) -> Iterator[bytes]: """Iterate over frames of an image instance. @@ -3037,6 +3186,8 @@ def iter_instance_frames( media_types: Union[Tuple[Union[str, Tuple[str, str]], ...], None], optional Acceptable media types and optionally the UIDs of the corresponding transfer syntaxes + additional_params: Union[Dict[str, Any], None], optional + Additional HTTP GET query parameters Returns ------- @@ -3054,7 +3205,8 @@ def iter_instance_frames( sop_instance_uid=sop_instance_uid, frame_numbers=frame_numbers, media_types=media_types, - stream=True + stream=True, + additional_params=additional_params ) def retrieve_instance_frames_rendered( diff --git a/tests/test_web.py b/tests/test_web.py index ff5c866..2de4893 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -136,6 +136,30 @@ def test_search_for_studies(httpserver, client, cache_dir): ) +def test_search_for_studies_with_additional_params( + httpserver, + client, + cache_dir +): + cache_filename = str(cache_dir.joinpath('search_for_studies.json')) + with open(cache_filename, 'r') as f: + content = f.read() + parsed_content = json.loads(content) + headers = {'content-type': 'application/dicom+json'} + httpserver.serve_content(content=content, code=200, headers=headers) + params = {"key1": ["value1", "value2"], "key2": "value3"} + assert client.search_for_studies(additional_params=params) == parsed_content + request = httpserver.requests[0] + assert request.path == '/studies' + assert request.query_string.decode() == ( + 'key1=value1&key1=value2&key2=value3' + ) + assert all( + mime[0] in ('application/json', 'application/dicom+json') + for mime in request.accept_mimetypes + ) + + def test_search_for_studies_with_retries(httpserver, client, cache_dir): headers = {'content-type': 'application/dicom+json'} max_attempts = 3 @@ -217,6 +241,30 @@ def test_search_for_series(httpserver, client, cache_dir): ) +def test_search_for_series_with_additional_params( + httpserver, + client, + cache_dir +): + cache_filename = str(cache_dir.joinpath('search_for_series.json')) + with open(cache_filename, 'r') as f: + content = f.read() + parsed_content = json.loads(content) + headers = {'content-type': 'application/dicom+json'} + httpserver.serve_content(content=content, code=200, headers=headers) + params = {"key1": ["value1", "value2"], "key2": "value3"} + assert client.search_for_series(additional_params=params) == parsed_content + request = httpserver.requests[0] + assert request.path == '/series' + assert request.query_string.decode() == ( + 'key1=value1&key1=value2&key2=value3' + ) + assert all( + mime[0] in ('application/json', 'application/dicom+json') + for mime in request.accept_mimetypes + ) + + def test_search_for_series_filter_modality(httpserver, client, cache_dir): cache_filename = str(cache_dir.joinpath('search_for_series.json')) with open(cache_filename, 'r') as f: @@ -288,6 +336,32 @@ def test_search_for_instances(httpserver, client, cache_dir): ) +def test_search_for_instances_with_additional_params( + httpserver, + client, + cache_dir +): + cache_filename = str(cache_dir.joinpath('search_for_instances.json')) + with open(cache_filename, 'r') as f: + content = f.read() + parsed_content = json.loads(content) + headers = {'content-type': 'application/dicom+json'} + httpserver.serve_content(content=content, code=200, headers=headers) + params = {"key1": ["value1", "value2"], "key2": "value3"} + assert client.search_for_instances( + additional_params=params + ) == parsed_content + request = httpserver.requests[0] + assert request.path == '/instances' + assert request.query_string.decode() == ( + 'key1=value1&key1=value2&key2=value3' + ) + assert all( + mime[0] in ('application/json', 'application/dicom+json') + for mime in request.accept_mimetypes + ) + + def test_search_for_instances_of_series(httpserver, client, cache_dir): cache_filename = str(cache_dir.joinpath('search_for_instances.json')) with open(cache_filename, 'r') as f: @@ -380,6 +454,44 @@ def test_retrieve_instance_metadata(httpserver, client, cache_dir): ) +def test_retrieve_instance_metadata_with_additional_params( + httpserver, + client, + cache_dir +): + cache_filename = str(cache_dir.joinpath('retrieve_instance_metadata.json')) + with open(cache_filename, 'r') as f: + content = f.read() + parsed_content = json.loads(content) + headers = {'content-type': 'application/dicom+json'} + httpserver.serve_content(content=content, code=200, headers=headers) + params = {"key1": ["value1", "value2"], "key2": "value3"} + study_instance_uid = '1.2.3' + series_instance_uid = '1.2.4' + sop_instance_uid = '1.2.5' + result = client.retrieve_instance_metadata( + study_instance_uid, + series_instance_uid, + sop_instance_uid, + additional_params=params + ) + assert result == parsed_content[0] + request = httpserver.requests[0] + assert request.query_string.decode() == ( + 'key1=value1&key1=value2&key2=value3' + ) + expected_path = ( + f'/studies/{study_instance_uid}' + f'/series/{series_instance_uid}' + f'/instances/{sop_instance_uid}/metadata' + ) + assert request.path == expected_path + assert all( + mime[0] in ('application/json', 'application/dicom+json') + for mime in request.accept_mimetypes + ) + + def test_retrieve_instance_metadata_wado_prefix(httpserver, client, cache_dir): client.wado_url_prefix = 'wadors' cache_filename = str(cache_dir.joinpath('retrieve_instance_metadata.json')) @@ -442,6 +554,53 @@ def test_iter_series(client, httpserver, cache_dir): assert len(response) == n_resources +def test_iter_series_with_additional_params(client, httpserver, cache_dir): + cache_filename = str(cache_dir.joinpath('file.dcm')) + with open(cache_filename, 'rb') as f: + data = f.read() + + n_resources = 3 + chunk_size = 10**3 + media_type = 'application/dicom' + boundary = 'boundary' + headers = { + 'content-type': ( + 'multipart/related; ' + f'type="{media_type}"; ' + f'boundary="{boundary}"' + ), + 'transfer-encoding': 'chunked' + } + params = {"key1": ["value1", "value2"], "key2": "value3"} + + message = DICOMwebClient._encode_multipart_message( + content=[data for _ in range(n_resources)], + content_type=headers['content-type'] + ) + chunked_message = _chunk_message(message, chunk_size) + + httpserver.serve_content(content=chunked_message, code=200, headers=headers) + study_uid = '1.2.3' + series_uid = '1.2.4' + iterator = client.iter_series( + study_uid, series_uid, additional_params=params + ) + assert isinstance(iterator, Generator) + response = list(iterator) + for instance in response: + with BytesIO() as fp: + pydicom.dcmwrite(fp, instance) + raw_result = fp.getvalue() + assert raw_result == data + request = httpserver.requests[0] + assert request.path == f'/studies/{study_uid}/series/{series_uid}' + assert request.query_string.decode() == ( + 'key1=value1&key1=value2&key2=value3' + ) + assert request.accept_mimetypes[0][0][:43] == headers['content-type'][:43] + assert len(response) == n_resources + + def test_retrieve_series(client, httpserver, cache_dir): cache_filename = str(cache_dir.joinpath('file.dcm')) with open(cache_filename, 'rb') as f: @@ -482,6 +641,50 @@ def test_retrieve_series(client, httpserver, cache_dir): assert len(response) == n_resources +def test_retrieve_series_with_additional_params(client, httpserver, cache_dir): + cache_filename = str(cache_dir.joinpath('file.dcm')) + with open(cache_filename, 'rb') as f: + data = f.read() + + n_resources = 3 + media_type = 'application/dicom' + boundary = 'boundary' + headers = { + 'content-type': ( + 'multipart/related; ' + f'type="{media_type}"; ' + f'boundary="{boundary}"' + ), + } + params = {"key1": ["value1", "value2"], "key2": "value3"} + message = DICOMwebClient._encode_multipart_message( + content=[data for _ in range(n_resources)], + content_type=headers['content-type'] + ) + httpserver.serve_content(content=message, code=200, headers=headers) + study_instance_uid = '1.2.3' + series_instance_uid = '1.2.4' + response = client.retrieve_series( + study_instance_uid, series_instance_uid, additional_params=params + ) + for resource in response: + with BytesIO() as fp: + pydicom.dcmwrite(fp, resource) + raw_result = fp.getvalue() + assert raw_result == data + request = httpserver.requests[0] + expected_path = ( + f'/studies/{study_instance_uid}' + f'/series/{series_instance_uid}' + ) + assert request.query_string.decode() == ( + 'key1=value1&key1=value2&key2=value3' + ) + assert request.path == expected_path + assert request.accept_mimetypes[0][0][:43] == headers['content-type'][:43] + assert len(response) == n_resources + + def test_retrieve_instance(httpserver, client, cache_dir): cache_filename = str(cache_dir.joinpath('file.dcm')) with open(cache_filename, 'rb') as f: @@ -520,6 +723,55 @@ def test_retrieve_instance(httpserver, client, cache_dir): assert request.accept_mimetypes[0][0][:43] == headers['content-type'][:43] +def test_retrieve_instance_with_additional_params( + httpserver, + client, + cache_dir +): + cache_filename = str(cache_dir.joinpath('file.dcm')) + with open(cache_filename, 'rb') as f: + data = f.read() + media_type = 'application/dicom' + boundary = 'boundary' + headers = { + 'content-type': ( + 'multipart/related; ' + f'type="{media_type}"; ' + f'boundary="{boundary}"' + ), + } + message = DICOMwebClient._encode_multipart_message( + content=[data], + content_type=headers['content-type'] + ) + httpserver.serve_content(content=message, code=200, headers=headers) + params = {"key1": ["value1", "value2"], "key2": "value3"} + study_instance_uid = '1.2.3' + series_instance_uid = '1.2.4' + sop_instance_uid = '1.2.5' + response = client.retrieve_instance( + study_instance_uid, + series_instance_uid, + sop_instance_uid, + additional_params=params + ) + with BytesIO() as fp: + pydicom.dcmwrite(fp, response) + raw_result = fp.getvalue() + assert raw_result == data + request = httpserver.requests[0] + assert request.query_string.decode() == ( + 'key1=value1&key1=value2&key2=value3' + ) + expected_path = ( + f'/studies/{study_instance_uid}' + f'/series/{series_instance_uid}' + f'/instances/{sop_instance_uid}' + ) + assert request.path == expected_path + assert request.accept_mimetypes[0][0][:43] == headers['content-type'][:43] + + def test_retrieve_instance_singlepart(httpserver, client, cache_dir): cache_filename = str(cache_dir.joinpath('file.dcm')) with open(cache_filename, 'rb') as f: @@ -1055,6 +1307,36 @@ def test_store_instance_error_with_retries(httpserver, client, cache_dir): ) +def test_store_instance_error_with_retries_and_additional_params( + httpserver, client, cache_dir +): + dataset = pydicom.Dataset.from_json({}) + dataset.is_little_endian = True + dataset.is_implicit_VR = True + max_attempts = 2 + client.set_http_retry_params( + retry=True, + max_attempts=max_attempts, + wait_exponential_multiplier=10 + ) + httpserver.serve_content( + content='', + code=HTTPStatus.REQUEST_TIMEOUT, + headers='' + ) + params = {"key1": ["value1", "value2"], "key2": "value3"} + with pytest.raises(RetryError): + client.store_instances([dataset], additional_params=params) + assert len(httpserver.requests) == max_attempts + request = httpserver.requests[0] + assert request.headers['Content-Type'].startswith( + 'multipart/related; type="application/dicom"' + ) + assert request.query_string.decode() == ( + 'key1=value1&key1=value2&key2=value3' + ) + + def test_store_instance_error_with_no_retries(httpserver, client, cache_dir): dataset = pydicom.Dataset.from_json({}) dataset.is_little_endian = True @@ -1074,6 +1356,31 @@ def test_store_instance_error_with_no_retries(httpserver, client, cache_dir): ) +def test_store_instance_error_with_no_retries_and_additional_params( + httpserver, client, cache_dir +): + dataset = pydicom.Dataset.from_json({}) + dataset.is_little_endian = True + dataset.is_implicit_VR = True + client.set_http_retry_params(retry=False) + httpserver.serve_content( + content='', + code=HTTPStatus.REQUEST_TIMEOUT, + headers='' + ) + params = {"key1": ["value1", "value2"], "key2": "value3"} + with pytest.raises(HTTPError): + client.store_instances([dataset], additional_params=params) + assert len(httpserver.requests) == 1 + request = httpserver.requests[0] + assert request.headers['Content-Type'].startswith( + 'multipart/related; type="application/dicom"' + ) + assert request.query_string.decode() == ( + 'key1=value1&key1=value2&key2=value3' + ) + + def test_delete_study_error(httpserver, client, cache_dir): study_instance_uid = '1.2.3' httpserver.serve_content( @@ -1090,6 +1397,31 @@ def test_delete_study_error(httpserver, client, cache_dir): assert request.method == 'DELETE' +def test_delete_study_error_with_additional_params( + httpserver, client, cache_dir +): + study_instance_uid = '1.2.3' + httpserver.serve_content( + content='', + code=HTTPStatus.METHOD_NOT_ALLOWED, + headers='' + ) + params = {"key1": ["value1", "value2"], "key2": "value3"} + with pytest.raises(HTTPError): + client.delete_study( + study_instance_uid=study_instance_uid, + additional_params=params + ) + assert len(httpserver.requests) == 1 + request = httpserver.requests[0] + expected_path = f'/studies/{study_instance_uid}' + assert request.path == expected_path + assert request.method == 'DELETE' + assert request.query_string.decode() == ( + 'key1=value1&key1=value2&key2=value3' + ) + + def test_delete_series_error(httpserver, client, cache_dir): study_instance_uid = '1.2.3' series_instance_uid = '1.2.4' @@ -1111,6 +1443,34 @@ def test_delete_series_error(httpserver, client, cache_dir): assert request.method == 'DELETE' +def test_delete_series_error_with_additional_params( + httpserver, client, cache_dir +): + study_instance_uid = '1.2.3' + series_instance_uid = '1.2.4' + httpserver.serve_content( + content='', + code=HTTPStatus.METHOD_NOT_ALLOWED, + headers='' + ) + params = {"key1": ["value1", "value2"], "key2": "value3"} + with pytest.raises(HTTPError): + client.delete_series(study_instance_uid=study_instance_uid, + series_instance_uid=series_instance_uid, + additional_params=params) + assert len(httpserver.requests) == 1 + request = httpserver.requests[0] + expected_path = ( + f'/studies/{study_instance_uid}' + f'/series/{series_instance_uid}' + ) + assert request.path == expected_path + assert request.method == 'DELETE' + assert request.query_string.decode() == ( + 'key1=value1&key1=value2&key2=value3' + ) + + def test_delete_instance_error(httpserver, client, cache_dir): study_instance_uid = '1.2.3' series_instance_uid = '1.2.4' @@ -1135,6 +1495,37 @@ def test_delete_instance_error(httpserver, client, cache_dir): assert request.method == 'DELETE' +def test_delete_instance_error_with_additional_params( + httpserver, client, cache_dir +): + study_instance_uid = '1.2.3' + series_instance_uid = '1.2.4' + sop_instance_uid = '1.2.5' + httpserver.serve_content( + content='', + code=HTTPStatus.METHOD_NOT_ALLOWED, + headers='' + ) + params = {"key1": ["value1", "value2"], "key2": "value3"} + with pytest.raises(HTTPError): + client.delete_instance(study_instance_uid=study_instance_uid, + series_instance_uid=series_instance_uid, + sop_instance_uid=sop_instance_uid, + additional_params=params) + assert len(httpserver.requests) == 1 + request = httpserver.requests[0] + expected_path = ( + f'/studies/{study_instance_uid}' + f'/series/{series_instance_uid}' + f'/instances/{sop_instance_uid}' + ) + assert request.path == expected_path + assert request.method == 'DELETE' + assert request.query_string.decode() == ( + 'key1=value1&key1=value2&key2=value3' + ) + + def test_load_json_dataset_da(httpserver, client, cache_dir): value = ['2018-11-21'] dicom_json = {