[ty] Recognize string-literal types as subtypes of Sequence[Literal[chars]] (#22415)

Co-authored-by: Alex Waygood <alex.waygood@gmail.com>
This commit is contained in:
Alexandr
2026-01-19 00:43:44 +07:00
committed by GitHub
parent 57c98a1f07
commit bab571c12c
5 changed files with 128 additions and 7 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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))
}

View File

@@ -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),

View File

@@ -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<char> = 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(_)