diff --git a/crates/ty_python_semantic/resources/mdtest/binary/in.md b/crates/ty_python_semantic/resources/mdtest/binary/in.md new file mode 100644 index 0000000000..508ccdb67c --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/binary/in.md @@ -0,0 +1,53 @@ +# Static binary operations using `in` + +## Basic functionality + +This demonstrates type inference support for ` in `: + +```py +from ty_extensions import static_assert + +static_assert("foo" in ("quux", "foo", "baz")) +static_assert("foo" not in ("quux", "bar", "baz")) +``` + +## With variables + +```py +from ty_extensions import static_assert + +x = ("quux", "foo", "baz") +static_assert("foo" in x) + +x = ("quux", "bar", "baz") +static_assert("foo" not in x) +``` + +## Statically unknown results in a type error + +```py +from ty_extensions import static_assert + +def _(a: str, b: str): + static_assert("foo" in (a, b)) # error: [static-assert-error] +``` + +## Values being unknown doesn't mean the result is unknown + +For example, when the types are completely disjoint: + +```py +from ty_extensions import static_assert + +def _(a: int, b: int): + static_assert("foo" not in (a, b)) +``` + +## Failure cases + +```py +from ty_extensions import static_assert + +# We don't support byte strings. +static_assert(b"foo" not in (b"quux", b"foo", b"baz")) # error: [static-assert-error] +``` diff --git a/crates/ty_python_semantic/resources/mdtest/ide_support/all_members.md b/crates/ty_python_semantic/resources/mdtest/ide_support/all_members.md new file mode 100644 index 0000000000..63a0c21c30 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/ide_support/all_members.md @@ -0,0 +1,488 @@ +# List all members + +## Basic functionality + + + +The `ty_extensions.all_members` function allows access to a list of accessible members/attributes on +a given object. For example, all member functions of `str` are available on `"a"`: + +```py +from ty_extensions import all_members, static_assert + +members_of_str = all_members("a") + +static_assert("replace" in members_of_str) +static_assert("startswith" in members_of_str) +static_assert("isupper" in members_of_str) +``` + +Similarly, special members such as `__add__` are also available: + +```py +static_assert("__add__" in members_of_str) +static_assert("__gt__" in members_of_str) +``` + +Members of base classes are also included (these dunder methods are defined on `object`): + +```py +static_assert("__doc__" in members_of_str) +static_assert("__repr__" in members_of_str) +``` + +Non-existent members are not included: + +```py +static_assert("non_existent" not in members_of_str) +``` + +Note: The full list of all members is relatively long, but `reveal_type` can theoretically be used +to see them all: + +```py +from typing_extensions import reveal_type + +reveal_type(members_of_str) # error: [revealed-type] +``` + +## Kinds of types + +### Class instances + +For instances of classes, `all_members` returns class members and implicit instance members of all +classes in the MRO: + +```py +from ty_extensions import all_members, static_assert + +class Base: + base_class_attr: int = 1 + + def f_base(self): + self.base_instance_attr: str = "Base" + +class Intermediate(Base): + intermediate_attr: int = 2 + + def f_intermediate(self): + self.intermediate_instance_attr: str = "Intermediate" + +class C(Intermediate): + class_attr: int = 3 + + def f_c(self): + self.instance_attr = "C" + + @property + def property_attr(self) -> int: + return 1 + + @classmethod + def class_method(cls) -> int: + return 1 + + @staticmethod + def static_method() -> int: + return 1 + +members_of_instance = all_members(C()) + +static_assert("base_class_attr" in members_of_instance) +static_assert("intermediate_attr" in members_of_instance) +static_assert("class_attr" in members_of_instance) + +static_assert("base_instance_attr" in members_of_instance) +static_assert("intermediate_instance_attr" in members_of_instance) +static_assert("instance_attr" in members_of_instance) + +static_assert("f_base" in members_of_instance) +static_assert("f_intermediate" in members_of_instance) +static_assert("f_c" in members_of_instance) + +static_assert("property_attr" in members_of_instance) +static_assert("class_method" in members_of_instance) +static_assert("static_method" in members_of_instance) + +static_assert("non_existent" not in members_of_instance) +``` + +### Class objects + +Class-level attributes can also be accessed through the class itself: + +```py +from ty_extensions import all_members, static_assert + +class Base: + base_attr: int = 1 + +class C(Base): + class_attr: str = "c" + + def f(self): + self.instance_attr = True + +members_of_class = all_members(C) + +static_assert("class_attr" in members_of_class) +static_assert("base_attr" in members_of_class) + +static_assert("non_existent" not in members_of_class) +``` + +But instance attributes can not be accessed this way: + +```py +static_assert("instance_attr" not in members_of_class) +``` + +When a class has a metaclass, members of that metaclass (and bases of that metaclass) are also +accessible: + +```py +class MetaBase(type): + meta_base_attr = 1 + +class Meta(MetaBase): + meta_attr = 2 + +class D(Base, metaclass=Meta): + class_attr = 3 + +static_assert("meta_base_attr" in all_members(D)) +static_assert("meta_attr" in all_members(D)) +static_assert("base_attr" in all_members(D)) +static_assert("class_attr" in all_members(D)) +``` + +### Generic classes + +```py +from ty_extensions import all_members, static_assert +from typing import Generic, TypeVar + +T = TypeVar("T") + +class C(Generic[T]): + base_attr: T + +static_assert("base_attr" in all_members(C[int])) +static_assert("base_attr" in all_members(C[int]())) +``` + +### Other instance-like types + +```py +from ty_extensions import all_members, static_assert +from typing_extensions import LiteralString + +static_assert("__xor__" in all_members(True)) +static_assert("bit_length" in all_members(1)) +static_assert("startswith" in all_members("a")) +static_assert("__buffer__" in all_members(b"a")) +static_assert("is_integer" in all_members(3.14)) + +def _(literal_string: LiteralString): + static_assert("startswith" in all_members(literal_string)) + +static_assert("count" in all_members(("some", "tuple", 1, 2))) + +static_assert("__doc__" in all_members(len)) +static_assert("__doc__" in all_members("a".startswith)) +``` + +### Unions + +For unions, `all_members` will only return members that are available on all elements of the union. + +```py +from ty_extensions import all_members, static_assert + +class A: + on_both: int = 1 + only_on_a: str = "a" + +class B: + on_both: int = 2 + only_on_b: str = "b" + +def f(union: A | B): + static_assert("on_both" in all_members(union)) + static_assert("only_on_a" not in all_members(union)) + static_assert("only_on_b" not in all_members(union)) +``` + +### Intersections + +#### Only positive types + +Conversely, for intersections, `all_members` will list members that are available on any of the +elements: + +```py +from ty_extensions import all_members, static_assert + +class A: + on_both: int = 1 + only_on_a: str = "a" + +class B: + on_both: int = 2 + only_on_b: str = "b" + +def f(intersection: object): + if isinstance(intersection, A): + if isinstance(intersection, B): + static_assert("on_both" in all_members(intersection)) + static_assert("only_on_a" in all_members(intersection)) + static_assert("only_on_b" in all_members(intersection)) +``` + +#### With negative types + +It also works when negative types are introduced: + +```py +from ty_extensions import all_members, static_assert + +class A: + on_all: int = 1 + only_on_a: str = "a" + only_on_ab: str = "a" + only_on_ac: str = "a" + +class B: + on_all: int = 2 + only_on_b: str = "b" + only_on_ab: str = "b" + only_on_bc: str = "b" + +class C: + on_all: int = 3 + only_on_c: str = "c" + only_on_ac: str = "c" + only_on_bc: str = "c" + +def f(intersection: object): + if isinstance(intersection, A): + if isinstance(intersection, B): + if not isinstance(intersection, C): + reveal_type(intersection) # revealed: A & B & ~C + static_assert("on_all" in all_members(intersection)) + static_assert("only_on_a" in all_members(intersection)) + static_assert("only_on_b" in all_members(intersection)) + static_assert("only_on_c" not in all_members(intersection)) + static_assert("only_on_ab" in all_members(intersection)) + static_assert("only_on_ac" in all_members(intersection)) + static_assert("only_on_bc" in all_members(intersection)) +``` + +## Modules + +### Basic support with sub-modules + +`all_members` can also list attributes on modules: + +```py +from ty_extensions import all_members, static_assert +import math + +static_assert("pi" in all_members(math)) +static_assert("cos" in all_members(math)) +``` + +This also works for submodules: + +```py +import os + +static_assert("path" in all_members(os)) + +import os.path + +static_assert("join" in all_members(os.path)) +``` + +Special members available on all modules are also included: + +```py +static_assert("__name__" in all_members(math)) +static_assert("__doc__" in all_members(math)) +``` + +### `__all__` is not respected for direct module access + +`foo.py`: + +```py +from ty_extensions import all_members, static_assert + +import bar + +static_assert("lion" in all_members(bar)) +static_assert("tiger" in all_members(bar)) +``` + +`bar.py`: + +```py +__all__ = ["lion"] + +lion = 1 +tiger = 1 +``` + +### `__all__` is respected for glob imports + +`foo.py`: + +```py +from ty_extensions import all_members, static_assert + +import bar + +static_assert("lion" in all_members(bar)) +static_assert("tiger" not in all_members(bar)) +``` + +`bar.py`: + +```py +from quux import * +``` + +`quux.py`: + +```py +__all__ = ["lion"] + +lion = 1 +tiger = 1 +``` + +### `__all__` is respected for stub files + +`module.py`: + +```py +def evaluate(x=None): + if x is None: + return 0 + return x +``` + +`module.pyi`: + +```pyi +from typing import Optional + +__all__ = ["evaluate"] + +def evaluate(x: Optional[int] = None) -> int: ... +``` + +`play.py`: + +```py +from ty_extensions import all_members, static_assert + +import module + +static_assert("evaluate" in all_members(module)) +static_assert("Optional" not in all_members(module)) +``` + +## Conditionally available members + +Some members are only conditionally available. For example, `int.bit_count` was only introduced in +Python 3.10: + +### 3.9 + +```toml +[environment] +python-version = "3.9" +``` + +```py +from ty_extensions import all_members, static_assert + +static_assert("bit_count" not in all_members(42)) +``` + +### 3.10 + +```toml +[environment] +python-version = "3.10" +``` + +```py +from ty_extensions import all_members, static_assert + +static_assert("bit_count" in all_members(42)) +``` + +## Failures cases + +### Dynamically added members + +Dynamically added members can not be accessed: + +```py +from ty_extensions import all_members, static_assert + +class C: + static_attr = 1 + + def __setattr__(self, name: str, value: str) -> None: + pass + + def __getattr__(self, name: str) -> str: + return "a" + +c = C() +c.dynamic_attr = "a" + +static_assert("static_attr" in all_members(c)) +static_assert("dynamic_attr" not in all_members(c)) +``` + +### Dataclasses + +So far, we do not include synthetic members of dataclasses. + +```py +from ty_extensions import all_members, static_assert +from dataclasses import dataclass + +@dataclass(order=True) +class Person: + name: str + age: int + +static_assert("name" in all_members(Person)) +static_assert("age" in all_members(Person)) + +# These are always available, since they are also defined on `object`: +static_assert("__init__" in all_members(Person)) +static_assert("__repr__" in all_members(Person)) +static_assert("__eq__" in all_members(Person)) + +# TODO: this should ideally be available: +static_assert("__lt__" in all_members(Person)) # error: [static-assert-error] +``` + +### Attributes not available at runtime + +Typeshed includes some attributes in `object` that are not available for some (builtin) types. For +example, `__annotations__` does not exist on `int` at runtime, but it is available as an attribute +on `object` in typeshed: + +```py +from ty_extensions import all_members, static_assert + +# TODO: this should ideally not be available: +static_assert("__annotations__" not in all_members(3)) # error: [static-assert-error] +``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/all_members.md_-_List_all_members_-_Basic_functionality_(6b9531a70334bfad).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/all_members.md_-_List_all_members_-_Basic_functionality_(6b9531a70334bfad).snap new file mode 100644 index 0000000000..55a377e0c3 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/all_members.md_-_List_all_members_-_Basic_functionality_(6b9531a70334bfad).snap @@ -0,0 +1,44 @@ +--- +source: crates/ty_test/src/lib.rs +expression: snapshot +--- +--- +mdtest name: all_members.md - List all members - Basic functionality +mdtest path: crates/ty_python_semantic/resources/mdtest/ide_support/all_members.md +--- + +# Python source files + +## mdtest_snippet.py + +``` + 1 | from ty_extensions import all_members, static_assert + 2 | + 3 | members_of_str = all_members("a") + 4 | + 5 | static_assert("replace" in members_of_str) + 6 | static_assert("startswith" in members_of_str) + 7 | static_assert("isupper" in members_of_str) + 8 | static_assert("__add__" in members_of_str) + 9 | static_assert("__gt__" in members_of_str) +10 | static_assert("__doc__" in members_of_str) +11 | static_assert("__repr__" in members_of_str) +12 | static_assert("non_existent" not in members_of_str) +13 | from typing_extensions import reveal_type +14 | +15 | reveal_type(members_of_str) # error: [revealed-type] +``` + +# Diagnostics + +``` +info[revealed-type]: Revealed type + --> src/mdtest_snippet.py:15:13 + | +13 | from typing_extensions import reveal_type +14 | +15 | reveal_type(members_of_str) # error: [revealed-type] + | ^^^^^^^^^^^^^^ `tuple[Literal["__add__"], Literal["__annotations__"], Literal["__class__"], Literal["__contains__"], Literal["__delattr__"], Literal["__dict__"], Literal["__dir__"], Literal["__doc__"], Literal["__eq__"], Literal["__format__"], Literal["__ge__"], Literal["__getattribute__"], Literal["__getitem__"], Literal["__getnewargs__"], Literal["__gt__"], Literal["__hash__"], Literal["__init__"], Literal["__init_subclass__"], Literal["__iter__"], Literal["__le__"], Literal["__len__"], Literal["__lt__"], Literal["__mod__"], Literal["__module__"], Literal["__mul__"], Literal["__ne__"], Literal["__new__"], Literal["__reduce__"], Literal["__reduce_ex__"], Literal["__repr__"], Literal["__reversed__"], Literal["__rmul__"], Literal["__setattr__"], Literal["__sizeof__"], Literal["__str__"], Literal["__subclasshook__"], Literal["capitalize"], Literal["casefold"], Literal["center"], Literal["count"], Literal["encode"], Literal["endswith"], Literal["expandtabs"], Literal["find"], Literal["format"], Literal["format_map"], Literal["index"], Literal["isalnum"], Literal["isalpha"], Literal["isascii"], Literal["isdecimal"], Literal["isdigit"], Literal["isidentifier"], Literal["islower"], Literal["isnumeric"], Literal["isprintable"], Literal["isspace"], Literal["istitle"], Literal["isupper"], Literal["join"], Literal["ljust"], Literal["lower"], Literal["lstrip"], Literal["maketrans"], Literal["partition"], Literal["removeprefix"], Literal["removesuffix"], Literal["replace"], Literal["rfind"], Literal["rindex"], Literal["rjust"], Literal["rpartition"], Literal["rsplit"], Literal["rstrip"], Literal["split"], Literal["splitlines"], Literal["startswith"], Literal["strip"], Literal["swapcase"], Literal["title"], Literal["translate"], Literal["upper"], Literal["zfill"]]` + | + +``` diff --git a/crates/ty_python_semantic/src/semantic_index.rs b/crates/ty_python_semantic/src/semantic_index.rs index 7f23fab163..6d57a8515c 100644 --- a/crates/ty_python_semantic/src/semantic_index.rs +++ b/crates/ty_python_semantic/src/semantic_index.rs @@ -99,7 +99,9 @@ pub(crate) fn use_def_map<'db>(db: &'db dyn Db, scope: ScopeId<'db>) -> Arc( @@ -109,6 +111,28 @@ pub(crate) fn attribute_assignments<'db, 's>( ) -> impl Iterator, FileScopeId)> + use<'s, 'db> { let file = class_body_scope.file(db); let index = semantic_index(db, file); + + attribute_scopes(db, class_body_scope).filter_map(|function_scope_id| { + let attribute_table = index.instance_attribute_table(function_scope_id); + let symbol = attribute_table.symbol_id_by_name(name)?; + let use_def = &index.use_def_maps[function_scope_id]; + Some(( + use_def.instance_attribute_bindings(symbol), + function_scope_id, + )) + }) +} + +/// Returns all attribute assignments as scope IDs for a specific class body scope. +/// +/// Only call this when doing type inference on the same file as `class_body_scope`, otherwise it +/// introduces a direct dependency on that file's AST. +pub(crate) fn attribute_scopes<'db, 's>( + db: &'db dyn Db, + class_body_scope: ScopeId<'db>, +) -> impl Iterator + use<'s, 'db> { + let file = class_body_scope.file(db); + let index = semantic_index(db, file); let class_scope_id = class_body_scope.file_scope_id(db); ChildrenIter::new(index, class_scope_id).filter_map(|(child_scope_id, scope)| { @@ -124,13 +148,7 @@ pub(crate) fn attribute_assignments<'db, 's>( }; function_scope.node().as_function()?; - let attribute_table = index.instance_attribute_table(function_scope_id); - let symbol = attribute_table.symbol_id_by_name(name)?; - let use_def = &index.use_def_maps[function_scope_id]; - Some(( - use_def.instance_attribute_bindings(symbol), - function_scope_id, - )) + Some(function_scope_id) }) } @@ -519,7 +537,7 @@ pub struct ChildrenIter<'a> { } impl<'a> ChildrenIter<'a> { - fn new(module_symbol_table: &'a SemanticIndex, parent: FileScopeId) -> Self { + pub(crate) fn new(module_symbol_table: &'a SemanticIndex, parent: FileScopeId) -> Self { let descendants = DescendantsIter::new(module_symbol_table, parent); Self { diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 6665ddb7a8..f885b45f58 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -65,6 +65,7 @@ mod context; mod diagnostic; mod display; mod generics; +mod ide_support; mod infer; mod instance; mod mro; @@ -7662,6 +7663,8 @@ pub enum KnownFunction { GenericContext, /// `ty_extensions.dunder_all_names` DunderAllNames, + /// `ty_extensions.all_members` + AllMembers, } impl KnownFunction { @@ -7721,7 +7724,8 @@ impl KnownFunction { | Self::IsSubtypeOf | Self::GenericContext | Self::DunderAllNames - | Self::StaticAssert => module.is_ty_extensions(), + | Self::StaticAssert + | Self::AllMembers => module.is_ty_extensions(), } } } @@ -9390,7 +9394,8 @@ pub(crate) mod tests { | KnownFunction::IsSingleValued | KnownFunction::IsAssignableTo | KnownFunction::IsEquivalentTo - | KnownFunction::IsGradualEquivalentTo => KnownModule::TyExtensions, + | KnownFunction::IsGradualEquivalentTo + | KnownFunction::AllMembers => KnownModule::TyExtensions, }; let function_definition = known_module_symbol(&db, module, function_name) diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index fa52bcd960..51ab005c1b 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -3,6 +3,7 @@ //! [signatures][crate::types::signatures], we have to handle the fact that the callable might be a //! union of types, each of which might contain multiple overloads. +use itertools::Itertools; use smallvec::{SmallVec, smallvec}; use super::{ @@ -22,7 +23,8 @@ use crate::types::signatures::{Parameter, ParameterForm}; use crate::types::{ BoundMethodType, DataclassParams, DataclassTransformerParams, FunctionDecorators, FunctionType, KnownClass, KnownFunction, KnownInstanceType, MethodWrapperKind, PropertyInstanceType, - SpecialFormType, TupleType, TypeMapping, UnionType, WrapperDescriptorKind, todo_type, + SpecialFormType, TupleType, TypeMapping, UnionType, WrapperDescriptorKind, ide_support, + todo_type, }; use ruff_db::diagnostic::{Annotation, Diagnostic, Severity, SubDiagnostic}; use ruff_python_ast as ast; @@ -656,6 +658,18 @@ impl<'db> Bindings<'db> { } } + Some(KnownFunction::AllMembers) => { + if let [Some(ty)] = overload.parameter_types() { + overload.set_return_type(TupleType::from_elements( + db, + ide_support::all_members(db, *ty) + .into_iter() + .sorted() + .map(|member| Type::string_literal(db, &member)), + )); + } + } + Some(KnownFunction::Len) => { if let [Some(first_arg)] = overload.parameter_types() { if let Some(len_ty) = first_arg.len(db) { diff --git a/crates/ty_python_semantic/src/types/ide_support.rs b/crates/ty_python_semantic/src/types/ide_support.rs new file mode 100644 index 0000000000..2ba4606d6a --- /dev/null +++ b/crates/ty_python_semantic/src/types/ide_support.rs @@ -0,0 +1,183 @@ +use crate::Db; +use crate::semantic_index::symbol::ScopeId; +use crate::semantic_index::{ + attribute_scopes, global_scope, semantic_index, symbol_table, use_def_map, +}; +use crate::symbol::{imported_symbol, symbol_from_bindings, symbol_from_declarations}; +use crate::types::{ClassBase, ClassLiteral, KnownClass, Type}; +use ruff_python_ast::name::Name; +use rustc_hash::FxHashSet; + +struct AllMembers { + members: FxHashSet, +} + +impl AllMembers { + fn of<'db>(db: &'db dyn Db, ty: Type<'db>) -> Self { + let mut all_members = Self { + members: FxHashSet::default(), + }; + all_members.extend_with_type(db, ty); + all_members + } + + fn extend_with_type<'db>(&mut self, db: &'db dyn Db, ty: Type<'db>) { + match ty { + Type::Union(union) => self.members.extend( + union + .elements(db) + .iter() + .map(|ty| AllMembers::of(db, *ty).members) + .reduce(|acc, members| acc.intersection(&members).cloned().collect()) + .unwrap_or_default(), + ), + + Type::Intersection(intersection) => self.members.extend( + intersection + .positive(db) + .iter() + .map(|ty| AllMembers::of(db, *ty).members) + .reduce(|acc, members| acc.union(&members).cloned().collect()) + .unwrap_or_default(), + ), + + Type::NominalInstance(instance) => { + let (class_literal, _specialization) = instance.class.class_literal(db); + self.extend_with_class_members(db, class_literal); + self.extend_with_instance_members(db, class_literal); + } + + Type::ClassLiteral(class_literal) => { + self.extend_with_class_members(db, class_literal); + + if let Type::ClassLiteral(meta_class_literal) = ty.to_meta_type(db) { + self.extend_with_class_members(db, meta_class_literal); + } + } + + Type::GenericAlias(generic_alias) => { + let class_literal = generic_alias.origin(db); + self.extend_with_class_members(db, class_literal); + } + + Type::SubclassOf(subclass_of_type) => { + if let Some(class_literal) = subclass_of_type.subclass_of().into_class() { + self.extend_with_class_members(db, class_literal.class_literal(db).0); + } + } + + Type::Dynamic(_) | Type::Never | Type::AlwaysTruthy | Type::AlwaysFalsy => {} + + Type::IntLiteral(_) + | Type::BooleanLiteral(_) + | Type::StringLiteral(_) + | Type::BytesLiteral(_) + | Type::LiteralString + | Type::Tuple(_) + | Type::PropertyInstance(_) + | Type::FunctionLiteral(_) + | Type::BoundMethod(_) + | Type::MethodWrapper(_) + | Type::WrapperDescriptor(_) + | Type::DataclassDecorator(_) + | Type::DataclassTransformer(_) + | Type::Callable(_) + | Type::ProtocolInstance(_) + | Type::SpecialForm(_) + | Type::KnownInstance(_) + | Type::TypeVar(_) + | Type::BoundSuper(_) => { + if let Type::ClassLiteral(class_literal) = ty.to_meta_type(db) { + self.extend_with_class_members(db, class_literal); + } + } + + Type::ModuleLiteral(literal) => { + self.extend_with_type(db, KnownClass::ModuleType.to_instance(db)); + + let Some(file) = literal.module(db).file() else { + return; + }; + + let module_scope = global_scope(db, file); + let use_def_map = use_def_map(db, module_scope); + let symbol_table = symbol_table(db, module_scope); + + for (symbol_id, _) in use_def_map.all_public_declarations() { + let symbol_name = symbol_table.symbol(symbol_id).name(); + if !imported_symbol(db, file, symbol_name, None) + .symbol + .is_unbound() + { + self.members + .insert(symbol_table.symbol(symbol_id).name().clone()); + } + } + } + } + } + + fn extend_with_declarations_and_bindings(&mut self, db: &dyn Db, scope_id: ScopeId) { + let use_def_map = use_def_map(db, scope_id); + let symbol_table = symbol_table(db, scope_id); + + for (symbol_id, declarations) in use_def_map.all_public_declarations() { + if symbol_from_declarations(db, declarations) + .is_ok_and(|result| !result.symbol.is_unbound()) + { + self.members + .insert(symbol_table.symbol(symbol_id).name().clone()); + } + } + + for (symbol_id, bindings) in use_def_map.all_public_bindings() { + if !symbol_from_bindings(db, bindings).is_unbound() { + self.members + .insert(symbol_table.symbol(symbol_id).name().clone()); + } + } + } + + fn extend_with_class_members<'db>( + &mut self, + db: &'db dyn Db, + class_literal: ClassLiteral<'db>, + ) { + for parent in class_literal + .iter_mro(db, None) + .filter_map(ClassBase::into_class) + .map(|class| class.class_literal(db).0) + { + let parent_scope = parent.body_scope(db); + self.extend_with_declarations_and_bindings(db, parent_scope); + } + } + + fn extend_with_instance_members<'db>( + &mut self, + db: &'db dyn Db, + class_literal: ClassLiteral<'db>, + ) { + for parent in class_literal + .iter_mro(db, None) + .filter_map(ClassBase::into_class) + .map(|class| class.class_literal(db).0) + { + let class_body_scope = parent.body_scope(db); + let file = class_body_scope.file(db); + let index = semantic_index(db, file); + for function_scope_id in attribute_scopes(db, class_body_scope) { + let attribute_table = index.instance_attribute_table(function_scope_id); + for symbol in attribute_table.symbols() { + self.members.insert(symbol.name().clone()); + } + } + } + } +} + +/// List all members of a given type: anything that would be valid when accessed +/// as an attribute on an object of the given type. +pub(crate) fn all_members<'db>(db: &'db dyn Db, ty: Type<'db>) -> FxHashSet { + AllMembers::of(db, ty).members +} diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index 50964ded3d..73aefafb3c 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -6719,6 +6719,37 @@ impl<'db> TypeInferenceBuilder<'db> { right: Type<'db>, range: TextRange, ) -> Result, CompareUnsupportedError<'db>> { + let is_str_literal_in_tuple = |literal: Type<'db>, tuple: TupleType<'db>| { + // Protect against doing a lot of work for pathologically large + // tuples. + // + // Ref: https://github.com/astral-sh/ruff/pull/18251#discussion_r2115909311 + if tuple.len(self.db()) > 1 << 12 { + return None; + } + + let mut definitely_true = false; + let mut definitely_false = true; + for element in tuple.elements(self.db()) { + if element.is_string_literal() { + if literal == *element { + definitely_true = true; + definitely_false = false; + } + } else if !literal.is_disjoint_from(self.db(), *element) { + definitely_false = false; + } + } + + if definitely_true { + Some(true) + } else if definitely_false { + Some(false) + } else { + None + } + }; + // Note: identity (is, is not) for equal builtin types is unreliable and not part of the // language spec. // - `[ast::CompOp::Is]`: return `false` if unequal, `bool` if equal @@ -6850,6 +6881,30 @@ impl<'db> TypeInferenceBuilder<'db> { } } } + (Type::StringLiteral(_), Type::Tuple(tuple)) if op == ast::CmpOp::In => { + if let Some(answer) = is_str_literal_in_tuple(left, tuple) { + return Ok(Type::BooleanLiteral(answer)); + } + + self.infer_binary_type_comparison( + KnownClass::Str.to_instance(self.db()), + op, + right, + range, + ) + } + (Type::StringLiteral(_), Type::Tuple(tuple)) if op == ast::CmpOp::NotIn => { + if let Some(answer) = is_str_literal_in_tuple(left, tuple) { + return Ok(Type::BooleanLiteral(!answer)); + } + + self.infer_binary_type_comparison( + KnownClass::Str.to_instance(self.db()), + op, + right, + range, + ) + } (Type::StringLiteral(_), _) => self.infer_binary_type_comparison( KnownClass::Str.to_instance(self.db()), op, diff --git a/crates/ty_vendored/ty_extensions/ty_extensions.pyi b/crates/ty_vendored/ty_extensions/ty_extensions.pyi index 127f97c3c5..0b98b7a55e 100644 --- a/crates/ty_vendored/ty_extensions/ty_extensions.pyi +++ b/crates/ty_vendored/ty_extensions/ty_extensions.pyi @@ -43,3 +43,13 @@ def generic_context(type: Any) -> Any: ... # Returns the `__all__` names of a module as a tuple of sorted strings, or `None` if # either the module does not have `__all__` or it has invalid elements. def dunder_all_names(module: Any) -> Any: ... + +# Returns a tuple of all members of the given object, similar to `dir(obj)` and +# `inspect.getmembers(obj)`, with at least the following differences: +# +# * `dir` and `inspect.getmembers` may use runtime mutable state to construct +# the list of attributes returned. In contrast, this routine is limited to +# static information only. +# * `dir` will respect an object's `__dir__` implementation, if present, but +# this method (currently) does not. +def all_members(obj: Any) -> tuple[str, ...]: ...