diff --git a/docs/faq.rst b/docs/faq.rst index bb30c82d8..89cbe4804 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -225,6 +225,13 @@ notice: * the ``jsonschema.compat`` module, which is for internal compatibility use + * the specific non-zero error codes presented by the command line + interface + + * the exact representation of errors presented by the command line + interface, other than that errors represented by the plain outputter + will be reported one per line + * anything marked private With the exception of the last two of those, flippant changes are diff --git a/docs/spelling-wordlist.txt b/docs/spelling-wordlist.txt index 496c56e14..e34c39f7c 100644 --- a/docs/spelling-wordlist.txt +++ b/docs/spelling-wordlist.txt @@ -20,6 +20,7 @@ jsonschema majorly metaschema online +outputter pre programmatically recurses diff --git a/jsonschema/cli.py b/jsonschema/cli.py index 601f65b7f..93db27997 100644 --- a/jsonschema/cli.py +++ b/jsonschema/cli.py @@ -1,27 +1,103 @@ """ The ``jsonschema`` command line. """ + from __future__ import absolute_import +from textwrap import dedent import argparse import json import sys +import traceback + +import attr from jsonschema import __version__ from jsonschema._reflect import namedAny +from jsonschema.compat import JSONDecodeError +from jsonschema.exceptions import SchemaError from jsonschema.validators import validator_for +@attr.s +class _Outputter(object): + + _formatter = attr.ib() + _stdout = attr.ib() + _stderr = attr.ib() + + @classmethod + def from_arguments(cls, arguments, stdout, stderr): + if arguments["output"] == "plain": + formatter = _PlainFormatter(arguments["error_format"]) + elif arguments["output"] == "pretty": + formatter = _PrettyFormatter() + return cls(formatter=formatter, stdout=stdout, stderr=stderr) + + def parsing_error(self, **kwargs): + self._stderr.write(self._formatter.parsing_error(**kwargs)) + + def validation_error(self, **kwargs): + self._stderr.write(self._formatter.validation_error(**kwargs)) + + def validation_success(self, **kwargs): + self._stdout.write(self._formatter.validation_success(**kwargs)) + + +@attr.s +class _PrettyFormatter(object): + + _ERROR_MSG = dedent( + """\ + ===[{type.__name__}]===({path})=== + + {body} + ----------------------------- + """, + ) + _SUCCESS_MSG = "===[SUCCESS]===({path})===\n" + + def parsing_error(self, path, exc_info): + exc_type, exc_value, exc_traceback = exc_info + exc_lines = "".join( + traceback.format_exception(exc_type, exc_value, exc_traceback), + ) + return self._ERROR_MSG.format(path=path, type=exc_type, body=exc_lines) + + def validation_error(self, instance_path, error): + return self._ERROR_MSG.format( + path=instance_path, + type=error.__class__, + body=error, + ) + + def validation_success(self, instance_path): + return self._SUCCESS_MSG.format(path=instance_path) + + +@attr.s +class _PlainFormatter(object): + + _error_format = attr.ib() + + def parsing_error(self, path, exc_info): + return "Failed to parse {}. Got the following error: {}\n".format( + "" if path == "" else repr(path), + exc_info[1], + ) + + def validation_error(self, instance_path, error): + return self._error_format.format(file_name=instance_path, error=error) + + def validation_success(self, instance_path): + return "" + + def _namedAnyWithDefault(name): if "." not in name: name = "jsonschema." + name return namedAny(name) -def _json_file(path): - with open(path) as file: - return json.load(file) - - parser = argparse.ArgumentParser( description="JSON Schema Validation CLI", ) @@ -29,29 +105,41 @@ def _json_file(path): "-i", "--instance", action="append", dest="instances", - type=_json_file, - help=( - "a path to a JSON instance (i.e. filename.json) " - "to validate (may be specified multiple times)" - ), + help=""" + a path to a JSON instance (i.e. filename.json) to validate (may + be specified multiple times). If no instances are provided via this + option, one will be expected on standard input. + """, ) parser.add_argument( "-F", "--error-format", - default="{error.instance}: {error.message}\n", - help=( - "the format to use for each error output message, specified in " - "a form suitable for passing to str.format, which will be called " - "with 'error' for each error" - ), + help=""" + the format to use for each validation error message, specified + in a form suitable for str.format. This string will be passed + one formatted object named 'error' for each ValidationError. + Only provide this option when using --output=plain, which is the + default. If this argument is unprovided and --output=plain is + used, a simple default representation will be used." + """, +) +parser.add_argument( + "-o", "--output", + choices=["plain", "pretty"], + default="plain", + help=""" + an output format to use. 'plain' (default) will produce minimal + text with one line for each error, while 'pretty' will produce + more detailed human-readable output on multiple lines. + """, ) parser.add_argument( "-V", "--validator", type=_namedAnyWithDefault, - help=( - "the fully qualified object name of a validator to use, or, for " - "validators that are registered with jsonschema, simply the name " - "of the class." - ), + help=""" + the fully qualified object name of a validator to use, or, for + validators that are registered with jsonschema, simply the name + of the class. + """, ) parser.add_argument( "--version", @@ -60,32 +148,96 @@ def _json_file(path): ) parser.add_argument( "schema", - help="the JSON Schema to validate with (i.e. schema.json)", - type=_json_file, + help="the path to a JSON Schema to validate with (i.e. schema.json)", ) +def _load_json_file(path): + with open(path) as file: + return json.load(file) + + def parse_args(args): arguments = vars(parser.parse_args(args=args or ["--help"])) if arguments["validator"] is None: arguments["validator"] = validator_for(arguments["schema"]) + if arguments["output"] != "plain" and arguments["error_format"]: + raise parser.error( + "--error-format can only be used with --output plain" + ) + if arguments["output"] == "plain" and arguments["error_format"] is None: + arguments["error_format"] = "{error.instance}: {error.message}\n" return arguments -def main(args=sys.argv[1:]): - sys.exit(run(arguments=parse_args(args=args))) +def _make_validator(schema_path, Validator, outputter): + try: + schema_obj = _load_json_file(schema_path) + except (ValueError, IOError) as error: + outputter.parsing_error(path=schema_path, exc_info=sys.exc_info()) + raise error + try: + validator = Validator(schema=schema_obj) + validator.check_schema(schema_obj) + except SchemaError as error: + outputter.validation_error(instance_path=schema_path, error=error) + raise error -def run(arguments, stdout=sys.stdout, stderr=sys.stderr, stdin=sys.stdin): - error_format = arguments["error_format"] - validator = arguments["validator"](schema=arguments["schema"]) + return validator + + +def _validate_instance(instance_path, instance, validator, outputter): + invalid = False + for error in validator.iter_errors(instance): + invalid = True + outputter.validation_error(instance_path=instance_path, error=error) - validator.check_schema(arguments["schema"]) + if not invalid: + outputter.validation_success(instance_path=instance_path) + return invalid - errored = False - for instance in arguments["instances"] or [json.load(stdin)]: - for error in validator.iter_errors(instance): - stderr.write(error_format.format(error=error)) - errored = True - return errored +def main(args=sys.argv[1:]): + sys.exit(run(arguments=parse_args(args=args))) + + +def run(arguments, stdout=sys.stdout, stderr=sys.stderr, stdin=sys.stdin): + outputter = _Outputter.from_arguments( + arguments=arguments, + stdout=stdout, + stderr=stderr, + ) + + try: + validator = _make_validator( + schema_path=arguments["schema"], + Validator=arguments["validator"], + outputter=outputter, + ) + except (IOError, ValueError, SchemaError): + return 1 + + if arguments["instances"]: + load, instances = _load_json_file, arguments["instances"] + else: + def load(_): + return json.load(stdin) + instances = [""] + + exit_code = 0 + for each in instances: + try: + instance = load(each) + except JSONDecodeError: + outputter.parsing_error(path=each, exc_info=sys.exc_info()) + exit_code = 1 + else: + exit_code |= _validate_instance( + instance_path=each, + instance=instance, + validator=validator, + outputter=outputter, + ) + + return exit_code diff --git a/jsonschema/compat.py b/jsonschema/compat.py index 47e098045..623b666e9 100644 --- a/jsonschema/compat.py +++ b/jsonschema/compat.py @@ -13,6 +13,11 @@ except ImportError: from collections import MutableMapping, Sequence # noqa +try: + from json import JSONDecodeError +except ImportError: + JSONDecodeError = ValueError + PY3 = sys.version_info[0] >= 3 if PY3: diff --git a/jsonschema/tests/_helpers.py b/jsonschema/tests/_helpers.py index 70f291fe2..e35084cd4 100644 --- a/jsonschema/tests/_helpers.py +++ b/jsonschema/tests/_helpers.py @@ -1,5 +1,22 @@ +import sys +from contextlib import contextmanager + +from jsonschema.compat import NativeIO + + def bug(issue=None): message = "A known bug." if issue is not None: message += " See issue #{issue}.".format(issue=issue) return message + + +@contextmanager +def captured_output(): + new_out, new_err = NativeIO(), NativeIO() + old_out, old_err = sys.stdout, sys.stderr + try: + sys.stdout, sys.stderr = new_out, new_err + yield sys.stdout, sys.stderr + finally: + sys.stdout, sys.stderr = old_out, old_err diff --git a/jsonschema/tests/test_cli.py b/jsonschema/tests/test_cli.py index 3347ba950..0037fe1a1 100644 --- a/jsonschema/tests/test_cli.py +++ b/jsonschema/tests/test_cli.py @@ -1,11 +1,12 @@ +from textwrap import dedent from unittest import TestCase -import json import subprocess import sys from jsonschema import Draft4Validator, ValidationError, cli, __version__ -from jsonschema.compat import NativeIO -from jsonschema.exceptions import SchemaError +from jsonschema.compat import JSONDecodeError, NativeIO +from jsonschema.tests._helpers import captured_output +from jsonschema.validators import _LATEST_VERSION as LatestValidator def fake_validator(*errors): @@ -26,32 +27,26 @@ def check_schema(self, schema): return FakeValidator -class TestParser(TestCase): +def fake_open(all_contents): + def open(path): + contents = all_contents.get(path) + if contents is None: # pragma: no cover + raise RuntimeError("Unknown test fixture {!r}".format(path)) + return NativeIO(contents) + return open - FakeValidator = fake_validator() - instance_file = "foo.json" - schema_file = "schema.json" - def setUp(self): - cli.open = self.fake_open - self.addCleanup(delattr, cli, "open") +class TestParser(TestCase): - def fake_open(self, path): - if path == self.instance_file: - contents = "" - elif path == self.schema_file: - contents = {} - else: # pragma: no cover - self.fail("What is {!r}".format(path)) - return NativeIO(json.dumps(contents)) + FakeValidator = fake_validator() def test_find_validator_by_fully_qualified_object_name(self): arguments = cli.parse_args( [ "--validator", "jsonschema.tests.test_cli.TestParser.FakeValidator", - "--instance", self.instance_file, - self.schema_file, + "--instance", "mem://some/instance", + "mem://some/schema", ] ) self.assertIs(arguments["validator"], self.FakeValidator) @@ -60,66 +55,141 @@ def test_find_validator_in_jsonschema(self): arguments = cli.parse_args( [ "--validator", "Draft4Validator", - "--instance", self.instance_file, - self.schema_file, + "--instance", "mem://some/instance", + "mem://some/schema", ] ) self.assertIs(arguments["validator"], Draft4Validator) + def test_latest_validator_is_the_default(self): + arguments = cli.parse_args( + [ + "--instance", "mem://some/instance", + "mem://some/schema", + ] + ) + self.assertIs(arguments["validator"], LatestValidator) + + def test_unknown_output(self): + # Avoid the help message on stdout + with captured_output() as (stdout, stderr): + with self.assertRaises(SystemExit): + cli.parse_args( + [ + "--output", "foo", + "mem://some/schema", + ] + ) + self.assertIn("invalid choice: 'foo'", stderr.getvalue()) + self.assertFalse(stdout.getvalue()) + + def test_useless_error_format(self): + # Avoid the help message on stdout + with captured_output() as (stdout, stderr): + with self.assertRaises(SystemExit): + cli.parse_args( + [ + "--output", "pretty", + "--error-format", "foo", + "mem://some/schema", + ] + ) + self.assertIn( + "--error-format can only be used with --output plain", + stderr.getvalue(), + ) + self.assertFalse(stdout.getvalue()) + class TestCLI(TestCase): - def test_draft3_schema_draft4_validator(self): - stdout, stderr = NativeIO(), NativeIO() - with self.assertRaises(SchemaError): - cli.run( - { - "validator": Draft4Validator, - "schema": { + + pretty_parsing_error_tag = "===[" + JSONDecodeError.__name__ + "]===" + pretty_validation_error_tag = "===[ValidationError]===" + pretty_success_tag = "===[SUCCESS]===" + + def setUp(self): + self.assertFalse(hasattr(cli, "open")) + cli.open = fake_open( + { + "an invalid instance": "1", + "a valid instance": "25", + "a schema": """ + { "anyOf": [ {"minimum": 20}, {"type": "string"}, - {"required": True}, - ], - }, - "instances": [1], - "error_format": "{error.message}", - }, - stdout=stdout, - stderr=stderr, - ) + {"required": true} + ] + } + """, + "an invalid schema": '{"title": 1}', + "invalid json": "{bad_key: val}", + "more invalid json": "{1 []}", + }, + ) + self.addCleanup(delattr, cli, "open") - def test_successful_validation(self): + def run_cli(self, stdin=NativeIO(), exit_code=0, **arguments): stdout, stderr = NativeIO(), NativeIO() - exit_code = cli.run( - { - "validator": fake_validator(), - "schema": {}, - "instances": [1], - "error_format": "{error.message}", - }, + actual_exit_code = cli.run( + arguments, + stdin=stdin, stdout=stdout, stderr=stderr, ) - self.assertFalse(stdout.getvalue()) - self.assertFalse(stderr.getvalue()) - self.assertEqual(exit_code, 0) + self.assertEqual( + actual_exit_code, exit_code, msg=dedent( + """ + Expected an exit code of {} != {}. + + stdout: {} + + stderr: {} + """.format( + exit_code, + actual_exit_code, + stdout.getvalue(), + stderr.getvalue(), + ), + ), + ) + return stdout.getvalue(), stderr.getvalue() + + def test_draft3_schema_draft4_validator(self): + stdout, stderr = self.run_cli( + validator=Draft4Validator, + schema="a schema", + instances=["an invalid instance"], + error_format="{error.message}", + output="plain", + exit_code=1, + ) + self.assertFalse(stdout) + self.assertTrue(stderr) + + def test_successful_validation(self): + stdout, stderr = self.run_cli( + validator=fake_validator(), + schema="a schema", + instances=["a valid instance"], + error_format="{error.message}", + output="plain", + ) + self.assertFalse(stdout) + self.assertFalse(stderr) def test_unsuccessful_validation(self): error = ValidationError("I am an error!", instance=1) - stdout, stderr = NativeIO(), NativeIO() - exit_code = cli.run( - { - "validator": fake_validator([error]), - "schema": {}, - "instances": [1], - "error_format": "{error.instance} - {error.message}", - }, - stdout=stdout, - stderr=stderr, + stdout, stderr = self.run_cli( + validator=fake_validator([error]), + schema="a schema", + instances=["an invalid instance"], + error_format="{error.instance} - {error.message}", + output="plain", + exit_code=1, ) - self.assertFalse(stdout.getvalue()) - self.assertEqual(stderr.getvalue(), "1 - I am an error!") - self.assertEqual(exit_code, 1) + self.assertFalse(stdout) + self.assertEqual(stderr, "1 - I am an error!") def test_unsuccessful_validation_multiple_instances(self): first_errors = [ @@ -127,20 +197,140 @@ def test_unsuccessful_validation_multiple_instances(self): ValidationError("8", instance=1), ] second_errors = [ValidationError("7", instance=2)] - stdout, stderr = NativeIO(), NativeIO() - exit_code = cli.run( - { - "validator": fake_validator(first_errors, second_errors), - "schema": {}, - "instances": [1, 2], - "error_format": "{error.instance} - {error.message}\t", - }, - stdout=stdout, - stderr=stderr, + stdout, stderr = self.run_cli( + validator=fake_validator(first_errors, second_errors), + schema="a schema", + instances=["an invalid instance", "a valid instance"], + error_format="{error.instance} - {error.message}\t", + output="plain", + exit_code=1, ) - self.assertFalse(stdout.getvalue()) - self.assertEqual(stderr.getvalue(), "1 - 9\t1 - 8\t2 - 7\t") - self.assertEqual(exit_code, 1) + self.assertFalse(stdout) + self.assertEqual(stderr, "1 - 9\t1 - 8\t2 - 7\t") + + def test_piping(self): + stdout, stderr = self.run_cli( + validator=fake_validator(), + schema="a schema", + instances=None, + error_format="{error.message}", + output="plain", + stdin=NativeIO("{}"), + ) + self.assertFalse(stdout) + self.assertFalse(stderr) + + def test_schema_parsing_error(self): + stdout, stderr = self.run_cli( + validator=fake_validator(), + schema="invalid json", + instances=["an invalid instance"], + error_format="{error.message}", + output="plain", + exit_code=1, + ) + self.assertFalse(stdout) + self.assertIn("Failed to parse 'invalid json'", stderr) + + def test_instance_parsing_error(self): + stdout, stderr = self.run_cli( + validator=fake_validator(), + schema="a schema", + instances=["invalid json", "more invalid json"], + error_format="{error.message}", + output="plain", + exit_code=1, + ) + self.assertFalse(stdout) + self.assertIn("Failed to parse 'invalid json'", stderr) + self.assertIn("Failed to parse 'more invalid json'", stderr) + + def test_stdin_parsing_error(self): + stdout, stderr = self.run_cli( + validator=fake_validator(), + schema="a schema", + instances=None, + error_format="{error.message}", + output="plain", + stdin=NativeIO("{foo}"), + exit_code=1, + ) + self.assertFalse(stdout) + self.assertIn("Failed to parse ", stderr) + + def test_stdin_pretty_parsing_error(self): + stdout, stderr = self.run_cli( + validator=fake_validator(), + schema="a schema", + instances=None, + output="pretty", + stdin=NativeIO("{foo}"), + exit_code=1, + ) + self.assertFalse(stdout) + self.assertIn("\nTraceback (most recent call last):\n", stderr) + self.assertIn(self.pretty_parsing_error_tag, stderr) + + def test_parsing_error(self): + stdout, stderr = self.run_cli( + validator=fake_validator(), + schema="invalid json", + instances=["an invalid instance"], + error_format="", + output="plain", + exit_code=1, + ) + self.assertFalse(stdout) + self.assertIn("Failed to parse 'invalid json'", stderr) + + def test_pretty_parsing_error(self): + stdout, stderr = self.run_cli( + validator=fake_validator(), + schema="invalid json", + instances=["an invalid instance"], + error_format="", + output="pretty", + exit_code=1, + ) + self.assertFalse(stdout) + self.assertIn("\nTraceback (most recent call last):\n", stderr) + self.assertIn(self.pretty_parsing_error_tag, stderr) + + def test_pretty_successful_validation(self): + stdout, stderr = self.run_cli( + validator=fake_validator(), + schema="a schema", + instances=["a valid instance"], + error_format="", + output="pretty", + ) + self.assertIn(self.pretty_success_tag, stdout) + self.assertFalse(stderr) + + def test_pretty_unsuccessful_validation(self): + error = ValidationError("I am an error!", instance=1) + stdout, stderr = self.run_cli( + validator=fake_validator([error]), + schema="a schema", + instances=["an invalid instance"], + error_format="", + output="pretty", + exit_code=1, + ) + self.assertFalse(stdout) + self.assertIn(self.pretty_validation_error_tag, stderr) + + def test_schema_validation(self): + stdout, stderr = self.run_cli( + validator=LatestValidator, + schema="an invalid schema", + instances=None, + error_format="{error.message}", + output="plain", + exit_code=1, + ) + self.assertFalse(stdout) + self.assertTrue(stderr) def test_license(self): output = subprocess.check_output( @@ -156,20 +346,3 @@ def test_version(self): ) version = version.decode("utf-8").strip() self.assertEqual(version, __version__) - - def test_piping(self): - stdout, stderr, stdin = NativeIO(), NativeIO(), NativeIO("{}") - exit_code = cli.run( - { - "validator": fake_validator(), - "schema": {}, - "instances": [], - "error_format": "{error.message}", - }, - stdout=stdout, - stderr=stderr, - stdin=stdin, - ) - self.assertFalse(stdout.getvalue()) - self.assertFalse(stderr.getvalue()) - self.assertEqual(exit_code, 0)