From e8e81808884dc5157bd1bf12e8235455879616ee Mon Sep 17 00:00:00 2001 From: David Peter Date: Wed, 12 Nov 2025 12:59:14 +0100 Subject: [PATCH] [ty] Implicit type aliases: Add support for `typing.Union` (#21363) ## Summary Add support for `typing.Union` in implicit type aliases / in value position. ## Typing conformance tests Two new tests are passing ## Ecosystem impact * The 2k new `invalid-key` diagnostics on pydantic are caused by https://github.com/astral-sh/ty/issues/1479#issuecomment-3513854645. * Everything else I've checked is either a known limitation (often related to type narrowing, because union types are often narrowed down to a subset of options), or a true positive. ## Test Plan New Markdown tests --- crates/ruff_benchmark/benches/ty_walltime.rs | 4 +- .../resources/mdtest/implicit_type_aliases.md | 102 +++++++++++++++++- .../resources/mdtest/invalid_syntax.md | 24 +++++ crates/ty_python_semantic/src/types.rs | 31 ++++-- .../src/types/infer/builder.rs | 59 ++++++++-- 5 files changed, 201 insertions(+), 19 deletions(-) diff --git a/crates/ruff_benchmark/benches/ty_walltime.rs b/crates/ruff_benchmark/benches/ty_walltime.rs index 61c67fb019..8f13ab7ca7 100644 --- a/crates/ruff_benchmark/benches/ty_walltime.rs +++ b/crates/ruff_benchmark/benches/ty_walltime.rs @@ -181,7 +181,7 @@ static PYDANTIC: Benchmark = Benchmark::new( max_dep_date: "2025-06-17", python_version: PythonVersion::PY39, }, - 1000, + 3000, ); static SYMPY: Benchmark = Benchmark::new( @@ -223,7 +223,7 @@ static STATIC_FRAME: Benchmark = Benchmark::new( max_dep_date: "2025-08-09", python_version: PythonVersion::PY311, }, - 800, + 900, ); #[track_caller] diff --git a/crates/ty_python_semantic/resources/mdtest/implicit_type_aliases.md b/crates/ty_python_semantic/resources/mdtest/implicit_type_aliases.md index aae10661b4..0a45e9e3c4 100644 --- a/crates/ty_python_semantic/resources/mdtest/implicit_type_aliases.md +++ b/crates/ty_python_semantic/resources/mdtest/implicit_type_aliases.md @@ -33,7 +33,7 @@ g(None) We also support unions in type aliases: ```py -from typing_extensions import Any, Never, Literal, LiteralString, Tuple, Annotated, Optional +from typing_extensions import Any, Never, Literal, LiteralString, Tuple, Annotated, Optional, Union from ty_extensions import Unknown IntOrStr = int | str @@ -41,6 +41,8 @@ IntOrStrOrBytes1 = int | str | bytes IntOrStrOrBytes2 = (int | str) | bytes IntOrStrOrBytes3 = int | (str | bytes) IntOrStrOrBytes4 = IntOrStr | bytes +IntOrStrOrBytes5 = int | Union[str, bytes] +IntOrStrOrBytes6 = Union[int, str] | bytes BytesOrIntOrStr = bytes | IntOrStr IntOrNone = int | None NoneOrInt = None | int @@ -70,6 +72,8 @@ reveal_type(IntOrStrOrBytes1) # revealed: types.UnionType reveal_type(IntOrStrOrBytes2) # revealed: types.UnionType reveal_type(IntOrStrOrBytes3) # revealed: types.UnionType reveal_type(IntOrStrOrBytes4) # revealed: types.UnionType +reveal_type(IntOrStrOrBytes5) # revealed: types.UnionType +reveal_type(IntOrStrOrBytes6) # revealed: types.UnionType reveal_type(BytesOrIntOrStr) # revealed: types.UnionType reveal_type(IntOrNone) # revealed: types.UnionType reveal_type(NoneOrInt) # revealed: types.UnionType @@ -100,6 +104,8 @@ def _( int_or_str_or_bytes2: IntOrStrOrBytes2, int_or_str_or_bytes3: IntOrStrOrBytes3, int_or_str_or_bytes4: IntOrStrOrBytes4, + int_or_str_or_bytes5: IntOrStrOrBytes5, + int_or_str_or_bytes6: IntOrStrOrBytes6, bytes_or_int_or_str: BytesOrIntOrStr, int_or_none: IntOrNone, none_or_int: NoneOrInt, @@ -129,6 +135,8 @@ def _( reveal_type(int_or_str_or_bytes2) # revealed: int | str | bytes reveal_type(int_or_str_or_bytes3) # revealed: int | str | bytes reveal_type(int_or_str_or_bytes4) # revealed: int | str | bytes + reveal_type(int_or_str_or_bytes5) # revealed: int | str | bytes + reveal_type(int_or_str_or_bytes6) # revealed: int | str | bytes reveal_type(bytes_or_int_or_str) # revealed: bytes | int | str reveal_type(int_or_none) # revealed: int | None reveal_type(none_or_int) # revealed: None | int @@ -505,13 +513,90 @@ def _( ## `Tuple` +We support implicit type aliases using `typing.Tuple`: + ```py from typing import Tuple IntAndStr = Tuple[int, str] +SingleInt = Tuple[int] +Ints = Tuple[int, ...] +EmptyTuple = Tuple[()] -def _(int_and_str: IntAndStr): +def _(int_and_str: IntAndStr, single_int: SingleInt, ints: Ints, empty_tuple: EmptyTuple): reveal_type(int_and_str) # revealed: tuple[int, str] + reveal_type(single_int) # revealed: tuple[int] + reveal_type(ints) # revealed: tuple[int, ...] + reveal_type(empty_tuple) # revealed: tuple[()] +``` + +Invalid uses cause diagnostics: + +```py +from typing import Tuple + +# error: [invalid-type-form] "Int literals are not allowed in this context in a type expression" +Invalid = Tuple[int, 1] + +def _(invalid: Invalid): + reveal_type(invalid) # revealed: tuple[int, Unknown] +``` + +## `Union` + +We support implicit type aliases using `typing.Union`: + +```py +from typing import Union + +IntOrStr = Union[int, str] +IntOrStrOrBytes = Union[int, Union[str, bytes]] + +reveal_type(IntOrStr) # revealed: types.UnionType +reveal_type(IntOrStrOrBytes) # revealed: types.UnionType + +def _( + int_or_str: IntOrStr, + int_or_str_or_bytes: IntOrStrOrBytes, +): + reveal_type(int_or_str) # revealed: int | str + reveal_type(int_or_str_or_bytes) # revealed: int | str | bytes +``` + +If a single type is given, no `types.UnionType` instance is created: + +```py +JustInt = Union[int] + +reveal_type(JustInt) # revealed: + +def _(just_int: JustInt): + reveal_type(just_int) # revealed: int +``` + +An empty `typing.Union` leads to a `TypeError` at runtime, so we emit an error. We still infer +`Never` when used as a type expression, which seems reasonable for an empty union: + +```py +# error: [invalid-type-form] "`typing.Union` requires at least one type argument" +EmptyUnion = Union[()] + +reveal_type(EmptyUnion) # revealed: types.UnionType + +def _(empty: EmptyUnion): + reveal_type(empty) # revealed: Never +``` + +Other invalid uses are also caught: + +```py +# error: [invalid-type-form] "Int literals are not allowed in this context in a type expression" +Invalid = Union[str, 1] + +def _( + invalid: Invalid, +): + reveal_type(invalid) # revealed: str | Unknown ``` ## Stringified annotations? @@ -544,10 +629,19 @@ We *do* support stringified annotations if they appear in a position where a typ syntactically expected: ```py -ListOfInts = list["int"] +from typing import Union -def _(list_of_ints: ListOfInts): +ListOfInts = list["int"] +StrOrStyle = Union[str, "Style"] + +class Style: ... + +def _( + list_of_ints: ListOfInts, + str_or_style: StrOrStyle, +): reveal_type(list_of_ints) # revealed: list[int] + reveal_type(str_or_style) # revealed: str | Style ``` ## Recursive diff --git a/crates/ty_python_semantic/resources/mdtest/invalid_syntax.md b/crates/ty_python_semantic/resources/mdtest/invalid_syntax.md index cc90879401..9594492982 100644 --- a/crates/ty_python_semantic/resources/mdtest/invalid_syntax.md +++ b/crates/ty_python_semantic/resources/mdtest/invalid_syntax.md @@ -104,3 +104,27 @@ from typing import Callable def _(c: Callable[]): reveal_type(c) # revealed: (...) -> Unknown ``` + +### `typing.Tuple` + +```py +from typing import Tuple + +# error: [invalid-syntax] "Expected index or slice expression" +InvalidEmptyTuple = Tuple[] + +def _(t: InvalidEmptyTuple): + reveal_type(t) # revealed: tuple[Unknown] +``` + +### `typing.Union` + +```py +from typing import Union + +# error: [invalid-syntax] "Expected index or slice expression" +InvalidEmptyUnion = Union[] + +def _(u: InvalidEmptyUnion): + reveal_type(u) # revealed: Unknown +``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index e91007047d..515e048840 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -6586,12 +6586,13 @@ impl<'db> Type<'db> { }), KnownInstanceType::UnionType(list) => { let mut builder = UnionBuilder::new(db); + let inferred_as = list.inferred_as(db); for element in list.elements(db) { - builder = builder.add(element.in_type_expression( - db, - scope_id, - typevar_binding_context, - )?); + builder = builder.add(if inferred_as.type_expression() { + *element + } else { + element.in_type_expression(db, scope_id, typevar_binding_context)? + }); } Ok(builder.build()) } @@ -9164,6 +9165,21 @@ impl<'db> TypeVarBoundOrConstraints<'db> { } } +/// Whether a given type originates from value expression inference or type expression inference. +/// For example, the symbol `int` would be inferred as `` in value expression context, +/// and as `int` (i.e. an instance of the class `int`) in type expression context. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::Update)] +pub enum InferredAs { + ValueExpression, + TypeExpression, +} + +impl InferredAs { + pub const fn type_expression(self) -> bool { + matches!(self, InferredAs::TypeExpression) + } +} + /// A salsa-interned list of types. /// /// # Ordering @@ -9174,6 +9190,7 @@ impl<'db> TypeVarBoundOrConstraints<'db> { pub struct InternedTypes<'db> { #[returns(deref)] elements: Box<[Type<'db>]>, + inferred_as: InferredAs, } impl get_size2::GetSize for InternedTypes<'_> {} @@ -9182,8 +9199,9 @@ impl<'db> InternedTypes<'db> { pub(crate) fn from_elements( db: &'db dyn Db, elements: impl IntoIterator>, + inferred_as: InferredAs, ) -> InternedTypes<'db> { - InternedTypes::new(db, elements.into_iter().collect::>()) + InternedTypes::new(db, elements.into_iter().collect::>(), inferred_as) } pub(crate) fn normalized_impl(self, db: &'db dyn Db, visitor: &NormalizedVisitor<'db>) -> Self { @@ -9193,6 +9211,7 @@ impl<'db> InternedTypes<'db> { .iter() .map(|ty| ty.normalized_impl(db, visitor)) .collect::>(), + self.inferred_as(db), ) } } diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index dea82603f4..3fe0bb003d 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -101,10 +101,10 @@ use crate::types::typed_dict::{ use crate::types::visitor::any_over_type; use crate::types::{ CallDunderError, CallableBinding, CallableType, ClassLiteral, ClassType, DataclassParams, - DynamicType, InternedType, InternedTypes, IntersectionBuilder, IntersectionType, KnownClass, - KnownInstanceType, MemberLookupPolicy, MetaclassCandidate, PEP695TypeAliasType, Parameter, - ParameterForm, Parameters, SpecialFormType, SubclassOfType, TrackedConstraintSet, Truthiness, - Type, TypeAliasType, TypeAndQualifiers, TypeContext, TypeQualifiers, + DynamicType, InferredAs, InternedType, InternedTypes, IntersectionBuilder, IntersectionType, + KnownClass, KnownInstanceType, MemberLookupPolicy, MetaclassCandidate, PEP695TypeAliasType, + Parameter, ParameterForm, Parameters, SpecialFormType, SubclassOfType, TrackedConstraintSet, + Truthiness, Type, TypeAliasType, TypeAndQualifiers, TypeContext, TypeQualifiers, TypeVarBoundOrConstraintsEvaluation, TypeVarDefaultEvaluation, TypeVarIdentity, TypeVarInstance, TypeVarKind, TypeVarVariance, TypedDictType, UnionBuilder, UnionType, binding_type, todo_type, @@ -9234,7 +9234,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { Some(left_ty) } else { Some(Type::KnownInstance(KnownInstanceType::UnionType( - InternedTypes::from_elements(self.db(), [left_ty, right_ty]), + InternedTypes::from_elements( + self.db(), + [left_ty, right_ty], + InferredAs::ValueExpression, + ), ))) } } @@ -9259,7 +9263,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { && instance.has_known_class(self.db(), KnownClass::NoneType) => { Some(Type::KnownInstance(KnownInstanceType::UnionType( - InternedTypes::from_elements(self.db(), [left_ty, right_ty]), + InternedTypes::from_elements( + self.db(), + [left_ty, right_ty], + InferredAs::ValueExpression, + ), ))) } @@ -10476,9 +10484,46 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } return Type::KnownInstance(KnownInstanceType::UnionType( - InternedTypes::from_elements(self.db(), [ty, Type::none(self.db())]), + InternedTypes::from_elements( + self.db(), + [ty, Type::none(self.db())], + InferredAs::ValueExpression, + ), )); } + Type::SpecialForm(SpecialFormType::Union) => { + let db = self.db(); + + match **slice { + ast::Expr::Tuple(ref tuple) => { + let mut elements = tuple + .elts + .iter() + .map(|elt| self.infer_type_expression(elt)) + .peekable(); + + let is_empty = elements.peek().is_none(); + let union_type = Type::KnownInstance(KnownInstanceType::UnionType( + InternedTypes::from_elements(db, elements, InferredAs::TypeExpression), + )); + + if is_empty { + if let Some(builder) = + self.context.report_lint(&INVALID_TYPE_FORM, subscript) + { + builder.into_diagnostic( + "`typing.Union` requires at least one type argument", + ); + } + } + + return union_type; + } + _ => { + return self.infer_expression(slice, TypeContext::default()); + } + } + } _ => {} }