Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions mypy/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1664,6 +1664,7 @@ class ClassDef(Statement):
"has_incompatible_baseclass",
"docstring",
"removed_statements",
"is_runtime_unreachable",
)

__match_args__ = ("name", "defs")
Expand Down Expand Up @@ -1714,6 +1715,8 @@ def __init__(
self.has_incompatible_baseclass = False
self.docstring: str | None = None
self.removed_statements = []
# Set for definitions in an if branch known not to execute at runtime.
self.is_runtime_unreachable = False

@property
def fullname(self) -> str:
Expand Down Expand Up @@ -1862,6 +1865,7 @@ class AssignmentStmt(Statement):
"is_alias_def",
"is_final_def",
"invalid_recursive_alias",
"is_runtime_unreachable",
)

__match_args__ = ("lvalues", "rvalues", "type")
Expand Down Expand Up @@ -1904,6 +1908,8 @@ def __init__(
self.is_alias_def = False
self.is_final_def = False
self.invalid_recursive_alias = False
# Set for definitions in an if branch known not to execute at runtime.
self.is_runtime_unreachable = False

def accept(self, visitor: StatementVisitor[T]) -> T:
return visitor.visit_assignment_stmt(self)
Expand Down
106 changes: 96 additions & 10 deletions mypy/reachability.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
from mypy.nodes import (
LITERAL_YES,
AssertStmt,
AssignmentStmt,
Block,
CallExpr,
ClassDef,
ComparisonExpr,
Expression,
FuncDef,
Expand All @@ -23,6 +25,7 @@
MemberExpr,
NameExpr,
OpExpr,
RefExpr,
SliceExpr,
StrExpr,
TupleExpr,
Expand Down Expand Up @@ -168,6 +171,63 @@ def infer_condition_value(expr: Expression, options: Options) -> int:
return result


def infer_runtime_condition_value(expr: Expression, options: Options) -> int:
"""Infer whether a condition is definitely true or false at runtime.

Unlike ``infer_condition_value()``, this intentionally doesn't use the
``MYPY_TRUE`` and ``MYPY_FALSE`` states. It only returns a definite result
when the expression's runtime value follows from the configured target.
"""
if isinstance(expr, UnaryExpr) and expr.op == "not":
positive = infer_runtime_condition_value(expr.expr, options)
return {
ALWAYS_TRUE: ALWAYS_FALSE,
ALWAYS_FALSE: ALWAYS_TRUE,
TRUTH_VALUE_UNKNOWN: TRUTH_VALUE_UNKNOWN,
}[positive]

if isinstance(expr, OpExpr):
if expr.op not in ("or", "and"):
return TRUTH_VALUE_UNKNOWN
left = infer_runtime_condition_value(expr.left, options)
right = infer_runtime_condition_value(expr.right, options)
if expr.op == "or":
if ALWAYS_TRUE in (left, right):
return ALWAYS_TRUE
if left == right == ALWAYS_FALSE:
return ALWAYS_FALSE
else:
if ALWAYS_FALSE in (left, right):
return ALWAYS_FALSE
if left == right == ALWAYS_TRUE:
return ALWAYS_TRUE
return TRUTH_VALUE_UNKNOWN

if isinstance(expr, RefExpr) and expr.fullname in (
"typing.TYPE_CHECKING",
"typing_extensions.TYPE_CHECKING",
):
return ALWAYS_FALSE

if isinstance(expr, (NameExpr, MemberExpr)):
if expr.name == "MYPY":
return ALWAYS_FALSE
if expr.name == "PY2":
return ALWAYS_FALSE
if expr.name == "PY3":
return ALWAYS_TRUE
if expr.name in options.always_true:
return ALWAYS_TRUE
if expr.name in options.always_false:
return ALWAYS_FALSE
return TRUTH_VALUE_UNKNOWN

result = consider_sys_version_info(expr, options.python_version, use_fullname=True)
if result == TRUTH_VALUE_UNKNOWN:
result = consider_sys_platform(expr, options.platform, use_fullname=True)
return result


def infer_pattern_value(pattern: Pattern) -> int:
if isinstance(pattern, AsPattern) and pattern.pattern is None:
return ALWAYS_TRUE
Expand All @@ -179,7 +239,9 @@ def infer_pattern_value(pattern: Pattern) -> int:
return TRUTH_VALUE_UNKNOWN


def consider_sys_version_info(expr: Expression, pyversion: tuple[int, ...]) -> int:
def consider_sys_version_info(
expr: Expression, pyversion: tuple[int, ...], *, use_fullname: bool = False
) -> int:
"""Consider whether expr is a comparison involving sys.version_info.

Return ALWAYS_TRUE, ALWAYS_FALSE, or TRUTH_VALUE_UNKNOWN.
Expand All @@ -198,10 +260,10 @@ def consider_sys_version_info(expr: Expression, pyversion: tuple[int, ...]) -> i
if op not in ("==", "!=", "<=", ">=", "<", ">"):
return TRUTH_VALUE_UNKNOWN

index = contains_sys_version_info(expr.operands[0])
index = contains_sys_version_info(expr.operands[0], use_fullname=use_fullname)
thing = contains_int_or_tuple_of_ints(expr.operands[1])
if index is None or thing is None:
index = contains_sys_version_info(expr.operands[1])
index = contains_sys_version_info(expr.operands[1], use_fullname=use_fullname)
thing = contains_int_or_tuple_of_ints(expr.operands[0])
op = reverse_op[op]
if isinstance(index, int) and isinstance(thing, int):
Expand All @@ -223,7 +285,7 @@ def consider_sys_version_info(expr: Expression, pyversion: tuple[int, ...]) -> i
return TRUTH_VALUE_UNKNOWN


def consider_sys_platform(expr: Expression, platform: str) -> int:
def consider_sys_platform(expr: Expression, platform: str, *, use_fullname: bool = False) -> int:
"""Consider whether expr is a comparison involving sys.platform.

Return ALWAYS_TRUE, ALWAYS_FALSE, or TRUTH_VALUE_UNKNOWN.
Expand All @@ -239,7 +301,7 @@ def consider_sys_platform(expr: Expression, platform: str) -> int:
op = expr.operators[0]
if op not in ("==", "!="):
return TRUTH_VALUE_UNKNOWN
if not is_sys_attr(expr.operands[0], "platform"):
if not is_sys_attr(expr.operands[0], "platform", use_fullname=use_fullname):
return TRUTH_VALUE_UNKNOWN
right = expr.operands[1]
if not isinstance(right, StrExpr):
Expand All @@ -250,7 +312,7 @@ def consider_sys_platform(expr: Expression, platform: str) -> int:
return TRUTH_VALUE_UNKNOWN
if len(expr.args) != 1 or not isinstance(expr.args[0], StrExpr):
return TRUTH_VALUE_UNKNOWN
if not is_sys_attr(expr.callee.expr, "platform"):
if not is_sys_attr(expr.callee.expr, "platform", use_fullname=use_fullname):
return TRUTH_VALUE_UNKNOWN
if expr.callee.name != "startswith":
return TRUTH_VALUE_UNKNOWN
Expand Down Expand Up @@ -296,10 +358,14 @@ def contains_int_or_tuple_of_ints(expr: Expression) -> None | int | tuple[int, .
return None


def contains_sys_version_info(expr: Expression) -> None | int | tuple[int | None, int | None]:
if is_sys_attr(expr, "version_info"):
def contains_sys_version_info(
expr: Expression, *, use_fullname: bool = False
) -> None | int | tuple[int | None, int | None]:
if is_sys_attr(expr, "version_info", use_fullname=use_fullname):
return (None, None) # Same as sys.version_info[:]
if isinstance(expr, IndexExpr) and is_sys_attr(expr.base, "version_info"):
if isinstance(expr, IndexExpr) and is_sys_attr(
expr.base, "version_info", use_fullname=use_fullname
):
index = expr.index
if isinstance(index, IntExpr):
return index.value
Expand All @@ -320,11 +386,13 @@ def contains_sys_version_info(expr: Expression) -> None | int | tuple[int | None
return None


def is_sys_attr(expr: Expression, name: str) -> bool:
def is_sys_attr(expr: Expression, name: str, *, use_fullname: bool = False) -> bool:
# TODO: This currently doesn't work with code like this:
# - import sys as _sys
# - from sys import version_info
if isinstance(expr, MemberExpr) and expr.name == name:
if use_fullname:
return expr.fullname == f"sys.{name}"
if isinstance(expr.expr, NameExpr) and expr.expr.name == "sys":
# TODO: Guard against a local named sys, etc.
# (Though later passes will still do most checking.)
Expand Down Expand Up @@ -354,6 +422,10 @@ def mark_block_mypy_only(block: Block) -> None:
block.accept(MarkImportsMypyOnlyVisitor())


def mark_block_runtime_unreachable(block: Block) -> None:
block.accept(MarkRuntimeUnreachableVisitor())


class MarkImportsMypyOnlyVisitor(TraverserVisitor):
"""Visitor that sets is_mypy_only (which affects priority)."""

Expand All @@ -368,3 +440,17 @@ def visit_import_all(self, node: ImportAll) -> None:

def visit_func_def(self, node: FuncDef) -> None:
node.is_mypy_only = True


class MarkRuntimeUnreachableVisitor(TraverserVisitor):
"""Mark definitions contained in code that cannot run at runtime."""

def visit_func_def(self, node: FuncDef) -> None:
super().visit_func_def(node)

def visit_class_def(self, node: ClassDef) -> None:
node.is_runtime_unreachable = True
super().visit_class_def(node)

def visit_assignment_stmt(self, node: AssignmentStmt) -> None:
node.is_runtime_unreachable = True
18 changes: 17 additions & 1 deletion mypy/semanal.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,12 @@
ALWAYS_TRUE,
MYPY_FALSE,
MYPY_TRUE,
TRUTH_VALUE_UNKNOWN,
infer_condition_value,
infer_reachability_of_if_statement,
infer_reachability_of_match_statement,
infer_runtime_condition_value,
mark_block_runtime_unreachable,
)
from mypy.scope import Scope
from mypy.semanal_enum import EnumCallAnalyzer
Expand Down Expand Up @@ -3676,7 +3679,7 @@ def analyze_typeddict_assign(self, s: AssignmentStmt) -> bool:
namespace = self.qualified_name(name)
with self.tvar_scope_frame(self.tvar_scope.class_frame(namespace)):
is_typed_dict, info, tvar_defs = self.typed_dict_analyzer.check_typeddict(
s.rvalue, name
s.rvalue, name, s.is_runtime_unreachable
)
if not is_typed_dict:
return False
Expand Down Expand Up @@ -5607,9 +5610,22 @@ def visit_continue_stmt(self, s: ContinueStmt) -> None:
def visit_if_stmt(self, s: IfStmt) -> None:
self.statement = s
infer_reachability_of_if_statement(s, self.options)
remaining = ALWAYS_TRUE
for i in range(len(s.expr)):
s.expr[i].accept(self)
condition = infer_runtime_condition_value(s.expr[i], self.options)
if remaining == ALWAYS_FALSE or condition == ALWAYS_FALSE:
mark_block_runtime_unreachable(s.body[i])

if remaining == ALWAYS_FALSE or condition == ALWAYS_TRUE:
remaining = ALWAYS_FALSE
elif remaining == ALWAYS_TRUE and condition == ALWAYS_FALSE:
remaining = ALWAYS_TRUE
else:
remaining = TRUTH_VALUE_UNKNOWN
self.visit_block(s.body[i])
if s.else_body and remaining == ALWAYS_FALSE:
mark_block_runtime_unreachable(s.else_body)
self.visit_block_maybe(s.else_body)

def visit_try_stmt(self, s: TryStmt) -> None:
Expand Down
41 changes: 36 additions & 5 deletions mypy/semanal_typeddict.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from mypy.semanal_shared import (
SemanticAnalyzerInterface,
has_placeholder,
parse_bool,
require_bool_literal_argument,
)
from mypy.state import state
Expand Down Expand Up @@ -122,6 +123,19 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N
and isinstance(defn.base_type_exprs[0], RefExpr)
and defn.base_type_exprs[0].fullname in TPDICT_NAMES
):
if (
is_closed is not None
and parse_bool(defn.keywords["closed"]) is not None
and defn.base_type_exprs[0].fullname == "typing.TypedDict"
and self.options.python_version < (3, 15)
and not self.api.is_stub_file
and not defn.is_runtime_unreachable
):
self.fail(
'"closed" argument to TypedDict is only available in Python 3.15 and later',
defn.keywords["closed"],
)
is_closed = None
# Building a new TypedDict
field_sources, statements = self.analyze_typeddict_classdef_fields(defn)
if field_sources is None:
Expand All @@ -148,7 +162,9 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N
typeddict_bases: list[Expression] = []
typeddict_bases_set = set()
for i, expr in enumerate(defn.base_type_exprs):
ok, maybe_type_info, _ = self.check_typeddict(expr, inline_base(defn.name, i))
ok, maybe_type_info, _ = self.check_typeddict(
expr, inline_base(defn.name, i), defn.is_runtime_unreachable
)
if ok and maybe_type_info is not None:
# expr is a CallExpr
info = maybe_type_info
Expand Down Expand Up @@ -576,7 +592,7 @@ def extract_meta_info(
return typ, is_required, readonly

def check_typeddict(
self, node: Expression, name: str
self, node: Expression, name: str, is_runtime_unreachable: bool = False
) -> tuple[bool, TypeInfo | None, list[TypeVarLikeType]]:
"""Check if a call defines a TypedDict.
Expand All @@ -599,7 +615,7 @@ def check_typeddict(
fullname = callee.fullname
if fullname not in TPDICT_NAMES:
return False, None, []
res = self.parse_typeddict_args(call)
res = self.parse_typeddict_args(call, fullname, is_runtime_unreachable)
if res is None:
# This is a valid typed dict, but some type is not ready.
# The caller should defer this until next iteration.
Expand Down Expand Up @@ -662,7 +678,7 @@ def check_typeddict(
return True, info, tvar_defs

def parse_typeddict_args(
self, call: CallExpr
self, call: CallExpr, fullname: str, is_runtime_unreachable: bool
) -> tuple[str, list[str], list[Type], bool, bool, list[TypeVarLikeType], bool] | None:
"""Parse typed dict call expression.
Expand Down Expand Up @@ -702,7 +718,22 @@ def parse_typeddict_args(
closed: bool = False
for arg_name, arg in zip(call.arg_names[2:], call.args[2:]):
assert arg_name
value = require_bool_literal_argument(self.api, arg, arg_name)
if arg_name == "closed":
value = require_bool_literal_argument(self.api, arg, "closed", False)
if (
parse_bool(arg) is not None
and fullname == "typing.TypedDict"
and self.options.python_version < (3, 15)
and not self.api.is_stub_file
and not is_runtime_unreachable
):
self.fail(
'"closed" argument to TypedDict is only available in Python 3.15 and later',
arg,
)
return "", [], [], True, False, [], False
else:
value = require_bool_literal_argument(self.api, arg, arg_name)
if value is None:
return "", [], [], True, False, [], False
if arg_name == "closed":
Expand Down
2 changes: 2 additions & 0 deletions mypy/treetransform.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ def visit_class_def(self, node: ClassDef) -> ClassDef:
new.fullname = node.fullname
new.info = node.info
new.decorators = [self.expr(decorator) for decorator in node.decorators]
new.is_runtime_unreachable = node.is_runtime_unreachable
return new

def visit_global_decl(self, node: GlobalDecl) -> GlobalDecl:
Expand Down Expand Up @@ -327,6 +328,7 @@ def duplicate_assignment(self, node: AssignmentStmt) -> AssignmentStmt:
)
new.line = node.line
new.is_final_def = node.is_final_def
new.is_runtime_unreachable = node.is_runtime_unreachable
new.type = self.optional_type(node.type)
return new

Expand Down
Loading
Loading