diff --git a/crates/red_knot_python_semantic/resources/mdtest/protocols.md b/crates/red_knot_python_semantic/resources/mdtest/protocols.md index c011cff751bbaa..966cfcebabcf2f 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/protocols.md +++ b/crates/red_knot_python_semantic/resources/mdtest/protocols.md @@ -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()` + + + +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 diff --git a/crates/red_knot_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Invalid_calls_to_`get_protocol_members()`.snap b/crates/red_knot_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Invalid_calls_to_`get_protocol_members()`.snap new file mode 100644 index 00000000000000..59d90c9e36fe59 --- /dev/null +++ b/crates/red_knot_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Invalid_calls_to_`get_protocol_members()`.snap @@ -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# + +``` diff --git a/crates/red_knot_python_semantic/src/types/diagnostic.rs b/crates/red_knot_python_semantic/src/types/diagnostic.rs index e35f19fea99fd7..7ae368c491bb92 100644 --- a/crates/red_knot_python_semantic/src/types/diagnostic.rs +++ b/crates/red_knot_python_semantic/src/types/diagnostic.rs @@ -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, diff --git a/crates/red_knot_python_semantic/src/types/infer.rs b/crates/red_knot_python_semantic/src/types/infer.rs index cfa297d1278f06..e07f17d7ac7b2a 100644 --- a/crates/red_knot_python_semantic/src/types/infer.rs +++ b/crates/red_knot_python_semantic/src/types/infer.rs @@ -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, + ); + } + } + } _ => {} } }