Skip to content

Commit

Permalink
[red-knot] Infer Literal types from comparisons with `sys.version_i…
Browse files Browse the repository at this point in the history
…nfo` (astral-sh#14244)
  • Loading branch information
AlexWaygood authored Nov 11, 2024
1 parent b3b5c19 commit fc15d8a
Show file tree
Hide file tree
Showing 4 changed files with 222 additions and 9 deletions.
137 changes: 137 additions & 0 deletions crates/red_knot_python_semantic/resources/mdtest/sys_version_info.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# `sys.version_info`

## The type of `sys.version_info`

The type of `sys.version_info` is `sys._version_info`, at least according to typeshed's stubs (which
we treat as the single source of truth for the standard library). This is quite a complicated type
in typeshed, so there are many things we don't fully understand about the type yet; this is the
source of several TODOs in this test file. Many of these TODOs should be naturally fixed as we
implement more type-system features in the future.

```py
import sys

reveal_type(sys.version_info) # revealed: _version_info
```

## Literal types from comparisons

Comparing `sys.version_info` with a 2-element tuple of literal integers always produces a `Literal`
type:

```py
import sys

reveal_type(sys.version_info >= (3, 8)) # revealed: Literal[True]
reveal_type((3, 8) <= sys.version_info) # revealed: Literal[True]

reveal_type(sys.version_info > (3, 8)) # revealed: Literal[True]
reveal_type((3, 8) < sys.version_info) # revealed: Literal[True]

reveal_type(sys.version_info < (3, 8)) # revealed: Literal[False]
reveal_type((3, 8) > sys.version_info) # revealed: Literal[False]

reveal_type(sys.version_info <= (3, 8)) # revealed: Literal[False]
reveal_type((3, 8) >= sys.version_info) # revealed: Literal[False]

reveal_type(sys.version_info == (3, 8)) # revealed: Literal[False]
reveal_type((3, 8) == sys.version_info) # revealed: Literal[False]

reveal_type(sys.version_info != (3, 8)) # revealed: Literal[True]
reveal_type((3, 8) != sys.version_info) # revealed: Literal[True]
```

## Non-literal types from comparisons

Comparing `sys.version_info` with tuples of other lengths will sometimes produce `Literal` types,
sometimes not:

```py
import sys

reveal_type(sys.version_info >= (3, 8, 1)) # revealed: bool
reveal_type(sys.version_info >= (3, 8, 1, "final", 0)) # revealed: bool

# TODO: this is an invalid comparison (`sys.version_info` is a tuple of length 5)
# Should we issue a diagnostic here?
reveal_type(sys.version_info >= (3, 8, 1, "final", 0, 5)) # revealed: bool

# TODO: this should be `Literal[False]`; see #14279
reveal_type(sys.version_info == (3, 8, 1, "finallllll", 0)) # revealed: bool
```

## Imports and aliases

Comparisons with `sys.version_info` still produce literal types, even if the symbol is aliased to
another name:

```py
from sys import version_info
from sys import version_info as foo

reveal_type(version_info >= (3, 8)) # revealed: Literal[True]
reveal_type(foo >= (3, 8)) # revealed: Literal[True]

bar = version_info
reveal_type(bar >= (3, 8)) # revealed: Literal[True]
```

## Non-stdlib modules named `sys`

Only comparisons with the symbol `version_info` from the `sys` module produce literal types:

```py path=package/__init__.py
```

```py path=package/sys.py
version_info: tuple[int, int] = (4, 2)
```

```py path=package/script.py
from .sys import version_info

reveal_type(version_info >= (3, 8)) # revealed: bool
```

## Accessing fields by name

The fields of `sys.version_info` can be accessed by name:

```py path=a.py
import sys

reveal_type(sys.version_info.major >= 3) # revealed: Literal[True]
reveal_type(sys.version_info.minor >= 8) # revealed: Literal[True]
reveal_type(sys.version_info.minor >= 9) # revealed: Literal[False]
```

But the `micro`, `releaselevel` and `serial` fields are inferred as `@Todo` until we support
properties on instance types:

```py path=b.py
import sys

reveal_type(sys.version_info.micro) # revealed: @Todo
reveal_type(sys.version_info.releaselevel) # revealed: @Todo
reveal_type(sys.version_info.serial) # revealed: @Todo
```

## Accessing fields by index/slice

The fields of `sys.version_info` can be accessed by index or by slice:

```py
import sys

reveal_type(sys.version_info[0] < 3) # revealed: Literal[False]
reveal_type(sys.version_info[1] > 8) # revealed: Literal[False]

# revealed: tuple[Literal[3], Literal[8], int, Literal["alpha", "beta", "candidate", "final"], int]
reveal_type(sys.version_info[:5])

reveal_type(sys.version_info[:2] >= (3, 8)) # revealed: Literal[True]
reveal_type(sys.version_info[0:2] >= (3, 9)) # revealed: Literal[False]
reveal_type(sys.version_info[:3] >= (3, 9, 1)) # revealed: Literal[False]
reveal_type(sys.version_info[3] == "final") # revealed: bool
reveal_type(sys.version_info[3] == "finalllllll") # revealed: Literal[False]
```
2 changes: 2 additions & 0 deletions crates/red_knot_python_semantic/src/stdlib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub(crate) enum CoreStdlibModule {
Typeshed,
TypingExtensions,
Typing,
Sys,
}

impl CoreStdlibModule {
Expand All @@ -24,6 +25,7 @@ impl CoreStdlibModule {
Self::Typing => "typing",
Self::Typeshed => "_typeshed",
Self::TypingExtensions => "typing_extensions",
Self::Sys => "sys",
}
}

Expand Down
69 changes: 62 additions & 7 deletions crates/red_knot_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use crate::symbol::{Boundness, Symbol};
use crate::types::diagnostic::TypeCheckDiagnosticsBuilder;
use crate::types::mro::{ClassBase, Mro, MroError, MroIterator};
use crate::types::narrow::narrowing_constraint;
use crate::{Db, FxOrderSet, HasTy, Module, SemanticModel};
use crate::{Db, FxOrderSet, HasTy, Module, Program, SemanticModel};

pub(crate) use self::builder::{IntersectionBuilder, UnionBuilder};
pub use self::diagnostic::{TypeCheckDiagnostic, TypeCheckDiagnostics};
Expand Down Expand Up @@ -991,7 +991,9 @@ impl<'db> Type<'db> {
.all(|elem| elem.is_single_valued(db)),

Type::Instance(InstanceType { class }) => match class.known(db) {
Some(KnownClass::NoneType | KnownClass::NoDefaultType) => true,
Some(
KnownClass::NoneType | KnownClass::NoDefaultType | KnownClass::VersionInfo,
) => true,
Some(
KnownClass::Bool
| KnownClass::Object
Expand Down Expand Up @@ -1081,9 +1083,18 @@ impl<'db> Type<'db> {
Type::ClassLiteral(class_ty) => class_ty.member(db, name),
Type::SubclassOf(subclass_of_ty) => subclass_of_ty.member(db, name),
Type::KnownInstance(known_instance) => known_instance.member(db, name),
Type::Instance(_) => {
// TODO MRO? get_own_instance_member, get_instance_member
Type::Todo.into()
Type::Instance(InstanceType { class }) => {
let ty = match (class.known(db), name) {
(Some(KnownClass::VersionInfo), "major") => {
Type::IntLiteral(Program::get(db).target_version(db).major.into())
}
(Some(KnownClass::VersionInfo), "minor") => {
Type::IntLiteral(Program::get(db).target_version(db).minor.into())
}
// TODO MRO? get_own_instance_member, get_instance_member
_ => Type::Todo,
};
ty.into()
}
Type::Union(union) => {
let mut builder = UnionBuilder::new(db);
Expand Down Expand Up @@ -1418,6 +1429,39 @@ impl<'db> Type<'db> {
KnownClass::NoneType.to_instance(db)
}

/// Return the type of `tuple(sys.version_info)`.
///
/// This is not exactly the type that `sys.version_info` has at runtime,
/// but it's a useful fallback for us in order to infer `Literal` types from `sys.version_info` comparisons.
fn version_info_tuple(db: &'db dyn Db) -> Self {
let target_version = Program::get(db).target_version(db);
let int_instance_ty = KnownClass::Int.to_instance(db);

// TODO: just grab this type from typeshed (it's a `sys._ReleaseLevel` type alias there)
let release_level_ty = {
let elements: Box<[Type<'db>]> = ["alpha", "beta", "candidate", "final"]
.iter()
.map(|level| Type::string_literal(db, level))
.collect();

// For most unions, it's better to go via `UnionType::from_elements` or use `UnionBuilder`;
// those techniques ensure that union elements are deduplicated and unions are eagerly simplified
// into other types where necessary. Here, however, we know that there are no duplicates
// in this union, so it's probably more efficient to use `UnionType::new()` directly.
Type::Union(UnionType::new(db, elements))
};

let version_info_elements = &[
Type::IntLiteral(target_version.major.into()),
Type::IntLiteral(target_version.minor.into()),
int_instance_ty,
release_level_ty,
int_instance_ty,
];

Self::tuple(db, version_info_elements)
}

/// Given a type that is assumed to represent an instance of a class,
/// return a type that represents that class itself.
#[must_use]
Expand Down Expand Up @@ -1541,6 +1585,8 @@ pub enum KnownClass {
SpecialForm,
TypeVar,
NoDefaultType,
// sys
VersionInfo,
}

impl<'db> KnownClass {
Expand All @@ -1565,6 +1611,12 @@ impl<'db> KnownClass {
Self::SpecialForm => "_SpecialForm",
Self::TypeVar => "TypeVar",
Self::NoDefaultType => "_NoDefaultType",
// This is the name the type of `sys.version_info` has in typeshed,
// which is different to what `type(sys.version_info).__name__` is at runtime.
// (At runtime, `type(sys.version_info).__name__ == "version_info"`,
// which is impossible to replicate in the stubs since the sole instance of the class
// also has that name in the `sys` module.)
Self::VersionInfo => "_version_info",
}
}

Expand All @@ -1591,6 +1643,7 @@ impl<'db> KnownClass {
| Self::Set
| Self::Dict
| Self::Slice => CoreStdlibModule::Builtins,
Self::VersionInfo => CoreStdlibModule::Sys,
Self::GenericAlias | Self::ModuleType | Self::FunctionType => CoreStdlibModule::Types,
Self::NoneType => CoreStdlibModule::Typeshed,
Self::SpecialForm | Self::TypeVar => CoreStdlibModule::Typing,
Expand All @@ -1607,7 +1660,7 @@ impl<'db> KnownClass {
const fn is_singleton(self) -> bool {
// TODO there are other singleton types (EllipsisType, NotImplementedType)
match self {
Self::NoneType | Self::NoDefaultType => true,
Self::NoneType | Self::NoDefaultType | Self::VersionInfo => true,
Self::Bool
| Self::Object
| Self::Bytes
Expand Down Expand Up @@ -1651,13 +1704,14 @@ impl<'db> KnownClass {
"FunctionType" => Self::FunctionType,
"_SpecialForm" => Self::SpecialForm,
"_NoDefaultType" => Self::NoDefaultType,
"_version_info" => Self::VersionInfo,
_ => return None,
};

candidate.check_module(module).then_some(candidate)
}

/// Private method checking if known class can be defined in the given module.
/// Return `true` if the module of `self` matches `module_name`
fn check_module(self, module: &Module) -> bool {
if !module.search_path().is_standard_library() {
return false;
Expand All @@ -1677,6 +1731,7 @@ impl<'db> KnownClass {
| Self::Slice
| Self::GenericAlias
| Self::ModuleType
| Self::VersionInfo
| Self::FunctionType => module.name() == self.canonical_module().as_str(),
Self::NoneType => matches!(module.name().as_str(), "_typeshed" | "types"),
Self::SpecialForm | Self::TypeVar | Self::NoDefaultType => {
Expand Down
23 changes: 21 additions & 2 deletions crates/red_knot_python_semantic/src/types/infer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1628,8 +1628,7 @@ impl<'db> TypeInferenceBuilder<'db> {

let mut annotation_ty = self.infer_annotation_expression(annotation);

// If the declared variable is annotated with _SpecialForm class then we treat it differently
// by assigning the known field to the instance.
// Handle various singletons.
if let Type::Instance(InstanceType { class }) = annotation_ty {
if class.is_known(self.db, KnownClass::SpecialForm) {
if let Some(name_expr) = target.as_name_expr() {
Expand Down Expand Up @@ -3531,6 +3530,16 @@ impl<'db> TypeInferenceBuilder<'db> {
(_, Type::BytesLiteral(_)) => {
self.infer_binary_type_comparison(left, op, KnownClass::Bytes.to_instance(self.db))
}
(Type::Tuple(_), Type::Instance(InstanceType { class }))
if class.is_known(self.db, KnownClass::VersionInfo) =>
{
self.infer_binary_type_comparison(left, op, Type::version_info_tuple(self.db))
}
(Type::Instance(InstanceType { class }), Type::Tuple(_))
if class.is_known(self.db, KnownClass::VersionInfo) =>
{
self.infer_binary_type_comparison(Type::version_info_tuple(self.db), op, right)
}
(Type::Tuple(lhs), Type::Tuple(rhs)) => {
// Note: This only works on heterogeneous tuple types.
let lhs_elements = lhs.elements(self.db);
Expand Down Expand Up @@ -3713,6 +3722,16 @@ impl<'db> TypeInferenceBuilder<'db> {
slice_ty: Type<'db>,
) -> Type<'db> {
match (value_ty, slice_ty) {
(
Type::Instance(InstanceType { class }),
Type::IntLiteral(_) | Type::BooleanLiteral(_) | Type::SliceLiteral(_),
) if class.is_known(self.db, KnownClass::VersionInfo) => self
.infer_subscript_expression_types(
value_node,
Type::version_info_tuple(self.db),
slice_ty,
),

// Ex) Given `("a", "b", "c", "d")[1]`, return `"b"`
(Type::Tuple(tuple_ty), Type::IntLiteral(int)) if i32::try_from(int).is_ok() => {
let elements = tuple_ty.elements(self.db);
Expand Down

0 comments on commit fc15d8a

Please sign in to comment.