From 890780b9497d3fbd142caf29c8de26ed5959b621 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 10:20:43 +0000 Subject: [PATCH 1/5] cli: fix --pointer-file without breaking positional POINTER `jsonpointer -f ptr.txt a.json b.json` failed because argparse assigned a.json to the optional positional POINTER, which was in a mutually exclusive group with -f. Move POINTER out of the group and treat it as the first file whenever the pointer is given via -f or the new -p/--pointer option. The existing `jsonpointer /a a.json b.json` usage keeps working unchanged. Fixes #43, backwards compatible alternative to #44. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019xLCiTJTbWQ9Nt7UHq2yiU --- bin/jsonpointer | 46 ++++++++++++++++++++++-------- doc/commandline.rst | 46 ++++++++++++++++++++---------- tests.py | 68 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 26 deletions(-) diff --git a/bin/jsonpointer b/bin/jsonpointer index d3d27ae..fd4dfc0 100755 --- a/bin/jsonpointer +++ b/bin/jsonpointer @@ -10,17 +10,21 @@ import jsonpointer parser = argparse.ArgumentParser( description='Resolve a JSON pointer on JSON files') -# Accept pointer as argument or as file -ptr_group = parser.add_mutually_exclusive_group(required=True) +# Accept pointer as option, as file or as first positional argument +ptr_group = parser.add_mutually_exclusive_group() ptr_group.add_argument('-f', '--pointer-file', type=argparse.FileType('r'), nargs='?', help='File containing a JSON pointer expression') -ptr_group.add_argument('POINTER', type=str, nargs='?', +ptr_group.add_argument('-p', '--pointer', type=str, help='A JSON pointer expression') -parser.add_argument('FILE', type=argparse.FileType('r'), nargs='+', +parser.add_argument('POINTER', type=str, nargs='?', + help='A JSON pointer expression (if neither -f nor -p ' + 'is given)') + +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') @@ -36,24 +40,42 @@ def main(): def parse_pointer(args): - if args.POINTER: + """ Return the pointer and the list of files to resolve it on + + If the pointer is given via -f or -p, a POINTER positional argument is + actually the first file. """ + files = args.FILE + + if args.pointer is not None or args.pointer_file is not None: + if args.POINTER is not None: + files = [args.POINTER] + files + + if args.pointer is not None: + ptr = args.pointer + else: + ptr = args.pointer_file.read().strip() + + elif args.POINTER is not None: ptr = args.POINTER - elif args.pointer_file: - ptr = args.pointer_file.read().strip() + else: - parser.print_usage() - sys.exit(1) + parser.error('a JSON pointer is required (POINTER, -p or -f)') - return ptr + return ptr, files def resolve_files(): """ Resolve a JSON pointer on JSON files """ args = parser.parse_args() - ptr = parse_pointer(args) + ptr, files = parse_pointer(args) + + for name in files: + try: + f = argparse.FileType('r')(name) + except argparse.ArgumentTypeError as e: + parser.error(str(e)) - for f in args.FILE: doc = json.load(f) try: result = jsonpointer.resolve_pointer(doc, ptr) diff --git a/doc/commandline.rst b/doc/commandline.rst index b4a01de..886a8d5 100644 --- a/doc/commandline.rst +++ b/doc/commandline.rst @@ -6,19 +6,28 @@ that can be used to resolve a JSON pointers on JSON files. The program has the following usage :: - usage: jsonpointer [-h] [--indent INDENT] [-v] POINTER FILE [FILE ...] + usage: jsonpointer [-h] [-f [POINTER_FILE] | -p POINTER] [--indent INDENT] + [-v] + [POINTER] FILE [FILE ...] Resolve a JSON pointer on JSON files positional arguments: - POINTER File containing a JSON pointer expression - FILE Files for which the pointer should be resolved - - optional arguments: - -h, --help show this help message and exit - --indent INDENT Indent output by n spaces - -v, --version show program's version number and exit - + POINTER A JSON pointer expression (if neither -f nor -p is + given) + FILE Files for which the pointer should be resolved + + options: + -h, --help show this help message and exit + -f [POINTER_FILE], --pointer-file [POINTER_FILE] + File containing a JSON pointer expression + -p POINTER, --pointer POINTER + A JSON pointer expression + --indent INDENT Indent output by n spaces + -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``. Example ^^^^^^^ @@ -32,11 +41,20 @@ Example $ cat b.json { "a": {"b": [1, 3, 4]}, "b": 1 } - # inspect JSON pointer - $ cat ptr.json - "/a" - # resolve JSON pointer - $ jsonpointer ptr.json a.json b.json + $ jsonpointer /a a.json b.json + [1, 2, 3] + {"b": [1, 3, 4]} + + # same, using the -p option + $ jsonpointer -p /a a.json b.json + [1, 2, 3] + {"b": [1, 3, 4]} + + # same, reading the pointer from a file + $ cat ptr.txt + /a + + $ jsonpointer -f ptr.txt a.json b.json [1, 2, 3] {"b": [1, 3, 4]} diff --git a/tests.py b/tests.py index 19bd621..2fe17ee 100755 --- a/tests.py +++ b/tests.py @@ -2,6 +2,10 @@ import copy import doctest +import os +import subprocess +import sys +import tempfile import unittest import jsonpointer @@ -401,6 +405,70 @@ def test_mock_dict_raises_key_error(self): self.assertRaises(JsonPointerException, resolve_pointer, doc, '/root/1/2/3/4') +class CommandLineTests(unittest.TestCase): + """ Tests the jsonpointer command line utility """ + + ROOT = os.path.dirname(os.path.abspath(__file__)) + SCRIPT = os.path.join(ROOT, 'bin', 'jsonpointer') + + A_OUT = '[1, 2, 3]\n' + B_OUT = '{"b": [1, 3, 4]}\n' + + def setUp(self): + tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(tmpdir.cleanup) + self.dir = tmpdir.name + + self._write('a.json', '{ "a": [1, 2, 3] }') + self._write('b.json', '{ "a": {"b": [1, 3, 4]}, "b": 1 }') + self._write('ptr.txt', '/a\n') + + def _write(self, name, content): + with open(os.path.join(self.dir, name), 'w') as f: + f.write(content) + + def _run(self, *args): + env = dict(os.environ, PYTHONPATH=self.ROOT) + return subprocess.run([sys.executable, self.SCRIPT] + list(args), + cwd=self.dir, env=env, capture_output=True, + text=True) + + def test_positional_pointer(self): + proc = self._run('/a', 'a.json', 'b.json') + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(proc.stdout, self.A_OUT + self.B_OUT) + + def test_positional_pointer_single_file(self): + proc = self._run('/a', 'a.json') + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(proc.stdout, self.A_OUT) + + def test_pointer_file(self): + proc = self._run('-f', 'ptr.txt', 'a.json', 'b.json') + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(proc.stdout, self.A_OUT + self.B_OUT) + + def test_pointer_file_single_file(self): + proc = self._run('-f', 'ptr.txt', 'a.json') + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(proc.stdout, self.A_OUT) + + def test_pointer_option(self): + proc = self._run('-p', '/a', 'a.json', 'b.json') + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(proc.stdout, self.A_OUT + self.B_OUT) + + def test_no_pointer(self): + proc = self._run('a.json') + self.assertEqual(proc.returncode, 2) + self.assertIn('a JSON pointer is required', proc.stderr) + + def test_pointer_and_pointer_file_exclusive(self): + proc = self._run('-p', '/a', '-f', 'ptr.txt', 'a.json') + self.assertEqual(proc.returncode, 2) + self.assertIn('not allowed with', proc.stderr) + + def load_tests(loader, tests, ignore): tests.addTests(doctest.DocTestSuite(jsonpointer)) return tests From 71f9b9ecf82e750d93acf92c65534fbf5f6725be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sat, 3 Oct 2026 23:08:00 +0200 Subject: [PATCH 2/5] fix flake8 issues --- tests.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests.py b/tests.py index 94294c7..e28d735 100755 --- a/tests.py +++ b/tests.py @@ -534,6 +534,7 @@ def test_pointer_and_pointer_file_exclusive(self): self.assertEqual(proc.returncode, 2) self.assertIn('not allowed with', proc.stderr) + class VerboseExceptionsTests(unittest.TestCase): def setUp(self): @@ -562,6 +563,7 @@ def test_non_verbose_exception_excludes_doc(self): self.assertNotIn(repr(doc), str(e)) self.assertIn('bar', str(e)) + def load_tests(loader, tests, ignore): tests.addTests(doctest.DocTestSuite(jsonpointer)) return tests From cb9fd8e1b4fe833cc3c482f2022bb15f753360da Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 21:15:27 +0000 Subject: [PATCH 3/5] cli: reject invalid pointers with a usage error When the pointer was omitted, e.g. `jsonpointer a.json b.json`, the first file was taken as the pointer, every file failed to resolve and the command still exited with status 0. Validate the pointer before processing any files and exit with a usage error if it is not a valid JSON pointer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019xLCiTJTbWQ9Nt7UHq2yiU --- bin/jsonpointer | 7 +++++++ tests.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/bin/jsonpointer b/bin/jsonpointer index fd4dfc0..8a1a63a 100755 --- a/bin/jsonpointer +++ b/bin/jsonpointer @@ -61,6 +61,13 @@ def parse_pointer(args): else: parser.error('a JSON pointer is required (POINTER, -p or -f)') + # Catches e.g. `jsonpointer a.json b.json`, where the pointer was omitted + # and the first file would otherwise be used as the pointer + try: + jsonpointer.JsonPointer(ptr) + except jsonpointer.JsonPointerException as e: + parser.error('invalid JSON pointer %r: %s' % (ptr, e)) + return ptr, files diff --git a/tests.py b/tests.py index e28d735..e049049 100755 --- a/tests.py +++ b/tests.py @@ -529,6 +529,23 @@ def test_no_pointer(self): self.assertEqual(proc.returncode, 2) self.assertIn('a JSON pointer is required', proc.stderr) + def test_missing_pointer_multiple_files(self): + proc = self._run('a.json', 'b.json') + self.assertEqual(proc.returncode, 2) + self.assertIn("invalid JSON pointer 'a.json'", proc.stderr) + self.assertEqual(proc.stdout, '') + + def test_invalid_pointer_file(self): + self._write('bad.txt', 'a\n') + proc = self._run('-f', 'bad.txt', 'a.json') + self.assertEqual(proc.returncode, 2) + self.assertIn("invalid JSON pointer 'a'", proc.stderr) + + def test_empty_pointer(self): + proc = self._run('', 'a.json') + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(proc.stdout, '{"a": [1, 2, 3]}\n') + def test_pointer_and_pointer_file_exclusive(self): proc = self._run('-p', '/a', '-f', 'ptr.txt', 'a.json') self.assertEqual(proc.returncode, 2) From 063e73cecf23494fce10b0d6f96e32db5a154444 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 12:43:20 +0000 Subject: [PATCH 4/5] cli: exit with status 1 if a pointer can't be resolved Previously the command printed "Could not resolve pointer" to stderr but still exited with status 0. Keep processing the remaining files, but exit with status 1 if the pointer could not be resolved on at least one of them. Document the exit statuses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019xLCiTJTbWQ9Nt7UHq2yiU --- bin/jsonpointer | 14 ++++++++++++-- doc/commandline.rst | 5 +++++ tests.py | 14 ++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/bin/jsonpointer b/bin/jsonpointer index 8a1a63a..ffa603c 100755 --- a/bin/jsonpointer +++ b/bin/jsonpointer @@ -34,10 +34,13 @@ parser.add_argument('-v', '--version', action='version', def main(): try: - resolve_files() + ok = resolve_files() except KeyboardInterrupt: sys.exit(1) + if not ok: + sys.exit(1) + def parse_pointer(args): """ Return the pointer and the list of files to resolve it on @@ -72,10 +75,14 @@ def parse_pointer(args): def resolve_files(): - """ Resolve a JSON pointer on JSON files """ + """ Resolve a JSON pointer on JSON files + + Returns False if the pointer could not be resolved on at least one + file. """ args = parser.parse_args() ptr, files = parse_pointer(args) + ok = True for name in files: try: @@ -89,6 +96,9 @@ def resolve_files(): print(json.dumps(result, indent=args.indent)) except jsonpointer.JsonPointerException as e: print('Could not resolve pointer: %s' % str(e), file=sys.stderr) + ok = False + + return ok if __name__ == "__main__": diff --git a/doc/commandline.rst b/doc/commandline.rst index 886a8d5..0196893 100644 --- a/doc/commandline.rst +++ b/doc/commandline.rst @@ -29,6 +29,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. + Example ^^^^^^^ diff --git a/tests.py b/tests.py index e049049..85f17c5 100755 --- a/tests.py +++ b/tests.py @@ -546,6 +546,20 @@ def test_empty_pointer(self): self.assertEqual(proc.returncode, 0, proc.stderr) self.assertEqual(proc.stdout, '{"a": [1, 2, 3]}\n') + def test_unresolvable_pointer(self): + proc = self._run('/x', 'a.json') + self.assertEqual(proc.returncode, 1) + self.assertIn('Could not resolve pointer', proc.stderr) + self.assertEqual(proc.stdout, '') + + def test_unresolvable_pointer_in_one_file(self): + # /b resolves in b.json only; a.json is still reported but b.json + # is processed + proc = self._run('/b', 'a.json', 'b.json') + self.assertEqual(proc.returncode, 1) + self.assertIn('Could not resolve pointer', proc.stderr) + self.assertEqual(proc.stdout, '1\n') + def test_pointer_and_pointer_file_exclusive(self): proc = self._run('-p', '/a', '-f', 'ptr.txt', 'a.json') self.assertEqual(proc.returncode, 2) From a7f2a5c1e051a741a82c9c82cb6a8a37753ee97e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 12:44:45 +0000 Subject: [PATCH 5/5] cli: require a value for -f/--pointer-file With nargs='?', a bare `-f` stored None, which equals the default, so argparse did not enforce the mutual exclusion with -p (e.g. `jsonpointer -f -p /a a.json` was accepted). A bare -f never had a useful meaning, so require the file argument. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019xLCiTJTbWQ9Nt7UHq2yiU --- bin/jsonpointer | 1 - doc/commandline.rst | 5 ++--- tests.py | 5 +++++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/bin/jsonpointer b/bin/jsonpointer index ffa603c..422eb7e 100755 --- a/bin/jsonpointer +++ b/bin/jsonpointer @@ -14,7 +14,6 @@ parser = argparse.ArgumentParser( ptr_group = parser.add_mutually_exclusive_group() ptr_group.add_argument('-f', '--pointer-file', type=argparse.FileType('r'), - nargs='?', help='File containing a JSON pointer expression') ptr_group.add_argument('-p', '--pointer', type=str, diff --git a/doc/commandline.rst b/doc/commandline.rst index 0196893..16d2bd2 100644 --- a/doc/commandline.rst +++ b/doc/commandline.rst @@ -6,8 +6,7 @@ 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] [-v] [POINTER] FILE [FILE ...] Resolve a JSON pointer on JSON files @@ -19,7 +18,7 @@ The program has the following usage :: options: -h, --help show this help message and exit - -f [POINTER_FILE], --pointer-file [POINTER_FILE] + -f POINTER_FILE, --pointer-file POINTER_FILE File containing a JSON pointer expression -p POINTER, --pointer POINTER A JSON pointer expression diff --git a/tests.py b/tests.py index 85f17c5..a330ab4 100755 --- a/tests.py +++ b/tests.py @@ -560,6 +560,11 @@ def test_unresolvable_pointer_in_one_file(self): self.assertIn('Could not resolve pointer', proc.stderr) self.assertEqual(proc.stdout, '1\n') + def test_pointer_file_requires_value(self): + proc = self._run('-f', '-p', '/a', 'a.json') + self.assertEqual(proc.returncode, 2) + self.assertIn('expected one argument', proc.stderr) + def test_pointer_and_pointer_file_exclusive(self): proc = self._run('-p', '/a', '-f', 'ptr.txt', 'a.json') self.assertEqual(proc.returncode, 2)