[ty] Offer completions for T when a value has type Unknown | T

Fixes astral-sh/ty#2197
This commit is contained in:
Andrew Gallant
2026-01-07 07:42:28 -05:00
committed by Andrew Gallant
parent 4cba2e8f91
commit 952193e0c6
4 changed files with 136 additions and 9 deletions

View File

@@ -7741,6 +7741,68 @@ TypedDi<CURSOR>
);
}
/// Tests that `xs = ["..."]; xs[0].<CURSOR>` gets completions
/// appropriate for `str`.
#[test]
fn dynamic_type_list_no_type_annotation() {
let builder = completion_test_builder(
r#"
my_list = ["foo"]
my_list[0].remove<CURSOR>
"#,
);
assert_snapshot!(
builder.build().snapshot(),
@r"
removeprefix
removesuffix
",
);
}
/// Tests that when we have `Any | T` that we offer
/// completions for `T`.
#[test]
fn dynamic_type_with_type_annotation() {
let builder = completion_test_builder(
r#"
from typing import Any
def f(x: Any | str):
x.remove<CURSOR>
"#,
);
assert_snapshot!(
builder.build().snapshot(),
@r"
removeprefix
removesuffix
",
);
}
/// Tests that when we have `(U & Any) | T` that we offer
/// completions for `T`.
#[test]
fn dynamic_type_with_intersection_type_annotation() {
let builder = completion_test_builder(
r#"
from typing import Any
from ty_extensions import Intersection
def f(x: Intersection[int, Any] | str):
x.remove<CURSOR>
"#,
);
assert_snapshot!(
builder.build().snapshot(),
@r"
removeprefix
removesuffix
",
);
}
/// A way to create a simple single-file (named `main.py`) completion test
/// builder.
///

View File

@@ -361,6 +361,52 @@ def f(union: A | B):
static_assert(not has_member(union, "only_on_b"))
```
Unless one of the elements of the union is `Any`, thus making it dynamic. In which case, we consider
items on the intersection of the non-`Any` elements:
```py
from typing import Any
from ty_extensions import has_member, 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: Any | A):
static_assert(has_member(union, "on_both"))
static_assert(has_member(union, "only_on_a"))
def g(union: Any | A | B):
static_assert(has_member(union, "on_both"))
static_assert(not has_member(union, "only_on_a"))
static_assert(not has_member(union, "only_on_b"))
```
Similarly, unioning with an intersection involving `Any` is treated the same as if it was just
unioned with `Any`:
```py
from typing import Any
from ty_extensions import Intersection, has_member, 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(x: Intersection[Any, A] | B):
static_assert(has_member(x, "on_both"))
static_assert(not has_member(x, "only_on_a"))
static_assert(has_member(x, "only_on_b"))
```
### Intersections
#### Only positive types

View File

@@ -1064,7 +1064,7 @@ impl<'db> Type<'db> {
}
}
const fn is_dynamic(&self) -> bool {
pub(crate) const fn is_dynamic(&self) -> bool {
matches!(self, Type::Dynamic(_))
}

View File

@@ -163,14 +163,33 @@ impl<'db> AllMembers<'db> {
fn extend_with_type(&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::Union(union) => {
fn is_dynamic(db: &dyn Db, ty: Type<'_>) -> bool {
// We don't need to use recursion here because
// `Type` guarantees that unions/intersections
// are kept in DNF (i.e., they are flattened).
ty.is_dynamic()
|| match ty {
Type::Intersection(intersection) => {
intersection.positive(db).iter().any(Type::is_dynamic)
}
_ => false,
}
}
let union = match union.filter(db, |&ty| !is_dynamic(db, ty)) {
Type::Union(union) => union,
ty => return self.extend_with_type(db, ty),
};
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