[ty] Retain typing-only symbols in explicit completions (#27435)

## Summary

We now exclude private type variables, parameter specifications,
type-variable tuples, aliases, and `@type_check_only` definitions from
completions, even though those names can be explicitly imported in
typing-only contexts:

```python
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from package import _Alias, _T
```

This PR makes those definitions consistent with existing
`@type_check_only` completion behavior: retain them as explicit import
and attribute completions, mark them as typing-only, and rank them below
runtime values.
This commit is contained in:
Charlie Marsh authored and GitHub committed 2026-08-04 13:43:27 +00:00
1 parent b56bb12ea6
commit d82ee58c34
4 files changed
+161 -40

No files matched your search

+87 -11
View File
@@ -453,6 +453,7 @@ impl<'db> CompletionBuilder<'db> {
Completion::builder(semantic.name)
.ty(semantic.ty)
.builtin(semantic.builtin)
.type_check_only(semantic.is_type_check_only)
.docstring(documentation)
}
@@ -492,7 +493,7 @@ impl<'db> CompletionBuilder<'db> {
query: &UserQuery,
) -> Completion<'db> {
if let Some(ty) = self.ty {
self.is_type_check_only = ty.is_type_check_only(db);
self.is_type_check_only |= ty.is_type_check_only(db);
// Tags completions with context-specific if they are
// known to be usable in an exception context and we have
// determined an `exception_ty`.
@@ -611,6 +612,11 @@ impl<'db> CompletionBuilder<'db> {
self
}
fn type_check_only(mut self, yes: bool) -> CompletionBuilder<'db> {
self.is_type_check_only = yes;
self
}
fn context_specific(mut self, yes: bool) -> CompletionBuilder<'db> {
self.is_context_specific = yes;
self
@@ -3609,25 +3615,95 @@ if TYPE_CHECKING:
test.contains("__mangled_name");
test.contains("__dunder_name__");
test.contains("public_type_var");
test.not_contains("_private_type_var");
test.not_contains("__mangled_type_var");
test.contains("public_param_spec");
test.not_contains("_private_param_spec");
test.contains("public_type_var_tuple");
test.not_contains("_private_type_var_tuple");
test.contains("public_explicit_type_alias");
test.not_contains("_private_explicit_type_alias");
test.contains("public_implicit_union_alias");
test.not_contains("_private_implicit_union_alias");
test.contains("_private_runtime_union");
test.contains("_private_runtime_typevar");
test.contains("_private_precise_runtime_union");
test.contains("PublicProtocol");
test.contains("_PrivateProtocol");
test.not_contains("PublicTypeOnlyProtocol");
test.not_contains("_PrivateTypeOnlyProtocol");
test.not_contains("PublicTypeCheckingProtocol");
test.not_contains("_PrivateTypeCheckingProtocol");
for name in [
"_private_type_var",
"__mangled_type_var",
"_private_param_spec",
"_private_type_var_tuple",
"_private_explicit_type_alias",
"_private_implicit_union_alias",
"PublicTypeOnlyProtocol",
"_PrivateTypeOnlyProtocol",
"PublicTypeCheckingProtocol",
"_PrivateTypeCheckingProtocol",
] {
assert!(
test.completions()
.iter()
.any(|completion| completion.name == name && completion.is_type_check_only),
"Expected `{name}` to be marked as typing-only",
);
}
}
#[test]
fn private_stub_symbols_rank_below_runtime_values() {
let builder = CursorTest::builder()
.source(
"package/__init__.pyi",
"from typing import TypeVar\n_Alpha = TypeVar(\"_Alpha\")\n_Zeta = 1",
)
.source("main.py", "import package; package._<CURSOR>")
.completion_test_builder()
.filter(|completion| matches!(completion.name.as_str(), "_Alpha" | "_Zeta"));
let test = builder.build();
let completions = test
.completions()
.iter()
.map(|completion| (completion.name.as_str(), completion.is_type_check_only))
.collect::<Vec<_>>();
assert_eq!(completions, [("_Zeta", false), ("_Alpha", true)]);
}
#[test]
fn type_checking_import_includes_private_stub_symbols() {
let builder = CursorTest::builder()
.source(
"package/__init__.pyi",
"from typing import TypeAlias, TypeVar\n_Alias: TypeAlias = int\n_T = TypeVar(\"_T\")",
)
.source(
"main.py",
"from typing import TYPE_CHECKING\nif TYPE_CHECKING:\n from package import _<CURSOR>",
)
.completion_test_builder();
let test = builder.build();
for name in ["_Alias", "_T"] {
assert!(
test.completions()
.iter()
.any(|completion| completion.name == name && completion.is_type_check_only),
"Expected `{name}` to be available as a typing-only completion",
);
}
}
#[test]
fn typing_only_project_builtins_not_suggested_implicitly() {
let builder = CursorTest::builder()
.source(
"__builtins__.pyi",
"from typing import TypeVar\n_typing_only = TypeVar(\"_typing_only\")\n_runtime: int",
)
.source("main.py", "_<CURSOR>")
.completion_test_builder()
.skip_auto_import();
let test = builder.build();
test.contains("_runtime").not_contains("_typing_only");
}
/// Unlike [`private_symbols_in_stub`], this test doesn't use a `.pyi` file so all of the names
@@ -675,6 +675,32 @@ static_assert(has_member(module, "evaluate"))
static_assert(not has_member(module, "Optional"))
```
### Private typing-only stub members
Typing-only helpers in stubs remain available as module members for autocomplete.
`module.pyi`:
```pyi
from typing import TypeAlias, TypeVar
_Alias: TypeAlias = int
_T = TypeVar("_T")
_runtime: int
```
`main.py`:
```py
import module
from ty_extensions import static_assert
from ty_extensions._internal import has_member
static_assert(has_member(module, "_runtime"))
static_assert(has_member(module, "_Alias"))
static_assert(has_member(module, "_T"))
```
## Conditionally available members
Some members are only conditionally available. For example, `bytearray.take_bytes` was only
+27 -11
View File
@@ -18,7 +18,7 @@ use ty_module_resolver::{
use crate::Db;
use crate::place::implicit_globals::all_implicit_module_globals;
use crate::types::ide_support::{ImportAliasResolution, definition_for_name};
use crate::types::list_members::{Member, all_members, all_reachable_members};
use crate::types::list_members::{all_members, all_reachable_members};
use crate::types::{
CycleDetector, ProgramEnvironment, SpecialFormType, Type, TypeQualifiers, binding_type,
infer_complete_scope_types, inferred_declaration,
@@ -169,6 +169,7 @@ impl<'db> SemanticModel<'db> {
name: CompactString::new(module.name(self.db).as_str()),
ty: Some(ty),
builtin,
is_type_check_only: false,
}
})
.collect()
@@ -239,11 +240,12 @@ impl<'db> SemanticModel<'db> {
clippy::iter_over_hash_type,
reason = "completion order is determined later by relevance ranking"
)]
for Member { name, ty } in all_members(db, &self.program_environment(), ty) {
for member in all_members(db, &self.program_environment(), ty) {
completions.push(Completion {
name: CompactString::new(name),
ty: Some(ty),
name: CompactString::new(member.name),
ty: Some(member.ty),
builtin,
is_type_check_only: member.is_type_check_only,
});
}
completions.extend(self.submodule_completions(&module));
@@ -262,6 +264,7 @@ impl<'db> SemanticModel<'db> {
name: CompactString::new(base),
ty: Some(ty),
builtin,
is_type_check_only: false,
});
}
completions
@@ -280,6 +283,7 @@ impl<'db> SemanticModel<'db> {
name: CompactString::new(member.name),
ty: Some(member.ty),
builtin: false,
is_type_check_only: member.is_type_check_only,
})
.collect()
}
@@ -304,6 +308,7 @@ impl<'db> SemanticModel<'db> {
name: CompactString::new(memberdef.member.name),
ty: Some(memberdef.member.ty),
builtin: false,
is_type_check_only: memberdef.member.is_type_check_only,
},
),
);
@@ -318,6 +323,7 @@ impl<'db> SemanticModel<'db> {
name: CompactString::new(name),
ty: Some(ty),
builtin: true,
is_type_check_only: false,
}),
);
@@ -326,17 +332,24 @@ impl<'db> SemanticModel<'db> {
let importing_file =
ImportingFile::File(self.file(), self.file.resolver_environment(self.db));
if resolve_module(self.db, importing_file, &project_builtins).is_some() {
completions.extend(self.module_completions(&project_builtins).into_iter().map(
|mut completion| {
completion.builtin = true;
completion
},
));
completions.extend(
self.module_completions(&project_builtins)
.into_iter()
.filter(|completion| !completion.is_type_check_only)
.map(|mut completion| {
completion.builtin = true;
completion
}),
);
}
// Builtins are available in all scopes.
let builtins = KnownModule::Builtins.name();
completions.extend(self.module_completions(&builtins));
completions.extend(
self.module_completions(&builtins)
.into_iter()
.filter(|completion| !completion.is_type_check_only),
);
// The above can sometimes result in duplicates. Get rid of them.
completions.sort_by(|c1, c2| c1.name.cmp(&c2.name));
@@ -725,6 +738,9 @@ pub struct Completion<'db> {
/// use it mainly in tests so that we can write less
/// noisy tests.
pub builtin: bool,
/// Whether this symbol is known to exist only for type checking and should
/// be ranked below runtime values.
pub is_type_check_only: bool,
}
#[derive(Clone, Debug)]
@@ -17,7 +17,7 @@ use crate::{
place_from_declarations,
},
types::{
ClassBase, ClassLiteral, KnownClass, KnownFunction, ProgramEnvironment, StaticClassLiteral,
ClassBase, ClassLiteral, KnownClass, ProgramEnvironment, StaticClassLiteral,
SubclassOfInner, Type, TypeVarBoundOrConstraints, class::CodeGeneratorKind,
exists_at_runtime,
},
@@ -52,6 +52,7 @@ pub(crate) fn all_end_of_scope_members<'db>(
let member = Member {
name: symbol.name().clone(),
ty,
is_type_check_only: false,
};
Some(MemberWithDefinition {
member,
@@ -72,6 +73,7 @@ pub(crate) fn all_end_of_scope_members<'db>(
let member = Member {
name: symbol.name().clone(),
ty,
is_type_check_only: false,
};
Some(MemberWithDefinition {
member,
@@ -109,6 +111,7 @@ pub(crate) fn all_reachable_members<'db>(
let member = Member {
name: symbol.name().clone(),
ty,
is_type_check_only: false,
};
Some(MemberWithDefinition {
member,
@@ -125,6 +128,7 @@ pub(crate) fn all_reachable_members<'db>(
let member = Member {
name: symbol.name().clone(),
ty,
is_type_check_only: false,
};
Some(MemberWithDefinition {
member,
@@ -419,6 +423,7 @@ impl<'db> AllMembers<'db> {
self.members.insert(Member {
name: Name::new_static("__file__"),
ty: dunder_file_type,
is_type_check_only: false,
});
self.extend_with_type(db, env, KnownClass::ModuleType.to_instance(db, env));
@@ -440,25 +445,13 @@ impl<'db> AllMembers<'db> {
continue;
};
if let Some(definition) = defined.provenance.definition()
&& !exists_at_runtime(db, definition)
// Source-module completions retain `@type_check_only` symbols and rank them
// lower.
&& (file.is_stub(db) || !defined.ty.is_type_check_only(db))
// The decorator itself is typing-only, but users must still be able to
// import it when defining typing-only classes and functions.
&& !matches!(
defined.ty,
Type::FunctionLiteral(function)
if function.known(db) == Some(KnownFunction::TypeCheckOnly)
)
{
continue;
}
self.members.insert(Member {
name: symbol_name.clone(),
ty: defined.ty,
is_type_check_only: defined
.provenance
.definition()
.is_some_and(|definition| !exists_at_runtime(db, definition)),
});
}
@@ -467,7 +460,11 @@ impl<'db> AllMembers<'db> {
|submodule_name| {
let ty = literal.resolve_submodule(db, &submodule_name)?;
let name = submodule_name.clone();
Some(Member { name, ty })
Some(Member {
name,
ty,
is_type_check_only: false,
})
},
));
}
@@ -516,6 +513,7 @@ impl<'db> AllMembers<'db> {
self.members.insert(Member {
name: memberdef.member.name,
ty,
is_type_check_only: memberdef.member.is_type_check_only,
});
}
}
@@ -565,6 +563,7 @@ impl<'db> AllMembers<'db> {
self.members.insert(Member {
name: Name::new(name),
ty,
is_type_check_only: false,
});
}
}
@@ -582,6 +581,7 @@ impl<'db> AllMembers<'db> {
self.members.insert(Member {
name: memberdef.member.name,
ty,
is_type_check_only: memberdef.member.is_type_check_only,
});
}
}
@@ -644,6 +644,7 @@ impl<'db> AllMembers<'db> {
self.members.insert(Member {
name: Name::from(*attr),
ty: synthetic_member,
is_type_check_only: false,
});
}
}
@@ -677,6 +678,8 @@ pub struct MemberWithDefinition<'db> {
pub struct Member<'db> {
pub(crate) name: Name,
pub(crate) ty: Type<'db>,
/// Whether this member is known to exist only during type checking.
pub(crate) is_type_check_only: bool,
}
impl std::hash::Hash for Member<'_> {