diff --git a/conformance/third_party/conformance.exp b/conformance/third_party/conformance.exp index 8d1da0a01..a797388d3 100644 --- a/conformance/third_party/conformance.exp +++ b/conformance/third_party/conformance.exp @@ -8187,6 +8187,16 @@ "name": "bad-assignment", "stop_column": 36, "stop_line": 56 + }, + { + "code": -2, + "column": 17, + "concise_description": "Cannot instantiate abstract class `Concrete7B`", + "description": "Cannot instantiate abstract class `Concrete7B`", + "line": 172, + "name": "bad-instantiation", + "stop_column": 19, + "stop_line": 172 } ], "protocols_generic.py": [ diff --git a/conformance/third_party/conformance.result b/conformance/third_party/conformance.result index 21ae31902..69941bd9a 100644 --- a/conformance/third_party/conformance.result +++ b/conformance/third_party/conformance.result @@ -360,7 +360,8 @@ "Line 90: Expected 1 errors", "Line 110: Expected 1 errors", "Line 135: Expected 1 errors", - "Line 165: Expected 1 errors" + "Line 165: Expected 1 errors", + "Line 172: Unexpected errors ['Cannot instantiate abstract class `Concrete7B`']" ], "protocols_generic.py": [ "Line 144: Unexpected errors ['`ConcreteHasProperty1` is not assignable to `HasPropertyProto`']" diff --git a/conformance/third_party/results.json b/conformance/third_party/results.json index 01476d7c4..4c5b03320 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": 7, "protocols_generic.py": 1, "protocols_merging.py": 1, "protocols_modules.py": 1, diff --git a/crates/pyrefly_types/src/callable.rs b/crates/pyrefly_types/src/callable.rs index 698d408e9..f8484b2cb 100644 --- a/crates/pyrefly_types/src/callable.rs +++ b/crates/pyrefly_types/src/callable.rs @@ -244,6 +244,8 @@ pub struct FuncFlags { pub has_enum_member_decoration: bool, pub is_override: bool, pub has_final_decoration: bool, + /// A function decorated with `@abc.abstractmethod` + pub is_abstract_method: bool, /// A function decorated with `typing.dataclass_transform(...)`, turning it into a /// `dataclasses.dataclass`-like decorator. Stores the keyword values passed to the /// `dataclass_transform` call. See diff --git a/pyrefly/lib/alt/call.rs b/pyrefly/lib/alt/call.rs index 91c7e11a3..866555bcb 100644 --- a/pyrefly/lib/alt/call.rs +++ b/pyrefly/lib/alt/call.rs @@ -396,6 +396,18 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { hint: Option<&Type>, ) -> Type { // Based on https://typing.readthedocs.io/en/latest/spec/constructors.html. + let instance_ty = Type::ClassType(cls.clone()); + + // Check if trying to instantiate an abstract class + if self.has_abstract_methods(&cls) { + self.error( + errors, + range, + ErrorInfo::Kind(ErrorKind::BadInstantiation), + format!("Cannot instantiate abstract class `{}`", cls.name()), + ); + } + if let Some(hint) = hint { self.solver() .freshen_class_targs(cls.targs_mut(), self.uniques); diff --git a/pyrefly/lib/alt/class/class_field.rs b/pyrefly/lib/alt/class/class_field.rs index 5c5261762..86b8aa84f 100644 --- a/pyrefly/lib/alt/class/class_field.rs +++ b/pyrefly/lib/alt/class/class_field.rs @@ -1782,4 +1782,117 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { .and_then(|ty| make_bound_method(Type::type_form(cls.clone().to_type()), ty).ok()) } } + + /// Check if a class inherits from abc.ABC + /// + /// This recursively traverses the class hierarchy to determine if the class + /// inherits from Python's abstract base class (abc.ABC). Only classes that + /// inherit from abc.ABC can be considered abstract in Python. + fn inherits_from_abc(&self, cls: &ClassType) -> bool { + let class_obj = cls.class_object(); + + // Check if this class is abc.ABC itself + if class_obj.module_name().as_str() == "abc" && class_obj.name().as_str() == "ABC" { + return true; + } + + // Recursively check base classes + let metadata = self.get_metadata_for_class(class_obj); + for base in metadata.bases_with_metadata() { + if self.inherits_from_abc(&base.0) { + return true; + } + } + + false + } + + /// Check if a class has any unimplemented abstract methods + /// + /// Returns true if the class: + /// 1. Inherits from abc.ABC (or is abc.ABC itself) + /// 2. Has abstract methods in its hierarchy that are not implemented + /// + /// This method is used to prevent instantiation of abstract classes. + pub fn has_abstract_methods(&self, cls: &ClassType) -> bool { + // Only classes that inherit from abc.ABC can be abstract + if !self.inherits_from_abc(cls) { + return false; + } + + // Check for unimplemented abstract methods with early exit + self.has_unimplemented_abstract_methods(cls) + } + + /// Check if a class has unimplemented abstract methods + fn has_unimplemented_abstract_methods(&self, cls: &ClassType) -> bool { + let mut abstract_methods = SmallSet::new(); + + // Collect all abstract methods from the hierarchy + self.collect_abstract_methods_from_class(cls, &mut abstract_methods); + + // Check each abstract method for concrete implementation + for method_name in &abstract_methods { + if !self.class_provides_concrete_implementation(cls, method_name) { + return true; + } + } + + false + } + + /// Recursively collect abstract methods from a class and its bases + fn collect_abstract_methods_from_class( + &self, + cls: &ClassType, + abstract_methods: &mut SmallSet, + ) { + let class_obj = cls.class_object(); + let metadata = self.get_metadata_for_class(class_obj); + + // First collect from base classes + for base in metadata.bases_with_metadata() { + self.collect_abstract_methods_from_class(&base.0, abstract_methods); + } + + // Then collect abstract methods from this class + for field_name in class_obj.fields() { + if let Some(field) = self.get_from_class( + class_obj, + &KeyClassField(class_obj.index(), field_name.clone()), + ) { + match &field.0 { + ClassFieldInner::Simple { ty, .. } => { + if let Type::Function(function) = ty { + if function.metadata.flags.is_abstract_method { + abstract_methods.insert(field_name.clone()); + } + } + } + } + } + } + } + + /// Check if a class provides a concrete (non-abstract) implementation of a method + fn class_provides_concrete_implementation(&self, cls: &ClassType, method_name: &Name) -> bool { + let class_obj = cls.class_object(); + + // Check if the class has this method + if let Some(field) = self.get_from_class( + class_obj, + &KeyClassField(class_obj.index(), method_name.clone()), + ) { + match &field.0 { + ClassFieldInner::Simple { ty, .. } => { + if let Type::Function(function) = ty { + // Method exists - check if it's concrete (not abstract) + return !function.metadata.flags.is_abstract_method; + } + } + } + } + + false // Method not found or not a function + } } diff --git a/pyrefly/lib/alt/function.rs b/pyrefly/lib/alt/function.rs index d7cf6daca..7348b769f 100644 --- a/pyrefly/lib/alt/function.rs +++ b/pyrefly/lib/alt/function.rs @@ -201,6 +201,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { let mut has_enum_member_decoration = false; let mut is_override = false; let mut has_final_decoration = false; + let mut is_abstract_method = false; let mut dataclass_transform_metadata = None; let decorators = decorators .iter() @@ -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 @@ -504,6 +509,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { has_enum_member_decoration, is_override, has_final_decoration, + is_abstract_method, dataclass_transform_metadata, }, }; diff --git a/pyrefly/lib/test/abstract_class_instantiation.rs b/pyrefly/lib/test/abstract_class_instantiation.rs new file mode 100644 index 000000000..0eed3d960 --- /dev/null +++ b/pyrefly/lib/test/abstract_class_instantiation.rs @@ -0,0 +1,103 @@ +/* + * 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_class_instantiation_error, + r#" +from abc import ABC, abstractmethod + +class Shape(ABC): + @abstractmethod + def area(self) -> float: + pass + +# This should error - cannot instantiate abstract class +shape = Shape() # E: Cannot instantiate abstract class `Shape` +"#, +); + +testcase!( + test_concrete_subclass_instantiation_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 + +# This should work - concrete subclass can be instantiated +circle = Circle() +"#, +); + +testcase!( + test_polymorphic_calls_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 + +def calculate_area(shape: Shape) -> float: + # This should work - polymorphic call is allowed + return shape.area() + +circle = Circle() +area = calculate_area(circle) +"#, +); + +testcase!( + test_multiple_abstract_methods, + r#" +from abc import ABC, abstractmethod + +class Drawable(ABC): + @abstractmethod + def draw(self) -> None: + pass + + @abstractmethod + def erase(self) -> None: + pass + +# This should error - class has multiple abstract methods +drawable = Drawable() # E: Cannot instantiate abstract class `Drawable` +"#, +); + +testcase!( + test_inherited_abstract_method, + r#" +from abc import ABC, abstractmethod + +class Base(ABC): + @abstractmethod + def method(self) -> None: + pass + +class Child(Base): + # Child doesn't implement method, so it's still abstract + pass + +# This should error - child class is still abstract +child = Child() # E: Cannot instantiate abstract class `Child` +"#, +); diff --git a/pyrefly/lib/test/mod.rs b/pyrefly/lib/test/mod.rs index 06701a256..3faba0010 100644 --- a/pyrefly/lib/test/mod.rs +++ b/pyrefly/lib/test/mod.rs @@ -7,6 +7,7 @@ #![cfg(test)] +mod abstract_class_instantiation; mod assign; mod attribute_narrow; mod attributes; diff --git a/website/docs/error-kinds.mdx b/website/docs/error-kinds.mdx index fcdb51347..2f3b5c0ae 100644 --- a/website/docs/error-kinds.mdx +++ b/website/docs/error-kinds.mdx @@ -170,12 +170,19 @@ For example, argument order is enforced by the parser, so `def f(x: int = 1, y: ## bad-instantiation -This error occurs when attempting to instantiate a class that cannot be instantiated, such as a protocol: +This error occurs when attempting to instantiate a class that cannot be instantiated, such as a protocol or an abstract class: ```python from typing import Protocol class C(Protocol): ... C() # bad-instantiation + +from abc import ABC, abstractmethod +class Shape(ABC): + @abstractmethod + def area(self) -> float: + pass +Shape() # bad-instantiation ``` ## bad-keyword-argument