diff --git a/pyrefly/lib/alt/attr.rs b/pyrefly/lib/alt/attr.rs index f56dd13c35..461fb47397 100644 --- a/pyrefly/lib/alt/attr.rs +++ b/pyrefly/lib/alt/attr.rs @@ -2017,6 +2017,24 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { { acc.not_found(NotFoundOn::ClassInstance(cls.class_object().clone(), base)) } + // A bound method is a `types.MethodType`, which does not override + // `__setattr__`/`__delattr__`, so it does not accept arbitrary attribute + // assignment (this fails at runtime). Without this arm the base would fall + // through to the general lookup and resolve the dunder via + // `MethodType.__getattr__`, incorrectly permitting the assignment. + AttributeBase1::BoundMethod(_) + if (*dunder_name == dunder::SETATTR || *dunder_name == dunder::DELATTR) + && self.field_is_inherited_from( + self.stdlib.method_type().class_object(), + dunder_name, + (ModuleName::builtins().as_str(), "object"), + ) => + { + acc.not_found(NotFoundOn::ClassInstance( + self.stdlib.method_type().class_object().clone(), + base, + )) + } AttributeBase1::ShapedArrayInstance(tensor) if (*dunder_name == dunder::SETATTR || *dunder_name == dunder::DELATTR diff --git a/pyrefly/lib/test/attributes.rs b/pyrefly/lib/test/attributes.rs index 437dc6640e..3ffef0ce8a 100644 --- a/pyrefly/lib/test/attributes.rs +++ b/pyrefly/lib/test/attributes.rs @@ -3055,3 +3055,16 @@ class C: x = untyped(1) # E: implicitly inferred to be `Any` "#, ); + +testcase!( + test_bound_method_no_arbitrary_attr_set, + r#" +class Foo: + def real_method(self) -> None: ... +f: Foo = Foo() +f.real_method.test = None # E: has no attribute `test` +del f.real_method.test # E: has no attribute `test` +# Known method attributes are still accessible. +name: str = f.real_method.__name__ +"#, +);