diff options
author | Matěj Cepl <mcepl@redhat.com> | 2011-10-28 14:30:53 +0200 |
---|---|---|
committer | Matěj Cepl <mcepl@redhat.com> | 2011-10-28 14:30:53 +0200 |
commit | e10506226abbca14ebe99c987feb155f1253abec (patch) | |
tree | 5efe7ff191e68045ad6ac569eb6401e8815bfd69 | |
parent | 81f605fb4885efbfccf615848a3f5ee6a1452025 (diff) | |
download | json_diff-e10506226abbca14ebe99c987feb155f1253abec.tar.gz |
HTML formatted output possible.
m--------- | hinnerup | 0 | ||||
-rwxr-xr-x[-rw-r--r--] | json_diff.py | 216 | ||||
-rw-r--r-- | test/diff-testing-data.json | 24 | ||||
-rw-r--r-- | test/diff.json | 28 | ||||
-rw-r--r-- | test/nested_html_output.html | 43 | ||||
-rw-r--r-- | test/new.json | 5 | ||||
-rw-r--r-- | test/old.json | 4 | ||||
-rw-r--r-- | test_json_diff.py | 62 |
8 files changed, 292 insertions, 90 deletions
diff --git a/hinnerup b/hinnerup deleted file mode 160000 -Subproject 5dd7de36d0b636c25aebf72d97c1ac2e618e8a1 diff --git a/json_diff.py b/json_diff.py index 1dadbf5..8a2cc5c 100644..100755 --- a/json_diff.py +++ b/json_diff.py @@ -2,72 +2,195 @@ # -*- coding: utf-8 -*- """ Script for comparing two objects + +Copyright (c) 2011, Red Hat Corp. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ import json from optparse import OptionParser import logging +__author__ = "Matěj Cepl" +__version__ = "0.1.0" + logging.basicConfig(format='%(levelname)s:%(funcName)s:%(message)s', level=logging.INFO) +STYLE_MAP = { + u"_append": "append_class", + u"_remove": "remove_class", + u"_update": "update_class" +} + + +LEVEL_INDENT = " " + +out_str_template = u""" +<!DOCTYPE html> +<html lang='en'> +<meta charset="utf-8" /> +<title>%s</title> +<style> +td { + text-align: center; +} +.append_class { + color: green; +} +.remove_class { + color: red; +} +.update_class { + color: navy; +} +</style> +<body> + <h1>%s</h1> + <table> + %s +""" + +class HTMLFormatter(object): + + def __init__(self, diff_object): + self.diff = diff_object + + def _generate_page(self, in_dict, title="json_diff result"): + out_str = out_str_template % (title, title, + self._format_dict(in_dict)) + out_str += """ + </table> + </body> + </html> + """ + return out_str + + @staticmethod + def _is_scalar(value): + return not isinstance(value, (list, tuple, dict)) + + def _is_leafnode(self, node): + # anything else than dict shouldn't happen here, so that would be + # pure error + assert(isinstance(node, dict)) + # the following lines mean that it is an expression + out = True + for key in node: + if not self._is_scalar(node[key]): + out = False + return out + + # doesn't have level and neither concept of it, much + def _format_dict(self, diff_dict, typch="unknown_change", level=0): + internal_keys = set(STYLE_MAP.keys()) + level_str = ("<td>" + LEVEL_INDENT + "</td>") * level + out_str = "" + logging.debug("out_str = %s", out_str) + + logging.debug("----------------------------------------------------------------") + logging.debug("diff_dict = %s", unicode(diff_dict)) + logging.debug("level = %s", unicode(level)) + logging.debug("diff_dict.keys() = %s", unicode(diff_dict.keys())) + + for typechange in set(diff_dict.keys()) & internal_keys: + logging.debug("---- internal typechange in diff_dict.keys() = %s", typechange) + logging.debug("---- diff_dict[typechange] = %s", unicode(diff_dict[typechange])) + logging.debug("---- self._is_leafnode(diff_dict[typechange]) = %s", + self._is_leafnode(diff_dict[typechange])) + out_str += self._format_dict(diff_dict[typechange], typechange, level) + + for variable in set(diff_dict.keys()) - internal_keys: + logging.debug("**** external variable in diff_dict.keys() = %s", variable) + logging.debug("**** diff_dict[variable] = %s", unicode(diff_dict[variable])) + logging.debug("**** self._is_scalar(diff_dict[variable]) = %s", + self._is_scalar(diff_dict[variable])) + if self._is_scalar(diff_dict[variable]): + out_str += ("<tr>\n %s<td class='%s'>%s = %s</td>\n </tr>\n" % + (level_str, STYLE_MAP[typch], variable, unicode(diff_dict[variable]))) + logging.debug("out_str = %s", out_str) + else: + out_str += self._format_dict(diff_dict[variable], None, level+1) + + return out_str + + + def __str__(self): + return self._generate_page(self.diff).encode("utf-8") + +class BadJSONError(ValueError): + pass + class Comparator(object): """ Main workhorse, the object itself """ def __init__(self, fn1=None, fn2=None, excluded_attrs=()): if fn1: - self.obj1 = json.load(fn1) + try: + self.obj1 = json.load(fn1) + except (TypeError, OverflowError, ValueError) as exc: + raise BadJSONError("Cannot decode object from JSON.\n%s" % unicode(exc)) if fn2: - self.obj2 = json.load(fn2) + try: + self.obj2 = json.load(fn2) + except (TypeError, OverflowError, ValueError) as exc: + raise BadJSONError("Cannot decode object from JSON\n%s" % unicode(exc)) self.excluded_attributes = excluded_attrs - if (fn1 and fn2): - logging.debug("self.obj1 = %s\nself.obj2 = %s\nself.excluded_attrs = %s", \ - (self.obj1, self.obj2, self.excluded_attributes)) @staticmethod - def _get_keys(obj): - """ - Getter for the current object's keys. - """ - out = set() - for key in obj.keys(): - out.add(key) - return out - - @staticmethod - def _is_scalar(value): + def is_scalar(value): """ Primitive version, relying on the fact that JSON cannot contain any more complicated data structures. """ return not isinstance(value, (list, tuple, dict)) - + def _compare_arrays(self, old_arr, new_arr): + """ + simpler version of compare_dicts; just an internal method, becase + it could never be called from outside. + """ inters = min(old_arr, new_arr) result = { - u"append": {}, - u"remove": {}, - u"update": {} + "_append": {}, + "_remove": {}, + "_update": {} } for idx in range(len(inters)): # changed objects, new value is new_arr if (type(old_arr[idx]) != type(new_arr[idx])): - result['update'][idx] = new_arr[idx] + result[u'_update'][idx] = new_arr[idx] # another simple variant ... scalars - elif (self._is_scalar(old_arr)): + elif (self.is_scalar(old_arr)): if old_arr[idx] != new_arr[idx]: - result['update'][idx] = new_arr[idx] + result[u'_update'][idx] = new_arr[idx] # recursive arrays elif (isinstance(old_arr[idx], list)): - res_arr = self._compare_arrays(old_arr[idx], \ + res_arr = self._compare_arrays(old_arr[idx], new_arr[idx]) if (len(res_arr) > 0): - result['update'][idx] = res_arr + result[u'_update'][idx] = res_arr # and now nested dicts elif isinstance(old_arr[idx], dict): res_dict = self.compare_dicts(old_arr[idx], new_arr[idx]) if (len(res_dict) > 0): - result['update'][idx] = res_dict + result[u'_update'][idx] = res_dict # Clear out unused inters in result out_result = {} @@ -89,16 +212,16 @@ class Comparator(object): old_keys = set() new_keys = set() if old_obj and len(old_obj) > 0: - old_keys = self._get_keys(old_obj) + old_keys = set(old_obj.keys()) if new_obj and len(new_obj) > 0: - new_keys = self._get_keys(new_obj) + new_keys = set(new_obj.keys()) keys = old_keys | new_keys result = { - u"append": {}, - u"remove": {}, - u"update": {} + "_append": {}, + "_remove": {}, + "_update": {} } for name in keys: # Explicitly excluded arguments @@ -106,35 +229,35 @@ class Comparator(object): continue # old_obj is missing if name not in old_obj: - result['append'][name] = new_obj[name] + result[u'_append'][name] = new_obj[name] # new_obj is missing elif name not in new_obj: - result['remove'][name] = old_obj[name] + result[u'_remove'][name] = old_obj[name] # changed objects, new value is new_obj elif (type(old_obj[name]) != type(new_obj[name])): - result['update'][name] = new_obj[name] + result[u'_update'][name] = new_obj[name] # last simple variant ... scalars - elif (self._is_scalar(old_obj[name])): + elif (self.is_scalar(old_obj[name])): if old_obj[name] != new_obj[name]: - result['update'][name] = new_obj[name] + result[u'_update'][name] = new_obj[name] # now arrays elif (isinstance(old_obj[name], list)): - res_arr = self._compare_arrays(old_obj[name], \ + res_arr = self._compare_arrays(old_obj[name], new_obj[name]) if (len(res_arr) > 0): - result['update'][name] = res_arr + result[u'_update'][name] = res_arr # and now nested dicts elif isinstance(old_obj[name], dict): res_dict = self.compare_dicts(old_obj[name], new_obj[name]) if (len(res_dict) > 0): - result['update'][name] = res_dict + result[u'_update'][name] = res_dict # Clear out unused keys in result out_result = {} for key in result: if len(result[key]) > 0: out_result[key] = result[key] - + return out_result @@ -144,11 +267,18 @@ if __name__ == "__main__": parser.add_option("-x", "--exclude", action="append", dest="exclude", metavar="ATTR", default=[], help="attributes which should be ignored when comparing") + parser.add_option("-H", "--HTML", + action="store_true", dest="HTMLoutput", metavar="BOOL", default=False, + help="program should output to HTML report") (options, args) = parser.parse_args() - logging.debug("options = %s", str(options)) - logging.debug("args = %s", str(args)) + if len(args) != 2: parser.error("Script requires two positional arguments, names for old and new JSON file.") diff = Comparator(file(args[0]), file(args[1]), options.exclude) - print json.dumps(diff.compare_dicts(), indent=4, ensure_ascii=False)
\ No newline at end of file + if options.HTMLoutput: + diff_res = diff.compare_dicts() + logging.debug("diff_res:\n%s", json.dumps(diff_res, indent=True)) + print HTMLFormatter(diff_res) + else: + print json.dumps(diff.compare_dicts(), indent=4, ensure_ascii=False).encode("utf-8") diff --git a/test/diff-testing-data.json b/test/diff-testing-data.json index 884b7e1..0fe28b1 100644 --- a/test/diff-testing-data.json +++ b/test/diff-testing-data.json @@ -1,54 +1,54 @@ { - "update": { + "_update": { "tests": { - "update": { + "_update": { "spec/ARB_shader_texture_lod/execution/arb_shader_texture_lod-texgrad": { - "update": { + "_update": { "info": "Returncode: 1\n\nErrors:\n\n\nOutput:\nLeft: texture2D, Right: texture2DGradARB\nProbe at (4,2)\n Left: 1.000000 0.000000 0.000000 1.000000\n Right: 0.945098 0.890196 0.945098 1.000000\n" } }, "glean/fbo": { - "update": { + "_update": { "info": "Returncode: 0\n\nErrors:\n\n\nOutput:\n----------------------------------------------------------------------\nfbo test: Test OpenGL Extension GL_EXT_framebuffer_object\n\nGL_EXT_framebuffer_object is supported\nGL_ARB_framebuffer_object is supported\n (FBOTest::testRender2SingleTexture:648)GL_FRAMEBUFFER_UNSUPPORTED_EXT\n (FBOTest::testRender2SingleTexture:648)GL_FRAMEBUFFER_UNSUPPORTED_EXT\n (FBOTest::testRender2SingleTexture:648)GL_FRAMEBUFFER_UNSUPPORTED_EXT\n (FBOTest::testRender2SingleTexture:648)GL_FRAMEBUFFER_UNSUPPORTED_EXT\nfbo: NOTE perf[0] = 182.54 MB/s, which is using glCopyTexImage2D\nfbo: NOTE perf[1] = 165.462 MB/s, which is using FBO\nfbo: PASS rgba8, db, z24, s8, win+pmap, id 33\n\t8 tests passed, 0 tests failed.\n\n" } }, "glx/glx-pixmap-crosscheck": { - "remove": { + "_remove": { "returncode": 0 } }, "glean/pbo": { - "update": { + "_update": { "info": "Returncode: 0\n\nErrors:\n\n\nOutput:\n----------------------------------------------------------------------\npbo test: Test OpenGL Extension GL_ARB_pixel_buffer_object\n\npbo: PASS rgba8, db, z24, s8, win+pmap, id 33\n\t10 tests passed, 0 tests failed.\n\n" } }, "glx/glx-swap-singlebuffer": { - "update": { + "_update": { "info": "Returncode: 1\n\nErrors:\nX Error of failed request: BadDrawable (invalid Pixmap or Window parameter)\n Major opcode of failed request: 137 (DRI2)\n Minor opcode of failed request: 8 ()\n Resource id in failed request: 0x9e00002\n Serial number of failed request: 35\n Current serial number in output stream: 35\n\n\nOutput:\n" } }, "glean/teapot": { - "update": { + "_update": { "info": "Returncode: 0\n\nErrors:\n\n\nOutput:\n----------------------------------------------------------------------\nThis test simply displays a teapot, rotates it, and attempts to\ndetermine the frame/sec the pipeline can generate\n\nteapot: PASS Teapots/Sec: 59.4814 rgba8, db, z24, s8, win+pmap, id 33\n\n" } }, "spec/!OpenGL 2.0/vertex-program-two-side enabled back2": { - "update": { + "_update": { "info": "Returncode: 1\n\nErrors:\n\n\nOutput:\nWindow quadrants show:\n+-------------------------+------------------------+\n| front gl_Color | back gl_Color |\n+-------------------------+------------------------+\n| front gl_SecondaryColor | back gl_SecondaryColor |\n+-------------------------+------------------------+\nProbe at (50,0)\n Expected: 0.000000 0.000000 0.250000 0.000000\n Observed: 0.007843 0.007843 0.007843 0.007843\n" } }, "spec/glsl-1.10/execution/samplers/in-parameter-struct": { - "update": { + "_update": { "info": "Returncode: -6\n\nErrors:\n\n\nOutput:\nir_dereference_variable @ 0x1247460 specifies undeclared variable `channel_expressions' @ 0x11d00e0\n" } }, "fbo/fbo-sys-blit": { - "update": { + "_update": { "info": "Returncode: 1\n\nErrors:\n\n\nOutput:\nProbe at (64,64)\n Expected: 0.000000 1.000000 0.000000\n Observed: 0.325490 0.529412 0.823529\n" } }, "spec/glsl-1.10/execution/samplers/normal-parameter-struct": { - "update": { + "_update": { "info": "Returncode: -6\n\nErrors:\n\n\nOutput:\nir_dereference_variable @ 0x1de33f0 specifies undeclared variable `channel_expressions' @ 0x1d6c230\n" } } diff --git a/test/diff.json b/test/diff.json index df7a5f5..e8a2330 100644 --- a/test/diff.json +++ b/test/diff.json @@ -1,17 +1,19 @@ { - "append": { - "c": 3, - "daughter": { - "name": "Maruška" + "_remove": { + "b": 2 + }, + "_update": { + "a": 2, + "children": { + "_update": { + "son": "Ivánek" + }, + "_append": { + "daughter": "Maruška" + } } - }, - "remove": { - "b": 2, - "son": { - "name": "Janošek" - } - }, - "update": { - "a": 2 + }, + "_append": { + "c": 3 } } diff --git a/test/nested_html_output.html b/test/nested_html_output.html new file mode 100644 index 0000000..6c5b83c --- /dev/null +++ b/test/nested_html_output.html @@ -0,0 +1,43 @@ + +<!DOCTYPE html> +<html lang='en'> +<meta charset="utf-8" /> +<title>json_diff result</title> +<style> +td { + text-align: center; +} +.append_class { + color: green; +} +.remove_class { + color: red; +} +.update_class { + color: navy; +} +</style> +<body> + <h1>json_diff result</h1> + <table> + <tr> + <td class='remove_class'>b = 2</td> + </tr> +<tr> + <td class='update_class'>a = 2</td> + </tr> +<tr> + <td> </td><td class='update_class'>son = Ivánek</td> + </tr> +<tr> + <td> </td><td class='append_class'>daughter = Maruška</td> + </tr> +<tr> + <td class='append_class'>c = 3</td> + </tr> + + + </table> + </body> + </html> + diff --git a/test/new.json b/test/new.json index 86da691..235c843 100644 --- a/test/new.json +++ b/test/new.json @@ -1,7 +1,8 @@ { "a": 2, "c": 3, - "daughter": { - "name": "Maruška" + "children": { + "son": "Ivánek", + "daughter": "Maruška" } } diff --git a/test/old.json b/test/old.json index 17d6738..f887343 100644 --- a/test/old.json +++ b/test/old.json @@ -1,7 +1,7 @@ { "a": 1, "b": 2, - "son": { - "name": "Janošek" + "children": { + "son": "Janošek" } } diff --git a/test_json_diff.py b/test_json_diff.py index 3a514eb..21faf70 100644 --- a/test_json_diff.py +++ b/test_json_diff.py @@ -25,13 +25,13 @@ SIMPLE_NEW = u""" SIMPLE_DIFF = u""" { - "append": { + "_append": { "d": "přidáno" }, - "remove": { + "_remove": { "a": 1 }, - "update": { + "_update": { "c": "Maruška", "b": false } @@ -60,63 +60,89 @@ NESTED_NEW = u""" NESTED_DIFF = u""" { - "append": { + "_append": { "c": 3, "daughter": { "name": "Maruška" } }, - "remove": { + "_remove": { "b": 2, "son": { "name": "Janošek" } }, - "update": { + "_update": { "a": 2 } } """ -class TestXorgAnalyze(unittest.TestCase): +class TestHappyPath(unittest.TestCase): def test_empty(self): diffator = json_diff.Comparator({}, {}) diff = diffator.compare_dicts() - self.assertEqual(json.dumps(diff).strip(), "{}", \ - "Empty objects diff.\n\nexpected = %s\n\nobserved = %s" % \ + self.assertEqual(json.dumps(diff).strip(), "{}", + "Empty objects diff.\n\nexpected = %s\n\nobserved = %s" % (str({}), str(diff))) def test_simple(self): diffator = json_diff.Comparator(StringIO(SIMPLE_OLD), StringIO(SIMPLE_NEW)) diff = diffator.compare_dicts() expected = json.loads(SIMPLE_DIFF) - self.assertEqual(diff, expected, "All-scalar objects diff." + \ - "\n\nexpected = %s\n\nobserved = %s" % \ + self.assertEqual(diff, expected, "All-scalar objects diff." + + "\n\nexpected = %s\n\nobserved = %s" % (str(expected), str(diff))) def test_realFile(self): diffator = json_diff.Comparator(open("test/old.json"), open("test/new.json")) diff = diffator.compare_dicts() expected = json.load(open("test/diff.json")) - self.assertEqual(diff, expected, "Simply nested objects (from file) diff." + \ - "\n\nexpected = %s\n\nobserved = %s" % \ + self.assertEqual(diff, expected, "Simply nested objects (from file) diff." + + "\n\nexpected = %s\n\nobserved = %s" % (str(expected), str(diff))) def test_nested(self): diffator = json_diff.Comparator(StringIO(NESTED_OLD), StringIO(NESTED_NEW)) diff = diffator.compare_dicts() expected = json.loads(NESTED_DIFF) - self.assertEqual(diff, expected, "Nested objects diff. " + \ - "\n\nexpected = %s\n\nobserved = %s" % \ + self.assertEqual(diff, expected, "Nested objects diff. " + + "\n\nexpected = %s\n\nobserved = %s" % (str(expected), str(diff))) + + def test_nested_formatted(self): + diffator = json_diff.Comparator(open("test/old.json"), open("test/new.json")) + diff = "\n".join([line.strip() \ + for line in str(json_diff.HTMLFormatter(diffator.compare_dicts())).split("\n")]) + expected = "\n".join([line.strip() for line in open("test/nested_html_output.html").readlines()]) + self.assertEqual(diff, expected, "Simply nested objects (from file) diff formatted as HTML." + + "\n\nexpected = %s\n\nobserved = %s" % + (expected, diff)) + def test_large_with_exclusions(self): - diffator = json_diff.Comparator(open("test/old-testing-data.json"), \ + diffator = json_diff.Comparator(open("test/old-testing-data.json"), open("test/new-testing-data.json"), ('command', 'time')) diff = diffator.compare_dicts() expected = json.load(open("test/diff-testing-data.json")) - self.assertEqual(diff, expected, "Large objects with exclusions diff." + \ - "\n\nexpected = %s\n\nobserved = %s" % \ + self.assertEqual(diff, expected, "Large objects with exclusions diff." + + "\n\nexpected = %s\n\nobserved = %s" % (str(expected), str(diff))) + +NO_JSON_OLD = u""" +THIS IS NOT A JSON STRING +""" + +NO_JSON_NEW = u""" +AND THIS NEITHER +""" + + +class TestSadPath(unittest.TestCase): + def test_no_JSON(self): + self.assertRaises(json_diff.BadJSONError, + json_diff.Comparator, StringIO(NO_JSON_OLD), StringIO(NO_JSON_NEW)) + + if __name__ == "__main__": unittest.main()
\ No newline at end of file |