Skip to content

Commit 6e49f68

Browse files
committed
Add tests for error handler
- add error handler example - add some rudimentary documentation - minor refactoring
1 parent 1c26881 commit 6e49f68

File tree

11 files changed

+387
-97
lines changed

11 files changed

+387
-97
lines changed

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,43 @@ Support for more cases may be added in the future.
147147
Defined terms are _not_ checked, because they are allowed to be user-defined, which means
148148
that any value may be valid.
149149

150+
### Using the IOD validator programmatically
151+
The commandline tool is based on the `DicomFileValidator` and `IODValidator` classes, which
152+
can also be used directly in another application, if you want to have better control of the output.
153+
To initialize such a class, you need the DICOM information (as a `DicomInfo` instance)
154+
read from the JSON files as described above.
155+
First, the needed edition of the DICOM standard has to be downloaded and processed.
156+
This needs to be done only once for a given edition:
157+
158+
```py
159+
from pathlib import Path
160+
from dicom_validator.spec_reader.edition_reader import EditionReader
161+
162+
reader = EditionReader() # uses the default location <user home>/dicom-validator
163+
# if the edition is not downloaded yet, load_dicom_info will download and process it
164+
dicom_info = reader.load_dicom_info("2025b") # could also use "current" for the current standard
165+
```
166+
Using this DICOM information, you can validate a DICOM file, or all DICOM files
167+
in a given directory:
168+
```py
169+
from dicom_validator.validator.dicom_file_validator import DicomFileValidator
170+
171+
validator = DicomFileValidator(dicom_info)
172+
result = validator.validate(dicom_file_path)
173+
```
174+
This will create the same output as the command line tool. If you need your own error
175+
handling instead, you can write your own error handler, and use it instead:
176+
```py
177+
from dicom_validator.validator.html_error_handler import HtmlErrorHandler
178+
...
179+
handler = HtmlErrorHandler(dicom_info)
180+
validator = DicomFileValidator(dicom_info, error_handler=handler)
181+
validator.validate(dicom_file_path)
182+
with open("result.html", "w") as f:
183+
f.write(handler.html)
184+
```
185+
This uses the `HtmlErrorHandler` class, which is included as a simple error handler example.
186+
150187
## dump_dcm_info
151188

152189
This is a very simple DICOM dump tool, which uses

dicom_validator/spec_reader/edition_reader.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,11 @@ class EditionReader:
5151
dict_info_json = "dict_info.json"
5252
uid_info_json = "uid_info.json"
5353

54-
def __init__(self, path: str | Path) -> None:
55-
self.path = Path(path)
54+
def __init__(self, path: str | Path | None = None) -> None:
55+
if path is not None:
56+
self.path = Path(path)
57+
else:
58+
self.path = Path.home() / "dicom-validator"
5659
self.logger = logging.getLogger()
5760
if not self.logger.hasHandlers():
5861
self.logger.addHandler(logging.StreamHandler(sys.stdout))

dicom_validator/tests/validator/conftest.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,24 @@
22
import logging
33
from pathlib import Path
44

5+
import pydicom
56
import pytest
7+
from pydicom import (
8+
dcmread,
9+
dcmwrite,
10+
FileMetaDataset,
11+
DataElement,
12+
Sequence,
13+
Dataset,
14+
uid,
15+
)
16+
from pydicom.datadict import dictionary_VR
17+
from pydicom.filebase import DicomBytesIO
18+
from pydicom.tag import Tag
619

720
from dicom_validator.spec_reader.edition_reader import EditionReader
821
from dicom_validator.validator.dicom_info import DicomInfo
22+
from dicom_validator.validator.iod_validator import IODValidator
923

1024
CURRENT_EDITION = "2025d"
1125

@@ -70,3 +84,52 @@ def disable_logging():
7084
logging.disable(logging.CRITICAL)
7185
yield
7286
logging.disable(logging.DEBUG)
87+
88+
89+
def new_data_set(tags, *, top_level: bool = True):
90+
"""Create a DICOM data set with the given attributes"""
91+
tags = tags or {}
92+
data_set = Dataset()
93+
if not tags:
94+
return data_set
95+
for tag, value in tags.items():
96+
tag = Tag(tag) # raises for invalid tag
97+
try:
98+
vr = dictionary_VR(tag)
99+
except KeyError:
100+
vr = "LO"
101+
if vr == "SQ":
102+
items = []
103+
for item_tags in value:
104+
items.append(new_data_set(item_tags, top_level=False))
105+
value = Sequence(items)
106+
data_set[tag] = DataElement(tag, vr, value)
107+
if not top_level:
108+
# this is a sequence item
109+
return data_set
110+
111+
# write the dataset into a file and read it back to ensure the real behavior
112+
if "SOPInstanceUID" not in data_set:
113+
data_set.SOPInstanceUID = "1.2.3"
114+
data_set.file_meta = FileMetaDataset()
115+
data_set.file_meta.TransferSyntaxUID = uid.ExplicitVRLittleEndian
116+
fp = DicomBytesIO()
117+
kwargs = (
118+
{"write_like_original": False}
119+
if int(pydicom.__version_info__[0]) < 3
120+
else {"enforce_file_format": True}
121+
)
122+
dcmwrite(fp, data_set, **kwargs)
123+
fp.seek(0)
124+
return dcmread(fp)
125+
126+
127+
@pytest.fixture
128+
def validator(dicom_info, request):
129+
marker = request.node.get_closest_marker("tag_set")
130+
if marker is None:
131+
tag_set = {}
132+
else:
133+
tag_set = marker.args[0]
134+
data_set = new_data_set(tag_set)
135+
return IODValidator(data_set, dicom_info, log_level=logging.WARNING)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
from pydicom import uid
5+
6+
from dicom_validator.validator.dicom_file_validator import DicomFileValidator
7+
from dicom_validator.validator.html_error_handler import HtmlErrorHandler
8+
from dicom_validator.validator.error_handler import (
9+
ValidationResultHandler,
10+
)
11+
from dicom_validator.validator.validation_result import (
12+
ValidationResult,
13+
)
14+
15+
16+
class GenericErrorHandler(ValidationResultHandler):
17+
def __init__(self):
18+
self.logs = []
19+
20+
def handle_validation_start(self, result: ValidationResult):
21+
self.logs.append("Starting Validation")
22+
23+
def handle_validation_result(self, result: ValidationResult):
24+
self.logs.append("Finished Validation")
25+
self.logs.append(f"Status: {result.status.name}")
26+
self.logs.append(f"Error: {result.errors}")
27+
28+
29+
@pytest.mark.tag_set(
30+
{
31+
"SOPClassUID": uid.CTImageStorage,
32+
"PatientName": "XXX",
33+
"PatientID": "ZZZ",
34+
}
35+
)
36+
def test_generic_error_handler(validator) -> None:
37+
handler = GenericErrorHandler()
38+
validator.handler = handler
39+
result = validator.validate()
40+
nr_errors = result.errors
41+
assert handler.logs == [
42+
"Starting Validation",
43+
"Finished Validation",
44+
"Status: Failed",
45+
f"Error: {nr_errors}",
46+
]
47+
48+
49+
@pytest.fixture(scope="module")
50+
def dicom_fixture_path():
51+
yield Path(__file__).parent.parent / "fixtures" / "dicom"
52+
53+
54+
def test_html_error_handler(dicom_info, dicom_fixture_path) -> None:
55+
rtdose_path = dicom_fixture_path / "rtdose.dcm"
56+
handler = HtmlErrorHandler(dicom_info)
57+
validator = DicomFileValidator(dicom_info, error_handler=handler)
58+
validator.validate(rtdose_path)
59+
assert (
60+
'<h3><a href="https://dicom.nema.org/medical/dicom/current/output/chtml/part03'
61+
'/sect_C.8.8.html">RT Series</a></h3>' in handler.html
62+
)

dicom_validator/tests/validator/test_iod_validator.py

Lines changed: 1 addition & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,14 @@
11
import logging
22

3-
import pydicom
43
import pytest
5-
from pydicom import DataElement, uid, Sequence, dcmwrite, dcmread
6-
from pydicom.datadict import dictionary_VR
7-
from pydicom.dataset import Dataset, FileMetaDataset
8-
from pydicom.filebase import DicomBytesIO
9-
from pydicom.tag import Tag
4+
from pydicom import uid
105

116
from dicom_validator.tests.utils import has_tag_error
12-
from dicom_validator.validator.iod_validator import IODValidator
137
from dicom_validator.validator.validation_result import Status, ErrorCode
148

159
pytestmark = pytest.mark.usefixtures("disable_logging")
1610

1711

18-
def new_data_set(tags, *, top_level: bool = True):
19-
"""Create a DICOM data set with the given attributes"""
20-
tags = tags or {}
21-
data_set = Dataset()
22-
if not tags:
23-
return data_set
24-
for tag, value in tags.items():
25-
tag = Tag(tag) # raises for invalid tag
26-
try:
27-
vr = dictionary_VR(tag)
28-
except KeyError:
29-
vr = "LO"
30-
if vr == "SQ":
31-
items = []
32-
for item_tags in value:
33-
items.append(new_data_set(item_tags, top_level=False))
34-
value = Sequence(items)
35-
data_set[tag] = DataElement(tag, vr, value)
36-
if not top_level:
37-
# this is a sequence item
38-
return data_set
39-
40-
# write the dataset into a file and read it back to ensure the real behavior
41-
if "SOPInstanceUID" not in data_set:
42-
data_set.SOPInstanceUID = "1.2.3"
43-
data_set.file_meta = FileMetaDataset()
44-
data_set.file_meta.TransferSyntaxUID = pydicom.uid.ExplicitVRLittleEndian
45-
fp = DicomBytesIO()
46-
kwargs = (
47-
{"write_like_original": False}
48-
if int(pydicom.__version_info__[0]) < 3
49-
else {"enforce_file_format": True}
50-
)
51-
dcmwrite(fp, data_set, **kwargs)
52-
fp.seek(0)
53-
return dcmread(fp)
54-
55-
56-
@pytest.fixture
57-
def validator(dicom_info, request):
58-
marker = request.node.get_closest_marker("tag_set")
59-
if marker is None:
60-
tag_set = {}
61-
else:
62-
tag_set = marker.args[0]
63-
data_set = new_data_set(tag_set)
64-
return IODValidator(data_set, dicom_info, logging.WARNING)
65-
66-
6712
class TestIODValidator:
6813
"""Tests IODValidator.
6914
Note: some of the fixture data are not consistent with the DICOM Standard.

dicom_validator/tests/validator/test_iod_validator_func_groups.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def validator(dicom_info, request):
8383
marker = request.node.get_closest_marker("per_frame_macros")
8484
per_frame_macros = {} if marker is None else marker.args[0]
8585
data_set = new_data_set(shared_macros, per_frame_macros)
86-
return IODValidator(data_set, dicom_info, logging.WARNING)
86+
return IODValidator(data_set, dicom_info, log_level=logging.WARNING)
8787

8888

8989
FRAME_ANATOMY = {
@@ -128,7 +128,7 @@ def test_missing_func_groups(self, dicom_info: DicomInfo, caplog):
128128
data_set = new_data_set({}, {})
129129
del data_set[0x52009229]
130130
del data_set[0x52009230]
131-
validator = IODValidator(data_set, dicom_info, logging.WARNING)
131+
validator = IODValidator(data_set, dicom_info, log_level=logging.WARNING)
132132
result = validator.validate()
133133
group_result = self.ensure_group_result(result)
134134
assert DicomTag(0x5200_9229) in group_result
Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
import logging
22
import os
3-
import sys
43
from os import PathLike
54

65
from pydicom import config, dcmread
76
from pydicom.errors import InvalidDicomError
87

8+
from dicom_validator.validator.dicom_info import DicomInfo
99
from dicom_validator.validator.error_handler import ValidationResultHandler
1010
from dicom_validator.validator.iod_validator import IODValidator
11-
from dicom_validator.validator.dicom_info import DicomInfo
1211
from dicom_validator.validator.validation_result import ValidationResult, Status
1312

1413

1514
class DicomFileValidator:
15+
"""Validates a single DICOM file or all DICOM files in a directory."""
1616

1717
def __init__(
1818
self,
@@ -22,37 +22,51 @@ def __init__(
2222
suppress_vr_warnings: bool = False,
2323
error_handler: ValidationResultHandler | None = None,
2424
) -> None:
25+
"""Initializes a DicomFileValidator object.
26+
27+
Parameters
28+
----------
29+
dicom_info : DicomInfo
30+
DICOM information as read from the JSON files created from the standard
31+
log_level : int
32+
The log level to use with the default error handler
33+
force_read: bool
34+
If `True`, the DICOM file is tried to read even if it may not be valid
35+
suppress_vr_warnings: bool
36+
By default, values not valid for the given VR are reported, using the
37+
`pydicom` validation. If set to `True`, this validation is not performed.
38+
error_handler: ValidationResultHandler or None
39+
If set, this handler will be used to handle the validation result,
40+
otherwise the default handler is used that logs errors to the console.
41+
"""
2542
self._dicom_info = dicom_info
26-
self.logger = logging.getLogger()
27-
self.logger.level = log_level
28-
if not self.logger.hasHandlers():
29-
self.logger.addHandler(logging.StreamHandler(sys.stdout))
43+
self.log_level = log_level
3044
self._force_read = force_read
3145
self._suppress_vr_warnings = suppress_vr_warnings
3246
self._error_handler = error_handler
3347

3448
def validate(self, path: str | PathLike) -> dict[str, ValidationResult]:
35-
errors: dict[str, ValidationResult] = {}
49+
results: dict[str, ValidationResult] = {}
3650
path = os.fspath(path)
3751
if not os.path.exists(path):
38-
errors[path] = ValidationResult(status=Status.MissingFile, errors=1)
39-
self.logger.warning('\n"%s" does not exist - skipping', path)
52+
results[path] = ValidationResult(
53+
file_path=path, status=Status.MissingFile, errors=1
54+
)
4055
else:
4156
if os.path.isdir(path):
42-
errors.update(self.validate_dir(path))
57+
results.update(self.validate_dir(path))
4358
else:
44-
errors.update(self.validate_file(path))
45-
return errors
59+
results.update(self.validate_file(path))
60+
return results
4661

4762
def validate_dir(self, dir_path: str) -> dict[str, ValidationResult]:
48-
errors: dict[str, ValidationResult] = {}
63+
results: dict[str, ValidationResult] = {}
4964
for root, _, names in os.walk(dir_path):
5065
for name in names:
51-
errors.update(self.validate(os.path.join(root, name)))
52-
return errors
66+
results.update(self.validate(os.path.join(root, name)))
67+
return results
5368

5469
def validate_file(self, file_path: str) -> dict[str, ValidationResult]:
55-
self.logger.info('\nProcessing DICOM file "%s"', file_path)
5670
try:
5771
# dcmread calls validate_value by default. If values don't match
5872
# required VR (value representation), it emits a warning but
@@ -63,14 +77,18 @@ def validate_file(self, file_path: str) -> dict[str, ValidationResult]:
6377
data_set = dcmread(file_path, defer_size=1024, force=self._force_read)
6478

6579
except InvalidDicomError:
66-
self.logger.error(f"Invalid DICOM file: {file_path}")
67-
return {file_path: ValidationResult(status=Status.InvalidFile, errors=1)}
80+
return {
81+
file_path: ValidationResult(
82+
file_path=file_path, status=Status.InvalidFile, errors=1
83+
)
84+
}
6885
return {
6986
file_path: IODValidator(
7087
data_set,
7188
self._dicom_info,
72-
self.logger.level,
89+
log_level=self.log_level,
7390
suppress_vr_warnings=self._suppress_vr_warnings,
7491
error_handler=self._error_handler,
92+
file_path=file_path,
7593
).validate()
7694
}

0 commit comments

Comments
 (0)