Skip to content

Commit 0ea37db

Browse files
authored
Support "permissive" UIDs in URI. (#49)
* Support "permissive" UIDs in `URI`. * Include `permissive` flag in `URI.update()` and `URI.from_string()`.
1 parent 20c92b4 commit 0ea37db

File tree

2 files changed

+83
-14
lines changed

2 files changed

+83
-14
lines changed

src/dicomweb_client/uri.py

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class URISuffix(enum.Enum):
2424
# For DICOM Standard spec validation of UID components in `URI`.
2525
_MAX_UID_LENGTH = 64
2626
_REGEX_UID = re.compile(r'[0-9]+([.][0-9]+)*')
27+
_REGEX_PERMISSIVE_UID = re.compile(r'[^/@]+')
2728

2829

2930
class URI:
@@ -60,12 +61,15 @@ def __init__(self,
6061
series_instance_uid: Optional[str] = None,
6162
sop_instance_uid: Optional[str] = None,
6263
frames: Optional[Sequence[int]] = None,
63-
suffix: Optional[URISuffix] = None):
64+
suffix: Optional[URISuffix] = None,
65+
permissive: bool = False):
6466
"""Instantiates an object.
6567
6668
As per the DICOM Standard, the Study, Series, and Instance UIDs must be
6769
a series of numeric components (``0``-``9``) separated by the period
6870
``.`` character, with a maximum length of 64 characters.
71+
If the ``permissive`` flag is set to ``True``, any alpha-numeric or
72+
special characters (except for ``/`` and ``@``) may be used.
6973
7074
Parameters
7175
----------
@@ -83,6 +87,14 @@ def __init__(self,
8387
suffix: URISuffix, optional
8488
Suffix attached to the DICOM resource URI. This could refer to a
8589
metadata, rendered, or thumbnail resource.
90+
permissive: bool
91+
If ``True``, relaxes the DICOM Standard validation for UIDs (see
92+
main docstring for details). This option is made available since
93+
users may be occasionally forced to work with DICOMs or services
94+
that may be in violation of the standard. Unless required, use of
95+
this flag is **not** recommended, since non-conformant UIDs may
96+
lead to unexpected errors downstream, e.g., rejection by a DICOMweb
97+
server, etc.
8698
8799
Raises
88100
------
@@ -112,6 +124,7 @@ def __init__(self,
112124
"""
113125
_validate_base_url(base_url)
114126
_validate_resource_identifiers_and_suffix(
127+
permissive,
115128
study_instance_uid,
116129
series_instance_uid,
117130
sop_instance_uid,
@@ -124,6 +137,7 @@ def __init__(self,
124137
self._instance_uid = sop_instance_uid
125138
self._frames = None if frames is None else tuple(frames)
126139
self._suffix = suffix
140+
self._permissive = permissive
127141

128142
def __str__(self) -> str:
129143
"""Returns the object as a DICOMweb URI string."""
@@ -207,6 +221,11 @@ def type(self) -> URIType:
207221
return URIType.INSTANCE
208222
return URIType.FRAME
209223

224+
@property
225+
def permissive(self) -> bool:
226+
"""Returns the ``permissive`` parameter value in the initializer."""
227+
return self._permissive
228+
210229
def base_uri(self) -> 'URI':
211230
"""Returns `URI` for the DICOM Service within this object."""
212231
return URI(self.base_url)
@@ -248,7 +267,8 @@ def update(self,
248267
series_instance_uid: Optional[str] = None,
249268
sop_instance_uid: Optional[str] = None,
250269
frames: Optional[Sequence[int]] = None,
251-
suffix: Optional[URISuffix] = None) -> 'URI':
270+
suffix: Optional[URISuffix] = None,
271+
permissive: Optional[bool] = False) -> 'URI':
252272
"""Creates a new `URI` object based on the current one.
253273
254274
Replaces the specified `URI` components in the current `URI` to create
@@ -274,6 +294,9 @@ def update(self,
274294
suffix: URISuffix, optional
275295
Suffix to use in the new `URI` or `None` if the `suffix` from the
276296
current `URI` should be used.
297+
permissive: bool, optional
298+
Set if permissive handling of UIDs (if any) in the updated ``URI``
299+
is required. See the class initializer docstring for details.
277300
278301
Returns
279302
-------
@@ -297,6 +320,7 @@ def update(self,
297320
if sop_instance_uid is not None else self.sop_instance_uid,
298321
frames if frames is not None else self.frames,
299322
suffix if suffix is not None else self.suffix,
323+
permissive if permissive is not None else self.permissive,
300324
)
301325

302326
@property
@@ -353,7 +377,8 @@ def parent(self) -> 'URI':
353377
@classmethod
354378
def from_string(cls,
355379
dicomweb_uri: str,
356-
uri_type: Optional[URIType] = None) -> 'URI':
380+
uri_type: Optional[URIType] = None,
381+
permissive: bool = False) -> 'URI':
357382
"""Parses the string to return the URI.
358383
359384
Any valid DICOMweb compatible HTTP[S] URI is permitted, e.g.,
@@ -367,6 +392,9 @@ def from_string(cls,
367392
The expected DICOM resource type referenced by the object. If set,
368393
it validates that the resource-scope of the `dicomweb_uri` matches
369394
the expected type.
395+
permissive: bool
396+
Set if permissive handling of UIDs (if any) in ``dicomweb_uri`` is
397+
required. See the class initializer docstring for details.
370398
371399
Returns
372400
-------
@@ -428,7 +456,7 @@ def from_string(cls,
428456
f'URI: {dicomweb_uri!r}')
429457

430458
uri = cls(base_url, study_instance_uid, series_instance_uid,
431-
sop_instance_uid, frames, suffix)
459+
sop_instance_uid, frames, suffix, permissive)
432460
# Validate that the URI is of the specified type, if applicable.
433461
if uri_type is not None and uri.type != uri_type:
434462
raise ValueError(
@@ -450,6 +478,7 @@ def _validate_base_url(url: str) -> None:
450478

451479

452480
def _validate_resource_identifiers_and_suffix(
481+
permissive: bool,
453482
study_instance_uid: Optional[str],
454483
series_instance_uid: Optional[str],
455484
sop_instance_uid: Optional[str],
@@ -473,7 +502,7 @@ def _validate_resource_identifiers_and_suffix(
473502

474503
for uid in (study_instance_uid, series_instance_uid, sop_instance_uid):
475504
if uid is not None:
476-
_validate_uid(uid)
505+
_validate_uid(uid, permissive)
477506

478507
if suffix in (URISuffix.RENDERED, URISuffix.THUMBNAIL) and (
479508
study_instance_uid is None):
@@ -487,13 +516,17 @@ def _validate_resource_identifiers_and_suffix(
487516
'resources: Study, Series, or SOP Instance UID')
488517

489518

490-
def _validate_uid(uid: str) -> None:
519+
def _validate_uid(uid: str, permissive: bool) -> None:
491520
"""Validates a DICOM UID."""
492521
if len(uid) > _MAX_UID_LENGTH:
493522
raise ValueError('UID cannot have more than 64 chars. '
494523
f'Actual count in {uid!r}: {len(uid)}')
495-
if _REGEX_UID.fullmatch(uid) is None:
496-
raise ValueError(f'UID {uid!r} must match regex {_REGEX_UID!r}.')
524+
if not permissive and _REGEX_UID.fullmatch(uid) is None:
525+
raise ValueError(f'UID {uid!r} must match regex {_REGEX_UID!r} in '
526+
'conformance with the DICOM Standard.')
527+
elif permissive and _REGEX_PERMISSIVE_UID.fullmatch(uid) is None:
528+
raise ValueError(f'Permissive mode is enabled. UID {uid!r} must match '
529+
f'regex {_REGEX_PERMISSIVE_UID!r}.')
497530

498531

499532
def _validate_frames(frames: Sequence[int]) -> None:

tests/test_uri.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,22 @@
1818
@pytest.mark.parametrize('illegal_char', ['/', '@', 'a', 'A'])
1919
def test_uid_illegal_character(illegal_char):
2020
"""Checks *ValueError* is raised when a UID contains an illegal char."""
21-
with pytest.raises(ValueError, match='must match'):
21+
with pytest.raises(ValueError, match='in conformance'):
2222
URI(_BASE_URL, f'1.2{illegal_char}3')
23-
with pytest.raises(ValueError, match='must match'):
23+
with pytest.raises(ValueError, match='in conformance'):
2424
URI(_BASE_URL, '1.2.3', f'4.5{illegal_char}6')
25-
with pytest.raises(ValueError, match='must match'):
25+
with pytest.raises(ValueError, match='in conformance'):
2626
URI(_BASE_URL, '1.2.3', '4.5.6', f'7.8{illegal_char}9')
2727

2828

2929
@pytest.mark.parametrize('illegal_uid', ['.23', '1.2..4', '1.2.', '.'])
3030
def test_uid_illegal_format(illegal_uid):
3131
"""Checks *ValueError* is raised if a UID is in an illegal format."""
32-
with pytest.raises(ValueError, match='must match'):
32+
with pytest.raises(ValueError, match='in conformance'):
3333
URI(_BASE_URL, illegal_uid)
34-
with pytest.raises(ValueError, match='must match'):
34+
with pytest.raises(ValueError, match='in conformance'):
3535
URI(_BASE_URL, '1.2.3', illegal_uid)
36-
with pytest.raises(ValueError, match='must match'):
36+
with pytest.raises(ValueError, match='in conformance'):
3737
URI(_BASE_URL, '1.2.3', '4.5.6', illegal_uid)
3838

3939

@@ -55,6 +55,21 @@ def test_uid_length():
5555
URI(_BASE_URL, '1.2.3', '4.5.6', uid_65)
5656

5757

58+
@pytest.mark.parametrize('uid', ['13-abc', 'hello', 'is it', 'me you\'re'])
59+
def test_uid_permissive_valid(uid):
60+
"""Tests valid "permissive" UIDs are accommodated iff the flag is set."""
61+
with pytest.raises(ValueError, match='in conformance'):
62+
URI(_BASE_URL, uid, permissive=False)
63+
URI(_BASE_URL, uid, permissive=True)
64+
65+
66+
@pytest.mark.parametrize('uid', ['1.23.5@', '1/23.4'])
67+
def test_uid_permissive_invalid(uid):
68+
"""Tests that invalid "permissive" UIDs are rejected."""
69+
with pytest.raises(ValueError, match='Permissive mode'):
70+
URI(_BASE_URL, uid, permissive=True)
71+
72+
5873
def test_uid_missing_error():
5974
"""Checks *ValueError* is raised when an expected UID is missing."""
6075
with pytest.raises(ValueError, match='`study_instance_uid` missing with'):
@@ -417,6 +432,27 @@ def test_update(uri_args, update_args, expected_uri_args):
417432
assert actual_uri == expected_uri
418433

419434

435+
@pytest.mark.parametrize('original,update,expected', [
436+
(None, None, False),
437+
(None, False, False),
438+
(None, True, True),
439+
(False, None, False),
440+
(True, None, True),
441+
(False, False, False),
442+
(False, True, True),
443+
(True, False, False),
444+
(True, True, True),
445+
])
446+
def test_update_permissive(original, update, expected):
447+
"""Tests for the expected value of `permissive` flag in `URI.update()`."""
448+
if original is None:
449+
original_uri = URI(_BASE_URL)
450+
else:
451+
original_uri = URI(_BASE_URL, permissive=original)
452+
updated_uri = original_uri.update(permissive=update)
453+
assert updated_uri.permissive == expected
454+
455+
420456
@pytest.mark.parametrize('uri_args,update_args,error_msg', [
421457
((_BASE_URL, ), (None, None, '1', None, None),
422458
'`study_instance_uid` missing'),

0 commit comments

Comments
 (0)