Repository: python/mypy
Pull request: When strict optional is disabled, anything can be falsey
Previously some code was treated incorrectly as unreachable, since
some checks where treated as always true even though they could
be false given a None value.

Fixes  #3601.

Problem statement:
Optional[NamedTuple] makes mypy think a branch is unreachable
In the following example, the `else` branch is not typechecked. As far as I've been able to narrow it down, it seems like it has to do with NamedTuple.

```py
from typing import Optional, NamedTuple, List

NamedTup = NamedTuple('NamedTup', [('a', str)])


class Foo:
    def __init__(self) -> None:
        self.x = get_optional()
        if self.x:
            # this is typechecked
            f: str = 2  # error: Incompatible types in assignment
        else:
            # this is not typechecked
            j: str = 2  # no error
            self.mypy_does_not_typecheck_this()  # no error
            reveal_type(j)  # no error


def get_optional() -> Optional[NamedTup]:
    return None


Foo()
```

Here is the output from `mypy` and `python`.
```
$ mypy n.py
n.py:11: error: Incompatible types in assignment (expression has type "int", variable has type "str")
$ python3 n.py
Traceback (most recent call last):
  File "n.py", line 23, in <module>
    Foo()
  File "n.py", line 15, in __init__
    self.mypy_does_not_typecheck_this()  # no error
AttributeError: 'Foo' object has no attribute 'mypy_does_not_typecheck_this'
```

Hints from issue discussion:
If that's true it's disturbing. Can you boil it down to a smaller repro? (E.g. does it need to be the `__init__` method in a class? Does the same thing happen in a toplevel function?)
The situation boils down to this:
```python
class N(NamedTuple):
    x: int

if N(1):
    1 + 1
else:
    1 + 'boom'  # this error is reported

x = N(1)
if x:
    1 + 1
else:
    1 + 'boom'  # but this is not

y: N
if y:
    1 + 1
else:
    1 + 'boom'  # nor this
```
@ilevkivskyi thanks for narrowing it down!
So it boils down to "a variable of type (some named tuple) is always
considered false" ?

On a second thought, it seems to me this is not a bug either. Tuples indeed cannot be false (only an empty tuple, but mypy treats it correctly). Also the initial example is treated correctly with ``--strict-optional``. Please close this issue if you agree.
A non-empty tuple could be false if a subclass overrides `__bool__`. Not sure if that's something we should be prepared for. We already sometimes assume that certain subclasses that might plausibly exist don't actually exist.
> A non-empty tuple could be false if a subclass overrides ``__bool__``.

Yes, this is why I left one of the two issues open. But TBH I think such situations are very rare.
I encountered this today. This also happens with regular tuples (when not using strict optional):

```py
from typing import Tuple

def f(x: Tuple[int, int]) -> None:
    if x:
        print(1)
    else:
        1 + ''  # No error

f(None)
```

The above behavior is clearly a bug. Let's make this issue only about the non-strict-optional case, as discussing the strict optional behavior only confuses things.
I have a possible fix that I need to validate still.

Commit message: When strict optional is disabled, anything can be falsey

Previously some code was treated incorrectly as unreachable, since
some checks where treated as always true even though they could
be false given a None value.

Fixes #6827.

Patch to review:
diff --git a/mypy/test/testtypes.py b/mypy/test/testtypes.py
index 7f3dc6a62c2c..72c206399da3 100644
--- a/mypy/test/testtypes.py
+++ b/mypy/test/testtypes.py
@@ -341,8 +341,17 @@ def test_true_only_of_union(self) -> None:
         assert_true(to.items[1] is tup_type)
 
     def test_false_only_of_true_type_is_uninhabited(self) -> None:
-        fo = false_only(self.tuple(AnyType(TypeOfAny.special_form)))
-        assert_type(UninhabitedType, fo)
+        with strict_optional_set(True):
+            fo = false_only(self.tuple(AnyType(TypeOfAny.special_form)))
+            assert_type(UninhabitedType, fo)
+
+    def test_false_only_tuple(self) -> None:
+        with strict_optional_set(False):
+            fo = false_only(self.tuple(self.fx.a))
+            assert_equal(fo, NoneType())
+        with strict_optional_set(True):
+            fo = false_only(self.tuple(self.fx.a))
+            assert_equal(fo, UninhabitedType())
 
     def test_false_only_of_false_type_is_idempotent(self) -> None:
         always_false = NoneType()
@@ -359,18 +368,19 @@ def test_false_only_of_instance(self) -> None:
         assert_true(self.fx.a.can_be_true)
 
     def test_false_only_of_union(self) -> None:
-        tup_type = self.tuple()
-        # Union of something that is unknown, something that is always true, something
-        # that is always false
-        union_type = UnionType([self.fx.a, self.tuple(AnyType(TypeOfAny.special_form)),
-                                tup_type])
-        assert_equal(len(union_type.items), 3)
-        fo = false_only(union_type)
-        assert isinstance(fo, UnionType)
-        assert_equal(len(fo.items), 2)
-        assert_false(fo.items[0].can_be_true)
-        assert_true(fo.items[0].can_be_false)
-        assert_true(fo.items[1] is tup_type)
+        with strict_optional_set(True):
+            tup_type = self.tuple()
+            # Union of something that is unknown, something that is always true, something
+            # that is always false
+            union_type = UnionType([self.fx.a, self.tuple(AnyType(TypeOfAny.special_form)),
+                                    tup_type])
+            assert_equal(len(union_type.items), 3)
+            fo = false_only(union_type)
+            assert isinstance(fo, UnionType)
+            assert_equal(len(fo.items), 2)
+            assert_false(fo.items[0].can_be_true)
+            assert_true(fo.items[0].can_be_false)
+            assert_true(fo.items[1] is tup_type)
 
     # Helpers
 
diff --git a/mypy/types.py b/mypy/types.py
index f14e506d26c0..050bb3492e79 100644
--- a/mypy/types.py
+++ b/mypy/types.py
@@ -2127,8 +2127,13 @@ def false_only(t: Type) -> Type:
     Restricted version of t with only False-ish values
     """
     if not t.can_be_false:
-        # All values of t are True-ish, so there are no false values in it
-        return UninhabitedType(line=t.line)
+        if state.strict_optional:
+            # All values of t are True-ish, so there are no false values in it
+            return UninhabitedType(line=t.line)
+        else:
+            # When strict optional checking is disabled, everything can be
+            # False-ish since anything can be None
+            return NoneType(line=t.line)
     elif not t.can_be_true:
         # All values of t are already False-ish, so false_only is idempotent in this case
         return t
diff --git a/test-data/unit/check-namedtuple.test b/test-data/unit/check-namedtuple.test
index 5a822c2487c5..76ae15914365 100644
--- a/test-data/unit/check-namedtuple.test
+++ b/test-data/unit/check-namedtuple.test
@@ -846,3 +846,19 @@ class Child(Parent):
 
 reveal_type(Child.class_method())  # E: Revealed type is 'Tuple[builtins.str, fallback=__main__.Child]'
 [builtins fixtures/classmethod.pyi]
+
+[case testNamedTupleAsConditionalStrictOptionalDisabled]
+# flags: --no-strict-optional
+from typing import NamedTuple
+
+class C(NamedTuple):
+    a: int
+    b: str
+
+a: C
+if not a:
+    1()  # E: "int" not callable
+b = (1, 2)
+if not a:
+    ''()  # E: "str" not callable
+[builtins fixtures/tuple.pyi]


Code review comments:
diff_hunk: @@ -846,3 +846,19 @@ class Child(Parent):
 
 reveal_type(Child.class_method())  # E: Revealed type is 'Tuple[builtins.str, fallback=__main__.Child]'
 [builtins fixtures/classmethod.pyi]
+
+[case testNamedTupleAsConditionalStrictOptionalDisabled]
+# flags: --no-strict-optional
+from typing import NamedTuple
+
+class C(NamedTuple):
+    a: int
+    b: str
+
+a: C
+if not a:
+    1()  # E: "int" not callable
+b = (1, 2)
+if not a:
path: test-data/unit/check-namedtuple.test
text: @user1:
Shouldn't this be `b` here?

@author:
Yes!