Skip to content

Commit 6d52c9e

Browse files
committed
Add assert_json_subset()
1 parent 26e8e63 commit 6d52c9e

File tree

5 files changed

+284
-2
lines changed

5 files changed

+284
-2
lines changed

NEWS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
News in asserts 0.9.0
2+
=====================
3+
4+
API Additions
5+
-------------
6+
7+
* Add `assert_json_subset()`.
8+
19
News in asserts 0.8.6
210
=====================
311

asserts/__init__.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
"""
2222

2323
import re
24+
import sys
2425
from datetime import datetime, timedelta
26+
from json import loads as json_loads
2527
from warnings import catch_warnings
2628

2729

@@ -1197,3 +1199,162 @@ def test(warning):
11971199
context = AssertWarnsRegexContext(warning_type, regex, msg_fmt)
11981200
context.add_test(test)
11991201
return context
1202+
1203+
1204+
if sys.version_info >= (3,):
1205+
_Str = str
1206+
else:
1207+
_Str = unicode
1208+
1209+
1210+
def assert_json_subset(first, second):
1211+
"""Assert that a JSON object or array is a subset of another JSON object
1212+
or array.
1213+
1214+
The first JSON object or array must be supplied as a JSON-compatible
1215+
dict or list, the JSON object or array to check must be a string, an
1216+
UTF-8 bytes object, or a JSON-compatible list or dict.
1217+
1218+
A JSON non-object, non-array value is the subset of another JSON value,
1219+
if they are equal.
1220+
1221+
A JSON object is the subset of another JSON object if for each name/value
1222+
pair in the former there is a name/value pair in the latter with the same
1223+
name. Additionally the value of the former pair must be a subset of the
1224+
value of the latter pair.
1225+
1226+
A JSON array is the subset of another JSON array, if they have the same
1227+
number of elements and each element in the former is a subset of the
1228+
corresponding element in the latter.
1229+
1230+
>>> assert_json_subset({}, '{}')
1231+
>>> assert_json_subset({}, '{"foo": "bar"}')
1232+
>>> assert_json_subset({"foo": "bar"}, '{}')
1233+
Traceback (most recent call last):
1234+
...
1235+
AssertionError: name 'foo' missing
1236+
>>> assert_json_subset([1, 2], '[1, 2]')
1237+
>>> assert_json_subset([2, 1], '[1, 2]')
1238+
Traceback (most recent call last):
1239+
...
1240+
AssertionError: element #0 differs: 2 != 1
1241+
>>> assert_json_subset([{}], '[{"foo": "bar"}]')
1242+
>>> assert_json_subset({}, "INVALID JSON")
1243+
Traceback (most recent call last):
1244+
...
1245+
TypeError: invalid JSON
1246+
"""
1247+
1248+
if not isinstance(second, (dict, list, str, bytes)):
1249+
raise TypeError("second must be dict, list, str, or bytes")
1250+
if isinstance(second, bytes):
1251+
second = second.decode("utf-8")
1252+
if isinstance(second, _Str):
1253+
parsed_second = json_loads(second)
1254+
else:
1255+
parsed_second = second
1256+
1257+
if not isinstance(parsed_second, (dict, list)):
1258+
raise AssertionError("second must decode to dict or list, not {}".
1259+
format(type(parsed_second)))
1260+
1261+
comparer = _JSONComparer(_JSONPath("$"), first, parsed_second)
1262+
comparer.assert_()
1263+
1264+
1265+
class _JSONComparer:
1266+
def __init__(self, path, expected, actual):
1267+
self._path = path
1268+
self._expected = expected
1269+
self._actual = actual
1270+
1271+
def assert_(self):
1272+
self._assert_types_are_equal()
1273+
if isinstance(self._expected, dict):
1274+
self._assert_dicts_equal()
1275+
elif isinstance(self._expected, list):
1276+
self._assert_arrays_equal()
1277+
else:
1278+
self._assert_fundamental_values_equal()
1279+
1280+
def _assert_types_are_equal(self):
1281+
if self._types_differ():
1282+
self._raise_different_values()
1283+
1284+
def _types_differ(self):
1285+
if self._expected is None:
1286+
return self._actual is not None
1287+
elif isinstance(self._expected, (int, float)):
1288+
return not isinstance(self._actual, (int, float))
1289+
for type_ in [bool, str, _Str, list, dict]:
1290+
if isinstance(self._expected, type_):
1291+
return not isinstance(self._actual, type_)
1292+
else:
1293+
raise TypeError("unsupported type {}".format(type(self._expected)))
1294+
1295+
def _assert_dicts_equal(self):
1296+
self._assert_all_expected_keys_in_actual_dict()
1297+
for name in self._expected:
1298+
self._assert_json_value_equals_with_item(name)
1299+
1300+
def _assert_all_expected_keys_in_actual_dict(self):
1301+
keys = set(self._expected.keys()).difference(self._actual.keys())
1302+
if keys:
1303+
self._raise_missing_element(keys)
1304+
1305+
def _assert_arrays_equal(self):
1306+
if len(self._expected) != len(self._actual):
1307+
self._raise_different_sizes()
1308+
for i in range(len(self._expected)):
1309+
self._assert_json_value_equals_with_item(i)
1310+
1311+
def _assert_json_value_equals_with_item(self, item):
1312+
path = self._path.append(item)
1313+
expected = self._expected[item]
1314+
actual = self._actual[item]
1315+
_JSONComparer(path, expected, actual).assert_()
1316+
1317+
def _assert_fundamental_values_equal(self):
1318+
if self._expected != self._actual:
1319+
self._raise_different_values()
1320+
1321+
def _raise_different_values(self):
1322+
self._raise_assertion_error(
1323+
"element {path} differs: {expected} != {actual}")
1324+
1325+
def _raise_different_sizes(self):
1326+
self._raise_assertion_error(
1327+
"JSON array {path} differs in size: "
1328+
"{expected_len} != {actual_len}",
1329+
expected_len=len(self._expected),
1330+
actual_len=len(self._actual))
1331+
1332+
def _raise_missing_element(self, keys):
1333+
if len(keys) == 1:
1334+
format_string = "element {elements} missing from element {path}"
1335+
elements = repr(next(iter(keys)))
1336+
else:
1337+
format_string = "elements {elements} missing from element {path}"
1338+
sorted_keys = sorted(keys)
1339+
elements = (", ".join(repr(k) for k in sorted_keys[:-1]) +
1340+
", and " + repr(sorted_keys[-1]))
1341+
self._raise_assertion_error(format_string, elements=elements)
1342+
1343+
def _raise_assertion_error(self, format_, **kwargs):
1344+
kwargs.update({
1345+
"path": self._path,
1346+
"expected": repr(self._expected),
1347+
"actual": repr(self._actual),
1348+
})
1349+
raise AssertionError(format_.format(**kwargs))
1350+
1351+
1352+
class _JSONPath:
1353+
def __init__(self, path):
1354+
self._path = path
1355+
1356+
def __str__(self):
1357+
return self._path
1358+
1359+
def append(self, item):
1360+
return _JSONPath("{0}[{1}]".format(self._path, repr(item)))

asserts/__init__.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,4 @@ def assert_raises_errno(exception: Type[BaseException], errno: int, msg_fmt: Tex
7070
def assert_succeeds(exception: Type[BaseException], msg_fmt: Text = ...) -> ContextManager: ...
7171
def assert_warns(warning_type: Type[Warning], msg_fmt: Text = ...) -> AssertWarnsContext: ...
7272
def assert_warns_regex(warning_type: Type[Warning], regex: Text, msg_fmt: Text = ...) -> AssertWarnsContext: ...
73+
def assert_json_subset(first: Union[dict, list], second: Union[dict, list, str, bytes]) -> None: ...

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def read(fname):
1010

1111
setup(
1212
name="asserts",
13-
version="0.8.6",
13+
version="0.9.0",
1414
description="Stand-alone Assertions",
1515
long_description=read("README.rst"),
1616
author="Sebastian Rittau",

test_asserts.py

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
# -*- coding: utf-8 -*-
2+
13
import re
24
import sys
5+
from collections import OrderedDict
36
from datetime import datetime, timedelta
47
from unittest import TestCase
58
from warnings import warn, catch_warnings
@@ -40,6 +43,7 @@
4043
assert_succeeds,
4144
assert_warns,
4245
assert_warns_regex,
46+
assert_json_subset,
4347
)
4448

4549

@@ -80,7 +84,6 @@ def __exit__(self, exc_type, exc_val, exc_tb):
8084

8185

8286
class AssertTest(TestCase):
83-
8487
_type_string = "type" if sys.version_info[0] < 3 else "class"
8588

8689
# fail()
@@ -1193,3 +1196,112 @@ def test_assert_warns_regex__wrong_message__custom_message(self):
11931196
msg_fmt = "{msg};{exc_type.__name__};{exc_name};{pattern}"
11941197
with assert_warns_regex(UserWarning, r"foo.*bar", msg_fmt=msg_fmt):
11951198
pass
1199+
1200+
# assert_json_subset()
1201+
1202+
def test_assert_json_subset__different_types(self):
1203+
with _assert_raises_assertion("element $ differs: {} != []"):
1204+
assert_json_subset({}, [])
1205+
1206+
def test_assert_json_subset__empty_objects(self):
1207+
with assert_succeeds(AssertionError):
1208+
assert_json_subset({}, {})
1209+
1210+
def test_assert_json_subset__objects_equal(self):
1211+
with assert_succeeds(AssertionError):
1212+
assert_json_subset({"foo": 3, "bar": "abc"},
1213+
{"bar": "abc", "foo": 3})
1214+
1215+
def test_assert_json_subset__one_key_missing_from_first_object(self):
1216+
with assert_succeeds(AssertionError):
1217+
assert_json_subset({"foo": 3}, {"foo": 3, "bar": 3})
1218+
1219+
def test_assert_json_subset__one_key_missing_from_second_object(self):
1220+
with _assert_raises_assertion("element 'bar' missing from element $"):
1221+
assert_json_subset({"foo": 3, "bar": 3}, {"foo": 3})
1222+
1223+
def test_assert_json_subset__multiple_keys_missing_from_second_object(
1224+
self):
1225+
with _assert_raises_assertion(
1226+
"elements 'bar', 'baz', and 'foo' missing from element $"):
1227+
assert_json_subset({"foo": 3, "bar": 3, "baz": 3}, {})
1228+
1229+
def test_assert_json_subset__value_differs(self):
1230+
with _assert_raises_assertion("element $['foo'] differs: 3 != 4"):
1231+
assert_json_subset({"foo": 3}, {"foo": 4})
1232+
1233+
def test_assert_json_subset__empty_lists(self):
1234+
with assert_succeeds(AssertionError):
1235+
assert_json_subset([], [])
1236+
1237+
def test_assert_json_subset__different_sized_lists(self):
1238+
with _assert_raises_assertion("JSON array $ differs in size: 2 != 1"):
1239+
assert_json_subset([1, 2], [1])
1240+
with _assert_raises_assertion("JSON array $ differs in size: 1 != 2"):
1241+
assert_json_subset([1], [1, 2])
1242+
1243+
def test_assert_json_subset__different_list_values(self):
1244+
with _assert_raises_assertion("element $[0] differs: {} != []"):
1245+
assert_json_subset([{}], [[]])
1246+
1247+
def test_assert_json_subset__fundamental_types_differ(self):
1248+
with _assert_raises_assertion("element $[0] differs: 1 != 'foo'"):
1249+
assert_json_subset([1], ["foo"])
1250+
1251+
def test_assert_json_subset__fundamental_values_differ(self):
1252+
with _assert_raises_assertion("element $[0] differs: 'bar' != 'foo'"):
1253+
assert_json_subset(["bar"], ["foo"])
1254+
1255+
def test_assert_json_subset__none(self):
1256+
with assert_succeeds(AssertionError):
1257+
assert_json_subset([None], [None])
1258+
with _assert_raises_assertion("element $[0] differs: 42 != None"):
1259+
assert_json_subset([42], [None])
1260+
with _assert_raises_assertion("element $[0] differs: None != 42"):
1261+
assert_json_subset([None], [42])
1262+
1263+
def test_assert_json_subset__compare_int_and_float(self):
1264+
with assert_succeeds(AssertionError):
1265+
assert_json_subset([42], [42.0])
1266+
assert_json_subset([42.0], [42])
1267+
1268+
def test_assert_json_subset__unsupported_type(self):
1269+
msg = "unsupported type <{} 'set'>".format(self._type_string)
1270+
with assert_raises_regex(TypeError, msg):
1271+
assert_json_subset([set()], [set()])
1272+
1273+
def test_assert_json_subset__subtypes(self):
1274+
with assert_succeeds(AssertionError):
1275+
assert_json_subset(OrderedDict(), {})
1276+
assert_json_subset({}, OrderedDict())
1277+
1278+
def test_assert_json_subset__second_is_string(self):
1279+
with assert_succeeds(AssertionError):
1280+
assert_json_subset({}, "{ }")
1281+
1282+
def test_assert_json_subset__second_is_unsupported_json_string(self):
1283+
msg = "second must decode to dict or list, not <{} 'int'>".format(
1284+
self._type_string)
1285+
with _assert_raises_assertion(msg):
1286+
assert_json_subset({}, "42")
1287+
1288+
def test_assert_json_subset__second_is_invalid_json_string(self):
1289+
try:
1290+
from json import JSONDecodeError
1291+
except ImportError:
1292+
JSONDecodeError = ValueError # type: ignore
1293+
with assert_raises(JSONDecodeError):
1294+
assert_json_subset({}, ",")
1295+
1296+
def test_assert_json_subset__second_is_bytes(self):
1297+
with assert_succeeds(AssertionError):
1298+
assert_json_subset([u"föo"], u'["föo"]'.encode("utf-8"))
1299+
1300+
def test_assert_json_subset__second_is_latin1_bytes(self):
1301+
with assert_raises(UnicodeDecodeError):
1302+
assert_json_subset([u"föo"], u'["föo"]'.encode("iso-8859-1"))
1303+
1304+
def test_assert_json_subset__invalid_type(self):
1305+
with assert_raises_regex(
1306+
TypeError, "second must be dict, list, str, or bytes"):
1307+
assert_json_subset({}, 42) # type: ignore

0 commit comments

Comments
 (0)