Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<PyCallable> 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);
Expand Down
13 changes: 13 additions & 0 deletions python/testData/quickdoc/AttributeOnUnknownQualifier.py
Original file line number Diff line number Diff line change
@@ -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.trig<the_ref>ger_id
7 changes: 7 additions & 0 deletions python/testData/quickdoc/ModuleAttributeFunction/m.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
def foo():
"""Foo from module m."""
pass


BAR: int = 42
"""BAR from module m."""
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import m

m.fo<the_ref>o
9 changes: 9 additions & 0 deletions python/testData/quickdoc/ModuleAttributeVariable/m.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Module m."""


def foo():
"""Foo from module m."""
pass

BAR: int = 42
"""BAR from module m."""
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import m

m.BA<the_ref>R
9 changes: 9 additions & 0 deletions python/testData/quickdoc/OptionalBoxPropertyAllowed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class Box:
@property
def value(self) -> int:
"""Box.value doc."""
return 0


x: Box | None
x.va<the_ref>lue
13 changes: 13 additions & 0 deletions python/testData/quickdoc/UnionCommonDefinitionAllowed.py
Original file line number Diff line number Diff line change
@@ -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<the_ref>
86 changes: 86 additions & 0 deletions python/testSrc/com/jetbrains/python/Py3QuickDocTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,92 @@ public void testUnderscoreCollectionsAbcSymbolRealOrigin() {
});
}

// PY-84088
public void testAttributeOnUnknownQualifier() {
Map<String, PsiElement> marks = loadTest();
final PsiElement originalElement = marks.get("<the_ref>");
assertNotNull("<the_ref> 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<String, PsiElement> marks = configureByFile(getTestName(false) + "/use_module.py");
final PsiElement originalElement = marks.get("<the_ref>");
assertNotNull("<the_ref> 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<String, PsiElement> marks = configureByFile(getTestName(false) + "/use_module.py");
final PsiElement originalElement = marks.get("<the_ref>");
assertNotNull("<the_ref> 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<String, PsiElement> marks = configureByFile(getTestName(false) + ".py");
final PsiElement originalElement = marks.get("<the_ref>");
assertNotNull("<the_ref> 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<String, PsiElement> marks = configureByFile(getTestName(false) + ".py");
final PsiElement originalElement = marks.get("<the_ref>");
assertNotNull("<the_ref> 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/";
Expand Down