Skip to content

Commit 2ffecd3

Browse files
committed
[ty] Add a new assert-type-unspellable-subtype diagnostic
1 parent 4c7d1f5 commit 2ffecd3

8 files changed

Lines changed: 328 additions & 89 deletions

File tree

crates/ty/docs/rules.md

Lines changed: 120 additions & 84 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
@@ -1533,6 +1533,54 @@ impl<'db> Type<'db> {
15331533
if yes { self.negate(db) } else { *self }
15341534
}
15351535

1536+
/// Return `true` if it is possible to spell an equivalent type to this one
1537+
/// in user annotations without nonstandard extensions to the type system
1538+
pub(crate) fn is_spellable(&self, db: &'db dyn Db) -> bool {
1539+
match self {
1540+
Type::StringLiteral(_)
1541+
| Type::LiteralString
1542+
| Type::IntLiteral(_)
1543+
| Type::BooleanLiteral(_)
1544+
| Type::BytesLiteral(_)
1545+
| Type::Never
1546+
| Type::NewTypeInstance(_)
1547+
| Type::EnumLiteral(_)
1548+
| Type::NominalInstance(_)
1549+
// `TypedDict` and `Protocol` can be synthesized,
1550+
// but it's always possible to create an equivalent type using a class definition.
1551+
| Type::TypedDict(_)
1552+
| Type::ProtocolInstance(_)
1553+
// Not all `Callable` types are spellable using the `Callable` type form,
1554+
// but they are all spellable using callback protocols.
1555+
| Type::Callable(_)
1556+
// `Unknown` and `@Todo` are nonstandard extensions,
1557+
// but they are both exactly equivalent to `Any`
1558+
| Type::Dynamic(_)
1559+
| Type::TypeVar(_)
1560+
| Type::TypeAlias(_)
1561+
| Type::SubclassOf(_)=> true,
1562+
Type::Intersection(_)
1563+
| Type::SpecialForm(_)
1564+
| Type::BoundSuper(_)
1565+
| Type::BoundMethod(_)
1566+
| Type::KnownBoundMethod(_)
1567+
| Type::AlwaysTruthy
1568+
| Type::AlwaysFalsy
1569+
| Type::TypeIs(_)
1570+
| Type::TypeGuard(_)
1571+
| Type::PropertyInstance(_)
1572+
| Type::FunctionLiteral(_)
1573+
| Type::ModuleLiteral(_)
1574+
| Type::WrapperDescriptor(_)
1575+
| Type::DataclassDecorator(_)
1576+
| Type::DataclassTransformer(_)
1577+
| Type::ClassLiteral(_)
1578+
| Type::GenericAlias(_)
1579+
| Type::KnownInstance(_) => false,
1580+
Type::Union(union) => union.elements(db).iter().all(|ty| ty.is_spellable(db)),
1581+
}
1582+
}
1583+
15361584
/// If the type is a union, filters union elements based on the provided predicate.
15371585
///
15381586
/// 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);
@@ -1971,6 +1972,36 @@ declare_lint! {
19711972
}
19721973
}
19731974

1975+
declare_lint! {
1976+
/// ## What it does
1977+
/// Checks for `assert_type()` calls where the actual type
1978+
/// is an unspellable subtype of the asserted type.
1979+
///
1980+
/// ## Why is this bad?
1981+
/// `assert_type()` is intended to ensure that the inferred type of a value
1982+
/// is exactly the same as the asserted type. But in some situations, ty
1983+
/// has nonstandard extensions to the type system that allow it to infer
1984+
/// more precise types than can be expressed in user annotations. ty emits a
1985+
/// different error code to `type-assertion-failure` in these situations so
1986+
/// that users can easily differentiate between the two cases.
1987+
///
1988+
/// ## Example
1989+
///
1990+
/// ```python
1991+
/// def _(x: int):
1992+
/// assert_type(x, int) # fine
1993+
/// if x:
1994+
/// assert_type(x, int) # error: [assert-type-unspellable-subtype]
1995+
/// # the actual type is `int & ~AlwaysFalsy`,
1996+
/// # which excludes types like `Literal[0]`
1997+
/// ```
1998+
pub(crate) static ASSERT_TYPE_UNSPELLABLE_SUBTYPE = {
1999+
summary: "detects failed type assertions",
2000+
status: LintStatus::stable("0.0.14"),
2001+
default_level: Level::Error,
2002+
}
2003+
}
2004+
19742005
declare_lint! {
19752006
/// ## What it does
19762007
/// 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;
@@ -1579,8 +1579,13 @@ impl KnownFunction {
15791579
if actual_ty.is_equivalent_to(db, *asserted_ty) {
15801580
return;
15811581
}
1582-
if let Some(builder) = context.report_lint(&TYPE_ASSERTION_FAILURE, call_expression)
1583-
{
1582+
let diagnostic =
1583+
if actual_ty.is_spellable(db) || !actual_ty.is_subtype_of(db, *asserted_ty) {
1584+
&TYPE_ASSERTION_FAILURE
1585+
} else {
1586+
&ASSERT_TYPE_UNSPELLABLE_SUBTYPE
1587+
};
1588+
if let Some(builder) = context.report_lint(diagnostic, call_expression) {
15841589
let mut diagnostic = builder.into_diagnostic(format_args!(
15851590
"Argument does not have asserted type `{}`",
15861591
asserted_ty.display(db),

scripts/conformance.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ def collect_ty_diagnostics(
346346
"check",
347347
f"--python-version={python_version}",
348348
"--output-format=gitlab",
349+
"--ignore=assert-type-unspellable-subtype",
349350
"--exit-zero",
350351
*map(str, test_files),
351352
],

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)