diff --git a/python/python-psi-impl/resources/inspectionDescriptions/PyUnusedFunctionInspection.html b/python/python-psi-impl/resources/inspectionDescriptions/PyUnusedFunctionInspection.html
new file mode 100644
index 000000000000..14b4b27049e3
--- /dev/null
+++ b/python/python-psi-impl/resources/inspectionDescriptions/PyUnusedFunctionInspection.html
@@ -0,0 +1,7 @@
+
+
+
+ Reports local functions that are defined but never used.
+
+
+
diff --git a/python/python-psi-impl/resources/inspectionDescriptions/PyUnusedLocalInspection.html b/python/python-psi-impl/resources/inspectionDescriptions/PyUnusedLocalInspection.html
index 6cc9ba8651e0..d1f995499f7c 100644
--- a/python/python-psi-impl/resources/inspectionDescriptions/PyUnusedLocalInspection.html
+++ b/python/python-psi-impl/resources/inspectionDescriptions/PyUnusedLocalInspection.html
@@ -1,7 +1,7 @@
- Reports local variables, parameters, and functions that are locally defined, but not used name in a function.
+ Reports local variables and other local symbols that are locally defined, but not used name in a function.
diff --git a/python/python-psi-impl/resources/inspectionDescriptions/PyUnusedParameterInspection.html b/python/python-psi-impl/resources/inspectionDescriptions/PyUnusedParameterInspection.html
new file mode 100644
index 000000000000..fdf591291fd4
--- /dev/null
+++ b/python/python-psi-impl/resources/inspectionDescriptions/PyUnusedParameterInspection.html
@@ -0,0 +1,7 @@
+
+
+
+ Reports parameters of a function that are not used in its body.
+
+
+
diff --git a/python/python-psi-impl/resources/inspectionDescriptions/metaInformation.json b/python/python-psi-impl/resources/inspectionDescriptions/metaInformation.json
index 40c87e92dd9b..8eaa1a262b6a 100644
--- a/python/python-psi-impl/resources/inspectionDescriptions/metaInformation.json
+++ b/python/python-psi-impl/resources/inspectionDescriptions/metaInformation.json
@@ -236,6 +236,14 @@
"id": "PyUnusedLocalInspection",
"codeQualityCategory": "Code Style"
},
+ {
+ "id": "PyUnusedParameterInspection",
+ "codeQualityCategory": "Code Style"
+ },
+ {
+ "id": "PyUnusedFunctionInspection",
+ "codeQualityCategory": "Code Style"
+ },
{
"id": "PyMethodFirstArgAssignmentInspection",
"codeQualityCategory": "Reliability"
diff --git a/python/python-psi-impl/resources/intellij.python.psi.impl.xml b/python/python-psi-impl/resources/intellij.python.psi.impl.xml
index c094294a396c..6c681b6524fb 100644
--- a/python/python-psi-impl/resources/intellij.python.psi.impl.xml
+++ b/python/python-psi-impl/resources/intellij.python.psi.impl.xml
@@ -199,6 +199,10 @@
+
+
+
+
diff --git a/python/python-psi-impl/resources/messages/PyPsiBundle.properties b/python/python-psi-impl/resources/messages/PyPsiBundle.properties
index 5407d8a4eb82..d144549c9c25 100644
--- a/python/python-psi-impl/resources/messages/PyPsiBundle.properties
+++ b/python/python-psi-impl/resources/messages/PyPsiBundle.properties
@@ -880,6 +880,8 @@ INSP.mock.patch.too.many.params=Function has {0} extra parameter(s) not matched
# PyUnusedLocalInspection
INSP.NAME.unused=Unused local symbols
+INSP.NAME.unused.parameter=Unused parameter
+INSP.NAME.unused.function=Unused function
INSP.unused.locals.parameter.isnot.used=Parameter `{0}` value is not used
INSP.unused.locals.local.variable.isnot.used=Local variable `{0}` value is not used
INSP.unused.locals.replace.with.wildcard=Replace with _
diff --git a/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedFunctionInspection.kt b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedFunctionInspection.kt
new file mode 100644
index 000000000000..11a7ee55784a
--- /dev/null
+++ b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedFunctionInspection.kt
@@ -0,0 +1,11 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.inspections.unusedLocal
+
+/**
+ * Reports unused local functions. Unused local variables and other local symbols are reported separately by
+ * [PyUnusedLocalInspection]; both inspections share [PyUnusedLocalInspectionVisitor] for the analysis.
+ */
+class PyUnusedFunctionInspection : PyUnusedSymbolInspection() {
+ override val reportTarget: PyUnusedLocalInspectionVisitor.ReportTarget
+ get() = PyUnusedLocalInspectionVisitor.ReportTarget.FUNCTIONS
+}
diff --git a/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedLocalInspection.java b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedLocalInspection.java
index 9a98c6138a5a..86e73dafd185 100644
--- a/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedLocalInspection.java
+++ b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedLocalInspection.java
@@ -19,7 +19,6 @@ public final class PyUnusedLocalInspection extends PyInspection {
private static final Key KEY = Key.create("PyUnusedLocal.Visitor");
public boolean ignoreTupleUnpacking = true;
- public boolean ignoreLambdaParameters = true;
public boolean ignoreLoopIterationVariables = true;
public boolean ignoreVariablesStartingWithUnderscore = true;
@@ -29,9 +28,11 @@ public final class PyUnusedLocalInspection extends PyInspection {
@NotNull LocalInspectionToolSession session) {
final PyUnusedLocalInspectionVisitor visitor = new PyUnusedLocalInspectionVisitor(holder,
ignoreTupleUnpacking,
- ignoreLambdaParameters,
+ true,
ignoreLoopIterationVariables,
ignoreVariablesStartingWithUnderscore,
+ PyUnusedLocalInspectionVisitor.ReportTarget.LOCALS,
+ PyUnusedLocalInspectionVisitor.getSharedAnalysis(session),
PyInspectionVisitor.getContext(session));
// buildVisitor() will be called on injected files in the same session - don't overwrite if we already have one
final PyUnusedLocalInspectionVisitor existingVisitor = session.getUserData(KEY);
@@ -54,7 +55,6 @@ public final class PyUnusedLocalInspection extends PyInspection {
public @NotNull OptPane getOptionsPane() {
return pane(
checkbox("ignoreTupleUnpacking", PyPsiBundle.message("INSP.unused.locals.ignore.variables.used.in.tuple.unpacking")),
- checkbox("ignoreLambdaParameters", PyPsiBundle.message("INSP.unused.locals.ignore.lambda.parameters")),
checkbox("ignoreLoopIterationVariables", PyPsiBundle.message("INSP.unused.locals.ignore.range.iteration.variables")),
checkbox("ignoreVariablesStartingWithUnderscore", PyPsiBundle.message("INSP.unused.locals.ignore.variables.starting.with"))
);
diff --git a/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedLocalInspectionVisitor.java b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedLocalInspectionVisitor.java
index 145cb1a4f098..889b9d897c68 100644
--- a/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedLocalInspectionVisitor.java
+++ b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedLocalInspectionVisitor.java
@@ -3,6 +3,7 @@ package com.jetbrains.python.inspections.unusedLocal;
import com.intellij.codeInsight.controlflow.ControlFlowUtil;
import com.intellij.codeInsight.controlflow.Instruction;
+import com.intellij.codeInspection.LocalInspectionToolSession;
import com.intellij.codeInspection.LocalQuickFix;
import com.intellij.codeInspection.ProblemHighlightType;
import com.intellij.codeInspection.ProblemsHolder;
@@ -10,6 +11,7 @@ import com.intellij.lang.injection.InjectedLanguageManager;
import com.intellij.modcommand.ModPsiUpdater;
import com.intellij.modcommand.PsiUpdateModCommandQuickFix;
import com.intellij.openapi.project.Project;
+import com.intellij.openapi.util.Key;
import com.intellij.openapi.util.Pair;
import com.intellij.openapi.util.TextRange;
import com.intellij.openapi.util.text.StringUtil;
@@ -102,23 +104,64 @@ public final class PyUnusedLocalInspectionVisitor extends PyInspectionVisitor {
private final boolean myIgnoreLambdaParameters;
private final boolean myIgnoreRangeIterationVariables;
private final boolean myIgnoreVariablesStartingWithUnderscore;
+ private final ReportTarget myReportTarget;
+ private final SharedAnalysis mySharedAnalysis;
+
+ /** Which kind of unused element this visitor reports; each kind is exposed as a separate inspection. */
+ public enum ReportTarget { LOCALS, PARAMETERS, FUNCTIONS }
+
+ /**
+ * Analysis state shared by the unused-locals, unused-parameter, and unused-function inspections. All three perform the same
+ * control-flow analysis but report different element kinds; sharing this state through the {@link LocalInspectionToolSession}
+ * keeps the heavy analysis running once per file instead of once per inspection.
+ */
+ public static final class SharedAnalysis {
+ // Names defined directly in a scope. They all belong to this scope.
+ private final Map> scopeWrites = new ConcurrentHashMap<>();
+ // Names read directly in a scope. They might belong to some outer scope.
+ private final Map> scopeReads = new ConcurrentHashMap<>();
+ private volatile Set unusedElements;
+ }
+
+ private static final Key SHARED_ANALYSIS_KEY = Key.create("PyUnusedLocal.SharedAnalysis");
+
+ /** Returns the analysis state shared by the unused-symbol inspections running on this session, creating it on first use. */
+ public static @NotNull SharedAnalysis getSharedAnalysis(@NotNull LocalInspectionToolSession session) {
+ SharedAnalysis analysis = session.getUserData(SHARED_ANALYSIS_KEY);
+ if (analysis == null) {
+ synchronized (SHARED_ANALYSIS_KEY) {
+ analysis = session.getUserData(SHARED_ANALYSIS_KEY);
+ if (analysis == null) {
+ analysis = new SharedAnalysis();
+ session.putUserData(SHARED_ANALYSIS_KEY, analysis);
+ }
+ }
+ }
+ return analysis;
+ }
// Names defined directly in a scope. They all belong to this scope.
- private final Map> myScopeWrites = new ConcurrentHashMap<>();
+ private final Map> myScopeWrites;
// Names read directly in a scope. They might belong to some outer scope.
- private final Map> myScopeReads = new ConcurrentHashMap<>();
+ private final Map> myScopeReads;
public PyUnusedLocalInspectionVisitor(@NotNull ProblemsHolder holder,
boolean ignoreTupleUnpacking,
boolean ignoreLambdaParameters,
boolean ignoreRangeIterationVariables,
boolean ignoreVariablesStartingWithUnderscore,
+ @NotNull ReportTarget reportTarget,
+ @NotNull SharedAnalysis sharedAnalysis,
@NotNull TypeEvalContext context) {
super(holder, context);
myIgnoreTupleUnpacking = ignoreTupleUnpacking;
myIgnoreLambdaParameters = ignoreLambdaParameters;
myIgnoreRangeIterationVariables = ignoreRangeIterationVariables;
myIgnoreVariablesStartingWithUnderscore = ignoreVariablesStartingWithUnderscore;
+ myReportTarget = reportTarget;
+ mySharedAnalysis = sharedAnalysis;
+ myScopeWrites = sharedAnalysis.scopeWrites;
+ myScopeReads = sharedAnalysis.scopeReads;
}
@Override
@@ -150,6 +193,10 @@ public final class PyUnusedLocalInspectionVisitor extends PyInspectionVisitor {
}
private void collectAllWrites(ScopeOwner owner) {
+ if (myScopeWrites.containsKey(owner)) {
+ // Already computed for another unused-symbol inspection sharing this session.
+ return;
+ }
Set scopeWrites = new HashSet<>();
// type parameter list is not included in CFG
if (owner instanceof PyTypeParameterListOwner typeParameterListOwner) {
@@ -379,6 +426,16 @@ public final class PyUnusedLocalInspectionVisitor extends PyInspectionVisitor {
return false;
}
+ private static @NotNull ReportTarget categorize(@NotNull PsiElement element) {
+ if (element instanceof PyNamedParameter || element.getParent() instanceof PyNamedParameter) {
+ return ReportTarget.PARAMETERS;
+ }
+ if (element instanceof PyFunction) {
+ return ReportTarget.FUNCTIONS;
+ }
+ return ReportTarget.LOCALS;
+ }
+
public void registerProblems() {
final List filters = PyInspectionExtension.EP_NAME.getExtensionList();
// Register problems
@@ -386,11 +443,21 @@ public final class PyUnusedLocalInspectionVisitor extends PyInspectionVisitor {
final Set functionsWithInheritors = new HashSet<>();
final Map emptyFunctions = new HashMap<>();
- Set unusedElements = StreamEx.of(myScopeWrites.entrySet())
- .flatCollection(writeEntry -> ContainerUtil.subtract(writeEntry.getValue(), getReadsInsideScope(writeEntry.getKey())))
- .toImmutableSet();
+ Set unusedElements = mySharedAnalysis.unusedElements;
+ if (unusedElements == null) {
+ unusedElements = StreamEx.of(myScopeWrites.entrySet())
+ .flatCollection(writeEntry -> ContainerUtil.subtract(writeEntry.getValue(), getReadsInsideScope(writeEntry.getKey())))
+ .toImmutableSet();
+ // Benign race: concurrent inspections may recompute the identical set; the last write wins.
+ mySharedAnalysis.unusedElements = unusedElements;
+ }
for (PsiElement element : unusedElements) {
+ // Parameters, functions, and the remaining "local symbols" are each reported by a separate inspection.
+ if (categorize(element) != myReportTarget) {
+ continue;
+ }
+
boolean ignoreUnused = false;
for (PyInspectionExtension filter : filters) {
if (filter.ignoreUnused(element, myTypeEvalContext)) {
diff --git a/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedParameterInspection.kt b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedParameterInspection.kt
new file mode 100644
index 000000000000..5612d5bf63ae
--- /dev/null
+++ b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedParameterInspection.kt
@@ -0,0 +1,24 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.inspections.unusedLocal
+
+import com.intellij.codeInspection.options.OptPane
+import com.intellij.codeInspection.options.OptPane.pane
+import com.jetbrains.python.PyPsiBundle
+
+/**
+ * Reports unused function parameters. Unused local variables and other local symbols are reported separately by
+ * [PyUnusedLocalInspection]; both inspections share [PyUnusedLocalInspectionVisitor] for the analysis.
+ */
+class PyUnusedParameterInspection : PyUnusedSymbolInspection() {
+ @JvmField
+ var ignoreLambdaParameters: Boolean = true
+
+ override val reportTarget: PyUnusedLocalInspectionVisitor.ReportTarget
+ get() = PyUnusedLocalInspectionVisitor.ReportTarget.PARAMETERS
+
+ override fun shouldIgnoreLambdaParameters(): Boolean = ignoreLambdaParameters
+
+ override fun getOptionsPane(): OptPane = pane(
+ OptPane.checkbox("ignoreLambdaParameters", PyPsiBundle.message("INSP.unused.locals.ignore.lambda.parameters")),
+ )
+}
diff --git a/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedSymbolInspection.kt b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedSymbolInspection.kt
new file mode 100644
index 000000000000..4ae9ed2e0a43
--- /dev/null
+++ b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedSymbolInspection.kt
@@ -0,0 +1,44 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.inspections.unusedLocal
+
+import com.intellij.codeInspection.LocalInspectionToolSession
+import com.intellij.codeInspection.ProblemsHolder
+import com.intellij.openapi.application.runReadActionBlocking
+import com.intellij.openapi.util.Key
+import com.intellij.psi.PsiElementVisitor
+import com.jetbrains.python.inspections.PyInspection
+import com.jetbrains.python.inspections.PyInspectionVisitor
+
+/**
+ * Base class for the unused-parameter and unused-function inspections. Both report a single kind of unused symbol via the shared
+ * [PyUnusedLocalInspectionVisitor]; the actual control-flow analysis is shared across all unused-symbol inspections running on the
+ * same session (see [PyUnusedLocalInspectionVisitor.SharedAnalysis]). Unused local variables and other local symbols are reported
+ * separately by [PyUnusedLocalInspection].
+ */
+abstract class PyUnusedSymbolInspection : PyInspection() {
+ private val visitorKey = Key.create("${javaClass.simpleName}.Visitor")
+
+ /** The single element kind this inspection reports. */
+ protected abstract val reportTarget: PyUnusedLocalInspectionVisitor.ReportTarget
+
+ /** Whether lambda parameters should be ignored; only meaningful when [reportTarget] is `PARAMETERS`. */
+ protected open fun shouldIgnoreLambdaParameters(): Boolean = true
+
+ final override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean, session: LocalInspectionToolSession): PsiElementVisitor {
+ val visitor = PyUnusedLocalInspectionVisitor(holder, false, shouldIgnoreLambdaParameters(), false, false,
+ reportTarget,
+ PyUnusedLocalInspectionVisitor.getSharedAnalysis(session),
+ PyInspectionVisitor.getContext(session))
+ // buildVisitor() will be called on injected files in the same session - don't overwrite if we already have one
+ if (session.getUserData(visitorKey) == null) {
+ session.putUserData(visitorKey, visitor)
+ }
+ return visitor
+ }
+
+ final override fun inspectionFinished(session: LocalInspectionToolSession, holder: ProblemsHolder) {
+ val visitor = session.getUserData(visitorKey) ?: return
+ runReadActionBlocking { visitor.registerProblems() }
+ session.putUserData(visitorKey, null)
+ }
+}
diff --git a/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedSymbolInspectionMerger.kt b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedSymbolInspectionMerger.kt
new file mode 100644
index 000000000000..b891736200a6
--- /dev/null
+++ b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedSymbolInspectionMerger.kt
@@ -0,0 +1,59 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.inspections.unusedLocal
+
+import com.intellij.codeInspection.ex.InspectionElementsMergerBase
+import org.jdom.Element
+
+/**
+ * Migrates user inspection-profile settings (enabled state, severity, and shared options such as `ignoreLambdaParameters`) from
+ * the former combined `PyUnusedLocalInspection` to the inspection that took over part of its reporting after PY-9687 split it into
+ * separate unused-local / unused-parameter / unused-function inspections. Without this, a user who had disabled or reconfigured
+ * `PyUnusedLocalInspection` would silently get the new inspections back at their defaults.
+ *
+ * Suppression is intentionally NOT inherited: `# noinspection PyUnusedLocal` no longer silences parameters or functions (the split
+ * is a deliberate clean break), so [getSuppressIds] returns only this inspection's own id instead of letting the base fall back to
+ * the source tool name.
+ */
+abstract class PyUnusedSymbolInspectionMerger : InspectionElementsMergerBase() {
+ /** Suppress id of the merged (new) inspection. */
+ protected abstract val mergedSuppressId: String
+
+ final override fun getSourceToolNames(): Array = arrayOf(SOURCE_TOOL_NAME)
+
+ final override fun getSuppressIds(): Array = arrayOf(mergedSuppressId)
+
+ final override fun merge(inspectionElements: Map): Element? {
+ val merged = super.merge(inspectionElements)
+ if (merged != null) return merged
+ // super.merge() produces nothing when the source node carried no option children (e.g. a plain enable/disable toggle or a
+ // severity change). Still carry over the enabled state and severity so the split stays transparent to such users.
+ val source = inspectionElements[SOURCE_TOOL_NAME] ?: return null
+ return Element(INSPECTION_TOOL_TAG).apply {
+ setAttribute(CLASS_ATTR, mergedToolName)
+ source.getAttributeValue(ENABLED_ATTR)?.let { setAttribute(ENABLED_ATTR, it) }
+ source.getAttributeValue(ENABLED_BY_DEFAULT_ATTR)?.let { setAttribute(ENABLED_BY_DEFAULT_ATTR, it) }
+ source.getAttributeValue(LEVEL_ATTR)?.let { setAttribute(LEVEL_ATTR, it) }
+ }
+ }
+
+ private companion object {
+ const val SOURCE_TOOL_NAME = "PyUnusedLocalInspection"
+
+ // Inspection-profile serialization names (mirror InspectionProfileImpl/ToolsImpl, which are not visible from here).
+ const val INSPECTION_TOOL_TAG = "inspection_tool"
+ const val CLASS_ATTR = "class"
+ const val ENABLED_ATTR = "enabled"
+ const val ENABLED_BY_DEFAULT_ATTR = "enabled_by_default"
+ const val LEVEL_ATTR = "level"
+ }
+}
+
+class PyUnusedParameterInspectionMerger : PyUnusedSymbolInspectionMerger() {
+ override fun getMergedToolName(): String = "PyUnusedParameterInspection"
+ override val mergedSuppressId: String get() = "unused-parameter"
+}
+
+class PyUnusedFunctionInspectionMerger : PyUnusedSymbolInspectionMerger() {
+ override fun getMergedToolName(): String = "PyUnusedFunctionInspection"
+ override val mergedSuppressId: String get() = "unused-function"
+}
diff --git a/python/src/com/jetbrains/python/console/ConsoleVisitorFilter.java b/python/src/com/jetbrains/python/console/ConsoleVisitorFilter.java
index f61664202c74..db7e0badd2c9 100644
--- a/python/src/com/jetbrains/python/console/ConsoleVisitorFilter.java
+++ b/python/src/com/jetbrains/python/console/ConsoleVisitorFilter.java
@@ -10,7 +10,9 @@ import com.jetbrains.python.inspections.PyPep8Inspection;
import com.jetbrains.python.inspections.PySingleQuotedDocstringInspection;
import com.jetbrains.python.inspections.PyStatementEffectInspection;
import com.jetbrains.python.inspections.PyUnboundLocalVariableInspection;
+import com.jetbrains.python.inspections.unusedLocal.PyUnusedFunctionInspection;
import com.jetbrains.python.inspections.unusedLocal.PyUnusedLocalInspection;
+import com.jetbrains.python.inspections.unusedLocal.PyUnusedParameterInspection;
import com.jetbrains.python.psi.PythonVisitorFilter;
import com.jetbrains.python.validation.PyDocStringHighlightingAnnotator;
import org.jetbrains.annotations.NotNull;
@@ -26,7 +28,8 @@ final class ConsoleVisitorFilter implements PythonVisitorFilter {
//if we're in console
if (PydevConsoleRunnerUtil.isInPydevConsole(file)) {
//inspections
- if (visitorClass == PyUnusedLocalInspection.class || visitorClass == PyUnboundLocalVariableInspection.class ||
+ if (visitorClass == PyUnusedLocalInspection.class || visitorClass == PyUnusedParameterInspection.class ||
+ visitorClass == PyUnusedFunctionInspection.class || visitorClass == PyUnboundLocalVariableInspection.class ||
visitorClass == PyStatementEffectInspection.class || visitorClass == PySingleQuotedDocstringInspection.class ||
visitorClass == PyIncorrectDocstringInspection.class || visitorClass == PyMissingOrEmptyDocstringInspection.class ||
visitorClass == PyMandatoryEncodingInspection.class || visitorClass == PyPep8Inspection.class ||
diff --git a/python/src/com/jetbrains/python/pyi/PyiVisitorFilter.java b/python/src/com/jetbrains/python/pyi/PyiVisitorFilter.java
index 45ede5636cc8..bb273887c378 100644
--- a/python/src/com/jetbrains/python/pyi/PyiVisitorFilter.java
+++ b/python/src/com/jetbrains/python/pyi/PyiVisitorFilter.java
@@ -10,7 +10,9 @@ import com.jetbrains.python.inspections.PyPropertyDefinitionInspection;
import com.jetbrains.python.inspections.PyShadowingBuiltinsInspection;
import com.jetbrains.python.inspections.PyStatementEffectInspection;
import com.jetbrains.python.inspections.PyTypeCheckerInspection;
+import com.jetbrains.python.inspections.unusedLocal.PyUnusedFunctionInspection;
import com.jetbrains.python.inspections.unusedLocal.PyUnusedLocalInspection;
+import com.jetbrains.python.inspections.unusedLocal.PyUnusedParameterInspection;
import com.jetbrains.python.psi.PythonVisitorFilter;
import org.jetbrains.annotations.NotNull;
@@ -20,6 +22,8 @@ public final class PyiVisitorFilter implements PythonVisitorFilter {
private static final @NotNull Set> disabledVisitors = ImmutableSet.of(
PyUnusedLocalInspection.class,
+ PyUnusedParameterInspection.class,
+ PyUnusedFunctionInspection.class,
PyStatementEffectInspection.class,
PyCompatibilityInspection.class,
PyMissingOrEmptyDocstringInspection.class,
diff --git a/python/testSrc/com/jetbrains/python/inspections/PyUnusedFunctionInspectionTest.kt b/python/testSrc/com/jetbrains/python/inspections/PyUnusedFunctionInspectionTest.kt
new file mode 100644
index 000000000000..bef8089e29e9
--- /dev/null
+++ b/python/testSrc/com/jetbrains/python/inspections/PyUnusedFunctionInspectionTest.kt
@@ -0,0 +1,59 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.inspections
+
+import com.intellij.lang.annotation.HighlightSeverity
+import com.intellij.idea.TestFor
+import com.jetbrains.python.PythonFileType
+import com.jetbrains.python.fixtures.PyCodeInsightTestCase
+import com.jetbrains.python.inspections.unusedLocal.PyUnusedFunctionInspection
+import org.intellij.lang.annotations.Language
+import org.junit.jupiter.api.Assertions
+import org.junit.jupiter.api.Test
+
+@TestFor(issues = ["PY-9687"])
+class PyUnusedFunctionInspectionTest : PyCodeInsightTestCase() {
+
+ /** Asserts the exact set of unused-function warnings (descriptions) reported for [code]. */
+ private fun doTest(@Language("Python") code: String, vararg expectedMessages: String) {
+ myFixture.configureByText(PythonFileType.INSTANCE, code.trimIndent())
+ myFixture.enableInspections(PyUnusedFunctionInspection())
+ val actual = myFixture.doHighlighting()
+ .filter { it.severity == HighlightSeverity.WEAK_WARNING }
+ .map { it.description }
+ .sorted()
+ Assertions.assertEquals(expectedMessages.sorted(), actual)
+ }
+
+ @Test
+ fun `unused local function is reported`() {
+ // The unused local variable 'a' belongs to PyUnusedLocal and is not enabled here.
+ doTest("""
+ def outer():
+ a = 1
+ def inner():
+ return 1
+ return a
+ """, "Local function 'inner' is not used")
+ }
+
+ @Test
+ fun `used local function is not reported`() {
+ doTest("""
+ def outer():
+ def inner():
+ return 1
+ return inner()
+ """)
+ }
+
+ @Test
+ fun `noinspection unused-function suppresses the function`() {
+ doTest("""
+ def outer():
+ # noinspection unused-function
+ def inner():
+ return 1
+ return 0
+ """)
+ }
+}
diff --git a/python/testSrc/com/jetbrains/python/inspections/PyUnusedLocalInspectionTest.java b/python/testSrc/com/jetbrains/python/inspections/PyUnusedLocalInspectionTest.java
index e869e47c6b8b..eec8e55f003c 100644
--- a/python/testSrc/com/jetbrains/python/inspections/PyUnusedLocalInspectionTest.java
+++ b/python/testSrc/com/jetbrains/python/inspections/PyUnusedLocalInspectionTest.java
@@ -15,19 +15,25 @@
*/
package com.jetbrains.python.inspections;
+import com.intellij.codeInspection.LocalInspectionTool;
import com.intellij.idea.TestFor;
import com.jetbrains.python.fixtures.PyInspectionTestCase;
+import com.jetbrains.python.inspections.unusedLocal.PyUnusedFunctionInspection;
import com.jetbrains.python.inspections.unusedLocal.PyUnusedLocalInspection;
+import com.jetbrains.python.inspections.unusedLocal.PyUnusedParameterInspection;
import com.jetbrains.python.psi.LanguageLevel;
import org.jetbrains.annotations.NotNull;
+import java.util.List;
+
public class PyUnusedLocalInspectionTest extends PyInspectionTestCase {
public void testPy2() {
final PyUnusedLocalInspection inspection = new PyUnusedLocalInspection();
inspection.ignoreTupleUnpacking = false;
- inspection.ignoreLambdaParameters = false;
- runWithLanguageLevel(LanguageLevel.PYTHON27, () -> doTest(inspection));
+ final PyUnusedParameterInspection parameterInspection = new PyUnusedParameterInspection();
+ parameterInspection.ignoreLambdaParameters = false;
+ runWithLanguageLevel(LanguageLevel.PYTHON27, () -> doTest(inspection, parameterInspection));
}
public void testNonlocal() {
@@ -149,7 +155,6 @@ public class PyUnusedLocalInspectionTest extends PyInspectionTestCase {
public void testIgnoringVariablesStartingWithUnderscore() {
final PyUnusedLocalInspection inspection = new PyUnusedLocalInspection();
inspection.ignoreVariablesStartingWithUnderscore = true;
- inspection.ignoreLambdaParameters = false;
inspection.ignoreLoopIterationVariables = false;
inspection.ignoreTupleUnpacking = false;
doTest(inspection);
@@ -310,10 +315,21 @@ def test():
return PyUnusedLocalInspection.class;
}
+ // Parameter and function reporting now live in separate inspections; enable them too so the shared testData keeps matching.
+ @NotNull
+ @Override
+ protected List> getAdditionalInspectionClasses() {
+ return List.of(PyUnusedParameterInspection.class, PyUnusedFunctionInspection.class);
+ }
+
private void doTest(@NotNull PyUnusedLocalInspection inspection) {
+ doTest(inspection, new PyUnusedParameterInspection());
+ }
+
+ private void doTest(@NotNull PyUnusedLocalInspection inspection, @NotNull PyUnusedParameterInspection parameterInspection) {
final String path = "inspections/PyUnusedLocalInspection/" + getTestName(true) + ".py";
myFixture.configureByFile(path);
- myFixture.enableInspections(inspection);
+ myFixture.enableInspections(inspection, parameterInspection, new PyUnusedFunctionInspection());
myFixture.checkHighlighting(true, false, true);
}
}
diff --git a/python/testSrc/com/jetbrains/python/inspections/PyUnusedParameterInspectionTest.kt b/python/testSrc/com/jetbrains/python/inspections/PyUnusedParameterInspectionTest.kt
new file mode 100644
index 000000000000..a604676a3438
--- /dev/null
+++ b/python/testSrc/com/jetbrains/python/inspections/PyUnusedParameterInspectionTest.kt
@@ -0,0 +1,71 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.inspections
+
+import com.intellij.lang.annotation.HighlightSeverity
+import com.intellij.idea.TestFor
+import com.jetbrains.python.PythonFileType
+import com.jetbrains.python.fixtures.PyCodeInsightTestCase
+import com.jetbrains.python.inspections.unusedLocal.PyUnusedParameterInspection
+import org.intellij.lang.annotations.Language
+import org.junit.jupiter.api.Assertions
+import org.junit.jupiter.api.Test
+
+@TestFor(issues = ["PY-9687"])
+class PyUnusedParameterInspectionTest : PyCodeInsightTestCase() {
+
+ /** Asserts the exact set of unused-parameter warnings (descriptions) reported for [code]. */
+ private fun doTest(@Language("Python") code: String, vararg expectedMessages: String) {
+ myFixture.configureByText(PythonFileType.INSTANCE, code.trimIndent())
+ myFixture.enableInspections(PyUnusedParameterInspection())
+ val actual = myFixture.doHighlighting()
+ .filter { it.severity == HighlightSeverity.WEAK_WARNING }
+ .map { it.description }
+ .sorted()
+ Assertions.assertEquals(expectedMessages.sorted(), actual)
+ }
+
+ @Test
+ fun `unused parameter is reported`() {
+ // The unused local 'a' belongs to PyUnusedLocal and is not enabled here.
+ doTest("""
+ def f(x):
+ a = 1
+ """, "Parameter 'x' value is not used")
+ }
+
+ @Test
+ fun `self is not reported but a regular parameter is`() {
+ doTest("""
+ class A:
+ def m(self, x):
+ return 1
+ """, "Parameter 'x' value is not used")
+ }
+
+ @Test
+ fun `lambda parameter is not reported`() {
+ doTest("""
+ f = lambda x: 1
+ print(f)
+ """)
+ }
+
+ @Test
+ fun `noinspection unused-parameter suppresses the parameter`() {
+ doTest("""
+ # noinspection unused-parameter
+ def f(x):
+ return 1
+ """)
+ }
+
+ @Test
+ fun `legacy PyUnusedLocal does not suppress the parameter`() {
+ // Clean break: PyUnusedLocal no longer silences parameters.
+ doTest("""
+ # noinspection PyUnusedLocal
+ def f(x):
+ return 1
+ """, "Parameter 'x' value is not used")
+ }
+}
diff --git a/python/testSrc/com/jetbrains/python/inspections/PyUnusedSymbolInspectionMergerTest.kt b/python/testSrc/com/jetbrains/python/inspections/PyUnusedSymbolInspectionMergerTest.kt
new file mode 100644
index 000000000000..4191484fa0f3
--- /dev/null
+++ b/python/testSrc/com/jetbrains/python/inspections/PyUnusedSymbolInspectionMergerTest.kt
@@ -0,0 +1,57 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.inspections
+
+import com.intellij.codeInspection.ex.InspectionProfileImpl
+import com.intellij.codeInspection.ex.InspectionToolRegistrar
+import com.intellij.idea.TestFor
+import com.intellij.openapi.util.JDOMUtil
+import com.jetbrains.python.fixtures.PyTestCase
+import com.jetbrains.python.inspections.unusedLocal.PyUnusedParameterInspection
+
+/**
+ * Verifies that profile settings from the former combined `PyUnusedLocalInspection` migrate to the inspections that PY-9687 split
+ * it into, so users who had disabled or reconfigured it are not silently reset to defaults.
+ */
+@TestFor(issues = ["PY-9687"])
+class PyUnusedSymbolInspectionMergerTest : PyTestCase() {
+ private lateinit var profile: InspectionProfileImpl
+
+ override fun setUp() {
+ super.setUp()
+ InspectionProfileImpl.INIT_INSPECTIONS = true
+ profile = InspectionProfileImpl("Test", InspectionToolRegistrar.getInstance(), InspectionProfileImpl("base"))
+ }
+
+ override fun tearDown() {
+ try {
+ InspectionProfileImpl.INIT_INSPECTIONS = false
+ }
+ finally {
+ super.tearDown()
+ }
+ }
+
+ // A user who disabled the former combined inspection should not get the split-out inspections back, enabled, at their defaults.
+ fun testDisabledStateMigratesToSplitInspections() {
+ profile.readExternal(JDOMUtil.load("""
+
+
+
+ """))
+ assertFalse(profile.getToolsOrNull("PyUnusedParameterInspection", null)!!.isEnabled)
+ assertFalse(profile.getToolsOrNull("PyUnusedFunctionInspection", null)!!.isEnabled)
+ }
+
+ // The ignoreLambdaParameters option used to live on PyUnusedLocalInspection; it must carry over to the parameter inspection.
+ fun testIgnoreLambdaParametersOptionMigrates() {
+ profile.readExternal(JDOMUtil.load("""
+
+
+
+
+
+ """))
+ val tool = profile.getToolsOrNull("PyUnusedParameterInspection", null)!!.tool.tool as PyUnusedParameterInspection
+ assertFalse(tool.ignoreLambdaParameters)
+ }
+}
diff --git a/python/testSrc/com/jetbrains/python/pyi/PyiInspectionsTest.java b/python/testSrc/com/jetbrains/python/pyi/PyiInspectionsTest.java
index f6b0b4728b77..ab30fa354ec8 100644
--- a/python/testSrc/com/jetbrains/python/pyi/PyiInspectionsTest.java
+++ b/python/testSrc/com/jetbrains/python/pyi/PyiInspectionsTest.java
@@ -33,7 +33,7 @@ import com.jetbrains.python.inspections.PyTypeCheckerInspection;
import com.jetbrains.python.inspections.PyUnboundLocalVariableInspection;
import com.jetbrains.python.inspections.PyUnusedImportsInspection;
import com.jetbrains.python.inspections.unresolvedReference.PyUnresolvedReferencesInspection;
-import com.jetbrains.python.inspections.unusedLocal.PyUnusedLocalInspection;
+import com.jetbrains.python.inspections.unusedLocal.PyUnusedParameterInspection;
import com.jetbrains.python.psi.PythonVisitorFilter;
import org.jetbrains.annotations.NotNull;
@@ -110,7 +110,7 @@ public class PyiInspectionsTest extends PyTestCase {
}
public void testPyiUnusedParameters() {
- doPyiTest(PyUnusedLocalInspection.class);
+ doPyiTest(PyUnusedParameterInspection.class);
}
public void testPyiStatementEffect() {
diff --git a/python/testSrc/com/jetbrains/python/quickFixes/PyAddFieldQuickFixTest.java b/python/testSrc/com/jetbrains/python/quickFixes/PyAddFieldQuickFixTest.java
index eea517a8867a..293067c3899f 100644
--- a/python/testSrc/com/jetbrains/python/quickFixes/PyAddFieldQuickFixTest.java
+++ b/python/testSrc/com/jetbrains/python/quickFixes/PyAddFieldQuickFixTest.java
@@ -21,7 +21,7 @@ import com.jetbrains.python.PyQuickFixTestCase;
import com.jetbrains.python.allure.Layers;
import com.jetbrains.python.allure.Subsystems;
import com.jetbrains.python.inspections.unresolvedReference.PyUnresolvedReferencesInspection;
-import com.jetbrains.python.inspections.unusedLocal.PyUnusedLocalInspection;
+import com.jetbrains.python.inspections.unusedLocal.PyUnusedParameterInspection;
import com.jetbrains.python.psi.LanguageLevel;
@TestDataPath("$CONTENT_ROOT/../testData//quickFixes/AddFieldQuickFixTest/")
@@ -52,16 +52,16 @@ public class PyAddFieldQuickFixTest extends PyQuickFixTestCase {
}
public void testFromUnusedParameter() { // PY-1398
- doQuickFixTest(PyUnusedLocalInspection.class, "Add field 'foo' to class A");
+ doQuickFixTest(PyUnusedParameterInspection.class, "Add field 'foo' to class A");
}
// PY-14733
public void testAddFieldInitializationInsideEmptyInit() {
- doQuickFixTest(PyUnusedLocalInspection.class, "Add field 'foo' to class A");
+ doQuickFixTest(PyUnusedParameterInspection.class, "Add field 'foo' to class A");
}
public void testFromUnusedParameterKeyword() { // PY-1602
- doQuickFixTest(PyUnusedLocalInspection.class, "Add field 'foo' to class A");
+ doQuickFixTest(PyUnusedParameterInspection.class, "Add field 'foo' to class A");
}
// PY-21284
diff --git a/python/testSrc/com/jetbrains/python/quickFixes/PyRemoveParameterQuickFixTest.java b/python/testSrc/com/jetbrains/python/quickFixes/PyRemoveParameterQuickFixTest.java
index 735f7d2c8a37..3509ec9ca049 100644
--- a/python/testSrc/com/jetbrains/python/quickFixes/PyRemoveParameterQuickFixTest.java
+++ b/python/testSrc/com/jetbrains/python/quickFixes/PyRemoveParameterQuickFixTest.java
@@ -8,7 +8,7 @@ import com.jetbrains.python.PyQuickFixTestCase;
import com.jetbrains.python.allure.Layers;
import com.jetbrains.python.allure.Subsystems;
import com.jetbrains.python.documentation.docstrings.DocStringFormat;
-import com.jetbrains.python.inspections.unusedLocal.PyUnusedLocalInspection;
+import com.jetbrains.python.inspections.unusedLocal.PyUnusedParameterInspection;
import com.jetbrains.python.psi.LanguageLevel;
@TestDataPath("$CONTENT_ROOT/../testData//quickFixes/PyRemoveParameterQuickFixTest/")
@@ -17,26 +17,26 @@ import com.jetbrains.python.psi.LanguageLevel;
public class PyRemoveParameterQuickFixTest extends PyQuickFixTestCase {
public void testParam() {
- doQuickFixTest(PyUnusedLocalInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter"));
+ doQuickFixTest(PyUnusedParameterInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter"));
}
public void testKwParam() {
- doQuickFixTest(PyUnusedLocalInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter"));
+ doQuickFixTest(PyUnusedParameterInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter"));
}
public void testDocstring() {
runWithDocStringFormat(DocStringFormat.REST, () ->
- doQuickFixTest(PyUnusedLocalInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter"))
+ doQuickFixTest(PyUnusedParameterInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter"))
);
}
public void testUsage() {
- doQuickFixTest(PyUnusedLocalInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter"));
+ doQuickFixTest(PyUnusedParameterInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter"));
}
public void testSingleStarTwoParam() {
runWithDocStringFormat(DocStringFormat.REST, () ->
- doQuickFixTest(PyUnusedLocalInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter"), LanguageLevel.PYTHON34)
+ doQuickFixTest(PyUnusedParameterInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter"), LanguageLevel.PYTHON34)
);
}
@@ -45,7 +45,7 @@ public class PyRemoveParameterQuickFixTest extends PyQuickFixTestCase {
LanguageLevel.PYTHON34,
() -> {
final String testFileName = getTestName(true);
- myFixture.enableInspections(PyUnusedLocalInspection.class);
+ myFixture.enableInspections(PyUnusedParameterInspection.class);
myFixture.configureByFile(testFileName + ".py");
myFixture.checkHighlighting(true, false, false);
final IntentionAction intentionAction = myFixture.getAvailableIntention(PyPsiBundle.message("QFIX.NAME.remove.parameter"));
@@ -56,11 +56,11 @@ public class PyRemoveParameterQuickFixTest extends PyQuickFixTestCase {
// PY-22971
public void testTopLevelOverloadsAndImplementation() {
- doQuickFixTest(PyUnusedLocalInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter"), LanguageLevel.PYTHON35);
+ doQuickFixTest(PyUnusedParameterInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter"), LanguageLevel.PYTHON35);
}
// PY-22971
public void testOverloadsAndImplementationInClass() {
- doQuickFixTest(PyUnusedLocalInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter"), LanguageLevel.PYTHON35);
+ doQuickFixTest(PyUnusedParameterInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter"), LanguageLevel.PYTHON35);
}
}
diff --git a/python/testSrc/com/jetbrains/python/quickFixes/PyRemoveStatementQuickFixTest.java b/python/testSrc/com/jetbrains/python/quickFixes/PyRemoveStatementQuickFixTest.java
index c15b23758bdc..7f2e8084a83a 100644
--- a/python/testSrc/com/jetbrains/python/quickFixes/PyRemoveStatementQuickFixTest.java
+++ b/python/testSrc/com/jetbrains/python/quickFixes/PyRemoveStatementQuickFixTest.java
@@ -21,6 +21,7 @@ import com.jetbrains.python.PyQuickFixTestCase;
import com.jetbrains.python.allure.Layers;
import com.jetbrains.python.allure.Subsystems;
import com.jetbrains.python.inspections.PyReturnFromInitInspection;
+import com.jetbrains.python.inspections.unusedLocal.PyUnusedFunctionInspection;
import com.jetbrains.python.inspections.unusedLocal.PyUnusedLocalInspection;
@TestDataPath("$CONTENT_ROOT/../testData//quickFixes/PyRemoveStatementQuickFixTest/")
@@ -37,7 +38,7 @@ public class PyRemoveStatementQuickFixTest extends PyQuickFixTestCase {
}
public void testFunction() {
- doQuickFixTest(PyUnusedLocalInspection.class, PyPsiBundle.message("QFIX.NAME.remove.statement"));
+ doQuickFixTest(PyUnusedFunctionInspection.class, PyPsiBundle.message("QFIX.NAME.remove.statement"));
}
public void testVariable() {
diff --git a/python/testSrc/com/jetbrains/python/testing/PyTestFixtureAndParametrizedTest.kt b/python/testSrc/com/jetbrains/python/testing/PyTestFixtureAndParametrizedTest.kt
index 9ab8938c6cb6..b2ba1a0481ba 100644
--- a/python/testSrc/com/jetbrains/python/testing/PyTestFixtureAndParametrizedTest.kt
+++ b/python/testSrc/com/jetbrains/python/testing/PyTestFixtureAndParametrizedTest.kt
@@ -5,6 +5,7 @@ import com.intellij.idea.TestFor
import com.intellij.testFramework.fixtures.CodeInsightTestFixture
import com.jetbrains.python.fixtures.PyTestCase
import com.jetbrains.python.inspections.unusedLocal.PyUnusedLocalInspection
+import com.jetbrains.python.inspections.unusedLocal.PyUnusedParameterInspection
import com.jetbrains.python.testing.pyTestParametrized.PyTestParametrizedInspection
/**
@@ -15,7 +16,8 @@ class PyTestFixtureAndParametrizedTest : PyTestCase() {
const val testSubfolder = "/testCompletion"
fun testInspectionStatic(fixture: CodeInsightTestFixture) {
fixture.configureByFile("test_for_inspection_test.py")
- fixture.enableInspections(PyUnusedLocalInspection::class.java, PyTestParametrizedInspection::class.java)
+ fixture.enableInspections(PyUnusedLocalInspection::class.java, PyUnusedParameterInspection::class.java,
+ PyTestParametrizedInspection::class.java)
fixture.checkHighlighting(true, false, true)
}
}