[ty] Add partial support for TypeIs (#18589)

## Summary

Part of [#117](https://github.com/astral-sh/ty/issues/117).

`TypeIs[]` is a special form that allows users to define their own
narrowing functions. Despite the syntax, `TypeIs` is not a generic and,
on its own, it is meaningless as a type.
[Officially](https://typing.python.org/en/latest/spec/narrowing.html#typeis),
a function annotated as returning a `TypeIs[T]` is a <i>type narrowing
function</i>, where `T` is called the <i>`TypeIs` return type</i>.

A `TypeIs[T]` may or may not be bound to a symbol. Only bound types have
narrowing effect:

```python
def f(v: object = object()) -> TypeIs[int]: ...

a: str = returns_str()

if reveal_type(f()):   # Unbound: TypeIs[int]
	reveal_type(a)     # str

if reveal_type(f(a)):  # Bound:   TypeIs[a, int]
	reveal_type(a)     # str & int
```

Delayed usages of a bound type has no effect, however:

```python
b = f(a)

if b:
	reveal_type(a)     # str
```

A `TypeIs[T]` type:

* Is fully static when `T` is fully static.
* Is a singleton/single-valued when it is bound.
* Has exactly two runtime inhabitants when it is unbound: `True` and
`False`.
  In other words, an unbound type have ambiguous truthiness.
It is possible to infer more precise truthiness for bound types;
however, that is not part of this change.

`TypeIs[T]` is a subtype of or otherwise assignable to `bool`. `TypeIs`
is invariant with respect to the `TypeIs` return type: `TypeIs[int]` is
neither a subtype nor a supertype of `TypeIs[bool]`. When ty sees a
function marked as returning `TypeIs[T]`, its `return`s will be checked
against `bool` instead. ty will also report such functions if they don't
accept a positional argument. Addtionally, a type narrowing function
call with no positional arguments (e.g., `f()` in the example above)
will be considered invalid.

## Test Plan

Markdown tests.

---------

Co-authored-by: Carl Meyer <carl@astral.sh>
This commit is contained in:
InSync 2025-06-14 05:27:45 +07:00 committed by GitHub
parent 89d915a1e3
commit 6d56ee803e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 841 additions and 97 deletions

View file

@ -19,7 +19,6 @@ def f(*args: Unpack[Ts]) -> tuple[Unpack[Ts]]:
reveal_type(Alias) # revealed: @Todo(Support for `typing.TypeAlias`)
def g() -> TypeGuard[int]: ...
def h() -> TypeIs[int]: ...
def i(callback: Callable[Concatenate[int, P], R_co], *args: P.args, **kwargs: P.kwargs) -> R_co:
reveal_type(args) # revealed: tuple[@Todo(Support for `typing.ParamSpec`), ...]
reveal_type(kwargs) # revealed: dict[str, @Todo(Support for `typing.ParamSpec`)]

View file

@ -0,0 +1,330 @@
# User-defined type guards
User-defined type guards are functions of which the return type is either `TypeGuard[...]` or
`TypeIs[...]`.
## Display
```py
from ty_extensions import Intersection, Not, TypeOf
from typing_extensions import TypeGuard, TypeIs
def _(
a: TypeGuard[str],
b: TypeIs[str | int],
c: TypeGuard[Intersection[complex, Not[int], Not[float]]],
d: TypeIs[tuple[TypeOf[bytes]]],
e: TypeGuard, # error: [invalid-type-form]
f: TypeIs, # error: [invalid-type-form]
):
# TODO: Should be `TypeGuard[str]`
reveal_type(a) # revealed: @Todo(`TypeGuard[]` special form)
reveal_type(b) # revealed: TypeIs[str | int]
# TODO: Should be `TypeGuard[complex & ~int & ~float]`
reveal_type(c) # revealed: @Todo(`TypeGuard[]` special form)
reveal_type(d) # revealed: TypeIs[tuple[<class 'bytes'>]]
reveal_type(e) # revealed: Unknown
reveal_type(f) # revealed: Unknown
# TODO: error: [invalid-return-type] "Function always implicitly returns `None`, which is not assignable to return type `TypeGuard[str]`"
def _(a) -> TypeGuard[str]: ...
# error: [invalid-return-type] "Function always implicitly returns `None`, which is not assignable to return type `TypeIs[str]`"
def _(a) -> TypeIs[str]: ...
def f(a) -> TypeGuard[str]:
return True
def g(a) -> TypeIs[str]:
return True
def _(a: object):
# TODO: Should be `TypeGuard[str @ a]`
reveal_type(f(a)) # revealed: @Todo(`TypeGuard[]` special form)
reveal_type(g(a)) # revealed: TypeIs[str @ a]
```
## Parameters
A user-defined type guard must accept at least one positional argument (in addition to `self`/`cls`
for non-static methods).
```pyi
from typing_extensions import TypeGuard, TypeIs
# TODO: error: [invalid-type-guard-definition]
def _() -> TypeGuard[str]: ...
# TODO: error: [invalid-type-guard-definition]
def _(**kwargs) -> TypeIs[str]: ...
class _:
# fine
def _(self, /, a) -> TypeGuard[str]: ...
@classmethod
def _(cls, a) -> TypeGuard[str]: ...
@staticmethod
def _(a) -> TypeIs[str]: ...
# errors
def _(self) -> TypeGuard[str]: ... # TODO: error: [invalid-type-guard-definition]
def _(self, /, *, a) -> TypeGuard[str]: ... # TODO: error: [invalid-type-guard-definition]
@classmethod
def _(cls) -> TypeIs[str]: ... # TODO: error: [invalid-type-guard-definition]
@classmethod
def _() -> TypeIs[str]: ... # TODO: error: [invalid-type-guard-definition]
@staticmethod
def _(*, a) -> TypeGuard[str]: ... # TODO: error: [invalid-type-guard-definition]
```
For `TypeIs` functions, the narrowed type must be assignable to the declared type of that parameter,
if any.
```pyi
from typing import Any
from typing_extensions import TypeIs
def _(a: object) -> TypeIs[str]: ...
def _(a: Any) -> TypeIs[str]: ...
def _(a: tuple[object]) -> TypeIs[tuple[str]]: ...
def _(a: str | Any) -> TypeIs[str]: ...
def _(a) -> TypeIs[str]: ...
# TODO: error: [invalid-type-guard-definition]
def _(a: int) -> TypeIs[str]: ...
# TODO: error: [invalid-type-guard-definition]
def _(a: bool | str) -> TypeIs[int]: ...
```
## Arguments to special forms
`TypeGuard` and `TypeIs` accept exactly one type argument.
```py
from typing_extensions import TypeGuard, TypeIs
a = 123
# TODO: error: [invalid-type-form]
def f(_) -> TypeGuard[int, str]: ...
# error: [invalid-type-form] "Special form `typing.TypeIs` expected exactly one type parameter"
# error: [invalid-type-form] "Variable of type `Literal[123]` is not allowed in a type expression"
def g(_) -> TypeIs[a, str]: ...
# TODO: Should be `Unknown`
reveal_type(f(0)) # revealed: @Todo(`TypeGuard[]` special form)
reveal_type(g(0)) # revealed: Unknown
```
## Return types
All code paths in a type guard function must return booleans.
```py
from typing_extensions import Literal, TypeGuard, TypeIs, assert_never
def _(a: object, flag: bool) -> TypeGuard[str]:
if flag:
return 0
# TODO: error: [invalid-return-type] "Return type does not match returned value: expected `TypeIs[str]`, found `Literal["foo"]`"
return "foo"
# error: [invalid-return-type] "Function can implicitly return `None`, which is not assignable to return type `TypeIs[str]`"
def f(a: object, flag: bool) -> TypeIs[str]:
if flag:
# error: [invalid-return-type] "Return type does not match returned value: expected `TypeIs[str]`, found `float`"
return 1.2
def g(a: Literal["foo", "bar"]) -> TypeIs[Literal["foo"]]:
if a == "foo":
# Logically wrong, but allowed regardless
return False
return False
```
## Invalid calls
```py
from typing import Any
from typing_extensions import TypeGuard, TypeIs
def f(a: object) -> TypeGuard[str]:
return True
def g(a: object) -> TypeIs[int]:
return True
def _(d: Any):
if f(): # error: [missing-argument]
...
# TODO: no error, once we support splatted call args
if g(*d): # error: [missing-argument]
...
if f("foo"): # TODO: error: [invalid-type-guard-call]
...
if g(a=d): # error: [invalid-type-guard-call]
...
```
## Narrowing
```py
from typing import Any
from typing_extensions import TypeGuard, TypeIs
def guard_str(a: object) -> TypeGuard[str]:
return True
def is_int(a: object) -> TypeIs[int]:
return True
```
```py
def _(a: str | int):
if guard_str(a):
# TODO: Should be `str`
reveal_type(a) # revealed: str | int
else:
reveal_type(a) # revealed: str | int
if is_int(a):
reveal_type(a) # revealed: int
else:
reveal_type(a) # revealed: str & ~int
```
Attribute and subscript narrowing is supported:
```py
from typing_extensions import Any, Generic, Protocol, TypeVar
T = TypeVar("T")
class C(Generic[T]):
v: T
def _(a: tuple[str, int] | tuple[int, str], c: C[Any]):
# TODO: Should be `TypeGuard[str @ a[1]]`
if reveal_type(guard_str(a[1])): # revealed: @Todo(`TypeGuard[]` special form)
# TODO: Should be `tuple[int, str]`
reveal_type(a) # revealed: tuple[str, int] | tuple[int, str]
# TODO: Should be `str`
reveal_type(a[1]) # revealed: Unknown
if reveal_type(is_int(a[0])): # revealed: TypeIs[int @ a[0]]
# TODO: Should be `tuple[int, str]`
reveal_type(a) # revealed: tuple[str, int] | tuple[int, str]
# TODO: Should be `int`
reveal_type(a[0]) # revealed: Unknown
# TODO: Should be `TypeGuard[str @ c.v]`
if reveal_type(guard_str(c.v)): # revealed: @Todo(`TypeGuard[]` special form)
reveal_type(c) # revealed: C[Any]
# TODO: Should be `str`
reveal_type(c.v) # revealed: Any
if reveal_type(is_int(c.v)): # revealed: TypeIs[int @ c.v]
reveal_type(c) # revealed: C[Any]
# TODO: Should be `int`
reveal_type(c.v) # revealed: Any
```
Indirect usage is supported within the same scope:
```py
def _(a: str | int):
b = guard_str(a)
c = is_int(a)
reveal_type(a) # revealed: str | int
# TODO: Should be `TypeGuard[str @ a]`
reveal_type(b) # revealed: @Todo(`TypeGuard[]` special form)
reveal_type(c) # revealed: TypeIs[int @ a]
if b:
# TODO should be `str`
reveal_type(a) # revealed: str | int
else:
reveal_type(a) # revealed: str | int
if c:
# TODO should be `int`
reveal_type(a) # revealed: str | int
else:
# TODO should be `str & ~int`
reveal_type(a) # revealed: str | int
```
Further writes to the narrowed place invalidate the narrowing:
```py
def _(x: str | int, flag: bool) -> None:
b = is_int(x)
reveal_type(b) # revealed: TypeIs[int @ x]
if flag:
x = ""
if b:
reveal_type(x) # revealed: str | int
```
The `TypeIs` type remains effective across generic boundaries:
```py
from typing_extensions import TypeVar, reveal_type
T = TypeVar("T")
def f(v: object) -> TypeIs[int]:
return True
def g(v: T) -> T:
return v
def _(a: str):
# `reveal_type()` has the type `[T]() -> T`
if reveal_type(f(a)): # revealed: TypeIs[int @ a]
reveal_type(a) # revealed: str & int
if g(f(a)):
reveal_type(a) # revealed: str & int
```
## `TypeGuard` special cases
```py
from typing import Any
from typing_extensions import TypeGuard, TypeIs
def guard_int(a: object) -> TypeGuard[int]:
return True
def is_int(a: object) -> TypeIs[int]:
return True
def does_not_narrow_in_negative_case(a: str | int):
if not guard_int(a):
# TODO: Should be `str`
reveal_type(a) # revealed: str | int
else:
reveal_type(a) # revealed: str | int
def narrowed_type_must_be_exact(a: object, b: bool):
if guard_int(b):
# TODO: Should be `int`
reveal_type(b) # revealed: bool
if isinstance(a, bool) and is_int(a):
reveal_type(a) # revealed: bool
if isinstance(a, bool) and guard_int(a):
# TODO: Should be `int`
reveal_type(a) # revealed: bool
```

View file

@ -871,4 +871,20 @@ def g3(obj: Foo[tuple[A]]):
f3(obj)
```
## `TypeGuard` and `TypeIs`
`TypeGuard[...]` and `TypeIs[...]` are always assignable to `bool`.
```py
from ty_extensions import Unknown, is_assignable_to, static_assert
from typing_extensions import Any, TypeGuard, TypeIs
static_assert(is_assignable_to(TypeGuard[Unknown], bool))
static_assert(is_assignable_to(TypeIs[Any], bool))
# TODO no error
static_assert(not is_assignable_to(TypeGuard[Unknown], str)) # error: [static-assert-error]
static_assert(not is_assignable_to(TypeIs[Any], str))
```
[typing documentation]: https://typing.python.org/en/latest/spec/concepts.html#the-assignable-to-or-consistent-subtyping-relation

View file

@ -402,6 +402,20 @@ static_assert(is_disjoint_from(TypeOf[C.prop], D))
static_assert(is_disjoint_from(D, TypeOf[C.prop]))
```
### `TypeGuard` and `TypeIs`
```py
from ty_extensions import static_assert, is_disjoint_from
from typing_extensions import TypeGuard, TypeIs
static_assert(not is_disjoint_from(bool, TypeGuard[str]))
static_assert(not is_disjoint_from(bool, TypeIs[str]))
# TODO no error
static_assert(is_disjoint_from(str, TypeGuard[str])) # error: [static-assert-error]
static_assert(is_disjoint_from(str, TypeIs[str]))
```
## Callables
No two callable types are disjoint because there exists a non-empty callable type

View file

@ -342,6 +342,38 @@ static_assert(is_subtype_of(Intersection[LiteralString, Not[Literal[""]]], Not[A
static_assert(is_subtype_of(Intersection[LiteralString, Not[Literal["", "a"]]], Not[AlwaysFalsy]))
```
### `TypeGuard` and `TypeIs`
Fully-static `TypeGuard[...]` and `TypeIs[...]` are subtypes of `bool`.
```py
from ty_extensions import is_subtype_of, static_assert
from typing_extensions import TypeGuard, TypeIs
# TODO: TypeGuard
# static_assert(is_subtype_of(TypeGuard[int], bool))
# static_assert(is_subtype_of(TypeGuard[int], int))
static_assert(is_subtype_of(TypeIs[str], bool))
static_assert(is_subtype_of(TypeIs[str], int))
```
`TypeIs` is invariant. `TypeGuard` is covariant.
```py
from ty_extensions import is_equivalent_to, is_subtype_of, static_assert
from typing_extensions import TypeGuard, TypeIs
# TODO: TypeGuard
# static_assert(is_subtype_of(TypeGuard[int], TypeGuard[int]))
# static_assert(is_subtype_of(TypeGuard[bool], TypeGuard[int]))
static_assert(is_subtype_of(TypeIs[int], TypeIs[int]))
static_assert(is_subtype_of(TypeIs[int], TypeIs[int]))
static_assert(not is_subtype_of(TypeGuard[int], TypeGuard[bool]))
static_assert(not is_subtype_of(TypeIs[bool], TypeIs[int]))
static_assert(not is_subtype_of(TypeIs[int], TypeIs[bool]))
```
### Module literals
```py

View file

@ -35,8 +35,8 @@ use crate::module_resolver::{KnownModule, resolve_module};
use crate::place::{Boundness, Place, PlaceAndQualifiers, imported_symbol};
use crate::semantic_index::ast_ids::HasScopedExpressionId;
use crate::semantic_index::definition::Definition;
use crate::semantic_index::place::ScopeId;
use crate::semantic_index::{imported_modules, semantic_index};
use crate::semantic_index::place::{ScopeId, ScopedPlaceId};
use crate::semantic_index::{imported_modules, place_table, semantic_index};
use crate::suppression::check_suppressions;
use crate::types::call::{Binding, Bindings, CallArgumentTypes, CallableBinding};
pub(crate) use crate::types::class_base::ClassBase;
@ -553,6 +553,8 @@ pub enum Type<'db> {
// This type doesn't handle an unbound super object like `super(A)`; for that we just use
// a `Type::NominalInstance` of `builtins.super`.
BoundSuper(BoundSuperType<'db>),
/// A subtype of `bool` that allows narrowing in both positive and negative cases.
TypeIs(TypeIsType<'db>),
// TODO protocols, overloads, generics
}
@ -726,6 +728,9 @@ impl<'db> Type<'db> {
.map(|ty| ty.materialize(db, variance)),
),
Type::TypeVar(type_var) => Type::TypeVar(type_var.materialize(db, variance)),
Type::TypeIs(type_is) => {
type_is.with_type(db, type_is.return_type(db).materialize(db, variance))
}
}
}
@ -777,6 +782,11 @@ impl<'db> Type<'db> {
*self
}
Self::TypeIs(type_is) => type_is.with_type(
db,
type_is.return_type(db).replace_self_reference(db, class),
),
Self::Dynamic(_)
| Self::AlwaysFalsy
| Self::AlwaysTruthy
@ -910,6 +920,8 @@ impl<'db> Type<'db> {
.iter()
.any(|ty| ty.any_over_type(db, type_fn)),
},
Self::TypeIs(type_is) => type_is.return_type(db).any_over_type(db, type_fn),
}
}
@ -1145,6 +1157,7 @@ impl<'db> Type<'db> {
Type::KnownInstance(known_instance) => {
Type::KnownInstance(known_instance.normalized(db))
}
Type::TypeIs(type_is) => type_is.with_type(db, type_is.return_type(db).normalized(db)),
Type::LiteralString
| Type::AlwaysFalsy
| Type::AlwaysTruthy
@ -1404,6 +1417,11 @@ impl<'db> Type<'db> {
false
}
// `TypeIs[T]` is a subtype of `bool`.
(Type::TypeIs(_), _) => KnownClass::Bool
.to_instance(db)
.has_relation_to(db, target, relation),
// Function-like callables are subtypes of `FunctionType`
(Type::Callable(callable), _)
if callable.is_function_like(db)
@ -1949,14 +1967,15 @@ impl<'db> Type<'db> {
known_instance_ty @ (Type::SpecialForm(_) | Type::KnownInstance(_)),
) => known_instance_ty.is_disjoint_from(db, tuple.homogeneous_supertype(db)),
(Type::BooleanLiteral(..), Type::NominalInstance(instance))
| (Type::NominalInstance(instance), Type::BooleanLiteral(..)) => {
(Type::BooleanLiteral(..) | Type::TypeIs(_), Type::NominalInstance(instance))
| (Type::NominalInstance(instance), Type::BooleanLiteral(..) | Type::TypeIs(_)) => {
// A `Type::BooleanLiteral()` must be an instance of exactly `bool`
// (it cannot be an instance of a `bool` subclass)
!KnownClass::Bool.is_subclass_of(db, instance.class)
}
(Type::BooleanLiteral(..), _) | (_, Type::BooleanLiteral(..)) => true,
(Type::BooleanLiteral(..) | Type::TypeIs(_), _)
| (_, Type::BooleanLiteral(..) | Type::TypeIs(_)) => true,
(Type::IntLiteral(..), Type::NominalInstance(instance))
| (Type::NominalInstance(instance), Type::IntLiteral(..)) => {
@ -2186,6 +2205,7 @@ impl<'db> Type<'db> {
.iter()
.all(|elem| elem.is_fully_static(db)),
Type::Callable(callable) => callable.is_fully_static(db),
Type::TypeIs(type_is) => type_is.return_type(db).is_fully_static(db),
}
}
@ -2310,6 +2330,7 @@ impl<'db> Type<'db> {
false
}
Type::AlwaysTruthy | Type::AlwaysFalsy => false,
Type::TypeIs(type_is) => type_is.is_bound(db),
}
}
@ -2367,6 +2388,8 @@ impl<'db> Type<'db> {
false
}
Type::TypeIs(type_is) => type_is.is_bound(db),
Type::Dynamic(_)
| Type::Never
| Type::Union(..)
@ -2495,7 +2518,8 @@ impl<'db> Type<'db> {
| Type::TypeVar(_)
| Type::NominalInstance(_)
| Type::ProtocolInstance(_)
| Type::PropertyInstance(_) => None,
| Type::PropertyInstance(_)
| Type::TypeIs(_) => None,
}
}
@ -2595,7 +2619,9 @@ impl<'db> Type<'db> {
},
Type::IntLiteral(_) => KnownClass::Int.to_instance(db).instance_member(db, name),
Type::BooleanLiteral(_) => KnownClass::Bool.to_instance(db).instance_member(db, name),
Type::BooleanLiteral(_) | Type::TypeIs(_) => {
KnownClass::Bool.to_instance(db).instance_member(db, name)
}
Type::StringLiteral(_) | Type::LiteralString => {
KnownClass::Str.to_instance(db).instance_member(db, name)
}
@ -3116,7 +3142,8 @@ impl<'db> Type<'db> {
| Type::SpecialForm(..)
| Type::KnownInstance(..)
| Type::PropertyInstance(..)
| Type::FunctionLiteral(..) => {
| Type::FunctionLiteral(..)
| Type::TypeIs(..) => {
let fallback = self.instance_member(db, name_str);
let result = self.invoke_descriptor_protocol(
@ -3381,9 +3408,11 @@ impl<'db> Type<'db> {
};
let truthiness = match self {
Type::Dynamic(_) | Type::Never | Type::Callable(_) | Type::LiteralString => {
Truthiness::Ambiguous
}
Type::Dynamic(_)
| Type::Never
| Type::Callable(_)
| Type::LiteralString
| Type::TypeIs(_) => Truthiness::Ambiguous,
Type::FunctionLiteral(_)
| Type::BoundMethod(_)
@ -4348,7 +4377,8 @@ impl<'db> Type<'db> {
| Type::LiteralString
| Type::Tuple(_)
| Type::BoundSuper(_)
| Type::ModuleLiteral(_) => CallableBinding::not_callable(self).into(),
| Type::ModuleLiteral(_)
| Type::TypeIs(_) => CallableBinding::not_callable(self).into(),
}
}
@ -4836,7 +4866,8 @@ impl<'db> Type<'db> {
| Type::LiteralString
| Type::BoundSuper(_)
| Type::AlwaysTruthy
| Type::AlwaysFalsy => None,
| Type::AlwaysFalsy
| Type::TypeIs(_) => None,
}
}
@ -4902,7 +4933,8 @@ impl<'db> Type<'db> {
| Type::FunctionLiteral(_)
| Type::BoundSuper(_)
| Type::ProtocolInstance(_)
| Type::PropertyInstance(_) => Err(InvalidTypeExpressionError {
| Type::PropertyInstance(_)
| Type::TypeIs(_) => Err(InvalidTypeExpressionError {
invalid_expressions: smallvec::smallvec![InvalidTypeExpression::InvalidType(
*self, scope_id
)],
@ -5141,7 +5173,7 @@ impl<'db> Type<'db> {
Type::SpecialForm(special_form) => special_form.to_meta_type(db),
Type::PropertyInstance(_) => KnownClass::Property.to_class_literal(db),
Type::Union(union) => union.map(db, |ty| ty.to_meta_type(db)),
Type::BooleanLiteral(_) => KnownClass::Bool.to_class_literal(db),
Type::BooleanLiteral(_) | Type::TypeIs(_) => KnownClass::Bool.to_class_literal(db),
Type::BytesLiteral(_) => KnownClass::Bytes.to_class_literal(db),
Type::IntLiteral(_) => KnownClass::Int.to_class_literal(db),
Type::FunctionLiteral(_) => KnownClass::FunctionType.to_class_literal(db),
@ -5315,6 +5347,8 @@ impl<'db> Type<'db> {
.map(|ty| ty.apply_type_mapping(db, type_mapping)),
),
Type::TypeIs(type_is) => type_is.with_type(db, type_is.return_type(db).apply_type_mapping(db, type_mapping)),
Type::ModuleLiteral(_)
| Type::IntLiteral(_)
| Type::BooleanLiteral(_)
@ -5424,6 +5458,10 @@ impl<'db> Type<'db> {
subclass_of.find_legacy_typevars(db, typevars);
}
Type::TypeIs(type_is) => {
type_is.return_type(db).find_legacy_typevars(db, typevars);
}
Type::Dynamic(_)
| Type::Never
| Type::AlwaysTruthy
@ -5553,8 +5591,9 @@ impl<'db> Type<'db> {
| Self::Never
| Self::Callable(_)
| Self::AlwaysTruthy
| Self::AlwaysFalsy
| Self::SpecialForm(_)
| Self::AlwaysFalsy => None,
| Self::TypeIs(_) => None,
}
}
@ -8476,6 +8515,54 @@ impl<'db> BoundSuperType<'db> {
}
}
#[salsa::interned(debug)]
pub struct TypeIsType<'db> {
return_type: Type<'db>,
/// The ID of the scope to which the place belongs
/// and the ID of the place itself within that scope.
place_info: Option<(ScopeId<'db>, ScopedPlaceId)>,
}
impl<'db> TypeIsType<'db> {
pub fn place_name(self, db: &'db dyn Db) -> Option<String> {
let (scope, place) = self.place_info(db)?;
let table = place_table(db, scope);
Some(format!("{}", table.place_expr(place)))
}
pub fn unbound(db: &'db dyn Db, ty: Type<'db>) -> Type<'db> {
Type::TypeIs(Self::new(db, ty, None))
}
pub fn bound(
db: &'db dyn Db,
return_type: Type<'db>,
scope: ScopeId<'db>,
place: ScopedPlaceId,
) -> Type<'db> {
Type::TypeIs(Self::new(db, return_type, Some((scope, place))))
}
#[must_use]
pub fn bind(self, db: &'db dyn Db, scope: ScopeId<'db>, place: ScopedPlaceId) -> Type<'db> {
Self::bound(db, self.return_type(db), scope, place)
}
#[must_use]
pub fn with_type(self, db: &'db dyn Db, ty: Type<'db>) -> Type<'db> {
Type::TypeIs(Self::new(db, ty, self.place_info(db)))
}
pub fn is_bound(&self, db: &'db dyn Db) -> bool {
self.place_info(db).is_some()
}
pub fn is_unbound(&self, db: &'db dyn Db) -> bool {
self.place_info(db).is_none()
}
}
// Make sure that the `Type` enum does not grow unexpectedly.
#[cfg(not(debug_assertions))]
#[cfg(target_pointer_width = "64")]

View file

@ -146,7 +146,8 @@ impl<'db> ClassBase<'db> {
| Type::BoundSuper(_)
| Type::ProtocolInstance(_)
| Type::AlwaysFalsy
| Type::AlwaysTruthy => None,
| Type::AlwaysTruthy
| Type::TypeIs(_) => None,
Type::KnownInstance(known_instance) => match known_instance {
KnownInstanceType::SubscriptedGeneric(_) => Some(Self::Generic),

View file

@ -54,6 +54,8 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) {
registry.register_lint(&INVALID_SUPER_ARGUMENT);
registry.register_lint(&INVALID_TYPE_CHECKING_CONSTANT);
registry.register_lint(&INVALID_TYPE_FORM);
registry.register_lint(&INVALID_TYPE_GUARD_DEFINITION);
registry.register_lint(&INVALID_TYPE_GUARD_CALL);
registry.register_lint(&INVALID_TYPE_VARIABLE_CONSTRAINTS);
registry.register_lint(&MISSING_ARGUMENT);
registry.register_lint(&NO_MATCHING_OVERLOAD);
@ -893,6 +895,62 @@ declare_lint! {
}
}
declare_lint! {
/// ## What it does
/// Checks for type guard functions without
/// a first non-self-like non-keyword-only non-variadic parameter.
///
/// ## Why is this bad?
/// Type narrowing functions must accept at least one positional argument
/// (non-static methods must accept another in addition to `self`/`cls`).
///
/// Extra parameters/arguments are allowed but do not affect narrowing.
///
/// ## Examples
/// ```python
/// from typing import TypeIs
///
/// def f() -> TypeIs[int]: ... # Error, no parameter
/// def f(*, v: object) -> TypeIs[int]: ... # Error, no positional arguments allowed
/// def f(*args: object) -> TypeIs[int]: ... # Error, expect variadic arguments
/// class C:
/// def f(self) -> TypeIs[int]: ... # Error, only positional argument expected is `self`
/// ```
pub(crate) static INVALID_TYPE_GUARD_DEFINITION = {
summary: "detects malformed type guard functions",
status: LintStatus::preview("1.0.0"),
default_level: Level::Error,
}
}
declare_lint! {
/// ## What it does
/// Checks for type guard function calls without a valid target.
///
/// ## Why is this bad?
/// The first non-keyword non-variadic argument to a type guard function
/// is its target and must map to a symbol.
///
/// Starred (`is_str(*a)`), literal (`is_str(42)`) and other non-symbol-like
/// expressions are invalid as narrowing targets.
///
/// ## Examples
/// ```python
/// from typing import TypeIs
///
/// def f(v: object) -> TypeIs[int]: ...
///
/// f() # Error
/// f(*a) # Error
/// f(10) # Error
/// ```
pub(crate) static INVALID_TYPE_GUARD_CALL = {
summary: "detects type guard function calls that has no narrowing effect",
status: LintStatus::preview("1.0.0"),
default_level: Level::Error,
}
}
declare_lint! {
/// ## What it does
/// Checks for constrained [type variables] with only one constraint.

View file

@ -211,6 +211,15 @@ impl Display for DisplayRepresentation<'_> {
owner = bound_super.owner(self.db).into_type().display(self.db)
)
}
Type::TypeIs(type_is) => {
f.write_str("TypeIs[")?;
type_is.return_type(self.db).display(self.db).fmt(f)?;
if let Some(name) = type_is.place_name(self.db) {
f.write_str(" @ ")?;
f.write_str(&name)?;
}
f.write_str("]")
}
}
}
}

View file

@ -116,7 +116,8 @@ impl AllMembers {
| Type::SpecialForm(_)
| Type::KnownInstance(_)
| Type::TypeVar(_)
| Type::BoundSuper(_) => {
| Type::BoundSuper(_)
| Type::TypeIs(_) => {
if let Type::ClassLiteral(class_literal) = ty.to_meta_type(db) {
self.extend_with_class_members(db, class_literal);
}

View file

@ -68,7 +68,7 @@ use crate::semantic_index::narrowing_constraints::ConstraintKey;
use crate::semantic_index::place::{
FileScopeId, NodeWithScopeKind, NodeWithScopeRef, PlaceExpr, ScopeId, ScopeKind, ScopedPlaceId,
};
use crate::semantic_index::{EagerSnapshotResult, SemanticIndex, semantic_index};
use crate::semantic_index::{EagerSnapshotResult, SemanticIndex, place_table, semantic_index};
use crate::types::call::{
Argument, Binding, Bindings, CallArgumentTypes, CallArguments, CallError,
};
@ -78,13 +78,14 @@ use crate::types::diagnostic::{
CYCLIC_CLASS_DEFINITION, DIVISION_BY_ZERO, INCONSISTENT_MRO, INVALID_ARGUMENT_TYPE,
INVALID_ASSIGNMENT, INVALID_ATTRIBUTE_ACCESS, INVALID_BASE, INVALID_DECLARATION,
INVALID_GENERIC_CLASS, INVALID_LEGACY_TYPE_VARIABLE, INVALID_PARAMETER_DEFAULT,
INVALID_TYPE_ALIAS_TYPE, INVALID_TYPE_FORM, INVALID_TYPE_VARIABLE_CONSTRAINTS,
POSSIBLY_UNBOUND_IMPLICIT_CALL, POSSIBLY_UNBOUND_IMPORT, TypeCheckDiagnostics,
UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_IMPORT, UNRESOLVED_REFERENCE,
UNSUPPORTED_OPERATOR, report_implicit_return_type, report_invalid_arguments_to_annotated,
report_invalid_arguments_to_callable, report_invalid_assignment,
report_invalid_attribute_assignment, report_invalid_generator_function_return_type,
report_invalid_return_type, report_possibly_unbound_attribute,
INVALID_TYPE_ALIAS_TYPE, INVALID_TYPE_FORM, INVALID_TYPE_GUARD_CALL,
INVALID_TYPE_VARIABLE_CONSTRAINTS, POSSIBLY_UNBOUND_IMPLICIT_CALL, POSSIBLY_UNBOUND_IMPORT,
TypeCheckDiagnostics, UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_IMPORT,
UNRESOLVED_REFERENCE, UNSUPPORTED_OPERATOR, report_implicit_return_type,
report_invalid_arguments_to_annotated, report_invalid_arguments_to_callable,
report_invalid_assignment, report_invalid_attribute_assignment,
report_invalid_generator_function_return_type, report_invalid_return_type,
report_possibly_unbound_attribute,
};
use crate::types::function::{
FunctionDecorators, FunctionLiteral, FunctionType, KnownFunction, OverloadLiteral,
@ -99,8 +100,8 @@ use crate::types::{
KnownInstanceType, LintDiagnosticGuard, MemberLookupPolicy, MetaclassCandidate,
PEP695TypeAliasType, Parameter, ParameterForm, Parameters, SpecialFormType, StringLiteralType,
SubclassOfType, Truthiness, TupleType, Type, TypeAliasType, TypeAndQualifiers,
TypeArrayDisplay, TypeQualifiers, TypeVarBoundOrConstraints, TypeVarInstance, TypeVarKind,
TypeVarVariance, UnionBuilder, UnionType, binding_type, todo_type,
TypeArrayDisplay, TypeIsType, TypeQualifiers, TypeVarBoundOrConstraints, TypeVarInstance,
TypeVarKind, TypeVarVariance, UnionBuilder, UnionType, binding_type, todo_type,
};
use crate::unpack::{Unpack, UnpackPosition};
use crate::util::subscript::{PyIndex, PySlice};
@ -672,6 +673,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
self.types.expressions.extend(inference.expressions.iter());
self.types.deferred.extend(inference.deferred.iter());
self.context.extend(inference.diagnostics());
self.types.cycle_fallback_type = self
.types
.cycle_fallback_type
.or(inference.cycle_fallback_type);
}
fn file(&self) -> File {
@ -1904,6 +1909,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
}
let declared_ty = self.file_expression_type(returns);
let expected_ty = match declared_ty {
Type::TypeIs(_) => KnownClass::Bool.to_instance(self.db()),
ty => ty,
};
let scope_id = self.index.node_scope(NodeWithScopeRef::Function(function));
if scope_id.is_generator_function(self.index) {
@ -1921,7 +1930,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
if !inferred_return
.to_instance(self.db())
.is_assignable_to(self.db(), declared_ty)
.is_assignable_to(self.db(), expected_ty)
{
report_invalid_generator_function_return_type(
&self.context,
@ -1947,7 +1956,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
ty if ty.is_notimplemented(self.db()) => None,
_ => Some(ty_range),
})
.filter(|ty_range| !ty_range.ty.is_assignable_to(self.db(), declared_ty))
.filter(|ty_range| !ty_range.ty.is_assignable_to(self.db(), expected_ty))
{
report_invalid_return_type(
&self.context,
@ -1959,7 +1968,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
}
let use_def = self.index.use_def_map(scope_id);
if use_def.can_implicit_return(self.db())
&& !Type::none(self.db()).is_assignable_to(self.db(), declared_ty)
&& !Type::none(self.db()).is_assignable_to(self.db(), expected_ty)
{
let no_return = self.return_types_and_ranges.is_empty();
report_implicit_return_type(
@ -3213,7 +3222,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
| Type::DataclassTransformer(_)
| Type::TypeVar(..)
| Type::AlwaysTruthy
| Type::AlwaysFalsy => {
| Type::AlwaysFalsy
| Type::TypeIs(_) => {
let is_read_only = || {
let dataclass_params = match object_ty {
Type::NominalInstance(instance) => match instance.class {
@ -5800,7 +5810,45 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
}
}
}
bindings.return_type(self.db())
let db = self.db();
let scope = self.scope();
let return_ty = bindings.return_type(db);
let find_narrowed_place = || match arguments.args.first() {
None => {
// This branch looks extraneous, especially in the face of `missing-arguments`.
// However, that lint won't be able to catch this:
//
// ```python
// def f(v: object = object()) -> TypeIs[int]: ...
//
// if f(): ...
// ```
//
// TODO: Will this report things that is actually fine?
if let Some(builder) = self
.context
.report_lint(&INVALID_TYPE_GUARD_CALL, arguments)
{
builder.into_diagnostic("Type guard call does not have a target");
}
None
}
Some(expr) => match PlaceExpr::try_from(expr) {
Ok(place_expr) => place_table(db, scope).place_id_by_expr(&place_expr),
Err(()) => None,
},
};
match return_ty {
// TODO: TypeGuard
Type::TypeIs(type_is) => match find_narrowed_place() {
Some(place) => type_is.bind(db, scope, place),
None => return_ty,
},
_ => return_ty,
}
}
Err(CallError(_, bindings)) => {
@ -6428,7 +6476,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
| Type::BytesLiteral(_)
| Type::Tuple(_)
| Type::BoundSuper(_)
| Type::TypeVar(_),
| Type::TypeVar(_)
| Type::TypeIs(_),
) => {
let unary_dunder_method = match op {
ast::UnaryOp::Invert => "__invert__",
@ -6759,7 +6808,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
| Type::BytesLiteral(_)
| Type::Tuple(_)
| Type::BoundSuper(_)
| Type::TypeVar(_),
| Type::TypeVar(_)
| Type::TypeIs(_),
Type::FunctionLiteral(_)
| Type::Callable(..)
| Type::BoundMethod(_)
@ -6785,7 +6835,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
| Type::BytesLiteral(_)
| Type::Tuple(_)
| Type::BoundSuper(_)
| Type::TypeVar(_),
| Type::TypeVar(_)
| Type::TypeIs(_),
op,
) => {
// We either want to call lhs.__op__ or rhs.__rop__. The full decision tree from
@ -9552,10 +9603,22 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
self.infer_type_expression(arguments_slice);
todo_type!("`Required[]` type qualifier")
}
SpecialFormType::TypeIs => {
self.infer_type_expression(arguments_slice);
todo_type!("`TypeIs[]` special form")
}
SpecialFormType::TypeIs => match arguments_slice {
ast::Expr::Tuple(_) => {
self.infer_type_expression(arguments_slice);
if let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, subscript) {
let diag = builder.into_diagnostic(format_args!(
"Special form `{}` expected exactly one type parameter",
special_form.repr()
));
diagnostic::add_type_expression_reference_link(diag);
}
Type::unknown()
}
_ => TypeIsType::unbound(self.db(), self.infer_type_expression(arguments_slice)),
},
SpecialFormType::TypeGuard => {
self.infer_type_expression(arguments_slice);
todo_type!("`TypeGuard[]` special form")

View file

@ -388,7 +388,6 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> {
let ast::ExprName { id, .. } = expr_name;
let symbol = self.expect_expr_name_symbol(id);
let ty = if is_positive {
Type::AlwaysFalsy.negate(self.db)
} else {
@ -728,6 +727,29 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> {
// TODO: add support for PEP 604 union types on the right hand side of `isinstance`
// and `issubclass`, for example `isinstance(x, str | (int | float))`.
match callable_ty {
Type::FunctionLiteral(function_type)
if matches!(
function_type.known(self.db),
None | Some(KnownFunction::RevealType)
) =>
{
let return_ty =
inference.expression_type(expr_call.scoped_expression_id(self.db, scope));
let (guarded_ty, place) = match return_ty {
// TODO: TypeGuard
Type::TypeIs(type_is) => {
let (_, place) = type_is.place_info(self.db)?;
(type_is.return_type(self.db), place)
}
_ => return None,
};
Some(NarrowingConstraints::from_iter([(
place,
guarded_ty.negate_if(self.db, !is_positive),
)]))
}
Type::FunctionLiteral(function_type) if expr_call.arguments.keywords.is_empty() => {
let [first_arg, second_arg] = &*expr_call.arguments.args else {
return None;

View file

@ -3,7 +3,7 @@ use std::cmp::Ordering;
use crate::db::Db;
use super::{
DynamicType, SuperOwnerKind, TodoType, Type, class_base::ClassBase,
DynamicType, SuperOwnerKind, TodoType, Type, TypeIsType, class_base::ClassBase,
subclass_of::SubclassOfInner,
};
@ -126,6 +126,10 @@ pub(super) fn union_or_intersection_elements_ordering<'db>(
(Type::SubclassOf(_), _) => Ordering::Less,
(_, Type::SubclassOf(_)) => Ordering::Greater,
(Type::TypeIs(left), Type::TypeIs(right)) => typeis_ordering(db, *left, *right),
(Type::TypeIs(_), _) => Ordering::Less,
(_, Type::TypeIs(_)) => Ordering::Greater,
(Type::NominalInstance(left), Type::NominalInstance(right)) => left.class.cmp(&right.class),
(Type::NominalInstance(_), _) => Ordering::Less,
(_, Type::NominalInstance(_)) => Ordering::Greater,
@ -248,3 +252,25 @@ fn dynamic_elements_ordering(left: DynamicType, right: DynamicType) -> Ordering
(_, DynamicType::TodoPEP695ParamSpec) => Ordering::Greater,
}
}
/// Determine a canonical order for two instances of [`TypeIsType`].
///
/// The following criteria are considered, in order:
/// * Boundness: Unbound precedes bound
/// * Symbol name: String comparison
/// * Guarded type: [`union_or_intersection_elements_ordering`]
fn typeis_ordering(db: &dyn Db, left: TypeIsType, right: TypeIsType) -> Ordering {
let (left_ty, right_ty) = (left.return_type(db), right.return_type(db));
match (left.place_info(db), right.place_info(db)) {
(None, Some(_)) => Ordering::Less,
(Some(_), None) => Ordering::Greater,
(None, None) => union_or_intersection_elements_ordering(db, &left_ty, &right_ty),
(Some(_), Some(_)) => match left.place_name(db).cmp(&right.place_name(db)) {
Ordering::Equal => union_or_intersection_elements_ordering(db, &left_ty, &right_ty),
ordering => ordering,
},
}
}