From b662c3ff7ea7ccf9bb8bd4d0814a20c082c7c244 Mon Sep 17 00:00:00 2001 From: David Peter Date: Tue, 8 Apr 2025 09:31:49 +0200 Subject: [PATCH] [red-knot] Add support for `assert_never` (#17287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary We already have partial "support" for `assert_never`, because it is annotated as ```pyi def assert_never(arg: Never, /) -> Never: ... ``` in typeshed. So we already emit a `invalid-argument-type` diagnostic if the argument type to `assert_never` is not assignable to `Never`. That is not enough, however. Gradual types like `Any`, `Unknown`, `@Todo(…)` or `Any & int` can be assignable to `Never`. Which means that we didn't issue any diagnostic in those cases. Also, it seems like `assert_never` deserves a dedicated diagnostic message, not just a generic "invalid argument type" error. ## Test Plan New Markdown tests. --- .../mdtest/directives/assert_never.md | 106 ++++++++++++++++++ crates/red_knot_python_semantic/src/types.rs | 22 ++++ .../src/types/diagnostic.rs | 2 +- .../src/types/infer.rs | 14 +++ knot.schema.json | 2 +- 5 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 crates/red_knot_python_semantic/resources/mdtest/directives/assert_never.md diff --git a/crates/red_knot_python_semantic/resources/mdtest/directives/assert_never.md b/crates/red_knot_python_semantic/resources/mdtest/directives/assert_never.md new file mode 100644 index 0000000000..b4d51b3927 --- /dev/null +++ b/crates/red_knot_python_semantic/resources/mdtest/directives/assert_never.md @@ -0,0 +1,106 @@ +# `assert_never` + +## Basic functionality + +`assert_never` makes sure that the type of the argument is `Never`. If it is not, a +`type-assertion-failure` diagnostic is emitted. + +```py +from typing_extensions import assert_never, Never, Any +from knot_extensions import Unknown + +def _(never: Never, any_: Any, unknown: Unknown, flag: bool): + assert_never(never) # fine + + assert_never(0) # error: [type-assertion-failure] + assert_never("") # error: [type-assertion-failure] + assert_never(None) # error: [type-assertion-failure] + assert_never([]) # error: [type-assertion-failure] + assert_never({}) # error: [type-assertion-failure] + assert_never(()) # error: [type-assertion-failure] + assert_never(1 if flag else never) # error: [type-assertion-failure] + + assert_never(any_) # error: [type-assertion-failure] + assert_never(unknown) # error: [type-assertion-failure] +``` + +## Use case: Type narrowing and exhaustiveness checking + +`assert_never` can be used in combination with type narrowing as a way to make sure that all cases +are handled in a series of `isinstance` checks or other narrowing patterns that are supported. + +```py +from typing_extensions import assert_never, Literal + +class A: ... +class B: ... +class C: ... + +def if_else_isinstance_success(obj: A | B): + if isinstance(obj, A): + pass + elif isinstance(obj, B): + pass + elif isinstance(obj, C): + pass + else: + assert_never(obj) + +def if_else_isinstance_error(obj: A | B): + if isinstance(obj, A): + pass + # B is missing + elif isinstance(obj, C): + pass + else: + # error: [type-assertion-failure] "Expected type `Never`, got `B & ~A & ~C` instead" + assert_never(obj) + +def if_else_singletons_success(obj: Literal[1, "a"] | None): + if obj == 1: + pass + elif obj == "a": + pass + elif obj is None: + pass + else: + assert_never(obj) + +def if_else_singletons_error(obj: Literal[1, "a"] | None): + if obj == 1: + pass + elif obj is "A": # "A" instead of "a" + pass + elif obj is None: + pass + else: + # error: [type-assertion-failure] "Expected type `Never`, got `Literal["a"]` instead" + assert_never(obj) + +def match_singletons_success(obj: Literal[1, "a"] | None): + match obj: + case 1: + pass + case "a": + pass + case None: + pass + case _ as obj: + # TODO: Ideally, we would not emit an error here + # error: [type-assertion-failure] "Expected type `Never`, got `@Todo" + assert_never(obj) + +def match_singletons_error(obj: Literal[1, "a"] | None): + match obj: + case 1: + pass + case "A": # "A" instead of "a" + pass + case None: + pass + case _ as obj: + # TODO: We should emit an error here, but the message should + # show the type `Literal["a"]` instead of `@Todo(…)`. + # error: [type-assertion-failure] "Expected type `Never`, got `@Todo" + assert_never(obj) +``` diff --git a/crates/red_knot_python_semantic/src/types.rs b/crates/red_knot_python_semantic/src/types.rs index 0b30254ee7..a6fc3c289b 100644 --- a/crates/red_knot_python_semantic/src/types.rs +++ b/crates/red_knot_python_semantic/src/types.rs @@ -3024,6 +3024,24 @@ impl<'db> Type<'db> { Signatures::single(signature) } + Some(KnownFunction::AssertNever) => { + let signature = CallableSignature::single( + self, + Signature::new( + Parameters::new([Parameter::positional_only(Some(Name::new_static( + "arg", + ))) + // We need to set the type to `Any` here (instead of `Never`), + // in order for every `assert_never` call to pass the argument + // check. If we set it to `Never`, we'll get invalid-argument-type + // errors instead of `type-assertion-failure` errors. + .with_annotated_type(Type::any())]), + Some(Type::none(db)), + ), + ); + Signatures::single(signature) + } + Some(KnownFunction::Cast) => { let signature = CallableSignature::single( self, @@ -4890,6 +4908,8 @@ pub enum KnownFunction { /// `typing(_extensions).assert_type` AssertType, + /// `typing(_extensions).assert_never` + AssertNever, /// `typing(_extensions).cast` Cast, /// `typing(_extensions).overload` @@ -4947,6 +4967,7 @@ impl KnownFunction { match self { Self::IsInstance | Self::IsSubclass | Self::Len | Self::Repr => module.is_builtins(), Self::AssertType + | Self::AssertNever | Self::Cast | Self::Overload | Self::RevealType @@ -6407,6 +6428,7 @@ pub(crate) mod tests { | KnownFunction::Overload | KnownFunction::RevealType | KnownFunction::AssertType + | KnownFunction::AssertNever | KnownFunction::NoTypeCheck => KnownModule::TypingExtensions, KnownFunction::IsSingleton diff --git a/crates/red_knot_python_semantic/src/types/diagnostic.rs b/crates/red_knot_python_semantic/src/types/diagnostic.rs index 42fba177dc..4dbff6cc56 100644 --- a/crates/red_knot_python_semantic/src/types/diagnostic.rs +++ b/crates/red_knot_python_semantic/src/types/diagnostic.rs @@ -682,7 +682,7 @@ declare_lint! { declare_lint! { /// ## What it does - /// Checks for `assert_type()` calls where the actual type + /// Checks for `assert_type()` and `assert_never()` calls where the actual type /// is not the same as the asserted type. /// /// ## Why is this bad? diff --git a/crates/red_knot_python_semantic/src/types/infer.rs b/crates/red_knot_python_semantic/src/types/infer.rs index 8cc8aeeb7e..98502ca061 100644 --- a/crates/red_knot_python_semantic/src/types/infer.rs +++ b/crates/red_knot_python_semantic/src/types/infer.rs @@ -4022,6 +4022,20 @@ impl<'db> TypeInferenceBuilder<'db> { } } } + KnownFunction::AssertNever => { + if let [Some(actual_ty)] = overload.parameter_types() { + if !actual_ty.is_equivalent_to(self.db(), Type::Never) { + self.context.report_lint( + &TYPE_ASSERTION_FAILURE, + call_expression, + format_args!( + "Expected type `Never`, got `{}` instead", + actual_ty.display(self.db()), + ), + ); + } + } + } KnownFunction::StaticAssert => { if let [Some(parameter_ty), message] = overload.parameter_types() { let truthiness = match parameter_ty.try_bool(self.db()) { diff --git a/knot.schema.json b/knot.schema.json index 07dca02b11..2808a7df2e 100644 --- a/knot.schema.json +++ b/knot.schema.json @@ -652,7 +652,7 @@ }, "type-assertion-failure": { "title": "detects failed type assertions", - "description": "## What it does\nChecks for `assert_type()` calls where the actual type\nis not the same as the asserted type.\n\n## Why is this bad?\n`assert_type()` allows confirming the inferred type of a certain value.\n\n## Example\n\n```python\ndef _(x: int):\n assert_type(x, int) # fine\n assert_type(x, str) # error: Actual type does not match asserted type\n```", + "description": "## What it does\nChecks for `assert_type()` and `assert_never()` calls where the actual type\nis not the same as the asserted type.\n\n## Why is this bad?\n`assert_type()` allows confirming the inferred type of a certain value.\n\n## Example\n\n```python\ndef _(x: int):\n assert_type(x, int) # fine\n assert_type(x, str) # error: Actual type does not match asserted type\n```", "default": "error", "oneOf": [ {