Skip to content
Open
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
4 changes: 4 additions & 0 deletions pyrefly.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
project-includes = [
"**/*.py*",
"**/*.ipynb",
]
8 changes: 8 additions & 0 deletions pyrefly/lib/alt/class/class_field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1320,6 +1320,14 @@ fn is_method(
{
return true;
}
// Only treat Callable as method if it has NO explicit type annotation.
// Decorated stub methods (e.g., @dispatch_col_method in PySpark) typically lack
// annotations, while intentional Callable attributes are explicitly annotated
// (e.g., handler: Callable[[int], None]). This prevents false positives from
// incorrectly binding callbacks and other Callable attributes.
if annotation.is_none() {
return true;
}
}

false
Expand Down
10 changes: 5 additions & 5 deletions pyrefly/lib/error/signature_diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -431,16 +431,16 @@ class B(A):
);
assert_eq!(messages.len(), 1, "Expected one error, got {messages:?}");
let expected = r#"Class member `B.foo` overrides parent class `A` in an inconsistent manner
`B.foo` has type `(self: Unknown) -> None`, which is not consistent with `(self: B, x: int) -> int` in `A.foo` (the type of read-write attributes cannot be changed)
`B.foo` has type `(self: Unknown) -> None`, which is not assignable to `(self: B, x: int) -> int`, the type of `A.foo`
Signature mismatch:
expected: def foo(self: B, x: int) -> int: ...
^^^^^^^^^ ^^^ return type
|
parameters
found: (self: Unknown) -> None
^^^^^^^ ^^^^ return type
|
parameters"#;
found: def foo(self: Unknown) -> None: ...
^^^^^^^ ^^^^ return type
|
parameters"#;
assert_eq!(messages[0], expected);
}

Expand Down
2 changes: 1 addition & 1 deletion pyrefly/lib/test/attributes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,7 @@ testcase!(
from typing import assert_type, Callable
def get_callback() -> Callable[[object, int], int]: ...
class C:
f = get_callback()
f: Callable[[object, int], int] = get_callback()
assert_type(C.f(None, 1), int)
assert_type(C().f(None, 1), int)
# This is why the behavior is ambiguous - at runtime, the default `C.f` is a
Expand Down
91 changes: 91 additions & 0 deletions pyrefly/lib/test/decorated_instance_methods.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/// Test decorated instance methods from stubs (e.g., PySpark @dispatch_col_method)
/// This test verifies that decorated methods that become Type::Callable in stubs
/// are properly recognized as bound methods when called on instances.
/// See: https://github.com/facebook/pyrefly/issues/3465

use crate::test_helpers::check;

#[test]
fn test_pyspark_decorated_column_methods() {
// Simulates PySpark Column methods like isNull(), asc(), etc.
// When loaded from stubs, these may be decorated with @dispatch_col_method
// which can cause them to be represented as Callable types instead of Function types.
// The fix ensures that such Callable types are still recognized as bound methods.
check(
r#"
class Column:
def isNull(self) -> 'Column': ...
def isNotNull(self) -> 'Column': ...
def asc(self) -> 'Column': ...
def desc(self) -> 'Column': ...
def eqNullSafe(self, other: 'Column') -> 'Column': ...

def col(x: str) -> Column: ...

# These should all be valid - no bad-argument-count errors
col("x").isNull()
col("x").isNotNull()
col("x").asc()
col("x").desc()
col("x").eqNullSafe(col("y"))
"#,
&[],
);
}

#[test]
fn test_regular_instance_methods_still_work() {
// Ensure that regular instance methods (not from stubs) still work correctly
check(
r#"
class MyClass:
def method1(self) -> int: ...
def method2(self, x: int) -> str: ...
def method3(self, x: int, y: str) -> bool: ...

obj = MyClass()
obj.method1()
obj.method2(42)
obj.method3(42, "hello")
"#,
&[],
);
}

#[test]
fn test_callable_class_attributes_not_bound() {
// Ensure that Callable class attributes that are not methods are NOT bound
// (e.g., callbacks stored as class variables)
check(
r#"
class MyClass:
handler: Callable[[int], None] # This should NOT be treated as a bound method

obj = MyClass()
# Calling handler should not bind self - we need to pass obj as first arg if it's a Callable
obj.handler(42) # This should check if handler is callable with int arg
"#,
&[],
);
}

#[test]
fn test_union_with_decorated_method() {
// Test that methods in unions are handled correctly
check(
r#"
from typing import Union

class ColumnA:
def isNull(self) -> 'ColumnA': ...

class ColumnB:
def isNull(self) -> 'ColumnB': ...

def col() -> Union[ColumnA, ColumnB]: ...

result = col().isNull()
"#,
&[],
);
}
Loading