Skip to content
Open
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
15 changes: 14 additions & 1 deletion packages/pyright-internal/src/analyzer/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,20 @@ export function applyClassDecorator(
}

if (FunctionType.isBuiltIn(decoratorType, 'runtime_checkable')) {
originalClassType.shared.flags |= ClassTypeFlags.RuntimeCheckable;
// Class decorators are applied bottom-up, so validate the class type
// that this decorator actually receives rather than the original
// (undecorated) class.
const decoratedClassType = isInstantiableClass(inputClassType) ? inputClassType : originalClassType;

if (!ClassType.isProtocolClass(decoratedClassType)) {
evaluator.addDiagnostic(
DiagnosticRule.reportGeneralTypeIssues,
LocMessage.runtimeCheckableNotProtocol(),
decoratorNode.d.expr
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning · Non-blocking recommendation

Class decorators are applied bottom-up, but this validates originalClassType rather than the type produced by decorators below @runtime_checkable. A class-replacing decorator could therefore allow a source Protocol even though runtime_checkable receives a non-protocol class at runtime. Validate the currently decorated class and add a composition regression test.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning · Non-blocking recommendation

When vendoring this change into pyrx, mirror it in packages/pylance-internal/src/analyzer/decorators.ts, which still sets RuntimeCheckable unconditionally, and add async-mode regression coverage. Otherwise Pylance's async evaluator can retain the old behavior.

} else {
originalClassType.shared.flags |= ClassTypeFlags.RuntimeCheckable;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning · Non-blocking recommendation

When a lower decorator returns a non-runtime-checkable protocol, this validates the replacement type but sets RuntimeCheckable on the discarded original class. Set the flag on the validated decorated class and add coverage using a replacement protocol that is not already runtime-checkable.

// Don't call getTypeOfDecorator for runtime_checkable. It appears
// frequently in stubs, and it's a waste of time to validate its
Expand Down
1 change: 1 addition & 0 deletions packages/pyright-internal/src/localization/localize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,7 @@ export namespace Localizer {
getRawString('Diagnostic.returnTypeMismatch')
);
export const returnTypeUnknown = () => getRawString('Diagnostic.returnTypeUnknown');
export const runtimeCheckableNotProtocol = () => getRawString('Diagnostic.runtimeCheckableNotProtocol');
export const returnTypePartiallyUnknown = () =>
new ParameterizedString<{ returnType: string }>(getRawString('Diagnostic.returnTypePartiallyUnknown'));
export const revealLocalsArgs = () => getRawString('Diagnostic.revealLocalsArgs');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,10 @@
"returnTypeMismatch": "Type \"{exprType}\" is not assignable to return type \"{returnType}\"",
"returnTypePartiallyUnknown": "Return type, \"{returnType}\", is partially unknown",
"returnTypeUnknown": "Return type is unknown",
"runtimeCheckableNotProtocol": {
"message": "@runtime_checkable can be applied only to a Protocol class",
"comment": "{Locked='@runtime_checkable','Protocol'}"
},
"revealLocalsArgs": {
"message": "Expected no arguments for \"reveal_locals\" call",
"comment": "{Locked='reveal_locals'}"
Expand Down
49 changes: 49 additions & 0 deletions packages/pyright-internal/src/tests/samples/protocol54.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# This sample tests that @runtime_checkable can be applied only to
# classes that are protocols (Protocol must appear in the base list).

from typing import Protocol, runtime_checkable


@runtime_checkable
class P1(Protocol):
def foo(self) -> int: ...


# This should generate an error because a subclass of a protocol is
# not itself a protocol unless Protocol is listed as a base class.
@runtime_checkable
class P2(P1):
def bar(self) -> str: ...


@runtime_checkable
class P3(P1, Protocol):
def bar(self) -> str: ...


# This should generate an error because C1 is not a protocol.
@runtime_checkable
class C1:
def foo(self) -> int: ...


# Class decorators are applied bottom-up, so runtime_checkable receives
# the class produced by the decorator below it.
def replace_with_protocol(cls: type) -> type[P1]: ...


def replace_with_non_protocol(cls: type) -> type[C1]: ...


@runtime_checkable
@replace_with_protocol
class C2:
pass


# This should generate an error because the decorator below
# runtime_checkable replaces the class with a non-protocol class.
@runtime_checkable
@replace_with_non_protocol
class P4(Protocol):
pass
13 changes: 13 additions & 0 deletions packages/pyright-internal/src/tests/typeEvaluator7.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
* arbitrarily among multiple files so they can run in parallel.
*/

import assert from 'assert';

import { ConfigOptions } from '../common/configOptions';
import {
pythonVersion3_10,
Expand Down Expand Up @@ -628,6 +630,17 @@ test('Protocol53', () => {
TestUtils.validateResults(analysisResults2, 8);
});

test('Protocol54', () => {
const analysisResults = TestUtils.typeAnalyzeSampleFiles(['protocol54.py']);

TestUtils.validateResults(analysisResults, 3);

// Verify that the errors are reported on the expected `@runtime_checkable`
// decorators rather than on some other (unrelated) declaration.
const errorLines = analysisResults[0].errors.map((diag) => diag.range.start.line).sort((a, b) => a - b);
assert.deepStrictEqual(errorLines, [13, 24, 45]);
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Info · Optional note

The aggregate diagnostic count can pass if diagnostics move from P2 and C1 to either valid control. Assert diagnostic locations or identities so the test proves exactly the two invalid declarations are rejected.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Info · Optional note

This checks only the aggregate diagnostic count, so unrelated diagnostics could satisfy the test. Assert the diagnostic message and decorator ranges for both invalid classes if the harness supports it.

[verified]


test('ProtocolExplicit1', () => {
const analysisResults = TestUtils.typeAnalyzeSampleFiles(['protocolExplicit1.py']);

Expand Down
Loading