diff --git a/python/python-psi-impl/src/com/jetbrains/python/documentation/PyDocumentationBuilder.java b/python/python-psi-impl/src/com/jetbrains/python/documentation/PyDocumentationBuilder.java index 0075c76d0860a..0af378e185b8d 100644 --- a/python/python-psi-impl/src/com/jetbrains/python/documentation/PyDocumentationBuilder.java +++ b/python/python-psi-impl/src/com/jetbrains/python/documentation/PyDocumentationBuilder.java @@ -40,6 +40,7 @@ import static com.intellij.lang.documentation.DocumentationMarkup.*; import static com.jetbrains.python.psi.PyUtil.as; +import static com.jetbrains.python.psi.types.PyNoneTypeKt.isNoneType; public final class PyDocumentationBuilder { private final PsiElement myElement; @@ -566,28 +567,117 @@ private boolean isAttribute() { return myElement instanceof PyTargetExpression && PyUtil.isAttribute((PyTargetExpression)myElement); } + // Helper: check if given expression is used as a callee in a call like `expr(...)` + private boolean isCallCallee(@NotNull PsiElement expr) { + PsiElement parent = expr.getParent(); + return parent instanceof PyCallExpression && ((PyCallExpression)parent).getCallee() == expr; + } + + // Helper: find attribute target in class by name considering methods, properties and class attributes + private @Nullable PsiElement findAttributeTargetInClass(@NotNull PyClass cls, @NotNull String attrName) { + PyFunction method = cls.findMethodByName(attrName, true, myContext); + if (method != null) return method; + + Property property = cls.findProperty(attrName, true, myContext); + if (property != null) { + Maybe getter = property.getGetter(); + PyCallable callable = getter.valueOrNull(); + return callable instanceof PsiElement ? (PsiElement)callable : property.getDefinitionSite(); + } + + PyTargetExpression classAttr = cls.findClassAttribute(attrName, true, myContext); + if (classAttr != null) return classAttr; + + return null; + } + + // Helper: for a union qualifier type, ensure every non-None class member has the same attribute target + private boolean hasConsistentAttributeTargetAcrossUnion(@NotNull PyUnionType unionType, @NotNull String attrName) { + PsiElement sameTarget = null; + for (PyType member : unionType.getMembers()) { + if (isNoneType(member)) continue; // Optional[X] + if (!(member instanceof PyClassType)) return false; + PyClass cls = ((PyClassType)member).getPyClass(); + PsiElement target = findAttributeTargetInClass(cls, attrName); + if (target == null) return false; + if (sameTarget == null) { + sameTarget = target; + } + else if (!sameTarget.isEquivalentTo(target)) { + return false; + } + } + return true; + } + + // Helper: decide whether it's safe to resolve a qualified attribute reference for documentation purposes + private boolean isQualifiedAttrResolutionSafe(@NotNull PyReferenceExpression ref) { + if (!ref.isQualified()) return true; + PyExpression qualifier = ref.getQualifier(); + if (qualifier == null) return true; + + PyType qType = myContext.getType(qualifier); + if (qType instanceof PyClassType || qType instanceof PyModuleType) return true; + + if (qType instanceof PyUnionType) { + String attrName = ref.getReferencedName(); + if (attrName != null) { + return hasConsistentAttributeTargetAcrossUnion((PyUnionType)qType, attrName); + } + } + return false; + } + + // Helper: if a target name is assigned from a reference whose resolved element has a docstring, use that owner + private @Nullable PsiElement resolveAssignedDocStringOwnerFromTarget(@NotNull PyTargetExpression target) { + if (target.getDocStringValue() != null) return null; + final PyExpression assignedValue = target.findAssignedValue(); + if (assignedValue instanceof PyReferenceExpression) { + final PsiElement resolved = resolve((PyReferenceExpression)assignedValue); + if (resolved instanceof PyDocStringOwner) { + String name = target.getName(); + if (name != null) { + mySectionsMap.get(PyPsiBundle.message("QDOC.assigned.to")).append(HtmlChunk.text(name).code()); + } + return resolved; + } + } + return null; + } + private @NotNull PsiElement resolveToDocStringOwner() { - // here the ^Q target is already resolved; the resolved element may point to intermediate assignments - if (myElement instanceof PyTargetExpression && ((PyTargetExpression)myElement).getDocStringValue() == null) { - final PyExpression assignedValue = ((PyTargetExpression)myElement).findAssignedValue(); - if (assignedValue instanceof PyReferenceExpression) { - final PsiElement resolved = resolve((PyReferenceExpression)assignedValue); - if (resolved instanceof PyDocStringOwner) { - String name = ((PyTargetExpression)myElement).getName(); - if (name != null) { - mySectionsMap.get(PyPsiBundle.message("QDOC.assigned.to")).append(HtmlChunk.text(name).code()); - } - return resolved; + // If the original element is an attribute reference with unknown/Any qualifier type, + // do not resolve for documentation — show neutral/empty docs instead of unrelated ones. + if (myOriginalElement != null) { + final PsiElement parent = myOriginalElement.getParent(); + if (parent instanceof PyReferenceExpression ref && ref.isQualified()) { + if (!isQualifiedAttrResolutionSafe(ref) && !isCallCallee(parent)) { + return ref; } } } + + // here the ^Q target is already resolved; the resolved element may point to intermediate assignments + if (myElement instanceof PyTargetExpression) { + PsiElement owner = resolveAssignedDocStringOwnerFromTarget((PyTargetExpression)myElement); + if (owner != null) return owner; + } + // Reference expression can be passed as the target element in Python console - if (myElement instanceof PyReferenceExpression) { - final PsiElement resolved = resolve((PyReferenceExpression)myElement); + if (myElement instanceof PyReferenceExpression ref) { + // If it's a qualified reference like `qual.attr` and the qualifier type is not a concrete class or module type, + // do not resolve for documentation purposes to avoid picking unrelated symbols. + if (ref.isQualified()) { + if (!isQualifiedAttrResolutionSafe(ref) && !isCallCallee(ref)) { + return myElement; + } + } + final PsiElement resolved = resolve(ref); if (resolved != null) { return resolved; } } + // Return wrapped function for functools.wraps decorated function if (myElement instanceof PyFunction function) { PyType type = new PyFunctoolsWrapsDecoratedFunctionTypeProvider().getCallableType(function, myContext); diff --git a/python/testData/quickdoc/AttributeOnUnknownQualifier.py b/python/testData/quickdoc/AttributeOnUnknownQualifier.py new file mode 100644 index 0000000000000..ecf2a69322351 --- /dev/null +++ b/python/testData/quickdoc/AttributeOnUnknownQualifier.py @@ -0,0 +1,13 @@ +# Simulate an unrelated symbol that has the same attribute name with proper docs +class Response: + """ + + :ivar trigger_id: Identifier for the trigger associated with the response. + :type trigger_id: int + """ + trigger_id: int = 1 + + +# In another scope, use an untyped parameter and access the same attribute name +def do(dto=None): + dto.trigger_id diff --git a/python/testData/quickdoc/ModuleAttributeFunction/m.py b/python/testData/quickdoc/ModuleAttributeFunction/m.py new file mode 100644 index 0000000000000..dc6937ca73475 --- /dev/null +++ b/python/testData/quickdoc/ModuleAttributeFunction/m.py @@ -0,0 +1,7 @@ +def foo(): + """Foo from module m.""" + pass + + +BAR: int = 42 +"""BAR from module m.""" diff --git a/python/testData/quickdoc/ModuleAttributeFunction/use_module.py b/python/testData/quickdoc/ModuleAttributeFunction/use_module.py new file mode 100644 index 0000000000000..4900408d09937 --- /dev/null +++ b/python/testData/quickdoc/ModuleAttributeFunction/use_module.py @@ -0,0 +1,3 @@ +import m + +m.foo diff --git a/python/testData/quickdoc/ModuleAttributeVariable/m.py b/python/testData/quickdoc/ModuleAttributeVariable/m.py new file mode 100644 index 0000000000000..c9294f65f6ad6 --- /dev/null +++ b/python/testData/quickdoc/ModuleAttributeVariable/m.py @@ -0,0 +1,9 @@ +"""Module m.""" + + +def foo(): + """Foo from module m.""" + pass + +BAR: int = 42 +"""BAR from module m.""" diff --git a/python/testData/quickdoc/ModuleAttributeVariable/use_module.py b/python/testData/quickdoc/ModuleAttributeVariable/use_module.py new file mode 100644 index 0000000000000..3be6c78c72cbe --- /dev/null +++ b/python/testData/quickdoc/ModuleAttributeVariable/use_module.py @@ -0,0 +1,3 @@ +import m + +m.BAR diff --git a/python/testData/quickdoc/OptionalBoxPropertyAllowed.py b/python/testData/quickdoc/OptionalBoxPropertyAllowed.py new file mode 100644 index 0000000000000..df10647bdefbd --- /dev/null +++ b/python/testData/quickdoc/OptionalBoxPropertyAllowed.py @@ -0,0 +1,9 @@ +class Box: + @property + def value(self) -> int: + """Box.value doc.""" + return 0 + + +x: Box | None +x.value diff --git a/python/testData/quickdoc/UnionCommonDefinitionAllowed.py b/python/testData/quickdoc/UnionCommonDefinitionAllowed.py new file mode 100644 index 0000000000000..7859411567af4 --- /dev/null +++ b/python/testData/quickdoc/UnionCommonDefinitionAllowed.py @@ -0,0 +1,13 @@ +class Common: + def a(self): + """Common.a doc.""" + pass + +class A(Common): + pass + +class B(Common): + pass + +x: A | B +x.a diff --git a/python/testSrc/com/jetbrains/python/Py3QuickDocTest.java b/python/testSrc/com/jetbrains/python/Py3QuickDocTest.java index 9c380a1f21999..e7adca2c090de 100644 --- a/python/testSrc/com/jetbrains/python/Py3QuickDocTest.java +++ b/python/testSrc/com/jetbrains/python/Py3QuickDocTest.java @@ -877,6 +877,92 @@ public void testUnderscoreCollectionsAbcSymbolRealOrigin() { }); } + // PY-84088 + public void testAttributeOnUnknownQualifier() { + Map marks = loadTest(); + final PsiElement originalElement = marks.get(""); + assertNotNull(" marker is missing in test data", originalElement); + final DocumentationManager manager = DocumentationManager.getInstance(myFixture.getProject()); + final PsiElement target = manager.findTargetElement(myFixture.getEditor(), + originalElement.getTextOffset(), + myFixture.getFile(), + originalElement); + final String html = myProvider.generateDoc(target, originalElement); + if (html == null) return; // empty help is acceptable/preferred + assertFalse("Quick Doc must not include unrelated attribute docstring when qualifier type is unknown/Any", + html.contains("Identifier for the trigger associated with the response.")); + } + + // PY-84088 + public void testModuleAttributeFunction() { + // Preload the module file into the temp project so that `import m` resolves + myFixture.copyFileToProject(getTestName(false) + "/m.py", "m.py"); + + final Map marks = configureByFile(getTestName(false) + "/use_module.py"); + final PsiElement originalElement = marks.get(""); + assertNotNull(" marker is missing in test data", originalElement); + final DocumentationManager manager = DocumentationManager.getInstance(myFixture.getProject()); + final PsiElement target = manager.findTargetElement(myFixture.getEditor(), + originalElement.getTextOffset(), + myFixture.getFile(), + originalElement); + final String html = myProvider.generateDoc(target, originalElement); + assertNotNull("Quick Doc should be available for module attribute access", html); + assertTrue("Quick Doc should include module function docstring", + html.contains("Foo from module m.")); + } + + // Module attribute access — variable + public void testModuleAttributeVariable() { + // Preload the module file into the temp project so that `import m` resolves + myFixture.copyFileToProject(getTestName(false) + "/m.py", "m.py"); + + final Map marks = configureByFile(getTestName(false) + "/use_module.py"); + final PsiElement originalElement = marks.get(""); + assertNotNull(" marker is missing in test data", originalElement); + final DocumentationManager manager = DocumentationManager.getInstance(myFixture.getProject()); + final PsiElement target = manager.findTargetElement(myFixture.getEditor(), + originalElement.getTextOffset(), + myFixture.getFile(), + originalElement); + final String html = myProvider.generateDoc(target, originalElement); + assertNotNull("Quick Doc should be available for module attribute access", html); + assertTrue("Quick Doc should include module variable doc/comment", + html.contains("BAR from module m.")); + } + + // PY-84088 + public void testUnionCommonDefinitionAllowed() { + final Map marks = configureByFile(getTestName(false) + ".py"); + final PsiElement originalElement = marks.get(""); + assertNotNull(" marker is missing in test data", originalElement); + final DocumentationManager manager = DocumentationManager.getInstance(myFixture.getProject()); + final PsiElement target = manager.findTargetElement(myFixture.getEditor(), + originalElement.getTextOffset(), + myFixture.getFile(), + originalElement); + final String html = myProvider.generateDoc(target, originalElement); + assertNotNull("Quick Doc should be available for Union[A, B].a where both resolve to Common.a", html); + assertTrue("Quick Doc should include Common.a docstring", + html.contains("Common.a doc.")); + } + + // PY-84088 + public void testOptionalBoxPropertyAllowed() { + final Map marks = configureByFile(getTestName(false) + ".py"); + final PsiElement originalElement = marks.get(""); + assertNotNull(" marker is missing in test data", originalElement); + final DocumentationManager manager = DocumentationManager.getInstance(myFixture.getProject()); + final PsiElement target = manager.findTargetElement(myFixture.getEditor(), + originalElement.getTextOffset(), + myFixture.getFile(), + originalElement); + final String html = myProvider.generateDoc(target, originalElement); + assertNotNull("Quick Doc should be available for Optional[Box].value", html); + assertTrue("Quick Doc should include Box.value docstring", + html.contains("Box.value doc.")); + } + @Override protected String getTestDataPath() { return super.getTestDataPath() + "/quickdoc/";