Skip to content

Commit 153536b

Browse files
brandtbucherilevkivskyi
authored andcommitted
Expression-scoped ignores in Python 3.8. (#6648)
Fixes #1032. On Python 3.8, we use `end_lineno` information to scope `# type: ignore` comments to enclosing expressions. Auto-formatter users, rejoice! The `--warn-unused-ignores` semantics should be clear from the test cases. Basically, ignores in inner scopes take precedence over ignores in enclosing scopes, and the first of multiple ignores in a scope "wins". A few notes: - This adds a new slot, `Context.end_line`. It defaults to `None` in the absence of an `end_lineno` or if `Context` is not an expression. - `ErrorInfo.origin` now has a third member, representing the end line of the error context. It is used to determine the range of lines to search for `# type: ignore` comments. If unavailable, it defaults to the same value as the origin line. - Because this uses 3.8-only AST features, a new `check-38.test` file has been created, and is hidden behind a version guard in `testcheck.py`.
1 parent 94746d6 commit 153536b

File tree

6 files changed

+121
-13
lines changed

6 files changed

+121
-13
lines changed

mypy/errors.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,9 @@ class ErrorInfo:
5454
# Only report this particular messages once per program.
5555
only_once = False
5656

57-
# Actual origin of the error message as tuple (path, line number)
58-
origin = None # type: Tuple[str, int]
57+
# Actual origin of the error message as tuple (path, line number, end line number)
58+
# If end line number is unknown, use line number.
59+
origin = None # type: Tuple[str, int, int]
5960

6061
# Fine-grained incremental target where this was reported
6162
target = None # type: Optional[str]
@@ -72,7 +73,7 @@ def __init__(self,
7273
message: str,
7374
blocker: bool,
7475
only_once: bool,
75-
origin: Optional[Tuple[str, int]] = None,
76+
origin: Optional[Tuple[str, int, int]] = None,
7677
target: Optional[str] = None) -> None:
7778
self.import_ctx = import_ctx
7879
self.file = file
@@ -85,7 +86,7 @@ def __init__(self,
8586
self.message = message
8687
self.blocker = blocker
8788
self.only_once = only_once
88-
self.origin = origin or (file, line)
89+
self.origin = origin or (file, line, line)
8990
self.target = target
9091

9192

@@ -233,7 +234,8 @@ def report(self,
233234
file: Optional[str] = None,
234235
only_once: bool = False,
235236
origin_line: Optional[int] = None,
236-
offset: int = 0) -> None:
237+
offset: int = 0,
238+
end_line: Optional[int] = None) -> None:
237239
"""Report message at the given line using the current error context.
238240
239241
Args:
@@ -244,6 +246,7 @@ def report(self,
244246
file: if non-None, override current file as context
245247
only_once: if True, only report this exact message once per build
246248
origin_line: if non-None, override current context as origin
249+
end_line: if non-None, override current context as end
247250
"""
248251
if self.scope:
249252
type = self.scope.current_type_name()
@@ -260,10 +263,17 @@ def report(self,
260263
file = self.file
261264
if offset:
262265
message = " " * offset + message
266+
267+
if origin_line is None:
268+
origin_line = line
269+
270+
if end_line is None:
271+
end_line = origin_line
272+
263273
info = ErrorInfo(self.import_context(), file, self.current_module(), type,
264274
function, line, column, severity, message,
265275
blocker, only_once,
266-
origin=(self.file, origin_line) if origin_line else None,
276+
origin=(self.file, origin_line, end_line),
267277
target=self.current_target())
268278
self.add_error_info(info)
269279

@@ -274,12 +284,17 @@ def _add_error_info(self, file: str, info: ErrorInfo) -> None:
274284
self.error_info_map[file].append(info)
275285

276286
def add_error_info(self, info: ErrorInfo) -> None:
277-
file, line = info.origin
287+
file, line, end_line = info.origin
278288
if not info.blocker: # Blockers cannot be ignored
279-
if file in self.ignored_lines and line in self.ignored_lines[file]:
280-
# Annotation requests us to ignore all errors on this line.
281-
self.used_ignored_lines[file].add(line)
282-
return
289+
if file in self.ignored_lines:
290+
# Check each line in this context for "type: ignore" comments.
291+
# For anything other than Python 3.8 expressions, line == end_line,
292+
# so we only loop once.
293+
for scope_line in range(line, end_line + 1):
294+
if scope_line in self.ignored_lines[file]:
295+
# Annotation requests us to ignore all errors on this line.
296+
self.used_ignored_lines[file].add(scope_line)
297+
return
283298
if file in self.ignored_files:
284299
return
285300
if info.only_once:

mypy/fastparse.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ def visit(self, node: Optional[AST]) -> Any:
280280
def set_line(self, node: N, n: Union[ast3.expr, ast3.stmt]) -> N:
281281
node.line = n.lineno
282282
node.column = n.col_offset
283+
node.end_line = getattr(n, "end_lineno", None) if isinstance(n, ast3.expr) else None
283284
return node
284285

285286
def translate_expr_list(self, l: Sequence[AST]) -> List[Expression]:

mypy/messages.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,18 @@ def report(self, msg: str, context: Optional[Context], severity: str,
111111
file: Optional[str] = None, origin: Optional[Context] = None,
112112
offset: int = 0) -> None:
113113
"""Report an error or note (unless disabled)."""
114+
if origin is not None:
115+
end_line = origin.end_line
116+
elif context is not None:
117+
end_line = context.end_line
118+
else:
119+
end_line = None
114120
if self.disable_count <= 0:
115121
self.errors.report(context.get_line() if context else -1,
116122
context.get_column() if context else -1,
117123
msg, severity=severity, file=file, offset=offset,
118-
origin_line=origin.get_line() if origin else None)
124+
origin_line=origin.get_line() if origin else None,
125+
end_line=end_line)
119126

120127
def fail(self, msg: str, context: Optional[Context], file: Optional[str] = None,
121128
origin: Optional[Context] = None) -> None:

mypy/nodes.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,12 @@
2222

2323
class Context:
2424
"""Base type for objects that are valid as error message locations."""
25-
__slots__ = ('line', 'column')
25+
__slots__ = ('line', 'column', 'end_line')
2626

2727
def __init__(self, line: int = -1, column: int = -1) -> None:
2828
self.line = line
2929
self.column = column
30+
self.end_line = None # type: Optional[int]
3031

3132
def set_line(self, target: Union['Context', int], column: Optional[int] = None) -> None:
3233
"""If target is a node, pull line (and column) information
@@ -38,6 +39,7 @@ def set_line(self, target: Union['Context', int], column: Optional[int] = None)
3839
else:
3940
self.line = target.line
4041
self.column = target.column
42+
self.end_line = target.end_line
4143

4244
if column is not None:
4345
self.column = column

mypy/test/testcheck.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@
8585
'check-newsemanal.test',
8686
]
8787

88+
# Tests that use Python 3.8-only AST features (like expression-scoped ignores):
89+
if sys.version_info >= (3, 8):
90+
typecheck_files.append('check-38.test')
91+
8892

8993
class TypeCheckSuite(DataSuite):
9094
files = typecheck_files

test-data/unit/check-38.test

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
[case testIgnoreScopeIssue1032]
2+
def f(a: int): ...
3+
f(
4+
"IGNORE"
5+
) # type: ignore
6+
7+
[case testIgnoreScopeNested1]
8+
def f(a: int) -> int: ...
9+
f(
10+
f(
11+
"IGNORE"
12+
) # type: ignore
13+
)
14+
15+
[case testIgnoreScopeNested2]
16+
[
17+
"IGNORE" # type: ignore
18+
&
19+
"IGNORE",
20+
]
21+
[builtins fixtures/list.pyi]
22+
23+
[case testIgnoreScopeNested3]
24+
{
25+
"IGNORE"
26+
| # type: ignore
27+
"IGNORE",
28+
}
29+
[builtins fixtures/set.pyi]
30+
31+
[case testIgnoreScopeNested4]
32+
{
33+
None: "IGNORE"
34+
^
35+
"IGNORE", # type: ignore
36+
}
37+
[builtins fixtures/dict.pyi]
38+
39+
[case testIgnoreScopeNestedNonOverlapping]
40+
def f(x: int): ...
41+
def g(x: int): ...
42+
(
43+
f("ERROR"), # E: Argument 1 to "f" has incompatible type "str"; expected "int"
44+
g("IGNORE"), # type: ignore
45+
f("ERROR"), # E: Argument 1 to "f" has incompatible type "str"; expected "int"
46+
)
47+
48+
[case testIgnoreScopeNestedOverlapping]
49+
def f(x: int): ...
50+
def g(x: int): ...
51+
(
52+
f("ERROR"), g( # E: Argument 1 to "f" has incompatible type "str"; expected "int"
53+
"IGNORE" # type: ignore
54+
), f("ERROR"), # E: Argument 1 to "f" has incompatible type "str"; expected "int"
55+
)
56+
57+
[case testIgnoreScopeUnused1]
58+
# flags: --warn-unused-ignores
59+
( # type: ignore # N: unused 'type: ignore' comment
60+
"IGNORE" # type: ignore
61+
+ # type: ignore # N: unused 'type: ignore' comment
62+
0 # type: ignore # N: unused 'type: ignore' comment
63+
) # type: ignore # N: unused 'type: ignore' comment
64+
65+
[case testIgnoreScopeUnused2]
66+
# flags: --warn-unused-ignores
67+
( # type: ignore # N: unused 'type: ignore' comment
68+
"IGNORE"
69+
- # type: ignore
70+
0 # type: ignore # N: unused 'type: ignore' comment
71+
) # type: ignore # N: unused 'type: ignore' comment
72+
73+
[case testIgnoreScopeUnused3]
74+
# flags: --warn-unused-ignores
75+
( # type: ignore # N: unused 'type: ignore' comment
76+
"IGNORE"
77+
/
78+
0 # type: ignore
79+
) # type: ignore # N: unused 'type: ignore' comment

0 commit comments

Comments
 (0)