Skip to content

Commit 67b2237

Browse files
committed
[ty] Add a new assert-type-unspellable-subtype diagnostic
1 parent 2643fb0 commit 67b2237

8 files changed

Lines changed: 329 additions & 90 deletions

File tree

crates/ty/docs/rules.md

Lines changed: 121 additions & 85 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ty_python_semantic/resources/mdtest/directives/assert_type.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,31 @@ def _(a: type[int]):
5353
assert_type(a, Type[int]) # fine
5454
```
5555

56+
## Unspellable types
57+
58+
<!-- snapshot-diagnostics -->
59+
60+
If the actual type is an unspellable subtype, we emit `assert-type-unspellable-subtype` instead of
61+
`type-assertion-failure`, on the grounds that it is often useful to distinguish this from cases
62+
where the type assertion failure is "fixable".
63+
64+
```py
65+
from typing_extensions import assert_type
66+
67+
class Foo: ...
68+
class Bar: ...
69+
class Baz: ...
70+
71+
def f(x: Foo):
72+
assert_type(x, Bar) # error: [type-assertion-failure] "Type `Bar` does not match asserted type `Foo`"
73+
if isinstance(x, Bar):
74+
assert_type(x, Bar) # error: [assert-type-unspellable-subtype] "Type `Bar` does not match asserted type `Foo & Bar`"
75+
76+
# The actual type must be a subtype of the asserted type, as well as being unspellable,
77+
# in order for `assert-type-unspellable-subtype` to be emitted instead of `type-assertion-failure`
78+
assert_type(x, Baz) # error: [type-assertion-failure]
79+
```
80+
5681
## Gradual types
5782

5883
```py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
---
2+
source: crates/ty_test/src/lib.rs
3+
expression: snapshot
4+
---
5+
6+
---
7+
mdtest name: assert_type.md - `assert_type` - Unspellable types
8+
mdtest path: crates/ty_python_semantic/resources/mdtest/directives/assert_type.md
9+
---
10+
11+
# Python source files
12+
13+
## mdtest_snippet.py
14+
15+
```
16+
1 | from typing_extensions import assert_type
17+
2 |
18+
3 | class Foo: ...
19+
4 | class Bar: ...
20+
5 | class Baz: ...
21+
6 |
22+
7 | def f(x: Foo):
23+
8 | assert_type(x, Bar) # error: [type-assertion-failure] "Type `Bar` does not match asserted type `Foo`"
24+
9 | if isinstance(x, Bar):
25+
10 | assert_type(x, Bar) # error: [assert-type-unspellable-subtype] "Type `Bar` does not match asserted type `Foo & Bar`"
26+
11 |
27+
12 | # The actual type must be a subtype of the asserted type, as well as being unspellable,
28+
13 | # in order for `assert-type-unspellable-subtype` to be emitted instead of `type-assertion-failure`
29+
14 | assert_type(x, Baz) # error: [type-assertion-failure]
30+
```
31+
32+
# Diagnostics
33+
34+
```
35+
error[type-assertion-failure]: Argument does not have asserted type `Bar`
36+
--> src/mdtest_snippet.py:8:5
37+
|
38+
7 | def f(x: Foo):
39+
8 | assert_type(x, Bar) # error: [type-assertion-failure] "Type `Bar` does not match asserted type `Foo`"
40+
| ^^^^^^^^^^^^-^^^^^^
41+
| |
42+
| Inferred type is `Foo`
43+
9 | if isinstance(x, Bar):
44+
10 | assert_type(x, Bar) # error: [assert-type-unspellable-subtype] "Type `Bar` does not match asserted type `Foo & Bar`"
45+
|
46+
info: `Bar` and `Foo` are not equivalent types
47+
info: rule `type-assertion-failure` is enabled by default
48+
49+
```
50+
51+
```
52+
error[assert-type-unspellable-subtype]: Argument does not have asserted type `Bar`
53+
--> src/mdtest_snippet.py:10:9
54+
|
55+
8 | assert_type(x, Bar) # error: [type-assertion-failure] "Type `Bar` does not match asserted type `Foo`"
56+
9 | if isinstance(x, Bar):
57+
10 | assert_type(x, Bar) # error: [assert-type-unspellable-subtype] "Type `Bar` does not match asserted type `Foo & Bar`"
58+
| ^^^^^^^^^^^^-^^^^^^
59+
| |
60+
| Inferred type is `Foo & Bar`
61+
11 |
62+
12 | # The actual type must be a subtype of the asserted type, as well as being unspellable,
63+
|
64+
info: `Foo & Bar` is a subtype of `Bar`, but they are not equivalent
65+
info: rule `assert-type-unspellable-subtype` is enabled by default
66+
67+
```
68+
69+
```
70+
error[type-assertion-failure]: Argument does not have asserted type `Baz`
71+
--> src/mdtest_snippet.py:14:9
72+
|
73+
12 | # The actual type must be a subtype of the asserted type, as well as being unspellable,
74+
13 | # in order for `assert-type-unspellable-subtype` to be emitted instead of `type-assertion-failure`
75+
14 | assert_type(x, Baz) # error: [type-assertion-failure]
76+
| ^^^^^^^^^^^^-^^^^^^
77+
| |
78+
| Inferred type is `Foo & Bar`
79+
|
80+
info: `Baz` and `Foo & Bar` are not equivalent types
81+
info: rule `type-assertion-failure` is enabled by default
82+
83+
```

crates/ty_python_semantic/src/types.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1481,6 +1481,54 @@ impl<'db> Type<'db> {
14811481
if yes { self.negate(db) } else { *self }
14821482
}
14831483

1484+
/// Return `true` if it is possible to spell an equivalent type to this one
1485+
/// in user annotations without nonstandard extensions to the type system
1486+
pub(crate) fn is_spellable(&self, db: &'db dyn Db) -> bool {
1487+
match self {
1488+
Type::StringLiteral(_)
1489+
| Type::LiteralString
1490+
| Type::IntLiteral(_)
1491+
| Type::BooleanLiteral(_)
1492+
| Type::BytesLiteral(_)
1493+
| Type::Never
1494+
| Type::NewTypeInstance(_)
1495+
| Type::EnumLiteral(_)
1496+
| Type::NominalInstance(_)
1497+
// `TypedDict` and `Protocol` can be synthesized,
1498+
// but it's always possible to create an equivalent type using a class definition.
1499+
| Type::TypedDict(_)
1500+
| Type::ProtocolInstance(_)
1501+
// Not all `Callable` types are spellable using the `Callable` type form,
1502+
// but they are all spellable using callback protocols.
1503+
| Type::Callable(_)
1504+
// `Unknown` and `@Todo` are nonstandard extensions,
1505+
// but they are both exactly equivalent to `Any`
1506+
| Type::Dynamic(_)
1507+
| Type::TypeVar(_)
1508+
| Type::TypeAlias(_)
1509+
| Type::SubclassOf(_)=> true,
1510+
Type::Intersection(_)
1511+
| Type::SpecialForm(_)
1512+
| Type::BoundSuper(_)
1513+
| Type::BoundMethod(_)
1514+
| Type::KnownBoundMethod(_)
1515+
| Type::AlwaysTruthy
1516+
| Type::AlwaysFalsy
1517+
| Type::TypeIs(_)
1518+
| Type::TypeGuard(_)
1519+
| Type::PropertyInstance(_)
1520+
| Type::FunctionLiteral(_)
1521+
| Type::ModuleLiteral(_)
1522+
| Type::WrapperDescriptor(_)
1523+
| Type::DataclassDecorator(_)
1524+
| Type::DataclassTransformer(_)
1525+
| Type::ClassLiteral(_)
1526+
| Type::GenericAlias(_)
1527+
| Type::KnownInstance(_) => false,
1528+
Type::Union(union) => union.elements(db).iter().all(|ty| ty.is_spellable(db)),
1529+
}
1530+
}
1531+
14841532
/// If the type is a union, filters union elements based on the provided predicate.
14851533
///
14861534
/// Otherwise, returns the type unchanged.

crates/ty_python_semantic/src/types/diagnostic.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) {
109109
registry.register_lint(&INEFFECTIVE_FINAL);
110110
registry.register_lint(&ABSTRACT_METHOD_IN_FINAL_CLASS);
111111
registry.register_lint(&TYPE_ASSERTION_FAILURE);
112+
registry.register_lint(&ASSERT_TYPE_UNSPELLABLE_SUBTYPE);
112113
registry.register_lint(&TOO_MANY_POSITIONAL_ARGUMENTS);
113114
registry.register_lint(&UNAVAILABLE_IMPLICIT_SUPER_ARGUMENTS);
114115
registry.register_lint(&UNDEFINED_REVEAL);
@@ -1972,6 +1973,36 @@ declare_lint! {
19721973
}
19731974
}
19741975

1976+
declare_lint! {
1977+
/// ## What it does
1978+
/// Checks for `assert_type()` calls where the actual type
1979+
/// is an unspellable subtype of the asserted type.
1980+
///
1981+
/// ## Why is this bad?
1982+
/// `assert_type()` is intended to ensure that the inferred type of a value
1983+
/// is exactly the same as the asserted type. But in some situations, ty
1984+
/// has nonstandard extensions to the type system that allow it to infer
1985+
/// more precise types than can be expressed in user annotations. ty emits a
1986+
/// different error code to `type-assertion-failure` in these situations so
1987+
/// that users can easily differentiate between the two cases.
1988+
///
1989+
/// ## Example
1990+
///
1991+
/// ```python
1992+
/// def _(x: int):
1993+
/// assert_type(x, int) # fine
1994+
/// if x:
1995+
/// assert_type(x, int) # error: [assert-type-unspellable-subtype]
1996+
/// # the actual type is `int & ~AlwaysFalsy`,
1997+
/// # which excludes types like `Literal[0]`
1998+
/// ```
1999+
pub(crate) static ASSERT_TYPE_UNSPELLABLE_SUBTYPE = {
2000+
summary: "detects failed type assertions",
2001+
status: LintStatus::stable("0.0.14"),
2002+
default_level: Level::Error,
2003+
}
2004+
}
2005+
19752006
declare_lint! {
19762007
/// ## What it does
19772008
/// Checks for calls that pass more positional arguments than the callable can accept.

crates/ty_python_semantic/src/types/function.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,9 @@ use crate::types::call::{Binding, CallArguments};
6868
use crate::types::constraints::ConstraintSet;
6969
use crate::types::context::InferContext;
7070
use crate::types::diagnostic::{
71-
INVALID_ARGUMENT_TYPE, REDUNDANT_CAST, STATIC_ASSERT_ERROR, TYPE_ASSERTION_FAILURE,
72-
report_bad_argument_to_get_protocol_members, report_bad_argument_to_protocol_interface,
73-
report_invalid_total_ordering_call,
71+
ASSERT_TYPE_UNSPELLABLE_SUBTYPE, INVALID_ARGUMENT_TYPE, REDUNDANT_CAST, STATIC_ASSERT_ERROR,
72+
TYPE_ASSERTION_FAILURE, report_bad_argument_to_get_protocol_members,
73+
report_bad_argument_to_protocol_interface, report_invalid_total_ordering_call,
7474
report_runtime_check_against_non_runtime_checkable_protocol,
7575
};
7676
use crate::types::display::DisplaySettings;
@@ -1557,8 +1557,13 @@ impl KnownFunction {
15571557
if actual_ty.is_equivalent_to(db, *asserted_ty) {
15581558
return;
15591559
}
1560-
if let Some(builder) = context.report_lint(&TYPE_ASSERTION_FAILURE, call_expression)
1561-
{
1560+
let diagnostic =
1561+
if actual_ty.is_spellable(db) || !actual_ty.is_subtype_of(db, *asserted_ty) {
1562+
&TYPE_ASSERTION_FAILURE
1563+
} else {
1564+
&ASSERT_TYPE_UNSPELLABLE_SUBTYPE
1565+
};
1566+
if let Some(builder) = context.report_lint(diagnostic, call_expression) {
15621567
let mut diagnostic = builder.into_diagnostic(format_args!(
15631568
"Argument does not have asserted type `{}`",
15641569
asserted_ty.display(db),

scripts/conformance.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,7 @@ def collect_ty_diagnostics(
461461
"check",
462462
f"--python-version={python_version}",
463463
"--output-format=gitlab",
464+
"--ignore=assert-type-unspellable-subtype",
464465
"--exit-zero",
465466
*map(str, test_files),
466467
],

ty.schema.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)