diff --git a/python/python-psi-api/src/com/jetbrains/python/psi/types/PyTopType.kt b/python/python-psi-api/src/com/jetbrains/python/psi/types/PyTopType.kt new file mode 100644 index 000000000000..35d66e3a9da2 --- /dev/null +++ b/python/python-psi-api/src/com/jetbrains/python/psi/types/PyTopType.kt @@ -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 = + objectType(location)?.resolveMember(name, location, direction, resolveContext).orEmpty() + + override fun getCompletionVariants( + completionPrefix: String?, + location: PsiElement, + context: ProcessingContext, + ): Array = + objectType(location)?.getCompletionVariants(completionPrefix, location, context).orEmpty() + + override fun getAllMembers(resolveContext: PyResolveContext): List = + objectType(resolveContext.typeEvalContext.origin)?.getAllMembers(resolveContext).orEmpty() + + override fun findMember(name: String, resolveContext: PyResolveContext): List = + 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 acceptTypeVisitor(visitor: PyTypeVisitor): T? = visitor.visitPyTopType(this) + + override fun toString(): String = name +} diff --git a/python/python-psi-api/src/com/jetbrains/python/psi/types/PyTypeVisitor.kt b/python/python-psi-api/src/com/jetbrains/python/psi/types/PyTypeVisitor.kt index 5aa323992e4b..c3a7048a0c48 100644 --- a/python/python-psi-api/src/com/jetbrains/python/psi/types/PyTypeVisitor.kt +++ b/python/python-psi-api/src/com/jetbrains/python/psi/types/PyTypeVisitor.kt @@ -73,6 +73,10 @@ abstract class PyTypeVisitor { 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 } diff --git a/python/python-psi-impl/src/com/jetbrains/python/documentation/PyTypeRenderer.java b/python/python-psi-impl/src/com/jetbrains/python/documentation/PyTypeRenderer.java index 33c612a96ada..c33046a705be 100644 --- a/python/python-psi-impl/src/com/jetbrains/python/documentation/PyTypeRenderer.java +++ b/python/python-psi-impl/src/com/jetbrains/python/documentation/PyTypeRenderer.java @@ -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 diff --git a/python/python-psi-impl/src/com/jetbrains/python/psi/types/PyIntersectionType.kt b/python/python-psi-impl/src/com/jetbrains/python/psi/types/PyIntersectionType.kt index 07eb5c0dd7b1..f12d5001e567 100644 --- a/python/python-psi-impl/src/com/jetbrains/python/psi/types/PyIntersectionType.kt +++ b/python/python-psi-impl/src/com/jetbrains/python/psi/types/PyIntersectionType.kt @@ -71,11 +71,30 @@ class PyIntersectionType private constructor(members: Collection) : 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? { + 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? { + return intersectionOrDefault(types, PyAnyType.unknown) + } + + private fun intersectionOrDefault(types: Collection, 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) : PyC } } } - return if (newMembers.size > 1) PyIntersectionType(newMembers) else newMembers.firstOrNull() + return when { + newMembers.size > 1 -> PyIntersectionType(newMembers) + newMembers.isEmpty() -> defaultResult + else -> newMembers.single() + } } } } diff --git a/python/python-psi-impl/src/com/jetbrains/python/psi/types/PyTypeChecker.kt b/python/python-psi-impl/src/com/jetbrains/python/psi/types/PyTypeChecker.kt index 0a9483fd4f89..ab11e6e66488 100644 --- a/python/python-psi-impl/src/com/jetbrains/python/psi/types/PyTypeChecker.kt +++ b/python/python-psi-impl/src/com/jetbrains/python/psi/types/PyTypeChecker.kt @@ -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) } diff --git a/python/python-psi-impl/src/com/jetbrains/python/psi/types/PyTypeUtil.kt b/python/python-psi-impl/src/com/jetbrains/python/psi/types/PyTypeUtil.kt index 0f36433fb505..dd67c8bff215 100644 --- a/python/python-psi-impl/src/com/jetbrains/python/psi/types/PyTypeUtil.kt +++ b/python/python-psi-impl/src/com/jetbrains/python/psi/types/PyTypeUtil.kt @@ -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 = if (this is PyCompositeType) @@ -275,14 +275,106 @@ object PyTypeUtil { } } - val PyType?.components: List + val PyType?.compositeComponents: List @ApiStatus.Experimental get() = if (this is PyCompositeType) members.toList() else listOf(this) - val PyType?.componentSequence: Sequence + val PyType?.compositeComponentSequence: Sequence @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) -> List): 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? = + 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?): Collector { return Collectors.collectingAndThen(Collectors.toList(), unionFactory) } diff --git a/python/python-psi-impl/src/com/jetbrains/python/psi/types/PyUnionType.java b/python/python-psi-impl/src/com/jetbrains/python/psi/types/PyUnionType.java index cfef49843fb5..231455bd29cf 100644 --- a/python/python-psi-impl/src/com/jetbrains/python/psi/types/PyUnionType.java +++ b/python/python-psi-impl/src/com/jetbrains/python/psi/types/PyUnionType.java @@ -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()); } diff --git a/python/testSrc/com/jetbrains/python/types/PyTopTypeTest.kt b/python/testSrc/com/jetbrains/python/types/PyTopTypeTest.kt new file mode 100644 index 000000000000..0057964a4393 --- /dev/null +++ b/python/testSrc/com/jetbrains/python/types/PyTopTypeTest.kt @@ -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)) + } + } +}