Skip to content

Commit 84663e1

Browse files
authored
Merge pull request #118 from jaya-krishnan-sr/feat/webclient_support_permissive_uid
feat: add permissive UID validation for DicomWebClient
2 parents ded157f + 2e1b156 commit 84663e1

File tree

4 files changed

+82
-41
lines changed

4 files changed

+82
-41
lines changed

src/dicomweb_client/uri.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -629,13 +629,16 @@ def _validate_resource_identifiers_and_suffix(
629629

630630
def _validate_uid(uid: str, permissive: bool) -> None:
631631
"""Validates a DICOM UID."""
632-
if len(uid) > _MAX_UID_LENGTH:
633-
raise ValueError('UID cannot have more than 64 chars. '
634-
f'Actual count in {uid!r}: {len(uid)}')
635-
if not permissive and _REGEX_UID.fullmatch(uid) is None:
636-
raise ValueError(f'UID {uid!r} must match regex {_REGEX_UID!r} in '
637-
'conformance with the DICOM Standard.')
638-
elif permissive and _REGEX_PERMISSIVE_UID.fullmatch(uid) is None:
632+
if not isinstance(uid, str):
633+
raise TypeError('DICOM UID must be a string.')
634+
if not permissive:
635+
if len(uid) > _MAX_UID_LENGTH:
636+
raise ValueError('UID cannot have more than 64 chars. '
637+
f'Actual count in {uid!r}: {len(uid)}')
638+
if _REGEX_UID.fullmatch(uid) is None:
639+
raise ValueError(f'UID {uid!r} must match regex {_REGEX_UID!r} in '
640+
'conformance with the DICOM Standard.')
641+
elif _REGEX_PERMISSIVE_UID.fullmatch(uid) is None:
639642
raise ValueError(f'Permissive mode is enabled. UID {uid!r} must match '
640643
f'regex {_REGEX_PERMISSIVE_UID!r}.')
641644

src/dicomweb_client/web.py

Lines changed: 25 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@
3232
import requests
3333
import retrying
3434

35-
from dicomweb_client.uri import build_query_string, parse_query_parameters
35+
from dicomweb_client.uri import (
36+
build_query_string,
37+
parse_query_parameters,
38+
_validate_uid
39+
)
3640

3741

3842
logger = logging.getLogger(__name__)
@@ -200,7 +204,8 @@ def __init__(
200204
proxies: Optional[Dict[str, str]] = None,
201205
headers: Optional[Dict[str, str]] = None,
202206
callback: Optional[Callable] = None,
203-
chunk_size: int = 10**6
207+
chunk_size: int = 10**6,
208+
permissive_uid: bool = False
204209
) -> None:
205210
"""Instatiate client.
206211
@@ -235,6 +240,14 @@ def __init__(
235240
when streaming data from the server using chunked transfer encoding
236241
(used by ``iter_*()`` methods as well as the ``store_instances()``
237242
method); defaults to ``10**6`` bytes (1MB)
243+
permissive_uid: bool, optional
244+
If ``True``, relaxes the DICOM Standard validation for UIDs (see
245+
main docstring for details). This option is made available since
246+
users may be occasionally forced to work with DICOMs or services
247+
that may be in violation of the standard. Unless required, use of
248+
this flag is **not** recommended, since non-conformant UIDs may
249+
lead to unexpected errors downstream, e.g., rejection by a DICOMweb
250+
server, etc.
238251
239252
Warning
240253
-------
@@ -314,6 +327,7 @@ def __init__(
314327
if callback is not None:
315328
self._session.hooks = {'response': [callback, ]}
316329
self._chunk_size = chunk_size
330+
self._permissive_uid = permissive_uid
317331
self.set_http_retry_params()
318332

319333
def _get_transaction_url(self, transaction: _Transaction) -> str:
@@ -2163,29 +2177,6 @@ def delete_study(
21632177
url += f'?{additional_params_query_string}'
21642178
self._http_delete(url)
21652179

2166-
def _assert_uid_format(self, uid: str) -> None:
2167-
"""Check whether a DICOM UID has the correct format.
2168-
2169-
Parameters
2170-
----------
2171-
uid: str
2172-
DICOM UID
2173-
2174-
Raises
2175-
------
2176-
TypeError
2177-
When `uid` is not a string
2178-
ValueError
2179-
When `uid` doesn't match the regular expression pattern
2180-
``"^[.0-9]+$"``
2181-
2182-
"""
2183-
if not isinstance(uid, str):
2184-
raise TypeError('DICOM UID must be a string.')
2185-
pattern = re.compile('^[.0-9]+$')
2186-
if not pattern.search(uid):
2187-
raise ValueError('DICOM UID has invalid format.')
2188-
21892180
def search_for_series(
21902181
self,
21912182
study_instance_uid: Optional[str] = None,
@@ -2238,7 +2229,7 @@ def search_for_series(
22382229
22392230
""" # noqa: E501
22402231
if study_instance_uid is not None:
2241-
self._assert_uid_format(study_instance_uid)
2232+
_validate_uid(study_instance_uid, self._permissive_uid)
22422233
logger.info(f'search for series of study "{study_instance_uid}"')
22432234
else:
22442235
logger.info('search for series')
@@ -2297,12 +2288,12 @@ def _get_series(
22972288
f'retrieve series "{series_instance_uid}" '
22982289
f'of study "{study_instance_uid}"'
22992290
)
2300-
self._assert_uid_format(study_instance_uid)
2291+
_validate_uid(study_instance_uid, self._permissive_uid)
23012292
if series_instance_uid is None:
23022293
raise ValueError(
23032294
'Series Instance UID is required for retrieval of series.'
23042295
)
2305-
self._assert_uid_format(series_instance_uid)
2296+
_validate_uid(series_instance_uid, self._permissive_uid)
23062297
url = self._get_series_url(
23072298
_Transaction.RETRIEVE,
23082299
study_instance_uid,
@@ -2457,7 +2448,7 @@ def retrieve_series_metadata(
24572448
'Study Instance UID is required for retrieval of '
24582449
'series metadata.'
24592450
)
2460-
self._assert_uid_format(study_instance_uid)
2451+
_validate_uid(study_instance_uid, self._permissive_uid)
24612452
if series_instance_uid is None:
24622453
raise ValueError(
24632454
'Series Instance UID is required for retrieval of '
@@ -2467,7 +2458,7 @@ def retrieve_series_metadata(
24672458
f'retrieve metadata of series "{series_instance_uid}" '
24682459
f'of study "{study_instance_uid}"'
24692460
)
2470-
self._assert_uid_format(series_instance_uid)
2461+
_validate_uid(series_instance_uid, self._permissive_uid)
24712462
url = self._get_series_url(
24722463
_Transaction.RETRIEVE,
24732464
study_instance_uid,
@@ -2660,7 +2651,7 @@ def search_for_instances(
26602651
if series_instance_uid is not None:
26612652
message += f' of series "{series_instance_uid}"'
26622653
if study_instance_uid is not None:
2663-
self._assert_uid_format(study_instance_uid)
2654+
_validate_uid(study_instance_uid, self._permissive_uid)
26642655
message += f' of study "{study_instance_uid}"'
26652656
logger.info(message)
26662657
url = self._get_instances_url(
@@ -2727,12 +2718,12 @@ def retrieve_instance(
27272718
raise ValueError(
27282719
'Study Instance UID is required for retrieval of instance.'
27292720
)
2730-
self._assert_uid_format(study_instance_uid)
2721+
_validate_uid(study_instance_uid, self._permissive_uid)
27312722
if series_instance_uid is None:
27322723
raise ValueError(
27332724
'Series Instance UID is required for retrieval of instance.'
27342725
)
2735-
self._assert_uid_format(series_instance_uid)
2726+
_validate_uid(series_instance_uid, self._permissive_uid)
27362727
if sop_instance_uid is None:
27372728
raise ValueError(
27382729
'SOP Instance UID is required for retrieval of instance.'
@@ -2742,7 +2733,7 @@ def retrieve_instance(
27422733
f'of series "{series_instance_uid}" '
27432734
f'of study "{study_instance_uid}"'
27442735
)
2745-
self._assert_uid_format(sop_instance_uid)
2736+
_validate_uid(sop_instance_uid, self._permissive_uid)
27462737
url = self._get_instances_url(
27472738
_Transaction.RETRIEVE,
27482739
study_instance_uid,

tests/test_uri.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,21 @@ def test_uid_permissive_invalid(uid):
7070
URI(_BASE_URL, uid, permissive=True)
7171

7272

73+
def test_uid_length_permissive():
74+
"""Tests that UIDs longer than 64 chars are allowed in permissive mode."""
75+
# Create a UID with 65 characters
76+
uid_65 = '1' * 65
77+
78+
# Should fail in strict mode
79+
with pytest.raises(ValueError, match='UID cannot have more than 64 chars'):
80+
URI(_BASE_URL, uid_65, permissive=False)
81+
82+
# Should succeed in permissive mode
83+
URI(_BASE_URL, uid_65, permissive=True)
84+
URI(_BASE_URL, '1.2.3', uid_65, permissive=True)
85+
URI(_BASE_URL, '1.2.3', '4.5.6', uid_65, permissive=True)
86+
87+
7388
def test_uid_missing_error():
7489
"""Checks *ValueError* is raised when an expected UID is missing."""
7590
with pytest.raises(ValueError, match='`study_instance_uid` missing with'):

tests/test_web.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1608,3 +1608,35 @@ def test_load_xml_response(httpserver, client, cache_dir):
16081608
dataset = _load_xml_dataset(tree)
16091609
assert dataset.RetrieveURL.startswith('https://wadors.hospital.com')
16101610
assert len(dataset.ReferencedSOPSequence) == 2
1611+
1612+
1613+
@pytest.mark.parametrize('invalid_uid', [
1614+
'1.2/3.4', '[email protected]', '1.2.a.4', '1.2.A.4', '.23.4', '1.2..4', '1.2.', '.'
1615+
])
1616+
def test_uid_strict_validation_fail(httpserver, invalid_uid):
1617+
client = DICOMwebClient(httpserver.url, permissive_uid=False)
1618+
with pytest.raises(ValueError,
1619+
match=f'UID {invalid_uid!r} must match regex'):
1620+
client.search_for_series(study_instance_uid=invalid_uid)
1621+
1622+
1623+
@pytest.mark.parametrize('uid', ['13-abc', 'hello', 'is it', "you're me"])
1624+
def test_uid_permissive_validation_pass(httpserver, uid):
1625+
client_strict = DICOMwebClient(httpserver.url, permissive_uid=False)
1626+
client_permissive = DICOMwebClient(httpserver.url, permissive_uid=True)
1627+
1628+
# Should fail in strict mode
1629+
with pytest.raises(ValueError, match=f'UID {uid!r} must match regex'):
1630+
client_strict.search_for_series(study_instance_uid=uid)
1631+
1632+
# Should not raise exception in permissive mode
1633+
resp = client_permissive.search_for_series(study_instance_uid=uid)
1634+
assert isinstance(resp, list)
1635+
1636+
1637+
@pytest.mark.parametrize('uid', ['1.23.5@', '1/23.4'])
1638+
def test_uid_permissive_validation_fail(httpserver, uid):
1639+
client = DICOMwebClient(httpserver.url, permissive_uid=True)
1640+
with pytest.raises(ValueError,
1641+
match=f'UID {uid!r} must match regex'):
1642+
client.search_for_series(study_instance_uid=uid)

0 commit comments

Comments
 (0)