diff --git a/README.md b/README.md
index 66bffd1d..b8a533c5 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ while you build your application.
- Gradle Kotlin and Groovy (gradle)
- Golang (go mod)
- Rust (cargo)
-- Python (pip) ecosystems, and base images in Dockerfile.
+- Python (pip, pyproject.toml) ecosystems, and base images in Dockerfile.
In future releases, Red Hat plans to support other package managers.
@@ -108,7 +108,7 @@ according to your preferences.
dependencies for Rust projects.
If the path is not provided, your IDE's `PATH` environment will be used to locate the executable.
-- **Python** (`requirements.txt`) :
+- **Python** (`requirements.txt`, `pyproject.toml`) :
Set the full paths of the Python and the package installer for Python executables, which allows Exhort to locate
and run the `pip3` commands to resolve dependencies for Python projects.
Python 2 executables `python` and `pip` can be used instead, if the `Use python 2.x` option is selected.
@@ -318,6 +318,21 @@ When modifying the grammar or lexer files, you need to regenerate the parser cla
tokio = { version = "1.0", features = ["full"] } # trustify-da-ignore
```
+ If you want to ignore vulnerabilities for a dependency in a `pyproject.toml` file, you must add `trustify-da-ignore` as a comment
+ against the dependency in the manifest file.
+ For PEP 621 format:
+ ```toml
+ [project]
+ dependencies = [
+ "anyio==3.6.2", # trustify-da-ignore
+ ]
+ ```
+ For Poetry format:
+ ```toml
+ [tool.poetry.dependencies]
+ anyio = "^3.6.2" # trustify-da-ignore
+ ```
+
- **Excluding developmental or test dependencies**
Red Hat Dependency Analytics does not analyze dependencies marked as `dev` or `test`, these dependencies are
ignored.
@@ -365,6 +380,10 @@ When modifying the grammar or lexer files, you need to regenerate the parser cla
You can create an alternative file to `requirements.txt`, for example, a `requirements-dev.txt` or
a `requirements-test.txt` file where you can add the development or test dependencies there.
+ For `pyproject.toml`, dependencies from `[project.dependencies]` (PEP 621),
+ `[project.optional-dependencies]`, and `[tool.poetry.dependencies]` are analyzed.
+ Poetry group dependencies (`[tool.poetry.group.*.dependencies]`) are excluded.
+
- **Excluding manifest files with patterns**
You can exclude specific manifest files from component analysis using configurable glob patterns. This feature allows you to avoid analyzing third-party dependencies, test files, or other manifests that are not relevant to your security analysis.
diff --git a/src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java b/src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java
index 296eec87..93ea1f45 100644
--- a/src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java
+++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java
@@ -524,7 +524,7 @@ public static String getPackageManager(String file) {
case "pom.xml" -> "maven";
case "package.json" -> "npm";
case "go.mod" -> "go";
- case "requirements.txt" -> "python";
+ case "requirements.txt", "pyproject.toml" -> "python";
case "build.gradle", "build.gradle.kts" -> "gradle";
case "Cargo.toml" -> "cargo";
default -> null;
diff --git a/src/main/java/org/jboss/tools/intellij/componentanalysis/SAIntentionAction.java b/src/main/java/org/jboss/tools/intellij/componentanalysis/SAIntentionAction.java
index 6bb50637..b57fccaf 100644
--- a/src/main/java/org/jboss/tools/intellij/componentanalysis/SAIntentionAction.java
+++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/SAIntentionAction.java
@@ -49,7 +49,8 @@ public boolean isAvailable(@NotNull Project project, Editor editor, PsiFile file
|| "requirements.txt".equals(file.getName())
|| "build.gradle".equals(file.getName())
|| "build.gradle.kts".equals(file.getName())
- || "Cargo.toml".equals(file.getName());
+ || "Cargo.toml".equals(file.getName())
+ || "pyproject.toml".equals(file.getName());
}
@Override
diff --git a/src/main/java/org/jboss/tools/intellij/componentanalysis/cargo/CargoCAAnnotator.java b/src/main/java/org/jboss/tools/intellij/componentanalysis/cargo/CargoCAAnnotator.java
index fe88c249..dd129db4 100644
--- a/src/main/java/org/jboss/tools/intellij/componentanalysis/cargo/CargoCAAnnotator.java
+++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/cargo/CargoCAAnnotator.java
@@ -70,6 +70,10 @@ protected String getInspectionShortName() {
@Override
protected Map> getDependencies(PsiFile file) {
+ if (!"Cargo.toml".equals(file.getName())) {
+ return Map.of();
+ }
+
Map> resultMap = new HashMap<>();
Set commentIgnoredDeps = getIgnoredDependencies(file);
@@ -381,4 +385,4 @@ private void parseFlatDependencies(TomlTable table, Set ignoredDeps, Map
resultMap.computeIfAbsent(dp, k -> new LinkedList<>()).add(keyValue);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotator.java b/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotator.java
new file mode 100644
index 00000000..9ede9c8b
--- /dev/null
+++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotator.java
@@ -0,0 +1,420 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Red Hat, Inc.
+ * Distributed under license by Red Hat, Inc. All rights reserved.
+ * This program is made available under the terms of the
+ * Eclipse Public License v2.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v20.html
+ *
+ * Contributors:
+ * Red Hat, Inc. - initial API and implementation
+ ******************************************************************************/
+
+package org.jboss.tools.intellij.componentanalysis.pypi;
+
+import com.intellij.openapi.editor.Document;
+import com.intellij.psi.PsiComment;
+import com.intellij.psi.PsiDocumentManager;
+import com.intellij.psi.PsiElement;
+import com.intellij.psi.PsiFile;
+import com.intellij.psi.util.PsiTreeUtil;
+import io.github.guacsec.trustifyda.api.v5.DependencyReport;
+import org.jboss.tools.intellij.componentanalysis.CAAnnotator;
+import org.jboss.tools.intellij.componentanalysis.CAIntentionAction;
+import org.jboss.tools.intellij.componentanalysis.CAUpdateManifestIntentionAction;
+import org.jboss.tools.intellij.componentanalysis.Dependency;
+import org.jboss.tools.intellij.componentanalysis.LicenseUpdateIntentionAction;
+import org.jboss.tools.intellij.componentanalysis.VulnerabilitySource;
+import org.jetbrains.annotations.Nullable;
+import org.toml.lang.psi.TomlArray;
+import org.toml.lang.psi.TomlInlineTable;
+import org.toml.lang.psi.TomlKey;
+import org.toml.lang.psi.TomlKeySegment;
+import org.toml.lang.psi.TomlKeyValue;
+import org.toml.lang.psi.TomlLiteral;
+import org.toml.lang.psi.TomlTable;
+import org.toml.lang.psi.TomlTableHeader;
+import org.toml.lang.psi.TomlValue;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.jboss.tools.intellij.componentanalysis.CAUtil.EXHORT_IGNORE;
+import static org.jboss.tools.intellij.componentanalysis.CAUtil.TRUSTIFY_DA_IGNORE;
+
+public class PyprojectCAAnnotator extends CAAnnotator {
+
+ private static final String PYPI = "pypi";
+ private static final String PYPROJECT_TOML = "pyproject.toml";
+
+ /** Matches PEP 508 dependency name: everything before the first version specifier or extra marker. */
+ private static final Pattern PEP508_NAME_PATTERN = Pattern.compile("^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)");
+
+ /** Matches version specifiers like ==3.6.2, >=2.0, ~=1.4, etc. */
+ private static final Pattern PEP508_VERSION_PATTERN = Pattern.compile("([<>=!~]+\\s*[^,;\\s]+)");
+
+ @Override
+ protected String getInspectionShortName() {
+ return PyprojectCAInspection.SHORT_NAME;
+ }
+
+ @Override
+ protected Map> getDependencies(PsiFile file) {
+ if (!"pyproject.toml".equals(file.getName())) {
+ return Map.of();
+ }
+
+ Map> resultMap = new HashMap<>();
+ Set ignoredDeps = getIgnoredDependencies(file);
+
+ List tables = PsiTreeUtil.getChildrenOfTypeAsList(file, TomlTable.class);
+ for (TomlTable table : tables) {
+ TomlTableHeader header = table.getHeader();
+ if (header == null) {
+ continue;
+ }
+ String tablePath = getTablePath(header);
+ if ("project".equals(tablePath)) {
+ parsePep621Dependencies(table, ignoredDeps, resultMap);
+ } else if ("project.optional-dependencies".equals(tablePath)) {
+ parseOptionalDependencies(table, ignoredDeps, resultMap);
+ } else if ("tool.poetry.dependencies".equals(tablePath)) {
+ parsePoetryDependencies(table, ignoredDeps, resultMap);
+ }
+ }
+
+ return resultMap;
+ }
+
+ @Override
+ protected CAIntentionAction createQuickFix(PsiElement element, VulnerabilitySource source, DependencyReport report) {
+ return new PyprojectCAIntentionAction(element, source, report);
+ }
+
+ @Override
+ protected CAUpdateManifestIntentionAction patchManifest(PsiElement element, DependencyReport report) {
+ return null;
+ }
+
+ @Override
+ protected boolean isQuickFixApplicable(PsiElement element) {
+ if (element.getContainingFile() == null || !PYPROJECT_TOML.equals(element.getContainingFile().getName())) {
+ return false;
+ }
+ // Poetry: key-value pair like anyio = "^3.6.2"
+ if (element instanceof TomlKeyValue) {
+ return true;
+ }
+ // PEP 621: string literal in dependencies array like "anyio==3.6.2"
+ return element instanceof TomlLiteral;
+ }
+
+ @Override
+ protected @Nullable PsiElement getLicenseFieldPsiElement(PsiFile file) {
+ return null;
+ }
+
+ @Override
+ protected @Nullable LicenseUpdateIntentionAction createLicenseUpdateFix(PsiElement element, String newLicense) {
+ return null;
+ }
+
+ private void parsePep621Dependencies(TomlTable projectTable, Set ignoredDeps,
+ Map> resultMap) {
+ List keyValues = PsiTreeUtil.getChildrenOfTypeAsList(projectTable, TomlKeyValue.class);
+ for (TomlKeyValue kv : keyValues) {
+ if (!"dependencies".equals(kv.getKey().getText())) {
+ continue;
+ }
+ TomlValue value = kv.getValue();
+ if (!(value instanceof TomlArray array)) {
+ continue;
+ }
+ for (TomlValue element : array.getElements()) {
+ if (!(element instanceof TomlLiteral literal)) {
+ continue;
+ }
+ String depString = unquote(literal.getText());
+ String name = extractPep508Name(depString);
+ if (name == null || ignoredDeps.contains(name.toLowerCase())) {
+ continue;
+ }
+ String version = extractPep508Version(depString);
+ Dependency dp = new Dependency(PYPI, null, name.toLowerCase(), version);
+ resultMap.computeIfAbsent(dp, k -> new LinkedList<>()).add(literal);
+ }
+ }
+ }
+
+ /**
+ * Parses PEP 621 [project.optional-dependencies] table.
+ * Format: dev = ["pytest>=7.0", "black"], security = ["certifi>=2023.7"]
+ */
+ private void parseOptionalDependencies(TomlTable optDepsTable, Set ignoredDeps,
+ Map> resultMap) {
+ List groups = PsiTreeUtil.getChildrenOfTypeAsList(optDepsTable, TomlKeyValue.class);
+ for (TomlKeyValue group : groups) {
+ TomlValue value = group.getValue();
+ if (!(value instanceof TomlArray array)) {
+ continue;
+ }
+ for (TomlValue element : array.getElements()) {
+ if (!(element instanceof TomlLiteral literal)) {
+ continue;
+ }
+ String depString = unquote(literal.getText());
+ String name = extractPep508Name(depString);
+ if (name == null || ignoredDeps.contains(name.toLowerCase())) {
+ continue;
+ }
+ String version = extractPep508Version(depString);
+ Dependency dp = new Dependency(PYPI, null, name.toLowerCase(), version);
+ resultMap.computeIfAbsent(dp, k -> new LinkedList<>()).add(literal);
+ }
+ }
+ }
+
+ /**
+ * Parses Poetry [tool.poetry.dependencies] table.
+ * Format: anyio = "^3.6.2" or anyio = {version = "^3.6.2", optional = true}
+ */
+ private void parsePoetryDependencies(TomlTable poetryTable, Set ignoredDeps,
+ Map> resultMap) {
+ List keyValues = PsiTreeUtil.getChildrenOfTypeAsList(poetryTable, TomlKeyValue.class);
+ for (TomlKeyValue kv : keyValues) {
+ String name = normalizeKeyName(kv.getKey().getText());
+ if ("python".equalsIgnoreCase(name) || ignoredDeps.contains(name.toLowerCase())) {
+ continue;
+ }
+ String version = extractPoetryVersion(kv.getValue());
+ Dependency dp = new Dependency(PYPI, null, name.toLowerCase(), version);
+ resultMap.computeIfAbsent(dp, k -> new LinkedList<>()).add(kv);
+ }
+ }
+
+ private String extractPoetryVersion(TomlValue value) {
+ if (value instanceof TomlLiteral literal) {
+ return unquote(literal.getText());
+ }
+ if (value instanceof TomlInlineTable inlineTable) {
+ for (TomlKeyValue entry : PsiTreeUtil.getChildrenOfTypeAsList(inlineTable, TomlKeyValue.class)) {
+ if ("version".equals(entry.getKey().getText()) && entry.getValue() instanceof TomlLiteral) {
+ return unquote(((TomlLiteral) entry.getValue()).getText());
+ }
+ }
+ }
+ return null;
+ }
+
+ private Set getIgnoredDependencies(PsiFile file) {
+ Set ignoredDeps = new HashSet<>();
+ Collection comments = PsiTreeUtil.collectElementsOfType(file, PsiComment.class);
+ for (PsiComment comment : comments) {
+ String commentText = comment.getText();
+ if (commentText.contains(TRUSTIFY_DA_IGNORE) || commentText.contains(EXHORT_IGNORE)) {
+ String dependencyName = findAssociatedDependency(file, comment);
+ if (dependencyName != null) {
+ ignoredDeps.add(dependencyName.toLowerCase());
+ }
+ }
+ }
+ return ignoredDeps;
+ }
+
+ private String findAssociatedDependency(PsiFile file, PsiComment comment) {
+ TomlKeyValue keyValue = findKeyValueOnSameLine(file, comment);
+ if (keyValue != null) {
+ if (isInPoetryDependencies(keyValue)) {
+ TomlKeyValue depEntry = resolvePoetryDependencyEntry(keyValue);
+ return normalizeKeyName(depEntry.getKey().getText());
+ }
+ }
+
+ // Check array elements — PEP 621 format where comment is on the same line as a literal in the array
+ TomlLiteral literal = findLiteralOnSameLine(file, comment);
+ if (literal != null) {
+ String text = unquote(literal.getText());
+ String name = extractPep508Name(text);
+ if (name != null) {
+ return name;
+ }
+ }
+
+ return null;
+ }
+
+ private boolean isInPoetryDependencies(TomlKeyValue keyValue) {
+ TomlTable parentTable = PsiTreeUtil.getParentOfType(keyValue, TomlTable.class);
+ if (parentTable != null) {
+ String tablePath = getTablePath(parentTable.getHeader());
+ return "tool.poetry.dependencies".equals(tablePath);
+ }
+ return false;
+ }
+
+ private TomlKeyValue resolvePoetryDependencyEntry(TomlKeyValue keyValue) {
+ // When the comment is on an inner key of a multi-line inline table,
+ // walk up to the outer TomlKeyValue that is the actual dependency entry.
+ TomlInlineTable inlineTable = PsiTreeUtil.getParentOfType(keyValue, TomlInlineTable.class);
+ if (inlineTable != null) {
+ PsiElement parent = inlineTable.getParent();
+ if (parent instanceof TomlKeyValue outerKv) {
+ return outerKv;
+ }
+ }
+ return keyValue;
+ }
+
+ private TomlKeyValue findKeyValueOnSameLine(PsiFile file, PsiComment comment) {
+ int commentLine = getLineNumber(file, comment);
+ Collection allKeyValues = PsiTreeUtil.collectElementsOfType(file, TomlKeyValue.class);
+ for (TomlKeyValue keyValue : allKeyValues) {
+ int keyValueLine = getLineNumber(file, keyValue);
+ if (commentLine == keyValueLine) {
+ return keyValue;
+ }
+ }
+ return null;
+ }
+
+ private TomlLiteral findLiteralOnSameLine(PsiFile file, PsiComment comment) {
+ int commentLine = getLineNumber(file, comment);
+ Collection literals = PsiTreeUtil.collectElementsOfType(file, TomlLiteral.class);
+ for (TomlLiteral literal : literals) {
+ int literalLine = getLineNumber(file, literal);
+ if (commentLine == literalLine) {
+ return literal;
+ }
+ }
+ return null;
+ }
+
+ private int getLineNumber(PsiFile file, PsiElement element) {
+ Document document = PsiDocumentManager.getInstance(element.getProject()).getDocument(file);
+ if (document == null) {
+ return -1;
+ }
+ int offset = element.getTextRange().getStartOffset();
+ return document.getLineNumber(offset);
+ }
+
+ private String getTablePath(TomlTableHeader header) {
+ TomlKey key = header.getKey();
+ if (key == null) {
+ return "";
+ }
+ List segments = key.getSegments();
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < segments.size(); i++) {
+ if (i > 0) {
+ sb.append(".");
+ }
+ sb.append(segments.get(i).getName());
+ }
+ return sb.toString();
+ }
+
+ private String normalizeKeyName(String keyText) {
+ if ((keyText.startsWith("\"") && keyText.endsWith("\"")) ||
+ (keyText.startsWith("'") && keyText.endsWith("'"))) {
+ if (keyText.length() > 2) {
+ return keyText.substring(1, keyText.length() - 1);
+ }
+ }
+ return keyText;
+ }
+
+ static String unquote(String text) {
+ if (text != null && text.length() >= 2) {
+ if ((text.startsWith("\"") && text.endsWith("\"")) ||
+ (text.startsWith("'") && text.endsWith("'"))) {
+ return text.substring(1, text.length() - 1);
+ }
+ }
+ return text;
+ }
+
+ static String extractPep508Name(String depString) {
+ if (depString == null || depString.isEmpty()) {
+ return null;
+ }
+ Matcher matcher = PEP508_NAME_PATTERN.matcher(depString.trim());
+ if (matcher.find()) {
+ return matcher.group(1);
+ }
+ return null;
+ }
+
+ static String extractPep508Extras(String depString) {
+ if (depString == null || depString.isEmpty()) {
+ return null;
+ }
+ Matcher nameMatcher = PEP508_NAME_PATTERN.matcher(depString.trim());
+ if (!nameMatcher.find()) {
+ return null;
+ }
+ String afterName = depString.substring(nameMatcher.end()).trim();
+ if (afterName.startsWith("[")) {
+ int closeBracket = afterName.indexOf(']');
+ if (closeBracket >= 0) {
+ return afterName.substring(0, closeBracket + 1);
+ }
+ }
+ return null;
+ }
+
+ static String extractPep508Markers(String depString) {
+ if (depString == null || depString.isEmpty()) {
+ return null;
+ }
+ int semiColon = depString.indexOf(';');
+ if (semiColon >= 0) {
+ String markers = depString.substring(semiColon).trim();
+ return markers.isEmpty() ? null : markers;
+ }
+ return null;
+ }
+
+ static String extractPep508Version(String depString) {
+ if (depString == null || depString.isEmpty()) {
+ return null;
+ }
+ // Remove the name part first
+ Matcher nameMatcher = PEP508_NAME_PATTERN.matcher(depString.trim());
+ if (!nameMatcher.find()) {
+ return null;
+ }
+ String afterName = depString.substring(nameMatcher.end()).trim();
+ // Remove extras like [security]
+ if (afterName.startsWith("[")) {
+ int closeBracket = afterName.indexOf(']');
+ if (closeBracket >= 0) {
+ afterName = afterName.substring(closeBracket + 1).trim();
+ }
+ }
+ // Strip environment markers (everything after ';')
+ int semiColon = afterName.indexOf(';');
+ if (semiColon >= 0) {
+ afterName = afterName.substring(0, semiColon).trim();
+ }
+ if (afterName.isEmpty()) {
+ return null;
+ }
+ // Collect all version specifiers
+ Matcher versionMatcher = PEP508_VERSION_PATTERN.matcher(afterName);
+ StringBuilder versionSpec = new StringBuilder();
+ while (versionMatcher.find()) {
+ if (versionSpec.length() > 0) {
+ versionSpec.append(",");
+ }
+ versionSpec.append(versionMatcher.group(1).trim());
+ }
+ return versionSpec.length() > 0 ? versionSpec.toString() : null;
+ }
+}
diff --git a/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAInspection.java b/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAInspection.java
new file mode 100644
index 00000000..a6223f2f
--- /dev/null
+++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAInspection.java
@@ -0,0 +1,39 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Red Hat, Inc.
+ * Distributed under license by Red Hat, Inc. All rights reserved.
+ * This program is made available under the terms of the
+ * Eclipse Public License v2.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v20.html
+ *
+ * Contributors:
+ * Red Hat, Inc. - initial API and implementation
+ ******************************************************************************/
+
+package org.jboss.tools.intellij.componentanalysis.pypi;
+
+import com.intellij.codeHighlighting.HighlightDisplayLevel;
+import com.intellij.codeInspection.LocalInspectionTool;
+import org.jetbrains.annotations.Nls;
+import org.jetbrains.annotations.NonNls;
+import org.jetbrains.annotations.NotNull;
+
+public class PyprojectCAInspection extends LocalInspectionTool {
+
+ @NonNls
+ public static final String SHORT_NAME = "PyprojectCAInspection";
+
+ @Override
+ public @Nls(capitalization = Nls.Capitalization.Sentence) @NotNull String getGroupDisplayName() {
+ return "Python";
+ }
+
+ @Override
+ public @NonNls @NotNull String getShortName() {
+ return SHORT_NAME;
+ }
+
+ @Override
+ public @NotNull HighlightDisplayLevel getDefaultLevel() {
+ return HighlightDisplayLevel.ERROR;
+ }
+}
diff --git a/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAIntentionAction.java b/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAIntentionAction.java
new file mode 100644
index 00000000..bff739da
--- /dev/null
+++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAIntentionAction.java
@@ -0,0 +1,117 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Red Hat, Inc.
+ * Distributed under license by Red Hat, Inc. All rights reserved.
+ * This program is made available under the terms of the
+ * Eclipse Public License v2.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v20.html
+ *
+ * Contributors:
+ * Red Hat, Inc. - initial API and implementation
+ ******************************************************************************/
+
+package org.jboss.tools.intellij.componentanalysis.pypi;
+
+import com.intellij.codeInsight.intention.FileModifier;
+import com.intellij.openapi.editor.Document;
+import com.intellij.openapi.editor.Editor;
+import com.intellij.openapi.project.Project;
+import com.intellij.psi.PsiDocumentManager;
+import com.intellij.psi.PsiElement;
+import com.intellij.psi.PsiFile;
+import com.intellij.psi.util.PsiTreeUtil;
+import io.github.guacsec.trustifyda.api.v5.DependencyReport;
+import org.jboss.tools.intellij.componentanalysis.CAIntentionAction;
+import org.jboss.tools.intellij.componentanalysis.VulnerabilitySource;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.toml.lang.psi.TomlInlineTable;
+import org.toml.lang.psi.TomlKeyValue;
+import org.toml.lang.psi.TomlLiteral;
+
+public final class PyprojectCAIntentionAction extends CAIntentionAction {
+
+ PyprojectCAIntentionAction(PsiElement element, VulnerabilitySource source, DependencyReport report) {
+ super(element, source, report);
+ }
+
+ @Override
+ protected void updateVersion(@NotNull Project project, Editor editor, PsiFile file, String version) {
+ if (version == null) {
+ return;
+ }
+
+ if (element instanceof TomlLiteral literal) {
+ // PEP 621: string literal in dependencies array like "anyio==3.6.2"
+ String depString = PyprojectCAAnnotator.unquote(literal.getText());
+ String name = PyprojectCAAnnotator.extractPep508Name(depString);
+ if (name != null) {
+ String extras = PyprojectCAAnnotator.extractPep508Extras(depString);
+ String markers = PyprojectCAAnnotator.extractPep508Markers(depString);
+ StringBuilder newDep = new StringBuilder(name);
+ if (extras != null) {
+ newDep.append(extras);
+ }
+ newDep.append("==").append(version);
+ if (markers != null) {
+ newDep.append(" ").append(markers);
+ }
+ replaceVersionLiteral(project, file, literal, newDep.toString());
+ }
+ } else if (element instanceof TomlKeyValue keyValue) {
+ // Poetry: key-value pair like anyio = "^3.6.2"
+ TomlLiteral valueLiteral = findVersionLiteral(keyValue);
+ if (valueLiteral != null) {
+ replaceVersionLiteral(project, file, valueLiteral, "==" + version);
+ }
+ }
+ }
+
+ private TomlLiteral findVersionLiteral(TomlKeyValue keyValue) {
+ if (keyValue.getValue() instanceof TomlLiteral literal) {
+ // Poetry simple string: anyio = "^3.6.2"
+ return literal;
+ }
+ if (keyValue.getValue() instanceof TomlInlineTable inlineTable) {
+ // Poetry inline table: anyio = {version = "^3.6.2"}
+ for (TomlKeyValue entry : PsiTreeUtil.getChildrenOfTypeAsList(inlineTable, TomlKeyValue.class)) {
+ if ("version".equals(entry.getKey().getText()) && entry.getValue() instanceof TomlLiteral) {
+ return (TomlLiteral) entry.getValue();
+ }
+ }
+ }
+ return null;
+ }
+
+ private void replaceVersionLiteral(@NotNull Project project, PsiFile file, TomlLiteral literal, String version) {
+ Document document = PsiDocumentManager.getInstance(project).getDocument(file);
+ if (document != null) {
+ String oldText = literal.getText();
+ String newVersionText;
+
+ if (oldText.startsWith("\"") && oldText.endsWith("\"")) {
+ newVersionText = "\"" + version + "\"";
+ } else if (oldText.startsWith("'") && oldText.endsWith("'")) {
+ newVersionText = "'" + version + "'";
+ } else {
+ newVersionText = "\"" + version + "\"";
+ }
+
+ int startOffset = literal.getTextRange().getStartOffset();
+ int endOffset = literal.getTextRange().getEndOffset();
+
+ document.replaceString(startOffset, endOffset, newVersionText);
+ PsiDocumentManager.getInstance(project).commitDocument(document);
+ }
+ }
+
+ @Override
+ protected @Nullable FileModifier createCAIntentionActionInCopy(PsiElement element) {
+ return new PyprojectCAIntentionAction(element, this.source, this.report);
+ }
+
+ @Override
+ public boolean isAvailable(@NotNull Project project, Editor editor, PsiFile file) {
+ return file != null && "pyproject.toml".equals(file.getName())
+ && (element instanceof TomlKeyValue || element instanceof TomlLiteral);
+ }
+}
diff --git a/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java b/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java
index b4cf5fd7..536c2fac 100644
--- a/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java
+++ b/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java
@@ -250,14 +250,14 @@ private void setRequestProperties(final String manifestName) {
System.clearProperty("TRUSTIFY_DA_PYTHON_VIRTUAL_ENV");
System.clearProperty("TRUSTIFY_DA_PYTHON_INSTALL_BEST_EFFORTS");
}
- if ("requirements.txt".equals(manifestName)) {
+ if ("requirements.txt".equals(manifestName) || "pyproject.toml".equals(manifestName)) {
if (settings.pythonMatchManifestVersions) {
System.setProperty("MATCH_MANIFEST_VERSIONS", "true");
} else {
System.setProperty("MATCH_MANIFEST_VERSIONS","false");
}
}
- if (!"go.mod".equals(manifestName) && !"requirements.txt".equals(manifestName)) {
+ if (!"go.mod".equals(manifestName) && !"requirements.txt".equals(manifestName) && !"pyproject.toml".equals(manifestName)) {
System.clearProperty("MATCH_MANIFEST_VERSIONS");
}
if (settings.licenseCheckEnabled) {
diff --git a/src/main/java/org/jboss/tools/intellij/stackanalysis/SaAction.java b/src/main/java/org/jboss/tools/intellij/stackanalysis/SaAction.java
index 419abbb2..481a7c8e 100644
--- a/src/main/java/org/jboss/tools/intellij/stackanalysis/SaAction.java
+++ b/src/main/java/org/jboss/tools/intellij/stackanalysis/SaAction.java
@@ -41,7 +41,8 @@ public class SaAction extends AnAction {
"requirements.txt",
"build.gradle",
"build.gradle.kts",
- "Cargo.toml"
+ "Cargo.toml",
+ "pyproject.toml"
);
public SaAction() {
diff --git a/src/main/java/org/jboss/tools/intellij/stackanalysis/SaUtils.java b/src/main/java/org/jboss/tools/intellij/stackanalysis/SaUtils.java
index 640ddcb6..9bfcc44c 100644
--- a/src/main/java/org/jboss/tools/intellij/stackanalysis/SaUtils.java
+++ b/src/main/java/org/jboss/tools/intellij/stackanalysis/SaUtils.java
@@ -27,7 +27,8 @@ public JsonObject performSA(VirtualFile manifestFile) {
|| "requirements.txt".equals(manifestFile.getName())
|| "build.gradle".equals(manifestFile.getName())
|| "build.gradle.kts".equals(manifestFile.getName())
- || "Cargo.toml".equals(manifestFile.getName())) {
+ || "Cargo.toml".equals(manifestFile.getName())
+ || "pyproject.toml".equals(manifestFile.getName())) {
ApiService apiService = ServiceManager.getService(ApiService.class);
reportLink = apiService.getStackAnalysis(
determinePackageManagerName(manifestFile.getName()),
@@ -61,7 +62,7 @@ private String determinePackageManagerName(String name) {
case "go.mod":
packageManager = "go";
break;
- case "requirements.txt":
+ case "requirements.txt", "pyproject.toml":
packageManager = "python";
break;
case "build.gradle", "build.gradle.kts":
diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml
index ebfae6fb..65a4278d 100644
--- a/src/main/resources/META-INF/plugin.xml
+++ b/src/main/resources/META-INF/plugin.xml
@@ -581,6 +581,15 @@
+
+
+
+
+
diff --git a/src/test/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotatorTest.java b/src/test/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotatorTest.java
new file mode 100644
index 00000000..ce1bdaa9
--- /dev/null
+++ b/src/test/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotatorTest.java
@@ -0,0 +1,458 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Red Hat, Inc.
+ * Distributed under license by Red Hat, Inc. All rights reserved.
+ * This program is made available under the terms of the
+ * Eclipse Public License v2.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v20.html
+ *
+ * Contributors:
+ * Red Hat, Inc. - initial API and implementation
+ ******************************************************************************/
+
+package org.jboss.tools.intellij.componentanalysis.pypi;
+
+import com.intellij.psi.PsiElement;
+import com.intellij.psi.PsiFile;
+import com.intellij.testFramework.fixtures.BasePlatformTestCase;
+import org.jboss.tools.intellij.componentanalysis.Dependency;
+import org.junit.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Tests for PyprojectCAAnnotator dependency extraction from pyproject.toml files.
+ */
+public class PyprojectCAAnnotatorTest extends BasePlatformTestCase {
+
+ /**
+ * Testable subclass that exposes the protected getDependencies method.
+ */
+ private static class TestablePyprojectCAAnnotator extends PyprojectCAAnnotator {
+ @Override
+ public Map> getDependencies(PsiFile file) {
+ return super.getDependencies(file);
+ }
+ }
+
+ // ── PEP 508 utility method tests ──────────────────────────────────────────
+
+ /** Verifies that a simple package name is extracted from a PEP 508 string. */
+ @Test
+ public void testExtractPep508NameSimple() {
+ assertEquals("anyio", PyprojectCAAnnotator.extractPep508Name("anyio==3.6.2"));
+ }
+
+ /** Verifies name extraction from a PEP 508 string with extras. */
+ @Test
+ public void testExtractPep508NameWithExtras() {
+ assertEquals("requests", PyprojectCAAnnotator.extractPep508Name("requests[security]>=2.25.0"));
+ }
+
+ /** Verifies name extraction from a PEP 508 string with environment markers. */
+ @Test
+ public void testExtractPep508NameWithMarkers() {
+ assertEquals("importlib-metadata", PyprojectCAAnnotator.extractPep508Name("importlib-metadata>=4.0; python_version<'3.8'"));
+ }
+
+ /** Verifies that a bare package name without version is extracted. */
+ @Test
+ public void testExtractPep508NameBare() {
+ assertEquals("setuptools", PyprojectCAAnnotator.extractPep508Name("setuptools"));
+ }
+
+ /** Verifies null is returned for null input. */
+ @Test
+ public void testExtractPep508NameNull() {
+ assertNull(PyprojectCAAnnotator.extractPep508Name(null));
+ }
+
+ /** Verifies null is returned for empty string. */
+ @Test
+ public void testExtractPep508NameEmpty() {
+ assertNull(PyprojectCAAnnotator.extractPep508Name(""));
+ }
+
+ /** Verifies that a pinned version specifier is extracted. */
+ @Test
+ public void testExtractPep508VersionPinned() {
+ assertEquals("==3.6.2", PyprojectCAAnnotator.extractPep508Version("anyio==3.6.2"));
+ }
+
+ /** Verifies that a range version specifier is extracted. */
+ @Test
+ public void testExtractPep508VersionRange() {
+ assertEquals(">=2.25.0", PyprojectCAAnnotator.extractPep508Version("requests>=2.25.0"));
+ }
+
+ /** Verifies that compound version specifiers are joined with commas. */
+ @Test
+ public void testExtractPep508VersionCompound() {
+ assertEquals(">=1.0,<2.0", PyprojectCAAnnotator.extractPep508Version("flask>=1.0,<2.0"));
+ }
+
+ /** Verifies that version extraction ignores environment markers after semicolon. */
+ @Test
+ public void testExtractPep508VersionWithMarkers() {
+ assertEquals(">=4.0", PyprojectCAAnnotator.extractPep508Version("importlib-metadata>=4.0; python_version<'3.8'"));
+ }
+
+ /** Verifies that version extraction ignores extras brackets. */
+ @Test
+ public void testExtractPep508VersionWithExtras() {
+ assertEquals(">=2.25.0", PyprojectCAAnnotator.extractPep508Version("requests[security]>=2.25.0"));
+ }
+
+ /** Verifies null is returned when no version specifier is present. */
+ @Test
+ public void testExtractPep508VersionNone() {
+ assertNull(PyprojectCAAnnotator.extractPep508Version("setuptools"));
+ }
+
+ /** Verifies that extras brackets are extracted from a PEP 508 string. */
+ @Test
+ public void testExtractPep508Extras() {
+ assertEquals("[security]", PyprojectCAAnnotator.extractPep508Extras("requests[security]>=2.25.0"));
+ }
+
+ /** Verifies null is returned when no extras are present. */
+ @Test
+ public void testExtractPep508ExtrasNone() {
+ assertNull(PyprojectCAAnnotator.extractPep508Extras("anyio==3.6.2"));
+ }
+
+ /** Verifies that environment markers are extracted from a PEP 508 string. */
+ @Test
+ public void testExtractPep508Markers() {
+ assertEquals("; python_version<'3.8'", PyprojectCAAnnotator.extractPep508Markers("importlib-metadata>=4.0; python_version<'3.8'"));
+ }
+
+ /** Verifies null is returned when no markers are present. */
+ @Test
+ public void testExtractPep508MarkersNone() {
+ assertNull(PyprojectCAAnnotator.extractPep508Markers("anyio==3.6.2"));
+ }
+
+ /** Verifies that double-quoted strings are unquoted correctly. */
+ @Test
+ public void testUnquoteDoubleQuotes() {
+ assertEquals("anyio==3.6.2", PyprojectCAAnnotator.unquote("\"anyio==3.6.2\""));
+ }
+
+ /** Verifies that single-quoted strings are unquoted correctly. */
+ @Test
+ public void testUnquoteSingleQuotes() {
+ assertEquals("anyio==3.6.2", PyprojectCAAnnotator.unquote("'anyio==3.6.2'"));
+ }
+
+ /** Verifies that unquoted strings are returned as-is. */
+ @Test
+ public void testUnquoteNoQuotes() {
+ assertEquals("anyio", PyprojectCAAnnotator.unquote("anyio"));
+ }
+
+ // ── getDependencies tests (PEP 621) ────────────────────────────────────────
+
+ /** Verifies that PEP 621 [project.dependencies] array is parsed correctly. */
+ @Test
+ public void testPep621Dependencies() {
+ String content = """
+ [project]
+ name = "my-app"
+ dependencies = [
+ "anyio==3.6.2",
+ "flask>=2.0",
+ "requests[security]>=2.25.0; sys_platform == 'win32'",
+ ]
+ """;
+
+ // Given a pyproject.toml with PEP 621 dependencies
+ TestablePyprojectCAAnnotator annotator = new TestablePyprojectCAAnnotator();
+ PsiFile file = myFixture.configureByText("pyproject.toml", content);
+
+ // When extracting dependencies
+ Map> deps = annotator.getDependencies(file);
+
+ // Then all 3 dependencies should be found
+ assertEquals("Should find 3 PEP 621 dependencies", 3, deps.size());
+ assertTrue("Should contain anyio", containsDependency(deps, "anyio", "==3.6.2"));
+ assertTrue("Should contain flask", containsDependency(deps, "flask", ">=2.0"));
+ assertTrue("Should contain requests", containsDependency(deps, "requests", ">=2.25.0"));
+ }
+
+ /** Verifies that PEP 621 [project.optional-dependencies] groups are parsed. */
+ @Test
+ public void testPep621OptionalDependencies() {
+ String content = """
+ [project.optional-dependencies]
+ dev = [
+ "pytest>=7.0",
+ "black>=23.0",
+ ]
+ security = [
+ "certifi>=2023.7",
+ ]
+ """;
+
+ // Given a pyproject.toml with optional dependency groups
+ TestablePyprojectCAAnnotator annotator = new TestablePyprojectCAAnnotator();
+ PsiFile file = myFixture.configureByText("pyproject.toml", content);
+
+ // When extracting dependencies
+ Map> deps = annotator.getDependencies(file);
+
+ // Then all 3 optional dependencies should be found across groups
+ assertEquals("Should find 3 optional dependencies across groups", 3, deps.size());
+ assertTrue("Should contain pytest", containsDependency(deps, "pytest", ">=7.0"));
+ assertTrue("Should contain black", containsDependency(deps, "black", ">=23.0"));
+ assertTrue("Should contain certifi", containsDependency(deps, "certifi", ">=2023.7"));
+ }
+
+ // ── getDependencies tests (Poetry) ─────────────────────────────────────────
+
+ /** Verifies that Poetry simple string dependencies are parsed correctly. */
+ @Test
+ public void testPoetrySimpleStringDependencies() {
+ String content = """
+ [tool.poetry.dependencies]
+ python = "^3.8"
+ anyio = "^3.6.2"
+ flask = ">=2.0"
+ """;
+
+ // Given a pyproject.toml with Poetry simple string dependencies
+ TestablePyprojectCAAnnotator annotator = new TestablePyprojectCAAnnotator();
+ PsiFile file = myFixture.configureByText("pyproject.toml", content);
+
+ // When extracting dependencies
+ Map> deps = annotator.getDependencies(file);
+
+ // Then python should be skipped and the other 2 should be found
+ assertEquals("Should find 2 dependencies (python is skipped)", 2, deps.size());
+ assertTrue("Should contain anyio", containsDependency(deps, "anyio", "^3.6.2"));
+ assertTrue("Should contain flask", containsDependency(deps, "flask", ">=2.0"));
+ }
+
+ /** Verifies that Poetry inline-table dependencies are parsed correctly. */
+ @Test
+ public void testPoetryInlineTableDependencies() {
+ String content = """
+ [tool.poetry.dependencies]
+ python = "^3.8"
+ requests = {version = "^2.28.0", optional = true}
+ uvicorn = {version = ">=0.18", extras = ["standard"]}
+ """;
+
+ // Given a pyproject.toml with Poetry inline-table dependencies
+ TestablePyprojectCAAnnotator annotator = new TestablePyprojectCAAnnotator();
+ PsiFile file = myFixture.configureByText("pyproject.toml", content);
+
+ // When extracting dependencies
+ Map> deps = annotator.getDependencies(file);
+
+ // Then both inline-table dependencies should be found with their versions
+ assertEquals("Should find 2 dependencies", 2, deps.size());
+ assertTrue("Should contain requests", containsDependency(deps, "requests", "^2.28.0"));
+ assertTrue("Should contain uvicorn", containsDependency(deps, "uvicorn", ">=0.18"));
+ }
+
+ // ── Mixed format tests ─────────────────────────────────────────────────────
+
+ /** Verifies that PEP 621 and Poetry dependencies in the same file are parsed independently. */
+ @Test
+ public void testMixedPep621AndPoetry() {
+ String content = """
+ [project]
+ name = "my-app"
+ dependencies = [
+ "anyio==3.6.2",
+ ]
+
+ [tool.poetry.dependencies]
+ flask = "^2.0"
+ """;
+
+ // Given a pyproject.toml with both PEP 621 and Poetry sections
+ TestablePyprojectCAAnnotator annotator = new TestablePyprojectCAAnnotator();
+ PsiFile file = myFixture.configureByText("pyproject.toml", content);
+
+ // When extracting dependencies
+ Map> deps = annotator.getDependencies(file);
+
+ // Then dependencies from both formats should be found
+ assertEquals("Should find 2 dependencies from both formats", 2, deps.size());
+ assertTrue("Should contain anyio from PEP 621", containsDependency(deps, "anyio", "==3.6.2"));
+ assertTrue("Should contain flask from Poetry", containsDependency(deps, "flask", "^2.0"));
+ }
+
+ // ── File name gating tests ──────────────────────────────────────────────────
+
+ /** Verifies that non-pyproject.toml TOML files are ignored. */
+ @Test
+ public void testNonPyprojectTomlFileReturnsEmpty() {
+ String content = """
+ [project]
+ name = "my-app"
+ dependencies = [
+ "anyio==3.6.2",
+ ]
+ """;
+
+ // Given a TOML file that is not named pyproject.toml
+ TestablePyprojectCAAnnotator annotator = new TestablePyprojectCAAnnotator();
+ PsiFile file = myFixture.configureByText("Cargo.toml", content);
+
+ // When extracting dependencies
+ Map> deps = annotator.getDependencies(file);
+
+ // Then no dependencies should be found
+ assertTrue("Should return empty map for non-pyproject.toml", deps.isEmpty());
+ }
+
+ /** Verifies that a pyproject.toml with no dependency sections returns an empty map. */
+ @Test
+ public void testPyprojectWithNoDependencies() {
+ String content = """
+ [project]
+ name = "my-app"
+ version = "1.0.0"
+ """;
+
+ // Given a pyproject.toml with no dependency arrays or tables
+ TestablePyprojectCAAnnotator annotator = new TestablePyprojectCAAnnotator();
+ PsiFile file = myFixture.configureByText("pyproject.toml", content);
+
+ // When extracting dependencies
+ Map> deps = annotator.getDependencies(file);
+
+ // Then no dependencies should be found
+ assertTrue("Should return empty map when no dependency sections exist", deps.isEmpty());
+ }
+
+ // ── Ignore comment tests ────────────────────────────────────────────────────
+
+ /** Verifies that a Poetry single-line ignore comment excludes the dependency. */
+ @Test
+ public void testPoetryIgnoreCommentSingleLine() {
+ String content = """
+ [tool.poetry.dependencies]
+ python = "^3.8"
+ anyio = "^3.6.2" # trustify-da-ignore
+ flask = ">=2.0"
+ """;
+
+ TestablePyprojectCAAnnotator annotator = new TestablePyprojectCAAnnotator();
+ PsiFile file = myFixture.configureByText("pyproject.toml", content);
+ Map> deps = annotator.getDependencies(file);
+
+ assertFalse("anyio should be ignored", containsDependency(deps, "anyio", "^3.6.2"));
+ assertTrue("flask should still be present", containsDependency(deps, "flask", ">=2.0"));
+ }
+
+ /** Verifies that a Poetry inline-table ignore comment on the same line excludes the dependency. */
+ @Test
+ public void testPoetryIgnoreCommentInlineTableSingleLine() {
+ String content = """
+ [tool.poetry.dependencies]
+ python = "^3.8"
+ requests = {version = "^2.28.0", optional = true} # trustify-da-ignore
+ flask = ">=2.0"
+ """;
+
+ TestablePyprojectCAAnnotator annotator = new TestablePyprojectCAAnnotator();
+ PsiFile file = myFixture.configureByText("pyproject.toml", content);
+ Map> deps = annotator.getDependencies(file);
+
+ assertFalse("requests should be ignored", containsDependency(deps, "requests", "^2.28.0"));
+ assertTrue("flask should still be present", containsDependency(deps, "flask", ">=2.0"));
+ }
+
+ /** Verifies that a Poetry multi-line inline-table ignore comment on an inner key excludes the dependency. */
+ @Test
+ public void testPoetryIgnoreCommentMultiLineInlineTable() {
+ String content = """
+ [tool.poetry.dependencies]
+ python = "^3.8"
+ requests = {
+ version = "^2.28.0", # trustify-da-ignore
+ optional = true
+ }
+ flask = ">=2.0"
+ """;
+
+ TestablePyprojectCAAnnotator annotator = new TestablePyprojectCAAnnotator();
+ PsiFile file = myFixture.configureByText("pyproject.toml", content);
+ Map> deps = annotator.getDependencies(file);
+
+ assertFalse("requests should be ignored via multi-line inline table comment",
+ containsDependency(deps, "requests", "^2.28.0"));
+ assertTrue("flask should still be present", containsDependency(deps, "flask", ">=2.0"));
+ }
+
+ /** Verifies that a PEP 621 ignore comment excludes the dependency. */
+ @Test
+ public void testPep621IgnoreComment() {
+ String content = """
+ [project]
+ name = "my-app"
+ dependencies = [
+ "anyio==3.6.2", # trustify-da-ignore
+ "flask>=2.0",
+ ]
+ """;
+
+ TestablePyprojectCAAnnotator annotator = new TestablePyprojectCAAnnotator();
+ PsiFile file = myFixture.configureByText("pyproject.toml", content);
+ Map> deps = annotator.getDependencies(file);
+
+ assertFalse("anyio should be ignored", containsDependency(deps, "anyio", "==3.6.2"));
+ assertTrue("flask should still be present", containsDependency(deps, "flask", ">=2.0"));
+ }
+
+ // ── SaAction / SaUtils recognition tests ────────────────────────────────────
+
+ /** Verifies that SaAction.supportedManifestFiles contains pyproject.toml. */
+ @Test
+ public void testSaActionRecognizesPyprojectToml() throws Exception {
+ // Given the SaAction class
+ Field field = org.jboss.tools.intellij.stackanalysis.SaAction.class
+ .getDeclaredField("supportedManifestFiles");
+ field.setAccessible(true);
+
+ // When reading the supported manifest files list
+ @SuppressWarnings("unchecked")
+ List manifests = (List) field.get(null);
+
+ // Then pyproject.toml should be included
+ assertTrue("SaAction should recognize pyproject.toml", manifests.contains("pyproject.toml"));
+ }
+
+ /** Verifies that SaUtils.determinePackageManagerName maps pyproject.toml to python. */
+ @Test
+ public void testSaUtilsMapsPyprojectTomlToPython() throws Exception {
+ // Given the SaUtils class
+ org.jboss.tools.intellij.stackanalysis.SaUtils saUtils =
+ new org.jboss.tools.intellij.stackanalysis.SaUtils();
+ Method method = org.jboss.tools.intellij.stackanalysis.SaUtils.class
+ .getDeclaredMethod("determinePackageManagerName", String.class);
+ method.setAccessible(true);
+
+ // When determining the package manager for pyproject.toml
+ String result = (String) method.invoke(saUtils, "pyproject.toml");
+
+ // Then it should map to python
+ assertEquals("pyproject.toml should map to python", "python", result);
+ }
+
+ // ── Helper methods ──────────────────────────────────────────────────────────
+
+ private boolean containsDependency(Map> dependencies,
+ String expectedName, String expectedVersion) {
+ return dependencies.keySet().stream()
+ .anyMatch(dep -> "pypi".equals(dep.getType())
+ && expectedName.equals(dep.getName())
+ && expectedVersion.equals(dep.getVersion()));
+ }
+}
diff --git a/src/test/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAIntentionActionTest.java b/src/test/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAIntentionActionTest.java
new file mode 100644
index 00000000..8c459b9c
--- /dev/null
+++ b/src/test/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAIntentionActionTest.java
@@ -0,0 +1,264 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Red Hat, Inc.
+ * Distributed under license by Red Hat, Inc. All rights reserved.
+ * This program is made available under the terms of the
+ * Eclipse Public License v2.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v20.html
+ *
+ * Contributors:
+ * Red Hat, Inc. - initial API and implementation
+ ******************************************************************************/
+
+package org.jboss.tools.intellij.componentanalysis.pypi;
+
+import com.intellij.openapi.command.WriteCommandAction;
+import com.intellij.psi.PsiElement;
+import com.intellij.psi.PsiFile;
+import com.intellij.testFramework.fixtures.BasePlatformTestCase;
+import io.github.guacsec.trustifyda.api.PackageRef;
+import io.github.guacsec.trustifyda.api.v5.DependencyReport;
+import org.jboss.tools.intellij.componentanalysis.Dependency;
+import org.jboss.tools.intellij.componentanalysis.VulnerabilitySource;
+import org.junit.Test;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Tests for PyprojectCAIntentionAction version update and availability.
+ */
+public class PyprojectCAIntentionActionTest extends BasePlatformTestCase {
+
+ private static final String NEW_VERSION = "4.0.0";
+
+ private DependencyReport createReportWithRecommendation() {
+ DependencyReport report = new DependencyReport();
+ report.setRecommendation(new PackageRef("pkg:pypi/test-package@" + NEW_VERSION));
+ return report;
+ }
+
+ private VulnerabilitySource dummySource() {
+ return new VulnerabilitySource("test-provider", "test-source");
+ }
+
+ /**
+ * Testable subclass that exposes the protected getDependencies method.
+ */
+ private static class TestablePyprojectCAAnnotator extends PyprojectCAAnnotator {
+ @Override
+ public Map> getDependencies(PsiFile file) {
+ return super.getDependencies(file);
+ }
+ }
+
+ private PsiElement findDependencyElement(PsiFile file, String name, String version) {
+ TestablePyprojectCAAnnotator annotator = new TestablePyprojectCAAnnotator();
+ Map> deps = annotator.getDependencies(file);
+ Dependency key = new Dependency("pypi", null, name, version);
+ List elements = deps.get(key);
+ assertNotNull("Should find dependency: " + name, elements);
+ assertFalse("Should have at least one element for: " + name, elements.isEmpty());
+ return elements.get(0);
+ }
+
+ // ── PEP 621 version update tests ────────────────────────────────────────────
+
+ /** Verifies that a simple PEP 621 pinned version is updated correctly. */
+ @Test
+ public void testUpdatePep621SimpleVersion() {
+ String content = """
+ [project]
+ name = "my-app"
+ dependencies = [
+ "anyio==3.6.2",
+ "flask>=2.0",
+ ]
+ """;
+
+ // Given a pyproject.toml with PEP 621 dependencies
+ PsiFile file = myFixture.configureByText("pyproject.toml", content);
+ PsiElement element = findDependencyElement(file, "anyio", "==3.6.2");
+
+ // When applying the quick fix
+ DependencyReport report = createReportWithRecommendation();
+ PyprojectCAIntentionAction action = new PyprojectCAIntentionAction(element, dummySource(), report);
+
+ WriteCommandAction.runWriteCommandAction(getProject(), () ->
+ action.updateVersion(getProject(), null, file, NEW_VERSION)
+ );
+
+ // Then the version should be updated and other deps unchanged
+ String updatedText = file.getText();
+ assertTrue("anyio version should be updated", updatedText.contains("\"anyio==" + NEW_VERSION + "\""));
+ assertTrue("flask should remain unchanged", updatedText.contains("\"flask>=2.0\""));
+ }
+
+ /** Verifies that extras and environment markers are preserved during version update. */
+ @Test
+ public void testUpdatePep621PreservesExtrasAndMarkers() {
+ String content = """
+ [project]
+ name = "my-app"
+ dependencies = [
+ "requests[security]>=2.25.0; sys_platform == 'win32'",
+ ]
+ """;
+
+ // Given a dependency with extras and markers
+ PsiFile file = myFixture.configureByText("pyproject.toml", content);
+ PsiElement element = findDependencyElement(file, "requests", ">=2.25.0");
+
+ // When applying the quick fix
+ DependencyReport report = createReportWithRecommendation();
+ PyprojectCAIntentionAction action = new PyprojectCAIntentionAction(element, dummySource(), report);
+
+ WriteCommandAction.runWriteCommandAction(getProject(), () ->
+ action.updateVersion(getProject(), null, file, NEW_VERSION)
+ );
+
+ // Then extras and markers should be preserved
+ String updatedText = file.getText();
+ assertTrue("Should preserve extras and markers",
+ updatedText.contains("requests[security]==" + NEW_VERSION + " ; sys_platform == 'win32'"));
+ }
+
+ // ── Poetry version update tests ────────────────────────────────────────────
+
+ /** Verifies that a Poetry simple string version is updated correctly. */
+ @Test
+ public void testUpdatePoetrySimpleStringVersion() {
+ String content = """
+ [tool.poetry.dependencies]
+ python = "^3.8"
+ anyio = "^3.6.2"
+ flask = ">=2.0"
+ """;
+
+ // Given a pyproject.toml with Poetry simple string dependencies
+ PsiFile file = myFixture.configureByText("pyproject.toml", content);
+ PsiElement element = findDependencyElement(file, "anyio", "^3.6.2");
+
+ // When applying the quick fix
+ DependencyReport report = createReportWithRecommendation();
+ PyprojectCAIntentionAction action = new PyprojectCAIntentionAction(element, dummySource(), report);
+
+ WriteCommandAction.runWriteCommandAction(getProject(), () ->
+ action.updateVersion(getProject(), null, file, NEW_VERSION)
+ );
+
+ // Then only the target version should be updated
+ String updatedText = file.getText();
+ assertTrue("anyio version should be updated",
+ updatedText.contains("anyio = \"==" + NEW_VERSION + "\""));
+ assertTrue("flask should remain unchanged",
+ updatedText.contains("flask = \">=2.0\""));
+ assertTrue("python should remain unchanged",
+ updatedText.contains("python = \"^3.8\""));
+ }
+
+ /** Verifies that a Poetry inline-table version is updated correctly. */
+ @Test
+ public void testUpdatePoetryInlineTableVersion() {
+ String content = """
+ [tool.poetry.dependencies]
+ python = "^3.8"
+ requests = {version = "^2.28.0", optional = true}
+ """;
+
+ // Given a pyproject.toml with Poetry inline-table dependency
+ PsiFile file = myFixture.configureByText("pyproject.toml", content);
+ PsiElement element = findDependencyElement(file, "requests", "^2.28.0");
+
+ // When applying the quick fix
+ DependencyReport report = createReportWithRecommendation();
+ PyprojectCAIntentionAction action = new PyprojectCAIntentionAction(element, dummySource(), report);
+
+ WriteCommandAction.runWriteCommandAction(getProject(), () ->
+ action.updateVersion(getProject(), null, file, NEW_VERSION)
+ );
+
+ // Then the version inside the inline table should be updated
+ String updatedText = file.getText();
+ assertTrue("requests version should be updated in inline table",
+ updatedText.contains("version = \"==" + NEW_VERSION + "\""));
+ assertTrue("optional flag should remain unchanged",
+ updatedText.contains("optional = true"));
+ }
+
+ // ── Edge case tests ─────────────────────────────────────────────────────────
+
+ /** Verifies that a null version is a no-op. */
+ @Test
+ public void testUpdateWithNullVersionIsNoOp() {
+ String content = """
+ [project]
+ name = "my-app"
+ dependencies = [
+ "anyio==3.6.2",
+ ]
+ """;
+
+ // Given a dependency element
+ PsiFile file = myFixture.configureByText("pyproject.toml", content);
+ PsiElement element = findDependencyElement(file, "anyio", "==3.6.2");
+
+ DependencyReport report = createReportWithRecommendation();
+ PyprojectCAIntentionAction action = new PyprojectCAIntentionAction(element, dummySource(), report);
+
+ // When updating with null version
+ WriteCommandAction.runWriteCommandAction(getProject(), () ->
+ action.updateVersion(getProject(), null, file, null)
+ );
+
+ // Then the file should remain unchanged
+ String updatedText = file.getText();
+ assertTrue("anyio should remain unchanged when version is null",
+ updatedText.contains("\"anyio==3.6.2\""));
+ }
+
+ // ── isAvailable tests ───────────────────────────────────────────────────────
+
+ /** Verifies that the intention action is available for pyproject.toml PEP 621 dependencies. */
+ @Test
+ public void testIsAvailableForPyprojectToml() {
+ String content = """
+ [project]
+ name = "my-app"
+ dependencies = [
+ "anyio==3.6.2",
+ ]
+ """;
+
+ // Given a pyproject.toml file
+ PsiFile file = myFixture.configureByText("pyproject.toml", content);
+ PsiElement element = findDependencyElement(file, "anyio", "==3.6.2");
+
+ DependencyReport report = createReportWithRecommendation();
+ PyprojectCAIntentionAction action = new PyprojectCAIntentionAction(element, dummySource(), report);
+
+ // Then the action should be available
+ assertTrue("Should be available for pyproject.toml",
+ action.isAvailable(getProject(), null, file));
+ }
+
+ /** Verifies that the intention action is not available for non-pyproject.toml files. */
+ @Test
+ public void testIsNotAvailableForOtherTomlFiles() {
+ String content = """
+ [dependencies]
+ serde = "1.0"
+ """;
+
+ // Given a non-pyproject.toml TOML file
+ PsiFile file = myFixture.configureByText("Cargo.toml", content);
+
+ DependencyReport report = createReportWithRecommendation();
+ // Use a dummy element from the file since we can't use the pypi annotator on Cargo.toml
+ PsiElement element = file.getFirstChild();
+ PyprojectCAIntentionAction action = new PyprojectCAIntentionAction(element, dummySource(), report);
+
+ // Then the action should not be available
+ assertFalse("Should not be available for Cargo.toml",
+ action.isAvailable(getProject(), null, file));
+ }
+}