From 0b3548755c81d10d9f43d52c2a4516f4b903210b Mon Sep 17 00:00:00 2001 From: David Peter Date: Wed, 27 Aug 2025 20:01:45 +0200 Subject: [PATCH] [ty] Preserve qualifiers when accessing attributes on unions/intersections (#20114) ## Summary Properly preserve type qualifiers when accessing attributes on unions and intersections. This is a prerequisite for https://github.com/astral-sh/ruff/pull/19579. Also fix a completely wrong implementation of `map_with_boundness_and_qualifiers`. It now closely follows `map_with_boundness` (just above). ## Test Plan I thought about it, but didn't find any easy way to test this. This only affected `Type::member`. Things like validation of attribute writes (where type qualifiers like `ClassVar` and `Final` are important) were already handling things correctly. --- .../resources/mdtest/annotations/callable.md | 2 +- crates/ty_python_semantic/src/types.rs | 36 ++++++++----------- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/callable.md b/crates/ty_python_semantic/resources/mdtest/annotations/callable.md index 1a7974089e..37f5923496 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/callable.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/callable.md @@ -398,7 +398,7 @@ def f_okay(c: Callable[[], None]): c.__qualname__ = "my_callable" result = getattr_static(c, "__qualname__") - reveal_type(result) # revealed: Never + reveal_type(result) # revealed: property if isinstance(result, property) and result.fset: c.__qualname__ = "my_callable" # okay ``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 91fe0ee8fa..3217f0bf05 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -3309,19 +3309,14 @@ impl<'db> Type<'db> { let name_str = name.as_str(); match self { - Type::Union(union) => union - .map_with_boundness(db, |elem| { - elem.member_lookup_with_policy(db, name_str.into(), policy) - .place - }) - .into(), + Type::Union(union) => union.map_with_boundness_and_qualifiers(db, |elem| { + elem.member_lookup_with_policy(db, name_str.into(), policy) + }), Type::Intersection(intersection) => intersection - .map_with_boundness(db, |elem| { + .map_with_boundness_and_qualifiers(db, |elem| { elem.member_lookup_with_policy(db, name_str.into(), policy) - .place - }) - .into(), + }), Type::Dynamic(..) | Type::Never => Place::bound(self).into(), @@ -9743,8 +9738,8 @@ impl<'db> IntersectionType<'db> { let mut builder = IntersectionBuilder::new(db); let mut qualifiers = TypeQualifiers::empty(); - let mut any_unbound = false; - let mut any_possibly_unbound = false; + let mut all_unbound = true; + let mut any_definitely_bound = false; for ty in self.positive_elements_or_object(db) { let PlaceAndQualifiers { place: member, @@ -9752,12 +9747,11 @@ impl<'db> IntersectionType<'db> { } = transform_fn(&ty); qualifiers |= new_qualifiers; match member { - Place::Unbound => { - any_unbound = true; - } + Place::Unbound => {} Place::Type(ty_member, member_boundness) => { - if member_boundness == Boundness::PossiblyUnbound { - any_possibly_unbound = true; + all_unbound = false; + if member_boundness == Boundness::Bound { + any_definitely_bound = true; } builder = builder.add_positive(ty_member); @@ -9766,15 +9760,15 @@ impl<'db> IntersectionType<'db> { } PlaceAndQualifiers { - place: if any_unbound { + place: if all_unbound { Place::Unbound } else { Place::Type( builder.build(), - if any_possibly_unbound { - Boundness::PossiblyUnbound - } else { + if any_definitely_bound { Boundness::Bound + } else { + Boundness::PossiblyUnbound }, ) },