Skip to content

Commit ed01fb5

Browse files
yeetypetemeta-codesync[bot]
authored andcommitted
feat: let ProxyMethod resolve callable attribute targets (#4915)
Summary: Let `ProxyMethod` resolve a target declared as a class attribute, in addition to an ordinary instance method. A target is accepted whenever pyrefly can call it: `Callable[...]` annotations, callback protocols, and `type[X]` constructors all work but non-callable targets such as `forward: int` are still rejected. This lets the shape stubs declare `nn.Module.forward` as `Callable[..., Any]`, matching PyTorch: https://github.com/pytorch/pytorch/blob/d6c03540dc0f040e1d8a920d19d9333de6513545/torch/nn/modules/module.py#L526 The stubs previously declared `forward` as a method because that was the only target `ProxyMethod` could resolve. Under `strict-callable-subtyping`, a method-form `forward(*args, **kwargs)` makes every concrete `forward(self, x)` override a `bad-override`. Pull Request resolved: #4915 Test Plan: Run `test.py`. Reviewed By: stroxler Differential Revision: D119805915 fbshipit-source-id: 5a7c941d69f3794e7896ee2bf0b3af9bd89e73a6
1 parent 6c8fffd commit ed01fb5

7 files changed

Lines changed: 134 additions & 10 deletions

File tree

pyrefly/lib/alt/attr.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,8 @@ pub enum NoAccessReason {
286286
SuperMethodNeedsImplementation(Class),
287287
/// A proxy method was accessed on the class object rather than an instance.
288288
ProxyMethodClassAccess(Class),
289-
/// A proxy method declaration exists, but its target cannot be used as an instance method.
289+
/// A proxy method declaration exists, but its target is neither an ordinary instance method
290+
/// nor a class attribute whose type is callable.
290291
ProxyMethodTargetInvalid { class: Class, target: Name },
291292
}
292293

pyrefly/lib/alt/class/class_field.rs

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ use crate::alt::answers_solver::AnswersSolver;
5555
use crate::alt::attr::AttrSubsetError;
5656
use crate::alt::attr::ClassBase;
5757
use crate::alt::attr::NoAccessReason;
58+
use crate::alt::call::CallTargetLookup;
5859
use crate::alt::callable::CallArg;
5960
use crate::alt::expr::TypeOrExpr;
6061
use crate::alt::types::class_bases::ClassBases;
@@ -2705,6 +2706,20 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> {
27052706
}
27062707
}
27072708

2709+
/// A `ProxyMethod` target is either an ordinary instance method or a class
2710+
/// attribute whose type is callable (e.g. `torch.nn.Module` declares
2711+
/// `forward: Callable[..., Any]`).
2712+
fn is_proxy_method_target(&self, field: &ClassFieldInner) -> bool {
2713+
match field {
2714+
ClassFieldInner::Method { ty, .. } => Self::is_ordinary_instance_method_type(ty),
2715+
ClassFieldInner::ClassAttribute { ty, .. } => matches!(
2716+
self.as_call_target(self.normalize_attr_ty(ty.clone())),
2717+
CallTargetLookup::Ok(_)
2718+
),
2719+
_ => false,
2720+
}
2721+
}
2722+
27082723
fn is_ordinary_instance_method_type(ty: &Type) -> bool {
27092724
ty.toplevel_func_metadata()
27102725
.is_some_and(&|metadata: &FuncMetadata| {
@@ -3547,13 +3562,7 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> {
35473562
}
35483563
ClassFieldInner::ProxyMethod { target, .. } => {
35493564
match self.get_class_member(instance.class, &target) {
3550-
Some(target_field)
3551-
if matches!(
3552-
&target_field.0,
3553-
ClassFieldInner::Method { ty, .. }
3554-
if Self::is_ordinary_instance_method_type(ty)
3555-
) =>
3556-
{
3565+
Some(target_field) if self.is_proxy_method_target(&target_field.0) => {
35573566
self.as_instance_attribute(&target, target_field.as_ref(), instance)
35583567
}
35593568
_ => ClassAttribute::no_access(NoAccessReason::ProxyMethodTargetInvalid {

pyrefly/lib/test/protocol.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,62 @@ Base()() # E: Proxy method `__call__` of class `Base` cannot resolve target met
436436
"#,
437437
);
438438

439+
testcase!(
440+
test_proxy_method_callable_attribute_target,
441+
proxy_method_env(),
442+
r#"
443+
from typing import Any, Callable, Protocol, assert_type
444+
from shape_extensions import ProxyMethod
445+
446+
class CallableTarget:
447+
__call__: ProxyMethod["forward"]
448+
forward: Callable[[int], str]
449+
450+
assert_type(CallableTarget()(1), str)
451+
CallableTarget()("bad") # E: `Literal['bad']` is not assignable to parameter with type `int`
452+
453+
class Callback(Protocol):
454+
def __call__(self, x: int) -> str: ...
455+
456+
class ProtocolTarget:
457+
__call__: ProxyMethod["forward"]
458+
forward: Callback
459+
460+
assert_type(ProtocolTarget()(1), str)
461+
462+
class GradualCallableTarget:
463+
__call__: ProxyMethod["forward"]
464+
forward: Callable[..., Any]
465+
466+
assert_type(GradualCallableTarget()(1, "two", three=3), Any)
467+
"#,
468+
);
469+
470+
testcase!(
471+
test_proxy_method_callable_attribute_target_strict_override,
472+
proxy_method_env().enable_strict_callable_subtyping(),
473+
r#"
474+
from typing import Any, Callable, assert_type
475+
from shape_extensions import ProxyMethod
476+
477+
class AttributeBase:
478+
__call__: ProxyMethod["forward"]
479+
forward: Callable[..., Any]
480+
481+
class AttributeChild(AttributeBase):
482+
def forward(self, x: int) -> str: ...
483+
484+
assert_type(AttributeChild()(1), str)
485+
486+
class MethodBase:
487+
__call__: ProxyMethod["forward"]
488+
def forward(self, *args: Any, **kwargs: Any) -> Any: ...
489+
490+
class MethodChild(MethodBase):
491+
def forward(self, x: int) -> str: ... # E: overrides parent class `MethodBase` in an inconsistent manner
492+
"#,
493+
);
494+
439495
testcase!(
440496
test_proxy_method_rejects_proxy_chain_and_self_reference,
441497
proxy_method_env(),

tensor-shapes/pyrefly-torch-stubs/suites.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@
2020
# torch runtime tests are separate unittest modules under test/runtime_tests.
2121
SUITES: list[Suite] = [
2222
Suite(name="torch-examples", patterns=("examples/*.py", "examples/runtime/*.py")),
23-
Suite(name="torch-positive", patterns=("test/test_*.py",)),
23+
Suite(
24+
name="torch-positive",
25+
patterns=("test/test_*.py",),
26+
strict_callable_subtyping=True,
27+
),
2428
Suite(
2529
name="torch-negative",
2630
patterns=("test/negative_tests/test_*.py",),
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
#
3+
# This source code is licensed under the MIT license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
6+
"""Test `nn.Module.forward` declared as a callable attribute."""
7+
8+
from typing import Any, assert_type, override, TYPE_CHECKING
9+
10+
import torch
11+
import torch.nn as nn
12+
from shape_extensions import Int, IntVar
13+
14+
if TYPE_CHECKING:
15+
from torch import Tensor
16+
17+
18+
class LinearLayer[N: IntVar, M: IntVar](nn.Module):
19+
def __init__(self, n: Int[N], m: Int[M]) -> None:
20+
super().__init__()
21+
self.linear = nn.Linear(n, m)
22+
23+
@override
24+
def forward[B: IntVar](self, x: Tensor[[B, N]]) -> Tensor[[B, M]]:
25+
return self.linear(x)
26+
27+
28+
class Passthrough(nn.Module):
29+
"""Transform-style module, like torchvision's `Transform.forward(self, *inputs)`."""
30+
31+
@override
32+
def forward(self, *inputs: Any) -> Any:
33+
return inputs
34+
35+
36+
def test_forward_override_keeps_call_proxy() -> None:
37+
x: Tensor[[16, 6]] = torch.randn(16, 6)
38+
layer = LinearLayer(6, 9)
39+
assert_type(layer(x), Tensor[[16, 9]])
40+
assert_type(layer.forward(x), Tensor[[16, 9]])
41+
42+
43+
def test_call_through_base_module_type() -> None:
44+
x: Tensor[[16, 6]] = torch.randn(16, 6)
45+
module: nn.Module = LinearLayer(6, 9)
46+
assert_type(module(x), Any)
47+
48+
49+
def test_forward_override_with_star_args() -> None:
50+
module = Passthrough()
51+
module(torch.zeros(2), torch.zeros(3))

tensor-shapes/pyrefly-torch-stubs/torch-stubs/nn/__init__.pyi

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ class Module:
157157
def __getattr__(self, name: str) -> Any: ...
158158
def __setattr__(self, name: str, value: Any) -> None: ...
159159
__call__: ProxyMethod["forward"]
160-
def forward(self, *args: Any, **kwargs: Any) -> Any: ...
160+
forward: Callable[..., Any]
161161
def extra_repr(self) -> str: ...
162162
def register_buffer(
163163
self, name: str, tensor: Tensor | None, persistent: bool = True

tensor-shapes/shape_testing.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ class Suite:
6969
# are otherwise suppressed, which is why the torch corpus enables it only
7070
# for the dedicated negative-test directories.
7171
expectations: bool = False
72+
strict_callable_subtyping: bool = False
7273

7374
def files(self, package_root: Path) -> list[str]:
7475
paths = sorted(
@@ -252,6 +253,8 @@ def check_suites(
252253
]
253254
if suite.expectations:
254255
command.append("--expectations")
256+
if suite.strict_callable_subtyping:
257+
command.append("--strict-callable-subtyping=true")
255258
for search_path in (
256259
*suite.extra_search_paths,
257260
package_root,

0 commit comments

Comments
 (0)