mirror of
https://github.com/astral-sh/ruff.git
synced 2025-11-18 19:41:34 +00:00
[ty] Make special cases for UnionType slightly narrower (#21276)
Fixes https://github.com/astral-sh/ty/issues/1478
This commit is contained in:
parent
5517c9943a
commit
f189aad6d2
3 changed files with 158 additions and 39 deletions
|
|
@ -135,14 +135,15 @@ def _(int_or_int: IntOrInt, list_of_int_or_list_of_int: ListOfIntOrListOfInt):
|
|||
None | None # error: [unsupported-operator] "Operator `|` is unsupported between objects of type `None` and `None`"
|
||||
```
|
||||
|
||||
When constructing something non-sensical like `int | 1`, we could ideally emit a diagnostic for the
|
||||
expression itself, as it leads to a `TypeError` at runtime. No other type checker supports this, so
|
||||
for now we only emit an error when it is used in a type expression:
|
||||
When constructing something nonsensical like `int | 1`, we emit a diagnostic for the expression
|
||||
itself, as it leads to a `TypeError` at runtime. The result of the expression is then inferred as
|
||||
`Unknown`, so we permit it to be used in a type expression.
|
||||
|
||||
```py
|
||||
IntOrOne = int | 1
|
||||
IntOrOne = int | 1 # error: [unsupported-operator]
|
||||
|
||||
reveal_type(IntOrOne) # revealed: Unknown
|
||||
|
||||
# error: [invalid-type-form] "Variable of type `Literal[1]` is not allowed in a type expression"
|
||||
def _(int_or_one: IntOrOne):
|
||||
reveal_type(int_or_one) # revealed: Unknown
|
||||
```
|
||||
|
|
@ -160,6 +161,77 @@ def f(SomeUnionType: UnionType):
|
|||
f(int | str)
|
||||
```
|
||||
|
||||
## `|` operator between class objects and non-class objects
|
||||
|
||||
Using the `|` operator between a class object and a non-class object does not create a `UnionType`
|
||||
instance; it calls the relevant dunder as normal:
|
||||
|
||||
```py
|
||||
class Foo:
|
||||
def __or__(self, other) -> str:
|
||||
return "foo"
|
||||
|
||||
reveal_type(Foo() | int) # revealed: str
|
||||
reveal_type(Foo() | list[int]) # revealed: str
|
||||
|
||||
class Bar:
|
||||
def __ror__(self, other) -> str:
|
||||
return "bar"
|
||||
|
||||
reveal_type(int | Bar()) # revealed: str
|
||||
reveal_type(list[int] | Bar()) # revealed: str
|
||||
|
||||
class Invalid:
|
||||
def __or__(self, other: "Invalid") -> str:
|
||||
return "Invalid"
|
||||
|
||||
def __ror__(self, other: "Invalid") -> str:
|
||||
return "Invalid"
|
||||
|
||||
# error: [unsupported-operator]
|
||||
reveal_type(int | Invalid()) # revealed: Unknown
|
||||
# error: [unsupported-operator]
|
||||
reveal_type(Invalid() | list[int]) # revealed: Unknown
|
||||
```
|
||||
|
||||
## Custom `__(r)or__` methods on metaclasses are only partially respected
|
||||
|
||||
A drawback of our extensive special casing of `|` operations between class objects is that
|
||||
`__(r)or__` methods on metaclasses are completely disregarded if two classes are `|`'d together. We
|
||||
respect the metaclass dunder if a class is `|`'d with a non-class, however:
|
||||
|
||||
```py
|
||||
class Meta(type):
|
||||
def __or__(self, other) -> str:
|
||||
return "Meta"
|
||||
|
||||
class Foo(metaclass=Meta): ...
|
||||
class Bar(metaclass=Meta): ...
|
||||
|
||||
X = Foo | Bar
|
||||
|
||||
# In an ideal world, perhaps we would respect `Meta.__or__` here and reveal `str`?
|
||||
# But we still need to record what the elements are, since (according to the typing spec)
|
||||
# `X` is still a valid type alias
|
||||
reveal_type(X) # revealed: types.UnionType
|
||||
|
||||
def f(obj: X):
|
||||
reveal_type(obj) # revealed: Foo | Bar
|
||||
|
||||
# We do respect the metaclass `__or__` if it's used between a class and a non-class, however:
|
||||
|
||||
Y = Foo | 42
|
||||
reveal_type(Y) # revealed: str
|
||||
|
||||
Z = Bar | 56
|
||||
reveal_type(Z) # revealed: str
|
||||
|
||||
def g(
|
||||
arg1: Y, # error: [invalid-type-form]
|
||||
arg2: Z, # error: [invalid-type-form]
|
||||
): ...
|
||||
```
|
||||
|
||||
## Generic types
|
||||
|
||||
Implicit type aliases can also refer to generic types:
|
||||
|
|
@ -191,7 +263,8 @@ From the [typing spec on type aliases](https://typing.python.org/en/latest/spec/
|
|||
> type hint is acceptable in a type alias
|
||||
|
||||
However, no other type checker seems to support stringified annotations in implicit type aliases. We
|
||||
currently also do not support them:
|
||||
currently also do not support them, and we detect places where these attempted unions cause runtime
|
||||
errors:
|
||||
|
||||
```py
|
||||
AliasForStr = "str"
|
||||
|
|
@ -200,9 +273,10 @@ AliasForStr = "str"
|
|||
def _(s: AliasForStr):
|
||||
reveal_type(s) # revealed: Unknown
|
||||
|
||||
IntOrStr = int | "str"
|
||||
IntOrStr = int | "str" # error: [unsupported-operator]
|
||||
|
||||
reveal_type(IntOrStr) # revealed: Unknown
|
||||
|
||||
# error: [invalid-type-form] "Variable of type `Literal["str"]` is not allowed in a type expression"
|
||||
def _(int_or_str: IntOrStr):
|
||||
reveal_type(int_or_str) # revealed: Unknown
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
use super::context::InferContext;
|
||||
use super::{Signature, Type, TypeContext};
|
||||
use crate::Db;
|
||||
use crate::types::PropertyInstanceType;
|
||||
use crate::types::call::bind::BindingError;
|
||||
use crate::types::{MemberLookupPolicy, PropertyInstanceType};
|
||||
use ruff_python_ast as ast;
|
||||
|
||||
mod arguments;
|
||||
|
|
@ -16,6 +16,16 @@ impl<'db> Type<'db> {
|
|||
left_ty: Type<'db>,
|
||||
op: ast::Operator,
|
||||
right_ty: Type<'db>,
|
||||
) -> Result<Bindings<'db>, CallBinOpError> {
|
||||
Self::try_call_bin_op_with_policy(db, left_ty, op, right_ty, MemberLookupPolicy::default())
|
||||
}
|
||||
|
||||
pub(crate) fn try_call_bin_op_with_policy(
|
||||
db: &'db dyn Db,
|
||||
left_ty: Type<'db>,
|
||||
op: ast::Operator,
|
||||
right_ty: Type<'db>,
|
||||
policy: MemberLookupPolicy,
|
||||
) -> Result<Bindings<'db>, CallBinOpError> {
|
||||
// We either want to call lhs.__op__ or rhs.__rop__. The full decision tree from
|
||||
// the Python spec [1] is:
|
||||
|
|
@ -43,39 +53,43 @@ impl<'db> Type<'db> {
|
|||
&& rhs_reflected != left_class.member(db, reflected_dunder).place
|
||||
{
|
||||
return Ok(right_ty
|
||||
.try_call_dunder(
|
||||
.try_call_dunder_with_policy(
|
||||
db,
|
||||
reflected_dunder,
|
||||
CallArguments::positional([left_ty]),
|
||||
&mut CallArguments::positional([left_ty]),
|
||||
TypeContext::default(),
|
||||
policy,
|
||||
)
|
||||
.or_else(|_| {
|
||||
left_ty.try_call_dunder(
|
||||
left_ty.try_call_dunder_with_policy(
|
||||
db,
|
||||
op.dunder(),
|
||||
CallArguments::positional([right_ty]),
|
||||
&mut CallArguments::positional([right_ty]),
|
||||
TypeContext::default(),
|
||||
policy,
|
||||
)
|
||||
})?);
|
||||
}
|
||||
}
|
||||
|
||||
let call_on_left_instance = left_ty.try_call_dunder(
|
||||
let call_on_left_instance = left_ty.try_call_dunder_with_policy(
|
||||
db,
|
||||
op.dunder(),
|
||||
CallArguments::positional([right_ty]),
|
||||
&mut CallArguments::positional([right_ty]),
|
||||
TypeContext::default(),
|
||||
policy,
|
||||
);
|
||||
|
||||
call_on_left_instance.or_else(|_| {
|
||||
if left_ty == right_ty {
|
||||
Err(CallBinOpError::NotSupported)
|
||||
} else {
|
||||
Ok(right_ty.try_call_dunder(
|
||||
Ok(right_ty.try_call_dunder_with_policy(
|
||||
db,
|
||||
op.reflected_dunder(),
|
||||
CallArguments::positional([left_ty]),
|
||||
&mut CallArguments::positional([left_ty]),
|
||||
TypeContext::default(),
|
||||
policy,
|
||||
)?)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8474,11 +8474,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
|
|||
| Type::GenericAlias(..)
|
||||
| Type::SpecialForm(_)
|
||||
| Type::KnownInstance(KnownInstanceType::UnionType(_)),
|
||||
_,
|
||||
ast::Operator::BitOr,
|
||||
)
|
||||
| (
|
||||
_,
|
||||
Type::ClassLiteral(..)
|
||||
| Type::SubclassOf(..)
|
||||
| Type::GenericAlias(..)
|
||||
|
|
@ -8486,30 +8481,66 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
|
|||
| Type::KnownInstance(KnownInstanceType::UnionType(_)),
|
||||
ast::Operator::BitOr,
|
||||
) if Program::get(self.db()).python_version(self.db()) >= PythonVersion::PY310 => {
|
||||
// For a value expression like `int | None`, the inferred type for `None` will be
|
||||
// a nominal instance of `NoneType`, so we need to convert it to a class literal
|
||||
// such that it can later be converted back to a nominal instance type when calling
|
||||
// `.in_type_expression` on the `UnionType` instance.
|
||||
let convert_none_type = |ty: Type<'db>| {
|
||||
if ty.is_none(self.db()) {
|
||||
KnownClass::NoneType.to_class_literal(self.db())
|
||||
} else {
|
||||
ty
|
||||
}
|
||||
};
|
||||
|
||||
if left_ty.is_equivalent_to(self.db(), right_ty) {
|
||||
Some(left_ty)
|
||||
} else {
|
||||
Some(Type::KnownInstance(KnownInstanceType::UnionType(
|
||||
UnionTypeInstance::new(
|
||||
self.db(),
|
||||
convert_none_type(left_ty),
|
||||
convert_none_type(right_ty),
|
||||
),
|
||||
UnionTypeInstance::new(self.db(), left_ty, right_ty),
|
||||
)))
|
||||
}
|
||||
}
|
||||
(
|
||||
Type::ClassLiteral(..)
|
||||
| Type::SubclassOf(..)
|
||||
| Type::GenericAlias(..)
|
||||
| Type::KnownInstance(..)
|
||||
| Type::SpecialForm(..),
|
||||
Type::NominalInstance(instance),
|
||||
ast::Operator::BitOr,
|
||||
)
|
||||
| (
|
||||
Type::NominalInstance(instance),
|
||||
Type::ClassLiteral(..)
|
||||
| Type::SubclassOf(..)
|
||||
| Type::GenericAlias(..)
|
||||
| Type::KnownInstance(..)
|
||||
| Type::SpecialForm(..),
|
||||
ast::Operator::BitOr,
|
||||
) if Program::get(self.db()).python_version(self.db()) >= PythonVersion::PY310
|
||||
&& instance.has_known_class(self.db(), KnownClass::NoneType) =>
|
||||
{
|
||||
Some(Type::KnownInstance(KnownInstanceType::UnionType(
|
||||
UnionTypeInstance::new(self.db(), left_ty, right_ty),
|
||||
)))
|
||||
}
|
||||
|
||||
// We avoid calling `type.__(r)or__`, as typeshed annotates these methods as
|
||||
// accepting `Any` (since typeforms are inexpressable in the type system currently).
|
||||
// This means that many common errors would not be caught if we fell back to typeshed's stubs here.
|
||||
//
|
||||
// Note that if a class had a custom metaclass that overrode `__(r)or__`, we would also ignore
|
||||
// that custom method as we'd take one of the earlier branches.
|
||||
// This seems like it's probably rare enough that it's acceptable, however.
|
||||
(
|
||||
Type::ClassLiteral(..) | Type::GenericAlias(..) | Type::SubclassOf(..),
|
||||
_,
|
||||
ast::Operator::BitOr,
|
||||
)
|
||||
| (
|
||||
_,
|
||||
Type::ClassLiteral(..) | Type::GenericAlias(..) | Type::SubclassOf(..),
|
||||
ast::Operator::BitOr,
|
||||
) if Program::get(self.db()).python_version(self.db()) >= PythonVersion::PY310 => {
|
||||
Type::try_call_bin_op_with_policy(
|
||||
self.db(),
|
||||
left_ty,
|
||||
ast::Operator::BitOr,
|
||||
right_ty,
|
||||
MemberLookupPolicy::META_CLASS_NO_TYPE_FALLBACK,
|
||||
)
|
||||
.ok()
|
||||
.map(|binding| binding.return_type(self.db()))
|
||||
}
|
||||
|
||||
// We've handled all of the special cases that we support for literals, so we need to
|
||||
// fall back on looking for dunder methods on one of the operand types.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue