diff --git a/bin/jsonpointer b/bin/jsonpointer index a1954e5..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, @@ -37,12 +37,26 @@ 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) +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 @@ -59,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 @@ -94,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/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..412cd21 100755 --- a/tests.py +++ b/tests.py @@ -755,6 +755,15 @@ 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) + 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): self._write('unicode.json', '{"value": "🚀"}') proc = self._run('/value', 'unicode.json', utf8=False) @@ -971,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)