Skip to content
Merged
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
41 changes: 32 additions & 9 deletions crates/red_knot_python_semantic/resources/mdtest/protocols.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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#

```
50 changes: 48 additions & 2 deletions crates/red_knot_python_semantic/src/types/diagnostic.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
16 changes: 15 additions & 1 deletion crates/red_knot_python_semantic/src/types/infer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
);
}
}
}
_ => {}
}
}
Expand Down
Loading