diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9ad9b7768..fc6ad4ef0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,13 +8,13 @@ CHANGELOG Unreleased ---------- -- Add official support for Python 3.14 -- Add Citation File Format schema and pre-commit hook. Thanks :user:`edgarrmondragon`! (:issue:`502`) - .. vendor-insert-here - Update vendored schemas: bitbucket-pipelines, buildkite, circle-ci, compose-spec, dependabot, gitlab-ci, meltano, mergify, renovate, snapcraft (2025-11-11) +- Add official support for Python 3.14 +- Add Citation File Format schema and pre-commit hook. Thanks :user:`edgarrmondragon`! (:issue:`502`) +- Improved default text output when parsing errors are encountered. (:issue:`581`) 0.34.1 ------ diff --git a/src/check_jsonschema/checker.py b/src/check_jsonschema/checker.py index c6cd852eb..4ddadd9bb 100644 --- a/src/check_jsonschema/checker.py +++ b/src/check_jsonschema/checker.py @@ -7,7 +7,7 @@ import jsonschema import referencing.exceptions -from . import utils +from . import format_errors from .formats import FormatOptions from .instance_loader import InstanceLoader from .parsers import ParseError @@ -31,7 +31,7 @@ def __init__( *, format_opts: FormatOptions, regex_impl: RegexImplementation, - traceback_mode: str = "short", + traceback_mode: t.Literal["minimal", "short", "full"] = "short", fill_defaults: bool = False, ) -> None: self._schema_loader = schema_loader @@ -46,7 +46,7 @@ def __init__( def _fail(self, msg: str, err: Exception | None = None) -> t.NoReturn: click.echo(msg, err=True) if err is not None: - utils.print_error(err, mode=self._traceback_mode) + format_errors.print_error(err, mode=self._traceback_mode) raise _Exit(1) def get_validator( diff --git a/src/check_jsonschema/cli/parse_result.py b/src/check_jsonschema/cli/parse_result.py index fd925118c..dd03a3768 100644 --- a/src/check_jsonschema/cli/parse_result.py +++ b/src/check_jsonschema/cli/parse_result.py @@ -41,7 +41,7 @@ def __init__(self) -> None: self.regex_variant: RegexVariantName = RegexVariantName.default # error and output controls self.verbosity: int = 1 - self.traceback_mode: str = "short" + self.traceback_mode: t.Literal["short", "full"] = "short" self.output_format: str = "text" def set_regex_variant( diff --git a/src/check_jsonschema/format_errors.py b/src/check_jsonschema/format_errors.py new file mode 100644 index 000000000..1fbf34d55 --- /dev/null +++ b/src/check_jsonschema/format_errors.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import linecache +import textwrap +import traceback +import typing as t + +import click + + +def format_error_message(err: BaseException) -> str: + return f"{type(err).__name__}: {err}" + + +def format_minimal_error(err: BaseException, *, indent: int = 0) -> str: + lines = [textwrap.indent(str(err), indent * " ")] + if err.__cause__ is not None: + lines.append( + textwrap.indent(format_error_message(err.__cause__), (indent + 2) * " ") + ) + + return "\n".join(lines) + + +def format_shortened_error(err: BaseException, *, indent: int = 0) -> str: + lines = [] + lines.append(textwrap.indent(format_error_message(err), indent * " ")) + if err.__traceback__ is not None: + lineno = err.__traceback__.tb_lineno + tb_frame = err.__traceback__.tb_frame + filename = tb_frame.f_code.co_filename + line = linecache.getline(filename, lineno) + lines.append((indent + 2) * " " + f'in "{filename}", line {lineno}') + lines.append((indent + 2) * " " + ">>> " + line.strip()) + return "\n".join(lines) + + +def format_shortened_trace(caught_err: BaseException) -> str: + err_stack: list[BaseException] = [caught_err] + while err_stack[-1].__context__ is not None: + err_stack.append(err_stack[-1].__context__) # type: ignore[arg-type] + + parts = [format_shortened_error(caught_err)] + indent = 0 + for err in err_stack[1:]: + indent += 2 + parts.append("\n" + indent * " " + "caused by\n") + parts.append(format_shortened_error(err, indent=indent)) + return "\n".join(parts) + + +def format_error( + err: Exception, mode: t.Literal["minimal", "short", "full"] = "short" +) -> str: + if mode == "minimal": + return format_minimal_error(err) + elif mode == "short": + return format_shortened_trace(err) + else: + return "".join(traceback.format_exception(type(err), err, err.__traceback__)) + + +def print_error( + err: Exception, mode: t.Literal["minimal", "short", "full"] = "short" +) -> None: + click.echo(format_error(err, mode=mode), err=True) diff --git a/src/check_jsonschema/reporter.py b/src/check_jsonschema/reporter.py index 48663002c..5e11aefcc 100644 --- a/src/check_jsonschema/reporter.py +++ b/src/check_jsonschema/reporter.py @@ -13,9 +13,10 @@ import click import jsonschema +from . import format_errors from .parsers import ParseError from .result import CheckResult -from .utils import format_error, iter_validation_error +from .utils import iter_validation_error class Reporter(abc.ABC): @@ -111,11 +112,12 @@ def _show_validation_error( def _show_parse_error(self, filename: str, err: ParseError) -> None: if self.verbosity < 2: - self._echo(click.style(str(err), fg="yellow"), indent=2) + mode: t.Literal["minimal", "short", "full"] = "minimal" elif self.verbosity < 3: - self._echo(textwrap.indent(format_error(err, mode="short"), " ")) + mode = "short" else: - self._echo(textwrap.indent(format_error(err, mode="full"), " ")) + mode = "full" + self._echo(textwrap.indent(format_errors.format_error(err, mode=mode), " ")) def report_errors(self, result: CheckResult) -> None: if self.verbosity < 1: diff --git a/src/check_jsonschema/utils.py b/src/check_jsonschema/utils.py index e6e36f282..56c2e0721 100644 --- a/src/check_jsonschema/utils.py +++ b/src/check_jsonschema/utils.py @@ -1,16 +1,12 @@ from __future__ import annotations -import linecache import os import pathlib import re -import textwrap -import traceback import typing as t import urllib.parse import urllib.request -import click import jsonschema WINDOWS = os.name == "nt" @@ -95,44 +91,6 @@ def filename2path(filename: str) -> pathlib.Path: return p.resolve() -def format_shortened_error(err: Exception, *, indent: int = 0) -> str: - lines = [] - lines.append(textwrap.indent(f"{type(err).__name__}: {err}", indent * " ")) - if err.__traceback__ is not None: - lineno = err.__traceback__.tb_lineno - tb_frame = err.__traceback__.tb_frame - filename = tb_frame.f_code.co_filename - line = linecache.getline(filename, lineno) - lines.append((indent + 2) * " " + f'in "{filename}", line {lineno}') - lines.append((indent + 2) * " " + ">>> " + line.strip()) - return "\n".join(lines) - - -def format_shortened_trace(caught_err: Exception) -> str: - err_stack: list[Exception] = [caught_err] - while err_stack[-1].__context__ is not None: - err_stack.append(err_stack[-1].__context__) # type: ignore[arg-type] - - parts = [format_shortened_error(caught_err)] - indent = 0 - for err in err_stack[1:]: - indent += 2 - parts.append("\n" + indent * " " + "caused by\n") - parts.append(format_shortened_error(err, indent=indent)) - return "\n".join(parts) - - -def format_error(err: Exception, mode: str = "short") -> str: - if mode == "short": - return format_shortened_trace(err) - else: - return "".join(traceback.format_exception(type(err), err, err.__traceback__)) - - -def print_error(err: Exception, mode: str = "short") -> None: - click.echo(format_error(err, mode=mode), err=True) - - def iter_validation_error( err: jsonschema.ValidationError, ) -> t.Iterator[jsonschema.ValidationError]: diff --git a/tests/unit/test_reporters.py b/tests/unit/test_reporters.py index b02fed7a6..d1bdf486e 100644 --- a/tests/unit/test_reporters.py +++ b/tests/unit/test_reporters.py @@ -4,6 +4,7 @@ import pytest from jsonschema import Draft7Validator +from check_jsonschema.parsers import ParseError from check_jsonschema.reporter import JsonReporter, TextReporter from check_jsonschema.result import CheckResult @@ -240,3 +241,30 @@ def test_json_format_validation_error_nested(capsys, pretty_json, verbosity): assert "{'baz': 'buzz'} is not of type 'string'" in [ item["message"] for item in bar_errors ] + + +def test_text_print_parse_error_with_cause(capsys): + cause = json.JSONDecodeError("a bad thing happened", "{,}", 1) + error = ParseError("whoopsie during parsing") + error.__cause__ = cause + + result = CheckResult() + result.record_parse_error("foo.json", error) + + text_reporter = TextReporter(verbosity=1) + text_reporter.report_result(result) + captured = capsys.readouterr() + + # nothing to stderr + assert captured.err == "" + # stdout contains a nicely formatted error + assert ( + textwrap.dedent( + """\ + Several files failed to parse. + whoopsie during parsing + JSONDecodeError: a bad thing happened: line 1 column 2 (char 1) + """ + ) + in captured.out + )