diff --git a/python/psi-api/src/com/jetbrains/python/psi/stubs/PyDataclassFieldStub.java b/python/psi-api/src/com/jetbrains/python/psi/stubs/PyDataclassFieldStub.java new file mode 100644 index 000000000000..3a35090c99a3 --- /dev/null +++ b/python/psi-api/src/com/jetbrains/python/psi/stubs/PyDataclassFieldStub.java @@ -0,0 +1,24 @@ +/* + * Copyright 2000-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ +package com.jetbrains.python.psi.stubs; + +import com.jetbrains.python.psi.impl.stubs.CustomTargetExpressionStub; + +public interface PyDataclassFieldStub extends CustomTargetExpressionStub { + + /** + * @return true if `default` parameter is specified, false otherwise. + */ + boolean hasDefault(); + + /** + * @return true if `default_factory` parameter is specified, false otherwise. + */ + boolean hasDefaultFactory(); + + /** + * @return value of `init` parameter. + */ + boolean initValue(); +} diff --git a/python/src/META-INF/python-core-common.xml b/python/src/META-INF/python-core-common.xml index 10f71887904c..2da8c42c98f8 100644 --- a/python/src/META-INF/python-core-common.xml +++ b/python/src/META-INF/python-core-common.xml @@ -700,6 +700,7 @@ + diff --git a/python/src/com/jetbrains/python/codeInsight/stdlib/PyDataclassesTypeProvider.kt b/python/src/com/jetbrains/python/codeInsight/stdlib/PyDataclassesTypeProvider.kt index 5e19c67e8935..a599e79347e1 100644 --- a/python/src/com/jetbrains/python/codeInsight/stdlib/PyDataclassesTypeProvider.kt +++ b/python/src/com/jetbrains/python/codeInsight/stdlib/PyDataclassesTypeProvider.kt @@ -7,7 +7,9 @@ import com.intellij.openapi.util.Ref import com.jetbrains.python.codeInsight.typing.PyTypingTypeProvider import com.jetbrains.python.psi.* import com.jetbrains.python.psi.impl.PyCallExpressionNavigator +import com.jetbrains.python.psi.impl.stubs.PyDataclassFieldStubImpl import com.jetbrains.python.psi.resolve.PyResolveContext +import com.jetbrains.python.psi.stubs.PyDataclassFieldStub import com.jetbrains.python.psi.types.* class PyDataclassesTypeProvider : PyTypeProviderBase() { @@ -64,13 +66,7 @@ class PyDataclassesTypeProvider : PyTypeProviderBase() { cls.processClassLevelDeclarations { element, _ -> if (element is PyTargetExpression && !PyTypingTypeProvider.isClassVar(element, context)) { - val value = when { - context.maySwitchToAST(element) -> element.findAssignedValue() - element.hasAssignedValue() -> ellipsis - else -> null - } - - parameters.add(PyCallableParameterImpl.nonPsi(element.name, getTypeForParameter(element, context), value)) + fieldToParameter(element, ellipsis, context)?.also { parameters.add(it) } } true @@ -79,6 +75,30 @@ class PyDataclassesTypeProvider : PyTypeProviderBase() { return PyCallableTypeImpl(parameters, context.getType(cls)) } + private fun fieldToParameter(field: PyTargetExpression, + ellipsis: PyNoneLiteralExpression, + context: TypeEvalContext): PyCallableParameter? { + val stub = field.stub + val fieldStub = if (stub == null) PyDataclassFieldStubImpl.create(field) else stub.getCustomStub(PyDataclassFieldStub::class.java) + + return if (fieldStub == null) { + val value = when { + context.maySwitchToAST(field) -> field.findAssignedValue() + field.hasAssignedValue() -> ellipsis + else -> null + } + + PyCallableParameterImpl.nonPsi(field.name, getTypeForParameter(field, context), value) + } + else if (!fieldStub.initValue()) { + null + } + else { + val value = if (fieldStub.hasDefault() || fieldStub.hasDefaultFactory()) ellipsis else null + PyCallableParameterImpl.nonPsi(field.name, getTypeForParameter(field, context), value) + } + } + private fun getTypeForParameter(element: PyTargetExpression, context: TypeEvalContext): PyType? { val type = context.getType(element) if (type is PyCollectionType && type is PyClassType && type.classQName == DATACLASSES_INITVAR_TYPE) { diff --git a/python/src/com/jetbrains/python/psi/PyFileElementType.java b/python/src/com/jetbrains/python/psi/PyFileElementType.java index d613faa3d1df..138610e566b0 100644 --- a/python/src/com/jetbrains/python/psi/PyFileElementType.java +++ b/python/src/com/jetbrains/python/psi/PyFileElementType.java @@ -62,7 +62,7 @@ public class PyFileElementType extends IStubFileElementType { @Override public int getStubVersion() { // Don't forget to update versions of indexes that use the updated stub-based elements - return 67; + return 68; } @Nullable diff --git a/python/src/com/jetbrains/python/psi/impl/stubs/PyDataclassFieldStubImpl.kt b/python/src/com/jetbrains/python/psi/impl/stubs/PyDataclassFieldStubImpl.kt new file mode 100644 index 000000000000..dd4ec9d7d681 --- /dev/null +++ b/python/src/com/jetbrains/python/psi/impl/stubs/PyDataclassFieldStubImpl.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2000-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ +package com.jetbrains.python.psi.impl.stubs + +import com.intellij.psi.stubs.StubInputStream +import com.intellij.psi.stubs.StubOutputStream +import com.intellij.psi.util.QualifiedName +import com.jetbrains.python.psi.* +import com.jetbrains.python.psi.impl.PyEvaluator +import com.jetbrains.python.psi.resolve.PyResolveUtil +import com.jetbrains.python.psi.stubs.PyDataclassFieldStub +import java.io.IOException + +class PyDataclassFieldStubImpl private constructor(private val calleeName: QualifiedName, + private val hasDefault: Boolean, + private val hasDefaultFactory: Boolean, + private val initValue: Boolean) : PyDataclassFieldStub { + companion object { + fun create(expression: PyTargetExpression): PyDataclassFieldStub? { + val value = expression.findAssignedValue() as? PyCallExpression ?: return null + val callee = value.callee as? PyReferenceExpression ?: return null + + val calleeName = calculateFullyQCalleeName(callee) ?: calculateImportedCalleeName(callee) ?: return null + val arguments = analyzeArguments(value) + + return PyDataclassFieldStubImpl(calleeName, arguments.first, arguments.second, arguments.third) + } + + @Throws(IOException::class) + fun deserialize(stream: StubInputStream): PyDataclassFieldStub? { + val calleeName = stream.readName() ?: return null + val hasDefault = stream.readBoolean() + val hasDefaultFactory = stream.readBoolean() + val initValue = stream.readBoolean() + + return PyDataclassFieldStubImpl(QualifiedName.fromDottedString(calleeName.string), hasDefault, hasDefaultFactory, initValue) + } + + private fun calculateFullyQCalleeName(callee: PyReferenceExpression): QualifiedName? { + // SUPPORTED CASES: + + // import dataclasses + // ... = dataclasses.field(...) + + // import dataclasses as dc + // ... = dc.field(...) + + val calleeName = callee.name + val qualifier = callee.qualifier + + if (calleeName == "field" && qualifier is PyReferenceExpression && !qualifier.isQualified && resolvesToDataclassesModule(qualifier)) { + return QualifiedName.fromComponents(qualifier.name, calleeName) + } + + return null + } + + private fun calculateImportedCalleeName(callee: PyReferenceExpression): QualifiedName? { + // SUPPORTED CASES: + + // from dataclasses import field + // ... = field(...) + + // from dataclasses import field as F + // ... = F(...) + + for (element in PyResolveUtil.resolveLocally(callee)) { + if (element is PyImportElement && element.importedQName.toString() == "field") { + val importStatement = element.containingImportStatement + if (importStatement is PyFromImportStatement && importStatement.importSourceQName.toString() == "dataclasses") { + return QualifiedName.fromComponents(callee.name) + } + } + } + + return null + } + + private fun analyzeArguments(call: PyCallExpression): Triple { + val hasDefault = call.getKeywordArgument("default") != null + val hasDefaultFactory = call.getKeywordArgument("default_factory") != null + val initValue = PyEvaluator().evaluate(call.getKeywordArgument("init")) as? Boolean ?: true + + return Triple(hasDefault, hasDefaultFactory, initValue) + } + + private fun resolvesToDataclassesModule(referenceExpression: PyReferenceExpression): Boolean { + return PyResolveUtil.resolveLocally(referenceExpression).any { it is PyImportElement && it.importedQName.toString() == "dataclasses" } + } + } + + override fun getTypeClass(): Class> { + return PyDataclassFieldStubType::class.java + } + + override fun serialize(stream: StubOutputStream) { + stream.writeName(calleeName.toString()) + stream.writeBoolean(hasDefault) + stream.writeBoolean(hasDefaultFactory) + stream.writeBoolean(initValue) + } + + override fun getCalleeName() = calleeName + override fun hasDefault() = hasDefault + override fun hasDefaultFactory() = hasDefaultFactory + override fun initValue() = initValue +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/psi/impl/stubs/PyDataclassFieldStubType.kt b/python/src/com/jetbrains/python/psi/impl/stubs/PyDataclassFieldStubType.kt new file mode 100644 index 000000000000..853366631806 --- /dev/null +++ b/python/src/com/jetbrains/python/psi/impl/stubs/PyDataclassFieldStubType.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2000-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ +package com.jetbrains.python.psi.impl.stubs + +import com.intellij.psi.stubs.StubInputStream +import com.jetbrains.python.psi.PyTargetExpression +import com.jetbrains.python.psi.stubs.PyDataclassFieldStub +import java.io.IOException + +class PyDataclassFieldStubType : CustomTargetExpressionStubType() { + + override fun createStub(psi: PyTargetExpression): PyDataclassFieldStub? { + return PyDataclassFieldStubImpl.create(psi) + } + + @Throws(IOException::class) + override fun deserializeStub(stream: StubInputStream): PyDataclassFieldStub? { + return PyDataclassFieldStubImpl.deserialize(stream) + } +} \ No newline at end of file diff --git a/python/testData/inspections/PyArgumentListInspection/InitializingDataclass/a.py b/python/testData/inspections/PyArgumentListInspection/InitializingDataclass/a.py index 56e4c02cf63d..4fac3854212c 100644 --- a/python/testData/inspections/PyArgumentListInspection/InitializingDataclass/a.py +++ b/python/testData/inspections/PyArgumentListInspection/InitializingDataclass/a.py @@ -85,3 +85,18 @@ D1() D1(1, 2) D1(1, 2, 3) + + +@dataclasses.dataclass +class E1: + a: int = dataclasses.field() + b: int = dataclasses.field(init=True) + c: int = dataclasses.field(init=False) + d: int = dataclasses.field(default=1) + e: int = dataclasses.field(default_factory=int) + +E1(1) +E1(1, 2) +E1(1, 2, 3) +E1(1, 2, 3, 4) +E1(1, 2, 3, 4, 5) diff --git a/python/testData/inspections/PyArgumentListInspection/InitializingDataclass/dataclasses.py b/python/testData/inspections/PyArgumentListInspection/InitializingDataclass/dataclasses.py index 3a2b30d36707..44d4ca30e16a 100644 --- a/python/testData/inspections/PyArgumentListInspection/InitializingDataclass/dataclasses.py +++ b/python/testData/inspections/PyArgumentListInspection/InitializingDataclass/dataclasses.py @@ -1,3 +1,16 @@ +class _InitVarMeta(type): + def __getitem__(self, params): + return self + +class InitVar(metaclass=_InitVarMeta): + pass + + def dataclass(_cls=None, *, init=True, repr=True, eq=True, order=False, hash=None, frozen=False): + pass + + +def field(*, default=_MISSING, default_factory=_MISSING, init=True, repr=True, + hash=None, compare=True, metadata=None): pass \ No newline at end of file diff --git a/python/testData/inspections/PyTypeCheckerInspection/InitializingDataclass/a.py b/python/testData/inspections/PyTypeCheckerInspection/InitializingDataclass/a.py index a54506c4f65d..0522749a5d5c 100644 --- a/python/testData/inspections/PyTypeCheckerInspection/InitializingDataclass/a.py +++ b/python/testData/inspections/PyTypeCheckerInspection/InitializingDataclass/a.py @@ -82,4 +82,22 @@ class D1: b: int D1(1, 2) -D1("1", "2") \ No newline at end of file +D1("1", "2") + + +@dataclasses.dataclass +class E1: + a: int = dataclasses.field() + b: str = dataclasses.field(init=True) + c: int = dataclasses.field(init=False) + d: bytes = dataclasses.field(default=b"b") + e: int = dataclasses.field(default_factory=int) + +E1(1, "1") +E1("1", 1) + +E1(1, "1", b"1") +E1(b"1", "1", 1) + +E1(1, "1", b"1", 1) +E1("1", b"1", "1", "1") \ No newline at end of file diff --git a/python/testData/inspections/PyTypeCheckerInspection/InitializingDataclass/dataclasses.py b/python/testData/inspections/PyTypeCheckerInspection/InitializingDataclass/dataclasses.py index e2c6ed38f1c6..44d4ca30e16a 100644 --- a/python/testData/inspections/PyTypeCheckerInspection/InitializingDataclass/dataclasses.py +++ b/python/testData/inspections/PyTypeCheckerInspection/InitializingDataclass/dataclasses.py @@ -8,4 +8,9 @@ class InitVar(metaclass=_InitVarMeta): def dataclass(_cls=None, *, init=True, repr=True, eq=True, order=False, hash=None, frozen=False): + pass + + +def field(*, default=_MISSING, default_factory=_MISSING, init=True, repr=True, + hash=None, compare=True, metadata=None): pass \ No newline at end of file diff --git a/python/testData/paramInfo/InitializingDataclass/a.py b/python/testData/paramInfo/InitializingDataclass/a.py index fad9eaf9905e..7f3b78028af7 100644 --- a/python/testData/paramInfo/InitializingDataclass/a.py +++ b/python/testData/paramInfo/InitializingDataclass/a.py @@ -63,4 +63,15 @@ class D1: a: dataclasses.InitVar[int] b: int -D1() \ No newline at end of file +D1() + + +@dataclasses.dataclass +class E1: + a: int = dataclasses.field() + b: int = dataclasses.field(init=True) + c: int = dataclasses.field(init=False) + d: int = dataclasses.field(default=1) + e: int = dataclasses.field(default_factory=int) + +E1() diff --git a/python/testData/paramInfo/InitializingDataclass/dataclasses.py b/python/testData/paramInfo/InitializingDataclass/dataclasses.py index e2c6ed38f1c6..44d4ca30e16a 100644 --- a/python/testData/paramInfo/InitializingDataclass/dataclasses.py +++ b/python/testData/paramInfo/InitializingDataclass/dataclasses.py @@ -8,4 +8,9 @@ class InitVar(metaclass=_InitVarMeta): def dataclass(_cls=None, *, init=True, repr=True, eq=True, order=False, hash=None, frozen=False): + pass + + +def field(*, default=_MISSING, default_factory=_MISSING, init=True, repr=True, + hash=None, compare=True, metadata=None): pass \ No newline at end of file diff --git a/python/testData/stubs/dataclassField/a.py b/python/testData/stubs/dataclassField/a.py new file mode 100644 index 000000000000..824179692093 --- /dev/null +++ b/python/testData/stubs/dataclassField/a.py @@ -0,0 +1,22 @@ +import dataclasses +import dataclasses as dc +from dataclasses import field +from dataclasses import field as F +from b import INIT_3 + + +INIT_0 = False +INIT_1 = False +INIT_2 = INIT_1 + + +@dataclasses.dataclass +class A: + a: int = dataclasses.field(default=1) + b: int = dc.field(default_factory=int) + c: int = field(init=False) + d: int = F(init=True) + e: int = field(init=INIT_0) + f: int = field(init=INIT_2) + g: int = field(init=INIT_3) + h: int = field() \ No newline at end of file diff --git a/python/testData/stubs/dataclassField/b.py b/python/testData/stubs/dataclassField/b.py new file mode 100644 index 000000000000..3d1ebf5356f3 --- /dev/null +++ b/python/testData/stubs/dataclassField/b.py @@ -0,0 +1 @@ +INIT_3 = False \ No newline at end of file diff --git a/python/testData/stubs/dataclassField/dataclasses.py b/python/testData/stubs/dataclassField/dataclasses.py new file mode 100644 index 000000000000..99be86c910a4 --- /dev/null +++ b/python/testData/stubs/dataclassField/dataclasses.py @@ -0,0 +1,8 @@ +def dataclass(_cls=None, *, init=True, repr=True, eq=True, order=False, + hash=None, frozen=False): + pass + + +def field(*, default=_MISSING, default_factory=_MISSING, init=True, repr=True, + hash=None, compare=True, metadata=None): + pass \ No newline at end of file diff --git a/python/testSrc/com/jetbrains/python/PyParameterInfoTest.java b/python/testSrc/com/jetbrains/python/PyParameterInfoTest.java index 9e18175521fb..7ae194d17d0f 100644 --- a/python/testSrc/com/jetbrains/python/PyParameterInfoTest.java +++ b/python/testSrc/com/jetbrains/python/PyParameterInfoTest.java @@ -731,7 +731,7 @@ public class PyParameterInfoTest extends LightMarkedTestCase { runWithLanguageLevel( LanguageLevel.PYTHON37, () -> { - final Map marks = loadMultiFileTest(7); + final Map marks = loadMultiFileTest(8); feignCtrlP(marks.get("").getTextOffset()).check("x: int, y: str, z: float=0.0", new String[]{"x: int, "}); feignCtrlP(marks.get("").getTextOffset()).check("x: int, y: str, z: float=0.0", new String[]{"x: int, "}); @@ -746,6 +746,8 @@ public class PyParameterInfoTest extends LightMarkedTestCase { feignCtrlP(marks.get("").getTextOffset()).check("b: int", new String[]{"b: int"}); feignCtrlP(marks.get("").getTextOffset()).check("b: int", new String[]{"b: int"}); feignCtrlP(marks.get("").getTextOffset()).check("a: int, b: int", new String[]{"a: int, "}); + + feignCtrlP(marks.get("").getTextOffset()).check("a: int, b: int, d: int=..., e: int=...", new String[]{"a: int, "}); } ); } diff --git a/python/testSrc/com/jetbrains/python/PyStubsTest.java b/python/testSrc/com/jetbrains/python/PyStubsTest.java index adfc004e6f1c..faec86832a0d 100644 --- a/python/testSrc/com/jetbrains/python/PyStubsTest.java +++ b/python/testSrc/com/jetbrains/python/PyStubsTest.java @@ -409,10 +409,10 @@ public class PyStubsTest extends PyTestCase { final PyClass c = file.findTopLevelClass("C"); assertNotNull(c); final TypeEvalContext context = TypeEvalContext.codeInsightFallback(myFixture.getProject()); - assertNotNull(c.getMetaClassType(context)); + assertNotNull(c.getMetaClassType(false, context)); final PyClass d = file.findTopLevelClass("D"); assertNotNull(d); - assertNotNull(d.getMetaClassType(context)); + assertNotNull(d.getMetaClassType(false, context)); assertNotParsed(file); } @@ -924,4 +924,52 @@ public class PyStubsTest extends PyTestCase { assertNotNull(pyClass.findClassAttribute("foo", false, context)); }); } + + // PY-27398 + public void testDataclassField() { + class FieldChecker { + + @NotNull + private final PyClass myClass; + + private FieldChecker(@NotNull PyClass cls) { + myClass = cls; + } + + private void check(@NotNull String name, boolean hasDefault, boolean hasDefaultFactory, boolean initValue) { + final TypeEvalContext context = TypeEvalContext.codeInsightFallback(myFixture.getProject()); + final PyTargetExpression field = myClass.findClassAttribute(name, false, context); + + final PyDataclassFieldStub fieldStub = field.getStub().getCustomStub(PyDataclassFieldStub.class); + assertNotNull(fieldStub); + + assertEquals(hasDefault, fieldStub.hasDefault()); + assertEquals(hasDefaultFactory, fieldStub.hasDefaultFactory()); + assertEquals(initValue, fieldStub.initValue()); + } + } + + runWithLanguageLevel( + LanguageLevel.PYTHON37, + () -> { + final PyFile file1 = getTestFile("dataclassField/a.py"); + final PyFile file2 = getTestFile("dataclassField/dataclasses.py"); + final PyFile file3 = getTestFile("dataclassField/b.py"); + + final FieldChecker checker = new FieldChecker(file1.findTopLevelClass("A")); + checker.check("a", true, false, true); + checker.check("b", false, true, true); + checker.check("c", false, false, false); + checker.check("d", false, false, true); + checker.check("e", false, false, false); + checker.check("f", false, false, false); + checker.check("g", false, false, true); // fallback `init` value + checker.check("h", false, false, true); + + assertNotParsed(file1); + assertNotParsed(file2); + assertNotParsed(file3); + } + ); + } }