PY-90942 typing: introduce PyTopType

(cherry picked from commit 219d3a69024c41a3ff0cc78ae3588dfa6c6982aa)

GitOrigin-RevId: 271d70a890524b0a8d8450550b080e26787e3327
This commit is contained in:
Morgan Bartholomew
2026-08-18 04:43:55 +00:00
committed by intellij-monorepo-bot
parent 47900480f0
commit 7c666d14f6
8 changed files with 303 additions and 8 deletions
@@ -0,0 +1,59 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.psi.types
import com.intellij.psi.PsiElement
import com.intellij.util.ProcessingContext
import com.jetbrains.python.PyNames
import com.jetbrains.python.psi.AccessDirection
import com.jetbrains.python.psi.PyExpression
import com.jetbrains.python.psi.PyPsiFacade
import com.jetbrains.python.psi.resolve.PyResolveContext
import com.jetbrains.python.psi.resolve.RatedResolveResult
import org.jetbrains.annotations.ApiStatus
/**
* The top type of the Python type hierarchy: the supertype of every type, equivalent to `builtins.object`.
*
* There is only ever one top type, so this is a singleton. Unlike a [PyClassType] wrapping `object`, it needs no
* anchor [PsiElement] to be produced.
*/
@ApiStatus.Experimental
object PyTopType : PyType {
override val name: String = "object"
override val isBuiltin: Boolean = true
override fun assertValid(message: String?) {}
override fun resolveMember(
name: String,
location: PyExpression?,
direction: AccessDirection,
resolveContext: PyResolveContext,
): List<RatedResolveResult> =
objectType(location)?.resolveMember(name, location, direction, resolveContext).orEmpty()
override fun getCompletionVariants(
completionPrefix: String?,
location: PsiElement,
context: ProcessingContext,
): Array<out Any> =
objectType(location)?.getCompletionVariants(completionPrefix, location, context).orEmpty()
override fun getAllMembers(resolveContext: PyResolveContext): List<PyTypeMember> =
objectType(resolveContext.typeEvalContext.origin)?.getAllMembers(resolveContext).orEmpty()
override fun findMember(name: String, resolveContext: PyResolveContext): List<PyTypeMember> =
objectType(resolveContext.typeEvalContext.origin)?.findMember(name, resolveContext).orEmpty()
private fun objectType(anchor: PsiElement?): PyClassType? {
if (anchor == null) return null
val facade = PyPsiFacade.getInstance(anchor.project)
val objectClass = facade.createClassByQName(PyNames.OBJECT, anchor) ?: return null
return facade.createClassType(objectClass, false)
}
override fun <T> acceptTypeVisitor(visitor: PyTypeVisitor<T>): T? = visitor.visitPyTopType(this)
override fun toString(): String = name
}
@@ -73,6 +73,10 @@ abstract class PyTypeVisitor<T> {
return visitPyType(neverType)
}
open fun visitPyTopType(topType: PyTopType): T? {
return visitPyType(topType)
}
open fun visitAnyType(): T? {
return if (PyAnyType.isEnabled) visitPyType(PyAnyType.Any) else null
}
@@ -43,6 +43,7 @@ import com.jetbrains.python.psi.types.PyNeverType;
import com.jetbrains.python.psi.types.PyOverloadType;
import com.jetbrains.python.psi.types.PyParamSpecType;
import com.jetbrains.python.psi.types.PySelfType;
import com.jetbrains.python.psi.types.PyTopType;
import com.jetbrains.python.psi.types.PyTupleType;
import com.jetbrains.python.psi.types.PyType;
import com.jetbrains.python.psi.types.PyTypeParameterType;
@@ -418,6 +419,11 @@ public abstract class PyTypeRenderer extends PyTypeVisitorExt<@NotNull HtmlChunk
return className(neverType.getName());
}
@Override
public @NotNull HtmlChunk visitPyTopType(@NotNull PyTopType topType) {
return className(isRenderingFqn() ? PyNames.FQN.OBJECT : topType.getName());
}
@Override
public HtmlChunk visitPyUnionType(@NotNull PyUnionType unionType) {
// TODO Exclude "Unknown" once it's introduced, don't exclude explicit typing.Any
@@ -71,11 +71,30 @@ class PyIntersectionType private constructor(members: Collection<PyType?>) : PyC
return intersection(types.toList())
}
/**
* Constructs an intersection of the given types.
*
* If the resulting intersection would be empty, returns [PyTopType], which is the natural colapse of an intersection.
*/
@JvmStatic
fun intersection(types: Collection<PyType?>): PyType? {
return intersectionOrDefault(types, PyTopType)
}
/**
* Constructs an intersection of the given types, falling back to Unknown instead of [PyTopType].
*
* An intersection of no types is the type that constrains nothing, i.e. the top type.
*/
@JvmStatic
fun intersectionOrUnknown(types: Collection<PyType?>): PyType? {
return intersectionOrDefault(types, PyAnyType.unknown)
}
private fun intersectionOrDefault(types: Collection<PyType?>, defaultResult: PyType?): PyType? {
val newMembers = buildSet {
for (member in types) {
if (member is PyNeverType) return@intersection member
if (member is PyNeverType) return member
if (member is PyIntersectionType) {
addAll(member.members)
}
@@ -84,7 +103,11 @@ class PyIntersectionType private constructor(members: Collection<PyType?>) : PyC
}
}
}
return if (newMembers.size > 1) PyIntersectionType(newMembers) else newMembers.firstOrNull()
return when {
newMembers.size > 1 -> PyIntersectionType(newMembers)
newMembers.isEmpty() -> defaultResult
else -> newMembers.single()
}
}
}
}
@@ -320,6 +320,12 @@ object PyTypeChecker {
}
}
// `PyTopType` is the top type `object`: every type is assignable to it, just like `matchObject` accepts
// any `actual` when `expected` is the `object` class.
if (expected is PyTopType) {
return Optional.of(true)
}
if (actual is PyNarrowedType && expected is PyNarrowedType) {
return match(expected, actual, context)
}
@@ -27,8 +27,6 @@ import com.jetbrains.python.psi.types.PyRecursiveTypeVisitor.PyTypeTraverser
import com.jetbrains.python.psi.types.PyTypeChecker.convertToType
import com.jetbrains.python.psi.types.PyTypeChecker.findGenericDefinitionType
import com.jetbrains.python.psi.types.PyTypeChecker.match
import com.jetbrains.python.psi.types.PyTypeUtil.createTupleOfLiteralStringsType
import com.jetbrains.python.psi.types.PyTypeUtil.extractStringLiteralsFromTupleType
import com.jetbrains.python.psi.types.PyTypeUtil.widenLiteralAndNumeric
import one.util.streamex.StreamEx
import org.jetbrains.annotations.ApiStatus
@@ -160,11 +158,13 @@ object PyTypeUtil {
}
/**
* Given a type creates a stream of all its members if it's a union type or of only the type itself otherwise.
* Given a type creates a stream of all its members if it's a [PyCompositeType] or of only the type itself otherwise.
*
*
* It allows to process types received as the result of multiresolve uniformly with the others.
* It lets a composite type (e.g. the union produced when a reference resolves to several declarations) be processed
* uniformly with a non-composite one.
*/
@Deprecated("Use the high level composite api instead `PyType.compositeX`")
@JvmStatic
fun PyType?.toStream(): StreamEx<PyType?> =
if (this is PyCompositeType)
@@ -275,14 +275,106 @@ object PyTypeUtil {
}
}
val PyType?.components: List<PyType?>
val PyType?.compositeComponents: List<PyType?>
@ApiStatus.Experimental
get() = if (this is PyCompositeType) members.toList() else listOf(this)
val PyType?.componentSequence: Sequence<PyType?>
val PyType?.compositeComponentSequence: Sequence<PyType?>
@ApiStatus.Experimental
get() = if (this is PyCompositeType) members.asSequence() else sequenceOf(this)
/**
* Decomposes [this] into the list of its members (or a single-element list for a non-composite type),
* hands that list to [transform], and reassembles the result into a composite type *of the same kind*
* ([PyUnionType], [PyUnsafeUnionType] or [PyIntersectionType]); a non-composite type is rebuilt as a union,
* which collapses a single remaining member back to itself.
*
* It lets one uniformly deconstruct a composite type, process its members and build it back without branching
* on the concrete composite kind:
* ```
* val transformed = someType.compositeTransform { members ->
* members.map { ... }.filter { ... }.mapNotNull { ... }
* }
* ```
*
* If [transform] returns an empty list, the result is the identity element of the composite kind:
* `Never` for a union or an unsafe union (bottom), and `object` for an intersection (top). The `object` type is
* resolved against [this]'s original members and degrades to [PyAnyType.unknown] only for an anchorless intersection
* (one made solely of `Any`/`None`).
*
* @see compositeMap
* @see compositeMatchesAsSubtype
* @see compositeMatchesAsSupertype
*/
@ApiStatus.Experimental
fun PyType?.compositeTransform(transform: (List<PyType?>) -> List<PyType?>): PyType? =
rebuildLike(transform(compositeComponents))
/**
* Applies [mapper] to every leaf member of [this] composite type and reassembles the results into composite types
* of the same kinds; a non-composite type is passed to [mapper] directly.
*
* Recurses through nested composites ([PyUnionType], [PyUnsafeUnionType], [PyIntersectionType]) so that [mapper]
* always receives a non-composite type, e.g. `Union[Intersection[A, B], C]` is mapped to
* `Union[Intersection[f(A), f(B)], f(C)]`. Use [compositeTransform] instead when you need the flat list of the
* *direct* members without recursion.
*
* @see compositeTransform
* @see PyUnionType.map
*/
@ApiStatus.Experimental
fun PyType?.compositeMap(mapper: (PyType?) -> PyType?): PyType? =
if (this is PyCompositeType) compositeTransform { members -> members.map { it.compositeMap(mapper) } }
else mapper(this)
/**
* Tests [predicate] against the members of [this] composite type with the quantifier that decides whether the
* *whole composite is a subtype* of some other type: since `Union[Ts] <: T` iff every `Ti <: T`, *all* members of a
* [PyUnionType] must match; since `Intersection[Ts] <: T` (and `UnsafeUnion[Ts] <: T`) iff some `Ti <: T`, *any*
* member of a [PyIntersectionType] or a [PyUnsafeUnionType] is enough. For a non-composite type [predicate] is
* applied directly.
*
* [predicate] is expected to test the member on the subtype side, e.g. `compositeMatchesAsSubtype { match(it, target) }`.
*
* @see compositeMatchesAsSupertype
*/
@ApiStatus.Experimental
fun PyType?.compositeMatchesAsSubtype(predicate: (PyType?) -> Boolean): Boolean =
when (this) {
is PyUnionType -> members.all(predicate)
is PyIntersectionType, is PyUnsafeUnionType -> members.any(predicate)
else -> predicate(this)
}
/**
* The mirror of [compositeMatchesAsSubtype]: the quantifier that decides whether some other type is a *subtype of the whole
* composite*. Since `T <: Union[Ts]` iff some `T <: Ti`, *any* member of a [PyUnionType] (or a [PyUnsafeUnionType])
* suffices; since `T <: Intersection[Ts]` iff `T <: Ti` for every member, *all* members of a [PyIntersectionType]
* must match. For a non-composite type [predicate] is applied directly.
*
* [predicate] is expected to test the member on the supertype side, e.g. `compositeMatchesAsSupertype { match(target, it) }`.
*
* @see compositeMatchesAsSubtype
*/
@ApiStatus.Experimental
fun PyType?.compositeMatchesAsSupertype(predicate: (PyType?) -> Boolean): Boolean =
when (this) {
is PyUnionType, is PyUnsafeUnionType -> members.any(predicate)
is PyIntersectionType -> members.all(predicate)
else -> predicate(this)
}
/**
* Rebuilds a composite type of the same kind as [this] from [members], folding an empty [members] to the identity
* element of that kind: `Never` for unions/unsafe unions, `Unknown` for intersections.
*/
private fun PyType?.rebuildLike(members: List<PyType?>): PyType? =
when (this) {
is PyIntersectionType -> PyIntersectionType.intersection(members)
is PyUnsafeUnionType -> if (members.isEmpty()) PyNeverType.NEVER else PyUnsafeUnionType.unsafeUnion(members)
else -> PyUnionType.unionOrNever(members)
}
private fun toUnion(unionFactory: (List<PyType?>) -> PyType?): Collector<PyType?, *, PyType?> {
return Collectors.collectingAndThen(Collectors.toList(), unionFactory)
}
@@ -123,6 +123,7 @@ public class PyUnionType extends PyCompositeTypeBase {
* @param members a collection of types to union
* @return a PyType representing the union, or null if no valid members
*/
// TODO: change the default to Never
public static @Nullable PyType union(@NotNull Collection<@Nullable PyType> members) {
return unionOrDefault(members, PyAnyType.getUnknown());
}
@@ -0,0 +1,104 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.types
import com.intellij.idea.TestFor
import com.intellij.openapi.application.runReadActionBlocking
import com.intellij.psi.util.PsiTreeUtil
import com.jetbrains.python.allure.Components
import com.jetbrains.python.allure.Layers
import com.jetbrains.python.allure.Subsystems
import com.jetbrains.python.documentation.PythonDocumentationProvider
import com.jetbrains.python.fixtures.PyCodeInsightTestCase
import com.jetbrains.python.psi.AccessDirection
import com.jetbrains.python.psi.PyExpression
import com.jetbrains.python.psi.impl.PyBuiltinCache
import com.jetbrains.python.psi.resolve.PyResolveContext
import com.jetbrains.python.psi.types.PyCloningTypeVisitor
import com.jetbrains.python.psi.types.PyTopType
import com.jetbrains.python.psi.types.PyTypeChecker
import com.jetbrains.python.psi.types.TypeEvalContext
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@Layers.Functional
@TestFor(classes = [PyTopType::class], issues = ["PY-90942"])
class PyTopTypeTest : PyCodeInsightTestCase() {
@Test
fun `matches like builtins object in the type checker`() {
myFixture.configureByText("aaa.py", "")
runReadActionBlocking {
val cache = PyBuiltinCache.getInstance(myFixture.file)
val obj = cache.objectType!!
val int = cache.intType!!
val ctx = TypeEvalContext.codeAnalysis(myFixture.project, myFixture.file)
// Everything is assignable to the top type, just like `object`.
assertTrue(PyTypeChecker.match(PyTopType, int, ctx))
assertTrue(PyTypeChecker.match(PyTopType, obj, ctx))
assertTrue(PyTypeChecker.match(PyTopType, PyTopType, ctx))
// The top type is accepted where `object` is expected.
assertTrue(PyTypeChecker.match(obj, PyTopType, ctx))
// ...but is not assignable to a narrower type.
assertFalse(PyTypeChecker.match(int, PyTopType, ctx))
}
}
@Test
fun `resolves builtins object members`() {
val file = myFixture.configureByText("aaa.py", "x = 1")
runReadActionBlocking {
val obj = PyBuiltinCache.getInstance(file).objectType!!
val ctx = TypeEvalContext.codeAnalysis(myFixture.project, file)
val resolveContext = PyResolveContext.defaultContext(ctx)
val anchor = PsiTreeUtil.findChildOfType(file, PyExpression::class.java)!!
// A member defined on `object` resolves through the top type, same as through `object` itself.
val resolved = PyTopType.resolveMember("__class__", anchor, AccessDirection.READ, resolveContext)
assertFalse(resolved.isEmpty())
assertEquals(
obj.resolveMember("__class__", anchor, AccessDirection.READ, resolveContext)!!.map { it.element },
resolved.map { it.element },
)
// A name that `object` does not define resolves to nothing.
assertTrue(PyTopType.resolveMember("no_such_member", anchor, AccessDirection.READ, resolveContext).isEmpty())
// `findMember` / `getAllMembers` mirror `object` too (anchor taken from the context's origin file).
assertEquals(obj.findMember("__class__", resolveContext), PyTopType.findMember("__class__", resolveContext))
assertEquals(obj.getAllMembers(resolveContext), PyTopType.getAllMembers(resolveContext))
assertFalse(PyTopType.getAllMembers(resolveContext).isEmpty())
}
}
@Test
fun `renders like builtins object`() {
myFixture.configureByText("aaa.py", "")
runReadActionBlocking {
val ctx = TypeEvalContext.codeAnalysis(myFixture.project, myFixture.file)
val obj = PyBuiltinCache.getInstance(myFixture.file).objectType!!
assertEquals("object", PythonDocumentationProvider.getTypeName(PyTopType, ctx))
assertEquals(PythonDocumentationProvider.getTypeName(obj, ctx), PythonDocumentationProvider.getTypeName(PyTopType, ctx))
// Under fully qualified rendering the top type is spelled out like the class it stands for.
assertEquals("builtins.object", PythonDocumentationProvider.getFullyQualifiedTypeHint(PyTopType, ctx))
assertEquals(
PythonDocumentationProvider.getFullyQualifiedTypeHint(obj, ctx),
PythonDocumentationProvider.getFullyQualifiedTypeHint(PyTopType, ctx),
)
}
}
@Test
fun `cloning returns the same singleton`() {
myFixture.configureByText("aaa.py", "")
runReadActionBlocking {
val ctx = TypeEvalContext.codeAnalysis(myFixture.project, myFixture.file)
val cloner = object : PyCloningTypeVisitor(ctx) {}
assertSame(PyTopType, PyCloningTypeVisitor.clone(PyTopType, cloner))
}
}
}