Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion pyrefly/lib/alt/attr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
22 changes: 22 additions & 0 deletions pyrefly/lib/alt/class/class_field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(..),
Expand Down
7 changes: 7 additions & 0 deletions pyrefly/lib/alt/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
285 changes: 285 additions & 0 deletions pyrefly/lib/test/class_overrides.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"
Expand Down
Loading
Loading