Skip to content

Commit 1ea17e5

Browse files
committed
Merge master.
2 parents 15e8fe4 + e568079 commit 1ea17e5

File tree

3 files changed

+107
-38
lines changed

3 files changed

+107
-38
lines changed

docs/usage.rst

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ Retrieve full instances of a given series using specific JPEG 2000 transfer synt
301301
302302
instance = client.retrieve_instance(
303303
study_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111148288.98361414.79379639',
304-
series_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.49685336.24517034'
304+
series_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.49685336.24517034',
305305
media_types=(('application/dicom', '1.2.840.10008.1.2.4.90', ), )
306306
)
307307
@@ -311,7 +311,7 @@ Retrieve bulk data of instances of a given series using specific JPEG 2000 trans
311311
312312
instance = client.retrieve_instance(
313313
study_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111148288.98361414.79379639',
314-
series_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.49685336.24517034'
314+
series_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.49685336.24517034',
315315
media_types=(('image/jp2', '1.2.840.10008.1.2.4.90', ), )
316316
)
317317
@@ -382,7 +382,7 @@ Retrieve metadata for a particular instance:
382382
383383
metadata = client.retrieve_instance_metadata(
384384
study_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111148288.98361414.79379639',
385-
series_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.49685336.24517034'
385+
series_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.49685336.24517034',
386386
sop_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.40440871.13152534'
387387
)
388388
@@ -401,9 +401,9 @@ Retrieve a set of frames with default transfer syntax ("application/octet-stream
401401
.. code-block:: python
402402
403403
frames = client.retrieve_instance_frames(
404-
study_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111148288.98361414.79379639'
405-
series_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.49685336.24517034'
406-
sop_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.40440871.13152534'
404+
study_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111148288.98361414.79379639',
405+
series_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.49685336.24517034',
406+
sop_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.40440871.13152534',
407407
frame_numbers=[1, 2]
408408
)
409409
@@ -412,9 +412,9 @@ Retrieve a set of frames of a given instances as JPEG compressed image:
412412
.. code-block:: python
413413
414414
frames = client.retrieve_instance_frames(
415-
study_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111148288.98361414.79379639'
416-
series_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.49685336.24517034'
417-
sop_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.40440871.13152534'
415+
study_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111148288.98361414.79379639',
416+
series_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.49685336.24517034',
417+
sop_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.40440871.13152534',
418418
frame_numbers=[1, 2],
419419
media_types=('image/jpeg', )
420420
)
@@ -424,9 +424,9 @@ Retrieve a set of frames of a given instances as compressed image in any availab
424424
.. code-block:: python
425425
426426
frames = client.retrieve_instance_frames(
427-
study_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111148288.98361414.79379639'
428-
series_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.49685336.24517034'
429-
sop_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.40440871.13152534'
427+
study_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111148288.98361414.79379639',
428+
series_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.49685336.24517034',
429+
sop_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.40440871.13152534',
430430
frame_numbers=[1, 2],
431431
media_types=('image/*', )
432432
)
@@ -436,9 +436,9 @@ Retrieve a set of frames of a given instances as either JPEG 2000 or JPEG-LS com
436436
.. code-block:: python
437437
438438
frames = client.retrieve_instance_frames(
439-
study_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111148288.98361414.79379639'
440-
series_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.49685336.24517034'
441-
sop_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.40440871.13152534'
439+
study_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111148288.98361414.79379639',
440+
series_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.49685336.24517034',
441+
sop_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.40440871.13152534',
442442
frame_numbers=[1, 2],
443443
media_types=('image/jp2', 'image/x-jpls', )
444444
)
@@ -448,9 +448,9 @@ Retrieve a set of frames of a given instances as either JPEG, JPEG 2000 or JPEG-
448448
.. code-block:: python
449449
450450
frames = client.retrieve_instance_frames(
451-
study_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111148288.98361414.79379639'
452-
series_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.49685336.24517034'
453-
sop_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.40440871.13152534'
451+
study_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111148288.98361414.79379639',
452+
series_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.49685336.24517034',
453+
sop_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.40440871.13152534',
454454
frame_numbers=[1, 2],
455455
media_types=(
456456
('image/jpeg', '1.2.840.10008.1.2.4.57', ),
@@ -481,9 +481,9 @@ Retrieve a single-frame image instance rendered as a PNG compressed image:
481481
.. code-block:: python
482482
483483
frames = client.retrieve_instance_rendered(
484-
study_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111148288.98361414.79379639'
485-
series_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.49685336.24517034'
486-
sop_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.40440871.13152534'
484+
study_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111148288.98361414.79379639',
485+
series_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.49685336.24517034',
486+
sop_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.40440871.13152534',
487487
media_types=('image/png', )
488488
)
489489
@@ -492,9 +492,9 @@ Retrieve a single frame of a multi-frame image instance rendered as a high-quali
492492
.. code-block:: python
493493
494494
frames = client.retrieve_instance_frames_rendered(
495-
study_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111148288.98361414.79379639'
496-
series_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.49685336.24517034'
497-
sop_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.40440871.13152534'
495+
study_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111148288.98361414.79379639',
496+
series_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.49685336.24517034',
497+
sop_instance_uid='1.2.826.0.1.3680043.8.1055.1.20111103111208937.40440871.13152534',
498498
frame_numbers=[1],
499499
media_types=('image/jpeg', ),
500500
params={'quality': 95, 'iccprofile': 'yes'}

src/dicomweb_client/uri.py

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class URISuffix(enum.Enum):
2525
# For DICOM Standard spec validation of UID components in `URI`.
2626
_MAX_UID_LENGTH = 64
2727
_REGEX_UID = re.compile(r'[0-9]+([.][0-9]+)*')
28+
_REGEX_PERMISSIVE_UID = re.compile(r'[^/@]+')
2829
# Used for Project ID and Location validation in `GoogleCloudHealthcareURL`.
2930
_REGEX_ID_1 = r'[\w-]+'
3031
_ATTR_VALIDATOR_ID_1 = attr.validators.matches_re(_REGEX_ID_1)
@@ -70,12 +71,15 @@ def __init__(self,
7071
series_instance_uid: Optional[str] = None,
7172
sop_instance_uid: Optional[str] = None,
7273
frames: Optional[Sequence[int]] = None,
73-
suffix: Optional[URISuffix] = None):
74+
suffix: Optional[URISuffix] = None,
75+
permissive: bool = False):
7476
"""Instantiates an object.
7577
7678
As per the DICOM Standard, the Study, Series, and Instance UIDs must be
7779
a series of numeric components (``0``-``9``) separated by the period
7880
``.`` character, with a maximum length of 64 characters.
81+
If the ``permissive`` flag is set to ``True``, any alpha-numeric or
82+
special characters (except for ``/`` and ``@``) may be used.
7983
8084
Parameters
8185
----------
@@ -93,6 +97,14 @@ def __init__(self,
9397
suffix: URISuffix, optional
9498
Suffix attached to the DICOM resource URI. This could refer to a
9599
metadata, rendered, or thumbnail resource.
100+
permissive: bool
101+
If ``True``, relaxes the DICOM Standard validation for UIDs (see
102+
main docstring for details). This option is made available since
103+
users may be occasionally forced to work with DICOMs or services
104+
that may be in violation of the standard. Unless required, use of
105+
this flag is **not** recommended, since non-conformant UIDs may
106+
lead to unexpected errors downstream, e.g., rejection by a DICOMweb
107+
server, etc.
96108
97109
Raises
98110
------
@@ -122,6 +134,7 @@ def __init__(self,
122134
"""
123135
_validate_base_url(base_url)
124136
_validate_resource_identifiers_and_suffix(
137+
permissive,
125138
study_instance_uid,
126139
series_instance_uid,
127140
sop_instance_uid,
@@ -134,6 +147,7 @@ def __init__(self,
134147
self._instance_uid = sop_instance_uid
135148
self._frames = None if frames is None else tuple(frames)
136149
self._suffix = suffix
150+
self._permissive = permissive
137151

138152
def __str__(self) -> str:
139153
"""Returns the object as a DICOMweb URI string."""
@@ -217,6 +231,11 @@ def type(self) -> URIType:
217231
return URIType.INSTANCE
218232
return URIType.FRAME
219233

234+
@property
235+
def permissive(self) -> bool:
236+
"""Returns the ``permissive`` parameter value in the initializer."""
237+
return self._permissive
238+
220239
def base_uri(self) -> 'URI':
221240
"""Returns `URI` for the DICOM Service within this object."""
222241
return URI(self.base_url)
@@ -258,7 +277,8 @@ def update(self,
258277
series_instance_uid: Optional[str] = None,
259278
sop_instance_uid: Optional[str] = None,
260279
frames: Optional[Sequence[int]] = None,
261-
suffix: Optional[URISuffix] = None) -> 'URI':
280+
suffix: Optional[URISuffix] = None,
281+
permissive: Optional[bool] = False) -> 'URI':
262282
"""Creates a new `URI` object based on the current one.
263283
264284
Replaces the specified `URI` components in the current `URI` to create
@@ -284,6 +304,9 @@ def update(self,
284304
suffix: URISuffix, optional
285305
Suffix to use in the new `URI` or `None` if the `suffix` from the
286306
current `URI` should be used.
307+
permissive: bool, optional
308+
Set if permissive handling of UIDs (if any) in the updated ``URI``
309+
is required. See the class initializer docstring for details.
287310
288311
Returns
289312
-------
@@ -307,6 +330,7 @@ def update(self,
307330
if sop_instance_uid is not None else self.sop_instance_uid,
308331
frames if frames is not None else self.frames,
309332
suffix if suffix is not None else self.suffix,
333+
permissive if permissive is not None else self.permissive,
310334
)
311335

312336
@property
@@ -363,7 +387,8 @@ def parent(self) -> 'URI':
363387
@classmethod
364388
def from_string(cls,
365389
dicomweb_uri: str,
366-
uri_type: Optional[URIType] = None) -> 'URI':
390+
uri_type: Optional[URIType] = None,
391+
permissive: bool = False) -> 'URI':
367392
"""Parses the string to return the URI.
368393
369394
Any valid DICOMweb compatible HTTP[S] URI is permitted, e.g.,
@@ -377,6 +402,9 @@ def from_string(cls,
377402
The expected DICOM resource type referenced by the object. If set,
378403
it validates that the resource-scope of the `dicomweb_uri` matches
379404
the expected type.
405+
permissive: bool
406+
Set if permissive handling of UIDs (if any) in ``dicomweb_uri`` is
407+
required. See the class initializer docstring for details.
380408
381409
Returns
382410
-------
@@ -438,7 +466,7 @@ def from_string(cls,
438466
f'URI: {dicomweb_uri!r}')
439467

440468
uri = cls(base_url, study_instance_uid, series_instance_uid,
441-
sop_instance_uid, frames, suffix)
469+
sop_instance_uid, frames, suffix, permissive)
442470
# Validate that the URI is of the specified type, if applicable.
443471
if uri_type is not None and uri.type != uri_type:
444472
raise ValueError(
@@ -540,6 +568,7 @@ def _validate_base_url(url: str) -> None:
540568

541569

542570
def _validate_resource_identifiers_and_suffix(
571+
permissive: bool,
543572
study_instance_uid: Optional[str],
544573
series_instance_uid: Optional[str],
545574
sop_instance_uid: Optional[str],
@@ -563,7 +592,7 @@ def _validate_resource_identifiers_and_suffix(
563592

564593
for uid in (study_instance_uid, series_instance_uid, sop_instance_uid):
565594
if uid is not None:
566-
_validate_uid(uid)
595+
_validate_uid(uid, permissive)
567596

568597
if suffix in (URISuffix.RENDERED, URISuffix.THUMBNAIL) and (
569598
study_instance_uid is None):
@@ -577,13 +606,17 @@ def _validate_resource_identifiers_and_suffix(
577606
'resources: Study, Series, or SOP Instance UID')
578607

579608

580-
def _validate_uid(uid: str) -> None:
609+
def _validate_uid(uid: str, permissive: bool) -> None:
581610
"""Validates a DICOM UID."""
582611
if len(uid) > _MAX_UID_LENGTH:
583612
raise ValueError('UID cannot have more than 64 chars. '
584613
f'Actual count in {uid!r}: {len(uid)}')
585-
if _REGEX_UID.fullmatch(uid) is None:
586-
raise ValueError(f'UID {uid!r} must match regex {_REGEX_UID!r}.')
614+
if not permissive and _REGEX_UID.fullmatch(uid) is None:
615+
raise ValueError(f'UID {uid!r} must match regex {_REGEX_UID!r} in '
616+
'conformance with the DICOM Standard.')
617+
elif permissive and _REGEX_PERMISSIVE_UID.fullmatch(uid) is None:
618+
raise ValueError(f'Permissive mode is enabled. UID {uid!r} must match '
619+
f'regex {_REGEX_PERMISSIVE_UID!r}.')
587620

588621

589622
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
@@ -33,22 +33,22 @@
3333
@pytest.mark.parametrize('illegal_char', ['/', '@', 'a', 'A'])
3434
def test_uid_illegal_character(illegal_char):
3535
"""Checks *ValueError* is raised when a UID contains an illegal char."""
36-
with pytest.raises(ValueError, match='must match'):
36+
with pytest.raises(ValueError, match='in conformance'):
3737
URI(_BASE_URL, f'1.2{illegal_char}3')
38-
with pytest.raises(ValueError, match='must match'):
38+
with pytest.raises(ValueError, match='in conformance'):
3939
URI(_BASE_URL, '1.2.3', f'4.5{illegal_char}6')
40-
with pytest.raises(ValueError, match='must match'):
40+
with pytest.raises(ValueError, match='in conformance'):
4141
URI(_BASE_URL, '1.2.3', '4.5.6', f'7.8{illegal_char}9')
4242

4343

4444
@pytest.mark.parametrize('illegal_uid', ['.23', '1.2..4', '1.2.', '.'])
4545
def test_uid_illegal_format(illegal_uid):
4646
"""Checks *ValueError* is raised if a UID is in an illegal format."""
47-
with pytest.raises(ValueError, match='must match'):
47+
with pytest.raises(ValueError, match='in conformance'):
4848
URI(_BASE_URL, illegal_uid)
49-
with pytest.raises(ValueError, match='must match'):
49+
with pytest.raises(ValueError, match='in conformance'):
5050
URI(_BASE_URL, '1.2.3', illegal_uid)
51-
with pytest.raises(ValueError, match='must match'):
51+
with pytest.raises(ValueError, match='in conformance'):
5252
URI(_BASE_URL, '1.2.3', '4.5.6', illegal_uid)
5353

5454

@@ -70,6 +70,21 @@ def test_uid_length():
7070
URI(_BASE_URL, '1.2.3', '4.5.6', uid_65)
7171

7272

73+
@pytest.mark.parametrize('uid', ['13-abc', 'hello', 'is it', 'me you\'re'])
74+
def test_uid_permissive_valid(uid):
75+
"""Tests valid "permissive" UIDs are accommodated iff the flag is set."""
76+
with pytest.raises(ValueError, match='in conformance'):
77+
URI(_BASE_URL, uid, permissive=False)
78+
URI(_BASE_URL, uid, permissive=True)
79+
80+
81+
@pytest.mark.parametrize('uid', ['1.23.5@', '1/23.4'])
82+
def test_uid_permissive_invalid(uid):
83+
"""Tests that invalid "permissive" UIDs are rejected."""
84+
with pytest.raises(ValueError, match='Permissive mode'):
85+
URI(_BASE_URL, uid, 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'):
@@ -432,6 +447,27 @@ def test_update(uri_args, update_args, expected_uri_args):
432447
assert actual_uri == expected_uri
433448

434449

450+
@pytest.mark.parametrize('original,update,expected', [
451+
(None, None, False),
452+
(None, False, False),
453+
(None, True, True),
454+
(False, None, False),
455+
(True, None, True),
456+
(False, False, False),
457+
(False, True, True),
458+
(True, False, False),
459+
(True, True, True),
460+
])
461+
def test_update_permissive(original, update, expected):
462+
"""Tests for the expected value of `permissive` flag in `URI.update()`."""
463+
if original is None:
464+
original_uri = URI(_BASE_URL)
465+
else:
466+
original_uri = URI(_BASE_URL, permissive=original)
467+
updated_uri = original_uri.update(permissive=update)
468+
assert updated_uri.permissive == expected
469+
470+
435471
@pytest.mark.parametrize('uri_args,update_args,error_msg', [
436472
((_BASE_URL, ), (None, None, '1', None, None),
437473
'`study_instance_uid` missing'),

0 commit comments

Comments
 (0)