8000 BUG: Reject extra JSON content in strict comparisons by jdotc1 · Pull Request #567 · QIICR/dcmqi · GitHub
[go: up one dir, main page]

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/cmake-macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ jobs:
build-macos:

runs-on: ${{ matrix.runner }}
# So far, a full superbuild (no caches) has always been complete in < 45 minutes.
timeout-minutes: 45
# Cold Intel superbuilds can exceed 45 minutes before tests and packaging.
timeout-minutes: ${{ matrix.arch == 'x86_64' && 90 || 45 }}

permissions:
contents: write
Expand Down Expand Up @@ -144,6 +144,7 @@ jobs:

# Build dcmqi
- name: "Build dcmqi"
id: build
run: |
cd ${{ github.workspace }}/dcmqi-build && ninja

Expand Down
7 changes: 7 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,13 @@ endif()

add_subdirectory("libsrc")

if(BUILD_TESTING)
add_test(NAME dcmqi_jsoncompare
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unittest discover -s <source>/util writes util/__pycache__/*.pyc into the source tree, so an out-of-source ctest run leaves the checkout dirty, which is a problem for read-only or shared source trees. Adding -B to the Python invocation (or setting PYTHONDONTWRITEBYTECODE=1 via set_tests_properties(... ENVIRONMENT ...)) avoids it.

🤖 Comment generated by Claude (Claude Code), posted on behalf of @fedorov.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added -B to the utility CTest, all nine metadata-comparison commands, and CLI test subprocesses. The out-of-source harness ran all ten Python CTests with PYTHONDONTWRITEBYTECODE unset and created no source-tree .pyc files.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @fedorov edorov, thank you again for the thorough review. I've addressed all seven points in commit 6f8ee01 and responded to each comment inline.

When you have a chance, would you mind taking another look? I'd be happy to address anything that remains.

Thanks again!

COMMAND "${Python3_EXECUTABLE}" -B -m unittest discover
-s "${CMAKE_CURRENT_SOURCE_DIR}/util" -p test_jsoncompare.py
)
endif()

if(DCMQI_BUILD_APPS)
add_subdirectory("apps")
endif()
Expand Down
6 changes: 4 additions & 2 deletions apps/paramaps/Testing/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,10 @@ dcmqi_add_test(
dcmqi_add_test(
NAME ${MODULE_NAME}_meta_roundtrip
MODULE_NAME ${MODULE_NAME}
COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/util/comparejson.py
COMMAND ${Python3_EXECUTABLE} -B ${CMAKE_SOURCE_DIR}/util/comparejson.py
${CMAKE_SOURCE_DIR}/doc/examples/pm-example.json
${MODULE_TEMP_DIR}/makeNRRDParametricMap-meta.json
"['DerivationDescription', 'DerivedPixelContrast']"
TEST_DEPENDS
${dcm2itk}_makeNRRDParametricMap
)
Expand All @@ -185,9 +186,10 @@ dcmqi_add_test(
dcmqi_add_test(
NAME ${MODULE_NAME}_FP_meta_roundtrip
MODULE_NAME ${MODULE_NAME}
COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/util/comparejson.py
COMMAND ${Python3_EXECUTABLE} -B ${CMAKE_SOURCE_DIR}/util/comparejson.py
${CMAKE_SOURCE_DIR}/doc/examples/pm-example-float.json
${MODULE_TEMP_DIR}/makeNRRDParametricMapFP-meta.json
"['DerivationDescription', 'DerivedPixelContrast']"
TEST_DEPENDS
${dcm2itk}_makeNRRDParametricMapFP
)
Expand Down
12 changes: 6 additions & 6 deletions apps/seg/Testing/CMakeLists.txt
8BD0
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ dcmqi_add_test(
dcmqi_add_test(
NAME ${dcm2itk}_makeNRRD_merged_segment_file_JSON
MODULE_NAME ${MODULE_NAME}
COMMAND python ${CMAKE_SOURCE_DIR}/util/comparejson.py
COMMAND python -B ${CMAKE_SOURCE_DIR}/util/comparejson.py
${CMAKE_SOURCE_DIR}/doc/examples/seg-example_multiple_segments_merged.json
${MODULE_TEMP_DIR}/makeNRRD_merged_segment_file-meta.json
TEST_DEPENDS
Expand Down Expand Up @@ -492,7 +492,7 @@ dcmqi_add_test(
dcmqi_add_test(
NAME ${dcm2itk}_makeNRRD_merged_segment_files_from_partial_overlaps_JSON
MODULE_NAME ${MODULE_NAME}
COMMAND python ${CMAKE_SOURCE_DIR}/util/comparejson.py
COMMAND python -B ${CMAKE_SOURCE_DIR}/util/comparejson.py
${CMAKE_SOURCE_DIR}/doc/examples/seg-example_partial_overlaps.json
${MODULE_TEMP_DIR}/makeNRRD_merged_segment_files_from_partial_overlaps-meta.json
TEST_DEPENDS
Expand All @@ -504,7 +504,7 @@ dcmqi_add_test(
dcmqi_add_test(
NAME seg_meta_roundtrip
MODULE_NAME ${MODULE_NAME}
COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/util/comparejson.py
COMMAND ${Python3_EXECUTABLE} -B ${CMAKE_SOURCE_DIR}/util/comparejson.py
${CMAKE_SOURCE_DIR}/doc/examples/seg-example.json
${MODULE_TEMP_DIR}/makeNRRD-meta.json
TEST_DEPENDS
Expand All @@ -519,7 +519,7 @@ dcmqi_add_test(
dcmqi_add_test(
NAME labelmap_meta_roundtrip
MODULE_NAME ${MODULE_NAME}
COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/util/comparejson.py
COMMAND ${Python3_EXECUTABLE} -B ${CMAKE_SOURCE_DIR}/util/comparejson.py
${CMAKE_SOURCE_DIR}/doc/examples/seg-example.json
${MODULE_TEMP_DIR}/makeNRRD_labelmap_direct-meta.json
TEST_DEPENDS
Expand All @@ -532,7 +532,7 @@ dcmqi_add_test(
dcmqi_add_test(
NAME labelmap_gapped_meta_roundtrip
MODULE_NAME ${MODULE_NAME}
COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/util/comparejson.py
COMMAND ${Python3_EXECUTABLE} -B ${CMAKE_SOURCE_DIR}/util/comparejson.py
${CMAKE_SOURCE_DIR}/doc/examples/seg-example_liver_spine_gapped_merged.json
${MODULE_TEMP_DIR}/makeNRRD_labelmap_gapped-meta.json
TEST_DEPENDS
Expand All @@ -542,7 +542,7 @@ dcmqi_add_test(
dcmqi_add_test(
NAME multi_seg_meta_roundtrip
MODULE_NAME ${MODULE_NAME}
COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/util/comparejson.py
COMMAND ${Python3_EXECUTABLE} -B ${CMAKE_SOURCE_DIR}/util/comparejson.py
${CMAKE_SOURCE_DIR}/doc/examples/seg-example_multiple_segments.json
${MODULE_TEMP_DIR}/makeNRRD_multiple_segments-meta.json
"['labelID']"
Expand Down
4 changes: 2 additions & 2 deletions apps/sr/Testing/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,10 @@ set(MODULE_NAME tid1500)
dcmqi_add_test(
NAME ${MODULE_NAME}_meta_roundtrip
MODULE_NAME ${READER_MODULE_NAME}
COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/util/comparejson.py
COMMAND ${Python3_EXECUTABLE} -B ${CMAKE_SOURCE_DIR}/util/comparejson.py
${EXAMPLES}/sr-tid1500-ct-liver-example.json
${MODULE_TEMP_DIR}/sr-tid1500-ct-liver-example.json
"['activitySession', 'timePoint', 'imageLibrary', 'compositeContext','procedureReported']"
"['activitySession', 'timePoint', 'imageLibrary', 'compositeContext','procedureReported', 'TrackingUniqueIdentifier']"
TEST_DEPENDS
${READER_MODULE_NAME}_ct-liver
)
1 change: 1 addition & 0 deletions doc/examples/seg-example.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"SeriesDescription": "Segmentation",
"SeriesNumber": "300",
"InstanceNumber": "1",
"BodyPartExamined": "",

"segmentAttributes": [
[
Expand Down
1 change: 1 addition & 0 deletions doc/examples/seg-example_multiple_segments.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"SeriesDescription": "Segmentation",
"SeriesNumber": "300",
"InstanceNumber": "1",
"BodyPartExamined": "",

"segmentAttributes": [
[
Expand Down
55 changes: 29 additions & 26 deletions util/jsoncompare.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,11 @@ def _generate_pprint_json(value):
return json.dumps(value, sort_keys=True, indent=4)


def _is_dict_same(expected, actual, ignore_value_of_keys):
# DAN - I had to flip flop this
def _is_dict_same(expected, actual, ignore_value_of_keys, ignore_missing_keys):
for key in expected:
if key in ignore_value_of_keys:
continue
# Strict mode already checked the key sets; containment still needs this check.
if key not in actual:
return False, \
Stack().append(
Expand All @@ -62,16 +62,18 @@ def _is_dict_same(expected, actual, ignore_value_of_keys):
expected,
actual))

are_same_flag, stack = _are_same(expected[key], actual[key],ignore_value_of_keys)
are_same_flag, stack = _are_same(
expected[key], actual[key], ignore_value_of_keys, ignore_missing_keys)
if not are_same_flag:
return False, \
stack.append(StackItem('Different values', expected[key], actual[key]))
return True, Stack()


def _is_list_same(expected, actual, ignore_value_of_keys):
def _is_list_same(expected, actual, ignore_value_of_keys, ignore_missing_keys):
for i in range(len(expected)):
are_same_flag, stack = _are_same(expected[i], actual[i], ignore_value_of_keys)
are_same_flag, stack = _are_same(
expected[i], actual[i], ignore_value_of_keys, ignore_missing_keys)
if not are_same_flag:
return False, \
stack.append(
Expand Down Expand Up @@ -114,45 +116,46 @@ def _are_same(expected, actual, ignore_value_of_keys, ignore_missing_keys=False)
if type(expected) in (int, str, bool, float):
return expected == actual, Stack()

if not ignore_missing_keys and len(expected) > len(actual):
all_missing_keys = get_missing_keys(expected, actual)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_missing_keys (line 140) loses its only caller with this removal, so it's now dead code. Either delete it, or reuse it for the missing and extra key diagnostic suggested on line 124.

🤖 Comment generated by Claude (Claude Code), posted on behalf of @fedorov.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the unused get_missing_keys helper in 6f8ee01. The diagnostic uses the filtered key sets directly.

missing_keys = [x for x in all_missing_keys if x not in ignore_value_of_keys]
if len(missing_keys):
stack = Stack().append(StackItem('Length Mismatch: Expected Length: {0}, Actual Length: {1}'
.format(len(expected), len(actual)), expected, actual))
if isinstance(expected, dict):
stack.append('\nMissing keys: {0}'.format(missing_keys))
stack.append('\nIgnored keys: {0}'.format([x for x in ignore_value_of_keys if x not in all_missing_keys]))
return False, stack
10BC8
if isinstance(expected, dict):
return _is_dict_same(expected, actual, ignore_value_of_keys)
if not ignore_missing_keys:
expected_keys = set(expected) - ignore_value_of_keys
actual_keys = set(actual) - ignore_value_of_keys
if expected_keys != actual_keys:
return False, Stack().append(
StackItem('Different keys: Missing keys: {0}; Extra keys: {1}'
.format(sorted(expected_keys - actual_keys),
sorted(actual_keys - expected_keys)), expected, actual))
return _is_dict_same(expected, actual, ignore_value_of_keys,
ignore_missing_keys)

if isinstance(expected, list):
return _is_list_same(expected, actual, ignore_value_of_keys)
if (len(expected) > len(actual) or
(not ignore_missing_keys and len(expected) != len(actual))):
return False, Stack().append(
StackItem('Length Mismatch: Expected Length: {0}, Actual Length: {1}'
.format(len(expected), len(actual)), expected, actual))
return _is_list_same(expected, actual, ignore_value_of_keys,
ignore_missing_keys)

return False, Stack().append(StackItem('Unhandled Type: {0}'.format(type(expected)), expected, actual))


def get_missing_keys(expected, actual):
return [key for key in expected if key not in actual]


def are_same(original_a, original_b, ignore_list_order_recursively=False, ignore_missing_keys=False, ignore_value_of_keys=None):
ignore_value_of_keys = ignore_value_of_keys if ignore_value_of_keys else []
ignore_value_of_keys = set(ignore_value_of_keys or ())
a = _bottom_up_sort(original_a) if ignore_list_order_recursively else original_a
b = _bottom_up_sort(original_b) if ignore_list_order_recursively else original_b
return _are_same(a, b, ignore_value_of_keys, ignore_missing_keys)


def contains(expected_original, actual_original, ignore_list_order_recursively=False, ignore_value_of_keys=None):
ignore_value_of_keys = ignore_value_of_keys if ignore_value_of_keys else []
ignore_value_of_keys = set(ignore_value_of_keys or ())
actual = _bottom_up_sort(actual_original) if ignore_list_order_recursively else actual_original
expected = _bottom_up_sort(expected_original) if ignore_list_order_recursively else expected_original
return _are_same(expected, actual, ignore_value_of_keys, True)


def json_are_same(a, b, ignore_list_order_recursively=False, ignore_value_of_keys=None):
ignore_value_of_keys = ignore_value_of_keys if ignore_value_of_keys else []
return are_same(json.loads(a), json.loads(b), ignore_list_order_recursively, ignore_value_of_keys)
return are_same(json.loads(a), json.loads(b),
ignore_list_order_recursively=ignore_list_order_recursively,
ignore_value_of_keys=ignore_value_of_keys)

131 changes: 131 additions & 0 deletions util/test_jsoncompare.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest

import jsoncompare


class TestJSONCompare(unittest.TestCase):
def test_equal_nested_values(self):
value = {'segments': [{'id': 1, 'data': [1, 2, 2]}], 'empty': None}
self.assertTrue(jsoncompare.are_same(value, value)[0])

def test_extra_dictionary_key_is_not_equal(self):
self.assertFalse(jsoncompare.are_same({'a': 1}, {'a': 1, 'b': 2})[0])

def test_missing_dictionary_key_is_not_equal(self):
self.assertFalse(jsoncompare.are_same({'a': 1, 'b': 2}, {'a': 1})[0])

def test_equal_length_different_dictionary_keys(self):
self.assertFalse(jsoncompare.are_same({'a': 1}, {'b': 1})[0])

def test_key_diagnostics_name_missing_and_extra_keys(self):
expected = {'z': 1, 'a': 2, 'ignored_expected': 3}
actual = {'y': 1, 'b': 2, 'ignored_actual': 3}
for left, right in [(expected, actual),
({'nested': expected}, {'nested': actual})]:
with self.subTest(expected=left, actual=right):
same, stack = jsoncompare.are_same(
left, right,
ignore_value_of_keys=['ignored_expected', 'ignored_actual'])
self.assertFalse(same)
self.assertIn("Missing keys: ['a', 'z']", str(stack))
self.assertIn("Extra keys: ['b', 'y']", str(stack))

def test_nested_extra_key_is_not_equal(self):
self.assertFalse(jsoncompare.are_same({'a': {'b': 1}},
{'a': {'b': 1, 'c': 2}})[0])

def test_extra_list_item_is_not_equal(self):
self.assertFalse(jsoncompare.are_same([1], [1, 2])[0])

def test_short_duplicate_list_is_not_equal(self):
self.assertFalse(jsoncompare.are_same([1, 1], [1])[0])

def test_nested_list_length_is_checked(self):
self.assertFalse(jsoncompare.are_same({'a': [1]}, {'a': [1, 1]})[0])

def test_ignored_dictionary_keys_in_either_operand(self):
for a, b in [({'x': 1}, {'x': 1, '@schema': 'new'}),
({'x': 1, '@schema': 'old'}, {'x': 1}),
({'x': 1, '@schema': 'old'},
{'x': 1, '@schema': 'new'})]:
with self.subTest(a=a, b=b):
self.assertTrue(jsoncompare.are_same(
a, b, ignore_value_of_keys=['@schema'])[0])

def test_ignored_key_does_not_hide_unexpected_key(self):
self.assertFalse(jsoncompare.are_same(
{'a': 1, '@schema': 'old'}, {'a': 1, 'extra': 2},
ignore_value_of_keys=['@schema'])[0])

def test_ignored_key_iterable_applies_at_every_depth(self):
expected = {'nested': {'value': 1, 'ignored': 'old'},
'items': [{'value': 2, 'ignored': 'old'}], 'ignored': 'old'}
actual = {'nested': {'value': 1}, 'items': [{'value': 2}]}
for compare in [jsoncompare.are_same, jsoncompare.contains]:
with self.subTest(compare=compare.__name__):
self.assertTrue(compare(
expected, actual, ignore_value_of_keys=iter(['ignored']))[0])

def test_contains_keeps_nested_subset_semantics(self):
expected = {'a': {'b': 1}, 'values': [1]}
actual = {'a': {'b': 1, 'c': 2}, 'values': [1, 2], 'extra': 3}
self.assertTrue(jsoncompare.contains(expected, actual)[0])
self.assertTrue(jsoncompare.are_same(
expected, actual, ignore_missing_keys=True)[0])

def test_contains_rejects_missing_expected_data(self):
for expected, actual in [({'a': 1}, {}), ([1, 1], [1]),
({'a': [1, 1]}, {'a': [1]})]:
with self.subTest(expected=expected, actual=actual):
self.assertFalse(jsoncompare.contains(expected, actual)[0])

def test_list_order_and_duplicate_counts(self):
self.assertFalse(jsoncompare.are_same([1, 2], [2, 1])[0])
self.assertTrue(jsoncompare.are_same(
[1, 2], [2, 1], ignore_list_order_recursively=True)[0])
self.assertFalse(jsoncompare.are_same(
[1, 1], [1], ignore_list_order_recursively=True)[0])

def test_type_checks_are_unchanged(self):
for expected, actual in [(True, 1), (1, 1.0), ([], {}), (None, 0)]:
with self.subTest(expected=expected, actual=actual):
self.assertFalse(jsoncompare.are_same(expected, actual)[0])

def test_json_wrapper_forwards_ignored_keys(self):
self.assertTrue(jsoncompare.json_are_same(
'{"a": 1, "@schema": "old"}', '{"a": 1}',
ignore_value_of_keys=['@schema'])[0])
self.assertFalse(jsoncompare.json_are_same(
'{"a": 1}', '{"a": 1, "extra": 2}',
ignore_value_of_keys=['@schema'])[0])

def test_cli_strict_exit_status(self):
script = Path(jsoncompare.__file__).with_name('comparejson.py')
cases = [({'a': 1}, {'a': 1}, 0, None),
({'a': 1}, {'a': 1, '@schema': 'new'}, 0, None),
({'a': 1}, {'a': 1, 'extra': 2}, 1, "Extra keys: ['extra']"),
({'a': {'b': 1}}, {'a': {'b': 1, 'extra': 2}}, 1, "Extra keys: ['extra']"),
({'a': 1, 'missing': 2}, {'a': 1}, 1, "Missing keys: ['missing']"),
({'a': [1]}, {'a': [1, 1]}, 1, 'Length Mismatch')]
with tempfile.TemporaryDirectory() as directory:
left = Path(directory) / 'expected.json'
right = Path(directory) / 'actual.json'
for expected, actual, code, diagnostic in cases:
with self.subTest(expected=expected, actual=actual):
left.write_text(json.dumps(expected), encoding='utf-8')
right.write_text(json.dumps(actual), encoding='utf-8')
result = subprocess.run(
[sys.executable, '-B', str(script), str(left), str(right)],
capture_output=True, text=True, check=False)
self.assertEqual(result.returncode, code, result.stdout)
if diagnostic:
self.assertIn(diagnostic, result.stdout)


if __name__ == '__main__':
unittest.main()
Loading
0