From ba249266dad94aed6d5611656f25ba1a4d70df63 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:05:43 +0000 Subject: [PATCH 1/3] Exit with status 130 when the CLI is interrupted Ctrl-C made jsonpointer exit with 1, the same status as "pointer could not be resolved", so callers could not tell the two apart. Use 130 (128 + SIGINT), the conventional status for an interrupted command, and document it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PewzpjhRBV84QNbKy9H564 --- bin/jsonpointer | 3 ++- doc/commandline.rst | 4 ++-- tests.py | 11 +++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/bin/jsonpointer b/bin/jsonpointer index a1954e5..15b9f5b 100755 --- a/bin/jsonpointer +++ b/bin/jsonpointer @@ -37,7 +37,8 @@ def main(): try: ok = resolve_files() except KeyboardInterrupt: - sys.exit(1) + # Conventional status for termination by SIGINT (128 + 2) + sys.exit(130) if not ok: sys.exit(1) diff --git a/doc/commandline.rst b/doc/commandline.rst index 5c5da83..e170d02 100644 --- a/doc/commandline.rst +++ b/doc/commandline.rst @@ -35,8 +35,8 @@ JSON input files and pointer files are read as UTF-8. 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. +processed), 2 on usage errors, such as an invalid pointer or a missing +file, and 130 if interrupted with Ctrl-C. By default, invalid JSON or a text decoding error stops processing. With ``--continue-on-error``, these errors are reported on standard error with diff --git a/tests.py b/tests.py index c1542d7..dcfd4c5 100755 --- a/tests.py +++ b/tests.py @@ -755,6 +755,17 @@ def test_keeps_stdin_open(self): self.assertTrue(cli['resolve_files']()) self.assertFalse(stdin.closed) + def test_keyboard_interrupt_exit_status(self): + cli = runpy.run_path(self.SCRIPT) + + def interrupted(): + raise KeyboardInterrupt + + cli['resolve_files'] = interrupted + with self.assertRaises(SystemExit) as caught: + cli['main']() + self.assertEqual(caught.exception.code, 130) + def test_utf8_json_file_without_utf8_mode(self): self._write('unicode.json', '{"value": "🚀"}') proc = self._run('/value', 'unicode.json', utf8=False) From 0c350271c7b07c2d8014e5b1beb4c26aa1633285 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:06:28 +0000 Subject: [PATCH 2/3] Fix the Ctrl-C exit status test runpy.run_path() returns a copy of the script's globals, so replacing resolve_files in it had no effect on main(). Raise KeyboardInterrupt from json.load() instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PewzpjhRBV84QNbKy9H564 --- tests.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests.py b/tests.py index dcfd4c5..6c12462 100755 --- a/tests.py +++ b/tests.py @@ -757,13 +757,11 @@ def test_keeps_stdin_open(self): def test_keyboard_interrupt_exit_status(self): cli = runpy.run_path(self.SCRIPT) - - def interrupted(): - raise KeyboardInterrupt - - cli['resolve_files'] = interrupted - with self.assertRaises(SystemExit) as caught: - cli['main']() + args = [self.SCRIPT, '/a', os.path.join(self.dir, 'a.json')] + with mock.patch.object(sys, 'argv', args), \ + mock.patch('json.load', side_effect=KeyboardInterrupt): + with self.assertRaises(SystemExit) as caught: + cli['main']() self.assertEqual(caught.exception.code, 130) def test_utf8_json_file_without_utf8_mode(self): From 725fd751aa4d150fe1e5b4b6ebb6311864b7c693 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:07:03 +0000 Subject: [PATCH 3/3] Stop using the deprecated argparse.FileType in the CLI argparse.FileType is deprecated since Python 3.14. Open the pointer file and the JSON inputs with a small helper instead, which keeps the existing behaviour: - "-" still means standard input, which is never closed - files are read as UTF-8 - a file that cannot be opened is a usage error (exit status 2) with the same "can't open" message The pointer file is now opened after argument parsing rather than during it, so the help output is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PewzpjhRBV84QNbKy9H564 --- bin/jsonpointer | 27 +++++++++++++++++++-------- tests.py | 6 ++++++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/bin/jsonpointer b/bin/jsonpointer index 15b9f5b..9e0efb2 100755 --- a/bin/jsonpointer +++ b/bin/jsonpointer @@ -13,7 +13,7 @@ parser = argparse.ArgumentParser( # 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', encoding='utf-8'), +ptr_group.add_argument('-f', '--pointer-file', type=str, help='File containing a JSON pointer expression') ptr_group.add_argument('-p', '--pointer', type=str, @@ -44,6 +44,19 @@ def main(): sys.exit(1) +def open_input(name): + """ Open a UTF-8 input file, or return stdin for "-" + + Exits with a usage error if the file cannot be opened. """ + if name == '-': + return sys.stdin + + try: + return open(name, encoding='utf-8') + except OSError as e: + parser.error("can't open '%s': %s" % (name, e)) + + def parse_pointer(args): """ Return the parsed pointer and the list of files to resolve it on @@ -60,13 +73,14 @@ def parse_pointer(args): else: # Strip only the trailing line ending; other whitespace may be # part of a member name. + pointer_file = open_input(args.pointer_file) try: - ptr = args.pointer_file.read().rstrip("\r\n") + ptr = pointer_file.read().rstrip("\r\n") except UnicodeDecodeError as e: parser.error('could not decode pointer file: %s' % e) finally: - if args.pointer_file is not sys.stdin: - args.pointer_file.close() + if pointer_file is not sys.stdin: + pointer_file.close() elif args.POINTER is not None: ptr = args.POINTER @@ -95,10 +109,7 @@ def resolve_files(): ok = True for name in files: - try: - f = argparse.FileType('r', encoding='utf-8')(name) - except argparse.ArgumentTypeError as e: - parser.error(str(e)) + f = open_input(name) try: doc = json.load(f) diff --git a/tests.py b/tests.py index 6c12462..412cd21 100755 --- a/tests.py +++ b/tests.py @@ -980,6 +980,12 @@ def test_continue_on_error_does_not_skip_missing_files(self): self.assertEqual(proc.stdout, self.A_OUT) self.assertIn('missing.json', proc.stderr) + def test_missing_pointer_file(self): + proc = self._run('-f', 'missing.txt', 'a.json') + self.assertEqual(proc.returncode, 2) + self.assertEqual(proc.stdout, '') + self.assertIn("can't open 'missing.txt'", proc.stderr) + def test_pointer_file_requires_value(self): proc = self._run('-f', '-p', '/a', 'a.json') self.assertEqual(proc.returncode, 2)