Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new flag for warning about instantiating classes with declared but uninitialized attributes #13797

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
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
21 changes: 21 additions & 0 deletions docs/source/command_line.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
8 changes: 8 additions & 0 deletions docs/source/config_file.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
******************
Expand Down
56 changes: 41 additions & 15 deletions mypy/checkexpr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions mypy/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions mypy/message_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
5 changes: 3 additions & 2 deletions mypy/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down
12 changes: 9 additions & 3 deletions mypy/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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"]]
Expand Down
4 changes: 4 additions & 0 deletions mypy/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class BuildType:
"strict_optional",
"warn_no_return",
"warn_return_any",
"warn_uninitialized_attributes",
"warn_unreachable",
"warn_unused_ignores",
}
Expand Down Expand Up @@ -175,6 +176,9 @@ def __init__(self) -> None:
# Warn about unused '[mypy-<pattern>]' 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

Expand Down
1 change: 1 addition & 0 deletions mypy/plugins/attrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ def _analyze_class(
continue
assert isinstance(node, Var)
node.is_initialized_in_class = False
node.is_uninitialized = False

# Traverse the MRO and collect attributes from the parents.
taken_attr_names = set(own_attrs)
Expand Down
3 changes: 3 additions & 0 deletions mypy/plugins/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_uninitialized = 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)
Expand Down
30 changes: 25 additions & 5 deletions mypy/semanal.py
Original file line number Diff line number Diff line change
Expand Up @@ -3384,13 +3384,18 @@ 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
lvalue.node.is_uninitialized = True
else:
if (
self.type
Expand Down Expand Up @@ -3952,12 +3957,18 @@ 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, ...
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_uninitialized
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.
Expand All @@ -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_uninitialized
and not self.type.is_protocol
):
# Something is assigned to this variable here, so we mark it as initialized.
node.node.is_uninitialized = False
self.check_lvalue_validity(lval.node, lval)

def is_self_member_ref(self, memberexpr: MemberExpr) -> bool:
Expand Down Expand Up @@ -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_uninitialized = False
if not s.type or not self.is_classvar(s.type):
return
if self.is_class_scope() and isinstance(lvalue, NameExpr):
Expand Down
31 changes: 24 additions & 7 deletions mypy/semanal_classprop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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])
Expand Down Expand Up @@ -84,16 +87,30 @@ def calculate_class_abstract_status(typ: TypeInfo, is_stub_file: bool, errors: E
if base is typ:
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 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))
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_uninitialized
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
Expand Down
Loading