diff --git a/python/resources/inspectionDescriptions/PyOverloadsInspection.html b/python/resources/inspectionDescriptions/PyOverloadsInspection.html new file mode 100644 index 000000000000..2fe9259e716e --- /dev/null +++ b/python/resources/inspectionDescriptions/PyOverloadsInspection.html @@ -0,0 +1,5 @@ + + +This inspection validates overloads in regular Python files. + + \ No newline at end of file diff --git a/python/src/META-INF/python-core-common.xml b/python/src/META-INF/python-core-common.xml index e532cce0b628..1fcd94f60f08 100644 --- a/python/src/META-INF/python-core-common.xml +++ b/python/src/META-INF/python-core-common.xml @@ -403,6 +403,7 @@ + diff --git a/python/src/com/jetbrains/python/inspections/PyOverloadsInspection.kt b/python/src/com/jetbrains/python/inspections/PyOverloadsInspection.kt new file mode 100644 index 000000000000..04a1c9a969f3 --- /dev/null +++ b/python/src/com/jetbrains/python/inspections/PyOverloadsInspection.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2000-2017 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.jetbrains.python.inspections + +import com.intellij.codeInspection.LocalInspectionToolSession +import com.intellij.codeInspection.ProblemsHolder +import com.intellij.psi.PsiElementVisitor +import com.intellij.util.Processor +import com.intellij.util.containers.SortedList +import com.jetbrains.python.codeInsight.controlflow.ScopeOwner +import com.jetbrains.python.psi.PyClass +import com.jetbrains.python.psi.PyFile +import com.jetbrains.python.psi.PyFunction +import com.jetbrains.python.psi.PyUtil +import com.jetbrains.python.pyi.PyiFile +import com.jetbrains.python.pyi.PyiTypeProvider +import java.util.* + +class PyOverloadsInspection : PyInspection() { + + override fun buildVisitor(holder: ProblemsHolder, + isOnTheFly: Boolean, + session: LocalInspectionToolSession): PsiElementVisitor = Visitor(holder, session) + + private class Visitor(holder: ProblemsHolder, session: LocalInspectionToolSession) : PyInspectionVisitor(holder, session) { + + override fun visitPyClass(node: PyClass?) { + if (node?.containingFile is PyiFile) return + + super.visitPyClass(node) + + if (node != null) { + processScope(node, { node.visitMethods(it, false, myTypeEvalContext) }) + } + } + + override fun visitPyFile(node: PyFile?) { + if (node is PyiFile) return + + super.visitPyFile(node) + + if (node != null) { + processScope(node, { processor -> node.topLevelFunctions.forEach { processor.process(it) } }) + } + } + + private fun processScope(owner: ScopeOwner, processorUsage: (GroupingFunctionsByNameProcessor) -> Unit) { + val processor = GroupingFunctionsByNameProcessor() + processorUsage(processor) + processor.result.values.forEach { processSameNameFunctions(owner, it) } + } + + private fun processSameNameFunctions(owner: ScopeOwner, functions: List) { + if (functions.find { PyiTypeProvider.isOverload(it, myTypeEvalContext) } == null) return + + val implementation = functions.lastOrNull { !PyiTypeProvider.isOverload(it, myTypeEvalContext) } + + if (implementation == null) { + functions + .maxBy { it.textOffset } + ?.let { + registerProblem(it.nameIdentifier, + "A series of @overload-decorated ${chooseBetweenFunctionsAndMethods(owner)} " + + "should always be followed by an implementation that is not @overload-ed") + } + } + else { + if (implementation != functions.last()) { + registerProblem(functions.last().nameIdentifier, + "A series of @overload-decorated ${chooseBetweenFunctionsAndMethods(owner)} " + + "should always be followed by an implementation that is not @overload-ed") + } + + functions + .asSequence() + .filter { isIncompatibleOverload(implementation, it) } + .forEach { + registerProblem(it.nameIdentifier, + "Signature of this @overload-decorated ${chooseBetweenFunctionAndMethod(owner)} " + + "is not compatible with the implementation") + } + } + } + + private fun chooseBetweenFunctionsAndMethods(owner: ScopeOwner) = if (owner is PyClass) "methods" else "functions" + + private fun chooseBetweenFunctionAndMethod(owner: ScopeOwner) = if (owner is PyClass) "method" else "function" + + private fun isIncompatibleOverload(implementation: PyFunction, overload: PyFunction): Boolean { + return implementation != overload && + PyiTypeProvider.isOverload(overload, myTypeEvalContext) && + !PyUtil.isSignatureCompatibleTo(implementation, overload, myTypeEvalContext) + } + } + + private class GroupingFunctionsByNameProcessor : Processor { + + val result: MutableMap> = HashMap() + + override fun process(t: PyFunction?): Boolean { + val name = t?.name + if (name != null) { + result + .getOrPut(name, { SortedList { f1, f2 -> f1.textOffset - f2.textOffset } }) + .add(t!!) + } + + return true + } + } +} \ No newline at end of file diff --git a/python/testData/inspections/PyOverloadsInspection/correct.py b/python/testData/inspections/PyOverloadsInspection/correct.py new file mode 100644 index 000000000000..b0422f80ea34 --- /dev/null +++ b/python/testData/inspections/PyOverloadsInspection/correct.py @@ -0,0 +1,37 @@ +from typing import overload + + +@overload +def foo(value: None) -> None: + pass + + +@overload +def foo(value: int) -> str: + pass + + +@overload +def foo(value: str) -> str: + pass + + +def foo(value): + return None + + +class A: + @overload + def foo(self, value: None) -> None: + pass + + @overload + def foo(self, value: int) -> str: + pass + + @overload + def foo(self, value: str) -> str: + pass + + def foo(self, value): + return None \ No newline at end of file diff --git a/python/testData/inspections/PyOverloadsInspection/incompatible.py b/python/testData/inspections/PyOverloadsInspection/incompatible.py new file mode 100644 index 000000000000..0a20a97a54c4 --- /dev/null +++ b/python/testData/inspections/PyOverloadsInspection/incompatible.py @@ -0,0 +1,28 @@ +from typing import overload + + +@overload +def foo() -> None: + pass + + +@overload +def foo(value: str) -> str: + pass + + +def foo(value): + return None + + +class A: + @overload + def foo(self) -> None: + pass + + @overload + def foo(self, value: str) -> str: + pass + + def foo(self, value): + return None \ No newline at end of file diff --git a/python/testData/inspections/PyOverloadsInspection/noImplementation.py b/python/testData/inspections/PyOverloadsInspection/noImplementation.py new file mode 100644 index 000000000000..c354030927c8 --- /dev/null +++ b/python/testData/inspections/PyOverloadsInspection/noImplementation.py @@ -0,0 +1,30 @@ +from typing import overload + + +@overload +def foo(value: None) -> None: + pass + + +@overload +def foo(value: int) -> str: + pass + + +@overload +def foo(value: str) -> str: + pass + + +class A: + @overload + def foo(self, value: None) -> None: + pass + + @overload + def foo(self, value: int) -> str: + pass + + @overload + def foo(self, value: str) -> str: + pass \ No newline at end of file diff --git a/python/testData/inspections/PyOverloadsInspection/overloadAfterImplementation.py b/python/testData/inspections/PyOverloadsInspection/overloadAfterImplementation.py new file mode 100644 index 000000000000..278cb52b2d2c --- /dev/null +++ b/python/testData/inspections/PyOverloadsInspection/overloadAfterImplementation.py @@ -0,0 +1,37 @@ +from typing import overload + + +@overload +def foo(value: None) -> None: + pass + + +@overload +def foo(value: int) -> str: + pass + + +def foo(value): + return None + + +@overload +def foo(value: str) -> str: + pass + + +class A: + @overload + def foo(self, value: None) -> None: + pass + + @overload + def foo(self, value: int) -> str: + pass + + def foo(self, value): + return None + + @overload + def foo(self, value: str) -> str: + pass \ No newline at end of file diff --git a/python/testSrc/com/jetbrains/python/inspections/PyOverloadsInspectionTest.java b/python/testSrc/com/jetbrains/python/inspections/PyOverloadsInspectionTest.java new file mode 100644 index 000000000000..409ecfbdc4ad --- /dev/null +++ b/python/testSrc/com/jetbrains/python/inspections/PyOverloadsInspectionTest.java @@ -0,0 +1,54 @@ +/* + * Copyright 2000-2017 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.jetbrains.python.inspections; + +import com.jetbrains.python.fixtures.PyTestCase; +import com.jetbrains.python.psi.LanguageLevel; + +public class PyOverloadsInspectionTest extends PyTestCase { + + // PY-22971 + public void testCorrect() { + doTest(); + } + + // PY-22971 + public void testNoImplementation() { + doTest(); + } + + // PY-22971 + public void testOverloadAfterImplementation() { + doTest(); + } + + // PY-22971 + public void testIncompatible() { + doTest(); + } + + private void doTest() { + runWithLanguageLevel( + LanguageLevel.PYTHON35, + () -> { + final String path = "inspections/PyOverloadsInspection/" + getTestName(true) + ".py"; + myFixture.configureByFile(path); + myFixture.enableInspections(PyOverloadsInspection.class); + myFixture.checkHighlighting(true, false, true); + } + ); + } +}