Skip to content

Commit 962d7b4

Browse files
authored
Print and show error end locations (#13148)
Using the flag --show-error-end the errors will be output like file:line:column:end_line:end_column, for example: ``` x: int = "no way" main:1:10:1:17: error: Incompatible types in assignment (expression has type "str", variable has type "int") ``` This will be helpful for various tools to highlight error location. Also when run with --pretty mypy itself will highlight the full span of error context (except if it spans multiple lines, like Python 3.11 itself does).
1 parent 53465bd commit 962d7b4

File tree

11 files changed

+156
-32
lines changed

11 files changed

+156
-32
lines changed

docs/source/command_line.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,14 @@ in error messages.
705705

706706
main.py:12:9: error: Unsupported operand types for / ("int" and "str")
707707

708+
.. option:: --show-error-end
709+
710+
This flag will make mypy show not just that start position where
711+
an error was detected, but also the end position of the relevant expression.
712+
This way various tools can easily highlight the whole error span. The format is
713+
``file:line:column:end_line:end_column``. This option implies
714+
``--show-column-numbers``.
715+
708716
.. option:: --show-error-codes
709717

710718
This flag will add an error code ``[<code>]`` to error messages. The error

mypy/build.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ def _build(sources: List[BuildSource],
197197
options.show_column_numbers,
198198
options.show_error_codes,
199199
options.pretty,
200+
options.show_error_end,
200201
lambda path: read_py_file(path, cached_read, options.python_version),
201202
options.show_absolute_path,
202203
options.enabled_error_codes,

mypy/errors.py

Lines changed: 60 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ class ErrorInfo:
5050
# The column number related to this error with file.
5151
column = 0 # -1 if unknown
5252

53+
# The end line number related to this error within file.
54+
end_line = 0 # -1 if unknown
55+
56+
# The end column number related to this error with file.
57+
end_column = 0 # -1 if unknown
58+
5359
# Either 'error' or 'note'
5460
severity = ''
5561

@@ -87,6 +93,8 @@ def __init__(self,
8793
function_or_member: Optional[str],
8894
line: int,
8995
column: int,
96+
end_line: int,
97+
end_column: int,
9098
severity: str,
9199
message: str,
92100
code: Optional[ErrorCode],
@@ -102,6 +110,8 @@ def __init__(self,
102110
self.function_or_member = function_or_member
103111
self.line = line
104112
self.column = column
113+
self.end_line = end_line
114+
self.end_column = end_column
105115
self.severity = severity
106116
self.message = message
107117
self.code = code
@@ -113,8 +123,10 @@ def __init__(self,
113123

114124

115125
# Type used internally to represent errors:
116-
# (path, line, column, severity, message, allow_dups, code)
126+
# (path, line, column, end_line, end_column, severity, message, allow_dups, code)
117127
ErrorTuple = Tuple[Optional[str],
128+
int,
129+
int,
118130
int,
119131
int,
120132
str,
@@ -221,6 +233,10 @@ class Errors:
221233
# Set to True to show column numbers in error messages.
222234
show_column_numbers: bool = False
223235

236+
# Set to True to show end line and end column in error messages.
237+
# Ths implies `show_column_numbers`.
238+
show_error_end: bool = False
239+
224240
# Set to True to show absolute file paths in error messages.
225241
show_absolute_path: bool = False
226242

@@ -241,6 +257,7 @@ def __init__(self,
241257
show_column_numbers: bool = False,
242258
show_error_codes: bool = False,
243259
pretty: bool = False,
260+
show_error_end: bool = False,
244261
read_source: Optional[Callable[[str], Optional[List[str]]]] = None,
245262
show_absolute_path: bool = False,
246263
enabled_error_codes: Optional[Set[ErrorCode]] = None,
@@ -251,6 +268,9 @@ def __init__(self,
251268
self.show_error_codes = show_error_codes
252269
self.show_absolute_path = show_absolute_path
253270
self.pretty = pretty
271+
self.show_error_end = show_error_end
272+
if show_error_end:
273+
assert show_column_numbers, "Inconsistent formatting, must be prevented by argparse"
254274
# We use fscache to read source code when showing snippets.
255275
self.read_source = read_source
256276
self.enabled_error_codes = enabled_error_codes or set()
@@ -343,7 +363,8 @@ def report(self,
343363
allow_dups: bool = False,
344364
origin_line: Optional[int] = None,
345365
offset: int = 0,
346-
end_line: Optional[int] = None) -> None:
366+
end_line: Optional[int] = None,
367+
end_column: Optional[int] = None) -> None:
347368
"""Report message at the given line using the current error context.
348369
349370
Args:
@@ -370,6 +391,12 @@ def report(self,
370391

371392
if column is None:
372393
column = -1
394+
if end_column is None:
395+
if column == -1:
396+
end_column = -1
397+
else:
398+
end_column = column + 1
399+
373400
if file is None:
374401
file = self.file
375402
if offset:
@@ -384,7 +411,7 @@ def report(self,
384411
code = code or (codes.MISC if not blocker else None)
385412

386413
info = ErrorInfo(self.import_context(), file, self.current_module(), type,
387-
function, line, column, severity, message, code,
414+
function, line, column, end_line, end_column, severity, message, code,
388415
blocker, only_once, allow_dups,
389416
origin=(self.file, origin_line, end_line),
390417
target=self.current_target())
@@ -470,7 +497,7 @@ def add_error_info(self, info: ErrorInfo) -> None:
470497
'may be out of date')
471498
note = ErrorInfo(
472499
info.import_ctx, info.file, info.module, info.type, info.function_or_member,
473-
info.line, info.column, 'note', msg,
500+
info.line, info.column, info.end_line, info.end_column, 'note', msg,
474501
code=None, blocker=False, only_once=False, allow_dups=False
475502
)
476503
self._add_error_info(file, note)
@@ -500,7 +527,9 @@ def report_hidden_errors(self, info: ErrorInfo) -> None:
500527
typ=None,
501528
function_or_member=None,
502529
line=info.line,
503-
column=info.line,
530+
column=info.column,
531+
end_line=info.end_line,
532+
end_column=info.end_column,
504533
severity='note',
505534
message=message,
506535
code=None,
@@ -571,7 +600,7 @@ def generate_unused_ignore_errors(self, file: str) -> None:
571600
message = f'Unused "type: ignore{unused_codes_message}" comment'
572601
# Don't use report since add_error_info will ignore the error!
573602
info = ErrorInfo(self.import_context(), file, self.current_module(), None,
574-
None, line, -1, 'error', message,
603+
None, line, -1, line, -1, 'error', message,
575604
None, False, False, False)
576605
self._add_error_info(file, info)
577606

@@ -606,7 +635,7 @@ def generate_ignore_without_code_errors(self,
606635
message = f'"type: ignore" comment without error code{codes_hint}'
607636
# Don't use report since add_error_info will ignore the error!
608637
info = ErrorInfo(self.import_context(), file, self.current_module(), None,
609-
None, line, -1, 'error', message, codes.IGNORE_WITHOUT_CODE,
638+
None, line, -1, line, -1, 'error', message, codes.IGNORE_WITHOUT_CODE,
610639
False, False, False)
611640
self._add_error_info(file, info)
612641

@@ -657,11 +686,13 @@ def format_messages(self, error_info: List[ErrorInfo],
657686
error_info = [info for info in error_info if not info.hidden]
658687
errors = self.render_messages(self.sort_messages(error_info))
659688
errors = self.remove_duplicates(errors)
660-
for file, line, column, severity, message, allow_dups, code in errors:
689+
for file, line, column, end_line, end_column, severity, message, allow_dups, code in errors:
661690
s = ''
662691
if file is not None:
663692
if self.show_column_numbers and line >= 0 and column >= 0:
664693
srcloc = f'{file}:{line}:{1 + column}'
694+
if self.show_error_end and end_line >=0 and end_column >= 0:
695+
srcloc += f':{end_line}:{end_column}'
665696
elif line >= 0:
666697
srcloc = f'{file}:{line}'
667698
else:
@@ -685,11 +716,15 @@ def format_messages(self, error_info: List[ErrorInfo],
685716

686717
# Shifts column after tab expansion
687718
column = len(source_line[:column].expandtabs())
719+
end_column = len(source_line[:end_column].expandtabs())
688720

689721
# Note, currently coloring uses the offset to detect source snippets,
690722
# so these offsets should not be arbitrary.
691723
a.append(' ' * DEFAULT_SOURCE_OFFSET + source_line_expanded)
692-
a.append(' ' * (DEFAULT_SOURCE_OFFSET + column) + '^')
724+
marker = '^'
725+
if end_line == line and end_column > column:
726+
marker = f'^{"~" * (end_column - column - 1)}'
727+
a.append(' ' * (DEFAULT_SOURCE_OFFSET + column) + marker)
693728
return a
694729

695730
def file_messages(self, path: str) -> List[str]:
@@ -763,7 +798,7 @@ def render_messages(self,
763798
# Remove prefix to ignore from path (if present) to
764799
# simplify path.
765800
path = remove_path_prefix(path, self.ignore_prefix)
766-
result.append((None, -1, -1, 'note',
801+
result.append((None, -1, -1, -1, -1, 'note',
767802
fmt.format(path, line), e.allow_dups, None))
768803
i -= 1
769804

@@ -776,32 +811,32 @@ def render_messages(self,
776811
e.type != prev_type):
777812
if e.function_or_member is None:
778813
if e.type is None:
779-
result.append((file, -1, -1, 'note', 'At top level:', e.allow_dups, None))
814+
result.append((file, -1, -1, -1, -1, 'note', 'At top level:', e.allow_dups, None))
780815
else:
781-
result.append((file, -1, -1, 'note', 'In class "{}":'.format(
816+
result.append((file, -1, -1, -1, -1, 'note', 'In class "{}":'.format(
782817
e.type), e.allow_dups, None))
783818
else:
784819
if e.type is None:
785-
result.append((file, -1, -1, 'note',
820+
result.append((file, -1, -1, -1, -1, 'note',
786821
'In function "{}":'.format(
787822
e.function_or_member), e.allow_dups, None))
788823
else:
789-
result.append((file, -1, -1, 'note',
824+
result.append((file, -1, -1, -1, -1, 'note',
790825
'In member "{}" of class "{}":'.format(
791826
e.function_or_member, e.type), e.allow_dups, None))
792827
elif e.type != prev_type:
793828
if e.type is None:
794-
result.append((file, -1, -1, 'note', 'At top level:', e.allow_dups, None))
829+
result.append((file, -1, -1, -1, -1, 'note', 'At top level:', e.allow_dups, None))
795830
else:
796-
result.append((file, -1, -1, 'note',
831+
result.append((file, -1, -1, -1, -1, 'note',
797832
f'In class "{e.type}":', e.allow_dups, None))
798833

799834
if isinstance(e.message, ErrorMessage):
800835
result.append(
801-
(file, e.line, e.column, e.severity, e.message.value, e.allow_dups, e.code))
836+
(file, e.line, e.column, e.end_line, e.end_column, e.severity, e.message.value, e.allow_dups, e.code))
802837
else:
803838
result.append(
804-
(file, e.line, e.column, e.severity, e.message, e.allow_dups, e.code))
839+
(file, e.line, e.column, e.end_line, e.end_column, e.severity, e.message, e.allow_dups, e.code))
805840

806841
prev_import_context = e.import_ctx
807842
prev_function_or_member = e.function_or_member
@@ -842,21 +877,21 @@ def remove_duplicates(self, errors: List[ErrorTuple]) -> List[ErrorTuple]:
842877
conflicts_notes = False
843878
j = i - 1
844879
# Find duplicates, unless duplicates are allowed.
845-
if not errors[i][5]:
880+
if not errors[i][7]:
846881
while j >= 0 and errors[j][0] == errors[i][0]:
847-
if errors[j][4].strip() == 'Got:':
882+
if errors[j][6].strip() == 'Got:':
848883
conflicts_notes = True
849884
j -= 1
850885
j = i - 1
851886
while (j >= 0 and errors[j][0] == errors[i][0] and
852887
errors[j][1] == errors[i][1]):
853-
if (errors[j][3] == errors[i][3] and
888+
if (errors[j][5] == errors[i][5] and
854889
# Allow duplicate notes in overload conflicts reporting.
855-
not ((errors[i][3] == 'note' and
856-
errors[i][4].strip() in allowed_duplicates)
857-
or (errors[i][4].strip().startswith('def ') and
890+
not ((errors[i][5] == 'note' and
891+
errors[i][6].strip() in allowed_duplicates)
892+
or (errors[i][6].strip().startswith('def ') and
858893
conflicts_notes)) and
859-
errors[j][4] == errors[i][4]): # ignore column
894+
errors[j][6] == errors[i][6]): # ignore column
860895
dup = True
861896
break
862897
j -= 1

mypy/main.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,10 @@ def add_invertible_flag(flag: str,
707707
add_invertible_flag('--show-column-numbers', default=False,
708708
help="Show column numbers in error messages",
709709
group=error_group)
710+
add_invertible_flag('--show-error-end', default=False,
711+
help="Show end line/end column numbers in error messages."
712+
" This implies --show-column-numbers",
713+
group=error_group)
710714
add_invertible_flag('--show-error-codes', default=False,
711715
help="Show error codes in error messages",
712716
group=error_group)
@@ -1036,6 +1040,10 @@ def set_strict_flags() -> None:
10361040
if options.cache_fine_grained:
10371041
options.local_partial_types = True
10381042

1043+
# Implicitly show column numbers if error location end is shown
1044+
if options.show_error_end:
1045+
options.show_column_numbers = True
1046+
10391047
# Let logical_deps imply cache_fine_grained (otherwise the former is useless).
10401048
if options.logical_deps:
10411049
options.cache_fine_grained = True

mypy/messages.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,9 @@ def report(self,
160160
context.get_column() if context else -1,
161161
msg, severity=severity, file=file, offset=offset,
162162
origin_line=origin.get_line() if origin else None,
163-
end_line=end_line, code=code, allow_dups=allow_dups)
163+
end_line=end_line,
164+
end_column=context.end_column if context else -1,
165+
code=code, allow_dups=allow_dups)
164166

165167
def fail(self,
166168
msg: str,

mypy/options.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ def __init__(self) -> None:
275275
# -- experimental options --
276276
self.shadow_file: Optional[List[List[str]]] = None
277277
self.show_column_numbers: bool = False
278+
self.show_error_end: bool = False
278279
self.show_error_codes = False
279280
# Use soft word wrap and show trimmed source snippets with error location markers.
280281
self.pretty = False

mypy/util.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -648,16 +648,26 @@ def fit_in_terminal(self, messages: List[str],
648648
# TODO: detecting source code highlights through an indent can be surprising.
649649
# Restore original error message and error location.
650650
error = error[DEFAULT_SOURCE_OFFSET:]
651-
column = messages[i+1].index('^') - DEFAULT_SOURCE_OFFSET
651+
marker_line = messages[i+1]
652+
marker_column = marker_line.index('^')
653+
column = marker_column - DEFAULT_SOURCE_OFFSET
654+
if '~' not in marker_line:
655+
marker = '^'
656+
else:
657+
# +1 because both ends are included
658+
marker = marker_line[marker_column:marker_line.rindex('~')+1]
652659

653660
# Let source have some space also on the right side, plus 6
654661
# to accommodate ... on each side.
655662
max_len = width - DEFAULT_SOURCE_OFFSET - 6
656663
source_line, offset = trim_source_line(error, max_len, column, MINIMUM_WIDTH)
657664

658665
new_messages[i] = ' ' * DEFAULT_SOURCE_OFFSET + source_line
659-
# Also adjust the error marker position.
660-
new_messages[i+1] = ' ' * (DEFAULT_SOURCE_OFFSET + column - offset) + '^'
666+
# Also adjust the error marker position and trim error marker is needed.
667+
new_marker_line = ' ' * (DEFAULT_SOURCE_OFFSET + column - offset) + marker
668+
if len(new_marker_line) > len(new_messages[i]) and len(marker) > 3:
669+
new_marker_line = new_marker_line[:len(new_messages[i]) - 3] + '...'
670+
new_messages[i+1] = new_marker_line
661671
return new_messages
662672

663673
def colorize(self, error: str) -> str:

test-data/unit/check-columns.test

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,3 +421,21 @@ def f(x: T) -> T:
421421
[case testColumnReturnValueExpected]
422422
def f() -> int:
423423
return # E:5: Return value expected
424+
425+
[case testCheckEndColumnPositions]
426+
# flags: --show-error-end
427+
x: int = "no way"
428+
429+
def g() -> int: ...
430+
def f(x: str) -> None: ...
431+
f(g(
432+
))
433+
x[0]
434+
[out]
435+
main:2:10:2:10: error: Incompatible types in assignment (expression has type "str", variable has type "int")
436+
main:6:3:6:3: error: Argument 1 to "f" has incompatible type "int"; expected "str"
437+
main:8:1:8:1: error: Value of type "int" is not indexable
438+
[out version>=3.8]
439+
main:2:10:2:17: error: Incompatible types in assignment (expression has type "str", variable has type "int")
440+
main:6:3:7:1: error: Argument 1 to "f" has incompatible type "int"; expected "str"
441+
main:8:1:8:4: error: Value of type "int" is not indexable

0 commit comments

Comments
 (0)