From 2e1219b951f50e6ee667491e357b590f52e9233b Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Sun, 24 May 2026 22:58:32 +0900 Subject: [PATCH 1/2] test --- pyrefly/lib/test/class_overrides.rs | 285 ++++++++++++++++++++++++++++ pyrefly/lib/test/descriptors.rs | 61 ++++++ 2 files changed, 346 insertions(+) diff --git a/pyrefly/lib/test/class_overrides.rs b/pyrefly/lib/test/class_overrides.rs index 136509d48a..85f52a3eb3 100644 --- a/pyrefly/lib/test/class_overrides.rs +++ b/pyrefly/lib/test/class_overrides.rs @@ -783,6 +783,291 @@ class B(A): "#, ); +// Regression test for https://github.com/facebook/pyrefly/issues/3062 +testcase!( + test_cached_property_overrides_unannotated_class_variable, + r#" +from functools import cached_property + +class BaseDatabaseFeatures: + can_introspect_fk = True + update_can_self_select = True + +class MySQLFeatures(BaseDatabaseFeatures): + @cached_property + def can_introspect_fk(self): + return self._check() + + @cached_property + def update_can_self_select(self): + return True + + def _check(self) -> bool: + return False + "#, +); + +testcase!( + test_cached_property_protocol_write, + r#" +from functools import cached_property +from typing import Literal, Protocol, assert_type + +class Writable(Protocol): + x: bool + +class Cached: + @cached_property + def x(self): + return True + +def write(value: Writable) -> None: + value.x = False + +def require_true(value: Literal[True]) -> None: ... + +def check(value: Cached) -> None: + assert_type(value.x, bool) + write(value) + assert_type(value.x, bool) + require_true(value.x) # E: Argument `bool` is not assignable + value.x = False + assert_type(value.x, bool) + "#, +); + +testcase!( + test_cached_property_inherited_write, + r#" +from functools import cached_property +from typing import Literal, assert_type + +class Base: + x = True + +class Child(Base): + @cached_property + def x(self): + return True + +def write(value: Base) -> None: + value.x = False + +def require_true(value: Literal[True]) -> None: ... + +def check(value: Child) -> None: + assert_type(value.x, bool) + write(value) + require_true(value.x) # E: Argument `bool` is not assignable + value.x = False + assert_type(value.x, bool) + "#, +); + +testcase!( + test_cached_property_overload_protocol_write, + r#" +from functools import cached_property +from typing import Protocol, assert_type, overload + +class Broad(Protocol): + x: int | str + +class Narrow(Protocol): + x: int + +class NarrowString(Protocol): + x: str + +class ReadOnly(Protocol): + @property + def x(self) -> int | str: ... + +class C[T: (int, str)]: + def __init__(self, value: T) -> None: + self.value = value + + @cached_property + @overload + def x(self: C[int]) -> int: ... + @cached_property + @overload + def x(self: C[str]) -> str: ... + @cached_property + def x(self) -> int | str: + return self.value + +def broad_write(value: Broad) -> None: + value.x = "unsafe" + +def narrow_write(value: Narrow) -> None: + value.x = 42 + +def narrow_string_write(value: NarrowString) -> None: + value.x = "safe" + +def read(value: ReadOnly) -> int | str: + return value.x + +def check(value: C[int]) -> None: + assert_type(value.x, int) + broad_write(value) # E: Argument `C[int]` is not assignable + narrow_write(value) + read(value) + value.x = "unsafe" # E: `Literal['unsafe']` is not assignable to attribute `x` with type `int` + value.x = 42 + assert_type(value.x, int) + +def check_string(value: C[str]) -> None: + assert_type(value.x, str) + broad_write(value) # E: Argument `C[str]` is not assignable + narrow_string_write(value) + read(value) + value.x = 42 # E: `Literal[42]` is not assignable to attribute `x` with type `str` + value.x = "safe" + assert_type(value.x, str) + "#, +); + +testcase!( + test_cached_property_recursive_self_protocol, + r#" +from functools import cached_property +from typing import Protocol + +class P(Protocol): + x: str + marker: int + +class WrongValue: + marker: int = 0 + + @cached_property + def x(self: P) -> int: + return 1 + +class Compatible: + marker: int = 0 + + @cached_property + def x(self: P) -> str: + return "value" + +class WrongMarker: + marker: str = "wrong" + + @cached_property + def x(self: P) -> str: + return "value" + +def consume(value: P) -> None: + value.x = "replacement" + +def check(good: Compatible, bad: WrongValue, wrong: WrongMarker) -> None: + consume(good) + consume(bad) # E: Argument `WrongValue` is not assignable + consume(wrong) # E: Argument `WrongMarker` is not assignable + consume(good) + consume(bad) # E: Argument `WrongValue` is not assignable + "#, +); + +testcase!( + test_cached_property_no_applicable_overload, + r#" +from functools import cached_property +from typing import Any, Protocol, overload + +class Writable(Protocol): + x: bytes + +class ReadOnly(Protocol): + @property + def x(self) -> bytes: ... + +class C[T]: + @cached_property + @overload + def x(self: C[int]) -> int: ... + @cached_property + @overload + def x(self: C[str]) -> str: ... + @cached_property + def x(self) -> int | str: + return 1 + +class Dynamic: + @cached_property + def x(self) -> Any: + return b"value" + +def write(value: Writable) -> None: + value.x = b"replacement" + +def read(value: ReadOnly) -> bytes: + return value.x + +def check(invalid: C[float], dynamic: Dynamic) -> None: + write(invalid) # E: Argument `C[float]` is not assignable + read(invalid) # E: Argument `C[float]` is not assignable + write(dynamic) + read(dynamic) + "#, +); + +testcase!( + test_cached_property_literal_override, + r#" +from functools import cached_property +from typing import Literal, assert_type + +class Base: + @cached_property + def x(self): + return True + +class Child(Base): + @cached_property + def x(self): + return False + +class LiteralParent: + @property + def x(self) -> Literal[True]: + return True + +class Incompatible(LiteralParent): + @cached_property + def x(self): # E: Class member `Incompatible.x` overrides parent class `LiteralParent` in an inconsistent manner + return True + +def check(value: Child) -> None: + assert_type(value.x, bool) + "#, +); + +testcase!( + test_cached_property_read_only_parent, + r#" +from dataclasses import dataclass +from functools import cached_property + +@dataclass(frozen=True) +class Base: + x: int + +class Compatible(Base): + @cached_property + def x(self) -> bool: + return True + +class Incompatible(Base): + @cached_property + def x(self) -> str: # pyrefly: ignore[bad-override-mutable-attribute] # E: Class member `Incompatible.x` overrides parent class `Base` in an inconsistent manner + return "wrong" + "#, +); + testcase!( test_inherit_type_attribute, r#" diff --git a/pyrefly/lib/test/descriptors.rs b/pyrefly/lib/test/descriptors.rs index ae867427a9..df5da704c5 100644 --- a/pyrefly/lib/test/descriptors.rs +++ b/pyrefly/lib/test/descriptors.rs @@ -291,6 +291,67 @@ def f(c: C) -> None: "#, ); +testcase!( + test_cached_property_literal_value_type, + r#" +from functools import cached_property +from typing import Literal, assert_type + +class C: + @cached_property + def inferred(self): + return True + + @cached_property + def annotated(self) -> Literal[True]: + return True + + @property + def readonly(self): + return True + +def check(value: C) -> None: + assert_type(value.inferred, bool) + value.inferred = False + assert_type(value.inferred, bool) + assert_type(value.annotated, Literal[True]) + value.annotated = False # E: `Literal[False]` is not assignable to attribute `annotated` with type `Literal[True]` + assert_type(value.readonly, Literal[True]) + "#, +); + +testcase!( + test_cached_property_inner_literal_decorator, + r#" +from collections.abc import Callable +from functools import cached_property +from typing import Literal, assert_type + +def preserve_true[F: Callable[..., Literal[True]]](function: F) -> F: + return function + +def declare_true[**P](function: Callable[P, Literal[True]]) -> Callable[P, Literal[True]]: + return function + +class C: + @cached_property + @preserve_true + def inferred(self): + return True + + @cached_property + @declare_true + def declared(self): + return True + +def check(value: C) -> None: + assert_type(value.inferred, bool) + value.inferred = False + assert_type(value.declared, Literal[True]) + value.declared = False # E: `Literal[False]` is not assignable to attribute `declared` with type `Literal[True]` + "#, +); + testcase!( test_property_decorated_with_lru_cache, r#" From b07ad9196babdec8bc1c1011c57f51d30087a6a4 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Fri, 23 Oct 2026 21:24:01 +0900 Subject: [PATCH 2/2] fix --- pyrefly/lib/alt/attr.rs | 11 ++++++++++- pyrefly/lib/alt/class/class_field.rs | 22 ++++++++++++++++++++++ pyrefly/lib/alt/function.rs | 7 +++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/pyrefly/lib/alt/attr.rs b/pyrefly/lib/alt/attr.rs index 2fa299857d..5d58bd9720 100644 --- a/pyrefly/lib/alt/attr.rs +++ b/pyrefly/lib/alt/attr.rs @@ -1553,7 +1553,16 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { attr_name.clone(), )); } - if matches!(got_attr, Attribute::GetAttr(..)) { + let is_cached_property = matches!( + got_attr, + Attribute::ClassAttribute(ClassAttribute::Property(getter, None, _)) + if getter + .toplevel_func_metadata() + .is_some_and(|meta| meta.flags.is_cached_property) + ); + // A cached getter can check a protocol-typed receiver through a fresh + // subtype check, so it needs the same guard as dynamic fallback. + if matches!(got_attr, Attribute::GetAttr(..)) || is_cached_property { let guard_key = (got.clone(), protocol.clone(), attr_name.clone()); if self.enter_protocol_member_check(guard_key.clone()) { continue; diff --git a/pyrefly/lib/alt/class/class_field.rs b/pyrefly/lib/alt/class/class_field.rs index eedd8656f2..28cdfb3630 100644 --- a/pyrefly/lib/alt/class/class_field.rs +++ b/pyrefly/lib/alt/class/class_field.rs @@ -79,6 +79,7 @@ use crate::error::context::ErrorContext; use crate::error::context::TypeCheckContext; use crate::error::context::TypeCheckKind; use crate::error::signature_diff::render_signature_diff; +use crate::error::style::ErrorStyle; use crate::solver::solver::SubsetError; use crate::types::annotation::Annotation; use crate::types::annotation::Qualifier; @@ -5956,6 +5957,27 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { }) }) } + ( + ClassAttribute::Property(getter, None, _), + ClassAttribute::ReadWrite(..) | ClassAttribute::ReadOnly(..), + ) if getter + .toplevel_func_metadata() + .is_some_and(|meta| meta.flags.is_cached_property) => + { + // An invalid getter's recovery type is not evidence of compatibility. + let errors = ErrorCollector::new(self.module().dupe(), ErrorStyle::Delayed); + let value = + self.call_property_getter(getter.clone(), TextRange::default(), &errors, None); + if errors.has_hard() { + return Err(Box::new(AttrSubsetError::NoAccess)); + } + self.is_class_attribute_subset( + &ClassAttribute::read_write(value), + want, + allow_property_override, + is_subset, + ) + } ( ClassAttribute::Property(_, _, _), ClassAttribute::ReadOnly(..) | ClassAttribute::ReadWrite(..), diff --git a/pyrefly/lib/alt/function.rs b/pyrefly/lib/alt/function.rs index 9da5011974..dc1d500bbe 100644 --- a/pyrefly/lib/alt/function.rs +++ b/pyrefly/lib/alt/function.rs @@ -1077,6 +1077,13 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { ); } } + if def.metadata.flags.is_cached_property && def.metadata.flags.is_return_inferred { + // Inner decorators see the original return type. The completed getter describes + // the writable cached value, so inferred literals must be widened. + ty.transform_toplevel_callable_signatures(|callable, _| { + callable.ret = callable.ret.clone().promote_implicit_literals(self.stdlib); + }); + } self.validate_singledispatch_dispatcher_signature( &ty, DispatcherDef {