Skip to content

Commit 6ce341a

Browse files
committed
Fix handling of missing values
- if the values for a condition needing a value cannot be parsed, the condition is now ignored - fixes pydicom#20 - add handling of a few more simple conditions
1 parent 2f092af commit 6ce341a

File tree

5 files changed

+132
-14
lines changed

5 files changed

+132
-14
lines changed

CHANGES.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ The released versions correspond to PyPi releases.
44
## [Version 0.4.0] (Unreleased)
55

66
### Fixes
7-
* condition parser: multiple or expressions handled correctly
7+
* Condition parser: multiple or expressions handled correctly
88
* Condition parser: handle a few more complicated conditions
99
* Condition parser: handle conditions without values,
1010
see [#15](../.. /issues/15)
1111
* Condition parser: fixed handling of "zero" and "non-zero" values
1212
see [#17](../.. /issues/17)
13+
* Condition parser: handle a few more simple expressions
14+
* Condition parser: ignore equality conditions with missing values
15+
(caused crash, see [#20](../.. /issues/20)
1316

1417
### Changes
1518
* Removed support for Python 3.6

dicom_validator/spec_reader/condition.py

+5
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ class Condition:
1515
otherwise not
1616
'MU': the object is mandatory if the condition is fulfilled,
1717
otherwise is user defined
18+
'MC': the object is mandatory if the condition is fulfilled,
19+
otherwise another condition will be checked
1820
tag: the ID of the required tag in the form '(####,####)' or None
1921
index: the index of the tag for multi-valued tags or 0
2022
values: a list of values the tag shall have if the condition
@@ -38,6 +40,9 @@ def __init__(self, ctype: Optional[str] = None,
3840
self.or_conditions: List[Condition] = []
3941
self.other_condition: Optional[Condition] = None
4042

43+
def __repr__(self):
44+
return f"Condition type={self.type} op='{self.operator}' tag={self.tag} values={self.values}"
45+
4146
@classmethod
4247
def read_condition(cls, condition_dict: Dict,
4348
condition: Optional["Condition"] = None) -> "Condition":

dicom_validator/spec_reader/condition_parser.py

+26-9
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,13 @@ class ConditionParser:
3131
r'(?P<id>\([\dA-Fa-f]{4},[\dA-Fa-f]{4}\))?(,? Value (?P<index>\d))?$')
3232

3333
operators = OrderedDict([
34+
(' is present and the value is ', '='),
35+
(' is present and has a value of ', '='),
3436
(' is greater than ', '>'),
3537
(' is present and equals ', '='),
3638
(' is present with a value of ', '='),
37-
(' value is ', '='),
39+
(' is set to ', '='),
40+
(' equals one of the following values: ', '='),
3841
(' has a value of more than ', '>'),
3942
(' has a value greater than ', '>'),
4043
(' has a value of ', '='),
@@ -43,16 +46,17 @@ class ConditionParser:
4346
(' equals other than ', '!='),
4447
(' equals ', '='),
4548
(' is other than ', '!='),
46-
(' is present and the value is ', '='),
47-
(' is present and has a value of ', '='),
4849
(' is one of the following: ', '='),
4950
(' is present and has a value', '++'),
5051
(' is present', '+'),
52+
(' value is not ', '!='),
53+
(' value is ', '='),
5154
(' is sent', '+'),
5255
(' is not sent', '-'),
5356
(' is not present', '-'),
5457
(' is absent', '-'),
5558
(' is not equal to ', '!='),
59+
(' is equal to ', '='),
5660
(' is not ', '!='),
5761
(' is ', '='),
5862
(' is: ', '='),
@@ -115,9 +119,18 @@ def _parse_tag_expression(
115119
if operator in ('=', '!=', '>', '<'):
116120
values, rest = self._parse_tag_values(rest)
117121
# fixup special values
118-
if values and values[0].startswith('non-zero'):
119-
operator = '!='
120-
values = ['0'] if values[0] == 'non-zero' else ['']
122+
if values:
123+
if values[0].startswith('non-zero'):
124+
operator = '!='
125+
values = ['0'] if values[0] == 'non-zero' else ['']
126+
elif values[0].startswith('non-null'):
127+
operator = '++'
128+
values = []
129+
elif values[0].startswith('zero-length'):
130+
values = ['']
131+
else:
132+
# failed to parse mandatory values - ignore the condition
133+
return Condition(ctype='U'), None
121134
elif operator == '=>':
122135
value_string, rest = self._split_value_part(rest)
123136
tag, _ = self._parse_tag(value_string)
@@ -203,7 +216,7 @@ def _parse_tag_values(
203216
def _split_value_part(self, value_string: str) -> Tuple[str, str]:
204217
value_string = value_string.strip()
205218
end_index = self._end_index_for_stop_chars(
206-
value_string, [';', '.', ', and ', ' and '])
219+
value_string, [';', '.', ', and ', ' and ', ':'])
207220
return value_string[:end_index], value_string[end_index:]
208221

209222
@staticmethod
@@ -232,13 +245,17 @@ def _get_const_value(self, value: str) -> Tuple[Optional[str], str]:
232245
value = value.strip()
233246
if value[0] == value[-1] == '"':
234247
return value[1:-1], ''
235-
if re.match('^[A-Z0-9_ ]+$', value) is not None:
248+
if re.match('^[A-Z0-9][A-Za-z0-9_ ]*$', value) is not None:
236249
return value, ''
250+
# sometimes a value explanation is present in scopes
251+
match = re.match(r'^([A-Z0-9_ ]+)\([A-Za-z ]+\)+$', value)
252+
if match is not None:
253+
return match.group(1).strip(), ''
237254
if value == 'zero length':
238255
return '', ''
239256
if value == 'zero':
240257
return '0', ''
241-
if value in ('non-zero', 'non-zero length'):
258+
if value in ('non-zero', 'non-zero length', 'non-null', 'zero-length'):
242259
return value, ''
243260
match = re.match(r'^.* \(\"([\d.]+)\"\)(.*)$', value)
244261
if match is not None:

dicom_validator/spec_reader/tests/test_condition_parser.py

+91-4
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,13 @@ def test_ignore_condition_without_tag(self):
4545
)
4646
self.assertEqual('U', result.type)
4747

48-
def test_handle_condition_without_value(self):
48+
def test_ignore_condition_without_value(self):
4949
# regression test for #15
5050
result = self.parser.parse(
5151
'required if Selector Attribute (0072,0026) is nested in '
5252
'one or more Sequences or is absent.'
5353
)
54-
self.assertEqual('MN', result.type)
55-
self.assertEqual('(0072,0026)', result.tag)
56-
self.assertEqual('=', result.operator)
54+
self.assertEqual('U', result.type)
5755

5856

5957
class SimpleConditionParserTest(ConditionParserTest):
@@ -86,6 +84,42 @@ def test_is_present(self):
8684
self.assertEqual('+', result.operator)
8785
self.assertEqual([], result.values)
8886

87+
def test_equal_sign(self):
88+
result = self.parser.parse(
89+
'Required if Pixel Component Organization = Bit aligned.'
90+
)
91+
self.assertEqual('MN', result.type)
92+
self.assertEqual('(0018,6044)', result.tag)
93+
self.assertEqual('=', result.operator)
94+
self.assertEqual(['Bit aligned'], result.values)
95+
96+
def test_value_has_explanation(self):
97+
result = self.parser.parse(
98+
'Required if Conversion Type (0008,0064) is DF (Digitized Film).'
99+
)
100+
self.assertEqual('MN', result.type)
101+
self.assertEqual('(0008,0064)', result.tag)
102+
self.assertEqual('=', result.operator)
103+
self.assertEqual(['DF'], result.values)
104+
105+
def test_values_have_explanation(self):
106+
result = self.parser.parse(
107+
'Required if Conversion Type (0008,0064) is SD (Scanned Document) or SI (Scanned Image).'
108+
)
109+
self.assertEqual('MN', result.type)
110+
self.assertEqual('(0008,0064)', result.tag)
111+
self.assertEqual('=', result.operator)
112+
self.assertEqual(['SD', 'SI'], result.values)
113+
114+
def test_is_with_colon_after_value(self):
115+
result = self.parser.parse(
116+
'Required if the value of Reformatting Operation Type (0072,0510) is 3D_RENDERING:'
117+
)
118+
self.assertEqual('MN', result.type)
119+
self.assertEqual('(0072,0510)', result.tag)
120+
self.assertEqual('=', result.operator)
121+
self.assertEqual(['3D_RENDERING'], result.values)
122+
89123
def test_is_present_with_value(self):
90124
result = self.parser.parse(
91125
'Required if Responsible Person is present and has a value.'
@@ -95,6 +129,15 @@ def test_is_present_with_value(self):
95129
self.assertEqual('++', result.operator)
96130
self.assertEqual([], result.values)
97131

132+
def test_is_set_to(self):
133+
result = self.parser.parse(
134+
'Required if Ophthalmic Volumetric Properties Flag (0022,1622) is set to YES. May be present otherwise.'
135+
)
136+
self.assertEqual('MU', result.type)
137+
self.assertEqual('(0022,1622)', result.tag)
138+
self.assertEqual('=', result.operator)
139+
self.assertEqual(['YES'], result.values)
140+
98141
def test_is_present_tag_name_with_digit(self):
99142
result = self.parser.parse(
100143
'Required if 3D Mating Point (0068,64C0) is present.'
@@ -135,6 +178,14 @@ def test_required_only_if(self):
135178
self.assertEqual('-', result.operator)
136179
self.assertEqual([], result.values)
137180

181+
def test_values_failed_parsing(self):
182+
"""Regression test for #20 (if the value could not be parsed ignore the condition)"""
183+
result = self.parser.parse(
184+
'Required if Constraint Violation Significance (0082,0036) '
185+
'is only significant under certain conditions.'
186+
)
187+
self.assertEqual('U', result.type)
188+
138189

139190
class ValueConditionParserTest(ConditionParserTest):
140191
def test_equality_tag(self):
@@ -318,6 +369,14 @@ def test_present_with_value(self):
318369
self.assertEqual('=', result.operator)
319370
self.assertEqual(['AS'], result.values)
320371

372+
def test_value_is_not(self):
373+
result = self.parser.parse(
374+
'Required if Shadow Style (0070,0244) value is not OFF.'
375+
)
376+
self.assertEqual('MN', result.type)
377+
self.assertEqual('!=', result.operator)
378+
self.assertEqual(['OFF'], result.values)
379+
321380
def test_other_than(self):
322381
result = self.parser.parse(
323382
'Required if Decay Correction (0054,1102) is other than NONE.'
@@ -335,6 +394,14 @@ def test_not_equal_to(self):
335394
self.assertEqual('!=', result.operator)
336395
self.assertEqual(['UNDEFINED'], result.values)
337396

397+
def test_equal_to(self):
398+
result = self.parser.parse(
399+
'Required if Blending Mode (0070,1B06) is equal to FOREGROUND.'
400+
)
401+
self.assertEqual('MN', result.type)
402+
self.assertEqual('=', result.operator)
403+
self.assertEqual(['FOREGROUND'], result.values)
404+
338405
def test_present_with_value_of(self):
339406
result = self.parser.parse(
340407
'Required if Partial View '
@@ -361,6 +428,26 @@ def test_non_zero(self):
361428
self.assertEqual('!=', result.operator)
362429
self.assertEqual(['0'], result.values)
363430

431+
def test_non_null(self):
432+
result = self.parser.parse(
433+
'Required if value Transfer Tube Number (300A,02A2) is non-null.'
434+
)
435+
assert result.type == 'MN'
436+
assert result.tag == '(300A,02A2)'
437+
assert result.operator == '++'
438+
self.assertEqual([], result.values)
439+
440+
def test_zero_length(self):
441+
result = self.parser.parse(
442+
'Required if Material ID (300A,00E1) is zero-length. '
443+
'May be present if Material ID (300A,00E1) is non-zero length.'
444+
)
445+
assert result.type == 'MC'
446+
assert result.tag == '(300A,00E1)'
447+
assert result.operator == '='
448+
self.assertEqual([''], result.values)
449+
assert result.other_condition is not None
450+
364451
def test_greater_than_zero(self):
365452
result = self.parser.parse(
366453
'Required if Number of Beams (300A,0080) is greater than zero'

dicom_validator/tests/fixtures/2021d/json/dict_info.json

+6
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@
4747
"vm": "1",
4848
"vr": "PN"
4949
},
50+
"(0018,6044)": {
51+
"name": "Pixel Component Organization",
52+
"prop": "",
53+
"vm": "1",
54+
"vr": "US"
55+
},
5056
"(0028,0008)": {
5157
"name": "Number of Frames",
5258
"prop": "",

0 commit comments

Comments
 (0)