From 6068bed51d2f4176ca35394112317a02fcd3e393 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Wed, 1 Jul 2026 16:04:23 +0100 Subject: [PATCH] [mypyc] Make instance attribute read-only at runtime if Final Currently this was only enforced statically. This is important, since we plan to perform more optimizations in the presence of Final in the future. --- mypyc/codegen/emitclass.py | 30 +++++++++++++++--------- mypyc/ir/class_ir.py | 4 ++++ mypyc/irbuild/prepare.py | 2 ++ mypyc/test-data/run-classes.test | 40 ++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/mypyc/codegen/emitclass.py b/mypyc/codegen/emitclass.py index 6054a934b25e1..9baaec06a4611 100644 --- a/mypyc/codegen/emitclass.py +++ b/mypyc/codegen/emitclass.py @@ -1065,12 +1065,14 @@ def generate_getseter_declarations(cl: ClassIR, emitter: Emitter) -> None: getter_name(cl, attr, emitter.names), cl.struct_name(emitter.names) ) ) - emitter.emit_line("static int") - emitter.emit_line( - "{}({} *self, PyObject *value, void *closure);".format( - setter_name(cl, attr, emitter.names), cl.struct_name(emitter.names) + # Final attributes are read-only, so they have no setter. + if attr not in cl.final_attributes: + emitter.emit_line("static int") + emitter.emit_line( + "{}({} *self, PyObject *value, void *closure);".format( + setter_name(cl, attr, emitter.names), cl.struct_name(emitter.names) + ) ) - ) for prop, (getter, setter) in cl.properties.items(): if getter.decl.implicit: @@ -1099,11 +1101,15 @@ def generate_getseters_table(cl: ClassIR, name: str, emitter: Emitter) -> None: if not cl.is_trait: for attr in cl.attributes: emitter.emit_line(f'{{"{attr}",') - emitter.emit_line( - " (getter){}, (setter){},".format( - getter_name(cl, attr, emitter.names), setter_name(cl, attr, emitter.names) + if attr in cl.final_attributes: + # Final attributes are read-only, so emit a NULL setter. + emitter.emit_line(f" (getter){getter_name(cl, attr, emitter.names)}, NULL,") + else: + emitter.emit_line( + " (getter){}, (setter){},".format( + getter_name(cl, attr, emitter.names), setter_name(cl, attr, emitter.names) + ) ) - ) emitter.emit_line(" NULL, NULL},") for prop, (getter, setter) in cl.properties.items(): if getter.decl.implicit: @@ -1129,8 +1135,10 @@ def generate_getseters(cl: ClassIR, emitter: Emitter) -> None: if not cl.is_trait: for i, (attr, rtype) in enumerate(cl.attributes.items()): generate_getter(cl, attr, rtype, emitter) - emitter.emit_line("") - generate_setter(cl, attr, rtype, emitter) + # Final attributes are read-only, so they have no setter. + if attr not in cl.final_attributes: + emitter.emit_line("") + generate_setter(cl, attr, rtype, emitter) if i < len(cl.attributes) - 1: emitter.emit_line("") for prop, (getter, setter) in cl.properties.items(): diff --git a/mypyc/ir/class_ir.py b/mypyc/ir/class_ir.py index f754275480edc..028df5898d920 100644 --- a/mypyc/ir/class_ir.py +++ b/mypyc/ir/class_ir.py @@ -145,6 +145,8 @@ def __init__( ) # Attributes defined in the class (not inherited) self.attributes: dict[str, RType] = {} + # Final attributes defined in the class (not inherited) + self.final_attributes: set[str] = set() # Deletable attributes self.deletable: list[str] = [] # We populate method_types with the signatures of every method before @@ -396,6 +398,7 @@ def serialize(self) -> JsonDict: "ctor": self.ctor.serialize(), # We serialize dicts as lists to ensure order is preserved "attributes": [(k, t.serialize()) for k, t in self.attributes.items()], + "final_attributes": sorted(self.final_attributes), # We try to serialize a name reference, but if the decl isn't in methods # then we can't be sure that will work so we serialize the whole decl. "method_decls": [ @@ -456,6 +459,7 @@ def deserialize(cls, data: JsonDict, ctx: DeserMaps) -> ClassIR: ir.builtin_base = data["builtin_base"] ir.ctor = FuncDecl.deserialize(data["ctor"], ctx) ir.attributes = {k: deserialize_type(t, ctx) for k, t in data["attributes"]} + ir.final_attributes = set(data["final_attributes"]) ir.method_decls = { k: ctx.functions[v].decl if isinstance(v, str) else FuncDecl.deserialize(v, ctx) for k, v in data["method_decls"] diff --git a/mypyc/irbuild/prepare.py b/mypyc/irbuild/prepare.py index 8b73b10bf8064..6e697823593a6 100644 --- a/mypyc/irbuild/prepare.py +++ b/mypyc/irbuild/prepare.py @@ -645,6 +645,8 @@ def prepare_methods_and_attributes( add_getter_declaration(ir, name, attr_rtype, module_name) add_setter_declaration(ir, name, attr_rtype, module_name) ir.attributes[name] = attr_rtype + if node.node.is_final: + ir.final_attributes.add(name) elif isinstance(node.node, (FuncDef, Decorator)): prepare_method_def(ir, module_name, cdef, mapper, node.node, options) elif isinstance(node.node, OverloadedFuncDef): diff --git a/mypyc/test-data/run-classes.test b/mypyc/test-data/run-classes.test index d830b0c04fa6f..73927bb037a71 100644 --- a/mypyc/test-data/run-classes.test +++ b/mypyc/test-data/run-classes.test @@ -2825,6 +2825,46 @@ def test_final_attribute() -> None: assert C.b['x'] == 'y' assert C.a is C.b +[case testFinalInstanceAttributeCannotBeRebound] +from typing import Any, Final + +from testutil import assertRaises + +class C: + def __init__(self, x: int) -> None: + self.x: Final[int] = x + +class D(C): + def __init__(self, x: int, y: int) -> None: + super().__init__(x) + self.y: Final[int] = y + +def rebind_via_any(o: Any, value: int) -> None: + o.x = value + +def test_rebind_via_any() -> None: + c = C(1) + with assertRaises(AttributeError): + rebind_via_any(c, 2) + assert c.x == 1 + +def test_rebind_via_setattr() -> None: + c = C(1) + with assertRaises(AttributeError): + setattr(c, "x", 3) + assert c.x == 1 + +def test_rebind_inherited_via_setattr() -> None: + d = D(1, 2) + # Inherited Final attribute can't be modified. + with assertRaises(AttributeError): + setattr(d, "x", 3) + # The subclass's own Final attribute can't be modified either. + with assertRaises(AttributeError): + setattr(d, "y", 4) + assert d.x == 1 + assert d.y == 2 + [case testClassDerivedFromIntEnum] from enum import IntEnum, auto