diff --git a/mypy/checkmember.py b/mypy/checkmember.py index 554b49d3eda2..bbda7adac7f0 100644 --- a/mypy/checkmember.py +++ b/mypy/checkmember.py @@ -511,7 +511,15 @@ def analyze_member_var_access( # independently of types. if mx.is_lvalue and not mx.chk.get_final_context(): check_final_member(name, info, mx.msg, mx.context) - + # If accessing a final attribute, check if it was properly assigned + # in init (for dataclass field() specifically) + if ( + not mx.is_lvalue + and not mx.chk.get_final_context() + and not mx.chk.is_stub + and not mx.chk.is_typeshed_stub + ): + check_final_assigned_in_init(name, info, mx.msg, mx.context) return analyze_var(name, v, itype, info, mx, implicit=implicit) elif isinstance(v, FuncDef): assert False, "Did not expect a function" @@ -600,6 +608,25 @@ def check_final_member(name: str, info: TypeInfo, msg: MessageBuilder, ctx: Cont msg.cant_assign_to_final(name, attr_assign=True, ctx=ctx) +def check_final_assigned_in_init( + name: str, info: TypeInfo, msg: MessageBuilder, ctx: Context +) -> None: + """Give an error if the final being accessed was never assigned in init (or the class).""" + for base in info.mro: + sym = base.names.get(name) + if ( + sym + and is_final_node(sym.node) + and ( + isinstance(sym.node, Var) + and not sym.node.final_set_in_init + and sym.node.final_unset_in_class + and sym.node.has_explicit_value + ) + ): + msg.final_field_not_set_in_init(name, ctx=ctx) + + def analyze_descriptor_access(descriptor_type: Type, mx: MemberContext) -> Type: """Type check descriptor access. diff --git a/mypy/messages.py b/mypy/messages.py index 85fa30512534..9bef6afade7c 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1402,6 +1402,9 @@ def cant_assign_to_final(self, name: str, attr_assign: bool, ctx: Context) -> No kind = "attribute" if attr_assign else "name" self.fail(f'Cannot assign to final {kind} "{unmangle(name)}"', ctx) + def final_field_not_set_in_init(self, name: str, ctx: Context) -> None: + self.fail(f'Final field "{name}" not set', ctx) + def protocol_members_cant_be_final(self, ctx: Context) -> None: self.fail("Protocol member cannot be final", ctx) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index 75496d5e56f9..ab17a0ed61d4 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -19,7 +19,9 @@ CallExpr, Context, Expression, + FuncDef, JsonDict, + MemberExpr, NameExpr, PlaceholderNode, RefExpr, @@ -412,7 +414,36 @@ def collect_attributes(self) -> list[DataclassAttribute] | None: # Second, collect attributes belonging to the current class. current_attr_names: set[str] = set() kw_only = _get_decorator_bool_argument(ctx, "kw_only", False) + final_unset_fields = set() for stmt in cls.defs.body: + # Check statements in __init__ and __post_init__ to see if any + # Final class variables = field() are being properly set + if isinstance(stmt, FuncDef) and ( + stmt.name == "__init__" or stmt.name == "__post_init__" + ): + for init_stmt in stmt.body.body: + if not isinstance(init_stmt, AssignmentStmt): + continue + if not ( + isinstance(init_stmt.lvalues[0], MemberExpr) + or isinstance(init_stmt.lvalues[0], NameExpr) + ): + continue + final_lhs = init_stmt.lvalues[0] + sym = cls.info.names.get(final_lhs.name) + if sym is None: + # There was probably a semantic analysis error. + continue + + node = sym.node + assert not isinstance(node, PlaceholderNode) + assert isinstance(node, Var) + if final_lhs.name in final_unset_fields: + node.final_unset_in_class = False + node.final_set_in_init = True + init_stmt.is_final_def = True + continue + # Any assignment that doesn't use the new type declaration # syntax can be ignored out of hand. if not (isinstance(stmt, AssignmentStmt) and stmt.new_syntax): @@ -469,6 +500,11 @@ def collect_attributes(self) -> list[DataclassAttribute] | None: else: is_in_init = bool(ctx.api.parse_bool(is_in_init_param)) + if has_field_call and node.is_final: + if not is_in_init: + node.final_unset_in_class = True + final_unset_fields.add(lhs.name) + has_default = False # Ensure that something like x: int = field() is rejected # after an attribute with a default. diff --git a/test-data/unit/check-dataclasses.test b/test-data/unit/check-dataclasses.test index c248f8db8585..e8a64686486e 100644 --- a/test-data/unit/check-dataclasses.test +++ b/test-data/unit/check-dataclasses.test @@ -2001,3 +2001,77 @@ class Bar(Foo): ... e: Element[Bar] reveal_type(e.elements) # N: Revealed type is "typing.Sequence[__main__.Element[__main__.Bar]]" [builtins fixtures/dataclasses.pyi] + +[case testErrorFinalFieldNoInitNoArgumentPassed] +from typing import Final +from dataclasses import dataclass, field +@dataclass +class Foo: + a: Final[int] = field(init=False) # E: Final name must be initialized with a value +Foo().a # E: Final field "a" not set +[builtins fixtures/dataclasses.pyi] + +[case testNoErrorFinalFieldDelayedInit] +from typing import Final +from dataclasses import dataclass, field + +@dataclass +class Bar: + a: Final[int] = field() + b: Final[int] + + def __init__(self) -> None: + self.a = 1 + self.b = 1 + +[builtins fixtures/dataclasses.pyi] + +[case testErrorFinalFieldInitNoArgumentPassed] +from typing import Final +from dataclasses import dataclass, field + +@dataclass +class Foo: + a: Final[int] = field() + +Foo().a # E: Missing positional argument "a" in call to "Foo" +[builtins fixtures/dataclasses.pyi] + +[case testFinalFieldGeneratedInitArgumentPassed] +from typing import Final +from dataclasses import dataclass, field + +@dataclass +class Foo: + a: Final[int] = field() + +Foo(1).a +[builtins fixtures/dataclasses.pyi] + +[case testFinalFieldInit] +from typing import Final +from dataclasses import dataclass, field + +@dataclass +class Foo: + a: Final[int] = field(init=False) + + def __init__(self): + self.a = 1 + +Foo().a +[builtins fixtures/dataclasses.pyi] + +[case testFinalFieldPostInit] +from typing import Final +from dataclasses import dataclass, field + +@dataclass +class Foo: + a: Final[int] = field(init=False) + + def __post_init__(self): + self.a = 1 + +Foo().a +[builtins fixtures/dataclasses.pyi] \ No newline at end of file diff --git a/test-data/unit/check-final.test b/test-data/unit/check-final.test index da034caced76..9422301b88d0 100644 --- a/test-data/unit/check-final.test +++ b/test-data/unit/check-final.test @@ -210,9 +210,9 @@ class C: y: Final[int] # E: Final name must be initialized with a value def __init__(self) -> None: self.z: Final # E: Type in Final[...] can only be omitted if there is an initializer -reveal_type(x) # N: Revealed type is "Any" +reveal_type(x) # N: Revealed type is "Any" reveal_type(y) # N: Revealed type is "builtins.int" -reveal_type(C().x) # N: Revealed type is "Any" +reveal_type(C().x) # N: Revealed type is "Any" reveal_type(C().y) # N: Revealed type is "builtins.int" reveal_type(C().z) # N: Revealed type is "Any" [out]