diff --git a/docs/changelog.md b/docs/changelog.md index 8bdd21e3..dd35d329 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 99a72d71..750032e3 100644 --- a/pyanalyze/name_check_visitor.py +++ b/pyanalyze/name_check_visitor.py @@ -152,7 +152,10 @@ ) 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 .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 @@ def _not_in(a: object, b: Container[object]) -> bool: 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 @@ def check_for_missing_generic_params(self, node: ast.AST, value: Value) -> None: # 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 @@ def visit_BoolOp(self, node: ast.BoolOp) -> Value: 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 @@ def visit_BoolOp(self, node: ast.BoolOp) -> Value: 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 @@ def visit_BoolOp(self, node: ast.BoolOp) -> Value: 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 @@ def _visit_single_compare( ) -> 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 @@ def _visit_single_compare( 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 @@ def visit_UnaryOp(self, node: ast.UnaryOp) -> Value: 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 @@ def visit_UnaryOp(self, node: ast.UnaryOp) -> Value: 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 @@ def visit_Slice(self, node: ast.Slice) -> Value: 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 @@ def visit_withitem(self, node: ast.withitem, is_async: bool = False) -> bool: 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 @@ def visit_try_except( 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 @@ def visit_Try( 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 @@ def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None: ) 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 @@ def _extract_exception_types( 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 @@ def _extract_exception_types( 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 @@ def visit_Expr(self, node: ast.Expr) -> Value: # 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 @@ def _composite_from_subscript_no_mvv( # 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 @@ def composite_from_attribute(self, node: ast.Attribute) -> Composite: 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 @@ def composite_from_node(self, node: ast.AST) -> Composite: 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 @@ def get_call_attribute(value: Value) -> Value: # 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/test_definite_value.py b/pyanalyze/test_definite_value.py new file mode 100644 index 00000000..76562058 --- /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) diff --git a/pyanalyze/value.py b/pyanalyze/value.py index 62284961..52c0ce71 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 `_ Annotated object.