From 4b7178021aecbed2340e6c338733b20844aa81de Mon Sep 17 00:00:00 2001 From: xingzhao-ji <185012714+xingzhao-ji@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:18:36 -0700 Subject: [PATCH 1/2] Continue CLI batches after malformed JSON input --- bin/jsonpointer | 13 ++++++++++--- doc/commandline.rst | 9 +++++---- tests.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/bin/jsonpointer b/bin/jsonpointer index 31ac315..6d56590 100755 --- a/bin/jsonpointer +++ b/bin/jsonpointer @@ -77,8 +77,8 @@ def parse_pointer(args): def resolve_files(): """ Resolve a JSON pointer on JSON files - Returns False if the pointer could not be resolved on at least one - file. """ + Returns False if at least one file contains invalid JSON or the pointer + could not be resolved on it. """ args = parser.parse_args() ptr, files = parse_pointer(args) @@ -90,7 +90,14 @@ def resolve_files(): except argparse.ArgumentTypeError as e: parser.error(str(e)) - doc = json.load(f) + try: + doc = json.load(f) + except json.JSONDecodeError as e: + print('Could not parse JSON in %r: %s' % (name, e), + file=sys.stderr) + ok = False + continue + try: result = jsonpointer.resolve_pointer(doc, ptr) print(json.dumps(result, indent=args.indent)) diff --git a/doc/commandline.rst b/doc/commandline.rst index 16d2bd2..db1f8c0 100644 --- a/doc/commandline.rst +++ b/doc/commandline.rst @@ -28,10 +28,11 @@ The program has the following usage :: The pointer can be passed as the first positional argument, with ``-p``, or read from a file with ``-f``. -The exit status is 0 if the pointer could be resolved on all files, 1 if it -could not be resolved on at least one file (the remaining files are still -processed) and 2 on usage errors, such as an invalid pointer or a missing -file. +The exit status is 0 if the pointer could be resolved on all files, 1 if a +file contains invalid JSON or the pointer could not be resolved on at least +one file (the remaining files are still processed) and 2 on usage errors, +such as an invalid pointer or a missing file. Invalid JSON is reported on +standard error with the file name and the decoder's error location. Example ^^^^^^^ diff --git a/tests.py b/tests.py index 8f6ce5f..4cff3e5 100755 --- a/tests.py +++ b/tests.py @@ -584,6 +584,40 @@ def test_unresolvable_pointer_in_one_file(self): self.assertIn('Could not resolve pointer', proc.stderr) self.assertEqual(proc.stdout, '1\n') + def test_invalid_json(self): + for content in ('', '{"a": }', '{"a": 1}\n{"a": 2}'): + with self.subTest(content=content): + self._write('invalid.json', content) + proc = self._run('/a', 'invalid.json') + self.assertEqual(proc.returncode, 1) + self.assertEqual(proc.stdout, '') + self.assertIn("Could not parse JSON in 'invalid.json':", + proc.stderr) + self.assertIn('line ', proc.stderr) + self.assertNotIn('Traceback', proc.stderr) + + def test_invalid_json_in_one_file(self): + self._write('invalid.json', '{"a": }') + for index in range(3): + with self.subTest(index=index): + files = ['a.json', 'b.json'] + files.insert(index, 'invalid.json') + proc = self._run('/a', *files) + self.assertEqual(proc.returncode, 1) + self.assertEqual(proc.stdout, self.A_OUT + self.B_OUT) + self.assertIn("Could not parse JSON in 'invalid.json':", + proc.stderr) + self.assertNotIn('Traceback', proc.stderr) + + def test_invalid_json_and_unresolvable_pointer(self): + self._write('invalid.json', '{"a": }') + proc = self._run('/b', 'invalid.json', 'a.json', 'b.json') + self.assertEqual(proc.returncode, 1) + self.assertEqual(proc.stdout, '1\n') + self.assertIn("Could not parse JSON in 'invalid.json':", proc.stderr) + self.assertIn('Could not resolve pointer:', proc.stderr) + self.assertNotIn('Traceback', proc.stderr) + def test_pointer_file_requires_value(self): proc = self._run('-f', '-p', '/a', 'a.json') self.assertEqual(proc.returncode, 2) From 15079d6f31882620c53f3e0db271fbd9a3cb2e67 Mon Sep 17 00:00:00 2001 From: xingzhao-ji <185012714+xingzhao-ji@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:43:13 -0700 Subject: [PATCH 2/2] Make malformed-input continuation opt-in --- bin/jsonpointer | 10 +++++--- doc/commandline.rst | 21 +++++++++++----- tests.py | 60 ++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 78 insertions(+), 13 deletions(-) diff --git a/bin/jsonpointer b/bin/jsonpointer index 6d56590..c269268 100755 --- a/bin/jsonpointer +++ b/bin/jsonpointer @@ -27,6 +27,8 @@ parser.add_argument('FILE', type=str, nargs='+', help='Files for which the pointer should be resolved') parser.add_argument('--indent', type=int, default=None, help='Indent output by n spaces') +parser.add_argument('--continue-on-error', action='store_true', + help='Continue with remaining files after JSON or text decoding errors') parser.add_argument('-v', '--version', action='version', version='%(prog)s ' + jsonpointer.__version__) @@ -77,8 +79,8 @@ def parse_pointer(args): def resolve_files(): """ Resolve a JSON pointer on JSON files - Returns False if at least one file contains invalid JSON or the pointer - could not be resolved on it. """ + Returns False if the pointer could not be resolved on at least one file, + or if --continue-on-error skipped a JSON or text decoding error. """ args = parser.parse_args() ptr, files = parse_pointer(args) @@ -92,7 +94,9 @@ def resolve_files(): try: doc = json.load(f) - except json.JSONDecodeError as e: + except (json.JSONDecodeError, UnicodeDecodeError) as e: + if not args.continue_on_error: + raise print('Could not parse JSON in %r: %s' % (name, e), file=sys.stderr) ok = False diff --git a/doc/commandline.rst b/doc/commandline.rst index db1f8c0..0995260 100644 --- a/doc/commandline.rst +++ b/doc/commandline.rst @@ -6,7 +6,8 @@ that can be used to resolve a JSON pointers on JSON files. The program has the following usage :: - usage: jsonpointer [-h] [-f POINTER_FILE | -p POINTER] [--indent INDENT] [-v] + usage: jsonpointer [-h] [-f POINTER_FILE | -p POINTER] [--indent INDENT] + [--continue-on-error] [-v] [POINTER] FILE [FILE ...] Resolve a JSON pointer on JSON files @@ -23,16 +24,24 @@ The program has the following usage :: -p POINTER, --pointer POINTER A JSON pointer expression --indent INDENT Indent output by n spaces + --continue-on-error Continue with remaining files after JSON or text + decoding errors -v, --version show program's version number and exit The pointer can be passed as the first positional argument, with ``-p``, or read from a file with ``-f``. -The exit status is 0 if the pointer could be resolved on all files, 1 if a -file contains invalid JSON or the pointer could not be resolved on at least -one file (the remaining files are still processed) and 2 on usage errors, -such as an invalid pointer or a missing file. Invalid JSON is reported on -standard error with the file name and the decoder's error location. +The exit status is 0 if the pointer could be resolved on all files, 1 if it +could not be resolved on at least one file (the remaining files are still +processed) and 2 on usage errors, such as an invalid pointer or a missing +file. + +By default, invalid JSON or a text decoding error stops processing. With +``--continue-on-error``, these errors are reported on standard error with +the file name and decoder details, and the remaining files are processed. +The exit status is 1 if any file has such an error, even if later files +succeed. Missing files and other usage errors still stop processing with +status 2. Example ^^^^^^^ diff --git a/tests.py b/tests.py index 4cff3e5..e184b5f 100755 --- a/tests.py +++ b/tests.py @@ -507,7 +507,7 @@ def _write(self, name, content): f.write(content) def _run(self, *args): - env = dict(os.environ, PYTHONPATH=self.ROOT) + env = dict(os.environ, PYTHONPATH=self.ROOT, PYTHONUTF8='1') return subprocess.run([sys.executable, self.SCRIPT] + list(args), cwd=self.dir, env=env, capture_output=True, text=True) @@ -588,7 +588,7 @@ def test_invalid_json(self): for content in ('', '{"a": }', '{"a": 1}\n{"a": 2}'): with self.subTest(content=content): self._write('invalid.json', content) - proc = self._run('/a', 'invalid.json') + proc = self._run('--continue-on-error', '/a', 'invalid.json') self.assertEqual(proc.returncode, 1) self.assertEqual(proc.stdout, '') self.assertIn("Could not parse JSON in 'invalid.json':", @@ -602,7 +602,7 @@ def test_invalid_json_in_one_file(self): with self.subTest(index=index): files = ['a.json', 'b.json'] files.insert(index, 'invalid.json') - proc = self._run('/a', *files) + proc = self._run('--continue-on-error', '/a', *files) self.assertEqual(proc.returncode, 1) self.assertEqual(proc.stdout, self.A_OUT + self.B_OUT) self.assertIn("Could not parse JSON in 'invalid.json':", @@ -611,13 +611,65 @@ def test_invalid_json_in_one_file(self): def test_invalid_json_and_unresolvable_pointer(self): self._write('invalid.json', '{"a": }') - proc = self._run('/b', 'invalid.json', 'a.json', 'b.json') + proc = self._run('--continue-on-error', '/b', + 'invalid.json', 'a.json', 'b.json') self.assertEqual(proc.returncode, 1) self.assertEqual(proc.stdout, '1\n') self.assertIn("Could not parse JSON in 'invalid.json':", proc.stderr) self.assertIn('Could not resolve pointer:', proc.stderr) self.assertNotIn('Traceback', proc.stderr) + def test_invalid_json_stops_without_continue_on_error(self): + self._write('invalid.json', '{"a": }') + for index in range(3): + with self.subTest(index=index): + files = ['a.json', 'b.json'] + files.insert(index, 'invalid.json') + proc = self._run('/a', *files) + self.assertEqual(proc.returncode, 1) + self.assertEqual(proc.stdout, + ''.join([self.A_OUT, self.B_OUT][:index])) + self.assertIn('JSONDecodeError', proc.stderr) + + def test_invalid_encoding_in_one_file(self): + with open(os.path.join(self.dir, 'invalid.json'), 'wb') as f: + f.write(b'{"a":"\xff"}') + for index in range(3): + with self.subTest(index=index): + files = ['a.json', 'b.json'] + files.insert(index, 'invalid.json') + proc = self._run('--continue-on-error', '/a', *files) + self.assertEqual(proc.returncode, 1) + self.assertEqual(proc.stdout, self.A_OUT + self.B_OUT) + self.assertIn("Could not parse JSON in 'invalid.json':", + proc.stderr) + self.assertIn('decode', proc.stderr) + self.assertNotIn('Traceback', proc.stderr) + + def test_invalid_encoding_stops_without_continue_on_error(self): + with open(os.path.join(self.dir, 'invalid.json'), 'wb') as f: + f.write(b'{"a":"\xff"}') + proc = self._run('/a', 'a.json', 'invalid.json', 'b.json') + self.assertEqual(proc.returncode, 1) + self.assertEqual(proc.stdout, self.A_OUT) + self.assertIn('UnicodeDecodeError', proc.stderr) + + def test_continue_on_error_with_valid_files(self): + for pointer in (['/a'], ['-p', '/a'], ['-f', 'ptr.txt']): + with self.subTest(pointer=pointer): + proc = self._run('--continue-on-error', *pointer, + 'a.json', 'b.json') + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(proc.stdout, self.A_OUT + self.B_OUT) + self.assertEqual(proc.stderr, '') + + def test_continue_on_error_does_not_skip_missing_files(self): + proc = self._run('--continue-on-error', '/a', + 'a.json', 'missing.json', 'b.json') + self.assertEqual(proc.returncode, 2) + self.assertEqual(proc.stdout, self.A_OUT) + self.assertIn('missing.json', proc.stderr) + def test_pointer_file_requires_value(self): proc = self._run('-f', '-p', '/a', 'a.json') self.assertEqual(proc.returncode, 2)