From 8387bfded3736532e1b6c1c124264711ea0c50bc Mon Sep 17 00:00:00 2001 From: Patrick Ribbsaeter Date: Tue, 11 Aug 2026 09:17:36 -0700 Subject: [PATCH] Fix descriptor semantics for initialized annotations (#4476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: - avoid applying descriptor protocol semantics to annotated fields initialized in recognized instance methods - preserve the existing class-field classification and class-level visibility behavior for unrelated annotations - add regression coverage for annotation plus `__init__` assignment while retaining annotation-only and method-only controls ## Root cause `DeclaredByAnnotation` already records `initialized_in_recognized_method`, but descriptor detection did not consult that signal. An annotation whose type implements `__get__` was therefore treated as a class-installed descriptor even when `self.field` was initialized in `__init__`. The fix uses that signal only to skip descriptor construction for the annotated-and-method-initialized case. It intentionally leaves the field's existing initialization classification unchanged, avoiding unrelated class-visibility and constructor behavior changes. Fixes https://github.com/facebook/pyrefly/issues/4471 ## Validation - `cargo fmt --all -- --check` — passed - `python test.py --no-test --no-tensor-shapes --no-conformance --no-jsonschema` — formatting passed; Rust lint was blocked locally because the Windows host has no MSVC `link.exe` - `git diff --check` — passed - repository Linux, Windows, macOS, and compatibility CI rerunning on the narrowed patch AI agent disclosure: OpenAI Codex prepared this PR; the contributor reviewed and understands the changes. Pull Request resolved: https://github.com/facebook/pyrefly/pull/4476 Reviewed By: yangdanny97 Differential Revision: D115325693 fbshipit-source-id: 45affc045840c7b6eba2bd87bef5f8a0a7499473 --- pyrefly/lib/alt/class/class_field.rs | 12 +++++++++++- pyrefly/lib/test/descriptors.rs | 11 ++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/pyrefly/lib/alt/class/class_field.rs b/pyrefly/lib/alt/class/class_field.rs index 94dee61..ce9a7df 100644 --- a/pyrefly/lib/alt/class/class_field.rs +++ b/pyrefly/lib/alt/class/class_field.rs @@ -2019,6 +2019,13 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { // Identify whether this is a descriptor. Construct the stored descriptor only after // forcing the field type so its class cannot retain solver variables. let mut descriptor_methods = None; + let is_annotation_initialized_in_method = match field_definition { + ClassFieldDefinition::DeclaredByAnnotation { + initialized_in_recognized_method, + .. + } => *initialized_in_recognized_method, + _ => false, + }; // Descriptor semantics apply when the field is modeled as class-level: // either by a class-body definition, by `Magic` for stub/interface // declarations where the runtime initializer is omitted, or by an @@ -2043,7 +2050,10 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { let deleter = self .get_class_member(cls.class_object(), &dunder::DELETE) .is_some(); - if getter || setter || deleter { + // A getter-only annotation that is initialized on the instance does not + // install a descriptor on the class. Keep data-descriptor behavior, + // however, for metaclass-powered fields such as SQLAlchemy's `Mapped[T]`. + if setter || deleter || getter && !is_annotation_initialized_in_method { descriptor_methods = Some((getter, setter, deleter)); } } diff --git a/pyrefly/lib/test/descriptors.rs b/pyrefly/lib/test/descriptors.rs index a936fba..d2b0666 100644 --- a/pyrefly/lib/test/descriptors.rs +++ b/pyrefly/lib/test/descriptors.rs @@ -332,15 +332,24 @@ class MethodInitialized: def __init__(self) -> None: self.device = Device() -def f(a: AnnotationOnly, m: MethodInitialized) -> None: +class AnnotatedAndMethodInitialized: + device: Device + def __init__(self) -> None: + self.device = Device() + +def f(a: AnnotationOnly, m: MethodInitialized, am: AnnotatedAndMethodInitialized) -> None: # Annotation-only descriptor: writes are rejected (no `__set__`). a.device = Device() # E: Attribute `device` of class `AnnotationOnly` is a read-only descriptor with no `__set__` and cannot be set # Method-initialized: plain instance attribute, write allowed. m.device = Device() + # An annotation does not install a descriptor on the class when the field is + # initialized on the instance. + am.device = Device() # Annotation-only descriptor: read invokes `__get__` and returns int. assert_type(a.device, int) # Method-initialized: read returns the attribute itself. assert_type(m.device, Device) + assert_type(am.device, Device) "#, ); -- 2.55.0.windows.3