Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@

<!-- Changes to Black's terminal output and error messages -->

- Report parser failures using editor-friendly `path:line:column` locations (#5237)

### _Blackd_

<!-- Changes to blackd -->
Expand Down
4 changes: 2 additions & 2 deletions src/black/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -901,7 +901,7 @@ def reformat_code(
except Exception as exc:
if report.verbose:
traceback.print_exc()
report.failed(path, str(exc))
report.failed(path, exc)


# diff-shades depends on being to monkeypatch this function to operate. I know it's
Expand Down Expand Up @@ -965,7 +965,7 @@ def reformat_one(
except Exception as exc:
if report.verbose:
traceback.print_exc()
report.failed(src, str(exc))
report.failed(src, exc)


def format_file_in_place(
Expand Down
2 changes: 1 addition & 1 deletion src/black/concurrency.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ async def schedule_formatting(
elif exc := task.exception():
if report.verbose:
traceback.print_exception(type(exc), exc, exc.__traceback__)
report.failed(src, str(exc))
report.failed(src, exc)
else:
changed = Changed.YES if task.result() else Changed.NO
# If the file was written back or was successfully checked as
Expand Down
44 changes: 35 additions & 9 deletions src/black/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,28 @@
class InvalidInput(ValueError):
"""Raised when input source code fails all parse attempts."""

lineno: int | None = None
column: int | None = None
context: str | None = None
details: str | None = None

@classmethod
def from_syntax_error(
cls,
message: str,
lineno: int,
column: int,
context: str,
details: str,
) -> "InvalidInput":
"""Create an error with source details for user-facing reports."""
error = cls(message)
error.lineno = lineno
error.column = column
error.context = context
error.details = details
return error


def get_grammars(target_versions: set[TargetVersion]) -> list[Grammar]:
if not target_versions:
Expand Down Expand Up @@ -80,14 +102,15 @@ def lib2to3_parse(
faulty_line = lines[lineno - 1]
except IndexError:
faulty_line = "<line number missing in source>"
error_msg = (
f"Cannot parse{tv_str}: {lineno}:{column}\n"
f" {faulty_line}\n"
f" {' ' * (column - 1)}^\n"
f"ParseError: {pe.msg}"
context = f"Cannot parse{tv_str}"
details = (
f" {faulty_line}\n {' ' * (column - 1)}^\nParseError: {pe.msg}"
)
error_msg = f"{context}: {lineno}:{column}\n{details}"

errors[grammar.version] = InvalidInput(error_msg)
errors[grammar.version] = InvalidInput.from_syntax_error(
error_msg, lineno, column, context, details
)

except TokenError as te:
lineno, column = te.args[1]
Expand All @@ -96,13 +119,16 @@ def lib2to3_parse(
faulty_line = lines[lineno - 1]
except IndexError:
faulty_line = "<line number missing in source>"
error_msg = (
f"Cannot parse{tv_str}: {lineno}:{column}\n"
context = f"Cannot parse{tv_str}"
details = (
f" {faulty_line}\n"
f" {' ' * (column - 1)}^\n"
f"TokenError: {te.args[0]}"
)
errors[grammar.version] = InvalidInput(error_msg)
error_msg = f"{context}: {lineno}:{column}\n{details}"
errors[grammar.version] = InvalidInput.from_syntax_error(
error_msg, lineno, column, context, details
)

else:
# Choose the latest version when raising the actual parsing error.
Expand Down
15 changes: 13 additions & 2 deletions src/black/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from pathlib import Path

from black.output import err, out, style_output
from black.parsing import InvalidInput


class Changed(Enum):
Expand Down Expand Up @@ -47,9 +48,19 @@ def done(self, src: Path, changed: Changed) -> None:
out(msg, bold=False)
self.same_count += 1

def failed(self, src: Path, message: str) -> None:
def failed(self, src: Path, message: str | BaseException) -> None:
"""Increment the counter for failed reformatting. Write out a message."""
err(f"error: cannot format {src}: {message}")
if (
isinstance(message, InvalidInput)
and message.lineno is not None
and message.column is not None
and message.context
):
context = message.context[0].lower() + message.context[1:]
details = f"\n{message.details}" if message.details else ""
err(f"error: {context}: {src}:{message.lineno}:{message.column}{details}")
else:
err(f"error: cannot format {src}: {message}")
self.failure_count += 1

def path_ignored(self, path: Path, message: str) -> None:
Expand Down
45 changes: 44 additions & 1 deletion tests/test_black.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import logging
import multiprocessing
import os
import pickle
import re
import sys
import textwrap
Expand Down Expand Up @@ -133,6 +134,40 @@ def invokeBlack(
assert result.exit_code == exit_code, msg


def test_invalid_input_error_includes_path_location(tmp_path: Path) -> None:
source = tmp_path / "invalid.py"
source.write_text("return if you can\n", encoding="utf8")

result = BlackRunner().invoke(
black.main, ["--config", str(THIS_DIR / "empty.toml"), str(source)]
)

assert result.exit_code == 123
assert (
f"error: cannot parse: {source}:1:7\n"
" return if you can\n"
" ^\n"
"ParseError: bad input\n"
in result.stderr
)

second_source = tmp_path / "also_invalid.py"
second_source.write_text("print(\n", encoding="utf8")
result = BlackRunner().invoke(
black.main,
[
"--config",
str(THIS_DIR / "empty.toml"),
str(source),
str(second_source),
],
)

assert result.exit_code == 123
assert f"error: cannot parse: {source}:1:7\n" in result.stderr
assert f"error: cannot parse: {second_source}:1:6\n" in result.stderr


class BlackTestCase(BlackBaseTestCase):
invokeBlack = staticmethod(invokeBlack)

Expand Down Expand Up @@ -813,8 +848,16 @@ def test_report_respects_no_color(self) -> None:
self.assertEqual(output, unstyle(output))

def test_lib2to3_parse(self) -> None:
with self.assertRaises(black.InvalidInput):
with self.assertRaises(black.InvalidInput) as exc_info:
black.lib2to3_parse("invalid syntax")
error = exc_info.exception
restored_error = pickle.loads(pickle.dumps(error))
self.assertEqual((error.lineno, error.column), (1, 8))
self.assertEqual(str(restored_error), str(error))
self.assertEqual(
(error.lineno, error.column),
(restored_error.lineno, restored_error.column),
)

straddling = "x + y"
black.lib2to3_parse(straddling)
Expand Down
Loading