Skip to content

[red-knot] Add support for assert_never #17287

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 8, 2025
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
Original file line number Diff line number Diff line change
@@ -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)
```
22 changes: 22 additions & 0 deletions crates/red_knot_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -4890,6 +4908,8 @@ pub enum KnownFunction {

/// `typing(_extensions).assert_type`
AssertType,
/// `typing(_extensions).assert_never`
AssertNever,
/// `typing(_extensions).cast`
Cast,
/// `typing(_extensions).overload`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -6407,6 +6428,7 @@ pub(crate) mod tests {
| KnownFunction::Overload
| KnownFunction::RevealType
| KnownFunction::AssertType
| KnownFunction::AssertNever
| KnownFunction::NoTypeCheck => KnownModule::TypingExtensions,

KnownFunction::IsSingleton
Expand Down
2 changes: 1 addition & 1 deletion crates/red_knot_python_semantic/src/types/diagnostic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I did consider making more changes in the lint description here. I also considered a dedicated new diagnostic. But eventually thought that this might be enough? Let me know if you have a different opinion.

/// is not the same as the asserted type.
///
/// ## Why is this bad?
Expand Down
14 changes: 14 additions & 0 deletions crates/red_knot_python_semantic/src/types/infer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
2 changes: 1 addition & 1 deletion knot.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
Loading