diff --git a/bin/jsonpointer b/bin/jsonpointer index d3d27ae..422eb7e 100755 --- a/bin/jsonpointer +++ b/bin/jsonpointer @@ -10,17 +10,20 @@ 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') @@ -30,36 +33,71 @@ 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): - 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)') + + # 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 + return ptr, files 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 = parse_pointer(args) + ptr, files = parse_pointer(args) + ok = True + + 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) 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 b4a01de..16d2bd2 100644 --- a/doc/commandline.rst +++ b/doc/commandline.rst @@ -6,19 +6,32 @@ 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 + POINTER A JSON pointer expression (if neither -f nor -p is + given) + 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 + 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``. + +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 ^^^^^^^ @@ -32,11 +45,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 6bf80f4..a330ab4 100755 --- a/tests.py +++ b/tests.py @@ -2,7 +2,10 @@ import copy import doctest +import os +import subprocess import sys +import tempfile import unittest import jsonpointer @@ -468,6 +471,106 @@ 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_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_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_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) + self.assertIn('not allowed with', proc.stderr) + + class VerboseExceptionsTests(unittest.TestCase): def setUp(self):