Skip to content

Commit a3a86ef

Browse files
authored
Merge pull request #620 from sirosen/show-stringified-parse-errors
Refactor error formatting, show parse error cause
2 parents 7eb53ab + 2280a40 commit a3a86ef

File tree

7 files changed

+107
-53
lines changed

7 files changed

+107
-53
lines changed

CHANGELOG.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ CHANGELOG
88
Unreleased
99
----------
1010

11-
- Add official support for Python 3.14
12-
- Add Citation File Format schema and pre-commit hook. Thanks :user:`edgarrmondragon`! (:issue:`502`)
13-
1411
.. vendor-insert-here
1512
1613
- Update vendored schemas: bitbucket-pipelines, buildkite, circle-ci, compose-spec,
1714
dependabot, gitlab-ci, meltano, mergify, renovate, snapcraft (2025-11-11)
15+
- Add official support for Python 3.14
16+
- Add Citation File Format schema and pre-commit hook. Thanks :user:`edgarrmondragon`! (:issue:`502`)
17+
- Improved default text output when parsing errors are encountered. (:issue:`581`)
1818

1919
0.34.1
2020
------

src/check_jsonschema/checker.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import jsonschema
88
import referencing.exceptions
99

10-
from . import utils
10+
from . import format_errors
1111
from .formats import FormatOptions
1212
from .instance_loader import InstanceLoader
1313
from .parsers import ParseError
@@ -31,7 +31,7 @@ def __init__(
3131
*,
3232
format_opts: FormatOptions,
3333
regex_impl: RegexImplementation,
34-
traceback_mode: str = "short",
34+
traceback_mode: t.Literal["minimal", "short", "full"] = "short",
3535
fill_defaults: bool = False,
3636
) -> None:
3737
self._schema_loader = schema_loader
@@ -46,7 +46,7 @@ def __init__(
4646
def _fail(self, msg: str, err: Exception | None = None) -> t.NoReturn:
4747
click.echo(msg, err=True)
4848
if err is not None:
49-
utils.print_error(err, mode=self._traceback_mode)
49+
format_errors.print_error(err, mode=self._traceback_mode)
5050
raise _Exit(1)
5151

5252
def get_validator(

src/check_jsonschema/cli/parse_result.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def __init__(self) -> None:
4141
self.regex_variant: RegexVariantName = RegexVariantName.default
4242
# error and output controls
4343
self.verbosity: int = 1
44-
self.traceback_mode: str = "short"
44+
self.traceback_mode: t.Literal["short", "full"] = "short"
4545
self.output_format: str = "text"
4646

4747
def set_regex_variant(
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from __future__ import annotations
2+
3+
import linecache
4+
import textwrap
5+
import traceback
6+
import typing as t
7+
8+
import click
9+
10+
11+
def format_error_message(err: BaseException) -> str:
12+
return f"{type(err).__name__}: {err}"
13+
14+
15+
def format_minimal_error(err: BaseException, *, indent: int = 0) -> str:
16+
lines = [textwrap.indent(str(err), indent * " ")]
17+
if err.__cause__ is not None:
18+
lines.append(
19+
textwrap.indent(format_error_message(err.__cause__), (indent + 2) * " ")
20+
)
21+
22+
return "\n".join(lines)
23+
24+
25+
def format_shortened_error(err: BaseException, *, indent: int = 0) -> str:
26+
lines = []
27+
lines.append(textwrap.indent(format_error_message(err), indent * " "))
28+
if err.__traceback__ is not None:
29+
lineno = err.__traceback__.tb_lineno
30+
tb_frame = err.__traceback__.tb_frame
31+
filename = tb_frame.f_code.co_filename
32+
line = linecache.getline(filename, lineno)
33+
lines.append((indent + 2) * " " + f'in "{filename}", line {lineno}')
34+
lines.append((indent + 2) * " " + ">>> " + line.strip())
35+
return "\n".join(lines)
36+
37+
38+
def format_shortened_trace(caught_err: BaseException) -> str:
39+
err_stack: list[BaseException] = [caught_err]
40+
while err_stack[-1].__context__ is not None:
41+
err_stack.append(err_stack[-1].__context__) # type: ignore[arg-type]
42+
43+
parts = [format_shortened_error(caught_err)]
44+
indent = 0
45+
for err in err_stack[1:]:
46+
indent += 2
47+
parts.append("\n" + indent * " " + "caused by\n")
48+
parts.append(format_shortened_error(err, indent=indent))
49+
return "\n".join(parts)
50+
51+
52+
def format_error(
53+
err: Exception, mode: t.Literal["minimal", "short", "full"] = "short"
54+
) -> str:
55+
if mode == "minimal":
56+
return format_minimal_error(err)
57+
elif mode == "short":
58+
return format_shortened_trace(err)
59+
else:
60+
return "".join(traceback.format_exception(type(err), err, err.__traceback__))
61+
62+
63+
def print_error(
64+
err: Exception, mode: t.Literal["minimal", "short", "full"] = "short"
65+
) -> None:
66+
click.echo(format_error(err, mode=mode), err=True)

src/check_jsonschema/reporter.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313
import click
1414
import jsonschema
1515

16+
from . import format_errors
1617
from .parsers import ParseError
1718
from .result import CheckResult
18-
from .utils import format_error, iter_validation_error
19+
from .utils import iter_validation_error
1920

2021

2122
class Reporter(abc.ABC):
@@ -111,11 +112,12 @@ def _show_validation_error(
111112

112113
def _show_parse_error(self, filename: str, err: ParseError) -> None:
113114
if self.verbosity < 2:
114-
self._echo(click.style(str(err), fg="yellow"), indent=2)
115+
mode: t.Literal["minimal", "short", "full"] = "minimal"
115116
elif self.verbosity < 3:
116-
self._echo(textwrap.indent(format_error(err, mode="short"), " "))
117+
mode = "short"
117118
else:
118-
self._echo(textwrap.indent(format_error(err, mode="full"), " "))
119+
mode = "full"
120+
self._echo(textwrap.indent(format_errors.format_error(err, mode=mode), " "))
119121

120122
def report_errors(self, result: CheckResult) -> None:
121123
if self.verbosity < 1:

src/check_jsonschema/utils.py

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
11
from __future__ import annotations
22

3-
import linecache
43
import os
54
import pathlib
65
import re
7-
import textwrap
8-
import traceback
96
import typing as t
107
import urllib.parse
118
import urllib.request
129

13-
import click
1410
import jsonschema
1511

1612
WINDOWS = os.name == "nt"
@@ -95,44 +91,6 @@ def filename2path(filename: str) -> pathlib.Path:
9591
return p.resolve()
9692

9793

98-
def format_shortened_error(err: Exception, *, indent: int = 0) -> str:
99-
lines = []
100-
lines.append(textwrap.indent(f"{type(err).__name__}: {err}", indent * " "))
101-
if err.__traceback__ is not None:
102-
lineno = err.__traceback__.tb_lineno
103-
tb_frame = err.__traceback__.tb_frame
104-
filename = tb_frame.f_code.co_filename
105-
line = linecache.getline(filename, lineno)
106-
lines.append((indent + 2) * " " + f'in "{filename}", line {lineno}')
107-
lines.append((indent + 2) * " " + ">>> " + line.strip())
108-
return "\n".join(lines)
109-
110-
111-
def format_shortened_trace(caught_err: Exception) -> str:
112-
err_stack: list[Exception] = [caught_err]
113-
while err_stack[-1].__context__ is not None:
114-
err_stack.append(err_stack[-1].__context__) # type: ignore[arg-type]
115-
116-
parts = [format_shortened_error(caught_err)]
117-
indent = 0
118-
for err in err_stack[1:]:
119-
indent += 2
120-
parts.append("\n" + indent * " " + "caused by\n")
121-
parts.append(format_shortened_error(err, indent=indent))
122-
return "\n".join(parts)
123-
124-
125-
def format_error(err: Exception, mode: str = "short") -> str:
126-
if mode == "short":
127-
return format_shortened_trace(err)
128-
else:
129-
return "".join(traceback.format_exception(type(err), err, err.__traceback__))
130-
131-
132-
def print_error(err: Exception, mode: str = "short") -> None:
133-
click.echo(format_error(err, mode=mode), err=True)
134-
135-
13694
def iter_validation_error(
13795
err: jsonschema.ValidationError,
13896
) -> t.Iterator[jsonschema.ValidationError]:

tests/unit/test_reporters.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pytest
55
from jsonschema import Draft7Validator
66

7+
from check_jsonschema.parsers import ParseError
78
from check_jsonschema.reporter import JsonReporter, TextReporter
89
from check_jsonschema.result import CheckResult
910

@@ -240,3 +241,30 @@ def test_json_format_validation_error_nested(capsys, pretty_json, verbosity):
240241
assert "{'baz': 'buzz'} is not of type 'string'" in [
241242
item["message"] for item in bar_errors
242243
]
244+
245+
246+
def test_text_print_parse_error_with_cause(capsys):
247+
cause = json.JSONDecodeError("a bad thing happened", "{,}", 1)
248+
error = ParseError("whoopsie during parsing")
249+
error.__cause__ = cause
250+
251+
result = CheckResult()
252+
result.record_parse_error("foo.json", error)
253+
254+
text_reporter = TextReporter(verbosity=1)
255+
text_reporter.report_result(result)
256+
captured = capsys.readouterr()
257+
258+
# nothing to stderr
259+
assert captured.err == ""
260+
# stdout contains a nicely formatted error
261+
assert (
262+
textwrap.dedent(
263+
"""\
264+
Several files failed to parse.
265+
whoopsie during parsing
266+
JSONDecodeError: a bad thing happened: line 1 column 2 (char 1)
267+
"""
268+
)
269+
in captured.out
270+
)

0 commit comments

Comments
 (0)