Repository: python/mypy

Issue:
Idea: allow assert with platform or version check at top of file
Some files only work for certain platforms, but sometimes it's inconvenient to move the file to a location where it'll only be picked up for that platform. And yet the file doesn't type check cleanly on another platform (or ditto for Python versions).

We could enclose the entire file in `if sys.platform == 'win32':` but that seems overkill (and adding the indentation makes for an awkward diff). I propose an alternative that would have the same effect: `assert sys.platform == 'win32'`.


Discussion:
I like it.

Another (minor) problem with the approach of enclosing the entire file in `if ...:` is that mypy won't flag imports of the file, only attempts to use names from it. In other words, if we're using the `if ...:` approach and I'm not on Windows, I would not get an error for `import only_on_windows`, but only for `only_on_windows.some_function()`. This could be confusing for users.
I think this is a good idea.

We also probably want to support this related idiom:
```
if sys.platform == 'linux':
    # stuff
elif sys.platform == 'darwin':
    # other stuff
else:
    assert False, "unsupported"
```
@msullivan We already support platform checks. So if I run this:

```
import sys
if sys.platform == 'darwin':
    def f() -> str:
        return ''
else:
    assert False, "unsupported"
    
reveal_type(f)
```
on Linux, I get `f` is `Any`, as the name is not defined on Linux/Windows. Are you suggesting that there be an error on the line with `assert False`?
I think giving an error when mypy finds a reachable `assert False` is going to cause a lot of false positives. And detecting sequences of if-elif-elif-else where all tests are sys.platform checks seems complicated and not worth it.

Anyway, I just rechecked our original use case, and there we would actually want mypy to be *silent* if it finds a file containing `assert sys.platform == 'win32'` and the platform isn't `'win32'`. The reason is that we statically compute a list of files to pass to mypy, and this file is always in it (the static computation is way too stupid to understand sys.platform checks). And on a non-win32 platform the contents of the file won't type-check at all (it imports other win32-specific modules, but since it is itself a win32-specific module, it doesn't wrap those in sys.platform checks). And the code that imports the module is inside a sys.platform check. And finally we don't want to edit the file to indent everything by an extra level and inserting a sys.platform check at the top, but we're fine adding a single assert at the top.

So really all I want is for mypy to silently ignore the rest of a file when it encounters a top-level assert with a sys.platform check in it.
@ethanhs When I check that, I get:
```
lol.py:8: error: Revealed type is 'Any'
lol.py:8: error: Name 'f' is not defined
```
I don't mean giving an error on `assert False`, I mean suppressing the `f` not defined error (by not checking it).

This is prompted by a request from an internal user trying to run mypy on multiple platforms with some code that looks like what I had above.
But if supporting it directly is a pain we can easily require adding a toplevel assert, if we implement that.
I like the top-level assert idea. It looks like this is something we should prioritize?
If it’s simple to do, yes.

This keeps coming, so I am raising priority to high.
To be clear, the implementation we're looking for is

> for mypy to silently ignore the rest of a file when it encounters a top-level assert with a [non-matching] `sys.platform` check in it

Would it be really lame if I implemented this *only* if the `assert` occurs at the top level in a file, without any indentation (i.e. not inside e.g. `if`, and certainly not inside a function)?

Resolving patch:
diff --git a/mypy/semanal.py b/mypy/semanal.py
--- a/mypy/semanal.py
+++ b/mypy/semanal.py
@@ -3805,6 +3805,10 @@ def infer_reachability_of_if_statement(s: IfStmt, options: Options) -> None:
             break
 
 
+def assert_will_always_fail(s: AssertStmt, options: Options) -> bool:
+    return infer_condition_value(s.expr, options) in (ALWAYS_FALSE, MYPY_FALSE)
+
+
 def infer_condition_value(expr: Expression, options: Options) -> int:
     """Infer whether the given condition is always true/false.
 
diff --git a/mypy/semanal_pass1.py b/mypy/semanal_pass1.py
--- a/mypy/semanal_pass1.py
+++ b/mypy/semanal_pass1.py
@@ -24,10 +24,12 @@
     MypyFile, SymbolTable, SymbolTableNode, Var, Block, AssignmentStmt, FuncDef, Decorator,
     ClassDef, TypeInfo, ImportFrom, Import, ImportAll, IfStmt, WhileStmt, ForStmt, WithStmt,
     TryStmt, OverloadedFuncDef, Lvalue, Context, ImportedName, LDEF, GDEF, MDEF, UNBOUND_IMPORTED,
-    MODULE_REF, implicit_module_attrs
+    MODULE_REF, implicit_module_attrs, AssertStmt,
 )
 from mypy.types import Type, UnboundType, UnionType, AnyType, TypeOfAny, NoneTyp, CallableType
-from mypy.semanal import SemanticAnalyzerPass2, infer_reachability_of_if_statement
+from mypy.semanal import (
+    SemanticAnalyzerPass2, infer_reachability_of_if_statement, assert_will_always_fail,
+)
 from mypy.semanal_shared import create_indirect_imported_name
 from mypy.options import Options
 from mypy.sametypes import is_same_type
@@ -92,8 +94,14 @@ def visit_file(self, file: MypyFile, fnam: str, mod_id: str, options: Options) -
                 v._fullname = self.sem.qualified_name(name)
                 self.sem.globals[name] = SymbolTableNode(GDEF, v)
 
-            for d in defs:
+            for i, d in enumerate(defs):
                 d.accept(self)
+                if isinstance(d, AssertStmt) and assert_will_always_fail(d, options):
+                    # We've encountered an assert that's always false,
+                    # e.g. assert sys.platform == 'lol'.  Truncate the
+                    # list of statements.  This mutates file.defs too.
+                    del defs[i + 1:]
+                    break
 
             # Add implicit definition of literals/keywords to builtins, as we
             # cannot define a variable with them explicitly.


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
@@ -623,3 +623,59 @@ class Child(Parent):
         reveal_type(self)       # E: Revealed type is '__main__.Child'
         return 3
 [builtins fixtures/isinstance.pyi]
+
+[case testUnreachableAfterToplevelAssert]
+import sys
+reveal_type(0)  # E: Revealed type is 'builtins.int'
+assert sys.platform == 'lol'
+reveal_type('')  # No error here :-)
+[builtins fixtures/ops.pyi]
+
+[case testUnreachableAfterToplevelAssert2]
+import sys
+reveal_type(0)  # E: Revealed type is 'builtins.int'
+assert sys.version_info[0] == 1
+reveal_type('')  # No error here :-)
+[builtins fixtures/ops.pyi]
+
+[case testUnreachableAfterToplevelAssert3]
+reveal_type(0)  # E: Revealed type is 'builtins.int'
+MYPY = False
+assert not MYPY
+reveal_type('')  # No error here :-)
+[builtins fixtures/ops.pyi]
+
+[case testUnreachableAfterToplevelAssert4]
+# flags: --always-false NOPE
+reveal_type(0)  # E: Revealed type is 'builtins.int'
+NOPE = False
+assert NOPE
+reveal_type('')  # No error here :-)
+[builtins fixtures/ops.pyi]
+
+[case testUnreachableAfterToplevelAssertImport]
+import foo
+foo.bar()  # E: "object" has no attribute "bar"
+[file foo.py]
+import sys
+assert sys.platform == 'lol'
+def bar() -> None: pass
+[builtins fixtures/ops.pyi]
+
+[case testUnreachableAfterToplevelAssertImport2]
+# flags: --platform lol
+import foo
+foo.bar()  # No error :-)
+[file foo.py]
+import sys
+assert sys.platform == 'lol'
+def bar() -> None: pass
+[builtins fixtures/ops.pyi]
+
+[case testUnreachableAfterToplevelAssertNotInsideIf]
+import sys
+if sys.version_info[0] >= 2:
+    assert sys.platform == 'lol'
+    reveal_type('')  # E: Revealed type is 'builtins.str'
+reveal_type('')  # E: Revealed type is 'builtins.str'
+[builtins fixtures/ops.pyi]