Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/ty_completion_eval/completion-evaluation-tasks.csv
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ higher-level-symbols-preferred,main.py,0,
higher-level-symbols-preferred,main.py,1,1
import-deprioritizes-dunder,main.py,0,1
import-deprioritizes-sunder,main.py,0,1
import-deprioritizes-type_check_only,main.py,0,1
import-deprioritizes-type_check_only,main.py,1,1
import-deprioritizes-type_check_only,main.py,2,1
import-deprioritizes-type_check_only,main.py,3,2
import-deprioritizes-type_check_only,main.py,4,3
internal-typeshed-hidden,main.py,0,4
none-completion,main.py,0,11
numpy-array,main.py,0,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[settings]
auto-import = true
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from module import UniquePrefixA<CURSOR:UniquePrefixAzurous>
from module import unique_prefix_<CURSOR:unique_prefix_azurous>

from module import Class

Class.meth_<CURSOR:meth_azurous>

# TODO: bound methods don't preserve type-check-only-ness, this is a bug
Class().meth_<CURSOR:meth_azurous>

# TODO: auto-imports don't take type-check-only-ness into account, this is a bug
UniquePrefixA<CURSOR:module.UniquePrefixAzurous>
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from typing import type_check_only


@type_check_only
class UniquePrefixApple: pass

class UniquePrefixAzurous: pass


@type_check_only
def unique_prefix_apple() -> None: pass

def unique_prefix_azurous() -> None: pass


class Class:
@type_check_only
def meth_apple(self) -> None: pass

def meth_azurous(self) -> None: pass
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[project]
name = "test"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = []

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

80 changes: 76 additions & 4 deletions crates/ty_ide/src/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ pub struct Completion<'db> {
/// use it mainly in tests so that we can write less
/// noisy tests.
pub builtin: bool,
/// Whether this item only exists for type checking purposes and
/// will be missing at runtime
pub is_type_check_only: bool,
/// The documentation associated with this item, if
/// available.
pub documentation: Option<Docstring>,
Expand All @@ -79,6 +82,7 @@ impl<'db> Completion<'db> {
.ty
.and_then(|ty| DefinitionsOrTargets::from_ty(db, ty));
let documentation = definition.and_then(|def| def.docstring(db));
let is_type_check_only = semantic.is_type_check_only(db);
Completion {
name: semantic.name,
insert: None,
Expand All @@ -87,6 +91,7 @@ impl<'db> Completion<'db> {
module_name: None,
import: None,
builtin: semantic.builtin,
is_type_check_only,
documentation,
}
}
Expand Down Expand Up @@ -294,6 +299,7 @@ fn add_keyword_value_completions<'db>(
kind: None,
module_name: None,
import: None,
is_type_check_only: false,
builtin: true,
documentation: None,
});
Expand Down Expand Up @@ -339,6 +345,8 @@ fn add_unimported_completions<'db>(
module_name: Some(symbol.module.name(db)),
import: import_action.import().cloned(),
builtin: false,
// TODO: `is_type_check_only` requires inferring the type of the symbol
is_type_check_only: false,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to mean that completions that would require an auto-import are always considered "not type-check-only". Here's a screenshot from a local playground build using your branch (to build the playground locally, use cd playground && npm start --workspace ty-playground from the Ruff repo root):

image

module.py has these contents, so I would expect Foo to be ranked below Foooo:

from typing import type_check_only

@type_check_only
class Foo: ...

class Foooo: ...

since that's what I get in other situations on your branch (which is great!):

image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if getting this right is difficult for auto-import completions, it's fine to defer it for now, but we should add a TODO comment here if so

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think let's defer it for now 👍
I added some TODOs in the integration test.

Right now deprecation and type-check-only-ness are stored on types, not symbols, which makes this difficult. (we'd have to eagerly infer types to produce autoimport suggestions if I understand it correctly)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, so we'd have to infer the types of every symbol that could possibly be imported in order to provide autocomplete suggestions for a single x = Fo<CURSOR> situation... that would indeed undermine the "only lazily infer exactly what we need" architecture of ty 😆

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

situation... that would indeed undermine the "only lazily infer exactly what we need" architecture of ty 😆

Yeah, let's wait for @BurntSushi on this, but we can definitely infer the type after we've done some filtering

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My loose plan at this point was to basically never infer the type of unimported symbols, by virtue of there being so many. It's true that we could try doing that after we've done filtering, but I fear this could still have a negative overall effect where we end needing to type check a substantial portion of third party code just by virtue of using completions. And I of course worry about the latency impact this will have on auto-import completions.

This is why the way auto-import works currently is to basically bypass any type checking and just pick up symbols straight from the AST. The way I would try to tackle type_check_only support here is to see if we can add it to that AST extraction bit and not try to bring in type checking machinery for it.

documentation: None,
});
}
Expand Down Expand Up @@ -837,16 +845,21 @@ fn is_in_string(parsed: &ParsedModuleRef, offset: TextSize) -> bool {
})
}

/// Order completions lexicographically, with these exceptions:
/// Order completions according to the following rules:
///
/// 1) A `_[^_]` prefix sorts last and
/// 2) A `__` prefix sorts last except before (1)
/// 1) Names with no underscore prefix
/// 2) Names starting with `_` but not dunders
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it's out of scope for this PR, but perhaps deprecated items should also ranked last? (and crossed out in the autocompletion list)

Image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, agree. I think that sounds like a great change for another PR

/// 3) `__dunder__` names
///
/// Among each category, type-check-only items are sorted last,
/// and otherwise completions are sorted lexicographically.
///
/// This has the effect of putting all dunder attributes after "normal"
/// attributes, and all single-underscore attributes after dunder attributes.
fn compare_suggestions(c1: &Completion, c2: &Completion) -> Ordering {
let (kind1, kind2) = (NameKind::classify(&c1.name), NameKind::classify(&c2.name));
kind1.cmp(&kind2).then_with(|| c1.name.cmp(&c2.name))

(kind1, c1.is_type_check_only, &c1.name).cmp(&(kind2, c2.is_type_check_only, &c2.name))
}

#[cfg(test)]
Expand Down Expand Up @@ -3378,6 +3391,65 @@ from os.<CURSOR>
);
}

#[test]
fn import_type_check_only_lowers_ranking() {
let test = CursorTest::builder()
.source(
"main.py",
r#"
import foo
foo.A<CURSOR>
"#,
)
.source(
"foo/__init__.py",
r#"
from typing import type_check_only

@type_check_only
class Apple: pass

class Banana: pass
class Cat: pass
class Azorubine: pass
"#,
)
.build();

let settings = CompletionSettings::default();
let completions = completion(&test.db, &settings, test.cursor.file, test.cursor.offset);

let [apple_pos, banana_pos, cat_pos, azo_pos, ann_pos] =
["Apple", "Banana", "Cat", "Azorubine", "__annotations__"].map(|name| {
completions
.iter()
.position(|comp| comp.name == name)
.unwrap()
});

assert!(completions[apple_pos].is_type_check_only);
assert!(apple_pos > banana_pos.max(cat_pos).max(azo_pos));
assert!(ann_pos > apple_pos);
}

#[test]
fn type_check_only_is_type_check_only() {
// `@typing.type_check_only` is a function that's unavailable at runtime
// and so should be the last "non-underscore" completion in `typing`
let test = cursor_test("from typing import t<CURSOR>");

let settings = CompletionSettings::default();
let completions = completion(&test.db, &settings, test.cursor.file, test.cursor.offset);
let last_nonunderscore = completions
.into_iter()
.filter(|c| !c.name.starts_with('_'))
.next_back()
.unwrap();

assert_eq!(&last_nonunderscore.name, "type_check_only");
assert!(last_nonunderscore.is_type_check_only);
}

#[test]
fn regression_test_issue_642() {
// Regression test for https://github.com/astral-sh/ty/issues/642
Expand Down
6 changes: 6 additions & 0 deletions crates/ty_python_semantic/src/semantic_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,12 @@ pub struct Completion<'db> {
pub builtin: bool,
}

impl<'db> Completion<'db> {
pub fn is_type_check_only(&self, db: &'db dyn Db) -> bool {
self.ty.is_some_and(|ty| ty.is_type_check_only(db))
}
}

pub trait HasType {
/// Returns the inferred type of `self`.
///
Expand Down
15 changes: 13 additions & 2 deletions crates/ty_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ pub use crate::types::display::DisplaySettings;
use crate::types::display::TupleSpecialization;
use crate::types::enums::{enum_metadata, is_single_member_enum};
use crate::types::function::{
DataclassTransformerFlags, DataclassTransformerParams, FunctionSpans, FunctionType,
KnownFunction,
DataclassTransformerFlags, DataclassTransformerParams, FunctionDecorators, FunctionSpans,
FunctionType, KnownFunction,
};
pub(crate) use crate::types::generics::GenericContext;
use crate::types::generics::{
Expand Down Expand Up @@ -901,6 +901,17 @@ impl<'db> Type<'db> {
matches!(self, Type::Dynamic(_))
}

/// Is a value of this type only usable in typing contexts?
pub(crate) fn is_type_check_only(&self, db: &'db dyn Db) -> bool {
match self {
Type::ClassLiteral(class_literal) => class_literal.type_check_only(db),
Type::FunctionLiteral(f) => {
f.has_known_decorator(db, FunctionDecorators::TYPE_CHECK_ONLY)
}
_ => false,
}
}

// If the type is a specialized instance of the given `KnownClass`, returns the specialization.
pub(crate) fn known_specialization(
&self,
Expand Down
1 change: 1 addition & 0 deletions crates/ty_python_semantic/src/types/call/bind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1001,6 +1001,7 @@ impl<'db> Bindings<'db> {
class_literal.body_scope(db),
class_literal.known(db),
class_literal.deprecated(db),
class_literal.type_check_only(db),
Some(params),
class_literal.dataclass_transformer_params(db),
)));
Expand Down
2 changes: 2 additions & 0 deletions crates/ty_python_semantic/src/types/class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,8 @@ pub struct ClassLiteral<'db> {
/// If this class is deprecated, this holds the deprecation message.
pub(crate) deprecated: Option<DeprecatedInstance<'db>>,

pub(crate) type_check_only: bool,

pub(crate) dataclass_params: Option<DataclassParams<'db>>,
pub(crate) dataclass_transformer_params: Option<DataclassTransformerParams<'db>>,
}
Expand Down
11 changes: 10 additions & 1 deletion crates/ty_python_semantic/src/types/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ bitflags! {
const STATICMETHOD = 1 << 5;
/// `@typing.override`
const OVERRIDE = 1 << 6;
/// `@typing.type_check_only`
const TYPE_CHECK_ONLY = 1 << 7;
}
}

Expand All @@ -135,6 +137,7 @@ impl FunctionDecorators {
Some(KnownFunction::AbstractMethod) => FunctionDecorators::ABSTRACT_METHOD,
Some(KnownFunction::Final) => FunctionDecorators::FINAL,
Some(KnownFunction::Override) => FunctionDecorators::OVERRIDE,
Some(KnownFunction::TypeCheckOnly) => FunctionDecorators::TYPE_CHECK_ONLY,
_ => FunctionDecorators::empty(),
},
Type::ClassLiteral(class) => match class.known(db) {
Expand Down Expand Up @@ -1278,6 +1281,8 @@ pub enum KnownFunction {
DisjointBase,
/// [`typing(_extensions).no_type_check`](https://typing.python.org/en/latest/spec/directives.html#no-type-check)
NoTypeCheck,
/// `typing(_extensions).type_check_only`
TypeCheckOnly,

/// `typing(_extensions).assert_type`
AssertType,
Expand Down Expand Up @@ -1362,7 +1367,7 @@ impl KnownFunction {
.then_some(candidate)
}

/// Return `true` if `self` is defined in `module` at runtime.
/// Return `true` if `self` is defined in `module`
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was it an important detail that it's really defined in the module at runtime? I am not sure, but type_check_only should be the only exception, probably

const fn check_module(self, module: KnownModule) -> bool {
match self {
Self::IsInstance
Expand Down Expand Up @@ -1416,6 +1421,8 @@ impl KnownFunction {
| Self::NegatedRangeConstraint
| Self::AllMembers => module.is_ty_extensions(),
Self::ImportModule => module.is_importlib(),

Self::TypeCheckOnly => matches!(module, KnownModule::Typing),
}
}

Expand Down Expand Up @@ -1821,6 +1828,8 @@ pub(crate) mod tests {
| KnownFunction::DisjointBase
| KnownFunction::NoTypeCheck => KnownModule::TypingExtensions,

KnownFunction::TypeCheckOnly => KnownModule::Typing,

KnownFunction::IsSingleton
| KnownFunction::IsSubtypeOf
| KnownFunction::GenericContext
Expand Down
15 changes: 15 additions & 0 deletions crates/ty_python_semantic/src/types/infer/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2207,6 +2207,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
let known_function =
KnownFunction::try_from_definition_and_name(self.db(), definition, name);

// `type_check_only` is itself not available at runtime
if known_function == Some(KnownFunction::TypeCheckOnly) {
function_decorators |= FunctionDecorators::TYPE_CHECK_ONLY;
}

let body_scope = self
.index
.node_scope(NodeWithScopeRef::Function(function))
Expand Down Expand Up @@ -2588,6 +2593,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
} = class_node;

let mut deprecated = None;
let mut type_check_only = false;
let mut dataclass_params = None;
let mut dataclass_transformer_params = None;
for decorator in decorator_list {
Expand All @@ -2612,6 +2618,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
continue;
}

if decorator_ty
.as_function_literal()
.is_some_and(|function| function.is_known(self.db(), KnownFunction::TypeCheckOnly))
{
type_check_only = true;
continue;
}

if let Type::FunctionLiteral(f) = decorator_ty {
// We do not yet detect or flag `@dataclass_transform` applied to more than one
// overload, or an overload and the implementation both. Nevertheless, this is not
Expand Down Expand Up @@ -2660,6 +2674,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
body_scope,
maybe_known_class,
deprecated,
type_check_only,
dataclass_params,
dataclass_transformer_params,
)),
Expand Down
Loading