Skip to content

Commit 40d432b

Browse files
committed
flag abstract base classes with no abstract methods
1 parent 264b247 commit 40d432b

16 files changed

Lines changed: 189 additions & 39 deletions

File tree

docs/benefits-over-pyright/new-diagnostic-rules.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,40 @@ class FooImpl(AbstractFoo, ABC):
191191
print("hi")
192192
```
193193

194+
## `reportEmptyAbstractUsage`
195+
196+
pyright only reports an error when you instantiate an abstract class that has unimplemented abstract methods. but a class that explicitly extends `ABC` (or uses `ABCMeta`) with no abstract methods can also be instantiated, and pyright has no issue with that:
197+
198+
```py
199+
from abc import ABC
200+
201+
202+
class Foo(ABC):
203+
"""abstract class with no abstract methods"""
204+
205+
206+
foo = Foo() # no error
207+
```
208+
209+
but the author of the class likely intended this class not to be used directly, and instead subtyped. so if a class extends `ABC` but defines no abstract methods, instantiating it is likely unintentional.
210+
211+
the `reportEmptyAbstractUsage` rule flags such instantiations. note that it only applies to classes that _directly_ extend `ABC` (or use `ABCMeta`), not to their subclasses, since subclasses may be intentionally concrete:
212+
213+
```py
214+
from abc import ABC
215+
216+
217+
class AbstractFoo(ABC):
218+
"""abstract class with no abstract methods"""
219+
220+
221+
class ConcreteFoo(AbstractFoo): ...
222+
223+
224+
foo = AbstractFoo() # error: reportEmptyAbstractUsage
225+
bar = ConcreteFoo() # no error
226+
```
227+
194228
## `reportIncompatibleUnannotatedOverride`
195229

196230
pyright's `reportIncompatibleVariableOverride` rule checks for class attribute overrides with an incompatible type:

docs/configuration/config-files.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,8 @@ The following settings allow more fine grained control over the **typeCheckingMo
304304

305305
- <a name="reportImplicitAbstractClass"></a> **reportImplicitAbstractClass** [boolean or string, optional]: Diagnostics for classes that extend abstract classes without also explicitly declaring themselves as abstract or implementing all of the required abstract methods. [more info](../benefits-over-pyright/new-diagnostic-rules.md#reportimplicitabstractclass)
306306

307+
- <a name="reportEmptyAbstractUsage"></a> **reportEmptyAbstractUsage** [boolean or string, optional]: Diagnostics for classes that have no abstract methods, but explicitly extend `ABC` or `ABCMeta`. [more info](../benefits-over-pyright/new-diagnostic-rules.md#reportemptyabstractusage)
308+
307309
- <a name="reportIncompatibleUnannotatedOverride"></a> **reportIncompatibleUnannotatedOverride** [boolean or string, optional]: Generate or suppress diagnostics for class variable declarations that override a symbol of the same name in a base class with a type that is incompatible with the base class symbol type, when the base class' symbol does not have a type annotation. [more info](../benefits-over-pyright/new-diagnostic-rules.md#reportincompatibleunannotatedoverride)
308310

309311
- <a name="reportUnannotatedClassAttribute"></a> **reportUnannotatedClassAttribute** [boolean or string, optional]: Generate or suppress diagnostics for class attribute declarations that do not have a type annotation. These are unsafe unless `reportIncompatibleUnannotatedOverride` is enabled. [more info](../benefits-over-pyright/new-diagnostic-rules.md#reportunannotatedclassattribute)
@@ -312,7 +314,6 @@ The following settings allow more fine grained control over the **typeCheckingMo
312314

313315
- <a name="reportSelfClsDefault"></a> **reportSelfClsDefault** [boolean or string, optional]: Generate or suppress diagnostics for a class or instance method having a default value for the first parameter.
314316

315-
316317
## Execution Environment Options
317318
Pyright allows multiple “execution environments” to be defined for different portions of your source tree. For example, a subtree may be designed to run with different import search paths or a different version of the python interpreter than the rest of the source base.
318319

packages/pyright-internal/src/analyzer/checker.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5281,11 +5281,8 @@ export class Checker extends ParseTreeWalker {
52815281
} else {
52825282
const baseClasses = classType.shared.baseClasses.filter(isClass);
52835283
if (
5284-
(classType.shared.declaredMetaclass?.category !== TypeCategory.Class ||
5285-
!ClassType.isBuiltIn(classType.shared.declaredMetaclass, 'ABCMeta')) &&
5286-
!baseClasses.some(
5287-
(baseClass) => baseClass.shared.fullName === 'abc.ABC' || ClassType.isBuiltIn(baseClass, 'Protocol')
5288-
)
5284+
!ClassType.isDirectSubtypeOfAbstractClass(classType) &&
5285+
!baseClasses.some((baseClass) => ClassType.isBuiltIn(baseClass, 'Protocol'))
52895286
) {
52905287
const errorMessage = classType.shared.mro.some(
52915288
(baseClass) => isClass(baseClass) && ClassType.isBuiltIn(baseClass, 'Protocol')

packages/pyright-internal/src/analyzer/typeEvaluator.ts

Lines changed: 40 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10787,42 +10787,52 @@ export function createTypeEvaluator(
1078710787
if (ClassType.supportsAbstractMethods(expandedCallType)) {
1078810788
const abstractSymbols = getAbstractSymbols(expandedCallType);
1078910789

10790-
if (
10791-
abstractSymbols.length > 0 &&
10792-
!expandedCallType.priv.includeSubclasses &&
10793-
!isTypeVar(unexpandedCallType)
10794-
) {
10795-
// If the class is abstract, it can't be instantiated.
10796-
const diagAddendum = new DiagnosticAddendum();
10797-
const errorsToDisplay = 2;
10790+
if (!expandedCallType.priv.includeSubclasses && !isTypeVar(unexpandedCallType)) {
10791+
// Handle abstract classes with abstract methods
10792+
if (abstractSymbols.length > 0) {
10793+
// If the class is abstract, it can't be instantiated.
10794+
const diagAddendum = new DiagnosticAddendum();
10795+
const errorsToDisplay = 2;
1079810796

10799-
abstractSymbols.forEach((abstractMethod, index) => {
10800-
if (index === errorsToDisplay) {
10801-
diagAddendum.addMessage(
10802-
LocAddendum.memberIsAbstractMore().format({
10803-
count: abstractSymbols.length - errorsToDisplay,
10804-
})
10805-
);
10806-
} else if (index < errorsToDisplay) {
10807-
if (isInstantiableClass(abstractMethod.classType)) {
10808-
const className = abstractMethod.classType.shared.name;
10797+
abstractSymbols.forEach((abstractMethod, index) => {
10798+
if (index === errorsToDisplay) {
1080910799
diagAddendum.addMessage(
10810-
LocAddendum.memberIsAbstract().format({
10811-
type: className,
10812-
name: abstractMethod.symbolName,
10800+
LocAddendum.memberIsAbstractMore().format({
10801+
count: abstractSymbols.length - errorsToDisplay,
1081310802
})
1081410803
);
10804+
} else if (index < errorsToDisplay) {
10805+
if (isInstantiableClass(abstractMethod.classType)) {
10806+
const className = abstractMethod.classType.shared.name;
10807+
diagAddendum.addMessage(
10808+
LocAddendum.memberIsAbstract().format({
10809+
type: className,
10810+
name: abstractMethod.symbolName,
10811+
})
10812+
);
10813+
}
1081510814
}
10816-
}
10817-
});
10815+
});
1081810816

10819-
addDiagnostic(
10820-
DiagnosticRule.reportAbstractUsage,
10821-
LocMessage.instantiateAbstract().format({
10822-
type: expandedCallType.shared.name,
10823-
}) + diagAddendum.getString(),
10824-
errorNode
10825-
);
10817+
addDiagnostic(
10818+
DiagnosticRule.reportAbstractUsage,
10819+
LocMessage.instantiateAbstract().format({
10820+
type: expandedCallType.shared.name,
10821+
}) + diagAddendum.getString(),
10822+
errorNode
10823+
);
10824+
} else if (ClassType.isDirectSubtypeOfAbstractClass(expandedCallType)) {
10825+
// Handle abstract classes with no abstract methods
10826+
const diag = new DiagnosticAddendum();
10827+
diag.addMessage(LocAddendum.classIsExplicitlyAbstract());
10828+
addDiagnostic(
10829+
DiagnosticRule.reportEmptyAbstractUsage,
10830+
LocMessage.instantiateAbstract().format({
10831+
type: expandedCallType.shared.name,
10832+
}) + diag.getString(),
10833+
errorNode
10834+
);
10835+
}
1082610836
}
1082710837
}
1082810838

packages/pyright-internal/src/analyzer/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1175,6 +1175,18 @@ export namespace ClassType {
11751175
return !!(classType.shared.flags & ClassTypeFlags.SupportsAbstractMethods);
11761176
}
11771177

1178+
export function isDirectSubtypeOfAbstractClass(classType: ClassType): boolean {
1179+
const derivesDirectlyFromABC = classType.shared.baseClasses.some(
1180+
(baseClass) => isClass(baseClass) && baseClass.shared.fullName === 'abc.ABC'
1181+
);
1182+
const hasABCMetaMetaclass =
1183+
classType.shared.declaredMetaclass !== undefined &&
1184+
isClass(classType.shared.declaredMetaclass) &&
1185+
classType.shared.declaredMetaclass.shared.fullName === 'abc.ABCMeta';
1186+
1187+
return derivesDirectlyFromABC || hasABCMetaMetaclass;
1188+
}
1189+
11781190
export function isDataClass(classType: ClassType) {
11791191
return !!classType.shared.dataClassBehaviors;
11801192
}

packages/pyright-internal/src/common/configOptions.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,9 @@ export interface DiagnosticRuleSet {
189189
// Report use of abstract method or variable?
190190
reportAbstractUsage: DiagnosticLevel;
191191

192+
// Report instantiation of abstract class with no abstract methods?
193+
reportEmptyAbstractUsage: DiagnosticLevel;
194+
192195
// Report argument type incompatibilities?
193196
reportArgumentType: DiagnosticLevel;
194197

@@ -481,6 +484,7 @@ export function getDiagLevelDiagnosticRules() {
481484
DiagnosticRule.reportDuplicateImport,
482485
DiagnosticRule.reportWildcardImportFromLibrary,
483486
DiagnosticRule.reportAbstractUsage,
487+
DiagnosticRule.reportEmptyAbstractUsage,
484488
DiagnosticRule.reportArgumentType,
485489
DiagnosticRule.reportAssertTypeFailure,
486490
DiagnosticRule.reportAssignmentType,
@@ -617,6 +621,7 @@ export function getOffDiagnosticRuleSet(): DiagnosticRuleSet {
617621
reportDuplicateImport: 'none',
618622
reportWildcardImportFromLibrary: 'none',
619623
reportAbstractUsage: 'none',
624+
reportEmptyAbstractUsage: 'none',
620625
reportArgumentType: 'none',
621626
reportAssertTypeFailure: 'none',
622627
reportAssignmentType: 'none',
@@ -737,6 +742,7 @@ export function getBasicDiagnosticRuleSet(): DiagnosticRuleSet {
737742
reportDuplicateImport: 'none',
738743
reportWildcardImportFromLibrary: 'warning',
739744
reportAbstractUsage: 'error',
745+
reportEmptyAbstractUsage: 'error',
740746
reportArgumentType: 'error',
741747
reportAssertTypeFailure: 'error',
742748
reportAssignmentType: 'error',
@@ -857,6 +863,7 @@ export function getStandardDiagnosticRuleSet(): DiagnosticRuleSet {
857863
reportDuplicateImport: 'none',
858864
reportWildcardImportFromLibrary: 'warning',
859865
reportAbstractUsage: 'error',
866+
reportEmptyAbstractUsage: 'error',
860867
reportArgumentType: 'error',
861868
reportAssertTypeFailure: 'error',
862869
reportAssignmentType: 'error',
@@ -976,6 +983,7 @@ export const getRecommendedDiagnosticRuleSet = (): DiagnosticRuleSet => ({
976983
reportDuplicateImport: 'warning',
977984
reportWildcardImportFromLibrary: 'warning',
978985
reportAbstractUsage: 'error',
986+
reportEmptyAbstractUsage: 'error',
979987
reportArgumentType: 'error',
980988
reportAssertTypeFailure: 'error',
981989
reportAssignmentType: 'error',
@@ -1092,6 +1100,7 @@ export const getAllDiagnosticRuleSet = (): DiagnosticRuleSet => ({
10921100
reportDuplicateImport: 'error',
10931101
reportWildcardImportFromLibrary: 'error',
10941102
reportAbstractUsage: 'error',
1103+
reportEmptyAbstractUsage: 'error',
10951104
reportArgumentType: 'error',
10961105
reportAssertTypeFailure: 'error',
10971106
reportAssignmentType: 'error',
@@ -1209,6 +1218,7 @@ export function getStrictDiagnosticRuleSet(): DiagnosticRuleSet {
12091218
reportDuplicateImport: 'error',
12101219
reportWildcardImportFromLibrary: 'error',
12111220
reportAbstractUsage: 'error',
1221+
reportEmptyAbstractUsage: 'error',
12121222
reportArgumentType: 'error',
12131223
reportAssertTypeFailure: 'error',
12141224
reportAssignmentType: 'error',

packages/pyright-internal/src/common/diagnosticRules.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export enum DiagnosticRule {
3838
reportDuplicateImport = 'reportDuplicateImport',
3939
reportWildcardImportFromLibrary = 'reportWildcardImportFromLibrary',
4040
reportAbstractUsage = 'reportAbstractUsage',
41+
reportEmptyAbstractUsage = 'reportEmptyAbstractUsage',
4142
reportArgumentType = 'reportArgumentType',
4243
reportAssertTypeFailure = 'reportAssertTypeFailure',
4344
reportAssignmentType = 'reportAssignmentType',

packages/pyright-internal/src/localization/localize.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1393,6 +1393,7 @@ export namespace Localizer {
13931393
);
13941394
export const memberIsAbstractMore = () =>
13951395
new ParameterizedString<{ count: number }>(getRawString('DiagnosticAddendum.memberIsAbstractMore'));
1396+
export const classIsExplicitlyAbstract = () => getRawString('DiagnosticAddendum.classIsExplicitlyAbstract');
13961397
export const memberIsClassVarInProtocol = () =>
13971398
new ParameterizedString<{ name: string }>(getRawString('DiagnosticAddendum.memberIsClassVarInProtocol'));
13981399
export const memberIsInitVar = () =>

packages/pyright-internal/src/localization/package.nls.en-us.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1934,6 +1934,9 @@
19341934
"message": "and {count} more...",
19351935
"comment": "{StrEnds='...'}"
19361936
},
1937+
"classIsExplicitlyAbstract": {
1938+
"message": "class has no abstract members, but is explicitly denoted with \"ABC\" or \"ABCMeta\""
1939+
},
19371940
"memberIsClassVarInProtocol": {
19381941
"message": "\"{name}\" is defined as a ClassVar in protocol",
19391942
"comment": "{Locked='ClassVar'}"

packages/pyright-internal/src/tests/checker.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,22 @@ test('AbstractClass11', () => {
128128
TestUtils.validateResults(analysisResults, 2);
129129
});
130130

131+
test('AbstractClass12', () => {
132+
const analysisResults = TestUtils.typeAnalyzeSampleFiles(['abstractClass12.py']);
133+
134+
TestUtils.validateResultsButBased(analysisResults, {
135+
// one error to validate the message, the rest use `pyright: ignore`
136+
errors: [
137+
{
138+
line: 11,
139+
message:
140+
'Cannot instantiate abstract class "ExplicitlyAbstract"\n  class has no abstract members, but is explicitly denoted with "ABC" or "ABCMeta"',
141+
code: DiagnosticRule.reportEmptyAbstractUsage,
142+
},
143+
],
144+
});
145+
});
146+
131147
test('Constants1', () => {
132148
const analysisResults = TestUtils.typeAnalyzeSampleFiles(['constants1.py']);
133149

0 commit comments

Comments
 (0)