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
20 changes: 20 additions & 0 deletions conformance/third_party/conformance.exp
Original file line number Diff line number Diff line change
Expand Up @@ -7994,6 +7994,16 @@
"stop_column": 17,
"stop_line": 26
},
{
"code": -2,
"column": 22,
"concise_description": "Cannot call abstract method `Proto.meth` - must be implemented in a subclass",
"description": "Cannot call abstract method `Proto.meth` - must be implemented in a subclass",
"line": 26,
"name": "abstract-method-call",
"stop_column": 24,
"stop_line": 26
},
{
"code": -2,
"column": 16,
Expand Down Expand Up @@ -8218,6 +8228,16 @@
}
],
"protocols_explicit.py": [
{
"code": -2,
"column": 28,
"concise_description": "Cannot call abstract method `PColor.draw` - must be implemented in a subclass",
"description": "Cannot call abstract method `PColor.draw` - must be implemented in a subclass",
"line": 27,
"name": "abstract-method-call",
"stop_column": 30,
"stop_line": 27
},
{
"code": -2,
"column": 20,
Expand Down
3 changes: 1 addition & 2 deletions conformance/third_party/conformance.result
Original file line number Diff line number Diff line change
Expand Up @@ -349,15 +349,14 @@
"Line 106: Expected 1 errors",
"Line 107: Expected 1 errors",
"Line 108: Expected 1 errors",
"Line 26: Unexpected errors ['Cannot instantiate `Proto` because it is a protocol']"
"Line 26: Unexpected errors ['Cannot instantiate `Proto` because it is a protocol', 'Cannot call abstract method `Proto.meth` - must be implemented in a subclass']"
],
"protocols_definition.py": [
"Line 116: Expected 1 errors",
"Line 117: Expected 1 errors",
"Line 79: Unexpected errors ['`Concrete` is not assignable to `Template`']"
],
"protocols_explicit.py": [
"Line 27: Expected 1 errors",
"Line 60: Expected 1 errors",
"Line 90: Expected 1 errors",
"Line 110: Expected 1 errors",
Expand Down
2 changes: 1 addition & 1 deletion conformance/third_party/results.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@
"overloads_evaluation.py": 14,
"protocols_class_objects.py": 7,
"protocols_definition.py": 3,
"protocols_explicit.py": 6,
"protocols_explicit.py": 5,
"protocols_generic.py": 1,
"protocols_merging.py": 1,
"protocols_modules.py": 1,
Expand Down
3 changes: 3 additions & 0 deletions crates/pyrefly_config/src/error_kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ impl Severity {
#[derive(Display, Sequence, Deserialize, Serialize, ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub enum ErrorKind {
/// Attempting to call an abstract method directly.
/// Abstract methods must be implemented in concrete subclasses.
AbstractMethodCall,
/// Attempting to annotate a name with incompatible annotations.
/// e.g. when a name is annotated in multiple branches of an if statement
AnnotationMismatch,
Expand Down
2 changes: 2 additions & 0 deletions crates/pyrefly_types/src/callable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,8 @@ pub struct FuncFlags {
/// `dataclass_transform` call. See
/// https://typing.python.org/en/latest/spec/dataclasses.html#specification.
pub dataclass_transform_metadata: Option<DataclassTransformKeywords>,
/// A function decorated with `@abc.abstractmethod`
pub is_abstract_method: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
Expand Down
22 changes: 22 additions & 0 deletions pyrefly/lib/alt/call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ struct CalledOverload {
}

impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
/// Check if a module is a builtin module that should be exempt from abstract method validation
fn is_builtin_module(&self, module: pyrefly_python::module_name::ModuleName) -> bool {
matches!(
module.as_str(),
"builtins" | "abc" | "typing" | "typing_extensions"
)
}
fn error_call_target(
&self,
errors: &ErrorCollector,
Expand Down Expand Up @@ -535,6 +542,21 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
),
);
}
// Check for abstract method calls
if let Some(m) = metadata
&& m.flags.is_abstract_method
&& !self.is_builtin_module(m.kind.as_func_id().module)
{
self.error(
errors,
range,
ErrorInfo::new(ErrorKind::AbstractMethodCall, context),
format!(
"Cannot call abstract method `{}` - must be implemented in a subclass",
m.kind.as_func_id().format(self.module().name())
),
);
}
// Does this call target correspond to a function whose keyword arguments we should save?
let kw_metadata = {
if let Some(m) = metadata
Expand Down
6 changes: 6 additions & 0 deletions pyrefly/lib/alt/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
let mut is_override = false;
let mut has_final_decoration = false;
let mut dataclass_transform_metadata = None;
let mut is_abstract_method = false;
let decorators = decorators
.iter()
.filter(|k| {
Expand Down Expand Up @@ -236,6 +237,10 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
has_final_decoration = true;
false
}
Some(CalleeKind::Function(FunctionKind::AbstractMethod)) => {
is_abstract_method = true;
false
}
_ if matches!(decorator_ty, Type::ClassType(cls) if cls.has_qname("warnings", "deprecated")) => {
is_deprecated = true;
false
Expand Down Expand Up @@ -505,6 +510,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
is_override,
has_final_decoration,
dataclass_transform_metadata,
is_abstract_method,
},
};
let mut ty = Forallable::Function(Function {
Expand Down
159 changes: 159 additions & 0 deletions pyrefly/lib/test/abstract_method_calls.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

use crate::testcase;

testcase!(
test_abstract_method_call_error,
r#"
from abc import ABC, abstractmethod

class Shape(ABC):
@abstractmethod
def area(self) -> float:
pass

shape = Shape()
shape.area() # E: Cannot call abstract method `Shape.area` - must be implemented in a subclass
"#,
);

testcase!(
test_abstract_method_call_direct,
r#"
from abc import ABC, abstractmethod

class Shape(ABC):
@abstractmethod
def area(self) -> float:
pass

Shape.area(Shape()) # E: Cannot call abstract method `Shape.area` - must be implemented in a subclass
"#,
);

testcase!(
test_abstract_static_method_call,
r#"
from abc import ABC, abstractmethod

class Shape(ABC):
@staticmethod
@abstractmethod
def create() -> "Shape":
pass

Shape.create() # E: Cannot call abstract method `Shape.create` - must be implemented in a subclass
"#,
);

testcase!(
test_abstract_class_method_call,
r#"
from abc import ABC, abstractmethod

class Shape(ABC):
@classmethod
@abstractmethod
def default_shape(cls) -> "Shape":
pass

Shape.default_shape() # E: Cannot call abstract method `Shape.default_shape` - must be implemented in a subclass
"#,
);

testcase!(
test_abstract_method_call_super,
r#"
from abc import ABC, abstractmethod

class Shape(ABC):
@abstractmethod
def area(self) -> float:
pass

class Circle(Shape):
def area(self) -> float:
return super().area() # E: Cannot call abstract method `Shape.area` - must be implemented in a subclass

circle = Circle()
"#,
);

testcase!(
test_concrete_method_call_ok,
r#"
from abc import ABC, abstractmethod

class Shape(ABC):
@abstractmethod
def area(self) -> float:
pass

class Circle(Shape):
def area(self) -> float:
return 3.14

circle = Circle()
circle.area() # This should be fine
"#,
);

testcase!(
test_multiple_abstract_methods,
r#"
from abc import ABC, abstractmethod

class Shape(ABC):
@abstractmethod
def area(self) -> float:
pass

@abstractmethod
def perimeter(self) -> float:
pass

shape = Shape()
shape.area() # E: Cannot call abstract method `Shape.area` - must be implemented in a subclass
shape.perimeter() # E: Cannot call abstract method `Shape.perimeter` - must be implemented in a subclass
"#,
);

testcase!(
test_abstract_property,
r#"
from abc import ABC, abstractmethod

class Shape(ABC):
@property
@abstractmethod
def name(self) -> str:
pass

shape = Shape()
_ = shape.name # E: Cannot call abstract method `Shape.name` - must be implemented in a subclass
"#,
);

testcase!(
test_inheritance_chain,
r#"
from abc import ABC, abstractmethod

class Shape(ABC):
@abstractmethod
def area(self) -> float:
pass

class Polygon(Shape):
# Still abstract, doesn't implement area
pass

polygon = Polygon()
polygon.area() # E: Cannot call abstract method `Shape.area` - must be implemented in a subclass
"#,
);
1 change: 1 addition & 0 deletions pyrefly/lib/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

#![cfg(test)]

mod abstract_method_calls;
mod assign;
mod attribute_narrow;
mod attributes;
Expand Down
32 changes: 32 additions & 0 deletions website/docs/error-kinds.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,38 @@ that an error is related to. Every error has exactly one kind.
The main use of error kinds is as short names ("slugs") that can be used in
error suppression comments.

## abstract-method-call

This error is raised when attempting to call an abstract method directly.
Abstract methods, decorated with `@abc.abstractmethod`, must be implemented
in concrete subclasses before they can be called.

```python
from abc import ABC, abstractmethod

class Shape(ABC):
@abstractmethod
def area(self) -> float:
pass

shape = Shape() # This would fail at runtime
shape.area() # Cannot call abstract method `area` - must be implemented in a subclass [abstract-method-call]
```

To fix this error, create a concrete subclass that implements the abstract method:

```python
class Circle(Shape):
def __init__(self, radius: float):
self.radius = radius

def area(self) -> float:
return 3.14159 * self.radius ** 2

circle = Circle(5.0)
circle.area() # This is valid
```

## annotation-mismatch

This error indicates a mismatch between multiple annotations for a single
Expand Down
Loading