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
206 changes: 121 additions & 85 deletions crates/ty/docs/rules.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,31 @@ def _(a: type[int]):
assert_type(a, Type[int]) # fine
```

## Unspellable types

<!-- snapshot-diagnostics -->

If the actual type is an unspellable subtype, we emit `assert-type-unspellable-subtype` instead of
`type-assertion-failure`, on the grounds that it is often useful to distinguish this from cases
where the type assertion failure is "fixable".

```py
from typing_extensions import assert_type

class Foo: ...
class Bar: ...
class Baz: ...

def f(x: Foo):
assert_type(x, Bar) # error: [type-assertion-failure] "Type `Bar` does not match asserted type `Foo`"
if isinstance(x, Bar):
assert_type(x, Bar) # error: [assert-type-unspellable-subtype] "Type `Bar` does not match asserted type `Foo & Bar`"

# The actual type must be a subtype of the asserted type, as well as being unspellable,
# in order for `assert-type-unspellable-subtype` to be emitted instead of `type-assertion-failure`
assert_type(x, Baz) # error: [type-assertion-failure]
```

## Gradual types

```py
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---

---
mdtest name: assert_type.md - `assert_type` - Unspellable types
mdtest path: crates/ty_python_semantic/resources/mdtest/directives/assert_type.md
---

# Python source files

## mdtest_snippet.py

```
1 | from typing_extensions import assert_type
2 |
3 | class Foo: ...
4 | class Bar: ...
5 | class Baz: ...
6 |
7 | def f(x: Foo):
8 | assert_type(x, Bar) # error: [type-assertion-failure] "Type `Bar` does not match asserted type `Foo`"
9 | if isinstance(x, Bar):
10 | assert_type(x, Bar) # error: [assert-type-unspellable-subtype] "Type `Bar` does not match asserted type `Foo & Bar`"
11 |
12 | # The actual type must be a subtype of the asserted type, as well as being unspellable,
13 | # in order for `assert-type-unspellable-subtype` to be emitted instead of `type-assertion-failure`
14 | assert_type(x, Baz) # error: [type-assertion-failure]
```

# Diagnostics

```
error[type-assertion-failure]: Argument does not have asserted type `Bar`
--> src/mdtest_snippet.py:8:5
|
7 | def f(x: Foo):
8 | assert_type(x, Bar) # error: [type-assertion-failure] "Type `Bar` does not match asserted type `Foo`"
| ^^^^^^^^^^^^-^^^^^^
| |
| Inferred type is `Foo`
9 | if isinstance(x, Bar):
10 | assert_type(x, Bar) # error: [assert-type-unspellable-subtype] "Type `Bar` does not match asserted type `Foo & Bar`"
|
info: `Bar` and `Foo` are not equivalent types
info: rule `type-assertion-failure` is enabled by default

```

```
error[assert-type-unspellable-subtype]: Argument does not have asserted type `Bar`
--> src/mdtest_snippet.py:10:9
|
8 | assert_type(x, Bar) # error: [type-assertion-failure] "Type `Bar` does not match asserted type `Foo`"
9 | if isinstance(x, Bar):
10 | assert_type(x, Bar) # error: [assert-type-unspellable-subtype] "Type `Bar` does not match asserted type `Foo & Bar`"
| ^^^^^^^^^^^^-^^^^^^
| |
| Inferred type is `Foo & Bar`
11 |
12 | # The actual type must be a subtype of the asserted type, as well as being unspellable,
|
info: `Foo & Bar` is a subtype of `Bar`, but they are not equivalent
info: rule `assert-type-unspellable-subtype` is enabled by default

```

```
error[type-assertion-failure]: Argument does not have asserted type `Baz`
--> src/mdtest_snippet.py:14:9
|
12 | # The actual type must be a subtype of the asserted type, as well as being unspellable,
13 | # in order for `assert-type-unspellable-subtype` to be emitted instead of `type-assertion-failure`
14 | assert_type(x, Baz) # error: [type-assertion-failure]
| ^^^^^^^^^^^^-^^^^^^
| |
| Inferred type is `Foo & Bar`
|
info: `Baz` and `Foo & Bar` are not equivalent types
info: rule `type-assertion-failure` is enabled by default

```
48 changes: 48 additions & 0 deletions crates/ty_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1481,6 +1481,54 @@ impl<'db> Type<'db> {
if yes { self.negate(db) } else { *self }
}

/// Return `true` if it is possible to spell an equivalent type to this one
/// in user annotations without nonstandard extensions to the type system
pub(crate) fn is_spellable(&self, db: &'db dyn Db) -> bool {
match self {
Type::StringLiteral(_)
| Type::LiteralString
| Type::IntLiteral(_)
| Type::BooleanLiteral(_)
| Type::BytesLiteral(_)
| Type::Never
| Type::NewTypeInstance(_)
| Type::EnumLiteral(_)
| Type::NominalInstance(_)
// `TypedDict` and `Protocol` can be synthesized,
// but it's always possible to create an equivalent type using a class definition.
| Type::TypedDict(_)
| Type::ProtocolInstance(_)
// Not all `Callable` types are spellable using the `Callable` type form,
// but they are all spellable using callback protocols.
| Type::Callable(_)
// `Unknown` and `@Todo` are nonstandard extensions,
// but they are both exactly equivalent to `Any`
| Type::Dynamic(_)
| Type::TypeVar(_)
| Type::TypeAlias(_)
| Type::SubclassOf(_)=> true,
Type::Intersection(_)
| Type::SpecialForm(_)
| Type::BoundSuper(_)
| Type::BoundMethod(_)
| Type::KnownBoundMethod(_)
| Type::AlwaysTruthy
| Type::AlwaysFalsy
| Type::TypeIs(_)
| Type::TypeGuard(_)
| Type::PropertyInstance(_)
| Type::FunctionLiteral(_)
| Type::ModuleLiteral(_)
| Type::WrapperDescriptor(_)
| Type::DataclassDecorator(_)
| Type::DataclassTransformer(_)
| Type::ClassLiteral(_)
| Type::GenericAlias(_)
| Type::KnownInstance(_) => false,
Type::Union(union) => union.elements(db).iter().all(|ty| ty.is_spellable(db)),
}
}

/// If the type is a union, filters union elements based on the provided predicate.
///
/// Otherwise, returns the type unchanged.
Expand Down
31 changes: 31 additions & 0 deletions crates/ty_python_semantic/src/types/diagnostic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) {
registry.register_lint(&INEFFECTIVE_FINAL);
registry.register_lint(&ABSTRACT_METHOD_IN_FINAL_CLASS);
registry.register_lint(&TYPE_ASSERTION_FAILURE);
registry.register_lint(&ASSERT_TYPE_UNSPELLABLE_SUBTYPE);
registry.register_lint(&TOO_MANY_POSITIONAL_ARGUMENTS);
registry.register_lint(&UNAVAILABLE_IMPLICIT_SUPER_ARGUMENTS);
registry.register_lint(&UNDEFINED_REVEAL);
Expand Down Expand Up @@ -1972,6 +1973,36 @@ declare_lint! {
}
}

declare_lint! {
/// ## What it does
/// Checks for `assert_type()` calls where the actual type
/// is an unspellable subtype of the asserted type.
///
/// ## Why is this bad?
/// `assert_type()` is intended to ensure that the inferred type of a value
/// is exactly the same as the asserted type. But in some situations, ty
/// has nonstandard extensions to the type system that allow it to infer
/// more precise types than can be expressed in user annotations. ty emits a
/// different error code to `type-assertion-failure` in these situations so
/// that users can easily differentiate between the two cases.
///
/// ## Example
///
/// ```python
/// def _(x: int):
/// assert_type(x, int) # fine
/// if x:
/// assert_type(x, int) # error: [assert-type-unspellable-subtype]
/// # the actual type is `int & ~AlwaysFalsy`,
/// # which excludes types like `Literal[0]`
/// ```
pub(crate) static ASSERT_TYPE_UNSPELLABLE_SUBTYPE = {
summary: "detects failed type assertions",
status: LintStatus::stable("0.0.14"),
default_level: Level::Error,
Copy link
Member

Choose a reason for hiding this comment

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

Should this be a warning by default, given that this is only a suspicious pattern?

Copy link
Member Author

Choose a reason for hiding this comment

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

I think it should still be an error — the assertion is still failing. The spec says that assert_type should only pass if the two types are equivalent, and we only emit this error if the two types are not equivalent

}
}

declare_lint! {
/// ## What it does
/// Checks for calls that pass more positional arguments than the callable can accept.
Expand Down
15 changes: 10 additions & 5 deletions crates/ty_python_semantic/src/types/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ use crate::types::call::{Binding, CallArguments};
use crate::types::constraints::ConstraintSet;
use crate::types::context::InferContext;
use crate::types::diagnostic::{
INVALID_ARGUMENT_TYPE, REDUNDANT_CAST, STATIC_ASSERT_ERROR, TYPE_ASSERTION_FAILURE,
report_bad_argument_to_get_protocol_members, report_bad_argument_to_protocol_interface,
report_invalid_total_ordering_call,
ASSERT_TYPE_UNSPELLABLE_SUBTYPE, INVALID_ARGUMENT_TYPE, REDUNDANT_CAST, STATIC_ASSERT_ERROR,
TYPE_ASSERTION_FAILURE, report_bad_argument_to_get_protocol_members,
report_bad_argument_to_protocol_interface, report_invalid_total_ordering_call,
report_runtime_check_against_non_runtime_checkable_protocol,
};
use crate::types::display::DisplaySettings;
Expand Down Expand Up @@ -1557,8 +1557,13 @@ impl KnownFunction {
if actual_ty.is_equivalent_to(db, *asserted_ty) {
return;
}
if let Some(builder) = context.report_lint(&TYPE_ASSERTION_FAILURE, call_expression)
{
let diagnostic =
if actual_ty.is_spellable(db) || !actual_ty.is_subtype_of(db, *asserted_ty) {
&TYPE_ASSERTION_FAILURE
} else {
&ASSERT_TYPE_UNSPELLABLE_SUBTYPE
};
if let Some(builder) = context.report_lint(diagnostic, call_expression) {
let mut diagnostic = builder.into_diagnostic(format_args!(
"Argument does not have asserted type `{}`",
asserted_ty.display(db),
Expand Down
1 change: 1 addition & 0 deletions scripts/conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ def collect_ty_diagnostics(
"check",
f"--python-version={python_version}",
"--output-format=gitlab",
"--ignore=assert-type-unspellable-subtype",
"--exit-zero",
*map(str, test_files),
],
Expand Down
10 changes: 10 additions & 0 deletions ty.schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading