[red-knot] Emit a diagnostic if a non-protocol is passed to get_protocol_members (#17551)
Some checks are pending
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (windows) (push) Blocked by required conditions
CI / mkdocs (push) Waiting to run
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (release) (push) Waiting to run
CI / cargo build (msrv) (push) Blocked by required conditions
CI / cargo fuzz build (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks (push) Blocked by required conditions
[Knot Playground] Release / publish (push) Waiting to run

This commit is contained in:
Alex Waygood 2025-04-23 11:13:20 +01:00 committed by GitHub
parent f9c7908bb7
commit 0a1f9d090e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 177 additions and 12 deletions

View file

@ -375,15 +375,6 @@ class Foo(Protocol):
reveal_type(get_protocol_members(Foo)) # revealed: @Todo(specialized non-generic class)
```
Calling `get_protocol_members` on a non-protocol class raises an error at runtime:
```py
class NotAProtocol: ...
# TODO: should emit `[invalid-protocol]` error, should reveal `Unknown`
reveal_type(get_protocol_members(NotAProtocol)) # revealed: @Todo(specialized non-generic class)
```
Certain special attributes and methods are not considered protocol members at runtime, and should
not be considered protocol members by type checkers either:
@ -423,6 +414,38 @@ class Baz2(Bar, Foo, Protocol): ...
reveal_type(get_protocol_members(Baz2)) # revealed: @Todo(specialized non-generic class)
```
## Invalid calls to `get_protocol_members()`
<!-- snapshot-diagnostics -->
Calling `get_protocol_members` on a non-protocol class raises an error at runtime:
```toml
[environment]
python-version = "3.12"
```
```py
from typing_extensions import Protocol, get_protocol_members
class NotAProtocol: ...
get_protocol_members(NotAProtocol) # error: [invalid-argument-type]
class AlsoNotAProtocol(NotAProtocol, object): ...
get_protocol_members(AlsoNotAProtocol) # error: [invalid-argument-type]
```
The original class object must be passed to the function; a specialised version of a generic version
does not suffice:
```py
class GenericProtocol[T](Protocol): ...
get_protocol_members(GenericProtocol[int]) # TODO: should emit a diagnostic here (https://github.com/astral-sh/ruff/issues/17549)
```
## Subtyping of protocols with attribute members
In the following example, the protocol class `HasX` defines an interface such that any other fully

View file

@ -0,0 +1,82 @@
---
source: crates/red_knot_test/src/lib.rs
expression: snapshot
---
---
mdtest name: protocols.md - Protocols - Invalid calls to `get_protocol_members()`
mdtest path: crates/red_knot_python_semantic/resources/mdtest/protocols.md
---
# Python source files
## mdtest_snippet.py
```
1 | from typing_extensions import Protocol, get_protocol_members
2 |
3 | class NotAProtocol: ...
4 |
5 | get_protocol_members(NotAProtocol) # error: [invalid-argument-type]
6 |
7 | class AlsoNotAProtocol(NotAProtocol, object): ...
8 |
9 | get_protocol_members(AlsoNotAProtocol) # error: [invalid-argument-type]
10 | class GenericProtocol[T](Protocol): ...
11 |
12 | get_protocol_members(GenericProtocol[int]) # TODO: should emit a diagnostic here (https://github.com/astral-sh/ruff/issues/17549)
```
# Diagnostics
```
error: lint:invalid-argument-type: Invalid argument to `get_protocol_members`
--> /src/mdtest_snippet.py:5:1
|
3 | class NotAProtocol: ...
4 |
5 | get_protocol_members(NotAProtocol) # error: [invalid-argument-type]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ This call will raise `TypeError` at runtime
6 |
7 | class AlsoNotAProtocol(NotAProtocol, object): ...
|
info: Only protocol classes can be passed to `get_protocol_members`
info: `NotAProtocol` is declared here, but it is not a protocol class:
--> /src/mdtest_snippet.py:3:7
|
1 | from typing_extensions import Protocol, get_protocol_members
2 |
3 | class NotAProtocol: ...
| ^^^^^^^^^^^^
4 |
5 | get_protocol_members(NotAProtocol) # error: [invalid-argument-type]
|
info: A class is only a protocol class if it directly inherits from `typing.Protocol` or `typing_extensions.Protocol`
info: See https://typing.python.org/en/latest/spec/protocol.html#
```
```
error: lint:invalid-argument-type: Invalid argument to `get_protocol_members`
--> /src/mdtest_snippet.py:9:1
|
7 | class AlsoNotAProtocol(NotAProtocol, object): ...
8 |
9 | get_protocol_members(AlsoNotAProtocol) # error: [invalid-argument-type]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ This call will raise `TypeError` at runtime
10 | class GenericProtocol[T](Protocol): ...
|
info: Only protocol classes can be passed to `get_protocol_members`
info: `AlsoNotAProtocol` is declared here, but it is not a protocol class:
--> /src/mdtest_snippet.py:7:7
|
5 | get_protocol_members(NotAProtocol) # error: [invalid-argument-type]
6 |
7 | class AlsoNotAProtocol(NotAProtocol, object): ...
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
8 |
9 | get_protocol_members(AlsoNotAProtocol) # error: [invalid-argument-type]
|
info: A class is only a protocol class if it directly inherits from `typing.Protocol` or `typing_extensions.Protocol`
info: See https://typing.python.org/en/latest/spec/protocol.html#
```

View file

@ -1,4 +1,5 @@
use super::context::InferContext;
use super::ClassLiteralType;
use crate::declare_lint;
use crate::lint::{Level, LintRegistryBuilder, LintStatus};
use crate::suppression::FileSuppressionId;
@ -8,9 +9,9 @@ use crate::types::string_annotation::{
RAW_STRING_TYPE_ANNOTATION,
};
use crate::types::{KnownInstanceType, Type};
use ruff_db::diagnostic::{Annotation, Diagnostic, Span};
use ruff_db::diagnostic::{Annotation, Diagnostic, Severity, Span, SubDiagnostic};
use ruff_python_ast::{self as ast, AnyNodeRef};
use ruff_text_size::Ranged;
use ruff_text_size::{Ranged, TextRange};
use rustc_hash::FxHashSet;
use std::fmt::Formatter;
@ -1313,6 +1314,51 @@ pub(crate) fn report_invalid_arguments_to_annotated(
));
}
pub(crate) fn report_bad_argument_to_get_protocol_members(
context: &InferContext,
call: &ast::ExprCall,
class: ClassLiteralType,
) {
let Some(builder) = context.report_lint(&INVALID_ARGUMENT_TYPE, call) else {
return;
};
let db = context.db();
let mut diagnostic = builder.into_diagnostic("Invalid argument to `get_protocol_members`");
diagnostic.set_primary_message("This call will raise `TypeError` at runtime");
diagnostic.info("Only protocol classes can be passed to `get_protocol_members`");
let class_scope = class.body_scope(db);
let class_node = class_scope.node(db).expect_class();
let class_name = &class_node.name;
let class_def_diagnostic_range = TextRange::new(
class_name.start(),
class_node
.arguments
.as_deref()
.map(Ranged::end)
.unwrap_or_else(|| class_name.end()),
);
let mut class_def_diagnostic = SubDiagnostic::new(
Severity::Info,
format_args!("`{class_name}` is declared here, but it is not a protocol class:"),
);
class_def_diagnostic.annotate(Annotation::primary(
Span::from(class_scope.file(db)).with_range(class_def_diagnostic_range),
));
diagnostic.sub(class_def_diagnostic);
diagnostic.info(
"A class is only a protocol class if it directly inherits \
from `typing.Protocol` or `typing_extensions.Protocol`",
);
// TODO the typing spec isn't really designed as user-facing documentation,
// but there isn't really any user-facing documentation that covers this specific issue well
// (it's not described well in the CPython docs; and PEP-544 is a snapshot of a decision taken
// years ago rather than up-to-date documentation). We should either write our own docs
// describing this well or contribute to type-checker-agnostic docs somewhere and link to those.
diagnostic.info("See https://typing.python.org/en/latest/spec/protocol.html#");
}
pub(crate) fn report_invalid_arguments_to_callable(
context: &InferContext,
subscript: &ast::ExprSubscript,

View file

@ -96,7 +96,8 @@ use crate::Db;
use super::context::{InNoTypeCheck, InferContext};
use super::diagnostic::{
report_index_out_of_bounds, report_invalid_exception_caught, report_invalid_exception_cause,
report_bad_argument_to_get_protocol_members, report_index_out_of_bounds,
report_invalid_exception_caught, report_invalid_exception_cause,
report_invalid_exception_raised, report_invalid_type_checking_constant,
report_non_subscriptable, report_possibly_unresolved_reference, report_slice_step_size_zero,
report_unresolved_reference, INVALID_METACLASS, INVALID_PROTOCOL, REDUNDANT_CAST,
@ -4486,6 +4487,19 @@ impl<'db> TypeInferenceBuilder<'db> {
}
}
}
KnownFunction::GetProtocolMembers => {
if let [Some(Type::ClassLiteral(class))] =
overload.parameter_types()
{
if !class.is_protocol(self.db()) {
report_bad_argument_to_get_protocol_members(
&self.context,
call_expression,
*class,
);
}
}
}
_ => {}
}
}