diff --git a/doc/commandline.rst b/doc/commandline.rst index b844336..8462ad1 100644 --- a/doc/commandline.rst +++ b/doc/commandline.rst @@ -7,6 +7,11 @@ The JSON patch package contains the commandline utilities ``jsondiff`` and Both utilities read JSON files as UTF-8. When ``jsonpatch`` modifies a file in-place, it also writes UTF-8. +If a file cannot be read or written, is not valid JSON, or if the patch +cannot be applied, both print a one-line error message to standard error and +exit with status 2. ``jsondiff`` exits with status 1 if the files differ, +like ``diff``, and with 0 if they are equal. + ``jsondiff`` ------------ diff --git a/jsonpatch_cli.py b/jsonpatch_cli.py index bd64115..b80dbf1 100644 --- a/jsonpatch_cli.py +++ b/jsonpatch_cli.py @@ -34,19 +34,38 @@ def _input_files(*streams): return inputs -def jsondiff_main(): +def _load(stream, loads=json.loads): + """ Parses the JSON document in stream; errors name the file """ try: - diff_files() + return loads(stream.read()) + except ValueError as ex: + # invalid JSON or, as a UnicodeDecodeError, invalid UTF-8 + name = getattr(stream, 'name', '') + raise ValueError('{0}: {1}'.format(name, ex)) from ex + + +def _run(parser, program): + """ Runs a command line program. Errors in its input, and in reading + and writing files, are reported in one line, with exit status 2 """ + try: + program() except KeyboardInterrupt: sys.exit(1) + except (jsonpatch.JsonPatchException, jsonpatch.JsonPointerException, + ValueError, OSError) as ex: + parser.exit(2, '{0}: error: {1}\n'.format(parser.prog, ex)) + + +def jsondiff_main(): + _run(jsondiff_parser, diff_files) def diff_files(): """ Diffs two JSON files and prints a patch """ args = jsondiff_parser.parse_args() with _input_files(args.FILE1, args.FILE2): - doc1 = json.load(args.FILE1) - doc2 = json.load(args.FILE2) + doc1 = _load(args.FILE1) + doc2 = _load(args.FILE2) patch = jsonpatch.make_patch(doc1, doc2, generate_test_ops=args.test_ops) if patch.patch: print(json.dumps(patch.patch, indent=args.indent, ensure_ascii=not args.preserve_unicode)) @@ -74,10 +93,7 @@ def diff_files(): def jsonpatch_main(): - try: - patch_files() - except KeyboardInterrupt: - sys.exit(1) + _run(jsonpatch_parser, patch_files) def patch_files(): @@ -86,8 +102,9 @@ def patch_files(): # The inputs are closed before anything is written, as Windows cannot # replace ORIGINAL while it is open. with _input_files(args.ORIGINAL, args.PATCH): - doc = json.load(args.ORIGINAL) - result = jsonpatch.apply_patch(doc, args.PATCH.read()) + doc = _load(args.ORIGINAL) + patch = _load(args.PATCH, jsonpatch.JsonPatch.from_string) + result = patch.apply(doc) if args.in_place: dirname = os.path.abspath(os.path.dirname(args.ORIGINAL.name)) diff --git a/tests.py b/tests.py index f0d7581..f742a21 100644 --- a/tests.py +++ b/tests.py @@ -3230,6 +3230,100 @@ def test_jsondiff_equal_files(self): res = self.run_script('jsondiff', src, dst) self.assertEqual((res.returncode, res.stdout), (0, ''), res.stderr) + def assert_error(self, res, program, *parts): + """ The program failed with a one-line message containing parts """ + self.assertEqual(res.returncode, 2, res.stderr) + self.assertEqual(res.stdout, '') + self.assertEqual(res.stderr.count('\n'), 1, res.stderr) + self.assertTrue(res.stderr.startswith(program + ': error: '), res.stderr) + for part in parts: + self.assertIn(part, res.stderr) + + def test_jsondiff_reports_invalid_json(self): + src = self.write('a.json', {'a': 1}) + dst = os.path.join(self.dir, 'b.json') + with open(dst, 'w', encoding='utf-8') as f: + f.write('{"a": ') + for files in ((src, dst), (dst, src)): + with self.subTest(files=files): + res = self.run_script('jsondiff', *files) + self.assert_error(res, 'jsondiff', 'b.json', 'line 1 column 7') + + def test_jsondiff_reports_invalid_utf8(self): + src = self.write('a.json', {'a': 1}) + dst = os.path.join(self.dir, 'b.json') + with open(dst, 'wb') as f: + f.write(b'"\xff"') + res = self.run_script('jsondiff', src, dst) + self.assert_error(res, 'jsondiff', 'b.json', 'utf-8') + + def test_jsonpatch_reports_failing_patch(self): + doc = self.write('doc.json', {'a': 1}) + cases = [ + ([{'op': 'remove', 'path': '/b'}], "can't remove a non-existent object 'b'"), + ([{'op': 'test', 'path': '/a', 'value': 2}], 'is not equal to tested value'), + ([{'op': 'remove', 'path': '/b/c'}], "member 'b' not found"), + ([{'op': 'frobnicate', 'path': '/a'}], "Unknown operation 'frobnicate'"), + ({'op': 'remove', 'path': '/a'}, 'sequence of operations'), + ] + for patch, message in cases: + with self.subTest(patch=patch): + res = self.run_script('jsonpatch', doc, self.write('patch.json', patch)) + self.assert_error(res, 'jsonpatch', message) + + def test_jsonpatch_reports_invalid_json(self): + doc = self.write('doc.json', {'a': 1}) + patch = self.write('patch.json', []) + broken = os.path.join(self.dir, 'broken.json') + with open(broken, 'w', encoding='utf-8') as f: + f.write('[') + res = self.run_script('jsonpatch', broken, patch) + self.assert_error(res, 'jsonpatch', 'broken.json', 'line 1 column 2') + res = self.run_script('jsonpatch', doc, broken) + self.assert_error(res, 'jsonpatch', 'broken.json', 'line 1 column 2') + res = self.run_script('jsonpatch', doc, stdin='[') + self.assert_error(res, 'jsonpatch', '', 'line 1 column 2') + + def test_jsonpatch_reports_invalid_json_of_unnamed_stream(self): + doc = self.write('doc.json', {'a': 1}) + parser = jsonpatch_cli.jsonpatch_parser + args = parser.parse_args([doc]) + self.addCleanup(args.ORIGINAL.close) + # a stream without a name, as sys.stdin can be replaced by + args.PATCH = io.StringIO('[') + with mock.patch.object(parser, 'parse_args', return_value=args), \ + contextlib.redirect_stderr(io.StringIO()) as stderr: + with self.assertRaises(SystemExit) as caught: + jsonpatch_cli.jsonpatch_main() + self.assertEqual(caught.exception.code, 2) + self.assertTrue(stderr.getvalue().startswith('jsonpatch: error: : '), + stderr.getvalue()) + + def test_jsonpatch_reports_output_error(self): + doc = self.write('doc.json', {'a': 1}) + patch = self.write('patch.json', []) + failure = PermissionError(13, 'Permission denied', 'doc.json') + with mock.patch('sys.argv', ['jsonpatch', '-i', doc, patch]), \ + mock.patch('os.replace', side_effect=failure), \ + contextlib.redirect_stderr(io.StringIO()) as stderr: + with self.assertRaises(SystemExit) as caught: + jsonpatch_cli.jsonpatch_main() + self.assertEqual(caught.exception.code, 2) + self.assertEqual(stderr.getvalue(), + "jsonpatch: error: [Errno 13] Permission denied: 'doc.json'\n") + self.assertEqual(self.read(doc), {'a': 1}) + + def test_interrupt_is_not_reported(self): + for main, name in ((jsonpatch_cli.jsondiff_main, 'diff_files'), + (jsonpatch_cli.jsonpatch_main, 'patch_files')): + with self.subTest(program=name): + with mock.patch.object(jsonpatch_cli, name, side_effect=KeyboardInterrupt), \ + contextlib.redirect_stderr(io.StringIO()) as stderr: + with self.assertRaises(SystemExit) as caught: + main() + self.assertEqual(caught.exception.code, 1) + self.assertEqual(stderr.getvalue(), '') + def test_jsondiff_closes_inputs(self): for target in (1, 2): with self.subTest(target=target): @@ -3265,8 +3359,10 @@ def test_jsondiff_closes_both_inputs_after_parse_error(self): self.addCleanup(args.FILE1.close) self.addCleanup(args.FILE2.close) with mock.patch.object(parser, 'parse_args', return_value=args): - with self.assertRaises(json.JSONDecodeError): + with self.assertRaises(ValueError) as caught: jsonpatch_cli.diff_files() + self.assertIsInstance(caught.exception.__cause__, json.JSONDecodeError) + self.assertIn(os.path.basename(paths[invalid]), str(caught.exception)) self.assertTrue(args.FILE1.closed) self.assertTrue(args.FILE2.closed) @@ -3381,8 +3477,7 @@ def test_jsonpatch_rejects_invalid_patch_text_before_in_place_write(self): with open(path, 'w', encoding='utf-8') as f: f.write(patch) res = self.run_script('jsonpatch', '-i', doc, path) - self.assertNotEqual(res.returncode, 0) - self.assertIn('InvalidJsonPatch', res.stderr) + self.assert_error(res, 'jsonpatch') with open(doc, 'rb') as f: self.assertEqual(f.read(), original) @@ -3611,8 +3706,7 @@ def test_jsonpatch_in_place_failing_patch(self): doc = self.write('doc.json', {'a': 1}) patch = self.write('patch.json', [{'op': 'remove', 'path': '/b'}]) res = self.run_script('jsonpatch', '-i', '-b', doc, patch) - self.assertNotEqual(res.returncode, 0) - self.assertIn('JsonPatchPathNotFound', res.stderr) + self.assert_error(res, 'jsonpatch', "can't remove a non-existent object 'b'") self.assertEqual(self.read(doc), {'a': 1}) self.assertEqual(sorted(os.listdir(self.dir)), ['doc.json', 'patch.json'])