From c5990a571d4742b27963e1074b2e8557188a5a9d Mon Sep 17 00:00:00 2001 From: Thomas M Kehrenberg Date: Wed, 20 Sep 2023 15:41:59 +0100 Subject: [PATCH 1/3] Add new flag for warning about uninitialized attributes --- docs/source/command_line.rst | 21 +++ docs/source/config_file.rst | 8 + mypy/checkexpr.py | 56 +++++-- mypy/main.py | 7 + mypy/message_registry.py | 1 + mypy/messages.py | 5 +- mypy/nodes.py | 6 + mypy/options.py | 4 + mypy/plugins/attrs.py | 1 + mypy/plugins/dataclasses.py | 3 + mypy/semanal.py | 26 ++- mypy/semanal_classprop.py | 29 +++- mypy/semanal_main.py | 10 +- mypy/semanal_namedtuple.py | 6 + test-data/unit/check-protocols.test | 37 +++++ test-data/unit/check-warnings.test | 235 ++++++++++++++++++++++++++++ 16 files changed, 427 insertions(+), 28 deletions(-) diff --git a/docs/source/command_line.rst b/docs/source/command_line.rst index 4e954c7c2ccb..ad7e8795a23a 100644 --- a/docs/source/command_line.rst +++ b/docs/source/command_line.rst @@ -531,6 +531,27 @@ potentially problematic or redundant in some way. This limitation will be removed in future releases of mypy. +.. option:: --warn-uninitialized-attributes + + This flag will make mypy report an error whenever it encounters a class + being instantiated which has attributes that were declared but never + initialized. + + For example, in the following code, mypy will warn that ``value`` was not + initialized. + + .. code-block:: python + + class Foo: + value: int + def __init__(self) -> None: + self.vaelu = 3 # typo in variable name + foo = Foo() # Error: 'value' is uninitialized + + .. note:: + + Mypy cannot properly detect initialization in very dynamic code like + classes with a custom ``__new__`` method. .. _miscellaneous-strictness-flags: diff --git a/docs/source/config_file.rst b/docs/source/config_file.rst index b5ce23ff11ec..1925721d6df0 100644 --- a/docs/source/config_file.rst +++ b/docs/source/config_file.rst @@ -633,6 +633,14 @@ section of the command line docs. Shows a warning when encountering any code inferred to be unreachable or redundant after performing type analysis. +.. confval:: warn_uninitialized_attributes + + :type: boolean + :default: False + + Shows a warning when a class is instantiated which has attributes that + were declared but not initialized. + Suppressing errors ****************** diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 7b9b84938930..7c5b7158be2c 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -28,7 +28,7 @@ from mypy.maptype import map_instance_to_supertype from mypy.meet import is_overlapping_types, narrow_declared_type from mypy.message_registry import ErrorMessage -from mypy.messages import MessageBuilder +from mypy.messages import MessageBuilder, format_string_list from mypy.nodes import ( ARG_NAMED, ARG_POS, @@ -1610,35 +1610,61 @@ def check_callable_call( # An Enum() call that failed SemanticAnalyzerPass2.check_enum_call(). return callee.ret_type, callee + typ = callee.type_object() if callee.is_type_obj() else None if ( - callee.is_type_obj() - and callee.type_object().is_protocol + typ is not None + and typ.is_protocol # Exception for Type[...] and not callee.from_type_type ): - self.chk.fail( - message_registry.CANNOT_INSTANTIATE_PROTOCOL.format(callee.type_object().name), - context, - ) + self.chk.fail(message_registry.CANNOT_INSTANTIATE_PROTOCOL.format(typ.name), context) elif ( - callee.is_type_obj() - and callee.type_object().is_abstract + typ is not None + and typ.is_abstract # Exception for Type[...] and not callee.from_type_type and not callee.type_object().fallback_to_any ): - type = callee.type_object() # Determine whether the implicitly abstract attributes are functions with # None-compatible return types. abstract_attributes: dict[str, bool] = {} - for attr_name, abstract_status in type.abstract_attributes: + for attr_name, abstract_status in typ.abstract_attributes: if abstract_status == IMPLICITLY_ABSTRACT: - abstract_attributes[attr_name] = self.can_return_none(type, attr_name) + abstract_attributes[attr_name] = self.can_return_none(typ, attr_name) else: abstract_attributes[attr_name] = False - self.msg.cannot_instantiate_abstract_class( - callee.type_object().name, abstract_attributes, context - ) + self.msg.cannot_instantiate_abstract_class(typ.name, abstract_attributes, context) + elif ( + typ is not None + and typ.uninitialized_vars + # Exception for Type[...] + and not callee.from_type_type + and not callee.type_object().fallback_to_any + ): + # Split the list of variables into instance and class vars. + ivars: list[str] = [] + cvars: list[str] = [] + for uninitialized in typ.uninitialized_vars: + for base in typ.mro: + symnode = base.names.get(uninitialized) + if symnode is None: + continue + node = symnode.node + if isinstance(node, Var): + if node.is_classvar: + cvars.append(uninitialized) + else: + ivars.append(uninitialized) + break + for vars, kind in [(ivars, "instance"), (cvars, "class")]: + if not vars: + continue + self.chk.fail( + message_registry.CLASS_HAS_UNINITIALIZED_VARS.format( + typ.name, kind, format_string_list([f'"{v}"' for v in vars], "attributes") + ), + context, + ) formal_to_actual = map_actuals_to_formals( arg_kinds, diff --git a/mypy/main.py b/mypy/main.py index 3eb8a76a6de3..9382e50d095a 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -785,6 +785,13 @@ def add_invertible_flag( help="Warn about statements or expressions inferred to be unreachable", group=lint_group, ) + add_invertible_flag( + "--warn-uninitialized-attributes", + default=False, + strict_flag=False, + help="Warn about instantiating classes with uninitialized attributes", + group=lint_group, + ) # Note: this group is intentionally added here even though we don't add # --strict to this group near the end. diff --git a/mypy/message_registry.py b/mypy/message_registry.py index 713ec2e3c759..58b2cc0f5a01 100644 --- a/mypy/message_registry.py +++ b/mypy/message_registry.py @@ -161,6 +161,7 @@ def with_additional_msg(self, info: str) -> ErrorMessage: ) NOT_CALLABLE: Final = "{} not callable" TYPE_MUST_BE_USED: Final = "Value of type {} must be used" +CLASS_HAS_UNINITIALIZED_VARS: Final = 'Class "{}" has annotated but unset {} attributes: {}' # Generic GENERIC_INSTANCE_VAR_CLASS_ACCESS: Final = ( diff --git a/mypy/messages.py b/mypy/messages.py index 8bc190b7d66d..19c06d35dbf4 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -2956,17 +2956,18 @@ def strip_quotes(s: str) -> str: return s -def format_string_list(lst: list[str]) -> str: +def format_string_list(lst: list[str], list_objects: str = "methods") -> str: assert lst if len(lst) == 1: return lst[0] elif len(lst) <= 5: return f"{', '.join(lst[:-1])} and {lst[-1]}" else: - return "%s, ... and %s (%i methods suppressed)" % ( + return "%s, ... and %s (%i %s suppressed)" % ( ", ".join(lst[:2]), lst[-1], len(lst) - 3, + list_objects, ) diff --git a/mypy/nodes.py b/mypy/nodes.py index 6556cd910b46..7400f18163bd 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -2827,6 +2827,7 @@ class is generic then it will be a type constructor of higher kind. "is_protocol", "runtime_protocol", "abstract_attributes", + "uninitialized_vars", "deletable_attributes", "slots", "assuming", @@ -2879,6 +2880,8 @@ class is generic then it will be a type constructor of higher kind. # List of names of abstract attributes together with their abstract status. # The abstract status must be one of `NOT_ABSTRACT`, `IS_ABSTRACT`, `IMPLICITLY_ABSTRACT`. abstract_attributes: list[tuple[str, int]] + # List of names of variables (instance vars and class vars) that are not initialized. + uninitialized_vars: list[str] deletable_attributes: list[str] # Used by mypyc only # Does this type have concrete `__slots__` defined? # If class does not have `__slots__` defined then it is `None`, @@ -3033,6 +3036,7 @@ def __init__(self, names: SymbolTable, defn: ClassDef, module_name: str) -> None self.metaclass_type = None self.is_abstract = False self.abstract_attributes = [] + self.uninitialized_vars = [] self.deletable_attributes = [] self.slots = None self.assuming = [] @@ -3257,6 +3261,7 @@ def serialize(self) -> JsonDict: "names": self.names.serialize(self.fullname), "defn": self.defn.serialize(), "abstract_attributes": self.abstract_attributes, + "uninitialized_vars": self.uninitialized_vars, "type_vars": self.type_vars, "has_param_spec_type": self.has_param_spec_type, "bases": [b.serialize() for b in self.bases], @@ -3295,6 +3300,7 @@ def deserialize(cls, data: JsonDict) -> TypeInfo: ti._fullname = data["fullname"] # TODO: Is there a reason to reconstruct ti.subtypes? ti.abstract_attributes = [(attr[0], attr[1]) for attr in data["abstract_attributes"]] + ti.uninitialized_vars = data["uninitialized_vars"] ti.type_vars = data["type_vars"] ti.has_param_spec_type = data["has_param_spec_type"] ti.bases = [mypy.types.Instance.deserialize(b) for b in data["bases"]] diff --git a/mypy/options.py b/mypy/options.py index 007ae0a78aa1..939c2ed76230 100644 --- a/mypy/options.py +++ b/mypy/options.py @@ -53,6 +53,7 @@ class BuildType: "strict_optional", "warn_no_return", "warn_return_any", + "warn_uninitialized_attributes", "warn_unreachable", "warn_unused_ignores", } @@ -175,6 +176,9 @@ def __init__(self) -> None: # Warn about unused '[mypy-]' or '[[tool.mypy.overrides]]' config sections self.warn_unused_configs = False + # Warn about instantiating classes with uninitialized attributes + self.warn_uninitialized_attributes = False + # Files in which to ignore all non-fatal errors self.ignore_errors = False diff --git a/mypy/plugins/attrs.py b/mypy/plugins/attrs.py index 3ddc234a7e4a..f5b38f8583fd 100644 --- a/mypy/plugins/attrs.py +++ b/mypy/plugins/attrs.py @@ -410,6 +410,7 @@ def _analyze_class( continue assert isinstance(node, Var) node.is_initialized_in_class = False + node.is_abstract_var = False # Traverse the MRO and collect attributes from the parents. taken_attr_names = set(own_attrs) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index a51b393fcbc4..a5755456f036 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -587,6 +587,9 @@ def collect_attributes(self) -> list[DataclassAttribute] | None: if node.is_classvar: continue + # In dataclasses, attributes are initialized in the generated __init__. + node.is_abstract_var = False + # x: InitVar[int] is turned into x: int and is removed from the class. is_init_var = False node_type = get_proper_type(node.type) diff --git a/mypy/semanal.py b/mypy/semanal.py index 6e103e5d382c..49a728787b0b 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -3384,11 +3384,16 @@ def process_type_annotation(self, s: AssignmentStmt) -> None: s.type = analyzed if ( self.type - and self.type.is_protocol + and ( + self.type.is_protocol + or (self.options.warn_uninitialized_attributes and not self.is_stub_file) + ) and isinstance(lvalue, NameExpr) and isinstance(s.rvalue, TempNode) and s.rvalue.no_rhs ): + # If this is a protocol or if `warn_uninitialized_attributes` is set, + # we keep track of uninitialized variables. if isinstance(lvalue.node, Var): lvalue.node.is_abstract_var = True else: @@ -3953,11 +3958,17 @@ def analyze_member_lvalue( # are inside __init__, but we do this always to preserve historical behaviour. if isinstance(cur_node.node, Var): cur_node.node.is_abstract_var = False + defined_in_this_class = lval.name in self.type.names if ( # If the attribute of self is not defined, create a new Var, ... node is None - # ... or if it is defined as abstract in a *superclass*. - or (cur_node is None and isinstance(node.node, Var) and node.node.is_abstract_var) + or ( + # ... or if it is defined as abstract in a *superclass*. + cur_node is None + and isinstance(node.node, Var) + and node.node.is_abstract_var + and not defined_in_this_class + ) # ... also an explicit declaration on self also creates a new Var. # Note that `explicit_type` might have been erased for bare `Final`, # so we also check if `is_final` is passed. @@ -3979,6 +3990,13 @@ def analyze_member_lvalue( lval.node = v # TODO: should we also set lval.kind = MDEF? self.type.names[lval.name] = SymbolTableNode(MDEF, v, implicit=True) + elif ( + isinstance(node.node, Var) + and node.node.is_abstract_var + and not self.type.is_protocol + ): + # Something is assigned to this variable here, so we mark it as initialized. + node.node.is_abstract_var = False self.check_lvalue_validity(lval.node, lval) def is_self_member_ref(self, memberexpr: MemberExpr) -> bool: @@ -4487,6 +4505,8 @@ def check_classvar(self, s: AssignmentStmt) -> None: lvalue = s.lvalues[0] if len(s.lvalues) != 1 or not isinstance(lvalue, RefExpr): return + if self.is_class_scope() and isinstance(lvalue, NameExpr) and isinstance(lvalue.node, Var): + lvalue.node.is_abstract_var = False if not s.type or not self.is_classvar(s.type): return if self.is_class_scope() and isinstance(lvalue, NameExpr): diff --git a/mypy/semanal_classprop.py b/mypy/semanal_classprop.py index dfd4e5b6f122..0bb02e0a7b61 100644 --- a/mypy/semanal_classprop.py +++ b/mypy/semanal_classprop.py @@ -39,7 +39,9 @@ } -def calculate_class_abstract_status(typ: TypeInfo, is_stub_file: bool, errors: Errors) -> None: +def calculate_class_abstract_status( + typ: TypeInfo, is_stub_file: bool, errors: Errors, options: Options +) -> None: """Calculate abstract status of a class. Set is_abstract of the type to True if the type has an unimplemented @@ -52,6 +54,7 @@ def calculate_class_abstract_status(typ: TypeInfo, is_stub_file: bool, errors: E # List of abstract attributes together with their abstract status abstract: list[tuple[str, int]] = [] abstract_in_this_class: list[str] = [] + uninitialized_vars: set[str] = set() if typ.is_newtype: # Special case: NewTypes are considered as always non-abstract, so they can be used as: # Config = NewType('Config', Mapping[str, str]) @@ -85,15 +88,29 @@ def calculate_class_abstract_status(typ: TypeInfo, is_stub_file: bool, errors: E abstract_in_this_class.append(name) elif isinstance(node, Var): if node.is_abstract_var and name not in concrete: - typ.is_abstract = True - abstract.append((name, IS_ABSTRACT)) - if base is typ: - abstract_in_this_class.append(name) + # If this abstract variable comes from a protocol, then `typ` + # is abstract. + if base.is_protocol: + typ.is_abstract = True + abstract.append((name, IS_ABSTRACT)) + if base is typ: + abstract_in_this_class.append(name) + elif options.warn_uninitialized_attributes: + uninitialized_vars.add(name) + elif ( + options.warn_uninitialized_attributes + and not node.is_abstract_var + and name in uninitialized_vars + ): + # A variable can also be initialized in a parent class. + uninitialized_vars.remove(name) concrete.add(name) + typ.abstract_attributes = sorted(abstract) + if uninitialized_vars: + typ.uninitialized_vars = sorted(list(uninitialized_vars)) # In stubs, abstract classes need to be explicitly marked because it is too # easy to accidentally leave a concrete class abstract by forgetting to # implement some methods. - typ.abstract_attributes = sorted(abstract) if is_stub_file: if typ.declared_metaclass and typ.declared_metaclass.type.has_base("abc.ABCMeta"): return diff --git a/mypy/semanal_main.py b/mypy/semanal_main.py index ec09deb0952f..18b00ff537a4 100644 --- a/mypy/semanal_main.py +++ b/mypy/semanal_main.py @@ -164,7 +164,11 @@ def restore_saved_attrs(saved_attrs: SavedAttributes) -> None: existing is None or # (An abstract Var is considered as not defined.) - (isinstance(existing.node, Var) and existing.node.is_abstract_var) + ( + isinstance(existing.node, Var) + and existing.node.is_abstract_var + and not defined_in_this_class + ) or # Also an explicit declaration on self creates a new Var unless # there is already one defined in the class body. @@ -498,7 +502,9 @@ def calculate_class_properties(graph: Graph, scc: list[str], errors: Errors) -> for _, node, _ in tree.local_definitions(): if isinstance(node.node, TypeInfo): with state.manager.semantic_analyzer.file_context(tree, state.options, node.node): - calculate_class_abstract_status(node.node, tree.is_stub, errors) + calculate_class_abstract_status( + node.node, tree.is_stub, errors, graph[module].options + ) check_protocol_status(node.node, errors) calculate_class_vars(node.node) add_type_promotion( diff --git a/mypy/semanal_namedtuple.py b/mypy/semanal_namedtuple.py index 51ea90e07f3d..2ad3eb89002d 100644 --- a/mypy/semanal_namedtuple.py +++ b/mypy/semanal_namedtuple.py @@ -652,10 +652,16 @@ def save_namedtuple_body(self, named_tuple_info: TypeInfo) -> Iterator[None]: if isinstance(sym.node, (FuncBase, Decorator)) and not sym.plugin_generated: # Keep user-defined methods as is. continue + elif isinstance(sym.node, Var): + # NamedTuple fields are initialized in the generated __init__. + sym.node.is_abstract_var = False # Keep existing (user-provided) definitions under mangled names, so they # get semantically analyzed. r_key = get_unique_redefinition_name(key, named_tuple_info.names) named_tuple_info.names[r_key] = sym + if isinstance(value.node, Var): + # NamedTuple fields are initialized in the generated __init__. + value.node.is_abstract_var = False named_tuple_info.names[key] = value # Helpers diff --git a/test-data/unit/check-protocols.test b/test-data/unit/check-protocols.test index e73add454a67..cba69c75484f 100644 --- a/test-data/unit/check-protocols.test +++ b/test-data/unit/check-protocols.test @@ -4127,3 +4127,40 @@ class P(Protocol): class C(P): ... C(0) # OK + +[case testEmptyContainer] +from typing import List, Protocol + +class P(Protocol): + s: str + l: List[str] + +class A(P): + l2: List[str] + def __init__(self) -> None: + self.s = "python" + self.l = [] + self.l2 = [] + +a = A() +reveal_type(a.s) # N: Revealed type is "builtins.str" +reveal_type(a.l) # N: Revealed type is "builtins.list[builtins.str]" +reveal_type(a.l2) # N: Revealed type is "builtins.list[builtins.str]" + +[case testClassAssignment] +# flags: --warn-uninitialized-attributes +from typing import ClassVar, Protocol + +class P(Protocol): + c: ClassVar[int] + c = 2 +class A(P): ... +A() +class B: ... +b: P = B() # E: Incompatible types in assignment (expression has type "B", variable has type "P") +class P2(Protocol): + c: ClassVar[int] = 2 +class A2(P2): ... +A2() +class B2: ... +b2: P2 = B2() # E: Incompatible types in assignment (expression has type "B2", variable has type "P2") diff --git a/test-data/unit/check-warnings.test b/test-data/unit/check-warnings.test index 90f40777d6b7..d35e5e55f124 100644 --- a/test-data/unit/check-warnings.test +++ b/test-data/unit/check-warnings.test @@ -234,3 +234,238 @@ def foo(a1: A) -> int: class A: def __init__(self, x: int) -> None: self._x = x + +[case testUninitializedAttribute] +# flags: --warn-uninitialized-attributes +class A: + x: int +a = A() # E: Class "A" has annotated but unset instance attributes: "x" +class B(A): + def __init__(self) -> None: + self.x = 10 +B() # OK +class C(A): ... +C() # E: Class "C" has annotated but unset instance attributes: "x" +class D(A): + def f(self) -> None: + self.x = "foo" # E: Incompatible types in assignment (expression has type "str", base class "A" defined the type as "int") +D() # OK + +class E: + x: int + def __init__(self) -> None: + self.x = 10 +E() #OK +class F: + def __init__(self) -> None: + self.x = 10 + x: int +F() +class G: + x: int + def f(self) -> None: + self.x = 10 +G() # OK + +[case testUninitializedAttributeAbstract] +# flags: --warn-uninitialized-attributes +from abc import ABC + +class A(ABC): + x: str +A() # E: Class "A" has annotated but unset instance attributes: "x" +class B(A): ... +B() # E: Class "B" has annotated but unset instance attributes: "x" +class C(A): + def f(self) -> None: + self.x = "foo" +C() + +[case testUninitializedAttributeMultiple] +# flags: --warn-uninitialized-attributes +from typing import ClassVar +class A: + x: int + y: str = "foo" + z: bool + c: ClassVar[int] +A() # E: Class "A" has annotated but unset instance attributes: "x" and "z" \ + # E: Class "A" has annotated but unset class attributes: "c" +class B(A): + c = 0 + def __init__(self) -> None: + self.x = 3 +B() # E: Class "B" has annotated but unset instance attributes: "z" +class C(B): + def __init__(self) -> None: + self.z = True +C() # This should ideally be an error because `super().__init__` wasn't called. + +[case testUninitializedAttributeDataclass] +# flags: --warn-uninitialized-attributes +from dataclasses import InitVar, dataclass +from typing import ClassVar + +@dataclass +class A: + x: int + y: InitVar[str] + z: ClassVar[bool] + @classmethod + def f(cls) -> None: + cls.z = True +A(3, "foo") # E: Class "A" has annotated but unset class attributes: "z" +reveal_type(A.z) # N: Revealed type is "builtins.bool" +@dataclass +class A2(A): + z: ClassVar[bool] = True +A2(3, "foo") # OK + +class B: + x: int + y: str + +@dataclass +class C(B): ... +C() # E: Class "C" has annotated but unset instance attributes: "x" and "y" + +@dataclass +class D(B): + x: int + y: str +D(3, "foo") +[builtins fixtures/dataclasses.pyi] + +[case testUninitializedAttributeAttr] +# flags: --warn-uninitialized-attributes --python-version 3.7 +import attr +from typing import ClassVar + +@attr.define +class A: + x: int + z: ClassVar[bool] +A(3) # E: Class "A" has annotated but unset class attributes: "z" + +class B: + x: int + y: str + +@attr.define +class C(B): ... +C() # E: Class "C" has annotated but unset instance attributes: "x" and "y" + +@attr.define +class D(B): + x: int + y: str +D(3, "foo") +[builtins fixtures/dataclasses.pyi] + +[case testUninitializedAttributeNamedTuple] +# flags: --warn-uninitialized-attributes +from typing import NamedTuple + +class Employee(NamedTuple): + name: str + id: int = 3 + + def __repr__(self) -> str: + return self.name +Employee("foo") +[builtins fixtures/tuple.pyi] + +[case testUninitializedAttributeTypedDict] +# flags: --warn-uninitialized-attributes +from mypy_extensions import TypedDict + +class A(TypedDict): + x: int +A(x=3) +[builtins fixtures/tuple.pyi] + +[case testUninitializedAttributeOverwrite] +# flags: --warn-uninitialized-attributes +from typing import List +class A: + x: List[int] + y: List[int] + def __init__(self) -> None: + self.x = [] + self.y = 2 # E: Incompatible types in assignment (expression has type "int", variable has type "List[int]") +a = A() +reveal_type(a.x) # N: Revealed type is "builtins.list[builtins.int]" +reveal_type(a.y) # N: Revealed type is "builtins.list[builtins.int]" + +[case testUninitializedAttributeStub] +# flags: --warn-uninitialized-attributes +from typing import TYPE_CHECKING +from stub import A +A() +class B: + x: int +if TYPE_CHECKING: + class C(B): ... +else: + class C: ... +C() # E: Class "C" has annotated but unset instance attributes: "x" +[file stub.pyi] +class A: + x: int + +[case testUninitializedAttributeClassVar] +# flags: --warn-uninitialized-attributes +from typing import ClassVar +from typing_extensions import Protocol +class A: + CONST: int + CONST = 4 +A() + +class B: + CONST: ClassVar[int] + CONST = 4 +B() + +class C(Protocol): + CONST: ClassVar[int] + CONST = 4 +class D(C): ... +D() +[builtins fixtures/tuple.pyi] + +[case testUninitializedAttributeSuperClass] +# flags: --warn-uninitialized-attributes +class A: + def __init__(self, x: int): + self.x = x + +class B(A): + x: int + def __init__(self, x: int): + super().__init__(x) + +b = B(0) +reveal_type(b.x) # N: Revealed type is "builtins.int" + +class C: + x: int +class D(C): + x: int +D() # E: Class "D" has annotated but unset instance attributes: "x" + +class E: + a: int + def __init__(self, a: int) -> None: + self.a = a + +class F(E): + a: int + b: int + def __init__(self, a: int, b: int) -> None: + super().__init__(a) + # or `self.a = a` + self.b = b + +E(0) # ok +F(1, 1) # ok From f01b176b0aab0aec712704ea3f38795a727977db Mon Sep 17 00:00:00 2001 From: Thomas M Kehrenberg Date: Wed, 20 Sep 2023 15:43:42 +0100 Subject: [PATCH 2/3] Rename variable --- mypy/nodes.py | 6 +++--- mypy/plugins/attrs.py | 2 +- mypy/plugins/dataclasses.py | 2 +- mypy/semanal.py | 12 ++++++------ mypy/semanal_classprop.py | 8 ++++---- mypy/semanal_main.py | 4 ++-- mypy/semanal_namedtuple.py | 4 ++-- 7 files changed, 19 insertions(+), 19 deletions(-) diff --git a/mypy/nodes.py b/mypy/nodes.py index 7400f18163bd..c92248fc75af 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -911,7 +911,7 @@ def deserialize(cls, data: JsonDict) -> Decorator: "is_settable_property", "is_suppressed_import", "is_classvar", - "is_abstract_var", + "is_uninitialized", "is_final", "final_unset_in_class", "final_set_in_init", @@ -947,7 +947,7 @@ class Var(SymbolNode): "is_property", "is_settable_property", "is_classvar", - "is_abstract_var", + "is_uninitialized", "is_final", "final_unset_in_class", "final_set_in_init", @@ -982,7 +982,7 @@ def __init__(self, name: str, type: mypy.types.Type | None = None) -> None: self.is_property = False self.is_settable_property = False self.is_classvar = False - self.is_abstract_var = False + self.is_uninitialized = False # Set to true when this variable refers to a module we were unable to # parse for some reason (eg a silenced module) self.is_suppressed_import = False diff --git a/mypy/plugins/attrs.py b/mypy/plugins/attrs.py index f5b38f8583fd..2cdd984903c9 100644 --- a/mypy/plugins/attrs.py +++ b/mypy/plugins/attrs.py @@ -410,7 +410,7 @@ def _analyze_class( continue assert isinstance(node, Var) node.is_initialized_in_class = False - node.is_abstract_var = False + node.is_uninitialized = False # Traverse the MRO and collect attributes from the parents. taken_attr_names = set(own_attrs) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index a5755456f036..4fc82c78483b 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -588,7 +588,7 @@ def collect_attributes(self) -> list[DataclassAttribute] | None: continue # In dataclasses, attributes are initialized in the generated __init__. - node.is_abstract_var = False + node.is_uninitialized = False # x: InitVar[int] is turned into x: int and is removed from the class. is_init_var = False diff --git a/mypy/semanal.py b/mypy/semanal.py index 49a728787b0b..46fff71e4701 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -3395,7 +3395,7 @@ def process_type_annotation(self, s: AssignmentStmt) -> None: # If this is a protocol or if `warn_uninitialized_attributes` is set, # we keep track of uninitialized variables. if isinstance(lvalue.node, Var): - lvalue.node.is_abstract_var = True + lvalue.node.is_uninitialized = True else: if ( self.type @@ -3957,7 +3957,7 @@ def analyze_member_lvalue( # Make this variable non-abstract, it would be safer to do this only if we # are inside __init__, but we do this always to preserve historical behaviour. if isinstance(cur_node.node, Var): - cur_node.node.is_abstract_var = False + cur_node.node.is_uninitialized = False defined_in_this_class = lval.name in self.type.names if ( # If the attribute of self is not defined, create a new Var, ... @@ -3966,7 +3966,7 @@ def analyze_member_lvalue( # ... or if it is defined as abstract in a *superclass*. cur_node is None and isinstance(node.node, Var) - and node.node.is_abstract_var + and node.node.is_uninitialized and not defined_in_this_class ) # ... also an explicit declaration on self also creates a new Var. @@ -3992,11 +3992,11 @@ def analyze_member_lvalue( self.type.names[lval.name] = SymbolTableNode(MDEF, v, implicit=True) elif ( isinstance(node.node, Var) - and node.node.is_abstract_var + and node.node.is_uninitialized and not self.type.is_protocol ): # Something is assigned to this variable here, so we mark it as initialized. - node.node.is_abstract_var = False + node.node.is_uninitialized = False self.check_lvalue_validity(lval.node, lval) def is_self_member_ref(self, memberexpr: MemberExpr) -> bool: @@ -4506,7 +4506,7 @@ def check_classvar(self, s: AssignmentStmt) -> None: if len(s.lvalues) != 1 or not isinstance(lvalue, RefExpr): return if self.is_class_scope() and isinstance(lvalue, NameExpr) and isinstance(lvalue.node, Var): - lvalue.node.is_abstract_var = False + lvalue.node.is_uninitialized = False if not s.type or not self.is_classvar(s.type): return if self.is_class_scope() and isinstance(lvalue, NameExpr): diff --git a/mypy/semanal_classprop.py b/mypy/semanal_classprop.py index 0bb02e0a7b61..dded2ede5f3a 100644 --- a/mypy/semanal_classprop.py +++ b/mypy/semanal_classprop.py @@ -87,9 +87,9 @@ def calculate_class_abstract_status( if base is typ: abstract_in_this_class.append(name) elif isinstance(node, Var): - if node.is_abstract_var and name not in concrete: - # If this abstract variable comes from a protocol, then `typ` - # is abstract. + if node.is_uninitialized and name not in concrete: + # If this uninitialized variable comes from a protocol, then we + # treat it as an "abstract" variable and mark `typ` as abstract. if base.is_protocol: typ.is_abstract = True abstract.append((name, IS_ABSTRACT)) @@ -99,7 +99,7 @@ def calculate_class_abstract_status( uninitialized_vars.add(name) elif ( options.warn_uninitialized_attributes - and not node.is_abstract_var + and not node.is_uninitialized and name in uninitialized_vars ): # A variable can also be initialized in a parent class. diff --git a/mypy/semanal_main.py b/mypy/semanal_main.py index 18b00ff537a4..812ce52bca60 100644 --- a/mypy/semanal_main.py +++ b/mypy/semanal_main.py @@ -163,10 +163,10 @@ def restore_saved_attrs(saved_attrs: SavedAttributes) -> None: if ( existing is None or - # (An abstract Var is considered as not defined.) + # (An uninitialized Var is considered as not defined.) ( isinstance(existing.node, Var) - and existing.node.is_abstract_var + and existing.node.is_uninitialized and not defined_in_this_class ) or diff --git a/mypy/semanal_namedtuple.py b/mypy/semanal_namedtuple.py index 2ad3eb89002d..ec210296dec0 100644 --- a/mypy/semanal_namedtuple.py +++ b/mypy/semanal_namedtuple.py @@ -654,14 +654,14 @@ def save_namedtuple_body(self, named_tuple_info: TypeInfo) -> Iterator[None]: continue elif isinstance(sym.node, Var): # NamedTuple fields are initialized in the generated __init__. - sym.node.is_abstract_var = False + sym.node.is_uninitialized = False # Keep existing (user-provided) definitions under mangled names, so they # get semantically analyzed. r_key = get_unique_redefinition_name(key, named_tuple_info.names) named_tuple_info.names[r_key] = sym if isinstance(value.node, Var): # NamedTuple fields are initialized in the generated __init__. - value.node.is_abstract_var = False + value.node.is_uninitialized = False named_tuple_info.names[key] = value # Helpers From 560f365c3f0990b26fb73e931009b45745a31514 Mon Sep 17 00:00:00 2001 From: Thomas M Kehrenberg Date: Wed, 20 Sep 2023 15:43:20 +0100 Subject: [PATCH 3/3] Add test for the weird NamedTuple behavior --- mypy/semanal_namedtuple.py | 4 ++-- test-data/unit/check-warnings.test | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/mypy/semanal_namedtuple.py b/mypy/semanal_namedtuple.py index ec210296dec0..e2bdb770a614 100644 --- a/mypy/semanal_namedtuple.py +++ b/mypy/semanal_namedtuple.py @@ -653,14 +653,14 @@ def save_namedtuple_body(self, named_tuple_info: TypeInfo) -> Iterator[None]: # Keep user-defined methods as is. continue elif isinstance(sym.node, Var): - # NamedTuple fields are initialized in the generated __init__. + # Don't track the initialization status of the mangled attributes. sym.node.is_uninitialized = False # Keep existing (user-provided) definitions under mangled names, so they # get semantically analyzed. r_key = get_unique_redefinition_name(key, named_tuple_info.names) named_tuple_info.names[r_key] = sym if isinstance(value.node, Var): - # NamedTuple fields are initialized in the generated __init__. + # When a NamedTuple is analyzed multiple times, we have to reset this again. value.node.is_uninitialized = False named_tuple_info.names[key] = value diff --git a/test-data/unit/check-warnings.test b/test-data/unit/check-warnings.test index d35e5e55f124..ef7be8d60390 100644 --- a/test-data/unit/check-warnings.test +++ b/test-data/unit/check-warnings.test @@ -375,6 +375,17 @@ class Employee(NamedTuple): Employee("foo") [builtins fixtures/tuple.pyi] +[case testUninitializedAttributeNamedTupleRunTwice] +# flags: --warn-uninitialized-attributes +from typing import NamedTuple + +NT: "N" # Force mypy to analyze this twice. +class N(NamedTuple): + x: int + +NT = N(1) +[builtins fixtures/tuple.pyi] + [case testUninitializedAttributeTypedDict] # flags: --warn-uninitialized-attributes from mypy_extensions import TypedDict