diff --git a/conformance/third_party/conformance.exp b/conformance/third_party/conformance.exp index 1aac9aca9..da036fb40 100644 --- a/conformance/third_party/conformance.exp +++ b/conformance/third_party/conformance.exp @@ -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, @@ -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, diff --git a/conformance/third_party/conformance.result b/conformance/third_party/conformance.result index 0998c1298..230e6b022 100644 --- a/conformance/third_party/conformance.result +++ b/conformance/third_party/conformance.result @@ -349,7 +349,7 @@ "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", @@ -357,7 +357,6 @@ "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", diff --git a/conformance/third_party/results.json b/conformance/third_party/results.json index 48c008e83..643d30640 100644 --- a/conformance/third_party/results.json +++ b/conformance/third_party/results.json @@ -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, diff --git a/crates/pyrefly_config/src/error_kind.rs b/crates/pyrefly_config/src/error_kind.rs index 778f70f81..c5751ab68 100644 --- a/crates/pyrefly_config/src/error_kind.rs +++ b/crates/pyrefly_config/src/error_kind.rs @@ -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, diff --git a/crates/pyrefly_types/src/callable.rs b/crates/pyrefly_types/src/callable.rs index c87b1bd3a..c0cace4d6 100644 --- a/crates/pyrefly_types/src/callable.rs +++ b/crates/pyrefly_types/src/callable.rs @@ -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, + /// A function decorated with `@abc.abstractmethod` + pub is_abstract_method: bool, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] diff --git a/pyrefly/lib/alt/call.rs b/pyrefly/lib/alt/call.rs index b2eb29b61..13fb72005 100644 --- a/pyrefly/lib/alt/call.rs +++ b/pyrefly/lib/alt/call.rs @@ -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, @@ -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 diff --git a/pyrefly/lib/alt/function.rs b/pyrefly/lib/alt/function.rs index 182441aca..79d89402c 100644 --- a/pyrefly/lib/alt/function.rs +++ b/pyrefly/lib/alt/function.rs @@ -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| { @@ -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 @@ -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 { diff --git a/pyrefly/lib/test/abstract_method_calls.rs b/pyrefly/lib/test/abstract_method_calls.rs new file mode 100644 index 000000000..00e7b97e2 --- /dev/null +++ b/pyrefly/lib/test/abstract_method_calls.rs @@ -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 +"#, +); diff --git a/pyrefly/lib/test/mod.rs b/pyrefly/lib/test/mod.rs index 06701a256..837233c22 100644 --- a/pyrefly/lib/test/mod.rs +++ b/pyrefly/lib/test/mod.rs @@ -7,6 +7,7 @@ #![cfg(test)] +mod abstract_method_calls; mod assign; mod attribute_narrow; mod attributes; diff --git a/website/docs/error-kinds.mdx b/website/docs/error-kinds.mdx index fcdb51347..c36bcdd4f 100644 --- a/website/docs/error-kinds.mdx +++ b/website/docs/error-kinds.mdx @@ -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