diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md
index c4df6a45d2849..519d95f5fd323 100644
--- a/crates/ty/docs/rules.md
+++ b/crates/ty/docs/rules.md
@@ -8,7 +8,7 @@
Default level: error ·
Added in 0.0.13 ·
Related issues ·
-View source
+View source
@@ -49,7 +49,7 @@ class Derived(Base): # Error: `Derived` does not implement `method`
Default level: warn ·
Added in 0.0.1-alpha.20 ·
Related issues ·
-View source
+View source
@@ -84,6 +84,42 @@ class SubProto(BaseProto, Protocol):
a = 42 # fine (declared in superclass)
```
+## `assert-type-unspellable-subtype`
+
+
+Default level: error ·
+Added in 0.0.14 ·
+Related issues ·
+View source
+
+
+
+**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]`
+```
+
## `byte-string-type-annotation`
@@ -121,7 +157,7 @@ def test(): -> "int":
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -145,7 +181,7 @@ Calling a non-callable object will raise a `TypeError` at runtime.
Default level: error ·
Added in 0.0.7 ·
Related issues ·
-View source
+View source
@@ -176,7 +212,7 @@ def f(x: object):
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -208,7 +244,7 @@ f(int) # error
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -239,7 +275,7 @@ a = 1
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -271,7 +307,7 @@ class C(A, B): ...
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -303,7 +339,7 @@ class B(A): ...
Default level: error ·
Added in 0.0.1-alpha.29 ·
Related issues ·
-View source
+View source
@@ -331,7 +367,7 @@ type B = A
Default level: warn ·
Added in 0.0.1-alpha.16 ·
Related issues ·
-View source
+View source
@@ -358,7 +394,7 @@ old_func() # emits [deprecated] diagnostic
Default level: ignore ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -387,7 +423,7 @@ false positives it can produce.
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -414,7 +450,7 @@ class B(A, A): ...
Default level: error ·
Added in 0.0.1-alpha.12 ·
Related issues ·
-View source
+View source
@@ -570,7 +606,7 @@ def test(): -> "Literal[5]":
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -600,7 +636,7 @@ class C(A, B): ...
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -626,7 +662,7 @@ t[3] # IndexError: tuple index out of range
Default level: warn ·
Added in 0.0.1-alpha.33 ·
Related issues ·
-View source
+View source
@@ -660,7 +696,7 @@ class MyClass: ...
Default level: error ·
Added in 0.0.1-alpha.12 ·
Related issues ·
-View source
+View source
@@ -749,7 +785,7 @@ an atypical memory layout.
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -776,7 +812,7 @@ func("foo") # error: [invalid-argument-type]
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -804,7 +840,7 @@ a: int = ''
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -838,7 +874,7 @@ C.instance_var = 3 # error: Cannot assign to instance variable
Default level: error ·
Added in 0.0.1-alpha.19 ·
Related issues ·
-View source
+View source
@@ -874,7 +910,7 @@ asyncio.run(main())
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -898,7 +934,7 @@ class A(42): ... # error: [invalid-base]
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -925,7 +961,7 @@ with 1:
Default level: error ·
Added in 0.0.12 ·
Related issues ·
-View source
+View source
@@ -962,7 +998,7 @@ class Foo(NamedTuple):
Default level: error ·
Added in 0.0.13 ·
Related issues ·
-View source
+View source
@@ -994,7 +1030,7 @@ class A:
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1023,7 +1059,7 @@ a: str
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1067,7 +1103,7 @@ except ZeroDivisionError:
Default level: error ·
Added in 0.0.1-alpha.28 ·
Related issues ·
-View source
+View source
@@ -1109,7 +1145,7 @@ class D(A):
Default level: error ·
Added in 0.0.1-alpha.35 ·
Related issues ·
-View source
+View source
@@ -1153,7 +1189,7 @@ class NonFrozenChild(FrozenBase): # Error raised here
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1191,7 +1227,7 @@ class D(Generic[U, T]): ...
Default level: error ·
Added in 0.0.12 ·
Related issues ·
-View source
+View source
@@ -1270,7 +1306,7 @@ a = 20 / 0 # type: ignore
Default level: error ·
Added in 0.0.1-alpha.17 ·
Related issues ·
-View source
+View source
@@ -1309,7 +1345,7 @@ carol = Person(name="Carol", age=25) # typo!
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1344,7 +1380,7 @@ def f(t: TypeVar("U")): ...
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1378,7 +1414,7 @@ class B(metaclass=f): ...
Default level: error ·
Added in 0.0.1-alpha.20 ·
Related issues ·
-View source
+View source
@@ -1485,7 +1521,7 @@ Correct use of `@override` is enforced by ty's `invalid-explicit-override` rule.
Default level: error ·
Added in 0.0.1-alpha.19 ·
Related issues ·
-View source
+View source
@@ -1539,7 +1575,7 @@ AttributeError: Cannot overwrite NamedTuple attribute _asdict
Default level: error ·
Added in 0.0.1-alpha.27 ·
Related issues ·
-View source
+View source
@@ -1569,7 +1605,7 @@ Baz = NewType("Baz", int | str) # error: invalid base for `typing.NewType`
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1619,7 +1655,7 @@ def foo(x: int) -> int: ...
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1645,7 +1681,7 @@ def f(a: int = ''): ...
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1676,7 +1712,7 @@ P2 = ParamSpec("S2") # error: ParamSpec name must match the variable it's assig
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1710,7 +1746,7 @@ TypeError: Protocols can only inherit from other protocols, got
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1759,7 +1795,7 @@ def g():
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1784,7 +1820,7 @@ def func() -> int:
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1880,7 +1916,7 @@ class C: ...
Default level: error ·
Added in 0.0.10 ·
Related issues ·
-View source
+View source
@@ -1926,7 +1962,7 @@ class MyClass:
Default level: error ·
Added in 0.0.1-alpha.6 ·
Related issues ·
-View source
+View source
@@ -1953,7 +1989,7 @@ NewAlias = TypeAliasType(get_name(), int) # error: TypeAliasType name mus
Default level: error ·
Added in 0.0.1-alpha.29 ·
Related issues ·
-View source
+View source
@@ -2000,7 +2036,7 @@ Bar[int] # error: too few arguments
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2030,7 +2066,7 @@ TYPE_CHECKING = ''
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2060,7 +2096,7 @@ b: Annotated[int] # `Annotated` expects at least two arguments
Default level: error ·
Added in 0.0.1-alpha.11 ·
Related issues ·
-View source
+View source
@@ -2094,7 +2130,7 @@ f(10) # Error
Default level: error ·
Added in 0.0.1-alpha.11 ·
Related issues ·
-View source
+View source
@@ -2128,7 +2164,7 @@ class C:
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2163,7 +2199,7 @@ T = TypeVar('T', bound=str) # valid bound TypeVar
Default level: error ·
Added in 0.0.14 ·
Related issues ·
-View source
+View source
@@ -2198,7 +2234,7 @@ def f(x: dict):
Default level: error ·
Added in 0.0.9 ·
Related issues ·
-View source
+View source
@@ -2229,7 +2265,7 @@ class Foo(TypedDict):
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2254,7 +2290,7 @@ func() # TypeError: func() missing 1 required positional argument: 'x'
Default level: error ·
Added in 0.0.1-alpha.20 ·
Related issues ·
-View source
+View source
@@ -2287,7 +2323,7 @@ alice["age"] # KeyError
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2316,7 +2352,7 @@ func("string") # error: [no-matching-overload]
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2342,7 +2378,7 @@ for i in 34: # TypeError: 'int' object is not iterable
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2366,7 +2402,7 @@ Subscripting an object that does not support it will raise a `TypeError` at runt
Default level: error ·
Added in 0.0.1-alpha.29 ·
Related issues ·
-View source
+View source
@@ -2399,7 +2435,7 @@ class B(A):
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2426,7 +2462,7 @@ f(1, x=2) # Error raised here
Default level: error ·
Added in 0.0.1-alpha.22 ·
Related issues ·
-View source
+View source
@@ -2453,7 +2489,7 @@ f(x=1) # Error raised here
Default level: warn ·
Added in 0.0.1-alpha.22 ·
Related issues ·
-View source
+View source
@@ -2481,7 +2517,7 @@ A.c # AttributeError: type object 'A' has no attribute 'c'
Default level: warn ·
Added in 0.0.1-alpha.22 ·
Related issues ·
-View source
+View source
@@ -2513,7 +2549,7 @@ A()[0] # TypeError: 'A' object is not subscriptable
Default level: ignore ·
Added in 0.0.1-alpha.22 ·
Related issues ·
-View source
+View source
@@ -2550,7 +2586,7 @@ from module import a # ImportError: cannot import name 'a' from 'module'
Default level: ignore ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2614,7 +2650,7 @@ def test(): -> "int":
Default level: warn ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2641,7 +2677,7 @@ cast(int, f()) # Redundant
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2671,7 +2707,7 @@ static_assert(int(2.0 * 3.0) == 6) # error: does not have a statically known tr
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2700,7 +2736,7 @@ class B(A): ... # Error raised here
Default level: error ·
Added in 0.0.1-alpha.30 ·
Related issues ·
-View source
+View source
@@ -2734,7 +2770,7 @@ class F(NamedTuple):
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2761,7 +2797,7 @@ f("foo") # Error raised here
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2789,7 +2825,7 @@ def _(x: int):
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2835,7 +2871,7 @@ class A:
Default level: warn ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2859,7 +2895,7 @@ reveal_type(1) # NameError: name 'reveal_type' is not defined
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2886,7 +2922,7 @@ f(x=1, y=2) # Error raised here
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2914,7 +2950,7 @@ A().foo # AttributeError: 'A' object has no attribute 'foo'
Default level: warn ·
Added in 0.0.1-alpha.15 ·
Related issues ·
-View source
+View source
@@ -2972,7 +3008,7 @@ def g():
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2997,7 +3033,7 @@ import foo # ModuleNotFoundError: No module named 'foo'
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -3022,7 +3058,7 @@ print(x) # NameError: name 'x' is not defined
Default level: warn ·
Added in 0.0.1-alpha.7 ·
Related issues ·
-View source
+View source
@@ -3061,7 +3097,7 @@ class D(C): ... # error: [unsupported-base]
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -3098,7 +3134,7 @@ b1 < b2 < b1 # exception raised here
Default level: ignore ·
Added in 0.0.12 ·
Related issues ·
-View source
+View source
@@ -3139,7 +3175,7 @@ def factory(base: type[Base]) -> type:
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -3203,7 +3239,7 @@ to `false` to prevent this rule from reporting unused `type: ignore` comments.
Default level: warn ·
Added in 0.0.1-alpha.22 ·
Related issues ·
-View source
+View source
@@ -3266,7 +3302,7 @@ def foo(x: int | str) -> int | str:
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
diff --git a/crates/ty_python_semantic/resources/mdtest/directives/assert_type.md b/crates/ty_python_semantic/resources/mdtest/directives/assert_type.md
index 7f99251017eb1..b35053ccd54a5 100644
--- a/crates/ty_python_semantic/resources/mdtest/directives/assert_type.md
+++ b/crates/ty_python_semantic/resources/mdtest/directives/assert_type.md
@@ -53,6 +53,31 @@ def _(a: type[int]):
assert_type(a, Type[int]) # fine
```
+## Unspellable types
+
+
+
+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
diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/assert_type.md_-_`assert_type`_-_Unspellable_types_(385d082f9803b184).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/assert_type.md_-_`assert_type`_-_Unspellable_types_(385d082f9803b184).snap
new file mode 100644
index 0000000000000..b0fdb5898322d
--- /dev/null
+++ b/crates/ty_python_semantic/resources/mdtest/snapshots/assert_type.md_-_`assert_type`_-_Unspellable_types_(385d082f9803b184).snap
@@ -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
+
+```
diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs
index e21a1774e1778..367f8b8776ce8 100644
--- a/crates/ty_python_semantic/src/types.rs
+++ b/crates/ty_python_semantic/src/types.rs
@@ -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.
diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs
index 2a36e3968c37a..3995f789a9023 100644
--- a/crates/ty_python_semantic/src/types/diagnostic.rs
+++ b/crates/ty_python_semantic/src/types/diagnostic.rs
@@ -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);
@@ -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,
+ }
+}
+
declare_lint! {
/// ## What it does
/// Checks for calls that pass more positional arguments than the callable can accept.
diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs
index 88718473b9471..7b50f73d540f4 100644
--- a/crates/ty_python_semantic/src/types/function.rs
+++ b/crates/ty_python_semantic/src/types/function.rs
@@ -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;
@@ -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),
diff --git a/scripts/conformance.py b/scripts/conformance.py
index 81da51fe68887..d77c3217ce65b 100644
--- a/scripts/conformance.py
+++ b/scripts/conformance.py
@@ -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),
],
diff --git a/ty.schema.json b/ty.schema.json
index 183248e14911d..2765571f2e7fd 100644
--- a/ty.schema.json
+++ b/ty.schema.json
@@ -360,6 +360,16 @@
}
]
},
+ "assert-type-unspellable-subtype": {
+ "title": "detects failed type assertions",
+ "description": "## What it does\nChecks for `assert_type()` calls where the actual type\nis an unspellable subtype of the asserted type.\n\n## Why is this bad?\n`assert_type()` is intended to ensure that the inferred type of a value\nis exactly the same as the asserted type. But in some situations, ty\nhas nonstandard extensions to the type system that allow it to infer\nmore precise types than can be expressed in user annotations. ty emits a\ndifferent error code to `type-assertion-failure` in these situations so\nthat users can easily differentiate between the two cases.\n\n## Example\n\n```python\ndef _(x: int):\n assert_type(x, int) # fine\n if x:\n assert_type(x, int) # error: [assert-type-unspellable-subtype]\n # the actual type is `int & ~AlwaysFalsy`,\n # which excludes types like `Literal[0]`\n```",
+ "default": "error",
+ "oneOf": [
+ {
+ "$ref": "#/definitions/Level"
+ }
+ ]
+ },
"byte-string-type-annotation": {
"title": "detects byte strings in type annotation positions",
"description": "## What it does\nChecks for byte-strings in type annotation positions.\n\n## Why is this bad?\nStatic analysis tools like ty can't analyze type annotations that use byte-string notation.\n\n## Examples\n```python\ndef test(): -> b\"int\":\n ...\n```\n\nUse instead:\n```python\ndef test(): -> \"int\":\n ...\n```",