Repository: quora/pyanalyze
Pull request #641 (2023-05-30 00:42:02)

Support sys.platform and sys.version_info checks


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 8bdd21e..dd35d32 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,8 @@
 
 ## Unreleased
 
+- Add support for suppressing errors in blocks based on
+  `sys.platform` and `sys.version_info` checks (#641)
 - Fix compatibility between stub-only callable classes
   and the bare `Callable` annotation (#640)
 - Add new error code `missing_generic_parameters` (off by
diff --git a/pyanalyze/name_check_visitor.py b/pyanalyze/name_check_visitor.py
index 99a72d7..750032e 100644
--- a/pyanalyze/name_check_visitor.py
+++ b/pyanalyze/name_check_visitor.py
@@ -152,7 +152,10 @@ from .suggested_type import (
 )
 from .type_object import get_mro, TypeObject
 from .value import (
+    SYS_PLATFORM_EXTENSION,
+    SYS_VERSION_INFO_EXTENSION,
     AlwaysPresentExtension,
+    DefiniteValueExtension,
     DeprecatedExtension,
     SkipDeprecatedExtension,
     annotate_value,
@@ -201,29 +204,11 @@ from .value import (
 )
 from .yield_checker import YieldChecker
 
-try:
-    from ast import NamedExpr
-except ImportError:
-    NamedExpr = Any  # 3.7 and lower
-
-try:
-    from ast import Match
-except ImportError:
-    # 3.9 and lower
-    Match = Any
-
-try:
-    from ast import TryStar
-    from builtins import BaseExceptionGroup, ExceptionGroup
-except ImportError:
-    # 3.10 and lower
-    TryStar = Any
-
-    class BaseExceptionGroup:
-        pass
 
-    class ExceptionGroup:
-        pass
+if sys.version_info >= (3, 11):
+    TryNode = ast.Try | ast.TryStar
+else:
+    TryNode = ast.Try
 
 
 try:
@@ -321,7 +306,6 @@ COMPARATOR_TO_OPERATOR = {
 
 SAFE_DECORATORS_FOR_ARGSPEC_TO_RETVAL = [KnownValue(asynq.asynq), KnownValue(property)]
 if sys.version_info < (3, 11):
-    # static analysis: ignore[undefined_attribute]
     SAFE_DECORATORS_FOR_ARGSPEC_TO_RETVAL.append(KnownValue(asyncio.coroutine))
 
 
@@ -2189,9 +2173,8 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
             # On 3.6 and 3.7, InitVar[type] just returns InitVar
             return
         # On 3.8, ast.Index has no lineno
-        if not hasattr(node, "lineno"):
+        if sys.version_info < (3, 9) and not hasattr(node, "lineno"):
             if isinstance(node, ast.Index):
-                # static analysis: ignore[undefined_attribute]
                 node = node.value
             else:
                 # Slice or ExtSlice, shouldn't happen
@@ -3096,6 +3079,7 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
         out_constraints = []
         values = []
         constraint = NULL_CONSTRAINT
+        definite_value = None
         with stack:
             for i, condition in enumerate(node.values):
                 is_last = i == len(node.values) - 1
@@ -3109,6 +3093,13 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
                 new_value, constraint = self.constraint_from_condition(
                     condition, check_boolability=not is_last
                 )
+                new_def_val = _extract_definite_value(new_value)
+                if is_and and new_def_val is False:
+                    definite_value = False
+                    stack.enter_context(self.catch_errors())
+                elif not is_and and new_def_val is True:
+                    definite_value = True
+                    stack.enter_context(self.catch_errors())
                 out_constraints.append(constraint)
 
                 if is_last:
@@ -3121,7 +3112,10 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
         self.scopes.combine_subscopes(scopes)
         constraint_cls = AndConstraint if is_and else OrConstraint
         constraint = constraint_cls.make(reversed(out_constraints))
-        return annotate_with_constraint(unite_values(*values), constraint)
+        out = unite_values(*values)
+        if definite_value is not None:
+            out = annotate_value(out, [DefiniteValueExtension(definite_value)])
+        return annotate_with_constraint(out, constraint)
 
     def visit_Compare(self, node: ast.Compare) -> Value:
         nodes = [node.left, *node.comparators]
@@ -3153,10 +3147,25 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
     ) -> Value:
         lhs_constraint = extract_constraints(lhs)
         rhs_constraint = extract_constraints(rhs)
-        if isinstance(lhs, AnnotatedValue):
-            lhs = lhs.value
         if isinstance(rhs, AnnotatedValue):
             rhs = rhs.value
+        definite_value = None
+        if isinstance(lhs, AnnotatedValue):
+            if (
+                SYS_PLATFORM_EXTENSION in lhs.metadata
+                and isinstance(rhs, KnownValue)
+                and isinstance(op, (ast.Eq, ast.NotEq))
+            ):
+                op_func, _ = COMPARATOR_TO_OPERATOR[type(op)]
+                definite_value = op_func(sys.platform, rhs.val)
+            elif (
+                SYS_VERSION_INFO_EXTENSION in lhs.metadata
+                and isinstance(rhs, KnownValue)
+                and isinstance(op, (ast.Gt, ast.GtE, ast.Lt, ast.LtE))
+            ):
+                op_func, _ = COMPARATOR_TO_OPERATOR[type(op)]
+                definite_value = op_func(sys.version_info, rhs.val)
+            lhs = lhs.value
         if isinstance(lhs_constraint, PredicateProvider) and isinstance(
             rhs, KnownValue
         ):
@@ -3205,6 +3214,8 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
                 allow_call=False,
             )
 
+        if definite_value is not None:
+            val = annotate_value(val, [DefiniteValueExtension(definite_value)])
         return annotate_with_constraint(val, constraint)
 
     def _constraint_from_compare_op(
@@ -3278,6 +3289,7 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
         if isinstance(node.op, ast.Not):
             # not doesn't have its own special method
             val, constraint = self.constraint_from_condition(node.operand)
+            definite_value = _extract_definite_value(val)
             boolability = get_boolability(val)
             if boolability.is_safely_true():
                 val = KnownValue(False)
@@ -3285,6 +3297,8 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
                 val = KnownValue(True)
             else:
                 val = TypedValue(bool)
+            if definite_value is not None:
+                val = annotate_value(val, [DefiniteValueExtension(not definite_value)])
             return annotate_with_constraint(val, constraint.invert())
         else:
             operand = self.composite_from_node(node.operand)
@@ -3508,16 +3522,15 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
         else:
             return TypedValue(slice)
 
-    # These two are unused in 3.8 and higher, and the typeshed stubs reflect
-    # that their .dims and .value attributes don't exist.
-    def visit_ExtSlice(self, node: ast.ExtSlice) -> Value:
-        # static analysis: ignore[undefined_attribute]
-        dims = [self.visit(dim) for dim in node.dims]
-        return self._maybe_make_sequence(tuple, dims, node)
+    # These two are unused in 3.9 and higher
+    if sys.version_info < (3, 9):
+
+        def visit_ExtSlice(self, node: ast.ExtSlice) -> Value:
+            dims = [self.visit(dim) for dim in node.dims]
+            return self._maybe_make_sequence(tuple, dims, node)
 
-    def visit_Index(self, node: ast.Index) -> Value:
-        # static analysis: ignore[undefined_attribute]
-        return self.visit(node.value)
+        def visit_Index(self, node: ast.Index) -> Value:
+            return self.visit(node.value)
 
     # Control flow
 
@@ -4008,9 +4021,7 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
                 self.visit(node.optional_vars)
         return can_suppress
 
-    def visit_try_except(
-        self, node: Union[ast.Try, TryStar], *, is_try_star: bool = False
-    ) -> None:
+    def visit_try_except(self, node: TryNode, *, is_try_star: bool = False) -> None:
         with self.scopes.subscope():
             with self.scopes.subscope() as dummy_scope:
                 pass
@@ -4043,9 +4054,7 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
 
         self.scopes.combine_subscopes([else_scope, *except_scopes])
 
-    def visit_Try(
-        self, node: Union[ast.Try, TryStar], *, is_try_star: bool = False
-    ) -> None:
+    def visit_Try(self, node: TryNode, *, is_try_star: bool = False) -> None:
         if node.finalbody:
             with self.scopes.subscope() as failure_scope:
                 with self.scopes.suppressing_subscope() as success_scope:
@@ -4064,8 +4073,10 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
             self.visit_try_except(node, is_try_star=is_try_star)
         self.yield_checker.reset_yield_checks()
 
-    def visit_TryStar(self, node: TryStar) -> None:
-        self.visit_Try(node, is_try_star=True)
+    if sys.version_info >= (3, 11):
+
+        def visit_TryStar(self, node: ast.TryStar) -> None:
+            self.visit_Try(node, is_try_star=True)
 
     def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
         if node.type is not None:
@@ -4076,7 +4087,7 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
             )
             if node.name is not None:
                 to_assign = unite_values(*[typ for _, typ in possible_types])
-                if is_try_star:
+                if is_try_star and sys.version_info >= (3, 11):
                     if all(is_exception for is_exception, _ in possible_types):
                         base = ExceptionGroup
                     else:
@@ -4113,7 +4124,11 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
                 if isinstance(subval.val, type) and issubclass(
                     subval.val, BaseException
                 ):
-                    if is_try_star and issubclass(subval.val, BaseExceptionGroup):
+                    if (
+                        is_try_star
+                        and sys.version_info >= (3, 11)
+                        and issubclass(subval.val, BaseExceptionGroup)
+                    ):
                         self._show_error_if_checking(
                             node,
                             (
@@ -4138,32 +4153,43 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
         return possible_types
 
     def visit_If(self, node: ast.If) -> None:
-        _, constraint = self.constraint_from_condition(node.test)
+        val, constraint = self.constraint_from_condition(node.test)
+        definite_value = _extract_definite_value(val)
         # reset yield checks to avoid incorrect errors when we yield in both the condition and one
         # of the blocks
         self.yield_checker.reset_yield_checks()
-        with self.scopes.subscope() as body_scope:
+        with self._subscope_and_maybe_supress(definite_value is False) as body_scope:
             self.add_constraint(node, constraint)
             self._generic_visit_list(node.body)
         self.yield_checker.reset_yield_checks()
 
-        with self.scopes.subscope() as else_scope:
+        with self._subscope_and_maybe_supress(definite_value is True) as else_scope:
             self.add_constraint(node, constraint.invert())
             self._generic_visit_list(node.orelse)
         self.scopes.combine_subscopes([body_scope, else_scope])
         self.yield_checker.reset_yield_checks()
 
     def visit_IfExp(self, node: ast.IfExp) -> Value:
-        _, constraint = self.constraint_from_condition(node.test)
-        with self.scopes.subscope() as if_scope:
+        val, constraint = self.constraint_from_condition(node.test)
+        definite_value = _extract_definite_value(val)
+        with self._subscope_and_maybe_supress(definite_value is False) as if_scope:
             self.add_constraint(node, constraint)
             then_val = self.visit(node.body)
-        with self.scopes.subscope() as else_scope:
+        with self._subscope_and_maybe_supress(definite_value is True) as else_scope:
             self.add_constraint(node, constraint.invert())
             else_val = self.visit(node.orelse)
         self.scopes.combine_subscopes([if_scope, else_scope])
         return unite_values(then_val, else_val)
 
+    @contextlib.contextmanager
+    def _subscope_and_maybe_supress(self, should_suppress: bool) -> Iterator[SubScope]:
+        with self.scopes.subscope() as scope:
+            if should_suppress:
+                with self.catch_errors():
+                    yield scope
+            else:
+                yield scope
+
     def constraint_from_condition(
         self, node: ast.AST, check_boolability: bool = True
     ) -> Tuple[Value, AbstractConstraint]:
@@ -4234,19 +4260,21 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
 
     # Assignments
 
-    def visit_NamedExpr(self, node: NamedExpr) -> Value:
-        composite = self.composite_from_walrus(node)
-        return composite.value
+    if sys.version_info >= (3, 8):
 
-    def composite_from_walrus(self, node: NamedExpr) -> Composite:
-        rhs = self.visit(node.value)
-        with qcore.override(self, "being_assigned", rhs):
-            if self.in_comprehension_body:
-                ctx = self.scopes.ignore_topmost_scope()
-            else:
-                ctx = qcore.empty_context
-            with ctx:
-                return self.composite_from_node(node.target)
+        def visit_NamedExpr(self, node: ast.NamedExpr) -> Value:
+            composite = self.composite_from_walrus(node)
+            return composite.value
+
+        def composite_from_walrus(self, node: ast.NamedExpr) -> Composite:
+            rhs = self.visit(node.value)
+            with qcore.override(self, "being_assigned", rhs):
+                if self.in_comprehension_body:
+                    ctx = self.scopes.ignore_topmost_scope()
+                else:
+                    ctx = qcore.empty_context
+                with ctx:
+                    return self.composite_from_node(node.target)
 
     def visit_Assign(self, node: ast.Assign) -> None:
         is_yield = isinstance(node.value, ast.Yield)
@@ -4492,8 +4520,6 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
                 # type.__getitem__ nor type.__class_getitem__ exists at runtime. Support
                 # it directly instead.
                 if isinstance(index, KnownValue):
-                    # self-check throws an error in 3.8 and lower
-                    # static analysis: ignore[unsupported_operation]
                     return_value = KnownValue(type[index.val])
                 else:
                     return_value = AnyValue(AnySource.inference)
@@ -4751,6 +4777,11 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
                 local_value = self._get_composite(composite.get_varname(), node, value)
                 if local_value is not UNINITIALIZED_VALUE:
                     value = local_value
+            if root_composite.value == KnownValue(sys):
+                if node.attr == "platform":
+                    value = annotate_value(value, [SYS_PLATFORM_EXTENSION])
+                elif node.attr == "version_info":
+                    value = annotate_value(value, [SYS_VERSION_INFO_EXTENSION])
             return Composite(value, composite, node)
         else:
             self.show_error(node, "Unknown context", ErrorCode.unexpected_node)
@@ -4903,15 +4934,12 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
             composite = self.composite_from_name(node)
         elif isinstance(node, ast.Subscript):
             composite = self.composite_from_subscript(node)
-        elif isinstance(node, ast.Index):
-            # static analysis: ignore[undefined_attribute]
+        elif sys.version_info < (3, 9) and isinstance(node, ast.Index):
             composite = self.composite_from_node(node.value)
         elif isinstance(node, (ast.ExtSlice, ast.Slice)):
             # These don't have a .lineno attribute, which would otherwise cause trouble.
             composite = Composite(self.visit(node), None, None)
-        # We need better support for version-straddling code
-        # static analysis: ignore[value_always_true]
-        elif hasattr(ast, "NamedExpr") and isinstance(node, ast.NamedExpr):
+        elif sys.version_info >= (3, 8) and isinstance(node, ast.NamedExpr):
             composite = self.composite_from_walrus(node)
         else:
             composite = Composite(self.visit(node), None, node)
@@ -5172,44 +5200,48 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
 
     # Match statements
 
-    def visit_Match(self, node: Match) -> None:
-        subject = self.composite_from_node(node.subject)
-        patma_visitor = PatmaVisitor(self)
-        with qcore.override(self, "match_subject", subject):
-            constraints_to_apply = []
-            subscopes = []
-            for case in node.cases:
-                with self.scopes.subscope() as case_scope:
-                    for constraint in constraints_to_apply:
-                        self.add_constraint(case, constraint)
-                    self.match_subject = self.match_subject._replace(
-                        value=constrain_value(
-                            self.match_subject.value,
-                            AndConstraint.make(constraints_to_apply),
+    if sys.version_info >= (3, 10):
+
+        def visit_Match(self, node: ast.Match) -> None:
+            subject = self.composite_from_node(node.subject)
+            patma_visitor = PatmaVisitor(self)
+            with qcore.override(self, "match_subject", subject):
+                constraints_to_apply = []
+                subscopes = []
+                for case in node.cases:
+                    with self.scopes.subscope() as case_scope:
+                        for constraint in constraints_to_apply:
+                            self.add_constraint(case, constraint)
+                        self.match_subject = self.match_subject._replace(
+                            value=constrain_value(
+                                self.match_subject.value,
+                                AndConstraint.make(constraints_to_apply),
+                            )
                         )
-                    )
 
-                    pattern_constraint = patma_visitor.visit(case.pattern)
-                    constraints = [pattern_constraint]
-                    self.add_constraint(case.pattern, pattern_constraint)
-                    if case.guard is not None:
-                        _, guard_constraint = self.constraint_from_condition(case.guard)
-                        self.add_constraint(case.guard, guard_constraint)
-                        constraints.append(guard_constraint)
+                        pattern_constraint = patma_visitor.visit(case.pattern)
+                        constraints = [pattern_constraint]
+                        self.add_constraint(case.pattern, pattern_constraint)
+                        if case.guard is not None:
+                            _, guard_constraint = self.constraint_from_condition(
+                                case.guard
+                            )
+                            self.add_constraint(case.guard, guard_constraint)
+                            constraints.append(guard_constraint)
 
-                    constraints_to_apply.append(
-                        AndConstraint.make(constraints).invert()
-                    )
-                    self._generic_visit_list(case.body)
-                    subscopes.append(case_scope)
+                        constraints_to_apply.append(
+                            AndConstraint.make(constraints).invert()
+                        )
+                        self._generic_visit_list(case.body)
+                        subscopes.append(case_scope)
 
-                self.yield_checker.reset_yield_checks()
+                    self.yield_checker.reset_yield_checks()
 
-            with self.scopes.subscope() as else_scope:
-                for constraint in constraints_to_apply:
-                    self.add_constraint(node, constraint)
-                subscopes.append(else_scope)
-            self.scopes.combine_subscopes(subscopes)
+                with self.scopes.subscope() as else_scope:
+                    for constraint in constraints_to_apply:
+                        self.add_constraint(node, constraint)
+                    subscopes.append(else_scope)
+                self.scopes.combine_subscopes(subscopes)
 
     # Attribute checking
 
@@ -5539,3 +5571,11 @@ def _has_annotation_for_attr(typ: type, attr: str) -> bool:
 
 def _is_asynq_future(value: Value) -> bool:
     return value.is_type(asynq.FutureBase) or value.is_type(asynq.AsyncTask)
+
+
+def _extract_definite_value(val: Value) -> Optional[bool]:
+    if isinstance(val, AnnotatedValue):
+        dv_exts = val.get_metadata_of_type(DefiniteValueExtension)
+        for dv_ext in dv_exts:
+            return dv_ext.value
+    return None
diff --git a/pyanalyze/value.py b/pyanalyze/value.py
index 6228496..52c0ce7 100644
--- a/pyanalyze/value.py
+++ b/pyanalyze/value.py
@@ -1947,6 +1947,30 @@ class DeprecatedExtension(Extension):
     deprecation_message: str
 
 
+@dataclass(frozen=True)
+class SysPlatformExtension(Extension):
+    """Used for sys.platform."""
+
+
+SYS_PLATFORM_EXTENSION = SysPlatformExtension()
+
+
+@dataclass(frozen=True)
+class SysVersionInfoExtension(Extension):
+    """Used for sys.version_info."""
+
+
+SYS_VERSION_INFO_EXTENSION = SysVersionInfoExtension()
+
+
+@dataclass(frozen=True)
+class DefiniteValueExtension(Extension):
+    """Used if a comparison has a definite value that should be used
+    to skip type checking."""
+
+    value: bool
+
+
 @dataclass(frozen=True)
 class AnnotatedValue(Value):
     """Value representing a `PEP 593 <https://www.python.org/dev/peps/pep-0593/>`_ Annotated object.


Test patch:
diff --git a/pyanalyze/test_definite_value.py b/pyanalyze/test_definite_value.py
new file mode 100644
index 0000000..7656205
--- /dev/null
+++ b/pyanalyze/test_definite_value.py
@@ -0,0 +1,35 @@
+# static analysis: ignore
+from .test_name_check_visitor import TestNameCheckVisitorBase
+from .test_node_visitor import assert_passes
+
+
+class TestSysPlatform(TestNameCheckVisitorBase):
+    @assert_passes()
+    def test(self):
+        import os
+        import sys
+        from typing_extensions import assert_type
+
+        def capybara() -> None:
+            if sys.platform == "win32":
+                assert_type(os.P_DETACH, int)
+            else:
+                os.P_DETACH  # E: undefined_attribute
+
+
+class TestSysVersion(TestNameCheckVisitorBase):
+    @assert_passes()
+    def test(self):
+        import ast
+        import sys
+        from typing_extensions import assert_type
+
+        if sys.version_info >= (3, 10):
+
+            def capybara(m: ast.Match) -> None:
+                assert_type(m, ast.Match)
+
+        if sys.version_info >= (3, 12):
+
+            def pacarana(m: ast.TypeVar) -> None:
+                assert_type(m, ast.TypeVar)