diff --git a/src/dicomweb_client/web.py b/src/dicomweb_client/web.py index 171f3fc..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, @@ -1659,7 +1659,7 @@ def search_for_studies( Study representations (see `Study Result Attributes `_) - Notes + Note ---- - The server may only return a subset of search results. In this case, a warning will notify the client that there are remaining results. @@ -2013,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. @@ -2023,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 ------- @@ -2044,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 ) ) @@ -2052,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. @@ -2062,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 ------- @@ -2086,7 +2093,8 @@ 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( @@ -2118,13 +2126,19 @@ def retrieve_study_metadata( url += '/metadata' 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 ---- @@ -2141,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: @@ -2207,7 +2227,7 @@ def search_for_series( Series representations (see `Series Result Attributes `_) - Notes + Note ---- - The server may only return a subset of search results. In this case, a warning will notify the client that there are remaining results. @@ -2317,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. @@ -2330,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 ------- @@ -2352,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 ) ) @@ -2360,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. @@ -2373,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 ------- @@ -2398,7 +2425,8 @@ 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( @@ -2525,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. @@ -2535,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 ---- @@ -2563,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( @@ -2741,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. @@ -2751,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 ------- @@ -2770,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, @@ -2780,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. @@ -2792,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 ---- @@ -2821,6 +2870,12 @@ 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( diff --git a/tests/test_web.py b/tests/test_web.py index 55cf95f..2de4893 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -554,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: @@ -594,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: @@ -1216,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 @@ -1235,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( @@ -1251,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' @@ -1272,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' @@ -1296,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 = {