From bab571c12c46dd1a8b3070df55b52b718832e17c Mon Sep 17 00:00:00 2001 From: Alexandr <65132968+jhartum@users.noreply.github.com> Date: Mon, 19 Jan 2026 00:43:44 +0700 Subject: [PATCH] [ty] Recognize string-literal types as subtypes of `Sequence[Literal[chars]]` (#22415) Co-authored-by: Alex Waygood --- .../type_properties/is_assignable_to.md | 23 +++++++ .../mdtest/type_properties/is_subtype_of.md | 23 +++++++ crates/ty_python_semantic/src/types.rs | 6 +- crates/ty_python_semantic/src/types/class.rs | 15 +++- .../ty_python_semantic/src/types/relation.rs | 68 +++++++++++++++++-- 5 files changed, 128 insertions(+), 7 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md b/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md index c64e659ed1..d7135975f1 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md @@ -113,6 +113,29 @@ static_assert(not is_assignable_to(str, Literal["foo"])) static_assert(not is_assignable_to(str, LiteralString)) ``` +### String literals and Sequence + +String literals are assignable to `Sequence[Literal[chars...]]` because strings are sequences of +their characters. + +```py +from typing import Literal, Sequence, Iterable, Collection, Reversible +from ty_extensions import is_assignable_to, static_assert + +static_assert(is_assignable_to(Literal["abba"], Sequence[Literal["a", "b"]])) +static_assert(is_assignable_to(Literal["abb"], Iterable[Literal["a", "b"]])) +static_assert(is_assignable_to(Literal["abb"], Collection[Literal["a", "b"]])) +static_assert(is_assignable_to(Literal["abb"], Reversible[Literal["a", "b"]])) +static_assert(is_assignable_to(Literal["aaa"], Sequence[Literal["a"]])) +static_assert(is_assignable_to(Literal[""], Sequence[Literal["a", "b"]])) # empty string +static_assert(is_assignable_to(Literal["ab"], Sequence[Literal["a", "b", "c"]])) # subset of allowed chars + +# String literals are NOT assignable when they contain chars outside the allowed set +static_assert(not is_assignable_to(Literal["abc"], Sequence[Literal["a", "b"]])) # 'c' not allowed +static_assert(not is_assignable_to(Literal["x"], Sequence[Literal["a", "b"]])) # 'x' not allowed +static_assert(not is_assignable_to(Literal["aa"], Sequence[Literal[""]])) +``` + ### Byte literals ```py diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md b/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md index 9877c14a86..8322caaa90 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md @@ -2254,6 +2254,29 @@ static_assert(not is_subtype_of(Callable[[str], str], CallableTypeOf[identity])) static_assert(not is_subtype_of(Callable[[str], int], CallableTypeOf[identity])) ``` +## String literals and Sequence + +String literals are subtypes of `Sequence[Literal[chars...]]` because strings are sequences of their +characters. + +```py +from typing import Literal, Sequence, Iterable, Collection, Reversible +from ty_extensions import is_subtype_of, static_assert + +static_assert(is_subtype_of(Literal["abba"], Sequence[Literal["a", "b"]])) +static_assert(is_subtype_of(Literal["abb"], Iterable[Literal["a", "b"]])) +static_assert(is_subtype_of(Literal["abb"], Collection[Literal["a", "b"]])) +static_assert(is_subtype_of(Literal["abb"], Reversible[Literal["a", "b"]])) +static_assert(is_subtype_of(Literal["aaa"], Sequence[Literal["a"]])) +static_assert(is_subtype_of(Literal[""], Sequence[Literal["a", "b"]])) # empty string +static_assert(is_subtype_of(Literal["ab"], Sequence[Literal["a", "b", "c"]])) # subset of allowed chars + +# String literals are NOT subtypes when they contain chars outside the allowed set +static_assert(not is_subtype_of(Literal["abc"], Sequence[Literal["a", "b"]])) # 'c' not allowed +static_assert(not is_subtype_of(Literal["x"], Sequence[Literal["a", "b"]])) # 'x' not allowed +static_assert(not is_subtype_of(Literal["aa"], Sequence[Literal[""]])) +``` + [gradual form]: https://typing.python.org/en/latest/spec/glossary.html#term-gradual-form [gradual tuple]: https://typing.python.org/en/latest/spec/tuples.html#tuple-type-form [special case for float and complex]: https://typing.python.org/en/latest/spec/special-types.html#special-cases-for-float-and-complex diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index f39422e287..3609abe7f8 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -1,4 +1,4 @@ -use compact_str::CompactString; +use compact_str::{CompactString, ToCompactString}; use infer::nearest_enclosing_class; use itertools::{Either, Itertools}; use ruff_diagnostics::{Edit, Fix}; @@ -1452,6 +1452,10 @@ impl<'db> Type<'db> { Self::StringLiteral(StringLiteralType::new(db, string)) } + pub(crate) fn single_char_string_literal(db: &'db dyn Db, c: char) -> Self { + Type::StringLiteral(StringLiteralType::new(db, c.to_compact_string())) + } + pub(crate) fn bytes_literal(db: &'db dyn Db, bytes: &[u8]) -> Self { Self::BytesLiteral(BytesLiteralType::new(db, bytes)) } diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 47f6ead57a..34bdaefbc8 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -6187,6 +6187,7 @@ pub enum KnownClass { SupportsIndex, Iterable, Iterator, + Sequence, Mapping, // typing_extensions ExtensionsTypeVar, // must be distinct from typing.TypeVar, backports new features @@ -6303,6 +6304,7 @@ impl KnownClass { | Self::ABCMeta | Self::Iterable | Self::Iterator + | Self::Sequence | Self::Mapping // Evaluating `NotImplementedType` in a boolean context was deprecated in Python 3.9 // and raises a `TypeError` in Python >=3.14 @@ -6391,6 +6393,7 @@ impl KnownClass { | KnownClass::SupportsIndex | KnownClass::Iterable | KnownClass::Iterator + | KnownClass::Sequence | KnownClass::Mapping | KnownClass::ChainMap | KnownClass::Counter @@ -6478,6 +6481,7 @@ impl KnownClass { | KnownClass::SupportsIndex | KnownClass::Iterable | KnownClass::Iterator + | KnownClass::Sequence | KnownClass::Mapping | KnownClass::ChainMap | KnownClass::Counter @@ -6565,6 +6569,7 @@ impl KnownClass { | KnownClass::SupportsIndex | KnownClass::Iterable | KnownClass::Iterator + | KnownClass::Sequence | KnownClass::Mapping | KnownClass::ChainMap | KnownClass::Counter @@ -6684,7 +6689,8 @@ impl KnownClass { | Self::ProtocolMeta | Self::Template | Self::Path - | Self::Mapping => false, + | Self::Mapping + | Self::Sequence => false, } } @@ -6758,6 +6764,7 @@ impl KnownClass { | KnownClass::SupportsIndex | KnownClass::Iterable | KnownClass::Iterator + | KnownClass::Sequence | KnownClass::Mapping | KnownClass::ChainMap | KnownClass::Counter @@ -6850,6 +6857,7 @@ impl KnownClass { Self::Super => "super", Self::Iterable => "Iterable", Self::Iterator => "Iterator", + Self::Sequence => "Sequence", Self::Mapping => "Mapping", // For example, `typing.List` is defined as `List = _Alias()` in typeshed Self::StdlibAlias => "_Alias", @@ -7192,6 +7200,7 @@ impl KnownClass { | Self::StdlibAlias | Self::Iterable | Self::Iterator + | Self::Sequence | Self::Mapping | Self::ProtocolMeta | Self::SupportsIndex => KnownModule::Typing, @@ -7331,6 +7340,7 @@ impl KnownClass { | Self::InitVar | Self::Iterable | Self::Iterator + | Self::Sequence | Self::Mapping | Self::NamedTupleFallback | Self::NamedTupleLike @@ -7423,6 +7433,7 @@ impl KnownClass { | Self::InitVar | Self::Iterable | Self::Iterator + | Self::Sequence | Self::Mapping | Self::NamedTupleFallback | Self::NamedTupleLike @@ -7487,6 +7498,7 @@ impl KnownClass { "TypeVar" => &[Self::TypeVar, Self::ExtensionsTypeVar], "Iterable" => &[Self::Iterable], "Iterator" => &[Self::Iterator], + "Sequence" => &[Self::Sequence], "Mapping" => &[Self::Mapping], "ParamSpec" => &[Self::ParamSpec, Self::ExtensionsParamSpec], "ParamSpecArgs" => &[Self::ParamSpecArgs], @@ -7630,6 +7642,7 @@ impl KnownClass { | Self::TypeVarTuple | Self::Iterable | Self::Iterator + | Self::Sequence | Self::Mapping | Self::ProtocolMeta | Self::NewType => matches!(module, KnownModule::Typing | KnownModule::TypingExtensions), diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index 4f977a0e6b..1963c5e345 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -1,12 +1,15 @@ +use itertools::Itertools; use ruff_python_ast::name::Name; +use rustc_hash::FxHashSet; use crate::place::{DefinedPlace, Place}; +use crate::types::builder::RecursivelyDefined; use crate::types::constraints::{IteratorConstraintsExtension, OptionConstraintsExtension}; use crate::types::enums::is_single_member_enum; use crate::types::{ - CallableType, ClassType, CycleDetector, DynamicType, KnownClass, KnownInstanceType, + CallableType, ClassBase, ClassType, CycleDetector, DynamicType, KnownClass, KnownInstanceType, MemberLookupPolicy, PairVisitor, ProtocolInstanceType, SubclassOfInner, - TypeVarBoundOrConstraints, + TypeVarBoundOrConstraints, UnionType, }; use crate::{ Db, @@ -1044,6 +1047,62 @@ impl<'db> Type<'db> { // All `StringLiteral` types are a subtype of `LiteralString`. (Type::StringLiteral(_), Type::LiteralString) => ConstraintSet::from(true), + // A string literal `Literal["abc"]` is assignable to `str` *and* to + // `Sequence[Literal["a", "b", "c"]]` because strings are sequences of their characters. + // + // Note that this strictly holds true for all type relations! + // However, as an optimisation (to avoid interning many single-character string-literal types), + // we only recognise this as being true for assignability. + (Type::StringLiteral(value), Type::NominalInstance(instance)) => { + let other_class = instance.class(db); + + if other_class.is_known(db, KnownClass::Str) { + return ConstraintSet::from(true); + } + + if let Some(sequence_class) = KnownClass::Sequence.try_to_class_literal(db) + && !sequence_class + .iter_mro(db, None) + .filter_map(ClassBase::into_class) + .map(|class| class.class_literal(db)) + .contains(&other_class.class_literal(db)) + { + return ConstraintSet::from(false); + } + + let chars: FxHashSet = value.value(db).chars().collect(); + + let spec = match chars.len() { + 0 => Type::Never, + 1 => Type::single_char_string_literal(db, *chars.iter().next().unwrap()), + _ => { + // Optimisation: since we know this union will only include string-literal types, + // avoid eagerly creating string-literal types when unnecessary, and avoid going + // via the union-builder. + let union_elements: Box<[Type<'db>]> = chars + .iter() + .map(|c| Type::single_char_string_literal(db, *c)) + .collect(); + Type::Union(UnionType::new(db, union_elements, RecursivelyDefined::No)) + } + }; + + KnownClass::Sequence + .to_specialized_class_type(db, &[spec]) + .when_some_and(|sequence| { + sequence.has_relation_to_impl( + db, + other_class, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ) + }) + } + + (Type::StringLiteral(_), _) => ConstraintSet::from(false), + // An instance is a subtype of an enum literal, if it is an instance of the enum class // and the enum has only one member. (Type::NominalInstance(_), Type::EnumLiteral(target_enum_literal)) => { @@ -1057,12 +1116,11 @@ impl<'db> Type<'db> { )) } - // Except for the special `LiteralString` case above, + // Except for the special `LiteralString` and `StringLiteral` cases above, // most `Literal` types delegate to their instance fallbacks // unless `self` is exactly equivalent to `target` (handled above) ( - Type::StringLiteral(_) - | Type::LiteralString + Type::LiteralString | Type::BooleanLiteral(_) | Type::IntLiteral(_) | Type::BytesLiteral(_)