From 932c6f3f7a8fe70c8e0102e56c79eb39c496d488 Mon Sep 17 00:00:00 2001 From: "Ilya.Kazakevich" Date: Wed, 9 May 2018 01:45:23 +0300 Subject: [PATCH] PY-18952: py.test parametrize and fixture ** Parametrize * Skip "unused" inspection for args, provided by decorator * Check all args are declared (inspection) * TODO: Support rerun only one parametrized test ** Fixtures * References from arguments to fixtures * Type interference * Disable "unused" inspection for fixture args --- python/intellij.python.community.impl.iml | 1 + .../inspections/PyInspectionExtension.java | 4 ++ .../PyParametrizedArgumentInspection.html | 5 ++ python/src/META-INF/python-core-common.xml | 9 ++- .../com/jetbrains/python/PyBundle.properties | 4 ++ .../PyShadowingNamesInspection.java | 6 ++ .../python/psi/impl/PyDecoratorImpl.java | 4 +- .../python/psi/impl/PyNamedParameterImpl.java | 12 ++++ .../testing/pyTestFixtures/PyTestFixture.kt | 46 ++++++++++++++++ .../PyTestFixtureInspectionExtension.kt | 19 +++++++ .../PyTestFixtureReferenceContributor.kt | 55 +++++++++++++++++++ .../python/testing/pyTestFixtures/README.md | 16 ++++++ .../PyParametrizedArgumentInspection.kt | 39 +++++++++++++ .../PyParametrizedInspectionExtension.kt | 12 ++++ .../pyTestParametrized/PyParamterized.kt | 37 +++++++++++++ .../testing/pyTestParametrized/README.md | 5 ++ 16 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 python/resources/inspectionDescriptions/PyParametrizedArgumentInspection.html create mode 100644 python/src/com/jetbrains/python/testing/pyTestFixtures/PyTestFixture.kt create mode 100644 python/src/com/jetbrains/python/testing/pyTestFixtures/PyTestFixtureInspectionExtension.kt create mode 100644 python/src/com/jetbrains/python/testing/pyTestFixtures/PyTestFixtureReferenceContributor.kt create mode 100644 python/src/com/jetbrains/python/testing/pyTestFixtures/README.md create mode 100644 python/src/com/jetbrains/python/testing/pyTestParametrized/PyParametrizedArgumentInspection.kt create mode 100644 python/src/com/jetbrains/python/testing/pyTestParametrized/PyParametrizedInspectionExtension.kt create mode 100644 python/src/com/jetbrains/python/testing/pyTestParametrized/PyParamterized.kt create mode 100644 python/src/com/jetbrains/python/testing/pyTestParametrized/README.md diff --git a/python/intellij.python.community.impl.iml b/python/intellij.python.community.impl.iml index e6d5841356f1..1bdfaca8b321 100644 --- a/python/intellij.python.community.impl.iml +++ b/python/intellij.python.community.impl.iml @@ -37,5 +37,6 @@ + \ No newline at end of file diff --git a/python/psi-api/src/com/jetbrains/python/inspections/PyInspectionExtension.java b/python/psi-api/src/com/jetbrains/python/inspections/PyInspectionExtension.java index 8bfc29072853..ac14d7ea6a9f 100644 --- a/python/psi-api/src/com/jetbrains/python/inspections/PyInspectionExtension.java +++ b/python/psi-api/src/com/jetbrains/python/inspections/PyInspectionExtension.java @@ -21,6 +21,10 @@ public abstract class PyInspectionExtension { return false; } + public boolean ignoreShadowed(@NotNull final PsiElement element) { + return false; + } + public boolean ignoreMissingDocstring(PyDocStringOwner docStringOwner) { return false; } diff --git a/python/resources/inspectionDescriptions/PyParametrizedArgumentInspection.html b/python/resources/inspectionDescriptions/PyParametrizedArgumentInspection.html new file mode 100644 index 000000000000..3ae2a0d86dfa --- /dev/null +++ b/python/resources/inspectionDescriptions/PyParametrizedArgumentInspection.html @@ -0,0 +1,5 @@ + + +Test function, decorated with @pytest.mark.parametrize, must have arguments to accept parameters from decorator + + diff --git a/python/src/META-INF/python-core-common.xml b/python/src/META-INF/python-core-common.xml index 3cd2b0367573..c2f3176fc4b7 100644 --- a/python/src/META-INF/python-core-common.xml +++ b/python/src/META-INF/python-core-common.xml @@ -349,7 +349,10 @@ - + @@ -533,6 +536,7 @@ + @@ -716,6 +720,7 @@ + @@ -735,6 +740,8 @@ + + diff --git a/python/src/com/jetbrains/python/PyBundle.properties b/python/src/com/jetbrains/python/PyBundle.properties index 8e3315020156..c625ab37e5bc 100644 --- a/python/src/com/jetbrains/python/PyBundle.properties +++ b/python/src/com/jetbrains/python/PyBundle.properties @@ -603,6 +603,10 @@ INSP.NAME.global.$0.undefined=Global variable ''{0}'' is undefined at the module INSP.NAME.assignment.to.loop.or.with.parameter.display.name=Assignment to 'for' loop or 'with' statement parameter INSP.NAME.assignment.to.loop.or.with.parameter.display.message=Variable ''{0}'' already declared in ''for'' loop or ''with'' statement above + +# PyParametrizedArgumentInspection +INSP.NAME.pytest-parametrized=Checks that functions decorated by pytest parametrize have correct arguments + # Refactoring refactoring.will.not.be.accessible=Member, you are trying to move depends on ''{0}'' which will not be accessible after this refactoring refactoring.error.file.exists=File "{0}" already exists diff --git a/python/src/com/jetbrains/python/inspections/PyShadowingNamesInspection.java b/python/src/com/jetbrains/python/inspections/PyShadowingNamesInspection.java index 80d925065f37..b337b3f7b74d 100644 --- a/python/src/com/jetbrains/python/inspections/PyShadowingNamesInspection.java +++ b/python/src/com/jetbrains/python/inspections/PyShadowingNamesInspection.java @@ -34,6 +34,8 @@ import com.jetbrains.python.psi.resolve.PyResolveUtil; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.Arrays; + /** * Warns about shadowing names defined in outer scopes. * @@ -111,6 +113,10 @@ public class PyShadowingNamesInspection extends PyInspection { if (scope.isGlobal(name) || scope.isNonlocal(name)) { return; } + if (Arrays.stream(PyInspectionExtension.EP_NAME.getExtensions()) + .anyMatch(o -> o.ignoreShadowed(resolved))) { + return; + } registerProblem(problemElement, String.format("Shadows name '%s' from outer scope", name), ProblemHighlightType.WEAK_WARNING, null, new PyRenameElementQuickFix()); return; diff --git a/python/src/com/jetbrains/python/psi/impl/PyDecoratorImpl.java b/python/src/com/jetbrains/python/psi/impl/PyDecoratorImpl.java index 63b70eaacc3c..0333c4347e4f 100644 --- a/python/src/com/jetbrains/python/psi/impl/PyDecoratorImpl.java +++ b/python/src/com/jetbrains/python/psi/impl/PyDecoratorImpl.java @@ -49,8 +49,8 @@ public class PyDecoratorImpl extends StubBasedPsiElementBase im @Override @Nullable - public PyFunction getTarget() { - return PsiTreeUtil.getParentOfType(this, PyFunction.class); + public final PyFunction getTarget() { + return PsiTreeUtil.getStubOrPsiParentOfType(this, PyFunction.class); } @Override diff --git a/python/src/com/jetbrains/python/psi/impl/PyNamedParameterImpl.java b/python/src/com/jetbrains/python/psi/impl/PyNamedParameterImpl.java index 835f1ee9569e..45c8a3737e12 100644 --- a/python/src/com/jetbrains/python/psi/impl/PyNamedParameterImpl.java +++ b/python/src/com/jetbrains/python/psi/impl/PyNamedParameterImpl.java @@ -20,9 +20,13 @@ import com.intellij.navigation.ItemPresentation; import com.intellij.openapi.extensions.Extensions; import com.intellij.openapi.util.Ref; import com.intellij.psi.*; +import com.intellij.psi.impl.source.resolve.reference.ReferenceProvidersRegistry; import com.intellij.psi.search.LocalSearchScope; import com.intellij.psi.search.SearchScope; import com.intellij.psi.stubs.IStubElementType; +import com.intellij.psi.util.CachedValueProvider; +import com.intellij.psi.util.CachedValuesManager; +import com.intellij.psi.util.PsiModificationTracker; import com.intellij.psi.util.PsiTreeUtil; import com.intellij.util.IncorrectOperationException; import com.intellij.util.PlatformIcons; @@ -62,6 +66,14 @@ public class PyNamedParameterImpl extends PyBaseElementImpl CachedValueProvider.Result.create( + ReferenceProvidersRegistry.getReferencesFromProviders(this, PsiReferenceService.Hints.NO_HINTS), + PsiModificationTracker.MODIFICATION_COUNT)); + } + @Nullable @Override public String getName() { diff --git a/python/src/com/jetbrains/python/testing/pyTestFixtures/PyTestFixture.kt b/python/src/com/jetbrains/python/testing/pyTestFixtures/PyTestFixture.kt new file mode 100644 index 000000000000..0f12aa579301 --- /dev/null +++ b/python/src/com/jetbrains/python/testing/pyTestFixtures/PyTestFixture.kt @@ -0,0 +1,46 @@ +// 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.testing.pyTestFixtures + +import com.intellij.openapi.module.Module +import com.intellij.openapi.module.ModuleUtilCore +import com.intellij.psi.stubs.StubIndex +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.util.ThreeState +import com.jetbrains.python.psi.PyDecorator +import com.jetbrains.python.psi.PyFunction +import com.jetbrains.python.psi.PyNamedParameter +import com.jetbrains.python.psi.stubs.PyDecoratorStubIndex +import com.jetbrains.python.psi.types.TypeEvalContext +import com.jetbrains.python.testing.isTestElement + +const val decoratorName = "pytest.fixture" + +/** + * If named parameter has fixture -- return it + */ +internal fun getFixture(element: PyNamedParameter, typeEvalContext: TypeEvalContext): PyFunction? { + val module = ModuleUtilCore.findModuleForPsiElement(element) ?: return null + val func = PsiTreeUtil.getParentOfType(element, PyFunction::class.java) ?: return null + if (!isTestElement(func, ThreeState.NO, typeEvalContext)) { + return null + } + return getFixtures(module).firstOrNull { o -> o.name == element.name } +} + +/** + * @return Boolean If named parameter has fixture or not + */ +internal fun hasFixture(element: PyNamedParameter, typeEvalContext: TypeEvalContext) = getFixture(element, typeEvalContext) != null + +/** + * @return Boolean is function decorated as fixture + */ +internal fun PyFunction.isFixture() = decoratorList?.findDecorator(decoratorName) != null +/** + * @return List all py.test fixtures in project + */ +internal fun getFixtures(module: Module) = + StubIndex.getElements(PyDecoratorStubIndex.KEY, decoratorName, module.project, + module.moduleContentScope, + PyDecorator::class.java).mapNotNull(PyDecorator::getTarget) + diff --git a/python/src/com/jetbrains/python/testing/pyTestFixtures/PyTestFixtureInspectionExtension.kt b/python/src/com/jetbrains/python/testing/pyTestFixtures/PyTestFixtureInspectionExtension.kt new file mode 100644 index 000000000000..0824a744d2a7 --- /dev/null +++ b/python/src/com/jetbrains/python/testing/pyTestFixtures/PyTestFixtureInspectionExtension.kt @@ -0,0 +1,19 @@ +// 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.testing.pyTestFixtures + +import com.intellij.psi.PsiElement +import com.jetbrains.python.inspections.PyInspectionExtension +import com.jetbrains.python.psi.PyFunction +import com.jetbrains.python.psi.PyNamedParameter +import com.jetbrains.python.psi.types.TypeEvalContext + +/** + * fixture-based parameters should be skipped by inspection + */ +object PyTestFixtureInspectionExtension : PyInspectionExtension() { + override fun ignoreUnused(local: PsiElement) = local is PyNamedParameter + && hasFixture(local, TypeEvalContext.codeAnalysis(local.project, + local.containingFile)) + + override fun ignoreShadowed(element: PsiElement) = element is PyFunction && element.isFixture() +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/testing/pyTestFixtures/PyTestFixtureReferenceContributor.kt b/python/src/com/jetbrains/python/testing/pyTestFixtures/PyTestFixtureReferenceContributor.kt new file mode 100644 index 000000000000..e7abd3ec9697 --- /dev/null +++ b/python/src/com/jetbrains/python/testing/pyTestFixtures/PyTestFixtureReferenceContributor.kt @@ -0,0 +1,55 @@ +// 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.testing.pyTestFixtures + +import com.intellij.openapi.util.Ref +import com.intellij.patterns.PlatformPatterns +import com.intellij.psi.* +import com.intellij.util.ProcessingContext +import com.jetbrains.python.BaseReference +import com.jetbrains.python.psi.PyElementGenerator +import com.jetbrains.python.psi.PyFunction +import com.jetbrains.python.psi.PyNamedParameter +import com.jetbrains.python.psi.PyParameter +import com.jetbrains.python.psi.types.PyType +import com.jetbrains.python.psi.types.PyTypeProviderBase +import com.jetbrains.python.psi.types.TypeEvalContext + +private class PyTextFixtureReference(namedParameter: PyNamedParameter, fixture: PyFunction) : BaseReference(namedParameter) { + private val fixtureRef = SmartPointerManager.createPointer(fixture) + + override fun resolve() = fixtureRef.element + + override fun getVariants() = emptyArray() + + override fun isSoft() = true + + override fun handleElementRename(newElementName: String) = myElement.replace( + PyElementGenerator.getInstance(myElement.project).createParameter(newElementName))!! +} + + +object PyTextFixtureTypeProvider : PyTypeProviderBase() { + override fun getReferenceType(referenceTarget: PsiElement, context: TypeEvalContext, anchor: PsiElement?): Ref? { + val param = referenceTarget as? PyNamedParameter ?: return null + val fixtureFunc = param.references.filterIsInstance(PyTextFixtureReference::class.java).firstOrNull()?.resolve() ?: return null + return context.getReturnType(fixtureFunc)?.let { Ref(it) } + + } +} + +private object PyTestReferenceProvider : PsiReferenceProvider() { + override fun getReferencesByElement(element: PsiElement, context: ProcessingContext): Array { + val namedParam = element as? PyNamedParameter ?: return emptyArray() + val fixture = getFixture(namedParam, TypeEvalContext.codeAnalysis(element.project, element.containingFile)) ?: return emptyArray() + return arrayOf(PyTextFixtureReference(namedParam, fixture)) + } +} + +object PyTestFixtureReferenceContributor : PsiReferenceContributor() { + + override fun registerReferenceProviders(registrar: PsiReferenceRegistrar) { + registrar.registerReferenceProvider(PlatformPatterns.psiElement(PyParameter::class.java), PyTestReferenceProvider, + PsiReferenceRegistrar.HIGHER_PRIORITY) + } + +} diff --git a/python/src/com/jetbrains/python/testing/pyTestFixtures/README.md b/python/src/com/jetbrains/python/testing/pyTestFixtures/README.md new file mode 100644 index 000000000000..20ad2883555d --- /dev/null +++ b/python/src/com/jetbrains/python/testing/pyTestFixtures/README.md @@ -0,0 +1,16 @@ +https://docs.pytest.org/en/latest/fixture.html + +@pytest.fixture +def foo():pass + +Somehwere in the code + +def test_test(foo):pass + +"foo" is fixture here: pytest calls foo() and provides its return value. +PyCharm: +* Disables "unused" and "hides" inspections +* Provides reference (navigate, find usage, rename etc) +* Provides type for foo + + diff --git a/python/src/com/jetbrains/python/testing/pyTestParametrized/PyParametrizedArgumentInspection.kt b/python/src/com/jetbrains/python/testing/pyTestParametrized/PyParametrizedArgumentInspection.kt new file mode 100644 index 000000000000..9a792b1eebb4 --- /dev/null +++ b/python/src/com/jetbrains/python/testing/pyTestParametrized/PyParametrizedArgumentInspection.kt @@ -0,0 +1,39 @@ +// 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.testing.pyTestParametrized + +import com.intellij.codeInspection.LocalInspectionToolSession +import com.intellij.codeInspection.ProblemHighlightType +import com.intellij.codeInspection.ProblemsHolder +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiElementVisitor +import com.jetbrains.python.PyBundle +import com.jetbrains.python.inspections.PyInspection +import com.jetbrains.python.psi.PyFunction +import com.jetbrains.python.psi.PyParameter + + +class PyParametrizedArgumentInspection : PyInspection() { + override fun buildVisitor(holder: ProblemsHolder, + isOnTheFly: Boolean, + session: LocalInspectionToolSession) = object : PsiElementVisitor() { + override fun visitElement(element: PsiElement?) { + if (element is PyFunction) { + val requiredParameters = element.getParametersFromGenerator() + if (requiredParameters.isNotEmpty()) { + val declaredParameters = element.parameterList.parameters.mapNotNull(PyParameter::getName) + val diff = requiredParameters.minus(declaredParameters) + if (diff.isNotEmpty()) { + // Some params are not declared + holder.registerProblem(element, "Following arguments are not declared but provided by decorator: $diff", + ProblemHighlightType.GENERIC_ERROR_OR_WARNING) + } + } + } + super.visitElement(element) + } + } + + override fun getDisplayName(): String { + return PyBundle.message("INSP.NAME.pytest-parametrized") + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/testing/pyTestParametrized/PyParametrizedInspectionExtension.kt b/python/src/com/jetbrains/python/testing/pyTestParametrized/PyParametrizedInspectionExtension.kt new file mode 100644 index 000000000000..87e0b06db9ec --- /dev/null +++ b/python/src/com/jetbrains/python/testing/pyTestParametrized/PyParametrizedInspectionExtension.kt @@ -0,0 +1,12 @@ +// 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.testing.pyTestParametrized + +import com.intellij.psi.PsiElement +import com.jetbrains.python.inspections.PyInspectionExtension +import com.jetbrains.python.psi.PyNamedParameter + +class PyParametrizedInspectionExtension : PyInspectionExtension() { + + override fun ignoreUnused(local: PsiElement) = local is PyNamedParameter && local.isParametrized() + +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/testing/pyTestParametrized/PyParamterized.kt b/python/src/com/jetbrains/python/testing/pyTestParametrized/PyParamterized.kt new file mode 100644 index 000000000000..9f76848391e2 --- /dev/null +++ b/python/src/com/jetbrains/python/testing/pyTestParametrized/PyParamterized.kt @@ -0,0 +1,37 @@ +// 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.testing.pyTestParametrized + +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.util.ThreeState +import com.jetbrains.python.psi.PyFunction +import com.jetbrains.python.psi.PyNamedParameter +import com.jetbrains.python.psi.impl.PyEvaluator +import com.jetbrains.python.psi.types.TypeEvalContext +import com.jetbrains.python.testing.isTestElement + +/** + * @return Boolean is parameter provided to function by parametrized decorator + */ +fun PyNamedParameter.isParametrized() = PsiTreeUtil.getParentOfType(this, PyFunction::class.java) + ?.getParametersFromGenerator() + ?.contains(name) + ?: false + +//TODO: Add inspection that checks all parameters are declared + +/** + * @return List if test function decorated with parametrize -- return parameter names + */ +internal fun PyFunction.getParametersFromGenerator(): List { + val decoratorList = decoratorList ?: return emptyList() + if (!isTestElement(this, ThreeState.NO, TypeEvalContext.codeAnalysis(project, containingFile))) { + return emptyList() + } + val pyEvaluator = PyEvaluator() + return decoratorList.decorators + .filter { it.name == "parametrize" } + .mapNotNull { pyEvaluator.evaluate(it.arguments.firstOrNull()) } + .filterIsInstance(String::class.java) + .flatMap { it.split(",") } + .map(String::trim) +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/testing/pyTestParametrized/README.md b/python/src/com/jetbrains/python/testing/pyTestParametrized/README.md new file mode 100644 index 000000000000..32362078b0ae --- /dev/null +++ b/python/src/com/jetbrains/python/testing/pyTestParametrized/README.md @@ -0,0 +1,5 @@ +https://docs.pytest.org/en/latest/parametrize.html + +* Disable "unused" inspections for parameters +* TODO: Support inspection that checks all params are declared +* TODO: Support "rerun with certain parameters" (using "-k" option). Support from python side required \ No newline at end of file