Repository: python/mypy

Issue:
Small bug: `if` expression doesn't short circuit for --always-false/--always-true
`if` expressions don't correctly short-circuit for variables that are set as "always true/false" constants with the `--always-false` and `--always-true` flags, even though they work for other "always true/false" conditions, like ones that use `sys.platform`.

Here's a test case:
```
# flags: --platform linux --always-false IS_THING
import sys

if sys.platform == 'darwin':
    darwin_only = 1

if sys.platform == 'darwin' and darwin_only:
    # This works because `sys.platform == 'darwin'` is inferred as `ALWAYS_FALSE`, and
    # that causes the short-circuit logic to run.
    pass

IS_THING = False

if IS_THING:
    thing_only = 1

if IS_THING and thing_only:
    # Blows up with the error `error: Name 'thing_only' is not defined` since
    # IS_THING is inferred as `MYPY_FALSE`, so the short-circuiting logic
    # doesn't run
    pass
```

The short-circuit logic is scattered throughout the codebase, but here's an [example](https://github.com/python/mypy/blob/3c311c24323d20bbf7e13084ba16bcb99e8934d7/mypy/semanal.py#L3074):

```
        if expr.op in ('and', 'or'):
            inferred = infer_condition_value(expr.left, self.options)
            if ((inferred == ALWAYS_FALSE and expr.op == 'and') or
                    (inferred == ALWAYS_TRUE and expr.op == 'or')):
                expr.right_unreachable = True
                return
            elif ((inferred == ALWAYS_TRUE and expr.op == 'and') or
                    (inferred == ALWAYS_FALSE and expr.op == 'or')):
                expr.right_always = True
```

Notice that it checks for ALWAYS_FALSE and ALWAYS_TRUE, but not MYPY_FALSE or MYPY_TRUE>

And the --always-true/false flags are translated to MYPY_TRUE/FALSE [here](https://github.com/python/mypy/blob/3c311c24323d20bbf7e13084ba16bcb99e8934d7/mypy/semanal.py#L3856).

There are two potential fixes:

1. Include two MYPY_TRUE/FALSE in all of the `if` short circuit checks.

I think that works, because even when using the MYPY variable directly, it's probably surprising that it doesn't short-circuit.

2. Use ALWAYS_TRUE/FALSE with the --always-true/false flags instead of MYPY_TRUE/FALSE.

@gvanrossum might have more context on why the --always-* flags use the MYPY_* constants instead of the ALWAYS_* constants, since he added them. FWIW, using ALWAYS_* makes more semantic sense to me, since I'm using the flags for variables that will have the same value at runtime. (e.g. I'm setting `--always-true=IS_WINDOWS` to typecheck my app for windows, so IS_WINDOWS will also be true at runtime.) That's distinct from how `MYPY` works, where it's always False at runtime and always True for typechecking.

Thoughts?

* What are the versions of mypy and Python you are using?

python 3.5.2, latest master for mypy



Discussion:
Thanks for the report! I'm not sure myself why we have both ALWAYS_FALSE/TRUE and MYPY_FALSE/TRUE. If the distinction is somehow important, we'll have to update all the places that only check for ALWAYS_FALSE/TRUE to also check MYPY_FALSE/TRUE. But maybe we don't need both?
I think this is the only other place where there's a distinction between MYPY_TRUE and ALWAYS_TRUE:
https://github.com/python/mypy/blob/3c311c24323d20bbf7e13084ba16bcb99e8934d7/mypy/semanal.py#L3792

It looks like we use it to set a different priority for mypy-only imports.
Huh, how quickly we forget. :-)

It sounds like a fix might be straightforward: just make sure both are tested everywhere else.
I think we should probably do idea 2: this isn't an `if MYPY`, so there's no clear reason to treat it like one. 

We maybe should also still do idea 1, though I'm pretty ambivalent about it because nesting `MYPY` into conditional expressions seems like a pretty bad idea anyways. Though it is probably still worth being consistent about this?
My 2c: I think folks are unlikely to do strange stuff with `MYPY`, but even when they do it's nice for it to be consistent with "ALWAYS_TRUE/FALSE" expressions. To users, there's no difference between `MYPY` and an expression that evaluates to `ALWAYS_TRUE`: they perceive both as variables that mypy treats as `True` when typechecking, and it would be confusing for their behavior to differ in user-visible ways.

I don't have strong feelings about it, though.

Resolving patch:
diff --git a/mypy/reachability.py b/mypy/reachability.py
--- a/mypy/reachability.py
+++ b/mypy/reachability.py
@@ -84,8 +84,8 @@ def infer_condition_value(expr: Expression, options: Options) -> int:
         name = expr.name
     elif isinstance(expr, OpExpr) and expr.op in ('and', 'or'):
         left = infer_condition_value(expr.left, options)
-        if ((left == ALWAYS_TRUE and expr.op == 'and') or
-                (left == ALWAYS_FALSE and expr.op == 'or')):
+        if ((left in (ALWAYS_TRUE, MYPY_TRUE) and expr.op == 'and') or
+                (left in (ALWAYS_FALSE, MYPY_FALSE) and expr.op == 'or')):
             # Either `True and <other>` or `False or <other>`: the result will
             # always be the right-hand-side.
             return infer_condition_value(expr.right, options)
@@ -105,9 +105,9 @@ def infer_condition_value(expr: Expression, options: Options) -> int:
         elif name == 'MYPY' or name == 'TYPE_CHECKING':
             result = MYPY_TRUE
         elif name in options.always_true:
-            result = MYPY_TRUE
+            result = ALWAYS_TRUE
         elif name in options.always_false:
-            result = MYPY_FALSE
+            result = ALWAYS_FALSE
     if negated:
         result = inverted_truth_mapping[result]
     return result
diff --git a/mypy/semanal.py b/mypy/semanal.py
--- a/mypy/semanal.py
+++ b/mypy/semanal.py
@@ -90,7 +90,8 @@
 from mypy.semanal_enum import EnumCallAnalyzer
 from mypy.semanal_newtype import NewTypeAnalyzer
 from mypy.reachability import (
-    infer_reachability_of_if_statement, infer_condition_value, ALWAYS_FALSE, ALWAYS_TRUE
+    infer_reachability_of_if_statement, infer_condition_value, ALWAYS_FALSE, ALWAYS_TRUE,
+    MYPY_TRUE, MYPY_FALSE
 )
 from mypy.typestate import TypeState
 
@@ -3102,12 +3103,12 @@ def visit_op_expr(self, expr: OpExpr) -> None:
 
         if expr.op in ('and', 'or'):
             inferred = infer_condition_value(expr.left, self.options)
-            if ((inferred == ALWAYS_FALSE and expr.op == 'and') or
-                    (inferred == ALWAYS_TRUE and expr.op == 'or')):
+            if ((inferred in (ALWAYS_FALSE, MYPY_FALSE) and expr.op == 'and') or
+                    (inferred in (ALWAYS_TRUE, MYPY_TRUE) and expr.op == 'or')):
                 expr.right_unreachable = True
                 return
-            elif ((inferred == ALWAYS_TRUE and expr.op == 'and') or
-                    (inferred == ALWAYS_FALSE and expr.op == 'or')):
+            elif ((inferred in (ALWAYS_TRUE, MYPY_TRUE) and expr.op == 'and') or
+                    (inferred in (ALWAYS_FALSE, MYPY_FALSE) and expr.op == 'or')):
                 expr.right_always = True
 
         expr.right.accept(self)


Test patch:
diff --git a/test-data/unit/check-unreachable-code.test b/test-data/unit/check-unreachable-code.test
--- a/test-data/unit/check-unreachable-code.test
+++ b/test-data/unit/check-unreachable-code.test
@@ -567,6 +567,42 @@ else:
 reveal_type(y)  # E: Revealed type is 'builtins.str'
 [builtins fixtures/ops.pyi]
 
+[case testShortCircuitNoEvaluation]
+# flags: --platform linux --always-false COMPILE_TIME_FALSE
+import sys
+
+if sys.platform == 'darwin':
+    mac_only = 'junk'
+
+# `mac_only` should not be evaluated
+if sys.platform == 'darwin' and mac_only:
+    pass
+if sys.platform == 'linux' or mac_only:
+    pass
+
+COMPILE_TIME_FALSE = 'junk'
+
+if COMPILE_TIME_FALSE:
+    compile_time_false_only = 'junk'
+
+# `compile_time_false_only` should not be evaluated
+if COMPILE_TIME_FALSE and compile_time_false_only:
+    pass
+if not COMPILE_TIME_FALSE or compile_time_false_only:
+    pass
+
+MYPY = False
+
+if not MYPY:
+    mypy_only = 'junk'
+
+# `mypy_only` should not be evaluated
+if not MYPY and mypy_only:
+    pass
+if MYPY or mypy_only:
+    pass
+[builtins fixtures/ops.pyi]
+
 [case testConditionalAssertWithoutElse]
 import typing