Skip to content

Commit 8030804

Browse files
Michael0x2aJukkaL
authored andcommitted
Add flag to warn about unreachable branches and exprs (#7050)
This diff adds a `--warn-unreachable` flag that reports an error if: 1. Any branch is inferred to be unreachable 2. Any subexpression in boolean expressions, inline if statements, and list/set/generator/dict comprehensions are always unreachable or redundant. This only takes into account the results of *type* analysis. We do not report errors for branches that are inferred to be unreachable in the semantic analysis phase (e.g. due to `sys.platform` checks and the like). Those checks are all intentional/deliberately flag certain branches as unreachable, so error messages would be annoying in those cases. A few additional notes: 1. I thought about enabling this flag for default with mypy, but unfortunately that ended up producing ~40 to 50 (apparently legitimate) error messages. About a fourth of them were due to runtime checks checking to see if some type is None (despite not being declared to be Optional), about a fourth seemed to be due to mypy not quite understanding how to handle things like traits and Bogus[...], and about a fourth seemed to be legitimately unnecessary checks we could safely remove. The final checks were a mixture of typeshed bugs and misc errors with the reachability checks. (e.g. see #7048) 2. For these reasons, I decided against adding this flag to `--strict` and against documenting it yet. We'll probably need to flush out a few bugs in mypy first/get mypy to the state where we can at least honestly dogfood this. Resolves #2395; partially addresses #7008.
1 parent 7bd951a commit 8030804

File tree

7 files changed

+372
-6
lines changed

7 files changed

+372
-6
lines changed

mypy/binder.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ def __init__(self) -> None:
3232
self.types = {} # type: Dict[Key, Type]
3333
self.unreachable = False
3434

35+
# Should be set only if we're entering a frame where it's not
36+
# possible to accurately determine whether or not contained
37+
# statements will be unreachable or not.
38+
#
39+
# Long-term, we should improve mypy to the point where we no longer
40+
# need this field.
41+
self.suppress_unreachable_warnings = False
42+
3543

3644
Assigns = DefaultDict[Expression, List[Tuple[Type, Optional[Type]]]]
3745

@@ -131,6 +139,9 @@ def put(self, expr: Expression, typ: Type) -> None:
131139
def unreachable(self) -> None:
132140
self.frames[-1].unreachable = True
133141

142+
def suppress_unreachable_warnings(self) -> None:
143+
self.frames[-1].suppress_unreachable_warnings = True
144+
134145
def get(self, expr: Expression) -> Optional[Type]:
135146
key = literal_hash(expr)
136147
assert key is not None, 'Internal error: binder tried to get non-literal'
@@ -141,6 +152,10 @@ def is_unreachable(self) -> bool:
141152
# this traversal on every statement?
142153
return any(f.unreachable for f in self.frames)
143154

155+
def is_unreachable_warning_suppressed(self) -> bool:
156+
# TODO: See todo in 'is_unreachable'
157+
return any(f.suppress_unreachable_warnings for f in self.frames)
158+
144159
def cleanse(self, expr: Expression) -> None:
145160
"""Remove all references to a Node from the binder."""
146161
key = literal_hash(expr)

mypy/checker.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -783,7 +783,8 @@ def enter_attribute_inference_context(self) -> Iterator[None]:
783783
def check_func_def(self, defn: FuncItem, typ: CallableType, name: Optional[str]) -> None:
784784
"""Type check a function definition."""
785785
# Expand type variables with value restrictions to ordinary types.
786-
for item, typ in self.expand_typevars(defn, typ):
786+
expanded = self.expand_typevars(defn, typ)
787+
for item, typ in expanded:
787788
old_binder = self.binder
788789
self.binder = ConditionalTypeBinder()
789790
with self.binder.top_frame_context():
@@ -932,6 +933,14 @@ def check_func_def(self, defn: FuncItem, typ: CallableType, name: Optional[str])
932933
# Type check body in a new scope.
933934
with self.binder.top_frame_context():
934935
with self.scope.push_function(defn):
936+
# We suppress reachability warnings when we use TypeVars with value
937+
# restrictions: we only want to report a warning if a certain statement is
938+
# marked as being suppressed in *all* of the expansions, but we currently
939+
# have no good way of doing this.
940+
#
941+
# TODO: Find a way of working around this limitation
942+
if len(expanded) >= 2:
943+
self.binder.suppress_unreachable_warnings()
935944
self.accept(item.body)
936945
unreachable = self.binder.is_unreachable()
937946

@@ -1777,13 +1786,46 @@ def check_import(self, node: ImportBase) -> None:
17771786

17781787
def visit_block(self, b: Block) -> None:
17791788
if b.is_unreachable:
1789+
# This block was marked as being unreachable during semantic analysis.
1790+
# It turns out any blocks marked in this way are *intentionally* marked
1791+
# as unreachable -- so we don't display an error.
17801792
self.binder.unreachable()
17811793
return
17821794
for s in b.body:
17831795
if self.binder.is_unreachable():
1796+
if (self.options.warn_unreachable
1797+
and not self.binder.is_unreachable_warning_suppressed()
1798+
and not self.is_raising_or_empty(s)):
1799+
self.msg.unreachable_statement(s)
17841800
break
17851801
self.accept(s)
17861802

1803+
def is_raising_or_empty(self, s: Statement) -> bool:
1804+
"""Returns 'true' if the given statement either throws an error of some kind
1805+
or is a no-op.
1806+
1807+
We use this function mostly while handling the '--warn-unreachable' flag. When
1808+
that flag is present, we normally report an error on any unreachable statement.
1809+
But if that statement is just something like a 'pass' or a just-in-case 'assert False',
1810+
reporting an error would be annoying.
1811+
"""
1812+
if isinstance(s, AssertStmt) and is_false_literal(s.expr):
1813+
return True
1814+
elif isinstance(s, (RaiseStmt, PassStmt)):
1815+
return True
1816+
elif isinstance(s, ExpressionStmt):
1817+
if isinstance(s.expr, EllipsisExpr):
1818+
return True
1819+
elif isinstance(s.expr, CallExpr):
1820+
self.expr_checker.msg.disable_errors()
1821+
typ = self.expr_checker.accept(
1822+
s.expr, allow_none_return=True, always_allow_any=True)
1823+
self.expr_checker.msg.enable_errors()
1824+
1825+
if isinstance(typ, UninhabitedType):
1826+
return True
1827+
return False
1828+
17871829
def visit_assignment_stmt(self, s: AssignmentStmt) -> None:
17881830
"""Type check an assignment statement.
17891831
@@ -2954,7 +2996,8 @@ def visit_try_stmt(self, s: TryStmt) -> None:
29542996
# type checks in both contexts, but only the resulting types
29552997
# from the latter context affect the type state in the code
29562998
# that follows the try statement.)
2957-
self.accept(s.finally_body)
2999+
if not self.binder.is_unreachable():
3000+
self.accept(s.finally_body)
29583001

29593002
def visit_try_without_finally(self, s: TryStmt, try_frame: bool) -> None:
29603003
"""Type check a try statement, ignoring the finally block.

mypy/checkexpr.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2367,14 +2367,25 @@ def check_boolean_op(self, e: OpExpr, context: Context) -> Type:
23672367
restricted_left_type = true_only(left_type)
23682368
result_is_left = not left_type.can_be_false
23692369

2370+
# If right_map is None then we know mypy considers the right branch
2371+
# to be unreachable and therefore any errors found in the right branch
2372+
# should be suppressed.
2373+
#
2374+
# Note that we perform these checks *before* we take into account
2375+
# the analysis from the semanal phase below. We assume that nodes
2376+
# marked as unreachable during semantic analysis were done so intentionally.
2377+
# So, we shouldn't report an error.
2378+
if self.chk.options.warn_unreachable:
2379+
if left_map is None:
2380+
self.msg.redundant_left_operand(e.op, e.left)
2381+
if right_map is None:
2382+
self.msg.redundant_right_operand(e.op, e.right)
2383+
23702384
if e.right_unreachable:
23712385
right_map = None
23722386
elif e.right_always:
23732387
left_map = None
23742388

2375-
# If right_map is None then we know mypy considers the right branch
2376-
# to be unreachable and therefore any errors found in the right branch
2377-
# should be suppressed.
23782389
if right_map is None:
23792390
self.msg.disable_errors()
23802391
try:
@@ -3178,19 +3189,30 @@ def check_for_comp(self, e: Union[GeneratorExpr, DictionaryComprehension]) -> No
31783189
self.accept(condition)
31793190

31803191
# values are only part of the comprehension when all conditions are true
3181-
true_map, _ = self.chk.find_isinstance_check(condition)
3192+
true_map, false_map = self.chk.find_isinstance_check(condition)
31823193

31833194
if true_map:
31843195
for var, type in true_map.items():
31853196
self.chk.binder.put(var, type)
31863197

3198+
if self.chk.options.warn_unreachable:
3199+
if true_map is None:
3200+
self.msg.redundant_condition_in_comprehension(False, condition)
3201+
elif false_map is None:
3202+
self.msg.redundant_condition_in_comprehension(True, condition)
3203+
31873204
def visit_conditional_expr(self, e: ConditionalExpr) -> Type:
31883205
self.accept(e.cond)
31893206
ctx = self.type_context[-1]
31903207

31913208
# Gain type information from isinstance if it is there
31923209
# but only for the current expression
31933210
if_map, else_map = self.chk.find_isinstance_check(e.cond)
3211+
if self.chk.options.warn_unreachable:
3212+
if if_map is None:
3213+
self.msg.redundant_condition_in_if(False, e.cond)
3214+
elif else_map is None:
3215+
self.msg.redundant_condition_in_if(True, e.cond)
31943216

31953217
if_type = self.analyze_cond_branch(if_map, e.if_expr, context=ctx)
31963218

mypy/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,10 @@ def add_invertible_flag(flag: str,
463463
help="Warn about returning values of type Any"
464464
" from non-Any typed functions",
465465
group=lint_group)
466+
add_invertible_flag('--warn-unreachable', default=False, strict_flag=False,
467+
help="Warn about statements or expressions inferred to be"
468+
" unreachable or redundant",
469+
group=lint_group)
466470

467471
# Note: this group is intentionally added here even though we don't add
468472
# --strict to this group near the end.

mypy/messages.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1228,6 +1228,35 @@ def note_call(self, subtype: Type, call: Type, context: Context) -> None:
12281228
self.note('"{}.__call__" has type {}'.format(self.format_bare(subtype),
12291229
self.format(call, verbosity=1)), context)
12301230

1231+
def unreachable_statement(self, context: Context) -> None:
1232+
self.fail("Statement is unreachable", context)
1233+
1234+
def redundant_left_operand(self, op_name: str, context: Context) -> None:
1235+
"""Indicates that the left operand of a boolean expression is redundant:
1236+
it does not change the truth value of the entire condition as a whole.
1237+
'op_name' should either be the string "and" or the string "or".
1238+
"""
1239+
self.redundant_expr("Left operand of '{}'".format(op_name), op_name == 'and', context)
1240+
1241+
def redundant_right_operand(self, op_name: str, context: Context) -> None:
1242+
"""Indicates that the right operand of a boolean expression is redundant:
1243+
it does not change the truth value of the entire condition as a whole.
1244+
'op_name' should either be the string "and" or the string "or".
1245+
"""
1246+
self.fail("Right operand of '{}' is never evaluated".format(op_name), context)
1247+
1248+
def redundant_condition_in_comprehension(self, truthiness: bool, context: Context) -> None:
1249+
self.redundant_expr("If condition in comprehension", truthiness, context)
1250+
1251+
def redundant_condition_in_if(self, truthiness: bool, context: Context) -> None:
1252+
self.redundant_expr("If condition", truthiness, context)
1253+
1254+
def redundant_condition_in_assert(self, truthiness: bool, context: Context) -> None:
1255+
self.redundant_expr("Condition in assert", truthiness, context)
1256+
1257+
def redundant_expr(self, description: str, truthiness: bool, context: Context) -> None:
1258+
self.fail("{} is always {}".format(description, str(truthiness).lower()), context)
1259+
12311260
def report_protocol_problems(self, subtype: Union[Instance, TupleType, TypedDictType],
12321261
supertype: Instance, context: Context) -> None:
12331262
"""Report possible protocol conflicts between 'subtype' and 'supertype'.

mypy/options.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class BuildType:
4848
"strict_optional_whitelist",
4949
"warn_no_return",
5050
"warn_return_any",
51+
"warn_unreachable",
5152
"warn_unused_ignores",
5253
} # type: Final
5354

@@ -164,6 +165,10 @@ def __init__(self) -> None:
164165
# This makes 1 == '1', 1 in ['1'], and 1 is '1' errors.
165166
self.strict_equality = False
166167

168+
# Report an error for any branches inferred to be unreachable as a result of
169+
# type analysis.
170+
self.warn_unreachable = False
171+
167172
# Variable names considered True
168173
self.always_true = [] # type: List[str]
169174

0 commit comments

Comments
 (0)